@jphutchins/code-review 0.1.0-alpha.67 → 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
@@ -574,7 +574,7 @@ var incompleteFindings = (summary) => ({
574
574
  var isIncompleteFindings = (findings) => findings.verdict === "error" && findings.findings.length === 0;
575
575
 
576
576
  // src/released.ts
577
- var RELEASED = "2026-10-10";
577
+ var RELEASED = "2026-10-11";
578
578
 
579
579
  // src/cost.ts
580
580
  var defaultWarn = (message) => {
@@ -2373,6 +2373,7 @@ var render = (input) => {
2373
2373
  answeredReRaiseNote: input.answeredReRaiseNote ?? "",
2374
2374
  contested: contestedBudget.kept,
2375
2375
  unfinished: shownUnfinished(input.findings, isFullReviewRound),
2376
+ unfinishedCaveat: unfinished.length > 0 ? ` \u2014 ${String(unfinished.length)} planned check${unfinished.length === 1 ? "" : "s"} went unfinished` : "",
2376
2377
  reviewUrl: input.reviewUrl ?? null,
2377
2378
  formatTokens: (n) => Number.isFinite(n) && n >= 0 ? n.toLocaleString("en-US") : "\u2014",
2378
2379
  // N/A (never a false $0.00) when no real price map was provided — real tokens, no rates to
@@ -2788,7 +2789,7 @@ var timeClause = (i) => i.elapsedMs === null ? null : i.wallMs !== null && i.wal
2788
2789
  var directive = (phase, draftPath, isSubagent) => {
2789
2790
  if (isSubagent)
2790
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.`;
2791
- 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, 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, 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.`;
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.`;
2792
2793
  };
2793
2794
  var budgetMessage = (i, phase, draftPath, isSubagent) => {
2794
2795
  const status = [spendClause(i), timeClause(i)].filter((c) => c !== null).join(" \xB7 ");
@@ -5058,14 +5059,17 @@ var react = async (input, ghApi = runGhApi) => {
5058
5059
  await removeReactions(input.repo, input.commentId, input.remove, ghApi);
5059
5060
  }
5060
5061
  };
5061
- var RunCodec = t.type({
5062
- id: t.number,
5063
- name: t.union([t.string, t.null]),
5064
- status: t.union([t.string, t.null]),
5065
- conclusion: t.union([t.string, t.null]),
5066
- run_number: t.number
5067
- });
5068
- 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}";
5069
5073
  var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
5070
5074
  const endpoint = `repos/${repo}/actions/runs?head_sha=${headSha}&per_page=100`;
5071
5075
  const rows = parseJsonl(await ghApi([endpoint, "--paginate", "--jq", RUN_JQ]));
@@ -5085,7 +5089,12 @@ var resolveCiRun = async (repo, headSha, workflowName, ghApi) => {
5085
5089
  null
5086
5090
  );
5087
5091
  return {
5088
- 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
+ },
5089
5098
  seenNames: [...new Set(runs.flatMap((r) => r.name === null ? [] : [r.name]))]
5090
5099
  };
5091
5100
  };
@@ -5104,7 +5113,12 @@ var awaitCiConclusion = async (repo, headSha, options, deps = { ghApi: runGhApi,
5104
5113
  const poll = async (lastSeenNames, lastRunId) => {
5105
5114
  const { run: run2, seenNames } = await safeResolve();
5106
5115
  if (run2 !== null && run2.status === "completed" && run2.conclusion !== null)
5107
- 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
+ };
5108
5122
  const runId = run2 === null ? lastRunId : run2.id;
5109
5123
  const names = seenNames.length > 0 ? seenNames : lastSeenNames;
5110
5124
  if (deps.elapsedMs() >= options.timeoutMs)
@@ -5122,6 +5136,7 @@ var monotonicElapsed = () => {
5122
5136
  var renderCiOutputs = (outcome) => outcome.kind === "concluded" ? `ci_settled=true
5123
5137
  ci_conclusion=${outcome.conclusion}
5124
5138
  ci_run_id=${String(outcome.runId)}
5139
+ ci_run_attempt=${outcome.runAttempt === null ? "" : String(outcome.runAttempt)}
5125
5140
  ` : `ci_settled=false
5126
5141
  ci_run_id=${outcome.runId === null ? "" : String(outcome.runId)}
5127
5142
  `;
@@ -5182,6 +5197,14 @@ var JobCodec = t.type({
5182
5197
  conclusion: t.union([t.string, t.null])
5183
5198
  });
5184
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`;
5185
5208
  var fetchPrMeta = async (repo, prNumber, ghApi) => {
5186
5209
  const stdout = await ghApi([
5187
5210
  `repos/${repo}/pulls/${String(prNumber)}`,
@@ -5331,35 +5354,46 @@ var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review
5331
5354
  body: clip(r.body)
5332
5355
  }));
5333
5356
  var MAX_STAGED_JOB_LOGS = 20;
5334
- var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
5335
- const rows = parseJsonl(
5336
- 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
+ )
5337
5383
  );
5338
- const decoded = t.array(JobCodec).decode(rows);
5339
5384
  if (decoded._tag === "Left") {
5340
5385
  throw new Error(`Jobs list for run ${runId} did not match the expected shape`);
5341
5386
  }
5342
- 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
+ );
5343
5393
  const selected = failing.slice(0, MAX_STAGED_JOB_LOGS);
5344
5394
  let staged = 0;
5345
5395
  for (const job of selected) {
5346
- try {
5347
- const log = await ghApi([`repos/${repo}/actions/jobs/${String(job.id)}/logs`]);
5348
- if (log.trim() === "no log available" || log.trim() === "") {
5349
- process.stderr.write(
5350
- `Warning: job ${String(job.id)} has no log available (placeholder body) \u2014 not staged
5351
- `
5352
- );
5353
- continue;
5354
- }
5355
- writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
5356
- staged += 1;
5357
- } catch (err) {
5358
- process.stderr.write(
5359
- `Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
5360
- `
5361
- );
5362
- }
5396
+ if (await stageJobLog(repo, job.id, outDir, ghApi) !== null) staged += 1;
5363
5397
  }
5364
5398
  writeFileSync(
5365
5399
  join(outDir, "failed_jobs.json"),
@@ -5387,6 +5421,58 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
5387
5421
  }
5388
5422
  return { staged, failing: failing.length };
5389
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
+ };
5390
5476
  var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghArtifactReader) => {
5391
5477
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
5392
5478
  const resolution = resolvePr(candidates, input.headBranch);
@@ -5490,7 +5576,8 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
5490
5576
  reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
5491
5577
  })
