@expo/code-review-cli 0.7.0 → 0.8.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.
- package/README.md +118 -13
- package/build/cli.js +7 -0
- package/build/commands/ci.js +299 -28
- package/build/commands/dismiss.js +6 -0
- package/build/commands/doctor.js +3 -0
- package/build/commands/feedback.js +433 -0
- package/build/commands/init.js +231 -15
- package/build/commands/review.js +191 -51
- package/build/commands/setup-auth.js +3 -0
- package/build/commands/verify-config.js +3 -0
- package/build/config/load.js +39 -0
- package/build/config/routing.js +7 -0
- package/build/config/schema.js +92 -0
- package/build/core/adjudicate.js +194 -0
- package/build/core/auth.js +5 -1
- package/build/core/claude-code.js +12 -1
- package/build/core/context-file.js +42 -0
- package/build/core/coordinator.js +2 -2
- package/build/core/diff.js +1 -0
- package/build/core/exec.js +4 -0
- package/build/core/log.js +1 -0
- package/build/core/noise.js +5 -0
- package/build/core/opencode.js +22 -0
- package/build/core/prompts.js +311 -3
- package/build/core/render.js +255 -45
- package/build/core/responses.js +158 -0
- package/build/core/review.js +290 -15
- package/build/core/schema.js +213 -2
- package/build/core/scrub.js +4 -0
- package/build/core/stack-confirm.js +137 -0
- package/build/core/stack.js +25 -0
- package/build/core/step-summary.js +1 -0
- package/build/core/suppress.js +2 -0
- package/build/core/throttle.js +2 -0
- package/build/core/util.js +1 -0
- package/build/core/verify.js +5 -0
- package/build/reporters/github.js +465 -31
- package/build/reporters/terminal.js +2 -0
- package/build/sources/github-pr.js +272 -0
- package/build/sources/local-git.js +3 -0
- package/build/sources/source.js +35 -0
- package/package.json +2 -1
- package/templates/agents/consistency.md +2 -0
- package/templates/agents/correctness.md +2 -0
- package/templates/agents/security.md +3 -0
- package/templates/atlantis.yml +123 -0
- package/templates/command.yml +4 -0
- package/templates/config.jsonc +50 -1
- package/templates/coordinator.md +34 -9
- package/templates/dismiss.yml +4 -0
- package/templates/routing.jsonc +3 -0
- package/templates/scope-config.jsonc +1 -0
- package/templates/shared.md +96 -1
- 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
|
+
}
|