import { getCommit, git, listTree, readBlob, revParse } from "./git";
import type { RepoRecord } from "./repos";
import { createRun, type Run, type RunEvent } from "./runs";
import { isWorkflowFile, matchesFilter, matchesPaths, parseWorkflow, WORKFLOW_DIR, WorkflowError, type Workflow } from "./workflows";
// Starting workflow runs: on pushes (over SSH, HTTPS or from the web editor), by hand, and again.
// Like lib/git.ts this is used by the SSH server, so it must not import anything from next/*.
export class CiError extends Error {}
type WorkflowFile = { path: string; workflow: Workflow } | { path: string; error: string; name: string };
/** The workflow files at a commit, parsed; files that don't parse carry their error instead. */
export async function readWorkflows(dir: string, sha: string): Promise<WorkflowFile[]> {
const entries = await listTree(dir, sha, WORKFLOW_DIR).catch(() => []);
const files: WorkflowFile[] = [];
for (const entry of entries) {
if (entry.type !== "blob" || !isWorkflowFile(entry.name)) continue;
files.push(await readWorkflow(dir, sha, entry.path));
}
return files;
}
async function readWorkflow(dir: string, sha: string, path: string): Promise<WorkflowFile> {
const name = path.split("/").pop()!.replace(/\.ya?ml$/i, "");
const blob = await readBlob(dir, sha, path).catch(() => null);
if (!blob) return { path, name, error: `${path} doesn't exist at this commit.` };
if (blob.tooLarge || blob.binary) return { path, name, error: "The workflow file is too large or not text." };
try {
return { path, workflow: parseWorkflow(blob.content.toString("utf8"), path) };
} catch (e) {
if (e instanceof WorkflowError) return { path, name, error: e.message };
throw e;
}
}
async function start(repo: RepoRecord, file: WorkflowFile, run: { event: RunEvent; ref: string; sha: string; actorId: string | null }) {
const commit = await getCommit(repo.dir, run.sha);
return createRun({
repoId: repo.id,
workflowPath: file.path,
workflowName: "workflow" in file ? file.workflow.name : file.name,
...run,
title: commit?.subject || run.sha.slice(0, 7),
jobs: "workflow" in file ? file.workflow.jobs : { error: file.error },
});
}
/** Files changed between two commits, or null when that isn't known (a new branch). */
async function changedFiles(dir: string, before: string | null, after: string) {
if (!before) return null;
const out = await git(dir, ["diff", "--name-only", "-z", before, after]).catch(() => null);
return out === null ? null : out.split("\0").filter(Boolean);
}
/** Starts the workflows that run on a push of `ref` (refs/heads/… or refs/tags/…). */
async function onPush(repo: RepoRecord, ref: string, before: string | null, after: string, actorId: string | null) {
const sha = await revParse(repo.dir, after);
if (!sha) return;
const tag = ref.startsWith("refs/tags/");
const name = ref.replace(/^refs\/(heads|tags)\//, "");
let changed: string[] | null | undefined;
for (const file of await readWorkflows(repo.dir, sha)) {
// A broken file can't say when it runs, so it fails on every push, where its author will see it.
if ("workflow" in file) {
const push = file.workflow.push;
if (!push || !matchesFilter(tag ? push.tags : push.branches, name)) continue;
if (!tag) {
changed ??= await changedFiles(repo.dir, before, sha);
if (!matchesPaths(push.paths, changed)) continue;
}
}
await start(repo, file, { event: "push", ref, sha, actorId });
}
}
/** Starts workflows for whatever a push changed, given the refs before and after it. */
export async function onRefsUpdated(repo: RepoRecord, before: Map<string, string>, after: Map<string, string>, actorId: string | null) {
for (const [ref, sha] of after) {
const old = before.get(ref) ?? null;
if (old === sha) continue;
await onPush(repo, ref, old, sha, actorId);
}
}
/** For callers that shouldn't wait on (or fail because of) workflows starting. */
export function startWorkflowsInBackground(work: Promise<unknown>) {
work.catch((e) => console.error("[ci] could not start workflows:", e));
}
/** Starts a workflow by hand on a branch, if it has `on: workflow_dispatch`. Returns the run number. */
export async function dispatchWorkflow(repo: RepoRecord, path: string, branch: string, actorId: string) {
const sha = await revParse(repo.dir, `refs/heads/${branch}`);
if (!sha) throw new CiError(`There is no branch named ${branch}.`);
const file = await readWorkflow(repo.dir, sha, path);
if ("error" in file) throw new CiError(file.error);
if (!file.workflow.dispatch) throw new CiError("This workflow can't be run by hand. Add workflow_dispatch to its on: triggers.");
return start(repo, file, { event: "workflow_dispatch", ref: `refs/heads/${branch}`, sha, actorId });
}
/** Runs a run's workflow again, as it was at the run's commit. Returns the new run's number. */
export async function rerunWorkflow(repo: RepoRecord, run: Run, actorId: string) {
const file = await readWorkflow(repo.dir, run.sha, run.workflowPath);
return start(repo, file, { event: run.event, ref: run.ref, sha: run.sha, actorId });
}