pi-pr-review 1.10.4 → 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,19 @@
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
+
10
+ ## [1.10.5](https://github.com/10ego/pi-pr-review/compare/v1.10.4...v1.10.5) (2026-07-17)
11
+
12
+
13
+ ### Miscellaneous Chores
14
+
15
+ * **release:** harden Node 24 automation ([#32](https://github.com/10ego/pi-pr-review/issues/32)) ([6b670b3](https://github.com/10ego/pi-pr-review/commit/6b670b3a1e33f3f2392027ae79c159afd9738690))
16
+
3
17
  ## [1.10.4](https://github.com/10ego/pi-pr-review/compare/v1.10.3...v1.10.4) (2026-07-15)
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,