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
bc1b2ba92 // Filter out private repos the user can't access. Org membership is
bc1b2ba93 // resolved via canAccessRepo (hub API roundtrip per private org repo),
bc1b2ba94 // not lazily — listing private repo names to non-members leaks
bc1b2ba95 // existence and is a real auth bug.
bc1b2ba96 const accessChecks = await Promise.all(
bc1b2ba97 allRepos.map(async (r) => ({ r, ok: await canAccessRepo(r, userId) })),
8d8e81598 );
bc1b2ba99 const repos = accessChecks.filter((x) => x.ok).map((x) => x.r);
8d8e815100
bc2f205101 const reposWithActivity = await Promise.all(
bc2f205102 repos.map(async (repo) => {
bc2f205103 try {
bc2f205104 const commits = await bridgeService.getCommits(
bc2f205105 repo.owner_name,
bc2f205106 repo.name,
bc2f205107 repo.default_branch ?? "main",
bc2f205108 { limit: 1 }
bc2f205109 );
bc2f205110 const latest = commits[0];
bc2f205111 return {
bc2f205112 ...repo,
bc2f205113 last_commit_ts: latest?.timestamp ?? null,
bc2f205114 };
bc2f205115 } catch {
bc2f205116 return {
bc2f205117 ...repo,
bc2f205118 last_commit_ts: null,
bc2f205119 };
bc2f205120 }
bc2f205121 })
bc2f205122 );
bc2f205123
bc2f205124 reposWithActivity.sort((a, b) => {
bc2f205125 const aUpdatedTs = a.updated_at
bc2f205126 ? Math.floor(new Date(a.updated_at).getTime() / 1000)
bc2f205127 : 0;
bc2f205128 const bUpdatedTs = b.updated_at
bc2f205129 ? Math.floor(new Date(b.updated_at).getTime() / 1000)
bc2f205130 : 0;
bc2f205131 const aTs = a.last_commit_ts ?? aUpdatedTs;
bc2f205132 const bTs = b.last_commit_ts ?? bUpdatedTs;
bc2f205133 if (aTs !== bTs) return bTs - aTs;
bc2f205134 return String(a.name ?? "").localeCompare(String(b.name ?? ""));
bc2f205135 });
bc2f205136
bc2f205137 return { repos: reposWithActivity };
3e3af55138 });
3e3af55139
f0bb192140 // Create a repo
f0bb192141 const createRepoSchema = z.object({
f0bb192142 name: z.string().min(1).max(100),
f0bb192143 description: z.string().max(500).optional(),
f0bb192144 default_branch: z.string().default("main"),
79efd41145 owner: z.string().optional(),
8d8e815146 is_private: z.boolean().default(false),
59129ad147 skip_seed: z.boolean().default(false),
8d8e815148 });
8d8e815149
8d8e815150 const updateRepoSchema = z.object({
8d8e815151 description: z.string().max(500).optional(),
8d8e815152 is_private: z.boolean().optional(),
8d8e815153 require_diffs: z.boolean().optional(),
e5b523e154 pages_enabled: z.boolean().optional(),
e5b523e155 pages_domain: z
e5b523e156 .string()
e5b523e157 .max(253)
e5b523e158 .regex(/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(\.[a-z]{2,})+$/i)
e5b523e159 .nullable()
e5b523e160 .optional(),
f0bb192161 });
f0bb192162
f0bb192163 app.post(
f0bb192164 "/",
f0bb192165 {
f0bb192166 preHandler: [(app as any).authenticate],
f0bb192167 },
f0bb192168 async (request: any, reply: any) => {
f0bb192169 const parsed = createRepoSchema.safeParse(request.body);
f0bb192170 if (!parsed.success) {
f0bb192171 return reply.code(400).send({ error: parsed.error.flatten() });
f0bb192172 }
59129ad173 const { name, description, default_branch, owner: ownerName, is_private, skip_seed } = parsed.data;
f0bb192174 const userId = request.user.id;
79efd41175 const username = request.user.username;
f0bb192176 const db = (app as any).db;
f0bb192177
79efd41178 let ownerId = userId;
79efd41179 let ownerType = "user";
79efd41180
79efd41181 // If owner specified and differs from user, treat as org repo
79efd41182 if (ownerName && ownerName !== username) {
5e57b27183 let orgFound = false;
5e57b27184 let sawNotFound = false;
5e57b27185 const errors: Array<{ hubApiUrl: string; status?: number; error?: string }> = [];
5e57b27186
5e57b27187 for (const hubApiUrl of HUB_API_URLS) {
5e57b27188 const controller = new AbortController();
5e57b27189 const timeout = setTimeout(() => controller.abort(), 3000);
5e57b27190 try {
5e57b27191 const res = await fetch(`${hubApiUrl}/api/orgs/${ownerName}`, {
5e57b27192 signal: controller.signal,
5e57b27193 });
5e57b27194
5e57b27195 if (res.status === 404) {
5e57b27196 sawNotFound = true;
5e57b27197 errors.push({ hubApiUrl, status: 404 });
5e57b27198 continue;
5e57b27199 }
5e57b27200 if (!res.ok) {
5e57b27201 errors.push({ hubApiUrl, status: res.status });
5e57b27202 continue;
5e57b27203 }
5e57b27204
5e57b27205 const { org, members } = await res.json();
5e57b27206 if (!Array.isArray(members)) {
5e57b27207 errors.push({ hubApiUrl, error: "Invalid org response shape" });
5e57b27208 continue;
5e57b27209 }
5e57b27210 const isMember = members.some((m: any) => m.user_id === userId);
5e57b27211 if (!isMember) {
5e57b27212 return reply.code(403).send({ error: "Not a member of this organization" });
5e57b27213 }
5e57b27214 ownerId = org.id;
5e57b27215 ownerType = "org";
5e57b27216 orgFound = true;
5e57b27217 // Sync org locally
5e57b27218 (app as any).ensureLocalOrg({ id: org.id, name: org.name, display_name: org.display_name });
5e57b27219 break;
5e57b27220 } catch (err: any) {
5e57b27221 errors.push({ hubApiUrl, error: err?.message ?? "Unknown error" });
5e57b27222 } finally {
5e57b27223 clearTimeout(timeout);
79efd41224 }
5e57b27225 }
5e57b27226
5e57b27227 if (!orgFound) {
5130d10228 app.log.error(
5e57b27229 { ownerName, hubApiUrlsTried: HUB_API_URLS, errors },
5e57b27230 "Failed to validate org owner against hub API candidates"
5130d10231 );
5e57b27232
5e57b27233 if (sawNotFound && errors.every((entry) => entry.status === 404)) {
79efd41234 return reply.code(404).send({ error: "Organization not found" });
79efd41235 }
5e57b27236
5130d10237 return reply.code(502).send({ error: "Organization service unavailable" });
79efd41238 }
79efd41239 }
79efd41240
f0bb192241 const existing = db
79efd41242 .prepare(`SELECT id FROM repos WHERE owner_id = ? AND owner_type = ? AND name = ?`)
79efd41243 .get(ownerId, ownerType, name);
f0bb192244
f0bb192245 if (existing) {
f0bb192246 return reply.code(409).send({ error: "Repository already exists" });
f0bb192247 }
f0bb192248
f0bb192249 const result = db
f0bb192250 .prepare(
8d8e815251 `INSERT INTO repos (owner_id, owner_type, name, description, default_branch, is_private)
8d8e815252 VALUES (?, ?, ?, ?, ?, ?)`
f0bb192253 )
8d8e815254 .run(ownerId, ownerType, name, description ?? null, default_branch, is_private ? 1 : 0);
f0bb192255
966d71f256 // Provision Mononoke config for the new repo
966d71f257 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
6c9fcae258 let provisioned = false;
966d71f259 try {
966d71f260 const mononokeRepoId = provisioner.provisionRepo(name);
966d71f261 db.prepare("UPDATE repos SET mononoke_repo_id = ? WHERE id = ?")
966d71f262 .run(mononokeRepoId, result.lastInsertRowid);
6c9fcae263 provisioned = true;
966d71f264 } catch (err) {
966d71f265 app.log.error({ err, repoName: name }, "Failed to provision Mononoke config");
966d71f266 }
966d71f267
6c9fcae268 // Restart Mononoke so it picks up the new repo config
6c9fcae269 let restartOk = false;
6c9fcae270 if (provisioned) {
6c9fcae271 try {
6c9fcae272 await provisioner.restartMononoke();
6c9fcae273 restartOk = true;
6c9fcae274 } catch (err) {
6c9fcae275 app.log.error({ err }, "Failed to restart Mononoke after repo provisioning");
6c9fcae276 }
6c9fcae277 }
6c9fcae278
c5a8edf279 // Seed the repo with an initial commit (README.md with repo name)
59129ad280 if (restartOk && !skip_seed) {
c5a8edf281 try {
c5a8edf282 const res = await fetch(`${BRIDGE_URL}/repos/${name}/seed`, {
c5a8edf283 method: "POST",
c5a8edf284 headers: { "Content-Type": "application/json" },
c5a8edf285 body: JSON.stringify({ name, bookmark: default_branch }),
c5a8edf286 signal: AbortSignal.timeout(10000),
c5a8edf287 });
c5a8edf288 if (!res.ok) {
c5a8edf289 const body = await res.text();
c5a8edf290 app.log.warn({ status: res.status, body, repoName: name }, "Seed endpoint returned non-OK");
c5a8edf291 }
c5a8edf292 } catch (err) {
c5a8edf293 app.log.warn({ err, repoName: name }, "Failed to seed initial commit (non-fatal)");
c5a8edf294 }
c5a8edf295 }
c5a8edf296
f0bb192297 const repo = db
79efd41298 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
f0bb192299 .get(result.lastInsertRowid);
f0bb192300
6c9fcae301 return reply.code(201).send({
6c9fcae302 repo,
6c9fcae303 ...(!restartOk && { warning: "Repository created but Mononoke restart failed. Push may not work until services are restarted." }),
6c9fcae304 });
f0bb192305 }
f0bb192306 );
f0bb192307
ab61b9d308 // Delete a repo
ab61b9d309 app.delete<{ Params: { owner: string; repo: string } }>(
ab61b9d310 "/:owner/:repo",
ab61b9d311 {
ab61b9d312 preHandler: [(app as any).authenticate],
ab61b9d313 },
ab61b9d314 async (request: any, reply: any) => {
ab61b9d315 const { owner, repo: repoName } = request.params;
ab61b9d316 const userId = request.user.id;
ab61b9d317 const db = (app as any).db;
ab61b9d318
ab61b9d319 const repoRow = db
ab61b9d320 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
ab61b9d321 .get(owner, repoName) as any;
ab61b9d322
ab61b9d323 if (!repoRow) {
ab61b9d324 return reply.code(404).send({ error: "Repository not found" });
ab61b9d325 }
ab61b9d326
ab61b9d327 // Verify ownership
ab61b9d328 if (repoRow.owner_type === "user") {
ab61b9d329 if (repoRow.owner_id !== userId) {
ab61b9d330 return reply.code(403).send({ error: "Not authorized to delete this repository" });
ab61b9d331 }
ab61b9d332 } else {
ab61b9d333 // Org repo — verify membership via hub API
ab61b9d334 let authorized = false;
ab61b9d335 for (const hubApiUrl of HUB_API_URLS) {
ab61b9d336 const controller = new AbortController();
ab61b9d337 const timeout = setTimeout(() => controller.abort(), 3000);
ab61b9d338 try {
ab61b9d339 const res = await fetch(`${hubApiUrl}/api/orgs/${owner}`, {
ab61b9d340 signal: controller.signal,
ab61b9d341 });
ab61b9d342 if (!res.ok) continue;
ab61b9d343 const { members } = await res.json();
ab61b9d344 if (Array.isArray(members) && members.some((m: any) => m.user_id === userId)) {
ab61b9d345 authorized = true;
ab61b9d346 }
ab61b9d347 break;
ab61b9d348 } catch {
ab61b9d349 // try next
ab61b9d350 } finally {
ab61b9d351 clearTimeout(timeout);
ab61b9d352 }
ab61b9d353 }
ab61b9d354 if (!authorized) {
ab61b9d355 return reply.code(403).send({ error: "Not authorized to delete this repository" });
ab61b9d356 }
ab61b9d357 }
ab61b9d358
ab61b9d359 // Delete related data (respect FK constraints)
ab61b9d360 db.prepare("DELETE FROM canopy_secrets WHERE repo_id = ?").run(repoRow.id);
ab61b9d361 db.prepare("DELETE FROM pipeline_runs WHERE repo_id = ?").run(repoRow.id);
ab61b9d362 db.prepare("DELETE FROM diffs WHERE repo_id = ?").run(repoRow.id);
ab61b9d363 db.prepare("DELETE FROM repos WHERE id = ?").run(repoRow.id);
ab61b9d364
e5b523e365 // Clean up pages if configured
e5b523e366 if (repoRow.pages_domain) {
e5b523e367 const pagesDeployer = (app as any).pagesDeployer;
e5b523e368 if (pagesDeployer) {
e5b523e369 pagesDeployer.undeploy(repoRow.pages_domain);
e5b523e370 }
e5b523e371 }
e5b523e372
8d0dc12373 // Respond immediately — Mononoke cleanup happens in the background
8d0dc12374 reply.code(204).send();
8d0dc12375
8d0dc12376 // Remove Mononoke config and restart (fire-and-forget)
ab61b9d377 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
ab61b9d378 try {
ab61b9d379 provisioner.deprovisionRepo(repoName);
8d0dc12380 provisioner.restartMononoke().catch((err) => {
8d0dc12381 app.log.error({ err, repoName }, "Failed to restart Mononoke after repo deletion");
8d0dc12382 });
ab61b9d383 } catch (err) {
8d0dc12384 app.log.error({ err, repoName }, "Failed to deprovision Mononoke after repo deletion");
ab61b9d385 }
ab61b9d386 }
ab61b9d387 );
ab61b9d388
8d8e815389 // Update repo settings
8d8e815390 app.patch<{ Params: { owner: string; repo: string } }>(
3e3af55391 "/:owner/:repo",
8d8e815392 {
8d8e815393 preHandler: [(app as any).authenticate],
8d8e815394 },
8d8e815395 async (request: any, reply: any) => {
8d8e815396 const { owner, repo: repoName } = request.params;
8d8e815397 const userId = request.user.id;
3e3af55398 const db = (app as any).db;
3e3af55399
8d8e815400 const parsed = updateRepoSchema.safeParse(request.body);
8d8e815401 if (!parsed.success) {
8d8e815402 return reply.code(400).send({ error: parsed.error.flatten() });
8d8e815403 }
8d8e815404
3e3af55405 const repoRow = db
79efd41406 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
8d8e815407 .get(owner, repoName) as any;
3e3af55408
3e3af55409 if (!repoRow) {
3e3af55410 return reply.code(404).send({ error: "Repository not found" });
3e3af55411 }
3e3af55412
8d8e815413 // Verify ownership
8d8e815414 if (repoRow.owner_type === "user") {
8d8e815415 if (repoRow.owner_id !== userId) {
8d8e815416 return reply.code(403).send({ error: "Not authorized to update this repository" });
8d8e815417 }
8d8e815418 } else {
8d8e815419 // Org repo — verify membership via hub API
8d8e815420 let authorized = false;
8d8e815421 for (const hubApiUrl of HUB_API_URLS) {
8d8e815422 const controller = new AbortController();
8d8e815423 const timeout = setTimeout(() => controller.abort(), 3000);
8d8e815424 try {
8d8e815425 const res = await fetch(`${hubApiUrl}/api/orgs/${owner}`, {
8d8e815426 signal: controller.signal,
8d8e815427 });
8d8e815428 if (!res.ok) continue;
8d8e815429 const { members } = await res.json();
8d8e815430 if (Array.isArray(members) && members.some((m: any) => m.user_id === userId)) {
8d8e815431 authorized = true;
8d8e815432 }
8d8e815433 break;
8d8e815434 } catch {
8d8e815435 // try next
8d8e815436 } finally {
8d8e815437 clearTimeout(timeout);
8d8e815438 }
8d8e815439 }
8d8e815440 if (!authorized) {
8d8e815441 return reply.code(403).send({ error: "Not authorized to update this repository" });
8d8e815442 }
8d8e815443 }
8d8e815444
8d8e815445 const updates = parsed.data;
8d8e815446 const setClauses: string[] = [];
8d8e815447 const values: any[] = [];
8d8e815448
8d8e815449 if (updates.description !== undefined) {
8d8e815450 setClauses.push("description = ?");
8d8e815451 values.push(updates.description);
8d8e815452 }
8d8e815453 if (updates.is_private !== undefined) {
8d8e815454 setClauses.push("is_private = ?");
8d8e815455 values.push(updates.is_private ? 1 : 0);
8d8e815456 }
8d8e815457 if (updates.require_diffs !== undefined) {
8d8e815458 setClauses.push("require_diffs = ?");
8d8e815459 values.push(updates.require_diffs ? 1 : 0);
8d8e815460 }
e5b523e461 if (updates.pages_enabled !== undefined) {
e5b523e462 setClauses.push("pages_enabled = ?");
e5b523e463 values.push(updates.pages_enabled ? 1 : 0);
e5b523e464 }
e5b523e465 if (updates.pages_domain !== undefined) {
e5b523e466 setClauses.push("pages_domain = ?");
e5b523e467 values.push(updates.pages_domain);
e5b523e468 }
8d8e815469
8d8e815470 if (setClauses.length === 0) {
8d8e815471 return reply.code(400).send({ error: "No fields to update" });
8d8e815472 }
8d8e815473
8d8e815474 setClauses.push("updated_at = datetime('now')");
8d8e815475 values.push(repoRow.id);
8d8e815476
ff50d03477 // Undeploy old deploy path if pages disabled or domain changed
e5b523e478 const pagesDeployer = (app as any).pagesDeployer;
b5baf6d479 const oldDeployInfo = pagesDeployer?.getDeployPath(repoRow.owner_name, repoRow.name);
ff50d03480 if (pagesDeployer && oldDeployInfo) {
e5b523e481 if (updates.pages_enabled === false ||
e5b523e482 (updates.pages_domain !== undefined && updates.pages_domain !== repoRow.pages_domain)) {
ff50d03483 pagesDeployer.undeploy(oldDeployInfo.path);
e5b523e484 }
e5b523e485 }
e5b523e486
8d8e815487 db.prepare(`UPDATE repos SET ${setClauses.join(", ")} WHERE id = ?`).run(...values);
8d8e815488
ff50d03489 // Trigger pages deploy if enabled
e5b523e490 if (pagesDeployer && (updates.pages_enabled === true || updates.pages_domain !== undefined)) {
b5baf6d491 void pagesDeployer.deploy(repoRow.owner_name, repoRow.name, repoRow.default_branch ?? "main").catch(
e5b523e492 (err: any) => app.log.error({ err, repo: repoRow.name }, "Initial pages deploy failed")
e5b523e493 );
e5b523e494 }
e5b523e495
8d8e815496 const updated = db
8d8e815497 .prepare(`SELECT * FROM repos_with_owner WHERE id = ?`)
8d8e815498 .get(repoRow.id);
8d8e815499
8d8e815500 return { repo: updated };
8d8e815501 }
8d8e815502 );
8d8e815503
8d8e815504 // Get single repo
8d8e815505 app.get<{ Params: { owner: string; repo: string } }>(
8d8e815506 "/:owner/:repo",
8d8e815507 { preHandler: [optionalAuth, resolveRepo] },
8d8e815508 async (request: any) => {
8d8e815509 const { owner, repo } = request.params;
8d8e815510 const repoRow = request.repoRow;
8d8e815511
3e3af55512 const ref = repoRow.default_branch ?? "main";
791afd4513 const readme = await bridgeService.getReadme(owner, repo, ref);
791afd4514 const branches = await bridgeService.getBranches(owner, repo);
3e3af55515
3e3af55516 return {
3e3af55517 repo: repoRow,
3e3af55518 readme,
3e3af55519 branches,
3e3af55520 };
3e3af55521 }
3e3af55522 );
3e3af55523
3e3af55524 // List directory tree
3e3af55525 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55526 "/:owner/:repo/tree/:ref/*",
8d8e815527 { preHandler: [optionalAuth, resolveRepo] },
8d8e815528 async (request: any, reply: any) => {
3e3af55529 const { owner, repo, ref } = request.params;
3e3af55530 const path = (request.params as any)["*"] ?? "";
3e3af55531
791afd4532 const entries = await bridgeService.listTree(owner, repo, ref, path);
3e3af55533 if (!entries.length && path) {
3e3af55534 return reply.code(404).send({ error: "Path not found" });
3e3af55535 }
3e3af55536
3e3af55537 return {
3e3af55538 path,
3e3af55539 ref,
8d8e815540 entries: entries.sort((a: any, b: any) => {
3e3af55541 // Directories first, then files
3e3af55542 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
3e3af55543 return a.name.localeCompare(b.name);
3e3af55544 }),
3e3af55545 };
3e3af55546 }
3e3af55547 );
3e3af55548
3e3af55549 // Also handle tree root (no path)
3e3af55550 app.get<{ Params: { owner: string; repo: string; ref: string } }>(
3e3af55551 "/:owner/:repo/tree/:ref",
8d8e815552 { preHandler: [optionalAuth, resolveRepo] },
8d8e815553 async (request: any) => {
3e3af55554 const { owner, repo, ref } = request.params;
791afd4555 const entries = await bridgeService.listTree(owner, repo, ref, "");
3e3af55556
3e3af55557 return {
3e3af55558 path: "",
3e3af55559 ref,
8d8e815560 entries: entries.sort((a: any, b: any) => {
3e3af55561 if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
3e3af55562 return a.name.localeCompare(b.name);
3e3af55563 }),
3e3af55564 };
3e3af55565 }
3e3af55566 );
3e3af55567
3e3af55568 // Get file content
3e3af55569 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55570 "/:owner/:repo/blob/:ref/*",
8d8e815571 { preHandler: [optionalAuth, resolveRepo] },
8d8e815572 async (request: any, reply: any) => {
3e3af55573 const { owner, repo, ref } = request.params;
3e3af55574 const path = (request.params as any)["*"];
3e3af55575
3e3af55576 if (!path) {
3e3af55577 return reply.code(400).send({ error: "File path required" });
3e3af55578 }
3e3af55579
791afd4580 const blob = await bridgeService.getBlob(owner, repo, ref, path);
3e3af55581 if (!blob) {
3e3af55582 return reply.code(404).send({ error: "File not found" });
3e3af55583 }
3e3af55584
3e3af55585 return {
3e3af55586 path,
3e3af55587 ref,
3e3af55588 content: blob.content,
3e3af55589 size: blob.size,
3e3af55590 };
3e3af55591 }
3e3af55592 );
3e3af55593
3e3af55594 // Get commit history
3e3af55595 app.get<{
3e3af55596 Params: { owner: string; repo: string; ref: string };
3e3af55597 Querystring: { path?: string; limit?: string; offset?: string };
3e3af55598 }>(
3e3af55599 "/:owner/:repo/commits/:ref",
8d8e815600 { preHandler: [optionalAuth, resolveRepo] },
8d8e815601 async (request: any) => {
3e3af55602 const { owner, repo, ref } = request.params;
3e3af55603 const { path, limit, offset } = request.query;
3e3af55604
791afd4605 const commits = await bridgeService.getCommits(owner, repo, ref, {
3e3af55606 path,
3e3af55607 limit: limit ? parseInt(limit) : 30,
3e3af55608 offset: offset ? parseInt(offset) : 0,
3e3af55609 });
3e3af55610
3e3af55611 return { ref, commits };
3e3af55612 }
3e3af55613 );
3e3af55614
3e3af55615 // Get blame
3e3af55616 app.get<{ Params: { owner: string; repo: string; ref: string; "*": string } }>(
3e3af55617 "/:owner/:repo/blame/:ref/*",
8d8e815618 { preHandler: [optionalAuth, resolveRepo] },
8d8e815619 async (request: any, reply: any) => {
3e3af55620 const { owner, repo, ref } = request.params;
3e3af55621 const path = (request.params as any)["*"];
3e3af55622
3e3af55623 if (!path) {
3e3af55624 return reply.code(400).send({ error: "File path required" });
3e3af55625 }
3e3af55626
791afd4627 const blame = await bridgeService.getBlame(owner, repo, ref, path);
3e3af55628 if (!blame.length) {
3e3af55629 return reply.code(404).send({ error: "File not found" });
3e3af55630 }
3e3af55631
3e3af55632 return { path, ref, blame };
3e3af55633 }
3e3af55634 );
3e3af55635
3e3af55636 // Get diff between refs
3e3af55637 app.get<{
3e3af55638 Params: { owner: string; repo: string };
3e3af55639 Querystring: { base: string; head: string };
3e3af55640 }>(
3e3af55641 "/:owner/:repo/diff",
8d8e815642 { preHandler: [optionalAuth, resolveRepo] },
8d8e815643 async (request: any) => {
3e3af55644 const { owner, repo } = request.params;
3e3af55645 const { base, head } = request.query;
3e3af55646
99f1a2e647 return await bridgeService.getDiff(owner, repo, base, head);
3e3af55648 }
3e3af55649 );
3e3af55650
3e3af55651 // List branches
3e3af55652 app.get<{ Params: { owner: string; repo: string } }>(
3e3af55653 "/:owner/:repo/branches",
8d8e815654 { preHandler: [optionalAuth, resolveRepo] },
8d8e815655 async (request: any) => {
3e3af55656 const { owner, repo } = request.params;
791afd4657 const branches = await bridgeService.getBranches(owner, repo);
3e3af55658 return { branches };
3e3af55659 }
3e3af55660 );
59a80f9661
59a80f9662 // Import a Git repository (SSE progress stream)
59a80f9663 const importSchema = z.object({
59a80f9664 url: z.string().url(),
59a80f9665 });
59a80f9666
59a80f9667 app.post<{ Params: { owner: string; repo: string } }>(
59a80f9668 "/:owner/:repo/import",
59a80f9669 {
59a80f9670 preHandler: [(app as any).authenticate],
59a80f9671 },
59a80f9672 async (request, reply) => {
59a80f9673 const { owner, repo: repoName } = request.params;
59a80f9674 const parsed = importSchema.safeParse(request.body);
59a80f9675 if (!parsed.success) {
59a80f9676 return reply.code(400).send({ error: parsed.error.flatten() });
59a80f9677 }
59a80f9678 const { url } = parsed.data;
59a80f9679 const db = (app as any).db;
59a80f9680
59a80f9681 // Verify repo exists
59a80f9682 const repoRow = db
59a80f9683 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
59a80f9684 .get(owner, repoName) as any;
59a80f9685 if (!repoRow) {
59a80f9686 return reply.code(404).send({ error: "Repository not found" });
59a80f9687 }
59a80f9688
59a80f9689 // SSE stream
59a80f9690 reply.raw.writeHead(200, {
59a80f9691 "Content-Type": "text/event-stream",
59a80f9692 "Cache-Control": "no-cache",
59a80f9693 Connection: "keep-alive",
59a80f9694 });
59a80f9695
59a80f9696 const send = (event: string, data: any) => {
59a80f9697 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
59a80f9698 };
59a80f9699
59a80f9700 try {
59a80f9701 // Step 1: git clone --bare via docker (grove/mononoke image has git)
59a80f9702 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
59a80f9703 send("progress", { step: "clone", message: `Cloning ${url}...` });
59a80f9704
59a80f9705 await runDocker([
59a80f9706 "run", "--rm",
59a80f9707 "-v", "/data/grove:/data/grove",
59a80f9708 "grove/mononoke:latest",
59a80f9709 "/usr/bin/git", "clone", "--bare", url, bareRepo,
59a80f9710 ], (line) => {
59a80f9711 send("log", { step: "clone", line });
59a80f9712 });
59a80f9713
59a80f9714 send("progress", { step: "clone", message: "Clone complete." });
59a80f9715
59a80f9716 // Step 2: gitimport into Mononoke
59a80f9717 send("progress", { step: "import", message: "Importing into Mononoke..." });
59a80f9718
59a80f9719 await runDocker([
59a80f9720 "run", "--rm",
59a80f9721 "-v", "/data/grove:/data/grove",
416062b722 "--entrypoint", "gitimport",
59a80f9723 "grove/mononoke:latest",
416062b724 "--repo-name", repoName,
416062b725 "--config-path", MONONOKE_CONFIG_PATH,
416062b726 "--local-configerator-path", `${DATA_DIR}/configerator`,
416062b727 "--cache-mode", "disabled",
416062b728 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
416062b729 "--generate-bookmarks",
416062b730 "--derive-hg",
416062b731 "--git-command-path", "/usr/bin/git",
416062b732 "--concurrency", "5",
416062b733 bareRepo,
416062b734 "full-repo",
59a80f9735 ], (line) => {
59a80f9736 send("log", { step: "import", line });
59a80f9737 });
59a80f9738
59a80f9739 send("progress", { step: "import", message: "Import complete." });
59a80f9740
59a80f9741 // Step 3: Restart Mononoke services to pick up the imported data
59a80f9742 send("progress", { step: "restart", message: "Restarting services..." });
59a80f9743
59a80f9744 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
59a80f9745 await provisioner.restartMononoke();
59a80f9746
59a80f9747 send("progress", { step: "restart", message: "Services restarted." });
59a80f9748
59a80f9749 // Clean up the bare clone
59a80f9750 await runDocker([
59a80f9751 "run", "--rm",
59a80f9752 "-v", "/data/grove:/data/grove",
59a80f9753 "grove/mononoke:latest",
59a80f9754 "rm", "-rf", bareRepo,
59a80f9755 ], () => {});
59a80f9756
59a80f9757 send("done", { success: true });
59a80f9758 } catch (err: any) {
59a80f9759 send("error", { message: err.message ?? "Import failed" });
59a80f9760 }
59a80f9761
59a80f9762 reply.raw.end();
59a80f9763 }
59a80f9764 );
90d5eb8765
90d5eb8766 // Import a Git repository from an uploaded bare repo tarball (SSE progress stream)
90d5eb8767 app.post<{ Params: { owner: string; repo: string } }>(
90d5eb8768 "/:owner/:repo/import-bundle",
90d5eb8769 {
90d5eb8770 preHandler: [(app as any).authenticate],
90d5eb8771 },
90d5eb8772 async (request, reply) => {
90d5eb8773 const { owner, repo: repoName } = request.params;
90d5eb8774 const db = (app as any).db;
90d5eb8775
90d5eb8776 // Verify repo exists
90d5eb8777 const repoRow = db
90d5eb8778 .prepare(`SELECT * FROM repos_with_owner WHERE owner_name = ? AND name = ?`)
90d5eb8779 .get(owner, repoName) as any;
90d5eb8780 if (!repoRow) {
90d5eb8781 return reply.code(404).send({ error: "Repository not found" });
90d5eb8782 }
90d5eb8783
90d5eb8784 // Read the uploaded file
90d5eb8785 const file = await (request as any).file();
90d5eb8786 if (!file) {
90d5eb8787 return reply.code(400).send({ error: "No file uploaded" });
90d5eb8788 }
90d5eb8789
90d5eb8790 // SSE stream
90d5eb8791 reply.raw.writeHead(200, {
90d5eb8792 "Content-Type": "text/event-stream",
90d5eb8793 "Cache-Control": "no-cache",
90d5eb8794 Connection: "keep-alive",
90d5eb8795 });
90d5eb8796
90d5eb8797 const send = (event: string, data: any) => {
90d5eb8798 reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
90d5eb8799 };
90d5eb8800
90d5eb8801 const bareRepo = `${DATA_DIR}/${repoName}-bare.git`;
90d5eb8802 const tarPath = `${DATA_DIR}/${repoName}-bare.tar.gz`;
90d5eb8803
90d5eb8804 try {
90d5eb8805 // Step 1: Save uploaded tarball and extract
90d5eb8806 send("progress", { step: "upload", message: "Receiving bare repo..." });
90d5eb8807
90d5eb8808 await pipeline(file.file, createWriteStream(tarPath));
90d5eb8809
90d5eb8810 send("progress", { step: "upload", message: "Extracting..." });
90d5eb8811
90d5eb8812 await rm(bareRepo, { recursive: true, force: true });
90d5eb8813
90d5eb8814 await runDocker([
90d5eb8815 "run", "--rm",
90d5eb8816 "-v", "/data/grove:/data/grove",
ea47cea817 "--entrypoint", "tar",
90d5eb8818 "grove/mononoke:latest",
ea47cea819 "xzf", tarPath, "-C", `${DATA_DIR}`,
90d5eb8820 ], (line) => {
90d5eb8821 send("log", { step: "upload", line });
90d5eb8822 });
90d5eb8823
90d5eb8824 // The tar extracts as bare.git/ — rename to match expected path
ea47cea825 await runDocker([
ea47cea826 "run", "--rm",
ea47cea827 "-v", "/data/grove:/data/grove",
416062b828 "--entrypoint", "sh",
ea47cea829 "grove/mononoke:latest",
416062b830 "-c", `mv ${DATA_DIR}/bare.git ${bareRepo} 2>/dev/null; chown -R root:root ${bareRepo}`,
416062b831 ], () => {});
90d5eb8832
90d5eb8833 send("progress", { step: "upload", message: "Extracted." });
90d5eb8834
90d5eb8835 // Step 2: gitimport into Mononoke
90d5eb8836 send("progress", { step: "import", message: "Importing into Mononoke..." });
90d5eb8837
90d5eb8838 await runDocker([
90d5eb8839 "run", "--rm",
90d5eb8840 "-v", "/data/grove:/data/grove",
416062b841 "--entrypoint", "gitimport",
90d5eb8842 "grove/mononoke:latest",
416062b843 "--repo-name", repoName,
416062b844 "--config-path", MONONOKE_CONFIG_PATH,
416062b845 "--local-configerator-path", `${DATA_DIR}/configerator`,
416062b846 "--cache-mode", "disabled",
416062b847 "--just-knobs-config-path", `${DATA_DIR}/justknobs.json`,
416062b848 "--generate-bookmarks",
416062b849 "--derive-hg",
416062b850 "--git-command-path", "/usr/bin/git",
416062b851 "--concurrency", "5",
416062b852 bareRepo,
416062b853 "full-repo",
90d5eb8854 ], (line) => {
90d5eb8855 send("log", { step: "import", line });
90d5eb8856 });
90d5eb8857
6d52207858 // Create Sapling-style bookmark (gitimport creates "heads/main", Sapling needs "main")
6d52207859 send("progress", { step: "import", message: "Creating bookmarks..." });
6d52207860
6d52207861 await runDocker([
6d52207862 "run", "--rm",
6d52207863 "-v", "/data/grove:/data/grove",
6d52207864 "--entrypoint", "sh",
6d52207865 "grove/mononoke:latest",
6d52207866 "-c",
6d52207867 `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`,
6d52207868 ], (line) => {
6d52207869 send("log", { step: "import", line });
6d52207870 });
6d52207871
90d5eb8872 send("progress", { step: "import", message: "Import complete." });
90d5eb8873
90d5eb8874 // Step 3: Restart Mononoke services
90d5eb8875 send("progress", { step: "restart", message: "Restarting services..." });
90d5eb8876
90d5eb8877 const provisioner = (app as any).mononokeProvisioner as MononokeProvisioner;
90d5eb8878 await provisioner.restartMononoke();
90d5eb8879
90d5eb8880 send("progress", { step: "restart", message: "Services restarted." });
90d5eb8881
90d5eb8882 // Clean up
90d5eb8883 await runDocker([
90d5eb8884 "run", "--rm",
90d5eb8885 "-v", "/data/grove:/data/grove",
ea47cea886 "--entrypoint", "rm",
90d5eb8887 "grove/mononoke:latest",
ea47cea888 "-rf", bareRepo, tarPath,
90d5eb8889 ], () => {});
90d5eb8890
90d5eb8891 send("done", { success: true });
90d5eb8892 } catch (err: any) {
90d5eb8893 // Clean up on error
90d5eb8894 await runDocker([
90d5eb8895 "run", "--rm",
90d5eb8896 "-v", "/data/grove:/data/grove",
ea47cea897 "--entrypoint", "rm",
90d5eb8898 "grove/mononoke:latest",
ea47cea899 "-rf", bareRepo, tarPath,
90d5eb8900 ], () => {}).catch(() => {});
90d5eb8901
90d5eb8902 send("error", { message: err.message ?? "Import failed" });
90d5eb8903 }
90d5eb8904
90d5eb8905 reply.raw.end();
90d5eb8906 }
90d5eb8907 );
59a80f9908}
59a80f9909
59a80f9910/**
59a80f9911 * Run a docker command, streaming stdout/stderr line-by-line to a callback.
59a80f9912 * Rejects on non-zero exit code.
59a80f9913 */
59a80f9914function runDocker(
59a80f9915 args: string[],
59a80f9916 onLine: (line: string) => void
59a80f9917): Promise<void> {
59a80f9918 return new Promise((resolve, reject) => {
59a80f9919 const proc = spawn("docker", args);
59a80f9920 let stderr = "";
59a80f9921
59a80f9922 const handleData = (data: Buffer) => {
59a80f9923 const text = data.toString();
59a80f9924 for (const line of text.split("\n")) {
59a80f9925 const trimmed = line.trimEnd();
59a80f9926 if (trimmed) onLine(trimmed);
59a80f9927 }
59a80f9928 };
59a80f9929
59a80f9930 proc.stdout.on("data", handleData);
59a80f9931 proc.stderr.on("data", (data: Buffer) => {
59a80f9932 stderr += data.toString();
59a80f9933 handleData(data);
59a80f9934 });
59a80f9935
59a80f9936 proc.on("close", (code) => {
59a80f9937 if (code === 0) resolve();
59a80f9938 else reject(new Error(stderr.trim() || `docker exited with code ${code}`));
59a80f9939 });
59a80f9940
59a80f9941 proc.on("error", reject);
59a80f9942 });
3e3af55943}