~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
813 lines · 28.9 KB Raw History
/**
 * The pull request list, read on the repository's own server.
 *
 * A pull request is a branch in one account's copy, and a copy is on its
 * author's server, so reading the list means reading several servers. The
 * server does that once, for every visitor, where the page did it once per
 * visitor. What the object graph says is computed here and never stored:
 * open and merged are where a tip stands against canonical main.
 *
 * Two kinds of copy are read. A collaborator the config names is read whole,
 * every branch. Anybody else is found through the discovery port, which
 * answers with the pull request records about this repository, and only the
 * branch each record names is read: the rest of a stranger's copy is not
 * this repository's business.
 *
 * Workers-clean: no node imports.
 */

import { createGitBrowser } from './browser.js';
import { mergeText } from './merge3.js';
import {
  byPull,
  fixesOf,
  mergedRun,
  place,
  pullKey,
  pullRkey,
  runsForCopies,
  shortRef,
  subjectOf,
} from './pull-requests.js';
import { repoNameOf, rkeyOf } from './record.js';
import { collaboratorsOf, reviewersOf, runnerOf } from './rules.js';

/** @typedef {import('./pull-requests.js').Contributor} Contributor */
/** @typedef {import('./pull-requests.js').PullRequest} PullRequest */
/** @typedef {import('./discovery.js').DiscoveryPort} DiscoveryPort */
/** @typedef {ReturnType<typeof createGitBrowser>} GitBrowser */
/** @typedef {Pick<GitBrowser, 'refNames'|'listCommits'|'changedPaths'|'readFile'>} CopyReader */

const REPO_COLLECTION = 'dev.pdsjs.git.repo';
const REVIEW_COLLECTION = 'dev.pdsjs.git.review';
const PULL_COLLECTION = 'dev.pdsjs.git.pull';
const CHECK_COLLECTION = 'dev.pdsjs.git.check';

/**
 * How far back main is read when placing a branch against it. A branch taken
 * further back than this reads as unrelated rather than as behind, which is
 * the honest answer from a bounded walk.
 */
const MAIN_DEPTH = 500;

/** How far back a copy's branch is read before it counts as unrelated. */
const BRANCH_DEPTH = 100;

/** Records read per listing, the most a PDS answers in one page. */
const LIST_LIMIT = 100;

/** Pages of the runner's runs read before the rest are left. */
const CHECK_PAGES = 10;

/** Records followed per discovery answer, so one noisy index stays bounded. */
const MAX_DISCOVERED = 100;

/**
 * Drifted paths a single pull request has its conflicts read on, and the most
 * across one listing. A three-way merge of one path is three file reads, so
 * an unbounded list of drifting pull requests is an unbounded read. Past the
 * cap a pull request's conflicts are left unread rather than half-read, and
 * the page says the merge itself will tell.
 */
const MAX_CONFLICT_PATHS = 25;
const CONFLICT_BUDGET = 200;

/** Readers over contributors' servers kept open, oldest dropped past this. */
const OPEN_COPIES = 32;

const READ_TIMEOUT_MS = 5000;

/** How long a copy's record key stands before its holder is asked again. */
const COPY_KEY_TTL_MS = 5 * 60 * 1000;

/**
 * @typedef {Object} PullRequestReaderContext
 * @property {(did: string) => Promise<Contributor|null>} resolveContributor -
 *   where one account's records and blobs are read from. A read names an
 *   account by DID, the server resolves the DID document, and reads its
 *   serviceEndpoint. A did:web the caller controls names any endpoint, so
 *   this is a request the server makes to a URL a request chose: bounded to
 *   GET of com.atproto.repo.* and dev.pdsjs.git.* paths whose answer is
 *   parsed as records, but a deployment behind a network where an internal
 *   host answers on those paths should resolve DIDs through a directory it
 *   trusts and hold the endpoint to public hosts.
 * @property {(did: string, collection: string, rkey: string) => Promise<Object|null>} readRecord -
 *   this account's own records
 * @property {Pick<GitBrowser, 'listCommits'|'changedPaths'|'readFile'>} browser - the
 *   server's own reader, which holds the canonical copy
 * @property {DiscoveryPort} discovery
 * @property {(message: string) => void} [onNotice] - where a bound the read hit is reported, a silent cap otherwise
 * @property {typeof fetch} [fetch] - what carries a request to another server; the global by default
 * @property {(who: Contributor) => CopyReader} [openCopy] - a reader over one
 *   contributor's server. By default a browser over `fetch`, kept per
 *   contributor.
 * @property {number} [maxEagerBytes] - the largest copy read whole from
 *   another server. A copy past it costs its branches, not the list.
 */

