infraweaver 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/agents/claude.d.ts +1 -1
- package/dist/agents/claudePretoolGate.d.ts +0 -36
- package/dist/agents/toolPlaneWatch.d.ts +31 -0
- package/dist/cli.mjs +31908 -18736
- package/dist/external.d.ts +2 -1
- package/dist/index.js +31992 -18749
- package/dist/internal.js +133 -52
- package/dist/mcp/baseBranch.d.ts +41 -0
- package/dist/mcp/changeSummary.d.ts +1 -1
- package/dist/mcp/findingAudit.d.ts +2 -0
- package/dist/mcp/guardrails.d.ts +38 -0
- package/dist/mcp/localContext.d.ts +1 -1
- package/dist/mcp/moduleExtraction.d.ts +5 -0
- package/dist/mcp/pr.d.ts +0 -31
- package/dist/mcp/reproveSuggestions.d.ts +27 -0
- package/dist/mcp/review.d.ts +126 -2
- package/dist/mcp/reviewCommentLimit.d.ts +17 -0
- package/dist/mcp/reviewComments.d.ts +9 -5
- package/dist/mcp/reviewDedup.d.ts +1 -0
- package/dist/mcp/reviewMarkers.d.ts +10 -0
- package/dist/mcp/reviewProvenance.d.ts +20 -6
- package/dist/mcp/shared.d.ts +15 -0
- package/dist/mcp/suggestionProver.d.ts +3 -2
- package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
- package/dist/mcp/terraform/consolidation.d.ts +26 -0
- package/dist/mcp/terraform/cost.d.ts +2 -2
- package/dist/mcp/terraform/currency.d.ts +11 -0
- package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
- package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
- package/dist/mcp/terraform/evidence.d.ts +10 -0
- package/dist/mcp/terraform/gateScan.d.ts +2 -16
- package/dist/mcp/terraform/hcl.d.ts +15 -0
- package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
- package/dist/mcp/terraform/localModules.d.ts +29 -0
- package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
- package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
- package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
- package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
- package/dist/mcp/terraform/refactor/references.d.ts +15 -0
- package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
- package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
- package/dist/mcp/terraform/scanDelta.d.ts +28 -0
- package/dist/mcp/terraform/scanSession.d.ts +13 -0
- package/dist/mcp/terraform/scanners.d.ts +67 -18
- package/dist/mcp/terraform/secretScan.d.ts +19 -0
- package/dist/mcp/terraform/standardsReport.d.ts +1 -1
- package/dist/mcp/terraform/tools/plan.d.ts +1 -1
- package/dist/mcp/terraform/tools/scan.d.ts +7 -7
- package/dist/mcp/terraform/tools/validate.d.ts +7 -2
- package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
- package/dist/mcp/terraform/treeLayout.d.ts +57 -0
- package/dist/mcp/terraform/types.d.ts +1 -1
- package/dist/phases/applyPlan.d.ts +0 -2
- package/dist/toolState.d.ts +3 -0
- package/dist/utils/agent/codexOAuth.d.ts +1 -1
- package/dist/utils/cli.d.ts +1 -7
- package/dist/utils/cloud/cloudReport.d.ts +1 -1
- package/dist/utils/config/baseRefConfig.d.ts +1 -1
- package/dist/utils/config/infraweaverConfig.d.ts +1 -1
- package/dist/utils/config/payload.d.ts +5 -0
- package/dist/utils/markdownTable.d.ts +6 -0
- package/dist/utils/setup/toolLicensing.d.ts +1 -1
- package/dist/utils/unifiedDiff.d.ts +69 -0
- package/package.json +34 -28
- package/src/agents/claude.ts +48 -13
- package/src/agents/claudePretoolGate.ts +9 -17
- package/src/agents/sessionLabeler.ts +3 -3
- package/src/agents/toolPlaneWatch.ts +71 -0
- package/src/external.ts +3 -1
- package/src/mcp/assess.ts +8 -8
- package/src/mcp/baseBranch.ts +96 -0
- package/src/mcp/changeSummary.ts +10 -11
- package/src/mcp/checkSuite.ts +7 -10
- package/src/mcp/checkout.ts +13 -40
- package/src/mcp/crosswalk.ts +5 -0
- package/src/mcp/findingAudit.ts +1 -1
- package/src/mcp/git.ts +44 -2
- package/src/mcp/guardrails.ts +188 -36
- package/src/mcp/localContext.ts +2 -0
- package/src/mcp/localServer.ts +1 -0
- package/src/mcp/moduleExtraction.ts +17 -5
- package/src/mcp/moduleTests.ts +9 -2
- package/src/mcp/modules.ts +10 -7
- package/src/mcp/pr.ts +17 -65
- package/src/mcp/reproveSuggestions.ts +86 -0
- package/src/mcp/review.ts +249 -75
- package/src/mcp/reviewAnchor.ts +11 -23
- package/src/mcp/reviewCommentLimit.ts +79 -0
- package/src/mcp/reviewComments.ts +34 -81
- package/src/mcp/reviewDedup.ts +1 -1
- package/src/mcp/reviewMarkers.ts +25 -2
- package/src/mcp/reviewProvenance.ts +56 -21
- package/src/mcp/roots.ts +3 -2
- package/src/mcp/server.ts +2 -1
- package/src/mcp/shared.ts +40 -1
- package/src/mcp/shell.ts +27 -28
- package/src/mcp/suggestionProver.ts +8 -4
- package/src/mcp/terraform/checkovSeverities.ts +1156 -0
- package/src/mcp/terraform/concernResult.ts +2 -1
- package/src/mcp/terraform/consolidation.ts +232 -19
- package/src/mcp/terraform/cost.ts +16 -7
- package/src/mcp/terraform/cspm.ts +8 -4
- package/src/mcp/terraform/currency.ts +28 -12
- package/src/mcp/terraform/decisions.ts +1 -1
- package/src/mcp/terraform/deltaSummary.ts +50 -1
- package/src/mcp/terraform/emittedArtefacts.ts +63 -0
- package/src/mcp/terraform/evidence.ts +23 -3
- package/src/mcp/terraform/gateScan.ts +191 -128
- package/src/mcp/terraform/hcl.ts +27 -2
- package/src/mcp/terraform/idleFloor.ts +2 -1
- package/src/mcp/terraform/initArtefacts.ts +76 -0
- package/src/mcp/terraform/localModules.ts +112 -0
- package/src/mcp/terraform/moduleDocs.ts +2 -5
- package/src/mcp/terraform/moduleFindings.ts +102 -0
- package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
- package/src/mcp/terraform/planPairs.ts +2 -20
- package/src/mcp/terraform/policyGate.ts +0 -15
- package/src/mcp/terraform/refactor/attribution.ts +39 -5
- package/src/mcp/terraform/refactor/equivalence.ts +49 -10
- package/src/mcp/terraform/refactor/expressions.ts +323 -0
- package/src/mcp/terraform/refactor/references.ts +101 -0
- package/src/mcp/terraform/refactor/resources.ts +79 -7
- package/src/mcp/terraform/refactor/substitution.ts +852 -256
- package/src/mcp/terraform/scanDelta.ts +41 -0
- package/src/mcp/terraform/scanSession.ts +30 -0
- package/src/mcp/terraform/scannerCache.ts +4 -0
- package/src/mcp/terraform/scanners.ts +553 -233
- package/src/mcp/terraform/secretScan.ts +131 -0
- package/src/mcp/terraform/standardsReport.ts +14 -13
- package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
- package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
- package/src/mcp/terraform/tools/emitOscal.ts +3 -2
- package/src/mcp/terraform/tools/emitVex.ts +10 -4
- package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
- package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
- package/src/mcp/terraform/tools/plan.ts +30 -17
- package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
- package/src/mcp/terraform/tools/scan.ts +5 -3
- package/src/mcp/terraform/tools/standardsReport.ts +1 -1
- package/src/mcp/terraform/tools/validate.ts +20 -5
- package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
- package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
- package/src/mcp/terraform/treeLayout.ts +378 -0
- package/src/mcp/terraform/types.ts +4 -1
- package/src/mcp/terraform/versionRequirements.ts +11 -2
- package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
- package/src/modes/modernize-deprecated.ts +2 -2
- package/src/modes/refactor.ts +9 -9
- package/src/modes/remediate.ts +1 -1
- package/src/modes/terraform-code-review.ts +7 -3
- package/src/modes/update-dependencies.ts +6 -6
- package/src/phases/applyPlan.ts +33 -14
- package/src/phases/applySuggestions.ts +2 -2
- package/src/phases/runAgentWithWatchdogs.ts +2 -1
- package/src/toolState.ts +7 -0
- package/src/utils/agent/codexOAuth.ts +7 -7
- package/src/utils/agent/openCodeModels.ts +4 -6
- package/src/utils/cli.ts +1 -31
- package/src/utils/cloud/runContext.ts +1 -8
- package/src/utils/config/baseRefConfig.ts +6 -0
- package/src/utils/config/infraweaverConfig.ts +2 -0
- package/src/utils/config/payload.ts +41 -0
- package/src/utils/github/assets.ts +1 -6
- package/src/utils/github/github.ts +42 -150
- package/src/utils/github/token.ts +6 -11
- package/src/utils/log.ts +4 -5
- package/src/utils/markdownTable.ts +11 -0
- package/src/utils/prompt/changeImpact.ts +18 -26
- package/src/utils/prompt/instructions.ts +1 -1
- package/src/utils/prompt/promptDirectives.ts +1 -1
- package/src/utils/prompt/toolSelection.ts +11 -2
- package/src/utils/setup/toolLicensing.ts +7 -0
- package/src/utils/telemetry.ts +2 -1
- package/src/utils/unifiedDiff.ts +120 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-proving the open, proven suggestions a later push made stale.
|
|
3
|
+
*
|
|
4
|
+
* A proof holds for the commit it was made on, so the apply command commits a
|
|
5
|
+
* suggestion only at that head. After a push, the review's restatement of the
|
|
6
|
+
* same finding is withheld as a duplicate of its open thread — so without this
|
|
7
|
+
* step a proven suggestion could never be applied again once anything else was
|
|
8
|
+
* pushed.
|
|
9
|
+
*
|
|
10
|
+
* Each such thread's OWN suggestion is re-proved, by the same prover, at the
|
|
11
|
+
* thread's current lines on the new head. When it still holds, the thread gets
|
|
12
|
+
* a reply carrying a `reproof` marker for this head and those lines, which is
|
|
13
|
+
* what the apply command honours. One that no longer holds gets no reply, and
|
|
14
|
+
* apply keeps refusing it.
|
|
15
|
+
*/
|
|
16
|
+
import type { OpenInfraweaverThread } from "#app/mcp/reviewComments";
|
|
17
|
+
import { renderCommentMarker, suggestionText } from "#app/mcp/reviewMarkers";
|
|
18
|
+
import type { SuggestionCandidate, SuggestionProof } from "#app/mcp/suggestionProver";
|
|
19
|
+
import { findingKey, type GateDelta } from "#app/mcp/terraform/scanDelta";
|
|
20
|
+
import { log } from "#app/utils/cli";
|
|
21
|
+
import { APPLY_COMMAND } from "#app/utils/github/applyCommand";
|
|
22
|
+
|
|
23
|
+
/** Returns how many threads were re-proved and replied to. */
|
|
24
|
+
export async function reproveStaleSuggestions(input: {
|
|
25
|
+
threads: readonly OpenInfraweaverThread[];
|
|
26
|
+
delta: GateDelta;
|
|
27
|
+
/** the head the review is submitted at */
|
|
28
|
+
headSha: string;
|
|
29
|
+
prove: (candidates: SuggestionCandidate[]) => Promise<SuggestionProof[]>;
|
|
30
|
+
reply: (rootCommentId: number, body: string) => Promise<void>;
|
|
31
|
+
}): Promise<number> {
|
|
32
|
+
const stale: { rootCommentId: number; candidate: SuggestionCandidate }[] = [];
|
|
33
|
+
for (const thread of input.threads) {
|
|
34
|
+
const marker = thread.marker;
|
|
35
|
+
if (marker?.kind !== "finding" || !marker.proven) continue;
|
|
36
|
+
// proved on this head already, or of unknown origin
|
|
37
|
+
if (!thread.rootCommit || thread.rootCommit === input.headSha) continue;
|
|
38
|
+
// outdated (no current lines), or not on the new code
|
|
39
|
+
if (thread.span?.side !== "RIGHT" || thread.rootCommentId === undefined) continue;
|
|
40
|
+
const suggestion = thread.rootBody ? suggestionText(thread.rootBody) : undefined;
|
|
41
|
+
if (suggestion === undefined) continue;
|
|
42
|
+
// a finding the PR no longer introduces has its thread resolved, not re-proved
|
|
43
|
+
const finding = input.delta.introduced.find((f) => findingKey(f) === marker.key);
|
|
44
|
+
if (!finding) continue;
|
|
45
|
+
stale.push({
|
|
46
|
+
rootCommentId: thread.rootCommentId,
|
|
47
|
+
candidate: {
|
|
48
|
+
index: stale.length,
|
|
49
|
+
path: thread.span.path,
|
|
50
|
+
startLine: thread.span.startLine,
|
|
51
|
+
line: thread.span.line,
|
|
52
|
+
side: "RIGHT",
|
|
53
|
+
suggestion,
|
|
54
|
+
finding,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (stale.length === 0) return 0;
|
|
59
|
+
|
|
60
|
+
let reproved = 0;
|
|
61
|
+
for (const proof of await input.prove(stale.map((s) => s.candidate))) {
|
|
62
|
+
const entry = stale[proof.index];
|
|
63
|
+
log.info(
|
|
64
|
+
`» suggestion re-proof: thread ${entry?.rootCommentId ?? "?"} ${proof.proven ? "still proven" : "not proven"} — ${proof.reason}`,
|
|
65
|
+
);
|
|
66
|
+
if (!entry || !proof.proven) continue;
|
|
67
|
+
const marker = renderCommentMarker({
|
|
68
|
+
kind: "reproof",
|
|
69
|
+
head: input.headSha,
|
|
70
|
+
startLine: entry.candidate.startLine,
|
|
71
|
+
line: entry.candidate.line,
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
await input.reply(
|
|
75
|
+
entry.rootCommentId,
|
|
76
|
+
`✅ Still proven by re-scan on \`${input.headSha.slice(0, 7)}\` — comment \`${APPLY_COMMAND}\` to commit it.\n\n${marker}`,
|
|
77
|
+
);
|
|
78
|
+
reproved += 1;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
log.warning(
|
|
81
|
+
`» suggestion re-proof: could not reply on thread ${entry.rootCommentId} — ${error}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return reproved;
|
|
86
|
+
}
|
package/src/mcp/review.ts
CHANGED
|
@@ -20,9 +20,12 @@ import {
|
|
|
20
20
|
claimsBlockingSeverity,
|
|
21
21
|
FindingAudit,
|
|
22
22
|
formatWithheldFindingsNote,
|
|
23
|
+
leadingMarker,
|
|
23
24
|
type WithheldFinding,
|
|
24
25
|
} from "#app/mcp/findingAudit";
|
|
26
|
+
import { reproveStaleSuggestions } from "#app/mcp/reproveSuggestions";
|
|
25
27
|
import { formatReanchoredNote, patchLinesBySide, reanchorComments } from "#app/mcp/reviewAnchor";
|
|
28
|
+
import { formatOverflowNote, limitInlineComments } from "#app/mcp/reviewCommentLimit";
|
|
26
29
|
import { listOpenInfraweaverThreads, type OpenInfraweaverThread } from "#app/mcp/reviewComments";
|
|
27
30
|
import {
|
|
28
31
|
type DuplicateComment,
|
|
@@ -34,7 +37,7 @@ import { renderCommentMarker } from "#app/mcp/reviewMarkers";
|
|
|
34
37
|
import {
|
|
35
38
|
type CommentKind,
|
|
36
39
|
classifyComments,
|
|
37
|
-
Dismissal,
|
|
40
|
+
type Dismissal,
|
|
38
41
|
provenanceLine,
|
|
39
42
|
renderReviewSummarySection,
|
|
40
43
|
renderReviewVerdict,
|
|
@@ -83,7 +86,7 @@ function getHttpStatus(err: unknown): number | undefined {
|
|
|
83
86
|
*/
|
|
84
87
|
export function isTransientReviewError(err: unknown): boolean {
|
|
85
88
|
if (getHttpStatus(err) !== 422) return false;
|
|
86
|
-
const msg =
|
|
89
|
+
const msg = errorMessage(err);
|
|
87
90
|
return /internal error occurred, please try again/i.test(msg);
|
|
88
91
|
}
|
|
89
92
|
|
|
@@ -410,6 +413,64 @@ export function formatDroppedCommentsNote(dropped: DroppedComment[]): string {
|
|
|
410
413
|
);
|
|
411
414
|
}
|
|
412
415
|
|
|
416
|
+
/**
|
|
417
|
+
* A dismissal as both review tools take it: one finding or several sharing a
|
|
418
|
+
* reason. Each tool accepts both forms, so a model that learnt one from the
|
|
419
|
+
* other is not rejected for it.
|
|
420
|
+
*/
|
|
421
|
+
const DismissalInput = type({
|
|
422
|
+
"finding_id?": type.string.describe("the `finding_id` from review_scan_delta"),
|
|
423
|
+
"finding_ids?": type.string
|
|
424
|
+
.array()
|
|
425
|
+
.describe("several `finding_id`s from review_scan_delta that share this reason"),
|
|
426
|
+
reason: type.string.describe(
|
|
427
|
+
"why this introduced finding needs no comment — e.g. mitigated by a named sibling resource, or a false positive for a stated reason. Shown to the author in the review summary.",
|
|
428
|
+
),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/** One inline comment of a review, as `create_pull_request_review` and `stage_review` take it. */
|
|
432
|
+
const ReviewComment = type({
|
|
433
|
+
path: type.string.describe(
|
|
434
|
+
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff.",
|
|
435
|
+
),
|
|
436
|
+
line: type.number.describe(
|
|
437
|
+
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format. Must sit inside a `@@` hunk in the PR diff — anchors on context-only or untouched lines are dropped silently (the rest of the review still posts; dropped entries are reported under `droppedComments` in the response).",
|
|
438
|
+
),
|
|
439
|
+
side: type
|
|
440
|
+
.enumerated("LEFT", "RIGHT")
|
|
441
|
+
.describe(
|
|
442
|
+
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT.",
|
|
443
|
+
)
|
|
444
|
+
.optional(),
|
|
445
|
+
body: type.string
|
|
446
|
+
.describe("Explanatory comment text (optional if suggestion is provided)")
|
|
447
|
+
.optional(),
|
|
448
|
+
suggestion: type.string
|
|
449
|
+
.describe(
|
|
450
|
+
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code.",
|
|
451
|
+
)
|
|
452
|
+
.optional(),
|
|
453
|
+
start_line: type.number
|
|
454
|
+
.describe(
|
|
455
|
+
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces. Both `start_line` and `line` must sit inside the same `@@` hunk — a `start_line` outside the hunk causes the whole comment to be dropped even when `line` is valid. If you need to comment on context just above/below a hunk, shrink the range to a single line that is provably modified.",
|
|
456
|
+
)
|
|
457
|
+
.optional(),
|
|
458
|
+
existing_code: type.string
|
|
459
|
+
.describe(
|
|
460
|
+
"The exact text of the lines [start_line, line], copied verbatim from the diff. ALWAYS pass this: it is the recovery path for a wrong line number. If the anchor misses the diff, the comment is re-anchored to where this text occurs, so the finding posts instead of being dropped. Recovery needs the text to be unique in that file's diff; a snippet matching several places is ambiguous and drops as before.",
|
|
461
|
+
)
|
|
462
|
+
.optional(),
|
|
463
|
+
finding_id: type.string
|
|
464
|
+
.describe(
|
|
465
|
+
"Customer-PR reviews only: the `finding_id` of the introduced finding (from review_scan_delta) this comment explains. It makes the comment SCANNER-BACKED — the tool adds the scanner, rule and controls, and re-scans any `suggestion` before offering it as committable. Omit it for a finding the scanners did not raise; that comment is labelled a review judgement.",
|
|
466
|
+
)
|
|
467
|
+
.optional(),
|
|
468
|
+
audit: FindingAudit.describe(
|
|
469
|
+
"REQUIRED for any 🚨/⚠️ finding: the pre-post audit, filled from your verification pass. The tool computes the severity from these inputs and puts its marker (🚨 critical/high, ⚠️ medium, ℹ️ low) at the front of the comment — do not rely on the one you wrote. A finding that FAILS a coherence rule, or cites a resource the file does not declare, is withheld and listed in a collapsed block instead of posted. Omit for nits and ℹ️ notes.",
|
|
470
|
+
).optional(),
|
|
471
|
+
});
|
|
472
|
+
type SubmittedComment = typeof ReviewComment.infer;
|
|
473
|
+
|
|
413
474
|
// one-shot review tool
|
|
414
475
|
const CreatePullRequestReview = type({
|
|
415
476
|
pull_number: type.number.describe("The pull request number to review"),
|
|
@@ -436,60 +497,108 @@ const CreatePullRequestReview = type({
|
|
|
436
497
|
"Optional SHA of the commit being reviewed. Defaults to latest. Must be the FULL 40-character SHA — abbreviated SHAs are rejected by GitHub with `422 Unprocessable Entity`. The PR-synchronize event payload's `head_sha` is already full-length.",
|
|
437
498
|
)
|
|
438
499
|
.optional(),
|
|
439
|
-
comments:
|
|
440
|
-
path: type.string.describe(
|
|
441
|
-
"The file path to comment on (relative to repo root). Must be a file that appears in the PR diff.",
|
|
442
|
-
),
|
|
443
|
-
line: type.number.describe(
|
|
444
|
-
"Line number to comment on. For multi-line ranges, this is the end line. Use NEW column from diff format. Must sit inside a `@@` hunk in the PR diff — anchors on context-only or untouched lines are dropped silently (the rest of the review still posts; dropped entries are reported under `droppedComments` in the response).",
|
|
445
|
-
),
|
|
446
|
-
side: type
|
|
447
|
-
.enumerated("LEFT", "RIGHT")
|
|
448
|
-
.describe(
|
|
449
|
-
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT.",
|
|
450
|
-
)
|
|
451
|
-
.optional(),
|
|
452
|
-
body: type.string
|
|
453
|
-
.describe("Explanatory comment text (optional if suggestion is provided)")
|
|
454
|
-
.optional(),
|
|
455
|
-
suggestion: type.string
|
|
456
|
-
.describe(
|
|
457
|
-
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code.",
|
|
458
|
-
)
|
|
459
|
-
.optional(),
|
|
460
|
-
start_line: type.number
|
|
461
|
-
.describe(
|
|
462
|
-
"Start line for multi-line comment ranges. Omit for single-line comments. The range [start_line, line] defines which lines a suggestion replaces. Both `start_line` and `line` must sit inside the same `@@` hunk — a `start_line` outside the hunk causes the whole comment to be dropped even when `line` is valid. If you need to comment on context just above/below a hunk, shrink the range to a single line that is provably modified.",
|
|
463
|
-
)
|
|
464
|
-
.optional(),
|
|
465
|
-
existing_code: type.string
|
|
466
|
-
.describe(
|
|
467
|
-
"The exact text of the lines [start_line, line], copied verbatim from the diff. ALWAYS pass this: it is the recovery path for a wrong line number. If the anchor misses the diff, the comment is re-anchored to where this text occurs, so the finding posts instead of being dropped. Recovery needs the text to be unique in that file's diff; a snippet matching several places is ambiguous and drops as before.",
|
|
468
|
-
)
|
|
469
|
-
.optional(),
|
|
470
|
-
finding_id: type.string
|
|
471
|
-
.describe(
|
|
472
|
-
"Customer-PR reviews only: the `finding_id` of the introduced finding (from review_scan_delta) this comment explains. It makes the comment SCANNER-BACKED — the tool adds the scanner, rule and controls, and re-scans any `suggestion` before offering it as committable. Omit it for a finding the scanners did not raise; that comment is labelled a review judgement.",
|
|
473
|
-
)
|
|
474
|
-
.optional(),
|
|
475
|
-
audit: FindingAudit.describe(
|
|
476
|
-
"REQUIRED for any 🚨/⚠️ finding: the pre-post audit, filled from your verification pass. The tool computes the severity from these inputs and puts its marker (🚨 critical/high, ⚠️ medium, ℹ️ low) at the front of the comment — do not rely on the one you wrote. A finding that FAILS a coherence rule, or cites a resource the file does not declare, is withheld and listed in a collapsed block instead of posted. Omit for nits and ℹ️ notes.",
|
|
477
|
-
).optional(),
|
|
478
|
-
})
|
|
479
|
-
.array()
|
|
500
|
+
comments: ReviewComment.array()
|
|
480
501
|
.describe(
|
|
481
502
|
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead.",
|
|
482
503
|
)
|
|
483
504
|
.optional(),
|
|
484
|
-
dismissed:
|
|
505
|
+
dismissed: DismissalInput.array()
|
|
485
506
|
.describe(
|
|
486
507
|
"Customer-PR reviews only: introduced findings (from review_scan_delta) you decided need no comment, each with the reason. Every introduced finding must be either cited by a comment's `finding_id` or listed here; the review summary shows the reasons to the author.",
|
|
487
508
|
)
|
|
488
509
|
.optional(),
|
|
489
510
|
});
|
|
490
511
|
|
|
512
|
+
/** Comments and dismissals `stage_review` holds for the next submission on one PR. */
|
|
513
|
+
export interface StagedReview {
|
|
514
|
+
pullNumber: number;
|
|
515
|
+
comments: SubmittedComment[];
|
|
516
|
+
dismissed: Dismissal[];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Each comment once, in the order first sent. */
|
|
520
|
+
function uniqueComments(comments: SubmittedComment[]): SubmittedComment[] {
|
|
521
|
+
const seen = new Set<string>();
|
|
522
|
+
return comments.filter((c) => {
|
|
523
|
+
const key = JSON.stringify([
|
|
524
|
+
c.path,
|
|
525
|
+
c.line,
|
|
526
|
+
c.start_line,
|
|
527
|
+
c.side,
|
|
528
|
+
c.body,
|
|
529
|
+
c.suggestion,
|
|
530
|
+
c.finding_id,
|
|
531
|
+
]);
|
|
532
|
+
if (seen.has(key)) return false;
|
|
533
|
+
seen.add(key);
|
|
534
|
+
return true;
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* One dismissal per finding, a later reason replacing an earlier one; an entry
|
|
540
|
+
* naming several findings stands for one dismissal of each.
|
|
541
|
+
*/
|
|
542
|
+
function uniqueDismissals(dismissed: (typeof DismissalInput.infer)[]): Dismissal[] {
|
|
543
|
+
const each = dismissed.flatMap((d) =>
|
|
544
|
+
[...(d.finding_id ? [d.finding_id] : []), ...(d.finding_ids ?? [])].map((id) => ({
|
|
545
|
+
finding_id: id,
|
|
546
|
+
reason: d.reason,
|
|
547
|
+
})),
|
|
548
|
+
);
|
|
549
|
+
return [...new Map(each.map((d) => [d.finding_id, d])).values()];
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const StageReview = type({
|
|
553
|
+
pull_number: type.number.describe("The pull request number the review is for"),
|
|
554
|
+
comments: ReviewComment.array()
|
|
555
|
+
.describe("Inline comments to add, exactly as create_pull_request_review takes them.")
|
|
556
|
+
.optional(),
|
|
557
|
+
dismissed: DismissalInput.array()
|
|
558
|
+
.describe(
|
|
559
|
+
"Customer-PR reviews only: introduced findings that need no comment. One entry can dismiss several findings with the same reason.",
|
|
560
|
+
)
|
|
561
|
+
.optional(),
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
export function StageReviewTool(ctx: GitHubWriteToolContext) {
|
|
565
|
+
return tool({
|
|
566
|
+
name: "stage_review",
|
|
567
|
+
description:
|
|
568
|
+
"Stage inline comments and dismissals for the review, in batches, before submitting it with create_pull_request_review. " +
|
|
569
|
+
"Use it whenever the review has more than about 10 comments or dismissals: send them in calls of up to 10, then call create_pull_request_review with the body — the staged items are submitted with it, so do not send them again. " +
|
|
570
|
+
"A malformed or rejected call then costs one batch, not the whole review. Nothing is posted until create_pull_request_review. " +
|
|
571
|
+
"Returns how many items are staged and, on a customer-PR review, the introduced findings still neither commented on nor dismissed.",
|
|
572
|
+
parameters: StageReview,
|
|
573
|
+
execute: execute(async ({ pull_number, comments = [], dismissed = [] }) => {
|
|
574
|
+
assertTargetInScope(ctx, pull_number, "stage a review on");
|
|
575
|
+
const current = ctx.toolState.stagedReview;
|
|
576
|
+
const staged: StagedReview =
|
|
577
|
+
current?.pullNumber === pull_number
|
|
578
|
+
? current
|
|
579
|
+
: { pullNumber: pull_number, comments: [], dismissed: [] };
|
|
580
|
+
staged.comments = uniqueComments([...staged.comments, ...comments]);
|
|
581
|
+
staged.dismissed = uniqueDismissals([...staged.dismissed, ...dismissed]);
|
|
582
|
+
ctx.toolState.stagedReview = staged;
|
|
583
|
+
const delta = scanSessionOf(ctx.toolState).gateDelta;
|
|
584
|
+
const cited = staged.comments.flatMap((c) => (c.finding_id ? [c.finding_id] : []));
|
|
585
|
+
return {
|
|
586
|
+
success: true,
|
|
587
|
+
staged: { comments: staged.comments.length, dismissed: staged.dismissed.length },
|
|
588
|
+
...(delta
|
|
589
|
+
? {
|
|
590
|
+
unaddressed_finding_ids: unaddressedFindings(delta, cited, staged.dismissed).map(
|
|
591
|
+
(f) => f.concern.id,
|
|
592
|
+
),
|
|
593
|
+
}
|
|
594
|
+
: {}),
|
|
595
|
+
};
|
|
596
|
+
}),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
491
600
|
/** A review comment as submitted, with the kind a customer-PR review gives it. */
|
|
492
|
-
type KindedComment =
|
|
601
|
+
type KindedComment = SubmittedComment & {
|
|
493
602
|
provenance?: CommentKind;
|
|
494
603
|
};
|
|
495
604
|
|
|
@@ -509,17 +618,23 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
509
618
|
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
|
|
510
619
|
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }` +
|
|
511
620
|
" CONSTRAINT: Inline comments can ONLY target files and lines that appear in the PR diff." +
|
|
512
|
-
" Comments anchored outside a diff hunk are dropped automatically (with a note appended to the review body) — the rest of the review still posts."
|
|
621
|
+
" Comments anchored outside a diff hunk are dropped automatically (with a note appended to the review body) — the rest of the review still posts." +
|
|
622
|
+
" Never trim or merge comments to fit a comment limit: the tool applies the repo's `review_comment_limit` itself after you submit, keeping the most severe comments inline and listing the rest in the review body, so write every comment the review needs.",
|
|
513
623
|
parameters: CreatePullRequestReview,
|
|
514
624
|
execute: execute(async (reviewInput) => {
|
|
515
|
-
const {
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
625
|
+
const { pull_number, approved, request_changes, commit_id } = reviewInput;
|
|
626
|
+
// what stage_review staged comes first; a comment or dismissal sent both
|
|
627
|
+
// ways is submitted once
|
|
628
|
+
const staged = ctx.toolState.stagedReview;
|
|
629
|
+
const fromStage = staged?.pullNumber === pull_number ? staged : undefined;
|
|
630
|
+
const comments = uniqueComments([
|
|
631
|
+
...(fromStage?.comments ?? []),
|
|
632
|
+
...(reviewInput.comments ?? []),
|
|
633
|
+
]);
|
|
634
|
+
const dismissed = uniqueDismissals([
|
|
635
|
+
...(fromStage?.dismissed ?? []),
|
|
636
|
+
...(reviewInput.dismissed ?? []),
|
|
637
|
+
]);
|
|
523
638
|
// a customer-PR review: the engine scanned the PR's base and head before
|
|
524
639
|
// the agent started. every comment is then scanner-backed or a judgement,
|
|
525
640
|
// suggestions are re-scanned before they can be committed, and the body
|
|
@@ -797,7 +912,11 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
797
912
|
}
|
|
798
913
|
: { kind: "judgement" },
|
|
799
914
|
);
|
|
800
|
-
|
|
915
|
+
const provenance = provenanceLine(comment.provenance, {
|
|
916
|
+
requiredControls: ctx.payload.requiredControls ?? [],
|
|
917
|
+
reviewerMarker: leadingMarker(comment.body ?? ""),
|
|
918
|
+
});
|
|
919
|
+
commentBody = `${commentBody}\n\n${provenance}\n${marker}`;
|
|
801
920
|
}
|
|
802
921
|
const side = comment.side || "RIGHT";
|
|
803
922
|
const reviewComment: ReviewComment = {
|
|
@@ -820,6 +939,19 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
820
939
|
// letting GitHub 422 and sink the whole review.
|
|
821
940
|
let droppedComments: DroppedComment[] = [];
|
|
822
941
|
let duplicateComments: DuplicateComment[] = [];
|
|
942
|
+
let overflowComments: ReviewComment[] = [];
|
|
943
|
+
// Fails SAFE: an open thread's lines are trusted only on a POSITIVE match.
|
|
944
|
+
// An unknown head, or no `checkoutSha` to compare it against (a run that
|
|
945
|
+
// never called `checkout_pr` and supplied its own `commit_id`), both mean
|
|
946
|
+
// we cannot prove the two numberings agree. The comments are numbered
|
|
947
|
+
// against the commit the review is SUBMITTED at, so a caller-supplied
|
|
948
|
+
// `commit_id` other than the head (e.g. an older `head_sha` from the event
|
|
949
|
+
// payload) is the same mismatch even when the checkout is current.
|
|
950
|
+
const anchorsAreCurrent =
|
|
951
|
+
latestHeadSha !== undefined &&
|
|
952
|
+
ctx.toolState.checkoutSha !== undefined &&
|
|
953
|
+
latestHeadSha === ctx.toolState.checkoutSha &&
|
|
954
|
+
params.commit_id === latestHeadSha;
|
|
823
955
|
if (reviewComments.length > 0 && commentableMap) {
|
|
824
956
|
const validation = validateInlineComments(reviewComments, commentableMap);
|
|
825
957
|
droppedComments = validation.dropped;
|
|
@@ -845,21 +977,9 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
845
977
|
// the same number means a different line, and a genuinely new finding
|
|
846
978
|
// could be withheld as a duplicate of an unrelated thread. Suppressing a
|
|
847
979
|
// real finding is the one outcome worse than a repeated comment, so on a
|
|
848
|
-
// mid-run push dedup stands down
|
|
980
|
+
// mid-run push dedup stands down — withholding a real finding is worse
|
|
981
|
+
// than repeating one.
|
|
849
982
|
let spans: ThreadSpan[] = [];
|
|
850
|
-
// Fails SAFE: dedup runs only on a POSITIVE match. An unknown head, or
|
|
851
|
-
// no `checkoutSha` to compare it against (a run that never called
|
|
852
|
-
// `checkout_pr` and supplied its own `commit_id`), both mean we cannot
|
|
853
|
-
// prove the two numberings agree — and withholding a real finding is
|
|
854
|
-
// worse than repeating one. The comments are numbered against the
|
|
855
|
-
// commit the review is SUBMITTED at, so a caller-supplied `commit_id`
|
|
856
|
-
// other than the head (e.g. an older `head_sha` from the event payload)
|
|
857
|
-
// is the same mismatch even when the checkout is current.
|
|
858
|
-
const anchorsAreCurrent =
|
|
859
|
-
latestHeadSha !== undefined &&
|
|
860
|
-
ctx.toolState.checkoutSha !== undefined &&
|
|
861
|
-
latestHeadSha === ctx.toolState.checkoutSha &&
|
|
862
|
-
params.commit_id === latestHeadSha;
|
|
863
983
|
if (!anchorsAreCurrent) {
|
|
864
984
|
log.info(
|
|
865
985
|
"cannot confirm the reviewed diff matches the PR head — posting without duplicate suppression (open-thread line numbers may not line up with these comments)",
|
|
@@ -880,9 +1000,19 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
880
1000
|
`withholding ${duplicateComments.length}/${reviewComments.length} inline comment(s) that duplicate open review threads`,
|
|
881
1001
|
);
|
|
882
1002
|
}
|
|
1003
|
+
// then the per-review limit: the most important comments stay inline,
|
|
1004
|
+
// the rest are listed in the body (see reviewCommentLimit.ts). After
|
|
1005
|
+
// dedup, so a withheld duplicate never takes a slot.
|
|
1006
|
+
const limited = limitInlineComments(deduped.kept, ctx.payload.reviewCommentLimit);
|
|
1007
|
+
overflowComments = limited.overflow;
|
|
1008
|
+
if (overflowComments.length > 0) {
|
|
1009
|
+
log.info(
|
|
1010
|
+
`listing ${overflowComments.length} inline comment(s) in the review body — past review_comment_limit (${ctx.payload.reviewCommentLimit})`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
883
1013
|
// always reassign so all-dropped reviews leave params.comments empty
|
|
884
1014
|
// instead of carrying the original invalid set (which would 422).
|
|
885
|
-
params.comments =
|
|
1015
|
+
params.comments = limited.kept;
|
|
886
1016
|
}
|
|
887
1017
|
|
|
888
1018
|
// if we dropped comments, surface them in the review body so the
|
|
@@ -932,14 +1062,56 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
932
1062
|
const note = formatDuplicateCommentsNote(duplicateComments);
|
|
933
1063
|
body = body ? body + note : note.replace(/^\n\n/, "");
|
|
934
1064
|
}
|
|
1065
|
+
if (overflowComments.length > 0) {
|
|
1066
|
+
const note = formatOverflowNote(overflowComments, ctx.payload.reviewCommentLimit);
|
|
1067
|
+
body = body ? body + note : note.replace(/^\n\n/, "");
|
|
1068
|
+
}
|
|
935
1069
|
|
|
936
1070
|
// a customer-PR review closes with the engine's own account of the delta:
|
|
937
1071
|
// the counts, the gate, the controls, and what the reviewer dismissed or
|
|
938
1072
|
// left unaddressed. written here, not by the agent, so it cannot drift
|
|
939
1073
|
// from what was scanned.
|
|
1074
|
+
// proven suggestions a push made stale are re-proved at their threads'
|
|
1075
|
+
// current lines, so the apply command can still take them — only when
|
|
1076
|
+
// those lines are known to be this head's, for the same reason dedup
|
|
1077
|
+
// needs that. Never fatal: the review goes out either way.
|
|
1078
|
+
let reproved = 0;
|
|
1079
|
+
if (delta && anchorsAreCurrent && params.commit_id) {
|
|
1080
|
+
try {
|
|
1081
|
+
reproved = await reproveStaleSuggestions({
|
|
1082
|
+
threads: await openInfraweaverThreads(),
|
|
1083
|
+
delta,
|
|
1084
|
+
headSha: params.commit_id,
|
|
1085
|
+
prove: (candidates) =>
|
|
1086
|
+
proveSuggestions(
|
|
1087
|
+
{ repoCwd: process.cwd(), tmpdir: ctx.tmpdir, candidates },
|
|
1088
|
+
{ scan: (cwd, files) => scanForProof(ctx, cwd, files) },
|
|
1089
|
+
),
|
|
1090
|
+
reply: async (commentId, replyBody) => {
|
|
1091
|
+
await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
|
1092
|
+
owner: ctx.repo.owner,
|
|
1093
|
+
repo: ctx.repo.name,
|
|
1094
|
+
pull_number,
|
|
1095
|
+
comment_id: commentId,
|
|
1096
|
+
body: replyBody,
|
|
1097
|
+
});
|
|
1098
|
+
},
|
|
1099
|
+
});
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
log.info(`could not re-prove earlier suggestions (${errorMessage(err)})`);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// a proof counts only for a comment that is posted: one withheld as a
|
|
1106
|
+
// duplicate of an open thread, or dropped off the diff, offers nothing on
|
|
1107
|
+
// this head for the apply command to commit.
|
|
1108
|
+
const posted = new Set<unknown>(params.comments ?? []);
|
|
1109
|
+
const postedProofs = [...proofs]
|
|
1110
|
+
.filter(([index]) => !commentableMap || posted.has(reviewComments[index]))
|
|
1111
|
+
.map(([, proof]) => proof);
|
|
940
1112
|
const suggestionCounts = {
|
|
941
|
-
proven:
|
|
942
|
-
unproven:
|
|
1113
|
+
proven: postedProofs.filter((p) => p.proven).length,
|
|
1114
|
+
unproven: postedProofs.filter((p) => !p.proven).length,
|
|
943
1115
|
};
|
|
944
1116
|
if (delta) {
|
|
945
1117
|
const cited = auditedComments.flatMap((c) =>
|
|
@@ -953,9 +1125,10 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
953
1125
|
concerns: auditedComments.filter(
|
|
954
1126
|
(c) => c.provenance?.kind === "judgement" && claimsBlockingSeverity(c.body),
|
|
955
1127
|
).length,
|
|
956
|
-
proven: suggestionCounts.proven,
|
|
1128
|
+
proven: suggestionCounts.proven + reproved,
|
|
957
1129
|
applyCommand: APPLY_COMMAND,
|
|
958
1130
|
fixedThreads: ctx.toolState.fixedThreads,
|
|
1131
|
+
requiredControls: ctx.payload.requiredControls ?? [],
|
|
959
1132
|
}) + body;
|
|
960
1133
|
body += renderReviewSummarySection({
|
|
961
1134
|
delta,
|
|
@@ -1079,6 +1252,7 @@ export function CreatePullRequestReviewTool(ctx: GitHubWriteToolContext) {
|
|
|
1079
1252
|
nodeId: reviewNodeId,
|
|
1080
1253
|
reviewedSha: actuallyReviewedSha,
|
|
1081
1254
|
};
|
|
1255
|
+
ctx.toolState.stagedReview = undefined;
|
|
1082
1256
|
if (delta) {
|
|
1083
1257
|
ctx.toolState.reviewOutcome = {
|
|
1084
1258
|
commentsPosted: params.comments?.length ?? 0,
|
|
@@ -1357,7 +1531,7 @@ function runDeltaCoveragePreflight(params: {
|
|
|
1357
1531
|
`${f.concern.id} (${f.concern.rule_id} at ${f.concern.location.file}:${f.concern.location.line ?? "?"})`,
|
|
1358
1532
|
)
|
|
1359
1533
|
.join(", ") +
|
|
1360
|
-
`. for each, either add an inline comment with that \`finding_id\` (and its \`audit\`), or list it in \`dismissed\` with the reason it needs no comment. ` +
|
|
1534
|
+
`. for each, either add an inline comment with that \`finding_id\` (and its \`audit\`), or list it in \`dismissed\` with the reason it needs no comment — stage them with stage_review, without resending what is already staged. ` +
|
|
1361
1535
|
`then call create_pull_request_review again. this is a one-time nudge — it will not block again in this review session.`,
|
|
1362
1536
|
);
|
|
1363
1537
|
}
|
|
@@ -1396,7 +1570,7 @@ async function proveReviewSuggestions(
|
|
|
1396
1570
|
if (candidates.length === 0) return proofs;
|
|
1397
1571
|
const results = await proveSuggestions(
|
|
1398
1572
|
{ repoCwd: process.cwd(), tmpdir: ctx.tmpdir, candidates },
|
|
1399
|
-
{ scan: (cwd) => scanForProof(ctx, cwd) },
|
|
1573
|
+
{ scan: (cwd, files) => scanForProof(ctx, cwd, files) },
|
|
1400
1574
|
);
|
|
1401
1575
|
for (const r of results) {
|
|
1402
1576
|
proofs.set(r.index, r);
|
package/src/mcp/reviewAnchor.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommentableLines } from "#app/toolState";
|
|
2
|
+
import { parseDiffLines } from "#app/utils/unifiedDiff";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Deterministic re-anchoring for inline review comments.
|
|
@@ -44,30 +45,17 @@ export function normalizeAnchorLine(line: string, stripMarker = true): string {
|
|
|
44
45
|
export function patchLinesBySide(patch: string): Record<"LEFT" | "RIGHT", PatchLine[]> {
|
|
45
46
|
const right: PatchLine[] = [];
|
|
46
47
|
const left: PatchLine[] = [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
48
|
+
for (const line of parseDiffLines(patch)) {
|
|
49
|
+
if (line.kind === "add") {
|
|
50
|
+
right.push({ line: line.newLine, text: normalizeAnchorLine(line.raw) });
|
|
51
|
+
} else if (line.kind === "remove") {
|
|
52
|
+
left.push({ line: line.oldLine, text: normalizeAnchorLine(line.raw) });
|
|
53
|
+
} else if (line.kind === "context") {
|
|
54
|
+
const text = normalizeAnchorLine(line.raw);
|
|
55
|
+
right.push({ line: line.newLine, text });
|
|
56
|
+
left.push({ line: line.oldLine, text });
|
|
55
57
|
}
|
|
56
|
-
|
|
57
|
-
const text = normalizeAnchorLine(line);
|
|
58
|
-
if (changeType === "+") {
|
|
59
|
-
right.push({ line: newLine, text });
|
|
60
|
-
newLine++;
|
|
61
|
-
} else if (changeType === "-") {
|
|
62
|
-
left.push({ line: oldLine, text });
|
|
63
|
-
oldLine++;
|
|
64
|
-
} else if (changeType === " ") {
|
|
65
|
-
right.push({ line: newLine, text });
|
|
66
|
-
left.push({ line: oldLine, text });
|
|
67
|
-
newLine++;
|
|
68
|
-
oldLine++;
|
|
69
|
-
}
|
|
70
|
-
// "\" (no newline marker) and anything else: not a content line
|
|
58
|
+
// hunk headers, "\" (no newline marker) and anything else: not a content line
|
|
71
59
|
}
|
|
72
60
|
return { LEFT: left, RIGHT: right };
|
|
73
61
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many inline comments one review posts.
|
|
3
|
+
*
|
|
4
|
+
* A review of forty inline comments is read by no one, and every one of them is
|
|
5
|
+
* a thread the author must resolve. Past the limit the most important comments
|
|
6
|
+
* still post inline — 🚨 before ⚠️ before the rest, a proven (one-click
|
|
7
|
+
* committable) fix ahead of an equal one without, otherwise in the order the
|
|
8
|
+
* reviewer wrote them — and the rest are listed in the review body, so nothing
|
|
9
|
+
* the review found is lost, only folded. The gate is not involved: it judges the
|
|
10
|
+
* scan delta, never the comments.
|
|
11
|
+
*
|
|
12
|
+
* The limit is a repo setting (`review_comment_limit`), read from the base
|
|
13
|
+
* branch on a pull request: lowering it moves findings out of the diff view,
|
|
14
|
+
* which is a way to make them easier to miss, so a PR does not get to set it for
|
|
15
|
+
* itself.
|
|
16
|
+
*/
|
|
17
|
+
import { leadingMarker } from "#app/mcp/findingAudit";
|
|
18
|
+
import { gistOf } from "#app/mcp/reviewDedup";
|
|
19
|
+
|
|
20
|
+
/** Lines the overflow note lists before counting the rest; keeps the body
|
|
21
|
+
* clear of GitHub's ~65 KB limit, as the other review notes do. */
|
|
22
|
+
const MAX_OVERFLOW_LINES = 50;
|
|
23
|
+
|
|
24
|
+
/** what `renderSuggestion` writes under a suggestion a re-scan proved. */
|
|
25
|
+
const PROVEN_MARK = "✅ **Proven by re-scan**";
|
|
26
|
+
|
|
27
|
+
interface LimitableComment {
|
|
28
|
+
path: string;
|
|
29
|
+
line?: number | undefined;
|
|
30
|
+
body?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rank(c: LimitableComment): number {
|
|
34
|
+
const body = c.body ?? "";
|
|
35
|
+
const marker = leadingMarker(body);
|
|
36
|
+
const severity = marker === "🚨" ? 0 : marker === "⚠️" || marker === "⚠" ? 2 : 4;
|
|
37
|
+
return severity + (body.includes(PROVEN_MARK) ? 0 : 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split `comments` into those posted inline and those folded into the body.
|
|
42
|
+
* `limit` 0 or undefined means no limit. Both halves keep the input order, so
|
|
43
|
+
* the posted review reads in the order the reviewer wrote it.
|
|
44
|
+
*/
|
|
45
|
+
export function limitInlineComments<T extends LimitableComment>(
|
|
46
|
+
comments: readonly T[],
|
|
47
|
+
limit: number | undefined,
|
|
48
|
+
): { kept: T[]; overflow: T[] } {
|
|
49
|
+
if (!limit || comments.length <= limit) return { kept: [...comments], overflow: [] };
|
|
50
|
+
const keep = new Set(
|
|
51
|
+
comments
|
|
52
|
+
.map((c, i) => ({ i, r: rank(c) }))
|
|
53
|
+
.sort((a, b) => a.r - b.r || a.i - b.i)
|
|
54
|
+
.slice(0, limit)
|
|
55
|
+
.map(({ i }) => i),
|
|
56
|
+
);
|
|
57
|
+
return {
|
|
58
|
+
kept: comments.filter((_, i) => keep.has(i)),
|
|
59
|
+
overflow: comments.filter((_, i) => !keep.has(i)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The review-body note listing the comments the limit folded, or "" for none. */
|
|
64
|
+
export function formatOverflowNote(overflow: readonly LimitableComment[], limit: number): string {
|
|
65
|
+
if (overflow.length === 0) return "";
|
|
66
|
+
const shown = overflow.slice(0, MAX_OVERFLOW_LINES).map((c) => {
|
|
67
|
+
const where = c.line ? `${c.path}:${c.line}` : c.path;
|
|
68
|
+
const gist = gistOf(c.body);
|
|
69
|
+
return `- \`${where.replace(/`/g, "'")}\`${gist ? ` — ${gist}` : ""}`;
|
|
70
|
+
});
|
|
71
|
+
const rest = overflow.length - shown.length;
|
|
72
|
+
if (rest > 0) shown.push(`- …and ${rest} more not shown`);
|
|
73
|
+
return (
|
|
74
|
+
"\n\n---\n\n" +
|
|
75
|
+
`**Note:** this review posts at most ${limit} inline comments (\`review_comment_limit\`), ` +
|
|
76
|
+
`so ${overflow.length} more ${overflow.length === 1 ? "is" : "are"} listed here instead:\n` +
|
|
77
|
+
shown.join("\n")
|
|
78
|
+
);
|
|
79
|
+
}
|