import { env } from "cloudflare:workers"; import { all, first, run, batch, stmt } from "./db"; import { randomId } from "./crypto"; import { loadRepoById } from "./repos"; import { loadProtections, protectionFor, bypassesProtection } from "./protection"; import { listCommitStatuses } from "./statuses"; import { authenticatedRemote, mintRepoToken } from "./git/artifacts"; import { ZERO_SHA, withStore } from "./git/store"; import { isAncestor } from "./git/history"; import { triggerPush } from "./events/push-trigger"; import type { MergeMethod, Permission, RepoRow } from "~/lib/types"; const MERGE_TOKEN_TTL_SECONDS = 1800; const MAX_EXTERNAL_MERGE_CHECKS = 10; const EXIT_CONFLICT = 2; const EXIT_HEAD_MOVED = 3; const EXIT_BASE_MOVED = 6; export type PullRow = { issue_id: string; repo_id: string; number: number; title: string; body: string; state: "open" | "closed"; author_id: string; head_repo_id: string; head_ref: string; head_sha: string; base_ref: string; base_sha: string; merge_base_sha: string | null; draft: number; mergeable: "unknown" | "clean" | "conflict"; merged_at: number | null; merged_by: string | null; merge_commit_sha: string | null; merge_method: string | null; }; export const PULL_SELECT = `SELECT p.*, i.number, i.title, i.body, i.state, i.author_id, i.repo_id FROM pull_requests p JOIN issues i ON i.id = p.issue_id`; export async function loadPull(repoId: string, number: number): Promise { return first(`${PULL_SELECT} WHERE i.repo_id = ? AND i.number = ? AND i.is_pull = 1`, repoId, number); } export async function loadPullByIssueId(issueId: string): Promise { return first(`${PULL_SELECT} WHERE p.issue_id = ?`, issueId); } export async function addIssueEvent( issueId: string, actorId: string | null, kind: string, data: Record, ) { await run( "INSERT INTO issue_events (id, issue_id, actor_id, kind, data, created_at) VALUES (?, ?, ?, ?, ?, ?)", randomId(), issueId, actorId, kind, JSON.stringify(data), Date.now(), ); } export type SynchronizedPull = { pull: PullRow; before: string }; /** Applies a pushed branch update to every open pull request that uses it as head or base. */ export async function syncPullsForPush( repo: RepoRow, branch: string, after: string, pusherId: string | null, ): Promise<{ synchronized: SynchronizedPull[]; mergedExternally: PullRow[]; baseUpdated: PullRow[] }> { const now = Date.now(); const heads = await all( `${PULL_SELECT} WHERE p.head_repo_id = ? AND p.head_ref = ? AND i.state = 'open'`, repo.id, branch, ); const synchronized: SynchronizedPull[] = []; const protections = await loadProtections(repo.id); for (const pull of heads) { if (after === ZERO_SHA) { await addIssueEvent(pull.issue_id, pusherId, "head_ref_deleted", { ref: branch }); continue; } if (pull.head_sha === after) continue; await batch([ stmt("UPDATE pull_requests SET head_sha = ?, mergeable = 'unknown' WHERE issue_id = ?", after, pull.issue_id), stmt("UPDATE issues SET updated_at = ? WHERE id = ?", now, pull.issue_id), ]); await addIssueEvent(pull.issue_id, pusherId, "head_pushed", { before: pull.head_sha, after }); const baseRepoProtections = pull.repo_id === repo.id ? protections : await loadProtections(pull.repo_id); if (protectionFor(baseRepoProtections, pull.base_ref)?.dismissStaleReviews) { await run("UPDATE reviews SET state = 'dismissed' WHERE issue_id = ? AND state = 'approved'", pull.issue_id); } synchronized.push({ pull: { ...pull, head_sha: after }, before: pull.head_sha }); } const bases = await all( `${PULL_SELECT} WHERE i.repo_id = ? AND p.base_ref = ? AND i.state = 'open'`, repo.id, branch, ); const mergedExternally: PullRow[] = []; const baseUpdated: PullRow[] = []; if (after !== ZERO_SHA) { await withStore(repo.artifacts_name, async (store) => { for (const [index, pull] of bases.entries()) { await run( "UPDATE pull_requests SET base_sha = ?, mergeable = 'unknown' WHERE issue_id = ?", after, pull.issue_id, ); const merged = pull.head_repo_id === repo.id && index < MAX_EXTERNAL_MERGE_CHECKS && (pull.head_sha === after || (await isAncestor(store, pull.head_sha, after))); if (merged) { await markMerged(pull, pusherId, after, "push"); mergedExternally.push(pull); } else { baseUpdated.push({ ...pull, base_sha: after }); } } }); } return { synchronized, mergedExternally, baseUpdated }; } export async function markMerged( pull: PullRow, actorId: string | null, mergeSha: string, method: string, ): Promise { const now = Date.now(); await batch([ stmt( `UPDATE pull_requests SET merged_at = ?, merged_by = ?, merge_commit_sha = ?, merge_method = ?, mergeable = 'clean' WHERE issue_id = ?`, now, actorId, mergeSha, method, pull.issue_id, ), stmt( "UPDATE issues SET state = 'closed', closed_at = ?, closed_by = ?, updated_at = ? WHERE id = ? AND state = 'open'", now, actorId, now, pull.issue_id, ), stmt("UPDATE repos SET open_pulls_count = MAX(open_pulls_count - 1, 0) WHERE id = ?", pull.repo_id), ]); await addIssueEvent(pull.issue_id, actorId, "merged", { sha: mergeSha, method }); } function gitTaskRunner(repoId: string) { return env.RUNNER.get(env.RUNNER.idFromName(`git:${repoId}`)); } async function remotesFor(pull: PullRow, scope: "read" | "write") { const [baseRepo, headRepo] = await Promise.all([loadRepoById(pull.repo_id), loadRepoById(pull.head_repo_id)]); if (!baseRepo || !headRepo) throw new Error("Pull request repositories are missing"); const baseSecret = await mintRepoToken(baseRepo.artifacts_name, scope, MERGE_TOKEN_TTL_SECONDS); const headSecret = headRepo.id === baseRepo.id ? baseSecret : await mintRepoToken(headRepo.artifacts_name, "read", MERGE_TOKEN_TTL_SECONDS); return { baseRepo, headRepo, baseUrl: authenticatedRemote(baseRepo.artifacts_remote, baseSecret), headUrl: authenticatedRemote(headRepo.artifacts_remote, headSecret), }; } const FETCH_SCRIPT = `set -uo pipefail work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT cd "$work" git init -q . git config user.name "$MERGER_NAME" git config user.email "$MERGER_EMAIL" git fetch -q "$BASE_URL" "+refs/heads/$BASE_REF:refs/stig/base" || { echo "failed to fetch base" >&2; exit 4; } git fetch -q "$HEAD_URL" "+refs/heads/$HEAD_REF:refs/stig/head" || { echo "failed to fetch head" >&2; exit 4; } if [ "$(git rev-parse refs/stig/head)" != "$EXPECTED_HEAD" ]; then echo "head branch changed" >&2; exit 3; fi echo "BEFORE $(git rev-parse refs/stig/base)" `; const MERGEABILITY_SCRIPT = `${FETCH_SCRIPT} if git merge-tree --write-tree --name-only --no-messages refs/stig/base refs/stig/head > "$work/out"; then echo "CLEAN" else echo "CONFLICT" tail -n +2 "$work/out" fi `; const MERGE_SCRIPT = `${FETCH_SCRIPT} git checkout -q -B work refs/stig/base case "$MERGE_METHOD" in merge) git merge -q --no-ff -m "$MERGE_MESSAGE" refs/stig/head || { git merge --abort; echo "merge conflict" >&2; exit 2; } ;; squash) git merge -q --squash refs/stig/head || { echo "merge conflict" >&2; exit 2; } GIT_AUTHOR_NAME="$AUTHOR_NAME" GIT_AUTHOR_EMAIL="$AUTHOR_EMAIL" git commit -q --allow-empty -m "$MERGE_MESSAGE" || exit 5 ;; rebase) git checkout -q -B rebasing refs/stig/head git rebase -q work || { git rebase --abort; echo "rebase conflict" >&2; exit 2; } git checkout -q work git merge -q --ff-only rebasing || exit 5 ;; esac git push -q "$BASE_URL" "HEAD:refs/heads/$BASE_REF" || { echo "push rejected; base branch moved" >&2; exit 6; } echo "AFTER $(git rev-parse HEAD)" `; export async function checkMergeability(pull: PullRow): Promise<"clean" | "conflict" | "unknown"> { const remotes = await remotesFor(pull, "read"); const result = await gitTaskRunner(pull.repo_id).runGitTask(MERGEABILITY_SCRIPT, { MERGER_NAME: "stig", MERGER_EMAIL: "stig@localhost", BASE_URL: remotes.baseUrl, HEAD_URL: remotes.headUrl, BASE_REF: pull.base_ref, HEAD_REF: pull.head_ref, EXPECTED_HEAD: pull.head_sha, }); const state = result.stdout.includes("CLEAN") ? "clean" : result.stdout.includes("CONFLICT") ? "conflict" : "unknown"; if (state !== "unknown") { await run( "UPDATE pull_requests SET mergeable = ? WHERE issue_id = ? AND head_sha = ?", state, pull.issue_id, pull.head_sha, ); } return state; } export type MergeGate = { allowed: boolean; blockers: string[]; bypassed: string[] }; /** Evaluates branch protection requirements (approvals, checks, change requests) for merging. */ export async function mergeGate(pull: PullRow, permission: Permission): Promise { const blockers: string[] = []; if (pull.state !== "open") blockers.push("Pull request is not open"); if (pull.draft === 1) blockers.push("Pull request is still a draft"); if (pull.mergeable === "conflict") blockers.push("Merge conflicts must be resolved"); const protection = protectionFor(await loadProtections(pull.repo_id), pull.base_ref); const protectionBlockers: string[] = []; if (protection) { const reviews = await all<{ author_id: string; state: string }>( `SELECT author_id, state FROM reviews WHERE issue_id = ? AND state != 'commented' ORDER BY created_at`, pull.issue_id, ); const latest = new Map(); for (const review of reviews) { if (review.author_id !== pull.author_id) latest.set(review.author_id, review.state); } const approvals = [...latest.values()].filter((state) => state === "approved").length; if (approvals < protection.requiredApprovals) { protectionBlockers.push(`${protection.requiredApprovals - approvals} more approving review(s) required`); } if ([...latest.values()].includes("changes_requested")) protectionBlockers.push("Changes were requested"); if (protection.requiredChecks.length > 0) { const statuses = await listCommitStatuses(pull.repo_id, pull.head_sha); for (const check of protection.requiredChecks) { const status = statuses.find((candidate) => candidate.context === check); if (!status || status.state !== "success") protectionBlockers.push(`Required check "${check}" has not passed`); } } } const bypass = protection !== null && bypassesProtection(protection, permission); if (!bypass) blockers.push(...protectionBlockers); const canWrite = permission === "write" || permission === "admin"; return { allowed: blockers.length === 0 && canWrite, blockers, bypassed: bypass ? protectionBlockers : [] }; } export class MergeError extends Error {} export async function performMerge(input: { pull: PullRow; method: MergeMethod; message: string; merger: { id: string; name: string; email: string }; author: { name: string; email: string }; }): Promise { const remotes = await remotesFor(input.pull, "write"); const result = await gitTaskRunner(input.pull.repo_id).runGitTask(MERGE_SCRIPT, { MERGER_NAME: input.merger.name, MERGER_EMAIL: input.merger.email, AUTHOR_NAME: input.author.name, AUTHOR_EMAIL: input.author.email, BASE_URL: remotes.baseUrl, HEAD_URL: remotes.headUrl, BASE_REF: input.pull.base_ref, HEAD_REF: input.pull.head_ref, EXPECTED_HEAD: input.pull.head_sha, MERGE_METHOD: input.method, MERGE_MESSAGE: input.message, }); const before = /BEFORE ([0-9a-f]{40})/.exec(result.stdout)?.[1]; const after = /AFTER ([0-9a-f]{40})/.exec(result.stdout)?.[1]; if (result.code !== 0 || !before || !after) { if (result.code === EXIT_CONFLICT) { await run("UPDATE pull_requests SET mergeable = 'conflict' WHERE issue_id = ?", input.pull.issue_id); throw new MergeError("This branch has conflicts that must be resolved"); } if (result.code === EXIT_HEAD_MOVED) throw new MergeError("The head branch changed; reload and try again"); if (result.code === EXIT_BASE_MOVED) throw new MergeError("The base branch moved during the merge; try again"); throw new MergeError(`Merge failed: ${result.stderr.trim() || `exit ${result.code}`}`); } await markMerged(input.pull, input.merger.id, after, input.method); await triggerPush({ source: "internal", repoId: remotes.baseRepo.id, artifactsName: remotes.baseRepo.artifacts_name, ref: `refs/heads/${input.pull.base_ref}`, before, after, pusherId: input.merger.id, }); return after; }