/**
 * @typedef {Object} PullRequestList
 * @property {PullRequest[]} open
 * @property {PullRequest[]} merged
 * @property {Contributor[]} collaborators
 * @property {Array<[string, Array<Record<string, unknown>>]>} reviews - by
 *   pull request id
 * @property {Array<[string, Array<Record<string, unknown>>]>} checks - by
 *   pull request id
 * @property {boolean} configured - whether the config names any collaborator
 */

/**
 * @param {PullRequestReaderContext} ctx
 */
export function createPullRequestReader(ctx) {
  const send = ctx.fetch ?? ((input, init) => globalThis.fetch(input, init));

  /** @type {Map<string, CopyReader>} */
  const copies = new Map();

  /**
   * A reader over one contributor's server. Their objects are there and
   * nowhere else, so every call crosses the network.
   * @param {Contributor} who
   * @returns {CopyReader}
   */
  function openCopy(who) {
    if (ctx.openCopy) return ctx.openCopy(who);
    let reader = copies.get(who.did);
    if (!reader) {
      reader = createGitBrowser({
        xrpc: (request) => {
          const url = new URL(request.url);
          return send(`${who.pds}${url.pathname}${url.search}`, {
            headers: request.headers,
            signal: AbortSignal.timeout(READ_TIMEOUT_MS),
          });
        },
        maxEagerBytes: ctx.maxEagerBytes,
      });
      if (copies.size >= OPEN_COPIES) {
        const oldest = copies.keys().next().value;
        if (oldest !== undefined) copies.delete(oldest);
      }
      copies.set(who.did, reader);
    }
    return reader;
  }

  /**
   * The server's own copy, read through its own browser. The refs come from
   * the record rather than a walk, since the record is in hand.
   * @param {string} did
   * @returns {CopyReader}
   */
  function ownCopy(did) {
    return {
      refNames: async (_did, repo) => {
        const record = /** @type {{refs?: unknown}|null} */ (
          await ctx.readRecord(did, REPO_COLLECTION, repo)
        );
        const refs = Array.isArray(record?.refs) ? record.refs : [];
        return refs
          .map((entry) => /** @type {{name?: unknown}|null} */ (entry)?.name)
          .filter((name) => typeof name === 'string')
          .sort((a, b) => b.length - a.length);
      },
      listCommits: (_did, repo, ref, limit) =>
        ctx.browser.listCommits(did, repo, ref, limit),
      changedPaths: (_did, repo, base, tip) =>
        ctx.browser.changedPaths(did, repo, base, tip),
      readFile: (_did, repo, ref, path) =>
        ctx.browser.readFile(did, repo, ref, path),
    };
  }

  /**
   * The key each account's copy of a repository is at, by account and name.
   * @type {Map<string, {at: number, rkey: string|null}>}
   */
  const copyKeys = new Map();

  /**
   * The record key one account's copy of a repository is at, on their own
   * server. A copy is its holder's own record and carries their own key, so
   * the name is what the two copies share and what finds it.
   *
   * The answer is kept, because a key never moves once minted; the hold is
   * short so that a copy deleted and pushed again is found.
   * @param {Contributor} who
   * @param {string} name
   * @returns {Promise<string|null>}
   */
  async function copyKeyOf(who, name) {
    const at = `${who.did}\u0000${name}`;
    const held = copyKeys.get(at);
    if (held && Date.now() - held.at < COPY_KEY_TTL_MS) return held.rkey;
    const found = (await listRecords(who, REPO_COLLECTION)).find(
      (row) => repoNameOf(row.value, rkeyOf(row.uri)) === name,
    );
    const rkey = found ? rkeyOf(found.uri) : null;
    copyKeys.set(at, { at: Date.now(), rkey });
    return rkey;
  }

  /**
   * One page of an account's records in a collection, from their server.
   * @param {Contributor} who
   * @param {string} collection
   * @returns {Promise<Array<{uri: string, cid: string, value: Record<string, unknown>}>>}
   */
  async function listRecords(who, collection) {
    const params = new URLSearchParams({
      repo: who.did,
      collection,
      limit: String(LIST_LIMIT),
    });
    try {
      const res = await send(
        `${who.pds}/xrpc/com.atproto.repo.listRecords?${params}`,
        { signal: AbortSignal.timeout(READ_TIMEOUT_MS) },
      );
      if (!res.ok) return [];
      const body = /** @type {{records?: unknown[]}} */ (await res.json());
      return (body.records ?? []).flatMap((entry) => {
        const row = /** @type {Record<string, unknown>} */ (entry ?? {});
        return typeof row.uri === 'string' && typeof row.cid === 'string'
          ? [
              {
                uri: row.uri,
                cid: row.cid,
                value: /** @type {Record<string, unknown>} */ (row.value ?? {}),
              },
            ]
          : [];
      });
    } catch {
      // An unreachable account costs its records, not the list.
      return [];
    }
  }

  /**
   * One record from its author's server.
   * @param {Contributor} who
   * @param {string} collection
   * @param {string} rkey
   * @returns {Promise<{uri: string, cid: string, value: Record<string, unknown>}|null>}
   */
  async function getRecord(who, collection, rkey) {
    const params = new URLSearchParams({ repo: who.did, collection, rkey });
    try {
      const res = await send(
        `${who.pds}/xrpc/com.atproto.repo.getRecord?${params}`,
        { signal: AbortSignal.timeout(READ_TIMEOUT_MS) },
      );
      if (!res.ok) return null;
      const body = /** @type {Record<string, unknown>} */ (await res.json());
      return typeof body.uri === 'string' && typeof body.cid === 'string'
        ? {
            uri: body.uri,
            cid: body.cid,
            value: /** @type {Record<string, unknown>} */ (body.value ?? {}),
          }
        : null;
    } catch {
      return null;
    }
  }

  /**
   * The records the discovery port names about the subject, each read from
   * its author. Accounts already read directly are skipped, so a listed
   * voice's word is read once.
   * @param {string} subjectUri
   * @param {string} collection
   * @param {Set<string>} except - DIDs read directly
   * @param {string} [path] - where the record names the subject; `subject.uri`
   *   unless given
   * @returns {Promise<Array<{who: Contributor, uri: string, cid: string, value: Record<string, unknown>}>>}
   */
  async function discovered(
    subjectUri,
    collection,
    except,
    path = 'subject.uri',
  ) {
    const all = (
      await ctx.discovery.about(subjectUri, collection, path)
    ).filter((entry) => !except.has(entry.did));
    if (all.length > MAX_DISCOVERED && ctx.onNotice) {
      ctx.onNotice(
        `discovery named ${all.length} ${collection} records; reading the first ${MAX_DISCOVERED}`,
      );
    }
    const entries = all.slice(0, MAX_DISCOVERED);
    const found = await Promise.all(
      entries.map(async (entry) => {
        const who = await ctx.resolveContributor(entry.did);
        if (!who) return null;
        const record = await getRecord(who, collection, entry.rkey);
        if (!record) return null;
        // The index said the record names this repository. The record is
        // what says so.
        const named =
          path === 'upstream'
            ? record.value.upstream
            : /** @type {{uri?: unknown}} */ (record.value.subject ?? {}).uri;
        if (named !== subjectUri) return null;
        return { who, ...record };
      }),
    );
    return found.filter((one) => one !== null);
  }

  /**
   * The pull requests one account's copy carries.
   * @param {Contributor} who
   * @param {string} repo
   * @param {{shas: string[], commits: Array<{sha: string, parents: string[]}>}} main
   * @param {string} canonicalBranch
   * @param {CopyReader} reader
   * @param {string[]} [only] - short branch names to read, where the caller
   *   wants some of them
   * @returns {Promise<PullRequest[]>}
   */
  async function pullsFrom(who, repo, main, canonicalBranch, reader, only) {
    /** @type {string[]|null} */
    let refs;
    try {
      refs = await reader.refNames(who.did, repo);
    } catch {
      return [];
    }
    if (!refs) return [];

    const branches = (
      only ??
      refs
        .filter((name) => name.startsWith('refs/heads/'))
        .map((name) => name.slice('refs/heads/'.length))
    ).filter((name) => name !== canonicalBranch);

    const found = await Promise.all(
      branches.map(async (branch) => {
        try {
          const log = await reader.listCommits(
            who.did,
            repo,
            branch,
            BRANCH_DEPTH,
          );
          if (!log || log.commits.length === 0) return null;
          const placed = place(log.commits, main.shas);
          const { ahead, base } = placed.merged
            ? mergedRun(log.commits, main.commits)
            : placed;
          const { behind, merged, rooted } = placed;
          const tip = log.commits[0];
          // One tree walk from the base to the tip, whatever the pull
          // request's length.
          const files =
            rooted && base
              ? ((await reader.changedPaths(who.did, repo, base, tip.sha)) ??
                [])
              : [];
          const fixes = [
            ...new Set(
              log.commits
                .slice(0, Math.max(ahead, 1))
                .flatMap((entry) => fixesOf(entry.message)),
            ),
          ];
          /** @type {PullRequest} */
          const pull = {
            id: pullKey(who.did, branch),
            author: who,
            branch,
            title: subjectOf(tip.message),
            sha: tip.sha,
            when: tip.committedAt,
            ahead,
            base,
            behind,
            merged,
            rooted,
            files,
            fixes,
          };
          return pull;
        } catch {
          // An unreachable copy costs its branches, not the list.
          return null;
        }
      }),
    );
    return found.filter((pull) => pull !== null);
  }

  /**
   * A file at one commit, as text, or null where the path is not there or
   * cannot be read as text. What a three-way merge of one path needs.
   * @param {CopyReader} reader
   * @param {string} did @param {string} repo @param {string} sha
   * @param {string} path
   * @returns {Promise<{text: string|null}|null>}
   */
  async function fileAt(reader, did, repo, sha, path) {
    try {
      const file = await reader.readFile(did, repo, sha, path);
      return file ? { text: file.binary ? null : file.text } : null;
    } catch {
      return null;
    }
  }

  /**
   * Whether main and the branch can be put together at one path, or
   * disagree there. The rules are the tree merge's: a path only one side
   * changed takes that side, both deleting it agree, text both changed
   * merges line by line, and anything else both changed is a conflict.
   * @param {PullRequest} pull
   * @param {string} mainTip
   * @param {string} did @param {string} repo
   * @param {CopyReader} theirs - the copy holding the branch
   * @param {string} path
   */
  async function conflictsAt(pull, mainTip, did, repo, theirs, path) {
    const base = /** @type {string} */ (pull.base);
    const [b, o, t] = await Promise.all([
      fileAt(ownCopy(did), did, repo, base, path),
      fileAt(ownCopy(did), did, repo, mainTip, path),
      fileAt(theirs, pull.author.did, repo, pull.sha, path),
    ]);
    const same = (
      /** @type {{text: string|null}|null} */ x,
      /** @type {{text: string|null}|null} */ y,
    ) =>
      (x === null && y === null) ||
      (x !== null && y !== null && x.text === y.text);
    if (same(o, t) || same(o, b) || same(t, b)) return false;
    if (!o || !t || o.text === null || t.text === null) return true;
    return mergeText(b?.text ?? '', o.text, t.text).conflicts > 0;
  }

  /**
   * What each pull request has drifted from: the files main changed since it
   * branched off, that the pull request touches too. One tree walk each,
   * from its own merge base to the tip of main. Then, at each of those
   * paths, whether the two sides can be put together, which is what a
   * reader wants to know before pressing merge.
   * @param {PullRequest[]} pulls
   * @param {string|undefined} mainTip
   * @param {string} did
   * @param {string} repo
   * @param {(pull: PullRequest) => CopyReader} copyOf - the reader over a
   *   pull request's copy
   */
  async function fillDrift(pulls, mainTip, did, repo, copyOf) {
    /** @type {Map<string, Promise<string[]|null>>} */
    const walks = new Map();
    let budget = CONFLICT_BUDGET;
    for (const pull of pulls) {
      if (!pull.base || pull.behind === 0 || !mainTip) {
        pull.drift = [];
        pull.conflicts = [];
        continue;
      }
      // Branches taken from the same commit ask the same question.
      let walk = walks.get(pull.base);
      if (!walk) {
        walk = ctx.browser
          .changedPaths(did, repo, pull.base, mainTip)
          .catch(() => null);
        walks.set(pull.base, walk);
      }
      const since = new Set((await walk) ?? []);
      pull.drift = pull.files.filter((path) => since.has(path));
      const check = pull.drift.slice(
        0,
        Math.max(0, Math.min(MAX_CONFLICT_PATHS, budget)),
      );
      budget -= check.length;
      const theirs = copyOf(pull);
      const clashes = await Promise.all(
        check.map((path) =>
          conflictsAt(pull, mainTip, did, repo, theirs, path),
        ),
      );
      pull.conflicts = check.filter((_path, at) => clashes[at]);
      // A conflict anywhere in the subset settles it. None in a subset short
      // of the whole drift leaves it unknown: the merge itself reads every
      // path, and the page says so rather than reporting a clean it did not
      // confirm.
      pull.conflictsUnknown =
        check.length < pull.drift.length && pull.conflicts.length === 0;
    }
  }

  /**
   * The pull requests from accounts the config does not name, read from their
   * own copies. Two things find them. A copy whose record names this
   * repository as its upstream is a fork, and every branch of it is read. A
   * pull request record names one branch, and only that branch is read: the
   * rest of that account's copy is not this repository's business.
   * @param {string} subjectUri
   * @param {{shas: string[], commits: Array<{sha: string, parents: string[]}>}} main
   * @param {string} canonicalBranch
   * @param {Set<string>} collaborators - DIDs read whole already
   * @returns {Promise<PullRequest[]>}
   */
  async function forkedPulls(subjectUri, main, canonicalBranch, collaborators) {
    const canonicalKey = rkeyOf(subjectUri);
    const [records, forks] = await Promise.all([
      discovered(subjectUri, PULL_COLLECTION, collaborators),
      discovered(subjectUri, REPO_COLLECTION, collaborators, 'upstream'),
    ]);
    /** @type {Map<string, {who: Contributor, copy: string, refs: Set<string>|null, words: Map<string, {note?: string, status?: string, title?: string, canonical?: boolean}>}>} */
    const byDid = new Map();
    // A declared fork is read whole: null names every branch. Discovery
    // named the fork's own record, so its key is in hand.
    for (const { who, uri } of forks) {
      byDid.set(who.did, {
        who,
        copy: rkeyOf(uri),
        refs: null,
        words: new Map(),
      });
    }
    // A pull request lives at the key its branch derives, and that record is
    // the current word. Discovery may name several records for one branch; the
    // one at the derived key wins, and where none is, the last read does.
    for (const { who, uri, value } of records) {
      const { ref, note, status, title } =
        /** @type {Record<string, string>} */ (value);
      if (typeof ref !== 'string') continue;
      const short = shortRef(ref);
      // The pull request record names the copy holding the branch, which is
      // the only thing that says where a fork under another name is.
      const copy =
        typeof (/** @type {{repo?: unknown}} */ (value).repo) === 'string'
          ? /** @type {{repo: string}} */ (value).repo
          : '';
      const group = byDid.get(who.did) ?? {
        who,
        copy,
        refs: new Set(),
        words: new Map(),
      };
      group.refs?.add(short);
      const rkey = rkeyOf(String(uri));
      if (note || status || title) {
        const canonical = rkey === pullRkey(canonicalKey, ref);
        const current = group.words.get(short);
        if (!current || canonical || !current.canonical) {
          group.words.set(short, { note, status, title, canonical });
        }
      }
      byDid.set(who.did, group);
    }

    const found = await Promise.all(
      [...byDid.values()].map(async ({ who, copy, refs, words }) => {
        if (!copy) return [];
        const pulls = await pullsFrom(
          who,
          copy,
          main,
          canonicalBranch,
          openCopy(who),
          refs ? [...refs] : undefined,
        );
        return pulls.map((pull) => ({
          ...pull,
          fork: true,
          title: words.get(pull.branch)?.title || pull.title,
          note: words.get(pull.branch)?.note ?? '',
          status: words.get(pull.branch)?.status,
        }));
      }),
    );
    return found.flat();
  }

  /**
   * Every statement about this repository in one collection: the listed
   * voices' own listings, and whoever else the discovery port names.
   * @param {Contributor[]} voices
   * @param {string} subjectUri
   * @param {string} collection
   * @param {string[]} [copies] - the other repository records a statement
   *   may name: the collaborators' copies, where the branches are
   * @returns {Promise<Array<Record<string, unknown>>>}
   */
  async function statements(voices, subjectUri, collection, copies) {
    // A statement may name the canonical record or the copy holding the
    // branch it speaks to; a review of a collaborator's branch names theirs.
    const named = new Set([subjectUri, ...(copies ?? [])]);
    const listed = await Promise.all(
      voices.map(async (who) =>
        (await listRecords(who, collection))
          .filter((row) => {
            const subject = /** @type {{uri?: unknown}} */ (
              row.value.subject ?? {}
            );
            return typeof subject.uri === 'string' && named.has(subject.uri);
          })
          .map((row) => ({
            ...row.value,
            uri: row.uri,
            cid: row.cid,
            by: who,
          })),
      ),
    );
    const strangers = await discovered(
      subjectUri,
      collection,
      new Set(voices.map((who) => who.did)),
    );
    return [
      ...listed.flat(),
      ...strangers.map(({ who, uri, cid, value }) => ({
        ...value,
        uri,
        cid,
        by: who,
        indexed: true,
      })),
    ];
  }

  /**
   * The runner's runs about any copy of this repository.
   * @param {unknown} config
   * @param {string[]} copyUris - the copies that count
   */
  async function runs(config, copyUris) {
    const runner = runnerOf(config);
    if (!runner) return [];
    const who = await ctx.resolveContributor(runner);
    if (!who) return [];
    /** @type {unknown[]} */
    const records = [];
    let cursor = '';
    for (let page = 0; page < CHECK_PAGES; page++) {
      const params = new URLSearchParams({
        repo: who.did,
        collection: CHECK_COLLECTION,
        limit: String(LIST_LIMIT),
      });
      if (cursor) params.set('cursor', cursor);
      try {
        const res = await send(
          `${who.pds}/xrpc/com.atproto.repo.listRecords?${params}`,
          { signal: AbortSignal.timeout(READ_TIMEOUT_MS) },
        );
        if (!res.ok) break;
        const body = /** @type {{records?: unknown[], cursor?: unknown}} */ (
          await res.json()
        );
        records.push(...(body.records ?? []));
        cursor = typeof body.cursor === 'string' ? body.cursor : '';
        if (!cursor) break;
      } catch {
        break;
      }
    }
    return runsForCopies(records, copyUris);
  }

  return {
    /**
     * A browser over one account's copy, for reading what is in it: a
     * commit, a range, a tree. Null for an account with no server.
     * @param {string} did
     * @returns {Promise<GitBrowser|null>}
     */
    async copy(did) {
      const who = await ctx.resolveContributor(did);
      return who ? /** @type {GitBrowser} */ (openCopy(who)) : null;
    },

    /**
     * The record key one account's copy of a repository is at.
     * @param {string} did
     * @param {string} name
     * @returns {Promise<string|null>}
     */
    async copyKey(did, name) {
      const who = await ctx.resolveContributor(did);
      return who ? copyKeyOf(who, name) : null;
    },

    /**
     * Every pull request on one repository, from every copy.
     * @param {string} did - the repository owner
     * @param {string} rkey - the repository's record key
     * @returns {Promise<PullRequestList|null>} null for no such repository
     */
    async listPulls(did, rkey) {
      const record =
        /** @type {{defaultBranch?: unknown, name?: unknown}|null} */ (
          await ctx.readRecord(did, REPO_COLLECTION, rkey)
        );
      if (!record) return null;
      const name = repoNameOf(record, rkey);
      const canonicalBranch =
        typeof record.defaultBranch === 'string' && record.defaultBranch
          ? record.defaultBranch
          : 'main';
      const config = await ctx.readRecord(did, 'dev.pdsjs.git.config', rkey);
      const subjectUri = `at://${did}/${REPO_COLLECTION}/${rkey}`;

      const mainLog = await ctx.browser
        .listCommits(did, rkey, canonicalBranch, MAIN_DEPTH)
        .catch(() => null);
      const main = {
        commits: mainLog?.commits ?? [],
        shas: (mainLog?.commits ?? []).map((entry) => entry.sha),
      };

      const dids = [did, ...collaboratorsOf(config)];
      const collaborators = (
        await Promise.all(dids.map((one) => ctx.resolveContributor(one)))
      ).filter((who) => who !== null);
      const voices = (
        await Promise.all(
          [...new Set([...dids, ...reviewersOf(config)])].map((one) =>
            ctx.resolveContributor(one),
          ),
        )
      ).filter((who) => who !== null);

      // Which key each collaborator's copy is at. A copy is its holder's own
      // record and carries their own key; the name is what the copies share.
      /** @type {Map<string, string>} */
      const keys = new Map([[did, rkey]]);
      await Promise.all(
        collaborators
          .filter((who) => who.did !== did)
          .map(async (who) => {
            const theirs = await copyKeyOf(who, name);
            if (theirs) keys.set(who.did, theirs);
          }),
      );
      const holding = collaborators.filter((who) => keys.has(who.did));
      const copyUris = [...keys].map(
        ([one, key]) => `at://${one}/${REPO_COLLECTION}/${key}`,
      );

      const [found, reviews, checks, forked] = await Promise.all([
        Promise.all(
          holding.map((who) =>
            pullsFrom(
              who,
              /** @type {string} */ (keys.get(who.did)),
              main,
              canonicalBranch,
              // This server holds its own copy; every other copy is read
              // from the server that holds it.
              who.did === did ? ownCopy(did) : openCopy(who),
            ),
          ),
        ),
        statements(voices, subjectUri, REVIEW_COLLECTION, copyUris),
        runs(config, copyUris),
        forkedPulls(subjectUri, main, canonicalBranch, new Set(dids)),
      ]);

      const mine = found.flat();
      await Promise.all(
        mine.map(async (pull) => {
          const who = pull.author;
          const record = await getRecord(
            who,
            PULL_COLLECTION,
            pullRkey(rkey, `refs/heads/${pull.branch}`),
          );
          if (!record) return;
          const value = /** @type {Record<string, unknown>} */ (record.value);
          if (typeof value.title === 'string') pull.title = value.title;
          if (typeof value.status === 'string') pull.status = value.status;
          if (typeof value.note === 'string') pull.note = value.note;
          if (typeof value.createdAt === 'string')
            pull.noteAt = value.createdAt;
        }),
      );

      const pulls = [...mine, ...forked].sort((a, b) => b.when - a.when);
      const open = pulls.filter((pull) => !pull.merged);
      await fillDrift(open, main.shas[0], did, rkey, (pull) =>
        pull.author.did === did ? ownCopy(did) : openCopy(pull.author),
      );
      return {
        open,
        merged: pulls.filter((pull) => pull.merged),
        collaborators,
        reviews: [...byPull(reviews)],
        checks: [...byPull(checks)],
        configured: collaboratorsOf(config).length > 0,
      };
    },
  };
}