pi-pr-review 1.17.4 → 1.17.6
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/CHANGELOG.md +14 -0
- package/README.md +4 -4
- package/lib/pr-review-artifacts.ts +113 -26
- package/lib/pr-review-deadlines.ts +2 -2
- package/lib/pr-review-markdown.ts +94 -6
- package/lib/pr-review-publish.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.17.6](https://github.com/10ego/pi-pr-review/compare/v1.17.5...v1.17.6) (2026-09-01)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **review:** retain findings from timed-out lanes ([#134](https://github.com/10ego/pi-pr-review/issues/134)) ([78de3e6](https://github.com/10ego/pi-pr-review/commit/78de3e6c0941292e9de3d8243c1fee88987168b5))
|
|
9
|
+
|
|
10
|
+
## [1.17.5](https://github.com/10ego/pi-pr-review/compare/v1.17.4...v1.17.5) (2026-09-01)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **review:** intercept manual publish requests ([#132](https://github.com/10ego/pi-pr-review/issues/132)) ([d251827](https://github.com/10ego/pi-pr-review/commit/d251827485de78deea93fbb4d5b90adedb5e5423))
|
|
16
|
+
|
|
3
17
|
## [1.17.4](https://github.com/10ego/pi-pr-review/compare/v1.17.3...v1.17.4) (2026-09-01)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -191,11 +191,11 @@ Example:
|
|
|
191
191
|
}
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
Every invocation has a host-owned monotonic 15-minute hard cap, including the two GitHub identity/lifecycle preflights, parent orchestration, synthesis, termination grace, and reserved cleanup. The dependent preflight calls share the one invocation budget, so they cannot each add an independent command timeout before review timing begins. The reviewer batch window activates once, at the first reviewer dispatch: preflight and diff capture remain charged to the total cap but cannot exhaust `batchMs` before any reviewer starts. The activated batch is still truncated by the original total deadline and its synthesis/termination/cleanup reserves. Defaults bound light/medium/heavy attempts to 3/6/12 minutes, a fallback attempt to 3 minutes, and the concurrent batch to 12 minutes. The complete `deadlines` object may be replaced at user scope or by a trusted project; partial, malformed, non-integer, out-of-range, or internally inconsistent objects are rejected as a unit and the last valid/default finite budget remains active. Supported inclusive ranges are: attempts 30–
|
|
194
|
+
Every invocation has a host-owned monotonic 15-minute hard cap, including the two GitHub identity/lifecycle preflights, parent orchestration, synthesis, termination grace, and reserved cleanup. The dependent preflight calls share the one invocation budget, so they cannot each add an independent command timeout before review timing begins. The reviewer batch window activates once, at the first reviewer dispatch: preflight and diff capture remain charged to the total cap but cannot exhaust `batchMs` before any reviewer starts. The activated batch is still truncated by the original total deadline and its synthesis/termination/cleanup reserves. Defaults bound light/medium/heavy attempts to 3/6/12 minutes, a fallback attempt to 3 minutes, and the concurrent batch to 12 minutes. The complete `deadlines` object may be replaced at user scope or by a trusted project; partial, malformed, non-integer, out-of-range, or internally inconsistent objects are rejected as a unit and the last valid/default finite budget remains active. Supported inclusive ranges are: attempts 30–900 seconds, fallback 30–360 seconds, batch 60–900 seconds, synthesis 10–120 seconds, total 120–1200 seconds, TERM grace 0.1–15 seconds, cleanup reserve 1–30 seconds, and minimum useful fallback 10–120 seconds. Minimum fallback must not exceed its attempt cap, and batch + synthesis + termination grace + cleanup must fit inside total.
|
|
195
195
|
|
|
196
196
|
A timed-out or retryable quota/rate-limit/capacity lane may start at most one configured fallback attempt. It starts only when at least `minimumFallbackMs` plus cleanup reserve remains; the host never changes the configured model, thinking level, or tool policy to save time. If a tier is unset, its existing nearest-configured-tier/Pi-default behavior is unchanged.
|
|
197
197
|
|
|
198
|
-
On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for
|
|
198
|
+
On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for concise `COMMENT` publication. Complete contract-valid candidate blocks retained before a timeout are deterministically recovered as canonical findings, including when terminal synthesis is absent; a truncated final block and arbitrary lane prose remain private. A timed-out lane is never reported as `NO FINDINGS` or full coverage, and a lane ended by the host total or synthesis deadline is disclosed with that kind (`deadline_expired`) instead of being mistaken for its own attempt deadline expiring. Host lane artifacts are authoritative for completeness in both directions: a false assistant completion claim cannot upgrade incomplete lanes, and a paraphrased or omitted `Lane completeness` line cannot downgrade a host-complete batch away from the concise renderer.
|
|
199
199
|
|
|
200
200
|
Initial operating targets are ordinary-review p50 ≤ 6 minutes and p95 ≤ 12 minutes, and large-review p50 ≤ 10 minutes and p95 ≤ 14 minutes, with the 15-minute hard cap authoritative. Invocation telemetry starts before GitHub preflight and records configured deadline source/caps, termination grace, cleanup reserve, and active wall time; batch details record invocation time consumed before each attempt, batch/total time remaining at dispatch, first event/output timing, lifecycle counts, configured and effective batch-truncated attempt deadlines, fallback starts/budget rejections, external total/synthesis deadline expiries per lane, and termination grace/escalation data. These are initial production targets, not a promise that every provider completes before its host deadline.
|
|
201
201
|
|
|
@@ -227,9 +227,9 @@ Publishing is off by default.
|
|
|
227
227
|
|
|
228
228
|
The extension owns normal publishing. Before review execution it captures repository, hostname, PR number/title, reviewed head, lifecycle state, posting/stale authority, and invocation identity independently of assistant text. After synthesis, it caches one validated completed review (the host-owned canonical artifact) per repository and PR in the current Pi session. `autoPostReviews` and `--comment` publish that cached review after completion; `--no-comment` suppresses publication for the run.
|
|
229
229
|
|
|
230
|
-
Review semantics are Markdown-first. A deterministic tolerant parser normalizes line endings, extracts complete findings when possible, and ignores heading-like text inside CommonMark fenced-code and HTML-block contexts. Every degraded synthesis — ambiguous, partially parsed, incomplete lane coverage, or absent terminal synthesis — is retained internally as one deterministic host-rendered artifact with generic code-review labels: a `Coverage` section with the exact lane lifecycle disclosure, a `Findings` section with host-formatted parsed findings (never a clean-review claim when nothing parsed), and the complete original synthesis plus every retained lane artifact preserved under `Retained synthesis` / `Retained lane output`. GitHub publication never dumps that artifact: it emits a concise `COMMENT` with a short incomplete-coverage or unstructured-output warning, validated inline findings, and summary-only `Other Notes`. Reserved markers are sanitized, size limits are enforced, and the canonical marker is appended only by host code. Optional formatting repair is never required and can never suppress the degraded fallback.
|
|
230
|
+
Review semantics are Markdown-first. A deterministic tolerant parser normalizes line endings, extracts complete findings when possible, and ignores heading-like text inside CommonMark fenced-code and HTML-block contexts. Every degraded synthesis — ambiguous, partially parsed, incomplete lane coverage, or absent terminal synthesis — is retained internally as one deterministic host-rendered artifact with generic code-review labels: a `Coverage` section with the exact lane lifecycle disclosure, a `Findings` section with host-formatted parsed or contract-valid retained-lane findings (never a clean-review claim when nothing parsed), and the complete original synthesis plus every retained lane artifact preserved under `Retained synthesis` / `Retained lane output`. GitHub publication never dumps that artifact: it emits a concise `COMMENT` with a short incomplete-coverage or unstructured-output warning, validated inline findings, and summary-only `Other Notes`. Reserved markers are sanitized, size limits are enforced, and the canonical marker is appended only by host code. Optional formatting repair is never required and can never suppress the degraded fallback.
|
|
231
231
|
|
|
232
|
-
You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
|
|
232
|
+
You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “manually post the review comments,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
|
|
233
233
|
|
|
234
234
|
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, and transport diagnostics stay out of the public summary. The complete original Markdown and retained lane evidence remain internal and are never substituted for a concise GitHub review, including degraded, incomplete, cached, direct, and stale `COMMENT` publication. If the concise body plus its canonical marker exceeds GitHub's limit, publication fails closed rather than dumping the full report. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
|
|
235
235
|
|
|
@@ -186,6 +186,17 @@ interface CandidateBlock {
|
|
|
186
186
|
readonly fields: ReadonlyMap<string, string>;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
export interface ValidatedReviewLaneCandidate {
|
|
190
|
+
readonly title: string;
|
|
191
|
+
readonly severity: "P0" | "P1" | "P2" | "P3" | "nit";
|
|
192
|
+
readonly why: string;
|
|
193
|
+
readonly location: string;
|
|
194
|
+
readonly side: "RIGHT" | "LEFT";
|
|
195
|
+
readonly inDiff: boolean;
|
|
196
|
+
readonly prRelated: boolean;
|
|
197
|
+
readonly confidence: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
189
200
|
function normalizeReviewText(text: string): string {
|
|
190
201
|
// Only CRLF is transport normalization. In particular, do not trim or
|
|
191
202
|
// rewrite line-leading whitespace before applying the grammar.
|
|
@@ -380,6 +391,50 @@ function contradictoryFraming(values: readonly string[]): boolean {
|
|
|
380
391
|
* exactly one nonempty value line per field. Candidate `why` may have
|
|
381
392
|
* indented continuation lines; reserved productions cannot be continuations.
|
|
382
393
|
*/
|
|
394
|
+
function parseCandidatePrefix(
|
|
395
|
+
lines: readonly string[],
|
|
396
|
+
startCursor: number,
|
|
397
|
+
): { candidates: CandidateBlock[]; consumedAll: boolean } {
|
|
398
|
+
let cursor = startCursor;
|
|
399
|
+
const candidates: CandidateBlock[] = [];
|
|
400
|
+
while (cursor < lines.length) {
|
|
401
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
402
|
+
if (cursor >= lines.length) return { candidates, consumedAll: true };
|
|
403
|
+
const start = cursor;
|
|
404
|
+
const fields = new Map<string, string>();
|
|
405
|
+
for (const expected of CANDIDATE_FIELDS) {
|
|
406
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
407
|
+
const fieldLine = lines[cursor] ?? "";
|
|
408
|
+
if (fieldLine.trim() && /[ \t]+$/.test(fieldLine)) return { candidates, consumedAll: false };
|
|
409
|
+
const field = candidateLabel(fieldLine);
|
|
410
|
+
if (!field || canonicalField(field.field) !== expected || fields.has(expected)) {
|
|
411
|
+
return { candidates, consumedAll: false };
|
|
412
|
+
}
|
|
413
|
+
if (hasReservedCandidateProduction(expected, field.value)) return { candidates, consumedAll: false };
|
|
414
|
+
fields.set(expected, field.value);
|
|
415
|
+
cursor++;
|
|
416
|
+
if (expected !== "why") continue;
|
|
417
|
+
const whyLines = [field.value];
|
|
418
|
+
while (cursor < lines.length) {
|
|
419
|
+
const continuation = lines[cursor]!;
|
|
420
|
+
if (!continuation.trim()) break;
|
|
421
|
+
if (/[ \t]+$/.test(continuation)) return { candidates, consumedAll: false };
|
|
422
|
+
if (isReservedContractLine(continuation)) break;
|
|
423
|
+
if (!/^ {2}\S/.test(continuation) || /^ {2}(?:[-*+>]|#{1,6})[ \t]+/.test(continuation)) {
|
|
424
|
+
return { candidates, consumedAll: false };
|
|
425
|
+
}
|
|
426
|
+
if (hasReservedCandidateProduction("why", continuation.slice(2))) return { candidates, consumedAll: false };
|
|
427
|
+
whyLines.push(continuation.slice(2));
|
|
428
|
+
cursor++;
|
|
429
|
+
}
|
|
430
|
+
fields.set("why", whyLines.join("\n"));
|
|
431
|
+
}
|
|
432
|
+
if (!validCandidateFields(fields)) return { candidates, consumedAll: false };
|
|
433
|
+
candidates.push({ start, end: cursor, fields });
|
|
434
|
+
}
|
|
435
|
+
return { candidates, consumedAll: true };
|
|
436
|
+
}
|
|
437
|
+
|
|
383
438
|
function parseIntegratedCompletion(text: string): boolean {
|
|
384
439
|
if (hasTrailingHorizontalWhitespace(text) || CODE_FENCE.test(text) || hasHtmlContainer(text)) return false;
|
|
385
440
|
const lines = text.split("\n");
|
|
@@ -416,38 +471,70 @@ function parseIntegratedCompletion(text: string): boolean {
|
|
|
416
471
|
return cursor === lines.length;
|
|
417
472
|
}
|
|
418
473
|
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
474
|
+
const parsed = parseCandidatePrefix(lines, cursor);
|
|
475
|
+
return parsed.consumedAll && parsed.candidates.length > 0;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Recover only complete, contract-valid candidate blocks from retained lane
|
|
480
|
+
* output. A timed-out final block may be truncated; earlier complete blocks
|
|
481
|
+
* remain usable, while arbitrary prose and unsafe containers recover nothing.
|
|
482
|
+
*/
|
|
483
|
+
export function extractValidatedReviewLaneCandidates(
|
|
484
|
+
rawText: string,
|
|
485
|
+
expectedOutput: "review_lane" | "nonempty" = "review_lane",
|
|
486
|
+
): readonly ValidatedReviewLaneCandidate[] {
|
|
487
|
+
const text = normalizeReviewText(rawText);
|
|
488
|
+
if (
|
|
489
|
+
!text.trim() || CODE_FENCE.test(text) ||
|
|
490
|
+
hasHtmlContainer(text) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text) ||
|
|
491
|
+
/<!--\s*pi-pr-review:/i.test(text)
|
|
492
|
+
) return [];
|
|
493
|
+
const lines = text.split("\n");
|
|
494
|
+
let cursor = 0;
|
|
495
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
496
|
+
if (expectedOutput === "nonempty") {
|
|
497
|
+
if (lines[cursor] !== "Review status: COMPLETE") return [];
|
|
498
|
+
const statusIndex = cursor++;
|
|
499
|
+
if (lines.some((line, index) => index !== statusIndex && hasReservedStatusProduction(line))) return [];
|
|
500
|
+
const framingValues: string[] = [];
|
|
501
|
+
for (const expected of FRAMING_LABELS) {
|
|
428
502
|
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
429
|
-
const
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
503
|
+
const framingLine = lines[cursor] ?? "";
|
|
504
|
+
if (framingLine.trim() && /[ \t]+$/.test(framingLine)) return [];
|
|
505
|
+
const label = framingLabel(framingLine);
|
|
506
|
+
if (!label || canonicalField(label.field) !== expected.toLowerCase()) return [];
|
|
433
507
|
cursor++;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
if (isReservedContractLine(continuation)) break;
|
|
440
|
-
if (!/^ {2}\S/.test(continuation) || /^ {2}(?:[-*+>]|#{1,6})[ \t]+/.test(continuation)) return false;
|
|
441
|
-
if (hasReservedCandidateProduction("why", continuation.slice(2))) return false;
|
|
442
|
-
whyLines.push(continuation.slice(2));
|
|
508
|
+
let value = label.value;
|
|
509
|
+
if (label.kind === "heading") {
|
|
510
|
+
const valueLine = lines[cursor] ?? "";
|
|
511
|
+
if (!valueLine || !valueLine.trim() || /[ \t]+$/.test(valueLine) || /^[ \t]/.test(valueLine) || CONTAINER_PREFIX.test(valueLine) || isReservedContractLine(valueLine)) return [];
|
|
512
|
+
value = valueLine;
|
|
443
513
|
cursor++;
|
|
444
514
|
}
|
|
445
|
-
|
|
515
|
+
if (!meaningfulValue(value) || framingValueHasReservedProduction(value)) return [];
|
|
516
|
+
framingValues.push(value);
|
|
446
517
|
}
|
|
447
|
-
if (
|
|
448
|
-
|
|
518
|
+
if (topLevelFailure(framingValues.join("\n")) || contradictoryFraming(framingValues)) return [];
|
|
519
|
+
} else if (lines.some((line) => hasReservedStatusProduction(line))) {
|
|
520
|
+
return [];
|
|
449
521
|
}
|
|
450
|
-
|
|
522
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
523
|
+
if (lines.some((line) => line.trim() === NO_FINDINGS_SENTINEL || line.trim() === "NO FINDINGS")) return [];
|
|
524
|
+
const parsed = parseCandidatePrefix(lines, cursor);
|
|
525
|
+
return parsed.candidates.map((candidate) => {
|
|
526
|
+
const fields = candidate.fields;
|
|
527
|
+
return Object.freeze({
|
|
528
|
+
title: fields.get("title")!.trim(),
|
|
529
|
+
severity: fields.get("severity")!.trim() as ValidatedReviewLaneCandidate["severity"],
|
|
530
|
+
why: fields.get("why")!.trim(),
|
|
531
|
+
location: fields.get("location")!.trim(),
|
|
532
|
+
side: fields.get("side")!.trim() as ValidatedReviewLaneCandidate["side"],
|
|
533
|
+
inDiff: fields.get("in_diff")!.trim() === "yes",
|
|
534
|
+
prRelated: fields.get("pr_related")!.trim() === "yes",
|
|
535
|
+
confidence: Number(fields.get("confidence")!.trim()),
|
|
536
|
+
});
|
|
537
|
+
});
|
|
451
538
|
}
|
|
452
539
|
|
|
453
540
|
function hasMeaningfulField(text: string, field: string): boolean {
|
|
@@ -29,9 +29,9 @@ export const DEFAULT_REVIEW_DEADLINES: Readonly<ReviewDeadlineConfig> = Object.f
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
const LIMITS = {
|
|
32
|
-
attemptMs: [30_000,
|
|
32
|
+
attemptMs: [30_000, 900_000],
|
|
33
33
|
fallbackAttemptMs: [30_000, 360_000],
|
|
34
|
-
batchMs: [60_000,
|
|
34
|
+
batchMs: [60_000, 900_000],
|
|
35
35
|
synthesisMs: [10_000, 120_000],
|
|
36
36
|
totalMs: [120_000, 1_200_000],
|
|
37
37
|
terminationGraceMs: [100, 15_000],
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import
|
|
3
|
+
import {
|
|
4
|
+
extractValidatedReviewLaneCandidates,
|
|
5
|
+
type ExpectedReviewLane,
|
|
6
|
+
type ReviewLaneArtifact,
|
|
7
|
+
} from "./pr-review-artifacts.ts";
|
|
4
8
|
import type { ReviewFindingLike, ReviewLike } from "./pr-review-publish.ts";
|
|
5
9
|
|
|
6
10
|
export type ReviewSynthesisQuality = "fully_parsed" | "partially_parsed" | "raw" | "lane_fallback";
|
|
@@ -488,6 +492,76 @@ function retainedLaneText(lane: ReviewLaneArtifact): string {
|
|
|
488
492
|
return "";
|
|
489
493
|
}
|
|
490
494
|
|
|
495
|
+
function retainedLaneFindings(
|
|
496
|
+
lanes: readonly ReviewLaneArtifact[],
|
|
497
|
+
expected: readonly ExpectedReviewLane[],
|
|
498
|
+
): ReviewFindingLike[] {
|
|
499
|
+
const findings: ReviewFindingLike[] = [];
|
|
500
|
+
const seen = new Set<string>();
|
|
501
|
+
for (const lane of lanes) {
|
|
502
|
+
const contract = expected.find((descriptor) => descriptor.key === lane.key)?.expectedOutput ?? "review_lane";
|
|
503
|
+
for (const candidate of extractValidatedReviewLaneCandidates(retainedLaneText(lane), contract)) {
|
|
504
|
+
if (!candidate.prRelated) continue;
|
|
505
|
+
const parsedLocation = candidate.location === "repo-wide"
|
|
506
|
+
? { status: "absent" as const, location: null }
|
|
507
|
+
: parseLocation(`${candidate.location} ${candidate.side}`);
|
|
508
|
+
if (parsedLocation.status === "unsafe") continue;
|
|
509
|
+
const codeLocation = parsedLocation.location
|
|
510
|
+
? { ...parsedLocation.location, commentable: candidate.inDiff }
|
|
511
|
+
: null;
|
|
512
|
+
const key = JSON.stringify([
|
|
513
|
+
candidate.severity,
|
|
514
|
+
candidate.title,
|
|
515
|
+
candidate.why,
|
|
516
|
+
candidate.location,
|
|
517
|
+
candidate.side,
|
|
518
|
+
]);
|
|
519
|
+
if (seen.has(key)) continue;
|
|
520
|
+
seen.add(key);
|
|
521
|
+
findings.push({
|
|
522
|
+
title: candidate.title,
|
|
523
|
+
severity: candidate.severity,
|
|
524
|
+
blocking: candidate.severity === "P0" || candidate.severity === "P1",
|
|
525
|
+
body: candidate.why,
|
|
526
|
+
confidence_score: candidate.confidence,
|
|
527
|
+
code_location: codeLocation,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return findings;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function mergeUniqueFindings(
|
|
535
|
+
primary: readonly ReviewFindingLike[],
|
|
536
|
+
additional: readonly ReviewFindingLike[],
|
|
537
|
+
): ReviewFindingLike[] {
|
|
538
|
+
const merged = [...primary];
|
|
539
|
+
const keys = new Set(merged.map((finding) => JSON.stringify([
|
|
540
|
+
finding.severity,
|
|
541
|
+
finding.title,
|
|
542
|
+
finding.body,
|
|
543
|
+
finding.code_location?.absolute_file_path,
|
|
544
|
+
finding.code_location?.line_range?.start,
|
|
545
|
+
finding.code_location?.line_range?.end,
|
|
546
|
+
finding.code_location?.side,
|
|
547
|
+
])));
|
|
548
|
+
for (const finding of additional) {
|
|
549
|
+
const key = JSON.stringify([
|
|
550
|
+
finding.severity,
|
|
551
|
+
finding.title,
|
|
552
|
+
finding.body,
|
|
553
|
+
finding.code_location?.absolute_file_path,
|
|
554
|
+
finding.code_location?.line_range?.start,
|
|
555
|
+
finding.code_location?.line_range?.end,
|
|
556
|
+
finding.code_location?.side,
|
|
557
|
+
]);
|
|
558
|
+
if (keys.has(key)) continue;
|
|
559
|
+
keys.add(key);
|
|
560
|
+
merged.push(finding);
|
|
561
|
+
}
|
|
562
|
+
return merged;
|
|
563
|
+
}
|
|
564
|
+
|
|
491
565
|
function disclosedPassId(passId: string): string {
|
|
492
566
|
const normalized = passId.replace(new RegExp(UNSAFE_TEXT_CONTROL.source, "g"), "_").replace(/[\r\n]/g, "_").trim();
|
|
493
567
|
const sanitized = normalized.replace(/[^A-Za-z0-9._:@/-]+/g, "_") || "unnamed-pass";
|
|
@@ -704,6 +778,7 @@ export function synthesizeReviewArtifact(input: {
|
|
|
704
778
|
lanes.every((lane) => expectedLaneDescriptors.some((expected) =>
|
|
705
779
|
expected.key === lane.key && expected.tier === lane.tier &&
|
|
706
780
|
expected.minorHygiene === !!lane.minorHygiene));
|
|
781
|
+
const validatedLaneFindings = retainedLaneFindings(lanes, expectedLaneDescriptors);
|
|
707
782
|
if (input.strictJsonReview) {
|
|
708
783
|
// Strict JSON carries no assistant disclosure line; host lane evidence is
|
|
709
784
|
// the only completeness authority whenever a batch ran.
|
|
@@ -715,7 +790,10 @@ export function synthesizeReviewArtifact(input: {
|
|
|
715
790
|
);
|
|
716
791
|
const safe = publicationSafeStrictReview(input.strictJsonReview);
|
|
717
792
|
const bodyFallback = !safe || completeness === "incomplete";
|
|
718
|
-
const strictFindings =
|
|
793
|
+
const strictFindings = mergeUniqueFindings(
|
|
794
|
+
safe ? (input.strictJsonReview.findings ?? []) : [],
|
|
795
|
+
bodyFallback ? validatedLaneFindings : [],
|
|
796
|
+
);
|
|
719
797
|
const body = bodyFallback
|
|
720
798
|
? buildDegradedReviewBody({
|
|
721
799
|
rawText: input.rawText,
|
|
@@ -761,7 +839,7 @@ export function synthesizeReviewArtifact(input: {
|
|
|
761
839
|
const body = buildDegradedReviewBody({
|
|
762
840
|
rawText: "",
|
|
763
841
|
lanes,
|
|
764
|
-
findings:
|
|
842
|
+
findings: validatedLaneFindings,
|
|
765
843
|
expectedLaneCount,
|
|
766
844
|
exactCoverage: exactLaneCoverage,
|
|
767
845
|
reason: "terminal synthesis was absent",
|
|
@@ -770,13 +848,18 @@ export function synthesizeReviewArtifact(input: {
|
|
|
770
848
|
quality: "lane_fallback" as const,
|
|
771
849
|
rawText: input.rawText,
|
|
772
850
|
body,
|
|
773
|
-
review: syntheticReview(input.prNumber, input.prTitle, input.headSha, body),
|
|
851
|
+
review: syntheticReview(input.prNumber, input.prTitle, input.headSha, body, validatedLaneFindings),
|
|
774
852
|
laneArtifacts: lanes,
|
|
775
853
|
expectedLaneDescriptors,
|
|
776
854
|
expectedLaneCount,
|
|
777
855
|
completeness,
|
|
778
856
|
mergeApprovalEligible: false,
|
|
779
|
-
diagnostics: Object.freeze([
|
|
857
|
+
diagnostics: Object.freeze([
|
|
858
|
+
"terminal synthesis was absent; body assembled deterministically from retained lanes",
|
|
859
|
+
...(validatedLaneFindings.length > 0
|
|
860
|
+
? [`recovered ${validatedLaneFindings.length} complete contract-valid finding(s) from retained lane output`]
|
|
861
|
+
: []),
|
|
862
|
+
]),
|
|
780
863
|
});
|
|
781
864
|
}
|
|
782
865
|
const parsed = parseFindings(raw);
|
|
@@ -814,7 +897,9 @@ export function synthesizeReviewArtifact(input: {
|
|
|
814
897
|
: hasStructure && canonicalParsed.complete && completeness === "complete"
|
|
815
898
|
? "fully_parsed"
|
|
816
899
|
: canonicalParsed.findings.length > 0 ? "partially_parsed" : "raw";
|
|
817
|
-
const
|
|
900
|
+
const parsedSynthesisFindings = canonicalParsed.unsafe ? [] : canonicalParsed.findings;
|
|
901
|
+
const recoveredLaneFindings = quality === "fully_parsed" ? [] : validatedLaneFindings;
|
|
902
|
+
const safeFindings = mergeUniqueFindings(parsedSynthesisFindings, recoveredLaneFindings);
|
|
818
903
|
const degradationReasons = (() => {
|
|
819
904
|
if (canonicalParsed.unsafe) {
|
|
820
905
|
return ["unsafe Markdown fields were preserved in the sanitized body and inline extraction was disabled"];
|
|
@@ -841,6 +926,9 @@ export function synthesizeReviewArtifact(input: {
|
|
|
841
926
|
if (parsed.count > parsed.findings.length) {
|
|
842
927
|
reasons.push(`${parsed.count - parsed.findings.length} finding section(s) could not be parsed and remain in the body`);
|
|
843
928
|
}
|
|
929
|
+
if (recoveredLaneFindings.length > 0) {
|
|
930
|
+
reasons.push(`recovered ${recoveredLaneFindings.length} complete contract-valid finding(s) from retained lane output`);
|
|
931
|
+
}
|
|
844
932
|
return reasons.length > 0 ? reasons : ["terminal synthesis was not structurally parseable; preserved as body-only Markdown"];
|
|
845
933
|
})();
|
|
846
934
|
// Markdown is the durable semantic product. Keep the complete deterministic
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -349,7 +349,7 @@ export function parseDirectPublishRequest(input: string): DirectPublishRequestPa
|
|
|
349
349
|
const trimmed = input.trim();
|
|
350
350
|
if (!trimmed || /[\r\n]/.test(trimmed)) return { matched: false };
|
|
351
351
|
const match = trimmed.match(
|
|
352
|
-
/^(?:(?:please|kindly)\s+|(?:(?:can|could|would|will)\s+you\s+))?(?:post|publish|submit)\s+(?:(?:(?:the|this|that|these|those|my|our)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request|review)\s+)*(?:reviews?|comments|(?:inline|review)\s+comment)|(?:it|this|that)\s+as\s+(?:(?:an?|the)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request|review)\s+)*(?:reviews?|comments|(?:inline|review)\s+comment))(?:\s+(?:for|on|to)\s+(?:(?:the\s+)?(?:pull\s+request|pr)\s*)?#?(\d+))?(?:\s+please)?[.!?]*$/i,
|
|
352
|
+
/^(?:(?:please|kindly)\s+|(?:(?:can|could|would|will)\s+you\s+))?(?:(?:manually|manualy|manuall)\s+)?(?:post|publish|submit)\s+(?:(?:(?:the|this|that|these|those|my|our)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request|review)\s+)*(?:reviews?|comments|(?:inline|review)\s+comment)|(?:it|this|that)\s+as\s+(?:(?:an?|the)\s+)?(?:(?:cached|completed|current|latest|inline|github|pr|pull[\s-]?request|review)\s+)*(?:reviews?|comments|(?:inline|review)\s+comment))(?:\s+(?:for|on|to)\s+(?:(?:the\s+)?(?:pull\s+request|pr)\s*)?#?(\d+))?(?:\s+(?:manually|manualy|manuall))?(?:\s+please)?[.!?]*$/i,
|
|
353
353
|
);
|
|
354
354
|
if (!match) return { matched: false };
|
|
355
355
|
if (match[1] === undefined) return { matched: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.6",
|
|
4
4
|
"description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and host-gated COMMENT or qualified APPROVE publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|