@expo/code-review-cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +307 -47
- package/build/cli.js +24 -17
- package/build/commands/ci.js +410 -43
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +219 -26
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +118 -30
- package/build/commands/verify-config.js +252 -0
- package/build/config/load.js +200 -55
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +153 -19
- package/build/core/auth.js +237 -75
- package/build/core/coordinator.js +7 -7
- package/build/core/diff.js +19 -19
- package/build/core/exec.js +10 -10
- package/build/core/log.js +3 -3
- package/build/core/noise.js +52 -52
- package/build/core/opencode.js +495 -95
- package/build/core/prompts.js +220 -150
- package/build/core/render.js +202 -48
- package/build/core/review.js +277 -102
- package/build/core/router.js +10 -10
- package/build/core/schema.js +26 -12
- package/build/core/step-summary.js +18 -0
- package/build/core/suppress.js +7 -7
- package/build/core/tools.js +9 -9
- package/build/core/util.js +2 -2
- package/build/core/verify.js +28 -26
- package/build/reporters/github.js +103 -51
- package/build/reporters/terminal.js +19 -19
- package/build/sources/github-pr.js +21 -21
- package/build/sources/local-git.js +20 -20
- package/build/sources/source.js +35 -1
- package/package.json +8 -3
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +167 -0
- package/templates/config.jsonc +26 -13
- package/templates/coordinator.md +5 -3
- package/templates/dismiss.yml +110 -0
- package/templates/routing.jsonc +27 -0
- package/templates/scope-config.jsonc +25 -0
- package/templates/shared.md +12 -0
- package/templates/workflow.yml +61 -26
package/build/core/router.js
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { promptAndParse } from
|
|
2
|
-
import { buildRouterSystem, buildRouterTask } from
|
|
3
|
-
import { parseRouteOutput } from
|
|
1
|
+
import { promptAndParse } from "./opencode.js";
|
|
2
|
+
import { buildRouterSystem, buildRouterTask } from "./prompts.js";
|
|
3
|
+
import { parseRouteOutput } from "./schema.js";
|
|
4
4
|
/**
|
|
5
5
|
* Ask the model which agents are relevant to the changed files. Agents marked
|
|
6
6
|
* `alwaysRun` are unioned in regardless. Falls back to ALL agents if the router
|
|
7
7
|
* returns nothing usable or errors — a review must never run with zero agents.
|
|
8
8
|
*/
|
|
9
9
|
export async function routeAgents(handle, config, files) {
|
|
10
|
-
const always = config.agents.filter(agent => agent.alwaysRun);
|
|
10
|
+
const always = config.agents.filter((agent) => agent.alwaysRun);
|
|
11
11
|
try {
|
|
12
12
|
const { value } = await promptAndParse(handle, {
|
|
13
|
-
agent:
|
|
13
|
+
agent: "coordinator",
|
|
14
14
|
system: buildRouterSystem(),
|
|
15
15
|
text: buildRouterTask(config.agents, files),
|
|
16
|
-
title:
|
|
16
|
+
title: "route",
|
|
17
17
|
}, parseRouteOutput);
|
|
18
|
-
const byId = new Map(config.agents.map(agent => [agent.id, agent]));
|
|
18
|
+
const byId = new Map(config.agents.map((agent) => [agent.id, agent]));
|
|
19
19
|
const picked = value.agents
|
|
20
|
-
.map(id => byId.get(id))
|
|
20
|
+
.map((id) => byId.get(id))
|
|
21
21
|
.filter((agent) => Boolean(agent));
|
|
22
|
-
const chosenIds = new Set([...picked, ...always].map(agent => agent.id));
|
|
22
|
+
const chosenIds = new Set([...picked, ...always].map((agent) => agent.id));
|
|
23
23
|
// Preserve config order and dedupe.
|
|
24
|
-
const chosen = config.agents.filter(agent => chosenIds.has(agent.id));
|
|
24
|
+
const chosen = config.agents.filter((agent) => chosenIds.has(agent.id));
|
|
25
25
|
if (chosen.length === 0) {
|
|
26
26
|
return { agents: config.agents, routed: false };
|
|
27
27
|
}
|
package/build/core/schema.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { createHash } from
|
|
2
|
-
import { z } from
|
|
3
|
-
import { normalizeCode } from
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { normalizeCode } from "./util.js";
|
|
4
4
|
/** Severity levels, ordered most→least severe for sorting/rendering. */
|
|
5
|
-
export const SEVERITIES = [
|
|
5
|
+
export const SEVERITIES = ["critical", "warning", "suggestion"];
|
|
6
6
|
/** Sort rank for severities (0 = most severe). Single source of truth. */
|
|
7
7
|
export const SEVERITY_RANK = { critical: 0, warning: 1, suggestion: 2 };
|
|
8
|
-
export const CATEGORIES = [
|
|
9
|
-
export const DECISIONS = [
|
|
8
|
+
export const CATEGORIES = ["correctness", "quality", "security", "secrets"];
|
|
9
|
+
export const DECISIONS = ["approve", "approve_with_comments", "request_changes"];
|
|
10
10
|
export const FindingSchema = z.object({
|
|
11
11
|
severity: z.enum(SEVERITIES),
|
|
12
12
|
category: z.enum(CATEGORIES),
|
|
@@ -25,7 +25,7 @@ export const FindingSchema = z.object({
|
|
|
25
25
|
/** A verifier's verdict on whether a finding is real (adversarial refute pass). */
|
|
26
26
|
export const VerdictSchema = z.object({
|
|
27
27
|
verified: z.boolean(),
|
|
28
|
-
reason: z.string().default(
|
|
28
|
+
reason: z.string().default(""),
|
|
29
29
|
});
|
|
30
30
|
export function parseVerdict(text) {
|
|
31
31
|
return VerdictSchema.parse(extractJsonObject(text));
|
|
@@ -60,10 +60,24 @@ const MIN_FP_EVIDENCE_LEN = 12;
|
|
|
60
60
|
* there's too little evidence to key on.
|
|
61
61
|
*/
|
|
62
62
|
export function fingerprintFinding(finding) {
|
|
63
|
-
const evidence = normalizeCode(finding.evidence ??
|
|
63
|
+
const evidence = normalizeCode(finding.evidence ?? "");
|
|
64
64
|
const key = evidence.length >= MIN_FP_EVIDENCE_LEN ? evidence : normalizeCode(finding.title);
|
|
65
|
-
const normalized = [
|
|
66
|
-
return createHash(
|
|
65
|
+
const normalized = ["v2", finding.file, finding.category, key].join("|");
|
|
66
|
+
return createHash("sha1").update(normalized).digest("hex").slice(0, 12);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Namespace a finding's fingerprint by scope so cross-scope dismissals never
|
|
70
|
+
* collide. The DEFAULT scope (config '.') passes `null` and keeps the plain
|
|
71
|
+
* fingerprintFinding value, so pre-routing dismissal state carries over unchanged
|
|
72
|
+
* (risk 9). Non-default scopes hash into the same hex alphabet the dismiss command
|
|
73
|
+
* sanitizes to (dismiss.ts strips /[^a-f0-9]/), at the same length.
|
|
74
|
+
*/
|
|
75
|
+
export function scopedFingerprint(scopeName, finding) {
|
|
76
|
+
const fp = fingerprintFinding(finding);
|
|
77
|
+
if (!scopeName) {
|
|
78
|
+
return fp;
|
|
79
|
+
}
|
|
80
|
+
return createHash("sha1").update(`scope|${scopeName}|${fp}`).digest("hex").slice(0, fp.length);
|
|
67
81
|
}
|
|
68
82
|
/**
|
|
69
83
|
* Extract the JSON payload from an LLM response. Prefers the last fenced
|
|
@@ -76,8 +90,8 @@ export function extractJsonObject(text) {
|
|
|
76
90
|
if (fenceMatches.length > 0) {
|
|
77
91
|
candidates.push(fenceMatches[fenceMatches.length - 1][1].trim());
|
|
78
92
|
}
|
|
79
|
-
const firstBrace = text.indexOf(
|
|
80
|
-
const lastBrace = text.lastIndexOf(
|
|
93
|
+
const firstBrace = text.indexOf("{");
|
|
94
|
+
const lastBrace = text.lastIndexOf("}");
|
|
81
95
|
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
82
96
|
candidates.push(text.slice(firstBrace, lastBrace + 1));
|
|
83
97
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { appendFile } from "node:fs/promises";
|
|
2
|
+
/**
|
|
3
|
+
* Append a markdown section to the GitHub Actions step summary, so a run's
|
|
4
|
+
* output survives on the workflow-run page after the PR comment is upserted
|
|
5
|
+
* away by the next run. No-op outside Actions (GITHUB_STEP_SUMMARY unset).
|
|
6
|
+
*/
|
|
7
|
+
export async function appendStepSummary(markdown) {
|
|
8
|
+
const file = process.env.GITHUB_STEP_SUMMARY;
|
|
9
|
+
if (!file) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
await appendFile(file, `${markdown}\n\n`, "utf8");
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Observability must never break a review.
|
|
17
|
+
}
|
|
18
|
+
}
|
package/build/core/suppress.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readFile } from
|
|
2
|
-
import path from
|
|
3
|
-
const DIRECTIVE =
|
|
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 ===
|
|
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),
|
|
53
|
+
lines = (await readFile(path.resolve(cwd, file), "utf8")).split("\n");
|
|
54
54
|
}
|
|
55
55
|
catch {
|
|
56
56
|
lines = null;
|
package/build/core/tools.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
}
|
package/build/core/util.js
CHANGED
|
@@ -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,
|
|
10
|
+
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
11
11
|
}
|
package/build/core/verify.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { readFile } from
|
|
2
|
-
import path from
|
|
3
|
-
import { parseVerdict } from
|
|
4
|
-
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from
|
|
5
|
-
import { buildVerifierSystem, buildVerifierTask } from
|
|
6
|
-
import { errorMessage, normalizeCode } from
|
|
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]*/,
|
|
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
|
|
39
|
+
return "unknown";
|
|
40
40
|
}
|
|
41
41
|
const normContent = normalizeCode(content);
|
|
42
42
|
if (normContent.includes(normEvidence)) {
|
|
43
|
-
return
|
|
43
|
+
return "present";
|
|
44
44
|
}
|
|
45
45
|
const fragments = evidenceFragments(evidence);
|
|
46
46
|
if (fragments.length === 0) {
|
|
47
|
-
return
|
|
47
|
+
return "unknown";
|
|
48
48
|
}
|
|
49
|
-
return fragments.some(fragment => normContent.includes(fragment)) ?
|
|
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),
|
|
55
|
+
content = await readFile(path.resolve(cwd, finding.file), "utf8");
|
|
56
56
|
}
|
|
57
57
|
catch {
|
|
58
|
-
return
|
|
58
|
+
return "unknown";
|
|
59
59
|
}
|
|
60
|
-
return matchEvidence(finding.evidence ??
|
|
60
|
+
return matchEvidence(finding.evidence ?? "", content);
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
63
|
* Guard against hallucinated findings before they're surfaced, WITHOUT silently
|
|
@@ -81,6 +81,7 @@ async function evidencePresence(finding, cwd) {
|
|
|
81
81
|
export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
82
82
|
const dropped = [];
|
|
83
83
|
let cost = 0;
|
|
84
|
+
let model;
|
|
84
85
|
const tokens = {};
|
|
85
86
|
// Phase 1 — deterministic quote-grounding for every finding.
|
|
86
87
|
const checked = await Promise.all(findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })));
|
|
@@ -88,42 +89,43 @@ export async function verifyFindings(handle, findings, cwd, onProgress) {
|
|
|
88
89
|
const verdicts = new Map();
|
|
89
90
|
const toVerify = [];
|
|
90
91
|
for (const { finding, presence } of checked) {
|
|
91
|
-
if (presence ===
|
|
92
|
+
if (presence === "absent" || finding.severity === "critical") {
|
|
92
93
|
toVerify.push({ finding, presence });
|
|
93
94
|
}
|
|
94
95
|
else {
|
|
95
|
-
verdicts.set(finding,
|
|
96
|
+
verdicts.set(finding, "keep"); // grounded (or uncheckable) non-critical
|
|
96
97
|
}
|
|
97
98
|
}
|
|
98
99
|
// Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
|
|
99
100
|
await Promise.all(toVerify.map(async ({ finding, presence }, index) => {
|
|
100
101
|
try {
|
|
101
|
-
const { value, cost: verifyCost, tokens: verifyTokens } = await promptAndParse(handle, {
|
|
102
|
+
const { value, cost: verifyCost, tokens: verifyTokens, model: verifyModel, } = await promptAndParse(handle, {
|
|
102
103
|
agent: VERIFIER_AGENT,
|
|
103
104
|
system: buildVerifierSystem(),
|
|
104
|
-
text: buildVerifierTask(finding, { evidenceUngrounded: presence ===
|
|
105
|
+
text: buildVerifierTask(finding, { evidenceUngrounded: presence === "absent" }),
|
|
105
106
|
title: `verify-${index}`,
|
|
106
107
|
maxWaitMs: VERIFY_TIMEOUT_MS,
|
|
107
108
|
finalizeOnTimeout: true,
|
|
108
109
|
}, parseVerdict);
|
|
109
110
|
cost += verifyCost;
|
|
110
111
|
addTokenUsage(tokens, verifyTokens);
|
|
112
|
+
model = verifyModel ?? model;
|
|
111
113
|
if (value.verified) {
|
|
112
|
-
verdicts.set(finding,
|
|
114
|
+
verdicts.set(finding, "keep");
|
|
113
115
|
}
|
|
114
116
|
else {
|
|
115
|
-
verdicts.set(finding,
|
|
116
|
-
dropped.push({ finding, reason: value.reason ||
|
|
117
|
-
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason ||
|
|
117
|
+
verdicts.set(finding, "drop");
|
|
118
|
+
dropped.push({ finding, reason: value.reason || "refuted by verifier" });
|
|
119
|
+
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason || "refuted by verifier"}`);
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
catch (error) {
|
|
121
123
|
// Fail open: keep the finding if verification itself failed.
|
|
122
|
-
verdicts.set(finding,
|
|
124
|
+
verdicts.set(finding, "keep");
|
|
123
125
|
onProgress?.(` verify: could not verify "${finding.title}" (${errorMessage(error)}); keeping it`);
|
|
124
126
|
}
|
|
125
127
|
}));
|
|
126
128
|
// Preserve original order.
|
|
127
|
-
const kept = findings.filter(finding => verdicts.get(finding) ===
|
|
128
|
-
return { kept, dropped, cost, tokens };
|
|
129
|
+
const kept = findings.filter((finding) => verdicts.get(finding) === "keep");
|
|
130
|
+
return { kept, dropped, cost, tokens, model };
|
|
129
131
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { writeFile, mkdtemp, rm } from
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import path from
|
|
4
|
-
import { run } from
|
|
5
|
-
import { parseUnifiedDiff } from
|
|
6
|
-
import { buildDiffLineIndex, commentMarker, parseReviewState, renderMarkdown } from
|
|
7
|
-
import { fingerprintFinding } from
|
|
8
|
-
|
|
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 ===
|
|
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),
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
126
|
+
throw new Error("The reviewer comment has no embedded state (posted before dismissals existed); re-run a review first.");
|
|
87
127
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
-
|
|
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 ??
|
|
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(
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
169
|
+
const { stdout } = await run("gh", [
|
|
170
|
+
"api",
|
|
171
|
+
"-X",
|
|
172
|
+
"GET",
|
|
125
173
|
`repos/${this.options.repo}/issues/${this.options.prNumber}/comments`,
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(),
|
|
168
|
-
const jsonPath = path.join(dir,
|
|
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 }),
|
|
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(
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
240
|
+
await this.withBodyFile(body, (jsonPath) => run("gh", [
|
|
241
|
+
"api",
|
|
242
|
+
"-X",
|
|
243
|
+
"PATCH",
|
|
192
244
|
`repos/${this.options.repo}/issues/comments/${commentId}`,
|
|
193
|
-
|
|
245
|
+
"--input",
|
|
194
246
|
jsonPath,
|
|
195
247
|
], { cwd: this.options.cwd }));
|
|
196
248
|
}
|
|
197
249
|
async deleteComment(commentId) {
|
|
198
|
-
await run(
|
|
250
|
+
await run("gh", ["api", "-X", "DELETE", `repos/${this.options.repo}/issues/comments/${commentId}`], { cwd: this.options.cwd });
|
|
199
251
|
}
|
|
200
252
|
}
|