@expo/code-review-cli 0.2.3 → 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.
- package/README.md +183 -6
- package/build/cli.js +24 -17
- package/build/commands/ci.js +427 -28
- package/build/commands/dismiss.js +16 -16
- package/build/commands/doctor.js +172 -32
- package/build/commands/init.js +244 -34
- package/build/commands/review.js +124 -30
- package/build/commands/verify-config.js +214 -0
- package/build/config/load.js +155 -52
- package/build/config/routing.js +122 -0
- package/build/config/schema.js +127 -8
- package/build/core/auth.js +101 -38
- package/build/core/coordinator.js +5 -5
- 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 +98 -44
- package/build/core/prompts.js +157 -148
- package/build/core/render.js +202 -48
- package/build/core/review.js +187 -81
- 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 +25 -25
- 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 +6 -1
- package/templates/agents/security.md +5 -0
- package/templates/command.yml +164 -0
- package/templates/config.jsonc +10 -0
- 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 +58 -23
|
@@ -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
|
|
@@ -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 ===
|
|
91
|
+
if (presence === "absent" || finding.severity === "critical") {
|
|
92
92
|
toVerify.push({ finding, presence });
|
|
93
93
|
}
|
|
94
94
|
else {
|
|
95
|
-
verdicts.set(finding,
|
|
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 ===
|
|
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,
|
|
112
|
+
verdicts.set(finding, "keep");
|
|
113
113
|
}
|
|
114
114
|
else {
|
|
115
|
-
verdicts.set(finding,
|
|
116
|
-
dropped.push({ finding, reason: value.reason ||
|
|
117
|
-
onProgress?.(` verify: dropped ${finding.severity} "${finding.title}" — ${value.reason ||
|
|
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,
|
|
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) ===
|
|
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
|
|
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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { decisionExitCode, decisionLabel, groupBySeverity, sortFindings
|
|
2
|
-
import { SEVERITIES } from
|
|
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:
|
|
14
|
-
warning:
|
|
15
|
-
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,
|
|
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,
|
|
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(
|
|
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,
|
|
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,
|
|
86
|
+
lines.push(` ${this.paint(DIM, "Suggestion:")} ${finding.suggestion}`);
|
|
87
87
|
}
|
|
88
|
-
return `${lines.join(
|
|
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
|
|
2
|
-
import { tmpdir } from
|
|
3
|
-
import path from
|
|
4
|
-
import { run } from
|
|
5
|
-
import { parseUnifiedDiff } from
|
|
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 ? [
|
|
16
|
+
return this.options.repo ? ["--repo", this.options.repo] : [];
|
|
17
17
|
}
|
|
18
18
|
async getMetadata() {
|
|
19
|
-
const { stdout } = await run(
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const { stdout } = await run("gh", [
|
|
20
|
+
"pr",
|
|
21
|
+
"view",
|
|
22
22
|
String(this.options.prNumber),
|
|
23
23
|
...this.repoArgs(),
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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(
|
|
58
|
-
parent = await mkdtemp(path.join(tmpdir(),
|
|
59
|
-
const dir = path.join(parent,
|
|
60
|
-
await run(
|
|
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(
|
|
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
|