@expo/code-review-cli 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +161 -13
  2. package/build/cli.js +12 -0
  3. package/build/commands/ci.js +299 -28
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +3 -0
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/ref-check.js +84 -0
  9. package/build/commands/review.js +191 -51
  10. package/build/commands/setup-auth.js +3 -0
  11. package/build/commands/verify-config.js +3 -0
  12. package/build/config/load.js +39 -0
  13. package/build/config/routing.js +7 -0
  14. package/build/config/schema.js +92 -0
  15. package/build/core/adjudicate.js +194 -0
  16. package/build/core/auth.js +5 -1
  17. package/build/core/claude-code.js +12 -1
  18. package/build/core/config-refs.js +772 -0
  19. package/build/core/context-file.js +42 -0
  20. package/build/core/coordinator.js +2 -2
  21. package/build/core/diff.js +1 -0
  22. package/build/core/exec.js +4 -0
  23. package/build/core/log.js +1 -0
  24. package/build/core/noise.js +5 -0
  25. package/build/core/opencode.js +22 -0
  26. package/build/core/prompts.js +311 -3
  27. package/build/core/render.js +268 -45
  28. package/build/core/responses.js +158 -0
  29. package/build/core/review.js +307 -15
  30. package/build/core/schema.js +223 -2
  31. package/build/core/scrub.js +4 -0
  32. package/build/core/stack-confirm.js +137 -0
  33. package/build/core/stack.js +25 -0
  34. package/build/core/step-summary.js +1 -0
  35. package/build/core/suppress.js +2 -0
  36. package/build/core/throttle.js +2 -0
  37. package/build/core/util.js +1 -0
  38. package/build/core/verify.js +5 -0
  39. package/build/reporters/github.js +465 -31
  40. package/build/reporters/terminal.js +10 -0
  41. package/build/sources/github-pr.js +272 -0
  42. package/build/sources/local-git.js +3 -0
  43. package/build/sources/source.js +35 -0
  44. package/package.json +2 -1
  45. package/templates/agents/consistency.md +6 -1
  46. package/templates/agents/correctness.md +9 -1
  47. package/templates/agents/security.md +11 -1
  48. package/templates/atlantis.yml +123 -0
  49. package/templates/command.yml +4 -0
  50. package/templates/config.jsonc +50 -1
  51. package/templates/coordinator.md +34 -9
  52. package/templates/dismiss.yml +4 -0
  53. package/templates/routing.jsonc +3 -0
  54. package/templates/scope-config.jsonc +1 -0
  55. package/templates/shared.md +99 -1
  56. package/templates/workflow.yml +5 -0
