@jphutchins/code-review 0.1.0-alpha.50 → 0.1.0-alpha.52

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
@@ -37,7 +37,7 @@ npx @jphutchins/code-review <subcommand>
37
37
 
38
38
  | Subcommand | What it does |
39
39
  | --- | --- |
40
- | `post` | Post a complete review (inline comments + sticky summary) from findings + envelope + diff — the one-call path |
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
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 prior review's answered-findings registry, failing-job logs) into the workspace for the agent |
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) |
@@ -96,8 +96,10 @@ links to:
96
96
 
97
97
  - **`code-review-findings`** — the findings JSON + result envelope the comment job renders. The
98
98
  sticky comment embeds this same JSON directly, base64-encoded, in an
99
- `<!-- code-review:findings-json;base64 <base64> -->` HTML comment a reviewing agent (or any
100
- downstream tool) SHOULD base64-decode and parse that marker rather than parse the comment's prose.
99
+ `<!-- code-review:findings-json;base64 <base64> -->` HTML comment. The comment usually renders that
100
+ same review as prose, which is the cheaper read; decode the marker where the prose is not the
101
+ review, where the prose carries only part of it, or where the prose omits a field you need. The directive that rides ahead of the
102
+ marker states that rule.
101
103
  Embedding in the comment (rather than only linking the artifact) keeps the pointer from expiring
102
104
  with artifact retention; when the encoded findings are too large to embed, the sticky falls back to
103
105
  a `<!-- code-review:findings-json <url> -->` link marker instead. The shared serializer is
