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);
}