import { parseLanguages } from "~/lib/languages"; import { requestCodeIndex } from "../code/search-index"; import { MAX_TOPIC_LENGTH, MAX_TOPICS, normalizeTopics, parseTopics, TopicError } from "~/lib/topics"; import { assertCanFork } from "../fork-policy"; import { kickRepoInsights } from "../insights/repo-insights-do"; import { purgeRepoCaches } from "../git/cache-purge"; import { waitUntil } from "cloudflare:workers"; import { syncHostingForRepo } from "../sites/sync"; import { z } from "zod"; import { all, first, run } from "../db"; import { conflict, forbidden, notFoundError } from "../errors"; import { listRefs } from "../git/refs"; import { resyncImportedRefs } from "../git/ref-sync"; import { dropHiddenRefsForRepo, requestHiddenRefCleanup } from "../git/pull-bridge"; import { syncRepoAdvisoryIndex } from "../security/advisory-packages"; import { purgeInaccessibleFollowers } from "../access-revocation"; import { assertBelowCap, CAPS, limitHeavy, limitWrite, limitAccountWrite } from "../limits"; import { assertOwnerNotDisabled, assertNotArchived, canCreateInAccount, hasPrivateForkAncestor, loadRepo, REPO_SELECT, visibleReposClause, } from "../repos"; import { createRepository, deleteRepository, forkRepository, importRepository, refreshRepoStatus, reindexRepo, } from "../repo-ops"; import { credentialAllows } from "../token-scope"; import { syncSiteForRepo } from "../sites/sync"; import { findCollaboratorUser, setCollaborator } from "../collaborators"; import { repoName } from "~/lib/validation"; import type { RepoRow } from "~/lib/types"; import { noContent, ownerAccount, readJson, repoJson, requireAccountScope, requireScopedAdminWrite, requireScopedRepo, unprocessable, } from "./http"; import type { ApiContext } from "./http"; import { assertMayDeleteRepo } from "../verified-writes"; const LIST_LIMIT = 100; async function assertOwnerCap(ownerId: string): Promise { assertBelowCap( (await first<{ count: number }>("SELECT COUNT(*) AS count FROM repos WHERE owner_id = ?", ownerId))?.count ?? 0, CAPS.reposPerOwner, "repositories", ); } export async function listRepos(ctx: ApiContext): Promise { const ownerName = ctx.url.searchParams.get("owner") ?? ctx.viewer.name; const owner = await ownerAccount(ownerName); const { clause, viewerId } = visibleReposClause(ctx.viewer); const rows = await all( `${REPO_SELECT} WHERE ${clause} AND r.owner_id = ?2 ORDER BY r.name LIMIT ${LIST_LIMIT}`, viewerId, owner.id, ); const allowed: RepoRow[] = []; for (const row of rows) if (await credentialAllows(row, ctx.credential, "contents", "read")) allowed.push(row); return Response.json(await Promise.all(allowed.map(repoJson))); } const createInput = z.object({ name: repoName, description: z.string().max(350).nullable().optional(), private: z.boolean().optional(), owner: z.string().min(1).optional(), }); export async function createRepo(ctx: ApiContext): Promise { requireAccountScope(ctx); const data = await readJson(ctx.request, createInput); await limitWrite(ctx.viewer); const owner = await ownerAccount(data.owner ?? ctx.viewer.name); if (!(await canCreateInAccount(owner.id, ctx.viewer))) throw forbidden("You cannot create repositories here"); await assertOwnerCap(owner.id); const repo = await createRepository({ owner, name: data.name, description: data.description ?? null, visibility: data.private ? "private" : "public", initReadme: false, actor: ctx.viewer, }); return Response.json(await repoJson(repo), { status: 201 }); } export async function getRepo(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); return Response.json(await repoJson(await refreshRepoStatus(repo))); } const updateInput = z.object({ description: z.string().max(350).nullable().optional(), private: z.boolean().optional(), default_branch: z.string().min(1).optional(), allow_forking: z.boolean().optional(), }); /** Making an archived repository private only narrows access, so it stays allowed; other edits do not. */ function onlyHides(data: z.infer): boolean { return ( data.private === true && data.description === undefined && data.default_branch === undefined && data.allow_forking === undefined ); } export async function updateRepo(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, updateInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); assertOwnerNotDisabled(repo, ctx.viewer); if (!onlyHides(data)) assertNotArchived(repo); const visibility = data.private === undefined ? repo.visibility : data.private ? "private" : "public"; if (visibility === "public" && repo.visibility !== "public" && (await hasPrivateForkAncestor(repo))) { throw unprocessable("Forks of private repositories cannot be made public"); } const defaultBranch = data.default_branch ?? repo.default_branch; if (data.default_branch !== undefined) { const refs = await listRefs(repo); if (refs.branches.length > 0 && !refs.branches.some((branch) => branch.name === defaultBranch)) { throw unprocessable("Default branch must be an existing branch"); } } const description = data.description === undefined ? repo.description : data.description; const allowForking = data.allow_forking === undefined ? repo.allow_forking : data.allow_forking ? 1 : 0; await run( "UPDATE repos SET description = ?, visibility = ?, default_branch = ?, allow_forking = ?, updated_at = ? WHERE id = ?", description, visibility, defaultBranch, allowForking, Date.now(), repo.id, ); if (defaultBranch !== repo.default_branch) { await kickRepoInsights(repo.id); await requestCodeIndex(repo.id); } await syncSiteForRepo(repo.id); if (visibility !== repo.visibility) { await requestHiddenRefCleanup(repo.id); waitUntil(dropHiddenRefsForRepo(repo.id)); waitUntil(purgeRepoCaches(repo).catch((error: unknown) => console.warn("cache purge failed", repo.id, error))); await syncHostingForRepo(repo.id); if (visibility === "private") await purgeInaccessibleFollowers(repo.id); await syncRepoAdvisoryIndex(repo.id).catch((error: unknown) => console.error("Advisory index sync failed", repo.id, error), ); } await reindexRepo({ id: repo.id, owner_name: repo.owner_name, name: repo.name, description, topics: parseTopics(repo.topics), }); return Response.json( await repoJson({ ...repo, description, visibility, default_branch: defaultBranch, allow_forking: allowForking, } satisfies RepoRow), ); } /** GitHub's GET /repos/{owner}/{repo}/languages: bytes per language on the default branch, largest first. */ export async function getLanguages(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); return Response.json(Object.fromEntries(parseLanguages(repo.languages).map((entry) => [entry.name, entry.bytes]))); } export async function getTopics(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); return Response.json({ names: parseTopics(repo.topics) }); } const topicsInput = z.object({ names: z.array(z.string().max(MAX_TOPIC_LENGTH * 2)).max(MAX_TOPICS * 2) }); /** GitHub's PUT /repos/{owner}/{repo}/topics: replaces every topic. */ export async function replaceTopics(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, topicsInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); assertOwnerNotDisabled(repo, ctx.viewer); assertNotArchived(repo); let topics: string[]; try { topics = normalizeTopics(data.names); } catch (error) { if (error instanceof TopicError) throw unprocessable(error.message); throw error; } await run("UPDATE repos SET topics = ?, updated_at = ? WHERE id = ?", JSON.stringify(topics), Date.now(), repo.id); await reindexRepo({ ...repo, topics }); return Response.json({ names: topics }); } export async function deleteRepo(ctx: ApiContext, owner: string, name: string): Promise { await limitAccountWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); assertMayDeleteRepo(repo, ctx.viewer); await deleteRepository(repo, ctx.viewer); return noContent(); } const forkInput = z.object({ owner: z.string().min(1).optional(), name: repoName.optional() }); export async function forkRepo(ctx: ApiContext, owner: string, name: string): Promise { requireAccountScope(ctx); const data = await readJson(ctx.request, forkInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); const target = await ownerAccount(data.owner ?? ctx.viewer.name); await assertCanFork(repo, target, ctx.viewer); await assertOwnerCap(target.id); const fork = await forkRepository({ source: repo, owner: target, name: data.name ?? repo.name, actor: ctx.viewer }); return Response.json(await repoJson(fork), { status: 201 }); } export async function getCollaborator(ctx: ApiContext, owner: string, name: string, username: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const user = await findCollaboratorUser(username); const row = await first("SELECT 1 AS ok FROM collaborators WHERE repo_id = ? AND user_id = ?", repo.id, user.id); if (!row) throw notFoundError("Not a collaborator"); return noContent(); } const collaboratorInput = z.object({ permission: z.enum(["read", "write", "admin"]) }); export async function putCollaborator(ctx: ApiContext, owner: string, name: string, username: string) { const data = await readJson(ctx.request, collaboratorInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedAdminWrite(ctx, owner, name); await setCollaborator(repo, username, data.permission); return noContent(); } const importInput = z.object({ url: z.url(), sync: z.boolean().optional(), token: z.string().max(500).optional() }); /** `sync: true` re-syncs refs of an existing import (ref-sync applies GIT-6 checks); otherwise creates a new import. */ export async function importRepo(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, importInput); if (data.sync) { const { repo } = await requireScopedAdminWrite(ctx, owner, name); if (!repo.import_source_url) throw unprocessable("This repository was not imported"); if (repo.import_source_url !== data.url) throw unprocessable("URL does not match the repository's import source"); if (repo.status !== "ready") throw conflict("The repository is still being provisioned"); await limitHeavy(ctx.viewer); return Response.json(await resyncImportedRefs(repo, repo.import_source_url)); } requireAccountScope(ctx); const repoNameResult = repoName.safeParse(name); if (!repoNameResult.success) throw unprocessable("Invalid repository name"); await limitWrite(ctx.viewer); const target = await ownerAccount(owner); if (!(await canCreateInAccount(target.id, ctx.viewer))) throw forbidden("You cannot create repositories here"); if (await loadRepo(target.name, repoNameResult.data)) throw conflict("A repository with that name already exists"); await assertOwnerCap(target.id); const repo = await importRepository({ owner: target, name: repoNameResult.data, description: null, visibility: "private", sourceUrl: data.url, branch: null, token: data.token ?? null, actor: ctx.viewer, }); return Response.json(await repoJson(repo), { status: 201 }); }