pi-pr-review 1.5.0 → 1.5.1

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
@@ -147,7 +147,7 @@ Closed/merged PRs no longer hard-skip. If you run `/pr-review 123` on a non-open
147
147
 
148
148
  `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
149
 
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.
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. 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
151
 
152
152
  ### Duplicate review handling
153
153
 
@@ -349,7 +349,23 @@ function findingLocation(finding: ReviewFindingLike): string {
349
349
  return `${location.absolute_file_path}:${start}${end !== start ? `-${end}` : ""} ${(location.side ?? "RIGHT").toUpperCase()}`;
350
350
  }
351
351
 
352
- export function buildReviewSummary(review: ReviewLike): string {
352
+ function publishCommentAnchor(comment: PublishComment): string {
353
+ return `${comment.path}:${comment.side}:${comment.start_line ?? comment.line}:${comment.line}`;
354
+ }
355
+
356
+ function findingAnchor(finding: ReviewFindingLike): string | undefined {
357
+ const location = finding.code_location;
358
+ const path = location?.absolute_file_path;
359
+ const side = location?.side?.toUpperCase();
360
+ const start = location?.line_range?.start;
361
+ const end = location?.line_range?.end;
362
+ if (!path || (side !== "LEFT" && side !== "RIGHT") || !Number.isInteger(start) || !Number.isInteger(end)) {
363
+ return undefined;
364
+ }
365
+ return `${path}:${side}:${start}:${end}`;
366
+ }
367
+
368
+ export function buildReviewSummary(review: ReviewLike, inlineComments: PublishComment[] = []): string {
353
369
  const lines: string[] = [];
354
370
  const number = review.pr?.number;
355
371
  const title = String(review.pr?.title ?? "").replace(/\r?\n/g, " ").trim();
@@ -360,18 +376,29 @@ export function buildReviewSummary(review: ReviewLike): string {
360
376
  lines.push("### Strengths", "", ...review.strengths.map((strength) => `- ${String(strength).replace(/^\s*-\s*/, "").trim()}`), "");
361
377
  }
362
378
  const findings = Array.isArray(review.findings) ? review.findings : [];
363
- lines.push(`### Findings ${findings.length}`, "");
379
+ const inlineAnchors = new Set(inlineComments.map(publishCommentAnchor));
380
+ const summaryFindings = findings.filter((finding) => {
381
+ if (!finding.code_location?.commentable || !isInlineSeverity(finding)) return true;
382
+ const anchor = findingAnchor(finding);
383
+ return !anchor || !inlineAnchors.has(anchor);
384
+ });
385
+ lines.push(
386
+ `### Findings — ${findings.length} total (${inlineComments.length} inline, ${summaryFindings.length} summary-only)`,
387
+ "",
388
+ );
364
389
  if (findings.length === 0) {
365
390
  lines.push("_No issues found._", "");
391
+ } else if (summaryFindings.length === 0) {
392
+ lines.push(`_All ${inlineComments.length} findings are attached inline below this review._`, "");
366
393
  } else {
367
- lines.push("| Severity | Finding | Location |", "|---|---|---|");
368
- for (const finding of findings) {
394
+ lines.push("| Severity | Summary-only finding | Location |", "|---|---|---|");
395
+ for (const finding of summaryFindings) {
369
396
  lines.push(
370
397
  `| ${cell(String(finding.severity ?? "—"))} | ${cell(String(finding.title ?? "(untitled)"))} | \`${cell(findingLocation(finding))}\` |`,
371
398
  );
372
399
  }
373
400
  lines.push("");
374
- for (const finding of findings) {
401
+ for (const finding of summaryFindings) {
375
402
  lines.push(`#### ${String(finding.title ?? "Finding")}`, `\`${findingLocation(finding)}\``, "");
376
403
  if (finding.body?.trim()) lines.push(finding.body.trim(), "");
377
404
  }
@@ -506,6 +533,10 @@ export function validateInlineComments(
506
533
  errors.push(`${label}: comment body is empty or too large`);
507
534
  continue;
508
535
  }
536
+ if (containsReservedReviewMarker(body)) {
537
+ errors.push(`${label}: comment body contains a reserved pi-pr-review marker`);
538
+ continue;
539
+ }
509
540
  comments.push({
510
541
  path,
511
542
  body,
@@ -546,6 +577,10 @@ export function collectFoldedComments(review: ReviewLike): CommentValidationResu
546
577
  errors.push(`${label}: folded comment body is empty or too large`);
547
578
  continue;
548
579
  }
580
+ if (containsReservedReviewMarker(body)) {
581
+ errors.push(`${label}: folded comment body contains a reserved pi-pr-review marker`);
582
+ continue;
583
+ }
549
584
  comments.push({
550
585
  path,
551
586
  body,
@@ -780,10 +815,6 @@ export async function publishPullReview(input: {
780
815
  return { status: "failed", message: `GitHub preflight failed: ${String(error)}` };
781
816
  }
782
817
 
783
- const summary = buildReviewSummary(review);
784
- const bodyError = validateReviewBody(summary);
785
- if (bodyError) return { status: "failed", message: bodyError };
786
-
787
818
  const lifecycle = authorizePullLifecycle(pull.state, pull.merged_at, allowNonOpen);
788
819
  if (!lifecycle.lifecycle) return { status: "failed", message: lifecycle.error ?? "invalid PR lifecycle" };
789
820
  const isOpen = lifecycle.lifecycle === "open";
@@ -809,6 +840,9 @@ export async function publishPullReview(input: {
809
840
  comments = validated.comments;
810
841
  }
811
842
 
843
+ const summary = buildReviewSummary(review, comments);
844
+ const bodyError = validateReviewBody(summary);
845
+ if (bodyError) return { status: "failed", message: bodyError };
812
846
  const reviewBody = `${isOpen ? summary : foldInlineComments(summary, comments)}\n\n${marker}`;
813
847
  if (Buffer.byteLength(reviewBody, "utf8") > MAX_BODY_BYTES) {
814
848
  return { status: "failed", message: "final review body exceeds 65536 UTF-8 bytes" };
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.1",
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,7 @@ 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
168
 
169
169
  ---
170
170
 
@@ -4,6 +4,7 @@ import {
4
4
  authorizePullLifecycle,
5
5
  bodyHasHeadMarker,
6
6
  buildPullReviewPayload,
7
+ buildReviewSummary,
7
8
  classifyAssistantCompletion,
8
9
  canonicalReviewMarker,
9
10
  collectFoldedComments,
@@ -202,7 +203,11 @@ describe("atomic COMMENT review payload", () => {
202
203
  const validated = validateInlineComments(review, changedFiles);
203
204
  expect(validated.errors).toEqual([]);
204
205
  expect(validated.comments).toHaveLength(1); // nits remain in the top-level summary
205
- const payload = buildPullReviewPayload("a".repeat(40), "Top-level review", validated.comments);
206
+ const summary = buildReviewSummary(review, validated.comments);
207
+ expect(summary).toContain("2 total (1 inline, 1 summary-only)");
208
+ expect(summary).not.toContain("[P2] Handle empty input");
209
+ expect(summary).toContain("[nit] Rename tmp");
210
+ const payload = buildPullReviewPayload("a".repeat(40), summary, validated.comments);
206
211
  expect(payload.event).toBe("COMMENT");
207
212
  expect(payload).not.toHaveProperty("event", "REQUEST_CHANGES");
208
213
  expect(payload.comments?.[0]).toMatchObject({
@@ -213,6 +218,29 @@ describe("atomic COMMENT review payload", () => {
213
218
  });
214
219
  });
215
220
 
221
+ test("keeps nits and noncommentable findings even when anchors collide", () => {
222
+ const colliding: ReviewLike = JSON.parse(JSON.stringify(review));
223
+ colliding.findings![1]!.code_location!.line_range = { start: 2, end: 3 };
224
+ colliding.findings!.push({
225
+ title: "[P3] Summary-only collision",
226
+ severity: "P3",
227
+ blocking: false,
228
+ body: "This finding intentionally remains in the summary.",
229
+ confidence_score: 0.8,
230
+ code_location: {
231
+ absolute_file_path: "src/parser.ts",
232
+ line_range: { start: 2, end: 3 },
233
+ side: "RIGHT",
234
+ commentable: false,
235
+ },
236
+ });
237
+ const validated = validateInlineComments(colliding, changedFiles);
238
+ expect(validated.errors).toEqual([]);
239
+ const summary = buildReviewSummary(colliding, validated.comments);
240
+ expect(summary).toContain("[nit] Rename tmp");
241
+ expect(summary).toContain("[P3] Summary-only collision");
242
+ });
243
+
216
244
  test("rejects anchors outside changed diff metadata", () => {
217
245
  const invalid: ReviewLike = JSON.parse(JSON.stringify(review));
218
246
  invalid.findings![0]!.code_location!.line_range = { start: 20, end: 20 };
@@ -221,15 +249,17 @@ describe("atomic COMMENT review payload", () => {
221
249
  expect(result.errors[0]).toContain("not inside one diff hunk");
222
250
  });
223
251
 
224
- test("folds inline findings into a body-only non-open review", () => {
252
+ test("folds inline findings exactly once into a body-only non-open review", () => {
225
253
  const folded = collectFoldedComments(review);
226
254
  expect(folded.errors).toEqual([]);
227
255
  expect(folded.comments).toHaveLength(1);
228
- const body = foldInlineComments("Top-level review", folded.comments);
256
+ const summary = buildReviewSummary(review, folded.comments);
257
+ const body = foldInlineComments(summary, folded.comments);
229
258
  const payload = buildPullReviewPayload("b".repeat(40), body, []);
230
259
  expect(payload.event).toBe("COMMENT");
231
260
  expect(payload.comments).toBeUndefined();
232
261
  expect(payload.body).toContain("Inline findings (folded for a non-open PR)");
262
+ expect(payload.body.match(/\[P2\] Handle empty input/g)).toHaveLength(1);
233
263
  });
234
264
 
235
265
  test("uses and reconciles a case-insensitive canonical same-head marker", () => {
@@ -240,9 +270,12 @@ describe("atomic COMMENT review payload", () => {
240
270
  expect(bodyHasHeadMarker(uppercase, "c".repeat(40))).toBeTrue();
241
271
  });
242
272
 
243
- test("rejects reserved marker prefixes case-insensitively", () => {
273
+ test("rejects reserved marker prefixes case-insensitively in inline bodies", () => {
244
274
  expect(containsReservedReviewMarker("<!-- PI-PR-REVIEW: forged -->")).toBeTrue();
245
275
  expect(containsReservedReviewMarker("ordinary review body")).toBeFalse();
276
+ const poisoned: ReviewLike = JSON.parse(JSON.stringify(review));
277
+ poisoned.findings![0]!.body = "<!-- PI-PR-REVIEW: forged -->";
278
+ expect(validateInlineComments(poisoned, changedFiles).errors[0]).toContain("reserved");
246
279
  });
247
280
 
248
281
  test("pins every API call to the resolved GitHub hostname", () => {