@jphutchins/code-review 0.1.0-alpha.55 → 0.1.0-alpha.56
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 +521 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/templates/comment.eta +44 -9
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) =>
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
`
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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:
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
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:
|
|
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
|
|
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
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
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,
|
|
3338
|
+
const [diff, commentRows] = await Promise.all([
|
|
3003
3339
|
fetchDiff(input.repo, prNumber, ghApi),
|
|
3004
|
-
|
|
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
|
-
|
|
3024
|
-
|
|
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;
|
|
@@ -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
|
|
3076
|
-
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
};
|
|
@@ -4029,7 +4474,7 @@ var gather = async (input, ghApi = runGhApi, gitRun = runGit, readArtifact = ghA
|
|
|
4029
4474
|
join(input.outDir, "prior_review.json"),
|
|
4030
4475
|
prior === null ? "null" : JSON.stringify(prior)
|
|
4031
4476
|
);
|
|
4032
|
-
const seedsFromPrior = input.conclusion === "success" && prior !== null && prior.body !== null &&
|
|
4477
|
+
const seedsFromPrior = input.conclusion === "success" && prior !== null && prior.body !== null && isFullReviewAncestry(prior.body);
|
|
4033
4478
|
const resolvedPrior = seedsFromPrior ? await resolvePriorFindings(prior.body, readArtifact) : null;
|
|
4034
4479
|
const priorFindings = resolvedPrior !== null && typeof resolvedPrior === "object" ? resolvedPrior : null;
|
|
4035
4480
|
writeFileSync(
|
|
@@ -5220,7 +5665,7 @@ var seedDraftCmd = defineCommand({
|
|
|
5220
5665
|
`
|
|
5221
5666
|
);
|
|
5222
5667
|
}
|
|
5223
|
-
if (parsedPrior !== null &&
|
|
5668
|
+
if (parsedPrior !== null && isFullReviewAncestry(priorBody ?? "")) {
|
|
5224
5669
|
try {
|
|
5225
5670
|
const belowFloor = priorBelowFloorNits(
|
|
5226
5671
|
parsedPrior,
|
|
@@ -5264,7 +5709,7 @@ var seedDraftCmd = defineCommand({
|
|
|
5264
5709
|
})();
|
|
5265
5710
|
if (seedDoc === null) return false;
|
|
5266
5711
|
if (isIncompleteFindings(seedDoc)) return false;
|
|
5267
|
-
if (
|
|
5712
|
+
if (!isFullReviewAncestry(priorBody ?? "")) return false;
|
|
5268
5713
|
writeFileSync(outPath, SEED_SENTINEL);
|
|
5269
5714
|
writeFileSync(priorContextPath(outPath), `${JSON.stringify(seedDoc, null, 2)}
|
|
5270
5715
|
`);
|