clone

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

main
forge / lib / jev-reviews.ts
jev-reviews.ts 20.5K
1// Jev on review pages: which changed files are mechanical, what each2// revision changed, and whether later revisions address comments.3//4// fill() runs after a push (lib/jev-fill.ts) and asks about live reviews5// only. The build reads the stored answers through isMechanical(),6// pairChanges() and annotateReviews(), and never calls the API. Three kinds:7//8// file.v1/<old blob>.<new blob> noul: is this file's change mechanical9// rev.v1/<earlier sha>.<later sha> choice: what a rewritten commit, or a10// revision on the same base, changed11// addressed.v1/<review>.<n>.<tip> noul: does the change since the12// comment address it13import fs from 'node:fs';14import path from 'node:path';15import { JevStore, ask, choice, diffState, noul, pool, remember, type FileState, type Stored } from './jev.ts';16import { readReviewLogs, buildReviews } from './reviews.ts';17import { settle } from './discussion.ts';18import { parseDiff } from './diff.ts';19import { tryRun } from './git.ts';20import { rangePairs } from './render.ts';21import { RepoCache } from './cache.ts';22import type { FillContext } from './jev-fill.ts';23import type { DiffFile, Review, ReviewComment, Revision } from './types.ts';2425const KINDS = ['file.v1', 'rev.v1', 'addressed.v1'];2627// A file folds as mechanical from 0.7. Benchmarked with the question28// below, lockfiles, version bumps, generated, vendored and reformatted29// files, renames and search-and-replace scored 0.91 to 0.97, and30// hand-written changes 0.50 or less (the highest: a one-line output change31// in a test's expected output). Repeats moved by 0.01, other batch mates by32// up to 0.2. A file folded by mistake hides real work, so the bar sits in33// that gap, nearer the yes side.34export const MECHANICAL = 0.7;35const MECHANICAL_YES = 'Mechanical, so it needs no line-by-line review: a rename or move, reformatting, generated output, a lockfile, a version bump, vendored code, or a pure search-and-replace.';36const MECHANICAL_NO = 'Hand-written: it changes what the program does, prints or says, however small.';37// "Likely addressed" from 0.7: in tests, comments a later push dealt with38// scored 0.83 and 0.94, ones it didn't 0.23 and 0.36. A comment marked by39// mistake may go unchecked, so the bar is the same as for folding.40export const ADDRESSED = 0.7;4142// What a rewrite changed. Pages name the most significant, in this order.43// Not "message": Jev confused it with files of messages (a translation44// catalog), so the label says what happened to the commit.45const CHANGES = {46 behavior: 'Behavior: changes what the code does, or what the program prints or says.',47 tests: 'Tests only: only tests or expected test output changed.',48 cosmetic: 'Cosmetic: formatting, comments, documentation wording or renames, with no change in what the code does.',49 reworded: 'Reworded: only the commit messages changed; every file has the same added and removed lines.',50};51type Kind = keyof typeof CHANGES;52const WORDS: Record<Kind, string> = { behavior: 'behavior change', tests: 'tests only', cosmetic: 'cosmetic', reworded: 'message only' };53const ORDER = Object.keys(CHANGES) as Kind[];5455const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';56const STATUS: Record<string, string> = { A: 'added', D: 'deleted', M: 'modified', R: 'renamed', C: 'copied' };5758// Changed lines per file when asking about files, the files and the59// 80-character lines per request, and the files asked about per diff (past60// that, review pages have stopped showing patches anyway).61const PER_FILE = 20;62const BATCH_FILES = 20;63const BATCH_LINES = 400;64const MAX_FILES = 100;6566// Diffs with full blob ids on their index lines, which key file answers.67const diffOf = (gitDir: string, args: string[]): DiffFile[] => parseDiff(tryRun(gitDir, ['diff', '--no-color', '-M', '--full-index', ...args]) ?? '');68const patchOf = (gitDir: string, sha: string): DiffFile[] =>69 parseDiff(tryRun(gitDir, ['show', '--no-color', '-M', '--full-index', '--diff-merges=first-parent', '--format=', sha]) ?? '');7071// A file's change as a key: its old and new blob ids, so the same change72// shares an answer wherever it shows.73const fileKey = (f: DiffFile): string | null => (f.oids ? `${f.oids[0]}.${f.oids[1]}` : null);7475const clip = (s: string, n: number): string => (s.length > n ? `${s.slice(0, n)}...` : s);7677// Long lines (minified or generated files) are cut to CUT characters. When78// the other side has a version of the line that differs only past the cut,79// both show from shortly before where they first differ.80const CUT = 200;81function clipLines(a: string[], b: string[]): [string[], string[]] {82 const common = (x: string, y: string): number => {83 let i = 0;84 while (i < x.length && i < y.length && x[i] === y[i]) i++;85 return i;86 };87 const cut = (s: string, other: string[]): string => {88 if (s.length <= CUT) return s;89 const same = Math.max(0, ...other.map((o) => common(s, o)));90 const from = same > CUT - 40 ? same - 40 : 0;91 return (from ? '...' : '') + clip(s.slice(from), CUT);92 };93 return [a.map((s) => cut(s, b)), b.map((s) => cut(s, a))];94}95const clipState = (s: FileState): FileState => {96 const [removed, added] = clipLines(s.removed, s.added);97 return { ...s, removed, added };98};99// A request's size in 80-character lines, so long lines count for more.100const size = (lines: string[]): number => lines.reduce((n, l) => n + Math.max(1, Math.ceil(l.length / 80)), 0);101102// Lines of `a` that `b` doesn't have, counting repeats.103function minus(a: string[], b: string[]): string[] {104 const left = new Map<string, number>();105 for (const s of b) left.set(s, (left.get(s) ?? 0) + 1);106 return a.filter((s) => {107 const n = left.get(s) ?? 0;108 if (n) left.set(s, n - 1);109 return !n;110 });111}112113// Full ids for the short ones range-diff prints, one cat-file for many.114// Remembered for the process, since an id never changes.115const resolved = new Map<string, string | null>();116function resolve(gitDir: string, shorts: string[]): (short: string) => string | null {117 const want = [...new Set(shorts)].filter((s) => /^[0-9a-f]{4,}$/.test(s) && !resolved.has(`${gitDir} ${s}`));118 if (want.length) {119 const out = (tryRun(gitDir, ['cat-file', '--batch-check=%(objectname) %(objecttype)'], { input: want.map((s) => `${s}^{commit}`).join('\n') + '\n' }) ?? '').split('\n');120 want.forEach((s, i) => {121 const [oid, type] = (out[i] ?? '').split(' ');122 resolved.set(`${gitDir} ${s}`, type === 'commit' ? oid : null);123 });124 }125 return (s) => resolved.get(`${gitDir} ${s}`) ?? null;126}127128const rewritten = (range: string) => rangePairs(range).filter((p) => p.mark === '!');129130// Asking, after a push.131132// Open and draft reviews, read as the build reads them, outdated comments marked.133function liveReviews(ctx: FillContext): Review[] {134 const branches = new Map<string, string>();135 for (const line of (tryRun(ctx.gitDir, ['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads']) ?? '').split('\n')) {136 const [ref, sha] = line.split(' ');137 if (sha) branches.set(ref.slice('refs/heads/'.length), sha);138 }139 // The build's range-diffs, so old revisions aren't compared again.140 const { ranges } = RepoCache.load(ctx.cfg, ctx.slug);141 const live = buildReviews(ctx.gitDir, { logs: readReviewLogs(ctx.gitDir), branches, defaultBranch: ctx.defaultBranch, ranges })142 .filter((r) => r.status === 'open' || r.status === 'draft');143 // Only which comments are outdated matters here, not approvals.144 for (const r of live) settle(r, null);145 return live;146}147148// Stores one request's answers, one per key.149function putEach(store: JevStore, kind: string, res: { model: string; usage: { input_tokens: number | null; output_tokens: number | null } }, answers: [string, Record<string, unknown>][]): void {150 const at = Math.floor(Date.now() / 1000);151 const tokens = Math.round(((res.usage.input_tokens ?? 0) + (res.usage.output_tokens ?? 0)) / answers.length) || null;152 for (const [key, a] of answers) store.put(kind, key, { model: res.model, at, answers: a, tokens });153}154155interface FileAsk {156 key: string;157 path: string;158 size: number;159 entry: Record<string, unknown>;160}161162// Every file in each review's combined diff and in each of its commits,163// asked about in batches. Each question names its file's entry by id, since164// one batch can hold several versions of a path.165async function files(ctx: FillContext, reviews: Review[]): Promise<void> {166 const todo = new Map<string, FileAsk>();167 for (const r of reviews) {168 if (!r.commits.length) continue;169 const base = tryRun(ctx.gitDir, ['rev-parse', '--verify', '-q', `${r.commits[0]}^`])?.trim() || EMPTY_TREE;170 for (const diff of [diffOf(ctx.gitDir, [base, r.tip]), ...r.commits.map((c) => patchOf(ctx.gitDir, c))]) {171 for (const f of diff.slice(0, MAX_FILES)) {172 const key = fileKey(f);173 if (!key || f.binary || !f.hunks.length || todo.has(key) || ctx.store.has('file.v1', key)) continue;174 const [s] = diffState([f], Infinity);175 const [removed, added] = clipLines(s.removed.slice(0, PER_FILE), s.added.slice(0, PER_FILE));176 const entry: Record<string, unknown> = {177 path: f.path,178 change: f.status === 'R' || f.status === 'C' ? `${STATUS[f.status]} from ${f.oldPath}` : STATUS[f.status],179 lines: `+${f.add} -${f.del}`,180 removed,181 added,182 };183 if (s.truncated || s.added.length > PER_FILE || s.removed.length > PER_FILE) entry.truncated = true;184 todo.set(key, { key, path: f.path, size: size([...removed, ...added]), entry });185 }186 }187 }188 const batches: FileAsk[][] = [];189 for (const x of todo.values()) {190 let b = batches.find((b) => b.length < BATCH_FILES && b.reduce((n, y) => n + y.size, x.size) <= BATCH_LINES);191 if (!b) batches.push((b = []));192 b.push(x);193 }194 await pool(batches, 4, async (batch) => {195 const questions = Object.fromEntries(batch.map((x, i) => [`f${i}`, noul(`Is the change to ${x.path} (f${i}) mechanical?`, { true: MECHANICAL_YES, false: MECHANICAL_NO })]));196 const res = await ask({ files: batch.map((x, i) => ({ id: `f${i}`, ...x.entry })) }, questions);197 if (res) putEach(ctx.store, 'file.v1', res, batch.flatMap((x, i) => (res.answers[`f${i}`] ? [[x.key, { mechanical: res.answers[`f${i}`] }] as [string, Record<string, unknown>]] : [])));198 });199}200201// A rewritten commit as Jev reads it: the message before and after, and per202// file the changed lines only one version's patch has.203function versionsState(gitDir: string, a: string, b: string, subject: string): unknown {204 const message = (sha: string): string => tryRun(gitDir, ['log', '-1', '--format=%B', sha])?.trim() ?? '';205 const lines = (sha: string): Map<string, { added: string[]; removed: string[] }> =>206 new Map(patchOf(gitDir, sha).map((f) => [f.path, diffState([f], Infinity)[0] ?? { added: [], removed: [] }]));207 const [ma, mb] = [message(a), message(b)];208 const [pa, pb] = [lines(a), lines(b)];209 const empty = { added: [], removed: [] };210 let left = BATCH_LINES;211 const files: Record<string, unknown>[] = [];212 for (const p of new Set([...pa.keys(), ...pb.keys()])) {213 const x = pa.get(p) ?? empty;214 const y = pb.get(p) ?? empty;215 const lists = [minus(x.removed, y.removed), minus(y.removed, x.removed), minus(x.added, y.added), minus(y.added, x.added)];216 if (lists.every((l) => !l.length)) continue;217 const e: Record<string, unknown> = { path: p };218 const kept = lists.map((l) => {219 if (l.length > left) e.truncated = true;220 const k = l.slice(0, Math.max(0, left));221 left -= l.length;222 return k;223 });224 const [er, lr] = clipLines(kept[0], kept[1]);225 const [ea, la] = clipLines(kept[2], kept[3]);226 const only: [string, string[]][] = [227 ['only the earlier version removes', er],228 ['only the later version removes', lr],229 ['only the earlier version adds', ea],230 ['only the later version adds', la],231 ];232 for (const [k, l] of only) if (l.length) e[k] = l;233 files.push(e);234 }235 return {236 commit: subject,237 'commit message': ma === mb ? 'unchanged' : { earlier: ma, later: mb },238 files: files.length ? files : 'both versions add and remove the same lines',239 };240}241242// A revision pushed on the same base as the one before: each one's commit243// messages, oldest first (so an added commit shows as one), and the diff244// between the two tips.245function tipsState(gitDir: string, from: Revision, to: Revision): unknown {246 const messages = (v: Revision): string[] => (tryRun(gitDir, ['log', '--reverse', '--format=%B%x00', `${v.base}..${v.sha}`]) ?? '').split('\0').map((m) => m.trim()).filter(Boolean);247 const [ma, mb] = [messages(from), messages(to)];248 return {249 'commit messages': ma.join('\0') === mb.join('\0') ? 'unchanged' : { earlier: ma, later: mb },250 files: diffState(diffOf(gitDir, [from.sha, to.sha]), BATCH_LINES).map(clipState),251 };252}253254// One choice per rewritten commit after a rebase, or one for the whole255// change when the base stayed put.256async function revisions(ctx: FillContext, reviews: Review[]): Promise<void> {257 const asks = new Map<string, () => { state: unknown; question: string }>();258 for (const r of reviews) {259 for (const v of r.revisions) {260 const prev = v.prev ? r.revisions[v.prev - 1] : null;261 if (!prev) continue;262 if (v.view === 'diff') {263 const key = `${prev.sha}.${v.sha}`;264 if (!ctx.store.has('rev.v1', key)) asks.set(key, () => ({ state: tipsState(ctx.gitDir, prev, v), question: `What kind of change turns revision r${prev.n} of this review into r${v.n}?` }));265 } else if (v.view === 'range' && v.range) {266 const pairs = rewritten(v.range);267 const full = resolve(ctx.gitDir, pairs.flatMap((p) => [p.a, p.b]));268 for (const p of pairs) {269 const [a, b] = [full(p.a), full(p.b)];270 const key = `${a}.${b}`;271 if (!a || !b || ctx.store.has('rev.v1', key)) continue;272 asks.set(key, () => ({ state: versionsState(ctx.gitDir, a, b, p.subject), question: `What kind of change turns the earlier version of the commit "${p.subject}" into the later one?` }));273 }274 }275 }276 }277 await pool([...asks], 4, async ([key, make]) => {278 const { state, question } = make();279 await remember(ctx.store, 'rev.v1', key, state, { change: choice(question, CHANGES) });280 });281}282283// One noul per outdated comment against the review's tip. Comments on the284// same revision and file see the same change, so they share a request.285async function comments(ctx: FillContext, reviews: Review[]): Promise<void> {286 const groups = new Map<string, { r: Review; c: ReviewComment; key: string }[]>();287 for (const r of reviews) {288 for (const c of r.comments) {289 const key = `${r.id}.${c.n}.${r.tip}`;290 if (!c.outdated || !/^[0-9a-f]+$/.test(c.sha) || ctx.store.has('addressed.v1', key)) continue;291 const g = `${r.id} ${c.sha} ${c.path ?? ''}`;292 groups.set(g, [...(groups.get(g) ?? []), { r, c, key }]);293 }294 }295 const asks = [...groups.values()].flatMap((list) => Array.from({ length: Math.ceil(list.length / 10) }, (_, i) => list.slice(i * 10, i * 10 + 10)));296 await pool(asks, 4, async (list) => {297 const { r, c } = list[0];298 const change = diffOf(ctx.gitDir, [c.sha, r.tip, ...(c.path ? ['--', c.path] : [])]);299 // Nothing changed (in the commented file): nothing can have addressed it.300 if (!change.length) return;301 const questions = Object.fromEntries(list.map(({ c }) => {302 const where = c.path ? ` on ${c.path} line ${c.line}${c.text != null ? `, which read: ${clip(c.text.trim(), 200)}` : ''}` : ' on the whole review';303 return [`c${c.n}`, noul(`Does the later change address this review comment by ${c.user}${where}?\n\n${clip(c.body, 2000)}`)];304 }));305 const res = await ask({ change: c.path ? `what changed in ${c.path} since the comment` : 'what changed since the comment', files: diffState(change, BATCH_LINES).map(clipState) }, questions);306 if (res) putEach(ctx.store, 'addressed.v1', res, list.flatMap(({ c, key }) => (res.answers[`c${c.n}`] ? [[key, { addressed: res.answers[`c${c.n}`] }] as [string, Record<string, unknown>]] : [])));307 });308}309310export async function fill(ctx: FillContext): Promise<void> {311 const live = liveReviews(ctx);312 await files(ctx, live);313 await revisions(ctx, live);314 await comments(ctx, live);315}316317// Reading, in the build.318319// A label for a revision or a rewritten commit: its kind, for styling, and320// how the page says it.321export interface Change {322 kind: string;323 text: string;324}325326const noulOf = (s: Stored | null, q: string): number | null => {327 const a = s?.answers[q] as { noul?: unknown } | undefined;328 return typeof a?.noul === 'number' ? a.noul : null;329};330331const kindOf = (store: JevStore, key: string): Kind | null => {332 const c = (store.get('rev.v1', key)?.answers.change as { choice?: string } | undefined)?.choice;333 return c && c in WORDS ? (c as Kind) : null;334};335336// Whether Jev judged the change from blob a to blob b mechanical (approval337// rules' @mechanical, lib/rules.ts). No stored answer is no.338export const mechanicalPair = (store: JevStore, a: string, b: string): boolean => (noulOf(store.get('file.v1', `${a}.${b}`), 'mechanical') ?? 0) >= MECHANICAL;339340// The hook for diff(): whether Jev judged a file's change mechanical.341export function isMechanical(gitDir: string): (f: DiffFile) => boolean {342 const store = new JevStore(gitDir);343 return (f) => {344 const key = fileKey(f);345 return !!key && (noulOf(store.get('file.v1', key), 'mechanical') ?? 0) >= MECHANICAL;346 };347}348349// The hook for rangeDiff(): the label of a rewritten pair, by its short shas.350export function pairChanges(gitDir: string, range: string): (a: string, b: string) => Change | null {351 const store = new JevStore(gitDir);352 const full = resolve(gitDir, rewritten(range).flatMap((p) => [p.a, p.b]));353 return (a, b) => {354 const [x, y] = [full(a), full(b)];355 const k = x && y ? kindOf(store, `${x}.${y}`) : null;356 return k && { kind: k, text: WORDS[k] };357 };358}359360// One line for what a revision changed: the label for the whole change on361// the same base, or after a rebase the most significant label among the362// rewritten commits, e.g. "behavior change in 2 of 4 commits, cosmetic in 1".363function summary(gitDir: string, store: JevStore, r: Review, v: Revision): Change | null {364 const prev = v.prev ? r.revisions[v.prev - 1] : null;365 if (!prev) return null;366 if (v.view === 'diff') {367 const k = kindOf(store, `${prev.sha}.${v.sha}`);368 return k && { kind: k, text: WORDS[k] };369 }370 if (v.view !== 'range' || !v.range) return null;371 const pairs = rangePairs(v.range);372 const full = resolve(gitDir, rewritten(v.range).flatMap((p) => [p.a, p.b]));373 const kinds = pairs.filter((p) => p.mark === '!').map((p) => {374 const [a, b] = [full(p.a), full(p.b)];375 return a && b ? kindOf(store, `${a}.${b}`) : null;376 });377 const counts = ORDER.map((k): [Kind, number] => [k, kinds.filter((x) => x === k).length]).filter(([, n]) => n);378 if (!counts.length) return null;379 const n = pairs.filter((p) => p.mark !== '<').length;380 const [[top, m], ...rest] = counts;381 return { kind: top, text: [`${WORDS[top]} in ${m} of ${n} commit${n === 1 ? '' : 's'}`, ...rest.map(([k, c]) => `${WORDS[k]} in ${c}`)].join(', ') };382}383384// The revision an outdated comment was likely addressed in: the earliest of385// the latest run of revisions whose answer clears the bar, so the tip's386// answer must.387function addressedIn(store: JevStore, r: Review, c: ReviewComment): number | null {388 let at: number | null = null;389 for (const v of [...r.revisions].reverse()) {390 if (c.rev != null && v.n <= c.rev) break;391 const p = noulOf(store.get('addressed.v1', `${r.id}.${c.n}.${v.sha}`), 'addressed');392 if (p == null || p < ADDRESSED) break;393 at = v.n;394 }395 return at;396}397398// Sets each revision's summary and each outdated comment's `addressed`.399export function annotateReviews(gitDir: string, reviews: Review[]): void {400 const store = new JevStore(gitDir);401 if (!fs.existsSync(store.dir)) return;402 // Resolve every review's short shas in one go.403 resolve(gitDir, reviews.flatMap((r) => r.revisions.flatMap((v) => (v.view === 'range' && v.range ? rewritten(v.range).flatMap((p) => [p.a, p.b]) : []))));404 for (const r of reviews) {405 for (const v of r.revisions) v.summary = summary(gitDir, store, r, v);406 for (const c of r.comments) c.addressed = c.outdated ? addressedIn(store, r, c) : null;407 }408}409410// Changes whenever an answer is stored (each is renamed into its kind's411// directory), so live reviews' pages are rebuilt when answers arrive after412// the push that prompted them.413export function answersStamp(gitDir: string): string {414 const dir = new JevStore(gitDir).dir;415 return KINDS.map((k) => {416 try {417 return String(fs.statSync(path.join(dir, k)).mtimeMs);418 } catch {419 return '-';420 }421 }).join(' ');422}