@expo/code-review-cli 0.3.0 → 0.4.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 (42) hide show
  1. package/README.md +183 -6
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +406 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +173 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +214 -0
  9. package/build/config/load.js +154 -52
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +116 -12
  12. package/build/core/auth.js +32 -29
  13. package/build/core/coordinator.js +5 -5
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +44 -44
  19. package/build/core/prompts.js +157 -148
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +147 -85
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +25 -25
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +6 -1
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +164 -0
  37. package/templates/coordinator.md +5 -3
  38. package/templates/dismiss.yml +110 -0
  39. package/templates/routing.jsonc +27 -0
  40. package/templates/scope-config.jsonc +25 -0
  41. package/templates/shared.md +12 -0
  42. package/templates/workflow.yml +50 -20
@@ -1,6 +1,6 @@
1
- import { readFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- const DIRECTIVE = 'expo-code-review-ignore';
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ const DIRECTIVE = "expo-code-review-ignore";
4
4
  /**
5
5
  * Deterministic backstop for the inline `expo-code-review-ignore` directive (which
6
6
  * was previously prompt-only, i.e. honored only if the model chose to). Drops a
@@ -20,7 +20,7 @@ export async function applyInlineIgnores(findings, cwd, onProgress) {
20
20
  kept.push(finding);
21
21
  continue;
22
22
  }
23
- if (finding.severity === 'critical' || finding.category === 'secrets') {
23
+ if (finding.severity === "critical" || finding.category === "secrets") {
24
24
  kept.push(finding);
25
25
  onProgress?.(` inline-ignore present but NOT honored for ${finding.severity}/${finding.category} "${finding.title}"`);
26
26
  }
@@ -40,8 +40,8 @@ async function hasDirectiveNear(finding, cwd, cache) {
40
40
  return false;
41
41
  }
42
42
  const idx = finding.line - 1; // 1-based → 0-based
43
- const flagged = lines[idx] ?? '';
44
- const above = idx > 0 ? (lines[idx - 1] ?? '') : '';
43
+ const flagged = lines[idx] ?? "";
44
+ const above = idx > 0 ? (lines[idx - 1] ?? "") : "";
45
45
  return flagged.includes(DIRECTIVE) || above.includes(DIRECTIVE);
46
46
  }
47
47
  async function readLines(file, cwd, cache) {
@@ -50,7 +50,7 @@ async function readLines(file, cwd, cache) {
50
50
  }
51
51
  let lines;
52
52
  try {
53
- lines = (await readFile(path.resolve(cwd, file), 'utf8')).split('\n');
53
+ lines = (await readFile(path.resolve(cwd, file), "utf8")).split("\n");
54
54
  }
55
55
  catch {
56
56
  lines = null;
@@ -1,16 +1,16 @@
1
1
  /** The OpenCode tool names the reviewer toggles. Single source of truth so the
2
2
  * agent and coordinator tool maps can't drift apart. */
3
3
  export const TOOL_NAMES = [
4
- 'read',
5
- 'grep',
6
- 'glob',
7
- 'list',
8
- 'bash',
9
- 'write',
10
- 'edit',
11
- 'patch',
4
+ "read",
5
+ "grep",
6
+ "glob",
7
+ "list",
8
+ "bash",
9
+ "write",
10
+ "edit",
11
+ "patch",
12
12
  ];
13
13
  /** Build a full tool map with only the listed tools enabled. */
14
14
  export function toolMap(enabled) {
15
- return Object.fromEntries(TOOL_NAMES.map(name => [name, enabled.includes(name)]));
15
+ return Object.fromEntries(TOOL_NAMES.map((name) => [name, enabled.includes(name)]));
16
16
  }
@@ -1,5 +1,5 @@
1
1
  export function sleep(ms) {
2
- return new Promise(resolve => setTimeout(resolve, ms));
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
3
  }
4
4
  /** Extract a human-readable message from an unknown thrown value. */
5
5
  export function errorMessage(error) {
@@ -7,5 +7,5 @@ export function errorMessage(error) {
7
7
  }
8
8
  /** Collapse whitespace + lowercase — for tolerant code matching / fingerprinting. */
9
9
  export function normalizeCode(text) {
10
- return text.replace(/\s+/g, ' ').trim().toLowerCase();
10
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
11
11
  }
@@ -1,9 +1,9 @@
1
- import { readFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { parseVerdict } from './schema.js';
4
- import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from './opencode.js';
5
- import { buildVerifierSystem, buildVerifierTask } from './prompts.js';
6
- import { errorMessage, normalizeCode } from './util.js';
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseVerdict } from "./schema.js";
4
+ import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
5
+ import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
6
+ import { errorMessage, normalizeCode } from "./util.js";
7
7
  // Verification runs after coordination (a serial tail step); keep it short. It
8
8
  // runs criticals in parallel, so this bounds the added latency regardless of count.
9
9
  const VERIFY_TIMEOUT_MS = 3 * 60 * 1000;
@@ -18,9 +18,9 @@ const MIN_EVIDENCE_LEN = 12;
18
18
  export function evidenceFragments(evidence) {
19
19
  return evidence
20
20
  .split(/\r?\n|…|\.\.\./)
21
- .map(line => line.replace(/^[+\-\s]*/, '').replace(/^(\/\/+|#+|\*+|\/\*)\s?/, ''))
21
+ .map((line) => line.replace(/^[+\-\s]*/, "").replace(/^(\/\/+|#+|\*+|\/\*)\s?/, ""))
22
22
  .map(normalizeCode)
23
- .filter(fragment => fragment.length >= MIN_EVIDENCE_LEN);
23
+ .filter((fragment) => fragment.length >= MIN_EVIDENCE_LEN);
24
24
  }
25
25
  /**
26
26
  * Does the finding's `evidence` correspond to code in the file?
@@ -36,28 +36,28 @@ export function evidenceFragments(evidence) {
36
36
  export function matchEvidence(evidence, content) {
37
37
  const normEvidence = normalizeCode(evidence);
38
38
  if (normEvidence.length < MIN_EVIDENCE_LEN) {
39
- return 'unknown';
39
+ return "unknown";
40
40
  }
41
41
  const normContent = normalizeCode(content);
42
42
  if (normContent.includes(normEvidence)) {
43
- return 'present';
43
+ return "present";
44
44
  }
45
45
  const fragments = evidenceFragments(evidence);
46
46
  if (fragments.length === 0) {
47
- return 'unknown';
47
+ return "unknown";
48
48
  }
49
- return fragments.some(fragment => normContent.includes(fragment)) ? 'present' : 'absent';
49
+ return fragments.some((fragment) => normContent.includes(fragment)) ? "present" : "absent";
50
50
  }
51
51
  /** Read the cited file and grade the evidence against it (see matchEvidence). */
52
52
  async function evidencePresence(finding, cwd) {
53
53
  let content;
54
54
  try {
55
- content = await readFile(path.resolve(cwd, finding.file), 'utf8');
55
+ content = await readFile(path.resolve(cwd, finding.file), "utf8");
56
56
  }
57
57
  catch {
58
- return 'unknown';
58
+ return "unknown";
59
59
  }
60
- return matchEvidence(finding.evidence ?? '', content);
60
+ return matchEvidence(finding.evidence ?? "", content);
61
61
  }
62
62
  /**
63
63
  * Guard against hallucinated findings before they're surfaced, WITHOUT silently
@@ -88,20 +88,20 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
88
88
  const verdicts = new Map();
89
89
  const toVerify = [];
90
90
  for (const { finding, presence } of checked) {
91
- if (presence === 'absent' || finding.severity === 'critical') {
91
+ if (presence === "absent" || finding.severity === "critical") {
92
92
  toVerify.push({ finding, presence });
93
93
  }
94
94
  else {
95
- verdicts.set(finding, 'keep'); // grounded (or uncheckable) non-critical
95
+ verdicts.set(finding, "keep"); // grounded (or uncheckable) non-critical
96
96
  }
97
97
  }
98
98
  // Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
99
99
  await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
100
100
  try {
101
- const { value, cost: verifyCost, tokens: verifyTokens } = await promptAndParse(handle, {
101
+ const { value, cost: verifyCost, tokens: verifyTokens, } = await promptAndParse(handle, {
102
102
  agent: VERIFIER_AGENT,
103
103
  system: buildVerifierSystem(),
104
- text: buildVerifierTask(finding, { evidenceUngrounded: presence === 'absent' }),
104
+ text: buildVerifierTask(finding, { evidenceUngrounded: presence === "absent" }),
105
105
  title: `verify-${index}`,
106
106
  maxWaitMs: VERIFY_TIMEOUT_MS,
107
107
  finalizeOnTimeout: true,
@@ -109,21 +109,21 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
109
109
  cost += verifyCost;
110
110
  addTokenUsage(tokens, verifyTokens);
111
111
  if (value.verified) {
112
- verdicts.set(finding, 'keep');
112
+ verdicts.set(finding, "keep");
113
113
  }
114
114
  else {
115
- verdicts.set(finding, 'drop');
116
- dropped.push({ finding, reason: value.reason || 'refuted by verifier' });
117
- onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason || 'refuted by verifier'}`);
115
+ verdicts.set(finding, "drop");
116
+ dropped.push({ finding, reason: value.reason || "refuted by verifier" });
117
+ onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason || "refuted by verifier"}`);
118
118
  }
119
119
  }
120
120
  catch (error) {
121
121
  // Fail open: keep the finding if verification itself failed.
122
- verdicts.set(finding, 'keep');
122
+ verdicts.set(finding, "keep");
123
123
  onProgress?.(` verify: could not verify "${finding.title}" (${errorMessage(error)}); keeping it`);
124
124
  }
125
125
  }));
126
126
  // Preserve original order.
127
- const kept = findings.filter(finding => verdicts.get(finding) === 'keep');
127
+ const kept = findings.filter((finding) => verdicts.get(finding) === "keep");
128
128
  return { kept, dropped, cost, tokens };
129
129
  }
@@ -1,11 +1,12 @@
1
- import { writeFile, mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import path from 'node:path';
4
- import { run } from '../core/exec.js';
5
- import { parseUnifiedDiff } from '../core/diff.js';
6
- import { buildDiffLineIndex, commentMarker, parseReviewState, renderMarkdown } from '../core/render.js';
7
- import { fingerprintFinding } from '../core/schema.js';
8
- const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
1
+ import { writeFile, mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { run } from "../core/exec.js";
5
+ import { parseUnifiedDiff } from "../core/diff.js";
6
+ import { buildDiffLineIndex, commentMarker, parseReviewState, renderAggregateMarkdown, renderMarkdown, } from "../core/render.js";
7
+ import { fingerprintFinding, scopedFingerprint } from "../core/schema.js";
8
+ import { appendStepSummary } from "../core/step-summary.js";
9
+ const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
9
10
  /**
10
11
  * Maintains exactly one PR comment, updating it in place across re-reviews (and
11
12
  * cleaning up duplicates) so the review converges instead of churning. Runs the
@@ -20,9 +21,9 @@ export class GitHubReporter {
20
21
  }
21
22
  async checkBreakGlass() {
22
23
  const comments = await this.fetchAllComments();
23
- return comments.some(comment => typeof comment.body === 'string' &&
24
+ return comments.some((comment) => typeof comment.body === "string" &&
24
25
  comment.body.includes(this.options.breakGlassMarker) &&
25
- MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ''));
26
+ MAINTAINER_ASSOCIATIONS.has(comment.author_association ?? ""));
26
27
  }
27
28
  async postSkipNote() {
28
29
  await this.upsertComment(`${this.marker}\n🤖 AI review skipped via \`${this.options.breakGlassMarker}\`.`);
@@ -37,6 +38,38 @@ export class GitHubReporter {
37
38
  const link = await this.linkContextAsync();
38
39
  await this.upsertComment(renderMarkdown(review, this.options.commentTag, dismissed, link));
39
40
  }
41
+ /** Post/update the aggregate multi-scope comment (comment:'single' mode). */
42
+ async reportAggregate(results, unmatchedFiles) {
43
+ const existing = await this.findExistingComment();
44
+ const dismissed = existing
45
+ ? (parseReviewState(existing.body, this.options.commentTag)?.dismissed ?? [])
46
+ : [];
47
+ const link = await this.linkContextAsync();
48
+ await this.upsertComment(renderAggregateMarkdown(results, this.options.commentTag, dismissed, link, {
49
+ unmatchedFiles,
50
+ }));
51
+ }
52
+ /**
53
+ * The embedded review state of the existing reviewer comment, or null when no
54
+ * comment (or no parseable state) exists. A partial ci run (--scopes) uses this
55
+ * to carry the non-rerun scopes' previous results into the new aggregate.
56
+ */
57
+ async readState() {
58
+ const existing = await this.findExistingComment();
59
+ return existing ? parseReviewState(existing.body, this.options.commentTag) : null;
60
+ }
61
+ /**
62
+ * Delete every comment carrying THIS reporter's full marker (stale-scope cleanup /
63
+ * mode switch). Only ever touches its own marker — `<!-- tag -->` is not a substring
64
+ * of `<!-- tag:scope -->`, so root vs scoped markers can't cross-match (the
65
+ * reviewdog #1911 lesson).
66
+ */
67
+ async clear() {
68
+ const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
69
+ for (const comment of marked) {
70
+ await this.deleteComment(comment.id);
71
+ }
72
+ }
40
73
  /**
41
74
  * PR context for turning finding locations into links: the set of lines actually
42
75
  * in the diff (for in-diff findings → diff-anchor links) and the base commit SHA
@@ -44,13 +77,18 @@ export class GitHubReporter {
44
77
  * soft — a missing piece just degrades to a plain-text location, never a dead link.
45
78
  */
46
79
  async linkContextAsync() {
80
+ // A prebuilt context (ci fan-out, one fetch shared across scopes) wins — skip
81
+ // the two `gh` calls entirely.
82
+ if (this.options.linkContext) {
83
+ return this.options.linkContext;
84
+ }
47
85
  const link = { repo: this.options.repo, prNumber: this.options.prNumber };
48
- const prArgs = [String(this.options.prNumber), '--repo', this.options.repo];
86
+ const prArgs = [String(this.options.prNumber), "--repo", this.options.repo];
49
87
  const cwd = this.options.cwd;
50
88
  await Promise.all([
51
89
  (async () => {
52
90
  try {
53
- const { stdout } = await run('gh', ['pr', 'diff', ...prArgs], { cwd });
91
+ const { stdout } = await run("gh", ["pr", "diff", ...prArgs], { cwd });
54
92
  link.diffLines = buildDiffLineIndex(parseUnifiedDiff(stdout));
55
93
  }
56
94
  catch {
@@ -59,7 +97,9 @@ export class GitHubReporter {
59
97
  })(),
60
98
  (async () => {
61
99
  try {
62
- const { stdout } = await run('gh', ['pr', 'view', ...prArgs, '--json', 'baseRefOid'], { cwd });
100
+ const { stdout } = await run("gh", ["pr", "view", ...prArgs, "--json", "baseRefOid"], {
101
+ cwd,
102
+ });
63
103
  const oid = JSON.parse(stdout).baseRefOid;
64
104
  if (oid) {
65
105
  link.baseSha = oid;
@@ -79,30 +119,38 @@ export class GitHubReporter {
79
119
  async applyDismissal(add, remove, by, reason) {
80
120
  const existing = await this.findExistingComment();
81
121
  if (!existing) {
82
- throw new Error('No reviewer comment found on this PR yet — run a review first.');
122
+ throw new Error("No reviewer comment found on this PR yet — run a review first.");
83
123
  }
84
124
  const state = parseReviewState(existing.body, this.options.commentTag);
85
125
  if (!state) {
86
- throw new Error('The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.');
126
+ throw new Error("The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.");
87
127
  }
88
- const validFps = new Set(state.review.findings.map(fingerprintFinding));
89
- const matched = add.filter(fp => validFps.has(fp));
90
- const unmatched = add.filter(fp => !validFps.has(fp));
91
- let dismissed = state.dismissed.filter(record => !remove.includes(record.fp));
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
+ 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));
92
137
  for (const fp of matched) {
93
- if (!dismissed.some(record => record.fp === fp)) {
138
+ if (!dismissed.some((record) => record.fp === fp)) {
94
139
  dismissed.push({ fp, by, reason });
95
140
  }
96
141
  }
97
142
  const link = await this.linkContextAsync();
98
- await this.patchComment(existing.id, renderMarkdown(state.review, this.options.commentTag, dismissed, link));
143
+ const body = isAggregate
144
+ ? renderAggregateMarkdown(state.scopes, this.options.commentTag, dismissed, link)
145
+ : renderMarkdown(state.review, this.options.commentTag, dismissed, link);
146
+ await this.patchComment(existing.id, body);
99
147
  return { dismissedCount: dismissed.length, matched, unmatched };
100
148
  }
101
149
  /** Newest reviewer-tagged comment (id + body), or null if none posted yet. */
102
150
  async findExistingComment() {
103
- const marked = (await this.fetchAllComments()).filter(comment => comment.body?.includes(this.marker));
151
+ const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
104
152
  const keep = marked[marked.length - 1];
105
- return keep ? { id: keep.id, body: keep.body ?? '' } : null;
153
+ return keep ? { id: keep.id, body: keep.body ?? "" } : null;
106
154
  }
107
155
  // Safety cap on pagination (100/page): 30 pages = 3000 comments. Bounds a
108
156
  // pathological PR; virtually every real PR exits far earlier.
@@ -118,14 +166,14 @@ export class GitHubReporter {
118
166
  async fetchAllComments() {
119
167
  const all = [];
120
168
  for (let page = 1; page <= GitHubReporter.MAX_COMMENT_PAGES; page++) {
121
- const { stdout } = await run('gh', [
122
- 'api',
123
- '-X',
124
- 'GET',
169
+ const { stdout } = await run("gh", [
170
+ "api",
171
+ "-X",
172
+ "GET",
125
173
  `repos/${this.options.repo}/issues/${this.options.prNumber}/comments`,
126
- '-f',
127
- 'per_page=100',
128
- '-f',
174
+ "-f",
175
+ "per_page=100",
176
+ "-f",
129
177
  `page=${page}`,
130
178
  ], { cwd: this.options.cwd });
131
179
  let batch;
@@ -151,23 +199,27 @@ export class GitHubReporter {
151
199
  * is the newest and is the keeper.
152
200
  */
153
201
  async upsertComment(body) {
154
- const marked = (await this.fetchAllComments()).filter(comment => comment.body?.includes(this.marker));
202
+ const marked = (await this.fetchAllComments()).filter((comment) => comment.body?.includes(this.marker));
155
203
  if (marked.length === 0) {
156
204
  await this.createComment(body);
157
- return;
158
205
  }
159
- const keep = marked[marked.length - 1];
160
- const duplicates = marked.slice(0, -1);
161
- await this.patchComment(keep.id, body);
162
- for (const duplicate of duplicates) {
163
- await this.deleteComment(duplicate.id);
206
+ else {
207
+ const keep = marked[marked.length - 1];
208
+ const duplicates = marked.slice(0, -1);
209
+ await this.patchComment(keep.id, body);
210
+ for (const duplicate of duplicates) {
211
+ await this.deleteComment(duplicate.id);
212
+ }
164
213
  }
214
+ // Mirror the exact posted body into the Actions step summary: the PR comment
215
+ // is upserted in place, so this is the only per-run record of what was posted.
216
+ await appendStepSummary(`### 🤖 AI review — posted comment\n\n${body}`);
165
217
  }
166
218
  async withBodyFile(body, fn) {
167
- const dir = await mkdtemp(path.join(tmpdir(), 'ecr-'));
168
- const jsonPath = path.join(dir, 'comment.json');
219
+ const dir = await mkdtemp(path.join(tmpdir(), "ecr-"));
220
+ const jsonPath = path.join(dir, "comment.json");
169
221
  try {
170
- await writeFile(jsonPath, JSON.stringify({ body }), 'utf8');
222
+ await writeFile(jsonPath, JSON.stringify({ body }), "utf8");
171
223
  return await fn(jsonPath);
172
224
  }
173
225
  finally {
@@ -175,26 +227,26 @@ export class GitHubReporter {
175
227
  }
176
228
  }
177
229
  async createComment(body) {
178
- await this.withBodyFile(body, jsonPath => run('gh', [
179
- 'api',
180
- '-X',
181
- 'POST',
230
+ await this.withBodyFile(body, (jsonPath) => run("gh", [
231
+ "api",
232
+ "-X",
233
+ "POST",
182
234
  `repos/${this.options.repo}/issues/${this.options.prNumber}/comments`,
183
- '--input',
235
+ "--input",
184
236
  jsonPath,
185
237
  ], { cwd: this.options.cwd }));
186
238
  }
187
239
  async patchComment(commentId, body) {
188
- await this.withBodyFile(body, jsonPath => run('gh', [
189
- 'api',
190
- '-X',
191
- 'PATCH',
240
+ await this.withBodyFile(body, (jsonPath) => run("gh", [
241
+ "api",
242
+ "-X",
243
+ "PATCH",
192
244
  `repos/${this.options.repo}/issues/comments/${commentId}`,
193
- '--input',
245
+ "--input",
194
246
  jsonPath,
195
247
  ], { cwd: this.options.cwd }));
196
248
  }
197
249
  async deleteComment(commentId) {
198
- await run('gh', ['api', '-X', 'DELETE', `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
250
+ await run("gh", ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
199
251
  }
200
252
  }
@@ -1,6 +1,6 @@
1
- import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings, } from '../core/render.js';
2
- import { SEVERITIES } from '../core/schema.js';
3
- const ESC = '';
1
+ import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings } from "../core/render.js";
2
+ import { SEVERITIES } from "../core/schema.js";
3
+ const ESC = "";
4
4
  const RESET = `${ESC}[0m`;
5
5
  const BOLD = `${ESC}[1m`;
6
6
  const DIM = `${ESC}[2m`;
@@ -10,9 +10,9 @@ const COLORS = {
10
10
  suggestion: `${ESC}[36m`,
11
11
  };
12
12
  const SEVERITY_LABEL = {
13
- critical: 'CRITICAL',
14
- warning: 'WARNING',
15
- suggestion: 'SUGGESTION',
13
+ critical: "CRITICAL",
14
+ warning: "WARNING",
15
+ suggestion: "SUGGESTION",
16
16
  };
17
17
  /**
18
18
  * Prints a human-readable summary grouped by severity; honors --json; never
@@ -38,19 +38,19 @@ export class TerminalReporter {
38
38
  process.exitCode = this.options.noFail ? 0 : decisionExitCode(review.decision);
39
39
  }
40
40
  renderPretty(review) {
41
- const out = [''];
41
+ const out = [""];
42
42
  out.push(this.paint(BOLD, `AI code review — ${decisionLabel(review.decision)}`));
43
- out.push(this.tally(review.findings), '');
44
- out.push(review.summary, '');
43
+ out.push(this.tally(review.findings), "");
44
+ out.push(review.summary, "");
45
45
  if (review.incomplete.length > 0) {
46
- out.push(this.paint(BOLD, '⏱️ Coverage note: some passes did not finish (partial coverage):'));
46
+ out.push(this.paint(BOLD, "⏱️ Coverage note: some passes did not finish (partial coverage):"));
47
47
  for (const note of review.incomplete) {
48
48
  out.push(this.paint(DIM, ` - ${note}`));
49
49
  }
50
- out.push('');
50
+ out.push("");
51
51
  }
52
52
  if (review.findings.length === 0) {
53
- out.push(this.paint(DIM, 'No findings.'), '');
53
+ out.push(this.paint(DIM, "No findings."), "");
54
54
  }
55
55
  else {
56
56
  const groups = groupBySeverity(sortFindings(review.findings));
@@ -59,21 +59,21 @@ export class TerminalReporter {
59
59
  if (findings.length === 0) {
60
60
  continue;
61
61
  }
62
- out.push(this.paint(`${BOLD}${COLORS[severity]}`, `${SEVERITY_LABEL[severity]} (${findings.length})`), '');
62
+ out.push(this.paint(`${BOLD}${COLORS[severity]}`, `${SEVERITY_LABEL[severity]} (${findings.length})`), "");
63
63
  for (const finding of findings) {
64
64
  out.push(this.renderFinding(finding));
65
65
  }
66
66
  }
67
67
  }
68
- return `${out.join('\n')}\n`;
68
+ return `${out.join("\n")}\n`;
69
69
  }
70
70
  /** One-line count headline, e.g. "2 critical · 5 warning". */
71
71
  tally(findings) {
72
- const parts = SEVERITIES.map(severity => {
73
- const n = findings.filter(finding => finding.severity === severity).length;
72
+ const parts = SEVERITIES.map((severity) => {
73
+ const n = findings.filter((finding) => finding.severity === severity).length;
74
74
  return n > 0 ? this.paint(COLORS[severity], `${n} ${severity}`) : null;
75
75
  }).filter((part) => part !== null);
76
- return parts.length > 0 ? parts.join(this.paint(DIM, ' · ')) : this.paint(DIM, 'no findings');
76
+ return parts.length > 0 ? parts.join(this.paint(DIM, " · ")) : this.paint(DIM, "no findings");
77
77
  }
78
78
  renderFinding(finding) {
79
79
  const loc = finding.line != null ? `${finding.file}:${finding.line}` : finding.file;
@@ -83,9 +83,9 @@ export class TerminalReporter {
83
83
  ` ${finding.rationale}`,
84
84
  ];
85
85
  if (finding.suggestion) {
86
- lines.push(` ${this.paint(DIM, 'Suggestion:')} ${finding.suggestion}`);
86
+ lines.push(` ${this.paint(DIM, "Suggestion:")} ${finding.suggestion}`);
87
87
  }
88
- return `${lines.join('\n')}\n`;
88
+ return `${lines.join("\n")}\n`;
89
89
  }
90
90
  paint(codes, text) {
91
91
  return this.color ? `${codes}${text}${RESET}` : text;
@@ -1,8 +1,8 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import path from 'node:path';
4
- import { run } from '../core/exec.js';
5
- import { parseUnifiedDiff } from '../core/diff.js';
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { run } from "../core/exec.js";
5
+ import { parseUnifiedDiff } from "../core/diff.js";
6
6
  /**
7
7
  * Pulls PR diff + metadata through the `gh` CLI, which is preinstalled and
8
8
  * authenticated on GitHub Actions runners via GH_TOKEN.
@@ -13,27 +13,27 @@ export class GitHubPRSource {
13
13
  this.options = options;
14
14
  }
15
15
  repoArgs() {
16
- return this.options.repo ? ['--repo', this.options.repo] : [];
16
+ return this.options.repo ? ["--repo", this.options.repo] : [];
17
17
  }
18
18
  async getMetadata() {
19
- const { stdout } = await run('gh', [
20
- 'pr',
21
- 'view',
19
+ const { stdout } = await run("gh", [
20
+ "pr",
21
+ "view",
22
22
  String(this.options.prNumber),
23
23
  ...this.repoArgs(),
24
- '--json',
25
- 'title,body,baseRefName,headRefName',
24
+ "--json",
25
+ "title,body,baseRefName,headRefName",
26
26
  ], { cwd: this.options.cwd });
27
27
  const parsed = JSON.parse(stdout);
28
28
  return {
29
- title: parsed.title ?? '',
30
- body: parsed.body ?? '',
31
- baseRef: parsed.baseRefName ?? '',
32
- headRef: parsed.headRefName ?? '',
29
+ title: parsed.title ?? "",
30
+ body: parsed.body ?? "",
31
+ baseRef: parsed.baseRefName ?? "",
32
+ headRef: parsed.headRefName ?? "",
33
33
  };
34
34
  }
35
35
  async getChangedFiles() {
36
- const { stdout } = await run('gh', ['pr', 'diff', String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
36
+ const { stdout } = await run("gh", ["pr", "diff", String(this.options.prNumber), ...this.repoArgs()], { cwd: this.options.cwd });
37
37
  return parseUnifiedDiff(stdout);
38
38
  }
39
39
  /**
@@ -54,16 +54,16 @@ export class GitHubPRSource {
54
54
  const ref = `refs/pull/${this.options.prNumber}/head`;
55
55
  let parent;
56
56
  try {
57
- await run('git', ['fetch', '--no-tags', '--depth=1', url, ref], { cwd });
58
- parent = await mkdtemp(path.join(tmpdir(), 'ecr-prhead-'));
59
- const dir = path.join(parent, 'head'); // must not pre-exist for `worktree add`
60
- await run('git', ['worktree', 'add', '--detach', dir, 'FETCH_HEAD'], { cwd });
57
+ await run("git", ["fetch", "--no-tags", "--depth=1", url, ref], { cwd });
58
+ parent = await mkdtemp(path.join(tmpdir(), "ecr-prhead-"));
59
+ const dir = path.join(parent, "head"); // must not pre-exist for `worktree add`
60
+ await run("git", ["worktree", "add", "--detach", dir, "FETCH_HEAD"], { cwd });
61
61
  const removeParent = parent;
62
62
  return {
63
63
  dir,
64
64
  cleanup: async () => {
65
65
  try {
66
- await run('git', ['worktree', 'remove', '--force', dir], { cwd });
66
+ await run("git", ["worktree", "remove", "--force", dir], { cwd });
67
67
  }
68
68
  catch {
69
69
  // best effort — fall through to removing the temp dir