@jphutchins/code-review 0.1.0-alpha.55 → 0.1.0-alpha.57

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/dist/index.js CHANGED
@@ -801,11 +801,17 @@ var parseReviewedRoute = (body) => ROUTE_RE.exec(body)?.[1] || null;
801
801
  var isFullReviewSticky = (body) => {
802
802
  const route = parseReviewedRoute(body);
803
803
  if (route === "full review") return true;
804
- if (route === "mechanic") return false;
804
+ if (route === "mechanic" || parseMechanicAncestor(body)) return false;
805
805
  return priorTrajectory(parseFindingsMarker(body), body).length > 0;
806
806
  };
807
+ var MECHANIC_ANCESTOR_MARKER = "<!-- review-mechanic-ancestor -->";
808
+ var parseMechanicAncestor = (body) => new RegExp(`(^|
809
+ )${MECHANIC_ANCESTOR_MARKER}(
810
+ |$)`).test(body);
807
811
  var COMPLETED_ANCESTOR_MARKER = "<!-- review-complete-ancestor -->";
808
- var parseCompletedAncestor = (body) => body.includes(COMPLETED_ANCESTOR_MARKER);
812
+ var parseCompletedAncestor = (body) => new RegExp(`(^|
813
+ )${COMPLETED_ANCESTOR_MARKER}(
814
+ |$)`).test(body);
809
815
  var REVIEW_COMPLETE_MARKER = "<!-- review-complete -->";
810
816
  var parseReviewComplete = (body) => body.includes(REVIEW_COMPLETE_MARKER);
811
817
  var parseFindingsMarker = (body) => {
@@ -957,6 +963,12 @@ var CRITICAL_FLOOR_MARGIN = 0.01;
957
963
  var MINOR_FLOOR = 0.1;
958
964
  var DEFAULT_CONVERGENCE_THRESHOLD = 1;
959
965
  var convergenceFloor = (severity, threshold) => severity === "critical" ? threshold + CRITICAL_FLOOR_MARGIN : severity === "major" ? 0.5 : severity === "minor" ? MINOR_FLOOR : 0;
966
+ var mechanicConvergence = (prior, threshold) => prior === null ? null : {
967
+ score: convergenceFloor("critical", threshold),
968
+ threshold,
969
+ converged: false,
970
+ rounds: prior.rounds
971
+ };
960
972
  var round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
961
973
  var contribution = (severity, confidence, likelihood, threshold) => {
962
974
  const floor = convergenceFloor(severity, threshold);
@@ -1134,17 +1146,36 @@ var stripSurfaceFields = (doc) => {
1134
1146
  );
1135
1147
  return { ...rest, schema_version: DEFAULT_SCHEMA_VERSION };
1136
1148
  };
1149
+ var carriedFindingsMarker = (body) => /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0] ?? null;
1150
+ var carriedMarkerPointer = (carried, convergence) => [
1151
+ `${AGENTS_STOP_DIRECTIVE}
1152
+ ${carried}`,
1153
+ ...convergence !== void 0 ? [convergenceMarker(convergence)] : []
1154
+ ].join("\n");
1155
+ var completedAncestryMarker = (body) => parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
1156
+ var carriedProvenanceMarkers = (body) => [
1157
+ // The placeholder keeps the REAL route marker: the seed chain requires it outright to stay
1158
+ // route-aware across the prose swap.
1159
+ ROUTE_RE.exec(body)?.[0],
1160
+ parseMechanicAncestor(body) ? MECHANIC_ANCESTOR_MARKER : void 0,
1161
+ completedAncestryMarker(body)
1162
+ ].filter((m) => m !== void 0).join("\n\n");
1163
+ var isFullReviewAncestry = (body) => parseReviewedRoute(body) === "full review" || parseReviewedRoute(body) === null && parseCompletedAncestor(body) && !parseMechanicAncestor(body);
1164
+ var carriedAncestryMarkers = (body) => [
1165
+ // A notice body carries no route marker, so the second hop reads the ancestor marker itself:
1166
+ // mechanic ancestry survives notice-over-notice, not just notice-over-sticky.
1167
+ parseReviewedRoute(body) === "mechanic" || parseMechanicAncestor(body) ? MECHANIC_ANCESTOR_MARKER : void 0,
1168
+ completedAncestryMarker(body)
1169
+ ].filter((m) => m !== void 0).join("\n\n");
1137
1170
  var carryForwardMarkers = (body) => {
1138
- const findings = /<!-- code-review:findings-json[^>]*-->/.exec(body)?.[0];
1171
+ const findings = carriedFindingsMarker(body);
1139
1172
  const reviewedSha = /<!-- reviewed-sha: [0-9a-fA-F]{40} -->/.exec(body)?.[0];
1140
- const reviewedRoute = ROUTE_RE.exec(body)?.[0];
1141
1173
  const convergence = CONVERGENCE_RE.exec(body)?.[0];
1142
1174
  const rounds = ROUNDS_RE.exec(body)?.[0];
1143
1175
  const signal = SIGNAL_RE.exec(body)?.[0];
1144
- const completedAncestor = parseReviewComplete(body) || parseCompletedAncestor(body) ? COMPLETED_ANCESTOR_MARKER : void 0;
1145
1176
  const findingsBlock = findings ? `${AGENTS_STOP_DIRECTIVE}
1146
1177
  ${findings}` : void 0;
1147
- return [findingsBlock, reviewedSha, reviewedRoute, completedAncestor, convergence, rounds, signal].filter((m) => m !== void 0).join("\n\n");
1178
+ return [findingsBlock, reviewedSha, carriedProvenanceMarkers(body), convergence, rounds, signal].filter((m) => m !== void 0 && m !== "").join("\n\n");
1148
1179
  };
