Vectrum
Sign In
191 lines · 8.5 KB · TypeScript
import type { Actor } from "./actor";
import type { RepoEvent } from "./events";
import { defaultBranch, getCommit, git, listTree, mergeBase, readBlob, revParse } from "./git";
import { canWrite, repoAccess, type RepoRecord } from "./repos";
import { createRun, type Run, type RunEvent } from "./runs";
import { getUserByUsername } from "./users";
import {
  EVENT_TRIGGERS,
  isWorkflowFile,
  matchesFilter,
  matchesPaths,
  parseWorkflow,
  triggerMatches,
  WORKFLOW_DIR,
  WorkflowError,
  type EventTriggerName,
  type Workflow,
} from "./workflows";

// Starting workflow runs: on pushes (over SSH, HTTPS or from the web editor), on issues, comments,
// pull requests and reviews, 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;
  }
}

type RunStart = { event: RunEvent; ref: string; sha: string; actor: Actor | null; payload?: Record<string, unknown> | null; title?: string };

async function start(repo: RepoRecord, file: WorkflowFile, run: RunStart) {
  const commit = run.title ? null : await getCommit(repo.dir, run.sha);
  return createRun({
    repoId: repo.id,
    workflowPath: file.path,
    workflowName: "workflow" in file ? file.workflow.name : file.name,
    event: run.event,
    ref: run.ref,
    sha: run.sha,
    actorId: run.actor?.userId ?? null,
    agentName: run.actor?.agent?.name ?? null,
    payload: run.payload ?? null,
    title: 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", "--end-of-options", 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, actor: Actor | 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, actor });
  }
}

/** Starts the push workflows for refs a push created or moved, given the refs before and after it. */
export async function startPushWorkflows(repo: RepoRecord, before: Map<string, string>, after: Map<string, string>, actor: Actor | null) {
  for (const [ref, sha] of after) {
    const old = before.get(ref) ?? null;
    if (old === sha) continue;
    await onPush(repo, ref, old, sha, actor);
  }
}

const obj = (v: unknown) => (typeof v === "object" && v !== null ? (v as Record<string, unknown>) : {});

/**
 * Starts the workflows that run on an issue, comment, pull request or review event. Issue events
 * run the workflows of the default branch (as it is now); pull request events those of the pull
 * request's head, filtered by its base branch and the files it changes. Comments on pull requests
 * are issue_comment events with the pull request in their payload, like on GitHub. Broken workflow
 * files are skipped here: they already fail on every push.
 *
 * The changes of a pull request from a fork only run by themselves when its author has push access
 * here: the runners are the repository's own. Otherwise someone with push access looks at them and
 * starts them (`approved`).
 */
export async function startEventWorkflows(
  repo: RepoRecord,
  event: Pick<RepoEvent, "kind" | "action" | "subject" | "payload">,
  actor: Actor | null,
  opts: { approved?: boolean } = {},
) {
  if (!(event.kind in EVENT_TRIGGERS)) return 0;
  const kind = event.kind as EventTriggerName;
  const pr = obj(event.payload.pull_request);
  let ref: string;
  let sha: string | null;
  let base: string | null = null;
  if ((kind === "pull_request" || kind === "pull_request_review") && pr.number !== undefined) {
    const head = obj(pr.head);
    const fork = typeof head.repo === "string" && head.repo !== "";
    if (fork && !opts.approved) {
      const author = await getUserByUsername(String(pr.author ?? ""));
      if (!author || !canWrite(await repoAccess(repo, author.id))) return 0;
    }
    ref = fork ? `refs/pull/${pr.number}/head` : `refs/heads/${head.ref}`;
    sha = typeof head.sha === "string" ? head.sha : null;
    base = String(obj(pr.base).ref ?? "");
  } else {
    const branch = await defaultBranch(repo.dir);
    if (!branch) return 0;
    ref = `refs/heads/${branch}`;
    sha = await revParse(repo.dir, ref);
  }
  if (!sha || !(await revParse(repo.dir, sha))) return 0;

  let changed: string[] | null | undefined;
  let started = 0;
  const title = String(obj(event.payload.issue).title ?? pr.title ?? "") || event.subject;
  for (const file of await readWorkflows(repo.dir, sha)) {
    if (!("workflow" in file)) continue;
    const trigger = file.workflow.events[kind];
    if (!trigger || !triggerMatches(trigger, event.action)) continue;
    if (base !== null) {
      if (!matchesFilter(trigger.branches, base)) continue;
      if (trigger.paths.include || trigger.paths.ignore) {
        if (changed === undefined) {
          const baseSha = await revParse(repo.dir, `refs/heads/${base}`);
          const from = baseSha && (await mergeBase(repo.dir, baseSha, sha));
          changed = from ? await changedFiles(repo.dir, from, sha) : null;
        }
        if (!matchesPaths(trigger.paths, changed)) continue;
      }
    }
    await start(repo, file, { event: kind, ref, sha, actor, payload: { event: kind, ...event.payload }, title: `${event.subject} ${title}`.trim() });
    started++;
  }
  return started;
}


/** 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, actor: Actor) {
  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, actor });
}

/** Runs a run's workflow again, as it was at the run's commit, for the same event. Returns the new run's number. */
export async function rerunWorkflow(repo: RepoRecord, run: Run, actor: Actor) {
  const file = await readWorkflow(repo.dir, run.sha, run.workflowPath);
  return start(repo, file, { event: run.event, ref: run.ref, sha: run.sha, actor, payload: run.payload, title: run.payload ? run.title : undefined });
}