he keeps doing it
71effab · gmorell · 2026-05-14 01:31
Files changed
modified
PROGRESS.md
+266
−2
| 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 | 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 | 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 | 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 | 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 | 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