chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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,
);
}