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/<number>/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<string, unknown>; 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<PullEvent[]> {
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<Commit[]> {
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<FileDiff[]> {
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<string, Review>();
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<PullStatus> {
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<string, unknown> = {};
const actions: [string, Record<string, unknown>][] = [];
const edits: Record<string, { from: unknown }> = {};
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");
}
}
}