clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / reviews.ts
reviews.ts 7.5K
1// Reviews are branches. The post-receive hook appends every push of a2// non-default branch to <repo>.git/forge/reviews/<id>; this module turns those3// logs plus the live refs into stacks of layers of commits.4import fs from 'node:fs';5import path from 'node:path';6import { revList, isAncestor, tryRun } from './git.ts';7import { readComments, parseApproval } from './discussion.ts';8import type { Review, ReviewLog, Revision } from './types.ts';910// comments: false leaves them out (the index's recent list, lib/recent.ts).11export function readReviewLogs(gitDir: string, comments = true): ReviewLog[] {12 const dir = path.join(gitDir, 'forge', 'reviews');13 if (!fs.existsSync(dir)) return [];14 return fs.readdirSync(dir).filter((f) => /^\d+$/.test(f)).map((f): ReviewLog => {15 const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: comments ? readComments(gitDir, Number(f)) : [] };16 for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {17 const sp = line.indexOf(' ');18 const k = sp < 0 ? line : line.slice(0, sp);19 const v = sp < 0 ? '' : line.slice(sp + 1);20 if (k === 'branch') r.branch = v;21 else if (k === 'created') {22 const [ts, user] = v.split(' ');23 r.created = { ts: Number(ts), user };24 } else if (k === 'opt') {25 const eq = v.indexOf('=');26 const key = eq < 0 ? v : v.slice(0, eq);27 if (key === 'draft') r.draft = true;28 else if (key === 'ready') r.draft = false;29 else r.opts[key] = eq < 0 ? '' : v.slice(eq + 1);30 } else if (k === 'rev') {31 const [ts, sha, user, base] = v.split(' ');32 r.revs.push({ n: r.revs.length + 1, ts: Number(ts), sha, user, base: base || null });33 } else if (k === 'deleted') {34 const [ts, user] = v.split(' ');35 r.deleted = { ts: Number(ts), user };36 } else if (k === 'approve') r.approvals.push(parseApproval(v));37 }38 return r;39 }).filter((r) => r.branch).sort((a, b) => a.id - b.id);40}4142interface BuildOptions {43 logs: ReviewLog[];44 branches: Map<string, string>;45 defaultBranch: string;46 // Cache of range-diff outputs, keyed by the two ranges (fixed commits).47 ranges?: Record<string, unknown>;48 // false skips revisions and their range-diffs, for the server's land.49 revisions?: boolean;50}5152// Build the review graph. `branches` maps name -> sha for live heads. Fields53// that need commit data (layer, ci, title, previews) are filled in by repo.ts.54export function buildReviews(gitDir: string, { logs, branches, defaultBranch, ranges = {}, revisions = true }: BuildOptions): Review[] {55 const defaultTip = branches.get(defaultBranch);56 const reviews: Review[] = [];57 for (const r of logs) {58 const live = !r.deleted && branches.has(r.branch);59 const tip = live ? branches.get(r.branch) : r.revs.at(-1)?.sha;60 if (!tip) continue;61 if (live && r.revs.at(-1)?.sha !== tip) {62 // Updated outside the hook (e.g. on the server): record a synthetic revision.63 r.revs.push({ n: r.revs.length + 1, ts: Math.floor(Date.now() / 1000), sha: tip, user: null, base: null });64 }65 const merged = !!(defaultTip && isAncestor(gitDir, tip, defaultTip));66 // What this review's commits are measured against before stack detection.67 // Landed reviews use the merge-base recorded at push time, since their68 // commits are now part of the default branch.69 const explicit = r.opts.target && branches.has(r.opts.target) ? r.opts.target : null;70 const lastBase = [...r.revs].reverse().find((v) => v.base)?.base ?? null;71 const floor = explicit ? branches.get(explicit)! : merged || !defaultTip ? lastBase ?? tip : defaultTip;72 reviews.push({73 ...r, live, tip, merged, explicit, floor, parent: null, children: [],74 commits: [], target: defaultBranch, status: 'open', updated: null, author: null, revisions: [],75 layer: [], ci: null, title: r.branch, combinedBase: null, combinedStats: { files: 0, add: 0, del: 0 }, previews: [],76 });77 }7879 // Stack detection: the nearest other review whose tip lies inside this one.80 const byTip = new Map<string, Review>();81 for (const r of [...reviews].sort((a, b) => Number(a.live) - Number(b.live))) byTip.set(r.tip, r);82 for (const r of reviews) {83 if (r.explicit && r.explicit !== defaultBranch) {84 r.parent = reviews.find((x) => x.live && x.branch === r.explicit && x !== r) ?? null;85 } else if (!r.explicit) {86 const inside = revList(gitDir, ['--topo-order', r.tip, '--not', r.floor]);87 for (const sha of inside.slice(1)) {88 const p = byTip.get(sha);89 if (p && p !== r) {90 r.parent = p;91 break;92 }93 }94 }95 if (r.parent) r.parent.children.push(r);96 }9798 for (const r of reviews) {99 const stop = [r.parent?.tip, r.floor].filter((x): x is string => !!x);100 r.commits = revList(gitDir, ['--reverse', '--topo-order', r.tip, ...(stop.length ? ['--not', ...stop] : [])]);101 r.target = r.parent?.branch ?? r.explicit ?? defaultBranch;102 r.status = r.merged && r.commits.length ? 'merged' : !r.live ? (r.merged ? 'merged' : 'closed') : r.draft || r.branch.startsWith('wip/') ? 'draft' : 'open';103 r.updated = r.revs.at(-1)?.ts ?? null;104 r.author = r.created?.user ?? null;105 }106107 // Revisions: what each push changed. On the same base, the diff between the108 // tips says it. After a rebase that diff would mix in everything that landed109 // meanwhile, so range-diff pairs up the commits instead.110 if (!revisions) return reviews;111 for (const r of reviews) {112 r.revisions = r.revs.map((rev, i): Revision => {113 const prev = r.revs[i - 1];114 let range: string | null = null;115 let view: Revision['view'] = null;116 if (prev && prev.sha !== rev.sha && prev.base && prev.base === rev.base) view = 'diff';117 else if (prev && prev.sha !== rev.sha) {118 const a = `${prev.base ?? r.floor ?? defaultTip}..${prev.sha}`;119 const b = `${rev.base ?? r.floor ?? defaultTip}..${rev.sha}`;120 // Both ranges name fixed commits, so the output never changes.121 const key = `${a} ${b}`;122 range = (ranges[key] ??= tryRun(gitDir, ['range-diff', '--no-color', a, b])) as string | null;123 view = range ? 'range' : null;124 }125 return { ...rev, prev: prev?.n ?? null, view, range };126 });127 }128 return reviews;129}130131export interface StackEntry {132 review: Review;133 depth: number;134}135136// Order a list of reviews with stacks nested: each review is followed by its137// children that are in the same list, one level deeper. Stacks come newest138// activity first.139export function nest(list: Review[]): StackEntry[] {140 const here = new Set(list);141 const latest = (r: Review): number => Math.max(r.updated ?? 0, ...r.children.filter((c) => here.has(c)).map(latest));142 const out: StackEntry[] = [];143 const walk = (r: Review, depth: number): void => {144 out.push({ review: r, depth });145 for (const c of r.children) if (here.has(c)) walk(c, depth + 1);146 };147 const roots = list.filter((r) => !r.parent || !here.has(r.parent));148 for (const r of roots.sort((a, b) => latest(b) - latest(a) || b.id - a.id)) walk(r, 0);149 return out;150}151152// Order a stack for display: root first, then depth-first through children.153export function stackOf(review: Review): StackEntry[] {154 let root = review;155 while (root.parent) root = root.parent;156 const out: StackEntry[] = [];157 const walk = (r: Review, depth: number): void => {158 out.push({ review: r, depth });159 for (const c of r.children) walk(c, depth + 1);160 };161 walk(root, 0);162 return out;163}