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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
225 lines · 8.1 KB Raw History
// The pull requests on a repository, as the page reads them.
//
// A pull request is a branch in one account's copy, identified by that
// account's DID and the ref name. The pair survives every rewrite of the
// branch, so a check or a review written against one version still names the
// work after a rebase, which under agents is most pushes.
//
// The list comes from the repository's own server, which reads every copy
// once for every visitor. What the page keeps is the addressing of a pull
// request, and a reader over one contributor's server for the objects of a
// commit in their copy, which are there and nowhere else.

import {
  byPull,
  fileSummary,
  fixesOf,
  mergedRun,
  place,
  pullKey,
  replacedTips,
  runsForCopies,
  shortRef,
  subjectOf,
} from '@pdsjs/git/pull-requests';

export {
  byPull,
  fileSummary,
  fixesOf,
  mergedRun,
  place,
  pullKey,
  replacedTips,
  runsForCopies,
  shortRef,
  subjectOf,
};

import { learnProfiles } from './authors.js';
import { account, ownerClient, readerFor, serverClient } from './git.js';

/** @typedef {import('@pdsjs/git/pull-requests').Contributor} Contributor */
/** @typedef {import('@pdsjs/git/pull-requests').PullRequest} PullRequest */

/** @type {Map<string, Promise<Contributor|null>>} */

/** @param {Contributor} who */

/**
 * The way back to the pull request list, naming the tab that holds this one.
 *
 * The list is one query, so a page reached from it has it in hand and can be
 * exact. A reader who opened the address directly has no such read, and asking
 * for one to place a breadcrumb would mean gathering every collaborator's copy
 * to render a link.
 * @param {string} repo
 * @param {Pulls|undefined} pulls - the cached read, where there is one
 * @param {string} tip - the tip commit
 * @returns {string}
 */
export function pullsHref(repo, pulls, tip) {
  const at = `/${encodeURIComponent(repo)}/pulls`;
  const on = (/** @type {PullRequest[]|undefined} */ list) =>
    (list ?? []).some((pull) => pull.sha === tip);
  if (on(pulls?.merged)) return `${at}/merged`;
  return at;
}

/**
 * How many of a pull request's commits are read at once. The timeline and the
 * commit listing share one cache entry, so they share this.
 */
export const PULL_COMMIT_LIMIT = 50;

/**
 * The commits a pull request is made of, from whichever copy holds them:
 * this account's, or a collaborator's or a fork's, read through this server
 * either way.
 * @param {string} repo
 * @param {string|null} base - the merge base, null where there is none
 * @param {string} tip
 * @param {string} from - the DID whose copy holds it, empty for this account
 * @param {number} limit
 * @returns {Promise<{commits: Array<Record<string, *>>, truncated: boolean}>}
 */
export async function pullCommits(repo, base, tip, from, limit) {
  const copy = from && from !== account.did ? from : undefined;
  const log = await readerFor(repo, copy).listRange(
    copy ?? account.did,
    repo,
    base,
    tip,
    limit,
  );
  // The reader answers null for a repository it cannot open. A screen that
  // took that for an answer would wait on a listing that never arrives.
  if (!log) throw new Error('That copy of the repository could not be read.');
  return log;
}

/**
 * Where a pull request opens, addressed by what identifies it: the account
 * whose copy holds the work, and the branch. Both survive a rebase, so the
 * address keeps working while the shas under it move.
 *
 * `tab` is what the reader asked to see of it, past the separator: `files`,
 * `checks`, or `commit/<sha>` for one of its own commits.
 * @param {string} repo
 * @param {PullRequest} pull
 * @param {string} [tab]
 * @returns {string}
 */
export function diffHref(repo, pull, tab = '') {
  const branch = pull.branch.split('/').map(encodeURIComponent).join('/');
  const at = `/${encodeURIComponent(repo)}/pull/${encodeURIComponent(
    pull.author.did,
  )}/${branch}`;
  return tab ? `${at}/${PULL_SEPARATOR}/${tab}` : at;
}

