pi-pr-review 1.10.5 → 1.10.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 +7 -0
- package/README.md +8 -6
- package/extensions/review-table.ts +106 -130
- package/lib/pr-review-publish.ts +343 -187
- package/package.json +1 -1
- package/prompts/pr-review.md +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.10.6](https://github.com/10ego/pi-pr-review/compare/v1.10.5...v1.10.6) (2026-07-17)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **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))
|
|
9
|
+
|
|
3
10
|
## [1.10.5](https://github.com/10ego/pi-pr-review/compare/v1.10.4...v1.10.5) (2026-07-17)
|
|
4
11
|
|
|
5
12
|
|
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;
|
|
@@ -626,6 +628,9 @@ export function parsePublishableReview(text: string): PublishableReviewParseResu
|
|
|
626
628
|
return { error: "final response is not exactly one JSON object" };
|
|
627
629
|
}
|
|
628
630
|
if (!isObject(value)) return { error: "final review must be a JSON object" };
|
|
631
|
+
if (containsReservedReviewMarker(JSON.stringify(value))) {
|
|
632
|
+
return { error: "review content contains a reserved pi-pr-review marker" };
|
|
633
|
+
}
|
|
629
634
|
const pr = value.pr;
|
|
630
635
|
if (!isObject(pr) || !Number.isInteger(pr.number) || Number(pr.number) <= 0 || typeof pr.title !== "string") {
|
|
631
636
|
return { error: "pr.number and pr.title are required" };
|
|
@@ -658,21 +663,8 @@ export function parsePublishableReview(text: string): PublishableReviewParseResu
|
|
|
658
663
|
if (!isConfidence(finding.confidence_score)) {
|
|
659
664
|
return { error: `finding ${index + 1} has invalid confidence_score` };
|
|
660
665
|
}
|
|
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
|
-
}
|
|
666
|
+
const locationError = validateFindingLocation(finding as ReviewFindingLike, index);
|
|
667
|
+
if (locationError) return { error: locationError };
|
|
676
668
|
}
|
|
677
669
|
if (!isObject(value.notes)) return { error: "notes must be an object" };
|
|
678
670
|
for (const key of ["correctness", "security", "performance"] as const) {
|
|
@@ -694,6 +686,88 @@ export function shouldPublishReview(review: ReviewLike): boolean {
|
|
|
694
686
|
return review.disposition === "reviewed";
|
|
695
687
|
}
|
|
696
688
|
|
|
689
|
+
interface DiffHunk {
|
|
690
|
+
left: Set<number>;
|
|
691
|
+
right: Set<number>;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function parsePatchHunks(patch: string): DiffHunk[] {
|
|
695
|
+
const hunks: DiffHunk[] = [];
|
|
696
|
+
let current: DiffHunk | undefined;
|
|
697
|
+
let left = 0;
|
|
698
|
+
let right = 0;
|
|
699
|
+
for (const line of patch.split("\n")) {
|
|
700
|
+
const header = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
701
|
+
if (header) {
|
|
702
|
+
left = Number(header[1]);
|
|
703
|
+
right = Number(header[2]);
|
|
704
|
+
current = { left: new Set<number>(), right: new Set<number>() };
|
|
705
|
+
hunks.push(current);
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (!current || line.startsWith("\\")) continue;
|
|
709
|
+
if (line.startsWith("+")) {
|
|
710
|
+
current.right.add(right++);
|
|
711
|
+
} else if (line.startsWith("-")) {
|
|
712
|
+
current.left.add(left++);
|
|
713
|
+
} else if (line.startsWith(" ")) {
|
|
714
|
+
current.left.add(left++);
|
|
715
|
+
current.right.add(right++);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return hunks;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
export interface ChangedFileLike {
|
|
722
|
+
filename?: string;
|
|
723
|
+
patch?: string;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function safeRelativePath(value: string): boolean {
|
|
727
|
+
if (!value || value.startsWith("/") || value.includes("\\") || /[\0-\x1f\x7f]/.test(value)) return false;
|
|
728
|
+
const segments = value.split("/");
|
|
729
|
+
return segments.every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function validateFindingLocation(finding: ReviewFindingLike, index: number): string | undefined {
|
|
733
|
+
const location = finding.code_location;
|
|
734
|
+
const label = `finding ${index + 1}`;
|
|
735
|
+
if (location === null) return undefined;
|
|
736
|
+
if (!isObject(location) || typeof location.commentable !== "boolean") {
|
|
737
|
+
return `${label} has invalid code_location`;
|
|
738
|
+
}
|
|
739
|
+
const path = location.absolute_file_path;
|
|
740
|
+
if (path !== null && typeof path !== "string") return `${label} has invalid absolute_file_path`;
|
|
741
|
+
const side = location.side;
|
|
742
|
+
if (side !== null && side !== "LEFT" && side !== "RIGHT") return `${label} has invalid side`;
|
|
743
|
+
const range = location.line_range;
|
|
744
|
+
if (!isObject(range) || !Number.isInteger(range.start) || !Number.isInteger(range.end)) {
|
|
745
|
+
return `${label} has invalid line_range`;
|
|
746
|
+
}
|
|
747
|
+
const start = Number(range.start);
|
|
748
|
+
const end = Number(range.end);
|
|
749
|
+
if (path === null) {
|
|
750
|
+
if (location.commentable) return `${label}: commentable location is missing a repo-relative path`;
|
|
751
|
+
if (side !== null || start !== 0 || end !== 0) {
|
|
752
|
+
return `${label}: location without a path must use null side and a 0:0 range`;
|
|
753
|
+
}
|
|
754
|
+
return undefined;
|
|
755
|
+
}
|
|
756
|
+
if (!safeRelativePath(path)) return `${label}: invalid repo-relative path`;
|
|
757
|
+
if (side !== "LEFT" && side !== "RIGHT") return `${label}: side must be LEFT or RIGHT`;
|
|
758
|
+
if (start <= 0 || end < start) return `${label}: invalid line range`;
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function isInlineSeverity(finding: ReviewFindingLike): boolean {
|
|
763
|
+
const severity = String(finding.severity ?? "").toUpperCase();
|
|
764
|
+
return ["P0", "P1", "P2", "P3"].includes(severity);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function publishCommentAnchor(comment: PublishComment): string {
|
|
768
|
+
return `${comment.path}:${comment.side}:${comment.start_line ?? comment.line}:${comment.line}`;
|
|
769
|
+
}
|
|
770
|
+
|
|
697
771
|
function cell(value: string): string {
|
|
698
772
|
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ").trim();
|
|
699
773
|
}
|
|
@@ -707,10 +781,6 @@ function findingLocation(finding: ReviewFindingLike): string {
|
|
|
707
781
|
return `${location.absolute_file_path}:${start}${end !== start ? `-${end}` : ""} ${(location.side ?? "RIGHT").toUpperCase()}`;
|
|
708
782
|
}
|
|
709
783
|
|
|
710
|
-
function publishCommentAnchor(comment: PublishComment): string {
|
|
711
|
-
return `${comment.path}:${comment.side}:${comment.start_line ?? comment.line}:${comment.line}`;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
784
|
function findingAnchor(finding: ReviewFindingLike): string | undefined {
|
|
715
785
|
const location = finding.code_location;
|
|
716
786
|
const path = location?.absolute_file_path;
|
|
@@ -786,140 +856,141 @@ export function buildReviewSummary(review: ReviewLike, inlineComments: PublishCo
|
|
|
786
856
|
return lines.join("\n").trim();
|
|
787
857
|
}
|
|
788
858
|
|
|
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 {
|
|
859
|
+
interface InlineSelection {
|
|
838
860
|
comments: PublishComment[];
|
|
861
|
+
diagnostics: string[];
|
|
839
862
|
errors: string[];
|
|
840
|
-
warnings?: string[];
|
|
841
863
|
}
|
|
842
864
|
|
|
843
|
-
|
|
865
|
+
function buildPublishComment(
|
|
866
|
+
path: string,
|
|
867
|
+
body: string,
|
|
868
|
+
side: "LEFT" | "RIGHT",
|
|
869
|
+
start: number,
|
|
870
|
+
end: number,
|
|
871
|
+
): PublishComment {
|
|
872
|
+
return {
|
|
873
|
+
path,
|
|
874
|
+
body,
|
|
875
|
+
line: end,
|
|
876
|
+
side,
|
|
877
|
+
...(start < end ? { start_line: start, start_side: side } : {}),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function selectInlineComments(
|
|
844
882
|
review: ReviewLike,
|
|
845
|
-
changedFiles: ChangedFileLike[],
|
|
846
|
-
):
|
|
847
|
-
const errors: string[] = [];
|
|
848
|
-
const warnings: string[] = [];
|
|
849
|
-
const comments: PublishComment[] = [];
|
|
883
|
+
changedFiles: readonly ChangedFileLike[],
|
|
884
|
+
): InlineSelection {
|
|
850
885
|
const files = new Map<string, ChangedFileLike>();
|
|
851
886
|
for (const file of changedFiles) {
|
|
852
|
-
if (file
|
|
887
|
+
if (!file || typeof file.filename !== "string") continue;
|
|
888
|
+
const existing = files.get(file.filename);
|
|
889
|
+
if (!existing || (!existing.patch && file.patch)) files.set(file.filename, file);
|
|
853
890
|
}
|
|
891
|
+
const comments: PublishComment[] = [];
|
|
892
|
+
const diagnostics: string[] = [];
|
|
893
|
+
const errors: string[] = [];
|
|
854
894
|
const anchors = new Set<string>();
|
|
895
|
+
const hunkCache = new Map<string, DiffHunk[]>();
|
|
855
896
|
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`);
|
|
897
|
+
const locationError = finding.code_location === undefined
|
|
898
|
+
? undefined
|
|
899
|
+
: validateFindingLocation(finding, index);
|
|
900
|
+
if (locationError) {
|
|
901
|
+
errors.push(locationError);
|
|
865
902
|
continue;
|
|
866
903
|
}
|
|
867
|
-
if (
|
|
868
|
-
|
|
904
|
+
if (!finding.code_location?.commentable || !isInlineSeverity(finding)) continue;
|
|
905
|
+
const location = finding.code_location;
|
|
906
|
+
const label = `finding ${index + 1}`;
|
|
907
|
+
const path = location.absolute_file_path as string;
|
|
908
|
+
const side = location.side as "LEFT" | "RIGHT";
|
|
909
|
+
const start = Number(location.line_range?.start);
|
|
910
|
+
const end = Number(location.line_range?.end);
|
|
911
|
+
const body = [
|
|
912
|
+
finding.title?.trim() ? `**${finding.title.trim()}**` : "",
|
|
913
|
+
finding.body?.trim(),
|
|
914
|
+
].filter(Boolean).join("\n\n");
|
|
915
|
+
if (!body || Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
916
|
+
errors.push(`${label}: comment body is empty or too large`);
|
|
869
917
|
continue;
|
|
870
918
|
}
|
|
871
|
-
if (
|
|
872
|
-
errors.push(`${label}:
|
|
919
|
+
if (containsReservedReviewMarker(body)) {
|
|
920
|
+
errors.push(`${label}: comment body contains a reserved pi-pr-review marker`);
|
|
873
921
|
continue;
|
|
874
922
|
}
|
|
923
|
+
const comment = buildPublishComment(path, body, side, start, end);
|
|
875
924
|
const file = files.get(path);
|
|
876
925
|
if (!file) {
|
|
877
|
-
|
|
926
|
+
diagnostics.push(`${label}: path is not a changed file; kept in the review summary`);
|
|
878
927
|
continue;
|
|
879
928
|
}
|
|
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`);
|
|
929
|
+
if (typeof file.patch !== "string" || file.patch.length === 0) {
|
|
930
|
+
diagnostics.push(`${label}: diff patch is unavailable; kept in the review summary`);
|
|
884
931
|
continue;
|
|
885
932
|
}
|
|
933
|
+
let hunks = hunkCache.get(path);
|
|
934
|
+
if (!hunks) {
|
|
935
|
+
hunks = parsePatchHunks(file.patch);
|
|
936
|
+
hunkCache.set(path, hunks);
|
|
937
|
+
}
|
|
886
938
|
const sideKey = side === "LEFT" ? "left" : "right";
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
errors.push(`${label}: line range is not inside one diff hunk on ${side}`);
|
|
939
|
+
if (!hunks.some((hunk) => hunk[sideKey].has(start) && hunk[sideKey].has(end))) {
|
|
940
|
+
diagnostics.push(
|
|
941
|
+
`${label}: line range is not inside one diff hunk on ${side}; kept in the review summary`,
|
|
942
|
+
);
|
|
892
943
|
continue;
|
|
893
944
|
}
|
|
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`);
|
|
945
|
+
const anchor = publishCommentAnchor(comment);
|
|
946
|
+
if (anchors.has(anchor)) {
|
|
947
|
+
diagnostics.push(`${label}: duplicate inline anchor; kept in the review summary`);
|
|
900
948
|
continue;
|
|
901
949
|
}
|
|
902
|
-
|
|
903
|
-
|
|
950
|
+
anchors.add(anchor);
|
|
951
|
+
if (comments.length >= MAX_INLINE_COMMENTS) {
|
|
952
|
+
diagnostics.push(
|
|
953
|
+
`${label}: inline comment limit of ${MAX_INLINE_COMMENTS} reached; kept in the review summary`,
|
|
954
|
+
);
|
|
904
955
|
continue;
|
|
905
956
|
}
|
|
906
|
-
|
|
907
|
-
if (anchors.has(anchor)) continue;
|
|
908
|
-
anchors.add(anchor);
|
|
909
|
-
comments.push({
|
|
910
|
-
path,
|
|
911
|
-
body,
|
|
912
|
-
line: Number(end),
|
|
913
|
-
side,
|
|
914
|
-
...(Number(start) < Number(end) ? { start_line: Number(start), start_side: side } : {}),
|
|
915
|
-
});
|
|
957
|
+
comments.push(comment);
|
|
916
958
|
}
|
|
917
|
-
|
|
918
|
-
|
|
959
|
+
return { comments, diagnostics, errors };
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export interface CommentValidationResult {
|
|
963
|
+
comments: PublishComment[];
|
|
964
|
+
errors: string[];
|
|
965
|
+
warnings?: string[];
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function canonicalReviewSnapshot(review: ReviewLike): PublishableReviewParseResult {
|
|
969
|
+
let serialized: string | undefined;
|
|
970
|
+
try {
|
|
971
|
+
serialized = JSON.stringify(review);
|
|
972
|
+
} catch {
|
|
973
|
+
return { error: "review could not be serialized for publication" };
|
|
974
|
+
}
|
|
975
|
+
if (typeof serialized !== "string") {
|
|
976
|
+
return { error: "review could not be serialized for publication" };
|
|
919
977
|
}
|
|
920
|
-
return
|
|
978
|
+
return parsePublishableReview(serialized);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
export function validateInlineComments(
|
|
982
|
+
review: ReviewLike,
|
|
983
|
+
changedFiles: readonly ChangedFileLike[],
|
|
984
|
+
): CommentValidationResult {
|
|
985
|
+
const selected = selectInlineComments(review, changedFiles);
|
|
986
|
+
return {
|
|
987
|
+
comments: selected.comments,
|
|
988
|
+
errors: selected.errors,
|
|
989
|
+
warnings: selected.diagnostics,
|
|
990
|
+
};
|
|
921
991
|
}
|
|
922
992
|
|
|
993
|
+
/** Compatibility helper for callers that fold would-be inline findings into a body-only review. */
|
|
923
994
|
export function collectFoldedComments(review: ReviewLike): CommentValidationResult {
|
|
924
995
|
const comments: PublishComment[] = [];
|
|
925
996
|
const errors: string[] = [];
|
|
@@ -950,17 +1021,12 @@ export function collectFoldedComments(review: ReviewLike): CommentValidationResu
|
|
|
950
1021
|
errors.push(`${label}: folded comment body contains a reserved pi-pr-review marker`);
|
|
951
1022
|
continue;
|
|
952
1023
|
}
|
|
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
|
-
});
|
|
1024
|
+
comments.push(buildPublishComment(path, body, side, Number(start), Number(end)));
|
|
960
1025
|
}
|
|
961
1026
|
return { comments, errors, warnings: [] };
|
|
962
1027
|
}
|
|
963
1028
|
|
|
1029
|
+
/** Compatibility formatter for body-only reviews assembled by earlier consumers. */
|
|
964
1030
|
export function foldInlineComments(summary: string, comments: PublishComment[]): string {
|
|
965
1031
|
if (comments.length === 0) return summary;
|
|
966
1032
|
const lines = [summary, "", "### Inline findings (folded for a non-open PR)", ""];
|
|
@@ -982,6 +1048,59 @@ function validateReviewBody(body: string): string | undefined {
|
|
|
982
1048
|
return undefined;
|
|
983
1049
|
}
|
|
984
1050
|
|
|
1051
|
+
function hasInlineCandidates(review: ReviewLike): boolean {
|
|
1052
|
+
return (review.findings ?? []).some(
|
|
1053
|
+
(finding) => finding.code_location?.commentable === true && isInlineSeverity(finding),
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function buildLosslessReviewPayload(input: {
|
|
1058
|
+
review: ReviewLike;
|
|
1059
|
+
commitId: string;
|
|
1060
|
+
markerHeadSha: string;
|
|
1061
|
+
allowInlineComments: boolean;
|
|
1062
|
+
changedFiles?: readonly ChangedFileLike[];
|
|
1063
|
+
bodyPreamble?: string;
|
|
1064
|
+
diagnostics?: readonly string[];
|
|
1065
|
+
}): { payload?: PullReviewPayload; diagnostics: string[]; errors: string[] } {
|
|
1066
|
+
const diagnostics = [...(input.diagnostics ?? [])];
|
|
1067
|
+
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.commitId)) {
|
|
1068
|
+
return { diagnostics, errors: ["publication commit ID must be a full hexadecimal commit SHA"] };
|
|
1069
|
+
}
|
|
1070
|
+
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(input.markerHeadSha)) {
|
|
1071
|
+
return { diagnostics, errors: ["publication marker head must be a full hexadecimal commit SHA"] };
|
|
1072
|
+
}
|
|
1073
|
+
const markerHeadSha = input.markerHeadSha.toLowerCase();
|
|
1074
|
+
if (input.review.pr?.head_sha?.toLowerCase() !== markerHeadSha) {
|
|
1075
|
+
return { diagnostics, errors: ["publication marker head does not match the validated review head"] };
|
|
1076
|
+
}
|
|
1077
|
+
if (input.bodyPreamble && containsReservedReviewMarker(input.bodyPreamble)) {
|
|
1078
|
+
return { diagnostics, errors: ["publication preamble contains a reserved pi-pr-review marker"] };
|
|
1079
|
+
}
|
|
1080
|
+
const selected = input.allowInlineComments
|
|
1081
|
+
? selectInlineComments(input.review, input.changedFiles ?? [])
|
|
1082
|
+
: { comments: [], diagnostics: [], errors: [] };
|
|
1083
|
+
diagnostics.push(...selected.diagnostics);
|
|
1084
|
+
if (selected.errors.length > 0) return { diagnostics, errors: selected.errors };
|
|
1085
|
+
|
|
1086
|
+
let content = buildReviewSummary(input.review, selected.comments);
|
|
1087
|
+
if (diagnostics.length > 0) {
|
|
1088
|
+
content = `${content}\n\n### Publication diagnostics\n\n${diagnostics.map((item) => `- ${item}`).join("\n")}`;
|
|
1089
|
+
}
|
|
1090
|
+
if (input.bodyPreamble?.trim()) content = `${input.bodyPreamble.trim()}\n\n${content}`;
|
|
1091
|
+
const bodyError = validateReviewBody(content);
|
|
1092
|
+
if (bodyError) return { diagnostics, errors: [bodyError] };
|
|
1093
|
+
const body = `${content}\n\n${canonicalReviewMarker(markerHeadSha)}`;
|
|
1094
|
+
if (Buffer.byteLength(body, "utf8") > MAX_BODY_BYTES) {
|
|
1095
|
+
return { diagnostics, errors: ["final review body exceeds 65536 UTF-8 bytes"] };
|
|
1096
|
+
}
|
|
1097
|
+
const payload = buildPullReviewPayload(input.commitId.toLowerCase(), body, selected.comments);
|
|
1098
|
+
if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
|
|
1099
|
+
return { diagnostics, errors: ["review payload is too large"] };
|
|
1100
|
+
}
|
|
1101
|
+
return { payload, diagnostics, errors: [] };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
985
1104
|
interface GhResult {
|
|
986
1105
|
stdout: string;
|
|
987
1106
|
stderr: string;
|
|
@@ -1046,15 +1165,49 @@ export async function resolveRepositoryBinding(cwd: string): Promise<RepositoryB
|
|
|
1046
1165
|
return binding;
|
|
1047
1166
|
}
|
|
1048
1167
|
|
|
1049
|
-
function
|
|
1050
|
-
if (!Array.isArray(value)) return
|
|
1051
|
-
if (value.
|
|
1052
|
-
|
|
1168
|
+
function normalizeChangedFilePages(value: unknown): ChangedFileLike[] | undefined {
|
|
1169
|
+
if (!Array.isArray(value)) return undefined;
|
|
1170
|
+
if (value.some(Array.isArray) && !value.every(Array.isArray)) return undefined;
|
|
1171
|
+
const entries: unknown[] = value.every(Array.isArray) ? value.flat() : value;
|
|
1172
|
+
const files: ChangedFileLike[] = [];
|
|
1173
|
+
for (const entry of entries) {
|
|
1174
|
+
if (!isObject(entry) || typeof entry.filename !== "string") return undefined;
|
|
1175
|
+
if (entry.patch !== undefined && entry.patch !== null && typeof entry.patch !== "string") {
|
|
1176
|
+
return undefined;
|
|
1177
|
+
}
|
|
1178
|
+
files.push({
|
|
1179
|
+
filename: entry.filename,
|
|
1180
|
+
...(typeof entry.patch === "string" ? { patch: entry.patch } : {}),
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
return files;
|
|
1053
1184
|
}
|
|
1054
1185
|
|
|
1055
1186
|
interface AuthoredBody {
|
|
1056
|
-
body
|
|
1057
|
-
user
|
|
1187
|
+
body: string | null;
|
|
1188
|
+
user: { login: string | null } | null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function normalizeAuthoredBodyPages(value: unknown): AuthoredBody[] | undefined {
|
|
1192
|
+
if (!Array.isArray(value)) return undefined;
|
|
1193
|
+
if (value.some(Array.isArray) && !value.every(Array.isArray)) return undefined;
|
|
1194
|
+
const entries: unknown[] = value.every(Array.isArray) ? value.flat() : value;
|
|
1195
|
+
const authoredBodies: AuthoredBody[] = [];
|
|
1196
|
+
for (const entry of entries) {
|
|
1197
|
+
if (!isObject(entry) || (entry.body !== null && typeof entry.body !== "string")) {
|
|
1198
|
+
return undefined;
|
|
1199
|
+
}
|
|
1200
|
+
const user = entry.user;
|
|
1201
|
+
if (user === null) {
|
|
1202
|
+
authoredBodies.push({ body: entry.body, user: null });
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (!isObject(user)) return undefined;
|
|
1206
|
+
const login = user.login;
|
|
1207
|
+
if (login !== null && typeof login !== "string") return undefined;
|
|
1208
|
+
authoredBodies.push({ body: entry.body, user: { login } });
|
|
1209
|
+
}
|
|
1210
|
+
return authoredBodies;
|
|
1058
1211
|
}
|
|
1059
1212
|
|
|
1060
1213
|
export function bodyHasHeadMarker(body: string | null | undefined, normalizedHeadSha: string): boolean {
|
|
@@ -1078,11 +1231,15 @@ async function hasExistingMarker(
|
|
|
1078
1231
|
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/reviews?per_page=100`),
|
|
1079
1232
|
cwd,
|
|
1080
1233
|
);
|
|
1234
|
+
const reviews = normalizeAuthoredBodyPages(reviewPages);
|
|
1235
|
+
if (!reviews) throw new Error("invalid paginated pull review response");
|
|
1081
1236
|
const commentPages = await ghJson<unknown>(
|
|
1082
1237
|
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/issues/${prNumber}/comments?per_page=100`),
|
|
1083
1238
|
cwd,
|
|
1084
1239
|
);
|
|
1085
|
-
|
|
1240
|
+
const comments = normalizeAuthoredBodyPages(commentPages);
|
|
1241
|
+
if (!comments) throw new Error("invalid paginated issue comment response");
|
|
1242
|
+
return [...reviews, ...comments].some(
|
|
1086
1243
|
(item) =>
|
|
1087
1244
|
item.user?.login?.toLowerCase() === identity.toLowerCase() &&
|
|
1088
1245
|
bodyHasHeadMarker(item.body, normalizedHeadSha),
|
|
@@ -1204,6 +1361,23 @@ export async function publishPullReview(input: {
|
|
|
1204
1361
|
if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
|
|
1205
1362
|
if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
|
|
1206
1363
|
const normalizedHeadSha = headSha.toLowerCase();
|
|
1364
|
+
const snapshot = canonicalReviewSnapshot(review);
|
|
1365
|
+
const validatedReview = snapshot.review;
|
|
1366
|
+
if (!validatedReview) {
|
|
1367
|
+
return {
|
|
1368
|
+
status: "failed",
|
|
1369
|
+
message: `publication planning failed: ${snapshot.error ?? "review is not publishable"}`,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
if (!shouldPublishReview(validatedReview)) {
|
|
1373
|
+
return { status: "failed", message: "only completed reviewed dispositions can be published" };
|
|
1374
|
+
}
|
|
1375
|
+
if (validatedReview.pr?.number !== prNumber) {
|
|
1376
|
+
return { status: "failed", message: "validated review PR number does not match the publication target" };
|
|
1377
|
+
}
|
|
1378
|
+
if (validatedReview.pr?.head_sha?.toLowerCase() !== normalizedHeadSha) {
|
|
1379
|
+
return { status: "failed", message: "validated review head does not match the publication target" };
|
|
1380
|
+
}
|
|
1207
1381
|
|
|
1208
1382
|
let repository: string;
|
|
1209
1383
|
let hostname: string;
|
|
@@ -1224,7 +1398,6 @@ export async function publishPullReview(input: {
|
|
|
1224
1398
|
}
|
|
1225
1399
|
if (!identity) return { status: "failed", message: "invalid GitHub identity" };
|
|
1226
1400
|
|
|
1227
|
-
const marker = canonicalReviewMarker(normalizedHeadSha);
|
|
1228
1401
|
const lockKey = `${hostname}:${repository}:${prNumber}:${normalizedHeadSha}:${identity.toLowerCase()}`;
|
|
1229
1402
|
return withPublishLock(lockKey, async () => {
|
|
1230
1403
|
let pull: PullState;
|
|
@@ -1247,57 +1420,38 @@ export async function publishPullReview(input: {
|
|
|
1247
1420
|
const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
|
|
1248
1421
|
if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
|
|
1249
1422
|
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;
|
|
1423
|
+
let allowInlineComments = isOpen && headPlan.allowInlineComments;
|
|
1424
|
+
let changedFiles: readonly ChangedFileLike[] = [];
|
|
1425
|
+
let changedFileLookupFailed = false;
|
|
1426
|
+
if (allowInlineComments && hasInlineCandidates(validatedReview)) {
|
|
1427
|
+
try {
|
|
1428
|
+
const filePages = await ghJson<unknown>(
|
|
1429
|
+
githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/files?per_page=100`),
|
|
1430
|
+
cwd,
|
|
1431
|
+
);
|
|
1432
|
+
const normalizedFiles = normalizeChangedFilePages(filePages);
|
|
1433
|
+
if (!normalizedFiles) throw new Error("invalid changed-file JSON response");
|
|
1434
|
+
changedFiles = normalizedFiles;
|
|
1435
|
+
} catch {
|
|
1436
|
+
allowInlineComments = false;
|
|
1437
|
+
changedFileLookupFailed = true;
|
|
1279
1438
|
}
|
|
1280
1439
|
}
|
|
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" };
|
|
1440
|
+
const built = buildLosslessReviewPayload({
|
|
1441
|
+
review: validatedReview,
|
|
1442
|
+
commitId: headPlan.commitId,
|
|
1443
|
+
markerHeadSha: normalizedHeadSha,
|
|
1444
|
+
allowInlineComments,
|
|
1445
|
+
changedFiles,
|
|
1446
|
+
...(changedFileLookupFailed ? { diagnostics: [CHANGED_FILE_LOOKUP_DIAGNOSTIC] } : {}),
|
|
1447
|
+
...(headPlan.stale
|
|
1448
|
+
? { bodyPreamble: buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha) }
|
|
1449
|
+
: {}),
|
|
1450
|
+
});
|
|
1451
|
+
if (!built.payload) {
|
|
1452
|
+
return { status: "failed", message: `publication planning failed: ${built.errors.join("; ")}` };
|
|
1300
1453
|
}
|
|
1454
|
+
const payload = built.payload;
|
|
1301
1455
|
|
|
1302
1456
|
try {
|
|
1303
1457
|
const refreshed = await ghJson<PullState>(
|
|
@@ -1322,10 +1476,12 @@ export async function publishPullReview(input: {
|
|
|
1322
1476
|
return { status: "failed", message: `final head check failed: ${String(error)}` };
|
|
1323
1477
|
}
|
|
1324
1478
|
|
|
1325
|
-
const inlineWarning =
|
|
1326
|
-
?
|
|
1327
|
-
:
|
|
1328
|
-
|
|
1479
|
+
const inlineWarning = built.diagnostics.length === 0
|
|
1480
|
+
? ""
|
|
1481
|
+
: changedFileLookupFailed
|
|
1482
|
+
? `; ${CHANGED_FILE_LOOKUP_DIAGNOSTIC}`
|
|
1483
|
+
: `; ${built.diagnostics.length} inline finding${built.diagnostics.length === 1 ? "" : "s"} kept in the summary: ${built.diagnostics.join("; ")}`;
|
|
1484
|
+
const degraded = !isOpen || headPlan.stale || built.diagnostics.length > 0;
|
|
1329
1485
|
const post = await runGh(
|
|
1330
1486
|
githubApiArgs(hostname, "--method", "POST", `repos/${repository}/pulls/${prNumber}/reviews`, "--input", "-"),
|
|
1331
1487
|
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.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 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
|
|