@@ -152,7 +154,7 @@ links to:
152
154
  mechanism frequencies carried in `convergence.rounds` (each entry's `codes` map), from which the
153
155
  re-review seed re-derives the advisory `scope_metastasis` entry it hands the next-round agent. Each
154
156
  inline review comment embeds only its own finding (a `schema_version` + one-finding fragment), and the
155
- review-object body only links the sticky — so the **sticky is the sole documented decode surface** for
157
+ review-object body links the sticky and the workflow run — so the **sticky is the sole documented decode surface** for
156
158
  the whole-document marker; a decoding agent reads it there. The review body is written only after the
157
159
  sticky exists (a failed sticky write aborts the run first), so it never carries the blob itself.
158
160
  - **`code-review-transcript`** — the full Claude Code session transcripts for the triage and review
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from 'citty';
3
- import { readFileSync, writeFileSync, copyFileSync, statSync, readdirSync } from 'fs';
3
+ import { readFileSync, writeFileSync, copyFileSync, statSync, readdirSync, appendFileSync } from 'fs';
4
4
  import { randomBytes } from 'crypto';
5
5
  import { resolve as resolve$1, join, dirname, basename, extname } from 'path';
6
6
  import { Eta } from 'eta';
@@ -282,9 +282,16 @@ var NonEmptyPriceSlots = t.refinement(
282
282
  (a) => a.length >= 1,
283
283
  "NonEmptyPriceSlots"
284
284
  );
285
+ var SlottedRequired = t.type({ slots: NonEmptyPriceSlots });
286
+ var SlottedOptional = t.partial({ weekend_slots: NonEmptyPriceSlots });
287
+ var SlottedShape = t.intersection([SlottedRequired, SlottedOptional]);
288
+ var SLOTTED_KEYS = /* @__PURE__ */ new Set([
289
+ ...Object.keys(SlottedRequired.props),
290
+ ...Object.keys(SlottedOptional.props)
291
+ ]);
285
292
  var SlottedModelPricesStrict = t.refinement(
286
- t.type({ slots: NonEmptyPriceSlots }),
287
- (s) => Object.keys(s).every((k) => k === "slots"),
293
+ SlottedShape,
294
+ (s) => Object.keys(s).every((k) => SLOTTED_KEYS.has(k)),
288
295
  "SlottedModelPricesStrict"
289
296
  );
290
297
  var SlottedModelPricesCodec = t.exact(SlottedModelPricesStrict);
@@ -339,6 +346,11 @@ var slotCovers = (slot, minute) => {
339
346
  const to = hhmmToMinutes(slot.utc_to);
340
347
  return from < to ? minute >= from && minute < to : minute >= from || minute < to;
341
348
  };
349
+ var BEIJING_OFFSET_MINUTES = 8 * 60;
350
+ var isBeijingWeekend = (instant) => {
351
+ const day = new Date(instant.getTime() + BEIJING_OFFSET_MINUTES * 6e4).getUTCDay();
352
+ return day === 0 || day === 6;
353
+ };
342
354
  var resolveFlatPrices = (model, p, pricedAt, warn) => {
343
355
  if (!("slots" in p)) return p;
344
356
  if (pricedAt === void 0) {
@@ -348,10 +360,13 @@ var resolveFlatPrices = (model, p, pricedAt, warn) => {
348
360
  return null;
349
361
  }
350
362
  const minute = utcMinuteOfDay(pricedAt);
351
- const covering = p.slots.filter((s) => slotCovers(s, minute));
363
+ const weekendSlots = p.weekend_slots;
364
+ const useWeekend = weekendSlots != null && isBeijingWeekend(pricedAt);
365
+ const slots = useWeekend ? weekendSlots : p.slots;
366
+ const covering = slots.filter((s) => slotCovers(s, minute));
352
367
  if (covering.length === 1) return covering[0] ?? null;
353
368
  warn(
354
- `code-review cost: model "${model}" \u2014 ${String(covering.length)} price slots cover ${hhmmOf(minute)} UTC (expected exactly 1); a model's slots must partition the 24h day with no gap or overlap; cost for this model set to $0`
369
+ `code-review cost: model "${model}" \u2014 ${String(covering.length)} price slots in \`${useWeekend ? "weekend_slots" : "slots"}\` cover ${hhmmOf(minute)} UTC (expected exactly 1); that array must partition the 24h day with no gap or overlap; cost for this model set to $0`
355
370
  );
356
371
  return null;
357
372
  };
@@ -507,7 +522,7 @@ var severityEmoji = (s) => {
507
522
  };
508
523
  var EMBED_LIMIT = 42700;
509
524
  var FINDINGS_SCHEMA_URL = "https://raw.githubusercontent.com/JPHutchins/code-review/main/schema/findings.schema.json";
510
- var AGENTS_STOP_DIRECTIVE = `<!-- AGENTS: STOP \u2014 this comment is a machine channel; act on the data, not the prose. It embeds a base64 code-review findings JSON (an oversized review instead links the artifact URL in the same marker). Decode or fetch it, read its schema_version, then DOWNLOAD AND READ THE FULL SCHEMA for that version before you act \u2014 the schema's own $id is its canonical URL; the current schema is at ${FINDINGS_SCHEMA_URL} \u2014 read that schema, then parse the WHOLE findings document, not only the fields you recognize: the convergence score, its threshold, and the per-round trajectory (so you know whether the review has converged and how to read the number), plus the recurrence signals an iterating agent needs (round streaks, scope metastasis, same-mechanism), are all fields inside that document. -->`;
525
+ var AGENTS_STOP_DIRECTIVE = `<!-- AGENTS: STOP \u2014 this comment carries a code-review findings document in the marker, and usually renders that same review as prose too. Read the prose when it is there: it is the cheaper read. Decode the marker when the prose is not the review (this comment may be a status notice instead), when the prose is only part of it (findings anchored to diff lines stay on the diff, and a review too large to embed links its artifact here rather than carrying it \u2014 the workflow run's summary renders both of those whole), or when you need a field the prose does not render. Read the document's schema_version and fetch the schema for THAT version before acting \u2014 a schema's own $id is its canonical URL, and the current version is at ${FINDINGS_SCHEMA_URL} \u2014 then parse the WHOLE findings document, not only the fields you recognize. -->`;
511
526
  var b64LengthOf = (document) => Buffer.from(JSON.stringify(document), "utf-8").toString("base64").length;
512
527
  var encodeMarker = (document, jsonUrl, limit) => {
513
528
  const b64 = Buffer.from(JSON.stringify(document), "utf-8").toString("base64");
@@ -848,15 +863,17 @@ ${findings}` : void 0;
848
863
  return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, convergence, rounds, signal].filter((m) => m !== void 0).join("\n\n");
849
864
  };
850
865
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
851
- var projectPatch = (patch) => {
866
+ var projectPatch = (patch, surface) => {
852
867
  if (patch === void 0) return { kind: "none" };
853
- const lowered = patchToSuggestion(patch);
868
+ const lowered = surface === "diff-anchored" ? patchToSuggestion(patch) : void 0;
854
869
  return typeof lowered === "string" ? { kind: "suggestion", text: escapeFence(lowered) } : { kind: "patch", raw: escapeFence(patch) };
855
870
  };
856
871
  var formatConfidence = (n) => n.toFixed(2);
857
- var reviewBodyPointer = (headSha, stickyUrl) => {
872
+ var reviewBodyPointer = (headSha, stickyUrl, runUrl, runHasSummary) => {
858
873
  const sha7 = headSha.slice(0, 7);
859
- return 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.`;
874
+ const summary = stickyUrl ? `the [summary comment](${stickyUrl})` : "the summary comment";
875
+ const run = runUrl ? ` See the [workflow run](${runUrl}) for ${runHasSummary ? "this round's review in full, its job log," : "the job log"} and the findings artifact.` : "";
876
+ return `\u{1F916} Automated code review for \`${sha7}\` \u2014 see ${summary} for the verdict, walkthrough, and cost.${run}`;
860
877
  };
861
878
  var asRecord = (u) => typeof u === "object" && u !== null && !Array.isArray(u) ? u : null;
862
879
  var errMsg = (e) => e instanceof Error ? e.message : String(e);
@@ -1195,7 +1212,7 @@ var sanitizeFinding = (f, answeredNotes) => {
1195
1212
  ...f,
1196
1213
  title: escapePipes(f.title),
1197
1214
  path: escapeCodeBackticks(f.path),
1198
- patchProjection: projectPatch(f.patch),
1215
+ patchProjection: projectPatch(f.patch, "comment-body"),
1199
1216
  answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : ""
1200
1217
  };
1201
1218
  };
@@ -1267,6 +1284,7 @@ var render = (input) => {
1267
1284
  systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
1268
1285
  unanchoredCount: input.unanchoredCount ?? 0,
1269
1286
  inlineDisposition: input.inlineDisposition ?? null,
1287
+ unverifiedNoLogs: input.unverifiedNoLogs === true,
1270
1288
  runUrl: input.runUrl ?? null,
1271
1289
  jsonUrl: input.jsonUrl ?? null,
1272
1290
  // The blob is the agent's complete document with the pipeline-stamped convergence field inside it
@@ -1365,7 +1383,7 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRoo
1365
1383
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
1366
1384
  eta.renderString(template, {
1367
1385
  ...f,
1368
- patchProjection: projectPatch(f.patch),
1386
+ patchProjection: projectPatch(f.patch, "diff-anchored"),
1369
1387
  severityEmoji,
1370
1388
  formatConfidence,
1371
1389
  modelsText,
@@ -2273,31 +2291,43 @@ var commentPayload = (c) => ({
2273
2291
  ...c.start_line !== void 0 && c.start_side !== void 0 ? { start_line: c.start_line, start_side: c.start_side } : {},
2274
2292
  body: formatMarkdown(c.body)
2275
2293
  });
2276
- var postInlineReview = async (repo, prNumber, headSha, comments, inDiff, stickyUrl, ghApi) => {
2277
- const pointer = reviewBodyPointer(headSha, stickyUrl);
2294
+ var postInlineReview = async (pr, comments, inDiff, ghApi) => {
2295
+ const pointer = reviewBodyPointer(
2296
+ pr.headSha,
2297
+ pr.stickyUrl,
2298
+ pr.runUrl,
2299
+ (process.env["GITHUB_STEP_SUMMARY"] ?? "") !== ""
2300
+ );
2278
2301
  const reviewBody = (withComments) => JSON.stringify({
2279
2302
  body: pointer,
2280
- commit_id: headSha,
2303
+ commit_id: pr.headSha,
2281
2304
  event: "COMMENT",
2282
2305
  comments: withComments ? comments.map(commentPayload) : []
2283
2306
  });
2284
- const reviewsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/reviews`, "--input", "-"];
2307
+ const reviewsEndpoint = [`repos/${pr.repo}/pulls/${String(pr.prNumber)}/reviews`, "--input", "-"];
2285
2308
  try {
2286
2309
  const stdout = await ghApi(reviewsEndpoint, reviewBody(true));
2287
2310
  return { url: parseHtmlUrl(stdout), inlinePosted: comments.length, unposted: [] };
2288
2311
  } catch (err) {
2289
2312
  if (comments.length === 0) throw err;
2290
2313
  process.stderr.write(
2291
- `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)
2314
+ `Warning: the batched inline review on PR #${String(pr.prNumber)} was rejected (${errMsg(err)}) \u2014 posting the review body-only, then each comment individually to keep the ones GitHub accepts (issue #57)
2292
2315
  `
2293
2316
  );
2294
2317
  const url = parseHtmlUrl(await ghApi(reviewsEndpoint, reviewBody(false)));
2295
- const commentsEndpoint = [`repos/${repo}/pulls/${String(prNumber)}/comments`, "--input", "-"];
2318
+ const commentsEndpoint = [
2319
+ `repos/${pr.repo}/pulls/${String(pr.prNumber)}/comments`,
2320
+ "--input",
2321
+ "-"
2322
+ ];
2296
2323
  const unposted = [];
2297
2324
  let inlinePosted = 0;
2298
2325
  for (const [i, c] of comments.entries()) {
2299
2326
  try {
2300
- await ghApi(commentsEndpoint, JSON.stringify({ commit_id: headSha, ...commentPayload(c) }));
2327
+ await ghApi(
2328
+ commentsEndpoint,
2329
+ JSON.stringify({ commit_id: pr.headSha, ...commentPayload(c) })
2330
+ );
2301
2331
  inlinePosted += 1;
2302
2332
  } catch (e) {
2303
2333
  const finding = inDiff[i];
@@ -2351,6 +2381,18 @@ var postComment = async (repo, prNumber, body, ghApi) => {
2351
2381
  );
2352
2382
  return parseCommentRef(stdout);
2353
2383
  };
2384
+ var appendRunSummary = (summaryPath, body) => {
2385
+ if (summaryPath === void 0 || summaryPath === "") return;
2386
+ const rendered = body();
2387
+ try {
2388
+ appendFileSync(summaryPath, `
2389
+ ${rendered}
2390
+ `);
2391
+ } catch (err) {
2392
+ process.stderr.write(`Warning: could not write the run summary: ${errMsg(err)}
2393
+ `);
2394
+ }
2395
+ };
2354
2396
  var upsertSticky = async (repo, prNumber, existing, body, ghApi) => {
2355
2397
  if (existing !== null) {
2356
2398
  const patched = await patchComment(repo, existing.id, body, ghApi);
@@ -2388,6 +2430,7 @@ var dismissReviews = async (repo, prNumber, ids, ghApi) => {
2388
2430
  "--input",
2389
2431
  "-"
2390
2432
  ],
2433
+ // GitHub caps a dismissal message at 140 chars.
2391
2434
  JSON.stringify({ message: "Superseded by a new review for an updated commit." })
2392
2435
  );
2393
2436
  } catch (err) {
@@ -2545,7 +2588,7 @@ ${conv}`;
2545
2588
  noticeBody(
2546
2589
  `${DEFAULT_MARKER}
2547
2590
 
2548
- \u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\` \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.${dropNote ? `
2591
+ \u26A0\uFE0F **CI-fix pass completed with no findings** for \`${input.headSha.slice(0, 7)}\`${input.unverifiedNoLogs === true ? ' \u2014 **but no failing-job logs were available**, so it had only the diff to work from and "no findings" is not evidence of none' : ""} \u2014 the completed full review of \`${priorSha ? priorSha.slice(0, 7) : "an earlier commit"}\` is preserved below.${dropNote ? `
2549
2592
 
2550
2593
  ${dropNote}` : ""}`,
2551
2594
  sticky.body
@@ -2560,7 +2603,7 @@ ${dropNote}` : ""}`,
2560
2603
  throw new Error(`Price map at ${input.pricesPath} does not match the expected shape`);
2561
2604
  }
2562
2605
  const template = readFileSync(input.templatePath, "utf-8");
2563
- const inlineTemplate = readFileSync(input.inlineTemplatePath, "utf-8");
2606
+ const inlineRequested = input.inline === true;
2564
2607
  const renderNotice = (message) => {
2565
2608
  const findings2 = stampConvergence(incompleteFindings(`### \u26A0\uFE0F ${message}`), priorConv);
2566
2609
  return formatMarkdown(
@@ -2677,19 +2720,27 @@ ${dropNote}` : ""}`,
2677
2720
  convergenceRound: false,
2678
2721
  testReport,
2679
2722
  clocDiff,
2680
- inlineDisposition: { kind: "no-envelope" },
2723
+ inlineDisposition: inlineRequested ? { kind: "no-envelope" } : { kind: "disabled" },
2681
2724
  runUrl: input.runUrl,
2725
+ unverifiedNoLogs: input.unverifiedNoLogs,
2682
2726
  jsonUrl: input.jsonUrl,
2683
2727
  findingsPointer: findingsBlob(stampedFindings2),
2684
2728
  postedAt: input.postedAt
2685
2729
  })
2686
2730
  );
2687
2731
  await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
2732
+ appendRunSummary(process.env["GITHUB_STEP_SUMMARY"], () => body);
2733
+ if (inlineRequested) {
2734
+ process.stderr.write(
2735
+ "Warning: inline: true was requested, but the result envelope is missing \u2014 inline comments cannot be built; the findings are in the sticky and the run summary instead\n"
2736
+ );
2737
+ }
2688
2738
  process.stderr.write(
2689
- "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data; no inline review\n"
2739
+ "Result envelope missing or malformed \u2014 posted sticky summary without usage/cost data\n"
2690
2740
  );
2691
2741
  process.exit(0);
2692
2742
  }
2743
+ const inlineTemplate = inlineRequested ? readFileSync(input.inlineTemplatePath, "utf-8") : "";
2693
2744
  const thisIncomplete = envelope.incomplete === true || isIncompleteFindings(findings);
2694
2745
  if (wouldBuryCompleted(thisIncomplete)) {
2695
2746
  logAnsweredDrops();
@@ -2703,14 +2754,14 @@ ${dropNote}` : ""}`,
2703
2754
  comments: rawComments,
2704
2755
  strays,
2705
2756
  inDiff
2706
- } = buildInlineComments(visibleFindings, diff, {
2757
+ } = inlineRequested ? buildInlineComments(visibleFindings, diff, {
2707
2758
  inlineTemplate,
2708
2759
  models: envelope.models.map((m) => m.model),
2709
2760
  findings,
2710
2761
  jsonUrl: input.jsonUrl,
2711
2762
  sameRootNotes,
2712
2763
  answeredNotes: reRaisedNotes
2713
- });
2764
+ }) : { comments: [], strays: visibleFindings, inDiff: [] };
2714
2765
  const { comments, longFiles } = checkLongSuggestions(rawComments);
2715
2766
  for (const wf of longFiles) {
2716
2767
  process.stderr.write(
@@ -2719,7 +2770,7 @@ ${dropNote}` : ""}`,
2719
2770
  );
2720
2771
  }
2721
2772
  const botReviews = await fetchBotReviews(input.repo, prNumber, input.botLogin, ghApi);
2722
- const initialDisposition = comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2773
+ const initialDisposition = !inlineRequested ? { kind: "disabled" } : comments.length === 0 && strays.length > 0 ? { kind: "none-in-diff" } : void 0;
2723
2774
  const currentCounts = computeSeverityCounts(findings.findings);
2724
2775
  const currentCodes = computeCodeCounts(findings.findings, findings.systemic_problems ?? []);
2725
2776
  const roundNumber = priorRoundCount + 1;
@@ -2767,6 +2818,7 @@ ${dropNote}` : ""}`,
2767
2818
  strays,
2768
2819
  suppressedNits,
2769
2820
  runUrl: input.runUrl,
2821
+ unverifiedNoLogs: input.unverifiedNoLogs,
2770
2822
  jsonUrl: input.jsonUrl,
2771
2823
  findingsPointer: findingsMarker,
2772
2824
  postedAt: input.postedAt,
@@ -2805,16 +2857,20 @@ ${dropNote}` : ""}`,
2805
2857
  inlinePosted,
2806
2858
  unposted
2807
2859
  } = await postInlineReview(
2808
- input.repo,
2809
- prNumber,
2810
- input.headSha,
2860
+ {
2861
+ repo: input.repo,
2862
+ prNumber,
2863
+ headSha: input.headSha,
2864
+ stickyUrl: stickyRef?.url,
2865
+ runUrl: input.runUrl
2866
+ },
2811
2867
  comments,
2812
2868
  inDiff,
2813
- stickyRef?.url,
2814
2869
  ghApi
2815
2870
  );
2816
2871
  process.stderr.write(
2817
- `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
2872
+ inlineRequested ? `Posted a review with ${String(inlinePosted)} inline comment(s) on PR #${String(prNumber)}
2873
+ ` : `Posted a body-only review on PR #${String(prNumber)}; the findings are in the sticky
2818
2874
  `
2819
2875
  );
2820
2876
  const priorReviewIds = botReviews.map((r) => r.id);
@@ -2844,6 +2900,14 @@ ${dropNote}` : ""}`,
2844
2900
  );
2845
2901
  }
2846
2902
  }
2903
+ appendRunSummary(
2904
+ process.env["GITHUB_STEP_SUMMARY"],
2905
+ () => renderBody(
2906
+ { kind: "whole-document", inlineCount: inlinePosted, rejectedCount: unanchoredCount },
2907
+ reviewUrl,
2908
+ visibleFindings
2909
+ )
2910
+ );
2847
2911
  };
2848
2912
  var noticeBody = (lead, existingBody) => {
2849
2913
  const carried = existingBody ? carryForwardMarkers(existingBody) : "";
@@ -3216,6 +3280,8 @@ conclusion=${result.conclusion}
3216
3280
  diff_size=${String(result.diffSize)}
3217
3281
  stacked=${String(result.stacked)}
3218
3282
  base_sha=${result.baseSha}
3283
+ staged_job_logs=${String(result.stagedJobLogs)}
3284
+ failing_jobs=${String(result.failingJobs)}
3219
3285
  `;
3220
3286
  }
