@expo/code-review-cli 0.6.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.
Files changed (54) hide show
  1. package/README.md +151 -25
  2. package/build/cli.js +7 -0
  3. package/build/commands/ci.js +307 -36
  4. package/build/commands/dismiss.js +6 -0
  5. package/build/commands/doctor.js +170 -33
  6. package/build/commands/feedback.js +433 -0
  7. package/build/commands/init.js +231 -15
  8. package/build/commands/review.js +191 -51
  9. package/build/commands/setup-auth.js +86 -11
  10. package/build/commands/verify-config.js +3 -0
  11. package/build/config/load.js +39 -0
  12. package/build/config/routing.js +7 -0
  13. package/build/config/schema.js +99 -3
  14. package/build/core/adjudicate.js +194 -0
  15. package/build/core/auth.js +127 -10
  16. package/build/core/claude-code.js +691 -0
  17. package/build/core/context-file.js +42 -0
  18. package/build/core/coordinator.js +2 -2
  19. package/build/core/diff.js +1 -0
  20. package/build/core/exec.js +282 -9
  21. package/build/core/log.js +1 -0
  22. package/build/core/noise.js +5 -0
  23. package/build/core/opencode.js +117 -15
  24. package/build/core/prompts.js +330 -5
  25. package/build/core/render.js +274 -45
  26. package/build/core/responses.js +158 -0
  27. package/build/core/review.js +447 -39
  28. package/build/core/schema.js +219 -3
  29. package/build/core/scrub.js +63 -1
  30. package/build/core/stack-confirm.js +137 -0
  31. package/build/core/stack.js +25 -0
  32. package/build/core/step-summary.js +1 -0
  33. package/build/core/suppress.js +2 -0
  34. package/build/core/throttle.js +12 -0
  35. package/build/core/util.js +18 -0
  36. package/build/core/verify.js +18 -1
  37. package/build/reporters/github.js +544 -44
  38. package/build/reporters/terminal.js +2 -0
  39. package/build/sources/github-pr.js +286 -7
  40. package/build/sources/local-git.js +6 -2
  41. package/build/sources/source.js +35 -0
  42. package/package.json +4 -3
  43. package/templates/agents/consistency.md +2 -0
  44. package/templates/agents/correctness.md +2 -0
  45. package/templates/agents/security.md +3 -0
  46. package/templates/atlantis.yml +123 -0
  47. package/templates/command.yml +4 -0
  48. package/templates/config.jsonc +71 -4
  49. package/templates/coordinator.md +34 -9
  50. package/templates/dismiss.yml +4 -0
  51. package/templates/routing.jsonc +3 -0
  52. package/templates/scope-config.jsonc +1 -0
  53. package/templates/shared.md +124 -1
  54. package/templates/workflow.yml +5 -0
@@ -1,12 +1,214 @@
1
+ // @ref LLP 0008#github-reporter-identity — the reporter's core problem: which PR comment is ours; identity is proven by author + marker, never marker alone
1
2
  import { writeFile, mkdtemp, rm } from "node:fs/promises";
2
3
  import { tmpdir } from "node:os";
3
4
  import path from "node:path";
4
- import { run } from "../core/exec.js";
5
+ import { resolveTrustedTool, run } from "../core/exec.js";
5
6
  import { parseUnifiedDiff } from "../core/diff.js";
6
7
  import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
