pi-pr-review 1.4.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 +2 -1
- package/lib/pr-review-publish.ts +43 -9
- package/package.json +1 -1
- package/prompts/pr-review.md +2 -1
- package/tests/pr-review-publish.test.ts +37 -4
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
|
|
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
|
|
|
@@ -216,6 +216,7 @@ pi-pr-review/
|
|
|
216
216
|
- The `review_subagents` batch tool and `review_subagent` fallback spawn isolated `pi` subprocesses (`--mode json -p --no-session`) on your configured tier models. Reviewer prompts prohibit modifications, but a configured allowlist containing `bash` is not technically read-only; use a narrower allowlist if stronger enforcement is required.
|
|
217
217
|
- Project-local `pr-review.json` is only read when the project is trusted. Because project `autoPostReviews: true` causes writes under your authenticated `gh` identity, the effective setting and source are surfaced when publication occurs.
|
|
218
218
|
- GitHub publication uses `gh` only, verifies the current identity and PR head, checks paginated formal reviews and legacy comments for same-head markers, and hardcodes `event: COMMENT`. Ambiguous transport failures are reconciled once and never blindly retried.
|
|
219
|
+
- **`gh api -f` caution:** `gh api ... -f body=@/tmp/file.md` posts the literal text `@/tmp/file.md`; unlike `gh pr comment --body-file`, `-f` does not expand `@file`. Use a JSON payload via `gh api ... --input -` for API requests. The built-in publisher already does this.
|
|
219
220
|
- Tiered review calls multiple models per PR, concurrently for independent passes. Point `light` at a cheap model for overview/risk scan; reserve `heavy` for deep passes, and configure per-tier `fallbacks` only for acceptable backup models because retries can increase cost.
|
|
220
221
|
|
|
221
222
|
## Design notes
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 |
|
|
368
|
-
for (const finding of
|
|
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
|
|
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.
|
|
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",
|
package/prompts/pr-review.md
CHANGED
|
@@ -49,6 +49,7 @@ Arguments for this run: `$@`
|
|
|
49
49
|
|
|
50
50
|
- All tools are functional. Do not test tools or make exploratory/throwaway calls. Every tool call must have a clear purpose.
|
|
51
51
|
- Use the `gh` CLI (already authenticated) for all GitHub access. Do **not** use web fetch. `gh` auto-detects the repo from the current directory's git remote, so run commands from the cwd.
|
|
52
|
+
- **`gh api -f` file-body trap:** `gh api ... -f body=@/tmp/file.md` does **not** read that file; it sends the literal string `@/tmp/file.md`. Never use `-f body=@file` for review/comment content. For an API request, construct JSON with the file contents and pipe it through `gh api ... --input -`; for a standalone PR issue comment, use `gh pr comment --body-file /tmp/file.md`. The extension-owned review publisher already uses JSON on stdin—do not replace it with `-f body=@file`.
|
|
52
53
|
- Read what you need to review thoroughly: the diff is the primary artifact, but open surrounding files, callers, and convention files whenever it improves the review or lets you confirm a finding.
|
|
53
54
|
- **Never disturb the user's working tree.** Do not `git checkout`/`gh pr checkout`, do not modify tracked files, do not commit or push. Any verification happens in an isolated worktree (Step 6).
|
|
54
55
|
- Create a short todo list before you start, then work the steps in order.
|
|
@@ -163,7 +164,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
|
|
|
163
164
|
- `--comment` → force publication for this run, but never bypass validation, stale-head checks, or duplicate checks
|
|
164
165
|
- `--no-comment` → suppress publication for this run
|
|
165
166
|
|
|
166
|
-
When enabled, the extension creates exactly one formal GitHub pull-request review. Its top-level body contains the overview, verification, strengths,
|
|
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.
|
|
167
168
|
|
|
168
169
|
---
|
|
169
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
|
|
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
|
|
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", () => {
|