12.4 KB409 lines
Blame
1import type { FastifyInstance } from "fastify";
2import { z } from "zod";
3
4const createDiffSchema = z.object({
5 title: z.string().min(1).max(256),
6 description: z.string().optional(),
7 head_commit: z.string().min(1),
8 base_commit: z.string().optional(),
9});
10
11const updateDiffSchema = z.object({
12 title: z.string().min(1).max(256).optional(),
13 description: z.string().optional(),
14 status: z.enum(["open", "closed"]).optional(),
15 head_commit: z.string().min(1).optional(),
16});
17
18const createCommentSchema = z.object({
19 body: z.string().min(1),
20 file_path: z.string().optional(),
21 line_number: z.number().int().positive().optional(),
22 side: z.enum(["left", "right"]).optional(),
23 commit_sha: z.string().optional(),
24 parent_id: z.number().int().positive().optional(),
25});
26
27const createReviewSchema = z.object({
28 status: z.enum(["approved", "changes_requested"]),
29 body: z.string().optional(),
30});
31
32export async function diffRoutes(app: FastifyInstance) {
33 // List diffs for a repo
34 app.get<{
35 Params: { owner: string; repo: string };
36 Querystring: { status?: string };
37 }>(
38 "/:owner/:repo/diffs",
39 async (request, reply) => {
40 const { owner, repo } = request.params;
41 const status = request.query.status ?? "open";
42 const db = (app as any).db;
43
44 const repoRow = db
45 .prepare(`
46 SELECT id FROM repos_with_owner
47 WHERE owner_name = ? AND name = ?
48 `)
49 .get(owner, repo) as any;
50
51 if (!repoRow) {
52 return reply.code(404).send({ error: "Repository not found" });
53 }
54
55 const diffs = db
56 .prepare(`
57 SELECT d.*, u.username as author_name, u.display_name as author_display_name
58 FROM diffs d
59 JOIN users u ON d.author_id = u.id
60 WHERE d.repo_id = ? AND d.status = ?
61 ORDER BY d.updated_at DESC
62 `)
63 .all(repoRow.id, status);
64
65 return { diffs };
66 }
67 );
68
69 // Create diff
70 app.post<{ Params: { owner: string; repo: string } }>(
71 "/:owner/:repo/diffs",
72 {
73 preHandler: [(app as any).authenticate],
74 handler: async (request, reply) => {
75 const parsed = createDiffSchema.safeParse(request.body);
76 if (!parsed.success) {
77 return reply.code(400).send({ error: parsed.error.flatten() });
78 }
79
80 const { owner, repo } = request.params;
81 const user = request.user as any;
82 const db = (app as any).db;
83
84 const repoRow = db
85 .prepare(`
86 SELECT id FROM repos_with_owner
87 WHERE owner_name = ? AND name = ?
88 `)
89 .get(owner, repo) as any;
90
91 if (!repoRow) {
92 return reply.code(404).send({ error: "Repository not found" });
93 }
94
95 const { title, description, head_commit, base_commit } = parsed.data;
96
97 // Idempotent: if an open diff already exists for this commit, return it
98 const existing = db
99 .prepare(`
100 SELECT * FROM diffs
101 WHERE repo_id = ? AND head_commit = ? AND status = 'open'
102 `)
103 .get(repoRow.id, head_commit) as any;
104 if (existing) {
105 return reply.code(200).send({ diff: existing });
106 }
107
108 // Get next diff number for this repo
109 const maxNumber = db
110 .prepare("SELECT MAX(number) as max_num FROM diffs WHERE repo_id = ?")
111 .get(repoRow.id) as any;
112 const nextNumber = (maxNumber?.max_num ?? 0) + 1;
113
114 const result = db
115 .prepare(`
116 INSERT INTO diffs (repo_id, number, title, description, author_id, head_commit, base_commit)
117 VALUES (?, ?, ?, ?, ?, ?, ?)
118 `)
119 .run(
120 repoRow.id,
121 nextNumber,
122 title,
123 description ?? "",
124 user.id,
125 head_commit,
126 base_commit ?? null
127 );
128
129 const diff = db
130 .prepare("SELECT * FROM diffs WHERE id = ?")
131 .get(result.lastInsertRowid);
132
133 return reply.code(201).send({ diff });
134 },
135 }
136 );
137
138 // Get single diff
139 app.get<{ Params: { owner: string; repo: string; number: string } }>(
140 "/:owner/:repo/diffs/:number",
141 async (request, reply) => {
142 const { owner, repo, number } = request.params;
143 const db = (app as any).db;
144
145 const diff = db
146 .prepare(`
147 SELECT d.*, u.username as author_name, u.display_name as author_display_name
148 FROM diffs d
149 JOIN users u ON d.author_id = u.id
150 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
151 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
152 `)
153 .get(owner, repo, parseInt(number)) as any;
154
155 if (!diff) {
156 return reply.code(404).send({ error: "Diff not found" });
157 }
158
159 // Get comments
160 const comments = db
161 .prepare(`
162 SELECT c.*, u.username as author_name, u.display_name as author_display_name
163 FROM comments c
164 JOIN users u ON c.author_id = u.id
165 WHERE c.diff_id = ?
166 ORDER BY c.created_at ASC
167 `)
168 .all(diff.id);
169
170 // Get reviews
171 const reviews = db
172 .prepare(`
173 SELECT rv.*, u.username as reviewer_name, u.display_name as reviewer_display_name
174 FROM reviews rv
175 JOIN users u ON rv.reviewer_id = u.id
176 WHERE rv.diff_id = ?
177 ORDER BY rv.created_at DESC
178 `)
179 .all(diff.id);
180
181 return { diff, comments, reviews };
182 }
183 );
184
185 // Update diff
186 app.patch<{ Params: { owner: string; repo: string; number: string } }>(
187 "/:owner/:repo/diffs/:number",
188 {
189 preHandler: [(app as any).authenticate],
190 handler: async (request, reply) => {
191 const parsed = updateDiffSchema.safeParse(request.body);
192 if (!parsed.success) {
193 return reply.code(400).send({ error: parsed.error.flatten() });
194 }
195
196 const { owner, repo, number } = request.params;
197 const db = (app as any).db;
198
199 const diff = db
200 .prepare(`
201 SELECT d.* FROM diffs d
202 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
203 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
204 `)
205 .get(owner, repo, parseInt(number)) as any;
206
207 if (!diff) {
208 return reply.code(404).send({ error: "Diff not found" });
209 }
210
211 const updates = parsed.data;
212 const setClauses: string[] = ["updated_at = datetime('now')"];
213 const values: any[] = [];
214
215 if (updates.title) {
216 setClauses.push("title = ?");
217 values.push(updates.title);
218 }
219 if (updates.description !== undefined) {
220 setClauses.push("description = ?");
221 values.push(updates.description);
222 }
223 if (updates.status) {
224 setClauses.push("status = ?");
225 values.push(updates.status);
226 }
227 if (updates.head_commit) {
228 setClauses.push("head_commit = ?");
229 values.push(updates.head_commit);
230 }
231
232 values.push(diff.id);
233
234 db.prepare(
235 `UPDATE diffs SET ${setClauses.join(", ")} WHERE id = ?`
236 ).run(...values);
237
238 const updated = db
239 .prepare("SELECT * FROM diffs WHERE id = ?")
240 .get(diff.id);
241
242 return { diff: updated };
243 },
244 }
245 );
246
247 // Land a diff (pushrebase commit onto main)
248 app.post<{ Params: { owner: string; repo: string; number: string } }>(
249 "/:owner/:repo/diffs/:number/land",
250 {
251 preHandler: [(app as any).authenticate],
252 handler: async (request, reply) => {
253 const { owner, repo, number } = request.params;
254 const user = request.user as any;
255 const db = (app as any).db;
256
257 const diff = db
258 .prepare(`
259 SELECT d.* FROM diffs d
260 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
261 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
262 `)
263 .get(owner, repo, parseInt(number)) as any;
264
265 if (!diff) {
266 return reply.code(404).send({ error: "Diff not found" });
267 }
268
269 if (diff.status !== "open") {
270 return reply
271 .code(400)
272 .send({ error: "Diff is not open" });
273 }
274
275 // Land the commit onto main via grove_bridge (Mononoke pushrebase)
276 const bridgeUrl = process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100";
277 try {
278 const res = await fetch(`${bridgeUrl}/repos/${repo}/land`, {
279 method: "POST",
280 headers: { "Content-Type": "application/json" },
281 body: JSON.stringify({
282 source_bookmark: diff.head_commit,
283 target_bookmark: "main",
284 }),
285 });
286 if (!res.ok) {
287 const body = await res.json().catch(() => ({ error: "Land failed" }));
288 return reply.code(409).send({ error: body.error || "Land failed" });
289 }
290 } catch (err: unknown) {
291 const msg = err instanceof Error ? err.message : "Land failed";
292 return reply.code(502).send({ error: msg });
293 }
294
295 db.prepare(`
296 UPDATE diffs
297 SET status = 'landed', landed_at = datetime('now'), landed_by = ?, updated_at = datetime('now')
298 WHERE id = ?
299 `).run(user.id, diff.id);
300
301 const updated = db
302 .prepare("SELECT * FROM diffs WHERE id = ?")
303 .get(diff.id);
304
305 return { diff: updated };
306 },
307 }
308 );
309
310 // Add comment to diff
311 app.post<{ Params: { owner: string; repo: string; number: string } }>(
312 "/:owner/:repo/diffs/:number/comments",
313 {
314 preHandler: [(app as any).authenticate],
315 handler: async (request, reply) => {
316 const parsed = createCommentSchema.safeParse(request.body);
317 if (!parsed.success) {
318 return reply.code(400).send({ error: parsed.error.flatten() });
319 }
320
321 const { owner, repo, number } = request.params;
322 const user = request.user as any;
323 const db = (app as any).db;
324
325 const diff = db
326 .prepare(`
327 SELECT d.id FROM diffs d
328 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
329 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
330 `)
331 .get(owner, repo, parseInt(number)) as any;
332
333 if (!diff) {
334 return reply.code(404).send({ error: "Diff not found" });
335 }
336
337 const { body, file_path, line_number, side, commit_sha, parent_id } =
338 parsed.data;
339
340 const result = db
341 .prepare(`
342 INSERT INTO comments (diff_id, author_id, body, file_path, line_number, side, commit_sha, parent_id)
343 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
344 `)
345 .run(diff.id, user.id, body, file_path, line_number, side, commit_sha, parent_id);
346
347 const comment = db
348 .prepare(`
349 SELECT c.*, u.username as author_name
350 FROM comments c JOIN users u ON c.author_id = u.id
351 WHERE c.id = ?
352 `)
353 .get(result.lastInsertRowid);
354
355 return reply.code(201).send({ comment });
356 },
357 }
358 );
359
360 // Submit review
361 app.post<{ Params: { owner: string; repo: string; number: string } }>(
362 "/:owner/:repo/diffs/:number/reviews",
363 {
364 preHandler: [(app as any).authenticate],
365 handler: async (request, reply) => {
366 const parsed = createReviewSchema.safeParse(request.body);
367 if (!parsed.success) {
368 return reply.code(400).send({ error: parsed.error.flatten() });
369 }
370
371 const { owner, repo, number } = request.params;
372 const user = request.user as any;
373 const db = (app as any).db;
374
375 const diff = db
376 .prepare(`
377 SELECT d.id FROM diffs d
378 JOIN repos_with_owner rwo ON d.repo_id = rwo.id
379 WHERE rwo.owner_name = ? AND rwo.name = ? AND d.number = ?
380 `)
381 .get(owner, repo, parseInt(number)) as any;
382
383 if (!diff) {
384 return reply.code(404).send({ error: "Diff not found" });
385 }
386
387 const { status, body } = parsed.data;
388
389 const result = db
390 .prepare(`
391 INSERT INTO reviews (diff_id, reviewer_id, status, body)
392 VALUES (?, ?, ?, ?)
393 `)
394 .run(diff.id, user.id, status, body);
395
396 const review = db
397 .prepare(`
398 SELECT rv.*, u.username as reviewer_name
399 FROM reviews rv JOIN users u ON rv.reviewer_id = u.id
400 WHERE rv.id = ?
401 `)
402 .get(result.lastInsertRowid);
403
404 return reply.code(201).send({ review });
405 },
406 }
407 );
408}
409