Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

code-suggestions.tsx

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.tsxBlame231 lines · 1 contributor
7c0203eClaude1/**
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;