31.2 KB944 lines
Blame
1import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
2import { z } from "zod";
3import { spawn, execSync } from "child_process";
4import { createWriteStream } from "fs";
5import { mkdir, rm } from "fs/promises";
6import { pipeline } from "stream/promises";
7import { BridgeService } from "../services/bridge.js";
8import type { MononokeProvisioner } from "../services/mononoke-provisioner.js";
9import { optionalAuth } from "../auth/middleware.js";
10
11const BRIDGE_URL =
12 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100";
13const DATA_DIR = process.env.GROVE_DATA_DIR ?? "/data/grove";
14const MONONOKE_CONFIG_PATH =
15 process.env.MONONOKE_CONFIG_PATH ?? "/data/grove/mononoke-config";
16
17const bridgeService = new BridgeService(BRIDGE_URL);
18
19export async function repoRoutes(app: FastifyInstance) {
20 const configuredHubApiUrl = process.env.GROVE_HUB_API_URL;
21 const HUB_API_URLS = Array.from(
22 new Set(
23 [
24 configuredHubApiUrl,
25 "http://hub-api:4000",
26 "http://grove-hub-api:4000",
27 "http://localhost:4001",
28 ].filter(Boolean)
29 )
30 ) as string[];
31
32 /**
33 * Check if a user can access a private repo.
34 * Public repos are always accessible. Private repos require the user to be:
35 * - the repo owner (for user repos), or
36 * - a member of the owning org (for org repos)
37 */
38 async function canAccessRepo(repoRow: any, userId: number | null): Promise<boolean> {
39 if (!repoRow.is_private) return true;
40 if (userId == null) return false;
41 if (repoRow.owner_type === "user") return repoRow.owner_id === userId;
42 // Org repo — check membership via hub API
43 for (const hubApiUrl of HUB_API_URLS) {
44 const controller = new AbortController();
45 const timeout = setTimeout(() => controller.abort(), 3000);
46 try {
47 const res = await fetch(`${hubApiUrl}/api/orgs/${repoRow.owner_name}`, {
48 signal: controller.signal,
49 });
50 if (!res.ok) continue;
51 const { members } = await res.json();
52 return Array.isArray(members) && members.some((m: any) => m.user_id === userId);
53 } catch {
54 // try next
55 } finally {
56 clearTimeout(timeout);
57 }
58 }
59 return false;
60 }
61
62 /** Middleware: resolve repo + enforce private access. Attaches repoRow to request. */
63 async function resolveRepo(request: any, reply: any) {
64 const { owner, repo: repoName } = request.params;
65 const db = (app as any).db;
66 const repoRow = db
67 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
68 .get(owner, repoName) as any;
69
70 if (!repoRow) {
71 return reply.code(404).send({ error: "Repository not found" });
72 }
73 const userId = (request.user as any)?.id ?? null;
74 if (!(await canAccessRepo(repoRow, userId))) {
75 // Return 404 for private repos to avoid leaking existence
76 return reply.code(404).send({ error: "Repository not found" });
77 }
78 request.repoRow = repoRow;
79 }
80
81 // List all repos
82 app.get(
83 "/",
84 { preHandler: [optionalAuth] },
85 async (request: any) => {
86 const db = (app as any).db;
87 const userId = (request.user as any)?.id ?? null;
88 const allRepos = db
89 .prepare(`SELECT * FROM repos_with_owner ORDER BY updated_at DESC`)
90 .all() as any[];
91
92 // Filter out private repos the user can't access. Org membership is
93 // resolved via canAccessRepo (hub API roundtrip per private org repo),
94 // not lazily — listing private repo names to non-members leaks
95 // existence and is a real auth bug.
96 const accessChecks = await Promise.all(
97 allRepos.map(async (r) => ({ r, ok: await canAccessRepo(r, userId) })),
98 );
99 const repos = accessChecks.filter((x) => x.ok).map((x) => x.r);
100
101 const reposWithActivity = await Promise.all(
102 repos.map(async (repo) => {
103 try {
104 const commits = await bridgeService.getCommits(
105 repo.owner_name,
106 repo.name,
107 repo.default_branch ?? "main",
108 { limit: 1 }
109 );
110 const latest = commits[0];
111 return {
112 ...repo,
113 last_commit_ts: latest?.timestamp ?? null,
114 };
115 } catch {
116 return {
117 ...repo,
118 last_commit_ts: null,
119 };
120 }
121 })
122 );
123
124 reposWithActivity.sort((a, b) => {
125 const aUpdatedTs = a.updated_at
126 ? Math.floor(new Date(a.updated_at).getTime() / 1000)
127 : 0;
128 const bUpdatedTs = b.updated_at
129 ? Math.floor(new Date(b.updated_at).getTime() / 1000)
130 : 0;
131 const aTs = a.last_commit_ts ?? aUpdatedTs;
132 const bTs = b.last_commit_ts ?? bUpdatedTs;
133 if (aTs !== bTs) return bTs - aTs;
134 return String(a.name ?? "").localeCompare(String(b.name ?? ""));
135 });
136
137 return { repos: reposWithActivity };
138 });
139
140 // Create a repo
141 const createRepoSchema = z.object({
142 name: z.string().min(1).max(100),
143 description: z.string().max(500).optional(),
144 default_branch: z.string().default("main"),
145 owner: z.string().optional(),
146 is_private: z.boolean().default(false),
147 skip_seed: z.boolean().default(false),
148 });
149
150 const updateRepoSchema = z.object({
151 description: z.string().max(500).optional(),
152 is_private: z.boolean().optional(),
153 require_diffs: z.boolean().optional(),
154 pages_enabled: z.boolean().optional(),
155 pages_domain: z
156 .string()
157 .max(253)
158 .regex(/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(\.[a-z]{2,})+$/i)
159 .nullable()
160 .optional(),
161 });
162
163 app.post(
164 "/",
165 {
166 preHandler: [(app as any).authenticate],
167 },
168 async (request: any, reply: any) => {
169 const parsed = createRepoSchema.safeParse(request.body);
170 if (!parsed.success) {
171 return reply.code(400).send({ error: parsed.error.flatten() });
172 }
173 const { name, description, default_branch, owner: ownerName, is_private, skip_seed } = parsed.data;
174 const userId = request.user.id;
175 const username = request.user.username;
176 const db = (app as any).db;
177
178 let ownerId = userId;
179 let ownerType = "user";
180
181 // If owner specified and differs from user, treat as org repo
182 if (ownerName && ownerName !== username) {
183 let orgFound = false;
184 let sawNotFound = false;
185 const errors: Array<{ hubApiUrl: string; status?: number; error?: string }> = [];
186
187 for (const hubApiUrl of HUB_API_URLS) {
188 const controller = new AbortController();
189 const timeout = setTimeout(() => controller.abort(), 3000);
190 try {
191 const res = await fetch(`${hubApiUrl}/api/orgs/${ownerName}`, {
192 signal: controller.signal,
193 });
194
195 if (res.status === 404) {
196 sawNotFound = true;
197 errors.push({ hubApiUrl, status: 404 });
198 continue;
199 }
200 if (!res.ok) {
201 errors.push({ hubApiUrl, status: res.status });
202 continue;
203 }
204
205 const { org, members } = await res.json();
206 if (!Array.isArray(members)) {
207 errors.push({ hubApiUrl, error: "Invalid org response shape" });
208 continue;
209 }
210 const isMember = members.some((m: any) => m.user_id === userId);
211 if (!isMember) {
212 return reply.code(403).send({ error: "Not a member of this organization" });
213 }
214 ownerId = org.id;
215 ownerType = "org";
216 orgFound = true;
217 // Sync org locally
218 (app as any).ensureLocalOrg({ id: org.id, name: org.name, display_name: org.display_name });
219 break;
220 } catch (err: any) {
221 errors.push({ hubApiUrl, error: err?.message ?? "Unknown error" });
222 } finally {
223 clearTimeout(timeout);
224 }
225 }
226
227 if (!orgFound) {
228 app.log.error(
229 { ownerName, hubApiUrlsTried: HUB_API_URLS, errors },
230 "Failed to validate org owner against hub API candidates"
231 );
232
233 if (sawNotFound && errors.every((entry) => entry.status === 404)) {
234 return reply.code(404).send({ error: "Organization not found" });
235 }
236
237 return reply.code(502).send({ error: "Organization service unavailable" });
238 }
239 }
240
241 const existing = db
242 .prepare(`SELECT id FROM repos WHERE owner_id = ? AND owner_type = ? AND name = ?`)
243 .get(ownerId, ownerType, name);
244
245 if (existing) {
246 return reply.code(409).send({ error: "Repository already exists" });
247 }
248
249 const result = db
250 .prepare(
251 `INSERT INTO repos (owner_id, owner_type, name, description, default_branch, is_private)
252 VALUES (?, ?, ?, ?, ?, ?)`
253 )
254 .run(ownerId, ownerType, name, description ?? null, default_branch, is_private ? 1 : 0);
255
256 // Provision Mononoke config for the new repo
257 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
258 let provisioned = false;
259 try {
260 const mononokeRepoId = provisioner.provisionRepo(name);
261 db.prepare("UPDATE repos SET mononoke_repo_id = ? WHERE id = ?")
262 .run(mononokeRepoId, result.lastInsertRowid);
263 provisioned = true;
264 } catch (err) {
265 app.log.error({ err, repoName: name }, "Failed to provision Mononoke config");
266 }
267
268 // Restart Mononoke so it picks up the new repo config
269 let restartOk = false;
270 if (provisioned) {
271 try {
272 await provisioner.restartMononoke();
273 restartOk = true;
274 } catch (err) {
275 app.log.error({ err }, "Failed to restart Mononoke after repo provisioning");
276 }
277 }
278
279 // Seed the repo with an initial commit (README.md with repo name)
280 if (restartOk && !skip_seed) {
281 try {
282 const res = await fetch(`${BRIDGE_URL}/repos/${name}/seed`, {
283 method: "POST",
284 headers: { "Content-Type": "application/json" },
285 body: JSON.stringify({ name, bookmark: default_branch }),
286 signal: AbortSignal.timeout(10000),
287 });
288 if (!res.ok) {
289 const body = await res.text();
290 app.log.warn({ status: res.status, body, repoName: name }, "Seed endpoint returned non-OK");
291 }
292 } catch (err) {
293 app.log.warn({ err, repoName: name }, "Failed to seed initial commit (non-fatal)");
294 }
295 }
296
297 const repo = db
298 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
299 .get(result.lastInsertRowid);
300
301 return reply.code(201).send({
302 repo,
303 ...(!restartOk && { warning: "Repository created but Mononoke restart failed. Push may not work until services are restarted." }),
304 });
305 }
306 );
307
308 // Delete a repo
309 app.delete<{ Params: { owner: string; repo: string } }>(
310 "/:owner/:repo",
311 {
312 preHandler: [(app as any).authenticate],
313 },
314 async (request: any, reply: any) => {
315 const { owner, repo: repoName } = request.params;
316 const userId = request.user.id;
317 const db = (app as any).db;
318
319 const repoRow = db
320 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
321 .get(owner, repoName) as any;
322
323 if (!repoRow) {
324 return reply.code(404).send({ error: "Repository not found" });
325 }
326
327 // Verify ownership
328 if (repoRow.owner_type === "user") {
329 if (repoRow.owner_id !== userId) {
330 return reply.code(403).send({ error: "Not authorized to delete this repository" });
331 }
332 } else {
333 // Org repo — verify membership via hub API
334 let authorized = false;
335 for (const hubApiUrl of HUB_API_URLS) {
336 const controller = new AbortController();
337 const timeout = setTimeout(() => controller.abort(), 3000);
338 try {
339 const res = await fetch(`${hubApiUrl}/api/orgs/${owner}`, {
340 signal: controller.signal,
341 });
342 if (!res.ok) continue;
343 const { members } = await res.json();
344 if (Array.isArray(members) && members.some((m: any) => m.user_id === userId)) {
345 authorized = true;
346 }
347 break;
348 } catch {
349 // try next
350 } finally {
351 clearTimeout(timeout);
352 }
353 }
354 if (!authorized) {
355 return reply.code(403).send({ error: "Not authorized to delete this repository" });
356 }
357 }
358
359 // Delete related data (respect FK constraints)
360 db.prepare("DELETE FROM canopy_secrets WHERE repo_id = ?").run(repoRow.id);
361 db.prepare("DELETE FROM pipeline_runs WHERE repo_id = ?").run(repoRow.id);
362 db.prepare("DELETE FROM diffs WHERE repo_id = ?").run(repoRow.id);
363 db.prepare("DELETE FROM repos WHERE id = ?").run(repoRow.id);
364
365 // Clean up pages if configured
366 if (repoRow.pages_domain) {
367 const pagesDeployer = (app as any).pagesDeployer;
368 if (pagesDeployer) {
369 pagesDeployer.undeploy(repoRow.pages_domain);
370 }
371 }
372
373 // Respond immediately — Mononoke cleanup happens in the background
374 reply.code(204).send();
375
376 // Remove Mononoke config and restart (fire-and-forget)
377 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
378 try {
379 provisioner.deprovisionRepo(repoName);
380 provisioner.restartMononoke().catch((err) => {
381 app.log.error({ err, repoName }, "Failed to restart Mononoke after repo deletion");
382 });
383 } catch (err) {
384 app.log.error({ err, repoName }, "Failed to deprovision Mononoke after repo deletion");
385 }
386 }
387 );
388
389 // Update repo settings
390 app.patch<{ Params: { owner: string; repo: string } }>(
391 "/:owner/:repo",
392 {
393 preHandler: [(app as any).authenticate],
394 },
395 async (request: any, reply: any) => {
396 const { owner, repo: repoName } = request.params;
397 const userId = request.user.id;
398 const db = (app as any).db;
399
400 const parsed = updateRepoSchema.safeParse(request.body);
401 if (!parsed.success) {
402 return reply.code(400).send({ error: parsed.error.flatten() });
403 }
404
405 const repoRow = db
406 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
407 .get(owner, repoName) as any;
408
409 if (!repoRow) {
410 return reply.code(404).send({ error: "Repository not found" });
411 }
412
413 // Verify ownership
414 if (repoRow.owner_type === "user") {
415 if (repoRow.owner_id !== userId) {
416 return reply.code(403).send({ error: "Not authorized to update this repository" });
417 }
418 } else {
419 // Org repo — verify membership via hub API
420 let authorized = false;
421 for (const hubApiUrl of HUB_API_URLS) {
422 const controller = new AbortController();
423 const timeout = setTimeout(() => controller.abort(), 3000);
424 try {
425 const res = await fetch(`${hubApiUrl}/api/orgs/${owner}`, {
426 signal: controller.signal,
427 });
428 if (!res.ok) continue;
429 const { members } = await res.json();
430 if (Array.isArray(members) && members.some((m: any) => m.user_id === userId)) {
431 authorized = true;
432 }
433 break;
434 } catch {
435 // try next
436 } finally {
437 clearTimeout(timeout);
438 }
439 }
440 if (!authorized) {
441 return reply.code(403).send({ error: "Not authorized to update this repository" });
442 }
443 }
444
445 const updates = parsed.data;
446 const setClauses: string[] = [];
447 const values: any[] = [];
448
449 if (updates.description !== undefined) {
450 setClauses.push("description = ?");
451 values.push(updates.description);
452 }
453 if (updates.is_private !== undefined) {
454 setClauses.push("is_private = ?");
455 values.push(updates.is_private ? 1 : 0);
456 }
457 if (updates.require_diffs !== undefined) {
458 setClauses.push("require_diffs = ?");
459 values.push(updates.require_diffs ? 1 : 0);
460 }
461 if (updates.pages_enabled !== undefined) {
462 setClauses.push("pages_enabled = ?");
463 values.push(updates.pages_enabled ? 1 : 0);
464 }
465 if (updates.pages_domain !== undefined) {
466 setClauses.push("pages_domain = ?");
467 values.push(updates.pages_domain);
468 }
469
470 if (setClauses.length === 0) {
471 return reply.code(400).send({ error: "No fields to update" });
472 }
473
474 setClauses.push("updated_at = datetime('now')");
475 values.push(repoRow.id);
476
477 // Undeploy old deploy path if pages disabled or domain changed
478 const pagesDeployer = (app as any).pagesDeployer;
479 const oldDeployInfo = pagesDeployer?.getDeployPath(repoRow.owner_name, repoRow.name);
480 if (pagesDeployer && oldDeployInfo) {
481 if (updates.pages_enabled === false ||
482 (updates.pages_domain !== undefined && updates.pages_domain !== repoRow.pages_domain)) {
483 pagesDeployer.undeploy(oldDeployInfo.path);
484 }
485 }
486
487 db.prepare(`UPDATE repos SET ${setClauses.join(", ")} WHERE id = ?`).run(...values);
488
489 // Trigger pages deploy if enabled
490 if (pagesDeployer && (updates.pages_enabled === true || updates.pages_domain !== undefined)) {
491 void pagesDeployer.deploy(repoRow.owner_name, repoRow.name, repoRow.default_branch ?? "main").catch(
492 (err: any) => app.log.error({ err, repo: repoRow.name }, "Initial pages deploy failed")
493 );
494 }
495
496 const updated = db
497 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
498 .get(repoRow.id);
499
500 return { repo: updated };
501 }
502 );
503
504 // Get single repo
505 app.get<{ Params: { owner: string; repo: string } }>(
506 "/:owner/:repo",
507 { preHandler: [optionalAuth, resolveRepo] },
508 async (request: any) => {
509 const { owner, repo } = request.params;
510 const repoRow = request.repoRow;
511
512 const ref = repoRow.default_branch ?? "main";
513 const readme = await bridgeService.getReadme(owner, repo, ref);
514 const branches = await bridgeService.getBranches(owner, repo);
515
516 return {
517 repo: repoRow,
518 readme,
519 branches,
520 };
521 }
522 );
523
524 // List directory tree
525 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
526 "/:owner/:repo/tree/:ref/*",
527 { preHandler: [optionalAuth, resolveRepo] },
528 async (request: any, reply: any) => {
529 const { owner, repo, ref } = request.params;
530 const path = (request.params as any)["*"] ?? "";
531
532 const entries = await bridgeService.listTree(owner, repo, ref, path);
533 if (!entries.length && path) {
534 return reply.code(404).send({ error: "Path not found" });
535 }
536
537 return {
538 path,
539 ref,
540 entries: entries.sort((a: any, b: any) => {
541 // Directories first, then files
542 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
543 return a.name.localeCompare(b.name);
544 }),
545 };
546 }
547 );
548
549 // Also handle tree root (no path)
550 app.get<{ Params: { owner: string; repo: string; ref: string } }>(
551 "/:owner/:repo/tree/:ref",
552 { preHandler: [optionalAuth, resolveRepo] },
553 async (request: any) => {
554 const { owner, repo, ref } = request.params;
555 const entries = await bridgeService.listTree(owner, repo, ref, "");
556
557 return {
558 path: "",
559 ref,
560 entries: entries.sort((a: any, b: any) => {
561 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
562 return a.name.localeCompare(b.name);
563 }),
564 };
565 }
566 );
567
568 // Get file content
569 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
570 "/:owner/:repo/blob/:ref/*",
571 { preHandler: [optionalAuth, resolveRepo] },
572 async (request: any, reply: any) => {
573 const { owner, repo, ref } = request.params;
574 const path = (request.params as any)["*"];
575
576 if (!path) {
577 return reply.code(400).send({ error: "File path required" });
578 }
579
580 const blob = await bridgeService.getBlob(owner, repo, ref, path);
581 if (!blob) {
582 return reply.code(404).send({ error: "File not found" });
583 }
584
585 return {
586 path,
587 ref,
588 content: blob.content,
589 size: blob.size,
590 };
591 }
592 );
593
594 // Get commit history
595 app.get<{
596 Params: { owner: string; repo: string; ref: string };
597 Querystring: { path?: string; limit?: string; offset?: string };
598 }>(
599 "/:owner/:repo/commits/:ref",
600 { preHandler: [optionalAuth, resolveRepo] },
601 async (request: any) => {
602 const { owner, repo, ref } = request.params;
603 const { path, limit, offset } = request.query;
604
605 const commits = await bridgeService.getCommits(owner, repo, ref, {
606 path,
607 limit: limit ? parseInt(limit) : 30,
608 offset: offset ? parseInt(offset) : 0,
609 });
610
611 return { ref, commits };
612 }
613 );
614
615 // Get blame
616 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
617 "/:owner/:repo/blame/:ref/*",
618 { preHandler: [optionalAuth, resolveRepo] },
619 async (request: any, reply: any) => {
620 const { owner, repo, ref } = request.params;
621 const path = (request.params as any)["*"];
622
623 if (!path) {
624 return reply.code(400).send({ error: "File path required" });
625 }
626
627 const blame = await bridgeService.getBlame(owner, repo, ref, path);
628 if (!blame.length) {
629 return reply.code(404).send({ error: "File not found" });
630 }
631
632 return { path, ref, blame };
633 }
634 );
635
636 // Get diff between refs
637 app.get<{
638 Params: { owner: string; repo: string };
639 Querystring: { base: string; head: string };
640 }>(
641 "/:owner/:repo/diff",
642 { preHandler: [optionalAuth, resolveRepo] },
643 async (request: any) => {
644 const { owner, repo } = request.params;
645 const { base, head } = request.query;
646
647 return await bridgeService.getDiff(owner, repo, base, head);
648 }
649 );
650
651 // List branches
652 app.get<{ Params: { owner: string; repo: string } }>(
653 "/:owner/:repo/branches",
654 { preHandler: [optionalAuth, resolveRepo] },
655 async (request: any) => {
656 const { owner, repo } = request.params;
657 const branches = await bridgeService.getBranches(owner, repo);
658 return { branches };
659 }
660 );
661
662 // Import a Git repository (SSE progress stream)
663 const importSchema = z.object({
664 url: z.string().url(),
665 });
666
667 app.post<{ Params: { owner: string; repo: string } }>(
668 "/:owner/:repo/import",
669 {
670 preHandler: [(app as any).authenticate],
671 },
672 async (request, reply) => {
673 const { owner, repo: repoName } = request.params;
674 const parsed = importSchema.safeParse(request.body);
675 if (!parsed.success) {
676 return reply.code(400).send({ error: parsed.error.flatten() });
677 }
678 const { url } = parsed.data;
679 const db = (app as any).db;
680
681 // Verify repo exists
682 const repoRow = db
683 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
684 .get(owner, repoName) as any;
685 if (!repoRow) {
686 return reply.code(404).send({ error: "Repository not found" });
687 }
688
689 // SSE stream
690 reply.raw.writeHead(200, {
691 "Content-Type": "text/event-stream",
692 "Cache-Control": "no-cache",
693 Connection: "keep-alive",
694 });
695
696 const send = (event: string, data: any) => {
697 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
698 };
699
700 try {
701 // Step 1: git clone --bare via docker (grove/mononoke image has git)
702 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
703 send("progress", { step: "clone", message: `Cloning ${url}...` });
704
705 await runDocker([
706 "run", "--rm",
707 "-v", "/data/grove:/data/grove",
708 "grove/mononoke:latest",
709 "/usr/bin/git", "clone", "--bare", url, bareRepo,
710 ], (line) => {
711 send("log", { step: "clone", line });
712 });
713
714 send("progress", { step: "clone", message: "Clone complete." });
715
716 // Step 2: gitimport into Mononoke
717 send("progress", { step: "import", message: "Importing into Mononoke..." });
718
719 await runDocker([
720 "run", "--rm",
721 "-v", "/data/grove:/data/grove",
722 "--entrypoint", "gitimport",
723 "grove/mononoke:latest",
724 "--repo-name", repoName,
725 "--config-path", MONONOKE_CONFIG_PATH,
726 "--local-configerator-path", `${DATA_DIR}/configerator`,
727 "--cache-mode", "disabled",
728 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
729 "--generate-bookmarks",
730 "--derive-hg",
731 "--git-command-path", "/usr/bin/git",
732 "--concurrency", "5",
733 bareRepo,
734 "full-repo",
735 ], (line) => {
736 send("log", { step: "import", line });
737 });
738
739 send("progress", { step: "import", message: "Import complete." });
740
741 // Step 3: Restart Mononoke services to pick up the imported data
742 send("progress", { step: "restart", message: "Restarting services..." });
743
744 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
745 await provisioner.restartMononoke();
746
747 send("progress", { step: "restart", message: "Services restarted." });
748
749 // Clean up the bare clone
750 await runDocker([
751 "run", "--rm",
752 "-v", "/data/grove:/data/grove",
753 "grove/mononoke:latest",
754 "rm", "-rf", bareRepo,
755 ], () => {});
756
757 send("done", { success: true });
758 } catch (err: any) {
759 send("error", { message: err.message ?? "Import failed" });
760 }
761
762 reply.raw.end();
763 }
764 );
765
766 // Import a Git repository from an uploaded bare repo tarball (SSE progress stream)
767 app.post<{ Params: { owner: string; repo: string } }>(
768 "/:owner/:repo/import-bundle",
769 {
770 preHandler: [(app as any).authenticate],
771 },
772 async (request, reply) => {
773 const { owner, repo: repoName } = request.params;
774 const db = (app as any).db;
775
776 // Verify repo exists
777 const repoRow = db
778 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
779 .get(owner, repoName) as any;
780 if (!repoRow) {
781 return reply.code(404).send({ error: "Repository not found" });
782 }
783
784 // Read the uploaded file
785 const file = await (request as any).file();
786 if (!file) {
787 return reply.code(400).send({ error: "No file uploaded" });
788 }
789
790 // SSE stream
791 reply.raw.writeHead(200, {
792 "Content-Type": "text/event-stream",
793 "Cache-Control": "no-cache",
794 Connection: "keep-alive",
795 });
796
797 const send = (event: string, data: any) => {
798 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
799 };
800
801 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
802 const tarPath = `${DATA_DIR}/${repoName}-bare.tar.gz`;
803
804 try {
805 // Step 1: Save uploaded tarball and extract
806 send("progress", { step: "upload", message: "Receiving bare repo..." });
807
808 await pipeline(file.file, createWriteStream(tarPath));
809
810 send("progress", { step: "upload", message: "Extracting..." });
811
812 await rm(bareRepo, { recursive: true, force: true });
813
814 await runDocker([
815 "run", "--rm",
816 "-v", "/data/grove:/data/grove",
817 "--entrypoint", "tar",
818 "grove/mononoke:latest",
819 "xzf", tarPath, "-C", `${DATA_DIR}`,
820 ], (line) => {
821 send("log", { step: "upload", line });
822 });
823
824 // The tar extracts as bare.git/ — rename to match expected path
825 await runDocker([
826 "run", "--rm",
827 "-v", "/data/grove:/data/grove",
828 "--entrypoint", "sh",
829 "grove/mononoke:latest",
830 "-c", `mv ${DATA_DIR}/bare.git ${bareRepo} 2>/dev/null; chown -R root:root ${bareRepo}`,
831 ], () => {});
832
833 send("progress", { step: "upload", message: "Extracted." });
834
835 // Step 2: gitimport into Mononoke
836 send("progress", { step: "import", message: "Importing into Mononoke..." });
837
838 await runDocker([
839 "run", "--rm",
840 "-v", "/data/grove:/data/grove",
841 "--entrypoint", "gitimport",
842 "grove/mononoke:latest",
843 "--repo-name", repoName,
844 "--config-path", MONONOKE_CONFIG_PATH,
845 "--local-configerator-path", `${DATA_DIR}/configerator`,
846 "--cache-mode", "disabled",
847 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
848 "--generate-bookmarks",
849 "--derive-hg",
850 "--git-command-path", "/usr/bin/git",
851 "--concurrency", "5",
852 bareRepo,
853 "full-repo",
854 ], (line) => {
855 send("log", { step: "import", line });
856 });
857
858 // Create Sapling-style bookmark (gitimport creates "heads/main", Sapling needs "main")
859 send("progress", { step: "import", message: "Creating bookmarks..." });
860
861 await runDocker([
862 "run", "--rm",
863 "-v", "/data/grove:/data/grove",
864 "--entrypoint", "sh",
865 "grove/mononoke:latest",
866 "-c",
867 `CSID=$(admin --config-path ${MONONOKE_CONFIG_PATH} --local-configerator-path ${DATA_DIR}/configerator --cache-mode disabled --just-knobs-config-path ${DATA_DIR}/justknobs.json bookmarks --repo-name ${repoName} list 2>/dev/null | grep 'heads/main' | awk '{print $1}') && admin --config-path ${MONONOKE_CONFIG_PATH} --local-configerator-path ${DATA_DIR}/configerator --cache-mode disabled --just-knobs-config-path ${DATA_DIR}/justknobs.json bookmarks --repo-name ${repoName} set main $CSID`,
868 ], (line) => {
869 send("log", { step: "import", line });
870 });
871
872 send("progress", { step: "import", message: "Import complete." });
873
874 // Step 3: Restart Mononoke services
875 send("progress", { step: "restart", message: "Restarting services..." });
876
877 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
878 await provisioner.restartMononoke();
879
880 send("progress", { step: "restart", message: "Services restarted." });
881
882 // Clean up
883 await runDocker([
884 "run", "--rm",
885 "-v", "/data/grove:/data/grove",
886 "--entrypoint", "rm",
887 "grove/mononoke:latest",
888 "-rf", bareRepo, tarPath,
889 ], () => {});
890
891 send("done", { success: true });
892 } catch (err: any) {
893 // Clean up on error
894 await runDocker([
895 "run", "--rm",
896 "-v", "/data/grove:/data/grove",
897 "--entrypoint", "rm",
898 "grove/mononoke:latest",
899 "-rf", bareRepo, tarPath,
900 ], () => {}).catch(() => {});
901
902 send("error", { message: err.message ?? "Import failed" });
903 }
904
905 reply.raw.end();
906 }
907 );
908}
909
910/**
911 * Run a docker command, streaming stdout/stderr line-by-line to a callback.
912 * Rejects on non-zero exit code.
913 */
914function runDocker(
915 args: string[],
916 onLine: (line: string) => void
917): Promise<void> {
918 return new Promise((resolve, reject) => {
919 const proc = spawn("docker", args);
920 let stderr = "";
921
922 const handleData = (data: Buffer) => {
923 const text = data.toString();
924 for (const line of text.split("\n")) {
925 const trimmed = line.trimEnd();
926 if (trimmed) onLine(trimmed);
927 }
928 };
929
930 proc.stdout.on("data", handleData);
931 proc.stderr.on("data", (data: Buffer) => {
932 stderr += data.toString();
933 handleData(data);
934 });
935
936 proc.on("close", (code) => {
937 if (code === 0) resolve();
938 else reject(new Error(stderr.trim() || `docker exited with code ${code}`));
939 });
940
941 proc.on("error", reject);
942 });
943}
944