pi-pr-review 1.5.1 → 1.5.2

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/README.md CHANGED
@@ -130,11 +130,13 @@ The orchestrator (which fetches the PR, merges findings, and emits the JSON) and
130
130
  Type `/` in the pi editor and pick `pr-review`, or:
131
131
 
132
132
  ```
133
- /pr-review 123 # use autoPostReviews (false by default)
134
- /pr-review 123 --comment # force one COMMENT review for this run
135
- /pr-review 123 --no-comment # suppress posting for this run
136
- /pr-review 123 --include-closed # review a closed/merged PR without a confirmation prompt
137
- /pr-review 123 --review-closed --comment # review and attempt a body-only COMMENT review
133
+ /pr-review 123 # use autoPostReviews (false by default)
134
+ /pr-review 123 --comment # force one COMMENT review for this run
135
+ /pr-review 123 --no-comment # suppress posting for this run
136
+ /pr-review 123 --include-closed # review a closed/merged PR without a confirmation prompt
137
+ /pr-review 123 --review-closed --comment # review and attempt a body-only COMMENT review
138
+ /pr-review-publish 123 # publish the completed review cached in this session
139
+ /pr-review-publish 123 --allow-stale # publish it after the PR head changed; never rerun the model
138
140
  ```
139
141
 
140
142
  `123` is the PR number in the current repo.
@@ -149,6 +151,18 @@ Closed/merged PRs no longer hard-skip. If you run `/pr-review 123` on a non-open
149
151
 
150
152
  Publishing is extension-owned—the model never constructs the write request, and final JSON marked `disposition: "skipped"` is never published. It creates exactly one formal pull-request review. The top-level body contains overview/verdict information, inline-vs-summary counts, nits, and findings that cannot be posted inline; successfully attached P0–P3 findings appear only in their associated inline comments, not duplicated in the top-level body. The GitHub event is hardcoded to `COMMENT`; the publisher cannot send `APPROVE` or `REQUEST_CHANGES`, even when the review's suggested verdict is `request_changes`. It validates the current head and every inline diff anchor before the single POST, so an invalid open-PR inline comment fails without leaving a partial review. Closed/merged PRs require either `--include-closed`/`--review-closed` or the one-shot affirmative confirmation, then use one body-only `COMMENT` review with each formerly-inline finding folded into the body exactly once. Unknown or unconfirmed non-open lifecycle states fail without posting.
151
153
 
154
+ ### Publishing a completed review after a new commit
155
+
156
+ The latest valid, reviewed final result for each repository and PR is cached before GitHub publication preflight for the current extension session. If a commit lands after review generation and the normal stale-head guard refuses to post, do **not** rerun `/pr-review` with `--comment` and do not ask the model to bypass its write policy. Use the extension-owned publish-only command:
157
+
158
+ ```
159
+ /pr-review-publish 123 --allow-stale
160
+ ```
161
+
162
+ This command calls no review model and reuses the completed result for PR `123`. The default `/pr-review-publish 123` still rejects a stale head; `--allow-stale` is the explicit acknowledgement. A stale review is posted as one body-only formal `COMMENT` review against the head observed during preflight, begins with a warning that shows both the reviewed and observed full SHAs, and keeps every finding in the body. Inline comments are intentionally disabled because their original diff anchors may no longer be valid. Repository binding, duplicate, draft, lifecycle, payload-size, identity, and final head-stability checks remain active. If another commit lands during the publish preflight, run the same publish-only command again to acknowledge that newer observed head.
163
+
164
+ The in-memory cache is discarded when a new pi session starts or extensions reload. `/pr-review-publish` explicitly refuses to start or rerun a review when no cached result exists, and it will not publish an older cached result while a new review of the same PR is active.
165
+
152
166
  ### Duplicate review handling
153
167
 
154
168
  Published reviews include a hidden `pi-pr-review` marker with the reviewed `headRefOid`. A later run skips only when it finds a same-head marker authored by the current authenticated GitHub identity in either formal reviews or legacy issue comments. If new commits were pushed, the head SHA changes and `/pr-review` reviews the PR again. Older or unmarked content is not proof that the current head was reviewed.
