he keeps doing it

71effab · gmorell · 2026-05-14 01:31

73 files +6266 -308

Files changed

modified PROGRESS.md
+266 −2
@@ -12,6 +12,256 @@ intermediate "I was here when I stopped" state) lives in the
12 12
13 13 ---
14 14
15 +## Slice 44 — Replace-with-mod-message moderation (+ undo)
16 +
17 +Replaces the slice-42 hard-delete with a soft, reversible flow. Reported content gets its body swapped out for a moderator note; the original is preserved and revealable to the people who need to see it.
18 +
19 +### Schema
20 +
21 +- **Migration `20260513280000`** — adds `moderated_at`, `moderated_by_id` (FK to users, ON DELETE SET NULL), `moderation_note :text`, `original_body :text` to `issues`, `issue_comments`, `pull_requests`, `pr_comments`. One `(moderated_at)` index per table.
22 +- Each of the four schemas gains `moderate_changeset/3`, `restore_changeset/1`, and `moderated?/1`. `moderate_changeset` snapshots `body``original_body` (only if not already snapshotted, so re-moderating doesn't clobber the original).
23 +
24 +### Reports module
25 +
26 +- **`Reports.moderate_target/3`** — replaces the old `delete_target/1`. Takes the report, the moderating user, and an optional note. Empty or whitespace-only note falls back to `"Removed by moderator"`. Notes are trimmed and clipped at 500 chars. Returns `{:ok, target}` or `{:error, :not_found | :unmoderatable_target_type}`.
27 +- **`Reports.restore_target/1`** — reverses a previous moderation. Returns `{:error, :not_moderated}` if the target isn't currently moderated, so admins don't accidentally clear a non-existent original.
28 +- **Preview shape**`deletable?: boolean``moderatable?: boolean` + `moderated?: boolean`. Excerpts now prefer `original_body` so admins reviewing the queue see what was actually reported, not the placeholder.
29 +
30 +### Admin UI
31 +
32 +- Reports queue: the old single "Delete content" button is replaced with one of two states based on `preview.moderated?`:
33 + - **Not moderated**: an inline form with an optional one-line note input and a "Replace with mod message" submit (`phx-submit="moderate_content"`). `data-confirm` gates the action.
34 + - **Already moderated**: a "Restore original" button (`phx-click="restore_content"`).
35 +- Both auto-resolve the report differently — replace marks it `actioned` with `"content replaced with mod message"`; restore leaves the report state alone (admin decides separately whether to dismiss).
36 +
37 +### Issue / PR rendering
38 +
39 +- **New `GitGudWeb.ModerationComponents.moderated_body/1`** — single shared component used by `IssueLive.Show` and `PrLive.Show` for issue/PR bodies *and* comments. When `moderated_at` is set, renders a warning banner (`hero-shield-exclamation` + the mod note) instead of the body.
40 +- **Show-original toggle** — admins and the original author get a `<details>` with the original body rendered as Markdown. Everyone else sees only the banner. No LiveView state needed; the native `<details>` element handles the toggle.
41 +- **`can_view_original?/2`** is a pure function (admin OR `author_id == viewer.id`) so it's trivially unit-testable.
42 +
43 +### Post order preserved
44 +
45 +- Moderated content keeps its slot in the thread because the row's id and `inserted_at` don't change — only the body is replaced.
46 +- Made that guarantee explicit by adding `order_by: [asc: inserted_at, asc: id]` to the comment + reviews preloads on `Issues.get_issue!/2` and `PullRequests.get_pull_request!/2`. The previous code relied on Postgres's implementation-defined preload order.
47 +
48 +### Refuse re-reports on moderated content
49 +
50 +- `Reports.open/4` now checks if the target is already moderated and returns `{:error, :already_moderated}` if so. No need to re-report something an admin has already replaced.
51 +- The flag (report) button is hidden in the UI on already-moderated issues, PRs, and comments — admins still see "Restore original" in the queue, but viewers can't pile new reports onto something that's already been actioned.
52 +- LV report handlers in `IssueLive.Show` and `PrLive.Show` surface the new error with a flash explaining "This content has already been moderated."
53 +
54 +### Tests
55 +
56 +- Updated `reports_actions_test.exs`: dropped the `delete_target` tests, added 12 new ones for `moderate_target` (basic flow, default note, whitespace-only note, 500-char clip, missing row, unmoderatable type, re-moderate-doesn't-clobber), `restore_target` (round-trip, not-moderated error, missing row), and `open/4` vs already-moderated (refuses for moderated targets, allows for unmoderatable kinds like actors).
57 +- Added `moderation_components_test.exs` — 4 tests on `can_view_original?/2` covering admin, author, neither, and anonymous viewer.
58 +- **410 tests pass** overall.
59 +
60 +## Slice 43 — Lazy per-file diff expansion
61 +
62 +Closes the perf item from slice 33: the initial render of a many-file diff no longer puts every hunk in the DOM.
63 +
64 +- **`DiffComponents.diff/1`** API change: `expanded_default` (integer) replaced with `loaded_idx` (`MapSet` of file indices whose hunks should be rendered). Files not in the set render only the header + a "Click to load diff…" placeholder. The `<details>` element stays — but for unloaded files it's closed and the inner `<table>` of hunks isn't in the DOM at all.
65 +- **`DiffComponents.default_loaded/1`** — derives the initial set from file count: ≤10 → preload all, ≤50 → first 5, otherwise first 2. Same tier the old `expanded_default/1` used, lifted into the component so the three callers don't each carry their own copy.
66 +- **`DiffComponents.expand/2`** — one-line socket update: `MapSet.put(loaded_diff_idx, idx)`. Each LV's handler is now `def handle_event("expand_diff_file", params, socket), do: {:noreply, DiffComponents.expand(socket, params)}`.
67 +- **Three call sites updated**`RepoLive.Compare`, `RepoLive.Commit`, `PrLive.Show`. Each assigns `loaded_diff_idx` after computing `diff_files` and delegates the expand event. The per-LV `expanded_default/1` helpers are gone.
68 +- **Click semantics**: `phx-click="expand_diff_file"` rides on the `<summary>` element. Native `<details>` open/close still works on subsequent clicks; the LV event is idempotent (MapSet.put on an already-present key) so re-clicking a loaded file is a no-op.
69 +- Cleanup: moved two `defp` helpers in `AdminFederationLive` that sat between `handle_event` clauses, killing a pre-existing "clauses with the same name and arity should be grouped together" warning. `mix compile --warnings-as-errors` is now green.
70 +- 9 new tests covering `default_loaded` tiers, `expand` (success + garbage-idx no-op), and component render (loaded vs unloaded placeholder, default fallback, empty-list). 397 tests pass overall.
71 +
72 +## Slice 42 — Actionable reports queue
73 +
74 +Closes the moderation loop: admins can now see *what* was reported and act on it without leaving the page.
75 +
76 +- **`Reports.preview/1`** — resolves a `{target_type, target_id}` to a uniform map: `kind`, `title`, `excerpt` (first ~240 chars of body), `url` (deep-link into the issue / PR / actor page), `author` (display name), `deletable?`, `suspendable_actor_id`. Supported targets: `issue`, `issue_comment`, `pull_request`, `pr_comment`, `actor`, `inbox_delivery`. Missing rows return `nil` (the queue tolerates orphan reports rather than crashing on a stale FK).
77 +- **`Reports.delete_target/1`** — soft-deletes reported issue / pr / comment content (sets body to a tombstone marker, preserves the row so threads don't reflow). Rejects unsupported target types so admins can't accidentally delete an actor profile via the wrong button.
78 +- **`Reports.suspend_actor/1`** — wraps the existing `Federation.suspend_actor/1` so a single click from the report queue both stops inbound activity *and* records the action on the report.
79 +- **Admin federation tab — reports**: each pending report now renders with an inline preview card (kind badge + title + excerpt + deep-link), followed by action buttons: **Delete content** (if `deletable?`), **Suspend actor** (if `suspendable_actor_id`), **Forward to source** (federated reports), **Mark actioned**, **Dismiss**. Reports whose target has already been deleted upstream show a "target gone" placeholder so they can still be dismissed cleanly.
80 +- 8 new tests: preview shape for issue / actor / missing; `delete_target` success / `:not_found` / unsupported-type rejection; `suspend_actor` success / not_found. 388 tests pass overall.
81 +
82 +## Slice 41 — Quote-reply on comments
83 +
84 +Tiny UX slice — every comment grows a "Quote" button next to the existing "Report" flag. Click it and the comment's body lands in your reply textarea as a Markdown blockquote, prefixed with `> @author wrote:`.
85 +
86 +- **`Markdown.quote_block/2`** — prefixes every line of the original body with `> `, attributing it to `@author`. Handles already-quoted text by adding another `> ` level (so nested quotes work).
87 +- Issue + PR LiveViews gained a `quote_reply` handler that fetches the comment by id from the in-memory thread, runs `quote_block`, and appends to the in-progress reply textarea separated by a blank line.
88 +- The textarea is bound to `socket.assigns.comment_body` (was a plain uncontrolled `<textarea>` before) so the LiveView can mutate it. `phx-change="update_comment_body"` keeps the assign in sync with user typing.
89 +
90 +## Slice 40 — Cross-references in markdown + new-issue flow
91 +
92 +### Cross-references
93 +
94 +`#42` and `owner/repo#42` in markdown now auto-resolve to issue or PR links wherever a repo context is present.
95 +
96 +- **`Issues.lookup_referent/2`** — number → `{:issue, struct} | {:pull_request, struct} | nil`. PR and issue numbering share a sequence per repo (via `Repositories.Numbering`) so a given number is unambiguous.
97 +- **`Markdown.to_html(body, repo: repo)`** — gates expansion on the `:repo` opt; without it the renderer is unchanged (so README + wiki + blob views, which legitimately contain `#42` in unrelated contexts, stay literal).
98 +- **Pre-pass walks the body alternating code / non-code regions** — single backticks, triple backticks, and nested fences all preserve their contents as-is so `#42` inside `\`#42\`` or a fenced block stays literal.
99 +- **Regex**: `(?<![\w/])(?:owner/repo)?#(\d+)(?![\w])`. The anchors keep us from matching inside identifiers, URLs (`/foo#bar`), or hashes (`a1b2c#`).
100 +- **Cache key extended** to include `repo_id` so the same body in two different repos can't collide.
101 +- **`Repositories.find_by_path/2`** — non-raising sibling of `get_repository_by_path!/2`, used by the cross-repo branch.
102 +- **Issue + PR show LiveViews** wired through with `:repo`. Wiki / README / blob explicitly NOT wired — code in source files often has `#` for legitimate reasons unrelated to issue refs.
103 +- 7 new tests covering current-repo issue resolution, PR resolution, unresolved-passthrough, code-span isolation, fenced-block isolation, cross-repo expansion, no-repo passthrough.
104 +
105 +### New issue flow
106 +
107 +- **Write / Preview tabs** — click "Preview" to see the markdown render (with cross-refs applied) before submitting.
108 +- **Label picker** — multi-select chips for repo + inherited org labels minus opt-outs (from slice 39). Selected ids round-trip via hidden inputs so `validate` events don't lose them. Org-inherited labels carry an `(org)` suffix in the chip.
109 +- **Query-string autofill**`/issues/new?title=...&body=...&labels=bug,security` pre-seeds the form. Lets external buttons / docs deep-link with structured content (e.g. a "report this comment" button could pre-fill the body).
110 +- Markdown helper hint below the textarea explains the `#42` syntax.
111 +
112 +374 tests pass overall.
113 +
114 +## Slice 39 — Labels: color picker + org-level + per-repo opt-out
115 +
116 +### Color picker
117 +
118 +Replaces the bare hex-text field on the label form with a real picker:
119 +
120 +- Native `<input type="color">` (browser-provided palette / eyedropper) bound to the same form field as the existing hex input — both stay in sync.
121 +- 16-swatch preset palette (`Tailwind` 500-shade rainbow + slate). Click a swatch → `pick_color` event → form re-renders with that color.
122 +- Live preview badge next to the color inputs shows the label-in-progress styled with the current color (and the typed name once the user starts entering it).
123 +- Hex input keeps the `^#[0-9a-fA-F]{6}$` pattern attr + the server-side `validate_format`.
124 +
125 +### Org-level labels with per-repo opt-out
126 +
127 +- **Schema**: `labels.organization_id` added (nullable, XOR with `repository_id` via CHECK constraint). New `repository_label_opt_outs(repo_id, label_id)` join table. Migration `20260513270000`.
128 +- **`Labels.list_labels/1`** now merges repo-own + parent-org labels minus opt-outs, surfacing each with an `:inherited?` boolean so the UI can distinguish.
129 +- **`Labels.create_organization_label/2`**, **`opt_out/2`**, **`opt_in/2`**, **`opted_out?/2`** — the new org-scope CRUD + opt-toggle.
130 +- **Repo labels page**: inherited rows render with an `inherited` badge + a **Hide** button (opts out) instead of the trash icon. Local labels keep their delete button. Trying to delete an inherited label is rejected server-side as belt-and-braces.
131 +- **Org labels page** at `/orgs/:handle/settings/labels` — admin-only, same color picker + palette UI, no opt-out controls (those live on the repo side). Wired into the org settings nav alongside Secrets / Runners.
132 +- **Opt-out semantics**: hides the label from the *pickable* set only. Labels already attached to issues/PRs on that repo stay attached and continue rendering — moderation decisions on past content shouldn't depend on an org-level hide.
133 +- 6 new tests covering inheritance flag, opt-out scoping, opt-in re-enabling, non-org label opt-out rejection, org-scope list isolation. 367 tests pass overall.
134 +
135 +## Slice 38 — Federated PR reviews + suggested-blocks queue
136 +
137 +Two federation completion items shipped together.
138 +
139 +### Federated PR reviews
140 +
141 +- **Schema**: `pr_reviews` gets `source_actor_id` + `activity_url` (matching the `pr_comments` shape from slice 22). `reviewer_id` is now nullable so federated reviews can carry no local user. Migration `20260513250000`.
142 +- **Inbound dispatch**: `ProcessInbox` accepts `Like``approved` review, `Dislike``changes_requested` review. The activity's `object` (either the local Ticket URL or the remote-minted `activity_url` we stored on the federated PR row) maps to the right PR. Idempotent on the activity's `id`.
143 +- **`find_pr_by_object_url` widened** — now matches against either a local Ticket URL (`<repo_actor>/pull_requests/<N>`) or a stored `pull_requests.activity_url`. The same helper is shared between `Create(Note)` (comments) and the new review dispatch.
144 +- **`PullRequests.add_federated_review/3`** — federated insert path with unique-partial-index idempotency. `PrReview.federated_changeset/2` + `federated?/1` helper.
145 +- **Outbound**: `PullRequests.add_review/3` now detects when the PR has `source_actor_id` set and fires `Federation.Publishing.publish_pr_review/3`. Maps `approved` → AP `Like`, `changes_requested``Dislike`, `commented` → no AP emit (no standard verb for it). Body carried in `content`.
146 +- **`ActivityBuilders.pr_review_verdict/4`** — builds the Like/Dislike activity with the PR's Ticket URL as `object` + body as `content`.
147 +- 3 new tests: Like → approved, Dislike → changes_requested, re-delivery idempotent.
148 +
149 +### Suggested-blocks queue (`mix gitgud.federation.suggest_blocks`)
150 +
151 +The safe alternative to defederation cascades — peer block lists become a *signal*, not enforcement.
152 +
153 +- **Schema**: `suggested_instance_blocks` table — `(host, reasons, peers[], peer_count, first_seen_at, last_seen_at, status: pending|approved|dismissed, resolved_by, resolved_at)`. Unique on `host`.
154 +- **`SuggestedBlocks.ingest/3`** — applies a `[{host, reason}, ...]` list as having come from `peer_host`. Per host: create with `peers: [peer]` if new, else add peer (deduped) + bump count + merge reasons. Filters out hosts already on `fed_instance_blocks` so we never propose what's enforced.
155 +- **`SuggestedBlocks.parse_list/1`** — accepts one host per line, or `host,reason` CSV. Blank lines + `#` comments ignored.
156 +- **`SuggestedBlocks.approve/3`** — wraps `Federation.block_instance/2` + flips status. **`dismiss/2`** flips status without applying.
157 +- **`Mix.Tasks.Gitgud.Federation.SuggestBlocks`**`mix gitgud.federation.suggest_blocks coop.example=https://coop/blocks.csv peer.example=https://peer/blocks.csv [--dry-run]`. Supports `file://` URLs for testing. Each pair fetches, parses, and ingests; logs counts.
158 +- **Admin LiveView tab** — new "suggested" tab on `/admin/federation` listing pending rows (sorted by peer_count desc, then last_seen_at), per-row Apply / Dismiss. Tab badge shows the pending count.
159 +- 8 new tests covering parser, ingest semantics (peer aggregation, already-blocked filtering, dry-run), approve/dismiss flow.
160 +
161 +361 tests pass overall.
162 +
163 +## Slice 37 — zot → Packages mirror
164 +
165 +Closes the OCI registry loop. zot is the authoritative blob store; the forge keeps a Postgres mirror so UIs can list packages without scraping `/v2/_catalog` on every request.
166 +
167 +- **`POST /_webhooks/zot`** — accepts zot's push/delete notification format (both `event` and `type` field shapes — different zot versions disagree). Bearer-token guarded via `config :git_gud, GitGudWeb.PackagesWebhookController, shared_secret: "..."`; nil = unauthenticated (dev / loopback-only).
168 +- **Repo path layout**: `<owner>/<repo>/<image...>` — the first two segments resolve a `GitGud.Repositories.Repository`, the rest become the package name. So `org/site/web` and `org/site/db` are distinct packages on the same repo. Falls back to using the repo name as the package name when the path has just two segments.
169 +- **`Packages.delete_version/4`** added — wipes a single version, recomputes `latest_version` from the next-most-recent push, deletes the package row if that was the last version.
170 +- **`RepoLive.Packages`** at `/r/:owner/:name/packages` — lists all packages for a repo with a click-to-expand version history (lazy-loaded into socket state). Renders short digest, size, push time.
171 +- **Repo header link** — "Packages" appears between Compare and Actions when the repo has at least one package mirrored; hidden otherwise so empty repos don't carry noise.
172 +- 6 new webhook tests covering PUSH (create + re-upsert), nested-path image names, DELETE (with latest_version recompute), unknown repos returning 422, shared-secret bearer enforcement. 350 tests pass overall.
173 +
174 +To wire zot:
175 +
176 +```toml
177 +# zot.json — `extensions.search.notifications`:
178 +{
179 + "url": "http://forge.internal/_webhooks/zot",
180 + "headers": [{"name": "Authorization", "value": "Bearer <same as shared_secret>"}],
181 + "events": ["push:*", "delete:*"]
182 +}
183 +```
184 +
185 +Then in `config/runtime.exs`:
186 +
187 +```elixir
188 +config :git_gud, GitGudWeb.PackagesWebhookController,
189 + shared_secret: System.fetch_env!("ZOT_WEBHOOK_SECRET")
190 +```
191 +
192 +## Slice 36 — Cache eviction worker
193 +
194 +Closes the operational loop on `actions/cache` so disk usage doesn't grow unboundedly.
195 +
196 +- **`GitGud.Workflows.Workers.CacheEviction`** — Oban cron worker, queue `:default`, runs `@hourly`. Two independently-configurable knobs:
197 + - `:max_age_days` — drops entries whose `last_used_at` (or `archived_at` when never read) is older than N days. `nil` disables.
198 + - `:per_repo_max_mb` — per-repo total finalized-bytes cap. After age pruning, anything past the cap is evicted LRU on `last_used_at`. `nil` disables.
199 + - `:dry_run` — computes what would be evicted without touching DB or storage. Useful when piloting knob values.
200 +- **Order matters**: storage object deletes happen *before* the row delete. An interruption mid-prune leaves stranded blobs (cheap, eventually overwritten by re-uploads) rather than DB rows pointing at missing objects (which would fail lookups).
201 +- **Per-repo size pruning** uses a SQL `GROUP BY ... HAVING SUM(size) > cap` pre-filter so we only walk entries for repos actually over the cap, then walks newest→oldest in memory to pick the eviction set.
202 +- **Config knobs** in `config/config.exs`. Default ships `max_age_days: 14, per_repo_max_mb: 5_000, dry_run: false` — operator-tunable per deployment.
203 +- **`run_now/0`** wraps `perform/1` with an empty job arg so the worker is callable from iex / mix tasks / tests without going through Oban.
204 +- 5 new tests covering age pruning, per-repo size LRU, dry_run no-op, both-nil no-op, and storage-then-row delete order. 344 tests pass overall.
205 +
206 +## Slice 35 — `actions/cache` hit matching
207 +
208 +The v1 cache server used to return 204 (miss) for every read — uploads landed in object storage but lookups never found them, so cross-job caching did nothing. Now the protocol works end to end.
209 +
210 +- **`cache_entries` table**`(repository_id, key, version, ref, storage_key, size, archived_at, last_used_at)`. Unique on `(repo, key, version, ref)` so the same triple re-uploads as a single row; pending uploads have `archived_at: nil` and are invisible to lookups.
211 +- **`GitGud.Workflows.ActionsCache`** context — `lookup/5`, `reserve/4`, `append/2`, `finalize/2`, `download_url/1`. Lookup implements the GitHub spec exactly:
212 + - For each candidate ref (current ref → repo default), try primary key as exact match
213 + - Then for each fallback key, do a prefix match (`LIKE prefix%`, newest first)
214 + - Skip pending rows
215 +- **Storage** — bytes live in the `gitgud-cache` bucket keyed `<repo_id>/<row_id>`, served via `Storage.presign_url/4`. Works for both Disk + Garage backends without controller changes.
216 +- **`ActionsCacheController`** rewired — `get_cache` decodes `keys=`, looks up via the new context, returns JSON with `archiveLocation` on hit. `reserve_cache` inserts a row (replacing any prior pending one on the same triple). `upload_chunk` appends bytes (read-modify-write; cache objects are small enough this is fine). `finalize` flips `archived_at` + records size.
217 +- **Operator precedence bug found** during testing — `||` binds looser than `|>` so a `case do ... end` chained after `result_or_fallback || ...` silently bypassed the case block when the primary side returned a truthy value. Refactored to an explicit `result = ...; case result do ...` to make precedence boring.
218 +- 8 new lookup-precedence tests (exact ref-priority, default-ref fallback, fallback prefix, version-mismatch miss, pending invisible, empty keys, reserve upsert, append+finalize roundtrip). 339 tests pass overall.
219 +
220 +Follow-up: **eviction worker**. Right now we never delete cache entries — `last_used_at` is updated on every lookup, but nothing scans it. A simple Oban-cron job that drops entries older than N days (or that exceed a configurable per-repo cap) would close the operational loop. Not blocking — disk fills up slowly with cache misses, and operators can run `Repo.delete_all` if it gets out of hand.
221 +
222 +## Slice 34 — Re-run / cancel workflow runs
223 +
224 +CI control surface closing the loop after slice 27's run UI.
225 +
226 +- **`Workflows.rerun/1`** — creates a fresh `WorkflowRun` carrying the same `workflow_doc` / `head_sha` / `head_ref`; jobs + steps are re-seeded from the stored doc via the new `Yaml.normalize_doc/1` helper so we don't need to re-fetch the workflow YAML from disk.
227 +- **`Workflows.cancel/1`**`Repo.update_all` flips run → cancelled, queued/running jobs → cancelled with `conclusion: "cancelled"` + `completed_at`, pending/running steps the same way. Broadcasts a status update so any open WorkflowLive.Show resubscribes. No-op when the run is already terminal. Runners mid-step on a cancelled job see the status flip on next heartbeat and self-terminate (we don't kill containers out-of-band — that's the runner's concern).
228 +- **`workflow_steps.status`** enum gains `cancelled` to match `runs` + `jobs` (already had it).
229 +- **`Yaml.normalize_doc/1`** — public-facing version of the existing private `normalize/1`. Lets `rerun/1` skip a YAML round-trip on the already-parsed doc we persisted.
230 +- **`WorkflowLive.Show`** — gains a header action row visible only to repo admins (or org admins on org-owned repos). Cancel button shows on `queued`/`running` runs, Re-run on `success`/`failure`/`cancelled`. Re-run navigates to the new run's page on success.
231 +- 3 new tests covering rerun (new number + same doc + queued state), cancel (cascading status), no-op on terminal runs. 331 tests pass overall.
232 +
233 +Notes:
234 +- Re-run creates a fresh `WorkflowRun` row rather than resetting the old one — keeps the audit trail. Runs index will show both.
235 +- Cancelling doesn't refund cache hits, secrets reads, or anything material — purely a state flip. Good enough for now; if we ever want hard cancellation that signals running runners directly, the runner protocol has a "task cancellation" message we'd need to emit on `UpdateTask` polling.
236 +
237 +## Slice 33 — Diff cap removal + caching + compare view
238 +
239 +Polish pass over the diff renderer.
240 +
241 +- **Hard 50-file cap removed** from PR show + commit show. Every diff renders inline regardless of size.
242 +- **`expanded_default/1`** in each view picks how many files to expand by default based on diff size — small diffs (≤10 files) expand everything; medium (11–50) expand the first 5; huge (>50) expand 2. The rest stay as collapsed `<details>` headers so initial page load isn't dominated by hunk markup, but the user can click into any of them without a roundtrip.
243 +- **`Repositories.get_diff/4` now caches** the parsed `file_diff` list in `GitGud.Cache` keyed by `(base_sha, head_sha, context)`. SHAs are immutable so entries never go stale; 24h TTL just bounds memory pressure. Re-loading a PR or commit page is now an ETS hit, not a fresh `git diff` shell-out + parser pass.
244 +- **`RepoLive.Compare` wired** with the same `<.diff>` component. After the commits list (when there's anything ahead), the same Lumis-highlighted hunks render with the same expansion behavior as PR show.
245 +- 328 tests still pass; no new tests since the change is just plumbing (parser + renderer are already covered from slice 32).
246 +
247 +Follow-up worth doing: **lazy per-file expansion via LiveView events**. Right now even with the cache, a 500-file diff sends a lot of HTML to the browser at mount. Switching to "render file headers eagerly, fetch hunks on `<details>` open" would let the page-load stay flat regardless of diff size. Not blocking for current PR sizes, but worth a slice when someone lands a 1000-file refactor.
248 +
249 +## Slice 32 — Line-level diff renderer
250 +
251 +PR show used to print "+3 / -1 in foo.rb" stats with no actual hunks. Now renders unified diffs inline with Lumis-highlighted lines.
252 +
253 +- **`Git.diff/4` backend callback** — added to `GitGud.Git.Backend`, `Git.Cli` (real impl via `git diff --no-color -U<ctx>`), `Git.Nif` (returns `:unsupported` → falls through to Cli). Root commit handled via `git show --format=`.
254 +- **`GitGud.Git.DiffParser`** — parses raw unified diff into structured `file_diff` maps. Tracks per-line `old_line`/`new_line` numbers, status (`:added | :deleted | :modified | :renamed | :binary`), insertions/deletions counts. Handles `/dev/null` (add/delete), `Binary files differ`, `\ No newline at end of file`, multi-file diffs.
255 +- **`Repositories.get_diff/4`** — wrapper that calls `Git.diff/4` + parses. Uncached for v1 (diff blobs can be MB-scale; LRU through `GitGud.Cache` is a follow-up).
256 +- **`GitGudWeb.DiffComponents`** — function component renderer. Each file is a collapsible `<details>` (first 5 expanded by default); inside, each hunk is a `<table>` with old-line / new-line / content columns. Add rows tinted `bg-success/10`, del rows `bg-error/10`, both keyed off daisyUI color tokens so it follows the active theme. Per-line content runs through `GitGud.SyntaxHighlight.highlight/2` (Lumis), keyed by file path, with the wrapping `<pre><code>` stripped so the inline highlight drops cleanly into the table cell.
257 +- **`PrLive.Show`** — gains a "Files changed" section after the stats. Caps inline render at 50 files; bigger PRs show a "diff too large" hint with a pointer to per-commit views.
258 +- 8 new parser tests covering modify / line numbers / add / delete / binary / no-newline marker / multi-file / empty input. 328 tests pass overall.
259 +
260 +Follow-ups:
261 +- Compare view doesn't render the diff yet (still summary-only) — straightforward to wire, same component
262 +- Cache the parsed diff in `GitGud.Cache` keyed by `(base_sha, head_sha)`
263 +- Per-line syntax highlighting loses multi-line context (a string opened on one line stops at the newline) — acceptable for diff rendering, but a full-file pass with line-anchored token slicing would be sharper
264 +
15 265 ## Slice 31 — Unified MFA: recovery codes for any factor
16 266
17 267 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.
@@ -413,7 +663,21 @@ Closed slices 14, 15, 16, 17 in this session. Final task graph:
413 663 | 91 | done | TOTP + recovery codes + instance enforcement + runner UUID fix (slice 29) |
414 664 | 92 | done | WebAuthn / security keys (slice 30) |
415 665 | 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.
666 +| 94 | done | Line-level diff renderer (slice 32) |
667 +| 95 | done | Diff cap removal + caching + compare view (slice 33) |
668 +| 96 | done | Re-run / cancel workflow runs (slice 34) |
669 +| 97 | done | `actions/cache` hit matching (slice 35) |
670 +| 98 | done | Cache eviction worker (slice 36) |
671 +| 99 | done | zot → Packages mirror (slice 37) |
672 +| 100 | done | Federated PR reviews + suggested-blocks queue (slice 38) |
673 +| 101 | done | Labels: color picker + org-level + opt-out (slice 39) |
674 +| 102 | done | Cross-references + new-issue flow (slice 40) |
675 +| 103 | done | Quote-reply on comments (slice 41) |
676 +| 104 | done | Actionable reports queue: preview + delete/suspend (slice 42) |
677 +| 105 | done | Refresh README + landing page for slices 32–42 |
678 +| 106 | done | Lazy per-file diff expansion (slice 43) |
679 +| 107 | done | Replace-with-mod-message moderation (slice 44) |
680 +
681 +End-of-session: 410 tests pass. Next likely: gix NIF fill-in for log/commit/tree/diff_stats/merge_base/merge_tree (10–100× perf for repo-page hot paths), or in-browser PR conflict resolution.
418 682 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).
419 683
modified README.md
+34 −18
@@ -20,27 +20,36 @@ external tools (Forgejo runner, Garage, zot) rather than reinvent them.
20 20 - **Git LFS** (Batch API) over pluggable object storage
21 21 - **Repo browsing**
22 22 - Tree, blob, commit, log views with deep linking
23 + - **Line-level diff renderer** with Lumis-highlighted hunks on PR show, commit show, and compare; parsed diffs cached per `(base, head)`
23 24 - **Syntax highlighting** via [Lumis](https://lumis.sh) (tree-sitter, Rust NIF) — cached per-blob
24 - **Full GFM markdown** (tables, task lists, autolink, footnotes, GitHub alerts) — cached per-render
25 - Per-user editor theme (curated subset of Lumis themes — Dracula, Solarized, Monokai, Tokyo Night, Catppuccin, etc)
26 - daisyUI page theming separate from editor theme
25 + - **Full GFM markdown** with cross-references (`#42`, `#x2a` hex, `owner/repo#42`), GitHub-style alerts, footnotes, task lists, fenced-code syntax highlighting — cached per-render
26 + - Per-user editor theme (curated subset of Lumis themes — Dracula, Solarized, Monokai, Tokyo Night, Catppuccin, etc) — separate from the daisyUI page theme
27 27 - **Collaboration**
28 - Issues + labels + comments (with markdown + reporting)
29 - Pull requests with mergeability probe, 3-way merge, cross-repo (fork) PRs
28 + - Issues + comments with Write/Preview tabs, label picker, query-string autofill
29 + - **Quote-reply** button on every comment — appends `> `-prefixed source with attribution
30 + - Pull requests with mergeability probe, 3-way merge, cross-repo (fork) PRs, line-level diff inline
30 31 - Forks + fork-sync (fast-forward CAS) + compare-any-two-refs view
31 - Branch protection rules, deploy keys, webhooks
32 - Wikis (rendered through the same markdown pipeline)
32 + - Branch protection, deploy keys, webhooks, wikis
33 + - **Labels** with HTML color picker + 16-swatch palette + live preview; **org-level labels** inherited by every repo unless the repo opts out (and re-opts in via a hidden-labels expander)
34 +- **Auth & MFA**
35 + - Magic-link login + email/password
36 + - **TOTP** (any RFC 6238 authenticator) with QR enrollment + 10 single-use recovery codes
37 + - **WebAuthn / FIDO2** — hardware security keys (YubiKey, Titan), platform authenticators (Touch ID / Windows Hello), browser passkeys
38 + - Recovery codes shared across factors; instance-level enforcement with N-day grace period
33 39 - **Federation (ForgeFed / ActivityPub)**
34 - HTTP Signatures on inbound + outbound deliveries
35 - Push / ticket / merge / comment activities outbound
36 - Follow, Offer (federated PR), Update, Reject, Create(Note) inbound
40 + - HTTP Signatures on inbound + outbound deliveries; outbound dead-letter after 8 retries
41 + - Push / ticket / merge / comment activities outbound; per-task hourly key refresh of stale remote actors
42 + - Inbound: Follow, Offer (federated PR), Update, Reject, Create(Note) comments, Like/Dislike as PR reviews
43 + - Outbound `Offer` from the PR new page; outbound Like/Dislike on local reviews of federated PRs
37 44 - Per-instance moderation: allowlist mode (default), per-instance + per-actor blocks, interaction policies (`public` / `followers` / `approved`)
38 - Cross-instance report forwarding via AP `Flag`
39 - Hourly stale-actor key refresh worker
45 + - **Actionable reports queue** in admin: inline previews, delete content, suspend remote actors, forward to source instance via AP `Flag`
46 + - **`mix gitgud.federation.suggest_blocks`** — peer block lists become a *review queue*, not enforcement (cascades are deliberately declined)
40 47 - **CI**
41 - Speaks the **Forgejo Actions runner protocol** (Twirp) — point any modern `forgejo-runner` at it
48 + - Speaks the **Forgejo Actions runner protocol** (Twirp) — point any modern `forgejo-runner` at it; protocol path autodetected (`ping.v1`, `runner.v1`, with/without `_apis/`)
49 + - Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI
50 + - Re-run / cancel buttons on the workflow show page
42 51 - Per-task HS256 JWT minted on `FetchTask`, gates artifact + cache endpoints
43 - GitHub Actions cache server v1 (cache-miss path always-fresh for now)
52 + - **GitHub Actions cache server v1** with hit matching (primary exact → fallback prefix, current ref → default ref) + age/size eviction worker
44 53 - GitHub Actions artifacts API v4
45 54 - Repo-scoped + org-scoped secrets/variables (AES-256-GCM at rest, key rotation)
46 55 - **Storage**
@@ -48,6 +57,9 @@ external tools (Forgejo runner, Garage, zot) rather than reinvent them.
48 57 - `GitGud.Storage.Disk` (default) — single-node dev, single-node prod, NFS-shared multi-node
49 58 - `GitGud.Storage.Garage` — S3-flavored ([Garage](https://garagehq.deuxfleurs.fr/))
50 59 - Add another with one `@behaviour` impl
60 +- **Packages**
61 + - OCI registry hosted in [zot](https://zotregistry.dev/); local mirror of `(package, version)` rows via the webhook at `/_webhooks/zot`
62 + - Browseable at `/r/<owner>/<name>/packages` with per-package version history
51 63
52 64 ## Architecture
53 65
@@ -120,6 +132,9 @@ These all live in `config/*.exs`. Key ones:
120 132 | `GitGud.Ssh.Server` `port` | SSH daemon port | `2222` |
121 133 | `GitGud.Federation` `federation_mode` | `:allowlist` or `:open` | `:allowlist` |
122 134 | `GitGud.Cache` | Nebulex cache sizing | 50k entries / 200MB |
135 +| `GitGud.Accounts.TwoFactor` `required` / `grace_period_days` | Instance-wide MFA enforcement + grace window | `false` / `14` |
136 +| `GitGud.Workflows.Workers.CacheEviction` `max_age_days` / `per_repo_max_mb` | `actions/cache` pruning knobs | `90` / `500` / `dry_run: false` |
137 +| `GitGudWeb.PackagesWebhookController` `shared_secret` | Bearer-token gate on the zot webhook | `nil` (open in dev) |
123 138
124 139 To swap to S3-flavored storage in prod:
125 140
@@ -203,15 +218,16 @@ external one works today and is well-tested by the Forgejo community.
203 218
204 219 See [ROADMAP.md](./ROADMAP.md). Highlights:
205 220
206 - Line-level diff renderer (currently summary stats only)
207 221 - gix NIF wiring for log / commit / tree / merge_tree (Cli fallback works)
208 - Cache-hit path on the v1 Actions cache server (currently always-miss)
222 +- In-browser PR conflict resolution
223 +- Lazy per-file diff expansion for very large diffs
209 224 - Native runner (low priority while the external one works)
210 - HTML in-browser conflict resolution
211 225 - Code search (Postgres FTS handles issues/PRs; code search is its own beast)
226 +- RFC 9421 (HTTP Messages) signatures when peers upgrade from draft-cavage-12
227 +- Reputation aging for new federated hosts
212 228
213 229 See [PROGRESS.md](./PROGRESS.md) for the slice-by-slice history of how
214 the forge was built, and what each shipped.
230 +the forge was built, and what each shipped — currently through slice 42.
215 231
216 232 ## Project conventions
217 233
modified ROADMAP.md
+7 −13

Click to load diff…

modified config/config.exs
+22 −1

Click to load diff…

modified config/dev.exs
+8 −7

Click to load diff…

modified lib/git_gud/federation/activity_builders.ex
+21 −0

Click to load diff…

modified lib/git_gud/federation/publishing.ex
+59 −0

Click to load diff…

added lib/git_gud/federation/suggested_blocks.ex
+165 −0

Click to load diff…

added lib/git_gud/federation/suggested_instance_block.ex
+50 −0

Click to load diff…

modified lib/git_gud/federation/workers/process_inbox.ex
+66 −8

Click to load diff…

modified lib/git_gud/git.ex
+13 −0

Click to load diff…

modified lib/git_gud/git/backend.ex
+2 −0

Click to load diff…

modified lib/git_gud/git/cli.ex
+21 −0

Click to load diff…

added lib/git_gud/git/diff_parser.ex
+228 −0

Click to load diff…

modified lib/git_gud/git/nif.ex
+3 −0

Click to load diff…

modified lib/git_gud/issues.ex
+42 −1

Click to load diff…

modified lib/git_gud/issues/issue.ex
+32 −0

Click to load diff…

modified lib/git_gud/issues/issue_comment.ex
+30 −0

Click to load diff…

modified lib/git_gud/labels.ex
+108 −10

Click to load diff…

modified lib/git_gud/labels/label.ex
+22 −1

Click to load diff…

added lib/git_gud/labels/repository_opt_out.ex
+21 −0

Click to load diff…

modified lib/git_gud/markdown.ex
+187 −17

Click to load diff…

modified lib/git_gud/packages.ex
+41 −0

Click to load diff…

modified lib/git_gud/pull_requests.ex
+50 −6

Click to load diff…

modified lib/git_gud/pull_requests/pr_comment.ex
+30 −0

Click to load diff…

modified lib/git_gud/pull_requests/pr_review.ex
+26 −0

Click to load diff…

modified lib/git_gud/pull_requests/pull_request.ex
+30 −0

Click to load diff…

modified lib/git_gud/reports.ex
+300 −9

Click to load diff…

modified lib/git_gud/repositories.ex
+38 −0

Click to load diff…

modified lib/git_gud/workflows.ex
+74 −0

Click to load diff…

added lib/git_gud/workflows/actions_cache.ex
+195 −0

Click to load diff…

added lib/git_gud/workflows/cache_entry.ex
+41 −0

Click to load diff…

added lib/git_gud/workflows/workers/cache_eviction.ex
+145 −0

Click to load diff…

modified lib/git_gud/workflows/workflow_step.ex
+1 −1

Click to load diff…

modified lib/git_gud/workflows/yaml.ex
+9 −0

Click to load diff…

modified lib/git_gud_web/components/core_components.ex
+5 −0

Click to load diff…

added lib/git_gud_web/components/diff_components.ex
+228 −0

Click to load diff…

added lib/git_gud_web/components/moderation_components.ex
+86 −0

Click to load diff…

modified lib/git_gud_web/controllers/actions_cache_controller.ex
+124 −46

Click to load diff…

added lib/git_gud_web/controllers/packages_webhook_controller.ex
+177 −0

Click to load diff…

modified lib/git_gud_web/controllers/page_html/home.html.heex
+31 −10

Click to load diff…

modified lib/git_gud_web/live/admin_federation_live/index.ex
+208 −10

Click to load diff…

modified lib/git_gud_web/live/issue_live/new.ex
+190 −9

Click to load diff…

modified lib/git_gud_web/live/issue_live/show.ex
+83 −28

Click to load diff…

modified lib/git_gud_web/live/label_live/index.ex
+184 −27

Click to load diff…

added lib/git_gud_web/live/org_live/labels.ex
+197 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/show.ex
+6 −0

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+105 −27

Click to load diff…

modified lib/git_gud_web/live/repo_live/commit.ex
+20 −4

Click to load diff…

modified lib/git_gud_web/live/repo_live/compare.ex
+23 −0

Click to load diff…

added lib/git_gud_web/live/repo_live/packages.ex
+146 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/show.ex
+10 −0

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+96 −22

Click to load diff…

modified lib/git_gud_web/router.ex
+45 −31

Click to load diff…

added lib/mix/tasks/gitgud.federation.suggest_blocks.ex
+93 −0

Click to load diff…

added priv/repo/migrations/20260513240000_create_cache_entries.exs
+49 −0

Click to load diff…

added priv/repo/migrations/20260513250000_add_federated_pr_review_origin.exs
+27 −0

Click to load diff…

added priv/repo/migrations/20260513260000_create_suggested_instance_blocks.exs
+30 −0

Click to load diff…

added priv/repo/migrations/20260513270000_org_labels_and_optouts.exs
+45 −0

Click to load diff…

added priv/repo/migrations/20260513280000_add_moderation_fields.exs
+18 −0

Click to load diff…

added test/git_gud/federation/suggested_blocks_test.exs
+108 −0

Click to load diff…

added test/git_gud/git/diff_parser_test.exs
+143 −0

Click to load diff…

added test/git_gud/labels_org_inheritance_test.exs
+98 −0

Click to load diff…

added test/git_gud/markdown_cross_refs_test.exs
+190 −0

Click to load diff…

modified test/git_gud/markdown_test.exs
+24 −0

Click to load diff…

added test/git_gud/pull_requests_federated_review_test.exs
+211 −0

Click to load diff…

added test/git_gud/reports_actions_test.exs
+230 −0

Click to load diff…

added test/git_gud/workflows/actions_cache_test.exs
+142 −0

Click to load diff…

added test/git_gud/workflows/cache_eviction_test.exs
+135 −0

Click to load diff…

added test/git_gud/workflows/rerun_cancel_test.exs
+105 −0

Click to load diff…

added test/git_gud_web/components/diff_components_test.exs
+102 −0

Click to load diff…

added test/git_gud_web/components/moderation_components_test.exs
+23 −0

Click to load diff…

added test/git_gud_web/packages_webhook_test.exs
+142 −0

Click to load diff…

Parents: c594edf