// 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>} stack * @returns {Array>} 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>>} */ 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>} */ const out = []; /** @param {Record} 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>} statements * @returns {Array<{root: Record, replies: number, latest: string, * thread: Array>}>} 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} 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, {root: Record, replies: number, latest: string, thread: Array>}>} */ 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>} 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>>>, * spans: Map>, * 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>>>} */ const byFile = new Map(); /** @type {Map>} */ 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, ); }