api/src/routes/repos.tsblame
View source
8d8e8151import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
f0bb1922import { z } from "zod";
90d5eb83import { spawn, execSync } from "child_process";
90d5eb84import { createWriteStream } from "fs";
90d5eb85import { mkdir, rm } from "fs/promises";
90d5eb86import { pipeline } from "stream/promises";
791afd47import { BridgeService } from "../services/bridge.js";
966d71f8import type { MononokeProvisioner } from "../services/mononoke-provisioner.js";
8d8e8159import { optionalAuth } from "../auth/middleware.js";
3e3af5510
791afd411const BRIDGE_URL =
791afd412 process.env.GROVE_BRIDGE_URL ?? "http://localhost:3100";
59a80f913const DATA_DIR = process.env.GROVE_DATA_DIR ?? "/data/grove";
59a80f914const MONONOKE_CONFIG_PATH =
59a80f915 process.env.MONONOKE_CONFIG_PATH ?? "/data/grove/mononoke-config";
3e3af5516
791afd417const bridgeService = new BridgeService(BRIDGE_URL);
3e3af5518
3e3af5519export async function repoRoutes(app: FastifyInstance) {
5e57b2720 const configuredHubApiUrl = process.env.GROVE_HUB_API_URL;
5e57b2721 const HUB_API_URLS = Array.from(
5e57b2722 new Set(
5e57b2723 [
5e57b2724 configuredHubApiUrl,
5e57b2725 "http://hub-api:4000",
5e57b2726 "http://grove-hub-api:4000",
5e57b2727 "http://localhost:4001",
5e57b2728 ].filter(Boolean)
5e57b2729 )
5e57b2730 ) as string[];
79efd4131
8d8e81532 /**
8d8e81533 * Check if a user can access a private repo.
8d8e81534 * Public repos are always accessible. Private repos require the user to be:
8d8e81535 * - the repo owner (for user repos), or
8d8e81536 * - a member of the owning org (for org repos)
8d8e81537 */
8d8e81538 async function canAccessRepo(repoRow: any, userId: number | null): Promise<boolean> {
8d8e81539 if (!repoRow.is_private) return true;
8d8e81540 if (userId == null) return false;
8d8e81541 if (repoRow.owner_type === "user") return repoRow.owner_id === userId;
8d8e81542 // Org repo — check membership via hub API
8d8e81543 for (const hubApiUrl of HUB_API_URLS) {
8d8e81544 const controller = new AbortController();
8d8e81545 const timeout = setTimeout(() => controller.abort(), 3000);
8d8e81546 try {
8d8e81547 const res = await fetch(`${hubApiUrl}/api/orgs/${repoRow.owner_name}`, {
8d8e81548 signal: controller.signal,
8d8e81549 });
8d8e81550 if (!res.ok) continue;
8d8e81551 const { members } = await res.json();
8d8e81552 return Array.isArray(members) && members.some((m: any) => m.user_id === userId);
8d8e81553 } catch {
8d8e81554 // try next
8d8e81555 } finally {
8d8e81556 clearTimeout(timeout);
8d8e81557 }
8d8e81558 }
8d8e81559 return false;
8d8e81560 }
8d8e81561
8d8e81562 /** Middleware: resolve repo + enforce private access. Attaches repoRow to request. */
8d8e81563 async function resolveRepo(request: any, reply: any) {
8d8e81564 const { owner, repo: repoName } = request.params;
8d8e81565 const db = (app as any).db;
8d8e81566 const repoRow = db
8d8e81567 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
8d8e81568 .get(owner, repoName) as any;
8d8e81569
8d8e81570 if (!repoRow) {
8d8e81571 return reply.code(404).send({ error: "Repository not found" });
8d8e81572 }
8d8e81573 const userId = (request.user as any)?.id ?? null;
8d8e81574 if (!(await canAccessRepo(repoRow, userId))) {
8d8e81575 // Return 404 for private repos to avoid leaking existence
8d8e81576 return reply.code(404).send({ error: "Repository not found" });
8d8e81577 }
8d8e81578 request.repoRow = repoRow;
8d8e81579 }
8d8e81580
3e3af5581 // List all repos
8d8e81582 app.get(
8d8e81583 "/",
8d8e81584 { preHandler: [optionalAuth] },
8d8e81585 async (request: any) => {
3e3af5586 const db = (app as any).db;
8d8e81587 const userId = (request.user as any)?.id ?? null;
8d8e81588 const allRepos = db
79efd4189 .prepare(`SELECT * FROM repos_with_owner ORDER BY updated_at DESC`)
bc2f20590 .all() as any[];
bc2f20591
8d8e81592 // Filter out private repos the user can't access
8d8e81593 const repos = allRepos.filter(
8d8e81594 (r) => !r.is_private || (userId != null && (
8d8e81595 (r.owner_type === "user" && r.owner_id === userId) ||
8d8e81596 r.owner_type === "org" // org membership checked lazily; show to any authed user for now
8d8e81597 ))
8d8e81598 );
8d8e81599
bc2f205100 const reposWithActivity = await Promise.all(
bc2f205101 repos.map(async (repo) => {
bc2f205102 try {
bc2f205103 const commits = await bridgeService.getCommits(
bc2f205104 repo.owner_name,
bc2f205105 repo.name,
bc2f205106 repo.default_branch ?? "main",
bc2f205107 { limit: 1 }
bc2f205108 );
bc2f205109 const latest = commits[0];
bc2f205110 return {
bc2f205111 ...repo,
bc2f205112 last_commit_ts: latest?.timestamp ?? null,
bc2f205113 };
bc2f205114 } catch {
bc2f205115 return {
bc2f205116 ...repo,
bc2f205117 last_commit_ts: null,
bc2f205118 };
bc2f205119 }
bc2f205120 })
bc2f205121 );
bc2f205122
bc2f205123 reposWithActivity.sort((a, b) => {
bc2f205124 const aUpdatedTs = a.updated_at
bc2f205125 ? Math.floor(new Date(a.updated_at).getTime() / 1000)
bc2f205126 : 0;
bc2f205127 const bUpdatedTs = b.updated_at
bc2f205128 ? Math.floor(new Date(b.updated_at).getTime() / 1000)
bc2f205129 : 0;
bc2f205130 const aTs = a.last_commit_ts ?? aUpdatedTs;
bc2f205131 const bTs = b.last_commit_ts ?? bUpdatedTs;
bc2f205132 if (aTs !== bTs) return bTs - aTs;
bc2f205133 return String(a.name ?? "").localeCompare(String(b.name ?? ""));
bc2f205134 });
bc2f205135
bc2f205136 return { repos: reposWithActivity };
3e3af55137 });
3e3af55138
f0bb192139 // Create a repo
f0bb192140 const createRepoSchema = z.object({
f0bb192141 name: z.string().min(1).max(100),
f0bb192142 description: z.string().max(500).optional(),
f0bb192143 default_branch: z.string().default("main"),
79efd41144 owner: z.string().optional(),
8d8e815145 is_private: z.boolean().default(false),
59129ad146 skip_seed: z.boolean().default(false),
8d8e815147 });
8d8e815148
8d8e815149 const updateRepoSchema = z.object({
8d8e815150 description: z.string().max(500).optional(),
8d8e815151 is_private: z.boolean().optional(),
8d8e815152 require_diffs: z.boolean().optional(),
e5b523e153 pages_enabled: z.boolean().optional(),
e5b523e154 pages_domain: z
e5b523e155 .string()
e5b523e156 .max(253)
e5b523e157 .regex(/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(\.[a-z]{2,})+$/i)
e5b523e158 .nullable()
e5b523e159 .optional(),
f0bb192160 });
f0bb192161
f0bb192162 app.post(
f0bb192163 "/",
f0bb192164 {
f0bb192165 preHandler: [(app as any).authenticate],
f0bb192166 },
f0bb192167 async (request: any, reply: any) => {
f0bb192168 const parsed = createRepoSchema.safeParse(request.body);
f0bb192169 if (!parsed.success) {
f0bb192170 return reply.code(400).send({ error: parsed.error.flatten() });
f0bb192171 }
59129ad172 const { name, description, default_branch, owner: ownerName, is_private, skip_seed } = parsed.data;
f0bb192173 const userId = request.user.id;
79efd41174 const username = request.user.username;
f0bb192175 const db = (app as any).db;
f0bb192176
79efd41177 let ownerId = userId;
79efd41178 let ownerType = "user";
79efd41179
79efd41180 // If owner specified and differs from user, treat as org repo
79efd41181 if (ownerName && ownerName !== username) {
5e57b27182 let orgFound = false;
5e57b27183 let sawNotFound = false;
5e57b27184 const errors: Array<{ hubApiUrl: string; status?: number; error?: string }> = [];
5e57b27185
5e57b27186 for (const hubApiUrl of HUB_API_URLS) {
5e57b27187 const controller = new AbortController();
5e57b27188 const timeout = setTimeout(() => controller.abort(), 3000);
5e57b27189 try {
5e57b27190 const res = await fetch(`${hubApiUrl}/api/orgs/${ownerName}`, {
5e57b27191 signal: controller.signal,
5e57b27192 });
5e57b27193
5e57b27194 if (res.status === 404) {
5e57b27195 sawNotFound = true;
5e57b27196 errors.push({ hubApiUrl, status: 404 });
5e57b27197 continue;
5e57b27198 }
5e57b27199 if (!res.ok) {
5e57b27200 errors.push({ hubApiUrl, status: res.status });
5e57b27201 continue;
5e57b27202 }
5e57b27203
5e57b27204 const { org, members } = await res.json();
5e57b27205 if (!Array.isArray(members)) {
5e57b27206 errors.push({ hubApiUrl, error: "Invalid org response shape" });
5e57b27207 continue;
5e57b27208 }
5e57b27209 const isMember = members.some((m: any) => m.user_id === userId);
5e57b27210 if (!isMember) {
5e57b27211 return reply.code(403).send({ error: "Not a member of this organization" });
5e57b27212 }
5e57b27213 ownerId = org.id;
5e57b27214 ownerType = "org";
5e57b27215 orgFound = true;
5e57b27216 // Sync org locally
5e57b27217 (app as any).ensureLocalOrg({ id: org.id, name: org.name, display_name: org.display_name });
5e57b27218 break;
5e57b27219 } catch (err: any) {
5e57b27220 errors.push({ hubApiUrl, error: err?.message ?? "Unknown error" });
5e57b27221 } finally {
5e57b27222 clearTimeout(timeout);
79efd41223 }
5e57b27224 }
5e57b27225
5e57b27226 if (!orgFound) {
5130d10227 app.log.error(
5e57b27228 { ownerName, hubApiUrlsTried: HUB_API_URLS, errors },
5e57b27229 "Failed to validate org owner against hub API candidates"
5130d10230 );
5e57b27231
5e57b27232 if (sawNotFound && errors.every((entry) => entry.status === 404)) {
79efd41233 return reply.code(404).send({ error: "Organization not found" });
79efd41234 }
5e57b27235
5130d10236 return reply.code(502).send({ error: "Organization service unavailable" });
79efd41237 }
79efd41238 }
79efd41239
f0bb192240 const existing = db
79efd41241 .prepare(`SELECT id FROM repos WHERE owner_id = ? AND owner_type = ? AND name = ?`)
79efd41242 .get(ownerId, ownerType, name);
f0bb192243
f0bb192244 if (existing) {
f0bb192245 return reply.code(409).send({ error: "Repository already exists" });
f0bb192246 }
f0bb192247
f0bb192248 const result = db
f0bb192249 .prepare(
8d8e815250 `INSERT INTO repos (owner_id, owner_type, name, description, default_branch, is_private)
8d8e815251 VALUES (?, ?, ?, ?, ?, ?)`
f0bb192252 )
8d8e815253 .run(ownerId, ownerType, name, description ?? null, default_branch, is_private ? 1 : 0);
f0bb192254
966d71f255 // Provision Mononoke config for the new repo
966d71f256 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
6c9fcae257 let provisioned = false;
966d71f258 try {
966d71f259 const mononokeRepoId = provisioner.provisionRepo(name);
966d71f260 db.prepare("UPDATE repos SET mononoke_repo_id = ? WHERE id = ?")
966d71f261 .run(mononokeRepoId, result.lastInsertRowid);
6c9fcae262 provisioned = true;
966d71f263 } catch (err) {
966d71f264 app.log.error({ err, repoName: name }, "Failed to provision Mononoke config");
966d71f265 }
966d71f266
6c9fcae267 // Restart Mononoke so it picks up the new repo config
6c9fcae268 let restartOk = false;
6c9fcae269 if (provisioned) {
6c9fcae270 try {
6c9fcae271 await provisioner.restartMononoke();
6c9fcae272 restartOk = true;
6c9fcae273 } catch (err) {
6c9fcae274 app.log.error({ err }, "Failed to restart Mononoke after repo provisioning");
6c9fcae275 }
6c9fcae276 }
6c9fcae277
c5a8edf278 // Seed the repo with an initial commit (README.md with repo name)
59129ad279 if (restartOk && !skip_seed) {
c5a8edf280 try {
c5a8edf281 const res = await fetch(`${BRIDGE_URL}/repos/${name}/seed`, {
c5a8edf282 method: "POST",
c5a8edf283 headers: { "Content-Type": "application/json" },
c5a8edf284 body: JSON.stringify({ name, bookmark: default_branch }),
c5a8edf285 signal: AbortSignal.timeout(10000),
c5a8edf286 });
c5a8edf287 if (!res.ok) {
c5a8edf288 const body = await res.text();
c5a8edf289 app.log.warn({ status: res.status, body, repoName: name }, "Seed endpoint returned non-OK");
c5a8edf290 }
c5a8edf291 } catch (err) {
c5a8edf292 app.log.warn({ err, repoName: name }, "Failed to seed initial commit (non-fatal)");
c5a8edf293 }
c5a8edf294 }
c5a8edf295
f0bb192296 const repo = db
79efd41297 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
f0bb192298 .get(result.lastInsertRowid);
f0bb192299
6c9fcae300 return reply.code(201).send({
6c9fcae301 repo,
6c9fcae302 ...(!restartOk && { warning: "Repository created but Mononoke restart failed. Push may not work until services are restarted." }),
6c9fcae303 });
f0bb192304 }
f0bb192305 );
f0bb192306
ab61b9d307 // Delete a repo
ab61b9d308 app.delete<{ Params: { owner: string; repo: string } }>(
ab61b9d309 "/:owner/:repo",
ab61b9d310 {
ab61b9d311 preHandler: [(app as any).authenticate],
ab61b9d312 },
ab61b9d313 async (request: any, reply: any) => {
ab61b9d314 const { owner, repo: repoName } = request.params;
ab61b9d315 const userId = request.user.id;
ab61b9d316 const db = (app as any).db;
ab61b9d317
ab61b9d318 const repoRow = db
ab61b9d319 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
ab61b9d320 .get(owner, repoName) as any;
ab61b9d321
ab61b9d322 if (!repoRow) {
ab61b9d323 return reply.code(404).send({ error: "Repository not found" });
ab61b9d324 }
ab61b9d325
ab61b9d326 // Verify ownership
ab61b9d327 if (repoRow.owner_type === "user") {
ab61b9d328 if (repoRow.owner_id !== userId) {
ab61b9d329 return reply.code(403).send({ error: "Not authorized to delete this repository" });
ab61b9d330 }
ab61b9d331 } else {
ab61b9d332 // Org repo — verify membership via hub API
ab61b9d333 let authorized = false;
ab61b9d334 for (const hubApiUrl of HUB_API_URLS) {
ab61b9d335 const controller = new AbortController();
ab61b9d336 const timeout = setTimeout(() => controller.abort(), 3000);
ab61b9d337 try {
ab61b9d338 const res = await fetch(`${hubApiUrl}/api/orgs/${owner}`, {
ab61b9d339 signal: controller.signal,
ab61b9d340 });
ab61b9d341 if (!res.ok) continue;
ab61b9d342 const { members } = await res.json();
ab61b9d343 if (Array.isArray(members) && members.some((m: any) => m.user_id === userId)) {
ab61b9d344 authorized = true;
ab61b9d345 }
ab61b9d346 break;
ab61b9d347 } catch {
ab61b9d348 // try next
ab61b9d349 } finally {
ab61b9d350 clearTimeout(timeout);
ab61b9d351 }
ab61b9d352 }
ab61b9d353 if (!authorized) {
ab61b9d354 return reply.code(403).send({ error: "Not authorized to delete this repository" });
ab61b9d355 }
ab61b9d356 }
ab61b9d357
ab61b9d358 // Delete related data (respect FK constraints)
ab61b9d359 db.prepare("DELETE FROM canopy_secrets WHERE repo_id = ?").run(repoRow.id);
ab61b9d360 db.prepare("DELETE FROM pipeline_runs WHERE repo_id = ?").run(repoRow.id);
ab61b9d361 db.prepare("DELETE FROM diffs WHERE repo_id = ?").run(repoRow.id);
ab61b9d362 db.prepare("DELETE FROM repos WHERE id = ?").run(repoRow.id);
ab61b9d363
e5b523e364 // Clean up pages if configured
e5b523e365 if (repoRow.pages_domain) {
e5b523e366 const pagesDeployer = (app as any).pagesDeployer;
e5b523e367 if (pagesDeployer) {
e5b523e368 pagesDeployer.undeploy(repoRow.pages_domain);
e5b523e369 }
e5b523e370 }
e5b523e371
8d0dc12372 // Respond immediately — Mononoke cleanup happens in the background
8d0dc12373 reply.code(204).send();
8d0dc12374
8d0dc12375 // Remove Mononoke config and restart (fire-and-forget)
ab61b9d376 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
ab61b9d377 try {
ab61b9d378 provisioner.deprovisionRepo(repoName);
8d0dc12379 provisioner.restartMononoke().catch((err) => {
8d0dc12380 app.log.error({ err, repoName }, "Failed to restart Mononoke after repo deletion");
8d0dc12381 });
ab61b9d382 } catch (err) {
8d0dc12383 app.log.error({ err, repoName }, "Failed to deprovision Mononoke after repo deletion");
ab61b9d384 }
ab61b9d385 }
ab61b9d386 );
ab61b9d387
8d8e815388 // Update repo settings
8d8e815389 app.patch<{ Params: { owner: string; repo: string } }>(
3e3af55390 "/:owner/:repo",
8d8e815391 {
8d8e815392 preHandler: [(app as any).authenticate],
8d8e815393 },
8d8e815394 async (request: any, reply: any) => {
8d8e815395 const { owner, repo: repoName } = request.params;
8d8e815396 const userId = request.user.id;
3e3af55397 const db = (app as any).db;
3e3af55398
8d8e815399 const parsed = updateRepoSchema.safeParse(request.body);
8d8e815400 if (!parsed.success) {
8d8e815401 return reply.code(400).send({ error: parsed.error.flatten() });
8d8e815402 }
8d8e815403
3e3af55404 const repoRow = db
79efd41405 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
8d8e815406 .get(owner, repoName) as any;
3e3af55407
3e3af55408 if (!repoRow) {
3e3af55409 return reply.code(404).send({ error: "Repository not found" });
3e3af55410 }
3e3af55411
8d8e815412 // Verify ownership
8d8e815413 if (repoRow.owner_type === "user") {
8d8e815414 if (repoRow.owner_id !== userId) {
8d8e815415 return reply.code(403).send({ error: "Not authorized to update this repository" });
8d8e815416 }
8d8e815417 } else {
8d8e815418 // Org repo — verify membership via hub API
8d8e815419 let authorized = false;
8d8e815420 for (const hubApiUrl of HUB_API_URLS) {
8d8e815421 const controller = new AbortController();
8d8e815422 const timeout = setTimeout(() => controller.abort(), 3000);
8d8e815423 try {
8d8e815424 const res = await fetch(`${hubApiUrl}/api/orgs/${owner}`, {
8d8e815425 signal: controller.signal,
8d8e815426 });
8d8e815427 if (!res.ok) continue;
8d8e815428 const { members } = await res.json();
8d8e815429 if (Array.isArray(members) && members.some((m: any) => m.user_id === userId)) {
8d8e815430 authorized = true;
8d8e815431 }
8d8e815432 break;
8d8e815433 } catch {
8d8e815434 // try next
8d8e815435 } finally {
8d8e815436 clearTimeout(timeout);
8d8e815437 }
8d8e815438 }
8d8e815439 if (!authorized) {
8d8e815440 return reply.code(403).send({ error: "Not authorized to update this repository" });
8d8e815441 }
8d8e815442 }
8d8e815443
8d8e815444 const updates = parsed.data;
8d8e815445 const setClauses: string[] = [];
8d8e815446 const values: any[] = [];
8d8e815447
8d8e815448 if (updates.description !== undefined) {
8d8e815449 setClauses.push("description = ?");
8d8e815450 values.push(updates.description);
8d8e815451 }
8d8e815452 if (updates.is_private !== undefined) {
8d8e815453 setClauses.push("is_private = ?");
8d8e815454 values.push(updates.is_private ? 1 : 0);
8d8e815455 }
8d8e815456 if (updates.require_diffs !== undefined) {
8d8e815457 setClauses.push("require_diffs = ?");
8d8e815458 values.push(updates.require_diffs ? 1 : 0);
8d8e815459 }
e5b523e460 if (updates.pages_enabled !== undefined) {
e5b523e461 setClauses.push("pages_enabled = ?");
e5b523e462 values.push(updates.pages_enabled ? 1 : 0);
e5b523e463 }
e5b523e464 if (updates.pages_domain !== undefined) {
e5b523e465 setClauses.push("pages_domain = ?");
e5b523e466 values.push(updates.pages_domain);
e5b523e467 }
8d8e815468
8d8e815469 if (setClauses.length === 0) {
8d8e815470 return reply.code(400).send({ error: "No fields to update" });
8d8e815471 }
8d8e815472
8d8e815473 setClauses.push("updated_at = datetime('now')");
8d8e815474 values.push(repoRow.id);
8d8e815475
ff50d03476 // Undeploy old deploy path if pages disabled or domain changed
e5b523e477 const pagesDeployer = (app as any).pagesDeployer;
b5baf6d478 const oldDeployInfo = pagesDeployer?.getDeployPath(repoRow.owner_name, repoRow.name);
ff50d03479 if (pagesDeployer && oldDeployInfo) {
e5b523e480 if (updates.pages_enabled === false ||
e5b523e481 (updates.pages_domain !== undefined && updates.pages_domain !== repoRow.pages_domain)) {
ff50d03482 pagesDeployer.undeploy(oldDeployInfo.path);
e5b523e483 }
e5b523e484 }
e5b523e485
8d8e815486 db.prepare(`UPDATE repos SET ${setClauses.join(", ")} WHERE id = ?`).run(...values);
8d8e815487
ff50d03488 // Trigger pages deploy if enabled
e5b523e489 if (pagesDeployer && (updates.pages_enabled === true || updates.pages_domain !== undefined)) {
b5baf6d490 void pagesDeployer.deploy(repoRow.owner_name, repoRow.name, repoRow.default_branch ?? "main").catch(
e5b523e491 (err: any) => app.log.error({ err, repo: repoRow.name }, "Initial pages deploy failed")
e5b523e492 );
e5b523e493 }
e5b523e494
8d8e815495 const updated = db
8d8e815496 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
8d8e815497 .get(repoRow.id);
8d8e815498
8d8e815499 return { repo: updated };
8d8e815500 }
8d8e815501 );
8d8e815502
8d8e815503 // Get single repo
8d8e815504 app.get<{ Params: { owner: string; repo: string } }>(
8d8e815505 "/:owner/:repo",
8d8e815506 { preHandler: [optionalAuth, resolveRepo] },
8d8e815507 async (request: any) => {
8d8e815508 const { owner, repo } = request.params;
8d8e815509 const repoRow = request.repoRow;
8d8e815510
3e3af55511 const ref = repoRow.default_branch ?? "main";
791afd4512 const readme = await bridgeService.getReadme(owner, repo, ref);
791afd4513 const branches = await bridgeService.getBranches(owner, repo);
3e3af55514
3e3af55515 return {
3e3af55516 repo: repoRow,
3e3af55517 readme,
3e3af55518 branches,
3e3af55519 };
3e3af55520 }
3e3af55521 );
3e3af55522
3e3af55523 // List directory tree
3e3af55524 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55525 "/:owner/:repo/tree/:ref/*",
8d8e815526 { preHandler: [optionalAuth, resolveRepo] },
8d8e815527 async (request: any, reply: any) => {
3e3af55528 const { owner, repo, ref } = request.params;
3e3af55529 const path = (request.params as any)["*"] ?? "";
3e3af55530
791afd4531 const entries = await bridgeService.listTree(owner, repo, ref, path);
3e3af55532 if (!entries.length && path) {
3e3af55533 return reply.code(404).send({ error: "Path not found" });
3e3af55534 }
3e3af55535
3e3af55536 return {
3e3af55537 path,
3e3af55538 ref,
8d8e815539 entries: entries.sort((a: any, b: any) => {
3e3af55540 // Directories first, then files
3e3af55541 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
3e3af55542 return a.name.localeCompare(b.name);
3e3af55543 }),
3e3af55544 };
3e3af55545 }
3e3af55546 );
3e3af55547
3e3af55548 // Also handle tree root (no path)
3e3af55549 app.get<{ Params: { owner: string; repo: string; ref: string } }>(
3e3af55550 "/:owner/:repo/tree/:ref",
8d8e815551 { preHandler: [optionalAuth, resolveRepo] },
8d8e815552 async (request: any) => {
3e3af55553 const { owner, repo, ref } = request.params;
791afd4554 const entries = await bridgeService.listTree(owner, repo, ref, "");
3e3af55555
3e3af55556 return {
3e3af55557 path: "",
3e3af55558 ref,
8d8e815559 entries: entries.sort((a: any, b: any) => {
3e3af55560 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
3e3af55561 return a.name.localeCompare(b.name);
3e3af55562 }),
3e3af55563 };
3e3af55564 }
3e3af55565 );
3e3af55566
3e3af55567 // Get file content
3e3af55568 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55569 "/:owner/:repo/blob/:ref/*",
8d8e815570 { preHandler: [optionalAuth, resolveRepo] },
8d8e815571 async (request: any, reply: any) => {
3e3af55572 const { owner, repo, ref } = request.params;
3e3af55573 const path = (request.params as any)["*"];
3e3af55574
3e3af55575 if (!path) {
3e3af55576 return reply.code(400).send({ error: "File path required" });
3e3af55577 }
3e3af55578
791afd4579 const blob = await bridgeService.getBlob(owner, repo, ref, path);
3e3af55580 if (!blob) {
3e3af55581 return reply.code(404).send({ error: "File not found" });
3e3af55582 }
3e3af55583
3e3af55584 return {
3e3af55585 path,
3e3af55586 ref,
3e3af55587 content: blob.content,
3e3af55588 size: blob.size,
3e3af55589 };
3e3af55590 }
3e3af55591 );
3e3af55592
3e3af55593 // Get commit history
3e3af55594 app.get<{
3e3af55595 Params: { owner: string; repo: string; ref: string };
3e3af55596 Querystring: { path?: string; limit?: string; offset?: string };
3e3af55597 }>(
3e3af55598 "/:owner/:repo/commits/:ref",
8d8e815599 { preHandler: [optionalAuth, resolveRepo] },
8d8e815600 async (request: any) => {
3e3af55601 const { owner, repo, ref } = request.params;
3e3af55602 const { path, limit, offset } = request.query;
3e3af55603
791afd4604 const commits = await bridgeService.getCommits(owner, repo, ref, {
3e3af55605 path,
3e3af55606 limit: limit ? parseInt(limit) : 30,
3e3af55607 offset: offset ? parseInt(offset) : 0,
3e3af55608 });
3e3af55609
3e3af55610 return { ref, commits };
3e3af55611 }
3e3af55612 );
3e3af55613
3e3af55614 // Get blame
3e3af55615 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55616 "/:owner/:repo/blame/:ref/*",
8d8e815617 { preHandler: [optionalAuth, resolveRepo] },
8d8e815618 async (request: any, reply: any) => {
3e3af55619 const { owner, repo, ref } = request.params;
3e3af55620 const path = (request.params as any)["*"];
3e3af55621
3e3af55622 if (!path) {
3e3af55623 return reply.code(400).send({ error: "File path required" });
3e3af55624 }
3e3af55625
791afd4626 const blame = await bridgeService.getBlame(owner, repo, ref, path);
3e3af55627 if (!blame.length) {
3e3af55628 return reply.code(404).send({ error: "File not found" });
3e3af55629 }
3e3af55630
3e3af55631 return { path, ref, blame };
3e3af55632 }
3e3af55633 );
3e3af55634
3e3af55635 // Get diff between refs
3e3af55636 app.get<{
3e3af55637 Params: { owner: string; repo: string };
3e3af55638 Querystring: { base: string; head: string };
3e3af55639 }>(
3e3af55640 "/:owner/:repo/diff",
8d8e815641 { preHandler: [optionalAuth, resolveRepo] },
8d8e815642 async (request: any) => {
3e3af55643 const { owner, repo } = request.params;
3e3af55644 const { base, head } = request.query;
3e3af55645
99f1a2e646 return await bridgeService.getDiff(owner, repo, base, head);
3e3af55647 }
3e3af55648 );
3e3af55649
3e3af55650 // List branches
3e3af55651 app.get<{ Params: { owner: string; repo: string } }>(
3e3af55652 "/:owner/:repo/branches",
8d8e815653 { preHandler: [optionalAuth, resolveRepo] },
8d8e815654 async (request: any) => {
3e3af55655 const { owner, repo } = request.params;
791afd4656 const branches = await bridgeService.getBranches(owner, repo);
3e3af55657 return { branches };
3e3af55658 }
3e3af55659 );
59a80f9660
59a80f9661 // Import a Git repository (SSE progress stream)
59a80f9662 const importSchema = z.object({
59a80f9663 url: z.string().url(),
59a80f9664 });
59a80f9665
59a80f9666 app.post<{ Params: { owner: string; repo: string } }>(
59a80f9667 "/:owner/:repo/import",
59a80f9668 {
59a80f9669 preHandler: [(app as any).authenticate],
59a80f9670 },
59a80f9671 async (request, reply) => {
59a80f9672 const { owner, repo: repoName } = request.params;
59a80f9673 const parsed = importSchema.safeParse(request.body);
59a80f9674 if (!parsed.success) {
59a80f9675 return reply.code(400).send({ error: parsed.error.flatten() });
59a80f9676 }
59a80f9677 const { url } = parsed.data;
59a80f9678 const db = (app as any).db;
59a80f9679
59a80f9680 // Verify repo exists
59a80f9681 const repoRow = db
59a80f9682 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
59a80f9683 .get(owner, repoName) as any;
59a80f9684 if (!repoRow) {
59a80f9685 return reply.code(404).send({ error: "Repository not found" });
59a80f9686 }
59a80f9687
59a80f9688 // SSE stream
59a80f9689 reply.raw.writeHead(200, {
59a80f9690 "Content-Type": "text/event-stream",
59a80f9691 "Cache-Control": "no-cache",
59a80f9692 Connection: "keep-alive",
59a80f9693 });
59a80f9694
59a80f9695 const send = (event: string, data: any) => {
59a80f9696 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
59a80f9697 };
59a80f9698
59a80f9699 try {
59a80f9700 // Step 1: git clone --bare via docker (grove/mononoke image has git)
59a80f9701 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
59a80f9702 send("progress", { step: "clone", message: `Cloning ${url}...` });
59a80f9703
59a80f9704 await runDocker([
59a80f9705 "run", "--rm",
59a80f9706 "-v", "/data/grove:/data/grove",
59a80f9707 "grove/mononoke:latest",
59a80f9708 "/usr/bin/git", "clone", "--bare", url, bareRepo,
59a80f9709 ], (line) => {
59a80f9710 send("log", { step: "clone", line });
59a80f9711 });
59a80f9712
59a80f9713 send("progress", { step: "clone", message: "Clone complete." });
59a80f9714
59a80f9715 // Step 2: gitimport into Mononoke
59a80f9716 send("progress", { step: "import", message: "Importing into Mononoke..." });
59a80f9717
59a80f9718 await runDocker([
59a80f9719 "run", "--rm",
59a80f9720 "-v", "/data/grove:/data/grove",
416062b721 "--entrypoint", "gitimport",
59a80f9722 "grove/mononoke:latest",
416062b723 "--repo-name", repoName,
416062b724 "--config-path", MONONOKE_CONFIG_PATH,
416062b725 "--local-configerator-path", `${DATA_DIR}/configerator`,
416062b726 "--cache-mode", "disabled",
416062b727 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
416062b728 "--generate-bookmarks",
416062b729 "--derive-hg",
416062b730 "--git-command-path", "/usr/bin/git",
416062b731 "--concurrency", "5",
416062b732 bareRepo,
416062b733 "full-repo",
59a80f9734 ], (line) => {
59a80f9735 send("log", { step: "import", line });
59a80f9736 });
59a80f9737
59a80f9738 send("progress", { step: "import", message: "Import complete." });
59a80f9739
59a80f9740 // Step 3: Restart Mononoke services to pick up the imported data
59a80f9741 send("progress", { step: "restart", message: "Restarting services..." });
59a80f9742
59a80f9743 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
59a80f9744 await provisioner.restartMononoke();
59a80f9745
59a80f9746 send("progress", { step: "restart", message: "Services restarted." });
59a80f9747
59a80f9748 // Clean up the bare clone
59a80f9749 await runDocker([
59a80f9750 "run", "--rm",
59a80f9751 "-v", "/data/grove:/data/grove",
59a80f9752 "grove/mononoke:latest",
59a80f9753 "rm", "-rf", bareRepo,
59a80f9754 ], () => {});
59a80f9755
59a80f9756 send("done", { success: true });
59a80f9757 } catch (err: any) {
59a80f9758 send("error", { message: err.message ?? "Import failed" });
59a80f9759 }
59a80f9760
59a80f9761 reply.raw.end();
59a80f9762 }
59a80f9763 );
90d5eb8764
90d5eb8765 // Import a Git repository from an uploaded bare repo tarball (SSE progress stream)
90d5eb8766 app.post<{ Params: { owner: string; repo: string } }>(
90d5eb8767 "/:owner/:repo/import-bundle",
90d5eb8768 {
90d5eb8769 preHandler: [(app as any).authenticate],
90d5eb8770 },
90d5eb8771 async (request, reply) => {
90d5eb8772 const { owner, repo: repoName } = request.params;
90d5eb8773 const db = (app as any).db;
90d5eb8774
90d5eb8775 // Verify repo exists
90d5eb8776 const repoRow = db
90d5eb8777 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
90d5eb8778 .get(owner, repoName) as any;
90d5eb8779 if (!repoRow) {
90d5eb8780 return reply.code(404).send({ error: "Repository not found" });
90d5eb8781 }
90d5eb8782
90d5eb8783 // Read the uploaded file
90d5eb8784 const file = await (request as any).file();
90d5eb8785 if (!file) {
90d5eb8786 return reply.code(400).send({ error: "No file uploaded" });
90d5eb8787 }
90d5eb8788
90d5eb8789 // SSE stream
90d5eb8790 reply.raw.writeHead(200, {
90d5eb8791 "Content-Type": "text/event-stream",
90d5eb8792 "Cache-Control": "no-cache",
90d5eb8793 Connection: "keep-alive",
90d5eb8794 });
90d5eb8795
90d5eb8796 const send = (event: string, data: any) => {
90d5eb8797 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
90d5eb8798 };
90d5eb8799
90d5eb8800 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
90d5eb8801 const tarPath = `${DATA_DIR}/${repoName}-bare.tar.gz`;
90d5eb8802
90d5eb8803 try {
90d5eb8804 // Step 1: Save uploaded tarball and extract
90d5eb8805 send("progress", { step: "upload", message: "Receiving bare repo..." });
90d5eb8806
90d5eb8807 await pipeline(file.file, createWriteStream(tarPath));
90d5eb8808
90d5eb8809 send("progress", { step: "upload", message: "Extracting..." });
90d5eb8810
90d5eb8811 await rm(bareRepo, { recursive: true, force: true });
90d5eb8812
90d5eb8813 await runDocker([
90d5eb8814 "run", "--rm",
90d5eb8815 "-v", "/data/grove:/data/grove",
ea47cea816 "--entrypoint", "tar",
90d5eb8817 "grove/mononoke:latest",
ea47cea818 "xzf", tarPath, "-C", `${DATA_DIR}`,
90d5eb8819 ], (line) => {
90d5eb8820 send("log", { step: "upload", line });
90d5eb8821 });
90d5eb8822
90d5eb8823 // The tar extracts as bare.git/ — rename to match expected path
ea47cea824 await runDocker([
ea47cea825 "run", "--rm",
ea47cea826 "-v", "/data/grove:/data/grove",
416062b827 "--entrypoint", "sh",
ea47cea828 "grove/mononoke:latest",
416062b829 "-c", `mv ${DATA_DIR}/bare.git ${bareRepo} 2>/dev/null; chown -R root:root ${bareRepo}`,
416062b830 ], () => {});
90d5eb8831
90d5eb8832 send("progress", { step: "upload", message: "Extracted." });
90d5eb8833
90d5eb8834 // Step 2: gitimport into Mononoke
90d5eb8835 send("progress", { step: "import", message: "Importing into Mononoke..." });
90d5eb8836
90d5eb8837 await runDocker([
90d5eb8838 "run", "--rm",
90d5eb8839 "-v", "/data/grove:/data/grove",
416062b840 "--entrypoint", "gitimport",
90d5eb8841 "grove/mononoke:latest",
416062b842 "--repo-name", repoName,
416062b843 "--config-path", MONONOKE_CONFIG_PATH,
416062b844 "--local-configerator-path", `${DATA_DIR}/configerator`,
416062b845 "--cache-mode", "disabled",
416062b846 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
416062b847 "--generate-bookmarks",
416062b848 "--derive-hg",
416062b849 "--git-command-path", "/usr/bin/git",
416062b850 "--concurrency", "5",
416062b851 bareRepo,
416062b852 "full-repo",
90d5eb8853 ], (line) => {
90d5eb8854 send("log", { step: "import", line });
90d5eb8855 });
90d5eb8856
6d52207857 // Create Sapling-style bookmark (gitimport creates "heads/main", Sapling needs "main")
6d52207858 send("progress", { step: "import", message: "Creating bookmarks..." });
6d52207859
6d52207860 await runDocker([
6d52207861 "run", "--rm",
6d52207862 "-v", "/data/grove:/data/grove",
6d52207863 "--entrypoint", "sh",
6d52207864 "grove/mononoke:latest",
6d52207865 "-c",
6d52207866 `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`,
6d52207867 ], (line) => {
6d52207868 send("log", { step: "import", line });
6d52207869 });
6d52207870
90d5eb8871 send("progress", { step: "import", message: "Import complete." });
90d5eb8872
90d5eb8873 // Step 3: Restart Mononoke services
90d5eb8874 send("progress", { step: "restart", message: "Restarting services..." });
90d5eb8875
90d5eb8876 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
90d5eb8877 await provisioner.restartMononoke();
90d5eb8878
90d5eb8879 send("progress", { step: "restart", message: "Services restarted." });
90d5eb8880
90d5eb8881 // Clean up
90d5eb8882 await runDocker([
90d5eb8883 "run", "--rm",
90d5eb8884 "-v", "/data/grove:/data/grove",
ea47cea885 "--entrypoint", "rm",
90d5eb8886 "grove/mononoke:latest",
ea47cea887 "-rf", bareRepo, tarPath,
90d5eb8888 ], () => {});
90d5eb8889
90d5eb8890 send("done", { success: true });
90d5eb8891 } catch (err: any) {
90d5eb8892 // Clean up on error
90d5eb8893 await runDocker([
90d5eb8894 "run", "--rm",
90d5eb8895 "-v", "/data/grove:/data/grove",
ea47cea896 "--entrypoint", "rm",
90d5eb8897 "grove/mononoke:latest",
ea47cea898 "-rf", bareRepo, tarPath,
90d5eb8899 ], () => {}).catch(() => {});
90d5eb8900
90d5eb8901 send("error", { message: err.message ?? "Import failed" });
90d5eb8902 }
90d5eb8903
90d5eb8904 reply.raw.end();
90d5eb8905 }
90d5eb8906 );
59a80f9907}
59a80f9908
59a80f9909/**
59a80f9910 * Run a docker command, streaming stdout/stderr line-by-line to a callback.
59a80f9911 * Rejects on non-zero exit code.
59a80f9912 */
59a80f9913function runDocker(
59a80f9914 args: string[],
59a80f9915 onLine: (line: string) => void
59a80f9916): Promise<void> {
59a80f9917 return new Promise((resolve, reject) => {
59a80f9918 const proc = spawn("docker", args);
59a80f9919 let stderr = "";
59a80f9920
59a80f9921 const handleData = (data: Buffer) => {
59a80f9922 const text = data.toString();
59a80f9923 for (const line of text.split("\n")) {
59a80f9924 const trimmed = line.trimEnd();
59a80f9925 if (trimmed) onLine(trimmed);
59a80f9926 }
59a80f9927 };
59a80f9928
59a80f9929 proc.stdout.on("data", handleData);
59a80f9930 proc.stderr.on("data", (data: Buffer) => {
59a80f9931 stderr += data.toString();
59a80f9932 handleData(data);
59a80f9933 });
59a80f9934
59a80f9935 proc.on("close", (code) => {
59a80f9936 if (code === 0) resolve();
59a80f9937 else reject(new Error(stderr.trim() || `docker exited with code ${code}`));
59a80f9938 });
59a80f9939
59a80f9940 proc.on("error", reject);
59a80f9941 });
3e3af55942}