7
- import { fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
+ import { matchReplies } from "../core/responses.js";
9
+ import { dropStaleVerdict, feedbackApplied, feedbackNeedsPrAuthor } from "../core/adjudicate.js";
10
+ import { applyPins, collectPins, fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
11
  import { appendStepSummary } from "../core/step-summary.js";
9
12
  const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
13
+ /**
14
+ * PR-author lookups shared across reporter INSTANCES, keyed by the exact PR they
15
+ * describe. Routed CI builds one GitHubReporter per scope comment (plus one for the
16
+ * aggregate comment), and every one of them would otherwise run its own `gh pr view` for
17
+ * the SAME PR. The key carries the checkout, the repo and the PR number, so a lookup can
18
+ * never leak across repos or PRs in one process (the `ecr feedback` crawl walks many
19
+ * PRs). The in-flight promise is stored, not just the result, so concurrent scopes share
20
+ * one call.
21
+ */
22
+ const prAuthorByPr = new Map();
23
+ /** Cache key for prAuthorByPr. Newline-joined with the free-form part (the checkout
24
+ * path) LAST: a PR number is digits and a repo is `owner/name`, neither of which can
25
+ * contain a newline, so two different PRs can never collide on one key. Exported for
26
+ * tests. */
27
+ export function prAuthorCacheKey(repo, prNumber, cwd) {
28
+ return `${prNumber}\n${repo}\n${cwd ?? ""}`;
29
+ }
30
+ /**
31
+ * Run `resolve` at most once per PR for the lifetime of the process — but cache only a
32
+ * SUCCESSFUL answer. A `gh pr view` that fails (rate limit, network blip) resolves to
33
+ * null, and caching that null would make one transient error fail-close every later
34
+ * scope of the same run: routed CI builds a reporter per scope, all reading this map,
35
+ * so every reply would be marked `author: false` and the adjudicated clear path could
36
+ * not fire again for the whole process. The entry is therefore removed once the promise
37
+ * settles to null (or rejects, which leaves nothing dangling either), so the next scope
38
+ * retries. Concurrency is unchanged: the in-flight promise is what's stored, so
39
+ * simultaneous scopes still join one call rather than each firing their own. Exported
40
+ * for tests (the reporter's own call path needs `gh`).
41
+ */
42
+ export function sharedPrAuthor(key, resolve) {
43
+ const cached = prAuthorByPr.get(key);
44
+ if (cached) {
45
+ return cached;
46
+ }
47
+ // Only ever forget OUR entry: a later call may already have started a new lookup.
48
+ const forget = (settled) => {
49
+ if (prAuthorByPr.get(key) === settled) {
50
+ prAuthorByPr.delete(key);
51
+ }
52
+ };
53
+ const pending = resolve().then((login) => {
54
+ if (login == null) {
55
+ forget(pending);
56
+ }
57
+ return login;
58
+ }, (error) => {
59
+ forget(pending);
60
+ throw error;
61
+ });
62
+ prAuthorByPr.set(key, pending);
63
+ return pending;
64
+ }
65
+ // @ref LLP 0011#suppression-is-never-silent [implements] — a verdict carries only while BOTH the reply and the reviewed source are unchanged
66
+ /**
67
+ * Carry a decision already recorded in the prior comment state onto the freshly
68
+ * matched records. A **verdict** (and the `applied` it justified) is bound to the words
69
+ * it judged AND to the source it judged: it carries only while the newest reply is the
70
+ * SAME comment it was decided about and `dropStaleVerdict` agrees the head is
71
+ * unchanged. A newer reply answers different words; a newer head means the code the
72
+ * rebuttal relied on may be gone. Either way the reply is re-judged rather than trusted.
73
+ *
74
+ * Fresh records own the reply identity (author, comment id, link); the prior state owns
75
+ * the decision. A maintainer's `/undismiss` pin is NOT merged here — it belongs to the
76
+ * finding, lives in the comment state's own `pins` set, and is stamped back on by
77
+ * `applyPins` (which is what keeps it alive through a run that matches no reply at all).
78
+ */
79
+ function mergeFeedback(fresh, previous, headSha) {
80
+ const priorByFp = new Map(previous.map((record) => [record.fp, record]));
81
+ return fresh.map((record) => {
82
+ const prior = priorByFp.get(record.fp);
83
+ if (!prior || prior.commentId !== record.commentId) {
84
+ // No prior decision, or different words: nothing decided about the old comment
85
+ // carries.
86
+ return record;
87
+ }
88
+ return dropStaleVerdict({
89
+ ...record,
90
+ ...(prior.verdict !== undefined ? { verdict: prior.verdict } : {}),
91
+ ...(prior.reason !== undefined ? { reason: prior.reason } : {}),
92
+ ...(prior.sourceSha !== undefined ? { sourceSha: prior.sourceSha } : {}),
93
+ applied: prior.applied,
94
+ }, headSha);
95
+ });
96
+ }
97
+ // @ref LLP 0011#deterministic-matching [implements] — the pure core of matchAdjudicationItems: no IO, so it is unit-testable; the caller supplies the fetched state/replies
98
+ /**
99
+ * Pair each matched reply with the finding it answers and its raw reply text, carrying
100
+ * any prior verdict forward. Pure: the caller has already fetched `previousFeedback`
101
+ * (the comment's stored records) and `replies`. `fpOf` MUST key findings the same way
102
+ * that comment stores them (scope-namespaced for an aggregate comment, plain otherwise)
103
+ * or a prior verdict can never carry — its fp would not match the fresh record's.
104
+ * `headSha` is the source revision being reviewed: a stored verdict carries only when
105
+ * it judged that same revision (see mergeFeedback). `pins` is the comment's
106
+ * `/undismiss` set: it is stamped onto the matched records so a pinned finding is never
107
+ * re-judged and never re-cleared (a v3 comment's record-level flags migrate in through
108
+ * collectPins). Exported for tests.
109
+ */
110
+ export function buildAdjudicationItems(review, previousFeedback, replies, fpOf, match, headSha, pins) {
111
+ const withFp = review.findings.map((finding) => ({ finding, fp: fpOf(finding) }));
112
+ const { records } = applyPins(mergeFeedback(matchReplies(replies, withFp, { match }), previousFeedback, headSha), collectPins(pins, previousFeedback));
113
+ const findingByFp = new Map(withFp.map((entry) => [entry.fp, entry.finding]));
114
+ const bodyById = new Map(replies.map((reply) => [reply.id, reply.body]));
115
+ const items = [];
116
+ for (const record of records) {
117
+ const finding = findingByFp.get(record.fp);
118
+ if (!finding) {
119
+ continue;
120
+ }
121
+ items.push({ finding, record, replyText: bodyById.get(record.commentId) ?? "" });
122
+ }
123
+ return items;
124
+ }
125
+ /**
126
+ * The findings a posted comment holds, keyed by the id that comment renders them under:
127
+ * scope-namespaced on an aggregate (comment:'single') comment, plain otherwise. This is
128
+ * both the valid-id set a `/dismiss` is checked against and the lookup that re-derives
129
+ * each feedback record's `applied` under the current config.
130
+ */
131
+ function stateFindingsById(state) {
132
+ const scopes = state.scopes;
133
+ if (scopes && scopes.length > 0) {
134
+ return new Map(scopes.flatMap((scope) => scope.review.findings.map((finding) => [scopedFingerprint(scope.isDefault ? null : scope.scope, finding), finding])));
135
+ }
136
+ return new Map(state.review.findings.map((finding) => [fingerprintFinding(finding), finding]));
137
+ }
138
+ // @ref LLP 0011#the-pin-belongs-to-the-finding [implements] — `/undismiss` writes the pin into the state's own pin set (never only onto a reply record), and `/dismiss` is the maintainer action that lifts it
139
+ // @ref LLP 0011#hard-floors-in-code [implements] — a re-render is a render: every kept record's `applied` is re-derived from feedbackApplied under the CURRENT config, never carried over as a stored fact
140
+ /**
141
+ * The pure state transition behind applyDismissal: which findings end up dismissed,
142
+ * which are pinned back to the active list, and what each feedback record's `applied`
143
+ * flag is under the config in force NOW. Exported for tests (the reporter's own path
144
+ * needs `gh`).
145
+ *
146
+ * Three rules meet here:
147
+ * - a `/dismiss` (an fp in `add`) is only recorded for an id this comment actually
148
+ * holds, and it LIFTS any pin on that finding — the same trusted hand deciding the
149
+ * opposite way;
150
+ * - a `/undismiss` (an fp in `remove`) restores a finding a REPLY cleared, not just a
151
+ * manual dismissal: it pins the finding so a later re-review recomputing `applied`
152
+ * from the still-present reply keeps it active;
153
+ * - every record's `applied` is then re-derived with `feedbackApplied` — the same single
154
+ * decision function every other render path uses. Passing a record through untouched
155
+ * would keep honoring the config of the run that stored it, so a repo that has since
156
+ * tightened `dismiss` (or widened `protectedCategories`) would leave an unrelated
157
+ * finding hidden until the next full review. A record whose finding is no longer in
158
+ * the comment keeps its stored flag, exactly as computeFeedback does.
159
+ */
160
+ export function applyDismissalToState(state, add, remove, config, by, reason) {
161
+ const findingById = stateFindingsById(state);
162
+ const matched = add.filter((fp) => findingById.has(fp));
163
+ const unmatched = add.filter((fp) => !findingById.has(fp));
164
+ const dismissed = state.dismissed.filter((record) => !remove.includes(record.fp));
165
+ for (const fp of matched) {
166
+ if (!dismissed.some((record) => record.fp === fp)) {
167
+ dismissed.push({ fp, by, reason });
168
+ }
169
+ }
170
+ const records = state.feedback ?? [];
171
+ const removeSet = new Set(remove);
172
+ const addSet = new Set(matched);
173
+ const pins = collectPins(state.pins, records).filter((pin) => !addSet.has(pin.fp));
174
+ for (const record of records) {
175
+ if (removeSet.has(record.fp) && !pins.some((pin) => pin.fp === record.fp)) {
176
+ // Pinned against the reply that is current right now: that same reply must not
177
+ // lift the pin later, only a maintainer reply posted after it (see applyPins).
178
+ pins.push({ fp: record.fp, commentId: record.commentId });
179
+ }
180
+ }
181
+ const stamped = applyPins(records, pins);
182
+ const feedback = stamped.records.map((record) => {
183
+ const finding = findingById.get(record.fp);
184
+ // No feedback config ⇒ the feature is off for this caller, so nothing is applied;
185
+ // the record itself (who replied, any verdict) is still preserved.
186
+ if (!config) {
187
+ return { ...record, applied: false };
188
+ }
189
+ return finding ? { ...record, applied: feedbackApplied(finding, record, config) } : record;
190
+ });
191
+ return { dismissed, feedback, pins: stamped.pins, matched, unmatched };
192
+ }
193
+ /**
194
+ * The reviewer's OWN marker comments, oldest-first: carrying the marker AND authored
195
+ * by `ownLogin`. The body marker alone is not identity — it defaults to a hardcoded,
196
+ * public literal and is readable in the base-branch config, so anyone who can comment
197
+ * on the PR (the untrusted PR author included) could post a comment carrying it plus a
198
+ * forged embedded review state; a newest-marker-wins lookup would then adopt that
199
+ * state and carry its `dismissed` list forward, silently suppressing real findings.
200
+ * GitHub sets a comment's author from the authenticated identity and it cannot be
201
+ * spoofed, so matching on author closes that. When `ownLogin` is null the author
202
+ * cannot be confirmed, so NOTHING is treated as ours (fail closed). Pure; exported for
203
+ * tests.
204
+ */
205
+ // @ref LLP 0008#github-reporter-identity [constrained-by] — marker-only matching would let a forged comment (from the untrusted PR author) carry forged dismissal state forward; author+marker is what makes identity unspoofable
206
+ export function selectOwnComments(comments, marker, ownLogin) {
207
+ if (!ownLogin) {
208
+ return [];
209
+ }
210
+ return comments.filter((comment) => comment.body?.includes(marker) && comment.user?.login === ownLogin);
211
+ }
10
212
  /**
11
213
  * Maintains exactly one PR comment, updating it in place across re-reviews (and
12
214
  * cleaning up duplicates) so the review converges instead of churning. Runs the
@@ -15,10 +217,87 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
15
217
  export class GitHubReporter {
16
218
  options;
17
219
  marker;
220
+ /** Memoized login of the account this reporter posts as (see resolveOwnLogin). */
221
+ ownLoginResolution;
18
222
  constructor(options) {
19
223
  this.options = options;
20
224
  this.marker = commentMarker(options.commentTag);
21
225
  }
226
+ /**
227
+ * The login of the account this reporter comments as, so its own comment is
228
+ * recognized by AUTHOR (see selectOwnComments for why the body marker is not enough).
229
+ * Resolution: `gh api user` (a user/PAT token), else the scaffolded workflow's default
230
+ * GITHUB_TOKEN identity, `github-actions[bot]`, when running under Actions — an
231
+ * installation token can't read `/user`. Null when neither is available, which makes
232
+ * selectOwnComments treat no comment as ours (fail closed). Memoized: the identity is
233
+ * stable for the process, and every reporter method consults it.
234
+ */
235
+ resolveOwnLogin() {
236
+ if (this.options.ownLogin) {
237
+ return Promise.resolve(this.options.ownLogin);
238
+ }
239
+ this.ownLoginResolution ??= (async () => {
240
+ try {
241
+ const gh = await resolveTrustedTool("gh");
242
+ const { stdout } = await run(gh, ["api", "user", "--jq", ".login"], {
243
+ cwd: this.options.cwd,
244
+ });
245
+ const login = stdout.trim();
246
+ if (login) {
247
+ return login;
248
+ }
249
+ }
250
+ catch {
251
+ // The default GITHUB_TOKEN is an installation token: `/user` returns 403.
252
+ }
253
+ return process.env.GITHUB_ACTIONS ? "github-actions[bot]" : null;
254
+ })();
255
+ return this.ownLoginResolution;
256
+ }
257
+ /**
258
+ * The PR author's login, used to mark a reply as coming from the author (see
259
+ * replyComments) so the adjudicated clear path can gate on it. Resolved via
260
+ * `gh pr view --json author` and fail-soft — a null result marks no reply as the
261
+ * author's, so the adjudicated path clears nothing (fail closed), exactly like an
262
+ * unresolved own-login. Memoized per PR across reporter INSTANCES (see
263
+ * prAuthorByPr), not per instance: routed CI builds one reporter per scope for the
264
+ * same PR, and the author's login is a property of the PR, not of the reporter.
265
+ */
266
+ resolvePrAuthor() {
267
+ const key = prAuthorCacheKey(this.options.repo, this.options.prNumber, this.options.cwd);
268
+ return sharedPrAuthor(key, async () => {
269
+ try {
270
+ const gh = await resolveTrustedTool("gh");
271
+ const { stdout } = await run(gh, [
272
+ "pr",
273
+ "view",
274
+ String(this.options.prNumber),
275
+ "--repo",
276
+ this.options.repo,
277
+ "--json",
278
+ "author",
279
+ "--jq",
280
+ ".author.login",
281
+ ], { cwd: this.options.cwd });
282
+ const login = stdout.trim();
283
+ return login || null;
284
+ }
285
+ catch {
286
+ // No PR author resolvable (missing PR, API error): fail closed — no reply is
287
+ // treated as the author's, so nothing clears via the adjudicated path.
288
+ return null;
289
+ }
290
+ });
291
+ }
292
+ /** This reporter's own marker comments, author-verified (see selectOwnComments). */
293
+ async ownComments() {
294
+ const [comments, ownLogin] = await Promise.all([
295
+ this.fetchAllComments(),
296
+ this.resolveOwnLogin(),
297
+ ]);
298
+ return selectOwnComments(comments, this.marker, ownLogin);
299
+ }
300
+ // @ref LLP 0008#comment-lifecycle [constrained-by] — gates on author_association (OWNER/MEMBER/COLLABORATOR), not on posting the marker string, so only a maintainer can skip review
22
301
  async checkBreakGlass() {
23
302
  const comments = await this.fetchAllComments();
24
303
  return comments.some((comment) => typeof comment.body === "string" &&
@@ -28,26 +307,208 @@ export class GitHubReporter {
28
307
  async postSkipNote() {
29
308
  await this.upsertComment(`${this.marker}\n🤖 AI review skipped via \`${this.options.breakGlassMarker}\`.`);
30
309
  }
31
- async report(review) {
310
+ /**
311
+ * Post/update the single-scope comment. When `feedback` is supplied (the
312
+ * adjudication path already matched + judged the replies) it is rendered as-is;
313
+ * otherwise the reporter matches live replies to this review's findings itself
314
+ * (annotate mode). Either way the feedback path fails soft — it never blocks the
315
+ * comment from being posted.
316
+ */
317
+ async report(review, feedback) {
32
318
  // Carry forward any per-PR dismissals recorded in the existing comment so they
33
319
  // survive re-reviews (a dismissed finding stays in the collapsed section).
34
320
  const existing = await this.findExistingComment();
35
- const dismissed = existing
36
- ? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
37
- : [];
321
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
322
+ const dismissed = state?.dismissed ?? [];
323
+ const withFp = review.findings.map((finding) => ({
324
+ finding,
325
+ fp: fingerprintFinding(finding),
326
+ }));
327
+ // The `/undismiss` pins carry on every render, whether or not this run matched a
328
+ // reply for the pinned finding — the records may come and go, the pin does not.
329
+ const priorRecords = state?.feedback ?? [];
330
+ const pinsIn = collectPins(state?.pins, priorRecords);
331
+ const { records, pins } = feedback
332
+ ? applyPins(feedback, pinsIn)
333
+ : await this.computeFeedback(withFp, priorRecords, pinsIn);
38
334
  const link = await this.linkContextAsync();
39
- await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link));
335
+ await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link, records, pins));
40
336
  }
41
337
  /** Post/update the aggregate multi-scope comment (comment:'single' mode). */
42
- async reportAggregate(results, unmatchedFiles) {
338
+ async reportAggregate(results, unmatchedFiles, feedback) {
43
339
  const existing = await this.findExistingComment();
44
- const dismissed = existing
45
- ? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
46
- : [];
340
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
341
+ const dismissed = state?.dismissed ?? [];
342
+ // Feedback is keyed by the SAME scope-namespaced id the aggregate comment
343
+ // renders, so a record can never drift across scopes.
344
+ const withFp = results.flatMap((result) => result.review.findings.map((finding) => ({
345
+ finding,
346
+ fp: scopedFingerprint(result.isDefault ? null : result.scope, finding),
347
+ })));
348
+ const priorRecords = state?.feedback ?? [];
349
+ const pinsIn = collectPins(state?.pins, priorRecords);
350
+ const { records, pins } = feedback
351
+ ? applyPins(feedback, pinsIn)
352
+ : await this.computeFeedback(withFp, priorRecords, pinsIn);
47
353
  const link = await this.linkContextAsync();
48
- await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, {
49
- unmatchedFiles,
50
- }));
354
+ await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, { unmatchedFiles }, records, pins));
355
+ }
356
+ /**
357
+ * Pair each matched PR reply with the finding it answers and its raw reply text —
358
+ * the input the adjudication step (core/review.ts) judges against the source. Any
359
+ * verdict already decided in the prior comment state is carried forward (so a reply
360
+ * is not re-judged for the same words). The reply text rides only the transient
361
+ * AdjudicationItem: it feeds the adjudicator prompt and is never stored on a record
362
+ * or rendered (see FeedbackRecordSchema). Gated on `mode !== "off"`. A fetch/parse
363
+ * error THROWS — the caller (runReview's feedback step) catches it and continues
364
+ * without records, which lets report() fall back to computeFeedback and preserve
365
+ * the previously recorded state; returning `[]` here would wipe it instead.
366
+ *
367
+ * `fpOf` MUST key findings the same way the comment this review lands in stores its
368
+ * feedback: scope-namespaced for the aggregate (comment:'single') comment, plain
369
+ * for a single/per-scope one. A mismatch would leave every fresh record keyed
370
+ * differently from the prior state, so mergeFeedback would carry no verdict and the
371
+ * budget would be re-spent judging the same words each run.
372
+ */
373
+ async matchAdjudicationItems(review, fpOf = fingerprintFinding) {
374
+ const config = this.options.feedback;
375
+ if (!config || config.mode === "off") {
376
+ return [];
377
+ }
378
+ // Deliberately NOT wrapped in a swallow-all: a fetch error here must propagate so
379
+ // runReview's own catch leaves `feedback` undefined — then report() falls back to
380
+ // computeFeedback, whose error path preserves the previously recorded state. A
381
+ // caught `[]` instead reads downstream as "there are no replies" and would wipe
382
+ // prior annotations (and un-hide reply-cleared findings) on a transient API error.
383
+ const existing = await this.findExistingComment();
384
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
385
+ const replies = await this.replyComments();
386
+ return buildAdjudicationItems(review, state?.feedback ?? [], replies, fpOf, config.match, this.options.headSha, state?.pins);
387
+ }
388
+ /**
389
+ * Read what humans pushed back on for THIS PR from the comment already posted:
390
+ * decode its embedded findings, match the non-bot replies, and merge any prior
391
+ * verdict. Works retroactively — the bot comment embeds its own findings, so no
392
+ * re-review is needed. `ecr feedback` crawls history through this. Fails soft:
393
+ * missing/unparseable comment ⇒ empty result, never a throw.
394
+ */
395
+ async collectFeedback() {
396
+ try {
397
+ const existing = await this.findExistingComment();
398
+ const state = existing ? parseReviewState(existing.body, this.options.commentTag) : null;
399
+ if (!state) {
400
+ return { findings: [], records: [] };
401
+ }
402
+ // An aggregate comment namespaces ids per scope; a single comment does not.
403
+ const withFp = state.scopes && state.scopes.length > 0
404
+ ? state.scopes.flatMap((scope) => scope.review.findings.map((finding) => ({
405
+ finding,
406
+ fp: scopedFingerprint(scope.isDefault ? null : scope.scope, finding),
407
+ })))
408
+ : state.review.findings.map((finding) => ({ finding, fp: fingerprintFinding(finding) }));
409
+ const replies = await this.replyComments();
410
+ // The retroactive crawl reports on any recorded pushback regardless of
411
+ // `mode`; only the match strategy is honored (default "both"). The crawl reviews
412
+ // nothing, so it has no head SHA to compare against: stored verdicts do not carry
413
+ // here. That costs the report nothing — it renders who replied and where, never a
414
+ // verdict — and keeps "unknown source" uniformly non-carrying.
415
+ const { records } = applyPins(mergeFeedback(matchReplies(replies, withFp, { match: this.options.feedback?.match ?? "both" }), state.feedback ?? [], this.options.headSha), collectPins(state.pins, state.feedback ?? []));
416
+ return { findings: withFp, records };
417
+ }
418
+ catch {
419
+ return { findings: [], records: [] };
420
+ }
421
+ }
422
+ // @ref LLP 0011#deterministic-matching [implements] — replies are matched to findings deterministically; the reporter never lets a model pick which finding a reply answers
423
+ /**
424
+ * The matched, merged feedback records to render, or none. Gated on
425
+ * `mode !== "off"` (the annotate/adjudicate switch) and wrapped so a fetch or
426
+ * match error degrades to the records already recorded — a review is never
427
+ * blocked by the feedback path.
428
+ */
429
+ async computeFeedback(withFp, previous, pinsIn) {
430
+ const config = this.options.feedback;
431
+ // `applied` is a function of the CURRENT config, never a stored fact: a carried
432
+ // record's flag was computed under the config of the run that stored it, so a
433
+ // repo flipping `dismiss` back to "never" (or `mode` to "off") must un-hide the
434
+ // finding on the next render, not keep honoring the old policy.
435
+ const findingByFp = new Map(withFp.map((entry) => [entry.fp, entry.finding]));
436
+ const reapply = (records) => {
437
+ // Stamp the pins first: `feedbackApplied` reads the pin off the record, and the
438
+ // set — not the record — is what says which finding a maintainer restored.
439
+ const stamped = applyPins(records, pinsIn);
440
+ return {
441
+ pins: stamped.pins,
442
+ records: stamped.records.map((record) => {
443
+ const finding = findingByFp.get(record.fp);
444
+ // With no feedback config at all, the feature is disabled, so nothing is
445
+ // applied — but the record itself (who replied, any verdict) is preserved.
446
+ if (!config) {
447
+ return { ...record, applied: false };
448
+ }
449
+ return finding
450
+ ? { ...record, applied: feedbackApplied(finding, record, config) }
451
+ : record;
452
+ }),
453
+ };
454
+ };
455
+ // mode "off" (or no config) preserves the previously recorded records and only
456
+ // stops matching NEW replies. Returning [] here would let render's reviewState
457
+ // drop the feedback key entirely, permanently losing every recorded reply, verdict
458
+ // and reply-dismissal decision — mirror applyDismissal, which keeps feedback on a
459
+ // re-render. `applied` is still recomputed (→ false under "off"), so a finding a
460
+ // reply had cleared correctly returns to the active list.
461
+ if (!config || config.mode === "off") {
462
+ return reapply(previous);
463
+ }
464
+ try {
465
+ const replies = await this.replyComments();
466
+ return reapply(mergeFeedback(matchReplies(replies, withFp, { match: config.match }), previous, this.options.headSha));
467
+ }
468
+ catch {
469
+ return reapply(previous);
470
+ }
471
+ }
472
+ // @ref LLP 0008#github-reporter-identity [implements] — a reply is any comment NOT authored by us and NOT carrying our marker; author identity (user.login) is what excludes our own, never the forgeable marker alone
473
+ /**
474
+ * The PR's human replies, as the matcher consumes them. Excludes comments we
475
+ * authored (by unspoofable `user.login`) and any comment carrying our marker, so
476
+ * our own footer is never read back as a reply; the `author_association` gives
477
+ * the maintainer flag and the PR author's login gives the `author` flag. When our
478
+ * login can't be resolved, the marker filter still keeps our own comments out.
479
+ *
480
+ * The PR-author lookup is an extra `gh` call, so it only runs when the flag can
481
+ * actually change an outcome — `dismiss: "adjudicated"` (see feedbackNeedsPrAuthor).
482
+ * Skipping it leaves every reply `author: false`, the same fail-closed answer a
483
+ * failed lookup gives, and under any other `dismiss` value nothing reads the flag.
484
+ */
485
+ async replyComments() {
486
+ const [comments, ownLogin, prAuthor] = await Promise.all([
487
+ this.fetchAllComments(),
488
+ this.resolveOwnLogin(),
489
+ feedbackNeedsPrAuthor(this.options.feedback) ? this.resolvePrAuthor() : null,
490
+ ]);
491
+ const out = [];
492
+ for (const comment of comments) {
493
+ const login = comment.user?.login;
494
+ if (!login || (ownLogin && login === ownLogin)) {
495
+ continue;
496
+ }
497
+ if (comment.body?.includes(this.marker)) {
498
+ continue;
499
+ }
500
+ out.push({
501
+ id: comment.id,
502
+ body: comment.body ?? "",
503
+ login,
504
+ maintainer: MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ""),
505
+ // Trusted-for-adjudication identity: the reply is from the PR author. Derived
506
+ // from the unspoofable comment author, not the (public) marker or reply text.
507
+ author: prAuthor != null && login === prAuthor,
508
+ ...(comment.html_url ? { url: comment.html_url } : {}),
509
+ });
510
+ }
511
+ return out;
51
512
  }
