341 lines · 11.1 KB
1 /**
2 * REST API: Merge Request endpoints.
3 */
4
5 import { authenticate, requireAuth } from '../../middleware/auth.js';
6 import { RepoService } from '../../services/repo-service.js';
7 import { ObjectStore } from '../../services/object-store.js';
8 import { RefStore } from '../../services/ref-store.js';
9 import { BrowseService } from '../../services/browse-service.js';
10 import { DiffService } from '../../services/diff-service.js';
11 import { ValidationError, NotFoundError, ConflictError } from '@gitfastr/shared/utils/errors.js';
12 import { ActivityService } from '../../services/activity-service.js';
13 import { WebhookService } from '../../services/webhook-service.js';
14 import { parseCommit, serializeGitObject } from '@gitfastr/shared';
15 import { gitObjectHash } from '@gitfastr/shared/git/sha1.js';
16 import type { HandlerContext } from '../../router.js';
17 import type { DbMergeRequest } from '@gitfastr/shared';
18
19 const encoder = new TextEncoder();
20
21 /** Helper: resolve repo + check access. */
22 async function getRepoContext(request: Request, hctx: HandlerContext) {
23 const { owner, repo: repoName } = hctx.params;
24 const repoService = new RepoService(hctx.env.DB);
25 const { repo } = await repoService.getByOwnerAndName(owner, repoName);
26 return { repo, repoService, owner, repoName };
27 }
28
29 /** POST /api/repos/:owner/:repo/merge-requests — Create MR */
30 export async function handleApiCreateMR(
31 request: Request,
32 hctx: HandlerContext
33 ): Promise<Response> {
34 const userId = await requireAuth(request, hctx.env.DB);
35 const { repo } = await getRepoContext(request, hctx);
36
37 const body = await request.json<{
38 title: string;
39 description?: string;
40 source_branch: string;
41 target_branch: string;
42 }>();
43
44 if (!body.title || !body.source_branch || !body.target_branch) {
45 throw new ValidationError('title, source_branch, and target_branch are required');
46 }
47
48 if (body.source_branch === body.target_branch) {
49 throw new ValidationError('Source and target branches must be different');
50 }
51
52 // Verify branches exist
53 const refStore = new RefStore(hctx.env.DB, repo.id);
54 const sourceRef = await refStore.get(`refs/heads/${body.source_branch}`);
55 const targetRef = await refStore.get(`refs/heads/${body.target_branch}`);
56
57 if (!sourceRef) throw new NotFoundError(`Branch not found: ${body.source_branch}`);
58 if (!targetRef) throw new NotFoundError(`Branch not found: ${body.target_branch}`);
59
60 // Get next MR number
61 const maxNum = await hctx.env.DB
62 .prepare('SELECT COALESCE(MAX(number), 0) as max_num FROM merge_requests WHERE repo_id = ?')
63 .bind(repo.id)
64 .first<{ max_num: number }>();
65 const number = (maxNum?.max_num ?? 0) + 1;
66
67 const result = await hctx.env.DB.prepare(
68 `INSERT INTO merge_requests (repo_id, number, title, description, author_id, source_branch, target_branch, source_sha)
69 VALUES (?, ?, ?, ?, ?, ?, ?, ?)
70 RETURNING *`
71 )
72 .bind(
73 repo.id, number, body.title, body.description ?? null,
74 userId, body.source_branch, body.target_branch, sourceRef.sha
75 )
76 .first<DbMergeRequest>();
77
78 // Log activity
79 const activityService = new ActivityService(hctx.env.DB);
80 hctx.ctx.waitUntil(
81 activityService.log(repo.id, userId, 'create_mr', { mr_number: number, mr_title: body.title })
82 );
83
84 return Response.json(result, { status: 201 });
85 }
86
87 /** GET /api/repos/:owner/:repo/merge-requests — List MRs */
88 export async function handleApiListMRs(
89 request: Request,
90 hctx: HandlerContext
91 ): Promise<Response> {
92 const { repo } = await getRepoContext(request, hctx);
93
94 const url = new URL(request.url);
95 const state = url.searchParams.get('state') || 'open';
96
97 const result = await hctx.env.DB.prepare(
98 `SELECT mr.*, u.username as author_username
99 FROM merge_requests mr
100 JOIN users u ON mr.author_id = u.id
101 WHERE mr.repo_id = ? AND mr.state = ?
102 ORDER BY mr.created_at DESC
103 LIMIT 50`
104 )
105 .bind(repo.id, state)
106 .all();
107
108 return Response.json(result.results);
109 }
110
111 /** GET /api/repos/:owner/:repo/merge-requests/:number — MR detail with diff */
112 export async function handleApiGetMR(
113 request: Request,
114 hctx: HandlerContext
115 ): Promise<Response> {
116 const { repo, owner, repoName } = await getRepoContext(request, hctx);
117 const mrNumber = parseInt(hctx.params.number);
118
119 const mr = await hctx.env.DB.prepare(
120 `SELECT mr.*, u.username as author_username
121 FROM merge_requests mr
122 JOIN users u ON mr.author_id = u.id
123 WHERE mr.repo_id = ? AND mr.number = ?`
124 )
125 .bind(repo.id, mrNumber)
126 .first();
127
128 if (!mr) throw new NotFoundError(`Merge request #${mrNumber} not found`);
129
130 // Get current branch tips
131 const refStore = new RefStore(hctx.env.DB, repo.id);
132 const sourceRef = await refStore.get(`refs/heads/${(mr as any).source_branch}`);
133 const targetRef = await refStore.get(`refs/heads/${(mr as any).target_branch}`);
134
135 // Compute diff
136 let diff: any[] = [];
137 if (sourceRef && targetRef) {
138 const objectStore = new ObjectStore(hctx.env.OBJECTS, owner, repoName);
139 const diffService = new DiffService(objectStore);
140 diff = await diffService.diffCommits(targetRef.sha, sourceRef.sha);
141 }
142
143 // Get comments
144 const comments = await hctx.env.DB.prepare(
145 `SELECT c.*, u.username as author_username
146 FROM mr_comments c
147 JOIN users u ON c.author_id = u.id
148 WHERE c.mr_id = ?
149 ORDER BY c.created_at ASC`
150 )
151 .bind((mr as any).id)
152 .all();
153
154 return Response.json({
155 ...mr,
156 source_sha: sourceRef?.sha,
157 target_sha: targetRef?.sha,
158 diff,
159 comments: comments.results,
160 });
161 }
162
163 /** POST /api/repos/:owner/:repo/merge-requests/:number/comments — Add comment */
164 export async function handleApiAddMRComment(
165 request: Request,
166 hctx: HandlerContext
167 ): Promise<Response> {
168 const userId = await requireAuth(request, hctx.env.DB);
169 const { repo } = await getRepoContext(request, hctx);
170 const mrNumber = parseInt(hctx.params.number);
171
172 const mr = await hctx.env.DB.prepare(
173 'SELECT id FROM merge_requests WHERE repo_id = ? AND number = ?'
174 )
175 .bind(repo.id, mrNumber)
176 .first<{ id: string }>();
177
178 if (!mr) throw new NotFoundError(`Merge request #${mrNumber} not found`);
179
180 const body = await request.json<{
181 body: string;
182 file_path?: string;
183 line_number?: number;
184 diff_side?: 'old' | 'new';
185 }>();
186
187 if (!body.body) throw new ValidationError('Comment body is required');
188
189 const result = await hctx.env.DB.prepare(
190 `INSERT INTO mr_comments (mr_id, author_id, body, file_path, line_number, diff_side)
191 VALUES (?, ?, ?, ?, ?, ?)
192 RETURNING *`
193 )
194 .bind(
195 mr.id, userId, body.body,
196 body.file_path ?? null, body.line_number ?? null, body.diff_side ?? null
197 )
198 .first();
199
200 return Response.json(result, { status: 201 });
201 }
202
203 /** POST /api/repos/:owner/:repo/merge-requests/:number/merge — Merge */
204 export async function handleApiMergeMR(
205 request: Request,
206 hctx: HandlerContext
207 ): Promise<Response> {
208 const userId = await requireAuth(request, hctx.env.DB);
209 const { repo, repoService, owner, repoName } = await getRepoContext(request, hctx);
210
211 // Check write access
212 const hasAccess = await repoService.hasWriteAccess(repo.id, userId);
213 if (!hasAccess) {
214 throw new ValidationError('You do not have write access to this repository');
215 }
216
217 const mrNumber = parseInt(hctx.params.number);
218
219 const mr = await hctx.env.DB.prepare(
220 'SELECT * FROM merge_requests WHERE repo_id = ? AND number = ?'
221 )
222 .bind(repo.id, mrNumber)
223 .first<DbMergeRequest>();
224
225 if (!mr) throw new NotFoundError(`Merge request #${mrNumber} not found`);
226 if (mr.state !== 'open') throw new ConflictError('Merge request is not open');
227
228 const refStore = new RefStore(hctx.env.DB, repo.id);
229 const sourceRef = await refStore.get(`refs/heads/${mr.source_branch}`);
230 const targetRef = await refStore.get(`refs/heads/${mr.target_branch}`);
231
232 if (!sourceRef) throw new NotFoundError(`Source branch not found: ${mr.source_branch}`);
233 if (!targetRef) throw new NotFoundError(`Target branch not found: ${mr.target_branch}`);
234
235 const objectStore = new ObjectStore(hctx.env.OBJECTS, owner, repoName);
236 const browseService = new BrowseService(objectStore, refStore);
237
238 // Check if target is an ancestor of source (fast-forward possible)
239 const canFF = await isAncestor(objectStore, targetRef.sha, sourceRef.sha);
240
241 let mergedSha: string;
242
243 if (canFF) {
244 // Fast-forward: just update the target ref to source tip
245 mergedSha = sourceRef.sha;
246 } else {
247 // Create a merge commit
248 const user = await hctx.env.DB
249 .prepare('SELECT username, email FROM users WHERE id = ?')
250 .bind(userId)
251 .first<{ username: string; email: string }>();
252
253 const timestamp = Math.floor(Date.now() / 1000);
254 const authorLine = `${user?.username || 'unknown'} <${user?.email || 'unknown'}> ${timestamp} +0000`;
255
256 const sourceCommit = await browseService.getCommit(sourceRef.sha);
257 const commitContent = encoder.encode(
258 `tree ${sourceCommit.tree}\n` +
259 `parent ${targetRef.sha}\n` +
260 `parent ${sourceRef.sha}\n` +
261 `author ${authorLine}\n` +
262 `committer ${authorLine}\n` +
263 `\n` +
264 `Merge branch '${mr.source_branch}' into ${mr.target_branch}\n`
265 );
266
267 mergedSha = await gitObjectHash('commit', commitContent);
268 await objectStore.put(mergedSha, 'commit', commitContent);
269 }
270
271 // Update target branch ref
272 await refStore.applyUpdates([{
273 oldSha: targetRef.sha,
274 newSha: mergedSha,
275 refName: `refs/heads/${mr.target_branch}`,
276 }]);
277
278 // Update MR state
279 await hctx.env.DB.prepare(
280 `UPDATE merge_requests SET state = 'merged', merged_sha = ?, merged_by = ?, updated_at = datetime('now')
281 WHERE id = ?`
282 )
283 .bind(mergedSha, userId, mr.id)
284 .run();
285
286 // Log activity + fire webhooks
287 const activityService = new ActivityService(hctx.env.DB);
288 hctx.ctx.waitUntil(
289 activityService.log(repo.id, userId, 'merge_mr', {
290 mr_number: mr.number,
291 mr_title: mr.title,
292 merged_sha: mergedSha,
293 })
294 );
295
296 const webhookService = new WebhookService(hctx.env.DB);
297 const deliveries = webhookService.fireWebhooks(repo.id, 'merge_request', {
298 event: 'merge_request',
299 action: 'merged',
300 repository: { owner, name: repoName, full_name: `${owner}/${repoName}` },
301 sender: { username: owner },
302 merge_request: { number: mr.number, title: mr.title, state: 'merged' },
303 });
304 deliveries.then((promises) => promises.forEach((p) => hctx.ctx.waitUntil(p)));
305
306 return Response.json({
307 merged: true,
308 sha: mergedSha,
309 strategy: canFF ? 'fast-forward' : 'merge-commit',
310 });
311 }
312
313 /**
314 * Check if `ancestor` is an ancestor of `descendant` by walking parent chain.
315 */
316 async function isAncestor(
317 objectStore: ObjectStore,
318 ancestorSha: string,
319 descendantSha: string
320 ): Promise<boolean> {
321 const visited = new Set<string>();
322 const queue = [descendantSha];
323
324 while (queue.length > 0) {
325 const sha = queue.shift()!;
326 if (sha === ancestorSha) return true;
327 if (visited.has(sha)) continue;
328 visited.add(sha);
329
330 const obj = await objectStore.get(sha);
331 if (!obj || obj.type !== 'commit') continue;
332
333 const commit = parseCommit(obj.content);
334 for (const parent of commit.parents) {
335 queue.push(parent);
336 }
337 }
338
339 return false;
340 }
341