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 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. 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;
@@ -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
- 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
- }
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 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 {
859
+ interface InlineSelection {
838
860
  comments: PublishComment[];
861
+ diagnostics: string[];
839
862
  errors: string[];
840
- warnings?: string[];
841
863
  }
842
864
 
843
- export function validateInlineComments(
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
- ): CommentValidationResult {
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.filename) files.set(file.filename, 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 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`);
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 (side !== "LEFT" && side !== "RIGHT") {
868
- errors.push(`${label}: side must be LEFT or RIGHT`);
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 (!Number.isInteger(start) || !Number.isInteger(end) || Number(start) <= 0 || Number(end) < Number(start)) {
872
- errors.push(`${label}: invalid line range`);
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
- errors.push(`${label}: path is not a changed file`);
926
+ diagnostics.push(`${label}: path is not a changed file; kept in the review summary`);
878
927
  continue;
879
928
  }
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`);
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
- 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}`);
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 = `${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`);
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
- if (containsReservedReviewMarker(body)) {
903
- errors.push(`${label}: comment body contains a reserved pi-pr-review marker`);
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
- // 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
- });
957
+ comments.push(comment);
916
958
  }
917
- if (comments.length > MAX_INLINE_COMMENTS) {
918
- errors.push(`too many inline comments (${comments.length}; max ${MAX_INLINE_COMMENTS})`);
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 { comments, errors, warnings };
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 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[];
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?: string | null;
1057
- user?: { login?: string | null } | null;
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
- return [...flattenPages<AuthoredBody>(reviewPages), ...flattenPages<AuthoredBody>(commentPages)].some(
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 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;
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
- 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" };
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 = 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;
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.5",
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",
@@ -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