pi-pr-review 1.10.5 → 1.10.7
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 +8 -6
- package/extensions/review-table.ts +106 -130
- package/lib/pr-review-publish.ts +360 -189
- package/package.json +1 -1
- package/prompts/pr-review.md +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.7](https://github.com/10ego/pi-pr-review/compare/v1.10.6...v1.10.7) (2026-07-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **publish:** auto-heal a Markdown-fenced review JSON object ([#37](https://github.com/10ego/pi-pr-review/issues/37)) ([9ecdd05](https://github.com/10ego/pi-pr-review/commit/9ecdd05e8f00b56176c52f5ca50fcf55698bd2b8))
|
|
9
|
+
|
|
10
|
+
## [1.10.6](https://github.com/10ego/pi-pr-review/compare/v1.10.5...v1.10.6) (2026-07-17)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **publish:** simplify reliable review posting ([#31](https://github.com/10ego/pi-pr-review/issues/31)) ([7d93560](https://github.com/10ego/pi-pr-review/commit/7d935604b620a51b04708633da7c4dc0850d6096))
|
|
16
|
+
|
|
3
17
|
## [1.10.5](https://github.com/10ego/pi-pr-review/compare/v1.10.4...v1.10.5) (2026-07-17)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -182,21 +182,23 @@ Publishing is off by default.
|
|
|
182
182
|
/pr-review-config auto_post_reviews=true
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
The extension—not the model—owns publishing.
|
|
185
|
+
The extension—not the model—owns publishing. After a successful review, it caches one validated completed review 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.
|
|
186
186
|
|
|
187
|
-
If the agent's final review is not valid exact-contract JSON, the extension logs the
|
|
187
|
+
If the agent's final review is not valid exact-contract JSON, the extension logs the reason and automatically asks the same agent to correct its completed output once, with tools disabled and the original posting authority unchanged. An invalid correction, a tool call, or overlapping input stops publication rather than starting another correction loop.
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
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.
|
|
190
190
|
|
|
191
|
-
|
|
191
|
+
Every authorized publish path builds one `COMMENT` payload and sends at most one GitHub review `POST`; it never submits `APPROVE` or `REQUEST_CHANGES` and never retries a rejected write with a fallback POST. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale or authorized non-open reviews are body-only.
|
|
192
192
|
|
|
193
|
-
|
|
193
|
+
The same safety gates apply to every path: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown or invalid states fail closed before a write.
|
|
194
|
+
|
|
195
|
+
Stale publication is enabled by default through `allowStalePublish: true`; disable it with `/pr-review-config allow_stale_publish=false`. Automatic posting and `/pr-review-publish` use the setting captured when the review starts unless the command supplies the explicit override:
|
|
194
196
|
|
|
195
197
|
```text
|
|
196
198
|
/pr-review-publish 123 --allow-stale
|
|
197
199
|
```
|
|
198
200
|
|
|
199
|
-
Inline comments are always disabled for stale reviews because the original anchors may no longer be valid
|
|
201
|
+
A matching direct request permits stale publication. Inline comments are always disabled for stale reviews because the original anchors may no longer be valid; the body identifies both the reviewed and current commit. The session-backed cache survives extension reloads and session resumes and remains bound to the originating session instance and repository.
|
|
200
202
|
|
|
201
203
|
## Optional verification
|
|
202
204
|
|
|
@@ -39,9 +39,7 @@ import {
|
|
|
39
39
|
type AutoPostResolution,
|
|
40
40
|
type CompletedReviewRecord,
|
|
41
41
|
type CompletedReviewSessionIdentity,
|
|
42
|
-
type RepositoryBinding,
|
|
43
42
|
type ReviewInvocation,
|
|
44
|
-
type ReviewLike,
|
|
45
43
|
} from "../lib/pr-review-publish.ts";
|
|
46
44
|
import {
|
|
47
45
|
ReviewLoopCoordinator,
|
|
@@ -379,51 +377,48 @@ function notifyPublishResult(
|
|
|
379
377
|
}
|
|
380
378
|
}
|
|
381
379
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
380
|
+
type ReviewPublicationOrigin =
|
|
381
|
+
| { readonly kind: "frozen-invocation" }
|
|
382
|
+
| { readonly kind: "publish-command"; readonly stalePolicy: "frozen" | "allow-stale" }
|
|
383
|
+
| { readonly kind: "direct-request" };
|
|
384
|
+
|
|
385
|
+
async function publishCompletedReview(
|
|
386
|
+
record: CompletedReviewRecord,
|
|
387
|
+
origin: ReviewPublicationOrigin,
|
|
385
388
|
ctx: ExtensionContext,
|
|
386
|
-
expectedRepository?: RepositoryBinding,
|
|
387
389
|
): Promise<void> {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
if (!authority.publish) return;
|
|
394
|
-
const parsed = parsePublishableReview(text);
|
|
395
|
-
if (!parsed.review) {
|
|
396
|
-
ctx.ui.notify(`PR review was not posted: ${parsed.error}`, "error");
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
const bindingError = validateReviewInvocation(parsed.review, invocation);
|
|
400
|
-
if (bindingError) {
|
|
401
|
-
ctx.ui.notify(`PR review was not posted: ${bindingError}`, "error");
|
|
390
|
+
const decision = origin.kind === "frozen-invocation"
|
|
391
|
+
? decideReviewPublication(record.invocation)
|
|
392
|
+
: undefined;
|
|
393
|
+
if (decision?.error) {
|
|
394
|
+
ctx.ui.notify(`PR review was not posted: ${decision.error}`, "error");
|
|
402
395
|
return;
|
|
403
396
|
}
|
|
404
|
-
if (!
|
|
405
|
-
const
|
|
397
|
+
if (decision && !decision.publish) return;
|
|
398
|
+
const explicitStale = origin.kind === "direct-request" ||
|
|
399
|
+
(origin.kind === "publish-command" && origin.stalePolicy === "allow-stale");
|
|
400
|
+
const allowStale = explicitStale || record.invocation.allowStalePublish;
|
|
401
|
+
const source = decision?.source ?? (origin.kind === "frozen-invocation"
|
|
402
|
+
? "frozen invocation"
|
|
403
|
+
: origin.kind === "direct-request"
|
|
404
|
+
? "direct user request"
|
|
405
|
+
: origin.stalePolicy === "allow-stale" ? "publish-only --allow-stale" : "publish-only");
|
|
406
|
+
|
|
407
|
+
const headSha = record.review.pr?.head_sha;
|
|
406
408
|
if (typeof headSha !== "string") {
|
|
407
|
-
ctx.ui.notify("PR review was not posted: final JSON is missing pr.head_sha", "error");
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
if (!expectedRepository) {
|
|
411
|
-
ctx.ui.notify(
|
|
412
|
-
"PR review was not posted because its repository identity could not be established before caching; no publish-only cache is available. Rerun /pr-review when GitHub repository lookup is working.",
|
|
413
|
-
"error",
|
|
414
|
-
);
|
|
409
|
+
ctx.ui.notify("PR review was not posted: cached final JSON is missing pr.head_sha", "error");
|
|
415
410
|
return;
|
|
416
411
|
}
|
|
417
412
|
const result = await publishPullReview({
|
|
418
413
|
cwd: ctx.cwd,
|
|
419
|
-
prNumber: invocation.prNumber,
|
|
414
|
+
prNumber: record.invocation.prNumber,
|
|
420
415
|
headSha,
|
|
421
|
-
allowNonOpen: invocation.allowNonOpen,
|
|
422
|
-
allowStale
|
|
423
|
-
expectedRepository,
|
|
424
|
-
review:
|
|
416
|
+
allowNonOpen: record.invocation.allowNonOpen,
|
|
417
|
+
allowStale,
|
|
418
|
+
expectedRepository: record.repository,
|
|
419
|
+
review: record.review,
|
|
425
420
|
});
|
|
426
|
-
notifyPublishResult(result,
|
|
421
|
+
notifyPublishResult(result, source, ctx);
|
|
427
422
|
}
|
|
428
423
|
|
|
429
424
|
export default function registerReviewTable(
|
|
@@ -447,15 +442,41 @@ export default function registerReviewTable(
|
|
|
447
442
|
}
|
|
448
443
|
restoreCompletedReviewBranch(completedReviews, ctx.sessionManager.getBranch(), session);
|
|
449
444
|
};
|
|
450
|
-
|
|
451
|
-
| {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
445
|
+
type PendingCompletion =
|
|
446
|
+
| { readonly record: CompletedReviewRecord; readonly session?: CompletedReviewSessionIdentity }
|
|
447
|
+
| { readonly error: string };
|
|
448
|
+
let pendingCompletion: PendingCompletion | undefined;
|
|
449
|
+
const completionError = (invocation: ReviewInvocation, failure?: string): PendingCompletion | undefined => {
|
|
450
|
+
const decision = decideReviewPublication(invocation);
|
|
451
|
+
const error = decision.error ?? (decision.publish ? failure : undefined);
|
|
452
|
+
return error ? { error } : undefined;
|
|
453
|
+
};
|
|
454
|
+
const resolveCompletion = async (
|
|
455
|
+
parsed: ReturnType<typeof parsePublishableReview>,
|
|
456
|
+
invocation: ReviewInvocation,
|
|
457
|
+
ctx: ExtensionContext,
|
|
458
|
+
): Promise<PendingCompletion | undefined> => {
|
|
459
|
+
if (!parsed.review) return completionError(invocation, parsed.error ?? "final review JSON is invalid");
|
|
460
|
+
const bindingError = validateReviewInvocation(parsed.review, invocation);
|
|
461
|
+
if (bindingError) return completionError(invocation, bindingError);
|
|
462
|
+
if (!shouldPublishReview(parsed.review)) return completionError(invocation);
|
|
463
|
+
try {
|
|
464
|
+
const repository = await resolveRepositoryBinding(ctx.cwd);
|
|
465
|
+
// Cache before publication preflight; persist after Pi stores the assistant message.
|
|
466
|
+
const record = completedReviews.remember(parsed.review, invocation, repository);
|
|
467
|
+
const session = sessionIdentity(ctx);
|
|
468
|
+
if (!session) {
|
|
469
|
+
ctx.ui.notify("Completed review cache will not survive reload: session identity is unavailable", "warning");
|
|
470
|
+
}
|
|
471
|
+
return session ? { record, session } : { record };
|
|
472
|
+
} catch (error) {
|
|
473
|
+
ctx.ui.notify(`Completed review is not available to publish-only: ${String(error)}`, "warning");
|
|
474
|
+
return completionError(
|
|
475
|
+
invocation,
|
|
476
|
+
"its repository identity could not be established before caching; no publish-only cache is available. Rerun /pr-review when GitHub repository lookup is working.",
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
459
480
|
let outputRepairAttempted = false;
|
|
460
481
|
let outputRepairCancelled = false;
|
|
461
482
|
const telemetryTracker = new ReviewTelemetryTracker();
|
|
@@ -469,44 +490,24 @@ export default function registerReviewTable(
|
|
|
469
490
|
}
|
|
470
491
|
};
|
|
471
492
|
|
|
472
|
-
type
|
|
473
|
-
|
|
474
|
-
| { error: string };
|
|
475
|
-
const publishCachedReview = async (
|
|
493
|
+
type CachedReviewResolution = { record: CompletedReviewRecord } | { error: string };
|
|
494
|
+
const resolveCachedReview = async (
|
|
476
495
|
requestedPrNumber: number | undefined,
|
|
477
|
-
allowStaleOverride: boolean | undefined,
|
|
478
496
|
ctx: ExtensionContext,
|
|
479
|
-
): Promise<
|
|
480
|
-
let repository: RepositoryBinding;
|
|
497
|
+
): Promise<CachedReviewResolution> => {
|
|
481
498
|
try {
|
|
482
|
-
repository = await resolveRepositoryBinding(ctx.cwd);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
? completedReviews.latest(repository)
|
|
488
|
-
: completedReviews.get(requestedPrNumber, repository);
|
|
489
|
-
if (!completed) {
|
|
499
|
+
const repository = await resolveRepositoryBinding(ctx.cwd);
|
|
500
|
+
const record = requestedPrNumber === undefined
|
|
501
|
+
? completedReviews.latest(repository)
|
|
502
|
+
: completedReviews.get(requestedPrNumber, repository);
|
|
503
|
+
if (record) return { record };
|
|
490
504
|
const target = requestedPrNumber === undefined ? "the latest PR" : `PR #${requestedPrNumber}`;
|
|
491
505
|
return {
|
|
492
506
|
error: `No completed review for ${target} is cached for this repository in the current extension session. Publishing never starts or reruns a review.`,
|
|
493
507
|
};
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return { error: `Cannot resolve the current GitHub repository: ${String(error)}` };
|
|
494
510
|
}
|
|
495
|
-
const prNumber = completed.invocation.prNumber;
|
|
496
|
-
const headSha = completed.review.pr?.head_sha;
|
|
497
|
-
if (typeof headSha !== "string") return { error: "Cached PR review is missing pr.head_sha" };
|
|
498
|
-
const allowStale = allowStaleOverride ?? completed.invocation.allowStalePublish;
|
|
499
|
-
return {
|
|
500
|
-
result: await publishPullReview({
|
|
501
|
-
cwd: ctx.cwd,
|
|
502
|
-
prNumber,
|
|
503
|
-
headSha,
|
|
504
|
-
allowNonOpen: completed.invocation.allowNonOpen,
|
|
505
|
-
allowStale,
|
|
506
|
-
expectedRepository: completed.repository,
|
|
507
|
-
review: completed.review,
|
|
508
|
-
}),
|
|
509
|
-
};
|
|
510
511
|
};
|
|
511
512
|
|
|
512
513
|
pi.registerCommand("pr-review-publish", {
|
|
@@ -535,20 +536,15 @@ export default function registerReviewTable(
|
|
|
535
536
|
);
|
|
536
537
|
return;
|
|
537
538
|
}
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
ctx,
|
|
542
|
-
);
|
|
543
|
-
if ("error" in published) {
|
|
544
|
-
ctx.ui.notify(published.error, "error");
|
|
539
|
+
const resolved = await resolveCachedReview(parsed.prNumber, ctx);
|
|
540
|
+
if ("error" in resolved) {
|
|
541
|
+
ctx.ui.notify(resolved.error, "error");
|
|
545
542
|
return;
|
|
546
543
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
parsed.allowStale ? "
|
|
550
|
-
|
|
551
|
-
);
|
|
544
|
+
await publishCompletedReview(resolved.record, {
|
|
545
|
+
kind: "publish-command",
|
|
546
|
+
stalePolicy: parsed.allowStale ? "allow-stale" : "frozen",
|
|
547
|
+
}, ctx);
|
|
552
548
|
},
|
|
553
549
|
});
|
|
554
550
|
|
|
@@ -630,9 +626,9 @@ export default function registerReviewTable(
|
|
|
630
626
|
);
|
|
631
627
|
return { action: "handled" as const };
|
|
632
628
|
}
|
|
633
|
-
const
|
|
634
|
-
if ("error" in
|
|
635
|
-
else
|
|
629
|
+
const resolved = await resolveCachedReview(directPublish.prNumber, ctx);
|
|
630
|
+
if ("error" in resolved) ctx.ui.notify(resolved.error, "error");
|
|
631
|
+
else await publishCompletedReview(resolved.record, { kind: "direct-request" }, ctx);
|
|
636
632
|
return { action: "handled" as const };
|
|
637
633
|
}
|
|
638
634
|
if (loopCoordinator.phase() === "awaiting_confirmation") {
|
|
@@ -699,24 +695,21 @@ export default function registerReviewTable(
|
|
|
699
695
|
const pending = pendingCompletion;
|
|
700
696
|
pendingCompletion = undefined;
|
|
701
697
|
if (!pending) return;
|
|
702
|
-
if (
|
|
698
|
+
if ("error" in pending) {
|
|
699
|
+
ctx.ui.notify(`PR review was not posted: ${pending.error}`, "error");
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (pending.session) {
|
|
703
703
|
const currentSession = sessionIdentity(ctx);
|
|
704
|
-
if (
|
|
705
|
-
!currentSession ||
|
|
706
|
-
currentSession.id !== pending.session.id ||
|
|
707
|
-
currentSession.startedAt !== pending.session.startedAt
|
|
708
|
-
) {
|
|
704
|
+
if (!currentSession || currentSession.id !== pending.session.id || currentSession.startedAt !== pending.session.startedAt) {
|
|
709
705
|
ctx.ui.notify("Completed review was not persisted or posted because the session identity changed", "warning");
|
|
710
706
|
return;
|
|
711
707
|
}
|
|
712
708
|
const leaf = ctx.sessionManager.getLeafEntry();
|
|
713
|
-
const
|
|
714
|
-
? assistantText(leaf.message as { content?: MessagePart[] })
|
|
715
|
-
: "";
|
|
716
|
-
const leafReview = parsePublishableReview(leafText).review;
|
|
717
|
-
const reviewEntryId = leaf?.type === "message" && leaf.message.role === "assistant" && leafReview
|
|
718
|
-
? leaf.id
|
|
709
|
+
const leafReview = leaf?.type === "message" && leaf.message.role === "assistant"
|
|
710
|
+
? parsePublishableReview(assistantText(leaf.message as { content?: MessagePart[] })).review
|
|
719
711
|
: undefined;
|
|
712
|
+
const reviewEntryId = leafReview ? leaf?.id : undefined;
|
|
720
713
|
try {
|
|
721
714
|
// Persist before any GitHub preflight so a failed post always remains retryable.
|
|
722
715
|
pi.appendEntry(
|
|
@@ -727,7 +720,7 @@ export default function registerReviewTable(
|
|
|
727
720
|
ctx.ui.notify(`Completed review cache will not survive an extension reload: ${String(error)}`, "warning");
|
|
728
721
|
}
|
|
729
722
|
}
|
|
730
|
-
await
|
|
723
|
+
await publishCompletedReview(pending.record, { kind: "frozen-invocation" }, ctx);
|
|
731
724
|
});
|
|
732
725
|
|
|
733
726
|
pi.on("message_end", async (event, ctx) => {
|
|
@@ -777,7 +770,7 @@ export default function registerReviewTable(
|
|
|
777
770
|
return;
|
|
778
771
|
}
|
|
779
772
|
|
|
780
|
-
|
|
773
|
+
const publishable = active ? parsePublishableReview(text) : undefined;
|
|
781
774
|
if (active && !publishable?.review && !outputRepairAttempted) {
|
|
782
775
|
const error = publishable?.error ?? "final review JSON is invalid";
|
|
783
776
|
if (loopCoordinator.suspendToolsForRepair()) {
|
|
@@ -810,36 +803,19 @@ export default function registerReviewTable(
|
|
|
810
803
|
outputRepairCancelled = false;
|
|
811
804
|
persistTelemetry("terminal_response");
|
|
812
805
|
}
|
|
813
|
-
const review =
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
!validateReviewInvocation(publishable.review, invocation) &&
|
|
822
|
-
shouldPublishReview(publishable.review)
|
|
823
|
-
) {
|
|
824
|
-
try {
|
|
825
|
-
repository = await resolveRepositoryBinding(ctx.cwd);
|
|
826
|
-
// Cache before publication preflight; persist after Pi stores the assistant message.
|
|
827
|
-
record = completedReviews.remember(publishable.review, invocation, repository);
|
|
828
|
-
session = sessionIdentity(ctx);
|
|
829
|
-
if (!session) {
|
|
830
|
-
ctx.ui.notify("Completed review cache will not survive reload: session identity is unavailable", "warning");
|
|
831
|
-
}
|
|
832
|
-
} catch (error) {
|
|
833
|
-
ctx.ui.notify(`Completed review is not available to publish-only: ${String(error)}`, "warning");
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
pendingCompletion = { text, invocation, repository, record, session };
|
|
837
|
-
}
|
|
806
|
+
const review = publishable?.review
|
|
807
|
+
? publishable.review as Review
|
|
808
|
+
: active
|
|
809
|
+
? null
|
|
810
|
+
: text.trim()
|
|
811
|
+
? parseReview(text)
|
|
812
|
+
: null;
|
|
813
|
+
if (invocation) pendingCompletion = await resolveCompletion(publishable!, invocation, ctx);
|
|
838
814
|
if (!review) return; // not a renderable /pr-review JSON payload — leave untouched
|
|
839
815
|
|
|
840
816
|
// Keep raw JSON for automation; only prettify for interactive terminals.
|
|
841
817
|
if (ctx.mode !== "tui") return;
|
|
842
|
-
const nonText =
|
|
818
|
+
const nonText = event.message.content.filter((part) => part.type !== "text");
|
|
843
819
|
return {
|
|
844
820
|
message: {
|
|
845
821
|
...event.message,
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -301,6 +301,8 @@ export const MAX_INLINE_COMMENTS = 50;
|
|
|
301
301
|
const MAX_BODY_BYTES = 65_536;
|
|
302
302
|
const MAX_PAYLOAD_BYTES = 900_000;
|
|
303
303
|
const RESERVED_MARKER_PREFIX = "<!-- pi-pr-review:";
|
|
304
|
+
const CHANGED_FILE_LOOKUP_DIAGNOSTIC =
|
|
305
|
+
"changed-file lookup failed; all inline findings kept in the review summary";
|
|
304
306
|
|
|
305
307
|
export interface PublishComment {
|
|
306
308
|
path: string;
|
|
@@ -617,15 +619,33 @@ function isConfidence(value: unknown): value is number {
|
|
|
617
619
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
|
|
618
620
|
}
|
|
619
621
|
|
|
620
|
-
/**
|
|
622
|
+
/**
|
|
623
|
+
* Strip a single surrounding Markdown fenced code block (```lang … ```) so a model
|
|
624
|
+
* response that wraps the review object in a code fence — despite the instruction
|
|
625
|
+
* to emit exactly one JSON object — can still be parsed without a repair round-trip.
|
|
626
|
+
* Prose-wrapped or mixed drafts are intentionally left untouched (still rejected).
|
|
627
|
+
* Returns the original text when there is no recognizable outer fence.
|
|
628
|
+
*/
|
|
629
|
+
function stripMarkdownCodeFence(text: string): string {
|
|
630
|
+
const match = text.trim().match(/^```[^\n]*\n([\s\S]*)\n```[ \t]*$/);
|
|
631
|
+
return match ? match[1] : text;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Publication accepts one complete JSON object. A single surrounding Markdown code
|
|
636
|
+
* fence is tolerated and stripped; prose-wrapped or partial drafts are rejected.
|
|
637
|
+
*/
|
|
621
638
|
export function parsePublishableReview(text: string): PublishableReviewParseResult {
|
|
622
639
|
let value: unknown;
|
|
623
640
|
try {
|
|
624
|
-
value = JSON.parse(text.trim());
|
|
641
|
+
value = JSON.parse(stripMarkdownCodeFence(text).trim());
|
|
625
642
|
} catch {
|
|
626
643
|
return { error: "final response is not exactly one JSON object" };
|
|
627
644
|
}
|
|
628
645
|
if (!isObject(value)) return { error: "final review must be a JSON object" };
|
|
646
|
+
if (containsReservedReviewMarker(JSON.stringify(value))) {
|
|
647
|
+
return { error: "review content contains a reserved pi-pr-review marker" };
|
|
648
|
+
}
|
|
629
649
|
const pr = value.pr;
|
|
630
650
|
if (!isObject(pr) || !Number.isInteger(pr.number) || Number(pr.number) <= 0 || typeof pr.title !== "string") {
|
|
631
651
|
return { error: "pr.number and pr.title are required" };
|
|
@@ -658,21 +678,8 @@ export function parsePublishableReview(text: string): PublishableReviewParseResu
|
|
|
658
678
|
if (!isConfidence(finding.confidence_score)) {
|
|
659
679
|
return { error: `finding ${index + 1} has invalid confidence_score` };
|
|
660
680
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (!isObject(location) || typeof location.commentable !== "boolean") {
|
|
664
|
-
return { error: `finding ${index + 1} has invalid code_location` };
|
|
665
|
-
}
|
|
666
|
-
if (location.absolute_file_path !== null && typeof location.absolute_file_path !== "string") {
|
|
667
|
-
return { error: `finding ${index + 1} has invalid absolute_file_path` };
|
|
668
|
-
}
|
|
669
|
-
if (location.side !== null && location.side !== "LEFT" && location.side !== "RIGHT") {
|
|
670
|
-
return { error: `finding ${index + 1} has invalid side` };
|
|
671
|
-
}
|
|
672
|
-
if (!isObject(location.line_range) || !Number.isInteger(location.line_range.start) || !Number.isInteger(location.line_range.end)) {
|
|
673
|
-
return { error: `finding ${index + 1} has invalid line_range` };
|
|
674
|
-
}
|
|
675
|
-
}
|
|
681
|
+
const locationError = validateFindingLocation(finding as ReviewFindingLike, index);
|
|
682
|
+
if (locationError) return { error: locationError };
|
|
676
683
|
}
|
|
677
684
|
if (!isObject(value.notes)) return { error: "notes must be an object" };
|
|
678
685
|
for (const key of ["correctness", "security", "performance"] as const) {
|
|
@@ -694,6 +701,88 @@ export function shouldPublishReview(review: ReviewLike): boolean {
|
|
|
694
701
|
return review.disposition === "reviewed";
|
|
695
702
|
}
|
|
696
703
|
|
|
704
|
+
interface DiffHunk {
|
|
705
|
+
left: Set<number>;
|
|
706
|
+
right: Set<number>;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function parsePatchHunks(patch: string): DiffHunk[] {
|
|
710
|
+
const hunks: DiffHunk[] = [];
|
|
711
|
+
let current: DiffHunk | undefined;
|
|
712
|
+
let left = 0;
|
|
713
|
+
let right = 0;
|
|
714
|
+
for (const line of patch.split("\n")) {
|
|
715
|
+
const header = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
716
|
+
if (header) {
|
|
717
|
+
left = Number(header[1]);
|
|
718
|
+
right = Number(header[2]);
|
|
719
|
+
current = { left: new Set<number>(), right: new Set<number>() };
|
|
720
|
+
hunks.push(current);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (!current || line.startsWith("\\")) continue;
|
|
724
|
+
if (line.startsWith("+")) {
|
|
725
|
+
current.right.add(right++);
|
|
726
|
+
} else if (line.startsWith("-")) {
|
|
727
|
+
current.left.add(left++);
|
|
728
|
+
} else if (line.startsWith(" ")) {
|
|
729
|
+
current.left.add(left++);
|
|
730
|
+
current.right.add(right++);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return hunks;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export interface ChangedFileLike {
|
|
737
|
+
filename?: string;
|
|
738
|
+
patch?: string;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function safeRelativePath(value: string): boolean {
|
|
742
|
+
if (!value || value.startsWith("/") || value.includes("\\") || /[\0-\x1f\x7f]/.test(value)) return false;
|
|
743
|
+
const segments = value.split("/");
|
|
744
|
+
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function validateFindingLocation(finding: ReviewFindingLike, index: number): string | undefined {
|
|
748
|
+
const location = finding.code_location;
|
|
749
|
+
const label = `finding ${index + 1}`;
|
|
750
|
+
if (location === null) return undefined;
|
|
751
|
+
if (!isObject(location) || typeof location.commentable !== "boolean") {
|
|
752
|
+
return `${label} has invalid code_location`;
|
|
753
|
+
}
|
|
754
|
+
const path = location.absolute_file_path;
|
|
755
|
+
if (path !== null && typeof path !== "string") return `${label} has invalid absolute_file_path`;
|
|
756
|
+
const side = location.side;
|
|
757
|
+
if (side !== null && side !== "LEFT" && side !== "RIGHT") return `${label} has invalid side`;
|
|
758
|
+
const range = location.line_range;
|
|
759
|
+
if (!isObject(range) || !Number.isInteger(range.start) || !Number.isInteger(range.end)) {
|
|
760
|
+
return `${label} has invalid line_range`;
|
|
761
|
+
}
|
|
762
|
+
const start = Number(range.start);
|
|
763
|
+
const end = Number(range.end);
|
|
764
|
+
if (path === null) {
|
|
765
|
+
if (location.commentable) return `${label}: commentable location is missing a repo-relative path`;
|
|
766
|
+
if (side !== null || start !== 0 || end !== 0) {
|
|
767
|
+
return `${label}: location without a path must use null side and a 0:0 range`;
|
|
768
|
+
}
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
if (!safeRelativePath(path)) return `${label}: invalid repo-relative path`;
|
|
772
|
+
if (side !== "LEFT" && side !== "RIGHT") return `${label}: side must be LEFT or RIGHT`;
|
|
773
|
+
if (start <= 0 || end < start) return `${label}: invalid line range`;
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function isInlineSeverity(finding: ReviewFindingLike): boolean {
|
|
778
|
+
const severity = String(finding.severity ?? "").toUpperCase();
|
|
779
|
+
return ["P0", "P1", "P2", "P3"].includes(severity);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function publishCommentAnchor(comment: PublishComment): string {
|
|
783
|
+
return `${comment.path}:${comment.side}:${comment.start_line ?? comment.line}:${comment.line}`;
|
|
784
|
+
}
|
|
785
|
+
|
|
697
786
|
function cell(value: string): string {
|
|
698
787
|
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
|
|
699
788
|
}
|
|
@@ -707,10 +796,6 @@ function findingLocation(finding: ReviewFindingLike): string {
|
|
|
707
796
|
return `${location.absolute_file_path}:${start}${end !== start ? `-${end}` : ""} ${(location.side ?? "RIGHT").toUpperCase()}`;
|
|
708
797
|
}
|
|
709
798
|
|
|
710
|
-
function publishCommentAnchor(comment: PublishComment): string {
|
|
711
|
-
return `${comment.path}:${comment.side}:${comment.start_line ?? comment.line}:${comment.line}`;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
799
|
function findingAnchor(finding: ReviewFindingLike): string | undefined {
|
|
715
800
|
const location = finding.code_location;
|
|
716
801
|
const path = location?.absolute_file_path;
|
|
@@ -786,140 +871,141 @@ export function buildReviewSummary(review: ReviewLike, inlineComments: PublishCo
|
|
|
786
871
|
return lines.join("\n").trim();
|
|
787
872
|
}
|
|
788
873
|
|
|
789
|
-
interface
|
|
790
|
-
left: Set<number>;
|
|
791
|
-
right: Set<number>;
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
function parsePatchHunks(patch: string): DiffHunk[] {
|
|
795
|
-
const hunks: DiffHunk[] = [];
|
|
796
|
-
let current: DiffHunk | undefined;
|
|
797
|
-
let left = 0;
|
|
798
|
-
let right = 0;
|
|
799
|
-
for (const line of patch.split("\n")) {
|
|
800
|
-
const header = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
801
|
-
if (header) {
|
|
802
|
-
left = Number(header[1]);
|
|
803
|
-
right = Number(header[2]);
|
|
804
|
-
current = { left: new Set<number>(), right: new Set<number>() };
|
|
805
|
-
hunks.push(current);
|
|
806
|
-
continue;
|
|
807
|
-
}
|
|
808
|
-
if (!current || line.startsWith("\\")) continue;
|
|
809
|
-
if (line.startsWith("+")) {
|
|
810
|
-
current.right.add(right++);
|
|
811
|
-
} else if (line.startsWith("-")) {
|
|
812
|
-
current.left.add(left++);
|
|
813
|
-
} else if (line.startsWith(" ")) {
|
|
814
|
-
current.left.add(left++);
|
|
815
|
-
current.right.add(right++);
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
return hunks;
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
export interface ChangedFileLike {
|
|
822
|
-
filename?: string;
|
|
823
|
-
patch?: string;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
function safeRelativePath(value: string): boolean {
|
|
827
|
-
if (!value || value.startsWith("/") || value.includes("\\") || value.includes("\0")) return false;
|
|
828
|
-
const segments = value.split("/");
|
|
829
|
-
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function isInlineSeverity(finding: ReviewFindingLike): boolean {
|
|
833
|
-
const severity = String(finding.severity ?? "").toUpperCase();
|
|
834
|
-
return ["P0", "P1", "P2", "P3"].includes(severity);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
export interface CommentValidationResult {
|
|
874
|
+
interface InlineSelection {
|
|
838
875
|
comments: PublishComment[];
|
|
876
|
+
diagnostics: string[];
|
|
839
877
|
errors: string[];
|
|
840
|
-
warnings?: string[];
|
|
841
878
|
}
|
|
842
879
|
|
|
843
|
-
|
|
880
|
+
function buildPublishComment(
|
|
881
|
+
path: string,
|
|
882
|
+
body: string,
|
|
883
|
+
side: "LEFT" | "RIGHT",
|
|
884
|
+
start: number,
|
|
885
|
+
end: number,
|
|
886
|
+
): PublishComment {
|
|
887
|
+
return {
|
|
888
|
+
path,
|
|
889
|
+
body,
|
|
890
|
+
line: end,
|
|
891
|
+
side,
|
|
892
|
+
...(start < end ? { start_line: start, start_side: side } : {}),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function selectInlineComments(
|
|
844
897
|
review: ReviewLike,
|
|
845
|
-
changedFiles: ChangedFileLike[],
|
|
846
|
-
):
|
|
847
|
-
const errors: string[] = [];
|
|
848
|
-
const warnings: string[] = [];
|
|
849
|
-
const comments: PublishComment[] = [];
|
|
898
|
+
changedFiles: readonly ChangedFileLike[],
|
|
899
|
+
): InlineSelection {
|
|
850
900
|
const files = new Map<string, ChangedFileLike>();
|
|
851
901
|
for (const file of changedFiles) {
|
|
852
|
-
if (file
|
|
902
|
+
if (!file || typeof file.filename !== "string") continue;
|
|
903
|
+
const existing = files.get(file.filename);
|
|
904
|
+
if (!existing || (!existing.patch && file.patch)) files.set(file.filename, file);
|
|
853
905
|
}
|
|
906
|
+
const comments: PublishComment[] = [];
|
|
907
|
+
const diagnostics: string[] = [];
|
|
908
|
+
const errors: string[] = [];
|
|
854
909
|
const anchors = new Set<string>();
|
|
910
|
+
const hunkCache = new Map<string, DiffHunk[]>();
|
|
855
911
|
for (const [index, finding] of (review.findings ?? []).entries()) {
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
const end = location.line_range?.end;
|
|
862
|
-
const label = `finding ${index + 1}`;
|
|
863
|
-
if (!safeRelativePath(path)) {
|
|
864
|
-
errors.push(`${label}: invalid repo-relative path`);
|
|
912
|
+
const locationError = finding.code_location === undefined
|
|
913
|
+
? undefined
|
|
914
|
+
: validateFindingLocation(finding, index);
|
|
915
|
+
if (locationError) {
|
|
916
|
+
errors.push(locationError);
|
|
865
917
|
continue;
|
|
866
918
|
}
|
|
867
|
-
if (
|
|
868
|
-
|
|
919
|
+
if (!finding.code_location?.commentable || !isInlineSeverity(finding)) continue;
|
|
920
|
+
const location = finding.code_location;
|
|
921
|
+
const label = `finding ${index + 1}`;
|
|
922
|
+
const path = location.absolute_file_path as string;
|
|
923
|
+
const side = location.side as "LEFT" | "RIGHT";
|
|
924
|
+
const start = Number(location.line_range?.start);
|
|
925
|
+
const end = Number(location.line_range?.end);
|
|
926
|
+
const body = [
|
|
927
|
+
finding.title?.trim() ? `**${finding.title.trim()}**` : "",
|
|
928
|
+
finding.body?.trim(),
|
|
929
|
+
].filter(Boolean).join("\n\n");
|
|
930
|
+
if (!body || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
931
|
+
errors.push(`${label}: comment body is empty or too large`);
|
|
869
932
|
continue;
|
|
870
933
|
}
|
|
871
|
-
if (
|
|
872
|
-
errors.push(`${label}:
|
|
934
|
+
if (containsReservedReviewMarker(body)) {
|
|
935
|
+
errors.push(`${label}: comment body contains a reserved pi-pr-review marker`);
|
|
873
936
|
continue;
|
|
874
937
|
}
|
|
938
|
+
const comment = buildPublishComment(path, body, side, start, end);
|
|
875
939
|
const file = files.get(path);
|
|
876
940
|
if (!file) {
|
|
877
|
-
|
|
941
|
+
diagnostics.push(`${label}: path is not a changed file; kept in the review summary`);
|
|
878
942
|
continue;
|
|
879
943
|
}
|
|
880
|
-
if (
|
|
881
|
-
|
|
882
|
-
// Keep the complete finding in the review summary rather than sending an unvalidated inline anchor.
|
|
883
|
-
warnings.push(`${label}: diff patch is unavailable; kept in the review summary`);
|
|
944
|
+
if (typeof file.patch !== "string" || file.patch.length === 0) {
|
|
945
|
+
diagnostics.push(`${label}: diff patch is unavailable; kept in the review summary`);
|
|
884
946
|
continue;
|
|
885
947
|
}
|
|
948
|
+
let hunks = hunkCache.get(path);
|
|
949
|
+
if (!hunks) {
|
|
950
|
+
hunks = parsePatchHunks(file.patch);
|
|
951
|
+
hunkCache.set(path, hunks);
|
|
952
|
+
}
|
|
886
953
|
const sideKey = side === "LEFT" ? "left" : "right";
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
errors.push(`${label}: line range is not inside one diff hunk on ${side}`);
|
|
954
|
+
if (!hunks.some((hunk) => hunk[sideKey].has(start) && hunk[sideKey].has(end))) {
|
|
955
|
+
diagnostics.push(
|
|
956
|
+
`${label}: line range is not inside one diff hunk on ${side}; kept in the review summary`,
|
|
957
|
+
);
|
|
892
958
|
continue;
|
|
893
959
|
}
|
|
894
|
-
const anchor =
|
|
895
|
-
|
|
896
|
-
.
|
|
897
|
-
.join("\n\n");
|
|
898
|
-
if (!body || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
899
|
-
errors.push(`${label}: comment body is empty or too large`);
|
|
960
|
+
const anchor = publishCommentAnchor(comment);
|
|
961
|
+
if (anchors.has(anchor)) {
|
|
962
|
+
diagnostics.push(`${label}: duplicate inline anchor; kept in the review summary`);
|
|
900
963
|
continue;
|
|
901
964
|
}
|
|
902
|
-
|
|
903
|
-
|
|
965
|
+
anchors.add(anchor);
|
|
966
|
+
if (comments.length >= MAX_INLINE_COMMENTS) {
|
|
967
|
+
diagnostics.push(
|
|
968
|
+
`${label}: inline comment limit of ${MAX_INLINE_COMMENTS} reached; kept in the review summary`,
|
|
969
|
+
);
|
|
904
970
|
continue;
|
|
905
971
|
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
972
|
+
comments.push(comment);
|
|
973
|
+
}
|
|
974
|
+
return { comments, diagnostics, errors };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export interface CommentValidationResult {
|
|
978
|
+
comments: PublishComment[];
|
|
979
|
+
errors: string[];
|
|
980
|
+
warnings?: string[];
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function canonicalReviewSnapshot(review: ReviewLike): PublishableReviewParseResult {
|
|
984
|
+
let serialized: string | undefined;
|
|
985
|
+
try {
|
|
986
|
+
serialized = JSON.stringify(review);
|
|
987
|
+
} catch {
|
|
988
|
+
return { error: "review could not be serialized for publication" };
|
|
916
989
|
}
|
|
917
|
-
if (
|
|
918
|
-
|
|
990
|
+
if (typeof serialized !== "string") {
|
|
991
|
+
return { error: "review could not be serialized for publication" };
|
|
919
992
|
}
|
|
920
|
-
return
|
|
993
|
+
return parsePublishableReview(serialized);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export function validateInlineComments(
|
|
997
|
+
review: ReviewLike,
|
|
998
|
+
changedFiles: readonly ChangedFileLike[],
|
|
999
|
+
): CommentValidationResult {
|
|
1000
|
+
const selected = selectInlineComments(review, changedFiles);
|
|
1001
|
+
return {
|
|
1002
|
+
comments: selected.comments,
|
|
1003
|
+
errors: selected.errors,
|
|
1004
|
+
warnings: selected.diagnostics,
|
|
1005
|
+
};
|
|
921
1006
|
}
|
|
922
1007
|
|
|
1008
|
+
/** Compatibility helper for callers that fold would-be inline findings into a body-only review. */
|
|
923
1009
|
export function collectFoldedComments(review: ReviewLike): CommentValidationResult {
|
|
924
1010
|
const comments: PublishComment[] = [];
|
|
925
1011
|
const errors: string[] = [];
|
|
@@ -950,17 +1036,12 @@ export function collectFoldedComments(review: ReviewLike): CommentValidationResu
|
|
|
950
1036
|
errors.push(`${label}: folded comment body contains a reserved pi-pr-review marker`);
|
|
951
1037
|
continue;
|
|
952
1038
|
}
|
|
953
|
-
comments.push(
|
|
954
|
-
path,
|
|
955
|
-
body,
|
|
956
|
-
line: Number(end),
|
|
957
|
-
side,
|
|
958
|
-
...(Number(start) < Number(end) ? { start_line: Number(start), start_side: side } : {}),
|
|
959
|
-
});
|
|
1039
|
+
comments.push(buildPublishComment(path, body, side, Number(start), Number(end)));
|
|
960
1040
|
}
|
|
961
1041
|
return { comments, errors, warnings: [] };
|
|
962
1042
|
}
|
|
963
1043
|
|
|
1044
|
+
/** Compatibility formatter for body-only reviews assembled by earlier consumers. */
|
|
964
1045
|
export function foldInlineComments(summary: string, comments: PublishComment[]): string {
|
|
965
1046
|
if (comments.length === 0) return summary;
|
|
966
1047
|
const lines = [summary, "", "### Inline findings (folded for a non-open PR)", ""];
|
|
@@ -982,6 +1063,59 @@ function validateReviewBody(body: string): string | undefined {
|
|
|
982
1063
|
return undefined;
|
|
983
1064
|
}
|
|
984
1065
|
|
|
1066
|
+
function hasInlineCandidates(review: ReviewLike): boolean {
|
|
1067
|
+
return (review.findings ?? []).some(
|
|
1068
|
+
(finding) => finding.code_location?.commentable === true && isInlineSeverity(finding),
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function buildLosslessReviewPayload(input: {
|
|
1073
|
+
review: ReviewLike;
|
|
1074
|
+
commitId: string;
|
|
1075
|
+
markerHeadSha: string;
|
|
1076
|
+
allowInlineComments: boolean;
|
|
1077
|
+
changedFiles?: readonly ChangedFileLike[];
|
|
1078
|
+
bodyPreamble?: string;
|
|
1079
|
+
diagnostics?: readonly string[];
|
|
1080
|
+
}): { payload?: PullReviewPayload; diagnostics: string[]; errors: string[] } {
|
|
1081
|
+
const diagnostics = [...(input.diagnostics ?? [])];
|
|
1082
|
+
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.commitId)) {
|
|
1083
|
+
return { diagnostics, errors: ["publication commit ID must be a full hexadecimal commit SHA"] };
|
|
1084
|
+
}
|
|
1085
|
+
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.markerHeadSha)) {
|
|
1086
|
+
return { diagnostics, errors: ["publication marker head must be a full hexadecimal commit SHA"] };
|
|
1087
|
+
}
|
|
1088
|
+
const markerHeadSha = input.markerHeadSha.toLowerCase();
|
|
1089
|
+
if (input.review.pr?.head_sha?.toLowerCase() !== markerHeadSha) {
|
|
1090
|
+
return { diagnostics, errors: ["publication marker head does not match the validated review head"] };
|
|
1091
|
+
}
|
|
1092
|
+
if (input.bodyPreamble && containsReservedReviewMarker(input.bodyPreamble)) {
|
|
1093
|
+
return { diagnostics, errors: ["publication preamble contains a reserved pi-pr-review marker"] };
|
|
1094
|
+
}
|
|
1095
|
+
const selected = input.allowInlineComments
|
|
1096
|
+
? selectInlineComments(input.review, input.changedFiles ?? [])
|
|
1097
|
+
: { comments: [], diagnostics: [], errors: [] };
|
|
1098
|
+
diagnostics.push(...selected.diagnostics);
|
|
1099
|
+
if (selected.errors.length > 0) return { diagnostics, errors: selected.errors };
|
|
1100
|
+
|
|
1101
|
+
let content = buildReviewSummary(input.review, selected.comments);
|
|
1102
|
+
if (diagnostics.length > 0) {
|
|
1103
|
+
content = `${content}\n\n### Publication diagnostics\n\n${diagnostics.map((item) => `- ${item}`).join("\n")}`;
|
|
1104
|
+
}
|
|
1105
|
+
if (input.bodyPreamble?.trim()) content = `${input.bodyPreamble.trim()}\n\n${content}`;
|
|
1106
|
+
const bodyError = validateReviewBody(content);
|
|
1107
|
+
if (bodyError) return { diagnostics, errors: [bodyError] };
|
|
1108
|
+
const body = `${content}\n\n${canonicalReviewMarker(markerHeadSha)}`;
|
|
1109
|
+
if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
1110
|
+
return { diagnostics, errors: ["final review body exceeds 65536 UTF-8 bytes"] };
|
|
1111
|
+
}
|
|
1112
|
+
const payload = buildPullReviewPayload(input.commitId.toLowerCase(), body, selected.comments);
|
|
1113
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
|
|
1114
|
+
return { diagnostics, errors: ["review payload is too large"] };
|
|
1115
|
+
}
|
|
1116
|
+
return { payload, diagnostics, errors: [] };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
985
1119
|
interface GhResult {
|
|
986
1120
|
stdout: string;
|
|
987
1121
|
stderr: string;
|
|
@@ -1046,15 +1180,49 @@ export async function resolveRepositoryBinding(cwd: string): Promise<RepositoryB
|
|
|
1046
1180
|
return binding;
|
|
1047
1181
|
}
|
|
1048
1182
|
|
|
1049
|
-
function
|
|
1050
|
-
if (!Array.isArray(value)) return
|
|
1051
|
-
if (value.
|
|
1052
|
-
|
|
1183
|
+
function normalizeChangedFilePages(value: unknown): ChangedFileLike[] | undefined {
|
|
1184
|
+
if (!Array.isArray(value)) return undefined;
|
|
1185
|
+
if (value.some(Array.isArray) && !value.every(Array.isArray)) return undefined;
|
|
1186
|
+
const entries: unknown[] = value.every(Array.isArray) ? value.flat() : value;
|
|
1187
|
+
const files: ChangedFileLike[] = [];
|
|
1188
|
+
for (const entry of entries) {
|
|
1189
|
+
if (!isObject(entry) || typeof entry.filename !== "string") return undefined;
|
|
1190
|
+
if (entry.patch !== undefined && entry.patch !== null && typeof entry.patch !== "string") {
|
|
1191
|
+
return undefined;
|
|
1192
|
+
}
|
|
1193
|
+
files.push({
|
|
1194
|
+
filename: entry.filename,
|
|
1195
|
+
...(typeof entry.patch === "string" ? { patch: entry.patch } : {}),
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
return files;
|
|
1053
1199
|
}
|
|
1054
1200
|
|
|
1055
1201
|
interface AuthoredBody {
|
|
1056
|
-
body
|
|
1057
|
-
user
|
|
1202
|
+
body: string | null;
|
|
1203
|
+
user: { login: string | null } | null;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function normalizeAuthoredBodyPages(value: unknown): AuthoredBody[] | undefined {
|
|
1207
|
+
if (!Array.isArray(value)) return undefined;
|
|
1208
|
+
if (value.some(Array.isArray) && !value.every(Array.isArray)) return undefined;
|
|
1209
|
+
const entries: unknown[] = value.every(Array.isArray) ? value.flat() : value;
|
|
1210
|
+
const authoredBodies: AuthoredBody[] = [];
|
|
1211
|
+
for (const entry of entries) {
|
|
1212
|
+
if (!isObject(entry) || (entry.body !== null && typeof entry.body !== "string")) {
|
|
1213
|
+
return undefined;
|
|
1214
|
+
}
|
|
1215
|
+
const user = entry.user;
|
|
1216
|
+
if (user === null) {
|
|
1217
|
+
authoredBodies.push({ body: entry.body, user: null });
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (!isObject(user)) return undefined;
|
|
1221
|
+
const login = user.login;
|
|
1222
|
+
if (login !== null && typeof login !== "string") return undefined;
|
|
1223
|
+
authoredBodies.push({ body: entry.body, user: { login } });
|
|
1224
|
+
}
|
|
1225
|
+
return authoredBodies;
|
|
1058
1226
|
}
|
|
1059
1227
|
|
|
1060
1228
|
export function bodyHasHeadMarker(body: string | null | undefined, normalizedHeadSha: string): boolean {
|
|
@@ -1078,11 +1246,15 @@ async function hasExistingMarker(
|
|
|
1078
1246
|
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/reviews?per_page=100`),
|
|
1079
1247
|
cwd,
|
|
1080
1248
|
);
|
|
1249
|
+
const reviews = normalizeAuthoredBodyPages(reviewPages);
|
|
1250
|
+
if (!reviews) throw new Error("invalid paginated pull review response");
|
|
1081
1251
|
const commentPages = await ghJson<unknown>(
|
|
1082
1252
|
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/issues/${prNumber}/comments?per_page=100`),
|
|
1083
1253
|
cwd,
|
|
1084
1254
|
);
|
|
1085
|
-
|
|
1255
|
+
const comments = normalizeAuthoredBodyPages(commentPages);
|
|
1256
|
+
if (!comments) throw new Error("invalid paginated issue comment response");
|
|
1257
|
+
return [...reviews, ...comments].some(
|
|
1086
1258
|
(item) =>
|
|
1087
1259
|
item.user?.login?.toLowerCase() === identity.toLowerCase() &&
|
|
1088
1260
|
bodyHasHeadMarker(item.body, normalizedHeadSha),
|
|
@@ -1204,6 +1376,23 @@ export async function publishPullReview(input: {
|
|
|
1204
1376
|
if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
|
|
1205
1377
|
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
|
|
1206
1378
|
const normalizedHeadSha = headSha.toLowerCase();
|
|
1379
|
+
const snapshot = canonicalReviewSnapshot(review);
|
|
1380
|
+
const validatedReview = snapshot.review;
|
|
1381
|
+
if (!validatedReview) {
|
|
1382
|
+
return {
|
|
1383
|
+
status: "failed",
|
|
1384
|
+
message: `publication planning failed: ${snapshot.error ?? "review is not publishable"}`,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
if (!shouldPublishReview(validatedReview)) {
|
|
1388
|
+
return { status: "failed", message: "only completed reviewed dispositions can be published" };
|
|
1389
|
+
}
|
|
1390
|
+
if (validatedReview.pr?.number !== prNumber) {
|
|
1391
|
+
return { status: "failed", message: "validated review PR number does not match the publication target" };
|
|
1392
|
+
}
|
|
1393
|
+
if (validatedReview.pr?.head_sha?.toLowerCase() !== normalizedHeadSha) {
|
|
1394
|
+
return { status: "failed", message: "validated review head does not match the publication target" };
|
|
1395
|
+
}
|
|
1207
1396
|
|
|
1208
1397
|
let repository: string;
|
|
1209
1398
|
let hostname: string;
|
|
@@ -1224,7 +1413,6 @@ export async function publishPullReview(input: {
|
|
|
1224
1413
|
}
|
|
1225
1414
|
if (!identity) return { status: "failed", message: "invalid GitHub identity" };
|
|
1226
1415
|
|
|
1227
|
-
const marker = canonicalReviewMarker(normalizedHeadSha);
|
|
1228
1416
|
const lockKey = `${hostname}:${repository}:${prNumber}:${normalizedHeadSha}:${identity.toLowerCase()}`;
|
|
1229
1417
|
return withPublishLock(lockKey, async () => {
|
|
1230
1418
|
let pull: PullState;
|
|
@@ -1247,57 +1435,38 @@ export async function publishPullReview(input: {
|
|
|
1247
1435
|
const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
|
|
1248
1436
|
if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
|
|
1249
1437
|
const isOpen = lifecycle.lifecycle === "open";
|
|
1250
|
-
let
|
|
1251
|
-
let
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
try {
|
|
1266
|
-
filePages = await ghJson<unknown>(
|
|
1267
|
-
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/files?per_page=100`),
|
|
1268
|
-
cwd,
|
|
1269
|
-
);
|
|
1270
|
-
} catch (error) {
|
|
1271
|
-
return { status: "failed", message: `changed-file lookup failed: ${String(error)}` };
|
|
1272
|
-
}
|
|
1273
|
-
const validated = validateInlineComments(review, flattenPages<ChangedFileLike>(filePages));
|
|
1274
|
-
if (validated.errors.length > 0) {
|
|
1275
|
-
return { status: "failed", message: `inline validation failed: ${validated.errors.join("; ")}` };
|
|
1276
|
-
}
|
|
1277
|
-
comments = validated.comments;
|
|
1278
|
-
inlineWarningCount = validated.warnings?.length ?? 0;
|
|
1438
|
+
let allowInlineComments = isOpen && headPlan.allowInlineComments;
|
|
1439
|
+
let changedFiles: readonly ChangedFileLike[] = [];
|
|
1440
|
+
let changedFileLookupFailed = false;
|
|
1441
|
+
if (allowInlineComments && hasInlineCandidates(validatedReview)) {
|
|
1442
|
+
try {
|
|
1443
|
+
const filePages = await ghJson<unknown>(
|
|
1444
|
+
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/files?per_page=100`),
|
|
1445
|
+
cwd,
|
|
1446
|
+
);
|
|
1447
|
+
const normalizedFiles = normalizeChangedFilePages(filePages);
|
|
1448
|
+
if (!normalizedFiles) throw new Error("invalid changed-file JSON response");
|
|
1449
|
+
changedFiles = normalizedFiles;
|
|
1450
|
+
} catch {
|
|
1451
|
+
allowInlineComments = false;
|
|
1452
|
+
changedFileLookupFailed = true;
|
|
1279
1453
|
}
|
|
1280
1454
|
}
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
?
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
headPlan.commitId,
|
|
1295
|
-
reviewBody,
|
|
1296
|
-
isOpen && headPlan.allowInlineComments ? comments : [],
|
|
1297
|
-
);
|
|
1298
|
-
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
|
|
1299
|
-
return { status: "failed", message: "review payload is too large" };
|
|
1455
|
+
const built = buildLosslessReviewPayload({
|
|
1456
|
+
review: validatedReview,
|
|
1457
|
+
commitId: headPlan.commitId,
|
|
1458
|
+
markerHeadSha: normalizedHeadSha,
|
|
1459
|
+
allowInlineComments,
|
|
1460
|
+
changedFiles,
|
|
1461
|
+
...(changedFileLookupFailed ? { diagnostics: [CHANGED_FILE_LOOKUP_DIAGNOSTIC] } : {}),
|
|
1462
|
+
...(headPlan.stale
|
|
1463
|
+
? { bodyPreamble: buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha) }
|
|
1464
|
+
: {}),
|
|
1465
|
+
});
|
|
1466
|
+
if (!built.payload) {
|
|
1467
|
+
return { status: "failed", message: `publication planning failed: ${built.errors.join("; ")}` };
|
|
1300
1468
|
}
|
|
1469
|
+
const payload = built.payload;
|
|
1301
1470
|
|
|
1302
1471
|
try {
|
|
1303
1472
|
const refreshed = await ghJson<PullState>(
|
|
@@ -1322,10 +1491,12 @@ export async function publishPullReview(input: {
|
|
|
1322
1491
|
return { status: "failed", message: `final head check failed: ${String(error)}` };
|
|
1323
1492
|
}
|
|
1324
1493
|
|
|
1325
|
-
const inlineWarning =
|
|
1326
|
-
?
|
|
1327
|
-
:
|
|
1328
|
-
|
|
1494
|
+
const inlineWarning = built.diagnostics.length === 0
|
|
1495
|
+
? ""
|
|
1496
|
+
: changedFileLookupFailed
|
|
1497
|
+
? `; ${CHANGED_FILE_LOOKUP_DIAGNOSTIC}`
|
|
1498
|
+
: `; ${built.diagnostics.length} inline finding${built.diagnostics.length === 1 ? "" : "s"} kept in the summary: ${built.diagnostics.join("; ")}`;
|
|
1499
|
+
const degraded = !isOpen || headPlan.stale || built.diagnostics.length > 0;
|
|
1329
1500
|
const post = await runGh(
|
|
1330
1501
|
githubApiArgs(hostname, "--method", "POST", `repos/${repository}/pulls/${prNumber}/reviews`, "--input", "-"),
|
|
1331
1502
|
cwd,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.7",
|
|
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 safe COMMENT-only publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/prompts/pr-review.md
CHANGED
|
@@ -194,9 +194,11 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
|
|
|
194
194
|
- `--comment` → force publication for this run, but never bypass validation, stale-head checks, or duplicate checks
|
|
195
195
|
- `--no-comment` → suppress publication for this run
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
After exact-contract validation, the extension caches one validated completed review 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. `/pr-review-publish` and a matching direct request publish only the cache and never start or rerun review agents. On a later turn, the extension intercepts that direct input before an agent turn and permits stale publication without asking the orchestrator to recreate the review.
|
|
198
198
|
|
|
199
|
-
|
|
199
|
+
Every authorized publish path builds one `COMMENT` payload and sends at most one GitHub review `POST`. The event is always `COMMENT`, never `APPROVE` or `REQUEST_CHANGES`. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. All other findings that pass content validation stay in the top-level review body, including nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow. Stale reviews and authorized closed or merged reviews are body-only. A failed write never triggers a fallback POST.
|
|
200
|
+
|
|
201
|
+
Every path retains the same safety gates: captured posting authority, exact repository/PR/review binding, safe locations, no reserved review markers, bounded bodies and payloads, current-head and stale policy, draft and lifecycle checks, non-open authorization, same-head duplicate detection, and a final head check. Unknown lifecycle states and unconfirmed non-open writes fail closed. The session-backed cache survives extension reloads and session resumes but remains bound to the originating session instance and repository. If the captured stale setting disabled publication, the user may explicitly run `/pr-review-publish <PR-NUM> --allow-stale`. Never rerun the review merely to change posting intent, and never attempt a direct GitHub write yourself.
|
|
200
202
|
|
|
201
203
|
---
|
|
202
204
|
|