import { z } from "zod"; import { all, batch, first, run, stmt } from "../db"; import { randomId, randomToken, sha256Hex } from "../crypto"; import { badRequest, conflict, forbidden, notFoundError } from "../errors"; import { recordActivity } from "../events/emit"; import { branchSha, listRefs, resolveRevision } from "../git/refs"; import { ZERO_SHA } from "../git/store"; import { announcePush, assertDirectUpdate, rethrowPush, } from "../git/web-commit"; import { createBranch as pushBranch, deleteRef, isValidRefName, } from "../git/writer"; import { assertBelowCap, CAPS, limitWrite } from "../limits"; import { toProtection } from "../protection"; import type { BranchProtectionRow } from "../protection"; import { repoMoveStatements } from "../redirects"; import { canCreateInAccount } from "../repos"; import { syncSiteForRepo } from "../sites/sync"; import { DEPLOY_TOKEN_PREFIX } from "../token-scope"; import { noContent, ownerAccount, readJson, repoJson, requireAccountScope, requireScopedRepo, } from "./http"; import type { ApiContext } from "./http"; const MAX_LISTED_REFS = 90; const VISIBLE_TOKEN_CHARS = 6; const DAY_MS = 24 * 60 * 60 * 1000; export async function listBranchesApi( ctx: ApiContext, owner: string, name: string, ): Promise { const { repo } = await requireScopedRepo( ctx, owner, name, "contents", "read", ); const refs = await listRefs(repo); return Response.json({ default_branch: repo.default_branch, branches: refs.branches .slice(0, MAX_LISTED_REFS) .map((branch) => ({ name: branch.name, sha: branch.sha })), }); } const branchInput = z.object({ name: z.string().min(1), from: z.string().min(1), }); /** Same steps as the createBranch server fn, including protection and push-rule checks. */ export async function createBranchApi( ctx: ApiContext, owner: string, name: string, ): Promise { const data = await readJson(ctx.request, branchInput); await limitWrite(ctx.viewer); const { repo, permission } = await requireScopedRepo( ctx, owner, name, "contents", "write", ); if (!isValidRefName(data.name)) throw badRequest("Invalid branch name"); const refs = await listRefs(repo); if (branchSha(refs, data.name)) throw conflict("Branch already exists"); const source = resolveRevision(refs, data.from); if (!source) throw notFoundError("Source ref not found"); await assertDirectUpdate(repo, permission, data.name, false); await pushBranch(repo, data.name, source.sha).catch(rethrowPush); await announcePush( repo, ctx.viewer, `refs/heads/${data.name}`, ZERO_SHA, source.sha, ); return Response.json({ name: data.name, sha: source.sha }, { status: 201 }); } export async function deleteBranchApi( ctx: ApiContext, owner: string, name: string, branch: string, ) { await limitWrite(ctx.viewer); const { repo, permission } = await requireScopedRepo( ctx, owner, name, "contents", "write", ); if (branch === repo.default_branch) throw badRequest("The default branch cannot be deleted"); const sha = branchSha(await listRefs(repo), branch); if (!sha) throw notFoundError("Branch not found"); await assertDirectUpdate(repo, permission, branch, true); await deleteRef(repo, `refs/heads/${branch}`, sha).catch(rethrowPush); await announcePush(repo, ctx.viewer, `refs/heads/${branch}`, sha, ZERO_SHA); return noContent(); } export async function listTagsApi( ctx: ApiContext, owner: string, name: string, ): Promise { const { repo } = await requireScopedRepo( ctx, owner, name, "contents", "read", ); const refs = await listRefs(repo); return Response.json( refs.tags .slice(0, MAX_LISTED_REFS) .map((tag) => ({ name: tag.name, sha: tag.sha })), ); } const protectionUpdate = z.object({ pattern: z.string().trim().min(1).max(200), require_pull_request: z.boolean(), required_approvals: z.number().int().min(0).max(10), required_checks: z.array(z.string().min(1)).max(50), dismiss_stale_reviews: z.boolean(), allow_deletions: z.boolean(), allow_force_push: z.boolean(), enforce_admins: z.boolean(), }); /** Full settings update, matching the saveProtection server fn's update branch. */ export async function updateProtectionApi( ctx: ApiContext, owner: string, name: string, id: string, ) { const data = await readJson(ctx.request, protectionUpdate); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const existing = await first( "SELECT 1 AS ok FROM branch_protections WHERE id = ? AND repo_id = ?", id, repo.id, ); if (!existing) throw notFoundError("Rule not found"); await run( `UPDATE branch_protections SET pattern = ?, require_pull_request = ?, required_approvals = ?, required_checks = ?, dismiss_stale_reviews = ?, allow_deletions = ?, allow_force_pushes = ?, enforce_admins = ? WHERE id = ? AND repo_id = ?`, data.pattern, data.require_pull_request ? 1 : 0, data.required_approvals, JSON.stringify(data.required_checks), data.dismiss_stale_reviews ? 1 : 0, data.allow_deletions ? 1 : 0, data.allow_force_push ? 1 : 0, data.enforce_admins ? 1 : 0, id, repo.id, ); const row = await first( "SELECT * FROM branch_protections WHERE id = ?", id, ); if (!row) throw notFoundError("Rule not found"); const rule = toProtection(row); return Response.json({ id: rule.id, pattern: rule.pattern, allow_force_push: rule.allowForcePushes, allow_deletions: rule.allowDeletions, require_pull_request: rule.requirePullRequest, required_approvals: rule.requiredApprovals, enforce_admins: rule.enforceAdmins, }); } export async function deleteProtectionApi( ctx: ApiContext, owner: string, name: string, id: string, ) { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await run( "DELETE FROM branch_protections WHERE id = ? AND repo_id = ?", id, repo.id, ); return noContent(); } export async function listDeployTokensApi( ctx: ApiContext, owner: string, name: string, ): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const rows = await all<{ id: string; name: string; token_prefix: string; permission: string; }>( "SELECT id, name, token_prefix, permission FROM deploy_tokens WHERE repo_id = ? ORDER BY created_at DESC", repo.id, ); return Response.json( rows.map((row) => ({ id: row.id, name: row.name, prefix: row.token_prefix, permission: row.permission, })), ); } const deployTokenInput = z.object({ name: z.string().trim().min(1).max(100), permission: z.enum(["read", "write"]), expires_in_days: z.number().int().min(0).max(366).default(0), }); /** Mints exactly like the createDeployToken server fn so git auth treats the token identically. */ export async function createDeployTokenApi( ctx: ApiContext, owner: string, name: string, ): Promise { const data = await readJson(ctx.request, deployTokenInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const count = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM deploy_tokens WHERE repo_id = ?", repo.id, ); assertBelowCap(count?.count ?? 0, CAPS.deployTokensPerRepo, "deploy tokens"); const token = randomToken(DEPLOY_TOKEN_PREFIX); const id = randomId(); await run( `INSERT INTO deploy_tokens (id, repo_id, name, token_hash, token_prefix, permission, created_by, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.id, data.name, await sha256Hex(token), token.slice(0, DEPLOY_TOKEN_PREFIX.length + 1 + VISIBLE_TOKEN_CHARS), data.permission, ctx.viewer.id, data.expires_in_days > 0 ? Date.now() + data.expires_in_days * DAY_MS : null, Date.now(), ); return Response.json( { id, token, permission: data.permission }, { status: 201 }, ); } export async function deleteDeployTokenApi( ctx: ApiContext, owner: string, name: string, id: string, ) { await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); await run( "DELETE FROM deploy_tokens WHERE id = ? AND repo_id = ?", id, repo.id, ); return noContent(); } const transferInput = z.object({ new_owner: z.string().min(1).max(64) }); /** Same checks and redirect bookkeeping as the transferRepo server fn. */ export async function transferRepoApi( ctx: ApiContext, owner: string, name: string, ): Promise { const data = await readJson(ctx.request, transferInput); requireAccountScope(ctx); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const target = await ownerAccount(data.new_owner); if (target.id === repo.owner_id) throw badRequest("The repository already belongs to that account"); if (!(await canCreateInAccount(target.id, ctx.viewer))) throw forbidden("You can't create repositories there"); if ( await first( "SELECT 1 AS ok FROM repos WHERE owner_id = ? AND name = ?", target.id, repo.name, ) ) { throw conflict( `${target.name} already has a repository named ${repo.name}`, ); } const owned = await first<{ count: number }>( "SELECT COUNT(*) AS count FROM repos WHERE owner_id = ?", target.id, ); assertBelowCap( owned?.count ?? 0, CAPS.reposPerOwner, "repositories per owner", ); const now = Date.now(); await batch([ stmt( "UPDATE repos SET owner_id = ?, updated_at = ? WHERE id = ?", target.id, now, repo.id, ), ...repoMoveStatements( repo.id, { owner: repo.owner_name, name: repo.name }, { owner: target.name, name: repo.name }, ), ]); await syncSiteForRepo(repo.id); await recordActivity(ctx.viewer.id, repo.id, "transfer", { from: repo.owner_name, to: target.name, }); return Response.json( await repoJson({ ...repo, owner_id: target.id, owner_name: target.name }), ); }