5492
5578
  );
5493
- 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;
5494
5581
  return {
5495
5582
  kind: "gathered",
5496
5583
  pr: prNumber,
@@ -5498,7 +5585,7 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
5498
5585
  diffSize: Buffer.byteLength(prDiff, "utf8"),
5499
5586
  stacked,
5500
5587
  baseSha: meta.base_sha,
5501
- stagedJobLogs: jobLogs.staged,
5588
+ stagedJobLogs: jobLogs.staged + passedLogs,
5502
5589
  failingJobs: jobLogs.failing
5503
5590
  };
5504
5591
  };
@@ -7081,7 +7168,7 @@ var stopGateCmd = defineCommand({
7081
7168
  var gatherCmd = defineCommand({
7082
7169
  meta: {
7083
7170
  name: "gather",
7084
- 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"
7085
7172
  },
7086
7173
  args: {
7087
7174
  repo: { type: "string", description: "Repository (owner/name)", required: true },
@@ -7101,12 +7188,16 @@ var gatherCmd = defineCommand({
7101
7188
  },
7102
7189
  "run-id": {
7103
7190
  type: "string",
7104
- 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",
7105
7192
  required: true
7106
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
+ },
7107
7198
  conclusion: {
7108
7199
  type: "string",
7109
- 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",
7110
7201
  required: true
7111
7202
  },
7112
7203
  "bot-login": {
@@ -7125,6 +7216,7 @@ var gatherCmd = defineCommand({
7125
7216
  headBranch: args["head-branch"],
7126
7217
  defaultBranch: args["default-branch"],
7127
7218
  runId: args["run-id"],
7219
+ runAttempt: parseRunAttempt(args["run-attempt"]),
7128
7220
  conclusion: args.conclusion,
7129
7221
  botLogin: args["bot-login"] || "github-actions[bot]",
7130
7222
  outDir: args["out-dir"] ? resolve(args["out-dir"]) : process.cwd()
@@ -7522,7 +7614,7 @@ var reactCmd = defineCommand({
7522
7614
  var awaitCiCmd = defineCommand({
7523
7615
  meta: {
7524
7616
  name: "await-ci",
7525
- 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)."
7526
7618
  },
7527
7619
  args: {
7528
7620
  repo: { type: "string", description: "Repository (owner/name)", required: true },