import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import { placeholders } from "~/server/db"; import { repoDb } from "~/server/scoped-db"; import { requireRepoAccess } from "~/server/repos"; import { rerunTrigger } from "~/server/ci/rerun"; import { publicUrl } from "~/server/events/emit"; import { pendingDeployments, visibleOutputs } from "~/server/ci/deployments"; import type { WorkflowJobRow, WorkflowRunRow } from "~/server/ci/jobs"; import { scheduleWorkflows } from "~/server/ci/scheduler"; import { approveRunRow, cancelRunRow, loadRunByNumber as loadRun } from "~/server/ci/run-control"; import { parseWorkflow, WORKFLOW_DIR } from "~/server/ci/spec"; import { listRefs, resolveRevision } from "~/server/git/refs"; import { withStore } from "~/server/git/store"; import { accountOrGhost, accountsById } from "~/server/people"; import { badRequest } from "~/server/errors"; import { limitBrowse, limitWrite, MAX_PAGE } from "~/server/limits"; import { repoRef } from "~/lib/validation"; import type { Conclusion, JobStatus } from "~/lib/types"; const PAGE_SIZE = 25; export const listRuns = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ workflow: z.string().optional(), page: z.number().int().min(1).max(MAX_PAGE).default(1) }), ) .handler(async ({ data, context }) => { const { repo, permission } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const rows = data.workflow ? await repoDb(repo.id).all( "SELECT * FROM workflow_runs WHERE repo_id = ? AND workflow_path = ? ORDER BY run_number DESC LIMIT ? OFFSET ?", repo.id, data.workflow, PAGE_SIZE + 1, (data.page - 1) * PAGE_SIZE, ) : await repoDb(repo.id).all( "SELECT * FROM workflow_runs WHERE repo_id = ? ORDER BY run_number DESC LIMIT ? OFFSET ?", repo.id, PAGE_SIZE + 1, (data.page - 1) * PAGE_SIZE, ); const workflows = await repoDb(repo.id).all<{ workflow_path: string; workflow_name: string }>( "SELECT workflow_path, MAX(workflow_name) AS workflow_name FROM workflow_runs WHERE repo_id = ? GROUP BY workflow_path ORDER BY workflow_name", repo.id, ); const people = await accountsById(rows.map((row) => row.actor_id)); return { canWrite: permission === "write" || permission === "admin", badgeBase: publicUrl(`/${repo.owner_name}/${repo.name}/actions/workflows/`), workflows: workflows.map((workflow) => ({ path: workflow.workflow_path, name: workflow.workflow_name })), hasMore: rows.length > PAGE_SIZE, runs: rows.slice(0, PAGE_SIZE).map((row) => ({ number: row.run_number, title: row.title, workflowName: row.workflow_name, workflowPath: row.workflow_path, event: row.event, ref: row.ref.replace(/^refs\/(heads|tags)\//, ""), sha: row.sha, status: row.status, conclusion: row.conclusion, actor: row.actor_id ? accountOrGhost(people, row.actor_id) : null, createdAt: row.created_at, startedAt: row.started_at, completedAt: row.completed_at, prNumber: row.pr_number, })), }; }); export const getRun = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int().positive() })) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const { repo, permission } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const runRow = await loadRun(repo.id, data.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<{ job_id: string; idx: number; name: string; status: string; conclusion: Conclusion | null; started_at: number | null; completed_at: number | null; }>( `SELECT * 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 people = await accountsById([runRow.actor_id]); const deployments = await pendingDeployments(runRow.id, context.viewer?.id ?? null); 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 { canWrite: permission === "write" || permission === "admin", deployments, run: { rerunOf: previous?.run_number ?? null, id: runRow.id, number: runRow.run_number, title: runRow.title, workflowName: runRow.workflow_name, workflowPath: runRow.workflow_path, event: runRow.event, ref: runRow.ref, sha: runRow.sha, status: runRow.status, conclusion: runRow.conclusion, actor: runRow.actor_id ? accountOrGhost(people, runRow.actor_id) : null, prNumber: runRow.pr_number, approvalRequired: runRow.approval_required === 1 && runRow.status === "queued", createdAt: runRow.created_at, startedAt: runRow.started_at, completedAt: runRow.completed_at, }, jobs: jobs.map((job) => ({ id: job.id, key: job.job_key, name: job.name, runsOn: JSON.parse(job.runs_on) as string[], needs: JSON.parse(job.needs) as string[], status: job.status as JobStatus, conclusion: job.conclusion, runnerName: job.runner_name, environment: job.environment, waiting: job.gate === "waiting" && job.status === "pending", outputs: visibleOutputs(job.outputs), startedAt: job.started_at, completedAt: job.completed_at, steps: steps .filter((step) => step.job_id === job.id) .map((step) => ({ index: step.idx, name: step.name, status: step.status, conclusion: step.conclusion, startedAt: step.started_at, completedAt: step.completed_at, })), })), artifacts, }; }); export const cancelRun = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int().positive() })) .handler(async ({ data, context }) => { await limitWrite(requireViewer(context.viewer)); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "write"); await cancelRunRow(await loadRun(repo.id, data.number)); return { ok: true }; }); export const rerunRun = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int().positive(), failedOnly: z.boolean().default(false) })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); const runRow = await loadRun(repo.id, data.number); const ids = await scheduleWorkflows(repo, await rerunTrigger(repo, runRow, viewer), { forceWorkflowPath: true, rerunOf: runRow.id, failedOnly: data.failedOnly === true, }); if (ids.length === 0) throw badRequest("The workflow file no longer exists at that commit"); const latest = await repoDb(repo.id).first<{ run_number: number }>( "SELECT run_number FROM workflow_runs WHERE id = ?", ids[0], ); return { number: latest?.run_number ?? data.number }; }); export const listWorkflowFiles = createServerFn({ method: "GET" }) .middleware([viewerMiddleware]) .validator(repoRef) .handler(async ({ data, context }) => { await limitBrowse(context.viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, context.viewer, "read"); const revision = resolveRevision(await listRefs(repo), repo.default_branch); if (!revision) return []; return withStore(repo.artifacts_name, async (store) => { const commit = await store.commit(revision.sha); const directory = commit ? await store.entryAtPath(commit.treeSha, WORKFLOW_DIR) : null; if (!directory || directory.type !== "tree") return []; const entries = (await store.listTree(directory.sha, WORKFLOW_DIR)).filter((entry) => /\.ya?ml$/.test(entry.name), ); return Promise.all( entries.map(async (entry) => { const bytes = await store.blob(entry.sha); const parsed = bytes ? parseWorkflow(new TextDecoder().decode(bytes)) : null; const inputs = parsed?.ok ? Object.entries(parsed.spec.on.workflow_dispatch?.inputs ?? {}) : []; return { path: entry.path, name: parsed?.ok ? (parsed.spec.name ?? entry.name) : entry.name, error: parsed && !parsed.ok ? parsed.error : null, dispatchable: Boolean(parsed?.ok && parsed.spec.on.workflow_dispatch), inputs: inputs.map(([name, input]) => ({ name, description: input.description ?? null, required: input.required ?? false, defaultValue: input.default ?? "", options: input.options ?? [], })), }; }), ); }); }); export const dispatchWorkflow = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( repoRef.extend({ path: z.string().min(1), ref: z.string().min(1), inputs: z.record(z.string(), z.string()).default({}), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "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: viewer.id, actorName: 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 { ok: true }; }); export const approveRun = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(repoRef.extend({ number: z.number().int().positive() })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { repo } = await requireRepoAccess(data.owner, data.repo, viewer, "write"); await approveRunRow(repo.id, await loadRun(repo.id, data.number)); return { ok: true }; });