import { first, run } from "./db"; import { repoDb, stmt } from "./scoped-db"; import { registerLocator } from "./scoped-db/locators"; import { scopedId } from "./scoped-db/ids"; import { captureServerEvent } from "./analytics"; import { badRequest, conflict, forbidden, notFoundError } from "./errors"; import { emitNotification, emitWebhook, recordActivity } from "./events/emit"; import { compareCommits } from "./git/history"; import { canBridge, isPullHeadRepo } from "./git/bridge-policy"; import { branchSha, listRefs } from "./git/refs"; import { withStore } from "./git/store"; import { deleteRef } from "./git/writer"; import { allocateIssueNumber, indexIssue } from "./issues"; import { pullUrl } from "./pull-access"; import { assertNotArchived } from "./repos"; import { afterPullChange } from "./pull-events"; import { addIssueEvent, MergeError, mergeGate, performMerge } from "./pulls"; import type { PullRow } from "./pulls"; import { hasPermission, repoPermission } from "./repos"; import type { MergeMethod, Permission, RepoRow, Viewer } from "~/lib/types"; /** * The repo owned by `ownerName` that may be a pull request head for `repo`: itself or a live direct fork. * The parent is deliberately not a candidate: a fork owner only reads the parent, and a pull request * "from parent into fork" would relay the parent's future history into the fork (GIT-A1). */ export async function findForkHead(repo: RepoRow, ownerName: string): Promise { return first( `SELECT r.*, a.name AS owner_name, a.disabled_at AS owner_disabled_at, a.disabled_reason AS owner_disabled_reason FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.name = ? AND ((r.fork_of_id = ? AND r.detached = 0) OR r.id = ?)`, ownerName, repo.id, repo.id, ); } /** * Opens a pull request once the caller has resolved and authorized the head repository. The head must be * the base or a live fork of it, and the author needs write on the head: a pull request stands for * branches its author can push to, and `pullHeadAccessible` re-checks their read on both sides for as * long as the pull request is open. */ export async function openPull(input: { repo: RepoRow; headRepo: RepoRow; headRef: string; base: string; title: string; body: string; draft: boolean; viewer: Viewer; }): Promise { const { repo, headRepo, headRef, viewer } = input; assertNotArchived(repo); if (!isPullHeadRepo(repo, headRepo)) throw notFoundError("Head repository not found"); if (!canBridge(headRepo, repo)) { throw forbidden( "A private repository can't open a pull request against a public one: its commits would become public", ); } if (headRepo.id !== repo.id && !hasPermission(await repoPermission(headRepo, viewer), "write")) { throw forbidden("You need write access to the head repository to open a pull request from it"); } const [baseRefs, headRefs] = await Promise.all([listRefs(repo), listRefs(headRepo)]); const baseSha = branchSha(baseRefs, input.base); const headSha = branchSha(headRefs, headRef); if (!baseSha || !headSha) throw badRequest("Both branches must exist"); if (headRepo.id === repo.id && headRef === input.base) throw badRequest("Head and base must differ"); const duplicate = await repoDb(repo.id).first( `SELECT 1 FROM pull_requests p JOIN issues i ON i.id = p.issue_id WHERE i.repo_id = ? AND p.head_repo_id = ? AND p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'`, repo.id, headRepo.id, headRef, input.base, ); if (duplicate) throw conflict("A pull request already exists for these branches"); const mergeBase = await withStore(repo.artifacts_name, (baseStore) => withStore( headRepo.artifacts_name, async (headStore) => (await compareCommits(baseStore, baseSha, headStore, headSha)).mergeBase, ), ); const number = await allocateIssueNumber(repo.id); const id = scopedId(repo.id); const now = Date.now(); await repoDb(repo.id).batch([ stmt( `INSERT INTO issues (id, repo_id, number, author_id, title, body, state, is_pull, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'open', 1, ?, ?)`, id, repo.id, number, viewer.id, input.title, input.body, now, now, ), stmt( `INSERT INTO pull_requests (issue_id, repo_id, head_repo_id, head_ref, head_sha, base_ref, base_sha, merge_base_sha, draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, repo.id, headRepo.id, headRef, headSha, input.base, baseSha, mergeBase, input.draft ? 1 : 0, ), ]); await registerLocator("issue", id, repo.id); await run("UPDATE repos SET open_pulls_count = open_pulls_count + 1 WHERE id = ?", repo.id); await recordActivity(viewer.id, repo.id, "pull_opened", { number, title: input.title }); await indexIssue({ id, repo_id: repo.id, title: input.title, body: input.body }); captureServerEvent( "pull_opened", viewer.id, { private_repo: repo.visibility !== "public", cross_repo: headRepo.id !== repo.id }, { ownerAccountId: repo.owner_id }, ); await emitNotification({ repoId: repo.id, issueId: id, actorId: viewer.id, reason: "comment", title: `${repo.owner_name}/${repo.name}#${number}: ${input.title}`, url: pullUrl(repo, number), mentionText: input.body, directRecipients: [], }); afterPullChange({ repo_id: repo.id, issue_id: id }, "opened", viewer); return number; } /** Gates and lands a pull request; MergeError surfaces as 409. `message` empty means the default message. */ export async function mergePullRequest(input: { repo: RepoRow; permission: Permission; pull: PullRow; headRepo: RepoRow; viewer: Viewer; method: MergeMethod; message: string; deleteBranch: boolean; expectedHead: string; timezone?: string; }): Promise { const { repo, pull, headRepo, viewer } = input; assertNotArchived(repo); if (pull.head_sha !== input.expectedHead) throw conflict("The head branch changed; reload and try again"); const gate = await mergeGate(pull, input.permission); if (!gate.allowed) throw forbidden(gate.blockers.join("; ") || "Merging is blocked"); 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, ); const defaultMessage = input.method === "squash" ? `${pull.title} (#${pull.number})` : `Merge pull request #${pull.number} from ${headRepo.owner_name}/${pull.head_ref}\n\n${pull.title}`; try { const sha = await performMerge({ pull, method: input.method, message: input.message.trim() || defaultMessage, merger: { id: viewer.id, name: viewer.displayName ?? viewer.name, email: viewer.email, timezone: input.timezone, }, author: { name: author?.display_name ?? author?.name ?? viewer.name, email: author?.email ?? viewer.email }, }); await recordActivity(viewer.id, repo.id, "pull_merged", { number: pull.number, title: pull.title }); captureServerEvent( "pull_merged", viewer.id, { method: input.method, private_repo: repo.visibility !== "public", cross_repo: headRepo.id !== repo.id }, { ownerAccountId: repo.owner_id }, ); 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: viewer.name, id: viewer.id }, }); if (input.deleteBranch && headRepo.id === repo.id && pull.head_ref !== repo.default_branch) { await deleteRef(repo, `refs/heads/${pull.head_ref}`, pull.head_sha).catch((error) => console.error("Branch delete failed", error), ); await addIssueEvent(pull, viewer.id, "head_ref_deleted", { ref: pull.head_ref }); } return sha; } catch (error) { if (error instanceof MergeError) throw conflict(error.message); throw error; } }