import { z } from "zod"; import { placeholders } from "../db"; import { repoDb } from "../scoped-db"; import { badRequest, forbidden, notFoundError } from "../errors"; import { limitWrite } from "../limits"; import { assertNotArchived } from "../repos"; import { cacheQuotaBytes, deleteCacheEntry } from "../ci/cache-api"; import { deleteConfigValue, listSecretNames, listVariables, upsertSecret, upsertVariable } from "../ci/config-admin"; import type { ConfigScope } from "../ci/config-admin"; import { pendingDeployments, reviewDeployment, visibleOutputs } from "../ci/deployments"; import type { DeploymentRow, EnvironmentRow } from "../ci/deployments"; import type { WorkflowJobRow, WorkflowRunRow } from "../ci/jobs"; import { rerunTrigger } from "../ci/rerun"; import { loadRunByNumber } from "../ci/run-control"; import { scheduleWorkflows } from "../ci/scheduler"; import { listRefs, resolveRevision } from "../git/refs"; import { combinedState, listCommitStatuses } from "../statuses"; import { noContent, readJson, requireScopedAdminWrite, requireScopedRepo, requireTokenWrite } from "./http"; import type { ApiContext } from "./http"; import { secretName } from "~/lib/validation"; const MAX_VALUE_BYTES = 48 * 1024; const DEPLOYMENT_PAGE = 50; const CACHE_PAGE = 100; const rerunInput = z.object({ failed_only: z.boolean().default(false) }); const dispatchInput = z.object({ path: z.string().min(1), ref: z.string().min(1), inputs: z.record(z.string(), z.string()).default({}), }); const valueInput = z.object({ value: z.string().min(1).max(MAX_VALUE_BYTES) }); const reviewInput = z.object({ approve: z.boolean() }); function configName(raw: string): string { const parsed = secretName.safeParse(raw); if (!parsed.success) throw badRequest(parsed.error.issues.map((issue) => issue.message).join("; ")); return parsed.data.toUpperCase(); } type StepRow = { job_id: string; idx: number; name: string; status: string; conclusion: string | null; started_at: number | null; completed_at: number | null; }; /** One run with jobs, steps and artifacts; job ids address `/api/jobs/:id/logs`, artifact ids `/api/artifacts/:id`. */ export async function getRunApi(ctx: ApiContext, owner: string, name: string, number: number): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "read"); const runRow = await loadRunByNumber(repo.id, number); const jobs = await repoDb(repo.id).all( "SELECT * FROM workflow_jobs WHERE run_id = ? ORDER BY created_at, job_key", runRow.id, ); const steps = jobs.length ? await repoDb(repo.id).all( `SELECT job_id, idx, name, status, conclusion, started_at, completed_at FROM job_steps WHERE job_id IN (${placeholders(jobs.length)}) ORDER BY idx`, ...jobs.map((job) => job.id), ) : []; const artifacts = await repoDb(repo.id).all<{ id: string; name: string; size: number; job_id: string }>( "SELECT id, name, size, job_id FROM job_artifacts WHERE run_id = ? ORDER BY name", runRow.id, ); const previous = runRow.rerun_of ? await repoDb(repo.id).first<{ run_number: number }>( "SELECT run_number FROM workflow_runs WHERE id = ?", runRow.rerun_of, ) : null; return Response.json({ id: runRow.id, number: runRow.run_number, title: runRow.title, workflow_path: runRow.workflow_path, event: runRow.event, ref: runRow.ref, sha: runRow.sha, status: runRow.status, conclusion: runRow.conclusion, pr_number: runRow.pr_number, approval_required: runRow.approval_required === 1 && runRow.status === "queued", rerun_of: previous?.run_number ?? null, pending_deployments: await pendingDeployments(runRow.id, ctx.viewer.id), jobs: jobs.map((job) => ({ id: job.id, key: job.job_key, 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, environment: job.environment, outputs: visibleOutputs(job.outputs), steps: steps .filter((step) => step.job_id === job.id) .map((step) => ({ index: step.idx, name: step.name, status: step.status, conclusion: step.conclusion })), })), artifacts, }); } async function runNumberOf(repoId: string, id: string | undefined, fallback: number): Promise { if (!id) return fallback; const row = await repoDb(repoId).first<{ run_number: number }>( "SELECT run_number FROM workflow_runs WHERE id = ?", id, ); return row?.run_number ?? fallback; } export async function rerunRunApi(ctx: ApiContext, owner: string, name: string, number: number): Promise { const data = await readJson(ctx.request, rerunInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "write"); const runRow: WorkflowRunRow = await loadRunByNumber(repo.id, number); const ids = await scheduleWorkflows(repo, await rerunTrigger(repo, runRow, ctx.viewer), { forceWorkflowPath: true, rerunOf: runRow.id, failedOnly: data.failed_only === true, }); if (ids.length === 0) throw badRequest("The workflow file no longer exists at that commit"); return Response.json({ number: await runNumberOf(repo.id, ids[0], number) }, { status: 201 }); } export async function dispatchWorkflowApi(ctx: ApiContext, owner: string, name: string): Promise { const data = await readJson(ctx.request, dispatchInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "write"); const revision = resolveRevision(await listRefs(repo), data.ref); if (!revision) throw badRequest("Unknown ref"); const ids = await scheduleWorkflows(repo, { event: "workflow_dispatch", action: null, ref: revision.kind === "tag" ? `refs/tags/${revision.name}` : `refs/heads/${revision.name}`, sha: revision.sha, actorId: ctx.viewer.id, actorName: ctx.viewer.name, title: `Manual run on ${revision.name}`, prNumber: null, headRef: null, baseRef: null, changedPaths: null, inputs: data.inputs, workflowPath: data.path, sourceRepo: repo, }); if (ids.length === 0) throw badRequest("That workflow does not accept workflow_dispatch at this ref"); return Response.json({ number: await runNumberOf(repo.id, ids[0], 0) }, { status: 201 }); } async function adminScope( ctx: ApiContext, owner: string, name: string, environment?: string, write = true, ): Promise { const { repo } = write ? await requireScopedAdminWrite(ctx, owner, name) : await requireScopedRepo(ctx, owner, name, "admin", "admin"); if (environment === undefined) return { kind: "repo", id: repo.id }; const row = await repoDb(repo.id).first( "SELECT * FROM environments WHERE repo_id = ? AND name = ?", repo.id, environment, ); if (!row) throw notFoundError("Environment not found"); return { kind: "environment", id: row.id, repoId: repo.id }; } export async function listSecretsApi(ctx: ApiContext, owner: string, name: string, environment?: string) { const scope = await adminScope(ctx, owner, name, environment, false); const rows = await listSecretNames(scope); return Response.json(rows.map((row) => ({ name: row.name, updated_at: row.updatedAt }))); } export async function putSecretApi(ctx: ApiContext, owner: string, name: string, key: string, environment?: string) { const data = await readJson(ctx.request, valueInput); await limitWrite(ctx.viewer); const scope = await adminScope(ctx, owner, name, environment); await upsertSecret(scope, configName(key), data.value); return noContent(); } export async function deleteSecretApi(ctx: ApiContext, owner: string, name: string, key: string, environment?: string) { await limitWrite(ctx.viewer); const scope = await adminScope(ctx, owner, name, environment, false); await deleteConfigValue("secret", scope, configName(key)); return noContent(); } export async function listVariablesApi(ctx: ApiContext, owner: string, name: string, environment?: string) { const scope = await adminScope(ctx, owner, name, environment, false); const rows = await listVariables(scope); return Response.json(rows.map((row) => ({ name: row.name, value: row.value, updated_at: row.updatedAt }))); } export async function putVariableApi(ctx: ApiContext, owner: string, name: string, key: string, environment?: string) { const data = await readJson(ctx.request, valueInput); await limitWrite(ctx.viewer); const scope = await adminScope(ctx, owner, name, environment); await upsertVariable(scope, configName(key), data.value); return noContent(); } export async function deleteVariableApi( ctx: ApiContext, owner: string, name: string, key: string, environment?: string, ) { await limitWrite(ctx.viewer); const scope = await adminScope(ctx, owner, name, environment, false); await deleteConfigValue("variable", scope, configName(key)); return noContent(); } export async function listDeploymentsApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "read"); const rows = await repoDb(repo.id).all( `SELECT d.*, e.name AS environment_name, w.run_number FROM deployments d JOIN environments e ON e.id = d.environment_id JOIN workflow_runs w ON w.id = d.run_id WHERE d.repo_id = ? ORDER BY d.created_at DESC LIMIT ?`, repo.id, DEPLOYMENT_PAGE, ); return Response.json( rows.map((row) => ({ id: row.id, environment: row.environment_name, status: row.status, ref: row.ref, sha: row.sha, url: row.url, run_number: row.run_number, created_at: row.created_at, })), ); } export async function reviewDeploymentApi(ctx: ApiContext, owner: string, name: string, id: string) { const data = await readJson(ctx.request, reviewInput); await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "read"); await requireTokenWrite(ctx, repo, ["actions"]); assertNotArchived(repo); if (!(await reviewDeployment(repo.id, id, ctx.viewer.id, data.approve))) { throw forbidden("You are not a required reviewer for this deployment"); } return noContent(); } export async function listCachesApi(ctx: ApiContext, owner: string, name: string): Promise { const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "write"); const rows = await repoDb(repo.id).all<{ id: string; ref: string; key: string; size: number; last_used_at: number }>( `SELECT id, ref, key, size, last_used_at FROM action_caches WHERE repo_id = ? AND size > 0 ORDER BY last_used_at DESC LIMIT ?`, repo.id, CACHE_PAGE, ); return Response.json({ quota_bytes: cacheQuotaBytes(), caches: rows }); } export async function listStatusesApi(ctx: ApiContext, owner: string, name: string, sha: string) { const { repo } = await requireScopedRepo(ctx, owner, name, "contents", "read"); if (!/^[0-9a-f]{40}$/.test(sha)) throw badRequest("Expected a full commit SHA"); const rows = await listCommitStatuses(repo.id, sha); return Response.json({ state: combinedState(rows), statuses: rows.map((row) => ({ context: row.context, state: row.state, description: row.description, target_url: row.target_url, source: row.source, })), }); } export async function deleteCacheApi(ctx: ApiContext, owner: string, name: string, id: string): Promise { await limitWrite(ctx.viewer); const { repo } = await requireScopedRepo(ctx, owner, name, "actions", "write"); await deleteCacheEntry(repo.id, id); return noContent(); }