import type { Actor } from "./actor"; import { startPushWorkflows } from "./ci"; import { emitEvent } from "./events"; import { commitsBetween, defaultBranch, getCommit, isAncestor, pinDefaultBranch, 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, after: Map, 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; await pinDefaultBranch(repo.dir); 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, after: Map, 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); }