Changelog
All notable changes to shithub are documented here. This project follows Keep a Changelog conventions and Semantic Versioning.
Pre-1.0 versioning: minor versions may break the API. The stability contract begins at v1.0.0; until then, expect changes between minor releases.
Unreleased
Fixed
- PRO08 Pro tier GA audit remediation. Thirteen audit findings
across the Stripe webhook layer, subscription state machine, and
Pro v1 feature gates: webhook receipts now record (subject_kind,
subject_id) for operator queries; the cross-kind price guard
refuses subscription events with empty
Items.Data; concurrent webhook replays serialize via a session-scoped advisory lock; the snapshot CTE preserves grace-lock columns underpast_duetransitions instead of wiping them; subscription-overwrite guard refuses to repoint a principal's bound subscription id at a different one; reverse-ordered (stale) Stripe events are dropped via a newlast_event_atcolumn;customer.subscription.deletedfor unknown subjects is now a 200 no-op so Stripe stops retrying. Charge refunds flip invoices tostatus='refunded'with UI surfacing on both user and org billing pages (newbilling_invoice_status='refunded'enum value +refunded_atcolumn). Advanced branch protection gate rewired to fire on the PRO01-ratified inputs (prevent_force_push,prevent_deletion,require_signed_commits) rather than only on required status checks —require_signed_commitsis now exposed in the rule form (visible toggle; underlying enforcement ships with commit signing). Multi-reviewer denies carry a distinct upgrade copy (required-reviewers-multi-upgrade(-pro)) and user-tier denies point at/settings/billinginstead of the org settings page.profilePinsRemainingnow respects the entitled cap for Pro users. Migrations 0077 (last_event_at) and 0078 (refundedenum +refunded_atcolumn) ship with the fix. Audit closure indocs/internal/billing.md; runbook updates indocs/internal/runbooks/stripe-billing.md.
Added
- Personal Pro tier feature gates (PRO07). Pro v1 lights up four
user-tier paygates ratified in PRO01: required reviewers on private
personal repos, multi-reviewer thresholds, advanced branch
protection (prevent force-push / deletion / require signed commits),
and profile pins above the Free cap of 6 (Pro raises this to 100).
Each gate is enforced via a per-feature operator flag in
billing.enforce.*so launches are reversible without a code rollback. PRO07 ships dark (all flags default false) and operators flip each feature on after the 7-day report-only telemetry soak. Pro user accounts retain existing required-reviewer rules and pin configuration through cancellation; the gate refuses to create new gated state on Free but never deletes prior data. Added theFeatureProfilePinsBeyondFree(user-only) andFeatureCodeOwnersReview(placeholder; no-op enforce until the CODEOWNERS parser ships) entitlement constants, plus theLimitProfilePinsFreeCap/LimitProfilePinsProCaplimit constants. Migration0076_profile_pins_pro_capraises theprofile_pins.positioncheck constraint from1..6to1..100. - REST API contract (S50 §0).
GET /api/v1/metareturns the server's version stamp and a list of feature capability strings for client-side feature detection. Every/api/v1/*response now carriesX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset, and (when PAT-authenticated)X-OAuth-Scopes. The 403 scope-reject response also carriesX-Accepted-OAuth-Scopes. Operators tune the API rate-limit budgets viaratelimit.api.authed_per_hour/ratelimit.api.anon_per_hour(defaults: 5000 / 60). - Pagination helper
internal/web/handlers/api/apipage— emits canonical RFC 8288 Link headers (first/prev/next/last) with absolute URLs rooted at the configured public base URL. - REST: user emails (S50 §1).
GET /api/v1/user/emailslists the authenticated user's emails. Optional?verified=true|falsefilter. Scope:user:read. - REST: user SSH keys (S50 §1).
GET/POST /api/v1/user/keysandGET/DELETE /api/v1/user/keys/{id}expose CRUD for git authentication keys. Signing keys are tracked separately by a newkindcolumn onuser_ssh_keysand remain on the HTML surface for now. Scopes:user:readfor GETs,user:writefor mutations. - Capabilities:
user-emails,ssh-keysadded to/api/v1/metaresponse. - REST: repos core (S50 §2).
GET /api/v1/user/repos,GET /api/v1/users/{username}/repos,GET /api/v1/orgs/{org}/repos,GET /api/v1/repos/{owner}/{repo},POST /api/v1/user/repos,POST /api/v1/orgs/{org}/repos,PATCH /api/v1/repos/{owner}/{repo}(description, has_issues, has_pulls, archived, visibility), andDELETE /api/v1/repos/{owner}/{repo}(soft-delete). Visibility-aware listing: a user's/users/{u}/reposshows private rows only to that user; an org's/orgs/{o}/reposshows private rows only to members. Single-repo GETs404for callers who can't see the row (no existence leak). - Capability:
reposadded to/api/v1/meta. - REST: issues + comments + lock (S50 §3).
GET /api/v1/repos/{o}/{r}/issues(with?state=filter andLink:-header pagination),GET /api/v1/repos/{o}/{r}/issues/{number},POST /api/v1/repos/{o}/{r}/issues,PATCH /api/v1/repos/{o}/{r}/issues/{number}(title/body author-gated, state/state_reason policy-gated),GET / POST /api/v1/repos/{o}/{r}/issues/{number}/comments,PATCH / DELETE /api/v1/repos/{o}/{r}/issues/comments/{cid},PUT / DELETE /api/v1/repos/{o}/{r}/issues/{number}/lock. - REST: repo labels (S50 §3).
GET / POST /api/v1/repos/{o}/{r}/labelsandGET / PATCH / DELETE /api/v1/repos/{o}/{r}/labels/{name}. - Capabilities:
issues,labelsadded to/api/v1/meta. - REST: milestones + assignees (S50 §3 follow-up). Full CRUD
for
/api/v1/repos/{o}/{r}/milestones(with?state=filter on list and liveopen_issues/closed_issuescounters on every response), plusGET /api/v1/repos/{o}/{r}/assignees(repo owner + collaborators eligible for issue assignment). Scope:repo:readon GETs,repo:writeon mutations. Mutations gate onActionIssueLabel. - Issue PATCH extensions.
PATCH /api/v1/repos/{o}/{r}/issues/{n}now acceptslabels,assignees, andmilestonefields with GitHub-style full-replace semantics. Each gates on its own policy action (ActionIssueLabel/ActionIssueAssign) so a caller missing one capability gets a clean 403 rather than a partial update. Unknown label names or assignee usernames → 422; cross-repo milestone ids → 422. - Capabilities:
milestones,assigneesadded to/api/v1/meta. - Reach:
internal/web/handlers/api.resolveAPIReponow resolves both user-owner and org-owner repos — check-runs and every later batch implicitly gain org-repo support.
Added (internal)
- REST: pull requests core (S50 §4).
GET /api/v1/repos/{o}/{r}/pullswith?state=and?draft=filters,GET /api/v1/repos/{o}/{r}/pulls/{number},POST /api/v1/repos/{o}/{r}/pulls,PATCH /api/v1/repos/{o}/{r}/pulls/{number}(title/body author-gated, state viaActionPullClose, draft→ready author-only),GET /api/v1/repos/{o}/{r}/pulls/{number}/commits,GET /api/v1/repos/{o}/{r}/pulls/{number}/files,PUT /api/v1/repos/{o}/{r}/pulls/{number}/merge(honoring the repo's default merge method and the optionalshahead guard). Reviews + comments + reviewers + update-branch + auto-merge land in a follow-up. - Capability:
pullsadded to/api/v1/meta. - REST: PR reviews + inline comments + requested reviewers (S50 §4b).
GET / POST /api/v1/repos/{o}/{r}/pulls/{number}/reviews(eventsAPPROVE/REQUEST_CHANGES/COMMENT, with pending-draft attachment on submit),GET / POST /api/v1/repos/{o}/{r}/pulls/{number}/comments(inline review comments with file_path / side / position anchoring,pendingdrafts,in_reply_to_idthreading), andGET / POST / DELETE /api/v1/repos/{o}/{r}/pulls/{number}/requested_reviewers(byuser_idorusername). - Capability:
pr-reviewsadded to/api/v1/meta. - REST: search (S50 §5).
GET /api/v1/search/repositories,GET /api/v1/search/issues?type=issue|pr, andGET /api/v1/search/codeover the existing FTS corpus. Canonical gh-shaped envelope{ total_count, incomplete_results, items }withLink:pagination. Anonymous callers allowed (visibility filter inside the search package narrows to public).?q=honors the existing operator vocabulary (repo:,is:,state:,author:, phrase). Thesearch/commitsandsearch/usersendpoints, plus thesort=/order=knobs, are deferred to follow-ups. - Capability:
searchadded to/api/v1/meta. - REST: orgs (S50 §7).
GET /api/v1/user/orgs(self),GET /api/v1/users/{username}/orgs(public; shithub has no hidden membership distinction in v1),GET /api/v1/orgs/{org}(single fetch; 404 for soft-deleted),GET /api/v1/orgs/{org}/members. Scope:user:read. - Capability:
orgsadded to/api/v1/meta. - REST: repo webhooks (S50 §8). Full CRUD over a repo's
webhook subscriptions:
GET/POST /api/v1/repos/{o}/{r}/hooks,GET/PATCH/DELETE /api/v1/repos/{o}/{r}/hooks/{id}. Deliveries read-side:GET /api/v1/repos/{o}/{r}/hooks/{id}/deliveries(paginated;Link:headers) andGET /api/v1/repos/{o}/{r}/hooks/{id}/deliveries/{did}(full transcript).POST .../deliveries/{did}/redeliverre-enqueues. Scope:repo:write; role floor: settings:general. Webhook secrets are write-only — set on create, rotated via PATCH'ssecretfield, never echoed back. Create-time SSRF gate rejects loopback / private / disallowed-port targets so misconfigurations surface synchronously instead of as silent delivery failures. - Capability:
webhooksadded to/api/v1/meta. - REST: branches + tags (S50 §9). Read-only ref enumeration:
GET /api/v1/repos/{o}/{r}/branches(paginated; each entry carriesprotectedreflecting the longest-prefix match against the configured branch-protection rules, plusis_default),GET /api/v1/repos/{o}/{r}/branches/{name}(slashes in branch names accepted verbatim or URL-encoded), andGET /api/v1/repos/{o}/{r}/tags(paginated). Scope:repo:read. Empty / uninitialised repos return[]rather than404. - Capabilities:
branches,tagsadded to/api/v1/meta. - REST: repo collaborators (S50 §10).
GET /api/v1/repos/{o}/{r}/collaborators(list),GET .../collaborators/{username}(204 membership probe),GET .../collaborators/{username}/permission(permission level —"none"when not a collaborator),PUT .../collaborators/{username}(add / upgrade, body{"role": "..."}accepting both shithub names and gh-style aliasespull/push),DELETE .../collaborators/{username}(remove). Scope:repo:readon GETs,repo:writeon mutations; mutations layerActionRepoAdminon top. Refuses (422) to enrol the repo owner. - Capability:
collaboratorsadded to/api/v1/meta. - REST: commits (S50 §11). Read-only git history surface:
GET /api/v1/repos/{o}/{r}/commits(paginated; honours?sha=,?path=,?author=,?since=,?until=) andGET /api/v1/repos/{o}/{r}/commits/{sha}(full commit detail with committer/parents/tree + per-filestatusandadditions/deletions, plus a rollupstatsobject). Backed byinternal/repos/git.Log/git.GetCommit— the response stays in lock-step with the bare repository. Scope:repo:read. Empty repos return[]; the single-commit GET accepts any unambiguous SHA prefix. - Capability:
commitsadded to/api/v1/meta. - GPG keys + commit signature verification (S51). Full
vertical: upload OpenPGP public keys at
Settings → SSH and GPG keys; shithub parses the armored block (RSA≥2048, ECC, multi-subkey, encryption-only all accepted) and stores primary + subkey fingerprint index. A background worker eagerly backfills verification rows for the uploader's existing commits across every repo, and theshithubd gpg-backfill-alladmin subcommand performs the once-off bulk walk on deploy. Signed commits + annotated tags render a green Verified pill on the commit list, single-commit, and tag list pages; the popover surfaces the signer, key id, and verified-at timestamp. Thereasonenum mirrors GitHub's documented set (valid,unsigned,unknown_key,bad_email,unverified_email,expired_key,not_signing_key,malformed_signature,invalid). REST surface:GET/POST/DELETE /api/v1/user/gpg_keys[/{id}]with the gh-exact JSON shape (splitcan_encrypt_comms/can_encrypt_storage, bothpublic_keyandraw_key, subkeys-as-nested-objects,emails[].verifiedcross-checked against the user's verified-email set). Scopes:user:readfor GETs,user:writefor mutations. Existing/api/v1/repos/{o}/{r}/commits[/{sha}]responses now carry averificationobject with the same shape gh emits. Migrations:0068_user_gpg_keys.sql,0069_user_gpg_subkeys.sql,0070_commit_verification_cache.sql. - Capability:
gpg-keysadded to/api/v1/meta. - REST: rulesets (S50 §9). Three read-only endpoints
synthesizing GitHub's modern rulesets shape from shithub's
existing
branch_protection_rulesrows:GET /api/v1/repos/{o}/{r}/rulesets(list),GET /api/v1/repos/{o}/{r}/rulesets/{id}(single),GET /api/v1/repos/{o}/{r}/rules/branches/{branch}(rules applying to a branch — every matching pattern, not just the longest-match the pre-receive enforcer uses). One protection row maps to one ruleset; each configured field projects as a typed rule (pull_request,non_fast_forward,deletion,required_signatures,required_status_checks). Scope:repo:read. Cross-repo lookups 404. Mutating rulesets via REST is a future surface — for now use the web UI atSettings → Branches. - Capability:
rulesetsadded to/api/v1/meta. - REST: repo contents (S50 §12).
GET /api/v1/repos/{o}/{r}/contents/{path}[?ref=]returns either a directory listing (dirs first, then files alphabetically) or a single file with base64-encodedcontent,encoding,size, and abinaryflag (UTF-8 validity check). Files over 1 MiB come back astruncated: truewith empty content — clients fall through to the raw download path. Scope:repo:read. Empty/contentspath returns the repo root. - Capability:
contentsadded to/api/v1/meta. - REST: forks (S50 §13).
GET /api/v1/repos/{o}/{r}/forks(paginated; per-row visibility filter so private forks of public repos only surface to authorized viewers) andPOST /api/v1/repos/{o}/{r}/forks(fork into the authenticated user's namespace; optionalname/visibilitybody). Reuses the existinginternal/repos/forkorchestrator and enqueues the on-disk clone via therepo:fork_cloneworker, so the response returns immediately withinit_status: "init_pending". Org-target forks land in a follow-up. - Capability:
forksadded to/api/v1/meta. - REST: notifications (S50 §14). User-scoped inbox surface:
GET /api/v1/notifications(defaults to unread only;?all=trueincludes read; paginated withLink:headers),GET /api/v1/notifications/threads/{id}(single fetch with existence-leak-safe cross-user 404),PATCH /api/v1/notifications/threads/{id}(mark read/unread — empty body /unread:false→ read;unread:trueflips back), andPUT /api/v1/notifications(mark all read). Scopes:user:readon GETs,user:writeon mutations. All routes are implicitly scoped to the authenticated user. - Capability:
notificationsadded to/api/v1/meta. - REST: watching / subscriptions (S50 §15). Per-repo
watch-level management mirroring GitHub's
/repos/{o}/{r}/subscriptionshape:GET .../subscribers(paginated watcher list excludingignoreand suspended users),GET .../subscription(viewer's level +explicitflag distinguishing the implicitparticipatingdefault from an explicit choice),PUT .../subscription(setall/participating/ignore), andDELETE .../subscription(revert to implicit). Reusesinternal/social.SetWatch/UnsetWatchandsocialdb.ListWatchersForRepo. Scope:repo:readon GETs,user:writeon mutations. - Capability:
watchingadded to/api/v1/meta. - REST: events / activity (S50 §16). Read-only activity feed
over
domain_events:GET /api/v1/repos/{o}/{r}/events(paginated; returns every event for the repo, gated byActionRepoRead) andGET /api/v1/users/{username}/events(paginated; onlypublic=truerows — matches gh, which never surfaces private-repo activity on a user feed). Scope:repo:read/user:read. Reuses the existingsocialdb.ListEventsForRepoandsocialdb.ListPublicEventsForActorqueries. - Capability:
eventsadded to/api/v1/meta. - REST: followers / following (S50 §17).
GET /api/v1/users/{username}/followersandGET /api/v1/users/{username}/following(both paginated;Link:headers), plus the authenticated-user-scopedGET /api/v1/user/following/{target}(204/404 membership probe matching gh),PUT /api/v1/user/following/{target}(follow), andDELETE(unfollow). Self-follow returns 422. Reusesinternal/social.FollowUser/UnfollowUserso the follow rate-limit andfollowed_userdomain event stay in one place. Org-follow variants remain on the HTML surface. - Capability:
followersadded to/api/v1/meta. - REST: actions workflow runs (S50 §18). Read-only access to
the Actions run history:
GET /api/v1/repos/{o}/{r}/actions/runs(paginated; filterable byworkflow_file,head_ref,actor,event,status,conclusion),GET /api/v1/repos/{o}/{r}/actions/runs/{run_id}(single run; existence-leak-safe cross-repo 404), andGET /api/v1/repos/{o}/{r}/actions/runs/{run_id}/jobs(job-index-ordered jobs list withneeds_jobsgraph). Reuses the existingactionsdb.ListWorkflowRunsForRepo/GetWorkflowRunByID/ListJobsForRunqueries. Scope:repo:read. Lifecycle controls (cancel / rerun / approve) remain on the actions-lifecycle routes; this surface is read-only. - Capability:
actions-runsadded to/api/v1/meta. - REST: stargazers + starred lists (S50 §19).
GET /api/v1/repos/{owner}/{repo}/stargazerspaginates the users who starred a repo (scope:repo:read); private-repo lists are gated byActionRepoRead.GET /api/v1/users/{username}/starredpaginates the repos a user has starred (scope:user:read); cross-user views post-filter private repos the caller can't see. Both endpoints emit standardLink:pagination headers and recency-sort bystarred_at DESC. The S26 caller-self star routes (/api/v1/user/starredand/api/v1/user/starred/{o}/{r}) are unchanged. - Capability:
stargazersadded to/api/v1/meta. - REST: issue events / timeline (S50 §20).
GET /api/v1/repos/{owner}/{repo}/issues/{number}/eventsreturns the issue's recorded timeline (everyclosed/reopened/labeled/unlabeled/milestoned/demilestoned/locked/unlocked/referenced/ merged / push event), withactor_usernameLEFT-joined and the raw eventmetapayload preserved verbatim. Paginated with the standardLink:headers, sorted oldest-first. Scope:repo:read. - Capability:
issue-eventsadded to/api/v1/meta. - Device-code login (S50 §1, RFC 8628).
POST /login/device/codeissues a fresh authorization grant for a non-browser client (CLI / TV / IoT).POST /login/oauth/access_tokenpolls for the user's approval and, on success, mints a PAT bound to the requested scopes. The browser-facing verification page is served atGET /login/device(CSRF-protected). The matching CLI endpoints are CSRF-exempt.client_idis enforced against an allowlist (default:shithub-cli); requested scopes go through the standardpat.ValidScopefilter so unknown scopes fail cleanly withinvalid_scope. The minted PAT is disclosed exactly once — subsequent exchanges of the samedevice_codereturninvalid_granteven after successful approval. RFC 8628 §3.5 error semantics (authorization_pending,slow_down,access_denied,expired_token) are honored. - Capability:
device-codeadded to/api/v1/meta. - REST: actions workflows + workflow_dispatch (S50 §13 part 1).
GET /api/v1/repos/{o}/{r}/actions/workflowslists the workflows discovered in.shithub/workflows/at the repo's default-branch HEAD (or?ref=);GET .../workflows/{id_or_file}fetches a single workflow by basename, full path, or a deterministic 64-bit hash of the path.POST .../workflows/{file}/dispatchestriggersworkflow_dispatchwith optionalrefand a typedinputsmap (choice / boolean / string with required/default enforcement, same semantics as the HTML "Run workflow" button). Workflow_dispatch input validation now lives in a sharedinternal/actions/dispatchpackage consumed by both the REST and HTML surfaces. Enable/disable knobs are deferred to a follow-up (needs a newworkflow_disabledtable); every listed workflow is reported asstate: "active"for now. - Capability:
actions-workflowsadded to/api/v1/meta. - REST: actions lifecycle + artifacts + job logs (S50 §13 part 2).
New migration
0064_workflow_disabledadds a per-workflow disable knob;trigger.Enqueuenow consults it and short-circuits matching events for disabled workflows. The list endpoint surfaces this as"state": "disabled". REST additions:PUT /api/v1/repos/{o}/{r}/actions/workflows/{file}/enableand.../disable,DELETE /api/v1/repos/{o}/{r}/actions/runs/{run_id}(cascades through jobs/steps/log-chunks/artifacts; object-store cleanup is async best-effort),GET /api/v1/repos/{o}/{r}/actions/runs/{run_id}/artifacts+GET .../actions/artifacts/{aid}+GET .../actions/artifacts/{aid}/zip(streams from object store) +DELETE .../actions/artifacts/{aid},GET /api/v1/repos/{o}/{r}/actions/jobs/{job_id}/logs(assembled transcript with##[group]/##[endgroup]step markers). - Capabilities:
actions-artifacts,actions-job-logsadded to/api/v1/meta. - REST: actions secrets + variables (S50 §13 part 3).
New
internal/auth/sealboxpackage owns the server's X25519 keypair and exposes NaClSealedBoxdecode. Clients fetch the public key viaGET /api/v1/repos/{o}/{r}/actions/secrets/public-key(and the org analog), encrypt the secret value withcrypto_box_seal, and PUT the base64 ciphertext + key_id. Server validates key_id, decrypts in memory, then re-encrypts with the shared storage AEAD for at-rest persistence — plaintext never reaches postgres. Secrets CRUD:GET .../secrets,GET .../secrets/{name},PUT .../secrets/{name},DELETE .../secrets/{name}. Variables CRUD (plaintext,${{ vars.NAME }}namespace):GET/POST .../variables,GET/PATCH/DELETE .../variables/{name}. Both surfaces have repo- and org-scoped variants. Operator setup:SHITHUB_ACTIONS__SECRETS__BOX_PRIVATE_KEY_B64carries the X25519 private key (32 bytes base64); unset means auto-generated-per-process with a loud warning at startup, which is dev-only behavior. - Capabilities:
actions-secrets,actions-variablesadded to/api/v1/meta. - REST: actions caches (S50 §13 part 4 — §13 closure). New
migration
0065_workflow_cachesadds theworkflow_cachestable keyed on(repo_id, cache_key, cache_version, git_ref)with size + last_accessed_at + object_key columns and a unique constraint. REST surface:GET /api/v1/repos/{o}/{r}/actions/caches(paginated; optional?key=and?ref=filters; standardLink:headers; sorted recency-DESC bylast_accessed_at),DELETE .../actions/caches/{cache_id}(single delete with cross-repo 404 guard + best-effort async S3 cleanup),DELETE .../actions/caches?key=...[&ref=...](bulk delete by key; idempotent — 204 even with zero matches). The runner-side upload protocol that POPULATES this table is a future sprint; this REST surface lands first so operators have an audit + purge seat for when caches arrive. - Capability:
actions-cachesadded to/api/v1/meta. - REST: repos follow-ups (S50 §2 closure).
GET /api/v1/repos/{o}/{r}/readme[?ref=]returns the repo's README as a base64-encoded blob withdownload_urlfor the raw bytes (capped at 1 MiB to match the HTML render cap; prefers.md/.markdownover plain text when multiple READMEs exist).PUT /api/v1/repos/{o}/{r}/topicsandDELETE .../topicsreplace and clear the topic set atomically (server-side normalization: lowercase, dedup; constraints: max 20, 1–50 chars of[a-z0-9-]).POST .../merge-upstreamfast-forwards a fork's default branch to its upstream — refuses non-forks with 422 and divergent forks with 409 (users must reconcile locally). Scopes:repo:readon README,repo:writeon topics and merge-upstream. - Capabilities:
readme,topics,merge-upstreamadded to/api/v1/meta.
Added (internal)
issues.Editorchestrator wrapsUpdateIssueTitleBodywith markdown re-render + cross-reference re-indexing. Used by the new PATCH-issue endpoint; available for the HTML edit flow when it lands.
Changed
- JSON error envelope on
/api/v1/*.401and403responses now emit{"error": "..."}withContent-Type: application/json(previouslytext/plain). Existing4xx/5xxresponses from the handler bodies are unchanged.
0.1.0 — TBD (operator fills in cutover date)
The first public release of shithub. Pre-1.0: there is no backward-compatibility promise yet. Migrations are forward-only; schema may change between minor versions.
Initial public surface
- Identity — signup, email verification, password reset, TOTP 2FA + recovery codes, SSH keys, scoped PATs, sessions with per-account epoch invalidation.
- Repositories — create, fork, archive, transfer, soft-delete with grace, rename with redirects, visibility toggles, branch protection, default-branch swap, topics, README/license/ .gitignore templates.
- Git — bare repos on disk; HTTPS smart-HTTP push/pull; pre/post-receive hook integration.
- Code browsing — tree, blob (chroma syntax highlighting), raw, blame, commit history, individual commit views, branch/tag listings, compare views, file finder.
- Issues + PRs — full CRUD; reviews; required-reviewer enforcement; status-check gates; three merge methods.
- Social — stars, watches, forks,
/explore, stargazer/ watcher lists. - Search — code, repo, user, issue.
- Notifications — in-app inbox, email fan-out, one-click unsubscribe.
- Orgs + teams — roles, invitations, one-level nesting, max-of-sources policy.
- Webhooks — HMAC-signed delivery, exponential backoff, auto-disable, SSRF defense, redelivery UI.
- Observability — structured logs, Prometheus metrics, optional OTel tracing, Sentry-protocol error reporting.
- Operations — Ansible playbook, systemd units, Caddy edge, WireGuard mesh for monitoring, Postgres WAL archive + daily logical backups to Spaces, cross-region DR, restore drill.
- Public landing page on
/for anonymous viewers; signed-in viewers get a quick-link dashboard. - Lightweight status page at
docs.<host>/status.html. - Cutover artifacts under
deploy/cutover/. - Public docs site built with mdBook.
- Operator runbooks for incidents, backups, restore, upgrade, rollback, rotate-secrets, rotate-keys, regenerate-akc, drain-workers, read-only-mode, day-one.
- a11y tooling (pa11y + axe) and k6 load-test scenarios.
- THIRD_PARTY_NOTICES.md with a CI-verified generator.
Known gaps at v0.1.0
- SSH git transport (HTTPS only)
- Actions / CI runner
- Packages, Releases, Pages, Projects, Gists
- GraphQL API (only a small REST surface today)
- Activity feed UI
These are all on the post-MVP roadmap.
View source
| 1 | # Changelog |
| 2 | |
| 3 | All notable changes to shithub are documented here. This project |
| 4 | follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) |
| 5 | conventions and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). |
| 6 | |
| 7 | Pre-1.0 versioning: minor versions may break the API. The |
| 8 | stability contract begins at v1.0.0; until then, expect changes |
| 9 | between minor releases. |
| 10 | |
| 11 | ## [Unreleased] |
| 12 | |
| 13 | ### Fixed |
| 14 | |
| 15 | - **PRO08 Pro tier GA audit remediation.** Thirteen audit findings |
| 16 | across the Stripe webhook layer, subscription state machine, and |
| 17 | Pro v1 feature gates: webhook receipts now record (subject_kind, |
| 18 | subject_id) for operator queries; the cross-kind price guard |
| 19 | refuses subscription events with empty `Items.Data`; concurrent |
| 20 | webhook replays serialize via a session-scoped advisory lock; the |
| 21 | snapshot CTE preserves grace-lock columns under `past_due` |
| 22 | transitions instead of wiping them; subscription-overwrite guard |
| 23 | refuses to repoint a principal's bound subscription id at a |
| 24 | different one; reverse-ordered (stale) Stripe events are dropped |
| 25 | via a new `last_event_at` column; `customer.subscription.deleted` |
| 26 | for unknown subjects is now a 200 no-op so Stripe stops retrying. |
| 27 | Charge refunds flip invoices to `status='refunded'` with UI |
| 28 | surfacing on both user and org billing pages (new |
| 29 | `billing_invoice_status='refunded'` enum value + `refunded_at` |
| 30 | column). Advanced branch protection gate rewired to fire on the |
| 31 | PRO01-ratified inputs (`prevent_force_push`, `prevent_deletion`, |
| 32 | `require_signed_commits`) rather than only on required status |
| 33 | checks — `require_signed_commits` is now exposed in the rule |
| 34 | form (visible toggle; underlying enforcement ships with commit |
| 35 | signing). Multi-reviewer denies carry a distinct upgrade copy |
| 36 | (`required-reviewers-multi-upgrade(-pro)`) and user-tier denies |
| 37 | point at `/settings/billing` instead of the org settings page. |
| 38 | `profilePinsRemaining` now respects the entitled cap for Pro users. |
| 39 | Migrations 0077 (`last_event_at`) and 0078 (`refunded` enum + |
| 40 | `refunded_at` column) ship with the fix. Audit closure in |
| 41 | `docs/internal/billing.md`; runbook updates in |
| 42 | `docs/internal/runbooks/stripe-billing.md`. |
| 43 | |
| 44 | ### Added |
| 45 | |
| 46 | - **Personal Pro tier feature gates (PRO07).** Pro v1 lights up four |
| 47 | user-tier paygates ratified in PRO01: required reviewers on private |
| 48 | personal repos, multi-reviewer thresholds, advanced branch |
| 49 | protection (prevent force-push / deletion / require signed commits), |
| 50 | and profile pins above the Free cap of 6 (Pro raises this to 100). |
| 51 | Each gate is enforced via a per-feature operator flag in |
| 52 | `billing.enforce.*` so launches are reversible without a code |
| 53 | rollback. PRO07 ships dark (all flags default false) and operators |
| 54 | flip each feature on after the 7-day report-only telemetry soak. |
| 55 | Pro user accounts retain existing required-reviewer rules and pin |
| 56 | configuration through cancellation; the gate refuses to create new |
| 57 | gated state on Free but never deletes prior data. Added the |
| 58 | `FeatureProfilePinsBeyondFree` (user-only) and |
| 59 | `FeatureCodeOwnersReview` (placeholder; no-op enforce until the |
| 60 | CODEOWNERS parser ships) entitlement constants, plus the |
| 61 | `LimitProfilePinsFreeCap` / `LimitProfilePinsProCap` limit |
| 62 | constants. Migration `0076_profile_pins_pro_cap` raises the |
| 63 | `profile_pins.position` check constraint from `1..6` to `1..100`. |
| 64 | - **REST API contract (S50 §0).** `GET /api/v1/meta` returns the |
| 65 | server's version stamp and a list of feature capability strings |
| 66 | for client-side feature detection. Every `/api/v1/*` response |
| 67 | now carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, |
| 68 | `X-RateLimit-Reset`, and (when PAT-authenticated) `X-OAuth-Scopes`. |
| 69 | The 403 scope-reject response also carries |
| 70 | `X-Accepted-OAuth-Scopes`. Operators tune the API rate-limit |
| 71 | budgets via `ratelimit.api.authed_per_hour` / |
| 72 | `ratelimit.api.anon_per_hour` (defaults: 5000 / 60). |
| 73 | - **Pagination helper** `internal/web/handlers/api/apipage` — |
| 74 | emits canonical RFC 8288 Link headers (`first`/`prev`/`next`/`last`) |
| 75 | with absolute URLs rooted at the configured public base URL. |
| 76 | - **REST: user emails (S50 §1).** `GET /api/v1/user/emails` lists |
| 77 | the authenticated user's emails. Optional `?verified=true|false` |
| 78 | filter. Scope: `user:read`. |
| 79 | - **REST: user SSH keys (S50 §1).** `GET/POST /api/v1/user/keys` |
| 80 | and `GET/DELETE /api/v1/user/keys/{id}` expose CRUD for git |
| 81 | authentication keys. Signing keys are tracked separately by a |
| 82 | new `kind` column on `user_ssh_keys` and remain on the HTML |
| 83 | surface for now. Scopes: `user:read` for GETs, `user:write` for |
| 84 | mutations. |
| 85 | - **Capabilities:** `user-emails`, `ssh-keys` added to |
| 86 | `/api/v1/meta` response. |
| 87 | - **REST: repos core (S50 §2).** |
| 88 | `GET /api/v1/user/repos`, `GET /api/v1/users/{username}/repos`, |
| 89 | `GET /api/v1/orgs/{org}/repos`, |
| 90 | `GET /api/v1/repos/{owner}/{repo}`, |
| 91 | `POST /api/v1/user/repos`, |
| 92 | `POST /api/v1/orgs/{org}/repos`, |
| 93 | `PATCH /api/v1/repos/{owner}/{repo}` (description, has_issues, |
| 94 | has_pulls, archived, visibility), and |
| 95 | `DELETE /api/v1/repos/{owner}/{repo}` (soft-delete). |
| 96 | Visibility-aware listing: a user's `/users/{u}/repos` shows |
| 97 | private rows only to that user; an org's `/orgs/{o}/repos` |
| 98 | shows private rows only to members. Single-repo GETs `404` |
| 99 | for callers who can't see the row (no existence leak). |
| 100 | - **Capability:** `repos` added to `/api/v1/meta`. |
| 101 | - **REST: issues + comments + lock (S50 §3).** |
| 102 | `GET /api/v1/repos/{o}/{r}/issues` (with `?state=` filter and |
| 103 | `Link:`-header pagination), |
| 104 | `GET /api/v1/repos/{o}/{r}/issues/{number}`, |
| 105 | `POST /api/v1/repos/{o}/{r}/issues`, |
| 106 | `PATCH /api/v1/repos/{o}/{r}/issues/{number}` (title/body |
| 107 | author-gated, state/state_reason policy-gated), |
| 108 | `GET / POST /api/v1/repos/{o}/{r}/issues/{number}/comments`, |
| 109 | `PATCH / DELETE /api/v1/repos/{o}/{r}/issues/comments/{cid}`, |
| 110 | `PUT / DELETE /api/v1/repos/{o}/{r}/issues/{number}/lock`. |
| 111 | - **REST: repo labels (S50 §3).** |
| 112 | `GET / POST /api/v1/repos/{o}/{r}/labels` and |
| 113 | `GET / PATCH / DELETE /api/v1/repos/{o}/{r}/labels/{name}`. |
| 114 | - **Capabilities:** `issues`, `labels` added to `/api/v1/meta`. |
| 115 | - **REST: milestones + assignees (S50 §3 follow-up).** Full CRUD |
| 116 | for `/api/v1/repos/{o}/{r}/milestones` (with `?state=` filter |
| 117 | on list and live `open_issues`/`closed_issues` counters on |
| 118 | every response), plus `GET /api/v1/repos/{o}/{r}/assignees` |
| 119 | (repo owner + collaborators eligible for issue assignment). |
| 120 | Scope: `repo:read` on GETs, `repo:write` on mutations. |
| 121 | Mutations gate on `ActionIssueLabel`. |
| 122 | - **Issue PATCH extensions.** `PATCH /api/v1/repos/{o}/{r}/issues/{n}` |
| 123 | now accepts `labels`, `assignees`, and `milestone` fields with |
| 124 | GitHub-style full-replace semantics. Each gates on its own |
| 125 | policy action (`ActionIssueLabel` / `ActionIssueAssign`) so a |
| 126 | caller missing one capability gets a clean 403 rather than a |
| 127 | partial update. Unknown label names or assignee usernames → |
| 128 | 422; cross-repo milestone ids → 422. |
| 129 | - **Capabilities:** `milestones`, `assignees` added to |
| 130 | `/api/v1/meta`. |
| 131 | - **Reach:** `internal/web/handlers/api.resolveAPIRepo` now |
| 132 | resolves both user-owner and org-owner repos — check-runs and |
| 133 | every later batch implicitly gain org-repo support. |
| 134 | |
| 135 | ### Added (internal) |
| 136 | |
| 137 | - **REST: pull requests core (S50 §4).** |
| 138 | `GET /api/v1/repos/{o}/{r}/pulls` with `?state=` and `?draft=` |
| 139 | filters, |
| 140 | `GET /api/v1/repos/{o}/{r}/pulls/{number}`, |
| 141 | `POST /api/v1/repos/{o}/{r}/pulls`, |
| 142 | `PATCH /api/v1/repos/{o}/{r}/pulls/{number}` (title/body |
| 143 | author-gated, state via `ActionPullClose`, draft→ready |
| 144 | author-only), |
| 145 | `GET /api/v1/repos/{o}/{r}/pulls/{number}/commits`, |
| 146 | `GET /api/v1/repos/{o}/{r}/pulls/{number}/files`, |
| 147 | `PUT /api/v1/repos/{o}/{r}/pulls/{number}/merge` (honoring |
| 148 | the repo's default merge method and the optional `sha` |
| 149 | head guard). Reviews + comments + reviewers + update-branch + |
| 150 | auto-merge land in a follow-up. |
| 151 | - **Capability:** `pulls` added to `/api/v1/meta`. |
| 152 | - **REST: PR reviews + inline comments + requested reviewers (S50 §4b).** |
| 153 | `GET / POST /api/v1/repos/{o}/{r}/pulls/{number}/reviews` |
| 154 | (events `APPROVE` / `REQUEST_CHANGES` / `COMMENT`, with |
| 155 | pending-draft attachment on submit), |
| 156 | `GET / POST /api/v1/repos/{o}/{r}/pulls/{number}/comments` |
| 157 | (inline review comments with file_path / side / position |
| 158 | anchoring, `pending` drafts, `in_reply_to_id` threading), and |
| 159 | `GET / POST / DELETE /api/v1/repos/{o}/{r}/pulls/{number}/requested_reviewers` |
| 160 | (by `user_id` or `username`). |
| 161 | - **Capability:** `pr-reviews` added to `/api/v1/meta`. |
| 162 | - **REST: search (S50 §5).** `GET /api/v1/search/repositories`, |
| 163 | `GET /api/v1/search/issues?type=issue|pr`, and |
| 164 | `GET /api/v1/search/code` over the existing FTS corpus. |
| 165 | Canonical gh-shaped envelope `{ total_count, |
| 166 | incomplete_results, items }` with `Link:` pagination. |
| 167 | Anonymous callers allowed (visibility filter inside the search |
| 168 | package narrows to public). `?q=` honors the existing operator |
| 169 | vocabulary (`repo:`, `is:`, `state:`, `author:`, phrase). The |
| 170 | `search/commits` and `search/users` endpoints, plus the |
| 171 | `sort=`/`order=` knobs, are deferred to follow-ups. |
| 172 | - **Capability:** `search` added to `/api/v1/meta`. |
| 173 | - **REST: orgs (S50 §7).** `GET /api/v1/user/orgs` (self), |
| 174 | `GET /api/v1/users/{username}/orgs` (public; shithub has no |
| 175 | hidden membership distinction in v1), |
| 176 | `GET /api/v1/orgs/{org}` (single fetch; 404 for soft-deleted), |
| 177 | `GET /api/v1/orgs/{org}/members`. Scope: `user:read`. |
| 178 | - **Capability:** `orgs` added to `/api/v1/meta`. |
| 179 | - **REST: repo webhooks (S50 §8).** Full CRUD over a repo's |
| 180 | webhook subscriptions: `GET/POST /api/v1/repos/{o}/{r}/hooks`, |
| 181 | `GET/PATCH/DELETE /api/v1/repos/{o}/{r}/hooks/{id}`. Deliveries |
| 182 | read-side: `GET /api/v1/repos/{o}/{r}/hooks/{id}/deliveries` |
| 183 | (paginated; `Link:` headers) and |
| 184 | `GET /api/v1/repos/{o}/{r}/hooks/{id}/deliveries/{did}` (full |
| 185 | transcript). `POST .../deliveries/{did}/redeliver` re-enqueues. |
| 186 | Scope: `repo:write`; role floor: settings:general. Webhook |
| 187 | secrets are write-only — set on create, rotated via PATCH's |
| 188 | `secret` field, never echoed back. Create-time SSRF gate |
| 189 | rejects loopback / private / disallowed-port targets so |
| 190 | misconfigurations surface synchronously instead of as silent |
| 191 | delivery failures. |
| 192 | - **Capability:** `webhooks` added to `/api/v1/meta`. |
| 193 | - **REST: branches + tags (S50 §9).** Read-only ref enumeration: |
| 194 | `GET /api/v1/repos/{o}/{r}/branches` (paginated; each entry |
| 195 | carries `protected` reflecting the longest-prefix match against |
| 196 | the configured branch-protection rules, plus `is_default`), |
| 197 | `GET /api/v1/repos/{o}/{r}/branches/{name}` (slashes in branch |
| 198 | names accepted verbatim or URL-encoded), and |
| 199 | `GET /api/v1/repos/{o}/{r}/tags` (paginated). Scope: `repo:read`. |
| 200 | Empty / uninitialised repos return `[]` rather than `404`. |
| 201 | - **Capabilities:** `branches`, `tags` added to `/api/v1/meta`. |
| 202 | - **REST: repo collaborators (S50 §10).** |
| 203 | `GET /api/v1/repos/{o}/{r}/collaborators` (list), |
| 204 | `GET .../collaborators/{username}` (204 membership probe), |
| 205 | `GET .../collaborators/{username}/permission` (permission |
| 206 | level — `"none"` when not a collaborator), |
| 207 | `PUT .../collaborators/{username}` (add / upgrade, body |
| 208 | `{"role": "..."}` accepting both shithub names and gh-style |
| 209 | aliases `pull`/`push`), |
| 210 | `DELETE .../collaborators/{username}` (remove). Scope: |
| 211 | `repo:read` on GETs, `repo:write` on mutations; mutations |
| 212 | layer `ActionRepoAdmin` on top. Refuses (422) to enrol the |
| 213 | repo owner. |
| 214 | - **Capability:** `collaborators` added to `/api/v1/meta`. |
| 215 | - **REST: commits (S50 §11).** Read-only git history surface: |
| 216 | `GET /api/v1/repos/{o}/{r}/commits` (paginated; honours |
| 217 | `?sha=`, `?path=`, `?author=`, `?since=`, `?until=`) and |
| 218 | `GET /api/v1/repos/{o}/{r}/commits/{sha}` (full commit detail |
| 219 | with committer/parents/tree + per-file `status` and |
| 220 | `additions`/`deletions`, plus a rollup `stats` object). Backed |
| 221 | by `internal/repos/git.Log` / `git.GetCommit` — the response |
| 222 | stays in lock-step with the bare repository. Scope: |
| 223 | `repo:read`. Empty repos return `[]`; the single-commit GET |
| 224 | accepts any unambiguous SHA prefix. |
| 225 | - **Capability:** `commits` added to `/api/v1/meta`. |
| 226 | - **GPG keys + commit signature verification (S51).** Full |
| 227 | vertical: upload OpenPGP public keys at |
| 228 | `Settings → SSH and GPG keys`; shithub parses the armored |
| 229 | block (RSA≥2048, ECC, multi-subkey, encryption-only all |
| 230 | accepted) and stores primary + subkey fingerprint index. A |
| 231 | background worker eagerly backfills verification rows for the |
| 232 | uploader's existing commits across every repo, and the |
| 233 | `shithubd gpg-backfill-all` admin subcommand performs the |
| 234 | once-off bulk walk on deploy. Signed commits + annotated tags |
| 235 | render a green **Verified** pill on the commit list, |
| 236 | single-commit, and tag list pages; the popover surfaces the |
| 237 | signer, key id, and verified-at timestamp. The `reason` enum |
| 238 | mirrors GitHub's documented set (`valid`, `unsigned`, |
| 239 | `unknown_key`, `bad_email`, `unverified_email`, `expired_key`, |
| 240 | `not_signing_key`, `malformed_signature`, `invalid`). REST |
| 241 | surface: `GET/POST/DELETE /api/v1/user/gpg_keys[/{id}]` with |
| 242 | the gh-exact JSON shape (split `can_encrypt_comms` / |
| 243 | `can_encrypt_storage`, both `public_key` and `raw_key`, |
| 244 | subkeys-as-nested-objects, `emails[].verified` cross-checked |
| 245 | against the user's verified-email set). Scopes: `user:read` |
| 246 | for GETs, `user:write` for mutations. Existing |
| 247 | `/api/v1/repos/{o}/{r}/commits[/{sha}]` responses now carry a |
| 248 | `verification` object with the same shape gh emits. |
| 249 | Migrations: `0068_user_gpg_keys.sql`, |
| 250 | `0069_user_gpg_subkeys.sql`, |
| 251 | `0070_commit_verification_cache.sql`. |
| 252 | - **Capability:** `gpg-keys` added to `/api/v1/meta`. |
| 253 | - **REST: rulesets (S50 §9).** Three read-only endpoints |
| 254 | synthesizing GitHub's modern rulesets shape from shithub's |
| 255 | existing `branch_protection_rules` rows: |
| 256 | `GET /api/v1/repos/{o}/{r}/rulesets` (list), |
| 257 | `GET /api/v1/repos/{o}/{r}/rulesets/{id}` (single), |
| 258 | `GET /api/v1/repos/{o}/{r}/rules/branches/{branch}` (rules |
| 259 | applying to a branch — every matching pattern, not just the |
| 260 | longest-match the pre-receive enforcer uses). One protection |
| 261 | row maps to one ruleset; each configured field projects as a |
| 262 | typed rule (`pull_request`, `non_fast_forward`, `deletion`, |
| 263 | `required_signatures`, `required_status_checks`). Scope: |
| 264 | `repo:read`. Cross-repo lookups 404. Mutating rulesets via |
| 265 | REST is a future surface — for now use the web UI at |
| 266 | `Settings → Branches`. |
| 267 | - **Capability:** `rulesets` added to `/api/v1/meta`. |
| 268 | - **REST: repo contents (S50 §12).** |
| 269 | `GET /api/v1/repos/{o}/{r}/contents/{path}[?ref=]` returns |
| 270 | either a directory listing (dirs first, then files |
| 271 | alphabetically) or a single file with base64-encoded |
| 272 | `content`, `encoding`, `size`, and a `binary` flag (UTF-8 |
| 273 | validity check). Files over 1 MiB come back as |
| 274 | `truncated: true` with empty content — clients fall through |
| 275 | to the raw download path. Scope: `repo:read`. Empty `/contents` |
| 276 | path returns the repo root. |
| 277 | - **Capability:** `contents` added to `/api/v1/meta`. |
| 278 | - **REST: forks (S50 §13).** |
| 279 | `GET /api/v1/repos/{o}/{r}/forks` (paginated; per-row |
| 280 | visibility filter so private forks of public repos only |
| 281 | surface to authorized viewers) and |
| 282 | `POST /api/v1/repos/{o}/{r}/forks` (fork into the |
| 283 | authenticated user's namespace; optional `name`/`visibility` |
| 284 | body). Reuses the existing `internal/repos/fork` orchestrator |
| 285 | and enqueues the on-disk clone via the |
| 286 | `repo:fork_clone` worker, so the response returns |
| 287 | immediately with `init_status: "init_pending"`. Org-target |
| 288 | forks land in a follow-up. |
| 289 | - **Capability:** `forks` added to `/api/v1/meta`. |
| 290 | - **REST: notifications (S50 §14).** User-scoped inbox surface: |
| 291 | `GET /api/v1/notifications` (defaults to unread only; |
| 292 | `?all=true` includes read; paginated with `Link:` headers), |
| 293 | `GET /api/v1/notifications/threads/{id}` (single fetch with |
| 294 | existence-leak-safe cross-user 404), |
| 295 | `PATCH /api/v1/notifications/threads/{id}` (mark read/unread |
| 296 | — empty body / `unread:false` → read; `unread:true` flips |
| 297 | back), and `PUT /api/v1/notifications` (mark all read). |
| 298 | Scopes: `user:read` on GETs, `user:write` on mutations. All |
| 299 | routes are implicitly scoped to the authenticated user. |
| 300 | - **Capability:** `notifications` added to `/api/v1/meta`. |
| 301 | - **REST: watching / subscriptions (S50 §15).** Per-repo |
| 302 | watch-level management mirroring GitHub's |
| 303 | `/repos/{o}/{r}/subscription` shape: |
| 304 | `GET .../subscribers` (paginated watcher list excluding |
| 305 | `ignore` and suspended users), `GET .../subscription` |
| 306 | (viewer's level + `explicit` flag distinguishing the |
| 307 | implicit `participating` default from an explicit choice), |
| 308 | `PUT .../subscription` (set `all` / `participating` / |
| 309 | `ignore`), and `DELETE .../subscription` (revert to implicit). |
| 310 | Reuses `internal/social.SetWatch` / `UnsetWatch` and |
| 311 | `socialdb.ListWatchersForRepo`. Scope: `repo:read` on GETs, |
| 312 | `user:write` on mutations. |
| 313 | - **Capability:** `watching` added to `/api/v1/meta`. |
| 314 | - **REST: events / activity (S50 §16).** Read-only activity feed |
| 315 | over `domain_events`: `GET /api/v1/repos/{o}/{r}/events` |
| 316 | (paginated; returns every event for the repo, gated by |
| 317 | `ActionRepoRead`) and `GET /api/v1/users/{username}/events` |
| 318 | (paginated; only `public=true` rows — matches gh, which never |
| 319 | surfaces private-repo activity on a user feed). Scope: |
| 320 | `repo:read` / `user:read`. Reuses the existing |
| 321 | `socialdb.ListEventsForRepo` and |
| 322 | `socialdb.ListPublicEventsForActor` queries. |
| 323 | - **Capability:** `events` added to `/api/v1/meta`. |
| 324 | - **REST: followers / following (S50 §17).** |
| 325 | `GET /api/v1/users/{username}/followers` and |
| 326 | `GET /api/v1/users/{username}/following` (both paginated; |
| 327 | `Link:` headers), plus the authenticated-user-scoped |
| 328 | `GET /api/v1/user/following/{target}` (204/404 membership |
| 329 | probe matching gh), `PUT /api/v1/user/following/{target}` |
| 330 | (follow), and `DELETE` (unfollow). Self-follow returns 422. |
| 331 | Reuses `internal/social.FollowUser` / `UnfollowUser` so the |
| 332 | follow rate-limit and `followed_user` domain event stay in |
| 333 | one place. Org-follow variants remain on the HTML surface. |
| 334 | - **Capability:** `followers` added to `/api/v1/meta`. |
| 335 | - **REST: actions workflow runs (S50 §18).** Read-only access to |
| 336 | the Actions run history: |
| 337 | `GET /api/v1/repos/{o}/{r}/actions/runs` (paginated; filterable |
| 338 | by `workflow_file`, `head_ref`, `actor`, `event`, `status`, |
| 339 | `conclusion`), |
| 340 | `GET /api/v1/repos/{o}/{r}/actions/runs/{run_id}` (single |
| 341 | run; existence-leak-safe cross-repo 404), and |
| 342 | `GET /api/v1/repos/{o}/{r}/actions/runs/{run_id}/jobs` |
| 343 | (job-index-ordered jobs list with `needs_jobs` graph). Reuses |
| 344 | the existing `actionsdb.ListWorkflowRunsForRepo` / |
| 345 | `GetWorkflowRunByID` / `ListJobsForRun` queries. Scope: |
| 346 | `repo:read`. Lifecycle controls (cancel / rerun / approve) |
| 347 | remain on the actions-lifecycle routes; this surface is |
| 348 | read-only. |
| 349 | - **Capability:** `actions-runs` added to `/api/v1/meta`. |
| 350 | - **REST: stargazers + starred lists (S50 §19).** |
| 351 | `GET /api/v1/repos/{owner}/{repo}/stargazers` paginates the users |
| 352 | who starred a repo (scope: `repo:read`); private-repo lists are |
| 353 | gated by `ActionRepoRead`. `GET /api/v1/users/{username}/starred` |
| 354 | paginates the repos a user has starred (scope: `user:read`); |
| 355 | cross-user views post-filter private repos the caller can't see. |
| 356 | Both endpoints emit standard `Link:` pagination headers and |
| 357 | recency-sort by `starred_at DESC`. The S26 caller-self star |
| 358 | routes (`/api/v1/user/starred` and `/api/v1/user/starred/{o}/{r}`) |
| 359 | are unchanged. |
| 360 | - **Capability:** `stargazers` added to `/api/v1/meta`. |
| 361 | - **REST: issue events / timeline (S50 §20).** |
| 362 | `GET /api/v1/repos/{owner}/{repo}/issues/{number}/events` returns |
| 363 | the issue's recorded timeline (every `closed` / `reopened` / |
| 364 | `labeled` / `unlabeled` / `milestoned` / `demilestoned` / |
| 365 | `locked` / `unlocked` / `referenced` / merged / push event), with |
| 366 | `actor_username` LEFT-joined and the raw event `meta` payload |
| 367 | preserved verbatim. Paginated with the standard `Link:` headers, |
| 368 | sorted oldest-first. Scope: `repo:read`. |
| 369 | - **Capability:** `issue-events` added to `/api/v1/meta`. |
| 370 | - **Device-code login (S50 §1, RFC 8628).** |
| 371 | `POST /login/device/code` issues a fresh authorization grant for |
| 372 | a non-browser client (CLI / TV / IoT). `POST /login/oauth/access_token` |
| 373 | polls for the user's approval and, on success, mints a PAT bound |
| 374 | to the requested scopes. The browser-facing verification page is |
| 375 | served at `GET /login/device` (CSRF-protected). The matching CLI |
| 376 | endpoints are CSRF-exempt. `client_id` is enforced against an |
| 377 | allowlist (default: `shithub-cli`); requested scopes go through |
| 378 | the standard `pat.ValidScope` filter so unknown scopes fail |
| 379 | cleanly with `invalid_scope`. The minted PAT is disclosed exactly |
| 380 | once — subsequent exchanges of the same `device_code` return |
| 381 | `invalid_grant` even after successful approval. RFC 8628 §3.5 |
| 382 | error semantics (`authorization_pending`, `slow_down`, |
| 383 | `access_denied`, `expired_token`) are honored. |
| 384 | - **Capability:** `device-code` added to `/api/v1/meta`. |
| 385 | - **REST: actions workflows + workflow_dispatch (S50 §13 part 1).** |
| 386 | `GET /api/v1/repos/{o}/{r}/actions/workflows` lists the workflows |
| 387 | discovered in `.shithub/workflows/` at the repo's default-branch |
| 388 | HEAD (or `?ref=`); `GET .../workflows/{id_or_file}` fetches a |
| 389 | single workflow by basename, full path, or a deterministic |
| 390 | 64-bit hash of the path. `POST .../workflows/{file}/dispatches` |
| 391 | triggers `workflow_dispatch` with optional `ref` and a typed |
| 392 | `inputs` map (choice / boolean / string with required/default |
| 393 | enforcement, same semantics as the HTML "Run workflow" button). |
| 394 | Workflow_dispatch input validation now lives in a shared |
| 395 | `internal/actions/dispatch` package consumed by both the REST |
| 396 | and HTML surfaces. Enable/disable knobs are deferred to a |
| 397 | follow-up (needs a new `workflow_disabled` table); every listed |
| 398 | workflow is reported as `state: "active"` for now. |
| 399 | - **Capability:** `actions-workflows` added to `/api/v1/meta`. |
| 400 | - **REST: actions lifecycle + artifacts + job logs (S50 §13 part 2).** |
| 401 | New migration `0064_workflow_disabled` adds a per-workflow |
| 402 | disable knob; `trigger.Enqueue` now consults it and short-circuits |
| 403 | matching events for disabled workflows. The list endpoint surfaces |
| 404 | this as `"state": "disabled"`. REST additions: |
| 405 | `PUT /api/v1/repos/{o}/{r}/actions/workflows/{file}/enable` and |
| 406 | `.../disable`, |
| 407 | `DELETE /api/v1/repos/{o}/{r}/actions/runs/{run_id}` (cascades |
| 408 | through jobs/steps/log-chunks/artifacts; object-store cleanup is |
| 409 | async best-effort), |
| 410 | `GET /api/v1/repos/{o}/{r}/actions/runs/{run_id}/artifacts` + |
| 411 | `GET .../actions/artifacts/{aid}` + |
| 412 | `GET .../actions/artifacts/{aid}/zip` (streams from object store) + |
| 413 | `DELETE .../actions/artifacts/{aid}`, |
| 414 | `GET /api/v1/repos/{o}/{r}/actions/jobs/{job_id}/logs` (assembled |
| 415 | transcript with `##[group]`/`##[endgroup]` step markers). |
| 416 | - **Capabilities:** `actions-artifacts`, `actions-job-logs` added to |
| 417 | `/api/v1/meta`. |
| 418 | - **REST: actions secrets + variables (S50 §13 part 3).** |
| 419 | New `internal/auth/sealbox` package owns the server's X25519 |
| 420 | keypair and exposes NaCl `SealedBox` decode. Clients fetch the |
| 421 | public key via |
| 422 | `GET /api/v1/repos/{o}/{r}/actions/secrets/public-key` (and the |
| 423 | org analog), encrypt the secret value with `crypto_box_seal`, and |
| 424 | PUT the base64 ciphertext + key_id. Server validates key_id, |
| 425 | decrypts in memory, then re-encrypts with the shared storage |
| 426 | AEAD for at-rest persistence — plaintext never reaches postgres. |
| 427 | Secrets CRUD: `GET .../secrets`, `GET .../secrets/{name}`, |
| 428 | `PUT .../secrets/{name}`, `DELETE .../secrets/{name}`. Variables |
| 429 | CRUD (plaintext, `${{ vars.NAME }}` namespace): `GET/POST |
| 430 | .../variables`, `GET/PATCH/DELETE .../variables/{name}`. Both |
| 431 | surfaces have repo- and org-scoped variants. Operator setup: |
| 432 | `SHITHUB_ACTIONS__SECRETS__BOX_PRIVATE_KEY_B64` carries the |
| 433 | X25519 private key (32 bytes base64); unset means |
| 434 | auto-generated-per-process with a loud warning at startup, which |
| 435 | is dev-only behavior. |
| 436 | - **Capabilities:** `actions-secrets`, `actions-variables` added to |
| 437 | `/api/v1/meta`. |
| 438 | - **REST: actions caches (S50 §13 part 4 — §13 closure).** New |
| 439 | migration `0065_workflow_caches` adds the `workflow_caches` table |
| 440 | keyed on `(repo_id, cache_key, cache_version, git_ref)` with |
| 441 | size + last_accessed_at + object_key columns and a unique |
| 442 | constraint. REST surface: |
| 443 | `GET /api/v1/repos/{o}/{r}/actions/caches` (paginated; optional |
| 444 | `?key=` and `?ref=` filters; standard `Link:` headers; sorted |
| 445 | recency-DESC by `last_accessed_at`), |
| 446 | `DELETE .../actions/caches/{cache_id}` (single delete with |
| 447 | cross-repo 404 guard + best-effort async S3 cleanup), |
| 448 | `DELETE .../actions/caches?key=...[&ref=...]` (bulk delete by |
| 449 | key; idempotent — 204 even with zero matches). The runner-side |
| 450 | upload protocol that POPULATES this table is a future sprint; |
| 451 | this REST surface lands first so operators have an audit + purge |
| 452 | seat for when caches arrive. |
| 453 | - **Capability:** `actions-caches` added to `/api/v1/meta`. |
| 454 | - **REST: repos follow-ups (S50 §2 closure).** |
| 455 | `GET /api/v1/repos/{o}/{r}/readme[?ref=]` returns the repo's |
| 456 | README as a base64-encoded blob with `download_url` for the raw |
| 457 | bytes (capped at 1 MiB to match the HTML render cap; prefers |
| 458 | `.md`/`.markdown` over plain text when multiple READMEs exist). |
| 459 | `PUT /api/v1/repos/{o}/{r}/topics` and |
| 460 | `DELETE .../topics` replace and clear the topic set atomically |
| 461 | (server-side normalization: lowercase, dedup; constraints: max |
| 462 | 20, 1–50 chars of `[a-z0-9-]`). `POST .../merge-upstream` |
| 463 | fast-forwards a fork's default branch to its upstream — refuses |
| 464 | non-forks with 422 and divergent forks with 409 (users must |
| 465 | reconcile locally). Scopes: `repo:read` on README, |
| 466 | `repo:write` on topics and merge-upstream. |
| 467 | - **Capabilities:** `readme`, `topics`, `merge-upstream` added to |
| 468 | `/api/v1/meta`. |
| 469 | |
| 470 | ### Added (internal) |
| 471 | |
| 472 | - `issues.Edit` orchestrator wraps `UpdateIssueTitleBody` with |
| 473 | markdown re-render + cross-reference re-indexing. Used by the |
| 474 | new PATCH-issue endpoint; available for the HTML edit flow when |
| 475 | it lands. |
| 476 | |
| 477 | ### Changed |
| 478 | |
| 479 | - **JSON error envelope on `/api/v1/*`.** `401` and `403` |
| 480 | responses now emit `{"error": "..."}` with |
| 481 | `Content-Type: application/json` (previously `text/plain`). |
| 482 | Existing `4xx`/`5xx` responses from the handler bodies are |
| 483 | unchanged. |
| 484 | |
| 485 | ## [0.1.0] — TBD (operator fills in cutover date) |
| 486 | |
| 487 | The first public release of shithub. Pre-1.0: there is no |
| 488 | backward-compatibility promise yet. Migrations are forward-only; |
| 489 | schema may change between minor versions. |
| 490 | |
| 491 | ### Initial public surface |
| 492 | |
| 493 | - **Identity** — signup, email verification, password reset, TOTP |
| 494 | 2FA + recovery codes, SSH keys, scoped PATs, sessions with |
| 495 | per-account epoch invalidation. |
| 496 | - **Repositories** — create, fork, archive, transfer, soft-delete |
| 497 | with grace, rename with redirects, visibility toggles, branch |
| 498 | protection, default-branch swap, topics, README/license/ |
| 499 | .gitignore templates. |
| 500 | - **Git** — bare repos on disk; HTTPS smart-HTTP push/pull; |
| 501 | pre/post-receive hook integration. |
| 502 | - **Code browsing** — tree, blob (chroma syntax highlighting), |
| 503 | raw, blame, commit history, individual commit views, branch/tag |
| 504 | listings, compare views, file finder. |
| 505 | - **Issues + PRs** — full CRUD; reviews; required-reviewer |
| 506 | enforcement; status-check gates; three merge methods. |
| 507 | - **Social** — stars, watches, forks, `/explore`, stargazer/ |
| 508 | watcher lists. |
| 509 | - **Search** — code, repo, user, issue. |
| 510 | - **Notifications** — in-app inbox, email fan-out, one-click |
| 511 | unsubscribe. |
| 512 | - **Orgs + teams** — roles, invitations, one-level nesting, |
| 513 | max-of-sources policy. |
| 514 | - **Webhooks** — HMAC-signed delivery, exponential backoff, |
| 515 | auto-disable, SSRF defense, redelivery UI. |
| 516 | - **Observability** — structured logs, Prometheus metrics, |
| 517 | optional OTel tracing, Sentry-protocol error reporting. |
| 518 | - **Operations** — Ansible playbook, systemd units, Caddy edge, |
| 519 | WireGuard mesh for monitoring, Postgres WAL archive + daily |
| 520 | logical backups to Spaces, cross-region DR, restore drill. |
| 521 | - **Public landing page** on `/` for anonymous viewers; signed-in |
| 522 | viewers get a quick-link dashboard. |
| 523 | - **Lightweight status page** at `docs.<host>/status.html`. |
| 524 | - **Cutover artifacts** under `deploy/cutover/`. |
| 525 | - **Public docs site** built with mdBook. |
| 526 | - **Operator runbooks** for incidents, backups, restore, upgrade, |
| 527 | rollback, rotate-secrets, rotate-keys, regenerate-akc, |
| 528 | drain-workers, read-only-mode, day-one. |
| 529 | - **a11y tooling** (pa11y + axe) and **k6 load-test scenarios**. |
| 530 | - **THIRD_PARTY_NOTICES.md** with a CI-verified generator. |
| 531 | |
| 532 | ### Known gaps at v0.1.0 |
| 533 | |
| 534 | - SSH git transport (HTTPS only) |
| 535 | - Actions / CI runner |
| 536 | - Packages, Releases, Pages, Projects, Gists |
| 537 | - GraphQL API (only a small REST surface today) |
| 538 | - Activity feed UI |
| 539 | |
| 540 | These are all on the post-MVP roadmap. |
| 541 | |
| 542 | [Unreleased]: https://shithub.sh/shithub/shithub/compare/v0.1.0...trunk |
| 543 | [0.1.0]: https://shithub.sh/shithub/shithub/releases/tag/v0.1.0 |