@jphutchins/code-review 0.1.0-alpha.66 → 0.1.0-alpha.68
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 +1 -1
- package/dist/index.js +201 -60
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schema/VERSIONING.md +1 -1
- package/schema/findings.schema.json +5 -0
- package/templates/comment.eta +10 -2
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ npx @jphutchins/code-review <subcommand>
|
|
|
38
38
|
| Subcommand | What it does |
|
|
39
39
|
| --- | --- |
|
|
40
40
|
| `post` | Post a complete review from findings + envelope + diff — the one-call path. The findings are listed in the sticky summary; `--inline` renders the in-diff ones as comments on the diff instead. Also renders the review into `$GITHUB_STEP_SUMMARY`: the sticky is overwritten each round, the run summary keeps each round as it stood |
|
|
41
|
-
| `gather` | Resolve the PR from the CI head SHA and gather the review inputs (diff with git-diff fallback, PR context, prior bot review, the implementer's answers (`Review-Response:` lines and verdict tables),
|
|
41
|
+
| `gather` | Resolve the PR from the CI head SHA and gather the review inputs (diff with git-diff fallback, PR context, prior bot review, the implementer's answers (`Review-Response:` lines and verdict tables), the CI run's job logs — the failing jobs' on a failure, the jobs that ran, up to 64, on a pass) into the directory named by `--out-dir` (default: the current directory; the shipped workflows use `$RUNNER_TEMP/gather`) |
|
|
42
42
|
| `parse-command` | Resolve a PR's head from its number and parse a ChatOps trigger comment (`/code-review [24m] [$1.00] <instructions>`) into review overrides — the on-demand comment trigger |
|
|
43
43
|
| `react` | Add/remove a GitHub comment reaction — the ChatOps acknowledgement (👀 on receipt, 🚀 on completion) |
|
|
44
44
|
| `await-ci` | Wait for a PR head's CI run to conclude and emit its real conclusion + run id — so an on-demand comment review routes on the CI result (success → full, failure → mechanic) instead of reviewing blind |
|
package/dist/index.js
CHANGED
|
@@ -70,6 +70,11 @@ var LineNumber = t.refinement(
|
|
|
70
70
|
"LineNumber"
|
|
71
71
|
);
|
|
72
72
|
var Confidence = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Confidence");
|
|
73
|
+
var NonEmptyString = t.refinement(
|
|
74
|
+
t.string,
|
|
75
|
+
(s) => s.trim().length > 0,
|
|
76
|
+
"NonEmptyString"
|
|
77
|
+
);
|
|
73
78
|
var Likelihood = t.refinement(t.number, (n) => n >= 0 && n <= 1, "Likelihood");
|
|
74
79
|
var FiniteNumber = t.refinement(t.number, (n) => Number.isFinite(n), "FiniteNumber");
|
|
75
80
|
var NonNegativePrice = t.refinement(t.number, (n) => n >= 0, "NonNegativePrice");
|
|
@@ -236,7 +241,8 @@ var FindingsCodec = t.exact(
|
|
|
236
241
|
// Pipeline-stamped only (issue #174); see ConvergenceCodec.
|
|
237
242
|
convergence: ConvergenceCodec,
|
|
238
243
|
// Agent-written best-effort (issue #182); see ChangeSizeCodec.
|
|
239
|
-
change_size: ChangeSizeCodec
|
|
244
|
+
change_size: ChangeSizeCodec,
|
|
245
|
+
unfinished: t.array(NonEmptyString)
|
|
240
246
|
})
|
|
241
247
|
])
|
|
242
248
|
);
|
|
@@ -246,8 +252,31 @@ var PIPELINE_STAMPED_FIELDS = /* @__PURE__ */ new Set([
|
|
|
246
252
|
]);
|
|
247
253
|
var RECOVERABLE_OPTIONAL_FIELDS = /* @__PURE__ */ new Set([
|
|
248
254
|
...PIPELINE_STAMPED_FIELDS,
|
|
249
|
-
"change_size"
|
|
255
|
+
"change_size",
|
|
256
|
+
"unfinished"
|
|
250
257
|
]);
|
|
258
|
+
var withoutBrokenOptionalFields = (doc, accepts) => {
|
|
259
|
+
const bare = Object.fromEntries(
|
|
260
|
+
Object.entries(doc).filter(([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2))
|
|
261
|
+
);
|
|
262
|
+
if (!accepts(bare)) return null;
|
|
263
|
+
const usable = (kept, key2, value) => {
|
|
264
|
+
if (accepts({ ...kept, [key2]: value })) return value;
|
|
265
|
+
if (!Array.isArray(value)) return void 0;
|
|
266
|
+
const entries = value.filter((entry) => accepts({ ...kept, [key2]: [entry] }));
|
|
267
|
+
return entries.length > 0 && accepts({ ...kept, [key2]: entries }) ? entries : void 0;
|
|
268
|
+
};
|
|
269
|
+
const restored = Object.entries(doc).filter(([key2]) => RECOVERABLE_OPTIONAL_FIELDS.has(key2)).reduce((kept, [key2, value]) => {
|
|
270
|
+
const restoredValue = usable(kept, key2, value);
|
|
271
|
+
return restoredValue === void 0 ? kept : { ...kept, [key2]: restoredValue };
|
|
272
|
+
}, bare);
|
|
273
|
+
return {
|
|
274
|
+
doc: restored,
|
|
275
|
+
stripped: Object.keys(doc).filter(
|
|
276
|
+
(key2) => !Object.hasOwn(restored, key2) || restored[key2] !== doc[key2]
|
|
277
|
+
)
|
|
278
|
+
};
|
|
279
|
+
};
|
|
251
280
|
var ID_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
252
281
|
var priorIdsFrom = (doc) => {
|
|
253
282
|
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return [];
|
|
@@ -545,7 +574,7 @@ var incompleteFindings = (summary) => ({
|
|
|
545
574
|
var isIncompleteFindings = (findings) => findings.verdict === "error" && findings.findings.length === 0;
|
|
546
575
|
|
|
547
576
|
// src/released.ts
|
|
548
|
-
var RELEASED = "2026-10-
|
|
577
|
+
var RELEASED = "2026-10-11";
|
|
549
578
|
|
|
550
579
|
// src/cost.ts
|
|
551
580
|
var defaultWarn = (message) => {
|
|
@@ -1939,6 +1968,19 @@ var overruledReRaiseNote = (entries, count) => entries.length === 0 ? "" : [
|
|
|
1939
1968
|
// src/render.ts
|
|
1940
1969
|
var escapePipes = (text) => text.replace(/\|/g, "\\|");
|
|
1941
1970
|
var shedNote = (count, summaryAvailable, runUrl, findingsDocLinked, thisRunArtifact) => count === 0 ? "" : `<sub>**${String(count)} finding${count === 1 ? "" : "s"} left out of this comment**${summaryAvailable && runUrl !== void 0 ? ` \u2014 the [run summary](<${runUrl}>) carries the whole review` : findingsDocLinked && thisRunArtifact ? " \u2014 the machine findings document carries them" : ""}.</sub>`;
|
|
1971
|
+
var UNFINISHED_SHOWN = 8;
|
|
1972
|
+
var UNFINISHED_CLIP_CHARS = 240;
|
|
1973
|
+
var unfinishedLine = (check) => {
|
|
1974
|
+
const escaped = escapeHtml(check.replace(/\s+/g, " ").trim());
|
|
1975
|
+
return escaped.length > UNFINISHED_CLIP_CHARS ? `${escaped.slice(0, UNFINISHED_CLIP_CHARS).replace(/&[#a-z0-9]*$/i, "").replace(/[\uD800-\uDBFF]$/, "")}\u2026` : escaped;
|
|
1976
|
+
};
|
|
1977
|
+
var shownUnfinished = (doc, fullReviewRound) => {
|
|
1978
|
+
const checks = fullReviewRound ? doc.unfinished ?? [] : [];
|
|
1979
|
+
return [
|
|
1980
|
+
...checks.slice(0, UNFINISHED_SHOWN).map(unfinishedLine),
|
|
1981
|
+
...checks.length > UNFINISHED_SHOWN ? [`\u2026 and ${String(checks.length - UNFINISHED_SHOWN)} more`] : []
|
|
1982
|
+
];
|
|
1983
|
+
};
|
|
1942
1984
|
var terminalNotice = (i) => [
|
|
1943
1985
|
`## ${i.verdictBadge}`,
|
|
1944
1986
|
"",
|
|
@@ -1950,6 +1992,7 @@ var terminalNotice = (i) => [
|
|
|
1950
1992
|
] : [],
|
|
1951
1993
|
"",
|
|
1952
1994
|
`> **The review for \`${escapeCodeBackticks(i.reviewedSha.slice(0, 7))}\` exceeds GitHub's comment-size limit.**` + (i.summaryAvailable ? i.runUrl ? ` The whole review lives in the [run summary](<${i.runUrl}>).` : " The whole review lives in the workflow run's summary." : i.findingsDocLinked && i.thisRunArtifact ? " The whole review lives in the machine findings document." : "") + (i.findingsDocLinked && i.thisRunArtifact ? " The machine findings document is linked above." : ""),
|
|
1995
|
+
...i.unfinished.length > 0 ? ["", `> **Unfinished checks:** ${i.unfinished.join(" \xB7 ")}`] : [],
|
|
1953
1996
|
"",
|
|
1954
1997
|
"> [!WARNING]",
|
|
1955
1998
|
`> **LLM Disclosure** \u2014 this review was produced by ${i.modelNames || "unknown model"}.`,
|
|
@@ -2113,6 +2156,8 @@ var render = (input) => {
|
|
|
2113
2156
|
const trajectory = convergence?.rounds ?? input.rounds ?? [];
|
|
2114
2157
|
const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
|
|
2115
2158
|
const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
|
|
2159
|
+
const unfinished = input.findings.unfinished ?? [];
|
|
2160
|
+
const unfinishedSuffix = unfinished.length > 0 ? ` \xB7 ${String(unfinished.length)} unfinished check${unfinished.length === 1 ? "" : "s"}` : "";
|
|
2116
2161
|
const contestedSuffix = (input.contested ?? []).length > 0 ? ` \xB7 ${String((input.contested ?? []).length)} contested` : "";
|
|
2117
2162
|
const advisoryAllowed = isFullReviewRound;
|
|
2118
2163
|
const discussionLinksFor = (id) => input.discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionByFinding, id) ? input.discussionByFinding[id] ?? [] : [];
|
|
@@ -2257,12 +2302,13 @@ var render = (input) => {
|
|
|
2257
2302
|
input.jsonUrl !== void 0
|
|
2258
2303
|
),
|
|
2259
2304
|
terminalNotice: terminalNotice({
|
|
2305
|
+
unfinished: shownUnfinished(input.findings, isFullReviewRound),
|
|
2260
2306
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
2261
2307
|
verdictBadge: verdictBadge(input.findings.verdict),
|
|
2262
2308
|
route: route ?? void 0,
|
|
2263
2309
|
postedAt: input.postedAt ?? "",
|
|
2264
2310
|
roundsSummary: roundsSummary(trajectory, input.roundCount),
|
|
2265
|
-
convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) + contestedSuffix : "",
|
|
2311
|
+
convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) + contestedSuffix + unfinishedSuffix : "",
|
|
2266
2312
|
inlinePosted: input.inlineDisposition?.kind === "posted" ? input.inlineDisposition.count : 0,
|
|
2267
2313
|
runUrl: input.runUrl,
|
|
2268
2314
|
summaryAvailable: input.summaryAvailable ?? false,
|
|
@@ -2289,7 +2335,7 @@ var render = (input) => {
|
|
|
2289
2335
|
reviewedSha: input.reviewedSha ?? "0000000000000000000000000000000000000000",
|
|
2290
2336
|
postedAt: input.postedAt ?? "",
|
|
2291
2337
|
severityCounts,
|
|
2292
|
-
convergenceSummary: !isFullReviewRound ? "" : (convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold)) + contestedSuffix,
|
|
2338
|
+
convergenceSummary: !isFullReviewRound ? "" : (convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold)) + contestedSuffix + unfinishedSuffix,
|
|
2293
2339
|
strays: discussionBudget.kept,
|
|
2294
2340
|
orphanedLines: orphanedRender.lines,
|
|
2295
2341
|
orphanedUnresolvable: input.orphanedUnresolvable === true,
|
|
@@ -2326,6 +2372,8 @@ var render = (input) => {
|
|
|
2326
2372
|
answeredNotes: input.answeredNotes ?? {},
|
|
2327
2373
|
answeredReRaiseNote: input.answeredReRaiseNote ?? "",
|
|
2328
2374
|
contested: contestedBudget.kept,
|
|
2375
|
+
unfinished: shownUnfinished(input.findings, isFullReviewRound),
|
|
2376
|
+
unfinishedCaveat: unfinished.length > 0 ? ` \u2014 ${String(unfinished.length)} planned check${unfinished.length === 1 ? "" : "s"} went unfinished` : "",
|
|
2329
2377
|
reviewUrl: input.reviewUrl ?? null,
|
|
2330
2378
|
formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
|
|
2331
2379
|
// N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to
|
|
@@ -2740,8 +2788,8 @@ var spendClause = (i) => i.spentUsd === null ? null : i.budgetUsd !== null && i.
|
|
|
2740
2788
|
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`;
|
|
2741
2789
|
var directive = (phase, draftPath, isSubagent) => {
|
|
2742
2790
|
if (isSubagent)
|
|
2743
|
-
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.`;
|
|
2744
|
-
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.`;
|
|
2791
|
+
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, naming each check you planned but did not finish. 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, naming each check you planned but did not finish \u2014 do not write ${draftPath} yourself; the main agent writes it, and you may run out of budget otherwise.`;
|
|
2792
|
+
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}, listing in its \`unfinished\` field, when its schema has one, every check you or a subagent planned but did not finish (never one a CI job already ran and passed), 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, listing in its \`unfinished\` field, when its schema has one, every check you or a subagent planned but did not finish (never one a CI job already ran and passed), 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.`;
|
|
2745
2793
|
};
|
|
2746
2794
|
var budgetMessage = (i, phase, draftPath, isSubagent) => {
|
|
2747
2795
|
const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
|
|
@@ -3333,15 +3381,16 @@ var loadFindings = (path) => {
|
|
|
3333
3381
|
}
|
|
3334
3382
|
const first = decodeFindings(raw);
|
|
3335
3383
|
if (first.kind === "invalid-shape" && typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.keys(raw).some((k) => RECOVERABLE_OPTIONAL_FIELDS.has(k))) {
|
|
3336
|
-
const
|
|
3337
|
-
|
|
3384
|
+
const recovered = withoutBrokenOptionalFields(
|
|
3385
|
+
raw,
|
|
3386
|
+
(candidate) => decodeFindings(candidate).kind === "ok"
|
|
3338
3387
|
);
|
|
3339
|
-
|
|
3340
|
-
if (retry.kind === "ok") {
|
|
3388
|
+
if (recovered !== null) {
|
|
3341
3389
|
process.stderr.write(
|
|
3342
|
-
|
|
3390
|
+
`Warning: the review draft carried an invalid best-effort field (${recovered.stripped.join("/")}) \u2014 stripped it and used the rest; the pipeline re-stamps convergence
|
|
3391
|
+
`
|
|
3343
3392
|
);
|
|
3344
|
-
return
|
|
3393
|
+
return decodeFindings(recovered.doc);
|
|
3345
3394
|
}
|
|
3346
3395
|
}
|
|
3347
3396
|
return first;
|
|
@@ -4304,6 +4353,7 @@ ${findingsPointer2}`
|
|
|
4304
4353
|
] : [],
|
|
4305
4354
|
"",
|
|
4306
4355
|
terminalNotice({
|
|
4356
|
+
unfinished: [],
|
|
4307
4357
|
reviewedSha: input.headSha,
|
|
4308
4358
|
verdictBadge: verdictBadge(loadedFindings.verdict),
|
|
4309
4359
|
route: route ?? void 0,
|
|
@@ -5009,14 +5059,17 @@ var react = async (input, ghApi = runGhApi) => {
|
|
|
5009
5059
|
await removeReactions(input.repo, input.commentId, input.remove, ghApi);
|
|
5010
5060
|
}
|
|
5011
5061
|
};
|
|
5012
|
-
var RunCodec = t.
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5062
|
+
var RunCodec = t.intersection([
|
|
5063
|
+
t.type({
|
|
5064
|
+
id: t.number,
|
|
5065
|
+
name: t.union([t.string, t.null]),
|
|
5066
|
+
status: t.union([t.string, t.null]),
|
|
5067
|
+
conclusion: t.union([t.string, t.null]),
|
|
5068
|
+
run_number: t.number
|
|
5069
|
+
}),
|
|
5070
|
+
t.partial({ run_attempt: t.union([t.number, t.null]) })
|
|
5071
|
+
]);
|
|
5072
|
+
var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number, run_attempt: .run_attempt}";
|
|
5020
5073
|
var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
|
|
5021
5074
|
const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
|
|
5022
5075
|
const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
|
|
@@ -5036,7 +5089,12 @@ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
|
|
|
5036
5089
|
null
|
|
5037
5090
|
);
|
|
5038
5091
|
return {
|
|
5039
|
-
run: latest === null ? null : {
|
|
5092
|
+
run: latest === null ? null : {
|
|
5093
|
+
id: latest.id,
|
|
5094
|
+
status: latest.status ?? "unknown",
|
|
5095
|
+
conclusion: latest.conclusion,
|
|
5096
|
+
attempt: latest.run_attempt ?? null
|
|
5097
|
+
},
|
|
5040
5098
|
seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
|
|
5041
5099
|
};
|
|
5042
5100
|
};
|
|
@@ -5055,7 +5113,12 @@ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi,
|
|
|
5055
5113
|
const poll = async (lastSeenNames, lastRunId) => {
|
|
5056
5114
|
const { run: run2, seenNames } = await safeResolve();
|
|
5057
5115
|
if (run2 !== null && run2.status === "completed" && run2.conclusion !== null)
|
|
5058
|
-
return {
|
|
5116
|
+
return {
|
|
5117
|
+
kind: "concluded",
|
|
5118
|
+
conclusion: run2.conclusion,
|
|
5119
|
+
runId: run2.id,
|
|
5120
|
+
runAttempt: run2.attempt
|
|
5121
|
+
};
|
|
5059
5122
|
const runId = run2 === null ? lastRunId : run2.id;
|
|
5060
5123
|
const names = seenNames.length > 0 ? seenNames : lastSeenNames;
|
|
5061
5124
|
if (deps.elapsedMs() >= options.timeoutMs)
|
|
@@ -5073,6 +5136,7 @@ var monotonicElapsed = () => {
|
|
|
5073
5136
|
var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
|
|
5074
5137
|
ci_conclusion=${outcome.conclusion}
|
|
5075
5138
|
ci_run_id=${String(outcome.runId)}
|
|
5139
|
+
ci_run_attempt=${outcome.runAttempt === null ? "" : String(outcome.runAttempt)}
|
|
5076
5140
|
` : `ci_settled=false
|
|
5077
5141
|
ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
|
|
5078
5142
|
`;
|
|
@@ -5133,6 +5197,14 @@ var JobCodec = t.type({
|
|
|
5133
5197
|
conclusion: t.union([t.string, t.null])
|
|
5134
5198
|
});
|
|
5135
5199
|
var JOBS_JQ = ".jobs[] | {id: .id, name: .name, conclusion: .conclusion}";
|
|
5200
|
+
var parseRunAttempt = (raw) => {
|
|
5201
|
+
if (raw === void 0 || raw === "") return void 0;
|
|
5202
|
+
if (!/^[1-9][0-9]*$/.test(raw)) {
|
|
5203
|
+
throw new Error(`--run-attempt must be a positive integer, got ${JSON.stringify(raw)}`);
|
|
5204
|
+
}
|
|
5205
|
+
return raw;
|
|
5206
|
+
};
|
|
5207
|
+
var jobsEndpoint = (repo, runId, runAttempt) => runAttempt === void 0 ? `repos/${repo}/actions/runs/${runId}/jobs` : `repos/${repo}/actions/runs/${runId}/attempts/${runAttempt}/jobs`;
|
|
5136
5208
|
var fetchPrMeta = async (repo, prNumber, ghApi) => {
|
|
5137
5209
|
const stdout = await ghApi([
|
|
5138
5210
|
`repos/${repo}/pulls/${String(prNumber)}`,
|
|
@@ -5282,35 +5354,46 @@ var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review
|
|
|
5282
5354
|
body: clip(r.body)
|
|
5283
5355
|
}));
|
|
5284
5356
|
var MAX_STAGED_JOB_LOGS = 20;
|
|
5285
|
-
var
|
|
5286
|
-
|
|
5287
|
-
await ghApi([`repos/${repo}/actions/
|
|
5357
|
+
var stageJobLog = async (repo, jobId, outDir, ghApi) => {
|
|
5358
|
+
try {
|
|
5359
|
+
const log = await ghApi([`repos/${repo}/actions/jobs/${String(jobId)}/logs`]);
|
|
5360
|
+
if (log.trim() === "no log available" || log.trim() === "") {
|
|
5361
|
+
process.stderr.write(
|
|
5362
|
+
`Warning: job ${String(jobId)} has no log available (placeholder body) \u2014 not staged
|
|
5363
|
+
`
|
|
5364
|
+
);
|
|
5365
|
+
return null;
|
|
5366
|
+
}
|
|
5367
|
+
const file = `job_${String(jobId)}.log`;
|
|
5368
|
+
writeFileSync(join(outDir, file), log);
|
|
5369
|
+
return file;
|
|
5370
|
+
} catch (err) {
|
|
5371
|
+
process.stderr.write(
|
|
5372
|
+
`Warning: failed to download logs for job ${String(jobId)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
|
|
5373
|
+
`
|
|
5374
|
+
);
|
|
5375
|
+
return null;
|
|
5376
|
+
}
|
|
5377
|
+
};
|
|
5378
|
+
var listRunJobs = async (repo, runId, runAttempt, ghApi) => {
|
|
5379
|
+
const decoded = t.array(JobCodec).decode(
|
|
5380
|
+
parseJsonl(
|
|
5381
|
+
await ghApi([jobsEndpoint(repo, runId, runAttempt), "--paginate", "--jq", JOBS_JQ])
|
|
5382
|
+
)
|
|
5288
5383
|
);
|
|
5289
|
-
const decoded = t.array(JobCodec).decode(rows);
|
|
5290
5384
|
if (decoded._tag === "Left") {
|
|
5291
5385
|
throw new Error(`Jobs list for run ${runId} did not match the expected shape`);
|
|
5292
5386
|
}
|
|
5293
|
-
|
|
5387
|
+
return decoded.right;
|
|
5388
|
+
};
|
|
5389
|
+
var downloadFailingJobLogs = async (repo, runId, runAttempt, outDir, ghApi) => {
|
|
5390
|
+
const failing = (await listRunJobs(repo, runId, runAttempt, ghApi)).filter(
|
|
5391
|
+
(j) => j.conclusion === "failure"
|
|
5392
|
+
);
|
|
5294
5393
|
const selected = failing.slice(0, MAX_STAGED_JOB_LOGS);
|
|
5295
5394
|
let staged = 0;
|
|
5296
5395
|
for (const job of selected) {
|
|
5297
|
-
|
|
5298
|
-
const log = await ghApi([`repos/${repo}/actions/jobs/${String(job.id)}/logs`]);
|
|
5299
|
-
if (log.trim() === "no log available" || log.trim() === "") {
|
|
5300
|
-
process.stderr.write(
|
|
5301
|
-
`Warning: job ${String(job.id)} has no log available (placeholder body) \u2014 not staged
|
|
5302
|
-
`
|
|
5303
|
-
);
|
|
5304
|
-
continue;
|
|
5305
|
-
}
|
|
5306
|
-
writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
|
|
5307
|
-
staged += 1;
|
|
5308
|
-
} catch (err) {
|
|
5309
|
-
process.stderr.write(
|
|
5310
|
-
`Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
|
|
5311
|
-
`
|
|
5312
|
-
);
|
|
5313
|
-
}
|
|
5396
|
+
if (await stageJobLog(repo, job.id, outDir, ghApi) !== null) staged += 1;
|
|
5314
5397
|
}
|
|
5315
5398
|
writeFileSync(
|
|
5316
5399
|
join(outDir, "failed_jobs.json"),
|
|
@@ -5338,6 +5421,58 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
|
|
|
5338
5421
|
}
|
|
5339
5422
|
return { staged, failing: failing.length };
|
|
5340
5423
|
};
|
|
5424
|
+
var MAX_STAGED_PASSED_JOB_LOGS = 64;
|
|
5425
|
+
var PASSED_LOG_CONCURRENCY = 8;
|
|
5426
|
+
var stagePassedRun = async (repo, runId, runAttempt, outDir, ghApi) => {
|
|
5427
|
+
try {
|
|
5428
|
+
const jobs = await listRunJobs(repo, runId, runAttempt, ghApi);
|
|
5429
|
+
const writeIndex = (logs2) => {
|
|
5430
|
+
writeFileSync(
|
|
5431
|
+
join(outDir, "ci_jobs.json"),
|
|
5432
|
+
`${JSON.stringify(
|
|
5433
|
+
jobs.map((j) => ({
|
|
5434
|
+
name: j.name,
|
|
5435
|
+
conclusion: j.conclusion,
|
|
5436
|
+
log: logs2.get(j.id) ?? null
|
|
5437
|
+
})),
|
|
5438
|
+
null,
|
|
5439
|
+
2
|
|
5440
|
+
)}
|
|
5441
|
+
`
|
|
5442
|
+
);
|
|
5443
|
+
};
|
|
5444
|
+
writeIndex(/* @__PURE__ */ new Map());
|
|
5445
|
+
const ran = jobs.filter((j) => j.conclusion !== "skipped");
|
|
5446
|
+
const selected = ran.slice(0, MAX_STAGED_PASSED_JOB_LOGS);
|
|
5447
|
+
const logs = await Array.from(
|
|
5448
|
+
{ length: Math.ceil(selected.length / PASSED_LOG_CONCURRENCY) },
|
|
5449
|
+
(_, i) => selected.slice(i * PASSED_LOG_CONCURRENCY, (i + 1) * PASSED_LOG_CONCURRENCY)
|
|
5450
|
+
).reduce(async (staged, batch) => {
|
|
5451
|
+
const prior = await staged;
|
|
5452
|
+
const files = await Promise.all(
|
|
5453
|
+
batch.map(async (job) => [job.id, await stageJobLog(repo, job.id, outDir, ghApi)])
|
|
5454
|
+
);
|
|
5455
|
+
return new Map([
|
|
5456
|
+
...prior,
|
|
5457
|
+
...files.flatMap(([id, file]) => file === null ? [] : [[id, file]])
|
|
5458
|
+
]);
|
|
5459
|
+
}, Promise.resolve(/* @__PURE__ */ new Map()));
|
|
5460
|
+
writeIndex(logs);
|
|
5461
|
+
if (ran.length > selected.length) {
|
|
5462
|
+
process.stderr.write(
|
|
5463
|
+
`::warning::${annotationSafe(`${String(ran.length)} jobs ran in the passed run ${runId}; logs were staged for the first ${String(selected.length)} only`)}
|
|
5464
|
+
`
|
|
5465
|
+
);
|
|
5466
|
+
}
|
|
5467
|
+
return logs.size;
|
|
5468
|
+
} catch (err) {
|
|
5469
|
+
process.stderr.write(
|
|
5470
|
+
`::warning::${annotationSafe(`the passed CI run's jobs could not be listed (${errMsg(err)}) \u2014 the review knows CI passed, not which jobs ran`)}
|
|
5471
|
+
`
|
|
5472
|
+
);
|
|
5473
|
+
return 0;
|
|
5474
|
+
}
|
|
5475
|
+
};
|
|
5341
5476
|
var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghArtifactReader) => {
|
|
5342
5477
|
const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
|
|
5343
5478
|
const resolution = resolvePr(candidates, input.headBranch);
|
|
@@ -5441,7 +5576,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
|
|
|
5441
5576
|
reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
|
|
5442
5577
|
})
|
|
5443
5578
|
);
|
|
5444
|
-
const jobLogs = input.conclusion === "failure" ? await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi) : { staged: 0, failing: 0 };
|
|
5579
|
+
const jobLogs = input.conclusion === "failure" ? await downloadFailingJobLogs(input.repo, input.runId, input.runAttempt, input.outDir, ghApi) : { staged: 0, failing: 0 };
|
|
5580
|
+
const passedLogs = input.conclusion === "success" ? await stagePassedRun(input.repo, input.runId, input.runAttempt, input.outDir, ghApi) : 0;
|
|
5445
5581
|
return {
|
|
5446
5582
|
kind: "gathered",
|
|
5447
5583
|
pr: prNumber,
|
|
@@ -5449,7 +5585,7 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
|
|
|
5449
5585
|
diffSize: Buffer.byteLength(prDiff, "utf8"),
|
|
5450
5586
|
stacked,
|
|
5451
5587
|
baseSha: meta.base_sha,
|
|
5452
|
-
stagedJobLogs: jobLogs.staged,
|
|
5588
|
+
stagedJobLogs: jobLogs.staged + passedLogs,
|
|
5453
5589
|
failingJobs: jobLogs.failing
|
|
5454
5590
|
};
|
|
5455
5591
|
};
|
|
@@ -6619,11 +6755,6 @@ var seedDraftCmd = defineCommand({
|
|
|
6619
6755
|
const seededFromPrior = priorFindings === null ? false : (() => {
|
|
6620
6756
|
try {
|
|
6621
6757
|
const schemaPath = args.schema ? resolve(args.schema) : schemaPathFor(kind, args["schema-version"]);
|
|
6622
|
-
const barePrior = typeof priorFindings === "object" && !Array.isArray(priorFindings) ? Object.fromEntries(
|
|
6623
|
-
Object.entries(priorFindings).filter(
|
|
6624
|
-
([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2)
|
|
6625
|
-
)
|
|
6626
|
-
) : priorFindings;
|
|
6627
6758
|
const accepts = (doc) => {
|
|
6628
6759
|
const resolved = resolvedPriorValue(doc);
|
|
6629
6760
|
if (resolved === null) return null;
|
|
@@ -6631,13 +6762,18 @@ var seedDraftCmd = defineCommand({
|
|
|
6631
6762
|
return validateAgainstSchema(carried, schemaPath).valid ? carried : null;
|
|
6632
6763
|
};
|
|
6633
6764
|
const seedDoc = accepts(priorFindings) ?? (() => {
|
|
6634
|
-
|
|
6635
|
-
|
|
6765
|
+
if (typeof priorFindings !== "object" || Array.isArray(priorFindings))
|
|
6766
|
+
return null;
|
|
6767
|
+
const recovered = withoutBrokenOptionalFields(
|
|
6768
|
+
priorFindings,
|
|
6769
|
+
(candidate) => accepts(candidate) !== null
|
|
6770
|
+
);
|
|
6771
|
+
if (recovered === null) return null;
|
|
6636
6772
|
process.stderr.write(
|
|
6637
|
-
`Note: the in-force schema rejects a carried recoverable field (
|
|
6773
|
+
`Note: the in-force schema rejects a carried recoverable field (${recovered.stripped.join("/")}) \u2014 seeding the prior without it (issue #150 review r2 / #182 review r2)
|
|
6638
6774
|
`
|
|
6639
6775
|
);
|
|
6640
|
-
return
|
|
6776
|
+
return accepts(recovered.doc);
|
|
6641
6777
|
})();
|
|
6642
6778
|
if (seedDoc === null) return false;
|
|
6643
6779
|
if (isIncompleteFindings(seedDoc)) return false;
|
|
@@ -7032,7 +7168,7 @@ var stopGateCmd = defineCommand({
|
|
|
7032
7168
|
var gatherCmd = defineCommand({
|
|
7033
7169
|
meta: {
|
|
7034
7170
|
name: "gather",
|
|
7035
|
-
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,
|
|
7171
|
+
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, the CI run's job logs \u2014 the failing jobs' with their identities in failed_jobs.json on a failure, the jobs that ran, up to 64, indexed in ci_jobs.json on a pass) as files for the review agent"
|
|
7036
7172
|
},
|
|
7037
7173
|
args: {
|
|
7038
7174
|
repo: { type: "string", description: "Repository (owner/name)", required: true },
|
|
@@ -7052,12 +7188,16 @@ var gatherCmd = defineCommand({
|
|
|
7052
7188
|
},
|
|
7053
7189
|
"run-id": {
|
|
7054
7190
|
type: "string",
|
|
7055
|
-
description: "CI run id (from workflow_run.id); its failing jobs'
|
|
7191
|
+
description: "CI run id (from workflow_run.id); its job logs are downloaded \u2014 the failing jobs' on failure, the jobs that ran (up to 64) on success",
|
|
7056
7192
|
required: true
|
|
7057
7193
|
},
|
|
7194
|
+
"run-attempt": {
|
|
7195
|
+
type: "string",
|
|
7196
|
+
description: "The CI run's attempt that triggered the review (from workflow_run.run_attempt); its jobs' logs are the ones staged, even after a re-run starts a newer attempt. Empty or omitted: the run's latest attempt"
|
|
7197
|
+
},
|
|
7058
7198
|
conclusion: {
|
|
7059
7199
|
type: "string",
|
|
7060
|
-
description: "CI conclusion (e.g. success | failure); failure
|
|
7200
|
+
description: "CI conclusion (e.g. success | failure); failure stages the failing jobs' logs, success the logs of the jobs that ran (up to 64) and ci_jobs.json",
|
|
7061
7201
|
required: true
|
|
7062
7202
|
},
|
|
7063
7203
|
"bot-login": {
|
|
@@ -7076,6 +7216,7 @@ var gatherCmd = defineCommand({
|
|
|
7076
7216
|
headBranch: args["head-branch"],
|
|
7077
7217
|
defaultBranch: args["default-branch"],
|
|
7078
7218
|
runId: args["run-id"],
|
|
7219
|
+
runAttempt: parseRunAttempt(args["run-attempt"]),
|
|
7079
7220
|
conclusion: args.conclusion,
|
|
7080
7221
|
botLogin: args["bot-login"] || "github-actions[bot]",
|
|
7081
7222
|
outDir: args["out-dir"] ? resolve(args["out-dir"]) : process.cwd()
|
|
@@ -7473,7 +7614,7 @@ var reactCmd = defineCommand({
|
|
|
7473
7614
|
var awaitCiCmd = defineCommand({
|
|
7474
7615
|
meta: {
|
|
7475
7616
|
name: "await-ci",
|
|
7476
|
-
description: "Wait for the PR head's CI workflow run to conclude, then emit its REAL conclusion
|
|
7617
|
+
description: "Wait for the PR head's CI workflow run to conclude, then emit its REAL conclusion, run id and run attempt to $GITHUB_OUTPUT (ci_settled, ci_conclusion, ci_run_id, ci_run_attempt). 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)."
|
|
7477
7618
|
},
|
|
7478
7619
|
args: {
|
|
7479
7620
|
repo: { type: "string", description: "Repository (owner/name)", required: true },
|