3221
3287
  };
@@ -3244,7 +3310,7 @@ var IssueCommentCodec = t.intersection([
3244
3310
  })
3245
3311
  ]);
3246
3312
  var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
3247
- var JobsResponseCodec = t.type({ jobs: t.array(JobCodec) });
3313
+ var JOBS_JQ = ".jobs[] | {id: .id, conclusion: .conclusion}";
3248
3314
  var fetchPrMeta = async (repo, prNumber, ghApi) => {
3249
3315
  const stdout = await ghApi([
3250
3316
  `repos/${repo}/pulls/${String(prNumber)}`,
@@ -3391,16 +3457,23 @@ var reviewsFrom = (reviews, botLogin) => boundedHuman(reviews, botLogin, "review
3391
3457
  state: r.state ?? null,
3392
3458
  body: clip(r.body)
3393
3459
  }));
3460
+ var MAX_STAGED_JOB_LOGS = 20;
3394
3461
  var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
3395
- const stdout = await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`]);
3396
- const decoded = JobsResponseCodec.decode(JSON.parse(stdout));
3462
+ const rows = parseJsonl(
3463
+ await ghApi([`repos/${repo}/actions/runs/${runId}/jobs`, "--paginate", "--jq", JOBS_JQ])
3464
+ );
3465
+ const decoded = t.array(JobCodec).decode(rows);
3397
3466
  if (decoded._tag === "Left") {
3398
3467
  throw new Error(`Jobs list for run ${runId} did not match the expected shape`);
3399
3468
  }
3400
- for (const job of decoded.right.jobs.filter((j) => j.conclusion === "failure")) {
3469
+ const failing = decoded.right.filter((j) => j.conclusion === "failure");
3470
+ const selected = failing.slice(0, MAX_STAGED_JOB_LOGS);
3471
+ let staged = 0;
3472
+ for (const job of selected) {
3401
3473
  try {
3402
3474
  const log = await ghApi([`repos/${repo}/actions/jobs/${String(job.id)}/logs`]);
3403
3475
  writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
3476
+ staged += 1;
3404
3477
  } catch (err) {
3405
3478
  process.stderr.write(
3406
3479
  `Warning: failed to download logs for job ${String(job.id)}: ${errMsg(err)} \u2014 continuing with the logs retrieved so far
@@ -3408,6 +3481,19 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
3408
3481
  );
