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
e5b523e476 // Undeploy old domain if pages disabled or domain changed
e5b523e477 const pagesDeployer = (app as any).pagesDeployer;
e5b523e478 if (pagesDeployer && repoRow.pages_domain) {
e5b523e479 if (updates.pages_enabled === false ||
e5b523e480 (updates.pages_domain !== undefined && updates.pages_domain !== repoRow.pages_domain)) {
e5b523e481 pagesDeployer.undeploy(repoRow.pages_domain);
e5b523e482 }
e5b523e483 }
e5b523e484
8d8e815485 db.prepare(`UPDATE repos SET ${setClauses.join(", ")} WHERE id = ?`).run(...values);
8d8e815486
e5b523e487 // Trigger initial pages deploy if enabled
e5b523e488 if (pagesDeployer && (updates.pages_enabled === true || updates.pages_domain !== undefined)) {
e5b523e489 void pagesDeployer.deploy(repoRow.name, repoRow.default_branch ?? "main").catch(
e5b523e490 (err: any) => app.log.error({ err, repo: repoRow.name }, "Initial pages deploy failed")
e5b523e491 );
e5b523e492 }
e5b523e493
8d8e815494 const updated = db
8d8e815495 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
8d8e815496 .get(repoRow.id);
8d8e815497
8d8e815498 return { repo: updated };
8d8e815499 }
8d8e815500 );
8d8e815501
8d8e815502 // Get single repo
8d8e815503 app.get<{ Params: { owner: string; repo: string } }>(
8d8e815504 "/:owner/:repo",
8d8e815505 { preHandler: [optionalAuth, resolveRepo] },
8d8e815506 async (request: any) => {
8d8e815507 const { owner, repo } = request.params;
8d8e815508 const repoRow = request.repoRow;
8d8e815509
3e3af55510 const ref = repoRow.default_branch ?? "main";
791afd4511 const readme = await bridgeService.getReadme(owner, repo, ref);
791afd4512 const branches = await bridgeService.getBranches(owner, repo);
3e3af55513
3e3af55514 return {
3e3af55515 repo: repoRow,
3e3af55516 readme,
3e3af55517 branches,
3e3af55518 };
3e3af55519 }
3e3af55520 );
3e3af55521
3e3af55522 // List directory tree
3e3af55523 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55524 "/:owner/:repo/tree/:ref/*",
8d8e815525 { preHandler: [optionalAuth, resolveRepo] },
8d8e815526 async (request: any, reply: any) => {
3e3af55527 const { owner, repo, ref } = request.params;
3e3af55528 const path = (request.params as any)["*"] ?? "";
3e3af55529
791afd4530 const entries = await bridgeService.listTree(owner, repo, ref, path);
3e3af55531 if (!entries.length && path) {
3e3af55532 return reply.code(404).send({ error: "Path not found" });
3e3af55533 }
3e3af55534
3e3af55535 return {
3e3af55536 path,
3e3af55537 ref,
8d8e815538 entries: entries.sort((a: any, b: any) => {
3e3af55539 // Directories first, then files
3e3af55540 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
3e3af55541 return a.name.localeCompare(b.name);
3e3af55542 }),
3e3af55543 };
3e3af55544 }
3e3af55545 );
3e3af55546
3e3af55547 // Also handle tree root (no path)
3e3af55548 app.get<{ Params: { owner: string; repo: string; ref: string } }>(
3e3af55549 "/:owner/:repo/tree/:ref",
8d8e815550 { preHandler: [optionalAuth, resolveRepo] },
8d8e815551 async (request: any) => {
3e3af55552 const { owner, repo, ref } = request.params;
791afd4553 const entries = await bridgeService.listTree(owner, repo, ref, "");
3e3af55554
3e3af55555 return {
3e3af55556 path: "",
3e3af55557 ref,
8d8e815558 entries: entries.sort((a: any, b: any) => {
3e3af55559 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
3e3af55560 return a.name.localeCompare(b.name);
3e3af55561 }),
3e3af55562 };
3e3af55563 }
3e3af55564 );
3e3af55565
3e3af55566 // Get file content
3e3af55567 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55568 "/:owner/:repo/blob/:ref/*",
8d8e815569 { preHandler: [optionalAuth, resolveRepo] },
8d8e815570 async (request: any, reply: any) => {
3e3af55571 const { owner, repo, ref } = request.params;
3e3af55572 const path = (request.params as any)["*"];
3e3af55573
3e3af55574 if (!path) {
3e3af55575 return reply.code(400).send({ error: "File path required" });
3e3af55576 }
3e3af55577
791afd4578 const blob = await bridgeService.getBlob(owner, repo, ref, path);
3e3af55579 if (!blob) {
3e3af55580 return reply.code(404).send({ error: "File not found" });
3e3af55581 }
3e3af55582
3e3af55583 return {
3e3af55584 path,
3e3af55585 ref,
3e3af55586 content: blob.content,
3e3af55587 size: blob.size,
3e3af55588 };
3e3af55589 }
3e3af55590 );
3e3af55591
3e3af55592 // Get commit history
3e3af55593 app.get<{
3e3af55594 Params: { owner: string; repo: string; ref: string };
3e3af55595 Querystring: { path?: string; limit?: string; offset?: string };
3e3af55596 }>(
3e3af55597 "/:owner/:repo/commits/:ref",
8d8e815598 { preHandler: [optionalAuth, resolveRepo] },
8d8e815599 async (request: any) => {
3e3af55600 const { owner, repo, ref } = request.params;
3e3af55601 const { path, limit, offset } = request.query;
3e3af55602
791afd4603 const commits = await bridgeService.getCommits(owner, repo, ref, {
3e3af55604 path,
3e3af55605 limit: limit ? parseInt(limit) : 30,
3e3af55606 offset: offset ? parseInt(offset) : 0,
3e3af55607 });
3e3af55608
3e3af55609 return { ref, commits };
3e3af55610 }
3e3af55611 );
3e3af55612
3e3af55613 // Get blame
3e3af55614 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55615 "/:owner/:repo/blame/:ref/*",
8d8e815616 { preHandler: [optionalAuth, resolveRepo] },
8d8e815617 async (request: any, reply: any) => {
3e3af55618 const { owner, repo, ref } = request.params;
3e3af55619 const path = (request.params as any)["*"];
3e3af55620
3e3af55621 if (!path) {
3e3af55622 return reply.code(400).send({ error: "File path required" });
3e3af55623 }
3e3af55624
791afd4625 const blame = await bridgeService.getBlame(owner, repo, ref, path);
3e3af55626 if (!blame.length) {
3e3af55627 return reply.code(404).send({ error: "File not found" });
3e3af55628 }
3e3af55629
3e3af55630 return { path, ref, blame };
3e3af55631 }
3e3af55632 );
3e3af55633
3e3af55634 // Get diff between refs
3e3af55635 app.get<{
3e3af55636 Params: { owner: string; repo: string };
3e3af55637 Querystring: { base: string; head: string };
3e3af55638 }>(
3e3af55639 "/:owner/:repo/diff",
8d8e815640 { preHandler: [optionalAuth, resolveRepo] },
8d8e815641 async (request: any) => {
3e3af55642 const { owner, repo } = request.params;
3e3af55643 const { base, head } = request.query;
3e3af55644
99f1a2e645 return await bridgeService.getDiff(owner, repo, base, head);
3e3af55646 }
3e3af55647 );
3e3af55648
3e3af55649 // List branches
3e3af55650 app.get<{ Params: { owner: string; repo: string } }>(
3e3af55651 "/:owner/:repo/branches",
8d8e815652 { preHandler: [optionalAuth, resolveRepo] },
8d8e815653 async (request: any) => {
3e3af55654 const { owner, repo } = request.params;
791afd4655 const branches = await bridgeService.getBranches(owner, repo);
3e3af55656 return { branches };
3e3af55657 }
3e3af55658 );
59a80f9659
59a80f9660 // Import a Git repository (SSE progress stream)
59a80f9661 const importSchema = z.object({
59a80f9662 url: z.string().url(),
59a80f9663 });
59a80f9664
59a80f9665 app.post<{ Params: { owner: string; repo: string } }>(
59a80f9666 "/:owner/:repo/import",
59a80f9667 {
59a80f9668 preHandler: [(app as any).authenticate],
59a80f9669 },
59a80f9670 async (request, reply) => {
59a80f9671 const { owner, repo: repoName } = request.params;
59a80f9672 const parsed = importSchema.safeParse(request.body);
59a80f9673 if (!parsed.success) {
59a80f9674 return reply.code(400).send({ error: parsed.error.flatten() });
59a80f9675 }
59a80f9676 const { url } = parsed.data;
59a80f9677 const db = (app as any).db;
59a80f9678
59a80f9679 // Verify repo exists
59a80f9680 const repoRow = db
59a80f9681 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
59a80f9682 .get(owner, repoName) as any;
59a80f9683 if (!repoRow) {
59a80f9684 return reply.code(404).send({ error: "Repository not found" });
59a80f9685 }
59a80f9686
59a80f9687 // SSE stream
59a80f9688 reply.raw.writeHead(200, {
59a80f9689 "Content-Type": "text/event-stream",
59a80f9690 "Cache-Control": "no-cache",
59a80f9691 Connection: "keep-alive",
59a80f9692 });
59a80f9693
59a80f9694 const send = (event: string, data: any) => {
59a80f9695 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
59a80f9696 };
59a80f9697
59a80f9698 try {
59a80f9699 // Step 1: git clone --bare via docker (grove/mononoke image has git)
59a80f9700 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
59a80f9701 send("progress", { step: "clone", message: `Cloning ${url}...` });
59a80f9702
59a80f9703 await runDocker([
59a80f9704 "run", "--rm",
59a80f9705 "-v", "/data/grove:/data/grove",
59a80f9706 "grove/mononoke:latest",
59a80f9707 "/usr/bin/git", "clone", "--bare", url, bareRepo,
59a80f9708 ], (line) => {
59a80f9709 send("log", { step: "clone", line });
59a80f9710 });
59a80f9711
59a80f9712 send("progress", { step: "clone", message: "Clone complete." });
59a80f9713
59a80f9714 // Step 2: gitimport into Mononoke
59a80f9715 send("progress", { step: "import", message: "Importing into Mononoke..." });
59a80f9716
59a80f9717 await runDocker([
59a80f9718 "run", "--rm",
59a80f9719 "-v", "/data/grove:/data/grove",
59a80f9720 "--entrypoint", "gitimport",
59a80f9721 "grove/mononoke:latest",
59a80f9722 "--repo-name", repoName,
59a80f9723 "--config-path", MONONOKE_CONFIG_PATH,
59a80f9724 "--local-configerator-path", `${DATA_DIR}/configerator`,
59a80f9725 "--cache-mode", "disabled",
59a80f9726 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
59a80f9727 "--generate-bookmarks",
59a80f9728 "--derive-hg",
59a80f9729 "--git-command-path", "/usr/bin/git",
59a80f9730 "--concurrency", "5",
59a80f9731 bareRepo,
59a80f9732 "full-repo",
59a80f9733 ], (line) => {
59a80f9734 send("log", { step: "import", line });
59a80f9735 });
59a80f9736
59a80f9737 send("progress", { step: "import", message: "Import complete." });
59a80f9738
59a80f9739 // Step 3: Restart Mononoke services to pick up the imported data
59a80f9740 send("progress", { step: "restart", message: "Restarting services..." });
59a80f9741
59a80f9742 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
59a80f9743 await provisioner.restartMononoke();
59a80f9744
59a80f9745 send("progress", { step: "restart", message: "Services restarted." });
59a80f9746
59a80f9747 // Clean up the bare clone
59a80f9748 await runDocker([
59a80f9749 "run", "--rm",
59a80f9750 "-v", "/data/grove:/data/grove",
59a80f9751 "grove/mononoke:latest",
59a80f9752 "rm", "-rf", bareRepo,
59a80f9753 ], () => {});
59a80f9754
59a80f9755 send("done", { success: true });
59a80f9756 } catch (err: any) {
59a80f9757 send("error", { message: err.message ?? "Import failed" });
59a80f9758 }
59a80f9759
59a80f9760 reply.raw.end();
59a80f9761 }
59a80f9762 );
90d5eb8763
90d5eb8764 // Import a Git repository from an uploaded bare repo tarball (SSE progress stream)
90d5eb8765 app.post<{ Params: { owner: string; repo: string } }>(
90d5eb8766 "/:owner/:repo/import-bundle",
90d5eb8767 {
90d5eb8768 preHandler: [(app as any).authenticate],
90d5eb8769 },
90d5eb8770 async (request, reply) => {
90d5eb8771 const { owner, repo: repoName } = request.params;
90d5eb8772 const db = (app as any).db;
90d5eb8773
90d5eb8774 // Verify repo exists
90d5eb8775 const repoRow = db
90d5eb8776 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
90d5eb8777 .get(owner, repoName) as any;
90d5eb8778 if (!repoRow) {
90d5eb8779 return reply.code(404).send({ error: "Repository not found" });
90d5eb8780 }
90d5eb8781
90d5eb8782 // Read the uploaded file
90d5eb8783 const file = await (request as any).file();
90d5eb8784 if (!file) {
90d5eb8785 return reply.code(400).send({ error: "No file uploaded" });
90d5eb8786 }
90d5eb8787
90d5eb8788 // SSE stream
90d5eb8789 reply.raw.writeHead(200, {
90d5eb8790 "Content-Type": "text/event-stream",
90d5eb8791 "Cache-Control": "no-cache",
90d5eb8792 Connection: "keep-alive",
90d5eb8793 });
90d5eb8794
90d5eb8795 const send = (event: string, data: any) => {
90d5eb8796 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
90d5eb8797 };
90d5eb8798
90d5eb8799 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
90d5eb8800 const tarPath = `${DATA_DIR}/${repoName}-bare.tar.gz`;
90d5eb8801
90d5eb8802 try {
90d5eb8803 // Step 1: Save uploaded tarball and extract
90d5eb8804 send("progress", { step: "upload", message: "Receiving bare repo..." });
90d5eb8805
90d5eb8806 await pipeline(file.file, createWriteStream(tarPath));
90d5eb8807
90d5eb8808 send("progress", { step: "upload", message: "Extracting..." });
90d5eb8809
90d5eb8810 await rm(bareRepo, { recursive: true, force: true });
90d5eb8811 await mkdir(bareRepo, { recursive: 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",
ea47cea827 "--entrypoint", "mv",
ea47cea828 "grove/mononoke:latest",
ea47cea829 `${DATA_DIR}/bare.git`, bareRepo,
ea47cea830 ], () => {}).catch(() => {
ea47cea831 // Already at the right path
ea47cea832 });
90d5eb8833
90d5eb8834 send("progress", { step: "upload", message: "Extracted." });
90d5eb8835
90d5eb8836 // Step 2: gitimport into Mononoke
90d5eb8837 send("progress", { step: "import", message: "Importing into Mononoke..." });
90d5eb8838
90d5eb8839 await runDocker([
90d5eb8840 "run", "--rm",
90d5eb8841 "-v", "/data/grove:/data/grove",
90d5eb8842 "--entrypoint", "gitimport",
90d5eb8843 "grove/mononoke:latest",
90d5eb8844 "--repo-name", repoName,
90d5eb8845 "--config-path", MONONOKE_CONFIG_PATH,
90d5eb8846 "--local-configerator-path", `${DATA_DIR}/configerator`,
90d5eb8847 "--cache-mode", "disabled",
90d5eb8848 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
90d5eb8849 "--generate-bookmarks",
90d5eb8850 "--derive-hg",
90d5eb8851 "--git-command-path", "/usr/bin/git",
90d5eb8852 "--concurrency", "5",
90d5eb8853 bareRepo,
90d5eb8854 "full-repo",
90d5eb8855 ], (line) => {
90d5eb8856 send("log", { step: "import", line });
90d5eb8857 });
90d5eb8858
90d5eb8859 send("progress", { step: "import", message: "Import complete." });
90d5eb8860
90d5eb8861 // Step 3: Restart Mononoke services
90d5eb8862 send("progress", { step: "restart", message: "Restarting services..." });
90d5eb8863
90d5eb8864 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
90d5eb8865 await provisioner.restartMononoke();
90d5eb8866
90d5eb8867 send("progress", { step: "restart", message: "Services restarted." });
90d5eb8868
90d5eb8869 // Clean up
90d5eb8870 await runDocker([
90d5eb8871 "run", "--rm",
90d5eb8872 "-v", "/data/grove:/data/grove",
ea47cea873 "--entrypoint", "rm",
90d5eb8874 "grove/mononoke:latest",
ea47cea875 "-rf", bareRepo, tarPath,
90d5eb8876 ], () => {});
90d5eb8877
90d5eb8878 send("done", { success: true });
90d5eb8879 } catch (err: any) {
90d5eb8880 // Clean up on error
90d5eb8881 await runDocker([
90d5eb8882 "run", "--rm",
90d5eb8883 "-v", "/data/grove:/data/grove",
ea47cea884 "--entrypoint", "rm",
90d5eb8885 "grove/mononoke:latest",
ea47cea886 "-rf", bareRepo, tarPath,
90d5eb8887 ], () => {}).catch(() => {});
90d5eb8888
90d5eb8889 send("error", { message: err.message ?? "Import failed" });
90d5eb8890 }
90d5eb8891
90d5eb8892 reply.raw.end();
90d5eb8893 }
90d5eb8894 );
59a80f9895}
59a80f9896
59a80f9897/**
59a80f9898 * Run a docker command, streaming stdout/stderr line-by-line to a callback.
59a80f9899 * Rejects on non-zero exit code.
59a80f9900 */
59a80f9901function runDocker(
59a80f9902 args: string[],
59a80f9903 onLine: (line: string) => void
59a80f9904): Promise<void> {
59a80f9905 return new Promise((resolve, reject) => {
59a80f9906 const proc = spawn("docker", args);
59a80f9907 let stderr = "";
59a80f9908
59a80f9909 const handleData = (data: Buffer) => {
59a80f9910 const text = data.toString();
59a80f9911 for (const line of text.split("\n")) {
59a80f9912 const trimmed = line.trimEnd();
59a80f9913 if (trimmed) onLine(trimmed);
59a80f9914 }
59a80f9915 };
59a80f9916
59a80f9917 proc.stdout.on("data", handleData);
59a80f9918 proc.stderr.on("data", (data: Buffer) => {
59a80f9919 stderr += data.toString();
59a80f9920 handleData(data);
59a80f9921 });
59a80f9922
59a80f9923 proc.on("close", (code) => {
59a80f9924 if (code === 0) resolve();
59a80f9925 else reject(new Error(stderr.trim() || `docker exited with code ${code}`));
59a80f9926 });
59a80f9927
59a80f9928 proc.on("error", reject);
59a80f9929 });
3e3af55930}