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 +20 -6
- package/extensions/review-table.ts +104 -11
- package/lib/pr-review-publish.ts +222 -47
- package/package.json +1 -1
- package/prompts/pr-review.md +4 -2
- package/tests/pr-review-publish.test.ts +179 -5
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
|
|
134
|
-
/pr-review 123 --comment
|
|
135
|
-
/pr-review 123 --no-comment
|
|
136
|
-
/pr-review 123 --include-closed
|
|
137
|
-
/pr-review 123 --review-closed --comment
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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;
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 |
|
|
368
|
-
for (const finding of
|
|
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
|
|
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
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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 (
|
|
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
|
-
|
|
771
|
-
|
|
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
|
-
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
|
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(
|
|
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() !==
|
|
827
|
-
return {
|
|
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:
|
|
855
|
-
message:
|
|
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 ? "
|
|
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.
|
|
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",
|
package/prompts/pr-review.md
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
|
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", () => {
|