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 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 }), ); }