@@ -19,15 +19,19 @@ import {
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import {
21
21
  classifyAssistantCompletion,
22
+ CompletedReviewCache,
22
23
  isNonOpenConfirmationPrompt,
24
+ parsePublishExistingArgs,
23
25
  parsePublishMode,
24
26
  parsePublishableReview,
25
27
  publishPullReview,
26
28
  resolveAutoPostSetting,
29
+ resolveRepositoryBinding,
27
30
  ReviewInvocationGate,
28
31
  shouldPublishReview,
29
32
  validateReviewInvocation,
30
33
  type AutoPostResolution,
34
+ type RepositoryBinding,
31
35
  type ReviewInvocation,
32
36
  type ReviewLike,
33
37
  } from "../lib/pr-review-publish.ts";
@@ -310,7 +314,28 @@ function resolvePublishingConfig(ctx: ExtensionContext): AutoPostResolution {
310
314
  return resolveAutoPostSetting(user.value, project?.value);
311
315
  }
312
316
 
313
- async function maybePublishReview(text: string, invocation: ReviewInvocation, ctx: ExtensionContext): Promise<void> {
317
+ function notifyPublishResult(
318
+ result: Awaited<ReturnType<typeof publishPullReview>>,
319
+ source: string,
320
+ ctx: ExtensionContext,
321
+ ): void {
322
+ if (result.status === "posted") {
323
+ ctx.ui.notify(`PR review posted as COMMENT (${source})${result.url ? `: ${result.url}` : ""}`, "info");
324
+ } else if (result.status === "posted_degraded") {
325
+ ctx.ui.notify(`PR review posted (${source}): ${result.message}${result.url ? ` ${result.url}` : ""}`, "warning");
326
+ } else if (result.status === "skipped_duplicate") {
327
+ ctx.ui.notify("PR review not reposted: this reviewed head was already posted by the current GitHub identity", "info");
328
+ } else {
329
+ ctx.ui.notify(`PR review publish ${result.status}: ${result.message}`, "error");
330
+ }
331
+ }
332
+
333
+ async function maybePublishReview(
334
+ text: string,
335
+ invocation: ReviewInvocation,
336
+ ctx: ExtensionContext,
337
+ expectedRepository?: RepositoryBinding,
338
+ ): Promise<void> {
314
339
  if (invocation.mode === "disabled") return;
315
340
  const setting = resolvePublishingConfig(ctx);
316
341
  if (invocation.mode === "auto") {
@@ -341,25 +366,76 @@ async function maybePublishReview(text: string, invocation: ReviewInvocation, ct
341
366
  prNumber: invocation.prNumber,
342
367
  headSha,
343
368
  allowNonOpen: invocation.allowNonOpen,
369
+ expectedRepository,
344
370
  review: parsed.review as ReviewLike,
345
371
  });
346
372
  const source = invocation.mode === "force" ? "--comment" : `${setting.source} config`;
347
- if (result.status === "posted") {
348
- ctx.ui.notify(`PR review posted as COMMENT (${source})${result.url ? `: ${result.url}` : ""}`, "info");
349
- } else if (result.status === "posted_degraded") {
350
- ctx.ui.notify(`PR review posted as body-only COMMENT for non-open PR (${source})`, "warning");
351
- } else if (result.status === "skipped_duplicate") {
352
- ctx.ui.notify("PR review not reposted: this head was already reviewed by the current GitHub identity", "info");
353
- } else {
354
- ctx.ui.notify(`PR review publish ${result.status}: ${result.message}`, "error");
355
- }
373
+ notifyPublishResult(result, source, ctx);
356
374
  }
357
375
 
358
376
  export default function (pi: ExtensionAPI) {
359
377
  const invocationGate = new ReviewInvocationGate();
378
+ const completedReviews = new CompletedReviewCache();
379
+
380
+ pi.registerCommand("pr-review-publish", {
381
+ description: "Publish a completed review from this session without rerunning the model",
382
+ handler: async (args, ctx) => {
383
+ const parsed = parsePublishExistingArgs(args ?? "");
384
+ if (parsed.error || !parsed.prNumber) {
385
+ ctx.ui.notify(
386
+ `Invalid /pr-review-publish command: ${parsed.error ?? "missing PR number"}. Usage: /pr-review-publish <PR-NUM> [--allow-stale]`,
387
+ "error",
388
+ );
389
+ return;
390
+ }
391
+ const active = invocationGate.peek();
392
+ if (active?.prNumber === parsed.prNumber) {
393
+ ctx.ui.notify(
394
+ `PR #${parsed.prNumber} is still being reviewed. The publish-only command will not post an older cached result while a new result is in progress.`,
395
+ "error",
396
+ );
397
+ return;
398
+ }
399
+ let repository: RepositoryBinding;
400
+ try {
401
+ repository = await resolveRepositoryBinding(ctx.cwd);
402
+ } catch (error) {
403
+ ctx.ui.notify(`Cannot resolve the current GitHub repository: ${String(error)}`, "error");
404
+ return;
405
+ }
406
+ const completed = completedReviews.get(parsed.prNumber, repository);
407
+ if (!completed) {
408
+ ctx.ui.notify(
409
+ `No completed review for PR #${parsed.prNumber} is cached for this repository in the current extension session. This command never starts or reruns a review.`,
410
+ "error",
411
+ );
412
+ return;
413
+ }
414
+ const headSha = completed.review.pr?.head_sha;
415
+ if (typeof headSha !== "string") {
416
+ ctx.ui.notify("Cached PR review is missing pr.head_sha", "error");
417
+ return;
418
+ }
419
+ const result = await publishPullReview({
420
+ cwd: ctx.cwd,
421
+ prNumber: parsed.prNumber,
422
+ headSha,
423
+ allowNonOpen: completed.invocation.allowNonOpen,
424
+ allowStale: parsed.allowStale,
425
+ expectedRepository: completed.repository,
426
+ review: completed.review,
427
+ });
428
+ notifyPublishResult(
429
+ result,
430
+ parsed.allowStale ? "publish-only --allow-stale" : "publish-only",
431
+ ctx,
432
+ );
433
+ },
434
+ });
360
435
 
361
436
  pi.on("session_start", () => {
362
437
  invocationGate.clear();
438
+ completedReviews.clear();
363
439
  });
364
440
 
365
441
  pi.on("input", (event, ctx) => {
@@ -406,7 +482,24 @@ export default function (pi: ExtensionAPI) {
406
482
  if (!text.trim()) return;
407
483
  const review = parseReview(text);
408
484
  if (!review) return; // not a /pr-review JSON payload — leave untouched
409
- if (invocation) await maybePublishReview(text, invocation, ctx);
485
+ if (invocation) {
486
+ const publishable = parsePublishableReview(text);
487
+ let repository: RepositoryBinding | undefined;
488
+ if (
489
+ publishable.review &&
490
+ !validateReviewInvocation(publishable.review, invocation) &&
491
+ shouldPublishReview(publishable.review)
492
+ ) {
493
+ try {
494
+ repository = await resolveRepositoryBinding(ctx.cwd);
495
+ // Cache before publication preflight so a stale failure remains directly publishable.
496
+ completedReviews.remember(publishable.review, invocation, repository);
497
+ } catch (error) {
498
+ ctx.ui.notify(`Completed review is not available to publish-only: ${String(error)}`, "warning");
499
+ }
500
+ }
501
+ await maybePublishReview(text, invocation, ctx, repository);
502
+ }
410
503
 
411
504
  // Keep raw JSON for automation; only prettify for interactive terminals.
412
505
  if (ctx.mode !== "tui") return;
@@ -88,6 +88,26 @@ export interface ReviewInvocation {
88
88
  allowNonOpen: boolean;
89
89
  }
90
90
 
91
+ export interface PublishExistingParseResult {
92
+ prNumber?: number;
93
+ allowStale: boolean;
94
+ error?: string;
95
+ }
96
+
97
+ /** Parse the direct, model-free `/pr-review-publish` command arguments. */
98
+ export function parsePublishExistingArgs(input: string): PublishExistingParseResult {
99
+ const tokens = input.trim().split(/\s+/).filter(Boolean);
100
+ const requested = Number(tokens[0]);
101
+ if (!Number.isInteger(requested) || requested <= 0) {
102
+ return { allowStale: false, error: "a positive PR number must be the first argument" };
103
+ }
104
+ const unknown = tokens.slice(1).filter((token) => token !== "--allow-stale");
105
+ if (unknown.length > 0) {
106
+ return { allowStale: false, error: `unknown argument${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}` };
107
+ }
108
+ return { prNumber: requested, allowStale: tokens.includes("--allow-stale") };
109
+ }
110
+
91
111
  export type ReviewInvocationPhase = "reviewing" | "awaiting_confirmation" | "confirmed";
92
112
 
93
113
  export function isNonOpenConfirmationPrompt(text: string, prNumber: number): boolean {
@@ -246,6 +266,42 @@ export interface ReviewLike {
246
266
  overall_confidence_score?: number;
247
267
  }
248
268
 
269
+ export interface RepositoryBinding {
270
+ repository: string;
271
+ hostname: string;
272
+ }
273
+
274
+ export interface CompletedReviewRecord {
275
+ review: ReviewLike;
276
+ invocation: ReviewInvocation;
277
+ repository: RepositoryBinding;
278
+ }
279
+
280
+ function completedReviewKey(repository: RepositoryBinding, prNumber: number): string {
281
+ return `${repository.hostname.toLowerCase()}:${repository.repository.toLowerCase()}:${prNumber}`;
282
+ }
283
+
284
+ /** Session-scoped latest completed review per repository and PR. */
285
+ export class CompletedReviewCache {
286
+ private readonly reviews = new Map<string, CompletedReviewRecord>();
287
+
288
+ remember(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding): void {
289
+ this.reviews.set(completedReviewKey(repository, invocation.prNumber), {
290
+ review,
291
+ invocation: { ...invocation },
292
+ repository: { ...repository },
293
+ });
294
+ }
295
+
296
+ get(prNumber: number, repository: RepositoryBinding): CompletedReviewRecord | undefined {
297
+ return this.reviews.get(completedReviewKey(repository, prNumber));
298
+ }
299
+
300
+ clear(): void {
301
+ this.reviews.clear();
302
+ }
303
+ }
304
+
249
305
  export interface PublishableReviewParseResult {
250
306
  review?: ReviewLike;
251
307
  error?: string;
@@ -665,6 +721,19 @@ async function ghJson<T>(args: string[], cwd: string): Promise<T> {
665
721
  return JSON.parse(text) as T;
666
722
  }
667
723
 
724
+ export async function resolveRepositoryBinding(cwd: string): Promise<RepositoryBinding> {
725
+ const repoInfo = await ghJson<{ nameWithOwner?: string; url?: string }>(
726
+ ["repo", "view", "--json", "nameWithOwner,url"],
727
+ cwd,
728
+ );
729
+ const repository = String(repoInfo.nameWithOwner ?? "");
730
+ const hostname = new URL(String(repoInfo.url ?? "")).hostname;
731
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repository) || !/^[a-z0-9.-]+$/i.test(hostname)) {
732
+ throw new Error("invalid GitHub repository or hostname");
733
+ }
734
+ return { repository, hostname };
735
+ }
736
+
668
737
  function flattenPages<T>(value: unknown): T[] {
669
738
  if (!Array.isArray(value)) return [];
670
739
  if (value.every(Array.isArray)) return value.flat() as T[];
@@ -715,6 +784,50 @@ interface PullState {
715
784
  head?: { sha?: string };
716
785
  }
717
786
 
787
+ export interface HeadPublicationPlan {
788
+ reviewedHeadSha: string;
789
+ currentHeadSha: string;
790
+ stale: boolean;
791
+ commitId: string;
792
+ allowInlineComments: boolean;
793
+ }
794
+
795
+ /** Authorize a reviewed/current head pairing without silently weakening stale protection. */
796
+ export function planHeadPublication(
797
+ reviewedHeadSha: string,
798
+ currentHeadSha: string | undefined,
799
+ allowStale: boolean,
800
+ ): { plan?: HeadPublicationPlan; error?: string } {
801
+ const reviewed = reviewedHeadSha.toLowerCase();
802
+ const current = currentHeadSha?.toLowerCase();
803
+ if (!current || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(current)) {
804
+ return { error: "GitHub returned an invalid current PR head SHA" };
805
+ }
806
+ const stale = current !== reviewed;
807
+ if (stale && !allowStale) {
808
+ return {
809
+ error: `PR head changed after review (${reviewed} -> ${current}); refusing to publish stale results. Use /pr-review-publish with --allow-stale to post the completed review without rerunning it`,
810
+ };
811
+ }
812
+ return {
813
+ plan: {
814
+ reviewedHeadSha: reviewed,
815
+ currentHeadSha: current,
816
+ stale,
817
+ commitId: stale ? current : reviewed,
818
+ allowInlineComments: !stale,
819
+ },
820
+ };
821
+ }
822
+
823
+ export function buildStaleReviewNotice(reviewedHeadSha: string, currentHeadSha: string): string {
824
+ return [
825
+ "> [!WARNING]",
826
+ `> This review was generated for commit \`${reviewedHeadSha}\`. At publish preflight, the PR pointed to \`${currentHeadSha}\`.`,
827
+ "> Inline findings were folded into this body because their original diff anchors may be stale.",
828
+ ].join("\n");
829
+ }
830
+
718
831
  export type PullLifecycle = "open" | "non_open";
719
832
 
720
833
  export function authorizePullLifecycle(
@@ -771,9 +884,11 @@ export async function publishPullReview(input: {
771
884
  prNumber: number;
772
885
  headSha: string;
773
886
  allowNonOpen: boolean;
887
+ allowStale?: boolean;
888
+ expectedRepository?: RepositoryBinding;
774
889
  review: ReviewLike;
775
890
  }): Promise<PublishResult> {
776
- const { cwd, prNumber, headSha, allowNonOpen, review } = input;
891
+ const { cwd, prNumber, headSha, allowNonOpen, allowStale = false, expectedRepository, review } = input;
777
892
  if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
778
893
  if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
779
894
  const normalizedHeadSha = headSha.toLowerCase();
@@ -782,29 +897,31 @@ export async function publishPullReview(input: {
782
897
  let hostname: string;
783
898
  let identity: string;
784
899
  try {
785
- const repoInfo = await ghJson<{ nameWithOwner?: string; url?: string }>(
786
- ["repo", "view", "--json", "nameWithOwner,url"],
787
- cwd,
788
- );
789
- repository = String(repoInfo.nameWithOwner ?? "");
790
- hostname = new URL(String(repoInfo.url ?? "")).hostname;
900
+ const binding = await resolveRepositoryBinding(cwd);
901
+ repository = binding.repository;
902
+ hostname = binding.hostname;
903
+ if (
904
+ expectedRepository &&
905
+ completedReviewKey(expectedRepository, prNumber) !== completedReviewKey(binding, prNumber)
906
+ ) {
907
+ return { status: "failed", message: "current GitHub repository does not match the cached review repository" };
908
+ }
791
909
  identity = await ghText(githubApiArgs(hostname, "user", "--jq", ".login"), cwd);
792
910
  } catch (error) {
793
911
  return { status: "failed", message: `GitHub identity/repository lookup failed: ${String(error)}` };
794
912
  }
795
- if (!/^[^/\s]+\/[^/\s]+$/.test(repository) || !/^[a-z0-9.-]+$/i.test(hostname) || !identity) {
796
- return { status: "failed", message: "invalid GitHub repository, hostname, or identity" };
797
- }
913
+ if (!identity) return { status: "failed", message: "invalid GitHub identity" };
798
914
 
799
915
  const marker = canonicalReviewMarker(normalizedHeadSha);
800
916
  const lockKey = `${hostname}:${repository}:${prNumber}:${normalizedHeadSha}:${identity.toLowerCase()}`;
801
917
  return withPublishLock(lockKey, async () => {
802
918
  let pull: PullState;
919
+ let headPlan: HeadPublicationPlan;
803
920
  try {
804
921
  pull = await ghJson<PullState>(githubApiArgs(hostname, `repos/${repository}/pulls/${prNumber}`), cwd);
805
- if (pull.head?.sha?.toLowerCase() !== normalizedHeadSha) {
806
- return { status: "failed", message: "PR head changed after review; refusing to publish stale results" };
807
- }
922
+ const planned = planHeadPublication(normalizedHeadSha, pull.head?.sha, allowStale);
923
+ if (!planned.plan) return { status: "failed", message: planned.error ?? "invalid PR head" };
924
+ headPlan = planned.plan;
808
925
  if (pull.draft) return { status: "failed", message: "draft PR reviews are not automatically published" };
809
926
  const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
810
927
  if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
@@ -818,36 +935,52 @@ export async function publishPullReview(input: {
818
935
  const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
819
936
  if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
820
937
  const isOpen = lifecycle.lifecycle === "open";
821
- const candidates = collectFoldedComments(review);
822
- if (candidates.errors.length > 0) {
823
- return { status: "failed", message: `inline candidate validation failed: ${candidates.errors.join("; ")}` };
824
- }
825
- let comments: PublishComment[] = candidates.comments;
826
- if (isOpen && comments.length > 0) {
827
- let filePages: unknown;
828
- try {
829
- filePages = await ghJson<unknown>(
830
- githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/files?per_page=100`),
831
- cwd,
832
- );
833
- } catch (error) {
834
- return { status: "failed", message: `changed-file lookup failed: ${String(error)}` };
938
+ let comments: PublishComment[] = [];
939
+ if (!isOpen) {
940
+ const candidates = collectFoldedComments(review);
941
+ if (candidates.errors.length > 0) {
942
+ return { status: "failed", message: `inline candidate validation failed: ${candidates.errors.join("; ")}` };
835
943
  }
836
- const validated = validateInlineComments(review, flattenPages<ChangedFileLike>(filePages));
837
- if (validated.errors.length > 0) {
838
- return { status: "failed", message: `inline validation failed: ${validated.errors.join("; ")}` };
944
+ comments = candidates.comments;
945
+ } else if (headPlan.allowInlineComments) {
946
+ const candidates = collectFoldedComments(review);
947
+ if (candidates.errors.length > 0) {
948
+ return { status: "failed", message: `inline candidate validation failed: ${candidates.errors.join("; ")}` };
949
+ }
950
+ if (candidates.comments.length > 0) {
951
+ let filePages: unknown;
952
+ try {
953
+ filePages = await ghJson<unknown>(
954
+ githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/files?per_page=100`),
955
+ cwd,
956
+ );
957
+ } catch (error) {
958
+ return { status: "failed", message: `changed-file lookup failed: ${String(error)}` };
959
+ }
960
+ const validated = validateInlineComments(review, flattenPages<ChangedFileLike>(filePages));
961
+ if (validated.errors.length > 0) {
962
+ return { status: "failed", message: `inline validation failed: ${validated.errors.join("; ")}` };
963
+ }
964
+ comments = validated.comments;
839
965
  }
840
- comments = validated.comments;
841
966
  }
842
967
 
843
968
  const summary = buildReviewSummary(review, comments);
844
969
  const bodyError = validateReviewBody(summary);
845
970
  if (bodyError) return { status: "failed", message: bodyError };
846
- const reviewBody = `${isOpen ? summary : foldInlineComments(summary, comments)}\n\n${marker}`;
971
+ const lifecycleBody = isOpen ? summary : foldInlineComments(summary, comments);
972
+ const disclosedBody = headPlan.stale
973
+ ? `${buildStaleReviewNotice(headPlan.reviewedHeadSha, headPlan.currentHeadSha)}\n\n${lifecycleBody}`
974
+ : lifecycleBody;
975
+ const reviewBody = `${disclosedBody}\n\n${marker}`;
847
976
  if (Buffer.byteLength(reviewBody, "utf8") > MAX_BODY_BYTES) {
848
977
  return { status: "failed", message: "final review body exceeds 65536 UTF-8 bytes" };
849
978
  }
850
- const payload = buildPullReviewPayload(normalizedHeadSha, reviewBody, isOpen ? comments : []);
979
+ const payload = buildPullReviewPayload(
980
+ headPlan.commitId,
981
+ reviewBody,
982
+ isOpen && headPlan.allowInlineComments ? comments : [],
983
+ );
851
984
  if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
852
985
  return { status: "failed", message: "review payload is too large" };
853
986
  }
@@ -857,8 +990,11 @@ export async function publishPullReview(input: {
857
990
  githubApiArgs(hostname, `repos/${repository}/pulls/${prNumber}`),
858
991
  cwd,
859
992
  );
860
- if (refreshed.head?.sha?.toLowerCase() !== normalizedHeadSha) {
861
- return { status: "failed", message: "PR head changed during publish preflight" };
993
+ if (refreshed.head?.sha?.toLowerCase() !== headPlan.currentHeadSha) {
994
+ return {
995
+ status: "failed",
996
+ message: "PR head changed during publish preflight; run the publish-only command again to acknowledge the new current head",
997
+ };
862
998
  }
863
999
  if (refreshed.draft) return { status: "failed", message: "PR became a draft during publish preflight" };
864
1000
  const refreshedLifecycle = authorizePullLifecycle(refreshed.state, refreshed.merged_at, allowNonOpen);
@@ -884,9 +1020,14 @@ export async function publishPullReview(input: {
884
1020
  } catch {
885
1021
  /* accepted response without parseable metadata */
886
1022
  }
1023
+ const degraded = !isOpen || headPlan.stale;
887
1024
  return {
888
- status: isOpen ? "posted" : "posted_degraded",
889
- message: isOpen ? "GitHub COMMENT review posted" : "body-only COMMENT review posted for non-open PR",
1025
+ status: degraded ? "posted_degraded" : "posted",
1026
+ message: headPlan.stale
1027
+ ? `body-only stale COMMENT review posted (${headPlan.reviewedHeadSha} -> ${headPlan.currentHeadSha})`
1028
+ : isOpen
1029
+ ? "GitHub COMMENT review posted"
1030
+ : "body-only COMMENT review posted for non-open PR",
890
1031
  reviewId: response.id,
891
1032
  url: response.html_url,
892
1033
  };
@@ -895,7 +1036,7 @@ export async function publishPullReview(input: {
895
1036
  try {
896
1037
  if (await hasExistingMarker(cwd, hostname, repository, prNumber, identity, normalizedHeadSha)) {
897
1038
  return {
898
- status: isOpen ? "posted" : "posted_degraded",
1039
+ status: !isOpen || headPlan.stale ? "posted_degraded" : "posted",
899
1040
  message: "GitHub review found during failure reconciliation",
900
1041
  reconciled: true,
901
1042
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Model-agnostic GitHub PR code review prompt for pi. Pass a PR number; it derives the base and head branches from the PR in the current repo, reviews the diff, and returns structured JSON findings.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -166,6 +166,8 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
166
166
 
167
167
  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. 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 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.
168
168
 
169
+ The extension caches the latest valid completed review per repository and PR before publication preflight for the current extension session. If publication reports that the PR head changed, the user can run `/pr-review-publish <PR-NUM> --allow-stale` to post that cached result without another model turn. This explicit stale override produces a body-only `COMMENT` review with the reviewed and preflight-observed SHAs disclosed and no potentially invalid inline anchors. Never rerun the review merely to change posting intent, and never attempt the GitHub write yourself.
170
+
169
171
  ---
170
172
 
171
173
  ## OUTPUT FORMAT — your entire response MUST be exactly this JSON
@@ -202,7 +204,7 @@ Your final message must be **exactly one JSON object** matching the shape below
202
204
  }
203
205
  ```
204
206
 
205
- - `pr.head_sha` is the exact full `headRefOid` reviewed; the publisher rejects stale or missing head SHAs.
207
+ - `pr.head_sha` is the exact full `headRefOid` reviewed; the publisher rejects missing SHAs and rejects stale SHAs by default unless the user explicitly invokes the publish-only `--allow-stale` override.
206
208
  - `disposition` is exactly `"reviewed"` or `"skipped"`. The extension never publishes `skipped` results.
207
209
  - `verdict` is exactly `"approve"`, `"request_changes"`, or `"comment"`.
208
210
  - `overall_correctness` is exactly `"patch is correct"` or `"patch is incorrect"`.
@@ -1,20 +1,27 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
3
5
  import {
4
6
  authorizePullLifecycle,
5
7
  bodyHasHeadMarker,
6
8
  buildPullReviewPayload,
7
9
  buildReviewSummary,
10
+ buildStaleReviewNotice,
8
11
  classifyAssistantCompletion,
9
12
  canonicalReviewMarker,
10
13
  collectFoldedComments,
14
+ CompletedReviewCache,
11
15
  containsReservedReviewMarker,
12
16
  foldInlineComments,
13
17
  githubApiArgs,
14
18
  isAffirmativeReviewConfirmation,
15
19
  isNonOpenConfirmationPrompt,
16
20
  parsePublishableReview,
21
+ parsePublishExistingArgs,
17
22
  parsePublishMode,
23
+ planHeadPublication,
24
+ publishPullReview,
18
25
  resolveAutoPostSetting,
19
26
  ReviewInvocationGate,
20
27
  shouldPublishReview,
@@ -155,6 +162,140 @@ describe("trusted invocation mode", () => {
155
162
  });
156
163
  });
157
164
 
165
+ describe("publish-only completed review command", () => {
166
+ test("requires an explicit PR and recognizes only the stale override", () => {
167
+ expect(parsePublishExistingArgs("7")).toEqual({ prNumber: 7, allowStale: false });
168
+ expect(parsePublishExistingArgs("7 --allow-stale")).toEqual({ prNumber: 7, allowStale: true });
169
+ expect(parsePublishExistingArgs("").error).toContain("positive PR number");
170
+ expect(parsePublishExistingArgs("7 --comment").error).toContain("unknown argument");
171
+ });
172
+
173
+ test("retains the latest completed review under its repository and PR", () => {
174
+ const cache = new CompletedReviewCache();
175
+ const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false };
176
+ const repository = { hostname: "github.com", repository: "owner/repo" };
177
+ cache.remember(review, invocation, repository);
178
+ expect(cache.get(7, repository)).toEqual({ review, invocation, repository });
179
+ expect(cache.get(7, { hostname: "github.com", repository: "other/repo" })).toBeUndefined();
180
+ expect(cache.get(8, repository)).toBeUndefined();
181
+ cache.clear();
182
+ expect(cache.get(7, repository)).toBeUndefined();
183
+ });
184
+
185
+ test("keeps stale protection by default and degrades an explicit override", () => {
186
+ const reviewed = "a".repeat(40);
187
+ const current = "b".repeat(40);
188
+ expect(planHeadPublication(reviewed, current, false).error).toContain("--allow-stale");
189
+ const plan = planHeadPublication(reviewed, current, true).plan!;
190
+ expect(plan).toMatchObject({
191
+ reviewedHeadSha: reviewed,
192
+ currentHeadSha: current,
193
+ stale: true,
194
+ commitId: current,
195
+ allowInlineComments: false,
196
+ });
197
+ const body = `${buildStaleReviewNotice(reviewed, current)}\n\n${buildReviewSummary(review, [])}`;
198
+ const payload = buildPullReviewPayload(plan.commitId, body, []);
199
+ expect(payload.comments).toBeUndefined();
200
+ expect(payload.body).toContain(reviewed);
201
+ expect(payload.body).toContain(current);
202
+ expect(payload.body).toContain("[P2] Handle empty input");
203
+ });
204
+
205
+ test("posts an explicitly stale review body through gh without inline comments", async () => {
206
+ const dir = mkdtempSync(join(tmpdir(), "pi-pr-review-gh-"));
207
+ const gh = join(dir, "gh");
208
+ const payloadPath = join(dir, "payload.json");
209
+ writeFileSync(
210
+ gh,
211
+ `#!/usr/bin/env bash
212
+ set -euo pipefail
213
+ args="$*"
214
+ if [[ "$args" == "repo view --json nameWithOwner,url" ]]; then
215
+ echo '{"nameWithOwner":"owner/repo","url":"https://github.com/owner/repo"}'
216
+ elif [[ "$args" == *" user --jq .login"* ]]; then
217
+ echo 'reviewer'
218
+ elif [[ "$args" == *"--method POST"* ]]; then
219
+ cat > "$GH_FAKE_PAYLOAD"
220
+ echo '{"id":42,"html_url":"https://github.com/owner/repo/pull/7#pullrequestreview-42"}'
221
+ elif [[ "$args" == *"pulls/7/reviews?per_page=100"* || "$args" == *"issues/7/comments?per_page=100"* ]]; then
222
+ echo '[]'
223
+ elif [[ "$args" == *"repos/owner/repo/pulls/7"* ]]; then
224
+ printf '{"state":"open","draft":false,"merged_at":null,"head":{"sha":"%s"}}\n' "$GH_FAKE_CURRENT"
225
+ else
226
+ echo "unexpected gh args: $args" >&2
227
+ exit 1
228
+ fi
229
+ `,
230
+ );
231
+ chmodSync(gh, 0o755);
232
+ const previousPath = process.env.PATH;
233
+ const previousPayload = process.env.GH_FAKE_PAYLOAD;
234
+ const previousCurrent = process.env.GH_FAKE_CURRENT;
235
+ const current = "b".repeat(40);
236
+ process.env.PATH = `${dir}:${previousPath ?? ""}`;
237
+ process.env.GH_FAKE_PAYLOAD = payloadPath;
238
+ process.env.GH_FAKE_CURRENT = current;
239
+ try {
240
+ const input = {
241
+ cwd: dir,
242
+ prNumber: 7,
243
+ headSha: "a".repeat(40),
244
+ allowNonOpen: false,
245
+ expectedRepository: { hostname: "github.com", repository: "owner/repo" },
246
+ review,
247
+ };
248
+ const wrongRepository = await publishPullReview({
249
+ ...input,
250
+ expectedRepository: { hostname: "github.com", repository: "other/repo" },
251
+ allowStale: true,
252
+ });
253
+ expect(wrongRepository.message).toContain("does not match the cached review repository");
254
+ const refused = await publishPullReview(input);
255
+ expect(refused.status).toBe("failed");
256
+ expect(refused.message).toContain("--allow-stale");
257
+ const posted = await publishPullReview({ ...input, allowStale: true });
258
+ expect(posted.status).toBe("posted_degraded");
259
+ const payload = JSON.parse(readFileSync(payloadPath, "utf8"));
260
+ expect(payload.commit_id).toBe(current);
261
+ expect(payload.comments).toBeUndefined();
262
+ expect(payload.body).toContain("a".repeat(40));
263
+ expect(payload.body).toContain(current);
264
+ expect(payload.body).toContain("At publish preflight");
265
+ expect(payload.body).toContain("[P2] Handle empty input");
266
+ } finally {
267
+ if (previousPath === undefined) delete process.env.PATH;
268
+ else process.env.PATH = previousPath;
269
+ if (previousPayload === undefined) delete process.env.GH_FAKE_PAYLOAD;
270
+ else process.env.GH_FAKE_PAYLOAD = previousPayload;
271
+ if (previousCurrent === undefined) delete process.env.GH_FAKE_CURRENT;
272
+ else process.env.GH_FAKE_CURRENT = previousCurrent;
273
+ rmSync(dir, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ test("preserves inline publication when the reviewed head is still current", () => {
278
+ const head = "a".repeat(40);
279
+ expect(planHeadPublication(head, head.toUpperCase(), false).plan).toMatchObject({
280
+ stale: false,
281
+ commitId: head,
282
+ allowInlineComments: true,
283
+ });
284
+ });
285
+
286
+ test("registers and documents a direct command rather than delegating stale publication to the model", () => {
287
+ const extension = readFileSync(new URL("../extensions/review-table.ts", import.meta.url), "utf8");
288
+ const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
289
+ const prompt = readFileSync(new URL("../prompts/pr-review.md", import.meta.url), "utf8");
290
+ expect(extension).toContain('pi.registerCommand("pr-review-publish"');
291
+ expect(extension).toContain("This command never starts or reruns a review");
292
+ expect(extension).toContain("still being reviewed");
293
+ expect(readme).toContain("/pr-review-publish 123 --allow-stale");
294
+ expect(readme).toContain("Inline comments are intentionally disabled");
295
+ expect(prompt).toContain("without another model turn");
296
+ });
297
+ });
298
+
158
299
  describe("non-open publication authorization", () => {
159
300
  test("rejects direct unconfirmed closed and unknown states", () => {
160
301
  expect(authorizePullLifecycle("closed", null, false).error).toContain("not authorized");