import { agentCommitter, agentFields, agentOf, type Actor, type AgentRef } from "./actor"; import { agentMayWrite, canMerge, linkedIssueNumbers, mergeRequirements, protectionFor, type CheckState, type Protection, type Requirement, } from "./branch-rules"; import { readWorkflows } from "./ci"; import { count, db, filter, findById, findFirst, orNull, withLock, type RecordModel } from "./db"; import { emitEvent } from "./events"; import { commitsBetween, countBetween, defaultBranch, diffRange, fetchBranch, git, isAncestor, isBranch, mergeBase, mergeTrees, revParse, type Commit, type FileDiff, } from "./git"; import { deleteBranch, updateBranch, writeCommit } from "./git-write"; import { isClosedStatus, type Status } from "./issue-meta"; import { getIssue, recordIssueEvent, updateIssue, withNextNumber } from "./issues"; import { assertRefUpdates, listBranchRules } from "./protection"; import { canWrite, findRepoById, markPushed, repoAccess, sameNetwork, type RepoRecord } from "./repos"; import { isFinished, latestRunsForSha, type Run } from "./runs"; import { getUserById } from "./users"; import { matchesFilter } from "./workflows"; /* * Pull requests: a proposal to merge one branch (head) into another (base) of the repository, with * the head on the repository itself or on a fork of it (or of what it was forked from). A fork's * head commit is fetched into the repository as refs/pull//head whenever it's pushed to, so * comparing, reviewing and merging all happen here. Comes with a conversation, reviews (approvals count for the commit they were given on), comments on * lines of the changes, and a merge that checks the base branch's rules. Agents work on agent/ * branches and open pull requests; people review and merge them. * * Pull requests share numbers with issues. "Fixes #12" in a title or description links an issue: it * moves to In Review when the pull request opens and to Done when it's merged into the default branch. * Used by the SSH server too (pushes update pull requests), so nothing from next/* here. */ export type PullState = "open" | "closed" | "merged"; export type MergeMethod = "merge" | "squash"; export type PullRequest = { id: string; repoId: string; number: number; title: string; body: string; authorId: string; author: string; /** The agent that opened it for its author. */ agent: AgentRef | null; head: string; /** For pull requests from a fork: that repository ("owner/name"), and its id unless it was deleted. */ headRepo: { id: string | null; fullName: string } | null; base: string; /** The head branch's commit when last seen (kept after the branch is deleted). */ headSha: string; state: PullState; draft: boolean; mergeSha: string | null; mergeMethod: MergeMethod | null; mergedBy: string | null; mergedByAgent: string | null; linkedIssues: number[]; commentCount: number; createdAt: number; updatedAt: number; closedAt: number | null; mergedAt: number | null; }; export type Review = { id: string; authorId: string; author: string; agent: string | null; state: "approved" | "changes_requested" | "commented"; body: string; commitSha: string; createdAt: number; }; export type PullComment = { id: string; authorId: string; author: string; agent: string | null; body: string; reviewId: string | null; /** For comments on a line of the changes. */ path: string | null; line: number | null; side: "new" | "old" | null; commitSha: string | null; snippet: string | null; resolved: boolean; replyTo: string | null; createdAt: number; }; export type PullEvent = { id: string; actor: string | null; agent: string | null; kind: string; data: Record; createdAt: number }; export class PullError extends Error {} const EXPAND = "author,merged_by,head_repo.owner"; function headRepoOf(r: RecordModel): PullRequest["headRepo"] { if (!r.head_repo_name) return null; const fork = r.expand?.head_repo as RecordModel | undefined; // The fork's current name, if it was renamed since. return { id: orNull(r.head_repo), fullName: fork ? `${fork.expand?.owner?.username}/${fork.name}` : r.head_repo_name }; } const toPull = (r: RecordModel): PullRequest => ({ id: r.id, repoId: r.repo, number: r.number, title: r.title, body: r.body, authorId: r.author, author: r.expand?.author?.username ?? "", agent: agentOf(r), head: r.head, headRepo: headRepoOf(r), base: r.base, headSha: r.head_sha, state: r.state, draft: r.draft, mergeSha: orNull(r.merge_sha), mergeMethod: orNull(r.merge_method), mergedBy: r.expand?.merged_by?.username ?? null, mergedByAgent: orNull(r.merged_by_agent), linkedIssues: r.linked_issues ?? [], commentCount: r.comment_count, createdAt: r.created_at, updatedAt: r.updated_at, closedAt: orNull(r.closed_at), mergedAt: orNull(r.merged_at), }); const toReview = (r: RecordModel): Review => ({ id: r.id, authorId: r.author, author: r.expand?.author?.username ?? "", agent: orNull(r.agent_name), state: r.state, body: r.body, commitSha: r.commit_sha, createdAt: r.created_at, }); const toComment = (r: RecordModel): PullComment => ({ id: r.id, authorId: r.author, author: r.expand?.author?.username ?? "", agent: orNull(r.agent_name), body: r.body, reviewId: orNull(r.review), path: orNull(r.path), line: r.path ? r.line : null, side: r.path ? r.side || "new" : null, commitSha: orNull(r.commit_sha), snippet: orNull(r.snippet), resolved: r.resolved, replyTo: orNull(r.reply_to), createdAt: r.created_at, }); const pulls = async () => (await db()).collection("pull_requests"); export const MAX_PULL_BODY = 65_536; /** Where the head commit is kept in the pull request's repository. */ export const headRef = (pr: PullRequest) => (pr.headRepo ? `refs/pull/${pr.number}/head` : `refs/heads/${pr.head}`); /** The head as people read it: "branch", or "owner:branch" for a fork's. */ export const headLabel = (pr: PullRequest) => (pr.headRepo ? `${pr.headRepo.fullName.split("/")[0]}:${pr.head}` : pr.head); /** The fork a pull request comes from, if it still exists. */ export async function headRepository(pr: PullRequest) { return pr.headRepo?.id ? findRepoById(pr.headRepo.id) : null; } /** * The head branch's commit now, or null once the branch (or its fork) is gone. A fork's commits * are fetched here when they're pushed (syncPullsAfterPush); this catches up if that was missed. */ export async function liveHead(repo: RepoRecord, pr: PullRequest) { if (!pr.headRepo) return revParse(repo.dir, `refs/heads/${pr.head}`); const fork = await headRepository(pr); const sha = fork ? await revParse(fork.dir, `refs/heads/${pr.head}`) : null; if (!fork || !sha) return null; if (sha === (await revParse(repo.dir, headRef(pr)))) return sha; return fetchBranch(repo.dir, fork.dir, pr.head, headRef(pr)); } // --------------------------------------------------------------------------- // Reading export async function listPulls(repoId: string, opts: { state?: PullState | "all"; head?: string; base?: string; page?: number; perPage?: number } = {}) { const where = [filter("repo = {:repoId}", { repoId })]; if (opts.state && opts.state !== "all") where.push(filter("state = {:state}", { state: opts.state })); if (opts.head) where.push(filter("head = {:head}", { head: opts.head })); if (opts.base) where.push(filter("base = {:base}", { base: opts.base })); const result = await (await pulls()).getList(opts.page ?? 1, opts.perPage ?? 50, { filter: where.join(" && "), sort: "-number", expand: EXPAND }); return { pulls: result.items.map(toPull), total: result.totalItems }; } export async function countOpenPulls(repoId: string) { return count("pull_requests", filter("repo = {:repoId} && state = 'open'", { repoId })); } export async function getPull(repoId: string, number: number) { if (!Number.isInteger(number)) return null; const row = await findFirst("pull_requests", filter("repo = {:repoId} && number = {:number}", { repoId, number }), { expand: EXPAND }); return row ? toPull(row) : null; } /** Open pull requests whose head (or base) is a branch of the repository. */ export async function openPullsFor(repoId: string, branch: string, side: "head" | "base") { const own = side === "head" ? " && head_repo_name = ''" : ""; const rows = await (await pulls()).getFullList({ filter: filter(`repo = {:repoId} && state = 'open' && ${side} = {:branch}${own}`, { repoId, branch }), expand: EXPAND, }); return rows.map(toPull); } /** Open pull requests from a branch of a fork, in the repositories they propose changes to. */ export async function openPullsFromFork(forkId: string, branch: string) { const rows = await (await pulls()).getFullList({ filter: filter("head_repo = {:forkId} && head = {:branch} && state = 'open'", { forkId, branch }), expand: EXPAND }); return rows.map(toPull); } /** Open pull requests an agent opened, across repositories. */ export async function listOpenPullsByAgent(agentKey: string) { const rows = await (await pulls()).getFullList({ filter: filter("agent = {:agentKey} && state = 'open'", { agentKey }), expand: EXPAND, sort: "-updated_at" }); return rows.map(toPull); } /** Pull requests that close an issue when merged. */ export async function pullsLinkingIssue(repoId: string, number: number) { const rows = await (await pulls()).getFullList({ filter: filter("repo = {:repoId} && linked_issues ~ {:n}", { repoId, n: String(number) }), expand: EXPAND, sort: "-number" }); return rows.map(toPull).filter((p) => p.linkedIssues.includes(number)); } export async function listReviews(prId: string) { const rows = await (await db()).collection("pr_reviews").getFullList({ filter: filter("pr = {:prId}", { prId }), sort: "created_at,@rowid", expand: "author" }); return rows.map(toReview); } export async function listPullComments(prId: string) { const rows = await (await db()).collection("pr_comments").getFullList({ filter: filter("pr = {:prId}", { prId }), sort: "created_at,@rowid", expand: "author" }); return rows.map(toComment); } export async function listPullEvents(prId: string): Promise { const rows = await (await db()).collection("pr_events").getFullList({ filter: filter("pr = {:prId}", { prId }), sort: "created_at,@rowid", expand: "actor" }); return rows.map((r) => ({ id: r.id, actor: r.expand?.actor?.username ?? null, agent: orNull(r.agent_name), kind: r.kind, data: r.data ?? {}, createdAt: r.created_at })); } export type Thread = { root: PullComment; replies: PullComment[]; outdated: boolean }; /** Comments on lines, grouped into threads (a first comment and its replies). */ export function threadsOf(comments: PullComment[], headSha: string): Thread[] { const roots = comments.filter((c) => c.path && !c.replyTo); return roots.map((root) => ({ root, replies: comments.filter((c) => c.replyTo === root.id), outdated: root.commitSha !== headSha })); } /** Where a pull request's changes start: the merge base of its branches, and the commits it brings. */ export async function pullRange(repo: RepoRecord, pr: PullRequest) { const head = pr.state === "open" ? ((await liveHead(repo, pr)) ?? pr.headSha) : pr.headSha; const baseSha = await revParse(repo.dir, `refs/heads/${pr.base}`); // Once merged, the changes are what the merge brought: from the merge commit's first parent. const mergedFrom = pr.mergeSha && pr.state === "merged" ? await revParse(repo.dir, `${pr.mergeSha}^1`) : null; const from = mergedFrom ? await mergeBase(repo.dir, mergedFrom, head) : baseSha ? await mergeBase(repo.dir, baseSha, head) : null; return { head, baseSha, from }; } export async function pullCommits(repo: RepoRecord, pr: PullRequest): Promise { const { head, from } = await pullRange(repo, pr); if (!from || !(await revParse(repo.dir, head))) return []; return (await commitsBetween(repo.dir, from, head)).reverse(); } export async function pullDiff(repo: RepoRecord, pr: PullRequest, paths: string[] = []): Promise { const { head, from } = await pullRange(repo, pr); if (!from || !(await revParse(repo.dir, head))) return []; return diffRange(repo.dir, from, head, paths); } // --------------------------------------------------------------------------- // Status and requirements export type PullStatus = { headSha: string; headExists: boolean; baseSha: string | null; ahead: number; behind: number; conflicts: string[] | null; checks: CheckState; runs: Run[]; /** Workflows would run, but the head comes from a fork by someone without push access: someone with it starts them. */ awaitingApproval: boolean; approvals: string[]; changesRequestedBy: string[]; protection: Protection | null; requirements: Requirement[]; mergeable: boolean; }; /** Whether a commit's workflows would run for this pull request, so "no runs yet" means "pending". */ async function expectsRuns(repo: RepoRecord, pr: PullRequest, sha: string) { const files = await readWorkflows(repo.dir, sha).catch(() => []); return files.some( (f) => !("workflow" in f) || (!pr.headRepo && f.workflow.push && matchesFilter(f.workflow.push.branches, pr.head)) || (f.workflow.events.pull_request && matchesFilter(f.workflow.events.pull_request.branches, pr.base)), ); } function checkState(runs: Run[], expected: boolean): CheckState { if (runs.length === 0) return expected ? "pending" : "none"; if (runs.some((r) => !isFinished(r.status))) return "pending"; if (runs.some((r) => r.status === "failure" || r.status === "cancelled")) return "failure"; return "success"; } /** Who approved the head commit, and who asks for changes: people with push access, by their latest review. */ async function reviewSummary(repo: RepoRecord, pr: PullRequest, headSha: string) { const latest = new Map(); for (const r of await listReviews(pr.id)) { // Agents' reviews are advice: only people's count. if (r.agent || r.state === "commented") continue; latest.set(r.authorId, r); } const approvals: string[] = []; const changesRequestedBy: string[] = []; for (const r of latest.values()) { if (!canWrite(await repoAccess(repo, r.authorId))) continue; // People don't approve their own work, but do approve what their agents did. if (r.state === "approved" && r.commitSha === headSha && (r.authorId !== pr.authorId || pr.agent)) approvals.push(r.author); if (r.state === "changes_requested") changesRequestedBy.push(r.author); } return { approvals, changesRequestedBy }; } /** Everything about whether the pull request can be merged, by `actor` (an agent or a person). */ export async function pullStatus(repo: RepoRecord, pr: PullRequest, actor: Actor | null = null): Promise { const live = pr.state === "open" ? await liveHead(repo, pr) : null; const headSha = live ?? pr.headSha; const headExists = live !== null; const baseSha = await revParse(repo.dir, `refs/heads/${pr.base}`); const [rules, runs] = await Promise.all([listBranchRules(repo.id), latestRunsForSha(repo.id, headSha)]); const protection = protectionFor(rules, pr.base); let ahead = 0; let behind = 0; let conflicts: string[] | null = null; if (baseSha && (await revParse(repo.dir, headSha))) { [ahead, behind] = await Promise.all([countBetween(repo.dir, baseSha, headSha), countBetween(repo.dir, headSha, baseSha)]); if (pr.state === "open" && ahead > 0) { const merged = await mergeTrees(repo.dir, baseSha, headSha); conflicts = merged.clean ? [] : merged.conflicts; } } const expected = runs.length === 0 && (await expectsRuns(repo, pr, headSha)); const checks = checkState(runs, expected); const awaitingApproval = expected && pr.state === "open" && !(await forkRunsTrusted(repo, pr)); const { approvals, changesRequestedBy } = await reviewSummary(repo, pr, headSha); const requirements = mergeRequirements({ state: pr.state, draft: pr.draft, headExists: headExists || pr.state !== "open", ahead, conflicts, checks, approvals: approvals.length, changesRequestedBy, protection, byAgent: !!actor?.agent, agentMayWriteBase: agentMayWrite(repo.agentPush, rules, pr.base), }); // Nothing runs until someone with push access starts the fork's workflows. if (awaitingApproval) for (const r of requirements) if (r.key === "checks") r.label = "Checks wait for a collaborator to start them"; return { headSha, headExists, baseSha, ahead, behind, conflicts, checks, runs, awaitingApproval, approvals, changesRequestedBy, protection, requirements, mergeable: canMerge(requirements) }; } // --------------------------------------------------------------------------- // Workflows of pull requests from forks /** * Workflows run on the repository's own runners, so a fork's changes only start them by themselves * when their author could have pushed them here anyway. lib/ci.ts applies the same rule. */ export async function forkRunsTrusted(repo: RepoRecord, pr: PullRequest) { return !pr.headRepo || canWrite(await repoAccess(repo, pr.authorId)); } /** Starts a pull request's workflows for its head as it is now, for someone with push access who looked at the changes. */ export async function approvePullWorkflows(repo: RepoRecord, pr: PullRequest, actor: Actor) { if (pr.state !== "open") throw new PullError(`#${pr.number} is ${pr.state}.`); const sha = await liveHead(repo, pr); if (!sha) throw new PullError(`The branch ${headLabel(pr)} was deleted.`); const current = { ...pr, headSha: sha }; const { startEventWorkflows } = await import("./ci"); return startEventWorkflows( repo, { kind: "pull_request", action: "synchronize", subject: `#${pr.number}`, payload: { pull_request: pullPayload(current) } }, actor, { approved: true }, ); } // --------------------------------------------------------------------------- // Events /** A pull request as events and agents see it. */ export function pullPayload(pr: PullRequest) { return { number: pr.number, title: pr.title, body: pr.body, state: pr.state, draft: pr.draft, merged: pr.state === "merged", // repo: the fork it comes from ("owner/name"), or null for a branch of this repository. head: { ref: pr.head, sha: pr.headSha, repo: pr.headRepo?.fullName ?? null, label: headLabel(pr) }, base: { ref: pr.base }, author: pr.author, author_agent: pr.agent?.name ?? null, linked_issues: pr.linkedIssues, merge_sha: pr.mergeSha, merged_by: pr.mergedBy, created_at: new Date(pr.createdAt).toISOString(), updated_at: new Date(pr.updatedAt).toISOString(), merged_at: pr.mergedAt ? new Date(pr.mergedAt).toISOString() : null, }; } async function recordPullEvent(pr: PullRequest, actor: Actor | null, kind: string, data: Record = {}) { await (await db()).collection("pr_events").create({ pr: pr.id, actor: actor?.userId ?? "", ...agentFields(actor?.agent), kind, data, created_at: Date.now() }); } async function announce(repo: RepoRecord, pr: PullRequest, actor: Actor | null, action: string, extra: Record = {}) { const fresh = (await getPull(repo.id, pr.number)) ?? pr; await emitEvent(repo, { kind: "pull_request", action, actor, subject: `#${pr.number}`, payload: { pull_request: pullPayload(fresh), ...extra } }); } // --------------------------------------------------------------------------- // Linked issues /** Existing issue numbers among those a text links. */ async function existingIssues(repo: RepoRecord, numbers: number[]) { const out: number[] = []; for (const n of numbers.slice(0, 50)) if (await getIssue(repo.id, n)) out.push(n); return out; } const NOT_STARTED: Status[] = ["backlog", "todo", "in_progress"]; /** Issues a newly linking pull request moves to In Review, with a line in their activity. */ async function linkIssues(repo: RepoRecord, pr: PullRequest, numbers: number[], actor: Actor) { for (const n of numbers) { const issue = await getIssue(repo.id, n); if (!issue) continue; await recordIssueEvent(issue.id, actor, "pull_request", null, `#${pr.number}`); if (pr.state === "open" && !pr.draft && NOT_STARTED.includes(issue.status)) await updateIssue(repo, issue, { status: "in_review" }, actor); } } /** A merge into the default branch closes the issues the pull request links. */ async function closeLinkedIssues(repo: RepoRecord, pr: PullRequest, actor: Actor) { if (pr.base !== (await defaultBranch(repo.dir))) return; for (const n of pr.linkedIssues) { const issue = await getIssue(repo.id, n); if (issue && !isClosedStatus(issue.status)) await updateIssue(repo, issue, { status: "done" }, actor); } } // --------------------------------------------------------------------------- // Opening and changing function cleanTitle(title: string) { const clean = title.trim().slice(0, 256); if (!clean) throw new PullError("Give the pull request a title."); return clean; } export type PullInput = { head: string; /** The fork the head branch is on, if it isn't on the repository itself. The caller checks it may read it. */ headRepo?: RepoRecord | null; base: string; title: string; body: string; draft: boolean; }; export async function createPull(repo: RepoRecord, actor: Actor, input: PullInput) { const { head, base } = input; const fork = input.headRepo && input.headRepo.id !== repo.id ? input.headRepo : null; const label = fork ? `${fork.owner}:${head}` : head; if (!fork && head === base) throw new PullError("The head and base branches are the same. Pick a branch with your changes."); if (fork && !(await sameNetwork(repo, fork))) { throw new PullError(`${fork.owner}/${fork.name} isn't a fork of ${repo.owner}/${repo.name} (or of what it was forked from).`); } if (!(await isBranch(fork?.dir ?? repo.dir, head))) throw new PullError(`There's no branch named ${label}.`); if (!(await isBranch(repo.dir, base))) throw new PullError(`There's no branch named ${base}.`); const existing = (fork ? (await openPullsFromFork(fork.id, head)).filter((p) => p.repoId === repo.id) : await openPullsFor(repo.id, head, "head")).find( (p) => p.base === base, ); if (existing) throw new PullError(`There's already an open pull request for ${label} into ${base}: #${existing.number}.`); const headSha = fork ? await fetchBranch(repo.dir, fork.dir, head) : (await revParse(repo.dir, `refs/heads/${head}`))!; const baseSha = (await revParse(repo.dir, `refs/heads/${base}`))!; if ((await countBetween(repo.dir, baseSha, headSha)) === 0) throw new PullError(`${label} has no commits that ${base} doesn't have.`); const title = cleanTitle(input.title); const body = input.body.trim().slice(0, MAX_PULL_BODY); const linked = await existingIssues(repo, linkedIssueNumbers(title, body)); const number = await withNextNumber(repo.id, async (number) => { const now = Date.now(); await (await pulls()).create({ repo: repo.id, number, title, body, author: actor.userId, ...agentFields(actor.agent), head, head_repo: fork?.id ?? "", head_repo_name: fork ? `${fork.owner}/${fork.name}` : "", base, head_sha: headSha, state: "open", draft: input.draft, linked_issues: linked, comment_count: 0, created_at: now, updated_at: now, }); // Keeps the fork's commits here, for as long as the pull request exists. if (fork) await git(repo.dir, ["update-ref", `refs/pull/${number}/head`, headSha]); return number; }); const pr = (await getPull(repo.id, number))!; await linkIssues(repo, pr, linked, actor); await announce(repo, pr, actor, "opened"); return pr; } export type PullPatch = { title?: string; body?: string; base?: string; draft?: boolean; state?: "open" | "closed" }; export async function updatePull(repo: RepoRecord, pr: PullRequest, actor: Actor, patch: PullPatch) { if (pr.state === "merged") throw new PullError(`#${pr.number} is merged already and can't be changed.`); const now = Date.now(); const fields: Record = {}; const actions: [string, Record][] = []; const edits: Record = {}; if (patch.title !== undefined && cleanTitle(patch.title) !== pr.title) { fields.title = cleanTitle(patch.title); edits.title = { from: pr.title }; await recordPullEvent(pr, actor, "title", { from: pr.title, to: fields.title }); } if (patch.body !== undefined && patch.body.trim() !== pr.body) { fields.body = patch.body.trim().slice(0, MAX_PULL_BODY); edits.body = { from: pr.body }; } let newlyLinked: number[] = []; if (fields.title !== undefined || fields.body !== undefined) { const linked = await existingIssues(repo, linkedIssueNumbers(String(fields.title ?? pr.title), String(fields.body ?? pr.body))); fields.linked_issues = linked; newlyLinked = linked.filter((n) => !pr.linkedIssues.includes(n)); } if (patch.base !== undefined && patch.base !== pr.base) { if (!pr.headRepo && patch.base === pr.head) throw new PullError("The base can't be the head branch."); if (!(await isBranch(repo.dir, patch.base))) throw new PullError(`There's no branch named ${patch.base}.`); fields.base = patch.base; edits.base = { from: pr.base }; await recordPullEvent(pr, actor, "base", { from: pr.base, to: patch.base }); } if (Object.keys(edits).length) actions.push(["edited", { changes: edits }]); if (patch.draft !== undefined && patch.draft !== pr.draft) { fields.draft = patch.draft; await recordPullEvent(pr, actor, patch.draft ? "draft" : "ready"); actions.push([patch.draft ? "converted_to_draft" : "ready_for_review", {}]); } if (patch.state !== undefined && patch.state !== pr.state) { if (patch.state === "open") { const sha = await liveHead(repo, pr); if (!sha) throw new PullError(`The branch ${headLabel(pr)} was deleted, so #${pr.number} can't be reopened.`); const siblings = pr.headRepo ? (await openPullsFromFork(pr.headRepo.id!, pr.head)).filter((p) => p.repoId === repo.id) : await openPullsFor(repo.id, pr.head, "head"); const other = siblings.find((p) => p.base === (fields.base ?? pr.base)); if (other) throw new PullError(`#${other.number} is already open for ${headLabel(pr)} into ${other.base}.`); fields.closed_at = 0; fields.head_sha = sha; } else { fields.closed_at = now; } fields.state = patch.state; await recordPullEvent(pr, actor, patch.state === "open" ? "reopened" : "closed"); actions.push([patch.state === "open" ? "reopened" : "closed", {}]); } if (Object.keys(fields).length === 0) return pr; fields.updated_at = now; await (await pulls()).update(pr.id, fields); const updated = (await getPull(repo.id, pr.number))!; if (newlyLinked.length) await linkIssues(repo, updated, newlyLinked, actor); if (pr.draft && patch.draft === false) await linkIssues(repo, updated, updated.linkedIssues.filter((n) => !newlyLinked.includes(n)), actor); for (const [action, extra] of actions) await announce(repo, updated, actor, action, extra); return updated; } // --------------------------------------------------------------------------- // Conversation and reviews async function commentPayload(c: { id: string; body: string; path?: string | null; line?: number | null; side?: string | null; replyTo?: string | null }, actor: Actor) { const user = await getUserById(actor.userId); return { id: c.id, author: user?.username ?? null, agent: actor.agent?.name ?? null, body: c.body, ...(c.path ? { path: c.path, line: c.line, side: c.side, in_reply_to: c.replyTo ?? null } : {}), created_at: new Date().toISOString(), }; } async function touch(pr: PullRequest, comments = 0) { await (await pulls()).update(pr.id, { updated_at: Date.now(), ...(comments ? { "comment_count+": comments } : {}) }); } /** A comment on the pull request's conversation. */ export async function addPullComment(repo: RepoRecord, pr: PullRequest, actor: Actor, body: string) { const text = body.trim().slice(0, MAX_PULL_BODY); if (!text) throw new PullError("Write a comment first."); const row = await (await db()).collection("pr_comments").create({ pr: pr.id, author: actor.userId, ...agentFields(actor.agent), body: text, created_at: Date.now() }); await touch(pr, 1); await emitEvent(repo, { kind: "issue_comment", action: "created", actor, subject: `#${pr.number}`, payload: { pull_request: pullPayload(pr), comment: await commentPayload({ id: row.id, body: text }, actor) }, }); return toComment({ ...row, expand: {} }); } export type LineComment = { path: string; line: number; side: "new" | "old"; body: string }; /** The diff lines a comment is about: up to four lines leading to it, for showing it later. */ function snippetFor(files: FileDiff[], c: LineComment) { const file = files.find((f) => (c.side === "old" ? f.oldPath : f.newPath) === c.path); if (!file) return null; const index = file.lines.findIndex((l) => l.kind !== "hunk" && (c.side === "old" ? l.oldNo === c.line && l.kind !== "add" : l.newNo === c.line && l.kind !== "del")); if (index < 0) return null; const sign = { add: "+", del: "-", ctx: " ", hunk: "" } as const; const lines: string[] = []; for (let i = index; i >= 0 && lines.length < 4 && file.lines[i].kind !== "hunk"; i--) lines.unshift(sign[file.lines[i].kind] + file.lines[i].text); return lines.join("\n").slice(0, 4000); } export type ReviewInput = { state: Review["state"]; body: string; comments: LineComment[] }; /** * Submits a review: an approval, a request for changes, or comments, optionally with comments on * lines of the changes. Agents can comment and ask for changes, but approvals have to come from people. */ export async function submitReview(repo: RepoRecord, pr: PullRequest, actor: Actor, input: ReviewInput) { if (pr.state !== "open") throw new PullError(`#${pr.number} is ${pr.state}; it can't be reviewed anymore.`); if (input.state === "approved" && actor.agent) throw new PullError("Approvals have to come from a person. Comment, or request changes, instead."); if (input.state !== "commented" && actor.userId === pr.authorId && !pr.agent && !actor.agent) { throw new PullError("You can't approve or request changes on your own pull request. Comment instead."); } const body = input.body.trim().slice(0, MAX_PULL_BODY); if (input.state === "commented" && !body && input.comments.length === 0) throw new PullError("Write a comment, or comment on some lines."); if (input.state === "changes_requested" && !body && input.comments.length === 0) throw new PullError("Say what should change."); const status = await pullStatus(repo, pr, actor); const files = input.comments.length ? await pullDiff(repo, pr) : []; const snippets = input.comments.map((c) => { if (!c.body.trim()) throw new PullError(`The comment on ${c.path}:${c.line} is empty.`); const snippet = snippetFor(files, c); if (snippet === null) throw new PullError(`Line ${c.line} of ${c.path} (${c.side === "old" ? "before" : "after"} the change) isn't part of the pull request's changes.`); return snippet; }); const now = Date.now(); const pb = await db(); const review = await pb.collection("pr_reviews").create({ pr: pr.id, author: actor.userId, ...agentFields(actor.agent), state: input.state, body, commit_sha: status.headSha, created_at: now, }); const created: RecordModel[] = []; for (const [i, c] of input.comments.entries()) { created.push( await pb.collection("pr_comments").create({ pr: pr.id, author: actor.userId, ...agentFields(actor.agent), body: c.body.trim().slice(0, MAX_PULL_BODY), review: review.id, path: c.path, line: c.line, side: c.side, commit_sha: status.headSha, snippet: snippets[i], created_at: now, }), ); } await touch(pr, created.length); const user = await getUserById(actor.userId); await emitEvent(repo, { kind: "pull_request_review", action: "submitted", actor, subject: `#${pr.number}`, payload: { pull_request: pullPayload(pr), review: { id: review.id, state: input.state, body, author: user?.username ?? null, agent: actor.agent?.name ?? null, commit_sha: status.headSha, comments: created.length }, }, }); for (const c of created) { await emitEvent(repo, { kind: "pull_request_review_comment", action: "created", actor, subject: `#${pr.number}`, payload: { pull_request: pullPayload(pr), comment: await commentPayload({ id: c.id, body: c.body, path: c.path, line: c.line, side: c.side }, actor) }, }); } return toReview({ ...review, expand: { author: user } }); } async function threadRoot(pr: PullRequest, commentId: string) { const comment = await findFirst("pr_comments", filter("id = {:id} && pr = {:pr}", { id: commentId, pr: pr.id })); if (!comment || !comment.path) throw new PullError(`#${pr.number} has no comment on its changes with the id ${commentId}.`); return comment.reply_to ? ((await findById("pr_comments", comment.reply_to)) ?? comment) : comment; } /** Replies in a thread on the changes, optionally resolving (or reopening) it. */ export async function replyToComment(repo: RepoRecord, pr: PullRequest, actor: Actor, commentId: string, body: string, resolve?: boolean) { const root = await threadRoot(pr, commentId); const text = body.trim().slice(0, MAX_PULL_BODY); if (!text && resolve === undefined) throw new PullError("Write a reply, or resolve the thread."); let replyId: string | null = null; if (text) { const reply = await (await db()).collection("pr_comments").create({ pr: pr.id, author: actor.userId, ...agentFields(actor.agent), body: text, path: root.path, line: root.line, side: root.side, commit_sha: root.commit_sha, reply_to: root.id, created_at: Date.now(), }); replyId = reply.id; await touch(pr, 1); await emitEvent(repo, { kind: "pull_request_review_comment", action: "created", actor, subject: `#${pr.number}`, payload: { pull_request: pullPayload(pr), comment: await commentPayload({ id: reply.id, body: text, path: root.path, line: root.line, side: root.side, replyTo: root.id }, actor) }, }); } if (resolve !== undefined && resolve !== root.resolved) await (await db()).collection("pr_comments").update(root.id, { resolved: resolve }); return { threadId: root.id, replyId }; } // --------------------------------------------------------------------------- // Merging export type MergeInput = { method: MergeMethod; deleteBranch: boolean; expectedHeadSha?: string }; /** Merges a pull request whose requirements are met. Returns the commit now at the head of its base. */ export async function mergePull(repo: RepoRecord, pr: PullRequest, actor: Actor, input: MergeInput) { return withLock(`merge:${repo.id}`, async () => { const current = (await getPull(repo.id, pr.number)) ?? pr; const status = await pullStatus(repo, current, actor); if (!status.mergeable) { const blocking = status.requirements.filter((r) => !r.ok).map((r) => `- ${r.label}${r.detail ? ` (${r.detail})` : ""}`); throw new PullError(`#${pr.number} can't be merged yet:\n${blocking.join("\n")}`); } const headSha = status.headSha; const baseSha = status.baseSha!; if (input.expectedHeadSha && !headSha.startsWith(input.expectedHeadSha.toLowerCase())) { throw new PullError(`${current.head} is at ${headSha} now, not ${input.expectedHeadSha}. Review the new commits first.`); } await assertRefUpdates(repo, actor, [{ ref: `refs/heads/${current.base}`, old: baseSha, new: headSha, fastForward: true }], { merge: true }); const merged = await mergeTrees(repo.dir, baseSha, headSha); if (!merged.clean) throw new PullError(`#${pr.number} conflicts with ${current.base}: ${merged.conflicts.join(", ")}`); const user = (await getUserById(actor.userId))!; const person = { name: user.name || user.username, email: user.email }; const committer = actor.agent ? agentCommitter(actor.agent) : person; let commit: string; if (input.method === "squash") { const commits = await commitsBetween(repo.dir, baseSha, headSha); const author = (await getUserById(current.authorId)) ?? user; const list = commits.reverse().map((c) => `* ${c.subject}`).join("\n"); commit = await writeCommit(repo.dir, { tree: merged.tree, parents: [baseSha], message: `${current.title} (#${current.number})\n${commits.length > 1 ? `\n${list}\n` : ""}`, author: { name: author.name || author.username, email: author.email }, committer, }); } else { commit = await writeCommit(repo.dir, { tree: merged.tree, parents: [baseSha, headSha], message: `Merge pull request #${current.number} from ${headLabel(current)}\n\n${current.title}\n`, author: person, committer, }); } await updateBranch(repo.dir, current.base, commit, baseSha, `merge pull request #${current.number}`); await markPushed(repo.id); const now = Date.now(); await (await pulls()).update(current.id, { state: "merged", head_sha: headSha, merge_sha: commit, merge_method: input.method, merged_by: actor.userId, merged_by_agent: actor.agent?.name ?? "", merged_at: now, closed_at: now, updated_at: now, }); await recordPullEvent(current, actor, "merged", { sha: commit, method: input.method }); const done = (await getPull(repo.id, current.number))!; await closeLinkedIssues(repo, done, actor); // Loaded when needed: lib/push.ts updates pull requests, which needs this module. const { afterRefsUpdated } = await import("./push"); await afterRefsUpdated(repo, new Map([[`refs/heads/${current.base}`, baseSha]]), new Map([[`refs/heads/${current.base}`, commit]]), actor); await announce(repo, done, actor, "merged"); if (input.deleteBranch) await deleteHeadBranch(repo, done, actor, headSha).catch((e) => console.error("[pulls] could not delete the head branch:", e)); return { sha: commit, pull: done }; }); } /** Whether `actor` may delete the head branch once the pull request is merged: it's on a fork they can push to (and they're a person). */ export async function mayDeleteHead(repo: RepoRecord, pr: PullRequest, actor: Actor) { if (!pr.headRepo) return pr.head !== (await defaultBranch(repo.dir)); const fork = await headRepository(pr); if (!fork || actor.agent || !canWrite(await repoAccess(fork, actor.userId))) return false; return pr.head !== (await defaultBranch(fork.dir)); } /** Deletes a merged pull request's head branch (on its fork, for one from a fork), if nothing else uses it and the rules allow it. */ async function deleteHeadBranch(repo: RepoRecord, pr: PullRequest, actor: Actor, headSha: string) { if (!(await mayDeleteHead(repo, pr, actor))) return; const target = pr.headRepo ? await headRepository(pr) : repo; if (!target || (await revParse(target.dir, `refs/heads/${pr.head}`)) !== headSha) return; const users = [...(await openPullsFor(target.id, pr.head, "head")), ...(await openPullsFor(target.id, pr.head, "base")), ...(await openPullsFromFork(target.id, pr.head))]; if (users.length) return; await assertRefUpdates(target, actor, [{ ref: `refs/heads/${pr.head}`, old: headSha, new: null, fastForward: true }]); await deleteBranch(target.dir, pr.head, headSha); await recordPullEvent(pr, actor, "head_deleted", { branch: headLabel(pr) }); const { afterRefsUpdated } = await import("./push"); await afterRefsUpdated(target, new Map([[`refs/heads/${pr.head}`, headSha]]), new Map(), actor); } // --------------------------------------------------------------------------- // Following pushes /** Records new commits on a pull request's head (and starts its pull_request workflows). */ async function headMoved(repo: RepoRecord, pr: PullRequest, from: string, after: string, actor: Actor | null) { const commits = (await isAncestor(repo.dir, from, after)) ? await countBetween(repo.dir, from, after) : null; await (await pulls()).update(pr.id, { head_sha: after, updated_at: Date.now() }); await recordPullEvent(pr, actor, commits === null ? "force_pushed" : "pushed", { before: from, after, commits }); await announce(repo, pr, actor, "synchronize", { before: from, after }); } /** Closes the open pull requests from a fork that's being deleted: they can't change anymore. */ export async function closePullsFromRepo(fork: RepoRecord) { const rows = await (await pulls()).getFullList({ filter: filter("head_repo = {:id} && state = 'open'", { id: fork.id }), expand: EXPAND }); for (const pr of rows.map(toPull)) { const repo = await findRepoById(pr.repoId); if (!repo) continue; const now = Date.now(); await (await pulls()).update(pr.id, { state: "closed", closed_at: now, updated_at: now }); await recordPullEvent(pr, null, "closed", { reason: "head_repo_deleted" }); await announce(repo, pr, null, "closed"); } } /** * Brings open pull requests up to date after branches changed: new commits on a head branch are * recorded (and start pull_request workflows), and a pull request whose commits reached its base * some other way (a push of a merge) counts as merged. */ export async function syncPullsAfterPush(repo: RepoRecord, changes: { branch: string; before: string | null; after: string | null }[], actor: Actor | null) { for (const { branch, before, after } of changes) { for (const pr of await openPullsFor(repo.id, branch, "head")) { if (!after) await recordPullEvent(pr, actor, "head_deleted", { branch }); else if (after !== pr.headSha) await headMoved(repo, pr, before ?? pr.headSha, after, actor); } // Pull requests from this branch to the repositories it was forked from (or their other forks). for (const pr of await openPullsFromFork(repo.id, branch)) { const target = await findRepoById(pr.repoId); if (!target) continue; if (!after) { await recordPullEvent(pr, actor, "head_deleted", { branch: headLabel(pr) }); continue; } if (after === pr.headSha) continue; const sha = await fetchBranch(target.dir, repo.dir, branch, headRef(pr)).catch((e) => console.error("[pulls] could not fetch from a fork:", e)); if (sha && sha !== pr.headSha) await headMoved(target, pr, pr.headSha, sha, actor); } if (!after) continue; for (const pr of await openPullsFor(repo.id, branch, "base")) { const head = (await liveHead(repo, pr)) ?? pr.headSha; if (!(await isAncestor(repo.dir, head, after))) continue; const now = Date.now(); await (await pulls()).update(pr.id, { state: "merged", head_sha: head, merge_sha: after, merged_by: actor?.userId ?? "", merged_by_agent: actor?.agent?.name ?? "", merged_at: now, closed_at: now, updated_at: now, }); await recordPullEvent(pr, actor, "merged", { sha: after, method: "push" }); const done = (await getPull(repo.id, pr.number))!; if (actor) await closeLinkedIssues(repo, done, actor); await announce(repo, done, actor, "merged"); } } }