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 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. It creates one formal review with the event hardcoded to `COMMENT`; it never submits `APPROVE` or `REQUEST_CHANGES`. Before writing, it verifies the current PR head, validates inline anchors, and checks for a review of the same head by the current GitHub identity.
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 validation reason and automatically asks the same agent to correct its completed output once, with all tools disabled and without changing the captured posting authority. A valid correction is cached and publication is attempted under the original flags/config. If that single correction is also invalid or attempts to call a tool, publication stops and reports the error instead of looping. Overlapping input cancels the correction, remains unqueued, and must be retried after the agent settles.
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
- Closed or merged PRs use a body-only review. Open PRs attach eligible P0–P3 findings as inline comments and keep nits or off-diff findings in the review body. When GitHub omits patch metadata needed to validate an inline anchor, the affected finding remains in the review summary instead of blocking publication. When multiple findings target the same diff anchor, the first is attached inline and later findings remain in the review body so publication neither fails nor drops review content.
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
- After a review completes, you can directly ask the agent to “post the inline review or “post it as an inline review.” The extension handles that request directly before an agent turn, selecting the latest cached review for an unnumbered request or the named PR in “publish the review for PR #123.” Only fresh interactive/RPC input can trigger this path; extension-generated, queued, or steering input cannot. The extension publishes only validated cached content, never replacement model-authored text, and never starts or reruns review agents. A direct request permits stale publication.
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
- Stale publication is also enabled by default through `allowStalePublish: true`; disable it with `/pr-review-config allow_stale_publish=false`. Automatic posting and `/pr-review-publish` without an override use the setting captured when the review starts. The explicit `--allow-stale` flag remains available when the captured setting disabled stale publication:
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. The stale review is body-only and displays a warning containing both the reviewed and current commit hashes. The cache is stored in the current Pi session, survives extension reloads and session resumes, and is bound to that session instance's ID and creation metadata as well as the repository.
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
- async function maybePublishReview(
383
- text: string,
384
- invocation: ReviewInvocation,
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 authority = decideReviewPublication(invocation);
389
- if (authority.error) {
390
- ctx.ui.notify(`PR review was not posted: ${authority.error}`, "error");
391
- return;
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 (!shouldPublishReview(parsed.review)) return;
405
- const headSha = parsed.review.pr?.head_sha;
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: invocation.allowStalePublish,
423
- expectedRepository,
424
- review: parsed.review as ReviewLike,
416
+ allowNonOpen: record.invocation.allowNonOpen,
417
+ allowStale,
418
+ expectedRepository: record.repository,
419
+ review: record.review,
425
420
  });