52
513
  /**
53
514
  * The embedded review state of the existing reviewer comment, or null when no
@@ -65,8 +526,9 @@ export class GitHubReporter {
65
526
  * reviewdog #1911 lesson).
66
527
  */
67
528
  async clear() {
68
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
69
- for (const comment of marked) {
529
+ // Only ever delete comments WE authored — never touch a look-alike posted by
530
+ // someone else (see selectOwnComments).
531
+ for (const comment of await this.ownComments()) {
70
532
  await this.deleteComment(comment.id);
71
533
  }
72
534
  }
@@ -88,7 +550,8 @@ export class GitHubReporter {
88
550
  await Promise.all([
89
551
  (async () => {
90
552
  try {
91
- const { stdout } = await run("gh", ["pr", "diff", ...prArgs], { cwd });
553
+ const gh = await resolveTrustedTool("gh");
554
+ const { stdout } = await run(gh, ["pr", "diff", ...prArgs], { cwd });
92
555
  link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
93
556
  }
94
557
  catch {
@@ -97,7 +560,8 @@ export class GitHubReporter {
97
560
  })(),
98
561
  (async () => {
99
562
  try {
100
- const { stdout } = await run("gh", ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
563
+ const gh = await resolveTrustedTool("gh");
564
+ const { stdout } = await run(gh, ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
101
565
  cwd,
102
566
  });
103
567
  const oid = JSON.parse(stdout).baseRefOid;
@@ -114,8 +578,13 @@ export class GitHubReporter {
114
578
  }
115
579
  /**
116
580
  * Add or remove per-PR finding dismissals in the reviewer's comment and re-render
117
- * it in place — no re-review needed (the comment embeds the full review state).
581
+ * it in place — no re-review needed (the comment embeds the full review state). The
582
+ * decision itself is pure: see applyDismissalToState, which also re-derives every
583
+ * feedback record's `applied` under the current config and carries the `/undismiss`
584
+ * pin set. This is a render like any other, so it must never leave a record hidden
585
+ * under a policy the repo has since changed.
118
586
  */
587
+ // @ref LLP 0008#comment-lifecycle [implements] — validates added fingerprints against the CURRENT review's valid set, scope-aware (scoped fingerprints for an aggregate comment, plain otherwise), rather than trusting caller-supplied ids
119
588
  async applyDismissal(add, remove, by, reason) {
120
589
  const existing = await this.findExistingComment();
121
590
  if (!existing) {
@@ -125,36 +594,55 @@ export class GitHubReporter {
125
594
  if (!state) {
126
595
  throw new Error("The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.");
127
596
  }
128
- // Scope-aware validity: on an aggregate comment the ids are scope-namespaced, so
129
- // validate against every scope's scoped fingerprints; otherwise the plain ones.
130
597
  const isAggregate = Array.isArray(state.scopes) && state.scopes.length > 0;
131
- const validFps = isAggregate
132
- ? new Set(state.scopes.flatMap((scope) => scope.review.findings.map((finding) => scopedFingerprint(scope.isDefault ? null : scope.scope, finding))))
133
- : new Set(state.review.findings.map(fingerprintFinding));
134
- const matched = add.filter((fp) => validFps.has(fp));
135
- const unmatched = add.filter((fp) => !validFps.has(fp));
136
- const dismissed = state.dismissed.filter((record) => !remove.includes(record.fp));
137
- for (const fp of matched) {
138
- if (!dismissed.some((record) => record.fp === fp)) {
139
- dismissed.push({ fp, by, reason });
140
- }
141
- }
598
+ const next = applyDismissalToState(state, add, remove, this.options.feedback, by, reason);
142
599
  const link = await this.linkContextAsync();
143
600
  const body = isAggregate
144
- ? renderAggregateMarkdown(state.scopes, this.options.commentTag, dismissed, link)
145
- : renderMarkdown(state.review, this.options.commentTag, dismissed, link);
601
+ ? renderAggregateMarkdown(state.scopes, this.options.commentTag, next.dismissed, link, undefined, next.feedback, next.pins)
602
+ : renderMarkdown(state.review, this.options.commentTag, next.dismissed, link, next.feedback, next.pins);
146
603
  await this.patchComment(existing.id, body);
147
- return { dismissedCount: dismissed.length, matched, unmatched };
604
+ return {
605
+ dismissedCount: next.dismissed.length,
606
+ matched: next.matched,
607
+ unmatched: next.unmatched,
608
+ };
148
609
  }
149
- /** Newest reviewer-tagged comment (id + body), or null if none posted yet. */
610
+ /** Newest comment WE authored carrying our marker (id + body), or null if none. */
150
611
  async findExistingComment() {
151
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
152
- const keep = marked[marked.length - 1];
612
+ const own = await this.ownComments();
613
+ const keep = own[own.length - 1];
153
614
  return keep ? { id: keep.id, body: keep.body ?? "" } : null;
154
615
  }
155
616
  // Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
156
617
  // pathological PR; virtually every real PR exits far earlier.
157
618
  static MAX_COMMENT_PAGES = 30;
619
+ /** How long a fetched comment list may be reused (see fetchAllComments). */
620
+ static COMMENTS_CACHE_TTL_MS = 30_000;
621
+ commentsCache;
622
+ /**
623
+ * Burst-collapsing cache over fetchAllCommentsUncached. One logical operation
624
+ * fans out into several comment reads (readState + collectFeedback in the
625
+ * `ecr feedback` crawl; findExistingComment + replyComments + upsert in a
626
+ * report) that would each re-fetch the identical paginated list. The TTL is
627
+ * deliberately short: across a real gap (break-glass check → 30-minute review →
628
+ * post) a stale list could miss a fresh `/skip-review` or a new duplicate, so
629
+ * only back-to-back calls coalesce. Any comment mutation invalidates it.
630
+ */
631
+ fetchAllComments() {
632
+ const now = Date.now();
633
+ if (!this.commentsCache || now - this.commentsCache.at > GitHubReporter.COMMENTS_CACHE_TTL_MS) {
634
+ const comments = this.fetchAllCommentsUncached().catch((error) => {
635
+ // Never cache a failure.
636
+ this.commentsCache = undefined;
637
+ throw error;
638
+ });
639
+ this.commentsCache = { at: now, comments };
640
+ }
641
+ return this.commentsCache.comments;
642
+ }
643
+ invalidateComments() {
644
+ this.commentsCache = undefined;
645
+ }
158
646
  /**
159
647
  * Fetch ALL issue comments, paginating manually (a single page's array is valid
160
648
  * JSON; `--paginate` concatenates arrays into invalid JSON). The issue-comments
@@ -163,10 +651,12 @@ export class GitHubReporter {
163
651
  * a recent `/skip-review` can otherwise fall outside a single 100-comment window,
164
652
  * causing duplicate comments and missed break-glass).
165
653
  */
166
- async fetchAllComments() {
654
+ // @ref LLP 0008#comment-lifecycle [constrained-by] — the issue-comments endpoint ignores sort/direction and returns oldest-first; pagination must reach the end or the newest comment (ours, or a recent break-glass) can fall outside the window
655
+ async fetchAllCommentsUncached() {
167
656
  const all = [];
657
+ const gh = await resolveTrustedTool("gh");
168
658
  for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
169
- const { stdout } = await run("gh", [
659
+ const { stdout } = await run(gh, [
170
660
  "api",
171
661
  "-X",
172
662
  "GET",
@@ -198,8 +688,12 @@ export class GitHubReporter {
198
688
  * otherwise create it. Comments come back oldest-first, so the LAST marked one
199
689
  * is the newest and is the keeper.
200
690
  */
691
+ // @ref LLP 0008#comment-lifecycle [implements] — converges to one live comment: patches the newest own-marker comment and deletes older duplicates, never touching a look-alike from someone else
201
692
  async upsertComment(body) {
202
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
693
+ // Update/clean up only comments WE authored, never a look-alike posted by
694
+ // someone else (see selectOwnComments) — otherwise the newest forged marker
695
+ // comment would be adopted as "ours" and edited/patched in its place.
696
+ const marked = await this.ownComments();
203
697
  if (marked.length === 0) {
204
698
  await this.createComment(body);
205
699
  }
@@ -227,7 +721,9 @@ export class GitHubReporter {
227
721
  }
228
722
  }
229
723
  async createComment(body) {
230
- await this.withBodyFile(body, (jsonPath) => run("gh", [
724
+ this.invalidateComments();
725
+ const gh = await resolveTrustedTool("gh");
726
+ await this.withBodyFile(body, (jsonPath) => run(gh, [
231
727
  "api",
232
728
  "-X",
233
729
  "POST",
@@ -237,7 +733,9 @@ export class GitHubReporter {
237
733
  ], { cwd: this.options.cwd }));
238
734
  }
239
735
  async patchComment(commentId, body) {
240
- await this.withBodyFile(body, (jsonPath) => run("gh", [
736
+ this.invalidateComments();
737
+ const gh = await resolveTrustedTool("gh");
738
+ await this.withBodyFile(body, (jsonPath) => run(gh, [
241
739
  "api",
242
740
  "-X",
243
741
  "PATCH",
@@ -247,6 +745,8 @@ export class GitHubReporter {
247
745
  ], { cwd: this.options.cwd }));
248
746
  }
249
747
  async deleteComment(commentId) {
250
- await run("gh", ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
748
+ this.invalidateComments();
749
+ const gh = await resolveTrustedTool("gh");
750
+ await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
251
751
  }
252
752
  }