@expo/code-review-cli 0.6.0 → 0.7.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.
@@ -3,7 +3,8 @@ import { prepareAuth } from "./auth.js";
3
3
  import { coordinate } from "./coordinator.js";
4
4
  import { writeRunLog } from "./log.js";
5
5
  import { filterNoise, writePatchWorkspace } from "./noise.js";
6
- import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
6
+ import { addTokenUsage, AgentTimeoutError, assertModelsResolvable, buildOpencodeConfig, CLAUDE_CODE_ENGINE, CROSS_CUTTING_AGENT, promptAndParse, startOpencode, } from "./opencode.js";
7
+ import { buildEngineMap, claudeTemperatureNote, claudeTokenCredential, startClaudeCode, } from "./claude-code.js";
7
8
  import { routeAgents } from "./router.js";
8
9
  import { buildCrossCuttingSystem, buildCrossCuttingTask, buildReviewerSystem, buildReviewerTask, NO_TOOLS_INSTRUCTION, } from "./prompts.js";
9
10
  import { fingerprintFinding, parseReviewerOutput } from "./schema.js";
@@ -33,18 +34,35 @@ function makeRunId() {
33
34
  * thin wrappers that supply a Source and render the result.
34
35
  */
35
36
  /**
36
- * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when a
37
- * subscription (oauth) credential is configured, else 6. One ChatGPT account
38
- * handles six parallel streams poorly — requests get parked server-side (the
39
- * stall signature seen on eas-cli#4084), and several PRs may be reviewing on the
40
- * same credential at once — so subscription runs trade a little wall-clock for a
41
- * lot of reliability. Exported for tests.
37
+ * Max concurrent reviewer calls: an explicit config value wins; otherwise 3 when the
38
+ * run leans on a subscription credential, else 6. One subscription account handles
39
+ * six parallel streams poorly — requests get parked server-side (the stall signature
40
+ * seen on eas-cli#4084), and several PRs may be reviewing on the same credential at
41
+ * once — so subscription runs trade a little wall-clock for a lot of reliability.
42
+ *
43
+ * A subscription run is any of: an oauth (ChatGPT/Codex) entry; OR the Claude Code
44
+ * engine being in use (an `anthropic/…` model) on an OAUTH credential — a subscription
45
+ * token OR the local `claude` login fallback (no forwardable token). An anthropic
46
+ * credential that classifies as an API KEY is metered per-request and does NOT force
47
+ * the cap. Exported for tests.
42
48
  */
43
- export function effectiveConcurrency(config) {
49
+ export function effectiveConcurrency(config, env = process.env) {
44
50
  if (config.chunk.concurrency) {
45
51
  return config.chunk.concurrency;
46
52
  }
47
- return config.auth.some((entry) => entry.mode === "oauth") ? 3 : 6;
53
+ if (config.auth.some((entry) => entry.mode === "oauth")) {
54
+ return 3;
55
+ }
56
+ if (buildEngineMap(config).usesClaude) {
57
+ const entry = config.auth.find((auth) => auth.provider === "anthropic");
58
+ const credential = claudeTokenCredential(entry, env);
59
+ // No forwardable token ⇒ the `claude` login (a subscription) covers the run; an
60
+ // "sk-ant-oat…" token is a subscription too. Either caps; an API key does not.
61
+ if (!credential || credential.kind === "oauth") {
62
+ return 3;
63
+ }
64
+ }
65
+ return 6;
48
66
  }
49
67
  export async function runReview(source, options) {
50
68
  const { config } = options;
@@ -105,6 +123,18 @@ export async function runReview(source, options) {
105
123
  // with a base-SHA checkout the fallback tree is pre-PR content, and silently
106
124
  // reviewing/verifying that drops real findings — while a local run falls back to
107
125
  // the user's own checkout with a warning.
126
+ // Resolve each agent's engine BEFORE prepareAuth/readRoot, so the per-engine
127
+ // startup below can't leak the readRoot worktree or a temp auth dir holding a
128
+ // live credential (buildEngineMap only inspects config, so nothing needs cleanup
129
+ // yet at this point). Nothing throws here anymore — one run may drive BOTH the
130
+ // Claude Code CLI engine and OpenCode at once, inferred per agent from its model.
131
+ //
132
+ // Scope the engine set to the SELECTED agents so a run whose passes never touch
133
+ // Claude doesn't start (and fail on a missing CLI/token for) the Claude Code
134
+ // engine. An explicit `--agents` subset is known here; routing picks from the full
135
+ // roster later (its router needs an engine up first), so a routed/all run keeps
136
+ // the full roster and can drive either engine.
137
+ const { engineOf, modelOf, usesOpencode, usesClaude } = buildEngineMap(config, explicitAgents ?? config.agents);
108
138
  const originalCwd = process.cwd();
109
139
  const readRoot = await resolveReadRoot(source, options.mode, progress);
110
140
  // Prepare auth BEFORE the chdir below: it doesn't depend on the working directory,
@@ -128,10 +158,21 @@ export async function runReview(source, options) {
128
158
  progress("Reviewing the PR-head tree (so reads match the PR, not the checkout).");
129
159
  process.chdir(readRoot.dir);
130
160
  }
131
- progress("Starting OpenCode server…");
132
- let handle = null;
161
+ const starting = [
162
+ usesClaude ? "Claude Code engine" : null,
163
+ usesOpencode ? "OpenCode server" : null,
164
+ ]
165
+ .filter(Boolean)
166
+ .join(" + ");
167
+ progress(`Starting ${starting}…`);
168
+ // Start each engine the run actually uses. OpenCode first so a claude failure can
169
+ // close it. Two separate try blocks keep the precise per-engine error messages.
170
+ let opencodeHandle = null;
171
+ let claudeHandle = null;
133
172
  try {
134
- handle = await startOpencode(buildOpencodeConfig(config));
173
+ if (usesOpencode) {
174
+ opencodeHandle = await startOpencode(buildOpencodeConfig(config));
175
+ }
135
176
  }
136
177
  catch (error) {
137
178
  await auth.cleanup();
@@ -139,11 +180,71 @@ export async function runReview(source, options) {
139
180
  throw new Error(`Failed to start the OpenCode server. Ensure the \`opencode\` CLI is installed and ` +
140
181
  `model credentials are configured (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
141
182
  }
142
- // Preflight: a model id the server can't resolve would otherwise fail EVERY pass
143
- // identically — N indistinguishable coverage gaps, after spending the run's budget
144
- // discovering the same fixable thing N times. Throw once, up front, naming the fix.
145
183
  try {
146
- await assertModelsResolvable(handle, [...config.agents.map((agent) => agent.model), config.coordinator.model], config.auth);
184
+ if (usesClaude) {
185
+ claudeHandle = await startClaudeCode(config);
186
+ }
187
+ }
188
+ catch (error) {
189
+ opencodeHandle?.close();
190
+ await auth.cleanup();
191
+ await restoreCwd();
192
+ throw new Error(`Failed to start the Claude Code engine. Ensure the \`claude\` CLI is installed and ` +
193
+ `logged into a Max/Team subscription (\`ecr doctor\` checks both).\n${errorMessage(error)}`);
194
+ }
195
+ // Build the single carrier handle so every downstream `handle` call is unchanged;
196
+ // per-agent dispatch happens inside via engineOf. When OpenCode is in use the
197
+ // carrier is the opencode handle (claude reached via `.claude`); a claude-only run
198
+ // uses the claude handle itself as the carrier (engineOf maps every id → claude).
199
+ const engineFn = (agent) => engineOf[agent] ?? "opencode";
200
+ let handle;
201
+ if (opencodeHandle) {
202
+ handle = opencodeHandle;
203
+ handle.claude = claudeHandle ?? undefined;
204
+ handle.engineOf = engineFn;
205
+ const closeOpencode = opencodeHandle.close;
206
+ handle.close = () => {
207
+ closeOpencode();
208
+ claudeHandle?.close(); // claude close is a noop, but keep the composition explicit
209
+ };
210
+ }
211
+ else {
212
+ handle = claudeHandle; // claude-only: the carrier is the claude handle itself
213
+ handle.engineOf = engineFn;
214
+ }
215
+ // The claude-code engine has no temperature control (the CLI exposes no flag), so
216
+ // surface a tuned-but-ignored temperature once, here, instead of letting the config
217
+ // divergence pass silently. Applies whenever any pass is claude-routed.
218
+ if (usesClaude) {
219
+ const note = claudeTemperatureNote(config, engineOf);
220
+ if (note) {
221
+ progress(`Note: ${note}`);
222
+ }
223
+ }
224
+ // Preflight: a model id an engine can't resolve would otherwise fail EVERY pass
225
+ // routed to it identically — N indistinguishable coverage gaps, after spending the
226
+ // run's budget rediscovering the same fixable thing. Throw once, up front, naming
227
+ // the fix. Split per engine so each engine only ever sees its own model subset: the
228
+ // OpenCode server never receives an anthropic-claude id (unknown-provider error) and
229
+ // assertClaudeModels never receives a non-anthropic id (its foreign-id throw).
230
+ const modelsFor = (eng) => [
231
+ ...new Set(Object.keys(engineOf)
232
+ .filter((id) => engineOf[id] === eng)
233
+ .map((id) => modelOf[id])),
234
+ ];
235
+ try {
236
+ if (opencodeHandle) {
237
+ const models = modelsFor("opencode");
238
+ if (models.length > 0) {
239
+ await assertModelsResolvable(opencodeHandle, models, config.auth);
240
+ }
241
+ }
242
+ if (claudeHandle) {
243
+ const models = modelsFor("claude-code");
244
+ if (models.length > 0) {
245
+ await assertModelsResolvable(claudeHandle, models, config.auth);
246
+ }
247
+ }
147
248
  }
148
249
  catch (error) {
149
250
  handle.close();
@@ -172,6 +273,10 @@ export async function runReview(source, options) {
172
273
  // log, reported in the log line, and surfaced as a coverage note when it happens.
173
274
  const agentModels = {};
174
275
  const substituted = new Set();
276
+ // The buckets whose model was substituted, so the coverage note can name each
277
+ // one's OWN engine (a mixed run may substitute on either side, for different
278
+ // reasons — a CLI usage-limit downgrade vs. OpenCode's silent default fallback).
279
+ const substitutedBuckets = new Set();
175
280
  const trackModel = (bucket, configured, actual) => {
176
281
  if (!actual) {
177
282
  return;
@@ -179,6 +284,7 @@ export async function runReview(source, options) {
179
284
  agentModels[bucket] = actual;
180
285
  if (configured && actual !== configured) {
181
286
  substituted.add(`${bucket}: configured ${configured}, ran ${actual}`);
287
+ substitutedBuckets.add(bucket);
182
288
  }
183
289
  };
184
290
  try {
@@ -448,10 +554,20 @@ export async function runReview(source, options) {
448
554
  for (const line of substituted) {
449
555
  progress(` ⚠ model substituted — ${line}`);
450
556
  }
557
+ const subEngines = new Set([...substitutedBuckets].map((bucket) => engineOf[bucket] ?? "opencode"));
558
+ const why = [];
559
+ if (subEngines.has(CLAUDE_CODE_ENGINE)) {
560
+ why.push("The Claude Code CLI answered with a different model than configured (usage-limit " +
561
+ "downgrades do this on a subscription), so these findings may come from a weaker " +
562
+ "model than intended — check the configured model ids and the subscription's limits.");
563
+ }
564
+ if (subEngines.has("opencode")) {
565
+ why.push("OpenCode silently falls back to a default model when the configured id is empty or " +
566
+ "unusable, so these findings may come from a different (possibly much weaker) model " +
567
+ "than intended — check the agents' `model`, `coordinator.model`, REVIEWER_MODEL, and the provider credential.");
568
+ }
451
569
  incomplete.push(`Some passes did not run on the configured model (${[...substituted].join("; ")}). ` +
452
- `OpenCode silently falls back to a default model when the configured id is empty or ` +
453
- `unusable, so these findings may come from a different (possibly much weaker) model ` +
454
- `than intended — check the agents' \`model\`, \`coordinator.model\`, REVIEWER_MODEL, and the provider credential.`);
570
+ why.join(" "));
455
571
  }
456
572
  // Note: routine noise filtering (lockfiles, generated, binary) is expected and
457
573
  // NOT a coverage gap — it stays in the run log (filteredFiles), not the
@@ -545,10 +661,22 @@ export async function runReview(source, options) {
545
661
  // Surface provider throttling as a fact about the run: passes already waited or
546
662
  // backed off, but the operator should still SEE that it happened (a run that
547
663
  // was rate-limited is slower and may carry partial passes — that's the cause).
548
- await handle.rateLimit.check();
549
- if (handle.rateLimit.events > 0) {
550
- progress(` ⚠ provider rate-limited this run ${handle.rateLimit.events} time(s) ` +
551
- `(429s in the OpenCode server log) — passes waited it out rather than failing`);
664
+ // Sum both engines' watches; name a cause only for an engine whose watch fired
665
+ // (claude events arrive via note() in runClaudePrompt; opencode via its server log).
666
+ await opencodeHandle?.rateLimit.check();
667
+ const rlOpencode = opencodeHandle?.rateLimit.events ?? 0;
668
+ const rlClaude = claudeHandle?.rateLimit.events ?? 0;
669
+ const rlTotal = rlOpencode + rlClaude;
670
+ if (rlTotal > 0) {
671
+ const causes = [];
672
+ if (rlClaude > 0) {
673
+ causes.push("subscription rate/usage limits reported by the Claude Code CLI");
674
+ }
675
+ if (rlOpencode > 0) {
676
+ causes.push("429s in the OpenCode server log");
677
+ }
678
+ progress(` ⚠ provider rate-limited this run ${rlTotal} time(s) (${causes.join("; ")}) ` +
679
+ `— passes waited it out rather than failing`);
552
680
  }
553
681
  // Every pass says which model actually answered it — in the job log, the step
554
682
  // summary table, and the run log — so a wrong or substituted model is always
@@ -570,7 +698,12 @@ export async function runReview(source, options) {
570
698
  agentFindings,
571
699
  coverageNotes,
572
700
  verifierDropped,
573
- ...(handle.rateLimit.events > 0 ? { rateLimitEvents: handle.rateLimit.events } : {}),
701
+ ...(rlTotal > 0
702
+ ? {
703
+ rateLimitEvents: rlTotal,
704
+ rateLimitByEngine: { opencode: rlOpencode, claudeCode: rlClaude },
705
+ }
706
+ : {}),
574
707
  durationMs: Date.now() - started,
575
708
  decision: output.decision,
576
709
  findingCount: output.findings.length,
@@ -595,7 +728,7 @@ export async function runReview(source, options) {
595
728
  throw error;
596
729
  }
597
730
  finally {
598
- handle?.close();
731
+ handle.close();
599
732
  await auth.cleanup();
600
733
  await restoreCwd();
601
734
  }
@@ -112,7 +112,12 @@ export function extractJsonObject(text) {
112
112
  lastError = error;
113
113
  }
114
114
  }
115
- throw new Error(`Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
115
+ throw new Error(lastError === undefined
116
+ ? // No candidate was even tried: the response held no {...} block at all —
117
+ // empty output, or prose/pseudo-tool-call text with no JSON in it.
118
+ `Could not extract JSON from model response: no JSON object found in ` +
119
+ `${text.trim() === "" ? "an EMPTY response" : `a ${text.length}-char response with no {...}`}`
120
+ : `Could not extract JSON from model response: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
116
121
  }
117
122
  /** The router's choice of which agent ids to run. */
118
123
  export const RouteOutputSchema = z.object({
@@ -1,4 +1,4 @@
1
- import { readdir, rm } from "node:fs/promises";
1
+ import { readdir, realpath, rm } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  /**
4
4
  * Ambient runtime configuration the OpenCode server (and the Claude-compatible
@@ -60,3 +60,61 @@ export async function scrubAmbientRuntimeConfig(root) {
60
60
  await walk(root);
61
61
  return removed.sort();
62
62
  }
63
+ /**
64
+ * Remove symlinks whose fully resolved target lies outside the materialized tree.
65
+ *
66
+ * The model runtime's read tools are path-scoped by the LITERAL path argument
67
+ * (buildClaudeArgs' permission rules, and equally OpenCode's project-root
68
+ * containment), but the fs layer underneath follows symlinks — so a PR-committed
69
+ * link (`docs/notes.md -> ~/.claude/.credentials.json`) passes the in-tree check
70
+ * and reads the out-of-tree target. Git can only materialize regular files,
71
+ * directories, and symlinks, so stripping escaping symlinks here closes the whole
72
+ * class.
73
+ *
74
+ * Fail closed: a link whose target cannot be resolved (broken, or a chain that
75
+ * leaves the tree at any hop) is removed too — the target could come into
76
+ * existence later, and a broken link has no legitimate review value. In-tree
77
+ * links survive (realpath resolves chains, so an in-tree alias of an in-tree
78
+ * file is provably contained). Unlike the config scrub, this walk descends into
79
+ * node_modules (a committed one is attacker content); `.git` stays skipped — in
80
+ * a worktree it is an ECR-created gitdir link, not PR content.
81
+ *
82
+ * Must only ever run on a tree ECR created and will delete. Returns the
83
+ * repo-relative paths removed so callers can log them.
84
+ */
85
+ export async function removeEscapingSymlinks(root) {
86
+ // realpath the boundary itself: tmpdir-based roots are often behind symlinks
87
+ // (macOS /var -> /private/var), and containment must compare resolved paths.
88
+ const boundary = await realpath(root);
89
+ const removed = [];
90
+ const walk = async (dir) => {
91
+ const entries = await readdir(dir, { withFileTypes: true });
92
+ for (const entry of entries) {
93
+ const full = path.join(dir, entry.name);
94
+ if (entry.isSymbolicLink()) {
95
+ let contained = false;
96
+ try {
97
+ const target = await realpath(full);
98
+ contained = target === boundary || target.startsWith(boundary + path.sep);
99
+ }
100
+ catch {
101
+ // Unresolvable link: leave `contained` false (fail closed).
102
+ }
103
+ if (!contained) {
104
+ await rm(full, { force: true });
105
+ // Relative to the RESOLVED boundary — the walk runs there, and the
106
+ // caller's `root` may itself sit behind a symlink (macOS /var).
107
+ removed.push(path.relative(boundary, full));
108
+ }
109
+ // In-tree directory links are kept but never descended: their contents
110
+ // are walked once via the real path, and descending would loop on cycles.
111
+ continue;
112
+ }
113
+ if (entry.isDirectory() && entry.name !== ".git") {
114
+ await walk(full);
115
+ }
116
+ }
117
+ };
118
+ await walk(boundary);
119
+ return removed.sort();
120
+ }
@@ -56,6 +56,16 @@ export class RateLimitWatch {
56
56
  constructor(file = opencodeLogFile()) {
57
57
  this.file = file;
58
58
  }
59
+ /**
60
+ * Record rate-limit evidence directly, for an engine that has no log file to
61
+ * scan (the Claude Code CLI surfaces limits per-invocation, not in a log the
62
+ * way the OpenCode server does). Feeds the same `events`/`recentlyLimited`
63
+ * signals `check()` would.
64
+ */
65
+ note(count = 1) {
66
+ this.events += count;
67
+ this.lastSeenAt = Date.now();
68
+ }
59
69
  /** Scan newly-appended log lines for rate-limit evidence. */
60
70
  async check() {
61
71
  try {
@@ -5,6 +5,23 @@ export function sleep(ms) {
5
5
  export function errorMessage(error) {
6
6
  return error instanceof Error ? error.message : String(error);
7
7
  }
8
+ /**
9
+ * A failure reason safe to post into a PUBLIC PR comment (see `ecr ci`'s failure
10
+ * notices). `run` (core/exec.ts) throws "Command failed: <cmd> <argv>\n<stderr>" (and
11
+ * "Command output exceeded …"), which embeds a subprocess's full command line, its
12
+ * stderr, and absolute runner paths — CI-internal detail of no use to a PR author and
13
+ * exactly the kind of thing that must not leak into an attacker-visible artifact. Those
14
+ * shapes collapse to a generic pointer at the workflow log; our own (argv/path-free)
15
+ * error messages pass through, since they are the actionable ones the fail-fast design
16
+ * means to surface. Every call site still writes the FULL reason to the job's stderr.
17
+ */
18
+ export function publicFailureReason(error) {
19
+ const message = errorMessage(error);
20
+ if (/^Command (failed|output exceeded)\b/.test(message)) {
21
+ return "a subprocess (gh, git, or the model CLI) failed — see the workflow logs for details";
22
+ }
23
+ return message;
24
+ }
8
25
  /** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
9
26
  export function normalizeCode(text) {
10
27
  return text.replace(/\s+/g, " ").trim().toLowerCase();
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { pathInside } from "./exec.js";
3
4
  import { parseVerdict } from "./schema.js";
4
5
  import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
5
6
  import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
@@ -50,9 +51,20 @@ export function matchEvidence(evidence, content) {
50
51
  }
51
52
  /** Read the cited file and grade the evidence against it (see matchEvidence). */
52
53
  async function evidencePresence(finding, cwd) {
54
+ // finding.file is an unconstrained, LLM-authored string produced over untrusted PR
55
+ // content, so a prompt-injected finding could point it at a host secret. path.resolve
56
+ // IGNORES cwd when finding.file is already absolute (e.g. ~/.claude/.credentials.json),
57
+ // and `..` segments escape upward — either would make this raw readFile reach outside
58
+ // the reviewed tree with the host user's privileges, and the present/absent grading
59
+ // would leak a content-oracle back into the review. Confine the read to cwd (the
60
+ // materialized PR-head tree); anything outside is uncheckable, never read.
61
+ const resolved = path.resolve(cwd, finding.file);
62
+ if (!pathInside(resolved, cwd)) {
63
+ return "unknown";
64
+ }
53
65
  let content;
54
66
  try {
55
- content = await readFile(path.resolve(cwd, finding.file), "utf8");
67
+ content = await readFile(resolved, "utf8");
56
68
  }
57
69
  catch {
58
70
  return "unknown";
@@ -1,12 +1,30 @@
1
1
  import { writeFile, mkdtemp, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
- import { run } from "../core/exec.js";
4
+ import { resolveTrustedTool, run } from "../core/exec.js";
5
5
  import { parseUnifiedDiff } from "../core/diff.js";
6
6
  import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
7
7
  import { fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
8
  import { appendStepSummary } from "../core/step-summary.js";
9
9
  const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
10
+ /**
11
+ * The reviewer's OWN marker comments, oldest-first: carrying the marker AND authored
12
+ * by `ownLogin`. The body marker alone is not identity — it defaults to a hardcoded,
13
+ * public literal and is readable in the base-branch config, so anyone who can comment
14
+ * on the PR (the untrusted PR author included) could post a comment carrying it plus a
15
+ * forged embedded review state; a newest-marker-wins lookup would then adopt that
16
+ * state and carry its `dismissed` list forward, silently suppressing real findings.
17
+ * GitHub sets a comment's author from the authenticated identity and it cannot be
18
+ * spoofed, so matching on author closes that. When `ownLogin` is null the author
19
+ * cannot be confirmed, so NOTHING is treated as ours (fail closed). Pure; exported for
20
+ * tests.
21
+ */
22
+ export function selectOwnComments(comments, marker, ownLogin) {
23
+ if (!ownLogin) {
24
+ return [];
25
+ }
26
+ return comments.filter((comment) => comment.body?.includes(marker) && comment.user?.login === ownLogin);
27
+ }
10
28
  /**
11
29
  * Maintains exactly one PR comment, updating it in place across re-reviews (and
12
30
  * cleaning up duplicates) so the review converges instead of churning. Runs the
@@ -15,10 +33,48 @@ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
15
33
  export class GitHubReporter {
16
34
  options;
17
35
  marker;
36
+ /** Memoized login of the account this reporter posts as (see resolveOwnLogin). */
37
+ ownLoginResolution;
18
38
  constructor(options) {
19
39
  this.options = options;
20
40
  this.marker = commentMarker(options.commentTag);
21
41
  }
42
+ /**
43
+ * The login of the account this reporter comments as, so its own comment is
44
+ * recognized by AUTHOR (see selectOwnComments for why the body marker is not enough).
45
+ * Resolution: `gh api user` (a user/PAT token), else the scaffolded workflow's default
46
+ * GITHUB_TOKEN identity, `github-actions[bot]`, when running under Actions — an
47
+ * installation token can't read `/user`. Null when neither is available, which makes
48
+ * selectOwnComments treat no comment as ours (fail closed). Memoized: the identity is
49
+ * stable for the process, and every reporter method consults it.
50
+ */
51
+ resolveOwnLogin() {
52
+ this.ownLoginResolution ??= (async () => {
53
+ try {
54
+ const gh = await resolveTrustedTool("gh");
55
+ const { stdout } = await run(gh, ["api", "user", "--jq", ".login"], {
56
+ cwd: this.options.cwd,
57
+ });
58
+ const login = stdout.trim();
59
+ if (login) {
60
+ return login;
61
+ }
62
+ }
63
+ catch {
64
+ // The default GITHUB_TOKEN is an installation token: `/user` returns 403.
65
+ }
66
+ return process.env.GITHUB_ACTIONS ? "github-actions[bot]" : null;
67
+ })();
68
+ return this.ownLoginResolution;
69
+ }
70
+ /** This reporter's own marker comments, author-verified (see selectOwnComments). */
71
+ async ownComments() {
72
+ const [comments, ownLogin] = await Promise.all([
73
+ this.fetchAllComments(),
74
+ this.resolveOwnLogin(),
75
+ ]);
76
+ return selectOwnComments(comments, this.marker, ownLogin);
77
+ }
22
78
  async checkBreakGlass() {
23
79
  const comments = await this.fetchAllComments();
24
80
  return comments.some((comment) => typeof comment.body === "string" &&
@@ -65,8 +121,9 @@ export class GitHubReporter {
65
121
  * reviewdog #1911 lesson).
66
122
  */
67
123
  async clear() {
68
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
69
- for (const comment of marked) {
124
+ // Only ever delete comments WE authored — never touch a look-alike posted by
125
+ // someone else (see selectOwnComments).
126
+ for (const comment of await this.ownComments()) {
70
127
  await this.deleteComment(comment.id);
71
128
  }
72
129
  }
@@ -88,7 +145,8 @@ export class GitHubReporter {
88
145
  await Promise.all([
89
146
  (async () => {
90
147
  try {
91
- const { stdout } = await run("gh", ["pr", "diff", ...prArgs], { cwd });
148
+ const gh = await resolveTrustedTool("gh");
149
+ const { stdout } = await run(gh, ["pr", "diff", ...prArgs], { cwd });
92
150
  link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
93
151
  }
94
152
  catch {
@@ -97,7 +155,8 @@ export class GitHubReporter {
97
155
  })(),
98
156
  (async () => {
99
157
  try {
100
- const { stdout } = await run("gh", ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
158
+ const gh = await resolveTrustedTool("gh");
159
+ const { stdout } = await run(gh, ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
101
160
  cwd,
102
161
  });
103
162
  const oid = JSON.parse(stdout).baseRefOid;
@@ -146,10 +205,10 @@ export class GitHubReporter {
146
205
  await this.patchComment(existing.id, body);
147
206
  return { dismissedCount: dismissed.length, matched, unmatched };
148
207
  }
149
- /** Newest reviewer-tagged comment (id + body), or null if none posted yet. */
208
+ /** Newest comment WE authored carrying our marker (id + body), or null if none. */
150
209
  async findExistingComment() {
151
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
152
- const keep = marked[marked.length - 1];
210
+ const own = await this.ownComments();
211
+ const keep = own[own.length - 1];
153
212
  return keep ? { id: keep.id, body: keep.body ?? "" } : null;
154
213
  }
155
214
  // Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
@@ -165,8 +224,9 @@ export class GitHubReporter {
165
224
  */
166
225
  async fetchAllComments() {
167
226
  const all = [];
227
+ const gh = await resolveTrustedTool("gh");
168
228
  for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
169
- const { stdout } = await run("gh", [
229
+ const { stdout } = await run(gh, [
170
230
  "api",
171
231
  "-X",
172
232
  "GET",
@@ -199,7 +259,10 @@ export class GitHubReporter {
199
259
  * is the newest and is the keeper.
200
260
  */
201
261
  async upsertComment(body) {
202
- const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
262
+ // Update/clean up only comments WE authored, never a look-alike posted by
263
+ // someone else (see selectOwnComments) — otherwise the newest forged marker
264
+ // comment would be adopted as "ours" and edited/patched in its place.
265
+ const marked = await this.ownComments();
203
266
  if (marked.length === 0) {
204
267
  await this.createComment(body);
205
268
  }
@@ -227,7 +290,8 @@ export class GitHubReporter {
227
290
  }
228
291
  }
229
292
  async createComment(body) {
230
- await this.withBodyFile(body, (jsonPath) => run("gh", [
293
+ const gh = await resolveTrustedTool("gh");
294
+ await this.withBodyFile(body, (jsonPath) => run(gh, [
231
295
  "api",
232
296
  "-X",
233
297
  "POST",
@@ -237,7 +301,8 @@ export class GitHubReporter {
237
301
  ], { cwd: this.options.cwd }));
238
302
  }
239
303
  async patchComment(commentId, body) {
240
- await this.withBodyFile(body, (jsonPath) => run("gh", [
304
+ const gh = await resolveTrustedTool("gh");
305
+ await this.withBodyFile(body, (jsonPath) => run(gh, [
241
306
  "api",
242
307
  "-X",
243
308
  "PATCH",
@@ -247,6 +312,7 @@ export class GitHubReporter {
247
312
  ], { cwd: this.options.cwd }));
248
313
  }
249
314
  async deleteComment(commentId) {
250
- await run("gh", ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
315
+ const gh = await resolveTrustedTool("gh");
316
+ await run(gh, ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
251
317
  }
252
318
  }