clone

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

main
forge / lib / review-list.ts
review-list.ts 4.8K
1// The review list as data. The build writes it to /<owner>/<repo>/reviews.json,2// and `forge reviews`, `checkout` and `land` read it over ssh: the server3// runs this file on it.4//5// node lib/review-list.ts <reviews.json> open and draft reviews, for people6// node lib/review-list.ts <reviews.json> --tsv every review, tab-separated, for bin/forge7// node lib/review-list.ts --repo <git-dir> --tsv the same rows from the repo itself (see liveList)8import fs from 'node:fs';9import { fileURLToPath } from 'node:url';10import { run, tryRun } from './git.ts';11import { buildReviews, readReviewLogs } from './reviews.ts';12import type { Review, ReviewCi, ReviewStatus } from './types.ts';1314export interface ReviewItem {15 id: number;16 title: string;17 status: ReviewStatus;18 branch: string;19 target: string;20 // Where landing it goes: the target of the lowest layer of its stack.21 base: string;22 parent: number | null;23 tip: string;24 author: string | null;25 updated: number | null;26 ci: ReviewCi | null;27 approvals: { user: string; rev: number | null; current: boolean }[];28 comments: number;29 url: string;30}3132export function reviewList(reviews: Review[], url: (id: number) => string): ReviewItem[] {33 return reviews.map((r) => {34 let root = r;35 while (root.parent) root = root.parent;36 return {37 id: r.id, title: r.title, status: r.status, branch: r.branch, target: r.target, base: root.target,38 parent: r.parent?.id ?? null, tip: r.tip, author: r.author, updated: r.updated, ci: r.ci,39 approvals: r.approvals.map((a) => ({ user: a.user, rev: a.rev, current: a.current })),40 comments: r.comments.length, url: url(r.id),41 };42 });43}4445const clip = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + '…' : s);4647function approvals(r: ReviewItem): string {48 const now = r.approvals.filter((a) => a.current).map((a) => a.user);49 const stale = r.approvals.filter((a) => !a.current).map((a) => `${a.user} approved r${a.rev ?? '?'}, changed since`);50 return [now.length ? `approved by ${now.join(', ')}` : '', ...stale].filter(Boolean).join('; ') || 'not approved';51}5253export function table(list: ReviewItem[]): string {54 const open = list.filter((r) => r.status === 'open' || r.status === 'draft').reverse();55 if (!open.length) return 'no open reviews\n';56 const rows = open.map((r) => [57 `#${r.id}`, clip(r.title, 48) + (r.status === 'draft' ? ' (draft)' : ''), `${r.branch} -> ${r.target}`, r.author ?? '',58 r.ci ? `CI ${r.ci.pass}/${r.ci.total}${r.ci.state === 'fail' ? ' failing' : ''}` : 'no CI', approvals(r),59 ]);60 const width = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));61 return rows.map((row) => row.map((c, i) => (i < row.length - 1 ? c.padEnd(width[i]) : c)).join(' ')).join('\n') + '\n';62}6364// One line per review; "-" for empty fields, since sh's read folds runs of tabs.65export function tsv(list: ReviewItem[]): string {66 const f = (v: unknown): string => String(v ?? '').replace(/\s+/g, ' ').trim() || '-';67 return list.map((r) => [r.id, r.status, r.branch, r.base, r.parent, r.tip, r.author, r.title].map(f).join('\t')).join('\n') + '\n';68}6970// The list straight from a bare repo's review logs and live refs, for the71// server's land, which can't wait for the build after a push. Stacks,72// targets, statuses and titles are the build's; CI and the freshness of73// approvals are left out.74export function liveList(dir: string): ReviewItem[] {75 const pairs = (out: string): [string, string][] => out.split('\n').filter(Boolean).map((l) => [l.slice(0, l.indexOf(' ')), l.slice(l.indexOf(' ') + 1)]);76 const branches = new Map(pairs(run(dir, ['for-each-ref', '--format=%(refname:lstrip=2) %(objectname)', 'refs/heads/'])));77 // As repo.ts: a default branch that doesn't exist yet gives way to the first.78 let defaultBranch = tryRun(dir, ['symbolic-ref', '--short', 'HEAD'])?.trim() ?? 'main';79 if (!branches.has(defaultBranch) && branches.size) defaultBranch = [...branches.keys()][0];80 const reviews = buildReviews(dir, { logs: readReviewLogs(dir), branches, defaultBranch, revisions: false });81 const firsts = reviews.map((r) => r.commits[0]).filter(Boolean);82 const subjects = new Map(firsts.length ? pairs(run(dir, ['log', '--no-walk=unsorted', '--format=%H %s', ...firsts])) : []);83 for (const r of reviews) r.title = r.opts.title || subjects.get(r.commits[0]) || r.branch;84 return reviewList(reviews, () => '');85}8687// Run directly (realpath: /tmp is a symlink on macOS, and import.meta.url is resolved).88if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {89 const [from, fmt] = process.argv[2] === '--repo' ? [process.argv[3], process.argv[4]] : [process.argv[2], process.argv[3]];90 const list = process.argv[2] === '--repo' ? liveList(from) : JSON.parse(fs.readFileSync(from, 'utf8')) as ReviewItem[];91 process.stdout.write(fmt === '--tsv' ? tsv(list) : table(list));92}