Vectrum
Sign In
961 lines · 42.8 KB · TypeScript
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");
    }
  }
}