more
47f1c44 · gmorell · 2026-05-13 22:24
Files changed
modified
.gitignore
+10
−0
| 35 | 35 | npm-debug.log |
| 36 | 36 | /assets/node_modules/ |
| 37 | 37 | |
| 38 | +# Generated git host directories for bare repos. | |
| 39 | +/priv/repos/ | |
| 40 | + | |
| 41 | +# SSH host keys generated on first daemon boot. | |
| 42 | +/priv/ssh/ | |
| 43 | + | |
| 44 | +# Disk-backed object storage (LFS objects, CI artifacts) when running | |
| 45 | +# with `GitGud.Storage.Disk`. | |
| 46 | +/priv/storage/ | |
| 47 | + |
modified
PROGRESS.md
+190
−3
| 12 | 12 | |
| 13 | 13 | --- |
| 14 | 14 | |
| 15 | +## Slice 31 — Unified MFA: recovery codes for any factor | |
| 16 | + | |
| 17 | +Cleanup pass over the two-factor surface so recovery codes are tied to "MFA" generally, not specifically to TOTP. With WebAuthn-only users now possible (slice 30 only minted codes on TOTP enroll), they would have had no recovery path on key loss. | |
| 18 | + | |
| 19 | +- **`TwoFactor.ensure_recovery_codes/1`** — mints codes only if the user has none unused. Called from both TOTP enable + first WebAuthn enrollment so codes are issued exactly once across MFA setup methods, no matter which factor the user adds first. | |
| 20 | +- **`Webauthn.register_credential/3`** return type widened to `{:ok, cred, recovery_codes | nil}`. Controller forwards the codes to the JS client; the JS hook renders them inline (via a `[data-recovery-codes-target]` sink in the page) with an "I've saved these" button to dismiss + reload. Existing TOTP flow unchanged — recovery codes still appear on the enrollment page after a successful first code. | |
| 21 | +- **`TwoFactor.disable/1`** — keeps recovery codes if the user has any WebAuthn credentials remaining (they're still useful as a fallback for the other factor). Only wipes them when 2FA is fully off. | |
| 22 | +- **`UserAuth.log_in_user/3`** — gates on `mfa_enabled?/1` (TOTP **or** WebAuthn), so a WebAuthn-only user no longer slips past the challenge. | |
| 23 | +- **`TwoFactorChallenge`** LiveView — reads `has_totp?` + `has_webauthn?` separately. WebAuthn-only users default to `:recovery` code entry (since there's no TOTP to fall back to) and the "Back to authenticator code" link is hidden. Security-key button stays visible for both states. | |
| 24 | +- 3 new tests; 320 pass overall. | |
| 25 | + | |
| 26 | +## Slice 30 — WebAuthn / security keys | |
| 27 | + | |
| 28 | +Second-factor option alongside TOTP. Hardware keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), and browser passkeys all work — anything implementing FIDO2. | |
| 29 | + | |
| 30 | +- **Dep**: `:wax_` for attestation + assertion verification (handles CBOR + COSE + multiple attestation formats + clock-skew bounds). | |
| 31 | +- **Schema**: new `user_webauthn_credentials` table — `credential_id`, COSE `public_key`, `sign_count` (clone-detection counter), `aaguid` (authenticator model), `name`, `last_used_at`. Unique on `(user_id, credential_id)` so re-registering the same physical key replaces rather than duplicates. | |
| 32 | +- **`GitGud.Accounts.Webauthn`** context — `registration_challenge/1`, `serialize_registration_options/2`, `register_credential/3`, `authentication_challenge/1`, `serialize_authentication_options/1`, `verify_assertion/3` plus CRUD (`list_credentials`, `delete_credential`, `rename_credential`, `count`, `has_credentials?`). Pre-existing credentials populate `excludeCredentials` so the browser doesn't try to enroll an already-paired key. | |
| 33 | +- **Endpoints**: | |
| 34 | + - `POST /api/webauthn/register/{start,finish}` — authed; the start handler stashes the `Wax.Challenge` in the session | |
| 35 | + - `POST /api/webauthn/auth/{start,finish}` — anonymous (mid-2FA); reads `:two_factor_user_id` from session, on success writes `:webauthn_verified_user_id` and returns the JSON redirect target | |
| 36 | +- **JS hooks** (`assets/js/webauthn.js`) — two LiveView hooks (`WebauthnRegister`, `WebauthnAuthenticate`) handle base64url ↔ Uint8Array conversion + `navigator.credentials.create / .get`. CSRF token forwarded via `x-csrf-token` header. | |
| 37 | +- **UI integration**: | |
| 38 | + - `TwoFactorSetup` LiveView gets a "Security keys" section with an **Add security key** button + a list of registered keys (name, AAGUID, added / last-used timestamps, remove button) | |
| 39 | + - `TwoFactorChallenge` LiveView (login second factor) shows a **Use security key** button when the user has any registered — visible alongside the TOTP / recovery code paths | |
| 40 | +- 7 new tests cover challenge generation, serialization shape, and CRUD. The cryptographic round-trip is covered upstream in Wax's own test suite + manual browser testing. 317 tests pass overall. | |
| 41 | + | |
| 42 | +Notes: | |
| 43 | +- `config :git_gud, GitGud.Accounts.Webauthn` accepts `:rp_id` (defaults to the endpoint host) and `:origin` (defaults to `GitGudWeb.Endpoint.url()`). For a multi-origin deployment (e.g. dev + prod under different hostnames), set these explicitly. | |
| 44 | +- Recovery flow when the user loses every security key + their TOTP secret: recovery codes from slice 29 still work. | |
| 45 | + | |
| 46 | +## Slice 29 — TOTP + recovery codes + instance enforcement | |
| 47 | + | |
| 48 | +- **Deps**: `:nimble_totp` for RFC 6238 codes, `:eqrcode` for the enrollment QR. | |
| 49 | +- **Schema**: `users` gains `totp_key_id`, `totp_ciphertext`, `totp_iv`, `totp_tag`, `totp_enabled_at`. Secrets encrypted via the existing AES-256-GCM helpers (same primitive as CI secrets, same key rotation story). New `user_recovery_codes` table — bcrypt-hashed, one row per code, `used_at` marks consumption. | |
| 50 | +- **`GitGud.Accounts.TwoFactor`** — context covering enroll / verify / disable / recovery codes / instance enforcement. Three user states: disabled / pending / enabled. A half-enrolled secret never gates login. | |
| 51 | +- **Login flow** — `UserAuth.log_in_user/3` detects `TwoFactor.enabled?/1` and stashes `:two_factor_user_id` in session, then redirects to `/users/log-in/two-factor`. The challenge LiveView accepts a 6-digit TOTP **or** a recovery code (the latter consumed single-use). On success, a small controller endpoint clears the stash and runs `complete_two_factor/2`, which is the only place where `log_in_user` gets to call `create_or_extend_session`. | |
| 52 | +- **Enrollment LiveView** at `/users/settings/two-factor` — three views in one module: disabled → pending (QR + manual key + confirmation field) → enabled (recovery codes shown once + regenerate / disable controls). Pending secrets live in socket state only; they touch the DB only after a valid first code. | |
| 53 | +- **Recovery codes** — 10 codes per user, lowercase base32, hashed with bcrypt (each row its own salt). Verification scans the user's unused codes; on miss runs `Bcrypt.no_user_verify/0` so timing doesn't leak validity. | |
| 54 | +- **Instance enforcement** — `config :git_gud, GitGud.Accounts.TwoFactor, required: true, grace_period_days: 14`. `enforcement_status/1` returns `:not_required | :enrolled | {:grace, days_left} | :enforced`. The `:require_authenticated` on_mount hook redirects users in the `:enforced` state to the enrollment LiveView (excluding the enrollment LiveView itself to avoid a loop). Default ships off so existing deployments aren't surprised. | |
| 55 | +- **Settings page** gets a Two-factor section with a status-aware enable/manage button. | |
| 56 | +- **Runner UUID fix** — Forgejo runner v12+ validates the `uuid` field on the `Register` response as a real UUID; we were sending stringified `runner.id` ("4", "5") and the runner failed config save with "invalid UUID length: 1". Added `runners.uuid` column (Ecto.UUID, backfilled with `gen_random_uuid()`), generated on registration, returned in the proto. | |
| 57 | +- 13 new TwoFactor tests; 310 tests pass overall. | |
| 58 | + | |
| 59 | +## Slice 28 — Per-scope runners + org secrets UI | |
| 60 | + | |
| 61 | +- **Runner scope** — migration `20260513200000` adds `runners.repository_id` + `runners.organization_id` (nullable, with a CHECK constraint enforcing at most one set) plus `runner_reg_token_hash` columns on repositories + organizations. | |
| 62 | +- **`Workflows.Runners`** rewritten around scope: | |
| 63 | + - `mint_registration_token(repo_or_org)` stores SHA-256 hash, returns bare token once | |
| 64 | + - `consume_registration_token/1` resolves a token to `:global` / `%Repository{}` / `%Organization{}` or `:error` | |
| 65 | + - `register/2` takes a scope, sets the right FK | |
| 66 | + - `list_for_scope/1` returns repo-eligible runners (repo + parent org + global), org-eligible (org + global), or just global | |
| 67 | + - `delete_runner/1` for revocation | |
| 68 | + - Old `valid_registration_token?/1` removed (callers use `consume_registration_token/1`) | |
| 69 | +- **`claim_next_job/1`** widened — joins through `workflow_runs` (and `repositories` for org scope) so a repo runner only sees its repo's jobs, an org runner sees jobs from any of its repos, and a global runner sees everything. Label match unchanged. | |
| 70 | +- **`RunnerController.register`** consumes the token to determine scope and threads it into `Runners.register/2`. Legacy global env token still works. | |
| 71 | +- **`RepoLive.SettingsRunners`** at `/r/:owner/:name/settings/runners` — mint / regenerate the repo token (with copy-paste `forgejo-runner register` snippet), list eligible runners with scope badges, remove repo-scoped runners. | |
| 72 | +- **`OrgLive.SettingsRunners`** at `/orgs/:handle/settings/runners` — same pattern for orgs. | |
| 73 | +- **`SecretsLive.Org`** at `/orgs/:handle/settings/secrets` — mirror of `SecretsLive.Repo`. The org secrets/vars backend was already shipped slice 16; this was the missing UI. | |
| 74 | +- **Settings nav** — repo settings page gets a sub-nav linking to Secrets / Runners / Branches / Webhooks / Deploy keys. Org show page gets an admin-only Secrets + Runners nav. | |
| 75 | +- 6 new tests covering token mint/consume/regen, scope-aware `register/2`, and `list_for_scope/1` aggregation. 297 tests pass overall. | |
| 76 | + | |
| 77 | +## Slice 27 — Expand repo CI UI | |
| 78 | + | |
| 79 | +Fills out the workflow LiveViews so the existing CI backend is actually visible. | |
| 80 | + | |
| 81 | +- **`Workflows.latest_run/1`** and **`Workflows.list_artifacts/1`** context helpers — the controller had inline queries, but the UI needed straightforward functions. | |
| 82 | +- **Repo show header** gets an **Actions** link with a status badge showing the most recent run's state (queued / running / success / failure). Currently sits next to Compare and is visible to anyone with read access. | |
| 83 | +- **Workflow index** lists each run with **start time + duration** ("started 2026-05-13 11:00 · in 1m 42s") alongside trigger/ref/sha. | |
| 84 | +- **Workflow show**: | |
| 85 | + - Run-level **started / finished / duration** under the badge | |
| 86 | + - Per-job **duration** in the job header | |
| 87 | + - Per-step **duration** in the toggle row | |
| 88 | + - New **Artifacts panel** below jobs: name, size, content-type, upload time + per-artifact Download button | |
| 89 | +- **`GitGudWeb.WorkflowArtifactController`** — browser-side download path at `/r/:owner/:name/actions/:number/artifacts/:id`. Gates on repo read access from `current_scope`, sets `Content-Disposition: attachment`, sanitizes filenames. The runner-side endpoint (`/api/actions/_apis/...`) keeps its JWT gate intact. | |
| 90 | +- 3 new tests covering `latest_run/1` + `list_artifacts/1` ordering. 291 tests pass overall. | |
| 91 | + | |
| 92 | +Still left from the CI roadmap (separate slices when wanted): | |
| 93 | +- Re-run / cancel actions | |
| 94 | +- Cache-hit path on the v1 Actions cache server | |
| 95 | +- Org-scoped secrets/vars LiveView (the repo equivalent shipped slice 16) | |
| 96 | + | |
| 97 | +## Slice 26 — Markdown styling + per-user editor theme | |
| 98 | + | |
| 99 | +Followup to slice 25 — the cache + highlighter were rendering correctly but two visible gaps remained: no typography styles for the `prose` selector (Tailwind v4 has no built-in typography), and no way for a user to pick a syntax theme. | |
| 100 | + | |
| 101 | +- **Hand-rolled `.prose` CSS** in `assets/css/app.css` covering headings (h1/h2 get separator rules like GitHub), paragraphs, lists with disc/decimal markers, GFM task lists (with the checkbox prefix), blockquotes, GitHub-style alerts (`> [!NOTE]` etc), inline + fenced code, tables, images, hr, heading anchors. Plain `.markdown-fallback` / `.lumis-fallback` styled too. All built on daisyUI's `--color-base-*` variables so it tracks the active page theme. | |
| 102 | +- **`users.editor_theme` column** (migration `20260513180000`) plus `User.editor_theme_changeset/2`. | |
| 103 | +- **`GitGud.Themes.Editor`** — curated Lumis subset surfaced in UI: OneDark, Dracula, Solarized Dark/Light, Monokai, GitHub Dark/Light, Gruvbox Dark/Light, Nord, Tokyo Night, Catppuccin Mocha/Latte, Rose Pine. `for_scope/1` resolves from `current_scope.user.editor_theme`, falling back to the app default when unset or invalid. | |
| 104 | +- **`assign_editor_theme/1`** hook in `UserAuth.on_mount/*` puts `@editor_theme` into every LiveView's assigns automatically (anon + authed). No per-view boilerplate. | |
| 105 | +- **`GitGud.Markdown.to_html/2`** + **`GitGud.SyntaxHighlight.highlight/2`** both accept `:theme`; cache keys include it so changing themes doesn't poison prior entries. | |
| 106 | +- **All call sites updated** — issue / PR / wiki / repo show / blob views now pass `@editor_theme` through. | |
| 107 | +- **User settings page** — new "Editor theme" form between email and password with a dropdown of the curated set. Empty selection means "use app default." | |
| 108 | +- 4 new tests for `GitGud.Themes.Editor`. 288 tests pass overall. | |
| 109 | + | |
| 110 | +## Slice 25 — Nebulex cache + syntax highlighting + markdown overhaul | |
| 111 | + | |
| 112 | +- **`GitGud.Cache`** — Nebulex local cache (ETS, partitioned) started under the app supervisor. Configured with `max_size: 50_000`, `allocated_memory: 200MB`, `gc_interval: 12h`. Adapter swap (replicated / partitioned) is a config change, not a code change. | |
| 113 | +- **`GitGud.SyntaxHighlight`** — Rust-backed highlighter via [Lumis](https://lumis.sh), the tree-sitter highlighter pulled in transitively through MDEx. (User asked for Syntect; Lumis is functionally equivalent and already in the dep graph — same Rust-NIF latency profile.) Language resolved from explicit `:language` or `:filename` (extension + basename map for `Dockerfile`, `Makefile`, etc.). Results cached by sha256(content) + language + theme; never go stale because the key includes the bytes. 250KB cap with HTML-escaped fallback above that. | |
| 114 | +- **`GitGud.Markdown`** — single entry point for all markdown rendering in the codebase. Backed by MDEx with the full GFM feature set: tables, strikethrough, task lists, autolink, footnotes, GitHub-style alerts (`> [!NOTE]`), heading anchors (`h-` prefix so deep links work), smart punctuation, relaxed task-list matching. Fenced code blocks get Lumis-powered syntax highlighting via MDEx's `:syntax_highlight` formatter. Sha256-keyed cache with 500KB input cap. | |
| 115 | +- **Duplicated `render_md` helpers eliminated** — `WikiLive.Show`, `PrLive.Show`, `IssueLive.Show`, `RepoLive.Show`, `RepoLive.Blob` all delegated to `GitGud.Markdown.to_html/1`. Each was previously its own copy of the same MDEx call. | |
| 116 | +- **`RepoLive.Blob` now syntax-highlights source files** — non-markdown text files render through `SyntaxHighlight.highlight/2`, picking up the filename-based language hint. Markdown files keep the markdown render path. Binary / oversize fallbacks unchanged. | |
| 117 | +- 12 new tests (8 markdown + 4 highlight) covering GFM features, cache hits, oversize fallback, language resolution. 284 tests pass overall. | |
| 118 | + | |
| 119 | +Notes for ops: | |
| 120 | +- Cache is per-node ETS; restart drops it. Acceptable since regen is cheap and miss path returns the right answer. | |
| 121 | +- To partition across a cluster, swap `Nebulex.Adapters.Local` for `Nebulex.Adapters.Partitioned` in `GitGud.Cache`. No call-site changes required. | |
| 122 | + | |
| 123 | +## Slice 24 — Disk storage backend + LFS | |
| 124 | + | |
| 125 | +Pluggable object storage with a Disk impl for dev / single-node prod / NFS-shared multi-node, plus the Git LFS Batch API on top. | |
| 126 | + | |
| 127 | +- **`GitGud.Storage.Disk`** — `@behaviour GitGud.Storage` impl reading/writing under a configured `:root` directory. `presign_url/4` returns a token-signed URL routed through a new `GitGudWeb.StorageController`. Token via `Phoenix.Token`, embeds bucket+key+method+expiry. Path components sanitized against traversal (`..`, `/`, `\`). | |
| 128 | +- **`GitGudWeb.StorageController`** — `GET` and `PUT` at `/_storage/:bucket/*key` verify the token and stream bytes to/from the on-disk path via `send_file` / chunked `read_body`. New `:raw` router pipeline so Plug.Parsers doesn't swallow the upload body. | |
| 129 | +- **`lfs_objects` table** (migration `20260513170000`) — `repository_id`, `oid` (64-hex SHA-256), `size`, `storage_bucket`, `storage_key`, `uploaded_at`. Unique on `(repository_id, oid)`. | |
| 130 | +- **`GitGud.Lfs` context** — `batch/3` for the LFS Batch API ("download" / "upload"), `verify/3` for the post-upload completion call, `get/2` lookup. Storage key layout `<repo_id>/<oid[0..1]>/<oid[2..]>` to keep fan-out reasonable on filesystem backends. Placeholder rows are created at batch time so concurrent uploads of the same oid don't race; `uploaded_at` is only set after `verify`. | |
| 131 | +- **`GitSmartHttp` plug** gets two new dispatch clauses: `POST /:owner/:name.git/info/lfs/objects/batch` and `POST /:owner/:name.git/info/lfs/verify`. JSON body parsed inline (Plug.Parsers is bypassed at this point in the pipeline). Auth reuses the existing basic-auth helpers — download maps to read, upload maps to write. | |
| 132 | +- **Config** — `config :git_gud, GitGud.Storage, impl: GitGud.Storage.Disk` is the new default; `config :git_gud, GitGud.Storage.Disk, root: "priv/storage"`. Tests + dev use Disk under `tmp/test/storage`. Prod can swap to `GitGud.Storage.Garage` with one config line. | |
| 133 | +- **Tests** — 11 unit tests (Storage.Disk roundtrip + token verification + LFS context lifecycle) and 3 HTTP-level tests against the LFS endpoints. 272 tests pass overall. | |
| 134 | + | |
| 135 | +Notes for ops: | |
| 136 | +- Multi-node Disk requires every node to mount the same shared filesystem at the configured `:root`. NFS or any POSIX-shared FS works; no node-local writes. | |
| 137 | +- For S3-flavored deployments, set `impl: GitGud.Storage.Garage` and configure `ex_aws` as described in that module's docstring. | |
| 138 | +- The `/_storage` routes only matter for the Disk backend; with Garage the presigned URLs go directly to S3. | |
| 139 | + | |
| 140 | +## Slice 23 — Federation follow-up UI | |
| 141 | + | |
| 142 | +The three small UI gaps left by slice 22. | |
| 143 | + | |
| 144 | +- **Admin reports queue — Forward to source button** — `AdminFederationLive.Index` reports tab now has a "Forward to source" action next to Dismiss/Mark actioned. Calls `Reports.forward_to_source/2`; surfaced only when `target_type ∈ {pull_request, pr_comment, actor}`. Backend gates the actual federation eligibility, button is just a heuristic gate. | |
| 145 | +- **Remote actor profile pages** — `GitGudWeb.ActorLive.Show` at `/actors/:id` renders a remote (or local) actor's cached profile: name, handle@host, summary, last_fetched_at, status, canonical URL link. Includes a "Report this actor" button that opens an `actor` target-type report. Federated-PR show page links the "Offered by" line to `/actors/:id` so reading users can reach the report path in one click. | |
| 146 | +- **"Send PR to remote repo" UI** — `PrLive.New` gains a toggle for "Send to a remote repository (ForgeFed Offer)". When on, the target-branch input becomes a free-text field (we don't know the remote's branches), an AP URL field appears, and Submit calls `Federation.Outbound.offer_pull_request/4` instead of `create_pull_request/3`. No local PR row is created — the user is told "Offer queued for delivery." | |
| 147 | +- 258 tests still pass; UI-only wiring with no new tests. | |
| 148 | + | |
| 149 | +ROADMAP federation section now empty except for the bigger discretionary items (federated reviews shape, `mix gitgud.federation.suggest_blocks`, RFC 9421, reputation aging). | |
| 150 | + | |
| 151 | +## Slice 22 — Federation completion | |
| 152 | + | |
| 153 | +Closes the remaining federation gaps. Covers the federated-PR lifecycle (Update/Reject + comments), outbound delivery hardening, remote-actor freshness, and abuse-report plumbing. | |
| 154 | + | |
| 155 | +- **Outbound dead-letter** — `DeliverOutbound` now distinguishes terminal vs in-flight failures: on the final attempt the row is written with `status: "dead_letter"` (new status enum value) and the job is `:discard`ed cleanly instead of bouncing as a retryable error. Status enum: `pending|success|failure|dropped|dead_letter`. | |
| 156 | +- **Inbound `Update`** — `ProcessInbox.dispatch("Update", …)` matches a Ticket with merge intent against the source actor's open federated PRs, calls `PullRequests.refresh_federated_head/2` to re-fetch the source branch, re-snapshot `head_sha` + `base_sha`, re-pin `refs/pull/<N>/head`, and clear `mergeable` for re-probing. | |
| 157 | +- **Inbound `Reject`** — Reject of a Ticket (by id or as a bare URL) closes the matching federated PR via `PullRequests.set_state/2`. | |
| 158 | +- **Inbound `Create(Note)`** — `Create` with a Note `inReplyTo` a local PR's Ticket URL lands as a federated PR comment with `source_actor_id` set and `author_id: nil`. Idempotent via unique partial index on `pr_comments.activity_url`. Migration `20260513160000_add_federated_pr_comment_origin` adds `source_actor_id`, `activity_url` columns; `PrComment.federated_changeset/2` + `federated?/1` helper. | |
| 159 | +- **Remote actor refresh worker** — `Federation.Workers.RefreshRemoteActors` walks 50 stalest active remote actors per hour (cron `@hourly`), re-fetching them via `RemoteActors.fetch/1`. 404/410 responses mark the actor `status: "suspended"` so we stop wasting refresh cycles on tombstones. | |
| 160 | +- **PR + actor reporting (UI wiring)** — PR show page now has a flag button on the header and on each comment. Handler mirrors the issue path: `GitGud.Reports.open(user, {type, id}, reason)`. `pr_comments` preload extended with `:source_actor` so federated comments render with origin handle + `federated` badge. | |
| 161 | +- **Cross-instance report forwarding** — `Reports.forward_to_source/2` resolves the report's target to its originating remote actor (PR / pr_comment / actor target types), builds an AP `Flag` activity authored by the admin's local actor, and queues `DeliverOutbound` to the remote shared / actor inbox. New `ActivityBuilders.flag/3`. Refuses non-federated targets with `{:error, :not_federated}`. | |
| 162 | +- **Outbound `Offer`** — `GitGud.Federation.Outbound.offer_pull_request/4` resolves a target repository's AP actor URL via `RemoteActors.fetch/1`, validates the target is a `Repository`-typed actor, builds an `Offer(Ticket)` via the new `ActivityBuilders.offer_pull_request/3`, inserts a local activity row, and queues `DeliverOutbound`. No local PR is created — the canonical state lives on the target instance. Operator-callable today; UI integration for "send to remote repo" is a follow-up. | |
| 163 | +- 7 new tests (`pull_requests_federated_test.exs` grows from 3 to 6 covering Update/Reject/Note; new `federation/outbound_test.exs` covers offer + Flag forwarding + non-federated refusal). 258 tests pass overall. | |
| 164 | + | |
| 165 | +Deliberately out of scope for the slice: | |
| 166 | +- Admin LiveView for the reports queue (with dismiss/action/forward buttons) — backend exists, UI is a separate small task | |
| 167 | +- Remote actor profile pages with their own "Report this actor" button — needs the actor browser first | |
| 168 | +- RFC 9421 signature support — current draft-cavage-12 still works against the federation universe | |
| 169 | + | |
| 170 | +## Slice 21 — Federated PRs (inbound `Offer`) | |
| 171 | + | |
| 172 | +- Migration `20260513150000_add_federated_pr_origin` adds three columns to `pull_requests`: `source_actor_id` (FK to `actors`), `activity_url` (unique partial index for idempotency), `source_clone_url`. Plus index on `source_actor_id`. | |
| 173 | +- `PullRequest` schema gains `belongs_to :source_actor`, `field :activity_url`, `field :source_clone_url`, and `federated?/1`. | |
| 174 | +- `PullRequests.create_federated_pull_request/3` — opens a PR whose source is a remote AP actor. Fetches the source branch directly from the supplied `source_clone_url` into the target repo (`refs/pull-tmp/...`), verifies the claimed head SHA is reachable, computes the merge base, inserts a PR with `source_repository_id=nil`, `author_id=nil`, `source_actor_id` set, and pins head into `refs/pull/<N>/head`. | |
| 175 | +- `ProcessInbox.dispatch("Offer", …)` — recognizes a ForgeFed `Ticket` with `isMerge: true` (or `sourceBranch`/`targetBranch` heuristics), resolves the source clone URL from `object.source.cloneUri` then `actor.endpoints.cloneUri` then the actor URL + `.git` as a last resort. Idempotent on `activity_url`. | |
| 176 | +- `PrLive.Show` and `PrLive.Index` render a "federated" badge for `source_actor_id`-bearing PRs; the show page also names the offering actor and the source clone URL. | |
| 177 | +- 3 new tests in `test/git_gud/pull_requests_federated_test.exs`: direct `create_federated_pull_request` path with head pinning, end-to-end through `ProcessInbox` with re-delivery idempotency, and non-merge Ticket rejection. 251 tests pass overall. | |
| 178 | +- Outbound `Offer` (local user opens a PR against a remote repo) is **not** in this slice — that needs a target-resolution flow (webfinger or AP id) and the existing `ActivityBuilders` has no `pr_offered` builder yet. Tracked in ROADMAP under outbound federation. | |
| 179 | + | |
| 180 | +## Slice 20 — Embedded SSH daemon | |
| 181 | + | |
| 182 | +- `GitGud.Ssh.Server` GenServer boots Erlang's `:ssh.daemon/2` under the app supervisor when `config :git_gud, GitGud.Ssh.Server, enabled: true`. Default port 2222. Host key auto-generated to `priv/ssh/ssh_host_ed25519_key` on first boot via `ssh-keygen` (gitignored). | |
| 183 | +- `GitGud.Ssh.KeyApi` (`:ssh_server_key_api`): looks up presented public key by SHA-256 fingerprint in `user_ssh_keys`, requires the SSH username to equal the user's email local-part (Gerrit pattern). Fingerprint parity with `SshKey.compute_fingerprint/1` via `:ssh_message.ssh2_pubkey_encode/1` with manual SSH wire-format fallback for ed25519 + RSA. | |
| 184 | +- `GitGud.Ssh.Channel` (`:ssh_server_channel`): parses `git-{upload,receive}-pack '<owner>/<repo>.git'`, authorizes against repo visibility / ownership / org admin, spawns git via Port using the subcommand form (`git upload-pack <path>`, not the dashed binary — wrong invocation reported "is not a git command"). Stderr stays on BEAM's stderr, **not** merged into stdout, so the pkt-line protocol stream isn't corrupted. Deny messages go via SSH extended-data type 1 (renders as `remote:` on the client). | |
| 185 | +- Empty-repo page's `ssh_clone_url/2` now uses the configured SSH port + optional `external_host`. | |
| 186 | +- `HookReceiver.receive/2` self-heals `Repository.default_branch` from disk HEAD when the configured branch doesn't resolve — closes the "first push to master while default_branch=main shows empty repo" gap. | |
| 187 | +- 4 fingerprint-parity tests in `test/git_gud/ssh/key_api_test.exs`. 248 tests pass. | |
| 188 | +- Deploy keys over the embedded daemon: not in v1 (still work via the external SSH wrapper). Documented in module doc. | |
| 189 | + | |
| 15 | 190 | ## Slice 19 — Fork sync, compare view, empty-repo onboarding |
| 16 | 191 | |
| 17 | 192 | - `Repositories.fork_status/2` — fetches upstream into the fork under `refs/upstream/<branch>` and returns `{behind, ahead, fast_forward?, parent_sha, fork_sha}` |
| 226 | 401 | | 65–70 | done | Forks + cross-repo PRs (slice 17) | |
| 227 | 402 | | 71–74 | done | Layout + nav + themes + landing (slice 18) | |
| 228 | 403 | | 75–77, 79 | done | Fork sync, compare, empty-repo onboarding (slice 19) | |
| 229 | −| 78 | pending | Federated PRs (next slice) | | |
| 230 | − | |
| 231 | −End-of-session: 244 tests pass. Federated PRs (#78) is the open follow-up. | |
| 404 | +| 80–83 | done | Embedded SSH daemon + self-healing default_branch (slice 20) | | |
| 405 | +| 78 | done | Federated PRs — inbound `Offer` (slice 21) | | |
| 406 | +| 84 | done | Federation completion: outbound dead-letter, Update/Reject/Note, refresh worker, PR reporting, forward Flag, outbound Offer (slice 22) | | |
| 407 | +| 85 | done | Federation follow-up UI: admin Forward button, actor profile page, Send-PR-to-remote toggle (slice 23) | | |
| 408 | +| 86 | done | Disk storage backend + LFS Batch API (slice 24) | | |
| 409 | +| 87 | done | Nebulex cache + Lumis syntax highlight + Markdown overhaul (slice 25) | | |
| 410 | +| 88 | done | Markdown styling + per-user editor theme (slice 26) | | |
| 411 | +| 89 | done | Repo CI UI: artifacts panel, timings, latest-run badge (slice 27) | | |
| 412 | +| 90 | done | Per-scope runners + org secrets UI (slice 28) | | |
| 413 | +| 91 | done | TOTP + recovery codes + instance enforcement + runner UUID fix (slice 29) | | |
| 414 | +| 92 | done | WebAuthn / security keys (slice 30) | | |
| 415 | +| 93 | done | Unified MFA: recovery codes for any factor (slice 31) | | |
| 416 | + | |
| 417 | +End-of-session: 320 tests pass. Next likely: outbound `Offer` (local PR against remote repo) — needs target-resolution (webfinger or AP id) and an `Offer(Ticket)` builder. Or LFS, the other "now" item. | |
| 418 | +Embedded SSH daemon verified end-to-end against `git push --set-upstream origin master` from a real client (after fixing two bugs: spawning git with the dashed subcommand name failed; `:stderr_to_stdout` on the Port corrupted the pkt-line stream). | |
| 232 | 419 |
modified
README.md
+221
−11
Click to load diff…
modified
ROADMAP.md
+7
−9
Click to load diff…
modified
assets/css/app.css
+147
−0
Click to load diff…
modified
assets/js/app.js
+2
−1
Click to load diff…
added
assets/js/webauthn.js
+181
−0
Click to load diff…
modified
config/config.exs
+39
−1
Click to load diff…
modified
config/dev.exs
+11
−3
Click to load diff…
modified
config/test.exs
+5
−0
Click to load diff…
modified
?
+0
−0
Click to load diff…
added
lib/git_gud/accounts/recovery_code.ex
+27
−0
Click to load diff…
added
lib/git_gud/accounts/two_factor.ex
+302
−0
Click to load diff…
modified
lib/git_gud/accounts/user.ex
+15
−0
Click to load diff…
added
lib/git_gud/accounts/webauthn.ex
+257
−0
Click to load diff…
added
lib/git_gud/accounts/webauthn_credential.ex
+42
−0
Click to load diff…
modified
lib/git_gud/application.ex
+2
−0
Click to load diff…
added
lib/git_gud/cache.ex
+30
−0
Click to load diff…
modified
lib/git_gud/federation/activity_builders.ex
+63
−0
Click to load diff…
added
lib/git_gud/federation/outbound.ex
+117
−0
Click to load diff…
modified
lib/git_gud/federation/outbound_delivery.ex
+1
−1
Click to load diff…
modified
lib/git_gud/federation/workers/deliver_outbound.ex
+13
−4
Click to load diff…
modified
lib/git_gud/federation/workers/process_inbox.ex
+279
−0
Click to load diff…
added
lib/git_gud/federation/workers/refresh_remote_actors.ex
+57
−0
Click to load diff…
added
lib/git_gud/lfs.ex
+177
−0
Click to load diff…
added
lib/git_gud/lfs/lfs_object.ex
+34
−0
Click to load diff…
added
lib/git_gud/markdown.ex
+126
−0
Click to load diff…
modified
lib/git_gud/organizations/organization.ex
+2
−0
Click to load diff…
modified
lib/git_gud/pull_requests.ex
+176
−1
Click to load diff…
modified
lib/git_gud/pull_requests/pr_comment.ex
+17
−0
Click to load diff…
modified
lib/git_gud/pull_requests/pull_request.ex
+11
−0
Click to load diff…
modified
lib/git_gud/reports.ex
+90
−0
Click to load diff…
modified
lib/git_gud/repositories/hook_receiver.ex
+67
−5
Click to load diff…
modified
lib/git_gud/repositories/repository.ex
+4
−0
Click to load diff…
added
lib/git_gud/ssh/channel.ex
+278
−0
Click to load diff…
added
lib/git_gud/ssh/key_api.ex
+102
−0
Click to load diff…
added
lib/git_gud/ssh/server.ex
+112
−0
Click to load diff…
added
lib/git_gud/storage/disk.ex
+144
−0
Click to load diff…
added
lib/git_gud/syntax_highlight.ex
+167
−0
Click to load diff…
added
lib/git_gud/themes/editor.ex
+86
−0
Click to load diff…
modified
lib/git_gud/workflows.ex
+62
−9
Click to load diff…
modified
lib/git_gud/workflows/runner.ex
+15
−1
Click to load diff…
modified
lib/git_gud/workflows/runners.ex
+183
−21
Click to load diff…
modified
lib/git_gud_web/components/core_components.ex
+260
−0
Click to load diff…
modified
lib/git_gud_web/controllers/runner_controller.ex
+41
−27
Click to load diff…
added
lib/git_gud_web/controllers/storage_controller.ex
+92
−0
Click to load diff…
added
lib/git_gud_web/controllers/webauthn_controller.ex
+139
−0
Click to load diff…
added
lib/git_gud_web/controllers/workflow_artifact_controller.ex
+75
−0
Click to load diff…
added
lib/git_gud_web/live/actor_live/show.ex
+116
−0
Click to load diff…
modified
lib/git_gud_web/live/admin_federation_live/index.ex
+36
−0
Click to load diff…
modified
lib/git_gud_web/live/branch_protection_live/index.ex
+1
−6
Click to load diff…
modified
lib/git_gud_web/live/deploy_key_live/index.ex
+1
−6
Click to load diff…
modified
lib/git_gud_web/live/interaction_policy_live/edit.ex
+1
−6
Click to load diff…
modified
lib/git_gud_web/live/issue_live/show.ex
+3
−13
Click to load diff…
added
lib/git_gud_web/live/org_live/settings_runners.ex
+167
−0
Click to load diff…
modified
lib/git_gud_web/live/org_live/show.ex
+14
−0
Click to load diff…
modified
lib/git_gud_web/live/pr_live/index.ex
+3
−0
Click to load diff…
modified
lib/git_gud_web/live/pr_live/new.ex
+116
−9
Click to load diff…
modified
lib/git_gud_web/live/pr_live/show.ex
+90
−19
Click to load diff…
modified
lib/git_gud_web/live/repo_live/blob.ex
+14
−22
Click to load diff…
added
lib/git_gud_web/live/repo_live/settings.ex
+162
−0
Click to load diff…
added
lib/git_gud_web/live/repo_live/settings_runners.ex
+198
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/show.ex
+56
−19
Click to load diff…
modified
lib/git_gud_web/live/repo_live/tree.ex
+11
−8
Click to load diff…
added
lib/git_gud_web/live/secrets_live/org.ex
+177
−0
Click to load diff…
modified
lib/git_gud_web/live/secrets_live/repo.ex
+1
−6
Click to load diff…
modified
lib/git_gud_web/live/user_live/settings.ex
+59
−0
Click to load diff…
added
lib/git_gud_web/live/user_live/two_factor_challenge.ex
+177
−0
Click to load diff…
added
lib/git_gud_web/live/user_live/two_factor_setup.ex
+289
−0
Click to load diff…
modified
lib/git_gud_web/live/webhook_live/index.ex
+1
−6
Click to load diff…
modified
lib/git_gud_web/live/wiki_live/show.ex
+2
−12
Click to load diff…
modified
lib/git_gud_web/live/workflow_live/index.ex
+18
−0
Click to load diff…
modified
lib/git_gud_web/live/workflow_live/show.ex
+65
−3
Click to load diff…
modified
lib/git_gud_web/plugs/git_smart_http.ex
+70
−0
Click to load diff…
modified
lib/git_gud_web/router.ex
+82
−3
Click to load diff…
modified
lib/git_gud_web/user_auth.ex
+89
−7
Click to load diff…
modified
mix.exs
+15
−2
Click to load diff…
modified
mix.lock
+8
−0
Click to load diff…
added
priv/repo/migrations/20260513150000_add_federated_pr_origin.exs
+25
−0
Click to load diff…
added
priv/repo/migrations/20260513160000_add_federated_pr_comment_origin.exs
+17
−0
Click to load diff…
added
priv/repo/migrations/20260513170000_create_lfs_objects.exs
+26
−0
Click to load diff…
added
priv/repo/migrations/20260513180000_add_user_editor_theme.exs
+11
−0
Click to load diff…
added
priv/repo/migrations/20260513190000_fix_editor_theme_ids.exs
+31
−0
Click to load diff…
added
priv/repo/migrations/20260513200000_scope_runners.exs
+41
−0
Click to load diff…
added
priv/repo/migrations/20260513210000_create_user_two_factor.exs
+32
−0
Click to load diff…
added
priv/repo/migrations/20260513220000_add_runner_uuid.exs
+26
−0
Click to load diff…
added
priv/repo/migrations/20260513230000_create_user_webauthn_credentials.exs
+42
−0
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/HEAD
+0
−1
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/config
+0
−6
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/description
+0
−1
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/applypatch-msg.sample
+0
−15
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/commit-msg.sample
+0
−74
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/fsmonitor-watchman.sample
+0
−168
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/post-receive
+0
−3
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/post-update.sample
+0
−8
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-applypatch.sample
+0
−14
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-commit.sample
+0
−49
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-merge-commit.sample
+0
−13
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-push.sample
+0
−53
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-rebase.sample
+0
−169
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-receive
+0
−3
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/pre-receive.sample
+0
−24
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/prepare-commit-msg.sample
+0
−42
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/push-to-checkout.sample
+0
−78
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/sendemail-validate.sample
+0
−77
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/hooks/update.sample
+0
−128
Click to load diff…
deleted
priv/repos/gmp/gitgud-2.git/info/exclude
+0
−6
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/HEAD
+0
−1
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/config
+0
−8
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/description
+0
−1
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/applypatch-msg.sample
+0
−25
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/commit-msg.sample
+0
−25
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/docs.url
+0
−1
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/fsmonitor-watchman.sample
+0
−16
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/post-receive
+0
−3
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/post-update.sample
+0
−12
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/pre-applypatch.sample
+0
−27
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/pre-commit.sample
+0
−19
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/pre-merge-commit.sample
+0
−16
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/pre-push.sample
+0
−46
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/pre-rebase.sample
+0
−40
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/pre-receive
+0
−3
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/hooks/prepare-commit-msg.sample
+0
−54
Click to load diff…
deleted
priv/repos/gmp/gitgud.git/info/exclude
+0
−5
Click to load diff…
added
test/git_gud/accounts/two_factor_test.exs
+180
−0
Click to load diff…
added
test/git_gud/accounts/webauthn_test.exs
+114
−0
Click to load diff…
added
test/git_gud/federation/outbound_test.exs
+224
−0
Click to load diff…
added
test/git_gud/lfs_test.exs
+69
−0
Click to load diff…
added
test/git_gud/markdown_test.exs
+81
−0
Click to load diff…
added
test/git_gud/pull_requests_federated_test.exs
+405
−0
Click to load diff…
added
test/git_gud/ssh/key_api_test.exs
+61
−0
Click to load diff…
added
test/git_gud/storage/disk_test.exs
+58
−0
Click to load diff…
added
test/git_gud/syntax_highlight_test.exs
+38
−0
Click to load diff…
added
test/git_gud/themes/editor_test.exs
+28
−0
Click to load diff…
added
test/git_gud/workflows/list_helpers_test.exs
+66
−0
Click to load diff…
added
test/git_gud/workflows/scoped_runners_test.exs
+82
−0
Click to load diff…
added
test/git_gud_web/lfs_http_test.exs
+98
−0
Click to load diff…
modified
test/support/fixtures/forge_fixtures.ex
+2
−1
Click to load diff…
Parents: cfa4a8b