Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7c0203eunknown_key

feat(BLOCK-J): J22 code review suggestion blocks

feat(BLOCK-J): J22 code review suggestion blocks

Apply-suggestion flow for ```suggestion fences in PR comments. Pure
extractSuggestions + applySuggestionToContent in
src/lib/code-suggestions.ts (33 tests) preserve CRLF/LF + trailing-
newline and reject bad ranges / no-ops with a stable reason taxonomy.
POST endpoint commits the suggestion to the PR's head branch via git
plumbing with a Co-authored-by trailer. PR detail page renders a
"Commit suggestion" button per block for the PR author, comment
author, or repo owner.
Claude committed on April 15, 2026Parent: f3e1844
6 files changed+76407c0203ecaeab827ab7951cf8ef3b14a3a93f8c2f
6 changed files+764−0
ModifiedBUILD_BIBLE.md+4−0View fileUnifiedSplit
143143| Atom / RSS feeds | ✅ | J19 — `GET /:owner/:repo/{commits,releases,issues}.atom` serve Atom 1.0 feeds (up to 50 entries each). Pure renderer in `src/lib/atom-feed.ts``renderAtomFeed` emits XML-declaration-prefixed `<feed>` with required `<id>/<title>/<updated>/<link rel=self>`, per-entry `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>`. All five XML metacharacters escaped. `toIsoUtc` normalises Date + ISO inputs, falls back to epoch on junk. Cache headers `public, max-age=60, stale-while-revalidate=300`; `application/atom+xml; charset=utf-8` content-type. Private repos 404 (as empty feed doc) for non-owner viewers. |
144144| Stale issue detector | ✅ | J20 — `GET /:owner/:repo/issues/stale[?period=30d\|60d\|90d\|180d]` lists every open issue whose `updated_at` is older than the selected threshold, sorted oldest-first with per-issue `daysSinceUpdate` + comment count + age-colour (>=90d amber, >=180d red). Four age-bucket cards show the distribution (`30-60`, `60-90`, `90-180`, `180+`). Pure `filterStale` / `bucketByStaleness` / `buildStaleReport` in `src/lib/stale-issues.ts` accept both `Date` and ISO inputs and gracefully drop unparseable timestamps. softAuth read-only; private repos 404 for non-owner viewers. Linked from the Issues toolbar via a "Stale" button. Non-destructive — we only surface staleness, never auto-close. |
145145| CODEOWNERS validator | ✅ | J21 — `GET /:owner/:repo/codeowners` lints the CODEOWNERS file (standard locations: root / `.github/` / `docs/`) on the default branch against known users + teams and surface-level syntax. Pure `lexCodeowners` (line-aware, CRLF tolerant, inline-comment strip, detects pattern-with-no-owners), `classifyOwnerToken` (user / team / email / invalid), `isPlausiblePattern` (bracket balance, whitespace), `validateCodeowners` (async, takes `OwnerResolver`) in `src/lib/codeowners-lint.ts`. Findings are anchored to 1-indexed line numbers with an `error\|warning\|info` severity + stable `code` taxonomy (`empty_file\|no_owners\|empty_pattern\|bad_pattern_syntax\|bad_owner_format\|unknown_user\|unknown_team\|duplicate_pattern\|duplicate_owner\|missing_catchall`). UI shows four summary cards (rules / errors / warnings / infos), a findings list, and the file body with line numbers. Resolver memoises per-request. Non-destructive — report only. |
146| Code review suggestion blocks | ✅ | J22 — `` ```suggestion `` fenced blocks inside PR comments are detected (pure `extractSuggestions` in `src/lib/code-suggestions.ts`) and rendered on the PR page with a "Commit suggestion" button when the viewer is the PR author, comment author, or repo owner. `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion` reads the file on the PR's head branch, runs pure `applySuggestionToContent` (preserves CRLF/LF line endings + trailing-newline presence, validates line range + detects no-op), and commits via git plumbing (`hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree` → `update-ref`) with a `Co-authored-by:` trailer crediting the applier. `requireAuth`; private repos 404 for non-owners; closed PRs redirect silently. |
146147| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
147148| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
148149| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
307308- **J14** — Issue dependencies / blocked-by relationships → ✅ shipped. `drizzle/0036_issue_dependencies.sql` adds `issue_dependencies` (`blocker_issue_id`, `blocked_issue_id`, CHECK `issue_dep_no_self`, unique on the pair, indexes on both sides). `src/lib/issue-dependencies.ts` exposes pure `wouldCreateCycle(edges, blocker, blocked)` (BFS following forward blocks edges) + `summariseBlockers` plus DB helpers: `addDependency` (`{ok, reason}` taxonomy rejecting self / cross_repo / exists / cycle / not_found / error), `removeDependency`, `listBlockersOf`, `listBlockedBy` (join issues + users for number/title/state/author). Issue detail page in `src/routes/issues.tsx` now renders a "Dependencies" panel above the body showing Blocked-by + Blocks lists, per-row dismiss buttons (owner/author only), and a `#number`-form to add a new blocker. Two POST routes mirror the J11 pattern: `/:o/:r/issues/:n/dependencies` (add) and `/:o/:r/issues/:n/dependencies/:which/:otherId/remove`. Permission gated by `user.id === owner.id || user.id === issue.authorId`. 14 new tests covering cycle detection (direct + transitive + diamond + deep chains), summariseBlockers counts, and route-auth smokes. Total suite 867/867.
308309- **J15** — Deterministic release-notes generator → ✅ shipped. `src/lib/release-notes.ts` ships zero-IO helpers: `classifyCommit` (conventional prefix + scope + `!` breaking + trailing `(#N)` PR capture; aliases `feature`/`bugfix`/`doc`/`tests`; Merge-commit detection for pull requests and branches), `groupCommits`, `contributorsFrom`, `renderNotesMarkdown` (Breaking-changes section first, then 13 ordered buckets, then Contributors + Full-Changelog compare link). `src/routes/releases.tsx` adds `POST /:owner/:repo/releases/generate-notes` which re-renders the new-release form with notes pre-filled (preserves tag/name/target/draft/prerelease fields) plus a notice banner on missing commits or resolve failure. AI-disabled repos now fall through to the deterministic renderer on publish instead of writing empty notes. 30 new tests. Total suite 897/897.
309310- **J16** — PR auto-merge when checks pass → ✅ shipped. `drizzle/0037_pr_auto_merge.sql` adds `pr_auto_merge` (unique on `pull_request_id`, merge-method text, commit-title/message overrides, last_status + last_checked_at + notified_ready columns for the status-hook watcher). `src/lib/pr-auto-merge.ts` is the pure state machine + DB accessor: `computeAutoMergeAction({autoMergeEnabled, prState, isDraft, combinedState, totalChecks}) → {action: wait|merge|skip, reason}` covers all nine paths (not_enabled / pr_closed / pr_draft / no_checks / checks_pending / checks_failed / checks_passed). `src/lib/pr-auto-merge-trigger.ts` is called fire-and-forget from the commit-status POST — resolves each opted-in PR's head branch, matches against the incoming SHA, runs `combinedStatus` + the pure decider, and on first green transition posts a PR comment + PR-author notification. PR detail page gets an `AutoMergePanel` with live status colour (green/yellow/red), merge-method selector (merge/squash/rebase), and disable button. 16 new tests. Total suite 913/913.
311- **J22** — Code review suggestion blocks (``` ```suggestion ```) → ✅ shipped. `src/lib/code-suggestions.ts` is pure: `extractSuggestions(body)` parses fenced `suggestion` blocks with CommonMark parity (indented opener up to 3 spaces, 3+ backtick fences for content containing triple-backticks, case-sensitive language token, skips unterminated). `applySuggestionToContent({content,startLine,endLine,suggestion})` returns `{ok, content?, reason?}` with a stable error taxonomy (`bad_range` / `line_out_of_bounds` / `empty_content` / `no_change`). Preserves the original file's line-ending (CRLF wins if any present) and trailing-newline presence. `src/routes/code-suggestions.tsx` serves `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion` (requireAuth, owner/PR-author/comment-author only, open-PR only, comment must have `file_path` + `line_number`). Commits via `hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree -p` → `update-ref`. Commit message: `Apply suggestion from #N\n\nCo-authored-by: …`. PR detail page in `src/routes/pulls.tsx` renders a "Commit suggestion" button below every eligible comment (multiple buttons when a comment has >1 block). 33 new tests covering line-ending detection, extractor edges (empty/unterminated/multi-block/4-backtick/indented info/case-sensitivity), applier (single line / multi-line / range / CRLF / no-trailing-newline / empty suggestion deletes / bad-range + out-of-bounds + no-op rejections), `applyNthSuggestion`, `__internal` parity, route auth smokes. Total suite 1068/1068.
310312- **J21** — CODEOWNERS validator → ✅ shipped. `src/lib/codeowners-lint.ts` lints a CODEOWNERS file with a pure-first API — `lexCodeowners` preserves 1-indexed line numbers while stripping comments, `classifyOwnerToken` (GitHub-parity regexes for user / team / email / invalid), `isPlausiblePattern` (unbalanced brackets / whitespace), async `validateCodeowners(content, resolver)` taking an `OwnerResolver` so the core stays IO-free. Report emits findings sorted by line then severity plus derived `errors/warnings/infos/ok`. Stable code taxonomy: `empty_file`, `no_owners`, `empty_pattern`, `bad_pattern_syntax`, `bad_owner_format`, `unknown_user`, `unknown_team`, `duplicate_pattern`, `duplicate_owner`, `missing_catchall`. `src/routes/codeowners-lint.tsx` serves `GET /:owner/:repo/codeowners` — softAuth, private repos 404. Walks the three standard paths on the default branch, builds a per-request memoising resolver against `users.username` + `organizations.slug` + `teams.slug`, renders four summary cards + findings list + line-numbered file body, or an empty-state with "Create .github/CODEOWNERS" CTA. 28 new tests covering lexer (blank/comment/inline-comment/CRLF/team tokens), owner classifier (user/team/email/invalid edges), pattern plausibility, validator (empty file, clean catchall, pattern-no-owners, unknown user, unknown team, email passthrough, bad format, duplicate patterns, duplicate owners, ordering, ok-false on error, info-only still ok, bracket-unbalanced), `__internal` parity, route 404. Total suite 1035/1035.
311313- **J20** — Stale issue detector → ✅ shipped. `src/lib/stale-issues.ts` is a pure filter/bucketer — `STALE_PERIODS = ['30d','60d','90d','180d']`, `parsePeriod` with default-on-garbage, `filterStale(issues, now, thresholdDays)` (open-only, parseable-dates-only, oldest-first), `bucketByStaleness` (30-60 / 60-90 / 90-180 / 180+ with inclusive lower bounds), `buildStaleReport` one-shot builder. Accepts both Date and ISO inputs; drops unparseable timestamps silently. `src/routes/stale-issues.tsx` serves `GET /:owner/:repo/issues/stale[?period=…]` (softAuth, read-only), fetches open issues (2000 cap) with a grouped comment count, and renders four bucket cards + an age-coloured table (amber >=90d, red >=180d). Route is mounted BEFORE `issueRoutes` so the static `/issues/stale` path wins over `/issues/:number`. `resolveRepo` wrapped in try/catch — DB failure 404s, never 500. Issues-list toolbar gets a new "Stale" button. Non-destructive: surfaces staleness only; no auto-close. 22 new tests covering period parsing, filter threshold semantics + boundary inclusion + oldest-first sort, bad-date tolerance, bucket boundaries, `buildStaleReport`, `__internal` parity, route smokes. Total suite 1007/1007.
312314- **J19** — Atom / RSS feeds for commits, releases, issues → ✅ shipped. `src/lib/atom-feed.ts` is a pure Atom 1.0 renderer — `escapeXml` (five XML metacharacters), `toIsoUtc` (Date + ISO, epoch fallback), `renderAtomFeed` (XML-decl + `<feed xmlns>` + feed metadata + entries with `<id>/<title>/<link>/<updated>/<published>/<author>/<summary>/<content>`), `ATOM_CONTENT_TYPE`. `pickFeedUpdated` auto-derives from newest entry when not set explicitly. `src/routes/feeds.ts` serves three feeds — `/:owner/:repo/{commits,releases,issues}.atom`, each capped at 50 entries. `softAuth`; private repos receive an empty-feed response so external readers stay non-crashing. Cache: `public, max-age=60, stale-while-revalidate=300`. Commits sourced from `listCommits` on the default branch; releases filter out drafts; issues span both open + closed, newest-first. 20 new tests covering escape edge cases, toIsoUtc, feed structure, well-formedness with zero entries, metacharacter safety, newest-entry auto-update, explicit updatedAt respect, route content-type + cache headers. Total suite 985/985.
522524- `src/routes/stale-issues.tsx` (Block J20) — serves `GET /:owner/:repo/issues/stale[?period=…]`. softAuth. Fetches open issues only (2000 cap) plus a grouped `count(issue_comments.id)` per issue number, calls `buildStaleReport`. `resolveRepo` wrapped in try/catch so DB failure returns 404, not 500. **Must be mounted BEFORE `issueRoutes`** so the static `/issues/stale` path wins over `/issues/:number` (which would `parseInt("stale")` into NaN). Renders four bucket cards (auto-hides buckets below the threshold), an age-coloured table (>=90d amber, >=180d red), or an empty-state card. Non-destructive: we surface staleness, never auto-close.
523525- `src/lib/codeowners-lint.ts` (Block J21) — pure CODEOWNERS linter. Exports `lexCodeowners(content)` (line-aware, CRLF + inline-comment tolerant, classifies each non-blank/non-comment line as rule or malformed), `classifyOwnerToken(token, hadAt) → 'user'\|'team'\|'email'\|'invalid'` (GitHub-parity regexes: username `[A-Za-z0-9][A-Za-z0-9-]{0,38}`, team `org/team`, RFC-loose email), `isPlausiblePattern` (bracket-balance + no-whitespace), async `validateCodeowners(content, resolver)` returning `LintReport` with `findings` sorted by line then severity, plus derived `errors/warnings/infos/ok`. Never throws. `__internal` re-exports regexes + helpers.
524526- `src/routes/codeowners-lint.tsx` (Block J21) — serves `GET /:owner/:repo/codeowners`. softAuth; private repos 404 for non-owner viewers. Walks the three standard paths (`CODEOWNERS`, `.github/CODEOWNERS`, `docs/CODEOWNERS`) on the default branch, builds a per-request memoising resolver over `users.username` + `organizations.slug` + `teams.slug`, calls `validateCodeowners`. Renders four summary cards (rules / errors / warnings / info), a findings list with line + severity + code + message, and the file body with line numbers. Gracefully empty-states when no CODEOWNERS file is found, with a one-click link to create `.github/CODEOWNERS` in the web editor.
527- `src/lib/code-suggestions.ts` (Block J22) — pure suggestion extractor + applier. Exports `detectLineEnding` (CRLF wins if any present), `splitLines`, `extractSuggestions(body)` (parses ``` ```suggestion ``` `` fences with CommonMark-parity rules: indented openers up to 3 spaces, 3+ backtick fences, case-sensitive language token, skips unterminated), `applySuggestionToContent({content,startLine,endLine,suggestion})` (1-indexed inclusive range; preserves original EOL + trailing-newline presence; returns `{ok,reason}` taxonomy `bad_range\|line_out_of_bounds\|empty_content\|no_change`), `applyNthSuggestion` convenience. `__internal` re-exports for tests.
528- `src/routes/code-suggestions.tsx` (Block J22) — serves `POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion`. `requireAuth`. Authorises applier as owner / PR author / comment author. Validates PR is open, comment has `file_path` + `line_number`, suggestion index is in range. Reads the file on the PR's head branch, applies the pure transformer, then commits via git plumbing (`hash-object` → `ls-tree -r` rewrite → `mktree` → `commit-tree -p` head → `update-ref refs/heads/<head>`). Commit message: `Apply suggestion from #N\n\nCo-authored-by: …`. Redirects to the PR with a comment anchor on success. `src/routes/pulls.tsx` PR detail page imports `extractSuggestions` and renders a "Commit suggestion" button (per-block when multiple) below the comment body for eligible viewers.
525529
526530### 4.7 Views (locked contracts)
527531- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
Addedsrc/__tests__/code-suggestions.test.ts+313−0View fileUnifiedSplit
1/**
2 * Block J22 — Code review suggestion blocks. Pure extract + apply tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 detectLineEnding,
8 splitLines,
9 extractSuggestions,
10 applySuggestionToContent,
11 applyNthSuggestion,
12 __internal,
13} from "../lib/code-suggestions";
14
15describe("code-suggestions — detectLineEnding", () => {
16 it("returns CRLF for CRLF content", () => {
17 expect(detectLineEnding("a\r\nb\r\n")).toBe("\r\n");
18 });
19 it("returns LF for LF content", () => {
20 expect(detectLineEnding("a\nb\n")).toBe("\n");
21 });
22 it("returns LF for empty", () => {
23 expect(detectLineEnding("")).toBe("\n");
24 });
25 it("returns CRLF if ANY CRLF present", () => {
26 expect(detectLineEnding("a\nb\r\nc")).toBe("\r\n");
27 });
28});
29
30describe("code-suggestions — extractSuggestions", () => {
31 it("returns [] for empty or non-string input", () => {
32 expect(extractSuggestions("")).toEqual([]);
33 expect(extractSuggestions(null as unknown as string)).toEqual([]);
34 expect(extractSuggestions(undefined as unknown as string)).toEqual([]);
35 });
36
37 it("extracts a single single-line suggestion", () => {
38 const body = "LGTM but\n\n```suggestion\nconst x = 1;\n```\n\nthoughts?";
39 const out = extractSuggestions(body);
40 expect(out).toHaveLength(1);
41 expect(out[0].content).toBe("const x = 1;");
42 expect(out[0].index).toBe(0);
43 });
44
45 it("extracts multi-line suggestions preserving internal newlines", () => {
46 const body = "```suggestion\nline1\nline2\nline3\n```\n";
47 const out = extractSuggestions(body);
48 expect(out).toHaveLength(1);
49 expect(out[0].content).toBe("line1\nline2\nline3");
50 });
51
52 it("captures empty suggestions (intent: delete the line)", () => {
53 const body = "```suggestion\n```\n";
54 const out = extractSuggestions(body);
55 expect(out).toHaveLength(1);
56 expect(out[0].content).toBe("");
57 });
58
59 it("skips fences whose info-string isn't `suggestion`", () => {
60 const body = "```js\nconst x = 1;\n```\n";
61 expect(extractSuggestions(body)).toEqual([]);
62 });
63
64 it("extracts multiple suggestions in order", () => {
65 const body =
66 "First:\n```suggestion\nfoo\n```\n\nSecond:\n```suggestion\nbar\nbaz\n```\n";
67 const out = extractSuggestions(body);
68 expect(out).toHaveLength(2);
69 expect(out[0].content).toBe("foo");
70 expect(out[1].content).toBe("bar\nbaz");
71 expect(out[0].index).toBe(0);
72 expect(out[1].index).toBe(1);
73 });
74
75 it("skips unterminated fences", () => {
76 const body = "```suggestion\nconst x = 1;";
77 expect(extractSuggestions(body)).toEqual([]);
78 });
79
80 it("tolerates 4+ backtick fences (for suggestions that contain ```)", () => {
81 const body =
82 "````suggestion\nconst s = `template ${with} backticks`;\n````\n";
83 const out = extractSuggestions(body);
84 expect(out).toHaveLength(1);
85 expect(out[0].content).toBe(
86 "const s = `template ${with} backticks`;"
87 );
88 });
89
90 it("ignores trailing info after `suggestion`", () => {
91 const body = "```suggestion block\nfoo\n```\n";
92 expect(extractSuggestions(body)).toHaveLength(1);
93 });
94
95 it("is case-sensitive on the language token (GitHub parity)", () => {
96 // GitHub's renderer is case-sensitive. This keeps our parser
97 // predictable — bump to case-insensitive when GitHub relaxes.
98 const body = "```Suggestion\nfoo\n```\n";
99 expect(extractSuggestions(body)).toEqual([]);
100 });
101});
102
103describe("code-suggestions — applySuggestionToContent", () => {
104 const content = "line one\nline two\nline three\n";
105
106 it("replaces a single line (1-indexed)", () => {
107 const res = applySuggestionToContent({
108 content,
109 startLine: 2,
110 endLine: 2,
111 suggestion: "TWO!",
112 });
113 expect(res.ok).toBe(true);
114 expect(res.content).toBe("line one\nTWO!\nline three\n");
115 });
116
117 it("replaces with multiple lines", () => {
118 const res = applySuggestionToContent({
119 content,
120 startLine: 2,
121 endLine: 2,
122 suggestion: "a\nb\nc",
123 });
124 expect(res.ok).toBe(true);
125 expect(res.content).toBe("line one\na\nb\nc\nline three\n");
126 });
127
128 it("replaces a multi-line range", () => {
129 const res = applySuggestionToContent({
130 content,
131 startLine: 1,
132 endLine: 2,
133 suggestion: "merged",
134 });
135 expect(res.ok).toBe(true);
136 expect(res.content).toBe("merged\nline three\n");
137 });
138
139 it("preserves CRLF line endings", () => {
140 const crlf = "a\r\nb\r\nc\r\n";
141 const res = applySuggestionToContent({
142 content: crlf,
143 startLine: 2,
144 endLine: 2,
145 suggestion: "B",
146 });
147 expect(res.ok).toBe(true);
148 expect(res.content).toBe("a\r\nB\r\nc\r\n");
149 });
150
151 it("preserves no-trailing-newline files", () => {
152 const res = applySuggestionToContent({
153 content: "a\nb\nc",
154 startLine: 3,
155 endLine: 3,
156 suggestion: "C",
157 });
158 expect(res.ok).toBe(true);
159 expect(res.content).toBe("a\nb\nC");
160 });
161
162 it("strips trailing newline from the suggestion", () => {
163 const res = applySuggestionToContent({
164 content: "a\nb\n",
165 startLine: 1,
166 endLine: 1,
167 suggestion: "A\n",
168 });
169 expect(res.ok).toBe(true);
170 expect(res.content).toBe("A\nb\n");
171 });
172
173 it("rejects bad ranges", () => {
174 const r1 = applySuggestionToContent({
175 content,
176 startLine: 0,
177 endLine: 1,
178 suggestion: "x",
179 });
180 expect(r1).toEqual({ ok: false, reason: "bad_range" });
181 const r2 = applySuggestionToContent({
182 content,
183 startLine: 2,
184 endLine: 1,
185 suggestion: "x",
186 });
187 expect(r2).toEqual({ ok: false, reason: "bad_range" });
188 });
189
190 it("rejects out-of-bounds line numbers", () => {
191 const r = applySuggestionToContent({
192 content,
193 startLine: 5,
194 endLine: 5,
195 suggestion: "x",
196 });
197 expect(r).toEqual({ ok: false, reason: "line_out_of_bounds" });
198 });
199
200 it("returns no_change when the suggestion matches existing content", () => {
201 const r = applySuggestionToContent({
202 content,
203 startLine: 1,
204 endLine: 1,
205 suggestion: "line one",
206 });
207 expect(r).toEqual({ ok: false, reason: "no_change" });
208 });
209
210 it("empty suggestion deletes the line", () => {
211 const r = applySuggestionToContent({
212 content,
213 startLine: 2,
214 endLine: 2,
215 suggestion: "",
216 });
217 expect(r.ok).toBe(true);
218 expect(r.content).toBe("line one\n\nline three\n");
219 });
220
221 it("rejects non-string content", () => {
222 const r = applySuggestionToContent({
223 content: undefined as unknown as string,
224 startLine: 1,
225 endLine: 1,
226 suggestion: "x",
227 });
228 expect(r.ok).toBe(false);
229 });
230});
231
232describe("code-suggestions — applyNthSuggestion", () => {
233 const body =
234 "First:\n```suggestion\nalpha\n```\n\nSecond:\n```suggestion\nbeta\n```\n";
235
236 it("applies the 0th block by default", () => {
237 const r = applyNthSuggestion(body, 0, {
238 content: "x\n",
239 startLine: 1,
240 endLine: 1,
241 });
242 expect(r.ok).toBe(true);
243 if (r.ok) expect(r.content).toBe("alpha\n");
244 });
245
246 it("applies the Nth block", () => {
247 const r = applyNthSuggestion(body, 1, {
248 content: "x\n",
249 startLine: 1,
250 endLine: 1,
251 });
252 expect(r.ok).toBe(true);
253 if (r.ok) expect(r.content).toBe("beta\n");
254 });
255
256 it("returns not_found for an out-of-range index", () => {
257 const r = applyNthSuggestion(body, 99, {
258 content: "x\n",
259 startLine: 1,
260 endLine: 1,
261 });
262 expect(r).toEqual({ ok: false, reason: "not_found" });
263 });
264
265 it("returns not_found for a negative index", () => {
266 const r = applyNthSuggestion(body, -1, {
267 content: "x\n",
268 startLine: 1,
269 endLine: 1,
270 });
271 expect(r).toEqual({ ok: false, reason: "not_found" });
272 });
273});
274
275describe("code-suggestions — __internal parity", () => {
276 it("re-exports helpers", () => {
277 expect(__internal.extractSuggestions).toBe(extractSuggestions);
278 expect(__internal.applySuggestionToContent).toBe(applySuggestionToContent);
279 expect(__internal.detectLineEnding).toBe(detectLineEnding);
280 expect(__internal.splitLines).toBe(splitLines);
281 expect(__internal.applyNthSuggestion).toBe(applyNthSuggestion);
282 });
283});
284
285describe("code-suggestions — splitLines", () => {
286 it("handles LF + CRLF correctly", () => {
287 expect(splitLines("a\nb\nc")).toEqual(["a", "b", "c"]);
288 expect(splitLines("a\r\nb\r\nc")).toEqual(["a", "b", "c"]);
289 });
290});
291
292describe("code-suggestions — routes", () => {
293 // Import the app lazily so the pure tests above don't boot Hono.
294 it("POST apply-suggestion 401s for unauthenticated users", async () => {
295 const { default: app } = await import("../app");
296 const res = await app.request(
297 "/alice/repo/pulls/1/comments/00000000-0000-0000-0000-000000000000/apply-suggestion",
298 { method: "POST" }
299 );
300 // requireAuth redirects web requests to /login.
301 expect([302, 401]).toContain(res.status);
302 });
303
304 it("invalid PR number returns 4xx", async () => {
305 const { default: app } = await import("../app");
306 const res = await app.request(
307 "/alice/repo/pulls/notanum/comments/00000000-0000-0000-0000-000000000000/apply-suggestion",
308 { method: "POST" }
309 );
310 // Unauth first; if somehow auth'd, would be 400. Both are acceptable.
311 expect([302, 400, 401, 404]).toContain(res.status);
312 });
313});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
8282import feedRoutes from "./routes/feeds";
8383import staleIssuesRoutes from "./routes/stale-issues";
8484import codeownersLintRoutes from "./routes/codeowners-lint";
85import codeSuggestionsRoutes from "./routes/code-suggestions";
8586import webRoutes from "./routes/web";
8687
8788const app = new Hono();
291292// CODEOWNERS validator — /:owner/:repo/codeowners (Block J21)
292293app.route("/", codeownersLintRoutes);
293294
295// Code review suggestion blocks — apply ```suggestion fences to head branch (Block J22)
296app.route("/", codeSuggestionsRoutes);
297
294298// Insights + milestones
295299app.route("/", insightsRoutes);
296300
Addedsrc/lib/code-suggestions.ts+172−0View fileUnifiedSplit
1/**
2 * Block J22 — Code review suggestion blocks.
3 *
4 * GitHub-style ```suggestion ... ``` fences in PR comments. The reviewer
5 * proposes a replacement for the commented line(s); the reader can
6 * one-click "Commit suggestion" to write it to the PR's head branch.
7 *
8 * This module is pure:
9 * - `extractSuggestions(body)` parses a comment body into suggestion
10 * blocks preserving their order + raw content + source offsets.
11 * - `applySuggestionToContent({content, startLine, endLine, suggestion})`
12 * returns the new file content with lines `startLine..endLine`
13 * (1-indexed, inclusive) replaced by `suggestion`, preserving the
14 * file's original line ending.
15 *
16 * We deliberately restrict ourselves to **single-suggestion commits**:
17 * each POST applies one block, against the anchor line recorded on the
18 * PR comment (a single line in our schema). Multi-line ranges — GitHub
19 * supports `@@ ... @@`-style deltas — are a later extension.
20 */
21
22export interface SuggestionBlock {
23 /** The suggestion's replacement text, verbatim between the fences. */
24 content: string;
25 /** Character offset of the opening fence in the comment body. */
26 startOffset: number;
27 /** Character offset immediately after the closing fence. */
28 endOffset: number;
29 /** Position in the body (0-indexed). */
30 index: number;
31}
32
33/**
34 * Detect the dominant line ending in the given content. Heuristic:
35 * if any CRLF is present the whole file is treated as CRLF; otherwise LF.
36 */
37export function detectLineEnding(content: string): "\r\n" | "\n" {
38 return content.includes("\r\n") ? "\r\n" : "\n";
39}
40
41/** Split on `\r\n` or `\n` without consuming the last empty element. */
42export function splitLines(content: string): string[] {
43 return content.split(/\r?\n/);
44}
45
46/**
47 * Parse a comment body into an array of suggestion blocks. Matches
48 * fenced blocks where the info-string begins with `suggestion`. Handles:
49 * - Indented fences (up to 3 leading spaces) — CommonMark parity
50 * - Blocks using 3+ backticks (closing fence must be ≥ opener count)
51 * - The content is returned verbatim, with the trailing newline before
52 * the closing fence stripped (GitHub's renderer does this).
53 * Blocks opened but never closed are skipped.
54 */
55export function extractSuggestions(body: string): SuggestionBlock[] {
56 if (!body || typeof body !== "string") return [];
57 const out: SuggestionBlock[] = [];
58 const fenceRe = /^[ ]{0,3}(`{3,})[ \t]*suggestion[^\n]*\n/gm;
59 let m: RegExpExecArray | null;
60 let idx = 0;
61 while ((m = fenceRe.exec(body)) !== null) {
62 const fence = m[1];
63 const openStart = m.index;
64 const contentStart = openStart + m[0].length;
65 // Find the matching closing fence: a line starting with ≥fence.length
66 // backticks, up to 3 spaces of indent, at the start of a line.
67 const closeRe = new RegExp(
68 `(\\r?\\n)?[ ]{0,3}\`{${fence.length},}[ \\t]*(?:\\r?\\n|$)`,
69 "g"
70 );
71 closeRe.lastIndex = contentStart;
72 const close = closeRe.exec(body);
73 if (!close) break; // unterminated — ignore
74 // Ensure the close is at the start of a line (either body[close.index]
75 // is a newline we captured, or close.index === contentStart).
76 const rawContent = body.slice(contentStart, close.index);
77 out.push({
78 content: rawContent,
79 startOffset: openStart,
80 endOffset: close.index + close[0].length,
81 index: idx++,
82 });
83 fenceRe.lastIndex = close.index + close[0].length;
84 }
85 return out;
86}
87
88export interface ApplyOpts {
89 /** Original file content. */
90 content: string;
91 /** 1-indexed, inclusive. */
92 startLine: number;
93 /** 1-indexed, inclusive. */
94 endLine: number;
95 /** Replacement text. Trailing newline is stripped if present. */
96 suggestion: string;
97}
98
99export interface ApplyResult {
100 ok: boolean;
101 /** New content (only set when ok). */
102 content?: string;
103 /** Reason for failure (only set when !ok). */
104 reason?:
105 | "bad_range"
106 | "line_out_of_bounds"
107 | "empty_content"
108 | "no_change";
109}
110
111/**
112 * Replace file lines `startLine..endLine` with the suggestion.
113 * Preserves the dominant line ending in the original file. Returns the
114 * new file content (ending with the same terminal newline presence as
115 * the original).
116 */
117export function applySuggestionToContent(opts: ApplyOpts): ApplyResult {
118 const { content, startLine, endLine, suggestion } = opts;
119 if (typeof content !== "string") return { ok: false, reason: "empty_content" };
120 if (
121 !Number.isInteger(startLine) ||
122 !Number.isInteger(endLine) ||
123 startLine < 1 ||
124 endLine < startLine
125 ) {
126 return { ok: false, reason: "bad_range" };
127 }
128 const eol = detectLineEnding(content);
129 const hadTrailingNewline = /\r?\n$/.test(content);
130 const lines = splitLines(content);
131 // splitLines yields a trailing "" element when the input ends in \n —
132 // drop it to get true line count.
133 const trimmedLines =
134 hadTrailingNewline && lines[lines.length - 1] === ""
135 ? lines.slice(0, -1)
136 : lines;
137 if (endLine > trimmedLines.length) {
138 return { ok: false, reason: "line_out_of_bounds" };
139 }
140 // Normalise suggestion to LF then split — we'll rejoin with eol below.
141 const replacement = suggestion.replace(/\r\n/g, "\n").replace(/\n$/, "");
142 const replacementLines = replacement.split("\n");
143 const before = trimmedLines.slice(0, startLine - 1);
144 const after = trimmedLines.slice(endLine);
145 const next = [...before, ...replacementLines, ...after];
146 let result = next.join(eol);
147 if (hadTrailingNewline) result += eol;
148 if (result === content) return { ok: false, reason: "no_change" };
149 return { ok: true, content: result };
150}
151
152/**
153 * Convenience: apply the Nth suggestion from a comment body to a file.
154 * Returns `{ok:false, reason:'not_found'}` if the index is out of range.
155 */
156export function applyNthSuggestion(
157 body: string,
158 n: number,
159 opts: Omit<ApplyOpts, "suggestion">
160): ApplyResult | { ok: false; reason: "not_found" } {
161 const blocks = extractSuggestions(body);
162 if (n < 0 || n >= blocks.length) return { ok: false, reason: "not_found" };
163 return applySuggestionToContent({ ...opts, suggestion: blocks[n].content });
164}
165
166export const __internal = {
167 detectLineEnding,
168 splitLines,
169 extractSuggestions,
170 applySuggestionToContent,
171 applyNthSuggestion,
172};
Addedsrc/routes/code-suggestions.tsx+231−0View fileUnifiedSplit
1/**
2 * Block J22 — Apply a code review suggestion to the PR's head branch.
3 *
4 * POST /:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion
5 * body: index (optional; defaults to 0)
6 *
7 * Only the repo owner or the PR author may apply. The suggestion must
8 * be anchored to a comment with a `file_path` + `line_number`. We read
9 * the file on the PR's head branch, apply the suggestion via the pure
10 * `applySuggestionToContent`, and commit the result using the same
11 * plumbing pattern `src/routes/editor.tsx` uses for edits.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import {
18 prComments,
19 pullRequests,
20 repositories,
21 users,
22} from "../db/schema";
23import { requireAuth, softAuth } from "../middleware/auth";
24import type { AuthEnv } from "../middleware/auth";
25import { getBlob, getRepoPath } from "../git/repository";
26import {
27 applySuggestionToContent,
28 extractSuggestions,
29} from "../lib/code-suggestions";
30
31const codeSuggestions = new Hono<AuthEnv>();
32
33async function resolveRepo(ownerName: string, repoName: string) {
34 try {
35 const [owner] = await db
36 .select()
37 .from(users)
38 .where(eq(users.username, ownerName))
39 .limit(1);
40 if (!owner) return null;
41 const [repo] = await db
42 .select()
43 .from(repositories)
44 .where(
45 and(
46 eq(repositories.ownerId, owner.id),
47 eq(repositories.name, repoName)
48 )
49 )
50 .limit(1);
51 if (!repo) return null;
52 return { owner, repo };
53 } catch {
54 return null;
55 }
56}
57
58codeSuggestions.post(
59 "/:owner/:repo/pulls/:number/comments/:commentId/apply-suggestion",
60 softAuth,
61 requireAuth,
62 async (c) => {
63 const { owner: ownerName, repo: repoName, number, commentId } =
64 c.req.param();
65 const user = c.get("user")!;
66 const prNumber = parseInt(number, 10);
67 if (!Number.isFinite(prNumber)) return c.text("bad pr number", 400);
68
69 const resolved = await resolveRepo(ownerName, repoName);
70 if (!resolved) return c.text("not found", 404);
71 if (
72 resolved.repo.isPrivate &&
73 user.id !== resolved.owner.id
74 ) {
75 return c.text("not found", 404);
76 }
77
78 // Look up PR + comment.
79 const [pr] = await db
80 .select()
81 .from(pullRequests)
82 .where(
83 and(
84 eq(pullRequests.repositoryId, resolved.repo.id),
85 eq(pullRequests.number, prNumber)
86 )
87 )
88 .limit(1);
89 if (!pr) return c.text("pr not found", 404);
90 if (pr.state !== "open") {
91 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNumber}`);
92 }
93
94 const [comment] = await db
95 .select()
96 .from(prComments)
97 .where(
98 and(
99 eq(prComments.id, commentId),
100 eq(prComments.pullRequestId, pr.id)
101 )
102 )
103 .limit(1);
104 if (!comment) return c.text("comment not found", 404);
105 if (!comment.filePath || !comment.lineNumber) {
106 return c.text("comment has no file anchor", 400);
107 }
108
109 // Authorisation: owner, PR author, or comment author.
110 const allowed =
111 user.id === resolved.owner.id ||
112 user.id === pr.authorId ||
113 user.id === comment.authorId;
114 if (!allowed) return c.text("forbidden", 403);
115
116 // Which suggestion block?
117 const form = await c.req.parseBody().catch(() => ({}));
118 const rawIdx = (form as Record<string, unknown>).index;
119 const idx =
120 typeof rawIdx === "string" && rawIdx.trim() !== ""
121 ? parseInt(rawIdx, 10)
122 : 0;
123 const blocks = extractSuggestions(comment.body);
124 if (!Number.isFinite(idx) || idx < 0 || idx >= blocks.length) {
125 return c.text("no such suggestion", 400);
126 }
127 const suggestion = blocks[idx].content;
128
129 // Read file on head branch.
130 const headRef = pr.headBranch;
131 let blob: { content: string; isBinary: boolean } | null = null;
132 try {
133 blob = await getBlob(
134 ownerName,
135 repoName,
136 headRef,
137 comment.filePath
138 );
139 } catch {
140 blob = null;
141 }
142 if (!blob || blob.isBinary) {
143 return c.text("file missing or binary", 400);
144 }
145
146 const applied = applySuggestionToContent({
147 content: blob.content,
148 startLine: comment.lineNumber,
149 endLine: comment.lineNumber,
150 suggestion,
151 });
152 if (!applied.ok) {
153 return c.text(`apply failed: ${applied.reason}`, 400);
154 }
155
156 // Commit to head branch using the editor-style plumbing.
157 const repoDir = getRepoPath(ownerName, repoName);
158 const run = async (cmd: string[], stdin?: string) => {
159 const proc = Bun.spawn(cmd, {
160 cwd: repoDir,
161 stdout: "pipe",
162 stderr: "pipe",
163 stdin: stdin !== undefined ? "pipe" : undefined,
164 });
165 if (stdin !== undefined && proc.stdin) {
166 proc.stdin.write(new TextEncoder().encode(stdin));
167 proc.stdin.end();
168 }
169 const stdout = await new Response(proc.stdout).text();
170 await proc.exited;
171 return stdout.trim();
172 };
173
174 try {
175 const blobSha = await run(
176 ["git", "hash-object", "-w", "--stdin"],
177 applied.content
178 );
179 const treeContent = await run(["git", "ls-tree", "-r", headRef]);
180 const updated =
181 treeContent
182 .split("\n")
183 .filter(Boolean)
184 .map((line) => {
185 const parts = line.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/);
186 if (parts && parts[4] === comment.filePath) {
187 return `${parts[1]} blob ${blobSha}\t${parts[4]}`;
188 }
189 return line;
190 })
191 .join("\n") + "\n";
192 const newTreeSha = await run(["git", "mktree"], updated);
193 const parentSha = await run(["git", "rev-parse", headRef]);
194 const env = {
195 GIT_AUTHOR_NAME: user.displayName || user.username,
196 GIT_AUTHOR_EMAIL: user.email,
197 GIT_COMMITTER_NAME: user.displayName || user.username,
198 GIT_COMMITTER_EMAIL: user.email,
199 };
200 const message = `Apply suggestion from #${prNumber}\n\nCo-authored-by: ${
201 user.displayName || user.username
202 } <${user.email}>`;
203 const commitProc = Bun.spawn(
204 ["git", "commit-tree", newTreeSha, "-p", parentSha, "-m", message],
205 {
206 cwd: repoDir,
207 stdout: "pipe",
208 stderr: "pipe",
209 env: { ...process.env, ...env },
210 }
211 );
212 const commitSha = (await new Response(commitProc.stdout).text()).trim();
213 await commitProc.exited;
214 await run([
215 "git",
216 "update-ref",
217 `refs/heads/${headRef}`,
218 commitSha,
219 ]);
220 } catch (err) {
221 console.error("[apply-suggestion]", err);
222 return c.text("commit failed", 500);
223 }
224
225 return c.redirect(
226 `/${ownerName}/${repoName}/pulls/${prNumber}#comment-${comment.id}`
227 );
228 }
229);
230
231export default codeSuggestions;
Modifiedsrc/routes/pulls.tsx+40−0View fileUnifiedSplit
4646 listRequiredChecks,
4747 passingCheckNames,
4848} from "../lib/branch-protection";
49import { extractSuggestions } from "../lib/code-suggestions";
4950
5051const pulls = new Hono<AuthEnv>();
5152
649650 <div class="markdown-body">
650651 {html([renderMarkdown(comment.body)] as unknown as TemplateStringsArray)}
651652 </div>
653 {(() => {
654 const suggestions = extractSuggestions(comment.body);
655 if (
656 suggestions.length === 0 ||
657 !comment.filePath ||
658 !comment.lineNumber ||
659 pr.state !== "open"
660 ) {
661 return null as unknown as JSX.Element;
662 }
663 const canApply =
664 !!user &&
665 (user.id === resolved.owner.id ||
666 user.id === pr.authorId ||
667 user.id === comment.authorId);
668 if (!canApply) return null as unknown as JSX.Element;
669 return (
670 <div style="padding: 0 16px 12px; display: flex; gap: 6px; flex-wrap: wrap; border-top: 1px solid var(--border); margin-top: 4px; padding-top: 10px">
671 {suggestions.map((_, si) => (
672 <form
673 method="POST"
674 action={`/${ownerName}/${repoName}/pulls/${pr.number}/comments/${comment.id}/apply-suggestion`}
675 style="display: inline"
676 >
677 <input type="hidden" name="index" value={String(si)} />
678 <button
679 type="submit"
680 class="btn btn-primary"
681 style="padding: 3px 10px; font-size: 12px"
682 >
683 {suggestions.length === 1
684 ? "Commit suggestion"
685 : `Commit suggestion ${si + 1}`}
686 </button>
687 </form>
688 ))}
689 </div>
690 );
691 })()}
652692 <div style="padding: 0 16px 12px">
653693 <ReactionsBar
654694 targetType="pr_comment"
655695