3409
3482
  }
3410
3483
  }
3484
+ if (failing.length > selected.length) {
3485
+ process.stderr.write(
3486
+ `::warning::${annotationSafe(`${String(failing.length)} failing job(s) in run ${runId}; staged ${String(staged)} of the first ${String(selected.length)} log(s) \u2014 the review does not see the rest`)}
3487
+ `
3488
+ );
3489
+ }
3490
+ if (staged === 0) {
3491
+ process.stderr.write(
3492
+ `::warning::${annotationSafe(`No failing-job logs could be staged for run ${runId} (${String(failing.length)} failing job(s) reported) \u2014 the review has only the diff to work from`)}
3493
+ `
3494
+ );
3495
+ }
3496
+ return { staged, failing: failing.length };
3411
3497
  };
3412
3498
  var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
3413
3499
  const candidates = await fetchPrCandidates(input.repo, input.headSha, ghApi);
@@ -3477,16 +3563,16 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit) => {
3477
3563
  reviews: reviews === null ? [] : reviewsFrom(reviews, input.botLogin)
3478
3564
  })
3479
3565
  );
3480
- if (input.conclusion === "failure") {
3481
- await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi);
3482
- }
3566
+ const jobLogs = input.conclusion === "failure" ? await downloadFailingJobLogs(input.repo, input.runId, input.outDir, ghApi) : { staged: 0, failing: 0 };
3483
3567
  return {
3484
3568
  kind: "gathered",
3485
3569
  pr: prNumber,
3486
3570
  conclusion: input.conclusion,
3487
3571
  diffSize: Buffer.byteLength(prDiff, "utf8"),
3488
3572
  stacked,
3489
- baseSha: meta.base_sha
3573
+ baseSha: meta.base_sha,
3574
+ stagedJobLogs: jobLogs.staged,
3575
+ failingJobs: jobLogs.failing
3490
3576
  };