/**
 * What divides a branch from what follows it.
 *
 * A ref name may hold slashes and a pull request is addressed by its branch,
 * so anything past the branch would otherwise read as the branch's own last
 * segment. A forge addressing by number never meets this; this one has no
 * number to address by, so it needs a divider.
 *
 * `~` is the divider because git refuses it in a ref name, so no branch can
 * hold a segment that looks like one. It is also unreserved in a URL, so it
 * survives a path without being escaped. GitLab's `-` is the better-known
 * spelling and git allows a ref component of `-`, which would leave
 * `fix/-/checks` meaning two things.
 */
const PULL_SEPARATOR = '~';

/**
 * Read a pull request's address back: the account, the branch and the tab.
 *
 * The branch is what makes this more than a split. A ref name may hold
 * slashes, so the branch is every segment up to the separator.
 *
 * A `-` segment divides the branch from what follows it, so no branch name
 * can be mistaken for a tab: git forbids a ref component of `-`, and a DID
 * holds no slash, so every part of the address reads without a guess.
 * @param {string[]} rest - the path segments after `pull`, already decoded
 * @returns {{did: string, branch: string, tab: string, commit: string}}
 */
export function parsePullPath(rest) {
  const did = rest[0] ?? '';
  const parts = rest.slice(1);
  const cut = parts.indexOf(PULL_SEPARATOR);
  // Everything before the separator is the branch, however many slashes it
  // holds. Everything after is what the reader asked to see of it.
  const branch = (cut === -1 ? parts : parts.slice(0, cut)).join('/');
  const after = cut === -1 ? [] : parts.slice(cut + 1);
  // One commit of this pull request, addressed under it so a reader who
  // opens one is still in the section they were reading.
  const commit = after[0] === 'commit' ? (after[1] ?? '') : '';
  const tab =
    commit || after.find((one) => one === 'files' || one === 'checks') || '';
  return { did, branch, tab: commit ? 'commit' : tab, commit };
}

/**
 * @typedef {Object} Pulls
 * @property {PullRequest[]} open - branches canonical main does not reach,
 *   from collaborators and from forks together
 * @property {PullRequest[]} merged
 * @property {Contributor[]} collaborators
 * @property {Map<string, Array<Record<string, unknown>>>} reviews
 * @property {Map<string, Array<Record<string, unknown>>>} checks
 * @property {boolean} configured - the repository names collaborators
 */

/**
 * Every pull request on a repository, as the server lists them. The faces
 * and names of everybody on the list are learned alongside, so a row is drawn
 * whole on the first paint. A repository in a space has none to list, which
 * is what the server answers.
 * @param {string} repo - what the repository is called
 * @param {string} [space]
 * @returns {Promise<Pulls>}
 */
export async function loadPulls(repo, space) {
  const list = await (space ? ownerClient() : await serverClient()).listPulls({
    repo,
    space,
  });
  const people = new Set(list.collaborators.map((who) => who.did));
  for (const pull of [...list.open, ...list.merged])
    people.add(pull.author.did);
  await learnProfiles([...people]);
  return {
    open: list.open,
    merged: list.merged,
    collaborators: list.collaborators,
    reviews: new Map(list.reviews),
    checks: new Map(list.checks),
    configured: list.configured,
  };
}

/**
 * Every review written about this repository's pull requests, for a page that
 * shows one pull request rather than all of them. The list holds each review
 * under two keys; this reads each once.
 * @param {string} repo - what the repository is called
 * @param {string} [space]
 * @returns {Promise<Array<Record<string, unknown>>>} newest first
 */
export async function pullStatements(repo, space) {
  const { reviews } = await loadPulls(repo, space);
  /** @type {Map<string, Record<string, unknown>>} */
  const once = new Map();
  for (const statements of reviews.values()) {
    for (const statement of statements) {
      const uri = typeof statement.uri === 'string' ? statement.uri : '';
      if (uri && !once.has(uri)) once.set(uri, statement);
    }
  }
  return [...once.values()].sort((a, b) =>
    String(b.reviewedAt ?? '').localeCompare(String(a.reviewedAt ?? '')),
  );
}