import { z } from "zod"; import { repoDb } from "../scoped-db"; import { notFoundError } from "../errors"; import { limitWrite } from "../limits"; import { canAdministerAccount } from "../repos"; import { approveRunRow, cancelRunRow, loadRunByNumber } from "../ci/run-control"; import { listRepoRunners, mintRegistrationToken, removeRepoRunner, repoScope } from "../ci/runner-registration"; import { forkRunsAllowed, setForkRunsAllowed } from "../ci/runner-policy"; import { hasAccountScope, noContent, readJson, requireAccountScope, requireScopedAdminWrite, requireScopedRepo, } from "./http"; import { requireOwnerAdmin } from "../ci/config-admin"; import { listOwnerRunners, removeOwnerRunner } from "../ci/runner-owner"; import type { ApiContext } from "./http"; const MAX_RUNS_LISTED = 20; const MAX_JOBS_LISTED = 200; const registrationInput = z.object({ scope: z.enum(["repository", "owner"]).default("repository"), labels: z.array(z.string().trim().min(1).max(32)).max(20).default([]), isolation: z.enum(["container", "none"]).default("container"), }); const policyInput = z.object({ allow_fork_pull_requests: z.boolean() }); export async function createRegistrationToken(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, registrationInput); await limitWrite(ctx.viewer); if (data.scope === "owner") requireAccountScope(ctx); const { repo } = await requireScopedAdminWrite(ctx, owner, name); const scope = await repoScope(repo, data.scope, ctx.viewer); const minted = await mintRegistrationToken(scope, ctx.viewer.id, data.labels, data.isolation); return Response.json({ token: minted.token, expires_at: minted.expiresAt }, { status: 201 }); } const ownerRegistrationInput = registrationInput.omit({ scope: true }); export async function createOwnerRegistrationToken(ctx: ApiContext, owner: string): Promise { requireAccountScope(ctx); const data = await readJson(ctx.request, ownerRegistrationInput); await limitWrite(ctx.viewer); const account = await requireOwnerAdmin(owner, ctx.viewer); const minted = await mintRegistrationToken( { kind: "owner", ownerId: account.id }, ctx.viewer.id, data.labels, data.isolation, ); return Response.json({ token: minted.token, expires_at: minted.expiresAt }, { status: 201 }); } export async function listOwnerRunnersApi(ctx: ApiContext, owner: string): Promise { requireAccountScope(ctx); const account = await requireOwnerAdmin(owner, ctx.viewer); return Response.json({ runners: await listOwnerRunners(account.id) }); } export async function deleteOwnerRunnerApi(ctx: ApiContext, owner: string, id: string): Promise { requireAccountScope(ctx); await limitWrite(ctx.viewer); const account = await requireOwnerAdmin(owner, ctx.viewer); if (!(await removeOwnerRunner(account.id, id))) throw notFoundError("Runner not found"); return noContent(); } export async function listRunnersApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); const runners = await listRepoRunners(repo); const ownerAdmin = hasAccountScope(ctx) && (await canAdministerAccount(repo.owner_id, ctx.viewer)); return Response.json({ runners: ownerAdmin ? runners : runners.filter((runner) => runner.scope === "repository"), allow_fork_pull_requests: await forkRunsAllowed(repo.id), }); } export async function deleteRunnerApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "admin", "admin"); if (!(await removeRepoRunner(repo, id, ctx.viewer, hasAccountScope(ctx)))) throw notFoundError("Runner not found"); return noContent(); } export async function putRunnerPolicy(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, policyInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedAdminWrite(ctx, owner, name); await setForkRunsAllowed(repo.id, data.allow_fork_pull_requests, ctx.viewer.id); return noContent(); } type RunListRow = { id: string; run_number: number; workflow_path: string; event: string; status: string; conclusion: string | null; approval_required: number; untrusted: number; source_repo_id: string; repo_id: string; sha: string; created_at: number; }; type JobListRow = { id: string; run_id: string; name: string; runs_on: string; status: string; conclusion: string | null; runner_id: string | null; runner_name: string | null; }; /** Recent runs with their jobs, newest first; bounded to keep the response cheap. */ export async function listRunsApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "read"); const runs = await repoDb(repo.id).all( `SELECT id, run_number, workflow_path, event, status, conclusion, approval_required, untrusted, source_repo_id, repo_id, sha, created_at FROM workflow_runs WHERE repo_id = ? ORDER BY run_number DESC LIMIT ?`, repo.id, MAX_RUNS_LISTED, ); const jobs = await repoDb(repo.id).all( `SELECT j.id, j.run_id, j.name, j.runs_on, j.status, j.conclusion, j.runner_id, j.runner_name FROM workflow_jobs j WHERE j.run_id IN (SELECT id FROM workflow_runs WHERE repo_id = ? ORDER BY run_number DESC LIMIT ?) ORDER BY j.created_at DESC LIMIT ?`, repo.id, MAX_RUNS_LISTED, MAX_JOBS_LISTED, ); return Response.json( runs.map((row) => ({ id: row.id, number: row.run_number, workflow_path: row.workflow_path, event: row.event, status: row.status, conclusion: row.conclusion, approval_required: row.approval_required === 1, fork: row.source_repo_id !== row.repo_id || row.untrusted === 1, sha: row.sha, created_at: row.created_at, jobs: jobs .filter((job) => job.run_id === row.id) .reverse() .map((job) => ({ id: job.id, name: job.name, runs_on: JSON.parse(job.runs_on) as string[], status: job.status, conclusion: job.conclusion, runner_id: job.runner_id, runner_name: job.runner_name, })), })), ); } export async function cancelRunApi(ctx: ApiContext, owner: string, name: string, number: number): Promise { await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "write"); await cancelRunRow(await loadRunByNumber(repo.id, number)); return noContent(); } export async function approveRunApi(ctx: ApiContext, owner: string, name: string, number: number): Promise { await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "write"); await approveRunRow(repo.id, await loadRunByNumber(repo.id, number)); return noContent(); }