import { env } from "cloudflare:workers"; import { first } from "./db"; import { repoDb, stmt } from "./scoped-db"; import { loadRepoById, repoPermission } from "./repos"; import { loadUserById, toViewer } from "./auth"; import { addIssueEvent, checkMergeability, loadPullByIssueId, mergeGate, MergeError, performMerge } from "./pulls"; import type { PullRow } from "./pulls"; import { closeLinkedIssues } from "./pull-links"; import { restackAfterMerge, stackParent } from "./stacks"; import { ownerAccountIds, pathsAwaitingOwners, pullCodeOwners } from "./codeowners"; import { emitWebhook, recordActivity } from "./events/emit"; import { pullUrl } from "./pull-access"; import type { MergeMethod, RepoRow, Viewer } from "~/lib/types"; const MAX_AUTO_MERGES_PER_PASS = 10; const MAX_STATUS_FANOUT = 10; const MAX_LISTED_OWNERS = 5; function displayOwner(token: string): string { if (token.startsWith("unresolved:")) return token.slice("unresolved:".length); if (token.startsWith("email:")) return `${token.slice("email:".length)} (no verified account)`; return `@${token} (no account)`; } function queueStub(repoId: string, baseRef: string) { return env.MERGE_QUEUE.get(env.MERGE_QUEUE.idFromName(`${repoId}:${baseRef}`)); } /** Wakes the per-branch merge Durable Object so it re-evaluates auto-merges and the merge queue. */ export async function kickBranch(repoId: string, baseRef: string): Promise { await queueStub(repoId, baseRef).kick(repoId, baseRef); } async function hasAutomation(repoId: string, issueId: string): Promise { const row = await repoDb(repoId).first( `SELECT 1 FROM pull_auto_merges WHERE issue_id = ?1 UNION ALL SELECT 1 FROM merge_queue_entries WHERE issue_id = ?1 AND state IN ('queued', 'testing') LIMIT 1`, issueId, ); return row !== null; } export async function kickMergeAutomation(pull: PullRow): Promise { if (await hasAutomation(pull.repo_id, pull.issue_id)) await kickBranch(pull.repo_id, pull.base_ref); } /** Called when a commit status settles: wakes branches whose auto-merge or queue entry waits on it. */ export async function onCommitStatus(repoId: string, sha: string): Promise { const rows = await repoDb(repoId).all<{ base_ref: string }>( `SELECT DISTINCT p.base_ref FROM pull_auto_merges am JOIN pull_requests p ON p.issue_id = am.issue_id WHERE p.repo_id = ?1 AND p.head_sha = ?2 UNION SELECT base_ref FROM merge_queue_entries WHERE repo_id = ?1 AND speculative_sha = ?2 AND state = 'testing' LIMIT ?3`, repoId, sha, MAX_STATUS_FANOUT, ); for (const row of rows) await kickBranch(repoId, row.base_ref); } /** Code owner blockers for the merge gate; fails closed when ownership cannot be fully evaluated. */ export async function codeOwnerBlockers(pull: PullRow, approvers: Set): Promise { const [baseRepo, headRepo] = await Promise.all([loadRepoById(pull.repo_id), loadRepoById(pull.head_repo_id)]); if (!baseRepo || !headRepo) return ["Code owner review cannot be evaluated"]; const { owned, incomplete } = await pullCodeOwners(pull, baseRepo, headRepo); if (incomplete) return [`Code owner review cannot be evaluated: ${incomplete}`]; if (owned.size === 0) return []; const ids = await ownerAccountIds(owned); const waiting = pathsAwaitingOwners(owned, ids, approvers, pull.author_id); if (waiting.length === 0) return []; const blockers = [`Code owner review required for ${waiting.length} file(s)`]; const unresolved = [ ...new Set(waiting.flatMap((path) => (owned.get(path) ?? []).filter((owner) => !ids.has(owner)))), ]; if (unresolved.length > 0) { const names = unresolved.slice(0, MAX_LISTED_OWNERS).map(displayOwner).join(", "); blockers.push( `CODEOWNERS lists owners that cannot review here: ${names}. Teams are not supported; list users (@login) or ` + "verified emails instead, or ask an admin to bypass branch protection", ); } return blockers; } /** Post-merge bookkeeping shared by every merge path (button, auto-merge, queue, external push). */ export async function afterMerged( pull: PullRow, actorId: string | null, sha: string, method: MergeMethod | "push" = "merge", ): Promise { await repoDb(pull.repo_id).batch([ stmt("DELETE FROM pull_auto_merges WHERE issue_id = ?", pull.issue_id), stmt( "UPDATE merge_queue_entries SET state = 'merged', updated_at = ? WHERE issue_id = ? AND state IN ('queued', 'testing')", Date.now(), pull.issue_id, ), ]); const repo = await loadRepoById(pull.repo_id); if (repo && pull.base_ref === repo.default_branch) await closeLinkedIssues(repo, pull, actorId, sha); const children = await restackAfterMerge(pull, method, sha, actorId); if (children.length > 0) await kickBranch(pull.repo_id, pull.base_ref); } export function defaultMergeMessage(pull: PullRow, headRepo: RepoRow, method: MergeMethod): string { return method === "squash" ? `${pull.title} (#${pull.number})` : `Merge pull request #${pull.number} from ${headRepo.owner_name}/${pull.head_ref}\n\n${pull.title}`; } export async function mergeIdentity(pull: PullRow, merger: Viewer) { const author = await first<{ name: string; display_name: string | null; email: string; }>( "SELECT a.name, a.display_name, u.email FROM accounts a JOIN users u ON u.account_id = a.id WHERE a.id = ?", pull.author_id, ); return { merger: { id: merger.id, name: merger.displayName ?? merger.name, email: merger.email, }, author: { name: author?.display_name ?? author?.name ?? merger.name, email: author?.email ?? merger.email, }, }; } export async function announceMerged(repo: RepoRow, pull: PullRow, actor: Viewer, sha: string): Promise { await recordActivity(actor.id, repo.id, "pull_merged", { number: pull.number, title: pull.title, }); await emitWebhook(repo.id, "pull_request", { action: "closed", number: pull.number, pull_request: { number: pull.number, merged: true, merge_commit_sha: sha, html_url: pullUrl(repo, pull.number), }, sender: { login: actor.name, id: actor.id }, }); } export async function actorViewer(userId: string): Promise { const user = await loadUserById(userId); return user ? toViewer(user) : null; } type AutoMergeRow = { issue_id: string; method: MergeMethod; message: string; enabled_by: string; head_sha: string | null; }; async function attemptAutoMerge(repo: RepoRow, row: AutoMergeRow): Promise { const db = repoDb(repo.id); const loaded = await loadPullByIssueId(repo.id, row.issue_id); const actor = await actorViewer(row.enabled_by); if (!loaded || loaded.state !== "open" || !actor) { await db.run("DELETE FROM pull_auto_merges WHERE issue_id = ?", row.issue_id); return; } if (loaded.head_repo_id !== repo.id && row.head_sha !== loaded.head_sha) { await db.run("DELETE FROM pull_auto_merges WHERE issue_id = ?", row.issue_id); await addIssueEvent(loaded, null, "auto_merge_disabled", { reason: "New commits were pushed to the fork after auto-merge was enabled", }); return; } // Stacked pull requests wait for the pull requests below them, then merge into the retargeted base. if (await stackParent(loaded)) return; const mergeable = loaded.mergeable === "unknown" ? await checkMergeability(loaded) : loaded.mergeable; if (mergeable !== "clean") return; const pull = { ...loaded, mergeable }; const gate = await mergeGate(pull, await repoPermission(repo, actor)); if (!gate.allowed) return; const headRepo = pull.head_repo_id === repo.id ? repo : await loadRepoById(pull.head_repo_id); if (!headRepo) return; try { const sha = await performMerge({ pull, method: row.method, message: row.message.trim() || defaultMergeMessage(pull, headRepo, row.method), ...(await mergeIdentity(pull, actor)), }); await announceMerged(repo, pull, actor, sha); } catch (error) { if (!(error instanceof MergeError)) throw error; await db.run("DELETE FROM pull_auto_merges WHERE issue_id = ?", row.issue_id); await addIssueEvent(pull, null, "auto_merge_disabled", { reason: error.message, }); } } /** Merges every auto-merge pull request on the branch whose requirements are now met. */ export async function processAutoMerges(repoId: string, baseRef: string): Promise { const repo = await loadRepoById(repoId); if (!repo) return; const rows = await repoDb(repoId).all( `SELECT am.issue_id, am.method, am.message, am.enabled_by, am.head_sha FROM pull_auto_merges am JOIN pull_requests p ON p.issue_id = am.issue_id JOIN issues i ON i.id = am.issue_id WHERE am.repo_id = ? AND p.base_ref = ? AND (i.state = 'closed' OR p.mergeable != 'conflict') ORDER BY am.created_at LIMIT ?`, repoId, baseRef, MAX_AUTO_MERGES_PER_PASS, ); for (const row of rows) { await attemptAutoMerge(repo, row).catch((error) => console.error("Auto-merge attempt failed", row.issue_id, error)); } }