1149
1180
  var escapeFence = (text) => text.replace(/```/g, "`` ` ``");
1150
1181
  var projectPatch = (patch, surface) => {
@@ -1159,6 +1190,17 @@ var reviewBodyPointer = (headSha, stickyUrl, runUrl, runHasSummary) => {
1159
1190
  const run2 = runUrl ? ` See the [workflow run](${runUrl}) for ${runHasSummary ? "this round's review in full, its job log," : "the job log"} and the findings artifact.` : "";
1160
1191
  return `\u{1F916} Automated code review for \`${sha7}\` \u2014 see ${summary} for the verdict, walkthrough, and cost.${run2}`;
1161
1192
  };
1193
+
1194
+ // src/comment-chain.ts
1195
+ function* ancestors(start, parentOf) {
1196
+ const seen = /* @__PURE__ */ new Set();
1197
+ let current = start;
1198
+ while (current !== null && !seen.has(current)) {
1199
+ yield current;
1200
+ seen.add(current);
1201
+ current = parentOf(current);
1202
+ }
1203
+ }
1162
1204
  var numField = (rec, key2) => {
1163
1205
  const v = rec[key2];
1164
1206
  return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
@@ -1325,15 +1367,14 @@ var answeredRegistryFrom = (comments, botLogin) => {
1325
1367
  const byId = /* @__PURE__ */ new Map();
1326
1368
  for (const c of ordered) byId.set(c.id, c);
1327
1369
  const rootOf = (c) => {
1328
- let current = c;
1329
- const seen = /* @__PURE__ */ new Set();
1330
- while (current.in_reply_to_id !== null && !seen.has(current.id)) {
1331
- seen.add(current.id);
1332
- const parent = byId.get(current.in_reply_to_id);
1333
- if (parent === void 0) return null;
1334
- current = parent;
1335
- }
1336
- return current.in_reply_to_id === null ? current : null;
1370
+ const chain = [
1371
+ ...ancestors(
1372
+ c,
1373
+ (n) => n.in_reply_to_id === null ? null : byId.get(n.in_reply_to_id) ?? null
1374
+ )
1375
+ ];
1376
+ const root = chain[chain.length - 1];
1377
+ return root !== void 0 && root.in_reply_to_id === null ? root : null;
1337
1378
  };
1338
1379
  const findingOf = (root) => {
1339
1380
  const decoded = parseFindingsMarker(root.body ?? "");
@@ -1454,9 +1495,9 @@ var fetchThreadComments = async (ghApi, repo, prNumber) => {
1454
1495
  try {
1455
1496
  const rows = parseJsonl(
1456
1497
  await ghApi([
1457
- `repos/${repo}/pulls/${String(prNumber)}/comments`,
1458
- "-f",
1459
- "per_page=100",
1498
+ // per_page rides the QUERY string — a `-f` field would flip `gh api` from GET to POST
1499
+ // and 422 every answered-thread fetch.
1500
+ `repos/${repo}/pulls/${String(prNumber)}/comments?per_page=100`,
1460
1501
  "--paginate",
1461
1502
  "--jq",
1462
1503
  THREAD_COMMENT_JQ
@@ -1498,7 +1539,44 @@ var permalinkFor = (base, f, anchor) => {
1498
1539
  };
1499
1540
  var CARRIED_TOTAL_CHARS = 4e4;
1500
1541
  var SUPPRESSED_NIT_BLOCK_OVERHEAD = 280;
1501
- var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1542
+ var DISCUSSION_TOTAL_CHARS = 8e3;
1543
+ var DISCUSSION_BLOCK_OVERHEAD = 120;
1544
+ var orphanedEntryHtml = (token, links, truncated) => `- **\`${token}\`**: ${links.map((d) => `[${escapeHtml(d.author)} \xB7 ${d.when}](${linkSafeUrl(d.url)})`).join(" ")}${truncated !== void 0 ? ` _(showing the ${String(PER_FINDING_LINKS)} newest of ${String(truncated)})_` : ""}`;
1545
+ var PER_FINDING_LINKS = 6;
1546
+ var DROPPED_IDS_SHOWN = 6;
1547
+ var budgetBySize = (items, cap, sizeOf, onDrop) => {
1548
+ const kept = [];
1549
+ const droppedItems = [];
1550
+ let used = 0;
1551
+ for (const item of items) {
1552
+ const size = sizeOf(item);
1553
+ if (used + size > cap) {
1554
+ droppedItems.push(item);
1555
+ const replaced = onDrop(item);
1556
+ if (replaced !== null) kept.push(replaced);
1557
+ continue;
1558
+ }
1559
+ kept.push(item);
1560
+ used += size;
1561
+ }
1562
+ return { kept, droppedItems, used };
1563
+ };
1564
+ var escapeHtml = (text) => text.replace(
1565
+ /[&<>"']/g,
1566
+ (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c
1567
+ );
1568
+ var discussionAsideHtml = (links, truncated, prefix) => [
1569
+ ...links.map((d) => `${prefix}- [${escapeHtml(d.author)} \xB7 ${d.when}](${linkSafeUrl(d.url)})`),
1570
+ ...truncated !== void 0 ? [
1571
+ // A blank line closes the list: the note stands apart as its own paragraph instead of
1572
+ // reading as a continuation of the newest bullet.
1573
+ prefix,
1574
+ `${prefix}_(showing the ${String(PER_FINDING_LINKS)} newest of ${String(
1575
+ truncated
1576
+ )} replies \u2014 the rest live on the comment thread.)_`
1577
+ ] : []
1578
+ ].join("\n");
1579
+ var sanitizeFinding = (f, answeredNotes, discussionByFinding, permalinkBase, unanchored, discussionTruncated) => {
1502
1580
  const key2 = answeredNoteKey(f);
1503
1581
  const anchored = permalinkBase !== void 0 && !(unanchored?.has(f) ?? false);
1504
1582
  const permalink = permalinkBase === void 0 ? void 0 : permalinkFor(permalinkBase, f, anchored);
@@ -1514,11 +1592,13 @@ var sanitizeFinding = (f, answeredNotes, permalinkBase, unanchored) => {
1514
1592
  rangeLabel: lineRange(f.start_line, f.end_line, "\u2013"),
1515
1593
  ...permalink !== void 0 ? { permalink, permalinkAnchored: anchored } : {},
1516
1594
  patchProjection: projectPatch(f.patch, "comment-body"),
1517
- answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : ""
1595
+ answeredNote: answeredNotes !== void 0 && Object.prototype.hasOwnProperty.call(answeredNotes, key2) ? answeredNotes[key2] ?? "" : "",
1596
+ discussion: discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(discussionByFinding, f.id) ? discussionByFinding[f.id] ?? [] : [],
1597
+ ...discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(discussionTruncated, f.id) ? { discussionTruncated: discussionTruncated[f.id] } : {}
1518
1598
  };
1519
1599
  };
1520
1600
  var commentSafe = (text) => text.replace(/--+(?=>)/g, (dashes) => `${dashes}\u200B`);
1521
- var sanitizeSuppressedNit = (f) => ({
1601
+ var sanitizeSuppressedNit = (f, discussion, discussionTruncated) => ({
1522
1602
  title: escapeCodeBackticks(f.title),
1523
1603
  id: escapeCodeBackticks(f.id),
1524
1604
  ...f.code_url !== void 0 ? { codeUrl: linkSafeUrl(f.code_url) } : {},
@@ -1530,7 +1610,10 @@ var sanitizeSuppressedNit = (f) => ({
1530
1610
  confidence: formatConfidence(f.confidence),
1531
1611
  likelihood: formatConfidence(f.likelihood),
1532
1612
  m: formatConfidence(f.confidence * f.likelihood),
1533
- carried: carriedLines(f)
1613
+ carried: carriedLines(f),
1614
+ discussion,
1615
+ ...discussionTruncated !== void 0 ? { discussionTruncated } : {},
1616
+ discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncated, "> ") : ""
1534
1617
  });
1535
1618
  var carriedLines = (f) => [
1536
1619
  `description: ${clipText(f.description, BODY_CLIP_CHARS)}`,
@@ -1538,9 +1621,12 @@ var carriedLines = (f) => [
1538
1621
  `reasoning: ${clipText(f.reasoning, BODY_CLIP_CHARS)}`,
1539
1622
  ...f.patch !== void 0 ? ["patch:", clipText(f.patch, BODY_CLIP_CHARS)] : []
1540
1623
  ].flatMap((block) => commentSafe(block).split("\n")).map((line) => line.trimEnd());
1541
- var sanitizeSystemic = (s) => ({
1624
+ var sanitizeSystemic = (s, discussion, discussionTruncated) => ({
1542
1625
  ...s,
1543
1626
  title: escapePipes(s.title),
1627
+ discussion,
1628
+ ...discussionTruncated !== void 0 ? { discussionTruncated } : {},
1629
+ discussionHtml: discussion.length > 0 ? discussionAsideHtml(discussion, discussionTruncated, "") : "",
1544
1630
  ...s.id !== void 0 ? { id: escapeCodeBackticks(s.id) } : {},
1545
1631
  ...s.code_url !== void 0 ? { code_url: linkSafeUrl(s.code_url) } : {},
1546
1632
  ...s.paths !== void 0 ? { paths: s.paths.map(escapeCodeBackticks) } : {},
@@ -1575,16 +1661,98 @@ var render = (input) => {
1575
1661
  const sameRootNotes = input.sameRootNotes ?? computeSameRootNotes(trajectory.slice(0, -1), input.findings.findings);
1576
1662
  const isFullReviewRound = (input.convergenceRound ?? (isConvergenceRound(route, incomplete) && trajectory.length > 0)) && isReviewVerdict(input.findings.verdict);
1577
1663
  const advisoryAllowed = isFullReviewRound;
1578
- const suppressedBudget = (input.suppressedNits ?? []).map(sanitizeSuppressedNit).reduce(
1579
- (acc, n) => {
1580
- const size = n.carried.reduce((sum, line) => sum + line.length + 3, 0) + SUPPRESSED_NIT_BLOCK_OVERHEAD + n.title.length + n.path.length * 2 + // The id renders with its two wrapper backticks; the code_url adds the [](...) link form.
1581
- n.id.length * 2 + 2 + (n.codeUrl !== void 0 ? n.codeUrl.length + 4 : 0) + String(n.startLine).length * 2 + String(n.endLine).length + (n.side !== void 0 ? n.side.length + 2 : 0);
1582
- return acc.used + size > CARRIED_TOTAL_CHARS ? { list: acc.list, used: acc.used, dropped: acc.dropped + 1 } : { list: [...acc.list, n], used: acc.used + size, dropped: acc.dropped };
1583
- },
1584
- { list: [], used: 0, dropped: 0 }
1664
+ const discussionLinksFor = (id) => input.discussionByFinding !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionByFinding, id) ? input.discussionByFinding[id] ?? [] : [];
1665
+ const discussionTruncatedFor = (id) => input.discussionTruncated !== void 0 && Object.prototype.hasOwnProperty.call(input.discussionTruncated, id) ? input.discussionTruncated[id] : void 0;
1666
+ const suppressedBudget = budgetBySize(
1667
+ (input.suppressedNits ?? []).map(
1668
+ (f) => sanitizeSuppressedNit(f, discussionLinksFor(f.id), discussionTruncatedFor(f.id))
1669
+ ),
1670
+ CARRIED_TOTAL_CHARS,
1671
+ (n) => (
1672
+ // The fixed overhead covers the summary line's structure; the reviewer-controlled variable
1673
+ // parts are budgeted at their true RENDERED size — the title, the path TWICE (summary +
1674
+ // location line), the code/code_url the summary line renders, each carried line's `> `
1675
+ // blockquote prefix (length + 3: prefix + newline), the location line's line numbers and
1676
+ // side label, and the discussion slot's link list.
1677
+ n.carried.reduce((sum, line) => sum + line.length + 3, 0) + SUPPRESSED_NIT_BLOCK_OVERHEAD + n.title.length + n.path.length * 2 + // The id renders with its two wrapper backticks; the code_url adds the [](...) link form.
1678
+ // The id renders ONCE: a backtick span inside parens, or the []() link form.
1679
+ n.id.length + (n.codeUrl !== void 0 ? n.codeUrl.length + 8 : 4) + String(n.startLine).length * 2 + String(n.endLine).length + (n.side !== void 0 ? n.side.length + 2 : 0) + // The composed slot's EXACT rendered size — its `> ` blockquote prefixes included.
1680
+ n.discussionHtml.length
1681
+ ),
1682
+ () => null
1585
1683
  );
1586
1684
  const permalinkBase = input.repo !== void 0 && input.repo !== "" && input.reviewedSha !== void 0 && input.reviewedSha !== "" ? `https://github.com/${input.repo}/blob/${input.reviewedSha}/` : void 0;
1587
1685
  const unanchored = new Set(input.unanchoredStrays ?? []);
1686
+ const strayViews = (input.strays ?? []).map((f) => {
1687
+ const view = sanitizeFinding(
1688
+ f,
1689
+ input.answeredNotes,
1690
+ input.discussionByFinding,
1691
+ permalinkBase,
1692
+ unanchored,
1693
+ input.discussionTruncated
1694
+ );
1695
+ return {
1696
+ ...view,
1697
+ discussionHtml: view.discussion.length > 0 ? discussionAsideHtml(view.discussion, view.discussionTruncated, "") : ""
1698
+ };
1699
+ });
1700
+ const orphanedPreBudget = /* @__PURE__ */ new Map();
1701
+ for (const [token, links] of Object.entries(input.orphanedDiscussion ?? {})) {
1702
+ const key2 = escapeCodeBackticks(token);
1703
+ const truncatedFor = input.orphanedTruncated !== void 0 && Object.prototype.hasOwnProperty.call(input.orphanedTruncated, token) ? input.orphanedTruncated[token] : void 0;
1704
+ const existing = orphanedPreBudget.get(key2);
1705
+ const htmlFor = (entryLinks, entryTruncated) => orphanedEntryHtml(key2, entryLinks, entryTruncated);
1706
+ if (existing === void 0) {
1707
+ orphanedPreBudget.set(key2, {
1708
+ links,
1709
+ ...truncatedFor !== void 0 ? { truncated: truncatedFor } : {},
1710
+ html: htmlFor(links, truncatedFor)
1711
+ });
1712
+ } else {
1713
+ const mergedTotal = (existing.truncated ?? existing.links.length) + (truncatedFor ?? links.length);
1714
+ const merged = [...existing.links, ...links].sort(
1715
+ (a, b) => (b.at ?? b.when).localeCompare(a.at ?? a.when)
1716
+ );
1717
+ const mergedLinks = merged.slice(0, PER_FINDING_LINKS);
1718
+ const mergedTruncated = mergedTotal > PER_FINDING_LINKS ? mergedTotal : void 0;
1719
+ orphanedPreBudget.set(key2, {
1720
+ links: mergedLinks,
1721
+ ...mergedTruncated !== void 0 ? { truncated: mergedTruncated } : {},
1722
+ html: htmlFor(mergedLinks, mergedTruncated)
1723
+ });
1724
+ }
1725
+ }
1726
+ const orphanedBudget = budgetBySize(
1727
+ [...orphanedPreBudget.entries()],
1728
+ DISCUSSION_TOTAL_CHARS,
1729
+ ([, entry]) => entry.html.length,
1730
+ () => null
1731
+ );
1732
+ const discussionBudget = budgetBySize(
1733
+ strayViews,
1734
+ DISCUSSION_TOTAL_CHARS - orphanedBudget.used,
1735
+ (view) => view.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + view.discussionHtml.length : 0,
1736
+ (view) => ({ ...view, discussion: [], discussionHtml: "" })
1737
+ );
1738
+ const systemicBudget = budgetBySize(
1739
+ (input.findings.systemic_problems ?? []).map(
1740
+ (s) => sanitizeSystemic(
1741
+ s,
1742
+ s.id !== void 0 ? discussionLinksFor(s.id) : [],
1743
+ s.id !== void 0 ? discussionTruncatedFor(s.id) : void 0
1744
+ )
1745
+ ),
1746
+ DISCUSSION_TOTAL_CHARS - orphanedBudget.used - discussionBudget.used,
1747
+ (s) => s.discussion.length > 0 ? DISCUSSION_BLOCK_OVERHEAD + s.discussionHtml.length : 0,
1748
+ (s) => ({ ...s, discussion: [], discussionHtml: "" })
1749
+ );
1750
+ const orphanedRender = {
1751
+ lines: orphanedBudget.kept.map(([, entry]) => entry.html),
1752
+ // The "(showing N of M)" total counts DISTINCT DISPLAYED entries — the pre-budget fold above
1753
+ // already merged twins, and the budget's own drops are named in the dropped-threads marker.
1754
+ total: input.orphanedTotal !== void 0 ? input.orphanedTotal - (Object.keys(input.orphanedDiscussion ?? {}).length - orphanedPreBudget.size) : orphanedPreBudget.size - orphanedBudget.droppedItems.length
1755
+ };
1588
1756
  return eta.renderString(input.template, {
1589
1757
  findings: input.findings,
1590
1758
  envelope: input.envelope,
@@ -1605,13 +1773,20 @@ var render = (input) => {
1605
1773
  postedAt: input.postedAt ?? "",
1606
1774
  severityCounts,
1607
1775
  convergenceSummary: !isFullReviewRound ? "" : convergence ? convergenceBadge(convergence) : convergenceSummary(input.findings, input.convergenceThreshold),
1608
- strays: (input.strays ?? []).map(
1609
- (f) => sanitizeFinding(f, input.answeredNotes, permalinkBase, unanchored)
1610
- ),
1611
- suppressedNits: suppressedBudget.list,
1612
- carriedDroppedNits: suppressedBudget.dropped,
1776
+ strays: discussionBudget.kept,
1777
+ orphanedLines: orphanedRender.lines,
1778
+ orphanedUnresolvable: input.orphanedUnresolvable === true,
1779
+ discussionDropped: discussionBudget.droppedItems.length + systemicBudget.droppedItems.length + orphanedBudget.droppedItems.length,
1780
+ discussionDroppedIds: discussionBudget.droppedItems.map((v) => clipText(escapeCodeBackticks(v.idKey), 64)).slice(0, DROPPED_IDS_SHOWN),
1781
+ discussionDroppedSystemicIds: systemicBudget.droppedItems.flatMap((s) => s.id !== void 0 ? [clipText(s.id, 64)] : []).slice(0, DROPPED_IDS_SHOWN),
1782
+ discussionDroppedOrphanedIds: orphanedBudget.droppedItems.map(([token]) => clipText(token, 64)).slice(0, DROPPED_IDS_SHOWN),
1783
+ discussionDroppedExtra: Math.max(0, discussionBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, systemicBudget.droppedItems.length - DROPPED_IDS_SHOWN) + Math.max(0, orphanedBudget.droppedItems.length - DROPPED_IDS_SHOWN),
1784
+ discussionCap: PER_FINDING_LINKS,
1785
+ orphanedTotal: orphanedRender.total,
1786
+ suppressedNits: suppressedBudget.kept,
1787
+ carriedDroppedNits: suppressedBudget.droppedItems.length,
1613
1788
  nitVisibilityFloor: input.nitVisibilityFloor ?? DEFAULT_NIT_VISIBILITY_FLOOR,
1614
- systemic: (input.findings.systemic_problems ?? []).map(sanitizeSystemic),
1789
+ systemic: systemicBudget.kept,
1615
1790
  unanchoredCount: input.unanchoredCount ?? 0,
1616
1791
  inlineDisposition: input.inlineDisposition ?? null,
1617
1792
  unverifiedNoLogs: input.unverifiedNoLogs === true,
@@ -1726,8 +1901,7 @@ var renderCommentBody = (f, eta, template, modelsText, jsonUrl, pointer, sameRoo
1726
1901
  );
1727
1902
  var buildInlineComments = (findings, diff, context) => {
1728
1903
  const { inlineTemplate, models = [], jsonUrl, findings: fullFindings } = context;
1729
- const index = indexDiff(diff);
1730
- const { inDiff, strays } = partitionFindings(findings, index);
1904
+ const { inDiff, strays } = context.partition ?? partitionFindings(findings, indexDiff(diff));
1731
1905
  const eta = new Eta({ autoTrim: false });
1732
1906
  const modelsText = formatModels(models);
1733
1907
  const noteFor = (notes, key2) => notes !== void 0 && Object.prototype.hasOwnProperty.call(notes, key2) ? notes[key2] ?? "" : "";
@@ -2803,24 +2977,58 @@ var postInlineReview = async (pr, comments, inDiff, ghApi) => {
2803
2977
  return { url, inlinePosted, unposted };
2804
2978
  }
2805
2979
  };
2806
- var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => {
2807
- const stdout = await ghApi(
2808
- [
2809
- `repos/${repo}/issues/${String(prNumber)}/comments`,
2810
- "--paginate",
2811
- "--jq",
2812
- ".[] | select(.user.login == env.CODE_REVIEW_BOT_LOGIN and (.body | startswith(env.CODE_REVIEW_MARKER))) | {id: .id, body: .body}"
2813
- ],
2814
- void 0,
2815
- { CODE_REVIEW_BOT_LOGIN: botLogin, CODE_REVIEW_MARKER: marker }
2980
+ var ISSUE_COMMENTS_JQ = '.[] | {id, user: (.user.login // "(deleted)"), created_at, html_url, body: (.body // "")}';
2981
+ var parseIssueCommentRows = (raw) => {
2982
+ let malformed = 0;
2983
+ const rows = [];
2984
+ for (const line of raw.split("\n")) {
2985
+ const trimmed = line.trim();
2986
+ if (trimmed === "") continue;
2987
+ const parsed = tryParseJson(trimmed);
2988
+ if (!parsed.ok) {
2989
+ malformed += 1;
2990
+ continue;
2991
+ }
2992
+ const rec = asRecord(parsed.value);
2993
+ const id = rec?.["id"];
2994
+ const author = rec?.["user"];
2995
+ const created = rec?.["created_at"];
2996
+ const url = rec?.["html_url"];
2997
+ const body = rec?.["body"];
2998
+ if (typeof id === "number" && typeof author === "string" && typeof created === "string" && typeof url === "string" && typeof body === "string") {
2999
+ rows.push({ id, author, created, url, body });
3000
+ } else {
3001
+ malformed += 1;
3002
+ }
3003
+ }
3004
+ rows.sort(
3005
+ (a, b) => a.created.localeCompare(b.created) || (a.id > b.id ? 1 : a.id < b.id ? -1 : 0)
2816
3006
  );
2817
- const lines = stdout.trim().split("\n").filter(Boolean);
2818
- if (lines.length === 0) return null;
2819
- const last = lines[lines.length - 1] ?? null;
2820
- if (last === null) return null;
2821
- const parsed = JSON.parse(last);
2822
- return { id: parsed.id, body: parsed.body };
3007
+ return { rows, malformed };
3008
+ };
3009
+ var fetchIssueCommentRows = async (repo, prNumber, ghApi) => {
3010
+ const raw = await ghApi([
3011
+ // per_page rides the QUERY string — a `-f` field would flip `gh api` from GET to POST
3012
+ // (create-comment) and 422 every sticky lookup.
3013
+ `repos/${repo}/issues/${String(prNumber)}/comments?per_page=100`,
3014
+ "--paginate",
3015
+ "--jq",
3016
+ ISSUE_COMMENTS_JQ
3017
+ ]);
3018
+ const { rows, malformed } = parseIssueCommentRows(raw);
3019
+ if (malformed > 0) {
3020
+ throw new Error(
3021
+ `${String(malformed)} issue-comment row(s) failed to decode \u2014 refusing to treat a corrupted history as an absent sticky`
3022
+ );
3023
+ }
3024
+ return rows;
3025
+ };
3026
+ var selectBotComment = (rows, botLogin, marker) => {
3027
+ const bot = rows.filter((r) => r.author === botLogin && r.body.startsWith(marker));
3028
+ const last = bot[bot.length - 1];
3029
+ return last === void 0 ? null : { id: last.id, body: last.body };
2823
3030
  };
3031
+ var findBotComment = async (repo, prNumber, botLogin, marker, ghApi) => selectBotComment(await fetchIssueCommentRows(repo, prNumber, ghApi), botLogin, marker);
2824
3032
  var parseCommentRef = (raw) => {
2825
3033
  const parsed = tryParseJson(raw);
2826
3034
  const rec = parsed.ok ? asRecord(parsed.value) : null;
@@ -2843,6 +3051,134 @@ var postComment = async (repo, prNumber, body, ghApi) => {
2843
3051
  );
2844
3052
  return parseCommentRef(stdout);
2845
3053
  };
3054
+ var ID_TOKEN_RE = /`([^`\n]+)`/g;
3055
+ var ORPHAN_TOKEN_CAP = 8;
3056
+ var escapedIdIndex = (ids) => {
3057
+ const owners = /* @__PURE__ */ new Map();
3058
+ for (const id of ids) {
3059
+ const escaped = escapeCodeBackticks(id);
3060
+ if (escaped === id) continue;
3061
+ const list = owners.get(escaped);
3062
+ if (list === void 0) owners.set(escaped, [id]);
3063
+ else if (!list.includes(id)) list.push(id);
3064
+ }
3065
+ return new Map(
3066
+ [...owners.entries()].flatMap(
3067
+ ([escaped, raws]) => raws.length === 1 && raws[0] !== void 0 ? [[escaped, raws[0]]] : []
3068
+ )
3069
+ );
3070
+ };
3071
+ var discussionRows = (rows, stickyId, botLogin) => {
3072
+ const seen = /* @__PURE__ */ new Set();
3073
+ return rows.filter((c) => {
3074
+ if (seen.has(c.id)) return false;
3075
+ seen.add(c.id);
3076
+ return c.id !== stickyId && c.author !== botLogin;
3077
+ });
3078
+ };
3079
+ var ID_SHAPE_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
3080
+ var mentionsOutsideKnown = (reachable, currentIds) => {
3081
+ const known = new Set(currentIds);
3082
+ const escapedToRaw = escapedIdIndex(currentIds);
3083
+ for (const c of reachable) {
3084
+ for (const m of c.body.matchAll(ID_TOKEN_RE)) {
3085
+ const token = m[1];
3086
+ if (token === void 0) continue;
3087
+ const raw = known.has(token) ? token : escapedToRaw.get(token);
3088
+ if ((raw === void 0 || !known.has(raw)) && ID_SHAPE_RE.test(token)) return true;
3089
+ }
3090
+ }
3091
+ return false;
3092
+ };
3093
+ var buildStickyDiscussion = (reachable, currentIds, priorIds) => {
3094
+ const known = new Set(currentIds);
3095
+ const departed = new Set(priorIds);
3096
+ const escapedToRaw = escapedIdIndex([...currentIds, ...priorIds]);
3097
+ const byFinding = Object.fromEntries(
3098
+ currentIds.map((id) => [id, []])
3099
+ );
3100
+ const orphaned = /* @__PURE__ */ new Map();
3101
+ const latestAt = /* @__PURE__ */ new Map();
3102
+ for (const c of reachable) {
3103
+ const link = { author: c.author, when: c.created.slice(0, 10), url: c.url, at: c.created };
3104
+ const pushed = /* @__PURE__ */ new Set();
3105
+ for (const m of c.body.matchAll(ID_TOKEN_RE)) {
3106
+ const token = m[1];
3107
+ if (token === void 0 || pushed.has(token)) continue;
3108
+ pushed.add(token);
3109
+ const rawKnown = known.has(token);
3110
+ const alias = rawKnown ? void 0 : escapedToRaw.get(token);
3111
+ const aliasIsKnown = alias !== void 0 && known.has(alias);
3112
+ const rawDeparted = departed.has(token);
3113
+ if (rawKnown) {
3114
+ byFinding[token]?.push(link);
3115
+ } else if (aliasIsKnown) {
3116
+ if (!rawDeparted) byFinding[alias]?.push(link);
3117
+ } else if (rawDeparted) {
3118
+ const key2 = token;
3119
+ const links = orphaned.get(key2);
3120
+ if (links === void 0) orphaned.set(key2, [link]);
3121
+ else links.push(link);
3122
+ const latest = latestAt.get(key2);
3123
+ if (latest === void 0 || c.created.localeCompare(latest) > 0)
3124
+ latestAt.set(key2, c.created);
3125
+ } else if (alias !== void 0 && departed.has(alias)) {
3126
+ const key2 = alias;
3127
+ const links = orphaned.get(key2);
3128
+ if (links === void 0) orphaned.set(key2, [link]);
3129
+ else links.push(link);
3130
+ const latest = latestAt.get(key2);
3131
+ if (latest === void 0 || c.created.localeCompare(latest) > 0)
3132
+ latestAt.set(key2, c.created);
3133
+ }
3134
+ }
3135
+ }
3136
+ const truncated = /* @__PURE__ */ new Map();
3137
+ for (const [id, links] of Object.entries(byFinding)) {
3138
+ links.reverse();
3139
+ if (links.length > PER_FINDING_LINKS) {
3140
+ truncated.set(id, links.length);
3141
+ links.splice(PER_FINDING_LINKS);
3142
+ }
3143
+ }
3144
+ const orphanedTruncated = /* @__PURE__ */ new Map();
3145
+ for (const [token, links] of orphaned) {
3146
+ links.reverse();
3147
+ if (links.length > PER_FINDING_LINKS) {
3148
+ orphanedTruncated.set(token, links.length);
3149
+ links.splice(PER_FINDING_LINKS);
3150
+ }
3151
+ }
3152
+ const orphanedTotal = orphaned.size;
3153
+ const byLatestReply = [...orphaned.entries()].sort(
3154
+ (a, b) => (latestAt.get(b[0]) ?? "").localeCompare(latestAt.get(a[0]) ?? "")
3155
+ );
3156
+ return {
3157
+ byFinding,
3158
+ orphaned: Object.fromEntries(byLatestReply.slice(0, ORPHAN_TOKEN_CAP)),
3159
+ orphanedTotal,
3160
+ truncated: Object.fromEntries(truncated),
3161
+ orphanedTruncated: Object.fromEntries(orphanedTruncated)
3162
+ };
3163
+ };
3164
+ var priorIdsFrom = (doc) => {
3165
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return [];
3166
+ const rec = doc;
3167
+ const idsOf = (field, systemic) => (Array.isArray(rec[field]) ? rec[field] : []).flatMap((raw) => {
3168
+ const item = typeof raw === "object" && raw !== null ? asRecord(raw) : null;
3169
+ if (item === null) return [];
3170
+ const title = typeof item["title"] === "string" ? item["title"] : "";
3171
+ const resolved = resolveRuleId({
3172
+ ...typeof item["id"] === "string" ? { id: item["id"] } : {},
3173
+ ...typeof item["code"] === "string" ? { code: item["code"] } : {},
3174
+ ...typeof item["path"] === "string" ? { path: item["path"] } : {},
3175
+ title
3176
+ });
3177
+ const id = resolved ?? (systemic && title !== "" ? synthesizedSystemicId(title) : void 0);
3178
+ return id !== void 0 ? [id] : [];
3179
+ });
3180
+ return [...idsOf("findings", false), ...idsOf("systemic_problems", true)];
3181
+ };
2846
3182
  var appendRunSummary = (summaryPath, body) => {
2847
3183
  if (summaryPath === void 0 || summaryPath === "") return;
2848
3184
  const rendered = body();
@@ -2999,13 +3335,14 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
2999
3335
  process.exit(0);
3000
3336
  }
3001
3337
  const prNumber = resolution.prNumber;
3002
- const [diff, existingSticky] = await Promise.all([
3338
+ const [diff, commentRows] = await Promise.all([
3003
3339
  fetchDiff(input.repo, prNumber, ghApi),
3004
- findBotComment(input.repo, prNumber, input.botLogin, DEFAULT_MARKER, ghApi)
3340
+ fetchIssueCommentRows(input.repo, prNumber, ghApi)
3005
3341
  ]);
3342
+ const existingSticky = selectBotComment(commentRows, input.botLogin, DEFAULT_MARKER);
3006
3343
  const existingComplete = existingSticky !== null && parseReviewComplete(existingSticky.body);
3007
3344
  const wouldBuryCompleted = (incomplete) => incomplete && existingComplete;
3008
- const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body));
3345
+ const priorIsFullReview = (body) => isFullReviewSticky(body) || parseReviewedRoute(body) === null && (parseReviewComplete(body) || parseCompletedAncestor(body) && !parseMechanicAncestor(body));
3009
3346
  const emptyMechanicWouldBury = (route, incomplete) => route === "mechanic" && !incomplete && findings.findings.length === 0 && existingSticky !== null && priorIsFullReview(existingSticky.body);
3010
3347
  const priorDoc = existingSticky !== null ? parseFindingsMarker(existingSticky.body) : null;
3011
3348
  const priorBody = existingSticky?.body ?? "";
@@ -3016,12 +3353,12 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3016
3353
  ...doc,
3017
3354
  convergence: conv ?? void 0
3018
3355
  });
3019
- const carriedFindingsLink = !input.jsonUrl && existingSticky !== null ? findingsArtifactUrl(existingSticky.body) : null;
3020
3356
  let warnedNoJsonUrl = false;
3021
3357
  const findingsBlob = (doc) => {
3022
3358
  if (input.jsonUrl) return findingsMarkerPair(input.jsonUrl, doc.convergence);
3023
- if (carriedFindingsLink !== null) {
3024
- return findingsMarkerPair(carriedFindingsLink, doc.convergence);
3359
+ const carriedLink = existingSticky !== null ? findingsArtifactUrl(existingSticky.body) : null;
3360
+ if (carriedLink !== null) {
3361
+ return findingsMarkerPair(carriedLink, doc.convergence);
3025
3362
  }
3026
3363
  if (!warnedNoJsonUrl) {
3027
3364
  warnedNoJsonUrl = true;
@@ -3056,7 +3393,7 @@ var post = async (input, ghApi = runGhApi, readArtifact = ghArtifactReader) => {
3056
3393
  noticeBody(
3057
3394
  `${DEFAULT_MARKER}
3058
3395
 
3059
- \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 ? `
3396
+ \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 worked from the diff and any local reproduction of the checks, 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 ? `
3060
3397
 
3061
3398
  ${dropNote}` : ""}`,
3062
3399
  sticky.body
@@ -3072,8 +3409,41 @@ ${dropNote}` : ""}`,
3072
3409
  }
3073
3410
  const template = readFileSync(input.templatePath, "utf-8");
3074
3411
  const inlineRequested = input.inline === true;
3075
- const renderNotice = (message) => {
3076
- const findings2 = stampConvergence(incompleteFindings(`### \u26A0\uFE0F ${message}`), priorConv);
3412
+ const envelope = loadEnvelope(input.envelopePath);
3413
+ const reachable = existingSticky !== null ? discussionRows(commentRows, existingSticky.id, input.botLogin) : [];
3414
+ const priorIsMechanic = existingSticky !== null && (parseReviewedRoute(existingSticky.body) === "mechanic" || parseMechanicAncestor(existingSticky.body));
3415
+ const noticeDiscussion = async () => {
3416
+ if (existingSticky === null) {
3417
+ return {
3418
+ orphanedDiscussion: {},
3419
+ orphanedTotal: 0,
3420
+ orphanedTruncated: {},
3421
+ orphanedUnresolvable: false
3422
+ };
3423
+ }
3424
+ const wantsPrior = !priorIsMechanic && mentionsOutsideKnown(reachable, []);
3425
+ const resolved = wantsPrior ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3426
+ const built = buildStickyDiscussion(reachable, [], priorIdsFrom(resolved));
3427
+ const hadMarker = carriedFindingsMarker(existingSticky.body) !== null;
3428
+ return {
3429
+ orphanedDiscussion: built.orphaned,
3430
+ orphanedTotal: built.orphanedTotal,
3431
+ orphanedTruncated: built.orphanedTruncated,
3432
+ orphanedUnresolvable: wantsPrior && hadMarker && resolved === null
3433
+ };
3434
+ };
3435
+ const renderNotice = (message, discussion) => {
3436
+ const noticeConvergence = (input.route ?? envelope?.route) === "mechanic" ? mechanicConvergence(
3437
+ priorConv,
3438
+ input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD
3439
+ ) : priorConv;
3440
+ const findings2 = stampConvergence(incompleteFindings(`### \u26A0\uFE0F ${message}`), noticeConvergence);
3441
+ const noticeFindingsPointer = (doc) => {
3442
+ const carried = existingSticky !== null ? carriedFindingsMarker(existingSticky.body) : null;
3443
+ if (carried === null) return findingsBlob(doc);
3444
+ const provenance = existingSticky !== null ? carriedAncestryMarkers(existingSticky.body) : "";
3445
+ return [carriedMarkerPointer(carried, doc.convergence), provenance].filter((p) => p !== "").join("\n\n");
3446
+ };
3077
3447
  return formatMarkdown(
3078
3448
  render({
3079
3449
  findings: findings2,
@@ -3090,18 +3460,23 @@ ${dropNote}` : ""}`,
3090
3460
  convergenceRound: false,
3091
3461
  runUrl: input.runUrl,
3092
3462
  jsonUrl: input.jsonUrl,
3093
- findingsPointer: findingsBlob(findings2),
3094
- postedAt: input.postedAt
3463
+ findingsPointer: noticeFindingsPointer(findings2),
3464
+ postedAt: input.postedAt,
3465
+ orphanedDiscussion: discussion.orphanedDiscussion,
3466
+ orphanedTotal: discussion.orphanedTotal,
3467
+ orphanedTruncated: discussion.orphanedTruncated,
3468
+ orphanedUnresolvable: discussion.orphanedUnresolvable
3095
3469
  })
3096
3470
  );
3097
3471
  };
3098
3472
  if (isEmptyDiff(diff)) {
3099
3473
  if (wouldBuryCompleted(true)) leaveInPlace();
3474
+ const discussion = await noticeDiscussion();
3100
3475
  await upsertSticky(
3101
3476
  input.repo,
3102
3477
  prNumber,
3103
3478
  existingSticky,
3104
- renderNotice("The diff for this PR is empty \u2014 nothing to review."),
3479
+ renderNotice("The diff for this PR is empty \u2014 nothing to review.", discussion),
3105
3480
  ghApi
3106
3481
  );
3107
3482
  process.exit(0);
@@ -3109,11 +3484,12 @@ ${dropNote}` : ""}`,
3109
3484
  const findingsResult = loadFindings(input.findingsPath);
3110
3485
  if (findingsResult.kind !== "ok") {
3111
3486
  if (wouldBuryCompleted(true)) leaveInPlace();
3487
+ const discussion = await noticeDiscussion();
3112
3488
  await upsertSticky(
3113
3489
  input.repo,
3114
3490
  prNumber,
3115
3491
  existingSticky,
3116
- renderNotice(noticeMessageFor(findingsResult)),
3492
+ renderNotice(noticeMessageFor(findingsResult), discussion),
3117
3493
  ghApi
3118
3494
  );
3119
3495
  process.exit(0);
@@ -3141,8 +3517,15 @@ ${dropNote}` : ""}`,
3141
3517
  findings: [...answeredFilter.findings],
3142
3518
  ...systemic.length > 0 ? { systemic_problems: systemic } : {}
3143
3519
  };
3520
+ const broadCurrentIds = [
3521
+ ...findings.findings.map((f) => f.id).filter((id) => id !== ""),
3522
+ ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
3523
+ ];
3144
3524
  const roundHasNit = findings.findings.some((f) => f.severity === "nit");
3145
- const priorDocForNits = roundHasNit && existingSticky !== null && isFullReviewSticky(existingSticky.body) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3525
+ const nitWantsPrior = roundHasNit && existingSticky !== null && (isFullReviewSticky(existingSticky.body) || isFullReviewAncestry(existingSticky.body));
3526
+ const broadWantsPrior = existingSticky !== null && !priorIsMechanic && mentionsOutsideKnown(reachable, broadCurrentIds);
3527
+ const resolvedPrior = existingSticky !== null && (nitWantsPrior || broadWantsPrior) ? await resolvePriorFindings(existingSticky.body, readArtifact) : null;
3528
+ const priorDocForNits = nitWantsPrior ? resolvedPrior : null;
3146
3529
  const priorSuppressedKeys = new Set(
3147
3530
  priorBelowFloorNits(priorDocForNits, input.nitVisibilityFloor).map(
3148
3531
  (n) => answeredNoteKey({ id: n.id ?? "", title: n.title })
@@ -3155,7 +3538,22 @@ ${dropNote}` : ""}`,
3155
3538
  const suppressedNits = findings.findings.filter(isSuppressedNit);
3156
3539
  const visibleFindings = findings.findings.filter((f) => !isSuppressedNit(f));
3157
3540
  const answeredDropNote = answeredReRaiseNote(verbatimReRaised, droppedCount) + (verbatimReRaised.length > 0 && findings.findings.length === 0 ? "\n> _The stop signal reflects the kept findings \u2014 this round carries none._" : "");
3158
- const envelope = loadEnvelope(input.envelopePath);
3541
+ const inlinePartition = input.inline === true && envelope !== null ? partitionFindings(visibleFindings, indexDiff(diff)) : null;
3542
+ const straysForDiscussion = inlinePartition?.strays ?? visibleFindings;
3543
+ const currentIds = [
3544
+ ...straysForDiscussion.map((f) => f.id).filter((id) => id !== ""),
3545
+ ...suppressedNits.map((f) => f.id).filter((id) => id !== ""),
3546
+ ...(findings.systemic_problems ?? []).map((s) => s.id).filter((id) => id !== void 0 && id !== "")
3547
+ ];
3548
+ const orphanResolveFailed = existingSticky !== null && broadWantsPrior && carriedFindingsMarker(existingSticky.body) !== null && resolvedPrior === null;
3549
+ const broadLive = new Set(broadCurrentIds);
3550
+ const departedIds = priorIdsFrom(resolvedPrior).filter((id) => !broadLive.has(id));
3551
+ const stickyDiscussion = existingSticky !== null ? buildStickyDiscussion(reachable, currentIds, departedIds) : { byFinding: {}, orphaned: {}, orphanedTotal: 0, truncated: {}, orphanedTruncated: {} };
3552
+ const discussionByFinding = stickyDiscussion.byFinding;
3553
+ const orphanedDiscussion = stickyDiscussion.orphaned;
3554
+ const orphanedTotal = stickyDiscussion.orphanedTotal;
3555
+ const discussionTruncated = stickyDiscussion.truncated;
3556
+ const orphanedTruncated = stickyDiscussion.orphanedTruncated;
3159
3557
  const testReport = input.testReportPath ? loadTestReport(input.testReportPath) : void 0;
3160
3558
  const clocDiff = input.clocDiffPath ? loadClocDiff(input.clocDiffPath) : void 0;
3161
3559
  const effectiveRoute = input.route ?? envelope?.route;
@@ -3167,7 +3565,13 @@ ${dropNote}` : ""}`,
3167
3565
  }
3168
3566
  if (emptyMechanicWouldBury(effectiveRoute, envelopelessIncomplete) && existingSticky !== null)
3169
3567
  await emptyMechanicLeaveOrNote(existingSticky);
3170
- const stampedFindings2 = stampConvergence(findings, priorConv);
3568
+ const stampedFindings2 = stampConvergence(
3569
+ findings,
3570
+ effectiveRoute === "mechanic" ? mechanicConvergence(
3571
+ priorConv,
3572
+ input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD
3573
+ ) : priorConv
3574
+ );
3171
3575
  const body = formatMarkdown(
3172
3576
  render({
3173
3577
  findings: stampedFindings2,
@@ -3202,7 +3606,13 @@ ${dropNote}` : ""}`,
3202
3606
  unverifiedNoLogs: input.unverifiedNoLogs,
3203
3607
  jsonUrl: input.jsonUrl,
3204
3608
  findingsPointer: findingsBlob(stampedFindings2),
3205
- postedAt: input.postedAt
3609
+ postedAt: input.postedAt,
3610
+ discussionByFinding,
3611
+ orphanedDiscussion,
3612
+ orphanedTotal,
3613
+ discussionTruncated,
3614
+ orphanedTruncated,
3615
+ orphanedUnresolvable: orphanResolveFailed
3206
3616
  })
3207
3617
  );
3208
3618
  await upsertSticky(input.repo, prNumber, existingSticky, body, ghApi);
@@ -3232,6 +3642,7 @@ ${dropNote}` : ""}`,
3232
3642
  strays,
3233
3643
  inDiff
3234
3644
  } = inlineRequested ? buildInlineComments(visibleFindings, diff, {
3645
+ ...inlinePartition !== null ? { partition: inlinePartition } : {},
3235
3646
  inlineTemplate,
3236
3647
  models: envelope.models.map((m) => m.model),
3237
3648
  findings,
@@ -3258,6 +3669,11 @@ ${dropNote}` : ""}`,
3258
3669
  roundNumber,
3259
3670
  currentCodes,
3260
3671
  input.headSha.slice(0, 12)
3672
+ ) : effectiveRoute === "mechanic" ? (
3673
+ // A mechanic pass means CI failed — whatever the carried prior says, its own stamp must
3674
+ // never read "converged". The threshold-relative critical floor keeps the carried triple
3675
+ // consistent at every threshold; null (no marker) when no prior round exists (issue #224).
3676
+ mechanicConvergence(priorConv, input.convergenceThreshold ?? DEFAULT_CONVERGENCE_THRESHOLD)
3261
3677
  ) : priorConv;
3262
3678
  const stampedFindings = stampConvergence(findings, convergence);
3263
3679
  const currentRoundCount = isRound ? roundNumber : priorRoundCount;
@@ -3279,6 +3695,12 @@ ${dropNote}` : ""}`,
3279
3695
  sameRootNotes,
3280
3696
  answeredNotes: reRaisedNotes,
3281
3697
  answeredReRaiseNote: answeredDropNote,
3698
+ discussionByFinding,
3699
+ orphanedDiscussion,
3700
+ orphanedTotal,
3701
+ discussionTruncated,
3702
+ orphanedTruncated,
3703
+ orphanedUnresolvable: orphanResolveFailed,
3282
3704
  roundCount: currentRoundCount,
3283
3705
  convergenceThreshold: input.convergenceThreshold,
3284
3706
  nitVisibilityFloor: input.nitVisibilityFloor,
@@ -3298,12 +3720,13 @@ ${dropNote}` : ""}`,
3298
3720
 
3299
3721
  > **Note:** ${String(longFiles.length)} suggestion(s) exceeded GitHub's ~10-line inline suggestion limit and were omitted from the inline comments; the affected findings remain in the review.
3300
3722
  ` : "";
3301
- const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays) => formatMarkdown(
3723
+ const renderBody = (inlineDisposition, reviewUrl2, straysOverride, unanchoredCount2, unanchoredStrays, discussionOverride) => formatMarkdown(
3302
3724
  render({
3303
3725
  ...commonRenderInput,
3304
3726
  ...straysOverride ? { strays: straysOverride } : {},
3305
3727
  ...unanchoredCount2 !== void 0 ? { unanchoredCount: unanchoredCount2 } : {},
3306
3728
  ...unanchoredStrays !== void 0 && unanchoredStrays.length > 0 ? { unanchoredStrays } : {},
3729
+ ...discussionOverride ?? {},
3307
3730
  inlineDisposition,
3308
3731
  reviewUrl: reviewUrl2
3309
3732
  }) + longFilesNote
@@ -3354,13 +3777,32 @@ ${dropNote}` : ""}`,
3354
3777
  await minimizeComments(prNumber, priorInlineComments, ghApi);
3355
3778
  const unanchoredCount = unposted.length;
3356
3779
  const finalStrays = unanchoredCount > 0 ? [...unposted, ...strays] : strays;
3780
+ const finalDiscussion = unanchoredCount > 0 ? buildStickyDiscussion(
3781
+ reachable,
3782
+ [...currentIds, ...unposted.map((f) => f.id).filter((id) => id !== "")],
3783
+ departedIds
3784
+ ) : null;
3785
+ const finalDiscussionOverride = finalDiscussion !== null ? {
3786
+ discussionByFinding: finalDiscussion.byFinding,
3787
+ orphanedDiscussion: finalDiscussion.orphaned,
3788
+ orphanedTotal: finalDiscussion.orphanedTotal,
3789
+ discussionTruncated: finalDiscussion.truncated,
3790
+ orphanedTruncated: finalDiscussion.orphanedTruncated
3791
+ } : void 0;
3357
3792
  if (stickyRef !== null && (inlinePosted > 0 || unanchoredCount > 0)) {
3358
3793
  const finalDisposition = inlinePosted > 0 ? { kind: "posted", count: inlinePosted, sha: input.headSha } : { kind: "inline-unavailable" };
3359
3794
  try {
3360
3795
  await patchComment(
3361
3796
  input.repo,
3362
3797
  stickyRef.id,
3363
- renderBody(finalDisposition, reviewUrl, finalStrays, unanchoredCount, unposted),
3798
+ renderBody(
3799
+ finalDisposition,
3800
+ reviewUrl,
3801
+ finalStrays,
3802
+ unanchoredCount,
3803
+ unposted,
3804
+ finalDiscussionOverride
3805
+ ),
3364
3806
  ghApi
3365
3807
  );
3366
3808
  process.stderr.write(
@@ -3385,7 +3827,10 @@ ${dropNote}` : ""}`,
3385
3827
  // document deliberately carries every finding, so the GitHub-rejected ones must keep their
3386
3828
  // path-only links here too (issue #231 r2). Unconditional: renderBody's own gate treats an
3387
3829
  // empty array exactly like absence, so the ternary was a duplicated decision (issue #231 r3).
3388
- unposted
3830
+ unposted,
3831
+ // The summary is the sticky's long-lived twin — a rejected finding's rebuilt discussion
3832
+ // trail renders here exactly as on the final patch.
3833
+ finalDiscussionOverride
3389
3834
  )
3390
3835
  );
3391
3836
  };
@@ -3789,8 +4234,14 @@ var IssueCommentCodec = t.intersection([
3789
4234
  author_association: t.union([t.string, t.null])
3790
4235
  })
3791
4236
  ]);
3792
- var JobCodec = t.type({ id: t.number, conclusion: t.union([t.string, t.null]) });
3793
- var JOBS_JQ = ".jobs[] | {id: .id, conclusion: .conclusion}";
4237
+ var JobCodec = t.type({
4238
+ id: t.number,
4239
+ // A null name (an API edge) degrades to a nameless entry rather than aborting the whole gather —
4240
+ // the identity the reproduction steer needs is best-effort, never load-bearing.
4241
+ name: t.union([t.string, t.null]),
4242
+ conclusion: t.union([t.string, t.null])
4243
+ });
4244
+ var JOBS_JQ = ".jobs[] | {id: .id, name: .name, conclusion: .conclusion}";
3794
4245
  var fetchPrMeta = async (repo, prNumber, ghApi) => {
3795
4246
  const stdout = await ghApi([
3796
4247
  `repos/${repo}/pulls/${String(prNumber)}`,
@@ -3951,6 +4402,13 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
3951
4402
  for (const job of selected) {
3952
4403
  try {
3953
4404
  const log = await ghApi([`repos/${repo}/actions/jobs/${String(job.id)}/logs`]);
4405
+ if (log.trim() === "no log available" || log.trim() === "") {
4406
+ process.stderr.write(
4407
+ `Warning: job ${String(job.id)} has no log available (placeholder body) \u2014 not staged
4408
+ `
4409
+ );
4410
+ continue;
4411
+ }
3954
4412
  writeFileSync(join(outDir, `job_${String(job.id)}.log`), log);
3955
4413
  staged += 1;
3956
4414
  } catch (err) {
@@ -3960,6 +4418,15 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
3960
4418
  );
3961
4419
  }
3962
4420
  }
4421
+ writeFileSync(
4422
+ join(outDir, "failed_jobs.json"),
4423
+ `${JSON.stringify(
4424
+ failing.map((j) => ({ name: j.name, conclusion: j.conclusion })),
4425
+ null,
4426
+ 2
4427
+ )}
4428
+ `
4429
+ );
3963
4430
  if (failing.length > selected.length) {
3964
4431
  process.stderr.write(
3965
4432
  `::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`)}
@@ -3967,8 +4434,11 @@ var downloadFailingJobLogs = async (repo, runId, outDir, ghApi) => {
3967
4434
  );
3968
4435
  }
3969
4436
  if (staged === 0) {
4437
+ const reported = failing.length > 0 ? `${String(failing.length)} failing job(s) reported` : "no job concluded 'failure' (timeouts or cancellations)";
3970
4438
  process.stderr.write(
3971
- `::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`)}
4439
+ `::warning::${annotationSafe(
4440
+ `No failing-job logs could be staged for run ${runId} (${reported}) \u2014 the review must reproduce the failures itself${failing.length > 0 ? " (the failing jobs are listed in failed_jobs.json)" : ""}`
4441
+ )}
3972
4442
  `
3973
4443
  );
3974
4444
  }
@@ -4029,7 +4499,7 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
4029
4499
  join(input.outDir, "prior_review.json"),
4030
4500
  prior === null ? "null" : JSON.stringify(prior)
4031
4501
  );
4032
- const seedsFromPrior = input.conclusion === "success" && prior !== null && prior.body !== null && parseReviewedRoute(prior.body) === "full review";
4502
+ const seedsFromPrior = input.conclusion === "success" && prior !== null && prior.body !== null && isFullReviewAncestry(prior.body);
4033
4503
  const resolvedPrior = seedsFromPrior ? await resolvePriorFindings(prior.body, readArtifact) : null;
4034
4504
  const priorFindings = resolvedPrior !== null && typeof resolvedPrior === "object" ? resolvedPrior : null;
4035
4505
  writeFileSync(
@@ -5220,7 +5690,7 @@ var seedDraftCmd = defineCommand({
5220
5690
  `
5221
5691
  );
5222
5692
  }
5223
- if (parsedPrior !== null && parseReviewedRoute(priorBody ?? "") === "full review") {
5693
+ if (parsedPrior !== null && isFullReviewAncestry(priorBody ?? "")) {
5224
5694
  try {
5225
5695
  const belowFloor = priorBelowFloorNits(
5226
5696
  parsedPrior,
@@ -5264,7 +5734,7 @@ var seedDraftCmd = defineCommand({
5264
5734
  })();
5265
5735
  if (seedDoc === null) return false;
5266
5736
  if (isIncompleteFindings(seedDoc)) return false;
5267
- if (parseReviewedRoute(priorBody ?? "") !== "full review") return false;
5737
+ if (!isFullReviewAncestry(priorBody ?? "")) return false;
5268
5738
  writeFileSync(outPath, SEED_SENTINEL);
5269
5739
  writeFileSync(priorContextPath(outPath), `${JSON.stringify(seedDoc, null, 2)}
5270
5740
  `);
@@ -5636,7 +6106,7 @@ var stopGateCmd = defineCommand({
5636
6106
  var gatherCmd = defineCommand({
5637
6107
  meta: {
5638
6108
  name: "gather",
5639
- description: "Resolve the PR from the CI head SHA and gather review inputs (diff with git-diff fallback, PR context, prior bot review, untrusted PR conversation to triage, failing-job logs) as files for the review agent"
6109
+ 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"
5640
6110
  },
5641
6111
  args: {
5642
6112
  repo: { type: "string", description: "Repository (owner/name)", required: true },