1// Comments and approvals on reviews, written by `forge comment` and2// `forge approve` on the server (see sc_comment and sc_approve in bin/forge).3//4// A comment is a file, <repo>.git/forge/comments/<review>/<n>: header lines5// ("time", "user", "sha", "rev", and for a line comment "path", "line",6// "text"; from forge critique, "agent" and "severity"), a blank line, then7// the message. An approval is a line in the8// review's log, "approve <ts> <user> <sha> <tree>", and like CI it is keyed by9// tree, so rewording or reordering commits keeps it.10import fs from 'node:fs';11import path from 'node:path';12import type { Approval, DiffFile, Review, ReviewComment } from './types.ts';1314export function readComments(gitDir: string, id: number): ReviewComment[] {15 const dir = path.join(gitDir, 'forge', 'comments', String(id));16 let names: string[];17 try {18 names = fs.readdirSync(dir).filter((f) => /^\d+$/.test(f));19 } catch {20 return [];21 }22 return names.map(Number).sort((a, b) => a - b).map((n): ReviewComment => {23 const text = fs.readFileSync(path.join(dir, String(n)), 'utf8');24 const blank = text.indexOf('\n\n');25 const head: Record<string, string> = {};26 for (const line of text.slice(0, blank < 0 ? text.length : blank).split('\n')) {27 const sp = line.indexOf(' ');28 if (sp > 0) head[line.slice(0, sp)] = line.slice(sp + 1);29 }30 return {31 n, ts: Number(head.time), user: head.user ?? '', sha: head.sha ?? '', rev: head.rev ? Number(head.rev) : null,32 path: head.path || null, line: head.line ? Number(head.line) : null, text: head.text ?? null,33 body: blank < 0 ? '' : text.slice(blank + 2).replace(/\s+$/, ''), outdated: false, inDiff: false,34 agent: head.agent || null, severity: head.severity === 'blocking' || head.severity === 'note' ? head.severity : null,35 };36 });37}3839export function parseApproval(v: string): Approval {40 const [ts, user, sha, tree] = v.split(' ');41 return { ts: Number(ts), user, sha, tree, rev: null, current: false };42}4344// Once the tip and revisions are known: the revision of each comment and45// approval, which comments are outdated, and one approval per person, a46// current one if they ever approved the tip's tree, else their latest.47export function settle(r: Review, tipTree: string | null): void {48 const revOf = (sha: string): number | null => r.revs.findLast((v) => v.sha === sha)?.n ?? null;49 for (const c of r.comments) {50 c.rev = revOf(c.sha) ?? c.rev;51 c.outdated = c.sha !== r.tip;52 }53 const mine = new Map<string, Approval>();54 for (const a of r.approvals) {55 a.rev = revOf(a.sha);56 a.current = !!tipTree && a.tree === tipTree;57 if (a.user === r.author) continue;58 const had = mine.get(a.user);59 if (!had || a.current || !had.current) mine.set(a.user, a);60 }61 r.approvals = [...mine.values()];62}6364// Line comments on the current revision whose line is in the combined diff.65export function markInDiff(r: Review, files: DiffFile[]): void {66 const shown = new Set<string>();67 for (const f of files) for (const h of f.hunks) for (const l of h.lines) if (l.n != null) shown.add(`${f.path}:${l.n}`);68 for (const c of r.comments) c.inDiff = !c.outdated && !!c.path && shown.has(`${c.path}:${c.line}`);69}7071// The hook for diff(): current line comments, rendered by `html`, under72// their line.73export function inline(r: Review, html: (c: ReviewComment) => string): ((p: string, n: number) => string | null) | null {74 const at = new Map<string, ReviewComment[]>();75 for (const c of r.comments) {76 if (c.outdated || !c.path || !c.line) continue;77 const k = `${c.path}:${c.line}`;78 at.set(k, [...(at.get(k) ?? []), c]);79 }80 if (!at.size) return null;81 return (p, n) => {82 const list = at.get(`${p}:${n}`);83 return list ? `<div class="cmts">${list.map(html).join('')}</div>` : null;84 };85}