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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
198 lines · 7.6 KB Raw History
// Placing anchored reviews onto the diff in view.
//
// A comment names the exact commit it was made against, so on that commit its
// line number is simply true. A force-push gives the branch a new sha; the
// comment still names the old one, and rather than guess it is shown as
// answering a commit the branch has replaced, counted here and marked on the
// conversation. The snippet in the anchor is what a later, smarter placement
// would search for; nothing here pretends to that yet.

/**
 * The key a diff row computes for a comment that sits on it. A range sits on
 * its last line, where a forge hangs a multi-line thread, so the row that
 * carries the conversation is the end of the span.
 * @param {{line?: number, endLine?: number, side?: string}} anchor
 * @returns {string}
 */
export function anchorKey(anchor) {
  return `${anchor.side === 'old' ? 'old' : 'new'}:${anchor.endLine ?? anchor.line ?? 0}`;
}

/**
 * Where a thread lives on the pull request page, as a fragment id. The card
 * links here and the diff marks the element, so "1 reply" is a place rather
 * than a number.
 * @param {string} path
 * @param {{line?: number, side?: string}} anchor
 * @returns {string}
 */
export function commentFragment(path, anchor) {
  return `comment-${path}-${anchorKey(anchor)}`;
}

/**
 * The key one diff row answers to. Deleted lines exist only on the old side;
 * everything else anchors to the new side, which is the code being proposed.
 * @param {{oldLine: number|null, newLine: number|null}} line
 * @returns {string}
 */
export function lineKey(line) {
  return line.newLine != null ? `new:${line.newLine}` : `old:${line.oldLine}`;
}

/**
 * Order one line's comments as a conversation: oldest first, each reply
 * directly under what it answers, one level deep the way a forge threads a
 * line. A reply whose parent is not in the stack (an unlisted voice, or a
 * deleted record) stands on its own rather than vanishing.
 * @param {Array<Record<string, *>>} stack
 * @returns {Array<Record<string, *>>} the same statements, ordered, each
 *   with a `depth` of 0 or 1
 */
export function threadStack(stack) {
  const sorted = [...stack].sort((a, b) =>
    String(a.reviewedAt ?? '').localeCompare(String(b.reviewedAt ?? '')),
  );
  const here = new Set(sorted.map((s) => s.uri).filter(Boolean));
  /** @type {Map<string, Array<Record<string, *>>>} */
  const children = new Map();
  for (const statement of sorted) {
    const parent = statement.replyTo?.uri;
    if (parent && here.has(parent)) {
      children.set(parent, [...(children.get(parent) ?? []), statement]);
    }
  }
  /** @type {Array<Record<string, *>>} */
  const out = [];
  /** @param {Record<string, *>} statement @param {number} depth */
  const place = (statement, depth) => {
    out.push({ ...statement, depth });
    for (const reply of children.get(statement.uri) ?? []) place(reply, 1);
  };
  for (const statement of sorted) {
    const parent = statement.replyTo?.uri;
    if (!(parent && here.has(parent))) place(statement, 0);
  }
  return out;
}

/** Steps a reply chain is followed before it counts as its own thread. */
const MAX_CHAIN = 20;

/**
 * One line per conversation, for a card that summarises rather than talks.
 *
 * A list sorted newest-first puts a reply above what it answers, and an
 * indent there claims an adjacency the order has broken. So a summary shows
 * each thread's opening statement once, with a count of the answers, and
 * threads sort by their latest word: a fresh reply bubbles its conversation
 * up without pretending to be the start of it.
 * @param {Array<Record<string, *>>} statements
 * @returns {Array<{root: Record<string, *>, replies: number, latest: string,
 *   thread: Array<Record<string, *>>}>} newest activity first; `thread` is
 *   every statement in the conversation, for a page that shows all of it
 */
export function threadSummaries(statements) {
  const byUri = new Map(statements.filter((s) => s.uri).map((s) => [s.uri, s]));
  /** @param {Record<string, *>} statement */
  const rootOf = (statement) => {
    let here = statement;
    for (let step = 0; step < MAX_CHAIN; step++) {
      const parent = here.replyTo?.uri && byUri.get(here.replyTo.uri);
      if (!parent) return here;
      here = parent;
    }
    return here;
  };

  /** @type {Map<Record<string, *>, {root: Record<string, *>, replies: number, latest: string, thread: Array<Record<string, *>>}>} */
  const threads = new Map();
  for (const statement of statements) {
    const root = rootOf(statement);
    const thread = threads.get(root) ?? {
      root,
      replies: -1,
      latest: '',
      thread: [],
    };
    thread.replies++;
    thread.thread.push(statement);
    const at = String(statement.reviewedAt ?? '');
    if (at > thread.latest) thread.latest = at;
    threads.set(root, thread);
  }
  return [...threads.values()].sort((a, b) => b.latest.localeCompare(a.latest));
}

/**
 * Sort the anchored statements about one pull request onto the version in
 * view.
 * @param {Array<Record<string, *>>} statements - review records, each with
 *   `by` naming who wrote it
 * @param {{sha: string, author?: string|null, ref?: string|null}} version -
 *   the version in view, and the pull request it belongs to
 * @returns {{byFile: Map<string, Map<string, Array<Record<string, *>>>>,
 *   spans: Map<string, Array<{side: string, start: number, end: number}>>,
 *   elsewhere: number}} comments per file and line, the ranges a comment
 *   covers per file for the diff to mark, and how many are on other versions
 *   of the same pull request
 */
export function placeComments(statements, version) {
  /** @type {Map<string, Map<string, Array<Record<string, *>>>>} */
  const byFile = new Map();
  /** @type {Map<string, Array<{side: string, start: number, end: number}>>} */
  const spans = new Map();
  let elsewhere = 0;
  for (const statement of statements) {
    const anchor = /** @type {{path?: unknown}} */ (statement.anchor ?? {});
    if (typeof anchor.path !== 'string') continue;
    const ours =
      statement.sha === version.sha ||
      (Boolean(version.author) &&
        statement.author === version.author &&
        statement.ref === version.ref);
    if (!ours) continue;
    if (statement.sha !== version.sha) {
      elsewhere++;
      continue;
    }
    const lines = byFile.get(anchor.path) ?? new Map();
    const key = anchorKey(/** @type {*} */ (anchor));
    lines.set(key, [...(lines.get(key) ?? []), statement]);
    byFile.set(anchor.path, lines);
    const range =
      /** @type {{line?: number, endLine?: number, side?: string}} */ (anchor);
    if (typeof range.endLine === 'number' && typeof range.line === 'number') {
      const list = spans.get(anchor.path) ?? [];
      list.push({
        side: range.side === 'old' ? 'old' : 'new',
        start: Math.min(range.line, range.endLine),
        end: Math.max(range.line, range.endLine),
      });
      spans.set(anchor.path, list);
    }
  }
  for (const lines of byFile.values()) {
    for (const [key, stack] of lines) {
      lines.set(key, threadStack(stack));
    }
  }
  return { byFile, spans, elsewhere };
}

/**
 * Whether a diff row falls inside a range a comment covers, so the diff can
 * mark the span its last line carries the thread for.
 * @param {Array<{side: string, start: number, end: number}>|undefined} spans
 * @param {{oldLine: number|null, newLine: number|null}} line
 * @returns {boolean}
 */
export function inSpan(spans, line) {
  if (!spans) return false;
  const side = line.newLine != null ? 'new' : 'old';
  const at = line.newLine ?? line.oldLine;
  if (at == null) return false;
  return spans.some(
    (span) => span.side === side && at >= span.start && at <= span.end,
  );
}