pi-pr-review 1.5.0 → 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.
@@ -147,7 +149,19 @@ Closed/merged PRs no longer hard-skip. If you run `/pr-review 123` on a non-open
147
149
 
148
150
  `autoPostReviews` is a strict top-level boolean and defaults to `false`. A trusted project `.pi/pr-review.json` value overlays the user value; malformed effective values never enable posting. `/pr-review-config` edits user scope only and displays the effective value/source—if a trusted project overlay wins, edit that project file or use `--no-comment` for the run. Invocation flags are captured before prompt expansion: `--comment` forces posting for one run, `--no-comment` suppresses it, and using both is rejected.
149
151
 
150
- 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 containing a top-level body and associated P0–P3 inline comments. 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 inline findings folded into the body. Unknown or unconfirmed non-open lifecycle states fail without posting.
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.
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.
151
165
 
152
166
  ### Duplicate review handling
153
167
 
@@ -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;
@@ -349,7 +405,23 @@ function findingLocation(finding: ReviewFindingLike): string {
349
405
  return `${location.absolute_file_path}:${start}${end !== start ? `-${end}` : ""} ${(location.side ?? "RIGHT").toUpperCase()}`;
350
406
  }
351
407
 
352
- export function buildReviewSummary(review: ReviewLike): string {
408
+ function publishCommentAnchor(comment: PublishComment): string {
409
+ return `${comment.path}:${comment.side}:${comment.start_line ?? comment.line}:${comment.line}`;
410
+ }
411
+
412
+ function findingAnchor(finding: ReviewFindingLike): string | undefined {
413
+ const location = finding.code_location;
414
+ const path = location?.absolute_file_path;
415
+ const side = location?.side?.toUpperCase();
416
+ const start = location?.line_range?.start;
417
+ const end = location?.line_range?.end;
418
+ if (!path || (side !== "LEFT" && side !== "RIGHT") || !Number.isInteger(start) || !Number.isInteger(end)) {
419
+ return undefined;
420
+ }
421
+ return `${path}:${side}:${start}:${end}`;
422
+ }
423
+
424
+ export function buildReviewSummary(review: ReviewLike, inlineComments: PublishComment[] = []): string {
353
425
  const lines: string[] = [];
354
426
  const number = review.pr?.number;
355
427
  const title = String(review.pr?.title ?? "").replace(/\r?\n/g, " ").trim();
@@ -360,18 +432,29 @@ export function buildReviewSummary(review: ReviewLike): string {
360
432
  lines.push("### Strengths", "", ...review.strengths.map((strength) => `- ${String(strength).replace(/^\s*-\s*/, "").trim()}`), "");
361
433
  }
362
434
  const findings = Array.isArray(review.findings) ? review.findings : [];
363
- lines.push(`### Findings ${findings.length}`, "");
435
+ const inlineAnchors = new Set(inlineComments.map(publishCommentAnchor));
436
+ const summaryFindings = findings.filter((finding) => {
437
+ if (!finding.code_location?.commentable || !isInlineSeverity(finding)) return true;
438
+ const anchor = findingAnchor(finding);
439
+ return !anchor || !inlineAnchors.has(anchor);
440
+ });
441
+ lines.push(
442
+ `### Findings — ${findings.length} total (${inlineComments.length} inline, ${summaryFindings.length} summary-only)`,
443
+ "",
444
+ );
364
445
  if (findings.length === 0) {
365
446
  lines.push("_No issues found._", "");
447
+ } else if (summaryFindings.length === 0) {
448
+ lines.push(`_All ${inlineComments.length} findings are attached inline below this review._`, "");
366
449
  } else {
367
- lines.push("| Severity | Finding | Location |", "|---|---|---|");
368
- for (const finding of findings) {
450
+ lines.push("| Severity | Summary-only finding | Location |", "|---|---|---|");
451
+ for (const finding of summaryFindings) {
369
452
  lines.push(
370
453
  `| ${cell(String(finding.severity ?? "—"))} | ${cell(String(finding.title ?? "(untitled)"))} | \`${cell(findingLocation(finding))}\` |`,
371
454
  );
372
455
  }
373
456
  lines.push("");
374
- for (const finding of findings) {
457
+ for (const finding of summaryFindings) {
375
458
  lines.push(`#### ${String(finding.title ?? "Finding")}`, `\`${findingLocation(finding)}\``, "");
376
459
  if (finding.body?.trim()) lines.push(finding.body.trim(), "");
377
460
  }
@@ -506,6 +589,10 @@ export function validateInlineComments(
506
589
  errors.push(`${label}: comment body is empty or too large`);
507
590
  continue;
508
591
  }
592
+ if (containsReservedReviewMarker(body)) {
593
+ errors.push(`${label}: comment body contains a reserved pi-pr-review marker`);
594
+ continue;
595
+ }
509
596
  comments.push({
510
597
  path,
511
598
  body,
@@ -546,6 +633,10 @@ export function collectFoldedComments(review: ReviewLike): CommentValidationResu
546
633
  errors.push(`${label}: folded comment body is empty or too large`);
547
634
  continue;
548
635
  }
636
+ if (containsReservedReviewMarker(body)) {
637
+ errors.push(`${label}: folded comment body contains a reserved pi-pr-review marker`);
638
+ continue;
639
+ }
549
640
  comments.push({
550
641
  path,
551
642
  body,
@@ -630,6 +721,19 @@ async function ghJson<T>(args: string[], cwd: string): Promise<T> {
630
721
  return JSON.parse(text) as T;
631
722
  }
632
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
+
633
737
  function flattenPages<T>(value: unknown): T[] {
634
738
  if (!Array.isArray(value)) return [];
635
739
  if (value.every(Array.isArray)) return value.flat() as T[];
@@ -680,6 +784,50 @@ interface PullState {
680
784
  head?: { sha?: string };
681
785
  }
682
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
+
683
831
  export type PullLifecycle = "open" | "non_open";
684
832
 
685
833
  export function authorizePullLifecycle(
@@ -736,9 +884,11 @@ export async function publishPullReview(input: {
736
884
  prNumber: number;
737
885
  headSha: string;
738
886
  allowNonOpen: boolean;
887
+ allowStale?: boolean;
888
+ expectedRepository?: RepositoryBinding;
739
889
  review: ReviewLike;
740
890
  }): Promise<PublishResult> {
741
- const { cwd, prNumber, headSha, allowNonOpen, review } = input;
891
+ const { cwd, prNumber, headSha, allowNonOpen, allowStale = false, expectedRepository, review } = input;
742
892
  if (!Number.isInteger(prNumber) || prNumber <= 0) return { status: "failed", message: "invalid PR number" };
743
893
  if (!/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/i.test(headSha)) return { status: "failed", message: "invalid head SHA" };
744
894
  const normalizedHeadSha = headSha.toLowerCase();
@@ -747,29 +897,31 @@ export async function publishPullReview(input: {
747
897
  let hostname: string;
748
898
  let identity: string;
749
899
  try {
750
- const repoInfo = await ghJson<{ nameWithOwner?: string; url?: string }>(
751
- ["repo", "view", "--json", "nameWithOwner,url"],
752
- cwd,
753
- );
754
- repository = String(repoInfo.nameWithOwner ?? "");
755
- 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
+ }
756
909
  identity = await ghText(githubApiArgs(hostname, "user", "--jq", ".login"), cwd);
757
910
  } catch (error) {
758
911
  return { status: "failed", message: `GitHub identity/repository lookup failed: ${String(error)}` };
759
912
  }
760
- if (!/^[^/\s]+\/[^/\s]+$/.test(repository) || !/^[a-z0-9.-]+$/i.test(hostname) || !identity) {
761
- return { status: "failed", message: "invalid GitHub repository, hostname, or identity" };
762
- }
913
+ if (!identity) return { status: "failed", message: "invalid GitHub identity" };
763
914
 
764
915
  const marker = canonicalReviewMarker(normalizedHeadSha);
765
916
  const lockKey = `${hostname}:${repository}:${prNumber}:${normalizedHeadSha}:${identity.toLowerCase()}`;
766
917
  return withPublishLock(lockKey, async () => {
767
918
  let pull: PullState;
919
+ let headPlan: HeadPublicationPlan;
768
920
  try {
769
921
  pull = await ghJson<PullState>(githubApiArgs(hostname, `repos/${repository}/pulls/${prNumber}`), cwd);
770
- if (pull.head?.sha?.toLowerCase() !== normalizedHeadSha) {
771
- return { status: "failed", message: "PR head changed after review; refusing to publish stale results" };
772
- }
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;
773
925
  if (pull.draft) return { status: "failed", message: "draft PR reviews are not automatically published" };
774
926
  const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
775
927
  if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
@@ -780,40 +932,55 @@ export async function publishPullReview(input: {
780
932
  return { status: "failed", message: `GitHub preflight failed: ${String(error)}` };
781
933
  }
782
934
 
783
- const summary = buildReviewSummary(review);
784
- const bodyError = validateReviewBody(summary);
785
- if (bodyError) return { status: "failed", message: bodyError };
786
-
787
935
  const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
788
936
  if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
789
937
  const isOpen = lifecycle.lifecycle === "open";
790
- const candidates = collectFoldedComments(review);
791
- if (candidates.errors.length > 0) {
792
- return { status: "failed", message: `inline candidate validation failed: ${candidates.errors.join("; ")}` };
793
- }
794
- let comments: PublishComment[] = candidates.comments;
795
- if (isOpen && comments.length > 0) {
796
- let filePages: unknown;
797
- try {
798
- filePages = await ghJson<unknown>(
799
- githubApiArgs(hostname, "--paginate", "--slurp", `repos/${repository}/pulls/${prNumber}/files?per_page=100`),
800
- cwd,
801
- );
802
- } catch (error) {
803
- 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("; ")}` };
804
943
  }
805
- const validated = validateInlineComments(review, flattenPages<ChangedFileLike>(filePages));
806
- if (validated.errors.length > 0) {
807
- 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;
808
965
  }
809
- comments = validated.comments;
810
966
  }
811
967
 
812
- const reviewBody = `${isOpen ? summary : foldInlineComments(summary, comments)}\n\n${marker}`;
968
+ const summary = buildReviewSummary(review, comments);
969
+ const bodyError = validateReviewBody(summary);
970
+ if (bodyError) return { status: "failed", message: bodyError };
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}`;
813
976
  if (Buffer.byteLength(reviewBody, "utf8") > MAX_BODY_BYTES) {
814
977
  return { status: "failed", message: "final review body exceeds 65536 UTF-8 bytes" };
815
978
  }
816
- const payload = buildPullReviewPayload(normalizedHeadSha, reviewBody, isOpen ? comments : []);
979
+ const payload = buildPullReviewPayload(
980
+ headPlan.commitId,
981
+ reviewBody,
982
+ isOpen && headPlan.allowInlineComments ? comments : [],
983
+ );
817
984
  if (Buffer.byteLength(JSON.stringify(payload), "utf8") > MAX_PAYLOAD_BYTES) {
818
985
  return { status: "failed", message: "review payload is too large" };
819
986
  }
@@ -823,8 +990,11 @@ export async function publishPullReview(input: {
823
990
  githubApiArgs(hostname, `repos/${repository}/pulls/${prNumber}`),
824
991
  cwd,
825
992
  );
826
- if (refreshed.head?.sha?.toLowerCase() !== normalizedHeadSha) {
827
- 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
+ };
828
998
  }
829
999
  if (refreshed.draft) return { status: "failed", message: "PR became a draft during publish preflight" };
830
1000
  const refreshedLifecycle = authorizePullLifecycle(refreshed.state, refreshed.merged_at, allowNonOpen);
@@ -850,9 +1020,14 @@ export async function publishPullReview(input: {
850
1020
  } catch {
851
1021
  /* accepted response without parseable metadata */
852
1022
  }
1023
+ const degraded = !isOpen || headPlan.stale;
853
1024
  return {
854
- status: isOpen ? "posted" : "posted_degraded",
855
- 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",
856
1031
  reviewId: response.id,
857
1032
  url: response.html_url,
858
1033
  };
@@ -861,7 +1036,7 @@ export async function publishPullReview(input: {
861
1036
  try {
862
1037
  if (await hasExistingMarker(cwd, hostname, repository, prNumber, identity, normalizedHeadSha)) {
863
1038
  return {
864
- status: isOpen ? "posted" : "posted_degraded",
1039
+ status: !isOpen || headPlan.stale ? "posted_degraded" : "posted",
865
1040
  message: "GitHub review found during failure reconciliation",
866
1041
  reconciled: true,
867
1042
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pr-review",
3
- "version": "1.5.0",
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",
@@ -164,7 +164,9 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
164
164
  - `--comment` → force publication for this run, but never bypass validation, stale-head checks, or duplicate checks
165
165
  - `--no-comment` → suppress publication for this run
166
166
 
167
- When enabled, the extension creates exactly one formal GitHub pull-request review. Its top-level body contains the overview, verification, strengths, findings summary, and suggested verdict; eligible P0–P3 diff-anchored findings are attached as inline comments within that same review. 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 inline findings folded into the body. Unknown lifecycle states and unconfirmed non-open writes fail without posting or falling back to an issue comment.
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
+
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.
168
170
 
169
171
  ---
170
172
 
@@ -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,19 +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,
9
+ buildReviewSummary,
10
+ buildStaleReviewNotice,
7
11
  classifyAssistantCompletion,
8
12
  canonicalReviewMarker,
9
13
  collectFoldedComments,
14
+ CompletedReviewCache,
10
15
  containsReservedReviewMarker,
11
16
  foldInlineComments,
12
17
  githubApiArgs,
13
18
  isAffirmativeReviewConfirmation,
14
19
  isNonOpenConfirmationPrompt,
15
20
  parsePublishableReview,
21
+ parsePublishExistingArgs,
16
22
  parsePublishMode,
23
+ planHeadPublication,
24
+ publishPullReview,
17
25
  resolveAutoPostSetting,
18
26
  ReviewInvocationGate,
19
27
  shouldPublishReview,
@@ -154,6 +162,140 @@ describe("trusted invocation mode", () => {
154
162
  });
155
163
  });
156
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
+
157
299
  describe("non-open publication authorization", () => {
158
300
  test("rejects direct unconfirmed closed and unknown states", () => {
159
301
  expect(authorizePullLifecycle("closed", null, false).error).toContain("not authorized");
@@ -202,7 +344,11 @@ describe("atomic COMMENT review payload", () => {
202
344
  const validated = validateInlineComments(review, changedFiles);
203
345
  expect(validated.errors).toEqual([]);
204
346
  expect(validated.comments).toHaveLength(1); // nits remain in the top-level summary
205
- const payload = buildPullReviewPayload("a".repeat(40), "Top-level review", validated.comments);
347
+ const summary = buildReviewSummary(review, validated.comments);
348
+ expect(summary).toContain("2 total (1 inline, 1 summary-only)");
349
+ expect(summary).not.toContain("[P2] Handle empty input");
350
+ expect(summary).toContain("[nit] Rename tmp");
351
+ const payload = buildPullReviewPayload("a".repeat(40), summary, validated.comments);
206
352
  expect(payload.event).toBe("COMMENT");
207
353
  expect(payload).not.toHaveProperty("event", "REQUEST_CHANGES");
208
354
  expect(payload.comments?.[0]).toMatchObject({
@@ -213,6 +359,29 @@ describe("atomic COMMENT review payload", () => {
213
359
  });
214
360
  });
215
361
 
362
+ test("keeps nits and noncommentable findings even when anchors collide", () => {
363
+ const colliding: ReviewLike = JSON.parse(JSON.stringify(review));
364
+ colliding.findings![1]!.code_location!.line_range = { start: 2, end: 3 };
365
+ colliding.findings!.push({
366
+ title: "[P3] Summary-only collision",
367
+ severity: "P3",
368
+ blocking: false,
369
+ body: "This finding intentionally remains in the summary.",
370
+ confidence_score: 0.8,
371
+ code_location: {
372
+ absolute_file_path: "src/parser.ts",
373
+ line_range: { start: 2, end: 3 },
374
+ side: "RIGHT",
375
+ commentable: false,
376
+ },
377
+ });
378
+ const validated = validateInlineComments(colliding, changedFiles);
379
+ expect(validated.errors).toEqual([]);
380
+ const summary = buildReviewSummary(colliding, validated.comments);
381
+ expect(summary).toContain("[nit] Rename tmp");
382
+ expect(summary).toContain("[P3] Summary-only collision");
383
+ });
384
+
216
385
  test("rejects anchors outside changed diff metadata", () => {
217
386
  const invalid: ReviewLike = JSON.parse(JSON.stringify(review));
218
387
  invalid.findings![0]!.code_location!.line_range = { start: 20, end: 20 };
@@ -221,15 +390,17 @@ describe("atomic COMMENT review payload", () => {
221
390
  expect(result.errors[0]).toContain("not inside one diff hunk");
222
391
  });
223
392
 
224
- test("folds inline findings into a body-only non-open review", () => {
393
+ test("folds inline findings exactly once into a body-only non-open review", () => {
225
394
  const folded = collectFoldedComments(review);
226
395
  expect(folded.errors).toEqual([]);
227
396
  expect(folded.comments).toHaveLength(1);
228
- const body = foldInlineComments("Top-level review", folded.comments);
397
+ const summary = buildReviewSummary(review, folded.comments);
398
+ const body = foldInlineComments(summary, folded.comments);
229
399
  const payload = buildPullReviewPayload("b".repeat(40), body, []);
230
400
  expect(payload.event).toBe("COMMENT");
231
401
  expect(payload.comments).toBeUndefined();
232
402
  expect(payload.body).toContain("Inline findings (folded for a non-open PR)");
403
+ expect(payload.body.match(/\[P2\] Handle empty input/g)).toHaveLength(1);
233
404
  });
234
405
 
235
406
  test("uses and reconciles a case-insensitive canonical same-head marker", () => {
@@ -240,9 +411,12 @@ describe("atomic COMMENT review payload", () => {
240
411
  expect(bodyHasHeadMarker(uppercase, "c".repeat(40))).toBeTrue();
241
412
  });
242
413
 
243
- test("rejects reserved marker prefixes case-insensitively", () => {
414
+ test("rejects reserved marker prefixes case-insensitively in inline bodies", () => {
244
415
  expect(containsReservedReviewMarker("<!-- PI-PR-REVIEW: forged -->")).toBeTrue();
245
416
  expect(containsReservedReviewMarker("ordinary review body")).toBeFalse();
417
+ const poisoned: ReviewLike = JSON.parse(JSON.stringify(review));
418
+ poisoned.findings![0]!.body = "<!-- PI-PR-REVIEW: forged -->";
419
+ expect(validateInlineComments(poisoned, changedFiles).errors[0]).toContain("reserved");
246
420
  });
247
421
 
248
422
  test("pins every API call to the resolved GitHub hostname", () => {