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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8716-a-reviewer-s-repeated-comment-becomes-a → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/8716-a-reviewer-s-repeated-comment-becomes-a was merged into main as 55b89d8.

Review

Sign in to review this change.
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";
131131 import { sweepStaleReds } from "./stale_reds.ts";
132132 import { parkPull, strikeNote } from "./park_pull.ts";
133133 import { keepVerdict } from "../flow/culprits.ts";
134 import { sweepRepeats } from "./repeat_checks.ts";
134135 import { keepBatch } from "../flow/red_batches.ts";
135136 import { recordQueueFlakes } from "../checks/flake.ts";
136137 import { readVitals, vitalsMisses, vitalsWords } from "../flow/vitals.ts";
@@ -1251,6 +1252,12 @@ export async function autonomyTick(
12511252 error: String(error),
12521253 });
12531254 });
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 }
12541261 // Before the picks: a pull a reviewer answered is finished before new
12551262 // work takes the slots it needs (#2172).
12561263 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 } {
outdated

Dedupe per (repo, pull, key) before grouping so a pull counts once per finding (change 3).

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 @@
55 * node's history. Everything here is counts, rates and names of public
66 * things — no sealed value, no private repository's content, no person.
77 */
8 import { readRepeats } from "../flow/repeats.ts";
89 import { readVerdicts } from "../flow/culprits.ts";
910 import type { App } from "../app.ts";
1011 import { SOURCES } from "../boards/sources.ts";
@@ -53,6 +54,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) {
5354 vitals: { says: vitalsLine(vitals), misses: vitalsMisses(vitals), ...vitals },
5455 // What the last red batches were: the pull named and returned with its failing tests, or a load red (#7639).
5556 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).

5659 // The four alarms (#5930) that are firing now; empty when none is.
5760 alarms: firedAlarms(app.db, now, { publicOnly: reader.kind === "agent" }),
5861 // 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";
4343 import { reviewLatencySays } from "../../flow/review_latency.ts";
4444 import type { Opener } from "../../flow/openers.ts";
4545 import { clogLines, readClog } from "../../flow/clog_read.ts";
46 import { readRepeats } from "../../flow/repeats.ts";
4647 import { readVerdicts } from "../../flow/culprits.ts";
4748 import { readQueues, redBatchWords } from "../../flow/queue.ts";
4849 import { reviewingSays } from "../../flow/reviewing.ts";
@@ -134,6 +135,7 @@ export function flowLines(
134135 ? []
135136 : wrap(`Red batches, last hour: ${red.map((q) => `${q.repo} ${redBatchWords(q)}`).join(", ")}.`, inner)),
136137 ...verdictLines(readVerdicts(db, now, agent !== undefined), inner),
138 ...repeatLines(readRepeats(db, "", now, agent !== undefined), inner),
137139 ...(lane === null ? [] : ["", ...wrap(`Rebases: ${lane}.`, inner)]),
138140 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
139141 ...idleLines(fig.idle, inner),
@@ -163,6 +165,12 @@ export function verdictLines(v: ReturnType<typeof readVerdicts>, inner: number):
163165 ];
164166 }
165167
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
166174 /** Gates shown under each repository's queue line. */
167175 const GATES_SHOWN = 5;
168176
src/web/pages/flow-panel.ts +14
@@ -42,6 +42,7 @@ import { hoursWords } from "../../flow/lead_time.ts";
4242 import { buildYieldSays } from "../../flow/build_yield.ts";
4343 import { reviewLatencySays } from "../../flow/review_latency.ts";
4444 import { openerCounts } from "../../flow/openers.ts";
45 import { readRepeats, type Repeats } from "../../flow/repeats.ts";
4546 import { readHeld } from "../../flow/held.ts";
4647 import { readVerified, verifiedSays, type VerifiedRow } from "../../flow/verified.ts";
4748 import type { App } from "../../app.ts";
@@ -213,6 +214,7 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
213214 </div>`,
214215 { 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 },
215216 )}
217 ${repeatsCard(readRepeats(app.db, "", new Date(now), reader.kind !== "owner"))}
216218 ${verifiedCard(readVerified(app, reader.kind === "owner"), now)}
217219 ${hotFilesCard(app, reader)}
218220 ${heldCard(readHeld(app, reader.kind === "owner", now))}
@@ -222,6 +224,18 @@ export function flowPanel(app: App, reader: Reader, url: URL, base: string): Htm
222224 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
223225 }
224226
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
225239 /**
226240 * How long main has gone unverified, per repository whose main is verified
227241 * 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("&lt;script&gt;");
178 expect(draw("agent")).toContain("public repositories:");
179 });
180 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs