Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

code-suggestions.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

code-suggestions.tsBlame172 lines · 1 contributor
7c0203eClaude1/**
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};