426
- notifyPublishResult(result, authority.source ?? "frozen invocation", ctx);
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
- let pendingCompletion:
451
- | {
452
- text: string;
453
- invocation: ReviewInvocation;
454
- repository?: RepositoryBinding;
455
- record?: CompletedReviewRecord;
456
- session?: CompletedReviewSessionIdentity;
457
- }
458
- | undefined;
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 CachedPublication =
473
- | { result: Awaited<ReturnType<typeof publishPullReview>> }
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<CachedPublication> => {
480
- let repository: RepositoryBinding;
497
+ ): Promise<CachedReviewResolution> => {
481
498
  try {
482
- repository = await resolveRepositoryBinding(ctx.cwd);
483
- } catch (error) {
484
- return { error: `Cannot resolve the current GitHub repository: ${String(error)}` };
485
- }
486
- const completed = requestedPrNumber === undefined
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 published = await publishCachedReview(
539
- parsed.prNumber,
540
- parsed.allowStale ? true : undefined,
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
- notifyPublishResult(
548
- published.result,
549
- parsed.allowStale ? "publish-only --allow-stale" : "publish-only",
550
- ctx,
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 published = await publishCachedReview(directPublish.prNumber, true, ctx);
634
- if ("error" in published) ctx.ui.notify(published.error, "error");
635
- else notifyPublishResult(published.result, "direct user request", ctx);
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 (pending.record && pending.session) {
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 leafText = leaf?.type === "message" && leaf.message.role === "assistant"
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 maybePublishReview(pending.text, pending.invocation, ctx, pending.repository);
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
- let publishable = active ? parsePublishableReview(text) : undefined;
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 = text.trim() ? parseReview(text) : null;
814
- if (invocation) {
815
- publishable ??= parsePublishableReview(text);
816
- let repository: RepositoryBinding | undefined;
817
- let record: CompletedReviewRecord | undefined;
818
- let session: CompletedReviewSessionIdentity | undefined;
819
- if (
820
- publishable.review &&
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 = (event.message.content as MessagePart[]).filter((p) => p.type !== "text");
818
+ const nonText = event.message.content.filter((part) => part.type !== "text");
843
819
  return {
844
820
  message: {
845
821
  ...event.message,
@@ -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
- /** Publication accepts only one complete JSON object, never fenced/prose-wrapped drafts. */
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
- if (finding.code_location !== null) {
662
- const location = finding.code_location;
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 DiffHunk {
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
- export function validateInlineComments(
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
- ): CommentValidationResult {
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.filename) files.set(file.filename, 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 location = finding.code_location;
857
- if (!location?.commentable || !isInlineSeverity(finding)) continue;
858
- const path = String(location.absolute_file_path ?? "");
859
- const side = String(location.side ?? "").toUpperCase();
860
- const start = location.line_range?.start;
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 (side !== "LEFT" && side !== "RIGHT") {
868
- errors.push(`${label}: side must be LEFT or RIGHT`);
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 (!Number.isInteger(start) || !Number.isInteger(end) || Number(start) <= 0 || Number(end) < Number(start)) {
872
- errors.push(`${label}: invalid line range`);
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
- errors.push(`${label}: path is not a changed file`);
941
+ diagnostics.push(`${label}: path is not a changed file; kept in the review summary`);
878
942
  continue;
879
943
  }
880
- if (!file.patch) {
881
- // GitHub legitimately omits patch metadata for some large, binary, or transiently unavailable diffs.
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
- const hunk = parsePatchHunks(file.patch).find(
888
- (candidate) => candidate[sideKey].has(Number(start)) && candidate[sideKey].has(Number(end)),
889
- );
890
- if (!hunk) {
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 = `${path}:${side}:${start}:${end}`;
895
- const body = [`**${String(finding.title ?? "Review finding").trim()}**`, finding.body?.trim()]
896
- .filter(Boolean)
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
- if (containsReservedReviewMarker(body)) {
903
- errors.push(`${label}: comment body contains a reserved pi-pr-review marker`);
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
- // GitHub receives one comment per anchor; later findings remain in the top-level summary.
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
- });
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 (comments.length > MAX_INLINE_COMMENTS) {
918
- errors.push(`too many inline comments (${comments.length}; max ${MAX_INLINE_COMMENTS})`);
990
+ if (typeof serialized !== "string") {
991
+ return { error: "review could not be serialized for publication" };
919
992
  }
920
- return { comments, errors, warnings };
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 flattenPages<T>(value: unknown): T[] {
1050
- if (!Array.isArray(value)) return [];
1051
- if (value.every(Array.isArray)) return value.flat() as T[];
1052
- return value as T[];
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?: string | null;
1057
- user?: { login?: string | null } | null;
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
- return [...flattenPages<AuthoredBody>(reviewPages), ...flattenPages<AuthoredBody>(commentPages)].some(
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 comments: PublishComment[] = [];
1251
- let inlineWarningCount = 0;
1252
- if (!isOpen) {
1253
- const candidates = collectFoldedComments(review);
1254
- if (candidates.errors.length > 0) {
1255
- return { status: "failed", message: `inline candidate validation failed: ${candidates.errors.join("; ")}` };
1256
- }
1257
- comments = candidates.comments;
1258
- } else if (headPlan.allowInlineComments) {
1259
- const candidates = collectFoldedComments(review);
1260
- if (candidates.errors.length > 0) {
1261
- return { status: "failed", message: `inline candidate validation failed: ${candidates.errors.join("; ")}` };
1262
- }
1263
- if (candidates.comments.length > 0) {
1264
- let filePages: unknown;
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
- const summary = buildReviewSummary(review, comments);
1283
- const bodyError = validateReviewBody(summary);
1284
- if (bodyError) return { status: "failed", message: bodyError };
1285
- const lifecycleBody = isOpen ? summary : foldInlineComments(summary, comments);
1286
- const disclosedBody = headPlan.stale
1287
- ? `${buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha)}\n\n${lifecycleBody}`
1288
- : lifecycleBody;
1289
- const reviewBody = `${disclosedBody}\n\n${marker}`;
1290
- if (Buffer.byteLength(reviewBody, "utf8") > MAX_BODY_BYTES) {
1291
- return { status: "failed", message: "final review body exceeds 65536 UTF-8 bytes" };
1292
- }
1293
- const payload = buildPullReviewPayload(
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 = inlineWarningCount > 0
1326
- ? `; ${inlineWarningCount} inline finding${inlineWarningCount === 1 ? "" : "s"} kept in the summary because GitHub omitted diff patch metadata`
1327
- : "";
1328
- const degraded = !isOpen || headPlan.stale || inlineWarningCount > 0;
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.5",
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",
@@ -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
- When enabled, the extension creates exactly one formal GitHub pull-request review. Its top-level body contains the overview, verification, strengths, suggested verdict, counts, nits, and findings that cannot be attached inline. Eligible P0–P3 diff-anchored findings are attached as inline comments within that same review and are omitted from the top-level issue list to avoid duplication. If GitHub omits patch metadata needed to validate an anchor, that finding remains in the top-level body instead of blocking publication. If multiple findings share one diff anchor, the first is attached inline and later findings remain in the top-level body instead of failing publication or dropping content. The API event is hardcoded to `COMMENT`: publication never sends `APPROVE` or `REQUEST_CHANGES`, even when the suggested verdict is `request_changes`. It appends the same-head marker, verifies the current head, validates every attempted inline anchor against GitHub diff metadata, and refuses partial open-PR publication. For a known closed/merged PR it requires either trusted `--include-closed`/`--review-closed` invocation authority or the one-shot affirmative confirmation flow, then posts one body-only `COMMENT` review with each inline finding folded into the body exactly once. Unknown lifecycle states and unconfirmed non-open writes fail without posting or falling back to an issue comment.
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
- The extension caches the latest valid completed review per repository and PR before publication preflight in the current Pi session. The session-backed cache survives extension reloads and session resumes but is bound to the originating session instance and repository. On a later turn, the extension intercepts that direct input before an agent turn, publishes only extension-cached content, permits stale publication, and cannot rerun review agents. Stale publication is also enabled by default through the invocation-captured `allowStalePublish` setting. Every stale review is body-only, displays a warning with the reviewed and preflight-observed commit hashes, and omits potentially invalid inline anchors. If the captured setting disabled stale publication, the user can still 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.
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