@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 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), failing-job logs) into the directory named by `--out-dir` (default: the current directory; the shipped workflows use `$RUNNER_TEMP/gather`) |
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-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 stripped = Object.fromEntries(
3337
- Object.entries(raw).filter(([key2]) => !RECOVERABLE_OPTIONAL_FIELDS.has(key2))
3384
+ const recovered = withoutBrokenOptionalFields(
3385
+ raw,
3386
+ (candidate) => decodeFindings(candidate).kind === "ok"
3338
3387
  );
3339
- const retry = decodeFindings(stripped);
3340
- if (retry.kind === "ok") {
3388
+ if (recovered !== null) {
3341
3389
  process.stderr.write(
3342
- "Warning: the review draft carried an invalid best-effort field (convergence/scope_metastasis/change_size) \u2014 stripped it and used the rest; the pipeline re-stamps convergence\n"
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 retry;
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.type({
5013
- id: t.number,
5014
- name: t.union([t.string, t.null]),
5015
- status: t.union([t.string, t.null]),
5016
- conclusion: t.union([t.string, t.null]),
5017
- run_number: t.number
5018
- });
5019
- var RUN_JQ = ".workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, run_number: .run_number}";
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 : { id: latest.id, status: latest.status ?? "unknown", conclusion: latest.conclusion },
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 { kind: "concluded", conclusion: run2.conclusion, runId: run2.id };
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 downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
5286
- const rows = parseJsonl(
5287
- await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`, "--paginate", "--jq", JOBS_JQ])
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
- const failing = decoded.right.filter((j) => j.conclusion === "failure");
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
- try {
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
- const bare = accepts(barePrior);
6635
- if (bare === null) return null;
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 (scope_metastasis/convergence/change_size) \u2014 seeding the prior without it (issue #150 review r2 / #182 review r2)
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 bare;
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, failing-job logs and the failing jobs' identities in failed_jobs.json) as files for the review agent"
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' logs are downloaded on failure",
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 triggers failing-job log download",
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 + 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)."
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 },