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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
323 lines · 11.7 KB Raw History
/**
 * Pull requests, read from what the object graph and the records say.
 *
 * A pull request is a branch in one account's copy, named by the account and
 * the branch. Nothing here stores a state: open and merged are where a tip
 * stands against canonical main, and every other fact is derived from the
 * commits, the statements written against them, and the runner's runs.
 *
 * These are the parts of that reading that touch no network: the page and
 * the server both read pull requests, and each brings its own fetching.
 *
 * Workers-clean: no node imports.
 */

/**
 * @typedef {Object} Contributor
 * @property {string} did
 * @property {string} handle
 * @property {string} pds
 */

/**
 * @typedef {Object} PullRequest
 * @property {string} id - the author's DID and the branch, which is what
 *   identifies a pull request
 * @property {Contributor} author
 * @property {string} branch - short ref name in the author's own copy
 * @property {string} title - the author's title, or the tip commit's subject
 * @property {string} sha - the tip commit
 * @property {number} when - the tip commit's commit date, epoch ms
 * @property {number} ahead - commits main does not have
 * @property {number} behind - commits main has that this does not
 * @property {string|null} base - the merge base with main
 * @property {boolean} merged - main already reaches this tip
 * @property {boolean} rooted - a merge base was found inside the walk
 * @property {string[]} files - paths the pull request touches
 * @property {string[]} fixes - issue at-uris its commits claim to fix
 * @property {string[]} [drift] - paths main has changed since it branched
 * @property {string[]} [conflicts] - paths where the branch and main changed
 *   the same thing differently, so a merge cannot put them together. Empty
 *   where a merge is clean, and where main has not moved.
 * @property {boolean} [conflictsUnknown] - the branch drifts in more files
 *   than the list read, and none of those it read conflict, so whether the
 *   rest do is unread. The merge itself reads them all.
 * @property {boolean} [fork] - from an account the repository has not named
 * @property {string} [status] - the author's word from their own record, such
 *   as draft or withdrawn
 * @property {string} [note] - the author's note from the same record
 * @property {string} [noteAt] - when they wrote it, where that is known
 */

/**
 * What identifies a pull request: the account whose copy holds the work, and
 * the branch holding it. A space appears in neither a DID nor a ref name, so
 * the two parts cannot run together.
 * @param {string} did
 * @param {string} branch - short ref name
 * @returns {string}
 */
export const pullKey = (did, branch) => `${did} ${branch}`;

/**
 * The short form of a ref name, which is how a branch is named on screen.
 * @param {unknown} ref
 * @returns {string}
 */
