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.
| 7c0203e | 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 | ||
| 14 | import { Hono } from "hono"; | |
| 15 | import { and, eq } from "drizzle-orm"; | |
| 16 | import { db } from "../db"; | |
| 17 | import { | |
| 18 | prComments, | |
| 19 | pullRequests, | |
| 20 | repositories, | |
| 21 | users, | |
| 22 | } from "../db/schema"; | |
| 23 | import { requireAuth, softAuth } from "../middleware/auth"; | |
| 24 | import type { AuthEnv } from "../middleware/auth"; | |
| 25 | import { getBlob, getRepoPath } from "../git/repository"; | |
| 26 | import { | |
| 27 | applySuggestionToContent, | |
| 28 | extractSuggestions, | |
| 29 | } from "../lib/code-suggestions"; | |
| 30 | ||
| 31 | const codeSuggestions = new Hono<AuthEnv>(); | |
| 32 | ||
| 33 | async 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 | ||
| 58 | codeSuggestions.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 | ||
| 231 | export default codeSuggestions; |