@jphutchins/code-review 0.1.0-alpha.3 → 0.1.0-alpha.31
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 +37 -7
- package/dist/index.js +2580 -291
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/schema/VERSIONING.md +3 -1
- package/schema/findings.schema.json +30 -13
- package/schema/v0.2/findings.schema.json +88 -0
- package/templates/comment.eta +74 -46
- package/templates/inline.eta +28 -5
package/dist/index.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from 'citty';
|
|
3
|
-
import { readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import {
|
|
3
|
+
import { readFileSync, writeFileSync, statSync, copyFileSync, readdirSync } from 'fs';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
|
|
5
6
|
import { Eta } from 'eta';
|
|
6
7
|
import parseDiff from 'parse-diff';
|
|
7
8
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
8
9
|
import _addFormats from 'ajv-formats';
|
|
9
10
|
import * as t from 'io-ts';
|
|
10
11
|
import { execFile } from 'child_process';
|
|
12
|
+
import { performance } from 'perf_hooks';
|
|
13
|
+
import { PathReporter } from 'io-ts/lib/PathReporter.js';
|
|
11
14
|
|
|
12
15
|
// src/cost.ts
|
|
13
16
|
var defaultWarn = (message) => {
|
|
@@ -53,44 +56,224 @@ var computeCost = (models, prices, warn = defaultWarn) => {
|
|
|
53
56
|
};
|
|
54
57
|
};
|
|
55
58
|
|
|
59
|
+
// src/patch.ts
|
|
60
|
+
var HUNK_HEADER_RE = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/;
|
|
61
|
+
var hunkOldStart = (line) => {
|
|
62
|
+
const raw = HUNK_HEADER_RE.exec(line)?.[1];
|
|
63
|
+
return raw !== void 0 ? Number(raw) : null;
|
|
64
|
+
};
|
|
65
|
+
var classifyBodyLine = (line) => {
|
|
66
|
+
if (line.startsWith(" ")) return { kind: "context", text: line.slice(1) };
|
|
67
|
+
if (line.startsWith("-")) return { kind: "removed", text: line.slice(1) };
|
|
68
|
+
if (line.startsWith("+")) return { kind: "added", text: line.slice(1) };
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
var trimmedMiddle = (body) => {
|
|
72
|
+
const first = body.findIndex((l) => l.kind !== "context");
|
|
73
|
+
if (first === -1) return [];
|
|
74
|
+
const last = body.findLastIndex((l) => l.kind !== "context");
|
|
75
|
+
return body.slice(first, last + 1);
|
|
76
|
+
};
|
|
77
|
+
var isContiguousChange = (middle) => {
|
|
78
|
+
if (middle.some((l) => l.kind === "context")) return false;
|
|
79
|
+
const firstAdded = middle.findIndex((l) => l.kind === "added");
|
|
80
|
+
if (firstAdded === -1) return true;
|
|
81
|
+
return middle.slice(0, firstAdded).every((l) => l.kind === "removed") && middle.slice(firstAdded).every((l) => l.kind === "added");
|
|
82
|
+
};
|
|
83
|
+
var removedRange = (body, oldStart) => body.reduce(
|
|
84
|
+
(acc, line) => line.kind === "added" ? acc : {
|
|
85
|
+
lineNumber: acc.lineNumber + 1,
|
|
86
|
+
firstRemoved: line.kind === "removed" && acc.firstRemoved === null ? acc.lineNumber : acc.firstRemoved,
|
|
87
|
+
lastRemoved: line.kind === "removed" ? acc.lineNumber : acc.lastRemoved
|
|
88
|
+
},
|
|
89
|
+
{ lineNumber: oldStart, firstRemoved: null, lastRemoved: null }
|
|
90
|
+
);
|
|
91
|
+
var drop = (reason) => ({
|
|
92
|
+
kind: "drop",
|
|
93
|
+
reason
|
|
94
|
+
});
|
|
95
|
+
var keep = (reason) => ({
|
|
96
|
+
kind: "keep",
|
|
97
|
+
reason
|
|
98
|
+
});
|
|
99
|
+
var parseHunk = (patch) => {
|
|
100
|
+
const rawLines = patch.split("\n");
|
|
101
|
+
const lines = rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
102
|
+
const headerHits = lines.reduce(
|
|
103
|
+
(acc, line, index) => {
|
|
104
|
+
const oldStart = hunkOldStart(line);
|
|
105
|
+
return oldStart !== null ? [...acc, { index, oldStart }] : acc;
|
|
106
|
+
},
|
|
107
|
+
[]
|
|
108
|
+
);
|
|
109
|
+
if (headerHits.length !== 1) {
|
|
110
|
+
return drop(`expected exactly one hunk, got ${String(headerHits.length)}`);
|
|
111
|
+
}
|
|
112
|
+
const hit = headerHits[0];
|
|
113
|
+
if (hit === void 0) return drop("malformed hunk header");
|
|
114
|
+
const bodyRaw = lines.slice(hit.index + 1).filter((line) => !line.startsWith("\\"));
|
|
115
|
+
const classified = bodyRaw.map(classifyBodyLine);
|
|
116
|
+
if (classified.some((line) => line === null)) return drop("malformed hunk body line");
|
|
117
|
+
const body = classified.filter((line) => line !== null);
|
|
118
|
+
return { kind: "ok", oldStart: hit.oldStart, body };
|
|
119
|
+
};
|
|
120
|
+
var validatePatch = (patch, fileLines) => {
|
|
121
|
+
const parsed = parseHunk(patch);
|
|
122
|
+
if (parsed.kind === "drop") return parsed;
|
|
123
|
+
const { oldStart, body } = parsed;
|
|
124
|
+
const oldSideTexts = body.filter((l) => l.kind !== "added").map((l) => l.text);
|
|
125
|
+
const expected = fileLines.slice(oldStart - 1, oldStart - 1 + oldSideTexts.length);
|
|
126
|
+
const oldSideMatches = expected.length === oldSideTexts.length && expected.every((line, i) => line === oldSideTexts[i]);
|
|
127
|
+
if (!oldSideMatches) {
|
|
128
|
+
return drop(
|
|
129
|
+
`patch context does not match the file at lines ${String(oldStart)}..${String(oldStart + oldSideTexts.length - 1)}`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
133
|
+
return drop("change is not a single contiguous block");
|
|
134
|
+
}
|
|
135
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
136
|
+
const addedCount = body.filter((l) => l.kind === "added").length;
|
|
137
|
+
if (removedCount === 0 && addedCount === 0) return drop("hunk contains no changes");
|
|
138
|
+
if (removedCount === 0) {
|
|
139
|
+
return keep("pure insertion applies cleanly but has no removed range to anchor a suggestion");
|
|
140
|
+
}
|
|
141
|
+
const { firstRemoved, lastRemoved } = removedRange(body, oldStart);
|
|
142
|
+
if (firstRemoved === null || lastRemoved === null) return drop("malformed hunk body");
|
|
143
|
+
return { kind: "anchored", startLine: firstRemoved, endLine: lastRemoved };
|
|
144
|
+
};
|
|
145
|
+
var patchToSuggestion = (patch) => {
|
|
146
|
+
const parsed = parseHunk(patch);
|
|
147
|
+
if (parsed.kind === "drop") return parsed;
|
|
148
|
+
const { body } = parsed;
|
|
149
|
+
if (!isContiguousChange(trimmedMiddle(body))) {
|
|
150
|
+
return drop("change is not a single contiguous block");
|
|
151
|
+
}
|
|
152
|
+
const removedCount = body.filter((l) => l.kind === "removed").length;
|
|
153
|
+
const addedLines = body.filter((l) => l.kind === "added");
|
|
154
|
+
if (removedCount === 0 && addedLines.length === 0) return drop("hunk contains no changes");
|
|
155
|
+
if (removedCount === 0) return drop("pure insertion can't be expressed as a suggestion");
|
|
156
|
+
return addedLines.map((l) => l.text).join("\n");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/surface.ts
|
|
160
|
+
var severityEmoji = (s) => {
|
|
161
|
+
switch (s) {
|
|
162
|
+
case "critical":
|
|
163
|
+
return "\u{1F534}";
|
|
164
|
+
case "major":
|
|
165
|
+
return "\u{1F7E0}";
|
|
166
|
+
case "minor":
|
|
167
|
+
return "\u{1F535}";
|
|
168
|
+
case "nit":
|
|
169
|
+
return "\u26AA";
|
|
170
|
+
default:
|
|
171
|
+
return "\u2753";
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
var EMBED_LIMIT = 4e4;
|
|
175
|
+
var AGENTS_STOP_DIRECTIVE = "<!-- AGENTS: STOP \u2014 do not parse the prose below; decode this findings JSON and read schema_version first. -->";
|
|
176
|
+
var encodeMarker = (document, jsonUrl, limit) => {
|
|
177
|
+
const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
|
|
178
|
+
const marker = b64.length <= limit ? `<!-- code-review:findings-json;base64 ${b64} -->` : jsonUrl ? `<!-- code-review:findings-json ${jsonUrl} -->` : "";
|
|
179
|
+
return marker ? `${AGENTS_STOP_DIRECTIVE}
|
|
180
|
+
${marker}` : "";
|
|
181
|
+
};
|
|
182
|
+
var findingsPointer = (findings, jsonUrl, limit = EMBED_LIMIT) => encodeMarker(findings, jsonUrl, limit);
|
|
183
|
+
var findingPointer = (finding, schemaVersion, jsonUrl, limit = EMBED_LIMIT) => encodeMarker({ schema_version: schemaVersion, findings: [finding] }, jsonUrl, limit);
|
|
184
|
+
var ZERO_SHA = "0000000000000000000000000000000000000000";
|
|
185
|
+
var parseReviewedSha = (body) => {
|
|
186
|
+
const sha = /<!-- reviewed-sha: ([0-9a-fA-F]{40}) -->/.exec(body)?.[1]?.toLowerCase();
|
|
187
|
+
return sha && sha !== ZERO_SHA ? sha : null;
|
|
188
|
+
};
|
|
189
|
+
var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
|
|
190
|
+
var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
|
|
191
|
+
var parseFindingsMarker = (body) => {
|
|
192
|
+
const match = /<!-- code-review:findings-json;base64 ([A-Za-z0-9+/=]+) -->/.exec(body);
|
|
193
|
+
const b64 = match?.[1];
|
|
194
|
+
if (b64 === void 0) return null;
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
var carryForwardMarkers = (body) => {
|
|
202
|
+
const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
|
|
203
|
+
const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
|
|
204
|
+
const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
|
|
205
|
+
${findings}` : void 0;
|
|
206
|
+
return [findingsBlock, reviewedSha].filter((m) => m !== void 0).join("\n\n");
|
|
207
|
+
};
|
|
208
|
+
var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
|
|
209
|
+
var projectPatch = (patch) => {
|
|
210
|
+
if (patch === void 0) return { kind: "none" };
|
|
211
|
+
const lowered = patchToSuggestion(patch);
|
|
212
|
+
return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
|
|
213
|
+
};
|
|
214
|
+
var formatConfidence = (n) => n.toFixed(2);
|
|
215
|
+
var reviewBodyPointer = (headSha, stickyUrl, marker) => {
|
|
216
|
+
const sha7 = headSha.slice(0, 7);
|
|
217
|
+
const linkLine = stickyUrl ? `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the [summary comment](${stickyUrl}) for the verdict, walkthrough, and cost.` : `\u{1F916} Automated code review for \`${sha7}\` \u2014 see the summary comment for the verdict, walkthrough, and cost.`;
|
|
218
|
+
return marker ? `${marker}
|
|
219
|
+
|
|
220
|
+
${linkLine}` : linkLine;
|
|
221
|
+
};
|
|
222
|
+
|
|
56
223
|
// src/render.ts
|
|
57
|
-
var escapeBackticks = (text) => text.replace(/```/g, "`` ` ``");
|
|
58
224
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
59
225
|
var escapeCodeBackticks = (text) => text.replace(/`/g, "-");
|
|
60
226
|
var sanitizeFinding = (f) => ({
|
|
61
227
|
...f,
|
|
62
228
|
title: escapePipes(f.title),
|
|
63
229
|
path: escapeCodeBackticks(f.path),
|
|
64
|
-
|
|
230
|
+
patchProjection: projectPatch(f.patch)
|
|
65
231
|
});
|
|
232
|
+
var emptySeverityCounts = () => ({
|
|
233
|
+
critical: 0,
|
|
234
|
+
major: 0,
|
|
235
|
+
minor: 0,
|
|
236
|
+
nit: 0
|
|
237
|
+
});
|
|
238
|
+
var computeSeverityCounts = (findings) => findings.reduce(
|
|
239
|
+
(acc, f) => f.severity in acc ? { ...acc, [f.severity]: acc[f.severity] + 1 } : acc,
|
|
240
|
+
emptySeverityCounts()
|
|
241
|
+
);
|
|
66
242
|
var render = (input) => {
|
|
67
243
|
const eta = new Eta({ autoTrim: false });
|
|
68
244
|
const usageAvailable = input.envelope !== null;
|
|
245
|
+
const hasUsage = input.envelope !== null && input.envelope.models.length > 0;
|
|
246
|
+
const incomplete = input.incomplete ?? input.envelope?.incomplete ?? false;
|
|
69
247
|
const costReport = input.envelope ? computeCost(input.envelope.models, input.prices) : null;
|
|
248
|
+
const pricesProvided = input.pricesProvided ?? true;
|
|
70
249
|
const route = input.route ?? input.envelope?.route ?? null;
|
|
71
250
|
const effort = input.effort ?? input.envelope?.effort ?? null;
|
|
72
251
|
const modelNames = input.envelope ? input.envelope.models.map((m) => m.model).join(", ") : "";
|
|
73
|
-
const findings = input.findings.findings.map(sanitizeFinding);
|
|
74
|
-
const safeFindings = { ...input.findings, findings };
|
|
75
|
-
const uniqueFiles = [...new Set(findings.map((f) => f.path))];
|
|
76
252
|
return eta.renderString(input.template, {
|
|
77
|
-
findings:
|
|
253
|
+
findings: input.findings,
|
|
78
254
|
envelope: input.envelope,
|
|
79
255
|
usageAvailable,
|
|
256
|
+
hasUsage,
|
|
257
|
+
incomplete,
|
|
80
258
|
costReport,
|
|
259
|
+
pricesProvided,
|
|
81
260
|
route,
|
|
82
261
|
effort,
|
|
83
262
|
modelNames,
|
|
84
263
|
testReport: input.testReport ?? null,
|
|
85
264
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
265
|
+
postedAt: input.postedAt ?? "",
|
|
266
|
+
severityCounts: input.severityCounts ?? computeSeverityCounts(input.findings.findings),
|
|
267
|
+
strays: (input.strays ?? []).map(sanitizeFinding),
|
|
268
|
+
unanchoredCount: input.unanchoredCount ?? 0,
|
|
269
|
+
inlineDisposition: input.inlineDisposition ?? null,
|
|
270
|
+
runUrl: input.runUrl ?? null,
|
|
271
|
+
jsonUrl: input.jsonUrl ?? null,
|
|
272
|
+
findingsPointer: input.findingsPointer ?? findingsPointer(input.findings, input.jsonUrl),
|
|
273
|
+
reviewUrl: input.reviewUrl ?? null,
|
|
92
274
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
93
|
-
|
|
275
|
+
// N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to price them.
|
|
276
|
+
formatCost: (n) => !pricesProvided ? "N/A" : Number.isFinite(n) ? n > 0 && n.toFixed(2) === "0.00" ? "<$0.01" : `$${n.toFixed(2)}` : "\u2014",
|
|
94
277
|
formatDuration: (ms) => {
|
|
95
278
|
if (!Number.isFinite(ms) || ms < 0) return "\u2014";
|
|
96
279
|
const s = Math.round(ms / 1e3);
|
|
@@ -108,20 +291,8 @@ var render = (input) => {
|
|
|
108
291
|
return `\u2753 ${v}`;
|
|
109
292
|
}
|
|
110
293
|
},
|
|
111
|
-
severityEmoji
|
|
112
|
-
|
|
113
|
-
case "critical":
|
|
114
|
-
return "\u{1F534}";
|
|
115
|
-
case "major":
|
|
116
|
-
return "\u{1F7E0}";
|
|
117
|
-
case "minor":
|
|
118
|
-
return "\u{1F535}";
|
|
119
|
-
case "nit":
|
|
120
|
-
return "\u26AA";
|
|
121
|
-
default:
|
|
122
|
-
return "\u2753";
|
|
123
|
-
}
|
|
124
|
-
}
|
|
294
|
+
severityEmoji,
|
|
295
|
+
formatConfidence
|
|
125
296
|
});
|
|
126
297
|
};
|
|
127
298
|
var key = (path, line) => `${path}:${String(line)}`;
|
|
@@ -178,33 +349,33 @@ var partitionFindings = (findings, index) => {
|
|
|
178
349
|
};
|
|
179
350
|
|
|
180
351
|
// src/inline.ts
|
|
181
|
-
var
|
|
182
|
-
var
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
parts.push(`\`\`\`suggestion
|
|
187
|
-
${safe}
|
|
188
|
-
\`\`\``);
|
|
189
|
-
}
|
|
190
|
-
return parts.join("\n\n");
|
|
191
|
-
};
|
|
192
|
-
var renderCommentBody = (f, eta, template) => {
|
|
193
|
-
return eta.renderString(template, {
|
|
352
|
+
var formatModels = (models) => models.length > 0 ? models.map((m) => `\`${m}\``).join("/") : "an AI model";
|
|
353
|
+
var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer) => (
|
|
354
|
+
// Eta.renderString returns string | Promise<string>; with autoTrim:false it's always sync.
|
|
355
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
|
356
|
+
eta.renderString(template, {
|
|
194
357
|
...f,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
358
|
+
patchProjection: projectPatch(f.patch),
|
|
359
|
+
severityEmoji,
|
|
360
|
+
formatConfidence,
|
|
361
|
+
modelsText,
|
|
362
|
+
jsonUrl: jsonUrl ?? null,
|
|
363
|
+
findingsPointer: pointer
|
|
364
|
+
})
|
|
365
|
+
);
|
|
366
|
+
var buildInlineComments = (findings, diff, context) => {
|
|
367
|
+
const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
|
|
199
368
|
const index = indexDiff(diff);
|
|
200
369
|
const { inDiff, strays } = partitionFindings(findings, index);
|
|
201
|
-
const eta =
|
|
370
|
+
const eta = new Eta({ autoTrim: false });
|
|
371
|
+
const modelsText = formatModels(models);
|
|
202
372
|
const comments = inDiff.map((f) => {
|
|
373
|
+
const pointer = fullFindings ? findingPointer(f, fullFindings.schema_version, jsonUrl) : "";
|
|
203
374
|
const comment = {
|
|
204
375
|
path: f.path,
|
|
205
376
|
line: f.end_line,
|
|
206
377
|
side: defaultSide(f.side),
|
|
207
|
-
body:
|
|
378
|
+
body: renderCommentBody(f, eta, inlineTemplate, modelsText, jsonUrl, pointer)
|
|
208
379
|
};
|
|
209
380
|
if (f.start_line < f.end_line) {
|
|
210
381
|
return {
|
|
@@ -215,7 +386,7 @@ var buildInlineComments = (findings, diff, inlineTemplate) => {
|
|
|
215
386
|
}
|
|
216
387
|
return comment;
|
|
217
388
|
});
|
|
218
|
-
return { comments, strays };
|
|
389
|
+
return { comments, strays, inDiff };
|
|
219
390
|
};
|
|
220
391
|
var renderStraysSection = (strays) => {
|
|
221
392
|
if (strays.length === 0) return "";
|
|
@@ -231,6 +402,129 @@ var renderStraysSection = (strays) => {
|
|
|
231
402
|
...items
|
|
232
403
|
].join("\n");
|
|
233
404
|
};
|
|
405
|
+
var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
|
|
406
|
+
var errMsg = (e) => e instanceof Error ? e.message : String(e);
|
|
407
|
+
var annotationSafe = (msg) => msg.replaceAll(/[\r\n]+/g, " ");
|
|
408
|
+
var tryParseJson = (text) => {
|
|
409
|
+
try {
|
|
410
|
+
return { ok: true, value: JSON.parse(text) };
|
|
411
|
+
} catch {
|
|
412
|
+
return { ok: false };
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
var readFileOrNull = (path) => {
|
|
416
|
+
try {
|
|
417
|
+
return readFileSync(path, "utf-8");
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// src/transcript.ts
|
|
424
|
+
var numField = (rec, key2) => {
|
|
425
|
+
const v = rec[key2];
|
|
426
|
+
return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
427
|
+
};
|
|
428
|
+
var messageUsage = (entry) => {
|
|
429
|
+
const rec = asRecord(entry);
|
|
430
|
+
if (rec === null || rec["type"] !== "assistant") return null;
|
|
431
|
+
const msg = asRecord(rec["message"]);
|
|
432
|
+
if (msg === null) return null;
|
|
433
|
+
const model = msg["model"];
|
|
434
|
+
const usage = asRecord(msg["usage"]);
|
|
435
|
+
if (typeof model !== "string" || usage === null) return null;
|
|
436
|
+
const id = msg["id"];
|
|
437
|
+
return {
|
|
438
|
+
id: typeof id === "string" ? id : null,
|
|
439
|
+
model,
|
|
440
|
+
input: numField(usage, "input_tokens"),
|
|
441
|
+
output: numField(usage, "output_tokens"),
|
|
442
|
+
cacheRead: numField(usage, "cache_read_input_tokens"),
|
|
443
|
+
cacheWrite: numField(usage, "cache_creation_input_tokens")
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
var tsMsOf = (entry) => {
|
|
447
|
+
const rec = asRecord(entry);
|
|
448
|
+
const ts = rec?.["timestamp"];
|
|
449
|
+
if (typeof ts !== "string") return null;
|
|
450
|
+
const ms = Date.parse(ts);
|
|
451
|
+
return Number.isNaN(ms) ? null : ms;
|
|
452
|
+
};
|
|
453
|
+
var EMPTY_TOTALS = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
454
|
+
var parseJsonl = (text) => text.split("\n").filter((line) => line.trim() !== "").flatMap((line) => {
|
|
455
|
+
try {
|
|
456
|
+
return [JSON.parse(line)];
|
|
457
|
+
} catch {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
var sumTranscriptUsage = (entries) => {
|
|
462
|
+
const summed = entries.reduce(
|
|
463
|
+
(acc, entry) => {
|
|
464
|
+
const u = messageUsage(entry);
|
|
465
|
+
if (u === null) return acc;
|
|
466
|
+
if (u.id !== null && acc.seen.has(u.id)) return acc;
|
|
467
|
+
if (u.id !== null) acc.seen.add(u.id);
|
|
468
|
+
const prev = acc.totals.get(u.model) ?? EMPTY_TOTALS;
|
|
469
|
+
acc.totals.set(u.model, {
|
|
470
|
+
input: prev.input + u.input,
|
|
471
|
+
output: prev.output + u.output,
|
|
472
|
+
cacheRead: prev.cacheRead + u.cacheRead,
|
|
473
|
+
cacheWrite: prev.cacheWrite + u.cacheWrite
|
|
474
|
+
});
|
|
475
|
+
return { totals: acc.totals, turns: acc.turns + 1, seen: acc.seen };
|
|
476
|
+
},
|
|
477
|
+
{ totals: /* @__PURE__ */ new Map(), turns: 0, seen: /* @__PURE__ */ new Set() }
|
|
478
|
+
);
|
|
479
|
+
const models = [...summed.totals].map(([model, t7]) => ({
|
|
480
|
+
model,
|
|
481
|
+
input_tokens: t7.input,
|
|
482
|
+
output_tokens: t7.output,
|
|
483
|
+
cache_read_tokens: t7.cacheRead,
|
|
484
|
+
cache_write_tokens: t7.cacheWrite
|
|
485
|
+
}));
|
|
486
|
+
const bounds = entries.reduce(
|
|
487
|
+
(acc, entry) => {
|
|
488
|
+
const ms = tsMsOf(entry);
|
|
489
|
+
if (ms === null) return acc;
|
|
490
|
+
return {
|
|
491
|
+
min: acc.min === null || ms < acc.min ? ms : acc.min,
|
|
492
|
+
max: acc.max === null || ms > acc.max ? ms : acc.max
|
|
493
|
+
};
|
|
494
|
+
},
|
|
495
|
+
{ min: null, max: null }
|
|
496
|
+
);
|
|
497
|
+
return {
|
|
498
|
+
models,
|
|
499
|
+
turns: summed.turns,
|
|
500
|
+
durationMs: bounds.min !== null && bounds.max !== null ? bounds.max - bounds.min : 0,
|
|
501
|
+
firstTsMs: bounds.min,
|
|
502
|
+
lastTsMs: bounds.max
|
|
503
|
+
};
|
|
504
|
+
};
|
|
505
|
+
var subagentFiles = (mainPath) => {
|
|
506
|
+
const dir = join(dirname(mainPath), basename(mainPath, ".jsonl"), "subagents");
|
|
507
|
+
try {
|
|
508
|
+
return readdirSync(dir).filter((name) => name.endsWith(".jsonl")).map((name) => join(dir, name));
|
|
509
|
+
} catch {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
var readTranscriptTree = (mainPath) => {
|
|
514
|
+
const mainText = readFileOrNull(mainPath);
|
|
515
|
+
const mainEntries = mainText === null ? [] : parseJsonl(mainText);
|
|
516
|
+
const inlineSidechains = mainEntries.some((e) => asRecord(e)?.["isSidechain"] === true);
|
|
517
|
+
const siblings = inlineSidechains ? [] : subagentFiles(mainPath);
|
|
518
|
+
const siblingReads = siblings.flatMap((path) => {
|
|
519
|
+
const text = readFileOrNull(path);
|
|
520
|
+
return text === null ? [] : [{ path, entries: parseJsonl(text) }];
|
|
521
|
+
});
|
|
522
|
+
return {
|
|
523
|
+
entries: [...mainEntries, ...siblingReads.flatMap((r) => r.entries)],
|
|
524
|
+
files: [...mainText === null ? [] : [mainPath], ...siblingReads.map((r) => r.path)],
|
|
525
|
+
missing: mainText === null
|
|
526
|
+
};
|
|
527
|
+
};
|
|
234
528
|
var SeverityCodec = t.union([
|
|
235
529
|
t.literal("critical"),
|
|
236
530
|
t.literal("major"),
|
|
@@ -258,14 +552,16 @@ var FindingShape = t.intersection([
|
|
|
258
552
|
end_line: LineNumber,
|
|
259
553
|
severity: SeverityCodec,
|
|
260
554
|
title: t.string,
|
|
261
|
-
|
|
555
|
+
description: t.string,
|
|
556
|
+
reasoning: t.string,
|
|
557
|
+
confidence: Confidence
|
|
262
558
|
}),
|
|
263
559
|
t.partial({
|
|
264
560
|
side: SideCodec,
|
|
265
|
-
suggestion: t.union([t.string, t.null]),
|
|
266
|
-
confidence: Confidence,
|
|
267
561
|
code: t.string,
|
|
268
|
-
code_url: t.string
|
|
562
|
+
code_url: t.string,
|
|
563
|
+
recommendation: t.string,
|
|
564
|
+
patch: t.string
|
|
269
565
|
})
|
|
270
566
|
]);
|
|
271
567
|
var EndGeStart = t.refinement(
|
|
@@ -313,7 +609,12 @@ var ResultEnvelopeCodec = t.intersection([
|
|
|
313
609
|
t.partial({
|
|
314
610
|
vendor_cost_usd: t.union([t.number, t.null]),
|
|
315
611
|
route: t.string,
|
|
316
|
-
effort: t.string
|
|
612
|
+
effort: t.string,
|
|
613
|
+
// The run produced a notice rather than a completed review (security-gate block, agent kill, no
|
|
614
|
+
// recoverable findings). An empty `findings` array alone can't say this — a genuine clean review
|
|
615
|
+
// is also empty — so the render suppresses "clean review" and the sticky precedence guard refuses
|
|
616
|
+
// to bury a completed review under it. Absent ⇒ a completed review.
|
|
617
|
+
incomplete: t.boolean
|
|
317
618
|
})
|
|
318
619
|
]);
|
|
319
620
|
var ModelPricesCodec = t.type({
|
|
@@ -341,7 +642,13 @@ var TestSummaryCodec = t.intersection([
|
|
|
341
642
|
failures: t.array(TestFailureCodec)
|
|
342
643
|
})
|
|
343
644
|
]);
|
|
344
|
-
var DEFAULT_SCHEMA_VERSION = "0.
|
|
645
|
+
var DEFAULT_SCHEMA_VERSION = "0.4.0";
|
|
646
|
+
var noticeFindings = (summary) => ({
|
|
647
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
648
|
+
summary,
|
|
649
|
+
verdict: "comment",
|
|
650
|
+
findings: []
|
|
651
|
+
});
|
|
345
652
|
|
|
346
653
|
// src/validate.ts
|
|
347
654
|
var addFormats = _addFormats;
|
|
@@ -377,10 +684,348 @@ var unsafeUnwrap = (decoded) => {
|
|
|
377
684
|
if (decoded._tag === "Right") return decoded.right;
|
|
378
685
|
throw new Error("io-ts decode failed \u2014 data does not match expected shape");
|
|
379
686
|
};
|
|
687
|
+
|
|
688
|
+
// src/stop-gate.ts
|
|
689
|
+
var whatsWrong = (state, draftPath, kind) => {
|
|
690
|
+
switch (state.kind) {
|
|
691
|
+
case "missing":
|
|
692
|
+
return `${draftPath} does not exist yet`;
|
|
693
|
+
case "unreadable":
|
|
694
|
+
return `${draftPath} could not be read: ${state.error}`;
|
|
695
|
+
case "invalid":
|
|
696
|
+
return `${draftPath} does not validate against the ${kind} schema:
|
|
697
|
+
${state.errors.map((e) => ` - ${e}`).join("\n")}`;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
var decideGate = (state, nudges, maxNudges, draftPath, kind) => {
|
|
701
|
+
if (state.kind === "valid") return { kind: "allow" };
|
|
702
|
+
if (nudges >= maxNudges) return { kind: "allow" };
|
|
703
|
+
return {
|
|
704
|
+
kind: "block",
|
|
705
|
+
reason: [
|
|
706
|
+
`This review is not complete \u2014 ${whatsWrong(state, draftPath, kind)}`,
|
|
707
|
+
`The only deliverable is a ${kind} document that validates against the ${kind} schema \u2014 run "code-review print-schema ${kind}" to see the exact shape.`,
|
|
708
|
+
`Write it to ${draftPath}, then run "code-review validate ${draftPath} --kind ${kind} --explain" until it exits 0 before ending your turn (--explain prints the schema when the shape is wrong).`
|
|
709
|
+
].join("\n")
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
var draftState = (draftPath, resolveSchema) => {
|
|
713
|
+
let raw;
|
|
714
|
+
try {
|
|
715
|
+
raw = readFileSync(draftPath, "utf-8");
|
|
716
|
+
} catch (err) {
|
|
717
|
+
if (err instanceof Error && err.code === "ENOENT") {
|
|
718
|
+
return { kind: "missing" };
|
|
719
|
+
}
|
|
720
|
+
return { kind: "unreadable", error: errMsg(err) };
|
|
721
|
+
}
|
|
722
|
+
let parsed;
|
|
723
|
+
try {
|
|
724
|
+
parsed = JSON.parse(raw);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
return {
|
|
727
|
+
kind: "invalid",
|
|
728
|
+
errors: [`not valid JSON: ${errMsg(err)}`]
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
let schemaPath;
|
|
732
|
+
try {
|
|
733
|
+
schemaPath = resolveSchema(parsed);
|
|
734
|
+
} catch (err) {
|
|
735
|
+
return { kind: "invalid", errors: [errMsg(err)] };
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
const { valid, errors } = validateAgainstSchema(parsed, schemaPath);
|
|
739
|
+
return valid ? { kind: "valid" } : { kind: "invalid", errors };
|
|
740
|
+
} catch (err) {
|
|
741
|
+
return { kind: "invalid", errors: [errMsg(err)] };
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
var readNudges = (counterPath) => {
|
|
745
|
+
try {
|
|
746
|
+
const n = Number.parseInt(readFileSync(counterPath, "utf-8").trim(), 10);
|
|
747
|
+
return Number.isInteger(n) && n >= 0 ? n : 0;
|
|
748
|
+
} catch {
|
|
749
|
+
return 0;
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
var bumpNudges = (counterPath, current) => {
|
|
753
|
+
writeFileSync(counterPath, `${String(current + 1)}
|
|
754
|
+
`);
|
|
755
|
+
};
|
|
756
|
+
var shellQuote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
757
|
+
var defaultHookCommand = (draftPath, opts) => [
|
|
758
|
+
"code-review stop-gate --draft",
|
|
759
|
+
shellQuote(draftPath),
|
|
760
|
+
...opts.kind ? ["--kind", shellQuote(opts.kind)] : [],
|
|
761
|
+
...opts.schema ? ["--schema", shellQuote(opts.schema)] : [],
|
|
762
|
+
...opts.schemaVersion ? ["--schema-version", shellQuote(opts.schemaVersion)] : [],
|
|
763
|
+
...opts.maxNudges ? ["--max-nudges", shellQuote(opts.maxNudges)] : [],
|
|
764
|
+
...opts.counter ? ["--counter", shellQuote(opts.counter)] : []
|
|
765
|
+
].join(" ");
|
|
766
|
+
var stopHookSettings = (command) => ({
|
|
767
|
+
hooks: { Stop: [{ hooks: [{ type: "command", command }] }] }
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// src/budget.ts
|
|
771
|
+
var DEADLINE_ENV = "CODE_REVIEW_DEADLINE_EPOCH";
|
|
772
|
+
var DEFAULT_RESERVE = {
|
|
773
|
+
frac: 0.15,
|
|
774
|
+
growth: 0.25,
|
|
775
|
+
flatUsd: 0.02,
|
|
776
|
+
flatMs: 12e4
|
|
777
|
+
};
|
|
778
|
+
var SOFT_MULTIPLE = 2;
|
|
779
|
+
var costAxis = (i) => i.spentUsd !== null && i.budgetUsd !== null && i.budgetUsd > 0 ? { used: i.spentUsd, limit: i.budgetUsd, flat: i.reserve.flatUsd } : null;
|
|
780
|
+
var timeAxis = (i) => i.elapsedMs !== null && i.wallMs !== null && i.wallMs > 0 ? { used: i.elapsedMs, limit: i.wallMs, flat: i.reserve.flatMs } : null;
|
|
781
|
+
var axisSeverity = (a, reserve) => {
|
|
782
|
+
const usedFrac = Math.min(1, Math.max(0, a.used / a.limit));
|
|
783
|
+
const effFrac = reserve.frac + reserve.growth * usedFrac;
|
|
784
|
+
const hardReserve = Math.max(a.flat, effFrac * a.limit);
|
|
785
|
+
const remaining = a.limit - a.used;
|
|
786
|
+
if (remaining <= hardReserve) return 2;
|
|
787
|
+
if (remaining <= SOFT_MULTIPLE * hardReserve) return 1;
|
|
788
|
+
return 0;
|
|
789
|
+
};
|
|
790
|
+
var decideBudget = (i) => {
|
|
791
|
+
const worst = [costAxis(i), timeAxis(i)].filter((a) => a !== null).reduce((max, a) => Math.max(max, axisSeverity(a, i.reserve)), 0);
|
|
792
|
+
return worst === 2 ? { kind: "hard" } : worst === 1 ? { kind: "soft" } : { kind: "ok" };
|
|
793
|
+
};
|
|
794
|
+
var pct = (n) => `${String(Math.round(n * 100))}%`;
|
|
795
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
796
|
+
var mins = (ms) => `${(ms / 6e4).toFixed(1)}m`;
|
|
797
|
+
var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.budgetUsd > 0 ? `spent ${money(i.spentUsd)}/${money(i.budgetUsd)} (${pct(i.spentUsd / i.budgetUsd)})` : `spent ${money(i.spentUsd)}`;
|
|
798
|
+
var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wallMs > 0 ? `${mins(i.elapsedMs)}/${mins(i.wallMs)} elapsed (${pct(i.elapsedMs / i.wallMs)})` : `${mins(i.elapsedMs)} elapsed`;
|
|
799
|
+
var directive = (phase, draftPath, isSubagent) => {
|
|
800
|
+
if (isSubagent)
|
|
801
|
+
return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now and report the findings you have back to the main agent in your reply. Do not write ${draftPath} yourself; only the main agent writes it.` : `Wind down investigation and report the findings you have back to the main agent in your reply \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
|
|
802
|
+
return phase.kind === "hard" ? `Budget nearly exhausted \u2014 STOP all new investigation now. Do not wait for subagents still running in the background. Write your COMPLETE findings from what you already have to ${draftPath} and run \`code-review validate ${draftPath} --explain\` until it passes (--explain prints the exact schema when the shape is wrong). Other tools are blocked until that draft is written.` : `Wind down investigation and write your COMPLETE findings to ${draftPath} now, then run \`code-review validate ${draftPath} --explain\` (it prints the exact schema if the shape is wrong) \u2014 fold in the subagent reports you already have rather than waiting on stragglers; you may run out of budget before you finish otherwise.`;
|
|
803
|
+
};
|
|
804
|
+
var budgetMessage = (i, phase, draftPath, isSubagent) => {
|
|
805
|
+
const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
|
|
806
|
+
return `Budget check \u2014 ${status}. ${directive(phase, draftPath, isSubagent)}`;
|
|
807
|
+
};
|
|
808
|
+
var invokesCodeReviewValidate = (toolInput) => {
|
|
809
|
+
const cmd = asRecord(toolInput)?.["command"];
|
|
810
|
+
return typeof cmd === "string" && /\bcode-review\s+validate(?![\w-])/.test(cmd);
|
|
811
|
+
};
|
|
812
|
+
var SPAWN_TOOLS = /* @__PURE__ */ new Set(["Agent", "Task"]);
|
|
813
|
+
var WEB_TOOLS = /* @__PURE__ */ new Set(["WebFetch", "WebSearch"]);
|
|
814
|
+
var blockedDuringConvergence = (toolName, toolInput) => {
|
|
815
|
+
if (SPAWN_TOOLS.has(toolName) || WEB_TOOLS.has(toolName)) return true;
|
|
816
|
+
if (toolName === "Bash") return !invokesCodeReviewValidate(toolInput);
|
|
817
|
+
return false;
|
|
818
|
+
};
|
|
819
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
820
|
+
var WRITE_TOOLS = /* @__PURE__ */ new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
|
|
821
|
+
var writesToDraft = (toolName, toolInput, draftPath) => {
|
|
822
|
+
const rec = asRecord(toolInput);
|
|
823
|
+
const targets = [draftPath, basename(draftPath), "$DRAFT", "${DRAFT}"];
|
|
824
|
+
if (WRITE_TOOLS.has(toolName)) {
|
|
825
|
+
const fp = rec?.["file_path"] ?? rec?.["notebook_path"];
|
|
826
|
+
if (typeof fp === "string" && targets.some((t7) => fp === t7 || basename(fp) === basename(t7)))
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
if (toolName === "Bash") {
|
|
830
|
+
const cmd = rec?.["command"];
|
|
831
|
+
if (typeof cmd !== "string") return false;
|
|
832
|
+
const alt = targets.map(escapeRegExp).join("|");
|
|
833
|
+
const end = "(?=$|[\\s|&;)])";
|
|
834
|
+
const redirect = new RegExp(`>>?\\|?\\s*(['"]?)(?:${alt})\\1${end}`);
|
|
835
|
+
const teeArg = new RegExp(`\\btee\\b(?:\\s+-{1,2}\\S+)*\\s+(['"]?)(?:${alt})\\1${end}`);
|
|
836
|
+
return redirect.test(cmd) || teeArg.test(cmd);
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
839
|
+
};
|
|
840
|
+
var singleWriterMessage = (draftPath) => `Only the main agent may write ${draftPath}. When a subagent writes it too, the concurrent writers clobber each other and the review comes out empty. Do NOT write, edit, or redirect into ${draftPath} \u2014 instead, return the findings you discovered in your reply (the field names are in the schema); the main agent collects every subagent's reported findings and writes the draft itself.`;
|
|
841
|
+
var seedMarkerPath = (draftPath) => `${draftPath}.seed`;
|
|
842
|
+
var lastValidPath = (draftPath) => {
|
|
843
|
+
const ext = extname(draftPath);
|
|
844
|
+
return join(dirname(draftPath), `${basename(draftPath, ext)}.last-valid${ext}`);
|
|
845
|
+
};
|
|
846
|
+
var mainHasWrittenDraft = (draftMtimeMs, seedMarkerMtimeMs) => draftMtimeMs !== null && (seedMarkerMtimeMs === null || draftMtimeMs > seedMarkerMtimeMs);
|
|
847
|
+
var spawnFloorMessage = (draftPath) => `Write your own first-pass findings to ${draftPath} before spawning subagents \u2014 a review must never depend on subagents alone, and a pre-seeded draft does not count until you have revised it yourself this run. Write ${draftPath} from what you have read so far (preliminary findings are fine), run \`code-review validate ${draftPath} --explain\` until it passes, then fan out; your subagents run in the background, so keep refining the draft as their reports arrive.`;
|
|
848
|
+
var forceBackgroundSpawn = (toolInput) => ({
|
|
849
|
+
hookSpecificOutput: {
|
|
850
|
+
hookEventName: "PreToolUse",
|
|
851
|
+
permissionDecision: "allow",
|
|
852
|
+
updatedInput: { ...asRecord(toolInput) ?? {}, run_in_background: true }
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
var denyPreTool = (reason) => ({
|
|
856
|
+
hookSpecificOutput: {
|
|
857
|
+
hookEventName: "PreToolUse",
|
|
858
|
+
permissionDecision: "deny",
|
|
859
|
+
permissionDecisionReason: reason
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
var isSubagentHookInput = (input) => {
|
|
863
|
+
const agentId = asRecord(input)?.["agent_id"];
|
|
864
|
+
return typeof agentId === "string" && agentId.length > 0;
|
|
865
|
+
};
|
|
866
|
+
var evaluateBudgetHook = (input, params) => {
|
|
867
|
+
const rec = asRecord(input);
|
|
868
|
+
const inputs = {
|
|
869
|
+
spentUsd: params.spentUsd,
|
|
870
|
+
budgetUsd: params.budgetUsd,
|
|
871
|
+
elapsedMs: params.elapsedMs,
|
|
872
|
+
wallMs: params.wallMs,
|
|
873
|
+
reserve: params.reserve
|
|
874
|
+
};
|
|
875
|
+
const phase = decideBudget(inputs);
|
|
876
|
+
const isSubagent = isSubagentHookInput(input);
|
|
877
|
+
switch (rec?.["hook_event_name"]) {
|
|
878
|
+
case "PostToolBatch":
|
|
879
|
+
return phase.kind === "ok" ? {} : {
|
|
880
|
+
hookSpecificOutput: {
|
|
881
|
+
hookEventName: "PostToolBatch",
|
|
882
|
+
additionalContext: budgetMessage(inputs, phase, params.draftPath, isSubagent)
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
case "PreToolUse": {
|
|
886
|
+
const toolName = rec["tool_name"];
|
|
887
|
+
if (typeof toolName !== "string") return {};
|
|
888
|
+
if (isSubagent && writesToDraft(toolName, rec["tool_input"], params.draftPath))
|
|
889
|
+
return denyPreTool(singleWriterMessage(params.draftPath));
|
|
890
|
+
if (phase.kind === "hard" && blockedDuringConvergence(toolName, rec["tool_input"]))
|
|
891
|
+
return denyPreTool(budgetMessage(inputs, phase, params.draftPath, isSubagent));
|
|
892
|
+
if (SPAWN_TOOLS.has(toolName)) {
|
|
893
|
+
if (!isSubagent && !params.mainDraftWritten)
|
|
894
|
+
return denyPreTool(spawnFloorMessage(params.draftPath));
|
|
895
|
+
return forceBackgroundSpawn(rec["tool_input"]);
|
|
896
|
+
}
|
|
897
|
+
return {};
|
|
898
|
+
}
|
|
899
|
+
default:
|
|
900
|
+
return {};
|
|
901
|
+
}
|
|
902
|
+
};
|
|
903
|
+
var parseWallMs = (raw) => {
|
|
904
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(raw.trim());
|
|
905
|
+
if (m === null) return null;
|
|
906
|
+
const [, num = "", unit = "s"] = m;
|
|
907
|
+
const n = Number.parseFloat(num);
|
|
908
|
+
if (!Number.isFinite(n)) return null;
|
|
909
|
+
switch (unit) {
|
|
910
|
+
case "ms":
|
|
911
|
+
return n;
|
|
912
|
+
case "s":
|
|
913
|
+
return n * 1e3;
|
|
914
|
+
case "m":
|
|
915
|
+
return n * 6e4;
|
|
916
|
+
default:
|
|
917
|
+
return n * 36e5;
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
var parseEpochSecMs = (raw) => {
|
|
921
|
+
if (raw === void 0) return null;
|
|
922
|
+
const t7 = raw.trim();
|
|
923
|
+
if (!/^\d+$/.test(t7)) return null;
|
|
924
|
+
const n = Number.parseInt(t7, 10);
|
|
925
|
+
return Number.isFinite(n) && n > 0 ? n * 1e3 : null;
|
|
926
|
+
};
|
|
927
|
+
var anchoredElapsedMs = (src) => {
|
|
928
|
+
if (src.deadlineMs !== null && src.wallMs !== null)
|
|
929
|
+
return Math.max(0, src.wallMs - (src.deadlineMs - src.nowMs));
|
|
930
|
+
if (src.firstTsMs !== null) return Math.max(0, src.nowMs - src.firstTsMs);
|
|
931
|
+
return null;
|
|
932
|
+
};
|
|
933
|
+
var deadlineEpochSec = (wallMs, nowMs) => Math.floor(nowMs / 1e3) + Math.ceil(wallMs / 1e3);
|
|
934
|
+
var parseFraction = (raw, fallback) => {
|
|
935
|
+
if (raw === void 0) return fallback;
|
|
936
|
+
const n = Number.parseFloat(raw);
|
|
937
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback;
|
|
938
|
+
};
|
|
939
|
+
var budgetHookCommand = (draftPath, opts) => [
|
|
940
|
+
"code-review budget-hook --draft",
|
|
941
|
+
shellQuote(draftPath),
|
|
942
|
+
...opts.budgetUsd ? ["--budget-usd", shellQuote(opts.budgetUsd)] : [],
|
|
943
|
+
...opts.wall ? ["--wall", shellQuote(opts.wall)] : [],
|
|
944
|
+
...opts.prices ? ["--prices", shellQuote(opts.prices)] : [],
|
|
945
|
+
...opts.reserveFrac ? ["--reserve-frac", shellQuote(opts.reserveFrac)] : [],
|
|
946
|
+
...opts.reserveGrowth ? ["--reserve-growth", shellQuote(opts.reserveGrowth)] : [],
|
|
947
|
+
...opts.reserveUsd ? ["--reserve-usd", shellQuote(opts.reserveUsd)] : [],
|
|
948
|
+
...opts.reserveWall ? ["--reserve-wall", shellQuote(opts.reserveWall)] : []
|
|
949
|
+
].join(" ");
|
|
950
|
+
|
|
951
|
+
// src/format.ts
|
|
952
|
+
var FENCE_RE = /^\s*```/;
|
|
953
|
+
var scanLine = (state, line) => {
|
|
954
|
+
if (FENCE_RE.test(line)) {
|
|
955
|
+
return { lines: [...state.lines, line], inFence: !state.inFence, blankRun: 0 };
|
|
956
|
+
}
|
|
957
|
+
if (state.inFence) {
|
|
958
|
+
return { lines: [...state.lines, line], inFence: true, blankRun: 0 };
|
|
959
|
+
}
|
|
960
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
961
|
+
if (trimmed !== "") {
|
|
962
|
+
return { lines: [...state.lines, trimmed], inFence: false, blankRun: 0 };
|
|
963
|
+
}
|
|
964
|
+
const blankRun = state.blankRun + 1;
|
|
965
|
+
return blankRun === 1 ? { lines: [...state.lines, ""], inFence: false, blankRun } : { ...state, blankRun };
|
|
966
|
+
};
|
|
967
|
+
var formatMarkdown = (md) => {
|
|
968
|
+
const { lines } = md.split("\n").reduce(scanLine, { lines: [], inFence: false, blankRun: 0 });
|
|
969
|
+
return `${lines.join("\n").replace(/\n+$/, "")}
|
|
970
|
+
`;
|
|
971
|
+
};
|
|
972
|
+
var pad2 = (n) => String(n).padStart(2, "0");
|
|
973
|
+
var formatUtc = (d) => `${String(d.getUTCFullYear())}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;
|
|
974
|
+
|
|
975
|
+
// src/notice.ts
|
|
976
|
+
var NOTICE_KINDS = [
|
|
977
|
+
"security-blocked",
|
|
978
|
+
"triage-error",
|
|
979
|
+
"setup-failed",
|
|
980
|
+
"checkout-failed",
|
|
981
|
+
"no-output"
|
|
982
|
+
];
|
|
983
|
+
var isNoticeKind = (s) => NOTICE_KINDS.some((k) => k === s);
|
|
984
|
+
var blockquote = (text) => text.replaceAll("\n", "\n> ");
|
|
985
|
+
var reasoned = (lead, noReason, reasons) => typeof reasons === "string" && reasons.trim() !== "" ? `${lead}
|
|
986
|
+
|
|
987
|
+
> ${blockquote(reasons)}` : noReason;
|
|
988
|
+
var noticeSummary = (kind, reasons) => {
|
|
989
|
+
switch (kind) {
|
|
990
|
+
case "security-blocked":
|
|
991
|
+
return reasoned(
|
|
992
|
+
"### \u{1F6D1} Code review skipped by the security gate\n\nThe diff was flagged as unsafe to apply and execute:",
|
|
993
|
+
"### \u{1F6D1} Code review skipped by the security gate\n\nThe security triage returned an unsafe verdict without a reason. See workflow logs.",
|
|
994
|
+
reasons
|
|
995
|
+
);
|
|
996
|
+
case "triage-error":
|
|
997
|
+
return reasoned(
|
|
998
|
+
"### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs). The triage step reported:",
|
|
999
|
+
"### \u{1F6E0}\uFE0F Security gate could not evaluate\n\nThe security triage could not produce a verdict (operational error), so the review failed closed \u2014 this is an infrastructure failure, not a finding about this diff. Re-run to retry a transient fault; a persistent one is a configuration issue (see the workflow logs).",
|
|
1000
|
+
reasons
|
|
1001
|
+
);
|
|
1002
|
+
case "setup-failed":
|
|
1003
|
+
return "### \u{1F6E0}\uFE0F Review did not run\n\nThe review job failed before the security triage could run (e.g. dependency install or environment setup). See the workflow logs \u2014 this is an infrastructure failure, not a security verdict.";
|
|
1004
|
+
case "checkout-failed":
|
|
1005
|
+
return "### \u26A0\uFE0F Could not check out the PR head\n\nThe PR head commit could not be fetched or checked out (it may have been force-pushed away, or is otherwise unavailable), so the review was skipped rather than run against the wrong tree. See workflow logs.";
|
|
1006
|
+
case "no-output":
|
|
1007
|
+
return "### \u26A0\uFE0F Review did not complete\n\nThe diff passed triage but the review produced no output. See workflow logs.";
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
var noticeEnvelope = (summary) => ({
|
|
1011
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
1012
|
+
findings: noticeFindings(summary),
|
|
1013
|
+
models: [],
|
|
1014
|
+
turns: 0,
|
|
1015
|
+
duration_ms: 0,
|
|
1016
|
+
vendor_cost_usd: null,
|
|
1017
|
+
incomplete: true
|
|
1018
|
+
});
|
|
1019
|
+
var buildNoticeEnvelope = (kind, reasons) => noticeEnvelope(noticeSummary(kind, reasons));
|
|
1020
|
+
var buildUnknownNoticeEnvelope = (kind) => noticeEnvelope(
|
|
1021
|
+
`### \u26A0\uFE0F Review could not be rendered
|
|
1022
|
+
|
|
1023
|
+
The workflow asked for an unrecognized notice kind (\`${kind}\`) \u2014 the pinned code-review CLI is older than the workflow calling it (check that its version matches). Failing closed. See the workflow logs.`
|
|
1024
|
+
);
|
|
380
1025
|
var identity = (decoded) => decoded;
|
|
381
1026
|
var findingsTable = [
|
|
382
1027
|
{
|
|
383
|
-
minor: "0.
|
|
1028
|
+
minor: "0.4",
|
|
384
1029
|
defaultVersion: DEFAULT_SCHEMA_VERSION,
|
|
385
1030
|
schemaFile: "findings.schema.json",
|
|
386
1031
|
codec: FindingsCodec,
|
|
@@ -460,6 +1105,7 @@ var resolvers = {
|
|
|
460
1105
|
prices: (raw) => resolveSingleVersion("prices", raw)
|
|
461
1106
|
};
|
|
462
1107
|
var resolve = (kind, raw) => resolvers[kind](raw);
|
|
1108
|
+
var describeEndpoint = (args) => args.find((a) => a === "graphql" || a.includes("/") && !a.startsWith("-")) ?? args[0] ?? "(no endpoint)";
|
|
463
1109
|
var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
|
|
464
1110
|
const child = execFile(
|
|
465
1111
|
"gh",
|
|
@@ -469,7 +1115,7 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
|
|
|
469
1115
|
if (err) {
|
|
470
1116
|
const stderrStr = typeof stderr === "string" && stderr.trim() ? stderr.trim() : "";
|
|
471
1117
|
const errStr = err instanceof Error ? err.message : "unknown error";
|
|
472
|
-
reject(new Error(`gh api failed: ${stderrStr || errStr}`));
|
|
1118
|
+
reject(new Error(`gh api ${describeEndpoint(args)} failed: ${stderrStr || errStr}`));
|
|
473
1119
|
} else {
|
|
474
1120
|
resolve3(stdout);
|
|
475
1121
|
}
|
|
@@ -481,18 +1127,34 @@ var runGhApi = (args, stdin, env) => new Promise((resolve3, reject) => {
|
|
|
481
1127
|
});
|
|
482
1128
|
|
|
483
1129
|
// src/pr.ts
|
|
1130
|
+
var CANDIDATE_JQ = ".[] | {number: .number, state: .state, headRef: .head.ref, headSha: .head.sha}";
|
|
1131
|
+
var parseCandidates = (stdout) => parseJsonl(stdout);
|
|
1132
|
+
var fetchDirectCandidates = async (repo, headSha, ghApi) => {
|
|
1133
|
+
try {
|
|
1134
|
+
return parseCandidates(
|
|
1135
|
+
await ghApi([`repos/${repo}/commits/${headSha}/pulls`, "--jq", CANDIDATE_JQ])
|
|
1136
|
+
);
|
|
1137
|
+
} catch {
|
|
1138
|
+
return [];
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
484
1141
|
var fetchPrCandidates = async (repo, headSha, ghApi) => {
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
1142
|
+
const direct = await fetchDirectCandidates(repo, headSha, ghApi);
|
|
1143
|
+
if (direct.length > 0) return direct;
|
|
1144
|
+
const open = parseCandidates(
|
|
1145
|
+
await ghApi([
|
|
1146
|
+
`repos/${repo}/pulls?state=open&per_page=100`,
|
|
1147
|
+
"--paginate",
|
|
1148
|
+
"--jq",
|
|
1149
|
+
CANDIDATE_JQ
|
|
1150
|
+
])
|
|
1151
|
+
);
|
|
1152
|
+
return open.filter((c) => c.headSha === headSha);
|
|
491
1153
|
};
|
|
492
1154
|
var resolvePr = (candidates, headBranch) => {
|
|
493
1155
|
if (candidates.length === 0) return { kind: "none" };
|
|
494
1156
|
const scoped = candidates.length > 1 && headBranch ? candidates.filter((c) => c.headRef === headBranch) : candidates;
|
|
495
|
-
const chosen = scoped[0] ?? candidates[0];
|
|
1157
|
+
const chosen = scoped.find((c) => c.state === "open") ?? scoped[0] ?? candidates[0];
|
|
496
1158
|
if (chosen === void 0) return { kind: "none" };
|
|
497
1159
|
return chosen.state === "open" ? { kind: "open", prNumber: chosen.number } : { kind: "not-open", prNumber: chosen.number, state: chosen.state };
|
|
498
1160
|
};
|
|
@@ -505,7 +1167,6 @@ var fetchDiff = async (repo, prNumber, ghApi) => ghApi([
|
|
|
505
1167
|
// src/post.ts
|
|
506
1168
|
var DEFAULT_MARKER = "<!-- code-review -->";
|
|
507
1169
|
var MAX_SUGGESTION_LINES = 10;
|
|
508
|
-
var REVIEWED_SHA_RE = /<!-- reviewed-sha: ([0-9a-f]{7,40}) -->/;
|
|
509
1170
|
var countSuggestionLines = (text) => text.split("\n").length;
|
|
510
1171
|
var checkLongSuggestions = (comments) => {
|
|
511
1172
|
const longFiles = [];
|
|
@@ -525,13 +1186,6 @@ var checkLongSuggestions = (comments) => {
|
|
|
525
1186
|
});
|
|
526
1187
|
return { comments: adjusted, longFiles };
|
|
527
1188
|
};
|
|
528
|
-
var extractReviewedSha = (commentBody) => REVIEWED_SHA_RE.exec(commentBody)?.[1] ?? null;
|
|
529
|
-
var noticeFindings = (message) => ({
|
|
530
|
-
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
531
|
-
summary: `### \u26A0\uFE0F ${message}`,
|
|
532
|
-
verdict: "comment",
|
|
533
|
-
findings: []
|
|
534
|
-
});
|
|
535
1189
|
var loadFindings = (path) => {
|
|
536
1190
|
let raw;
|
|
537
1191
|
try {
|
|
@@ -578,7 +1232,7 @@ var loadTestReport = (path) => {
|
|
|
578
1232
|
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
579
1233
|
} catch (err) {
|
|
580
1234
|
process.stderr.write(
|
|
581
|
-
`Warning: could not read test report at ${path}: ${
|
|
1235
|
+
`Warning: could not read test report at ${path}: ${errMsg(err)} \u2014 omitting test panel
|
|
582
1236
|
`
|
|
583
1237
|
);
|
|
584
1238
|
return void 0;
|
|
@@ -593,20 +1247,55 @@ var loadTestReport = (path) => {
|
|
|
593
1247
|
}
|
|
594
1248
|
return decoded.right;
|
|
595
1249
|
};
|
|
596
|
-
var
|
|
597
|
-
const
|
|
598
|
-
|
|
1250
|
+
var parseHtmlUrl = (raw) => {
|
|
1251
|
+
const parsed = tryParseJson(raw);
|
|
1252
|
+
const htmlUrl = parsed.ok ? asRecord(parsed.value)?.["html_url"] : void 0;
|
|
1253
|
+
return typeof htmlUrl === "string" ? htmlUrl : void 0;
|
|
1254
|
+
};
|
|
1255
|
+
var commentPayload = (c) => ({
|
|
1256
|
+
path: c.path,
|
|
1257
|
+
line: c.line,
|
|
1258
|
+
side: c.side,
|
|
1259
|
+
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
1260
|
+
body: formatMarkdown(c.body)
|
|
1261
|
+
});
|
|
1262
|
+
var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, marker, ghApi) => {
|
|
1263
|
+
const pointer = reviewBodyPointer(headSha, stickyUrl, marker);
|
|
1264
|
+
const reviewBody = (withComments) => JSON.stringify({
|
|
1265
|
+
body: pointer,
|
|
599
1266
|
commit_id: headSha,
|
|
600
1267
|
event: "COMMENT",
|
|
601
|
-
comments: comments.map(
|
|
602
|
-
path: c.path,
|
|
603
|
-
line: c.line,
|
|
604
|
-
side: c.side,
|
|
605
|
-
...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
|
|
606
|
-
body: c.body
|
|
607
|
-
}))
|
|
1268
|
+
comments: withComments ? comments.map(commentPayload) : []
|
|
608
1269
|
});
|
|
609
|
-
|
|
1270
|
+
const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
|
|
1271
|
+
try {
|
|
1272
|
+
const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
|
|
1273
|
+
return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
if (comments.length === 0) throw err;
|
|
1276
|
+
process.stderr.write(
|
|
1277
|
+
`Warning: the batched inline review on PR #${String(prNumber)} was rejected (${errMsg(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
|
|
1278
|
+
`
|
|
1279
|
+
);
|
|
1280
|
+
const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
|
|
1281
|
+
const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
|
|
1282
|
+
const unposted = [];
|
|
1283
|
+
let inlinePosted = 0;
|
|
1284
|
+
for (const [i, c] of comments.entries()) {
|
|
1285
|
+
try {
|
|
1286
|
+
await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
|
|
1287
|
+
inlinePosted += 1;
|
|
1288
|
+
} catch (e) {
|
|
1289
|
+
const finding = inDiff[i];
|
|
1290
|
+
if (finding) unposted.push(finding);
|
|
1291
|
+
process.stderr.write(
|
|
1292
|
+
`Warning: inline comment on ${c.path}:${String(c.line)} rejected (${errMsg(e)}) \u2014 surfacing that finding in the sticky instead (issue #57)
|
|
1293
|
+
`
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return { url, inlinePosted, unposted };
|
|
1298
|
+
}
|
|
610
1299
|
};
|
|
611
1300
|
var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
612
1301
|
const stdout = await ghApi(
|
|
@@ -626,33 +1315,44 @@ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
|
|
|
626
1315
|
const parsed = JSON.parse(last);
|
|
627
1316
|
return { id: parsed.id, body: parsed.body };
|
|
628
1317
|
};
|
|
1318
|
+
var parseCommentRef = (raw) => {
|
|
1319
|
+
const parsed = tryParseJson(raw);
|
|
1320
|
+
const rec = parsed.ok ? asRecord(parsed.value) : null;
|
|
1321
|
+
const id = rec?.["id"];
|
|
1322
|
+
const html_url = rec?.["html_url"];
|
|
1323
|
+
return typeof id === "number" && typeof html_url === "string" ? { id, html_url } : null;
|
|
1324
|
+
};
|
|
629
1325
|
var patchComment = async (repo, commentId, body, ghApi) => {
|
|
630
|
-
await ghApi(
|
|
1326
|
+
const stdout = await ghApi(
|
|
631
1327
|
[`repos/${repo}/issues/comments/${String(commentId)}`, "--input", "-"],
|
|
632
1328
|
JSON.stringify({ body })
|
|
633
1329
|
);
|
|
1330
|
+
const htmlUrl = parseHtmlUrl(stdout);
|
|
1331
|
+
return htmlUrl !== void 0 ? { html_url: htmlUrl } : null;
|
|
634
1332
|
};
|
|
635
1333
|
var postComment = async (repo, prNumber, body, ghApi) => {
|
|
636
|
-
await ghApi(
|
|
1334
|
+
const stdout = await ghApi(
|
|
637
1335
|
[`repos/${repo}/issues/${String(prNumber)}/comments`, "--input", "-"],
|
|
638
1336
|
JSON.stringify({ body })
|
|
639
1337
|
);
|
|
1338
|
+
return parseCommentRef(stdout);
|
|
640
1339
|
};
|
|
641
1340
|
var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
|
|
642
1341
|
if (existing !== null) {
|
|
643
|
-
await patchComment(repo, existing.id, body, ghApi);
|
|
1342
|
+
const patched = await patchComment(repo, existing.id, body, ghApi);
|
|
644
1343
|
process.stderr.write(
|
|
645
1344
|
`Updated sticky comment #${String(existing.id)} on PR #${String(prNumber)}
|
|
646
1345
|
`
|
|
647
1346
|
);
|
|
648
|
-
|
|
649
|
-
await postComment(repo, prNumber, body, ghApi);
|
|
650
|
-
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
651
|
-
`);
|
|
1347
|
+
return { id: existing.id, url: patched?.html_url };
|
|
652
1348
|
}
|
|
1349
|
+
const posted = await postComment(repo, prNumber, body, ghApi);
|
|
1350
|
+
process.stderr.write(`Posted new sticky comment on PR #${String(prNumber)}
|
|
1351
|
+
`);
|
|
1352
|
+
return posted ? { id: posted.id, url: posted.html_url } : null;
|
|
653
1353
|
};
|
|
654
1354
|
var isBotReview = (r) => typeof r === "object" && r !== null && typeof r.id === "number" && typeof r.state === "string" && typeof r.user?.login === "string";
|
|
655
|
-
var
|
|
1355
|
+
var fetchBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
656
1356
|
const stdout = await ghApi([`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--paginate"]);
|
|
657
1357
|
let reviews;
|
|
658
1358
|
try {
|
|
@@ -661,10 +1361,9 @@ var fetchBotReviewIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
661
1361
|
return [];
|
|
662
1362
|
}
|
|
663
1363
|
if (!Array.isArray(reviews)) return [];
|
|
664
|
-
return reviews.filter(
|
|
1364
|
+
return reviews.filter(isBotReview).filter((r) => r.user.login === botLogin && r.state !== "DISMISSED").map((r) => ({ id: r.id }));
|
|
665
1365
|
};
|
|
666
|
-
var
|
|
667
|
-
const ids = await fetchBotReviewIds(repo, prNumber, botLogin, ghApi);
|
|
1366
|
+
var dismissReviews = async (repo, prNumber, ids, ghApi) => {
|
|
668
1367
|
for (const id of ids) {
|
|
669
1368
|
try {
|
|
670
1369
|
await ghApi(
|
|
@@ -679,11 +1378,91 @@ var dismissPriorBotReviews = async (repo, prNumber, botLogin, ghApi) => {
|
|
|
679
1378
|
);
|
|
680
1379
|
} catch (err) {
|
|
681
1380
|
process.stderr.write(
|
|
682
|
-
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${
|
|
1381
|
+
`Warning: failed to dismiss prior review #${String(id)} on PR #${String(prNumber)}: ${errMsg(err)}
|
|
1382
|
+
`
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
var REVIEW_THREAD_COMMENTS_QUERY = "query($owner:String!,$name:String!,$pr:Int!){repository(owner:$owner,name:$name){pullRequest(number:$pr){reviewThreads(first:100){pageInfo{hasNextPage}nodes{comments(first:100){nodes{id isMinimized author{login}}}}}}}}";
|
|
1388
|
+
var MINIMIZE_COMMENT_MUTATION = "mutation($id:ID!){minimizeComment(input:{subjectId:$id,classifier:OUTDATED}){minimizedComment{isMinimized}}}";
|
|
1389
|
+
var priorBotCommentId = (c, logins) => {
|
|
1390
|
+
if (typeof c !== "object" || c === null) return null;
|
|
1391
|
+
const o = c;
|
|
1392
|
+
const login = o.author?.login;
|
|
1393
|
+
return typeof o.id === "string" && o.isMinimized !== true && typeof login === "string" && logins.includes(login) ? o.id : null;
|
|
1394
|
+
};
|
|
1395
|
+
var priorBotCommentIds = (raw, botLogin) => {
|
|
1396
|
+
let parsed;
|
|
1397
|
+
try {
|
|
1398
|
+
parsed = JSON.parse(raw);
|
|
1399
|
+
} catch {
|
|
1400
|
+
return { ids: [], truncated: false };
|
|
1401
|
+
}
|
|
1402
|
+
const conn = parsed.data?.repository?.pullRequest?.reviewThreads;
|
|
1403
|
+
const truncated = conn?.pageInfo?.hasNextPage === true;
|
|
1404
|
+
const nodes = conn?.nodes;
|
|
1405
|
+
if (!Array.isArray(nodes)) return { ids: [], truncated };
|
|
1406
|
+
const logins = [botLogin.replace(/\[bot\]$/, ""), botLogin];
|
|
1407
|
+
const ids = nodes.flatMap((t7) => {
|
|
1408
|
+
const cnodes = t7.comments?.nodes;
|
|
1409
|
+
return Array.isArray(cnodes) ? cnodes.map((c) => priorBotCommentId(c, logins)).filter((id) => id !== null) : [];
|
|
1410
|
+
});
|
|
1411
|
+
return { ids, truncated };
|
|
1412
|
+
};
|
|
1413
|
+
var listPriorBotCommentIds = async (repo, prNumber, botLogin, ghApi) => {
|
|
1414
|
+
const slash = repo.indexOf("/");
|
|
1415
|
+
if (slash <= 0) return [];
|
|
1416
|
+
const owner = repo.slice(0, slash);
|
|
1417
|
+
const name = repo.slice(slash + 1);
|
|
1418
|
+
let raw;
|
|
1419
|
+
try {
|
|
1420
|
+
raw = await ghApi([
|
|
1421
|
+
"graphql",
|
|
1422
|
+
"-f",
|
|
1423
|
+
`query=${REVIEW_THREAD_COMMENTS_QUERY}`,
|
|
1424
|
+
"-f",
|
|
1425
|
+
`owner=${owner}`,
|
|
1426
|
+
"-f",
|
|
1427
|
+
`name=${name}`,
|
|
1428
|
+
"-F",
|
|
1429
|
+
`pr=${String(prNumber)}`
|
|
1430
|
+
]);
|
|
1431
|
+
} catch (err) {
|
|
1432
|
+
process.stderr.write(
|
|
1433
|
+
`Warning: could not list review threads to minimize stale comments on PR #${String(prNumber)}: ${errMsg(err)}
|
|
1434
|
+
`
|
|
1435
|
+
);
|
|
1436
|
+
return [];
|
|
1437
|
+
}
|
|
1438
|
+
const { ids, truncated } = priorBotCommentIds(raw, botLogin);
|
|
1439
|
+
if (truncated) {
|
|
1440
|
+
process.stderr.write(
|
|
1441
|
+
`Note: PR #${String(prNumber)} has more than 100 review threads \u2014 only the first 100 were scanned for stale bot comments
|
|
1442
|
+
`
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
return ids;
|
|
1446
|
+
};
|
|
1447
|
+
var minimizeComments = async (prNumber, ids, ghApi) => {
|
|
1448
|
+
let minimized = 0;
|
|
1449
|
+
for (const id of ids) {
|
|
1450
|
+
try {
|
|
1451
|
+
await ghApi(["graphql", "-f", `query=${MINIMIZE_COMMENT_MUTATION}`, "-f", `id=${id}`]);
|
|
1452
|
+
minimized += 1;
|
|
1453
|
+
} catch (err) {
|
|
1454
|
+
process.stderr.write(
|
|
1455
|
+
`Warning: failed to minimize a stale review comment on PR #${String(prNumber)}: ${errMsg(err)}
|
|
683
1456
|
`
|
|
684
1457
|
);
|
|
685
1458
|
}
|
|
686
1459
|
}
|
|
1460
|
+
if (minimized > 0) {
|
|
1461
|
+
process.stderr.write(
|
|
1462
|
+
`Minimized ${String(minimized)} stale inline comment(s) from superseded reviews on PR #${String(prNumber)}
|
|
1463
|
+
`
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
687
1466
|
};
|
|
688
1467
|
var post = async (input, ghApi = runGhApi) => {
|
|
689
1468
|
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
@@ -709,25 +1488,40 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
709
1488
|
DEFAULT_MARKER,
|
|
710
1489
|
ghApi
|
|
711
1490
|
);
|
|
712
|
-
const
|
|
713
|
-
const
|
|
1491
|
+
const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
|
|
1492
|
+
const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
|
|
1493
|
+
const leaveInPlace = () => {
|
|
1494
|
+
process.stderr.write(
|
|
1495
|
+
`Review did not complete and the sticky already reflects a completed review \u2014 leaving it in place
|
|
1496
|
+
`
|
|
1497
|
+
);
|
|
1498
|
+
process.exit(0);
|
|
1499
|
+
};
|
|
714
1500
|
const prices = JSON.parse(readFileSync(input.pricesPath, "utf-8"));
|
|
715
1501
|
const decodedPrices = PriceMapCodec.decode(prices);
|
|
716
1502
|
if (decodedPrices._tag === "Left") {
|
|
717
1503
|
throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
|
|
718
1504
|
}
|
|
719
1505
|
const template = readFileSync(input.templatePath, "utf-8");
|
|
720
|
-
const inlineTemplate =
|
|
721
|
-
const renderNotice = (message) =>
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1506
|
+
const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
|
|
1507
|
+
const renderNotice = (message) => formatMarkdown(
|
|
1508
|
+
render({
|
|
1509
|
+
findings: noticeFindings(`### \u26A0\uFE0F ${message}`),
|
|
1510
|
+
envelope: null,
|
|
1511
|
+
incomplete: true,
|
|
1512
|
+
prices: decodedPrices.right,
|
|
1513
|
+
pricesProvided: input.pricesProvided,
|
|
1514
|
+
template,
|
|
1515
|
+
route: input.route,
|
|
1516
|
+
reviewedSha: input.headSha,
|
|
1517
|
+
effort: input.effort,
|
|
1518
|
+
runUrl: input.runUrl,
|
|
1519
|
+
jsonUrl: input.jsonUrl,
|
|
1520
|
+
postedAt: input.postedAt
|
|
1521
|
+
})
|
|
1522
|
+
);
|
|
730
1523
|
if (isEmptyDiff(diff)) {
|
|
1524
|
+
if (wouldBuryCompleted(true)) leaveInPlace();
|
|
731
1525
|
await upsertSticky(
|
|
732
1526
|
input.repo,
|
|
733
1527
|
prNumber,
|
|
@@ -739,6 +1533,7 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
739
1533
|
}
|
|
740
1534
|
const findingsResult = loadFindings(input.findingsPath);
|
|
741
1535
|
if (findingsResult.kind !== "ok") {
|
|
1536
|
+
if (wouldBuryCompleted(true)) leaveInPlace();
|
|
742
1537
|
await upsertSticky(
|
|
743
1538
|
input.repo,
|
|
744
1539
|
prNumber,
|
|
@@ -752,27 +1547,42 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
752
1547
|
const envelope = loadEnvelope(input.envelopePath);
|
|
753
1548
|
const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
|
|
754
1549
|
if (envelope === null) {
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1550
|
+
const body = formatMarkdown(
|
|
1551
|
+
render({
|
|
1552
|
+
findings,
|
|
1553
|
+
envelope: null,
|
|
1554
|
+
prices: decodedPrices.right,
|
|
1555
|
+
pricesProvided: input.pricesProvided,
|
|
1556
|
+
template,
|
|
1557
|
+
route: input.route,
|
|
1558
|
+
reviewedSha: input.headSha,
|
|
1559
|
+
effort: input.effort,
|
|
1560
|
+
testReport,
|
|
1561
|
+
inlineDisposition: { kind: "no-envelope" },
|
|
1562
|
+
runUrl: input.runUrl,
|
|
1563
|
+
jsonUrl: input.jsonUrl,
|
|
1564
|
+
postedAt: input.postedAt
|
|
1565
|
+
})
|
|
1566
|
+
);
|
|
1567
|
+
await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
|
|
766
1568
|
process.stderr.write(
|
|
767
1569
|
"Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
|
|
768
1570
|
);
|
|
769
1571
|
process.exit(0);
|
|
770
1572
|
}
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1573
|
+
const thisIncomplete = envelope.incomplete === true;
|
|
1574
|
+
if (wouldBuryCompleted(thisIncomplete)) leaveInPlace();
|
|
1575
|
+
const findingsMarker = findingsPointer(findings, input.jsonUrl);
|
|
1576
|
+
const {
|
|
1577
|
+
comments: rawComments,
|
|
1578
|
+
strays,
|
|
1579
|
+
inDiff
|
|
1580
|
+
} = buildInlineComments(findings.findings, diff, {
|
|
1581
|
+
inlineTemplate,
|
|
1582
|
+
models: envelope.models.map((m) => m.model),
|
|
1583
|
+
findings,
|
|
1584
|
+
jsonUrl: input.jsonUrl
|
|
1585
|
+
});
|
|
776
1586
|
const { comments, longFiles } = checkLongSuggestions(rawComments);
|
|
777
1587
|
for (const wf of longFiles) {
|
|
778
1588
|
process.stderr.write(
|
|
@@ -780,45 +1590,392 @@ var post = async (input, ghApi = runGhApi) => {
|
|
|
780
1590
|
`
|
|
781
1591
|
);
|
|
782
1592
|
}
|
|
783
|
-
|
|
1593
|
+
const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
|
|
1594
|
+
const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
|
|
1595
|
+
const commonRenderInput = {
|
|
784
1596
|
findings,
|
|
785
1597
|
envelope,
|
|
1598
|
+
incomplete: thisIncomplete,
|
|
786
1599
|
prices: decodedPrices.right,
|
|
1600
|
+
pricesProvided: input.pricesProvided,
|
|
787
1601
|
template,
|
|
788
1602
|
route: input.route,
|
|
789
1603
|
reviewedSha: input.headSha,
|
|
790
1604
|
effort: input.effort,
|
|
791
|
-
testReport
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1605
|
+
testReport,
|
|
1606
|
+
severityCounts: computeSeverityCounts(findings.findings),
|
|
1607
|
+
strays,
|
|
1608
|
+
runUrl: input.runUrl,
|
|
1609
|
+
jsonUrl: input.jsonUrl,
|
|
1610
|
+
findingsPointer: findingsMarker,
|
|
1611
|
+
postedAt: input.postedAt
|
|
1612
|
+
};
|
|
1613
|
+
const longFilesNote = longFiles.length > 0 ? `
|
|
797
1614
|
|
|
798
1615
|
---
|
|
799
1616
|
|
|
800
|
-
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from inline comments
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1617
|
+
> **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
|
|
1618
|
+
` : "";
|
|
1619
|
+
const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2) => formatMarkdown(
|
|
1620
|
+
render({
|
|
1621
|
+
...commonRenderInput,
|
|
1622
|
+
...straysOverride ? { strays: straysOverride } : {},
|
|
1623
|
+
...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
|
|
1624
|
+
inlineDisposition,
|
|
1625
|
+
reviewUrl: reviewUrl2
|
|
1626
|
+
}) + longFilesNote
|
|
1627
|
+
);
|
|
1628
|
+
const stickyRef = await upsertSticky(
|
|
1629
|
+
input.repo,
|
|
1630
|
+
prNumber,
|
|
1631
|
+
existingSticky,
|
|
1632
|
+
renderBody(initialDisposition),
|
|
1633
|
+
ghApi
|
|
1634
|
+
);
|
|
1635
|
+
const priorInlineComments = await listPriorBotCommentIds(
|
|
1636
|
+
input.repo,
|
|
1637
|
+
prNumber,
|
|
1638
|
+
input.botLogin,
|
|
1639
|
+
ghApi
|
|
1640
|
+
);
|
|
1641
|
+
const {
|
|
1642
|
+
url: reviewUrl,
|
|
1643
|
+
inlinePosted,
|
|
1644
|
+
unposted
|
|
1645
|
+
} = await postInlineReview(
|
|
1646
|
+
input.repo,
|
|
1647
|
+
prNumber,
|
|
1648
|
+
input.headSha,
|
|
1649
|
+
comments,
|
|
1650
|
+
inDiff,
|
|
1651
|
+
stickyRef?.url,
|
|
1652
|
+
findingsMarker,
|
|
1653
|
+
ghApi
|
|
1654
|
+
);
|
|
1655
|
+
process.stderr.write(
|
|
1656
|
+
`Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
|
|
810
1657
|
`
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1658
|
+
);
|
|
1659
|
+
const priorReviewIds = botReviews.map((r) => r.id);
|
|
1660
|
+
if (priorReviewIds.length > 0) {
|
|
1661
|
+
await dismissReviews(input.repo, prNumber, priorReviewIds, ghApi);
|
|
1662
|
+
}
|
|
1663
|
+
await minimizeComments(prNumber, priorInlineComments, ghApi);
|
|
1664
|
+
const unanchoredCount = unposted.length;
|
|
1665
|
+
const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
|
|
1666
|
+
if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
|
|
1667
|
+
const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
|
|
1668
|
+
try {
|
|
1669
|
+
await patchComment(
|
|
1670
|
+
input.repo,
|
|
1671
|
+
stickyRef.id,
|
|
1672
|
+
renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount),
|
|
1673
|
+
ghApi
|
|
1674
|
+
);
|
|
1675
|
+
process.stderr.write(
|
|
1676
|
+
`Updated sticky comment #${String(stickyRef.id)} to reflect the review
|
|
818
1677
|
`
|
|
819
|
-
|
|
1678
|
+
);
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
process.stderr.write(
|
|
1681
|
+
`Warning: failed to update the sticky summary after the review: ${errMsg(err)}
|
|
1682
|
+
`
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
};
|
|
1687
|
+
var announceBody = (headSha, runUrl, existingBody) => {
|
|
1688
|
+
const notice = `${DEFAULT_MARKER}
|
|
1689
|
+
|
|
1690
|
+
\u{1F504} **Code review in progress** for \`${headSha.slice(0, 7)}\` \u2014 see the [workflow run](${runUrl}) for progress; this comment is updated with the review when it completes.`;
|
|
1691
|
+
const carried = existingBody ? carryForwardMarkers(existingBody) : "";
|
|
1692
|
+
return carried ? `${notice}
|
|
1693
|
+
|
|
1694
|
+
${carried}` : notice;
|
|
1695
|
+
};
|
|
1696
|
+
var announce = async (input, ghApi = runGhApi) => {
|
|
1697
|
+
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
1698
|
+
const resolution = resolvePr(candidates, input.headBranch);
|
|
1699
|
+
if (resolution.kind !== "open") {
|
|
1700
|
+
process.stderr.write(
|
|
1701
|
+
`No open PR for ${input.headSha} \u2014 nothing to announce (${resolution.kind})
|
|
1702
|
+
`
|
|
1703
|
+
);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const existing = await findBotComment(
|
|
1707
|
+
input.repo,
|
|
1708
|
+
resolution.prNumber,
|
|
1709
|
+
input.botLogin,
|
|
1710
|
+
DEFAULT_MARKER,
|
|
1711
|
+
ghApi
|
|
1712
|
+
);
|
|
1713
|
+
if (existing !== null && parseReviewComplete(existing.body) && parseReviewedSha(existing.body) === input.headSha.toLowerCase()) {
|
|
1714
|
+
process.stderr.write(
|
|
1715
|
+
`Sticky already reflects a completed review of ${input.headSha} \u2014 leaving it in place
|
|
1716
|
+
`
|
|
1717
|
+
);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
await upsertSticky(
|
|
1721
|
+
input.repo,
|
|
1722
|
+
resolution.prNumber,
|
|
1723
|
+
existing,
|
|
1724
|
+
announceBody(input.headSha, input.runUrl, existing?.body),
|
|
1725
|
+
ghApi
|
|
1726
|
+
);
|
|
1727
|
+
};
|
|
1728
|
+
var DURATION_RE = /^(\d+)(h|m|s)$/;
|
|
1729
|
+
var USD_RE = /^\$(\d+(?:\.\d+)?)$/;
|
|
1730
|
+
var toSeconds = (n, unit) => unit === "h" ? n * 3600 : unit === "m" ? n * 60 : n;
|
|
1731
|
+
var stripTrigger = (body, trigger) => {
|
|
1732
|
+
const trimmed = body.replace(/^\s+/, "");
|
|
1733
|
+
if (!trimmed.startsWith(trigger)) return null;
|
|
1734
|
+
const after = trimmed.slice(trigger.length);
|
|
1735
|
+
return after === "" || /^\s/.test(after) ? after : null;
|
|
1736
|
+
};
|
|
1737
|
+
var scanLeading = (s, acc) => {
|
|
1738
|
+
const m = /^(\s*)(\S+)([\s\S]*)$/.exec(s);
|
|
1739
|
+
if (m === null) return { ...acc, rest: "" };
|
|
1740
|
+
const [, , token = "", tail = ""] = m;
|
|
1741
|
+
const dm = DURATION_RE.exec(token);
|
|
1742
|
+
if (dm && acc.durationSec === null)
|
|
1743
|
+
return scanLeading(tail, {
|
|
1744
|
+
...acc,
|
|
1745
|
+
durationSec: toSeconds(Number.parseInt(dm[1] ?? "", 10), dm[2] ?? "s")
|
|
1746
|
+
});
|
|
1747
|
+
const um = USD_RE.exec(token);
|
|
1748
|
+
if (um && acc.usd === null)
|
|
1749
|
+
return scanLeading(tail, { ...acc, usd: Number.parseFloat(um[1] ?? "") });
|
|
1750
|
+
return { ...acc, rest: s };
|
|
1751
|
+
};
|
|
1752
|
+
var clampDuration = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
|
|
1753
|
+
value: ceiling,
|
|
1754
|
+
notes: [
|
|
1755
|
+
`requested duration ${String(requested)}s exceeds the ${String(ceiling)}s ceiling \u2014 clamped to ${String(ceiling)}s`
|
|
1756
|
+
]
|
|
1757
|
+
} : { value: requested, notes: [] };
|
|
1758
|
+
var clampUsd = (requested, ceiling) => requested !== null && ceiling !== null && requested > ceiling ? {
|
|
1759
|
+
value: ceiling,
|
|
1760
|
+
notes: [
|
|
1761
|
+
`requested $${requested.toFixed(2)} exceeds the $${ceiling.toFixed(2)} ceiling \u2014 clamped to $${ceiling.toFixed(2)}`
|
|
1762
|
+
]
|
|
1763
|
+
} : { value: requested, notes: [] };
|
|
1764
|
+
var capInstructions = (text, maxLen) => text.length > maxLen ? {
|
|
1765
|
+
value: text.slice(0, maxLen),
|
|
1766
|
+
notes: [
|
|
1767
|
+
`instructions truncated from ${String(text.length)} to ${String(maxLen)} characters`
|
|
1768
|
+
]
|
|
1769
|
+
} : { value: text, notes: [] };
|
|
1770
|
+
var parseCommandArgs = (body, options) => {
|
|
1771
|
+
const afterTrigger = stripTrigger(body, options.trigger);
|
|
1772
|
+
if (afterTrigger === null) return { kind: "not-a-command" };
|
|
1773
|
+
const scan = scanLeading(afterTrigger, { durationSec: null, usd: null });
|
|
1774
|
+
const duration = clampDuration(scan.durationSec, options.maxDurationSec);
|
|
1775
|
+
const usd = clampUsd(scan.usd, options.maxUsd);
|
|
1776
|
+
const instructions = capInstructions(scan.rest.trim(), options.maxInstructionsLen);
|
|
1777
|
+
return {
|
|
1778
|
+
kind: "command",
|
|
1779
|
+
args: {
|
|
1780
|
+
durationSec: duration.value,
|
|
1781
|
+
usd: usd.value,
|
|
1782
|
+
instructions: instructions.value,
|
|
1783
|
+
notes: [...duration.notes, ...usd.notes, ...instructions.notes]
|
|
1784
|
+
}
|
|
1785
|
+
};
|
|
1786
|
+
};
|
|
1787
|
+
var PrHeadCodec = t.type({
|
|
1788
|
+
head_sha: t.string,
|
|
1789
|
+
head_ref: t.string,
|
|
1790
|
+
head_repo: t.union([t.string, t.null]),
|
|
1791
|
+
state: t.string
|
|
1792
|
+
});
|
|
1793
|
+
var resolvePrHead = async (repo, prNumber, ghApi) => {
|
|
1794
|
+
const stdout = await ghApi([
|
|
1795
|
+
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
1796
|
+
"--jq",
|
|
1797
|
+
"{head_sha: .head.sha, head_ref: .head.ref, head_repo: .head.repo.full_name, state: .state}"
|
|
1798
|
+
]);
|
|
1799
|
+
const decoded = PrHeadCodec.decode(JSON.parse(stdout));
|
|
1800
|
+
if (decoded._tag === "Left") {
|
|
1801
|
+
throw new Error(`PR head for #${String(prNumber)} did not match the expected shape`);
|
|
1802
|
+
}
|
|
1803
|
+
return decoded.right;
|
|
1804
|
+
};
|
|
1805
|
+
var parseCommand = async (input, ghApi = runGhApi) => {
|
|
1806
|
+
const parse = parseCommandArgs(input.body, input.options);
|
|
1807
|
+
if (parse.kind === "not-a-command") {
|
|
1808
|
+
return {
|
|
1809
|
+
kind: "skip",
|
|
1810
|
+
reason: `comment does not begin with the trigger "${input.options.trigger}"`
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
const head = await resolvePrHead(input.repo, input.prNumber, ghApi).catch(
|
|
1814
|
+
(err) => err instanceof Error ? err : new Error(String(err))
|
|
1815
|
+
);
|
|
1816
|
+
if (head instanceof Error) {
|
|
1817
|
+
return {
|
|
1818
|
+
kind: "skip",
|
|
1819
|
+
reason: `could not resolve PR #${String(input.prNumber)}: ${head.message}`
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
if (head.state !== "open") {
|
|
1823
|
+
return {
|
|
1824
|
+
kind: "skip",
|
|
1825
|
+
reason: `PR #${String(input.prNumber)} is not open (state: ${head.state})`
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
return {
|
|
1829
|
+
kind: "run",
|
|
1830
|
+
headSha: head.head_sha,
|
|
1831
|
+
headBranch: head.head_ref,
|
|
1832
|
+
headRepo: head.head_repo ?? input.repo,
|
|
1833
|
+
args: parse.args
|
|
1834
|
+
};
|
|
1835
|
+
};
|
|
1836
|
+
var safeHeredocDelim = (instructions, randomHex, attemptsLeft = 8) => {
|
|
1837
|
+
const candidate = `GHOUT_${randomHex()}`;
|
|
1838
|
+
if (!instructions.split("\n").includes(candidate)) return candidate;
|
|
1839
|
+
if (attemptsLeft <= 0) throw new Error("could not derive a collision-free heredoc delimiter");
|
|
1840
|
+
return safeHeredocDelim(instructions, randomHex, attemptsLeft - 1);
|
|
1841
|
+
};
|
|
1842
|
+
var renderCommandOutputs = (result, delim) => {
|
|
1843
|
+
if (result.kind === "skip") return "should_run=false\n";
|
|
1844
|
+
const { headSha, headBranch, headRepo, args } = result;
|
|
1845
|
+
return `${[
|
|
1846
|
+
"should_run=true",
|
|
1847
|
+
`head_sha=${headSha}`,
|
|
1848
|
+
`head_branch=${headBranch}`,
|
|
1849
|
+
`head_repo=${headRepo}`,
|
|
1850
|
+
`duration=${args.durationSec === null ? "" : `${String(args.durationSec)}s`}`,
|
|
1851
|
+
`usd=${args.usd === null ? "" : args.usd.toFixed(2)}`,
|
|
1852
|
+
`instructions<<${delim}`,
|
|
1853
|
+
args.instructions,
|
|
1854
|
+
delim
|
|
1855
|
+
].join("\n")}
|
|
1856
|
+
`;
|
|
1857
|
+
};
|
|
1858
|
+
var REACTIONS = [
|
|
1859
|
+
"+1",
|
|
1860
|
+
"-1",
|
|
1861
|
+
"laugh",
|
|
1862
|
+
"confused",
|
|
1863
|
+
"heart",
|
|
1864
|
+
"hooray",
|
|
1865
|
+
"rocket",
|
|
1866
|
+
"eyes"
|
|
1867
|
+
];
|
|
1868
|
+
var isReaction = (s) => REACTIONS.includes(s);
|
|
1869
|
+
var ReactionCodec = t.type({ id: t.number, content: t.string });
|
|
1870
|
+
var reactionsPath = (repo, commentId) => `repos/${repo}/issues/comments/${String(commentId)}/reactions`;
|
|
1871
|
+
var removeReactions = async (repo, commentId, content, ghApi) => {
|
|
1872
|
+
const stdout = await ghApi([
|
|
1873
|
+
reactionsPath(repo, commentId),
|
|
1874
|
+
"--paginate",
|
|
1875
|
+
"--jq",
|
|
1876
|
+
".[] | {id, content}"
|
|
1877
|
+
]);
|
|
1878
|
+
for (const line of stdout.split("\n").filter((l) => l.trim() !== "")) {
|
|
1879
|
+
const parsed = tryParseJson(line);
|
|
1880
|
+
const decoded = parsed.ok ? ReactionCodec.decode(parsed.value) : void 0;
|
|
1881
|
+
if (decoded === void 0 || decoded._tag === "Left") {
|
|
1882
|
+
process.stderr.write("code-review react: could not decode a reaction entry \u2014 skipping\n");
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (decoded.right.content !== content) continue;
|
|
1886
|
+
await ghApi([
|
|
1887
|
+
"--method",
|
|
1888
|
+
"DELETE",
|
|
1889
|
+
`${reactionsPath(repo, commentId)}/${String(decoded.right.id)}`
|
|
1890
|
+
]).catch(
|
|
1891
|
+
(err) => process.stderr.write(
|
|
1892
|
+
`code-review react: could not remove reaction ${String(decoded.right.id)} (${errMsg(err)}) \u2014 skipping
|
|
1893
|
+
`
|
|
1894
|
+
)
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
var react = async (input, ghApi = runGhApi) => {
|
|
1899
|
+
if (input.add !== void 0) {
|
|
1900
|
+
await ghApi([
|
|
1901
|
+
"--method",
|
|
1902
|
+
"POST",
|
|
1903
|
+
reactionsPath(input.repo, input.commentId),
|
|
1904
|
+
"-f",
|
|
1905
|
+
`content=${input.add}`
|
|
1906
|
+
]);
|
|
1907
|
+
}
|
|
1908
|
+
if (input.remove !== void 0) {
|
|
1909
|
+
await removeReactions(input.repo, input.commentId, input.remove, ghApi);
|
|
1910
|
+
}
|
|
1911
|
+
};
|
|
1912
|
+
var RunCodec = t.type({
|
|
1913
|
+
id: t.number,
|
|
1914
|
+
name: t.union([t.string, t.null]),
|
|
1915
|
+
status: t.union([t.string, t.null]),
|
|
1916
|
+
conclusion: t.union([t.string, t.null]),
|
|
1917
|
+
run_number: t.number
|
|
1918
|
+
});
|
|
1919
|
+
var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
|
|
1920
|
+
var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
|
|
1921
|
+
const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
|
|
1922
|
+
const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
|
|
1923
|
+
const decoded = rows.map((row) => RunCodec.decode(row));
|
|
1924
|
+
const runs = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
|
|
1925
|
+
const dropped = decoded.length - runs.length;
|
|
1926
|
+
if (dropped > 0) {
|
|
1927
|
+
const firstDrift = decoded.find((d) => d._tag === "Left");
|
|
1928
|
+
const detail = firstDrift === void 0 ? "" : ` (${PathReporter.report(firstDrift).join("; ")})`;
|
|
1929
|
+
process.stderr.write(
|
|
1930
|
+
`Warning: ${String(dropped)} of ${String(rows.length)} workflow-run row(s) from ${endpoint} failed to decode${detail} \u2014 excluded from the lookup
|
|
1931
|
+
`
|
|
1932
|
+
);
|
|
820
1933
|
}
|
|
1934
|
+
const latest = runs.filter((r) => r.name === workflowName).reduce(
|
|
1935
|
+
(best, r) => best === null || r.run_number > best.run_number ? r : best,
|
|
1936
|
+
null
|
|
1937
|
+
);
|
|
1938
|
+
return {
|
|
1939
|
+
run: latest === null ? null : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
|
|
1940
|
+
seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
|
|
1941
|
+
};
|
|
821
1942
|
};
|
|
1943
|
+
var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi, sleep: defaultSleep, elapsedMs: monotonicElapsed() }) => {
|
|
1944
|
+
const safeResolve = async () => {
|
|
1945
|
+
try {
|
|
1946
|
+
return await resolveCiRun(repo, headSha, options.workflowName, deps.ghApi);
|
|
1947
|
+
} catch (err) {
|
|
1948
|
+
process.stderr.write(
|
|
1949
|
+
`Warning: CI-run lookup for ${headSha} failed (${errMsg(err)}) \u2014 retrying until the timeout
|
|
1950
|
+
`
|
|
1951
|
+
);
|
|
1952
|
+
return { run: null, seenNames: [] };
|
|
1953
|
+
}
|
|
1954
|
+
};
|
|
1955
|
+
const poll = async (lastSeenNames, lastRunId) => {
|
|
1956
|
+
const { run, seenNames } = await safeResolve();
|
|
1957
|
+
if (run !== null && run.status === "completed" && run.conclusion !== null)
|
|
1958
|
+
return { kind: "concluded", conclusion: run.conclusion, runId: run.id };
|
|
1959
|
+
const runId = run === null ? lastRunId : run.id;
|
|
1960
|
+
const names = seenNames.length > 0 ? seenNames : lastSeenNames;
|
|
1961
|
+
if (deps.elapsedMs() >= options.timeoutMs)
|
|
1962
|
+
return { kind: "timed-out", runId, seenNames: names };
|
|
1963
|
+
await deps.sleep(options.pollIntervalMs);
|
|
1964
|
+
return poll(names, runId);
|
|
1965
|
+
};
|
|
1966
|
+
return poll([], null);
|
|
1967
|
+
};
|
|
1968
|
+
var defaultSleep = (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
1969
|
+
var monotonicElapsed = () => {
|
|
1970
|
+
const start = performance.now();
|
|
1971
|
+
return () => performance.now() - start;
|
|
1972
|
+
};
|
|
1973
|
+
var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
|
|
1974
|
+
ci_conclusion=${outcome.conclusion}
|
|
1975
|
+
ci_run_id=${String(outcome.runId)}
|
|
1976
|
+
` : `ci_settled=false
|
|
1977
|
+
ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
|
|
1978
|
+
`;
|
|
822
1979
|
var renderOutputs = (result) => {
|
|
823
1980
|
switch (result.kind) {
|
|
824
1981
|
case "skip":
|
|
@@ -827,6 +1984,7 @@ var renderOutputs = (result) => {
|
|
|
827
1984
|
return `pr=${String(result.pr)}
|
|
828
1985
|
conclusion=${result.conclusion}
|
|
829
1986
|
diff_size=${String(result.diffSize)}
|
|
1987
|
+
stacked=${String(result.stacked)}
|
|
830
1988
|
`;
|
|
831
1989
|
}
|
|
832
1990
|
};
|
|
@@ -849,22 +2007,28 @@ var runGit = (args) => new Promise((resolve3, reject) => {
|
|
|
849
2007
|
var PrMetaCodec = t.type({
|
|
850
2008
|
changed_files: t.number,
|
|
851
2009
|
base_sha: t.string,
|
|
2010
|
+
base_ref: t.string,
|
|
852
2011
|
title: t.string,
|
|
853
2012
|
body: t.union([t.string, t.null])
|
|
854
2013
|
});
|
|
855
|
-
var IssueCommentCodec = t.
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
})
|
|
860
|
-
|
|
2014
|
+
var IssueCommentCodec = t.intersection([
|
|
2015
|
+
t.type({
|
|
2016
|
+
id: t.number,
|
|
2017
|
+
body: t.union([t.string, t.null]),
|
|
2018
|
+
user: t.type({ login: t.string })
|
|
2019
|
+
}),
|
|
2020
|
+
t.partial({
|
|
2021
|
+
created_at: t.union([t.string, t.null]),
|
|
2022
|
+
author_association: t.union([t.string, t.null])
|
|
2023
|
+
})
|
|
2024
|
+
]);
|
|
861
2025
|
var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
|
|
862
2026
|
var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
|
|
863
2027
|
var fetchPrMeta = async (repo, prNumber, ghApi) => {
|
|
864
2028
|
const stdout = await ghApi([
|
|
865
2029
|
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
866
2030
|
"--jq",
|
|
867
|
-
"{changed_files: .changed_files, base_sha: .base.sha, title: .title, body: .body}"
|
|
2031
|
+
"{changed_files: .changed_files, base_sha: .base.sha, base_ref: .base.ref, title: .title, body: .body}"
|
|
868
2032
|
]);
|
|
869
2033
|
const decoded = PrMetaCodec.decode(JSON.parse(stdout));
|
|
870
2034
|
if (decoded._tag === "Left") {
|
|
@@ -879,18 +2043,139 @@ var fetchApiDiff = async (repo, prNumber, ghApi) => {
|
|
|
879
2043
|
return null;
|
|
880
2044
|
}
|
|
881
2045
|
};
|
|
882
|
-
var
|
|
2046
|
+
var fetchFullDiff = async (repo, defaultBranch, headSha, ghApi, gitRun) => {
|
|
883
2047
|
try {
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
2048
|
+
const diff = await ghApi([
|
|
2049
|
+
`repos/${repo}/compare/${defaultBranch}...${headSha}`,
|
|
2050
|
+
"-H",
|
|
2051
|
+
"Accept: application/vnd.github.v3.diff"
|
|
2052
|
+
]);
|
|
2053
|
+
if (diff.length > 0) return diff;
|
|
2054
|
+
process.stderr.write(
|
|
2055
|
+
`compare diff for ${defaultBranch}...${headSha} was empty \u2014 falling back to git diff
|
|
2056
|
+
`
|
|
2057
|
+
);
|
|
2058
|
+
} catch (err) {
|
|
2059
|
+
process.stderr.write(`compare diff fetch failed (${errMsg(err)}) \u2014 falling back to git diff
|
|
2060
|
+
`);
|
|
2061
|
+
}
|
|
2062
|
+
await gitRun(["fetch", "origin", headSha]);
|
|
2063
|
+
const base = (await gitRun(["rev-parse", "HEAD"])).trim();
|
|
2064
|
+
return gitRun(["diff", `${base}...${headSha}`]);
|
|
2065
|
+
};
|
|
2066
|
+
var CommitCodec = t.type({
|
|
2067
|
+
sha: t.string,
|
|
2068
|
+
message: t.string,
|
|
2069
|
+
author: t.union([t.string, t.null]),
|
|
2070
|
+
email: t.union([t.string, t.null])
|
|
2071
|
+
});
|
|
2072
|
+
var COMMIT_JQ = ".commits[] | {sha: .sha, message: .commit.message, author: .commit.author.name, email: .commit.author.email}";
|
|
2073
|
+
var fetchCompareCommits = async (repo, defaultBranch, headSha, ghApi) => {
|
|
2074
|
+
const rows = parseJsonl(
|
|
2075
|
+
await ghApi([
|
|
2076
|
+
`repos/${repo}/compare/${defaultBranch}...${headSha}`,
|
|
2077
|
+
"--paginate",
|
|
2078
|
+
"--jq",
|
|
2079
|
+
COMMIT_JQ
|
|
2080
|
+
])
|
|
2081
|
+
);
|
|
2082
|
+
const decoded = rows.map((row) => CommitCodec.decode(row));
|
|
2083
|
+
const commits = decoded.flatMap((d) => d._tag === "Right" ? [d.right] : []);
|
|
2084
|
+
const dropped = decoded.length - commits.length;
|
|
2085
|
+
if (dropped > 0) {
|
|
2086
|
+
process.stderr.write(
|
|
2087
|
+
`Warning: ${String(dropped)} of ${String(rows.length)} commit row(s) failed to decode \u2014 their messages are NOT in the triage scan though a checkout's git log still exposes them
|
|
2088
|
+
`
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
return commits;
|
|
2092
|
+
};
|
|
2093
|
+
var COMMENT_JQ = ".[] | {id: .id, body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association}";
|
|
2094
|
+
var REVIEW_COMMENT_JQ = ".[] | {body: .body, user: {login: .user.login}, created_at: .created_at, author_association: .author_association, path: .path, line: .line}";
|
|
2095
|
+
var REVIEW_JQ = ".[] | {body: .body, user: {login: .user.login}, submitted_at: .submitted_at, author_association: .author_association, state: .state}";
|
|
2096
|
+
var ReviewCommentCodec = t.intersection([
|
|
2097
|
+
t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
|
|
2098
|
+
t.partial({
|
|
2099
|
+
created_at: t.union([t.string, t.null]),
|
|
2100
|
+
author_association: t.union([t.string, t.null]),
|
|
2101
|
+
path: t.union([t.string, t.null]),
|
|
2102
|
+
line: t.union([t.number, t.null])
|
|
2103
|
+
})
|
|
2104
|
+
]);
|
|
2105
|
+
var ReviewCodec = t.intersection([
|
|
2106
|
+
t.type({ body: t.union([t.string, t.null]), user: t.type({ login: t.string }) }),
|
|
2107
|
+
t.partial({
|
|
2108
|
+
submitted_at: t.union([t.string, t.null]),
|
|
2109
|
+
author_association: t.union([t.string, t.null]),
|
|
2110
|
+
state: t.union([t.string, t.null])
|
|
2111
|
+
})
|
|
2112
|
+
]);
|
|
2113
|
+
var fetchJsonlRows = async (ghApi, endpoint, jq) => {
|
|
2114
|
+
try {
|
|
2115
|
+
return parseJsonl(await ghApi([endpoint, "--paginate", "--jq", jq]));
|
|
2116
|
+
} catch (err) {
|
|
2117
|
+
process.stderr.write(
|
|
2118
|
+
`Warning: could not fetch ${endpoint} (${errMsg(err)}) \u2014 omitting it from the review context
|
|
2119
|
+
`
|
|
2120
|
+
);
|
|
891
2121
|
return null;
|
|
892
2122
|
}
|
|
893
2123
|
};
|
|
2124
|
+
var decodeArrayOrNull = (codec, rows) => {
|
|
2125
|
+
if (rows === null) return null;
|
|
2126
|
+
return rows.flatMap((row) => {
|
|
2127
|
+
const decoded = codec.decode(row);
|
|
2128
|
+
return decoded._tag === "Right" ? [decoded.right] : [];
|
|
2129
|
+
});
|
|
2130
|
+
};
|
|
2131
|
+
var priorReviewFrom = (comments, botLogin) => {
|
|
2132
|
+
const byBot = comments.filter((c) => c.user.login === botLogin);
|
|
2133
|
+
const last = byBot[byBot.length - 1];
|
|
2134
|
+
return last ? { id: last.id, body: last.body } : null;
|
|
2135
|
+
};
|
|
2136
|
+
var MAX_CONVERSATION_COMMENTS = 50;
|
|
2137
|
+
var MAX_CONVERSATION_BODY_CHARS = 4e3;
|
|
2138
|
+
var clip = (body) => {
|
|
2139
|
+
if (body.length <= MAX_CONVERSATION_BODY_CHARS) return body;
|
|
2140
|
+
const cut = body.slice(0, MAX_CONVERSATION_BODY_CHARS);
|
|
2141
|
+
const safe = /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
2142
|
+
return `${safe}
|
|
2143
|
+
\u2026 [truncated]`;
|
|
2144
|
+
};
|
|
2145
|
+
var boundedHuman = (items, botLogin, label, project) => {
|
|
2146
|
+
const human = items.filter(
|
|
2147
|
+
(a) => a.user.login !== botLogin && typeof a.body === "string" && a.body.trim() !== ""
|
|
2148
|
+
);
|
|
2149
|
+
const kept = human.slice(-MAX_CONVERSATION_COMMENTS);
|
|
2150
|
+
if (kept.length < human.length) {
|
|
2151
|
+
process.stderr.write(
|
|
2152
|
+
`Note: PR has ${String(human.length)} ${label} \u2014 feeding the review the most recent ${String(MAX_CONVERSATION_COMMENTS)}
|
|
2153
|
+
`
|
|
2154
|
+
);
|
|
2155
|
+
}
|
|
2156
|
+
return kept.map(project);
|
|
2157
|
+
};
|
|
2158
|
+
var issueCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "discussion comments", (c) => ({
|
|
2159
|
+
author: c.user.login,
|
|
2160
|
+
author_association: c.author_association ?? null,
|
|
2161
|
+
created_at: c.created_at ?? null,
|
|
2162
|
+
body: clip(c.body)
|
|
2163
|
+
}));
|
|
2164
|
+
var reviewCommentsFrom = (comments, botLogin) => boundedHuman(comments, botLogin, "inline review comments", (c) => ({
|
|
2165
|
+
author: c.user.login,
|
|
2166
|
+
author_association: c.author_association ?? null,
|
|
2167
|
+
created_at: c.created_at ?? null,
|
|
2168
|
+
path: c.path ?? null,
|
|
2169
|
+
line: c.line ?? null,
|
|
2170
|
+
body: clip(c.body)
|
|
2171
|
+
}));
|
|
2172
|
+
var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review submissions", (r) => ({
|
|
2173
|
+
author: r.user.login,
|
|
2174
|
+
author_association: r.author_association ?? null,
|
|
2175
|
+
submitted_at: r.submitted_at ?? null,
|
|
2176
|
+
state: r.state ?? null,
|
|
2177
|
+
body: clip(r.body)
|
|
2178
|
+
}));
|
|
894
2179
|
var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
895
2180
|
const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
|
|
896
2181
|
const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
|
|
@@ -903,7 +2188,7 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
|
903
2188
|
writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
|
|
904
2189
|
} catch (err) {
|
|
905
2190
|
process.stderr.write(
|
|
906
|
-
`Warning: failed to download logs for job ${String(job.id)}: ${
|
|
2191
|
+
`Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
|
|
907
2192
|
`
|
|
908
2193
|
);
|
|
909
2194
|
}
|
|
@@ -926,8 +2211,10 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
926
2211
|
}
|
|
927
2212
|
const prNumber = resolution.prNumber;
|
|
928
2213
|
const meta = await fetchPrMeta(input.repo, prNumber, ghApi);
|
|
929
|
-
const
|
|
930
|
-
const
|
|
2214
|
+
const stacked = meta.base_ref !== input.defaultBranch;
|
|
2215
|
+
const prDiff = await (async () => {
|
|
2216
|
+
const apiDiff = await fetchApiDiff(input.repo, prNumber, ghApi);
|
|
2217
|
+
if (apiDiff !== null && !(apiDiff.length === 0 && meta.changed_files > 0)) return apiDiff;
|
|
931
2218
|
process.stderr.write(
|
|
932
2219
|
`PR diff fetch failed or was empty for ${String(meta.changed_files)} changed files \u2014 falling back to git diff
|
|
933
2220
|
`
|
|
@@ -935,16 +2222,40 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
935
2222
|
await gitRun(["fetch", "origin", input.headSha]);
|
|
936
2223
|
return gitRun(["diff", meta.base_sha, input.headSha]);
|
|
937
2224
|
})();
|
|
938
|
-
|
|
2225
|
+
const fullDiff = stacked ? await fetchFullDiff(input.repo, input.defaultBranch, input.headSha, ghApi, gitRun) : prDiff;
|
|
2226
|
+
const commits = await fetchCompareCommits(input.repo, input.defaultBranch, input.headSha, ghApi);
|
|
2227
|
+
writeFileSync(join(input.outDir, "full.diff"), fullDiff);
|
|
2228
|
+
writeFileSync(join(input.outDir, "pr.diff"), prDiff);
|
|
2229
|
+
writeFileSync(join(input.outDir, "commits.json"), JSON.stringify(commits));
|
|
939
2230
|
writeFileSync(
|
|
940
2231
|
join(input.outDir, "pr_context.json"),
|
|
941
2232
|
JSON.stringify({ title: meta.title, body: meta.body })
|
|
942
2233
|
);
|
|
943
|
-
const
|
|
2234
|
+
const [issueRows, reviewCommentRows, reviewRows] = await Promise.all([
|
|
2235
|
+
fetchJsonlRows(ghApi, `repos/${input.repo}/issues/${String(prNumber)}/comments`, COMMENT_JQ),
|
|
2236
|
+
fetchJsonlRows(
|
|
2237
|
+
ghApi,
|
|
2238
|
+
`repos/${input.repo}/pulls/${String(prNumber)}/comments`,
|
|
2239
|
+
REVIEW_COMMENT_JQ
|
|
2240
|
+
),
|
|
2241
|
+
fetchJsonlRows(ghApi, `repos/${input.repo}/pulls/${String(prNumber)}/reviews`, REVIEW_JQ)
|
|
2242
|
+
]);
|
|
2243
|
+
const issueComments = decodeArrayOrNull(IssueCommentCodec, issueRows);
|
|
2244
|
+
const reviewComments = decodeArrayOrNull(ReviewCommentCodec, reviewCommentRows);
|
|
2245
|
+
const reviews = decodeArrayOrNull(ReviewCodec, reviewRows);
|
|
2246
|
+
const prior = issueComments === null ? null : priorReviewFrom(issueComments, input.botLogin);
|
|
944
2247
|
writeFileSync(
|
|
945
2248
|
join(input.outDir, "prior_review.json"),
|
|
946
2249
|
prior === null ? "null" : JSON.stringify(prior)
|
|
947
2250
|
);
|
|
2251
|
+
writeFileSync(
|
|
2252
|
+
join(input.outDir, "pr_conversation.json"),
|
|
2253
|
+
JSON.stringify({
|
|
2254
|
+
issue_comments: issueComments === null ? [] : issueCommentsFrom(issueComments, input.botLogin),
|
|
2255
|
+
review_comments: reviewComments === null ? [] : reviewCommentsFrom(reviewComments, input.botLogin),
|
|
2256
|
+
reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
|
|
2257
|
+
})
|
|
2258
|
+
);
|
|
948
2259
|
if (input.conclusion === "failure") {
|
|
949
2260
|
await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
|
|
950
2261
|
}
|
|
@@ -952,9 +2263,12 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
|
|
|
952
2263
|
kind: "gathered",
|
|
953
2264
|
pr: prNumber,
|
|
954
2265
|
conclusion: input.conclusion,
|
|
955
|
-
diffSize: Buffer.byteLength(
|
|
2266
|
+
diffSize: Buffer.byteLength(prDiff, "utf8"),
|
|
2267
|
+
stacked
|
|
956
2268
|
};
|
|
957
2269
|
};
|
|
2270
|
+
|
|
2271
|
+
// src/extract.ts
|
|
958
2272
|
var fieldOf = (raw, key2) => typeof raw === "object" && raw !== null && key2 in raw ? raw[key2] : void 0;
|
|
959
2273
|
var parseNativeForExtraction = (raw) => ({
|
|
960
2274
|
result: fieldOf(raw, "result"),
|
|
@@ -1007,20 +2321,6 @@ var gateCandidate = (kind, rawCandidate) => {
|
|
|
1007
2321
|
const resolution = resolve(kind, candidate);
|
|
1008
2322
|
return resolution.kind === "ok" ? { version: resolution.version, candidate } : null;
|
|
1009
2323
|
};
|
|
1010
|
-
var tryParseJson = (text) => {
|
|
1011
|
-
try {
|
|
1012
|
-
return { ok: true, value: JSON.parse(text) };
|
|
1013
|
-
} catch {
|
|
1014
|
-
return { ok: false };
|
|
1015
|
-
}
|
|
1016
|
-
};
|
|
1017
|
-
var readFileOrNull = (path) => {
|
|
1018
|
-
try {
|
|
1019
|
-
return readFileSync(path, "utf-8");
|
|
1020
|
-
} catch {
|
|
1021
|
-
return null;
|
|
1022
|
-
}
|
|
1023
|
-
};
|
|
1024
2324
|
var candidateFromJsonText = (kind, text) => {
|
|
1025
2325
|
if (text === null) return null;
|
|
1026
2326
|
const parsed = tryParseJson(text);
|
|
@@ -1028,7 +2328,7 @@ var candidateFromJsonText = (kind, text) => {
|
|
|
1028
2328
|
};
|
|
1029
2329
|
var FENCE_OPEN = /^\s*(`{3,})/;
|
|
1030
2330
|
var FENCE_MARKER_ONLY = /^`+$/;
|
|
1031
|
-
var
|
|
2331
|
+
var scanLine2 = (state, line) => {
|
|
1032
2332
|
if (state.openLength === null) {
|
|
1033
2333
|
const opened = FENCE_OPEN.exec(line)?.[1]?.length;
|
|
1034
2334
|
return opened !== void 0 ? { blocks: state.blocks, openLength: opened, buffer: [] } : state;
|
|
@@ -1037,7 +2337,31 @@ var scanLine = (state, line) => {
|
|
|
1037
2337
|
const closes = FENCE_MARKER_ONLY.test(trimmed) && trimmed.length >= state.openLength;
|
|
1038
2338
|
return closes ? { blocks: [...state.blocks, state.buffer.join("\n")], openLength: null, buffer: [] } : { ...state, buffer: [...state.buffer, line] };
|
|
1039
2339
|
};
|
|
1040
|
-
var scanFencedBlocks = (text) => text.split("\n").reduce(
|
|
2340
|
+
var scanFencedBlocks = (text) => text.split("\n").reduce(scanLine2, { blocks: [], openLength: null, buffer: [] }).blocks;
|
|
2341
|
+
var ladderFailureDiagnostics = (input) => {
|
|
2342
|
+
const native = parseNativeForExtraction(input.native);
|
|
2343
|
+
const preview = (s) => {
|
|
2344
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
2345
|
+
return flat.length > 200 ? `${flat.slice(0, 200)}\u2026` : flat;
|
|
2346
|
+
};
|
|
2347
|
+
const lines = [];
|
|
2348
|
+
if (input.kind === "findings") {
|
|
2349
|
+
lines.push(
|
|
2350
|
+
input.agentFilePath === void 0 ? "agent-file rung: no --agent-file given" : `agent-file rung: ${input.agentFilePath} did not validate (or was unreadable)`
|
|
2351
|
+
);
|
|
2352
|
+
if (input.agentFileFallbackPath !== void 0)
|
|
2353
|
+
lines.push(
|
|
2354
|
+
`last-valid rung: ${input.agentFileFallbackPath} did not validate (or was absent)`
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
lines.push(
|
|
2358
|
+
isNullish(native.structuredOutput) ? "structured_output rung: absent (null) \u2014 the CLI's --json-schema likely did not enforce" : "structured_output rung: present but did not validate against the schema"
|
|
2359
|
+
);
|
|
2360
|
+
lines.push(
|
|
2361
|
+
typeof native.result === "string" ? `result rung: ${String(native.result.length)} chars, ${String(scanFencedBlocks(native.result).length)} fenced JSON block(s), none validated; preview: ${preview(native.result)}` : "result rung: absent or not a string"
|
|
2362
|
+
);
|
|
2363
|
+
return lines.join("\n");
|
|
2364
|
+
};
|
|
1041
2365
|
var describeLadderFailure = (outcome) => {
|
|
1042
2366
|
switch (outcome.kind) {
|
|
1043
2367
|
case "error-envelope":
|
|
@@ -1055,13 +2379,16 @@ var okOutcome = (gated) => ({
|
|
|
1055
2379
|
});
|
|
1056
2380
|
var extractStructured = (input) => {
|
|
1057
2381
|
const native = parseNativeForExtraction(input.native);
|
|
2382
|
+
if (input.kind === "findings") {
|
|
2383
|
+
for (const path of [input.agentFilePath, input.agentFileFallbackPath]) {
|
|
2384
|
+
if (path === void 0) continue;
|
|
2385
|
+
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(path));
|
|
2386
|
+
if (fromFile) return okOutcome(fromFile);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
1058
2389
|
if (isErrorEnvelope(native)) {
|
|
1059
2390
|
return { kind: "error-envelope", detail: describeErrorEnvelope(native) };
|
|
1060
2391
|
}
|
|
1061
|
-
if (input.kind === "findings" && input.agentFilePath !== void 0) {
|
|
1062
|
-
const fromFile = candidateFromJsonText(input.kind, readFileOrNull(input.agentFilePath));
|
|
1063
|
-
if (fromFile) return okOutcome(fromFile);
|
|
1064
|
-
}
|
|
1065
2392
|
if (native.structuredOutput !== void 0) {
|
|
1066
2393
|
const fromStructured = gateCandidate(input.kind, native.structuredOutput);
|
|
1067
2394
|
if (fromStructured) return okOutcome(fromStructured);
|
|
@@ -1081,9 +2408,10 @@ var extractStructured = (input) => {
|
|
|
1081
2408
|
};
|
|
1082
2409
|
}
|
|
1083
2410
|
}
|
|
2411
|
+
const fallbackRung = input.agentFileFallbackPath ? ", last-valid snapshot" : "";
|
|
1084
2412
|
return {
|
|
1085
2413
|
kind: "none",
|
|
1086
|
-
detail: `no --agent-file, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
2414
|
+
detail: `no --agent-file${fallbackRung}, structured_output, JSON result, or fenced block validated against the ${input.kind} schema`
|
|
1087
2415
|
};
|
|
1088
2416
|
};
|
|
1089
2417
|
|
|
@@ -1120,45 +2448,118 @@ var mapModelUsage = (modelUsage) => Object.entries(modelUsage).map(([model, entr
|
|
|
1120
2448
|
...entry.cacheReadInputTokens !== void 0 ? { cache_read_tokens: entry.cacheReadInputTokens } : {},
|
|
1121
2449
|
...entry.cacheCreationInputTokens !== void 0 ? { cache_write_tokens: entry.cacheCreationInputTokens } : {}
|
|
1122
2450
|
}));
|
|
1123
|
-
var
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
2451
|
+
var findingsOutcome = (native, agentFilePath, agentFileFallbackPath) => {
|
|
2452
|
+
const ladder = extractStructured({
|
|
2453
|
+
kind: "findings",
|
|
2454
|
+
native,
|
|
2455
|
+
agentFilePath,
|
|
2456
|
+
agentFileFallbackPath
|
|
2457
|
+
});
|
|
2458
|
+
if (ladder.kind !== "ok")
|
|
2459
|
+
return { kind: "telemetry-only", reason: describeLadderFailure(ladder) };
|
|
2460
|
+
const resolution = resolve("findings", ladder.candidate);
|
|
2461
|
+
return resolution.kind === "ok" ? { kind: "ok", version: resolution.version, findings: resolution.value } : {
|
|
2462
|
+
kind: "telemetry-only",
|
|
2463
|
+
reason: "internal error: the extraction ladder validated a candidate the registry then rejected"
|
|
2464
|
+
};
|
|
2465
|
+
};
|
|
2466
|
+
var withMeta = (base, meta) => ({
|
|
2467
|
+
...base,
|
|
2468
|
+
...meta.route ? { route: meta.route } : {},
|
|
2469
|
+
...meta.effort ? { effort: meta.effort } : {}
|
|
2470
|
+
});
|
|
2471
|
+
var resolveTelemetry = (native, meta) => {
|
|
2472
|
+
const fb = (() => {
|
|
2473
|
+
try {
|
|
2474
|
+
return meta.transcriptFallback?.();
|
|
2475
|
+
} catch {
|
|
2476
|
+
return void 0;
|
|
2477
|
+
}
|
|
2478
|
+
})();
|
|
2479
|
+
const wallTurns = fb !== void 0 && fb.durationMs > 0 ? { turns: fb.turns, duration_ms: fb.durationMs } : { turns: native.turns, duration_ms: native.durationMs };
|
|
2480
|
+
return withMeta(
|
|
2481
|
+
{
|
|
2482
|
+
models: native.models.length > 0 ? native.models : fb ? [...fb.models] : native.models,
|
|
2483
|
+
...wallTurns,
|
|
2484
|
+
vendor_cost_usd: native.vendorCostUsd
|
|
2485
|
+
},
|
|
2486
|
+
meta
|
|
2487
|
+
);
|
|
2488
|
+
};
|
|
2489
|
+
var nativeTelemetry = (native, meta) => resolveTelemetry(
|
|
2490
|
+
{
|
|
1132
2491
|
models: mapModelUsage(native.modelUsage),
|
|
1133
2492
|
turns: native.num_turns,
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
2493
|
+
durationMs: native.duration_ms,
|
|
2494
|
+
vendorCostUsd: native.total_cost_usd ?? null
|
|
2495
|
+
},
|
|
2496
|
+
meta
|
|
2497
|
+
);
|
|
2498
|
+
var absentTelemetry = (meta) => resolveTelemetry({ models: [], turns: 0, durationMs: 0, vendorCostUsd: null }, meta);
|
|
2499
|
+
var buildEnvelope = (telemetry, native, agentFilePath, agentFileFallbackPath) => {
|
|
2500
|
+
const outcome = findingsOutcome(native, agentFilePath, agentFileFallbackPath);
|
|
2501
|
+
switch (outcome.kind) {
|
|
2502
|
+
case "ok":
|
|
2503
|
+
return { schema_version: outcome.version, findings: outcome.findings, ...telemetry };
|
|
2504
|
+
case "telemetry-only":
|
|
2505
|
+
return {
|
|
2506
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
2507
|
+
findings: noticeFindings(`### \u26A0\uFE0F Review did not complete
|
|
2508
|
+
|
|
2509
|
+
${outcome.reason}`),
|
|
2510
|
+
incomplete: true,
|
|
2511
|
+
...telemetry
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
1141
2514
|
};
|
|
1142
2515
|
var adapt = (adapterName, native, agentFilePath, meta = {}) => {
|
|
1143
2516
|
switch (adapterName) {
|
|
1144
2517
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- exhaustive by design; AdapterName grows (e.g. "opencode") without collapsing this switch to an if
|
|
1145
2518
|
case "claude-code": {
|
|
2519
|
+
if (native === void 0 || native === null)
|
|
2520
|
+
return right(
|
|
2521
|
+
buildEnvelope(
|
|
2522
|
+
absentTelemetry(meta),
|
|
2523
|
+
void 0,
|
|
2524
|
+
agentFilePath,
|
|
2525
|
+
meta.agentFileFallbackPath
|
|
2526
|
+
)
|
|
2527
|
+
);
|
|
1146
2528
|
const decoded = ClaudeCodeEnvelopeCodec.decode(native);
|
|
1147
|
-
if (decoded._tag === "Left")
|
|
2529
|
+
if (decoded._tag === "Left")
|
|
1148
2530
|
return left("native envelope does not match the Claude Code output shape");
|
|
1149
|
-
|
|
1150
|
-
|
|
2531
|
+
return right(
|
|
2532
|
+
buildEnvelope(
|
|
2533
|
+
nativeTelemetry(decoded.right, meta),
|
|
2534
|
+
native,
|
|
2535
|
+
agentFilePath,
|
|
2536
|
+
meta.agentFileFallbackPath
|
|
2537
|
+
)
|
|
2538
|
+
);
|
|
1151
2539
|
}
|
|
1152
2540
|
}
|
|
1153
2541
|
};
|
|
1154
2542
|
|
|
2543
|
+
// src/settings.ts
|
|
2544
|
+
var composeReviewSettings = (opts) => {
|
|
2545
|
+
const budgetCommand = budgetHookCommand(opts.draftPath, opts.budget);
|
|
2546
|
+
return {
|
|
2547
|
+
hooks: {
|
|
2548
|
+
Stop: [
|
|
2549
|
+
{ hooks: [{ type: "command", command: defaultHookCommand(opts.draftPath, opts.stop) }] }
|
|
2550
|
+
],
|
|
2551
|
+
PreToolUse: [{ hooks: [{ type: "command", command: budgetCommand }] }],
|
|
2552
|
+
PostToolBatch: [{ hooks: [{ type: "command", command: budgetCommand }] }]
|
|
2553
|
+
}
|
|
2554
|
+
};
|
|
2555
|
+
};
|
|
2556
|
+
|
|
1155
2557
|
// src/index.ts
|
|
1156
2558
|
var readJSON = (path) => {
|
|
1157
2559
|
try {
|
|
1158
2560
|
return JSON.parse(readFileSync(resolve$1(path), "utf-8"));
|
|
1159
2561
|
} catch (err) {
|
|
1160
|
-
fail(`Cannot read ${path}: ${
|
|
1161
|
-
throw new Error("unreachable", { cause: err });
|
|
2562
|
+
return fail(`Cannot read ${path}: ${errMsg(err)}`);
|
|
1162
2563
|
}
|
|
1163
2564
|
};
|
|
1164
2565
|
var fail = (msg) => {
|
|
@@ -1166,32 +2567,86 @@ var fail = (msg) => {
|
|
|
1166
2567
|
`);
|
|
1167
2568
|
process.exit(1);
|
|
1168
2569
|
};
|
|
2570
|
+
var readJSONOrAbsent = (path) => {
|
|
2571
|
+
const read = (() => {
|
|
2572
|
+
try {
|
|
2573
|
+
return { text: readFileSync(resolve$1(path), "utf-8") };
|
|
2574
|
+
} catch (err) {
|
|
2575
|
+
return { error: errMsg(err) };
|
|
2576
|
+
}
|
|
2577
|
+
})();
|
|
2578
|
+
if ("error" in read) {
|
|
2579
|
+
process.stderr.write(
|
|
2580
|
+
`code-review: native envelope ${path} could not be read (${read.error}) \u2014 proceeding with no native telemetry
|
|
2581
|
+
`
|
|
2582
|
+
);
|
|
2583
|
+
return void 0;
|
|
2584
|
+
}
|
|
2585
|
+
if (read.text.trim() === "") {
|
|
2586
|
+
process.stderr.write(
|
|
2587
|
+
`code-review: native envelope ${path} is empty \u2014 proceeding with no native telemetry
|
|
2588
|
+
`
|
|
2589
|
+
);
|
|
2590
|
+
return void 0;
|
|
2591
|
+
}
|
|
2592
|
+
try {
|
|
2593
|
+
return JSON.parse(read.text);
|
|
2594
|
+
} catch (err) {
|
|
2595
|
+
process.stderr.write(
|
|
2596
|
+
`code-review: native envelope ${path} is not valid JSON (${errMsg(err)}) \u2014 proceeding with no native telemetry
|
|
2597
|
+
`
|
|
2598
|
+
);
|
|
2599
|
+
return void 0;
|
|
2600
|
+
}
|
|
2601
|
+
};
|
|
2602
|
+
var readStdinJSON = () => {
|
|
2603
|
+
if (process.stdin.isTTY) return null;
|
|
2604
|
+
const raw = (() => {
|
|
2605
|
+
try {
|
|
2606
|
+
return readFileSync(0, "utf-8");
|
|
2607
|
+
} catch {
|
|
2608
|
+
return "";
|
|
2609
|
+
}
|
|
2610
|
+
})();
|
|
2611
|
+
if (raw.trim() === "") return null;
|
|
2612
|
+
const parsed = tryParseJson(raw);
|
|
2613
|
+
return parsed.ok ? parsed.value : null;
|
|
2614
|
+
};
|
|
1169
2615
|
var decode = (either, label) => {
|
|
1170
2616
|
try {
|
|
1171
2617
|
return unsafeUnwrap(either);
|
|
1172
2618
|
} catch {
|
|
1173
|
-
fail(`${label} does not match expected shape`);
|
|
2619
|
+
return fail(`${label} does not match expected shape`);
|
|
1174
2620
|
}
|
|
1175
|
-
throw new Error("unreachable");
|
|
1176
2621
|
};
|
|
1177
2622
|
var unwrapAdapt = (either) => {
|
|
1178
2623
|
try {
|
|
1179
2624
|
if (either._tag === "Left") throw new Error(either.left);
|
|
1180
2625
|
return either.right;
|
|
1181
2626
|
} catch (err) {
|
|
1182
|
-
fail(
|
|
2627
|
+
return fail(errMsg(err));
|
|
1183
2628
|
}
|
|
1184
|
-
|
|
2629
|
+
};
|
|
2630
|
+
var transcriptFallbackFrom = (path) => {
|
|
2631
|
+
const tree = readTranscriptTree(resolve$1(path));
|
|
2632
|
+
if (tree.missing)
|
|
2633
|
+
process.stderr.write(
|
|
2634
|
+
`code-review adapt: transcript ${path} is unreadable \u2014 no telemetry fallback
|
|
2635
|
+
`
|
|
2636
|
+
);
|
|
2637
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2638
|
+
return { models: usage.models, turns: usage.turns, durationMs: usage.durationMs };
|
|
1185
2639
|
};
|
|
1186
2640
|
var bundledPath = (...segments) => resolve$1(import.meta.dirname, "..", ...segments);
|
|
1187
2641
|
var packageVersion = JSON.parse(readFileSync(bundledPath("package.json"), "utf-8")).version;
|
|
1188
2642
|
var resolveTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "comment.eta");
|
|
1189
|
-
var
|
|
1190
|
-
|
|
2643
|
+
var resolveInlineTemplatePath = (templateArg) => templateArg ? resolve$1(templateArg) : bundledPath("templates", "inline.eta");
|
|
2644
|
+
var resolvePrices = (pricesArg) => {
|
|
2645
|
+
if (pricesArg) return { kind: "provided", path: resolve$1(pricesArg) };
|
|
1191
2646
|
process.stderr.write(
|
|
1192
|
-
"code-review: no --prices given \u2014
|
|
2647
|
+
"code-review: no --prices given \u2014 cost will be reported as N/A (no price map to recompute from)\n"
|
|
1193
2648
|
);
|
|
1194
|
-
return bundledPath("schema", "prices.example.json");
|
|
2649
|
+
return { kind: "absent", path: bundledPath("schema", "prices.example.json") };
|
|
1195
2650
|
};
|
|
1196
2651
|
var TEST_REPORT_DESCRIPTION = 'Path to a JSON test summary: {"passed": number, "failed": number, "total": number, "failures"?: [{"name": string, "message"?: string}]}';
|
|
1197
2652
|
var renderCmd = defineCommand({
|
|
@@ -1239,19 +2694,21 @@ var renderCmd = defineCommand({
|
|
|
1239
2694
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1240
2695
|
const envelope = decode(ResultEnvelopeCodec.decode(readJSON(args.usage)), "envelope");
|
|
1241
2696
|
const templatePath = resolveTemplatePath(args.template);
|
|
1242
|
-
const
|
|
1243
|
-
const prices = decode(PriceMapCodec.decode(readJSON(
|
|
2697
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2698
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
1244
2699
|
const template = readFileSync(templatePath, "utf-8");
|
|
1245
2700
|
const testReport = args["test-report"] ? decode(TestSummaryCodec.decode(readJSON(args["test-report"])), "test report") : void 0;
|
|
1246
2701
|
const output = render({
|
|
1247
2702
|
findings,
|
|
1248
2703
|
envelope,
|
|
1249
2704
|
prices,
|
|
2705
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1250
2706
|
template,
|
|
1251
2707
|
reviewedSha: args["reviewed-sha"],
|
|
1252
2708
|
route: args.route,
|
|
1253
2709
|
effort: args.effort,
|
|
1254
|
-
testReport
|
|
2710
|
+
testReport,
|
|
2711
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1255
2712
|
});
|
|
1256
2713
|
process.stdout.write(output);
|
|
1257
2714
|
}
|
|
@@ -1274,14 +2731,17 @@ var inlineCmd = defineCommand({
|
|
|
1274
2731
|
},
|
|
1275
2732
|
template: {
|
|
1276
2733
|
type: "string",
|
|
1277
|
-
description: "Path to inline comment Eta template (default:
|
|
2734
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1278
2735
|
}
|
|
1279
2736
|
},
|
|
1280
2737
|
run: async ({ args }) => {
|
|
1281
2738
|
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
1282
2739
|
const diff = readFileSync(resolve$1(args.diff), "utf-8");
|
|
1283
|
-
const inlineTemplate =
|
|
1284
|
-
const { comments, strays } = buildInlineComments(findings.findings, diff,
|
|
2740
|
+
const inlineTemplate = readFileSync(resolveInlineTemplatePath(args.template), "utf-8");
|
|
2741
|
+
const { comments, strays } = buildInlineComments(findings.findings, diff, {
|
|
2742
|
+
inlineTemplate,
|
|
2743
|
+
findings
|
|
2744
|
+
});
|
|
1285
2745
|
process.stdout.write(
|
|
1286
2746
|
JSON.stringify({ comments, strays, stray_markdown: renderStraysSection(strays) }, null, 2)
|
|
1287
2747
|
);
|
|
@@ -1311,51 +2771,455 @@ var costCmd = defineCommand({
|
|
|
1311
2771
|
process.stdout.write(JSON.stringify(report, null, 2));
|
|
1312
2772
|
}
|
|
1313
2773
|
});
|
|
1314
|
-
var
|
|
1315
|
-
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredSchemaVersion(raw) : void 0;
|
|
1316
|
-
var validateCmd = defineCommand({
|
|
2774
|
+
var checkCostCmd = defineCommand({
|
|
1317
2775
|
meta: {
|
|
1318
|
-
name: "
|
|
1319
|
-
description: "
|
|
2776
|
+
name: "check-cost",
|
|
2777
|
+
description: "Sum real USD spend from a Claude Code transcript tree (main + subagents) against a price map"
|
|
1320
2778
|
},
|
|
1321
2779
|
args: {
|
|
1322
|
-
|
|
2780
|
+
transcript: {
|
|
1323
2781
|
type: "positional",
|
|
1324
|
-
description: "Path to the
|
|
2782
|
+
description: "Path to the session transcript JSONL (the hook's transcript_path)",
|
|
1325
2783
|
required: true
|
|
1326
2784
|
},
|
|
1327
|
-
|
|
1328
|
-
type: "string",
|
|
1329
|
-
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
1330
|
-
},
|
|
1331
|
-
schema: {
|
|
1332
|
-
type: "string",
|
|
1333
|
-
description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
|
|
1334
|
-
},
|
|
1335
|
-
"schema-version": {
|
|
2785
|
+
prices: {
|
|
1336
2786
|
type: "string",
|
|
1337
|
-
description: "
|
|
2787
|
+
description: "Path to price map JSON (default: bundled schema/prices.example.json \u2014 token totals stay real, cost reads as $0)"
|
|
1338
2788
|
}
|
|
1339
2789
|
},
|
|
1340
2790
|
run: async ({ args }) => {
|
|
1341
|
-
const
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
} else {
|
|
1348
|
-
process.stderr.write("\u274C invalid\n");
|
|
1349
|
-
for (const e of errors) process.stderr.write(` - ${e}
|
|
1350
|
-
`);
|
|
1351
|
-
process.exit(1);
|
|
2791
|
+
const tree = readTranscriptTree(resolve$1(args.transcript));
|
|
2792
|
+
if (tree.missing) {
|
|
2793
|
+
process.stderr.write(
|
|
2794
|
+
`code-review check-cost: transcript ${args.transcript} is unreadable \u2014 reporting zero spend
|
|
2795
|
+
`
|
|
2796
|
+
);
|
|
1352
2797
|
}
|
|
1353
|
-
|
|
2798
|
+
const usage = sumTranscriptUsage(tree.entries);
|
|
2799
|
+
const priceResolution = resolvePrices(args.prices);
|
|
2800
|
+
const prices = decode(PriceMapCodec.decode(readJSON(priceResolution.path)), "prices");
|
|
2801
|
+
const report = computeCost(usage.models, prices);
|
|
2802
|
+
process.stdout.write(
|
|
2803
|
+
`${JSON.stringify(
|
|
2804
|
+
{
|
|
2805
|
+
...report,
|
|
2806
|
+
turns: usage.turns,
|
|
2807
|
+
durationMs: usage.durationMs,
|
|
2808
|
+
transcripts: tree.files,
|
|
2809
|
+
pricesProvided: priceResolution.kind === "provided"
|
|
2810
|
+
},
|
|
2811
|
+
null,
|
|
2812
|
+
2
|
|
2813
|
+
)}
|
|
2814
|
+
`
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
});
|
|
2818
|
+
var tryReadPrices = (path) => {
|
|
2819
|
+
try {
|
|
2820
|
+
const decoded = PriceMapCodec.decode(JSON.parse(readFileSync(resolve$1(path), "utf-8")));
|
|
2821
|
+
return decoded._tag === "Right" ? decoded.right : null;
|
|
2822
|
+
} catch {
|
|
2823
|
+
return null;
|
|
2824
|
+
}
|
|
2825
|
+
};
|
|
2826
|
+
var parseBudgetUsd = (raw) => {
|
|
2827
|
+
if (raw === void 0) return null;
|
|
2828
|
+
const n = Number.parseFloat(raw);
|
|
2829
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
2830
|
+
};
|
|
2831
|
+
var mtimeMsOf = (path) => {
|
|
2832
|
+
try {
|
|
2833
|
+
return statSync(path).mtimeMs;
|
|
2834
|
+
} catch {
|
|
2835
|
+
return null;
|
|
2836
|
+
}
|
|
2837
|
+
};
|
|
2838
|
+
var transcriptPathOf = (input) => {
|
|
2839
|
+
const tp = (typeof input === "object" && input !== null ? input : {})["transcript_path"];
|
|
2840
|
+
return typeof tp === "string" ? tp : void 0;
|
|
2841
|
+
};
|
|
2842
|
+
var snapshotIfValid = (draftPath) => {
|
|
2843
|
+
try {
|
|
2844
|
+
if (extractStructured({ kind: "findings", native: void 0, agentFilePath: draftPath }).kind === "ok")
|
|
2845
|
+
copyFileSync(draftPath, lastValidPath(draftPath));
|
|
2846
|
+
} catch (err) {
|
|
2847
|
+
process.stderr.write(
|
|
2848
|
+
`code-review: could not snapshot the last-valid draft (${errMsg(err)}) \u2014 any prior snapshot is unchanged
|
|
2849
|
+
`
|
|
2850
|
+
);
|
|
2851
|
+
}
|
|
2852
|
+
};
|
|
2853
|
+
var budgetHookCmd = defineCommand({
|
|
2854
|
+
meta: {
|
|
2855
|
+
name: "budget-hook",
|
|
2856
|
+
description: "Self-dispatching Claude Code budget hook: on PostToolBatch steer the agent to converge as spend/wall-clock nears the budget; on PreToolUse deny budget-burning tools under the hard reserve, gate subagent spawns until the main agent has drafted, and run permitted spawns in the background. Reads the hook payload on stdin; degrades to a no-op on error."
|
|
2857
|
+
},
|
|
2858
|
+
args: {
|
|
2859
|
+
draft: {
|
|
2860
|
+
type: "string",
|
|
2861
|
+
description: "Path to the findings draft that is the sole permitted write target under forced convergence",
|
|
2862
|
+
required: true
|
|
2863
|
+
},
|
|
2864
|
+
"budget-usd": {
|
|
2865
|
+
type: "string",
|
|
2866
|
+
description: "Dollar budget for the run; the cost axis is measured against it (needs --prices)"
|
|
2867
|
+
},
|
|
2868
|
+
wall: {
|
|
2869
|
+
type: "string",
|
|
2870
|
+
description: "Wall-clock budget (e.g. 20m, 1200s, 2h); the time axis is measured against it"
|
|
2871
|
+
},
|
|
2872
|
+
prices: {
|
|
2873
|
+
type: "string",
|
|
2874
|
+
description: "Price map JSON to recompute real spend from the transcript (omit to disable the cost axis)"
|
|
2875
|
+
},
|
|
2876
|
+
"reserve-frac": {
|
|
2877
|
+
type: "string",
|
|
2878
|
+
description: "Base wind-down headroom as a fraction of each budget: converge once less than this remains (default: 0.15; the soft steer tier reserves 2\xD7 this)"
|
|
2879
|
+
},
|
|
2880
|
+
"reserve-growth": {
|
|
2881
|
+
type: "string",
|
|
2882
|
+
description: "How much the reserve grows as a budget is spent \u2014 added at full usage, so convergence lands earlier the longer the run has gone (default: 0.25; 0 = flat reserve)"
|
|
2883
|
+
},
|
|
2884
|
+
"reserve-usd": {
|
|
2885
|
+
type: "string",
|
|
2886
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2887
|
+
},
|
|
2888
|
+
"reserve-wall": {
|
|
2889
|
+
type: "string",
|
|
2890
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m, 120s), whichever is larger with --reserve-frac (default: 2m)"
|
|
2891
|
+
}
|
|
2892
|
+
},
|
|
2893
|
+
run: async ({ args }) => {
|
|
2894
|
+
try {
|
|
2895
|
+
const draftPath = resolve$1(args.draft);
|
|
2896
|
+
const input = readStdinJSON();
|
|
2897
|
+
const transcriptPath = transcriptPathOf(input);
|
|
2898
|
+
const tree = transcriptPath ? readTranscriptTree(resolve$1(transcriptPath)) : void 0;
|
|
2899
|
+
const usage = tree ? sumTranscriptUsage(tree.entries) : void 0;
|
|
2900
|
+
const prices = args.prices ? tryReadPrices(args.prices) : null;
|
|
2901
|
+
const spentUsd = prices !== null && usage ? computeCost(usage.models, prices).totalCostUSD : null;
|
|
2902
|
+
const wallMs = args.wall ? parseWallMs(args.wall) : null;
|
|
2903
|
+
const output = evaluateBudgetHook(input, {
|
|
2904
|
+
spentUsd,
|
|
2905
|
+
budgetUsd: parseBudgetUsd(args["budget-usd"]),
|
|
2906
|
+
elapsedMs: anchoredElapsedMs({
|
|
2907
|
+
deadlineMs: parseEpochSecMs(process.env[DEADLINE_ENV]),
|
|
2908
|
+
wallMs,
|
|
2909
|
+
firstTsMs: usage?.firstTsMs ?? null,
|
|
2910
|
+
nowMs: Date.now()
|
|
2911
|
+
}),
|
|
2912
|
+
wallMs,
|
|
2913
|
+
reserve: {
|
|
2914
|
+
frac: parseFraction(args["reserve-frac"], DEFAULT_RESERVE.frac),
|
|
2915
|
+
growth: parseFraction(args["reserve-growth"], DEFAULT_RESERVE.growth),
|
|
2916
|
+
flatUsd: parseBudgetUsd(args["reserve-usd"]) ?? DEFAULT_RESERVE.flatUsd,
|
|
2917
|
+
flatMs: args["reserve-wall"] ? parseWallMs(args["reserve-wall"]) ?? DEFAULT_RESERVE.flatMs : DEFAULT_RESERVE.flatMs
|
|
2918
|
+
},
|
|
2919
|
+
draftPath,
|
|
2920
|
+
mainDraftWritten: mainHasWrittenDraft(
|
|
2921
|
+
mtimeMsOf(draftPath),
|
|
2922
|
+
mtimeMsOf(seedMarkerPath(draftPath))
|
|
2923
|
+
)
|
|
2924
|
+
});
|
|
2925
|
+
if (asRecord(input)?.["hook_event_name"] === "PostToolBatch" && !isSubagentHookInput(input))
|
|
2926
|
+
snapshotIfValid(draftPath);
|
|
2927
|
+
process.stdout.write(`${JSON.stringify(output)}
|
|
2928
|
+
`);
|
|
2929
|
+
} catch (err) {
|
|
2930
|
+
process.stderr.write(`code-review budget-hook: degrading to no-op \u2014 ${errMsg(err)}
|
|
2931
|
+
`);
|
|
2932
|
+
process.stdout.write("{}\n");
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
});
|
|
2936
|
+
var printSettingsCmd = defineCommand({
|
|
2937
|
+
meta: {
|
|
2938
|
+
name: "print-settings",
|
|
2939
|
+
description: "Emit one Claude Code --settings JSON composing the Stop deliverable gate and the budget hooks (PreToolUse convergence + PostToolBatch steer) from one self-dispatching command"
|
|
2940
|
+
},
|
|
2941
|
+
args: {
|
|
2942
|
+
draft: {
|
|
2943
|
+
type: "string",
|
|
2944
|
+
description: "Path to the findings draft the agent must produce \u2014 the Stop gate's target and the only write allowed under forced convergence",
|
|
2945
|
+
required: true
|
|
2946
|
+
},
|
|
2947
|
+
kind: {
|
|
2948
|
+
type: "string",
|
|
2949
|
+
description: "Schema kind for the Stop gate: findings | triage | prices (default: findings)"
|
|
2950
|
+
},
|
|
2951
|
+
schema: {
|
|
2952
|
+
type: "string",
|
|
2953
|
+
description: "Path to a schema file for the Stop gate (wins over --kind)"
|
|
2954
|
+
},
|
|
2955
|
+
"schema-version": {
|
|
2956
|
+
type: "string",
|
|
2957
|
+
description: "Schema major.minor for the Stop gate (default: the draft's declared version)"
|
|
2958
|
+
},
|
|
2959
|
+
"max-nudges": {
|
|
2960
|
+
type: "string",
|
|
2961
|
+
description: "Stop-gate nudge budget before relenting (default: 5)"
|
|
2962
|
+
},
|
|
2963
|
+
counter: {
|
|
2964
|
+
type: "string",
|
|
2965
|
+
description: "Path for the Stop-gate nudge counter (default: <draft>.nudges)"
|
|
2966
|
+
},
|
|
2967
|
+
"budget-usd": {
|
|
2968
|
+
type: "string",
|
|
2969
|
+
description: "Dollar budget the cost axis is measured against (needs --prices)"
|
|
2970
|
+
},
|
|
2971
|
+
wall: {
|
|
2972
|
+
type: "string",
|
|
2973
|
+
description: "Wall-clock budget the time axis is measured against (e.g. 20m, 1200s)"
|
|
2974
|
+
},
|
|
2975
|
+
prices: {
|
|
2976
|
+
type: "string",
|
|
2977
|
+
description: "Price map JSON to recompute real spend from the transcript"
|
|
2978
|
+
},
|
|
2979
|
+
"reserve-frac": {
|
|
2980
|
+
type: "string",
|
|
2981
|
+
description: "Base wind-down headroom as a fraction of each budget (default: 0.15; soft tier is 2\xD7)"
|
|
2982
|
+
},
|
|
2983
|
+
"reserve-growth": {
|
|
2984
|
+
type: "string",
|
|
2985
|
+
description: "How much the reserve grows as a budget is spent, converging earlier the longer the run has gone (default: 0.25; 0 = flat)"
|
|
2986
|
+
},
|
|
2987
|
+
"reserve-usd": {
|
|
2988
|
+
type: "string",
|
|
2989
|
+
description: "Flat dollar wind-down floor, whichever is larger with --reserve-frac (default: 0.02)"
|
|
2990
|
+
},
|
|
2991
|
+
"reserve-wall": {
|
|
2992
|
+
type: "string",
|
|
2993
|
+
description: "Flat wall-clock wind-down floor (e.g. 2m), whichever is larger with --reserve-frac (default: 2m)"
|
|
2994
|
+
}
|
|
2995
|
+
},
|
|
2996
|
+
run: async ({ args }) => {
|
|
2997
|
+
if (args.kind && !["findings", "triage", "prices"].includes(args.kind))
|
|
2998
|
+
fail(`--kind must be one of findings|triage|prices (got '${args.kind}')`);
|
|
2999
|
+
const settings = composeReviewSettings({
|
|
3000
|
+
draftPath: resolve$1(args.draft),
|
|
3001
|
+
stop: {
|
|
3002
|
+
kind: args.kind,
|
|
3003
|
+
schema: args.schema,
|
|
3004
|
+
schemaVersion: args["schema-version"],
|
|
3005
|
+
maxNudges: args["max-nudges"],
|
|
3006
|
+
counter: args.counter
|
|
3007
|
+
},
|
|
3008
|
+
budget: {
|
|
3009
|
+
budgetUsd: args["budget-usd"],
|
|
3010
|
+
wall: args.wall,
|
|
3011
|
+
prices: args.prices,
|
|
3012
|
+
reserveFrac: args["reserve-frac"],
|
|
3013
|
+
reserveGrowth: args["reserve-growth"],
|
|
3014
|
+
reserveUsd: args["reserve-usd"],
|
|
3015
|
+
reserveWall: args["reserve-wall"]
|
|
3016
|
+
}
|
|
3017
|
+
});
|
|
3018
|
+
process.stdout.write(`${JSON.stringify(settings)}
|
|
3019
|
+
`);
|
|
3020
|
+
}
|
|
3021
|
+
});
|
|
3022
|
+
var deadlineCmd = defineCommand({
|
|
3023
|
+
meta: {
|
|
3024
|
+
name: "deadline",
|
|
3025
|
+
description: "Print the run's absolute deadline as Unix epoch seconds (now + --wall) \u2014 exported as CODE_REVIEW_DEADLINE_EPOCH so every budget hook (main and subagents) measures the same remaining wall"
|
|
3026
|
+
},
|
|
3027
|
+
args: {
|
|
3028
|
+
wall: {
|
|
3029
|
+
type: "string",
|
|
3030
|
+
description: "Wall-clock budget for the run (e.g. 24m, 1200s, 2h) \u2014 the deadline is now + this",
|
|
3031
|
+
required: true
|
|
3032
|
+
}
|
|
3033
|
+
},
|
|
3034
|
+
run: async ({ args }) => {
|
|
3035
|
+
const wallMs = parseWallMs(args.wall);
|
|
3036
|
+
if (wallMs === null) {
|
|
3037
|
+
fail(`--wall must be a duration like 24m, 1200s, or 2h (got '${args.wall}')`);
|
|
3038
|
+
} else {
|
|
3039
|
+
process.stdout.write(`${String(deadlineEpochSec(wallMs, Date.now()))}
|
|
3040
|
+
`);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
});
|
|
3044
|
+
var derivedSchemaVersion = (kind, raw) => kind === "findings" ? declaredVersion(raw) : void 0;
|
|
3045
|
+
var printableSchema = (schemaPath) => {
|
|
3046
|
+
const schema = JSON.parse(readFileSync(schemaPath, "utf-8"));
|
|
3047
|
+
const enforcementSchema = Object.fromEntries(
|
|
3048
|
+
Object.entries(schema).filter(([key2]) => key2 !== "$schema")
|
|
3049
|
+
);
|
|
3050
|
+
return JSON.stringify(enforcementSchema, null, 2);
|
|
3051
|
+
};
|
|
3052
|
+
var validateCmd = defineCommand({
|
|
3053
|
+
meta: {
|
|
3054
|
+
name: "validate",
|
|
3055
|
+
description: "Validate a findings/triage/prices JSON document against the canonical schema"
|
|
3056
|
+
},
|
|
3057
|
+
args: {
|
|
3058
|
+
document: {
|
|
3059
|
+
type: "positional",
|
|
3060
|
+
description: "Path to the JSON document to validate (of the given --kind)",
|
|
3061
|
+
required: true
|
|
3062
|
+
},
|
|
3063
|
+
kind: {
|
|
3064
|
+
type: "string",
|
|
3065
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
3066
|
+
},
|
|
3067
|
+
schema: {
|
|
3068
|
+
type: "string",
|
|
3069
|
+
description: "Path to a schema file (wins over --kind; default: the bundled schema derived from --kind, --schema-version, the document's declared schema_version, or the bundled latest)"
|
|
3070
|
+
},
|
|
3071
|
+
"schema-version": {
|
|
3072
|
+
type: "string",
|
|
3073
|
+
description: "Schema major.minor version to validate against (default: the document's declared schema_version for findings, or the kind's latest)"
|
|
3074
|
+
},
|
|
3075
|
+
explain: {
|
|
3076
|
+
type: "boolean",
|
|
3077
|
+
description: "On failure, also print the schema after the errors \u2014 its field descriptions are the authoritative spec, so the document can be fixed in one pass instead of by trial and error"
|
|
3078
|
+
}
|
|
3079
|
+
},
|
|
3080
|
+
run: async ({ args }) => {
|
|
3081
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
3082
|
+
const documentRaw = readJSON(args.document);
|
|
3083
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : requireSchemaPath(kind, args["schema-version"] || derivedSchemaVersion(kind, documentRaw));
|
|
3084
|
+
const { valid, errors } = validateAgainstSchema(documentRaw, schemaPath);
|
|
3085
|
+
if (valid) {
|
|
3086
|
+
process.stdout.write("\u2705 valid\n");
|
|
3087
|
+
} else {
|
|
3088
|
+
process.stderr.write("\u274C invalid\n");
|
|
3089
|
+
for (const e of errors) process.stderr.write(` - ${e}
|
|
3090
|
+
`);
|
|
3091
|
+
if (args.explain) {
|
|
3092
|
+
process.stderr.write(
|
|
3093
|
+
`
|
|
3094
|
+
The ${kind} document must conform to this schema (the field descriptions are the authoritative spec \u2014 match the property names exactly):
|
|
3095
|
+
${printableSchema(schemaPath)}
|
|
3096
|
+
`
|
|
3097
|
+
);
|
|
3098
|
+
}
|
|
3099
|
+
process.exit(1);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
});
|
|
3103
|
+
var seedDraftCmd = defineCommand({
|
|
3104
|
+
meta: {
|
|
3105
|
+
name: "seed-draft",
|
|
3106
|
+
description: "Write a valid findings $DRAFT before the review runs: the decoded findings from a prior review when one exists and still validates (incremental re-review), else an empty-but-valid scaffold \u2014 so a valid draft exists from turn 0. Also drops a sidecar marker beside the seed so the budget hook can tell the untouched seed from a draft the agent wrote itself. Prints the mode to stdout (prior-same|prior-new when seeded, by whether the prior review examined this same commit; empty-had-prior when a prior review exists but its findings could not be loaded; empty on a first review; none when even the scaffold write failed) and always exits 0"
|
|
3107
|
+
},
|
|
3108
|
+
args: {
|
|
3109
|
+
prior: {
|
|
3110
|
+
type: "string",
|
|
3111
|
+
description: "Path to the prior-review JSON gather staged ({ id, body }, or the literal null); its embedded base64 findings marker is decoded and becomes the seed when it validates against the schema"
|
|
3112
|
+
},
|
|
3113
|
+
"head-sha": {
|
|
3114
|
+
type: "string",
|
|
3115
|
+
description: "Current head SHA, compared against the prior review's embedded reviewed-sha to distinguish a same-commit re-review from a new-commit one; an unknown or mismatched prior SHA is treated as a new commit"
|
|
3116
|
+
},
|
|
3117
|
+
out: {
|
|
3118
|
+
type: "string",
|
|
3119
|
+
description: "Path to write the seed $DRAFT to (an absolute path outside the worktree)",
|
|
3120
|
+
required: true
|
|
3121
|
+
},
|
|
3122
|
+
kind: {
|
|
3123
|
+
type: "string",
|
|
3124
|
+
description: "Schema kind to validate the prior findings against (default: findings)"
|
|
3125
|
+
},
|
|
3126
|
+
schema: {
|
|
3127
|
+
type: "string",
|
|
3128
|
+
description: "Path to a schema file (wins over --kind/--schema-version)"
|
|
3129
|
+
},
|
|
3130
|
+
"schema-version": {
|
|
3131
|
+
type: "string",
|
|
3132
|
+
description: "Schema major.minor to validate the prior findings against (default: the kind's latest \u2014 an older-shaped prior review then falls back to the empty scaffold)"
|
|
3133
|
+
}
|
|
3134
|
+
},
|
|
3135
|
+
run: async ({ args }) => {
|
|
3136
|
+
const outPath = resolve$1(args.out);
|
|
3137
|
+
const kindArg = args.kind || "findings";
|
|
3138
|
+
const kind = isSchemaKind(kindArg) ? kindArg : "findings";
|
|
3139
|
+
if (kind !== kindArg) {
|
|
3140
|
+
process.stderr.write(
|
|
3141
|
+
`Warning: unknown --kind "${kindArg}" \u2014 validating against "findings"
|
|
3142
|
+
`
|
|
3143
|
+
);
|
|
3144
|
+
}
|
|
3145
|
+
const writeSeedMarker = () => {
|
|
3146
|
+
try {
|
|
3147
|
+
writeFileSync(seedMarkerPath(outPath), "code-review seed marker\n");
|
|
3148
|
+
} catch (err) {
|
|
3149
|
+
process.stderr.write(
|
|
3150
|
+
`Warning: could not write the seed marker beside ${outPath} (${errMsg(err)}) \u2014 the seeded draft will count as agent-written
|
|
3151
|
+
`
|
|
3152
|
+
);
|
|
3153
|
+
}
|
|
3154
|
+
};
|
|
3155
|
+
const writeScaffold = () => {
|
|
3156
|
+
try {
|
|
3157
|
+
writeFileSync(outPath, `${JSON.stringify(noticeFindings(""), null, 2)}
|
|
3158
|
+
`);
|
|
3159
|
+
writeSeedMarker();
|
|
3160
|
+
process.stderr.write(
|
|
3161
|
+
`Seeded ${outPath} with an empty valid scaffold \u2014 no decodable prior findings to build on
|
|
3162
|
+
`
|
|
3163
|
+
);
|
|
3164
|
+
return true;
|
|
3165
|
+
} catch (err) {
|
|
3166
|
+
process.stderr.write(
|
|
3167
|
+
`Warning: could not write the seed scaffold to ${outPath} (${errMsg(err)}) \u2014 the agent will create $DRAFT itself
|
|
3168
|
+
`
|
|
3169
|
+
);
|
|
3170
|
+
return false;
|
|
3171
|
+
}
|
|
3172
|
+
};
|
|
3173
|
+
const priorBody = (() => {
|
|
3174
|
+
if (!args.prior) return null;
|
|
3175
|
+
const raw = (() => {
|
|
3176
|
+
try {
|
|
3177
|
+
return JSON.parse(readFileSync(resolve$1(args.prior), "utf-8"));
|
|
3178
|
+
} catch {
|
|
3179
|
+
return null;
|
|
3180
|
+
}
|
|
3181
|
+
})();
|
|
3182
|
+
return typeof raw === "object" && raw !== null && "body" in raw && typeof raw.body === "string" ? raw.body : null;
|
|
3183
|
+
})();
|
|
3184
|
+
const priorFindings = priorBody === null ? null : parseFindingsMarker(priorBody);
|
|
3185
|
+
const seededFromPrior = priorFindings === null ? false : (() => {
|
|
3186
|
+
try {
|
|
3187
|
+
const schemaPath = args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"]);
|
|
3188
|
+
if (!validateAgainstSchema(priorFindings, schemaPath).valid) return false;
|
|
3189
|
+
writeFileSync(outPath, `${JSON.stringify(priorFindings, null, 2)}
|
|
3190
|
+
`);
|
|
3191
|
+
writeSeedMarker();
|
|
3192
|
+
const priorList = priorFindings.findings;
|
|
3193
|
+
const count = Array.isArray(priorList) ? priorList.length : 0;
|
|
3194
|
+
process.stderr.write(
|
|
3195
|
+
`Seeded ${outPath} from the prior review (${String(count)} finding(s))
|
|
3196
|
+
`
|
|
3197
|
+
);
|
|
3198
|
+
return true;
|
|
3199
|
+
} catch (err) {
|
|
3200
|
+
process.stderr.write(
|
|
3201
|
+
`Warning: could not seed from the prior review (${errMsg(err)}) \u2014 falling back to the empty scaffold
|
|
3202
|
+
`
|
|
3203
|
+
);
|
|
3204
|
+
return false;
|
|
3205
|
+
}
|
|
3206
|
+
})();
|
|
3207
|
+
const mode = (() => {
|
|
3208
|
+
if (seededFromPrior) {
|
|
3209
|
+
const priorSha = priorBody === null ? null : parseReviewedSha(priorBody);
|
|
3210
|
+
return args["head-sha"] && priorSha && priorSha === args["head-sha"].toLowerCase() ? "prior-same" : "prior-new";
|
|
3211
|
+
}
|
|
3212
|
+
if (!writeScaffold()) return "none";
|
|
3213
|
+
return priorBody === null ? "empty" : "empty-had-prior";
|
|
3214
|
+
})();
|
|
3215
|
+
process.stdout.write(`${mode}
|
|
3216
|
+
`);
|
|
3217
|
+
}
|
|
1354
3218
|
});
|
|
1355
3219
|
var adaptCmd = defineCommand({
|
|
1356
3220
|
meta: {
|
|
1357
3221
|
name: "adapt",
|
|
1358
|
-
description: "Map a native agent-CLI result envelope onto the abstract SPEC
|
|
3222
|
+
description: "Map a native agent-CLI result envelope onto the abstract SPEC envelope"
|
|
1359
3223
|
},
|
|
1360
3224
|
args: {
|
|
1361
3225
|
native: {
|
|
@@ -1372,6 +3236,10 @@ var adaptCmd = defineCommand({
|
|
|
1372
3236
|
type: "string",
|
|
1373
3237
|
description: "Path to a file the agent was told to write its own validated findings JSON to (wins over the native envelope's structured_output/result when it validates)"
|
|
1374
3238
|
},
|
|
3239
|
+
"agent-file-fallback": {
|
|
3240
|
+
type: "string",
|
|
3241
|
+
description: "Path to the last-valid findings snapshot, tried after --agent-file and before the native envelope; recovers the last valid state when a wall-clock kill left --agent-file truncated, so the review posts that instead of a 'did not complete' notice"
|
|
3242
|
+
},
|
|
1375
3243
|
route: {
|
|
1376
3244
|
type: "string",
|
|
1377
3245
|
description: 'Review route label to stamp into the envelope (e.g. "full review" or "mechanic")'
|
|
@@ -1379,25 +3247,64 @@ var adaptCmd = defineCommand({
|
|
|
1379
3247
|
effort: {
|
|
1380
3248
|
type: "string",
|
|
1381
3249
|
description: 'Effort label to stamp into the envelope (e.g. "max" or "low")'
|
|
3250
|
+
},
|
|
3251
|
+
transcript: {
|
|
3252
|
+
type: "string",
|
|
3253
|
+
description: "Path to the session transcript (main .jsonl). Its tree (main + subagents) gives the true wall + turn count the native envelope under-reports, and refills per-model usage when the native envelope is empty (e.g. after a wall-clock kill)"
|
|
1382
3254
|
}
|
|
1383
3255
|
},
|
|
1384
3256
|
run: async ({ args }) => {
|
|
1385
3257
|
const envelope = unwrapAdapt(
|
|
1386
|
-
adapt(requireAdapterName(args.adapter),
|
|
3258
|
+
adapt(requireAdapterName(args.adapter), readJSONOrAbsent(args.native), args["agent-file"], {
|
|
1387
3259
|
route: args.route,
|
|
1388
|
-
effort: args.effort
|
|
3260
|
+
effort: args.effort,
|
|
3261
|
+
agentFileFallbackPath: args["agent-file-fallback"],
|
|
3262
|
+
...args.transcript ? {
|
|
3263
|
+
transcriptFallback: () => transcriptFallbackFrom(args.transcript)
|
|
3264
|
+
} : {}
|
|
1389
3265
|
})
|
|
1390
3266
|
);
|
|
1391
3267
|
process.stdout.write(`${JSON.stringify(envelope, null, 2)}
|
|
1392
3268
|
`);
|
|
1393
3269
|
}
|
|
1394
3270
|
});
|
|
3271
|
+
var noticeCmd = defineCommand({
|
|
3272
|
+
meta: {
|
|
3273
|
+
name: "notice",
|
|
3274
|
+
description: "Emit an abstract envelope for a run that produced no completed review (security block, triage error, setup failure, checkout failure, or empty run) \u2014 flagged incomplete so the commenter renders it honestly and won't bury a real review"
|
|
3275
|
+
},
|
|
3276
|
+
args: {
|
|
3277
|
+
kind: {
|
|
3278
|
+
type: "positional",
|
|
3279
|
+
description: `One of: ${NOTICE_KINDS.join(", ")} (an unrecognized kind renders a generic incomplete notice rather than failing \u2014 the pinned CLI is older than the workflow)`,
|
|
3280
|
+
required: true
|
|
3281
|
+
},
|
|
3282
|
+
reasons: {
|
|
3283
|
+
type: "string",
|
|
3284
|
+
description: "security-blocked / triage-error only: the triage's fail-closed reason string (empty/omitted \u21D2 the no-reason wording)"
|
|
3285
|
+
}
|
|
3286
|
+
},
|
|
3287
|
+
// An unrecognized kind degrades to a generic incomplete notice instead of exiting non-zero: a
|
|
3288
|
+
// `notice <kind>` call under the workflow's `set -euo pipefail` must never crash the assemble
|
|
3289
|
+
// step into posting nothing, and an unknown kind almost always means version skew, not a typo.
|
|
3290
|
+
run: ({ args }) => {
|
|
3291
|
+
if (!isNoticeKind(args.kind)) {
|
|
3292
|
+
process.stderr.write(
|
|
3293
|
+
`::warning::code-review notice: unrecognized kind "${args.kind}" \u2014 the pinned CLI is older than the workflow calling it; rendering a generic incomplete notice
|
|
3294
|
+
`
|
|
3295
|
+
);
|
|
3296
|
+
process.stdout.write(`${JSON.stringify(buildUnknownNoticeEnvelope(args.kind), null, 2)}
|
|
3297
|
+
`);
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
process.stdout.write(
|
|
3301
|
+
`${JSON.stringify(buildNoticeEnvelope(args.kind, args.reasons), null, 2)}
|
|
3302
|
+
`
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
});
|
|
1395
3306
|
var isExtractSchemaKind = (s) => s === "findings" || s === "triage";
|
|
1396
|
-
var requireExtractSchemaKind = (name) => {
|
|
1397
|
-
if (isExtractSchemaKind(name)) return name;
|
|
1398
|
-
fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1399
|
-
throw new Error("unreachable");
|
|
1400
|
-
};
|
|
3307
|
+
var requireExtractSchemaKind = (name) => isExtractSchemaKind(name) ? name : fail(`Unknown kind "${name}" for extract \u2014 expected one of: findings, triage`);
|
|
1401
3308
|
var failClosedTriage = (outcome) => ({
|
|
1402
3309
|
safe: false,
|
|
1403
3310
|
reasons: describeLadderFailure(outcome)
|
|
@@ -1431,16 +3338,18 @@ var extractCmd = defineCommand({
|
|
|
1431
3338
|
run: async ({ args }) => {
|
|
1432
3339
|
requireAdapterName(args.adapter);
|
|
1433
3340
|
const kind = requireExtractSchemaKind(args.kind);
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
native: readJSON(args.native),
|
|
1437
|
-
agentFilePath: args["agent-file"]
|
|
1438
|
-
});
|
|
3341
|
+
const input = { kind, native: readJSON(args.native), agentFilePath: args["agent-file"] };
|
|
3342
|
+
const outcome = extractStructured(input);
|
|
1439
3343
|
if (outcome.kind === "ok") {
|
|
1440
3344
|
process.stdout.write(`${JSON.stringify(outcome.candidate, null, 2)}
|
|
1441
3345
|
`);
|
|
1442
3346
|
return;
|
|
1443
3347
|
}
|
|
3348
|
+
if (outcome.kind === "none" || outcome.kind === "ambiguous") {
|
|
3349
|
+
process.stderr.write(`extract: recovery failed \u2014
|
|
3350
|
+
${ladderFailureDiagnostics(input)}
|
|
3351
|
+
`);
|
|
3352
|
+
}
|
|
1444
3353
|
if (kind === "triage") {
|
|
1445
3354
|
process.stdout.write(`${JSON.stringify(failClosedTriage(outcome), null, 2)}
|
|
1446
3355
|
`);
|
|
@@ -1449,24 +3358,79 @@ var extractCmd = defineCommand({
|
|
|
1449
3358
|
fail(describeLadderFailure(outcome));
|
|
1450
3359
|
}
|
|
1451
3360
|
});
|
|
1452
|
-
var
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
3361
|
+
var withoutPatch = (finding) => {
|
|
3362
|
+
const copy = { ...finding };
|
|
3363
|
+
delete copy.patch;
|
|
3364
|
+
return copy;
|
|
1456
3365
|
};
|
|
1457
|
-
var
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
3366
|
+
var readFileLines = (path) => {
|
|
3367
|
+
try {
|
|
3368
|
+
const rawLines = readFileSync(path, "utf-8").split("\n");
|
|
3369
|
+
return rawLines.length > 0 && rawLines[rawLines.length - 1] === "" ? rawLines.slice(0, -1) : rawLines;
|
|
3370
|
+
} catch {
|
|
3371
|
+
return null;
|
|
3372
|
+
}
|
|
1462
3373
|
};
|
|
3374
|
+
var validateFinding = (finding, repoRoot) => {
|
|
3375
|
+
if (finding.patch === void 0) return finding;
|
|
3376
|
+
const lines = readFileLines(resolve$1(repoRoot, finding.path));
|
|
3377
|
+
if (lines === null) {
|
|
3378
|
+
process.stderr.write(
|
|
3379
|
+
`validate-patches: ${finding.path}: could not read file at "${repoRoot}" \u2014 dropping patch
|
|
3380
|
+
`
|
|
3381
|
+
);
|
|
3382
|
+
return withoutPatch(finding);
|
|
3383
|
+
}
|
|
3384
|
+
const result = validatePatch(finding.patch, lines);
|
|
3385
|
+
switch (result.kind) {
|
|
3386
|
+
case "anchored":
|
|
3387
|
+
return { ...finding, start_line: result.startLine, end_line: result.endLine };
|
|
3388
|
+
case "keep":
|
|
3389
|
+
return finding;
|
|
3390
|
+
case "drop":
|
|
3391
|
+
process.stderr.write(
|
|
3392
|
+
`validate-patches: ${finding.path}:${String(finding.start_line)}: ${result.reason} \u2014 dropping patch
|
|
3393
|
+
`
|
|
3394
|
+
);
|
|
3395
|
+
return withoutPatch(finding);
|
|
3396
|
+
}
|
|
3397
|
+
};
|
|
3398
|
+
var validatePatchesCmd = defineCommand({
|
|
3399
|
+
meta: {
|
|
3400
|
+
name: "validate-patches",
|
|
3401
|
+
description: "Validate each finding's patch against the real PR-head tree: align the finding's range and keep the patch when it anchors, keep it unaligned for a pure insertion, or drop it when it doesn't apply"
|
|
3402
|
+
},
|
|
3403
|
+
args: {
|
|
3404
|
+
findings: {
|
|
3405
|
+
type: "positional",
|
|
3406
|
+
description: "Path to findings JSON",
|
|
3407
|
+
required: true
|
|
3408
|
+
},
|
|
3409
|
+
"repo-root": {
|
|
3410
|
+
type: "string",
|
|
3411
|
+
description: "Directory to resolve each finding's path against \u2014 the review job's checked-out, clean PR-head tree (default: .)"
|
|
3412
|
+
}
|
|
3413
|
+
},
|
|
3414
|
+
run: async ({ args }) => {
|
|
3415
|
+
const findings = decode(FindingsCodec.decode(readJSON(args.findings)), "findings");
|
|
3416
|
+
const repoRoot = args["repo-root"] ? resolve$1(args["repo-root"]) : process.cwd();
|
|
3417
|
+
const validated = {
|
|
3418
|
+
...findings,
|
|
3419
|
+
findings: findings.findings.map((f) => validateFinding(f, repoRoot))
|
|
3420
|
+
};
|
|
3421
|
+
process.stdout.write(`${JSON.stringify(validated, null, 2)}
|
|
3422
|
+
`);
|
|
3423
|
+
}
|
|
3424
|
+
});
|
|
3425
|
+
var requireAdapterName = (name) => isAdapterName(name) ? name : fail(`Unknown adapter "${name}" \u2014 supported: claude-code`);
|
|
3426
|
+
var isSchemaKind = (s) => s === "findings" || s === "triage" || s === "prices";
|
|
3427
|
+
var requireSchemaKind = (name) => isSchemaKind(name) ? name : fail(`Unknown schema "${name}" \u2014 expected one of: findings, triage, prices`);
|
|
1463
3428
|
var requireSchemaPath = (kind, version) => {
|
|
1464
3429
|
try {
|
|
1465
3430
|
return schemaPathFor(kind, version);
|
|
1466
3431
|
} catch (err) {
|
|
1467
|
-
fail(
|
|
3432
|
+
return fail(errMsg(err));
|
|
1468
3433
|
}
|
|
1469
|
-
throw new Error("unreachable");
|
|
1470
3434
|
};
|
|
1471
3435
|
var printSchemaCmd = defineCommand({
|
|
1472
3436
|
meta: {
|
|
@@ -1487,18 +3451,105 @@ var printSchemaCmd = defineCommand({
|
|
|
1487
3451
|
run: async ({ args }) => {
|
|
1488
3452
|
const schemaKind = requireSchemaKind(args.name);
|
|
1489
3453
|
const schemaPath = requireSchemaPath(schemaKind, args["schema-version"]);
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
3454
|
+
process.stdout.write(`${printableSchema(schemaPath)}
|
|
3455
|
+
`);
|
|
3456
|
+
}
|
|
3457
|
+
});
|
|
3458
|
+
var MAX_NUDGES_DEFAULT = 5;
|
|
3459
|
+
var drainStdin = () => {
|
|
3460
|
+
if (process.stdin.isTTY) return;
|
|
3461
|
+
try {
|
|
3462
|
+
readFileSync(0);
|
|
3463
|
+
} catch {
|
|
3464
|
+
}
|
|
3465
|
+
};
|
|
3466
|
+
var requireMaxNudges = (raw) => {
|
|
3467
|
+
if (raw === void 0) return MAX_NUDGES_DEFAULT;
|
|
3468
|
+
if (!/^\d+$/.test(raw)) {
|
|
3469
|
+
fail(`--max-nudges must be a non-negative integer; got "${raw}"`);
|
|
3470
|
+
}
|
|
3471
|
+
const n = Number.parseInt(raw, 10);
|
|
3472
|
+
if (n < 1) {
|
|
3473
|
+
fail(`--max-nudges must be >= 1 \u2014 a gate that never blocks must be omitted, not set to ${raw}`);
|
|
3474
|
+
}
|
|
3475
|
+
return n;
|
|
3476
|
+
};
|
|
3477
|
+
var stopGateCmd = defineCommand({
|
|
3478
|
+
meta: {
|
|
3479
|
+
name: "stop-gate",
|
|
3480
|
+
description: "Claude Code Stop-hook gate: refuse to let the agent end its turn until --draft validates against the schema (bounded by --max-nudges). With --print-settings, emit the --settings JSON that wires this as the Stop hook."
|
|
3481
|
+
},
|
|
3482
|
+
args: {
|
|
3483
|
+
draft: {
|
|
3484
|
+
type: "string",
|
|
3485
|
+
description: "Path to the findings document the agent must produce and keep valid",
|
|
3486
|
+
required: true
|
|
3487
|
+
},
|
|
3488
|
+
kind: {
|
|
3489
|
+
type: "string",
|
|
3490
|
+
description: "Schema kind to validate against: findings | triage | prices (default: findings)"
|
|
3491
|
+
},
|
|
3492
|
+
schema: { type: "string", description: "Path to a schema file (wins over --kind)" },
|
|
3493
|
+
"schema-version": {
|
|
3494
|
+
type: "string",
|
|
3495
|
+
description: "Schema major.minor to validate against (default: the draft's declared version)"
|
|
3496
|
+
},
|
|
3497
|
+
"max-nudges": {
|
|
3498
|
+
type: "string",
|
|
3499
|
+
description: `Times to block before relenting so the step fails downstream as before (default: ${String(MAX_NUDGES_DEFAULT)})`
|
|
3500
|
+
},
|
|
3501
|
+
counter: {
|
|
3502
|
+
type: "string",
|
|
3503
|
+
description: "Path for the nudge counter (default: <draft>.nudges)"
|
|
3504
|
+
},
|
|
3505
|
+
"print-settings": {
|
|
3506
|
+
type: "boolean",
|
|
3507
|
+
description: "Print the Stop-hook settings JSON that wires this gate, then exit"
|
|
3508
|
+
}
|
|
3509
|
+
},
|
|
3510
|
+
run: async ({ args }) => {
|
|
3511
|
+
const draftPath = resolve$1(args.draft);
|
|
3512
|
+
if (args["print-settings"]) {
|
|
3513
|
+
const command = defaultHookCommand(draftPath, {
|
|
3514
|
+
kind: args.kind,
|
|
3515
|
+
schema: args.schema,
|
|
3516
|
+
schemaVersion: args["schema-version"],
|
|
3517
|
+
maxNudges: args["max-nudges"],
|
|
3518
|
+
counter: args.counter
|
|
3519
|
+
});
|
|
3520
|
+
process.stdout.write(`${JSON.stringify(stopHookSettings(command))}
|
|
3521
|
+
`);
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
drainStdin();
|
|
3525
|
+
const kind = requireSchemaKind(args.kind || "findings");
|
|
3526
|
+
const maxNudges = requireMaxNudges(args["max-nudges"]);
|
|
3527
|
+
const counterPath = args.counter ? resolve$1(args.counter) : `${draftPath}.nudges`;
|
|
3528
|
+
const state = draftState(
|
|
3529
|
+
draftPath,
|
|
3530
|
+
(parsed) => args.schema ? resolve$1(args.schema) : schemaPathFor(kind, args["schema-version"] || derivedSchemaVersion(kind, parsed))
|
|
1493
3531
|
);
|
|
1494
|
-
|
|
3532
|
+
const nudges = readNudges(counterPath);
|
|
3533
|
+
const decision = decideGate(state, nudges, maxNudges, draftPath, kind);
|
|
3534
|
+
if (decision.kind === "block") {
|
|
3535
|
+
try {
|
|
3536
|
+
bumpNudges(counterPath, nudges);
|
|
3537
|
+
} catch (err) {
|
|
3538
|
+
process.stderr.write(
|
|
3539
|
+
`stop-gate: cannot persist nudge counter at ${counterPath} \u2192 allowing to avoid an unbounded block loop: ${errMsg(err)}
|
|
3540
|
+
`
|
|
3541
|
+
);
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
process.stdout.write(`${JSON.stringify({ decision: "block", reason: decision.reason })}
|
|
1495
3545
|
`);
|
|
3546
|
+
}
|
|
1496
3547
|
}
|
|
1497
3548
|
});
|
|
1498
3549
|
var gatherCmd = defineCommand({
|
|
1499
3550
|
meta: {
|
|
1500
3551
|
name: "gather",
|
|
1501
|
-
description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, failing-job logs) as files for the review agent"
|
|
3552
|
+
description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, untrusted PR conversation to triage, failing-job logs) as files for the review agent"
|
|
1502
3553
|
},
|
|
1503
3554
|
args: {
|
|
1504
3555
|
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
@@ -1511,6 +3562,11 @@ var gatherCmd = defineCommand({
|
|
|
1511
3562
|
type: "string",
|
|
1512
3563
|
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
1513
3564
|
},
|
|
3565
|
+
"default-branch": {
|
|
3566
|
+
type: "string",
|
|
3567
|
+
description: "The repo's default branch \u2014 the trusted base the review checks out, and the reference for the full (triage) diff; a PR based on any other branch is treated as stacked",
|
|
3568
|
+
required: true
|
|
3569
|
+
},
|
|
1514
3570
|
"run-id": {
|
|
1515
3571
|
type: "string",
|
|
1516
3572
|
description: "CI run id (from workflow_run.id); its failing jobs' logs are downloaded on failure",
|
|
@@ -1535,6 +3591,7 @@ var gatherCmd = defineCommand({
|
|
|
1535
3591
|
repo: args.repo,
|
|
1536
3592
|
headSha: args["head-sha"],
|
|
1537
3593
|
headBranch: args["head-branch"],
|
|
3594
|
+
defaultBranch: args["default-branch"],
|
|
1538
3595
|
runId: args["run-id"],
|
|
1539
3596
|
conclusion: args.conclusion,
|
|
1540
3597
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
@@ -1579,7 +3636,7 @@ var postCmd = defineCommand({
|
|
|
1579
3636
|
},
|
|
1580
3637
|
"inline-template": {
|
|
1581
3638
|
type: "string",
|
|
1582
|
-
description: "Path to inline comment Eta template (default:
|
|
3639
|
+
description: "Path to inline comment Eta template (default: bundled templates/inline.eta)"
|
|
1583
3640
|
},
|
|
1584
3641
|
route: {
|
|
1585
3642
|
type: "string",
|
|
@@ -1600,47 +3657,279 @@ var postCmd = defineCommand({
|
|
|
1600
3657
|
"test-report": {
|
|
1601
3658
|
type: "string",
|
|
1602
3659
|
description: TEST_REPORT_DESCRIPTION
|
|
3660
|
+
},
|
|
3661
|
+
"run-url": {
|
|
3662
|
+
type: "string",
|
|
3663
|
+
description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
|
|
3664
|
+
},
|
|
3665
|
+
"json-url": {
|
|
3666
|
+
type: "string",
|
|
3667
|
+
description: "URL to the machine-readable findings JSON artifact, pointed at from the sticky and each inline comment"
|
|
1603
3668
|
}
|
|
1604
3669
|
},
|
|
1605
3670
|
run: async ({ args }) => {
|
|
3671
|
+
const priceResolution = resolvePrices(args.prices);
|
|
1606
3672
|
await post({
|
|
1607
3673
|
repo: args.repo,
|
|
1608
3674
|
headSha: args["head-sha"],
|
|
1609
3675
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
1610
3676
|
findingsPath: args.findings,
|
|
1611
3677
|
envelopePath: args.usage,
|
|
1612
|
-
pricesPath:
|
|
3678
|
+
pricesPath: priceResolution.path,
|
|
3679
|
+
pricesProvided: priceResolution.kind === "provided",
|
|
1613
3680
|
templatePath: resolveTemplatePath(args.template),
|
|
1614
|
-
inlineTemplatePath:
|
|
3681
|
+
inlineTemplatePath: resolveInlineTemplatePath(args["inline-template"]),
|
|
1615
3682
|
route: args.route,
|
|
1616
3683
|
headBranch: args["head-branch"],
|
|
1617
3684
|
effort: args.effort,
|
|
1618
|
-
testReportPath: args["test-report"]
|
|
3685
|
+
testReportPath: args["test-report"],
|
|
3686
|
+
runUrl: args["run-url"],
|
|
3687
|
+
jsonUrl: args["json-url"],
|
|
3688
|
+
postedAt: formatUtc(/* @__PURE__ */ new Date())
|
|
1619
3689
|
});
|
|
1620
3690
|
}
|
|
1621
3691
|
});
|
|
3692
|
+
var announceCmd = defineCommand({
|
|
3693
|
+
meta: {
|
|
3694
|
+
name: "announce",
|
|
3695
|
+
description: "Post (or update) the sticky the moment a review starts \u2014 an in-progress placeholder linking the run \u2014 so a workflow_run review, which runs from the default branch and is otherwise invisible on the PR, is visibly under way. Preserves a prior sticky's embedded findings + reviewed-sha markers so the re-review seed survives the swap."
|
|
3696
|
+
},
|
|
3697
|
+
args: {
|
|
3698
|
+
"head-sha": {
|
|
3699
|
+
type: "string",
|
|
3700
|
+
description: "Trusted head SHA to resolve the PR (from workflow_run.head_sha)",
|
|
3701
|
+
required: true
|
|
3702
|
+
},
|
|
3703
|
+
repo: {
|
|
3704
|
+
type: "string",
|
|
3705
|
+
description: "Repository (owner/name)",
|
|
3706
|
+
required: true
|
|
3707
|
+
},
|
|
3708
|
+
"run-url": {
|
|
3709
|
+
type: "string",
|
|
3710
|
+
description: "Workflow run URL the placeholder links to",
|
|
3711
|
+
required: true
|
|
3712
|
+
},
|
|
3713
|
+
"bot-login": {
|
|
3714
|
+
type: "string",
|
|
3715
|
+
description: "Bot login to trust for the sticky comment upsert (default: github-actions[bot])"
|
|
3716
|
+
},
|
|
3717
|
+
"head-branch": {
|
|
3718
|
+
type: "string",
|
|
3719
|
+
description: "Head branch to disambiguate the PR when multiple share a commit"
|
|
3720
|
+
}
|
|
3721
|
+
},
|
|
3722
|
+
run: async ({ args }) => {
|
|
3723
|
+
await announce({
|
|
3724
|
+
repo: args.repo,
|
|
3725
|
+
headSha: args["head-sha"],
|
|
3726
|
+
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
3727
|
+
runUrl: args["run-url"],
|
|
3728
|
+
headBranch: args["head-branch"]
|
|
3729
|
+
}).catch(
|
|
3730
|
+
(err) => (
|
|
3731
|
+
// `::warning::` so a persistently broken announce shows up in the run's annotations, not only
|
|
3732
|
+
// buried in the step log — `announce` otherwise reports success while having posted nothing.
|
|
3733
|
+
process.stderr.write(
|
|
3734
|
+
`::warning::code-review announce: could not post the in-progress sticky (${annotationSafe(errMsg(err))}) \u2014 continuing (cosmetic)
|
|
3735
|
+
`
|
|
3736
|
+
)
|
|
3737
|
+
)
|
|
3738
|
+
);
|
|
3739
|
+
}
|
|
3740
|
+
});
|
|
3741
|
+
var requireCeilingSec = (raw) => {
|
|
3742
|
+
if (raw === void 0) return null;
|
|
3743
|
+
const ms = parseWallMs(raw);
|
|
3744
|
+
if (ms === null)
|
|
3745
|
+
return fail(`--max-duration must be a duration like 60m, 3600s, or 1h (got "${raw}")`);
|
|
3746
|
+
return Math.floor(ms / 1e3);
|
|
3747
|
+
};
|
|
3748
|
+
var requireCeilingUsd = (raw) => {
|
|
3749
|
+
if (raw === void 0) return null;
|
|
3750
|
+
const n = Number.parseFloat(raw.replace(/^\$/, ""));
|
|
3751
|
+
if (!Number.isFinite(n) || n < 0) fail(`--max-usd must be a non-negative number (got "${raw}")`);
|
|
3752
|
+
return n;
|
|
3753
|
+
};
|
|
3754
|
+
var requireMaxInstructions = (raw) => {
|
|
3755
|
+
if (raw === void 0) return 4e3;
|
|
3756
|
+
if (!/^\d+$/.test(raw)) fail(`--max-instructions must be a non-negative integer (got "${raw}")`);
|
|
3757
|
+
return Number.parseInt(raw, 10);
|
|
3758
|
+
};
|
|
3759
|
+
var requirePositiveInt = (raw, flag) => {
|
|
3760
|
+
const n = Number.parseInt(raw, 10);
|
|
3761
|
+
return Number.isInteger(n) && n > 0 && /^\d+$/.test(raw) ? n : fail(`${flag} must be a positive integer; got "${raw}"`);
|
|
3762
|
+
};
|
|
3763
|
+
var requireWallMs = (raw, flag, fallback) => {
|
|
3764
|
+
const ms = parseWallMs(raw || fallback);
|
|
3765
|
+
return ms === null ? fail(`${flag} must be a duration like 30m, 15s, or 1h (got "${raw ?? ""}")`) : ms;
|
|
3766
|
+
};
|
|
3767
|
+
var parseCommandCmd = defineCommand({
|
|
3768
|
+
meta: {
|
|
3769
|
+
name: "parse-command",
|
|
3770
|
+
description: `Resolve a PR's head (SHA/branch/repo) from its NUMBER via the API and parse a ChatOps trigger comment ("/code-review [24m] [$1.00] <instructions>") into $GITHUB_OUTPUT lines. The untrusted comment is parsed here in type-safe code, never in workflow bash, and the head is resolved from the trusted PR number, never from the comment text. Emits should_run=false (and nothing else) when the comment is not the trigger, the PR is closed, or resolution fails.`
|
|
3771
|
+
},
|
|
3772
|
+
args: {
|
|
3773
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
3774
|
+
pr: {
|
|
3775
|
+
type: "string",
|
|
3776
|
+
description: "PR number (from github.event.issue.number \u2014 trusted event data)",
|
|
3777
|
+
required: true
|
|
3778
|
+
},
|
|
3779
|
+
"comment-body": {
|
|
3780
|
+
type: "string",
|
|
3781
|
+
description: "The comment body to parse (default: the CODE_REVIEW_COMMENT_BODY env var \u2014 the safe way to pass untrusted text without shell interpolation)"
|
|
3782
|
+
},
|
|
3783
|
+
trigger: {
|
|
3784
|
+
type: "string",
|
|
3785
|
+
description: 'Trigger token the comment must begin with (default: "/code-review")'
|
|
3786
|
+
},
|
|
3787
|
+
"max-duration": {
|
|
3788
|
+
type: "string",
|
|
3789
|
+
description: "Ceiling the requested duration is clamped to (e.g. 60m); omit for no clamp \u2014 a comment could then request an unbounded wall, so set this"
|
|
3790
|
+
},
|
|
3791
|
+
"max-usd": {
|
|
3792
|
+
type: "string",
|
|
3793
|
+
description: "Ceiling the requested USD budget is clamped to (e.g. 5); omit for no clamp"
|
|
3794
|
+
},
|
|
3795
|
+
"max-instructions": {
|
|
3796
|
+
type: "string",
|
|
3797
|
+
description: "Max characters of free-form instructions kept (default: 4000)"
|
|
3798
|
+
}
|
|
3799
|
+
},
|
|
3800
|
+
run: async ({ args }) => {
|
|
3801
|
+
const body = args["comment-body"] || process.env["CODE_REVIEW_COMMENT_BODY"] || "";
|
|
3802
|
+
const result = await parseCommand({
|
|
3803
|
+
repo: args.repo,
|
|
3804
|
+
prNumber: requirePositiveInt(args.pr, "--pr"),
|
|
3805
|
+
body,
|
|
3806
|
+
options: {
|
|
3807
|
+
trigger: args.trigger || "/code-review",
|
|
3808
|
+
maxDurationSec: requireCeilingSec(args["max-duration"]),
|
|
3809
|
+
maxUsd: requireCeilingUsd(args["max-usd"]),
|
|
3810
|
+
maxInstructionsLen: requireMaxInstructions(args["max-instructions"])
|
|
3811
|
+
}
|
|
3812
|
+
});
|
|
3813
|
+
if (result.kind === "skip") {
|
|
3814
|
+
process.stderr.write(`code-review parse-command: not running \u2014 ${result.reason}
|
|
3815
|
+
`);
|
|
3816
|
+
process.stdout.write(renderCommandOutputs(result, "UNUSED"));
|
|
3817
|
+
return;
|
|
3818
|
+
}
|
|
3819
|
+
for (const note of result.args.notes)
|
|
3820
|
+
process.stderr.write(`code-review parse-command: ${note}
|
|
3821
|
+
`);
|
|
3822
|
+
const delim = safeHeredocDelim(result.args.instructions, () => randomBytes(16).toString("hex"));
|
|
3823
|
+
process.stdout.write(renderCommandOutputs(result, delim));
|
|
3824
|
+
}
|
|
3825
|
+
});
|
|
3826
|
+
var requireReaction = (name) => isReaction(name) ? name : fail(`Unknown reaction "${name}" \u2014 one of: ${REACTIONS.join(", ")}`);
|
|
3827
|
+
var reactCmd = defineCommand({
|
|
3828
|
+
meta: {
|
|
3829
|
+
name: "react",
|
|
3830
|
+
description: "Add and/or remove a GitHub reaction on a PR/issue comment \u2014 the ChatOps acknowledgement (\u{1F440} on receipt, swapped to \u{1F680} on completion). Cosmetic: warns and exits 0 on any API error so a reaction never fails the job."
|
|
3831
|
+
},
|
|
3832
|
+
args: {
|
|
3833
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
3834
|
+
"comment-id": {
|
|
3835
|
+
type: "string",
|
|
3836
|
+
description: "Comment id to react on (github.event.comment.id)",
|
|
3837
|
+
required: true
|
|
3838
|
+
},
|
|
3839
|
+
add: { type: "string", description: `Reaction to add: ${REACTIONS.join(" | ")}` },
|
|
3840
|
+
remove: {
|
|
3841
|
+
type: "string",
|
|
3842
|
+
description: "Reaction (of the token owner) to remove after adding \u2014 for the \u{1F440}\u2192\u{1F680} swap"
|
|
3843
|
+
}
|
|
3844
|
+
},
|
|
3845
|
+
run: async ({ args }) => {
|
|
3846
|
+
const commentId = requirePositiveInt(args["comment-id"], "--comment-id");
|
|
3847
|
+
const add = args.add ? requireReaction(args.add) : void 0;
|
|
3848
|
+
const remove = args.remove ? requireReaction(args.remove) : void 0;
|
|
3849
|
+
if (add === void 0 && remove === void 0)
|
|
3850
|
+
fail("react: nothing to do \u2014 pass --add and/or --remove");
|
|
3851
|
+
await react({ repo: args.repo, commentId, add, remove }).catch(
|
|
3852
|
+
(err) => process.stderr.write(
|
|
3853
|
+
`code-review react: reaction update failed (${errMsg(err)}) \u2014 continuing (reactions are cosmetic)
|
|
3854
|
+
`
|
|
3855
|
+
)
|
|
3856
|
+
);
|
|
3857
|
+
}
|
|
3858
|
+
});
|
|
3859
|
+
var awaitCiCmd = defineCommand({
|
|
3860
|
+
meta: {
|
|
3861
|
+
name: "await-ci",
|
|
3862
|
+
description: "Wait for the PR head's CI workflow run to conclude, then emit its REAL conclusion + run id to $GITHUB_OUTPUT (ci_settled, ci_conclusion, ci_run_id). The on-demand comment trigger uses this so it routes on the same CI result the CI-completion trigger would \u2014 success \u2192 full review, failure \u2192 mechanic with that run's logs \u2014 instead of reviewing blind. Polls until the run completes or the timeout elapses; ci_settled=false \u21D2 no conclusive result (caller should decline to review, not guess)."
|
|
3863
|
+
},
|
|
3864
|
+
args: {
|
|
3865
|
+
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
3866
|
+
"head-sha": {
|
|
3867
|
+
type: "string",
|
|
3868
|
+
description: "PR head SHA to find the CI run for (resolved from the trusted PR number)",
|
|
3869
|
+
required: true
|
|
3870
|
+
},
|
|
3871
|
+
"ci-workflow": {
|
|
3872
|
+
type: "string",
|
|
3873
|
+
description: 'CI workflow name to wait for \u2014 the name: of your CI workflow (default: "CI")'
|
|
3874
|
+
},
|
|
3875
|
+
timeout: {
|
|
3876
|
+
type: "string",
|
|
3877
|
+
description: "Give up waiting after this wall (default: 30m)"
|
|
3878
|
+
},
|
|
3879
|
+
"poll-interval": {
|
|
3880
|
+
type: "string",
|
|
3881
|
+
description: "How often to re-check the run status (default: 15s)"
|
|
3882
|
+
}
|
|
3883
|
+
},
|
|
3884
|
+
run: async ({ args }) => {
|
|
3885
|
+
const workflowName = args["ci-workflow"] || "CI";
|
|
3886
|
+
const outcome = await awaitCiConclusion(args.repo, args["head-sha"], {
|
|
3887
|
+
workflowName,
|
|
3888
|
+
pollIntervalMs: requireWallMs(args["poll-interval"], "--poll-interval", "15s"),
|
|
3889
|
+
timeoutMs: requireWallMs(args.timeout, "--timeout", "30m")
|
|
3890
|
+
});
|
|
3891
|
+
process.stderr.write(
|
|
3892
|
+
outcome.kind === "concluded" ? `code-review await-ci: CI run ${String(outcome.runId)} ("${workflowName}") concluded "${outcome.conclusion}"
|
|
3893
|
+
` : `code-review await-ci: no run named "${workflowName}" concluded before the timeout \u2014 not reviewing.${outcome.seenNames.length > 0 ? ` Workflow names seen for this head SHA: ${outcome.seenNames.join(", ")} \u2014 check --ci-workflow matches one.` : " No workflow runs were seen for this head SHA at all."}
|
|
3894
|
+
`
|
|
3895
|
+
);
|
|
3896
|
+
process.stdout.write(renderCiOutputs(outcome));
|
|
3897
|
+
}
|
|
3898
|
+
});
|
|
1622
3899
|
var main = defineCommand({
|
|
1623
3900
|
meta: {
|
|
1624
3901
|
name: "code-review",
|
|
1625
3902
|
version: packageVersion,
|
|
1626
|
-
description: "Deterministic commenter for agentic PR review
|
|
3903
|
+
description: "Deterministic commenter for agentic PR review"
|
|
1627
3904
|
},
|
|
1628
3905
|
subCommands: {
|
|
1629
3906
|
gather: gatherCmd,
|
|
3907
|
+
"parse-command": parseCommandCmd,
|
|
3908
|
+
react: reactCmd,
|
|
3909
|
+
"await-ci": awaitCiCmd,
|
|
1630
3910
|
render: renderCmd,
|
|
1631
3911
|
inline: inlineCmd,
|
|
1632
3912
|
post: postCmd,
|
|
3913
|
+
announce: announceCmd,
|
|
1633
3914
|
cost: costCmd,
|
|
3915
|
+
"check-cost": checkCostCmd,
|
|
1634
3916
|
validate: validateCmd,
|
|
3917
|
+
"seed-draft": seedDraftCmd,
|
|
1635
3918
|
adapt: adaptCmd,
|
|
3919
|
+
notice: noticeCmd,
|
|
1636
3920
|
extract: extractCmd,
|
|
1637
|
-
"
|
|
3921
|
+
"validate-patches": validatePatchesCmd,
|
|
3922
|
+
"print-schema": printSchemaCmd,
|
|
3923
|
+
"stop-gate": stopGateCmd,
|
|
3924
|
+
"budget-hook": budgetHookCmd,
|
|
3925
|
+
"print-settings": printSettingsCmd,
|
|
3926
|
+
deadline: deadlineCmd
|
|
1638
3927
|
}
|
|
1639
3928
|
});
|
|
1640
3929
|
if (!process.env["VITEST"]) {
|
|
1641
3930
|
await runMain(main);
|
|
1642
3931
|
}
|
|
1643
3932
|
|
|
1644
|
-
export { main };
|
|
3933
|
+
export { main, snapshotIfValid };
|
|
1645
3934
|
//# sourceMappingURL=index.js.map
|
|
1646
3935
|
//# sourceMappingURL=index.js.map
|