export const shortRef = (ref) =>
  String(ref || '').replace(/^refs\/heads\//, '');

/**
 * The DID an AT-URI names.
 * @param {unknown} uri
 * @returns {string|null}
 */
const didOf = (uri) => /^at:\/\/([^/]+)\//.exec(String(uri || ''))?.[1] ?? null;

/**
 * The issues a commit message claims to fix: each a `Fixes:` trailer naming
 * an issue record's at-uri. The fixer writes it in the fix. An issue here has
 * no number, so its name is its address.
 * @param {string} message
 * @returns {string[]}
 */
export function fixesOf(message) {
  return [
    ...String(message || '').matchAll(/^Fixes:\s*(at:\/\/\S+)\s*$/gm),
  ].map((match) => match[1]);
}

/**
 * The first line of a commit message.
 * @param {string|undefined} message
 */
export const subjectOf = (message) =>
  String(message || '')
    .split('\n')[0]
    .trim();

/**
 * Place a branch against main by sha alone. Both walks come from different
 * servers and neither holds the other's objects, but a commit id is the same
 * id everywhere, so the intersection of the two walks is the merge base.
 * @param {Array<{sha: string}>} branch - the branch's commits, newest first
 * @param {string[]} mainShas - main's commits, newest first
 * @returns {{ahead: number, base: string|null, behind: number,
 *   merged: boolean, rooted: boolean}}
 */
export function place(branch, mainShas) {
  const inMain = new Set(mainShas);
  const here = branch[0]?.sha;
  if (!here || inMain.has(here)) {
    return {
      ahead: 0,
      base: here ?? null,
      behind: 0,
      merged: true,
      rooted: true,
    };
  }
  let ahead = 0;
  let base = null;
  for (const entry of branch) {
    if (inMain.has(entry.sha)) {
      base = entry.sha;
      break;
    }
    ahead++;
  }
  // No shared commit inside either walk. The branch is not placed against
  // main, and counting it as ahead of everything would be a guess.
  if (!base)
    return { ahead, base: null, behind: 0, merged: false, rooted: false };
  return {
    ahead,
    base,
    behind: mainShas.indexOf(base),
    merged: false,
    rooted: true,
  };
}

/**
 * Where a merged branch's work began, read from the merge that brought it in.
 *
 * Once main reaches a branch there is no divergence left to measure: the
 * branch's commits are main's commits. A merge commit still says where they
 * came from. Its second parent is the tip that was merged, and its first
 * parents are main's own trunk, so the branch's earliest commit outside that
 * trunk is where the work began.
 *
 * A branch fast-forwarded or squashed into main leaves no such commit. The
 * honest answer from that history is the tip alone.
 * @param {Array<{sha: string, parents: string[]}>} commits - the branch's,
 *   newest first
 * @param {Array<{sha: string, parents: string[]}>} mainCommits - newest first
 * @returns {{ahead: number, base: string|null}}
 */
export function mergedRun(commits, mainCommits) {
  const tip = commits[0].sha;
  const merge = mainCommits.find((entry) =>
    (entry.parents ?? []).slice(1).includes(tip),
  );
  if (!merge) return { ahead: 1, base: commits[0].parents[0] ?? null };

  const byMain = new Map(mainCommits.map((entry) => [entry.sha, entry]));
  /** @type {Set<string>} */
  const trunk = new Set();
  /** @type {string|undefined} */
  let at = merge.parents[0];
  while (at && !trunk.has(at)) {
    trunk.add(at);
    at = byMain.get(at)?.parents?.[0];
  }

  let ahead = 0;
  for (const entry of commits) {
    if (trunk.has(entry.sha)) {
      return { ahead: Math.max(ahead, 1), base: entry.sha };
    }
    ahead++;
  }
  return { ahead, base: commits[commits.length - 1].parents[0] ?? null };
}

/**
 * A file list too long to read, said in one line. An agent's pull request
 * touches tens of files, and printing them all buries everything else on the
 * card.
 * @param {string[]} paths
 * @returns {{count: number, dirs: Array<{dir: string, count: number}>}}
 *   directories by how many of the paths are in them, most first
 */
export function fileSummary(paths) {
  /** @type {Map<string, number>} */
  const byDir = new Map();
  for (const path of paths) {
    const cut = path.lastIndexOf('/');
    const dir = cut === -1 ? '' : path.slice(0, cut);
    byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
  }
  const dirs = [...byDir.entries()]
    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
    .map(([dir, count]) => ({ dir, count }));
  return { count: paths.length, dirs };
}

/** Characters a record key may hold; anything else becomes `_`. */
const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g;

/**
 * The record key one branch's pull request lives at: the canonical
 * repository's own key and the full ref, which together name the pull
 * request. The CLI writes it with `putRecord` and the reader reads it back,
 * so both must agree or an update is never seen.
 *
 * The canonical key is the one value both sides of a pull request hold, and
 * it stands through a rename.
 * @param {string} repoKey - the canonical repository's record key
 * @param {string} ref - full ref name
 * @returns {string}
 */
export const pullRkey = (repoKey, ref) =>
  `${repoKey}:${ref}`.replace(RKEY_SAFE, '_');

/**
 * Every reviewer's records about this repository, indexed by what they name.
 * A reviewer keys reviews by TID in one collection, so the listing is filtered
 * here rather than by the server.
 * @param {unknown} config
 * @returns {Promise<Array<Record<string, unknown>>>} newest first
 */

/**
 * The runner's runs about any copy of one repository. The runner checks the
 * branch a collaborator pushed to that collaborator's own server, so a run
 * names their record rather than the canonical one.
 * @param {unknown[]} records - listRecords entries
 * @param {string[]} copyUris - the record of every copy that counts, each
 *   under its own holder's key
 * @returns {Array<Record<string, unknown>>} newest first
 */
export function runsForCopies(records, copyUris) {
  const copies = new Set(copyUris);
  const runs = [];
  for (const entry of records) {
    const row = /** @type {{value?: unknown, uri?: unknown}|null} */ (entry);
    const run = /** @type {Record<string, unknown>} */ (row?.value ?? {});
    const subject = /** @type {{uri?: unknown}} */ (run.subject ?? {});
    if (typeof subject.uri !== 'string' || !copies.has(subject.uri)) continue;
    if (typeof run.sha !== 'string') continue;
    // The record key, which is what addresses the run's own page. Without it
    // a run reads here and cannot be opened.
    const rkey = String(row?.uri ?? '')
      .split('/')
      .pop();
    runs.push(rkey ? { ...run, rkey } : run);
  }
  return runs.sort((a, b) =>
    String(b.startedAt ?? '').localeCompare(String(a.startedAt ?? '')),
  );
}

/**
 * The pull request one statement names: its author DID and ref, or the DID in
 * the repository record it read and its ref. A check names the copy it ran
 * against rather than an author, and both forms carry the branch.
 * @param {Record<string, unknown>} statement
 * @returns {string|null}
 */
function statementPull(statement) {
  const author =
    typeof statement.author === 'string'
      ? statement.author
      : didOf(/** @type {{uri?: string}} */ (statement.subject ?? {}).uri);
  const ref = statement.ref;
  if (!author || typeof ref !== 'string') return null;
  return pullKey(author, shortRef(ref));
}

/**
 * Index statements by what they name. A statement reaches its pull request
 * under two keys, the author and branch pair and the sha it was written
 * against, so a reader finds it whether or not the writer named the branch.
 * @param {Array<Record<string, unknown>>} statements - newest first
 * @returns {Map<string, Array<Record<string, unknown>>>}
 */
export function byPull(statements) {
  /** @type {Map<string, Array<Record<string, unknown>>>} */
  const index = new Map();
  for (const statement of statements) {
    for (const key of [statementPull(statement), statement.sha]) {
      if (typeof key !== 'string') continue;
      const list = index.get(key) ?? [];
      if (!list.includes(statement)) list.push(statement);
      index.set(key, list);
    }
  }
  return index;
}

/**
 * The tips one branch has carried that are not the tip now, newest first.
 *
 * A force-push puts the commit before it out of reach: nothing in the graph
 * says it existed, and a review or a check written against it names a sha
 * nothing points at. What remembers is the checks and the reviews, each of
 * which names the tip it answered.
 *
 * What this is for is telling an old tip from an unrelated one, so a comment
 * on a rewritten commit reads as outdated rather than as lost.
 * @param {PullRequest} pull
 * @param {Array<Record<string, unknown>>} statements - about this pull request
 * @returns {Set<string>} commit ids, the tip excluded
 */
export function replacedTips(pull, statements) {
  /** @type {Set<string>} */
  const tips = new Set();
  for (const statement of statements) {
    if (typeof statement.sha === 'string') tips.add(statement.sha);
  }
  tips.delete(pull.sha);
  return tips;
}