api/src/routes/diffs.tsblame
View source
d12933e1import type { FastifyInstance } from "fastify";
d12933e2import { z } from "zod";
d12933e3
d12933e4const createDiffSchema = z.object({
d12933e5 title: z.string().min(1).max(256),
d12933e6 description: z.string().optional(),
2ec68687 head_commit: z.string().min(1),
2ec68688 base_commit: z.string().optional(),
d12933e9});
d12933e10
d12933e11const updateDiffSchema = z.object({
d12933e12 title: z.string().min(1).max(256).optional(),
d12933e13 description: z.string().optional(),
d12933e14 status: z.enum(["open", "closed"]).optional(),
2ec686815 head_commit: z.string().min(1).optional(),
d12933e16});
d12933e17
d12933e18const createCommentSchema = z.object({
d12933e19 body: z.string().min(1),
d12933e20 file_path: z.string().optional(),
d12933e21 line_number: z.number().int().positive().optional(),
d12933e22 side: z.enum(["left", "right"]).optional(),
d12933e23 commit_sha: z.string().optional(),
d12933e24 parent_id: z.number().int().positive().optional(),
d12933e25});
d12933e26
d12933e27const createReviewSchema = z.object({
d12933e28 status: z.enum(["approved", "changes_requested"]),
d12933e29 body: z.string().optional(),
d12933e30});
d12933e31
d12933e32export async function diffRoutes(app: FastifyInstance) {
d12933e33 // List diffs for a repo
d12933e34 app.get<{
d12933e35 Params: { owner: string; repo: string };
d12933e36 Querystring: { status?: string };
d12933e37 }>(
d12933e38 "/:owner/:repo/diffs",
d12933e39 async (request, reply) => {
d12933e40 const { owner, repo } = request.params;
d12933e41 const status = request.query.status ?? "open";
d12933e42 const db = (app as any).db;
d12933e43
d12933e44 const repoRow = db
d12933e45 .prepare(`
d12933e46 SELECT id FROM repos_with_owner
d12933e47 WHERE owner_name = ? AND name = ?
d12933e48 `)
d12933e49 .get(owner, repo) as any;
d12933e50
d12933e51 if (!repoRow) {
d12933e52 return reply.code(404).send({ error: "Repository not found" });
d12933e53 }
d12933e54
d12933e55 const diffs = db
d12933e56 .prepare(`
d12933e57 SELECT d.*, u.username as author_name, u.display_name as author_display_name
d12933e58 FROM diffs d
d12933e59 JOIN users u ON d.author_id = u.id
d12933e60 WHERE d.repo_id = ? AND d.status = ?
d12933e61 ORDER BY d.updated_at DESC
d12933e62 `)
d12933e63 .all(repoRow.id, status);
d12933e64
d12933e65 return { diffs };
d12933e66 }
d12933e67 );
d12933e68
d12933e69 // Create diff
d12933e70 app.post<{ Params: { owner: string; repo: string } }>(
d12933e71 "/:owner/:repo/diffs",
d12933e72 {
d12933e73 preHandler: [(app as any).authenticate],
d12933e74 handler: async (request, reply) => {
d12933e75 const parsed = createDiffSchema.safeParse(request.body);
d12933e76 if (!parsed.success) {
d12933e77 return reply.code(400).send({ error: parsed.error.flatten() });
d12933e78 }
d12933e79
d12933e80 const { owner, repo } = request.params;
d12933e81 const user = request.user as any;
d12933e82 const db = (app as any).db;
d12933e83
d12933e84 const repoRow = db
d12933e85 .prepare(`
55e950186 SELECT id FROM repos_with_owner
55e950187 WHERE owner_name = ? AND name = ?
d12933e88 `)
d12933e89 .get(owner, repo) as any;
d12933e90
d12933e91 if (!repoRow) {
d12933e92 return reply.code(404).send({ error: "Repository not found" });
d12933e93 }
d12933e94
2ec686895 const { title, description, head_commit, base_commit } = parsed.data;
2ec686896
2ec686897 // Idempotent: if an open diff already exists for this commit, return it
2ec686898 const existing = db
2ec686899 .prepare(`
2ec6868100 SELECT * FROM diffs
2ec6868101 WHERE repo_id = ? AND head_commit = ? AND status = 'open'
2ec6868102 `)
2ec6868103 .get(repoRow.id, head_commit) as any;
2ec6868104 if (existing) {
2ec6868105 return reply.code(200).send({ diff: existing });
2ec6868106 }
2ec6868107
d12933e108 // Get next diff number for this repo
d12933e109 const maxNumber = db
d12933e110 .prepare("SELECT MAX(number) as max_num FROM diffs WHERE repo_id = ?")
d12933e111 .get(repoRow.id) as any;
d12933e112 const nextNumber = (maxNumber?.max_num ?? 0) + 1;
d12933e113
d12933e114 const result = db
d12933e115 .prepare(`
2ec6868116 INSERT INTO diffs (repo_id, number, title, description, author_id, head_commit, base_commit)
d12933e117 VALUES (?, ?, ?, ?, ?, ?, ?)
d12933e118 `)
d12933e119 .run(
d12933e120 repoRow.id,
d12933e121 nextNumber,
d12933e122 title,
d12933e123 description ?? "",
d12933e124 user.id,
2ec6868125 head_commit,
2ec6868126 base_commit ?? null
d12933e127 );
d12933e128
d12933e129 const diff = db
d12933e130 .prepare("SELECT * FROM diffs WHERE id = ?")
d12933e131 .get(result.lastInsertRowid);
d12933e132
d12933e133 return reply.code(201).send({ diff });
d12933e134 },
d12933e135 }
d12933e136 );
d12933e137
d12933e138 // Get single diff
d12933e139 app.get<{ Params: { owner: string; repo: string; number: string } }>(
d12933e140 "/:owner/:repo/diffs/:number",
d12933e141 async (request, reply) => {
d12933e142 const { owner, repo, number } = request.params;
d12933e143 const db = (app as any).db;
d12933e144
d12933e145 const diff = db
d12933e146 .prepare(`
d12933e147 SELECT d.*, u.username as author_name, u.display_name as author_display_name
d12933e148 FROM diffs d
d12933e149 JOIN users u ON d.author_id = u.id
55e9501150 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
55e9501151 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
d12933e152 `)
d12933e153 .get(owner, repo, parseInt(number)) as any;
d12933e154
d12933e155 if (!diff) {
d12933e156 return reply.code(404).send({ error: "Diff not found" });
d12933e157 }
d12933e158
d12933e159 // Get comments
d12933e160 const comments = db
d12933e161 .prepare(`
d12933e162 SELECT c.*, u.username as author_name, u.display_name as author_display_name
d12933e163 FROM comments c
d12933e164 JOIN users u ON c.author_id = u.id
d12933e165 WHERE c.diff_id = ?
d12933e166 ORDER BY c.created_at ASC
d12933e167 `)
d12933e168 .all(diff.id);
d12933e169
d12933e170 // Get reviews
d12933e171 const reviews = db
d12933e172 .prepare(`
d12933e173 SELECT rv.*, u.username as reviewer_name, u.display_name as reviewer_display_name
d12933e174 FROM reviews rv
d12933e175 JOIN users u ON rv.reviewer_id = u.id
d12933e176 WHERE rv.diff_id = ?
d12933e177 ORDER BY rv.created_at DESC
d12933e178 `)
d12933e179 .all(diff.id);
d12933e180
d12933e181 return { diff, comments, reviews };
d12933e182 }
d12933e183 );
d12933e184
d12933e185 // Update diff
d12933e186 app.patch<{ Params: { owner: string; repo: string; number: string } }>(
d12933e187 "/:owner/:repo/diffs/:number",
d12933e188 {
d12933e189 preHandler: [(app as any).authenticate],
d12933e190 handler: async (request, reply) => {
d12933e191 const parsed = updateDiffSchema.safeParse(request.body);
d12933e192 if (!parsed.success) {
d12933e193 return reply.code(400).send({ error: parsed.error.flatten() });
d12933e194 }
d12933e195
d12933e196 const { owner, repo, number } = request.params;
d12933e197 const db = (app as any).db;
d12933e198
d12933e199 const diff = db
d12933e200 .prepare(`
d12933e201 SELECT d.* FROM diffs d
55e9501202 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
55e9501203 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
d12933e204 `)
d12933e205 .get(owner, repo, parseInt(number)) as any;
d12933e206
d12933e207 if (!diff) {
d12933e208 return reply.code(404).send({ error: "Diff not found" });
d12933e209 }
d12933e210
d12933e211 const updates = parsed.data;
d12933e212 const setClauses: string[] = ["updated_at = datetime('now')"];
d12933e213 const values: any[] = [];
d12933e214
d12933e215 if (updates.title) {
d12933e216 setClauses.push("title = ?");
d12933e217 values.push(updates.title);
d12933e218 }
d12933e219 if (updates.description !== undefined) {
d12933e220 setClauses.push("description = ?");
d12933e221 values.push(updates.description);
d12933e222 }
d12933e223 if (updates.status) {
d12933e224 setClauses.push("status = ?");
d12933e225 values.push(updates.status);
d12933e226 }
2ec6868227 if (updates.head_commit) {
2ec6868228 setClauses.push("head_commit = ?");
2ec6868229 values.push(updates.head_commit);
2ec6868230 }
d12933e231
d12933e232 values.push(diff.id);
d12933e233
d12933e234 db.prepare(
d12933e235 `UPDATE diffs SET ${setClauses.join(", ")} WHERE id = ?`
d12933e236 ).run(...values);
d12933e237
d12933e238 const updated = db
d12933e239 .prepare("SELECT * FROM diffs WHERE id = ?")
d12933e240 .get(diff.id);
d12933e241
d12933e242 return { diff: updated };
d12933e243 },
d12933e244 }
d12933e245 );
d12933e246
2ec6868247 // Land a diff (pushrebase commit onto main)
d12933e248 app.post<{ Params: { owner: string; repo: string; number: string } }>(
2ec6868249 "/:owner/:repo/diffs/:number/land",
d12933e250 {
d12933e251 preHandler: [(app as any).authenticate],
d12933e252 handler: async (request, reply) => {
d12933e253 const { owner, repo, number } = request.params;
d12933e254 const user = request.user as any;
d12933e255 const db = (app as any).db;
d12933e256
d12933e257 const diff = db
d12933e258 .prepare(`
d12933e259 SELECT d.* FROM diffs d
55e9501260 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
55e9501261 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
d12933e262 `)
d12933e263 .get(owner, repo, parseInt(number)) as any;
d12933e264
d12933e265 if (!diff) {
d12933e266 return reply.code(404).send({ error: "Diff not found" });
d12933e267 }
d12933e268
d12933e269 if (diff.status !== "open") {
d12933e270 return reply
d12933e271 .code(400)
d12933e272 .send({ error: "Diff is not open" });
d12933e273 }
d12933e274
2ec6868275 // Land the commit onto main via grove_bridge (Mononoke pushrebase)
d12933e276 const bridgeUrl = process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100";
d12933e277 try {
d12933e278 const res = await fetch(`${bridgeUrl}/repos/${repo}/land`, {
d12933e279 method: "POST",
d12933e280 headers: { "Content-Type": "application/json" },
d12933e281 body: JSON.stringify({
2ec6868282 source_bookmark: diff.head_commit,
2ec6868283 target_bookmark: "main",
d12933e284 }),
d12933e285 });
d12933e286 if (!res.ok) {
d12933e287 const body = await res.json().catch(() => ({ error: "Land failed" }));
2ec6868288 return reply.code(409).send({ error: body.error || "Land failed" });
d12933e289 }
d12933e290 } catch (err: unknown) {
2ec6868291 const msg = err instanceof Error ? err.message : "Land failed";
d12933e292 return reply.code(502).send({ error: msg });
d12933e293 }
d12933e294
d12933e295 db.prepare(`
d12933e296 UPDATE diffs
2ec6868297 SET status = 'landed', landed_at = datetime('now'), landed_by = ?, updated_at = datetime('now')
d12933e298 WHERE id = ?
d12933e299 `).run(user.id, diff.id);
d12933e300
d12933e301 const updated = db
d12933e302 .prepare("SELECT * FROM diffs WHERE id = ?")
d12933e303 .get(diff.id);
d12933e304
d12933e305 return { diff: updated };
d12933e306 },
d12933e307 }
d12933e308 );
d12933e309
d12933e310 // Add comment to diff
d12933e311 app.post<{ Params: { owner: string; repo: string; number: string } }>(
d12933e312 "/:owner/:repo/diffs/:number/comments",
d12933e313 {
d12933e314 preHandler: [(app as any).authenticate],
d12933e315 handler: async (request, reply) => {
d12933e316 const parsed = createCommentSchema.safeParse(request.body);
d12933e317 if (!parsed.success) {
d12933e318 return reply.code(400).send({ error: parsed.error.flatten() });
d12933e319 }
d12933e320
d12933e321 const { owner, repo, number } = request.params;
d12933e322 const user = request.user as any;
d12933e323 const db = (app as any).db;
d12933e324
d12933e325 const diff = db
d12933e326 .prepare(`
d12933e327 SELECT d.id FROM diffs d
55e9501328 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
55e9501329 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
d12933e330 `)
d12933e331 .get(owner, repo, parseInt(number)) as any;
d12933e332
d12933e333 if (!diff) {
d12933e334 return reply.code(404).send({ error: "Diff not found" });
d12933e335 }
d12933e336
d12933e337 const { body, file_path, line_number, side, commit_sha, parent_id } =
d12933e338 parsed.data;
d12933e339
d12933e340 const result = db
d12933e341 .prepare(`
d12933e342 INSERT INTO comments (diff_id, author_id, body, file_path, line_number, side, commit_sha, parent_id)
d12933e343 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
d12933e344 `)
d12933e345 .run(diff.id, user.id, body, file_path, line_number, side, commit_sha, parent_id);
d12933e346
d12933e347 const comment = db
d12933e348 .prepare(`
d12933e349 SELECT c.*, u.username as author_name
d12933e350 FROM comments c JOIN users u ON c.author_id = u.id
d12933e351 WHERE c.id = ?
d12933e352 `)
d12933e353 .get(result.lastInsertRowid);
d12933e354
d12933e355 return reply.code(201).send({ comment });
d12933e356 },
d12933e357 }
d12933e358 );
d12933e359
d12933e360 // Submit review
d12933e361 app.post<{ Params: { owner: string; repo: string; number: string } }>(
d12933e362 "/:owner/:repo/diffs/:number/reviews",
d12933e363 {
d12933e364 preHandler: [(app as any).authenticate],
d12933e365 handler: async (request, reply) => {
d12933e366 const parsed = createReviewSchema.safeParse(request.body);
d12933e367 if (!parsed.success) {
d12933e368 return reply.code(400).send({ error: parsed.error.flatten() });
d12933e369 }
d12933e370
d12933e371 const { owner, repo, number } = request.params;
d12933e372 const user = request.user as any;
d12933e373 const db = (app as any).db;
d12933e374
d12933e375 const diff = db
d12933e376 .prepare(`
d12933e377 SELECT d.id FROM diffs d
55e9501378 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
55e9501379 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
d12933e380 `)
d12933e381 .get(owner, repo, parseInt(number)) as any;
d12933e382
d12933e383 if (!diff) {
d12933e384 return reply.code(404).send({ error: "Diff not found" });
d12933e385 }
d12933e386
d12933e387 const { status, body } = parsed.data;
d12933e388
d12933e389 const result = db
d12933e390 .prepare(`
d12933e391 INSERT INTO reviews (diff_id, reviewer_id, status, body)
d12933e392 VALUES (?, ?, ?, ?)
d12933e393 `)
d12933e394 .run(diff.id, user.id, status, body);
d12933e395
d12933e396 const review = db
d12933e397 .prepare(`
d12933e398 SELECT rv.*, u.username as reviewer_name
d12933e399 FROM reviews rv JOIN users u ON rv.reviewer_id = u.id
d12933e400 WHERE rv.id = ?
d12933e401 `)
d12933e402 .get(result.lastInsertRowid);
d12933e403
d12933e404 return reply.code(201).send({ review });
d12933e405 },
d12933e406 }
d12933e407 );
d12933e408}