Public: anyone can read this; the crew can change it.
A reviewer's repeated comment becomes a check: review load falls week by week #8721
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8716-a-reviewer-s-repeated-comment-becomes-a → main 2 commits fork 2 threads · 2 unresolved
10 files
changed +508
−0
docs/features/review-repeats.md added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | # A repeated review comment becomes a check (#8716) | |
| 2 | ||
| 3 | Once a reviewer asks for the same thing four times in a week, a rule says it for them. | |
| 4 | ||
| 5 | - **Grouped cheaply.** `src/flow/repeats.ts` reads the week's `changes_requested` reviews and review line comments (indexed by time), splits them into list items, and groups them by the words they ask with (code, links, numbers and filler dropped; half the words shared is one finding). No agent run. | |
| 6 | - **Proposed once.** `src/crew/repeat_checks.ts`, called from the autonomy tick at most every 6 h per repository, opens one issue per finding seen 4 times (`REPEATS`), at most two per sweep, naming each comment and its pull and asking for a lint or test. The finding is kept in `review_finding`, so it is never proposed twice. | |
| 7 | - **Retired.** When that issue closes as `done`, the finding is retired. | |
| 8 | - **Shown.** `flow` (MCP `flow.reviewRepeats`, and the terminal flow screen's "Review repeats" line) gives findings proposed and retired beside review comments per pull this week and last. An agent reads public repositories only. | |
| 9 | ||
| 10 | Left out: matching is by shared words, not meaning; a finding is retired when its issue is done, not by proving the check fires. |
src/core/migration-pins/20261102200000-review-findings.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 34f5dba606a12cd8b9bdb62003b7b2b1adc4047f781ebd43597d0ec03f07f075 |
src/core/migrations/20261102200000-review-findings.ts added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | // A reviewer's finding that repeated until a check was proposed for it (#8716): | |
| 2 | // one row per finding, its words as the key, the issue that asks for the lint | |
| 3 | // or test, and when that issue was done (retired). Two indexes let the weekly | |
| 4 | // read walk the week's reviews and review line comments by time. Additive. | |
| 5 | export default ` | |
| 6 | CREATE TABLE IF NOT EXISTS review_finding ( | |
| 7 | id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 8 | repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE, | |
| 9 | key TEXT NOT NULL, | |
| 10 | sample TEXT NOT NULL, | |
| 11 | seen INTEGER NOT NULL, | |
| 12 | issue_number INTEGER NOT NULL, | |
| 13 | proposed_at TEXT NOT NULL, | |
| 14 | retired_at TEXT | |
| 15 | ); | |
| 16 | CREATE INDEX IF NOT EXISTS review_finding_repo ON review_finding (repo_id, proposed_at); | |
| 17 | CREATE INDEX IF NOT EXISTS review_created ON review (created_at); | |
| 18 | CREATE INDEX IF NOT EXISTS comment_kind_created ON comment (kind, created_at); | |
| 19 | `; |
src/crew/autonomy.ts +7
| @@ -131,6 +131,7 @@ import { recheckRedApproved } from "./red_approved.ts"; | ||
| 131 | 131 | import { sweepStaleReds } from "./stale_reds.ts"; |
| 132 | 132 | import { parkPull, strikeNote } from "./park_pull.ts"; |
| 133 | 133 | import { keepVerdict } from "../flow/culprits.ts"; |
| 134 | import { sweepRepeats } from "./repeat_checks.ts"; | |
| 134 | 135 | import { keepBatch } from "../flow/red_batches.ts"; |
| 135 | 136 | import { recordQueueFlakes } from "../checks/flake.ts"; |
| 136 | 137 | import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts"; |
| @@ -1251,6 +1252,12 @@ export async function autonomyTick( | ||
| 1251 | 1252 | error: String(error), |
| 1252 | 1253 | }); |
| 1253 | 1254 | }); |
| 1255 | // What reviewers keep asking for becomes an issue to automate it (#8716). | |
| 1256 | try { | |
| 1257 | sweepRepeats(app, current, now); | |
| 1258 | } catch (error) { | |
| 1259 | log.error("repeat check sweep failed", { repo: `${repo.ownerName}/${repo.name}`, error: String(error) }); | |
| 1260 | } | |
| 1254 | 1261 | // Before the picks: a pull a reviewer answered is finished before new |
| 1255 | 1262 | // work takes the slots it needs (#2172). |
| 1256 | 1263 | const requested = await moveRequested(app, current, freeSlots(app, current, now), now); |
src/crew/repeat_checks.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** | |
| 2 | * The duty behind #8716: what reviewers keep asking for becomes one issue to | |
| 3 | * automate it. Rules and two indexed reads, no run. Each sweep retires the | |
| 4 | * findings whose check landed, then opens an issue for each finding repeated | |
| 5 | * {@link REPEATS} times this week that has none. At most {@link PER_SWEEP} | |
| 6 | * issues a sweep, and a sweep at most every {@link EVERY_MS} per repository. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import { createIssue } from "../core/issues.ts"; | |
| 11 | import type { Repo } from "../core/repos.ts"; | |
| 12 | import { dueGroups, keepFinding, proposalBody, retireLanded } from "../flow/repeats.ts"; | |
| 13 | ||
| 14 | export const EVERY_MS = 6 * 3_600_000; | |
| 15 | export const PER_SWEEP = 2; | |
| 16 | ||
| 17 | const swept = new Map<string, number>(); | |
| 18 | ||
| 19 | export interface RepeatMoves { | |
| 20 | readonly proposed: number[]; | |
| 21 | readonly retired: number; | |
| 22 | } | |
| 23 | ||
| 24 | export function sweepRepeats(app: App, repo: Repo, now = new Date()): RepeatMoves { | |
| 25 | const last = swept.get(repo.id); | |
| 26 | if (last !== undefined && now.getTime() - last < EVERY_MS) return { proposed: [], retired: 0 }; | |
| 27 | swept.set(repo.id, now.getTime()); | |
| 28 | const retired = retireLanded(app.db, repo.id, now); | |
| 29 | const proposed: number[] = []; | |
| 30 | for (const g of dueGroups(app.db, repo.id, now).slice(0, PER_SWEEP)) { | |
| 31 | const made = createIssue(app.db, { | |
| 32 | repoId: repo.id, | |
| 33 | authorId: g.findings[0]!.authorId, | |
| 34 | title: `Automate a review finding as a lint or test: ${g.sample.replace(/\s+/g, " ").slice(0, 80)}`, | |
| 35 | body: proposalBody(g, now), | |
| 36 | }); | |
| 37 | if (!made.ok) continue; | |
| 38 | keepFinding(app.db, repo.id, g, made.value.number, now); | |
| 39 | app.bus.append({ kind: "issue.opened", repoId: repo.id, actorId: g.findings[0]!.authorId, payload: { number: made.value.number } }); | |
| 40 | proposed.push(made.value.number); | |
| 41 | } | |
| 42 | return { proposed, retired }; | |
| 43 | } |
src/flow/repeats.ts added +223
| @@ -0,0 +1,223 @@ | ||
| 1 | /** | |
| 2 | * A reviewer's repeated comment becomes a check (#8716). Review findings of | |
| 3 | * the last week — the lines of a review that asked for changes and the line | |
| 4 | * comments on a diff — are grouped by the words they ask with: no agent run, | |
| 5 | * one read of two indexed tables. A finding seen {@link REPEATS} times in the | |
| 6 | * week is proposed once as an issue to automate it as a lint or test, naming | |
| 7 | * the comments. When that issue is done the finding is retired, and the | |
| 8 | * week's review comments per pull are shown beside it, this week and last. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { Database } from "bun:sqlite"; | |
| 12 | ||
| 13 | const DAY_MS = 86_400_000; | |
| 14 | export const WEEK_MS = 7 * DAY_MS; | |
| 15 | ||
| 16 | /** Times a finding repeats in a week before a check is proposed (Boris's "3 or 4"). */ | |
| 17 | export const REPEATS = 4; | |
| 18 | /** Words two findings share, of all their words, to be one finding. */ | |
| 19 | const SAME = 0.5; | |
| 20 | const MIN_WORDS = 3; | |
| 21 | const MAX_LINES = 20; | |
| 22 | const MAX_LINE = 400; | |
| 23 | const MAX_NAMED = 8; | |
| 24 | ||
| 25 | const STOP = new Set( | |
| 26 | "the and for that this with from not are was but you your can could should would please into when then than there their have has had its use using used also one any all too more instead need needs make sure still does doesn".split( | |
| 27 | " ", | |
| 28 | ), | |
| 29 | ); | |
| 30 | ||
| 31 | export interface Finding { | |
| 32 | readonly repo: string; | |
| 33 | readonly pull: number; | |
| 34 | readonly author: string; | |
| 35 | readonly authorId: string; | |
| 36 | readonly text: string; | |
| 37 | } | |
| 38 | ||
| 39 | export interface Group { | |
| 40 | readonly key: string; | |
| 41 | readonly sample: string; | |
| 42 | readonly findings: Finding[]; | |
| 43 | } | |
| 44 | ||
| 45 | /** The words a finding asks with: no code, links or numbers, crudely stemmed. */ | |
| 46 | export function wordsOf(text: string): string[] { | |
| 47 | const plain = text | |
| 48 | .toLowerCase() | |
| 49 | .replace(/```[\s\S]*?```/g, " ") | |
| 50 | .replace(/`[^`]*`/g, " ") | |
| 51 | .replace(/https?:\/\/\S+/g, " ") | |
| 52 | .replace(/[\w./-]+\.(?:ts|js|md|json|css)\b/g, " "); | |
| 53 | const out = new Set<string>(); | |
| 54 | for (const w of plain.match(/[a-z]{3,}/g) ?? []) { | |
| 55 | const stem = w.replace(/(?:ing|ed|es|s)$/, ""); | |
| 56 | if (stem.length >= 3 && !STOP.has(w) && !STOP.has(stem)) out.add(stem); | |
| 57 | } | |
| 58 | return [...out].sort(); | |
| 59 | } | |
| 60 | ||
| 61 | /** The findings in one review body: its list items or lines, each a request. */ | |
| 62 | export function linesOf(body: string): string[] { | |
| 63 | return body | |
| 64 | .split("\n") | |
| 65 | .map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").trim()) | |
| 66 | .filter((l) => l.length >= 12) | |
| 67 | .slice(0, MAX_LINES) | |
| 68 | .map((l) => l.slice(0, MAX_LINE)); | |
| 69 | } | |
| 70 | ||
| 71 | const similar = (a: readonly string[], b: readonly string[]): boolean => { | |
| 72 | const inter = a.filter((w) => b.includes(w)).length; | |
| 73 | return inter / (a.length + b.length - inter) >= SAME; | |
| 74 | }; | |
| 75 | ||
| 76 | /** Findings grouped by what they ask for, the largest group first. */ | |
| 77 | export function group(findings: readonly Finding[]): Group[] { | |
| 78 | const groups: { words: string[]; g: Group }[] = []; | |
| 79 | for (const f of findings) { | |
| 80 | const words = wordsOf(f.text); | |
| 81 | if (words.length < MIN_WORDS) continue; | |
| 82 | const hit = groups.find((x) => similar(x.words, words)); | |
| 83 | // a pull says a finding once: its review body and its line comments are one ask | |
| 84 | if (hit) { | |
| 85 | if (!hit.g.findings.some((x) => x.repo === f.repo && x.pull === f.pull)) hit.g.findings.push(f); | |
| 86 | } | |
| 87 | else groups.push({ words, g: { key: words.join(" "), sample: f.text, findings: [f] } }); | |
| 88 | } | |
| 89 | return groups.map((x) => x.g).sort((a, b) => b.findings.length - a.findings.length); | |
| 90 | } | |
| 91 | ||
| 92 | interface Row { | |
| 93 | repo: string; | |
| 94 | number: number; | |
| 95 | author: string; | |
| 96 | authorId: string; | |
| 97 | body: string; | |
| 98 | } | |
| 99 | ||
| 100 | /** The reviewers' findings on a repository's pulls created in `[from, to)`. */ | |
| 101 | export function findingsIn(db: Database, repoId: string, from: Date, to: Date, publicOnly = false): Finding[] { | |
| 102 | const range = [from.toISOString(), to.toISOString(), repoId, publicOnly ? 1 : 0]; | |
| 103 | const reviews = db | |
| 104 | .query<Row, (string | number)[]>( | |
| 105 | `SELECT issue.repo_id AS repo, issue.number AS number, user.username AS author, user.id AS authorId, review.body AS body | |
| 106 | FROM review JOIN issue ON issue.id = review.issue_id JOIN user ON user.id = review.author_id JOIN repo ON repo.id = issue.repo_id | |
| 107 | WHERE review.created_at >= ?1 AND review.created_at < ?2 AND (?3 = '' OR issue.repo_id = ?3) AND (?4 = 0 OR repo.visibility = 'public') | |
| 108 | AND review.state = 'changes_requested' AND review.author_id != issue.author_id`, | |
| 109 | ) | |
| 110 | .all(...range); | |
| 111 | const lines = db | |
| 112 | .query<Row, (string | number)[]>( | |
| 113 | `SELECT issue.repo_id AS repo, issue.number AS number, user.username AS author, user.id AS authorId, comment.body AS body | |
| 114 | FROM comment JOIN issue ON issue.id = comment.issue_id JOIN user ON user.id = comment.author_id JOIN repo ON repo.id = issue.repo_id | |
| 115 | WHERE comment.kind = 'review' AND comment.created_at >= ?1 AND comment.created_at < ?2 | |
| 116 | AND (?3 = '' OR issue.repo_id = ?3) AND (?4 = 0 OR repo.visibility = 'public') AND comment.author_id != issue.author_id`, | |
| 117 | ) | |
| 118 | .all(...range); | |
| 119 | return [...reviews, ...lines].flatMap((r) => | |
| 120 | linesOf(r.body).map((text) => ({ repo: r.repo, pull: r.number, author: r.author, authorId: r.authorId, text })), | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 124 | /** Review comments per pull reviewed, in `[from, to)`: every finding line over the pulls they were on. */ | |
| 125 | export function perPull(db: Database, repoId: string, from: Date, to: Date, publicOnly = false): { comments: number; pulls: number; per: number } { | |
| 126 | const found = findingsIn(db, repoId, from, to, publicOnly); | |
| 127 | const pulls = new Set(found.map((f) => `${f.repo}#${f.pull}`)).size; | |
| 128 | return { comments: found.length, pulls, per: pulls === 0 ? 0 : Math.round((found.length / pulls) * 10) / 10 }; | |
| 129 | } | |
| 130 | ||
| 131 | /** The issue body that asks for the check, naming the comments. */ | |
| 132 | export function proposalBody(g: Group, now: Date): string { | |
| 133 | const named = g.findings | |
| 134 | .slice(0, MAX_NAMED) | |
| 135 | .map((f) => `- #${f.pull} (${f.author}): ${f.text.replace(/\s+/g, " ")}`); | |
| 136 | return [ | |
| 137 | `Reviewers asked for the same thing ${g.findings.length} times in the week to ${now.toISOString().slice(0, 10)}. Once a comment repeats, a check says it for them (#8716).`, | |
| 138 | "", | |
| 139 | ...named, | |
| 140 | ...(g.findings.length > MAX_NAMED ? [`- and ${g.findings.length - MAX_NAMED} more`] : []), | |
| 141 | "", | |
| 142 | "**Done when:**", | |
| 143 | "- [ ] A lint (`scripts/`) or a test that fails on exactly what these comments ask for, so a reviewer need not say it again.", | |
| 144 | "- [ ] The check names the rule in its failure message.", | |
| 145 | "", | |
| 146 | "Files: scripts/gate.ts, test/", | |
| 147 | ].join("\n"); | |
| 148 | } | |
| 149 | ||
| 150 | export interface Proposed { | |
| 151 | readonly key: string; | |
| 152 | readonly sample: string; | |
| 153 | readonly seen: number; | |
| 154 | readonly issue: number; | |
| 155 | readonly retired: boolean; | |
| 156 | } | |
| 157 | ||
| 158 | export interface Repeats { | |
| 159 | /** Whose figures these are: every repository, or only the public ones. */ | |
| 160 | readonly scope: string; | |
| 161 | readonly says: string; | |
| 162 | readonly proposed: number; | |
| 163 | readonly retired: number; | |
| 164 | readonly thisWeek: ReturnType<typeof perPull>; | |
| 165 | readonly lastWeek: ReturnType<typeof perPull>; | |
| 166 | readonly findings: Proposed[]; | |
| 167 | } | |
| 168 | ||
| 169 | /** | |
| 170 | * Groups worth a check and not yet asked for: a stored finding whose words are | |
| 171 | * the same one is never proposed twice. | |
| 172 | */ | |
| 173 | export function dueGroups(db: Database, repoId: string, now: Date): Group[] { | |
| 174 | const kept = db | |
| 175 | .query<{ key: string }, [string]>(`SELECT key FROM review_finding WHERE repo_id = ?1`) | |
| 176 | .all(repoId) | |
| 177 | .map((r) => r.key.split(" ")); | |
| 178 | return group(findingsIn(db, repoId, new Date(now.getTime() - WEEK_MS), now)).filter( | |
| 179 | (g) => g.findings.length >= REPEATS && !kept.some((k) => similar(k, g.key.split(" "))), | |
| 180 | ); | |
| 181 | } | |
| 182 | ||
| 183 | /** Remember a finding once its issue is opened. */ | |
| 184 | export function keepFinding(db: Database, repoId: string, g: Group, issue: number, now: Date): void { | |
| 185 | db.query( | |
| 186 | `INSERT INTO review_finding (repo_id, key, sample, seen, issue_number, proposed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, | |
| 187 | ).run(repoId, g.key, g.sample.slice(0, MAX_LINE), g.findings.length, issue, now.toISOString()); | |
| 188 | } | |
| 189 | ||
| 190 | /** Retire each finding whose issue was done: the check landed. How many were retired now. */ | |
| 191 | export function retireLanded(db: Database, repoId: string, now: Date): number { | |
| 192 | return db | |
| 193 | .query( | |
| 194 | `UPDATE review_finding SET retired_at = ?2 | |
| 195 | WHERE repo_id = ?1 AND retired_at IS NULL AND issue_number IN | |
| 196 | (SELECT number FROM issue WHERE repo_id = ?1 AND state = 'closed' AND close_reason = 'done')`, | |
| 197 | ) | |
| 198 | .run(repoId, now.toISOString()).changes; | |
| 199 | } | |
| 200 | ||
| 201 | /** The reading: findings proposed and retired, with the week's comments per pull and last week's. */ | |
| 202 | export function readRepeats(db: Database, repoId: string, now = new Date(), publicOnly = false): Repeats { | |
| 203 | const rows = db | |
| 204 | .query<{ key: string; sample: string; seen: number; issue_number: number; retired_at: string | null }, [string, number]>( | |
| 205 | `SELECT key, sample, seen, issue_number, retired_at FROM review_finding JOIN repo ON repo.id = review_finding.repo_id | |
| 206 | WHERE (?1 = '' OR review_finding.repo_id = ?1) AND (?2 = 0 OR repo.visibility = 'public') ORDER BY review_finding.id DESC`, | |
| 207 | ) | |
| 208 | .all(repoId, publicOnly ? 1 : 0); | |
| 209 | const week = new Date(now.getTime() - WEEK_MS); | |
| 210 | const thisWeek = perPull(db, repoId, week, now, publicOnly); | |
| 211 | const lastWeek = perPull(db, repoId, new Date(week.getTime() - WEEK_MS), week, publicOnly); | |
| 212 | const retired = rows.filter((r) => r.retired_at !== null).length; | |
| 213 | const scope = repoId !== "" ? "this repository" : publicOnly ? "public repositories" : "all repositories"; | |
| 214 | return { | |
| 215 | scope, | |
| 216 | says: `${scope}: ${rows.length} repeated finding${rows.length === 1 ? "" : "s"} proposed as checks, ${retired} retired; review comments per pull ${thisWeek.per} this week, ${lastWeek.per} last`, | |
| 217 | proposed: rows.length, | |
| 218 | retired, | |
| 219 | thisWeek, | |
| 220 | lastWeek, | |
| 221 | findings: rows.map((r) => ({ key: r.key, sample: r.sample, seen: r.seen, issue: r.issue_number, retired: r.retired_at !== null })), | |
| 222 | }; | |
| 223 | } |
src/system/telemetry.ts +3
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * node's history. Everything here is counts, rates and names of public |
| 6 | 6 | * things — no sealed value, no private repository's content, no person. |
| 7 | 7 | */ |
| 8 | import { readRepeats } from "../flow/repeats.ts"; | |
| 8 | 9 | import { readVerdicts } from "../flow/culprits.ts"; |
| 9 | 10 | import type { App } from "../app.ts"; |
| 10 | 11 | import { SOURCES } from "../boards/sources.ts"; |
| @@ -53,6 +54,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 53 | 54 | vitals: { says: vitalsLine(vitals), misses: vitalsMisses(vitals), ...vitals }, |
| 54 | 55 | // What the last red batches were: the pull named and returned with its failing tests, or a load red (#7639). |
| 55 | 56 | redBatches: readVerdicts(app.db, now, reader.kind === "agent"), |
| 57 | // Findings reviewers repeated, proposed as checks and retired, beside review comments per pull (#8716). | |
| 58 | reviewRepeats: readRepeats(app.db, "", now, reader.kind === "agent"), | |
Scope or label this forge-wide figure (change 2); also surface it on the web flow page (change 1). |
||
| 56 | 59 | // The four alarms (#5930) that are firing now; empty when none is. |
| 57 | 60 | alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent" }), |
| 58 | 61 | // Review runs in flight per reviewer, cut to public repositories for an agent (#4733). |
src/tui/screens/flow.ts +8
| @@ -43,6 +43,7 @@ import { buildYieldSays } from "../../flow/build_yield.ts"; | ||
| 43 | 43 | import { reviewLatencySays } from "../../flow/review_latency.ts"; |
| 44 | 44 | import type { Opener } from "../../flow/openers.ts"; |
| 45 | 45 | import { clogLines, readClog } from "../../flow/clog_read.ts"; |
| 46 | import { readRepeats } from "../../flow/repeats.ts"; | |
| 46 | 47 | import { readVerdicts } from "../../flow/culprits.ts"; |
| 47 | 48 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 48 | 49 | import { reviewingSays } from "../../flow/reviewing.ts"; |
| @@ -134,6 +135,7 @@ export function flowLines( | ||
| 134 | 135 | ? [] |
| 135 | 136 | : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)), |
| 136 | 137 | ...verdictLines(readVerdicts(db, now, agent !== undefined), inner), |
| 138 | ...repeatLines(readRepeats(db, "", now, agent !== undefined), inner), | |
| 137 | 139 | ...(lane === null ? [] : ["", ...wrap(`Rebases: ${lane}.`, inner)]), |
| 138 | 140 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), |
| 139 | 141 | ...idleLines(fig.idle, inner), |
| @@ -163,6 +165,12 @@ export function verdictLines(v: ReturnType<typeof readVerdicts>, inner: number): | ||
| 163 | 165 | ]; |
| 164 | 166 | } |
| 165 | 167 | |
| 168 | /** What reviewers repeated (#8716): the line, only once a finding was proposed or a review comment seen. */ | |
| 169 | export function repeatLines(r: ReturnType<typeof readRepeats>, inner: number): string[] { | |
| 170 | if (r.proposed === 0 && r.thisWeek.comments === 0 && r.lastWeek.comments === 0) return []; | |
| 171 | return ["", ...wrap(`Review repeats: ${r.says}.`, inner)]; | |
| 172 | } | |
| 173 | ||
| 166 | 174 | /** Gates shown under each repository's queue line. */ |
| 167 | 175 | const GATES_SHOWN = 5; |
| 168 | 176 |
src/web/pages/flow-panel.ts +14
| @@ -42,6 +42,7 @@ import { hoursWords } from "../../flow/lead_time.ts"; | ||
| 42 | 42 | import { buildYieldSays } from "../../flow/build_yield.ts"; |
| 43 | 43 | import { reviewLatencySays } from "../../flow/review_latency.ts"; |
| 44 | 44 | import { openerCounts } from "../../flow/openers.ts"; |
| 45 | import { readRepeats, type Repeats } from "../../flow/repeats.ts"; | |
| 45 | 46 | import { readHeld } from "../../flow/held.ts"; |
| 46 | 47 | import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verified.ts"; |
| 47 | 48 | import type { App } from "../../app.ts"; |
| @@ -213,6 +214,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 213 | 214 | </div>`, |
| 214 | 215 | { head: html`<h2>Pipeline</h2><a class="muted small" href="/pipeline">The four steps</a><a class="muted small" href="/settings/capacity">Capacity</a>`, id: "pipeline", pad: false }, |
| 215 | 216 | )} |
| 217 | ${repeatsCard(readRepeats(app.db, "", new Date(now), reader.kind !== "owner"))} | |
| 216 | 218 | ${verifiedCard(readVerified(app, reader.kind === "owner"), now)} |
| 217 | 219 | ${hotFilesCard(app, reader)} |
| 218 | 220 | ${heldCard(readHeld(app, reader.kind === "owner", now))} |
| @@ -222,6 +224,18 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm | ||
| 222 | 224 | ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`; |
| 223 | 225 | } |
| 224 | 226 | |
| 227 | /** What reviewers repeated (#8716): the proposals and retirements, beside comments per pull. */ | |
| 228 | function repeatsCard(r: Repeats): Html { | |
| 229 | return card( | |
| 230 | html`<h2>Review repeats</h2> | |
| 231 | <p data-repeats data-scope="${r.scope}">${r.says}.</p> | |
| 232 | ${r.findings.length === 0 | |
| 233 | ? null | |
| 234 | : html`<ul>${r.findings.map((f) => html`<li data-finding>${f.sample} <span class="muted small">seen ${String(f.seen)}× · #${String(f.issue)}${f.retired ? " · retired" : ""}</span></li>`)}</ul>`}`, | |
| 235 | { id: "repeats" }, | |
| 236 | ); | |
| 237 | } | |
| 238 | ||
| 225 | 239 | /** |
| 226 | 240 | * How long main has gone unverified, per repository whose main is verified |
| 227 | 241 | * after landing (#7786): the whole suite runs there on a schedule, after |
test/review-repeats.test.ts added +180
| @@ -0,0 +1,180 @@ | ||
| 1 | /** A reviewer's repeated comment becomes one issue to automate it, and is retired when that issue is done (#8716). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createIssue, getIssue } from "../src/core/issues.ts"; | |
| 9 | import type { Repo } from "../src/core/repos.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { promote } from "./helpers/members.ts"; | |
| 12 | import { sweepRepeats } from "../src/crew/repeat_checks.ts"; | |
| 13 | import { group, linesOf, readRepeats, REPEATS, wordsOf } from "../src/flow/repeats.ts"; | |
| 14 | import { flowFor } from "../src/system/telemetry.ts"; | |
| 15 | import { repeatLines } from "../src/tui/screens/flow.ts"; | |
| 16 | ||
| 17 | let root = ""; | |
| 18 | let app: App; | |
| 19 | let repo: Repo; | |
| 20 | let builder: User; | |
| 21 | let reviewer: User; | |
| 22 | const now = new Date("2026-11-02T12:00:00Z"); | |
| 23 | ||
| 24 | const person = async (name: string): Promise<User> => { | |
| 25 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase" }); | |
| 26 | if (!made.ok) throw new Error(made.error); | |
| 27 | return made.value; | |
| 28 | }; | |
| 29 | ||
| 30 | /** A pull by the builder, reviewed `ago` ms before `now` with the given body. */ | |
| 31 | function reviewed(body: string, ago = 3_600_000): number { | |
| 32 | const pull = createIssue(app.db, { repoId: repo.id, authorId: builder.id, title: "work", kind: "pull" }); | |
| 33 | if (!pull.ok) throw new Error(pull.error); | |
| 34 | app.db | |
| 35 | .query(`INSERT INTO review (id, issue_id, author_id, state, body, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4, ?5)`) | |
| 36 | .run(crypto.randomUUID(), pull.value.id, reviewer.id, body, new Date(now.getTime() - ago).toISOString()); | |
| 37 | return pull.value.number; | |
| 38 | } | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | root = mkdtempSync(join(tmpdir(), "forge-repeats-")); | |
| 42 | app = createApp({ dataDir: root, port: 0 }); | |
| 43 | builder = await person("builder"); | |
| 44 | reviewer = await person("critic"); | |
| 45 | promote(app.db, builder); | |
| 46 | const r = await createRepo(app, builder, { name: "yard", visibility: "public" }); | |
| 47 | if (!r.ok) throw new Error(String(r.error)); | |
| 48 | repo = r.value; | |
| 49 | }); | |
| 50 | afterAll(() => rmSync(root, { recursive: true, force: true })); | |
| 51 | ||
| 52 | describe("findings are grouped by what they ask for", () => { | |
| 53 | test("words ignore code, links and filler", () => { | |
| 54 | expect(wordsOf("Please validate the `name` field at https://x.y/z in src/a.ts")).toEqual(["field", "validate"]); | |
| 55 | }); | |
| 56 | test("list items are separate findings; short lines are not", () => { | |
| 57 | expect(linesOf("- Escape the output in the page\n2. Add a test for the new route\nok")).toEqual([ | |
| 58 | "Escape the output in the page", | |
| 59 | "Add a test for the new route", | |
| 60 | ]); | |
| 61 | }); | |
| 62 | test("different wording of the same ask is one group", () => { | |
| 63 | const f = (text: string, pull: number) => ({ repo: "r", pull, author: "a", authorId: "i", text }); | |
| 64 | const g = group([ | |
| 65 | f("Escape the user name before rendering it into the page", 1), | |
| 66 | f("The user name is rendered into the page without being escaped", 2), | |
| 67 | f("Add a test that the route refuses a cross-origin post", 3), | |
| 68 | ]); | |
| 69 | expect(g.map((x) => x.findings.length)).toEqual([2, 1]); | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("a finding repeated four times in a week becomes one issue", () => { | |
| 74 | test("three is not enough; four proposes once, naming the comments", () => { | |
| 75 | const pulls = [1, 2, 3].map((i) => reviewed(`- Escape the user name before rendering it into the page (${i})`)); | |
| 76 | expect(sweepRepeats(app, repo, now).proposed).toEqual([]); | |
| 77 | pulls.push(reviewed("- Escape the user name before rendering it into the page")); | |
| 78 | expect(REPEATS).toBe(4); | |
| 79 | const later = new Date(now.getTime() + 7 * 3_600_000); | |
| 80 | const moves = sweepRepeats(app, repo, later); | |
| 81 | expect(moves.proposed).toHaveLength(1); | |
| 82 | const issue = getIssue(app.db, repo.id, moves.proposed[0]!); | |
| 83 | expect(issue?.title).toContain("Automate a review finding"); | |
| 84 | for (const n of pulls) expect(issue?.body).toContain(`#${n} (critic)`); | |
| 85 | // the same finding never opens a second issue | |
| 86 | expect(sweepRepeats(app, repo, new Date(later.getTime() + 7 * 3_600_000)).proposed).toEqual([]); | |
| 87 | }); | |
| 88 | ||
| 89 | test("a sweep within six hours of the last does nothing", () => { | |
| 90 | expect(sweepRepeats(app, repo, new Date(now.getTime() + 8 * 3_600_000))).toEqual({ proposed: [], retired: 0 }); | |
| 91 | }); | |
| 92 | ||
| 93 | test("done issue retires the finding; per-pull is shown beside it, reachable in flow", () => { | |
| 94 | const before = readRepeats(app.db, repo.id, now); | |
| 95 | expect(before).toMatchObject({ proposed: 1, retired: 0 }); | |
| 96 | app.db.query(`UPDATE issue SET state = 'closed', close_reason = 'done' WHERE repo_id = ?1 AND title LIKE 'Automate%'`).run(repo.id); | |
| 97 | sweepRepeats(app, repo, new Date(now.getTime() + 30 * 3_600_000)); | |
| 98 | const after = readRepeats(app.db, repo.id, now); | |
| 99 | expect(after.retired).toBe(1); | |
| 100 | expect(after.thisWeek).toMatchObject({ comments: 4, pulls: 4, per: 1 }); | |
| 101 | expect(after.says).toContain("1 retired"); | |
| 102 | expect(after.says).toContain("per pull 1 this week, 0 last"); | |
| 103 | const flow = flowFor(app, { kind: "agent" } as never, now); | |
| 104 | expect(flow.reviewRepeats.retired).toBe(1); | |
| 105 | expect(repeatLines(after, 80).join(" ")).toContain("Review repeats"); | |
| 106 | expect(repeatLines(readRepeats(app.db, "none", now), 80)).toEqual([]); | |
| 107 | }); | |
| 108 | ||
| 109 | test("a finding older than a week is not counted", () => { | |
| 110 | const stale = Array.from({ length: 4 }, () => reviewed("- Rename the exported helper so its name says what it returns", 9 * 86_400_000)); | |
| 111 | expect(stale).toHaveLength(4); | |
| 112 | expect(sweepRepeats(app, repo, new Date(now.getTime() + 60 * 3_600_000)).proposed).toEqual([]); | |
| 113 | }); | |
| 114 | }); | |
| 115 | ||
| 116 | describe("what is not a repeat", () => { | |
| 117 | const f = (text: string, pull: number, repo = "r") => ({ repo, pull, author: "a", authorId: "i", text }); | |
| 118 | const at = () => new Date(now.getTime() - 3_600_000).toISOString(); | |
| 119 | const pullIn = (r: Repo, author: User): { id: string } => { | |
| 120 | const p = createIssue(app.db, { repoId: r.id, authorId: author.id, title: "w", kind: "pull" }); | |
| 121 | if (!p.ok) throw new Error(p.error); | |
| 122 | return p.value; | |
| 123 | }; | |
| 124 | const review = (issue: string, by: User, body: string): void => { | |
| 125 | app.db | |
| 126 | .query(`INSERT INTO review (id, issue_id, author_id, state, body, created_at) VALUES (?1, ?2, ?3, 'changes_requested', ?4, ?5)`) | |
| 127 | .run(crypto.randomUUID(), issue, by.id, body, at()); | |
| 128 | }; | |
| 129 | ||
| 130 | test("one pull saying a finding twice counts once", () => { | |
| 131 | const ask = "Escape the user name before rendering it into the page"; | |
| 132 | expect(group([f(ask, 1), f(ask, 1), f(ask, 1), f(ask, 1)])[0]?.findings).toHaveLength(1); | |
| 133 | expect(group([f(ask, 1), f(ask, 1, "other"), f(ask, 2)])[0]?.findings).toHaveLength(3); | |
| 134 | }); | |
| 135 | ||
| 136 | test("a review body and its line comment on one pull are one finding", async () => { | |
| 137 | const made = await createRepo(app, builder, { name: "solo", visibility: "public" }); | |
| 138 | if (!made.ok) throw new Error(String(made.error)); | |
| 139 | const ask = "Validate the length of the title at the boundary"; | |
| 140 | for (let i = 0; i < 3; i++) { | |
| 141 | const p = pullIn(made.value, builder); | |
| 142 | review(p.id, reviewer, `- ${ask}`); | |
| 143 | app.db | |
| 144 | .query(`INSERT INTO comment (id, issue_id, author_id, kind, body, created_at) VALUES (?1, ?2, ?3, 'review', ?4, ?5)`) | |
| 145 | .run(crypto.randomUUID(), p.id, reviewer.id, ask, at()); | |
| 146 | } | |
| 147 | // three pulls, each saying it twice: six lines, three repeats | |
| 148 | expect(sweepRepeats(app, made.value, new Date(now.getTime() + 60 * 3_600_000)).proposed).toEqual([]); | |
| 149 | // one more pull makes four pulls, and proposes | |
| 150 | review(pullIn(made.value, builder).id, reviewer, `- ${ask}`); | |
| 151 | expect(sweepRepeats(app, made.value, new Date(now.getTime() + 90 * 3_600_000)).proposed).toHaveLength(1); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the pull's own author and other repositories do not add up", async () => { | |
| 155 | const a = await createRepo(app, builder, { name: "left", visibility: "public" }); | |
| 156 | const b = await createRepo(app, builder, { name: "right", visibility: "public" }); | |
| 157 | if (!a.ok || !b.ok) throw new Error("repo"); | |
| 158 | const ask = "Cache the immutable assets with a long max-age"; | |
| 159 | for (let i = 0; i < 2; i++) review(pullIn(a.value, builder).id, reviewer, `- ${ask}`); | |
| 160 | for (let i = 0; i < 2; i++) review(pullIn(b.value, builder).id, reviewer, `- ${ask}`); | |
| 161 | for (let i = 0; i < 4; i++) review(pullIn(a.value, builder).id, builder, `- ${ask}`); // own comments | |
| 162 | const later = new Date(now.getTime() + 120 * 3_600_000); | |
| 163 | expect(sweepRepeats(app, a.value, later).proposed).toEqual([]); | |
| 164 | expect(sweepRepeats(app, b.value, later).proposed).toEqual([]); | |
| 165 | }); | |
| 166 | }); | |
| 167 | ||
| 168 | describe("the web door", () => { | |
| 169 | test("the boards panel shows the line, labelled by scope, escaped", async () => { | |
| 170 | const { flowPanel } = await import("../src/web/pages/flow-panel.ts"); | |
| 171 | const draw = (kind: "owner" | "agent") => String(flowPanel(app, { kind }, new URL("http://x/boards"), "/boards")); | |
| 172 | expect(draw("owner")).toContain("Review repeats"); | |
| 173 | expect(draw("owner")).toContain("all repositories:"); | |
| 174 | expect(draw("owner")).toMatch(/proposed as checks, \d+ retired/); | |
| 175 | app.db.query(`UPDATE review_finding SET sample = '<script>x</script>' WHERE id = (SELECT MAX(id) FROM review_finding)`).run(); | |
| 176 | expect(draw("owner")).not.toContain("<script>x"); | |
| 177 | expect(draw("owner")).toContain("<script>"); | |
| 178 | expect(draw("agent")).toContain("public repositories:"); | |
| 179 | }); | |
| 180 | }); |
new above
offline
Dedupe per (repo, pull, key) before grouping so a pull counts once per finding (change 3).