@@ -0,0 +1,433 @@
1
+ // @ref LLP 0011#deterministic-matching [implements] — retroactive crawl: findings come from
2
+ // the bot's own past comment (already embeds them), so history is minable with no re-review
3
+ import { loadReviewConfig } from "../config/load.js";
4
+ import { repoRoot, resolveRepo, resolveTrustedTool, run } from "../core/exec.js";
5
+ import { runGrowableQueue } from "../core/review.js";
6
+ import { normalizeTitle } from "../core/responses.js";
7
+ import { errorMessage } from "../core/util.js";
8
+ import { GitHubReporter } from "../reporters/github.js";
9
+ const USAGE = `ecr feedback — report what humans pushed back on
10
+
11
+ Usage:
12
+ ecr feedback [--repo <owner/repo>] [--limit <n>] [--state <all|open|closed|merged>]
13
+ [--since <YYYY-MM-DD>] [--as <login>] [--json]
14
+
15
+ Crawls PRs on GitHub and matches non-bot replies to the findings the reviewer's
16
+ OWN comment already embeds — retroactively, on history, with no re-review and
17
+ no model call. Reports totals, breakdowns by category/severity/agent, and the
18
+ "repeat offenders": findings whose title recurs across PRs and drew a reply
19
+ every time.
20
+
21
+ Options:
22
+ --repo <owner/repo> Repo to scan (else resolved from the current checkout).
23
+ --limit <n> Max PRs to scan, newest first (default 50).
24
+ --state <s> PR state: all|open|closed|merged (default all).
25
+ --since <YYYY-MM-DD> Only PRs updated on or after this date.
26
+ --as <login> The login the reviewer comments were posted under
27
+ (default github-actions[bot], the scaffolded workflow's
28
+ identity). Without it, a local crawl would look for
29
+ comments authored by YOUR gh login and find none.
30
+ --json Emit the report as a stable JSON object instead of text.
31
+ `;
32
+ const VALID_STATES = new Set(["all", "open", "closed", "merged"]);
33
+ /** Same convention as review.ts: a flag's value must exist and not be a flag. */
34
+ function requireValue(flag, value) {
35
+ if (value === undefined || value.startsWith("--")) {
36
+ throw new Error(`${flag} requires a value`);
37
+ }
38
+ return value;
39
+ }
40
+ function parseArgs(argv) {
41
+ const args = { limit: 50, state: "all", as: "github-actions[bot]", json: false };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const arg = argv[i];
44
+ switch (arg) {
45
+ case "--repo":
46
+ args.repo = requireValue(arg, argv[++i]);
47
+ break;
48
+ case "--limit": {
49
+ const value = Number(requireValue(arg, argv[++i]));
50
+ if (!Number.isInteger(value) || value <= 0) {
51
+ throw new Error("--limit requires a positive integer");
52
+ }
53
+ args.limit = value;
54
+ break;
55
+ }
56
+ case "--state": {
57
+ const value = requireValue(arg, argv[++i]);
58
+ if (!VALID_STATES.has(value)) {
59
+ throw new Error("--state must be one of: all, open, closed, merged");
60
+ }
61
+ args.state = value;
62
+ break;
63
+ }
64
+ case "--since": {
65
+ const value = requireValue(arg, argv[++i]);
66
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
67
+ throw new Error("--since requires a YYYY-MM-DD date");
68
+ }
69
+ args.since = value;
70
+ break;
71
+ }
72
+ case "--as":
73
+ args.as = requireValue(arg, argv[++i]);
74
+ break;
75
+ case "--json":
76
+ args.json = true;
77
+ break;
78
+ default:
79
+ throw new Error(`Unknown argument: ${arg}`);
80
+ }
81
+ }
82
+ return args;
83
+ }
84
+ function feedbackTotals(prs) {
85
+ const prsWithComment = prs.filter((pr) => pr.hasComment).length;
86
+ const findingsSurfaced = prs.reduce((sum, pr) => sum + pr.findings.length, 0);
87
+ const findingsReplied = prs.reduce((sum, pr) => sum + pr.records.length, 0);
88
+ return {
89
+ prsScanned: prs.length,
90
+ prsWithComment,
91
+ findingsSurfaced,
92
+ findingsReplied,
93
+ replyRate: findingsSurfaced > 0 ? findingsReplied / findingsSurfaced : 0,
94
+ };
95
+ }
96
+ /** Group findings by a caller-chosen key (category/severity/agent), each with
97
+ * how many were surfaced vs. actually replied to. Sorted by volume, then key. */
98
+ function breakdown(prs, keyOf) {
99
+ const counts = new Map();
100
+ for (const pr of prs) {
101
+ const repliedFps = new Set(pr.records.map((record) => record.fp));
102
+ for (const { finding, fp } of pr.findings) {
103
+ const key = keyOf(finding);
104
+ const entry = counts.get(key) ?? { findings: 0, replied: 0 };
105
+ entry.findings++;
106
+ if (repliedFps.has(fp)) {
107
+ entry.replied++;
108
+ }
109
+ counts.set(key, entry);
110
+ }
111
+ }
112
+ return [...counts.entries()]
113
+ .map(([key, value]) => ({ key, ...value }))
114
+ .sort((a, b) => b.findings - a.findings || a.key.localeCompare(b.key));
115
+ }
116
+ /**
117
+ * Findings whose normalized title appears in 2+ distinct PRs, where EVERY one
118
+ * of those PRs drew a reply to it — not just some. A title that recurred three
119
+ * times but only got answered once is noise, not a pattern; this is deliberately
120
+ * stricter than "recurred and got at least one reply" for that reason.
121
+ */
122
+ function repeatOffenders(prs) {
123
+ const byTitle = new Map();
124
+ for (const pr of prs) {
125
+ const byFp = new Map(pr.records.map((record) => [record.fp, record]));
126
+ for (const { finding, fp } of pr.findings) {
127
+ const key = normalizeTitle(finding.title);
128
+ if (!key) {
129
+ continue;
130
+ }
131
+ const record = byFp.get(fp);
132
+ const group = byTitle.get(key) ?? { display: finding.title.trim(), byPr: new Map() };
133
+ const occurrence = {
134
+ pr: pr.number,
135
+ url: pr.url,
136
+ by: record?.by ?? "",
137
+ ...(record?.url ? { commentUrl: record.url } : {}),
138
+ replied: record !== undefined,
139
+ };
140
+ group.byPr.set(pr.number, [...(group.byPr.get(pr.number) ?? []), occurrence]);
141
+ byTitle.set(key, group);
142
+ }
143
+ }
144
+ const offenders = [];
145
+ for (const { display, byPr } of byTitle.values()) {
146
+ const prNumbers = [...byPr.keys()];
147
+ if (prNumbers.length < 2) {
148
+ continue;
149
+ }
150
+ const answeredEveryTime = prNumbers.every((n) => byPr.get(n).some((o) => o.replied));
151
+ if (!answeredEveryTime) {
152
+ continue;
153
+ }
154
+ const occurrences = prNumbers
155
+ .sort((a, b) => a - b)
156
+ .map((n) => byPr.get(n).find((o) => o.replied))
157
+ .map(({ pr, url, by, commentUrl }) => ({
158
+ pr,
159
+ url,
160
+ by,
161
+ ...(commentUrl ? { commentUrl } : {}),
162
+ }));
163
+ offenders.push({ title: display, occurrences });
164
+ }
165
+ return offenders.sort((a, b) => b.occurrences.length - a.occurrences.length || a.title.localeCompare(b.title));
166
+ }
167
+ function perPrEntries(prs) {
168
+ return prs
169
+ .filter((pr) => pr.records.length > 0)
170
+ .map((pr) => {
171
+ const byFp = new Map(pr.records.map((record) => [record.fp, record]));
172
+ const findings = pr.findings
173
+ .filter(({ fp }) => byFp.has(fp))
174
+ .map(({ finding, fp }) => {
175
+ const record = byFp.get(fp);
176
+ return {
177
+ title: finding.title.trim(),
178
+ by: record.by,
179
+ ...(record.url ? { commentUrl: record.url } : {}),
180
+ };
181
+ });
182
+ return { pr: pr.number, title: pr.title, url: pr.url, findings };
183
+ })
184
+ .sort((a, b) => b.pr - a.pr);
185
+ }
186
+ /**
187
+ * The whole report, from already-fetched material — no `gh`, no network, so
188
+ * it is exercised over a fixture. `prs` includes every PR the crawl looked at,
189
+ * with or without a bot comment, so "PRs scanned" and "PRs with a bot comment"
190
+ * can differ.
191
+ */
192
+ export function aggregateFeedback(prs) {
193
+ return {
194
+ totals: feedbackTotals(prs),
195
+ byCategory: breakdown(prs, (finding) => finding.category),
196
+ bySeverity: breakdown(prs, (finding) => finding.severity),
197
+ byAgent: breakdown(prs, (finding) => finding.agent ?? "unknown"),
198
+ repeatOffenders: repeatOffenders(prs),
199
+ perPr: perPrEntries(prs),
200
+ };
201
+ }
202
+ function pct(rate) {
203
+ return `${Math.round(rate * 100)}%`;
204
+ }
205
+ /**
206
+ * When every scanned PR came back with no bot comment at all, the crawl
207
+ * found nothing but that fact is easy to miss buried in "0 with a bot
208
+ * comment" — this makes it an explicit, visible line instead of a silent
209
+ * zero. Pure.
210
+ * @ref LLP 0011#ecr-feedback-the-same-substrate-read-backwards — retroactive
211
+ * crawl must not fail silent when the scan targets the wrong repo/login/config.
212
+ */
213
+ export function allNullCrawlWarning(totals) {
214
+ if (totals.prsScanned > 0 && totals.prsWithComment === 0) {
215
+ return (` no PRs had a matching review comment (0/${totals.prsScanned} scanned) — ` +
216
+ `check --as and the commentTag, or that --repo matches the config in use`);
217
+ }
218
+ return undefined;
219
+ }
220
+ /** Human-readable rendering of a report, in the order the spec calls for:
221
+ * totals, breakdowns, repeat offenders, then the per-PR list. Pure. */
222
+ export function formatFeedbackReport(report) {
223
+ const lines = [];
224
+ const t = report.totals;
225
+ lines.push(`PRs scanned: ${t.prsScanned} (${t.prsWithComment} with a bot comment)`, `Findings surfaced: ${t.findingsSurfaced}`, `Findings with an author reply: ${t.findingsReplied} (${pct(t.replyRate)})`);
226
+ const warning = allNullCrawlWarning(t);
227
+ if (warning) {
228
+ lines.push(warning);
229
+ }
230
+ lines.push("");
231
+ const renderBreakdown = (title, entries) => {
232
+ lines.push(`${title}:`);
233
+ if (entries.length === 0) {
234
+ lines.push(" (none)");
235
+ }
236
+ for (const entry of entries) {
237
+ lines.push(` ${entry.key}: ${entry.findings} surfaced, ${entry.replied} replied`);
238
+ }
239
+ lines.push("");
240
+ };
241
+ renderBreakdown("By category", report.byCategory);
242
+ renderBreakdown("By severity", report.bySeverity);
243
+ renderBreakdown("By agent", report.byAgent);
244
+ lines.push("Repeat offenders (recurred across PRs, answered every time):");
245
+ if (report.repeatOffenders.length === 0) {
246
+ lines.push(" (none)");
247
+ }
248
+ for (const offender of report.repeatOffenders) {
249
+ lines.push(` "${offender.title}" — ${offender.occurrences.length} PRs`);
250
+ for (const occ of offender.occurrences) {
251
+ lines.push(` ${occ.url} — @${occ.by || "unknown"}${occ.commentUrl ? ` (${occ.commentUrl})` : ""}`);
252
+ }
253
+ }
254
+ lines.push("");
255
+ lines.push("Per PR:");
256
+ if (report.perPr.length === 0) {
257
+ lines.push(" (none)");
258
+ }
259
+ for (const pr of report.perPr) {
260
+ lines.push(` #${pr.pr} ${pr.title} (${pr.url})`);
261
+ for (const finding of pr.findings) {
262
+ lines.push(` "${finding.title}" — @${finding.by || "unknown"}${finding.commentUrl ? ` (${finding.commentUrl})` : ""}`);
263
+ }
264
+ }
265
+ return lines.join("\n");
266
+ }
267
+ /**
268
+ * `--repo` lets a crawl target a repo other than the local checkout, but
269
+ * `feedbackCommand` always loads `.expo-code-review/config.jsonc` from the
270
+ * LOCAL checkout — it never fetches or trusts a remote repo's config, since
271
+ * config from another repo is untrusted input (AGENTS.md). If the two repos
272
+ * differ, the local `commentTag` may not match how the target repo's bot
273
+ * comments were tagged, so `readState()` silently returns null for every PR
274
+ * and the crawl reports zero findings with no hint why. This only warns; it
275
+ * never changes which config is loaded. Pure — takes the already-resolved
276
+ * local repo (or `undefined` when resolution wasn't attempted/failed) so the
277
+ * decision is testable without `gh`.
278
+ * @ref LLP 0011#ecr-feedback-the-same-substrate-read-backwards — retroactive
279
+ * crawl reuses the local reporter's config; a repo mismatch there must be
280
+ * visible, not silently zeroed out.
281
+ */
282
+ export function repoConfigMismatchWarning(targetRepo, localRepo) {
283
+ if (localRepo === undefined || localRepo === targetRepo) {
284
+ return undefined;
285
+ }
286
+ return (`warning: scanning ${targetRepo}, but the local .expo-code-review config is from ${localRepo}. ` +
287
+ `commentTag and other settings may not match the target repo — zero matches below may mean a ` +
288
+ `config mismatch, not zero pushback.\n`);
289
+ }
290
+ /**
291
+ * One PR's material, via the reporter that already owns comment fetching,
292
+ * pagination, and matching (`GitHubReporter.collectFeedback`) — this command
293
+ * does not talk to `gh` for comments itself, only for the PR list below.
294
+ * `readState()` is the only way to tell "no bot comment" apart from "a bot
295
+ * comment with zero findings", so it is checked first; `collectFeedback()` is
296
+ * skipped entirely when there is no comment to match against.
297
+ */
298
+ async function fetchPrFeedback(pr, repo, config, cwd, botLogin) {
299
+ const reporter = new GitHubReporter({
300
+ prNumber: pr.number,
301
+ repo,
302
+ commentTag: config.commentTag,
303
+ breakGlassMarker: config.breakGlassMarker,
304
+ cwd,
305
+ feedback: config.feedback,
306
+ // The crawl reads comments CI posted; the local gh identity would match
307
+ // nothing (read-only use, this reporter never posts).
308
+ ownLogin: botLogin,
309
+ });
310
+ const state = await reporter.readState();
311
+ if (!state) {
312
+ return {
313
+ number: pr.number,
314
+ title: pr.title,
315
+ url: pr.url,
316
+ hasComment: false,
317
+ findings: [],
318
+ records: [],
319
+ };
320
+ }
321
+ const { findings, records } = await reporter.collectFeedback();
322
+ return { number: pr.number, title: pr.title, url: pr.url, hasComment: true, findings, records };
323
+ }
324
+ // PRs are crawled with bounded concurrency so a `--limit` of hundreds doesn't
325
+ // fire hundreds of simultaneous `gh api` calls at once.
326
+ const CRAWL_CONCURRENCY = 4;
327
+ /**
328
+ * Runs `fetchOne` over `prs` through `runGrowableQueue`, isolating each PR's
329
+ * failure from the rest: a transient `gh api` error on one PR is recorded in
330
+ * `failed` and that PR is skipped, but every other PR still gets crawled and
331
+ * the report is still produced — one bad fetch must never lose the whole
332
+ * multi-PR report. Pure aside from calling `fetchOne`, so the queue/isolation
333
+ * behavior is unit-testable without `gh`.
334
+ */
335
+ export async function crawlPrFeedback(prs, concurrency, fetchOne, onProgress) {
336
+ const results = Array.from({ length: prs.length });
337
+ const failed = [];
338
+ let scanned = 0;
339
+ await runGrowableQueue(prs.map((pr, index) => ({ pr, index })), concurrency, async ({ pr, index }) => {
340
+ try {
341
+ results[index] = await fetchOne(pr);
342
+ }
343
+ catch (error) {
344
+ failed.push({
345
+ number: pr.number,
346
+ title: pr.title,
347
+ url: pr.url,
348
+ error: errorMessage(error),
349
+ });
350
+ }
351
+ scanned++;
352
+ onProgress(scanned, prs.length);
353
+ });
354
+ return { prs: results.filter((pr) => pr !== undefined), failed };
355
+ }
356
+ async function crawlFeedback(args, repo, config, cwd, onProgress) {
357
+ const gh = await resolveTrustedTool("gh");
358
+ const listArgs = [
359
+ "pr",
360
+ "list",
361
+ "--repo",
362
+ repo,
363
+ "--limit",
364
+ String(args.limit),
365
+ "--state",
366
+ args.state,
367
+ "--json",
368
+ "number,title,url,updatedAt",
369
+ ];
370
+ if (args.since) {
371
+ listArgs.push("--search", `updated:>=${args.since}`);
372
+ }
373
+ const { stdout } = await run(gh, listArgs, { cwd });
374
+ const prs = JSON.parse(stdout);
375
+ return crawlPrFeedback(prs, CRAWL_CONCURRENCY, (pr) => fetchPrFeedback(pr, repo, config, cwd, args.as), onProgress);
376
+ }
377
+ /** CLI wrapper: parse flags, crawl, aggregate, print. Makes no model calls. */
378
+ export async function feedbackCommand(argv) {
379
+ if (argv.includes("-h") || argv.includes("--help")) {
380
+ process.stdout.write(USAGE);
381
+ return;
382
+ }
383
+ let args;
384
+ try {
385
+ args = parseArgs(argv);
386
+ }
387
+ catch (error) {
388
+ process.stderr.write(`${errorMessage(error)}\n\n${USAGE}`);
389
+ process.exitCode = 2;
390
+ return;
391
+ }
392
+ const root = await repoRoot();
393
+ if (root && root !== process.cwd()) {
394
+ process.chdir(root);
395
+ }
396
+ const cwd = process.cwd();
397
+ try {
398
+ const config = await loadReviewConfig(cwd);
399
+ let repo;
400
+ if (args.repo) {
401
+ repo = args.repo;
402
+ // Compare against the local checkout's own repo so a mismatch — the
403
+ // config we just loaded is ALWAYS the local one, never the target's —
404
+ // can be surfaced instead of silently zeroing out the crawl.
405
+ const localRepo = await resolveRepo(cwd).catch(() => undefined);
406
+ const warning = repoConfigMismatchWarning(repo, localRepo);
407
+ if (warning) {
408
+ process.stderr.write(warning);
409
+ }
410
+ }
411
+ else {
412
+ repo = await resolveRepo(cwd);
413
+ }
414
+ const { prs, failed } = await crawlFeedback(args, repo, config, cwd, (scanned, total) => {
415
+ process.stderr.write(` scanned PR ${scanned}/${total}…\n`);
416
+ });
417
+ if (failed.length > 0) {
418
+ process.stderr.write(`warning: skipped ${failed.length} PR(s) that failed to fetch: ` +
419
+ `${failed.map((pr) => `#${pr.number} (${pr.error})`).join(", ")}\n`);
420
+ }
421
+ const report = aggregateFeedback(prs);
422
+ if (args.json) {
423
+ process.stdout.write(`${JSON.stringify({ ...report, failed })}\n`);
424
+ }
425
+ else {
426
+ process.stdout.write(`${formatFeedbackReport(report)}\n`);
427
+ }
428
+ }
429
+ catch (error) {
430
+ process.stderr.write(`feedback failed: ${errorMessage(error)}\n`);
431
+ process.exitCode = 2;
432
+ }
433
+ }