3491
3577
  };
3492
3578
 
@@ -5146,7 +5232,7 @@ var postCmd = defineCommand({
5146
5232
  },
5147
5233
  "run-url": {
5148
5234
  type: "string",
5149
- description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside"
5235
+ description: "Workflow run URL (transcript/traces), rendered as a link in the LLM Disclosure aside and in the review-object body"
5150
5236
  },
5151
5237
  "json-url": {
5152
5238
  type: "string",
@@ -5159,6 +5245,14 @@ var postCmd = defineCommand({
5159
5245
  "nit-visibility-floor": {
5160
5246
  type: "string",
5161
5247
  description: NIT_VISIBILITY_FLOOR_DESCRIPTION
5248
+ },
5249
+ inline: {
5250
+ type: "boolean",
5251
+ description: "Also render findings as inline review comments on the diff. Off by default: an inline thread cannot be revised by a later round, so stale threads accumulate. The review object is posted either way; with this off the sticky lists the findings instead"
5252
+ },
5253
+ "unverified-no-logs": {
5254
+ type: "boolean",
5255
+ description: "Mark the review unverified: the fast-fix route ran with no failing-job logs staged, so its findings came from the diff alone. The caller decides this \u2014 the logs are staged in the review job, not here"
5162
5256
  }
5163
5257
  },
5164
5258
  run: async ({ args }) => {
@@ -5182,6 +5276,8 @@ var postCmd = defineCommand({
5182
5276
  jsonUrl: args["json-url"],
5183
5277
  convergenceThreshold: parseConvergenceThreshold(args["convergence-threshold"]),
5184
5278
  nitVisibilityFloor: parseNitVisibilityFloor(args["nit-visibility-floor"]),
5279
+ inline: args.inline,
5280
+ unverifiedNoLogs: args["unverified-no-logs"],
5185
5281
  postedAt: formatUtc(/* @__PURE__ */ new Date()),
5186
5282
  pricedAt: /* @__PURE__ */ new Date()
5187
5283
  });