Vectrum
Sign In
99 lines · 4.7 KB · TypeScript
import type { Actor } from "./actor";
import { startPushWorkflows } from "./ci";
import { emitEvent } from "./events";
import { commitsBetween, defaultBranch, getCommit, isAncestor, revParse, type Commit } from "./git";
import { syncPullsAfterPush } from "./pulls";
import type { RepoRecord } from "./repos";

/*
 * What follows a change to a repository's branches and tags, however it happened (a git push over
 * SSH or HTTPS, the web editor, an agent's commit, a merge, a release creating its tag): events for
 * the feed and webhooks, the workflows the push starts, and pull requests catching up. Like lib/ci.ts
 * this is used by the SSH server, so it must not import anything from next/*.
 */

/** Commits listed in a push event. */
const MAX_LISTED = 20;

const commitPayload = (c: Commit) => ({
  sha: c.sha,
  message: c.body ? `${c.subject}\n\n${c.body}` : c.subject,
  author: { name: c.authorName, email: c.authorEmail },
  committer: { name: c.committerName, email: c.committerEmail },
  timestamp: new Date(c.date).toISOString(),
});

async function pushedCommits(repo: RepoRecord, ref: string, before: string | null, after: string) {
  if (ref.startsWith("refs/tags/")) return { commits: [], total: 0, forced: false };
  if (before) {
    if (!(await isAncestor(repo.dir, before, after))) {
      const head = await getCommit(repo.dir, after);
      return { commits: head ? [head] : [], total: head ? 1 : 0, forced: true };
    }
    const commits = await commitsBetween(repo.dir, before, after, 1000);
    return { commits: commits.slice(0, MAX_LISTED), total: commits.length, forced: false };
  }
  // A new branch: what it has that the default branch doesn't.
  const main = await defaultBranch(repo.dir);
  const mainSha = main && `refs/heads/${main}` !== ref ? await revParse(repo.dir, `refs/heads/${main}`) : null;
  const commits = mainSha ? await commitsBetween(repo.dir, mainSha, after, 1000) : [];
  if (commits.length === 0) {
    const head = await getCommit(repo.dir, after);
    return { commits: head ? [head] : [], total: head ? 1 : 0, forced: false };
  }
  return { commits: commits.slice(0, MAX_LISTED), total: commits.length, forced: false };
}

async function announcePush(repo: RepoRecord, ref: string, before: string | null, after: string | null, actor: Actor | null) {
  const tag = ref.startsWith("refs/tags/");
  const name = ref.replace(/^refs\/(heads|tags)\//, "");
  const refType = tag ? "tag" : "branch";
  if (!before && after) await emitEvent(repo, { kind: "create", actor, subject: name, payload: { ref: name, ref_type: refType, sha: after } });
  if (before && !after) await emitEvent(repo, { kind: "delete", actor, subject: name, payload: { ref: name, ref_type: refType, sha: before } });
  const { commits, total, forced } = after ? await pushedCommits(repo, ref, before, after) : { commits: [], total: 0, forced: false };
  await emitEvent(repo, {
    kind: "push",
    actor,
    subject: name,
    payload: {
      ref,
      ref_type: refType,
      before,
      after,
      created: !before,
      deleted: !after,
      forced,
      commits: commits.map(commitPayload),
      total_commits: total,
    },
  });
}

/** Everything that follows a change of refs, given the refs before and after it. */
export async function afterRefsUpdated(repo: RepoRecord, before: Map<string, string>, after: Map<string, string>, actor: Actor | null) {
  const refs = new Set([...before.keys(), ...after.keys()]);
  const changes: { ref: string; before: string | null; after: string | null }[] = [];
  for (const ref of refs) {
    const old = before.get(ref) ?? null;
    const now = after.get(ref) ?? null;
    if (old !== now && (ref.startsWith("refs/heads/") || ref.startsWith("refs/tags/"))) changes.push({ ref, before: old, after: now });
  }
  if (changes.length === 0) return;
  for (const c of changes) await announcePush(repo, c.ref, c.before, c.after, actor);
  await startPushWorkflows(repo, before, after, actor);
  await syncPullsAfterPush(
    repo,
    changes.filter((c) => c.ref.startsWith("refs/heads/")).map((c) => ({ branch: c.ref.slice(11), before: c.before, after: c.after })),
    actor,
  );
}

/** For callers that shouldn't wait on (or fail because of) what follows. */
export function afterRefsUpdatedInBackground(repo: RepoRecord, before: Map<string, string>, after: Map<string, string>, actor: Actor | null) {
  afterRefsUpdated(repo, before, after, actor).catch((e) => console.error("[push] could not process a push:", e));
}

/** The same for one ref (refs/heads/… or refs/tags/…) moving from `before` to `after`. */
export function afterRefUpdated(repo: RepoRecord, ref: string, before: string | null, after: string | null, actor: Actor | null) {
  afterRefsUpdatedInBackground(repo, new Map(before ? [[ref, before]] : []), new Map(after ? [[ref, after]] : []), actor);
}