pi-pr-review 1.6.5 → 1.6.6
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/extensions/review-table.ts +98 -5
- package/lib/pr-review-publish.ts +191 -8
- package/package.json +1 -1
- package/prompts/pr-review.md +1 -1
- package/tests/pr-review-extension-lifecycle.test.ts +197 -0
- package/tests/pr-review-publish.test.ts +74 -1
- package/tests/pr-review-telemetry.test.ts +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.6](https://github.com/10ego/pi-pr-review/compare/v1.6.5...v1.6.6) (2026-07-13)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **publish:** persist completed review cache ([#10](https://github.com/10ego/pi-pr-review/issues/10)) ([e5a1f33](https://github.com/10ego/pi-pr-review/commit/e5a1f333ce6a4bbf624d889a0954bdd251e7d902))
|
|
9
|
+
|
|
3
10
|
## [1.6.5](https://github.com/10ego/pi-pr-review/compare/v1.6.4...v1.6.5) (2026-07-13)
|
|
4
11
|
|
|
5
12
|
|
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ If a new commit makes a completed review stale, publish the cached result withou
|
|
|
150
150
|
/pr-review-publish 123 --allow-stale
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
-
Inline comments are intentionally disabled for stale reviews because the original anchors may no longer be valid. The stale review is body-only and identifies both the reviewed and current SHAs. The cache
|
|
153
|
+
Inline comments are intentionally disabled for stale reviews because the original anchors may no longer be valid. The stale review is body-only and identifies both the reviewed and current SHAs. The cache is stored in the current Pi session, survives extension reloads and session resumes, and is bound to that session instance's ID and creation metadata as well as the repository.
|
|
154
154
|
|
|
155
155
|
## Optional verification
|
|
156
156
|
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
} from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import {
|
|
21
21
|
classifyAssistantCompletion,
|
|
22
|
+
COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE,
|
|
23
|
+
COMPLETED_REVIEW_ENTRY_TYPE,
|
|
22
24
|
CompletedReviewCache,
|
|
23
25
|
decideReviewPublication,
|
|
24
26
|
isNonOpenConfirmationPrompt,
|
|
@@ -28,10 +30,13 @@ import {
|
|
|
28
30
|
publishPullReview,
|
|
29
31
|
resolveAutoPostSetting,
|
|
30
32
|
resolveRepositoryBinding,
|
|
33
|
+
restoreCompletedReviewBranch,
|
|
31
34
|
ReviewInvocationGate,
|
|
32
35
|
shouldPublishReview,
|
|
33
36
|
validateReviewInvocation,
|
|
34
37
|
type AutoPostResolution,
|
|
38
|
+
type CompletedReviewRecord,
|
|
39
|
+
type CompletedReviewSessionIdentity,
|
|
35
40
|
type RepositoryBinding,
|
|
36
41
|
type ReviewInvocation,
|
|
37
42
|
type ReviewLike,
|
|
@@ -363,6 +368,13 @@ async function maybePublishReview(
|
|
|
363
368
|
ctx.ui.notify("PR review was not posted: final JSON is missing pr.head_sha", "error");
|
|
364
369
|
return;
|
|
365
370
|
}
|
|
371
|
+
if (!expectedRepository) {
|
|
372
|
+
ctx.ui.notify(
|
|
373
|
+
"PR review was not posted because its repository identity could not be established before caching; no publish-only cache is available. Rerun /pr-review when GitHub repository lookup is working.",
|
|
374
|
+
"error",
|
|
375
|
+
);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
366
378
|
const result = await publishPullReview({
|
|
367
379
|
cwd: ctx.cwd,
|
|
368
380
|
prNumber: invocation.prNumber,
|
|
@@ -377,6 +389,30 @@ async function maybePublishReview(
|
|
|
377
389
|
export default function (pi: ExtensionAPI) {
|
|
378
390
|
const invocationGate = new ReviewInvocationGate();
|
|
379
391
|
const completedReviews = new CompletedReviewCache();
|
|
392
|
+
const sessionIdentity = (ctx: ExtensionContext): CompletedReviewSessionIdentity | undefined => {
|
|
393
|
+
const header = ctx.sessionManager.getHeader();
|
|
394
|
+
const id = ctx.sessionManager.getSessionId();
|
|
395
|
+
return header?.id === id && typeof header.timestamp === "string"
|
|
396
|
+
? { id, startedAt: header.timestamp }
|
|
397
|
+
: undefined;
|
|
398
|
+
};
|
|
399
|
+
const restoreCompletedReviews = (ctx: ExtensionContext) => {
|
|
400
|
+
const session = sessionIdentity(ctx);
|
|
401
|
+
if (!session) {
|
|
402
|
+
completedReviews.clear();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
restoreCompletedReviewBranch(completedReviews, ctx.sessionManager.getBranch(), session);
|
|
406
|
+
};
|
|
407
|
+
let pendingCompletion:
|
|
408
|
+
| {
|
|
409
|
+
text: string;
|
|
410
|
+
invocation: ReviewInvocation;
|
|
411
|
+
repository?: RepositoryBinding;
|
|
412
|
+
record?: CompletedReviewRecord;
|
|
413
|
+
session?: CompletedReviewSessionIdentity;
|
|
414
|
+
}
|
|
415
|
+
| undefined;
|
|
380
416
|
const telemetryTracker = new ReviewTelemetryTracker();
|
|
381
417
|
const persistTelemetry = (completion: ReviewPerformanceTelemetry["completion"]) => {
|
|
382
418
|
const telemetry = telemetryTracker.finish(completion);
|
|
@@ -444,12 +480,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
444
480
|
},
|
|
445
481
|
});
|
|
446
482
|
|
|
447
|
-
pi.on("session_start", () => {
|
|
483
|
+
pi.on("session_start", (_event, ctx) => {
|
|
448
484
|
invocationGate.clear();
|
|
449
|
-
|
|
485
|
+
pendingCompletion = undefined;
|
|
486
|
+
restoreCompletedReviews(ctx);
|
|
450
487
|
telemetryTracker.clear();
|
|
451
488
|
});
|
|
452
489
|
|
|
490
|
+
pi.on("session_tree", (event, ctx) => {
|
|
491
|
+
invocationGate.clear();
|
|
492
|
+
pendingCompletion = undefined;
|
|
493
|
+
restoreCompletedReviews(ctx);
|
|
494
|
+
telemetryTracker.clear();
|
|
495
|
+
const session = sessionIdentity(ctx);
|
|
496
|
+
if (event.summaryEntry || !session) return;
|
|
497
|
+
try {
|
|
498
|
+
// Pi otherwise resumes at the JSONL tail, not a no-summary /tree selection.
|
|
499
|
+
pi.appendEntry(COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE, { schemaVersion: 2, session });
|
|
500
|
+
} catch (error) {
|
|
501
|
+
ctx.ui.notify(`PR review cache branch selection will not survive session resume: ${String(error)}`, "warning");
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
453
505
|
pi.on("input", (event, ctx) => {
|
|
454
506
|
if (invocationGate.phase() === "awaiting_confirmation") {
|
|
455
507
|
const confirmation = invocationGate.resolveConfirmationInput(event.text);
|
|
@@ -486,6 +538,41 @@ export default function (pi: ExtensionAPI) {
|
|
|
486
538
|
telemetryTracker.toolEnded(event.toolCallId);
|
|
487
539
|
});
|
|
488
540
|
|
|
541
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
542
|
+
const pending = pendingCompletion;
|
|
543
|
+
pendingCompletion = undefined;
|
|
544
|
+
if (!pending) return;
|
|
545
|
+
if (pending.record && pending.session) {
|
|
546
|
+
const currentSession = sessionIdentity(ctx);
|
|
547
|
+
if (
|
|
548
|
+
!currentSession ||
|
|
549
|
+
currentSession.id !== pending.session.id ||
|
|
550
|
+
currentSession.startedAt !== pending.session.startedAt
|
|
551
|
+
) {
|
|
552
|
+
ctx.ui.notify("Completed review was not persisted or posted because the session identity changed", "warning");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const leaf = ctx.sessionManager.getLeafEntry();
|
|
556
|
+
const leafText = leaf?.type === "message" && leaf.message.role === "assistant"
|
|
557
|
+
? assistantText(leaf.message as { content?: MessagePart[] })
|
|
558
|
+
: "";
|
|
559
|
+
const leafReview = parsePublishableReview(leafText).review;
|
|
560
|
+
const reviewEntryId = leaf?.type === "message" && leaf.message.role === "assistant" && leafReview
|
|
561
|
+
? leaf.id
|
|
562
|
+
: undefined;
|
|
563
|
+
try {
|
|
564
|
+
// Persist before any GitHub preflight so a failed post always remains retryable.
|
|
565
|
+
pi.appendEntry(
|
|
566
|
+
COMPLETED_REVIEW_ENTRY_TYPE,
|
|
567
|
+
completedReviews.persist(pending.record, pending.session, reviewEntryId, leafReview),
|
|
568
|
+
);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
ctx.ui.notify(`Completed review cache will not survive an extension reload: ${String(error)}`, "warning");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
await maybePublishReview(pending.text, pending.invocation, ctx, pending.repository);
|
|
574
|
+
});
|
|
575
|
+
|
|
489
576
|
pi.on("message_end", async (event, ctx) => {
|
|
490
577
|
if (event.message.role !== "assistant") return;
|
|
491
578
|
const completion = classifyAssistantCompletion(event.message.stopReason, hasToolCall(event.message));
|
|
@@ -517,6 +604,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
517
604
|
if (invocation) {
|
|
518
605
|
const publishable = parsePublishableReview(text);
|
|
519
606
|
let repository: RepositoryBinding | undefined;
|
|
607
|
+
let record: CompletedReviewRecord | undefined;
|
|
608
|
+
let session: CompletedReviewSessionIdentity | undefined;
|
|
520
609
|
if (
|
|
521
610
|
publishable.review &&
|
|
522
611
|
!validateReviewInvocation(publishable.review, invocation) &&
|
|
@@ -524,13 +613,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
524
613
|
) {
|
|
525
614
|
try {
|
|
526
615
|
repository = await resolveRepositoryBinding(ctx.cwd);
|
|
527
|
-
// Cache before publication preflight
|
|
528
|
-
completedReviews.remember(publishable.review, invocation, repository);
|
|
616
|
+
// Cache before publication preflight; persist after Pi stores the assistant message.
|
|
617
|
+
record = completedReviews.remember(publishable.review, invocation, repository);
|
|
618
|
+
session = sessionIdentity(ctx);
|
|
619
|
+
if (!session) {
|
|
620
|
+
ctx.ui.notify("Completed review cache will not survive reload: session identity is unavailable", "warning");
|
|
621
|
+
}
|
|
529
622
|
} catch (error) {
|
|
530
623
|
ctx.ui.notify(`Completed review is not available to publish-only: ${String(error)}`, "warning");
|
|
531
624
|
}
|
|
532
625
|
}
|
|
533
|
-
|
|
626
|
+
pendingCompletion = { text, invocation, repository, record, session };
|
|
534
627
|
}
|
|
535
628
|
|
|
536
629
|
// Keep raw JSON for automation; only prettify for interactive terminals.
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
|
|
4
5
|
export type PublishMode = "auto" | "force" | "disabled";
|
|
5
6
|
export type AutoPostSource = "default" | "user" | "project";
|
|
@@ -313,20 +314,164 @@ export interface CompletedReviewRecord {
|
|
|
313
314
|
repository: RepositoryBinding;
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
export const COMPLETED_REVIEW_ENTRY_TYPE = "pr-review-completed";
|
|
318
|
+
export const COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE = "pr-review-cache-branch";
|
|
319
|
+
|
|
320
|
+
export interface CompletedReviewSessionIdentity {
|
|
321
|
+
id: string;
|
|
322
|
+
startedAt: string;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface PersistedCompletedReview {
|
|
326
|
+
schemaVersion: 2;
|
|
327
|
+
session: CompletedReviewSessionIdentity;
|
|
328
|
+
invocation: ReviewInvocation;
|
|
329
|
+
repository: RepositoryBinding;
|
|
330
|
+
reviewHash: string;
|
|
331
|
+
reviewEntryId?: string;
|
|
332
|
+
review?: ReviewLike;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface CompletedReviewSessionEntryLike {
|
|
336
|
+
type: string;
|
|
337
|
+
id?: string;
|
|
338
|
+
customType?: string;
|
|
339
|
+
data?: unknown;
|
|
340
|
+
message?: unknown;
|
|
341
|
+
}
|
|
342
|
+
|
|
316
343
|
function completedReviewKey(repository: RepositoryBinding, prNumber: number): string {
|
|
317
344
|
return `${repository.hostname.toLowerCase()}:${repository.repository.toLowerCase()}:${prNumber}`;
|
|
318
345
|
}
|
|
319
346
|
|
|
347
|
+
export function validRepositoryBinding(value: unknown): value is RepositoryBinding {
|
|
348
|
+
if (!isObject(value)) return false;
|
|
349
|
+
return (
|
|
350
|
+
typeof value.repository === "string" &&
|
|
351
|
+
/^[^/\s]+\/[^/\s]+$/.test(value.repository) &&
|
|
352
|
+
typeof value.hostname === "string" &&
|
|
353
|
+
/^[a-z0-9.-]+$/i.test(value.hostname)
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function validSessionIdentity(value: unknown): value is CompletedReviewSessionIdentity {
|
|
358
|
+
return (
|
|
359
|
+
isObject(value) &&
|
|
360
|
+
typeof value.id === "string" &&
|
|
361
|
+
value.id.length > 0 &&
|
|
362
|
+
typeof value.startedAt === "string" &&
|
|
363
|
+
value.startedAt.length > 0
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function sameSessionIdentity(left: CompletedReviewSessionIdentity, right: CompletedReviewSessionIdentity): boolean {
|
|
368
|
+
return left.id === right.id && left.startedAt === right.startedAt;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function reviewHash(review: ReviewLike): string {
|
|
372
|
+
return createHash("sha256").update(JSON.stringify(review)).digest("hex");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined {
|
|
376
|
+
if (!isObject(value)) return undefined;
|
|
377
|
+
if (!new Set(["auto", "force", "disabled"]).has(String(value.mode))) return undefined;
|
|
378
|
+
if (!Number.isInteger(value.prNumber) || Number(value.prNumber) <= 0 || typeof value.allowNonOpen !== "boolean") {
|
|
379
|
+
return undefined;
|
|
380
|
+
}
|
|
381
|
+
const autoPost = value.autoPost;
|
|
382
|
+
if (
|
|
383
|
+
!isObject(autoPost) ||
|
|
384
|
+
typeof autoPost.value !== "boolean" ||
|
|
385
|
+
typeof autoPost.valid !== "boolean" ||
|
|
386
|
+
!new Set(["default", "user", "project"]).has(String(autoPost.source)) ||
|
|
387
|
+
(autoPost.error !== undefined && typeof autoPost.error !== "string")
|
|
388
|
+
) {
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
mode: value.mode as PublishMode,
|
|
393
|
+
prNumber: Number(value.prNumber),
|
|
394
|
+
allowNonOpen: value.allowNonOpen,
|
|
395
|
+
autoPost: {
|
|
396
|
+
value: autoPost.value,
|
|
397
|
+
valid: autoPost.valid,
|
|
398
|
+
source: autoPost.source as AutoPostSource,
|
|
399
|
+
...(typeof autoPost.error === "string" ? { error: autoPost.error } : {}),
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
320
404
|
/** Session-scoped latest completed review per repository and PR. */
|
|
321
405
|
export class CompletedReviewCache {
|
|
322
406
|
private readonly reviews = new Map<string, CompletedReviewRecord>();
|
|
323
407
|
|
|
324
|
-
remember(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding):
|
|
325
|
-
|
|
408
|
+
remember(review: ReviewLike, invocation: ReviewInvocation, repository: RepositoryBinding): CompletedReviewRecord {
|
|
409
|
+
const record = {
|
|
326
410
|
review,
|
|
327
|
-
invocation: { ...invocation },
|
|
411
|
+
invocation: { ...invocation, autoPost: { ...invocation.autoPost } },
|
|
328
412
|
repository: { ...repository },
|
|
329
|
-
}
|
|
413
|
+
};
|
|
414
|
+
this.reviews.set(completedReviewKey(repository, invocation.prNumber), record);
|
|
415
|
+
return record;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
persist(
|
|
419
|
+
record: CompletedReviewRecord,
|
|
420
|
+
session: CompletedReviewSessionIdentity,
|
|
421
|
+
reviewEntryId?: string,
|
|
422
|
+
referencedReview?: ReviewLike,
|
|
423
|
+
): PersistedCompletedReview {
|
|
424
|
+
const digest = reviewHash(record.review);
|
|
425
|
+
const useReference = !!reviewEntryId && !!referencedReview && reviewHash(referencedReview) === digest;
|
|
426
|
+
return {
|
|
427
|
+
schemaVersion: 2,
|
|
428
|
+
session: { ...session },
|
|
429
|
+
invocation: { ...record.invocation, autoPost: { ...record.invocation.autoPost } },
|
|
430
|
+
repository: { ...record.repository },
|
|
431
|
+
reviewHash: digest,
|
|
432
|
+
...(useReference ? { reviewEntryId } : { review: record.review }),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Restore only strictly validated state created by this exact Pi session instance. */
|
|
437
|
+
restore(
|
|
438
|
+
value: unknown,
|
|
439
|
+
session: CompletedReviewSessionIdentity,
|
|
440
|
+
referencedReview?: ReviewLike,
|
|
441
|
+
): boolean {
|
|
442
|
+
if (
|
|
443
|
+
!isObject(value) ||
|
|
444
|
+
value.schemaVersion !== 2 ||
|
|
445
|
+
!validSessionIdentity(value.session) ||
|
|
446
|
+
!sameSessionIdentity(value.session, session) ||
|
|
447
|
+
!validRepositoryBinding(value.repository)
|
|
448
|
+
) {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
const invocation = parsePersistedInvocation(value.invocation);
|
|
452
|
+
if (!invocation || typeof value.reviewHash !== "string" || !/^[0-9a-f]{64}$/.test(value.reviewHash)) {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
const hasReference = typeof value.reviewEntryId === "string" && value.reviewEntryId.length > 0;
|
|
456
|
+
const hasInlineReview = Object.prototype.hasOwnProperty.call(value, "review");
|
|
457
|
+
if (hasReference === hasInlineReview) return false;
|
|
458
|
+
const candidate = hasReference ? referencedReview : value.review;
|
|
459
|
+
let parsed: PublishableReviewParseResult;
|
|
460
|
+
try {
|
|
461
|
+
parsed = parsePublishableReview(JSON.stringify(candidate));
|
|
462
|
+
} catch {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
if (
|
|
466
|
+
!parsed.review ||
|
|
467
|
+
reviewHash(parsed.review) !== value.reviewHash ||
|
|
468
|
+
!shouldPublishReview(parsed.review) ||
|
|
469
|
+
validateReviewInvocation(parsed.review, invocation)
|
|
470
|
+
) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
this.remember(parsed.review, invocation, value.repository);
|
|
474
|
+
return true;
|
|
330
475
|
}
|
|
331
476
|
|
|
332
477
|
get(prNumber: number, repository: RepositoryBinding): CompletedReviewRecord | undefined {
|
|
@@ -338,6 +483,45 @@ export class CompletedReviewCache {
|
|
|
338
483
|
}
|
|
339
484
|
}
|
|
340
485
|
|
|
486
|
+
function reviewFromSessionMessage(entry: CompletedReviewSessionEntryLike | undefined): ReviewLike | undefined {
|
|
487
|
+
if (!entry || entry.type !== "message" || !isObject(entry.message) || entry.message.role !== "assistant") {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
const content = entry.message.content;
|
|
491
|
+
const text = typeof content === "string"
|
|
492
|
+
? content
|
|
493
|
+
: Array.isArray(content)
|
|
494
|
+
? content
|
|
495
|
+
.filter((part) => isObject(part) && part.type === "text" && typeof part.text === "string")
|
|
496
|
+
.map((part) => String(part.text))
|
|
497
|
+
.join("")
|
|
498
|
+
: "";
|
|
499
|
+
return parsePublishableReview(text).review;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Rebuild cache state after session load, reload, resume, or tree navigation. */
|
|
503
|
+
export function restoreCompletedReviewBranch(
|
|
504
|
+
cache: CompletedReviewCache,
|
|
505
|
+
entries: CompletedReviewSessionEntryLike[],
|
|
506
|
+
session: CompletedReviewSessionIdentity,
|
|
507
|
+
): number {
|
|
508
|
+
cache.clear();
|
|
509
|
+
const seenEntries = new Map<string, CompletedReviewSessionEntryLike>();
|
|
510
|
+
let restored = 0;
|
|
511
|
+
for (const entry of entries) {
|
|
512
|
+
if (typeof entry.id === "string") seenEntries.set(entry.id, entry);
|
|
513
|
+
if (entry.type !== "custom" || entry.customType !== COMPLETED_REVIEW_ENTRY_TYPE) continue;
|
|
514
|
+
const reviewEntryId = isObject(entry.data) && typeof entry.data.reviewEntryId === "string"
|
|
515
|
+
? entry.data.reviewEntryId
|
|
516
|
+
: undefined;
|
|
517
|
+
const referencedReview = reviewEntryId
|
|
518
|
+
? reviewFromSessionMessage(seenEntries.get(reviewEntryId))
|
|
519
|
+
: undefined;
|
|
520
|
+
if (cache.restore(entry.data, session, referencedReview)) restored++;
|
|
521
|
+
}
|
|
522
|
+
return restored;
|
|
523
|
+
}
|
|
524
|
+
|
|
341
525
|
export interface PublishableReviewParseResult {
|
|
342
526
|
review?: ReviewLike;
|
|
343
527
|
error?: string;
|
|
@@ -764,10 +948,9 @@ export async function resolveRepositoryBinding(cwd: string): Promise<RepositoryB
|
|
|
764
948
|
);
|
|
765
949
|
const repository = String(repoInfo.nameWithOwner ?? "");
|
|
766
950
|
const hostname = new URL(String(repoInfo.url ?? "")).hostname;
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
return { repository, hostname };
|
|
951
|
+
const binding = { repository, hostname };
|
|
952
|
+
if (!validRepositoryBinding(binding)) throw new Error("invalid GitHub repository or hostname");
|
|
953
|
+
return binding;
|
|
771
954
|
}
|
|
772
955
|
|
|
773
956
|
function flattenPages<T>(value: unknown): T[] {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.6",
|
|
4
4
|
"description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and safe COMMENT-only publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/prompts/pr-review.md
CHANGED
|
@@ -196,7 +196,7 @@ The orchestrator must never call `gh` to post comments or reviews. Always finish
|
|
|
196
196
|
|
|
197
197
|
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.
|
|
198
198
|
|
|
199
|
-
The extension caches the latest valid completed review per repository and PR before publication preflight
|
|
199
|
+
The extension caches the latest valid completed review per repository and PR before publication preflight in the current Pi session. The session-backed cache survives extension reloads and session resumes but is bound to the originating session instance and repository. 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.
|
|
200
200
|
|
|
201
201
|
---
|
|
202
202
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE,
|
|
7
|
+
COMPLETED_REVIEW_ENTRY_TYPE,
|
|
8
|
+
CompletedReviewCache,
|
|
9
|
+
resolveAutoPostSetting,
|
|
10
|
+
type CompletedReviewSessionIdentity,
|
|
11
|
+
type ReviewLike,
|
|
12
|
+
} from "../lib/pr-review-publish.ts";
|
|
13
|
+
|
|
14
|
+
mock.module("@earendil-works/pi-coding-agent", () => ({
|
|
15
|
+
CONFIG_DIR_NAME: ".pi",
|
|
16
|
+
getAgentDir: () => join(tmpdir(), "pi-pr-review-empty-agent-dir"),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const reviewTable = (await import("../extensions/review-table.ts")).default;
|
|
20
|
+
|
|
21
|
+
const review: ReviewLike = {
|
|
22
|
+
pr: { number: 7, title: "Lifecycle review", head_sha: "a".repeat(40) },
|
|
23
|
+
disposition: "reviewed",
|
|
24
|
+
verification: "Not run.",
|
|
25
|
+
overview: "Checks lifecycle persistence.",
|
|
26
|
+
strengths: [],
|
|
27
|
+
findings: [],
|
|
28
|
+
notes: { correctness: "", security: "", performance: "" },
|
|
29
|
+
verdict: "approve",
|
|
30
|
+
overall_correctness: "patch is correct",
|
|
31
|
+
overall_explanation: "No issues found.",
|
|
32
|
+
overall_confidence_score: 0.9,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const session: CompletedReviewSessionIdentity = {
|
|
36
|
+
id: "shared-explicit-id",
|
|
37
|
+
startedAt: "2026-07-13T00:00:00.000Z",
|
|
38
|
+
};
|
|
39
|
+
const repository = { hostname: "github.com", repository: "owner/repo" };
|
|
40
|
+
const invocation = {
|
|
41
|
+
mode: "disabled" as const,
|
|
42
|
+
prNumber: 7,
|
|
43
|
+
allowNonOpen: false,
|
|
44
|
+
autoPost: resolveAutoPostSetting({ autoPostReviews: false }),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface Harness {
|
|
48
|
+
handlers: Map<string, Array<(event: any, ctx: any) => any>>;
|
|
49
|
+
commands: Map<string, (args: string, ctx: any) => Promise<void>>;
|
|
50
|
+
branch: any[];
|
|
51
|
+
notifications: string[];
|
|
52
|
+
ctx: any;
|
|
53
|
+
appendMessage(message: any, id?: string): any;
|
|
54
|
+
emit(name: string, event: any): Promise<any[]>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tempDirs: string[] = [];
|
|
58
|
+
let previousPath: string | undefined;
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
if (previousPath !== undefined) process.env.PATH = previousPath;
|
|
62
|
+
previousPath = undefined;
|
|
63
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function installFakeGh(): string {
|
|
67
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-pr-review-lifecycle-"));
|
|
68
|
+
tempDirs.push(dir);
|
|
69
|
+
const gh = join(dir, "gh");
|
|
70
|
+
writeFileSync(
|
|
71
|
+
gh,
|
|
72
|
+
`#!/usr/bin/env bash
|
|
73
|
+
set -euo pipefail
|
|
74
|
+
if [[ "$*" == "repo view --json nameWithOwner,url" ]]; then
|
|
75
|
+
echo '{"nameWithOwner":"owner/repo","url":"https://github.com/owner/repo"}'
|
|
76
|
+
else
|
|
77
|
+
echo 'intentional lifecycle-test stop' >&2
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
`,
|
|
81
|
+
);
|
|
82
|
+
chmodSync(gh, 0o755);
|
|
83
|
+
if (previousPath === undefined) previousPath = process.env.PATH;
|
|
84
|
+
process.env.PATH = `${dir}:${process.env.PATH ?? ""}`;
|
|
85
|
+
return dir;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function createHarness(initialBranch: any[] = [], identity = session): Harness {
|
|
89
|
+
let nextId = 1;
|
|
90
|
+
const handlers = new Map<string, Array<(event: any, ctx: any) => any>>();
|
|
91
|
+
const commands = new Map<string, (args: string, ctx: any) => Promise<void>>();
|
|
92
|
+
const branch = [...initialBranch];
|
|
93
|
+
const notifications: string[] = [];
|
|
94
|
+
const sessionManager = {
|
|
95
|
+
getBranch: () => [...branch],
|
|
96
|
+
getSessionId: () => identity.id,
|
|
97
|
+
getHeader: () => ({ type: "session", id: identity.id, timestamp: identity.startedAt, cwd: "/tmp" }),
|
|
98
|
+
getLeafEntry: () => branch.at(-1),
|
|
99
|
+
};
|
|
100
|
+
const ctx = {
|
|
101
|
+
cwd: installFakeGh(),
|
|
102
|
+
mode: "json",
|
|
103
|
+
isProjectTrusted: () => false,
|
|
104
|
+
sessionManager,
|
|
105
|
+
ui: { notify: (message: string) => notifications.push(message) },
|
|
106
|
+
};
|
|
107
|
+
const pi = {
|
|
108
|
+
on: (name: string, handler: (event: any, ctx: any) => any) => {
|
|
109
|
+
const registered = handlers.get(name) ?? [];
|
|
110
|
+
registered.push(handler);
|
|
111
|
+
handlers.set(name, registered);
|
|
112
|
+
},
|
|
113
|
+
registerCommand: (name: string, options: { handler: (args: string, ctx: any) => Promise<void> }) => {
|
|
114
|
+
commands.set(name, options.handler);
|
|
115
|
+
},
|
|
116
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
117
|
+
branch.push({ type: "custom", id: `custom-${nextId++}`, customType, data });
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
reviewTable(pi as any);
|
|
121
|
+
return {
|
|
122
|
+
handlers,
|
|
123
|
+
commands,
|
|
124
|
+
branch,
|
|
125
|
+
notifications,
|
|
126
|
+
ctx,
|
|
127
|
+
appendMessage(message: any, id = `message-${nextId++}`) {
|
|
128
|
+
const entry = { type: "message", id, message };
|
|
129
|
+
branch.push(entry);
|
|
130
|
+
return entry;
|
|
131
|
+
},
|
|
132
|
+
async emit(name: string, event: any) {
|
|
133
|
+
const results = [];
|
|
134
|
+
for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
|
|
135
|
+
return results;
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function persistedInlineReview(identity = session): any {
|
|
141
|
+
const cache = new CompletedReviewCache();
|
|
142
|
+
const record = cache.remember(review, invocation, repository);
|
|
143
|
+
return cache.persist(record, identity);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe("completed review extension lifecycle", () => {
|
|
147
|
+
test("persists a reference before publishing after Pi stores exact assistant JSON", async () => {
|
|
148
|
+
const harness = createHarness();
|
|
149
|
+
await harness.emit("input", { text: "/pr-review 7 --comment" });
|
|
150
|
+
const message = {
|
|
151
|
+
role: "assistant",
|
|
152
|
+
stopReason: "stop",
|
|
153
|
+
content: [{ type: "text", text: JSON.stringify(review) }],
|
|
154
|
+
};
|
|
155
|
+
await harness.emit("message_end", { message });
|
|
156
|
+
const assistantEntry = harness.appendMessage(message, "assistant-review");
|
|
157
|
+
await harness.emit("turn_end", { message, toolResults: [] });
|
|
158
|
+
|
|
159
|
+
const persisted = harness.branch.findLast(
|
|
160
|
+
(entry) => entry.type === "custom" && entry.customType === COMPLETED_REVIEW_ENTRY_TYPE,
|
|
161
|
+
);
|
|
162
|
+
expect(persisted?.data.reviewEntryId).toBe(assistantEntry.id);
|
|
163
|
+
expect(persisted?.data.review).toBeUndefined();
|
|
164
|
+
expect(harness.notifications.some((message) => message.includes("PR review publish failed"))).toBeTrue();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("restores on session_start, clears on tree navigation, and scopes reused IDs by header", async () => {
|
|
168
|
+
const persisted = persistedInlineReview();
|
|
169
|
+
const cacheEntry = { type: "custom", id: "cache", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted };
|
|
170
|
+
const harness = createHarness([cacheEntry]);
|
|
171
|
+
await harness.emit("session_start", { reason: "reload" });
|
|
172
|
+
await harness.commands.get("pr-review-publish")!("7 --allow-stale", harness.ctx);
|
|
173
|
+
expect(harness.notifications.some((message) => message.includes("No completed review"))).toBeFalse();
|
|
174
|
+
|
|
175
|
+
harness.branch.splice(0);
|
|
176
|
+
harness.notifications.splice(0);
|
|
177
|
+
await harness.emit("session_tree", { newLeafId: null, oldLeafId: "cache" });
|
|
178
|
+
expect(harness.branch.at(-1)?.customType).toBe(COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE);
|
|
179
|
+
await harness.commands.get("pr-review-publish")!("7 --allow-stale", harness.ctx);
|
|
180
|
+
expect(harness.notifications.some((message) => message.includes("No completed review"))).toBeTrue();
|
|
181
|
+
|
|
182
|
+
const reusedId = createHarness([cacheEntry], { id: session.id, startedAt: "2026-07-14T00:00:00.000Z" });
|
|
183
|
+
await reusedId.emit("session_start", { reason: "fork" });
|
|
184
|
+
await reusedId.commands.get("pr-review-publish")!("7 --allow-stale", reusedId.ctx);
|
|
185
|
+
expect(reusedId.notifications.some((message) => message.includes("No completed review"))).toBeTrue();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("does not append a redundant anchor for summarized tree navigation", async () => {
|
|
189
|
+
const harness = createHarness();
|
|
190
|
+
await harness.emit("session_tree", {
|
|
191
|
+
newLeafId: "summary",
|
|
192
|
+
oldLeafId: "old",
|
|
193
|
+
summaryEntry: { type: "branch_summary", id: "summary" },
|
|
194
|
+
});
|
|
195
|
+
expect(harness.branch.some((entry) => entry.customType === COMPLETED_REVIEW_BRANCH_ANCHOR_TYPE)).toBeFalse();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
classifyAssistantCompletion,
|
|
12
12
|
canonicalReviewMarker,
|
|
13
13
|
collectFoldedComments,
|
|
14
|
+
COMPLETED_REVIEW_ENTRY_TYPE,
|
|
14
15
|
CompletedReviewCache,
|
|
15
16
|
decideReviewPublication,
|
|
16
17
|
containsReservedReviewMarker,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
planHeadPublication,
|
|
25
26
|
publishPullReview,
|
|
26
27
|
resolveAutoPostSetting,
|
|
28
|
+
restoreCompletedReviewBranch,
|
|
27
29
|
ReviewInvocationGate,
|
|
28
30
|
shouldPublishReview,
|
|
29
31
|
validateInlineComments,
|
|
@@ -80,6 +82,7 @@ const changedFiles = [
|
|
|
80
82
|
];
|
|
81
83
|
|
|
82
84
|
const autoOff = resolveAutoPostSetting({ autoPostReviews: false });
|
|
85
|
+
const sessionA = { id: "session-a", startedAt: "2026-07-13T00:00:00.000Z" };
|
|
83
86
|
|
|
84
87
|
describe("automatic posting configuration", () => {
|
|
85
88
|
test("defaults to disabled", () => {
|
|
@@ -292,7 +295,7 @@ describe("publish-only completed review command", () => {
|
|
|
292
295
|
|
|
293
296
|
test("retains the latest completed review under its repository and PR", () => {
|
|
294
297
|
const cache = new CompletedReviewCache();
|
|
295
|
-
const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false };
|
|
298
|
+
const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
|
|
296
299
|
const repository = { hostname: "github.com", repository: "owner/repo" };
|
|
297
300
|
cache.remember(review, invocation, repository);
|
|
298
301
|
expect(cache.get(7, repository)).toEqual({ review, invocation, repository });
|
|
@@ -302,6 +305,76 @@ describe("publish-only completed review command", () => {
|
|
|
302
305
|
expect(cache.get(7, repository)).toBeUndefined();
|
|
303
306
|
});
|
|
304
307
|
|
|
308
|
+
test("restores only validated state from the same Pi session instance", () => {
|
|
309
|
+
const cache = new CompletedReviewCache();
|
|
310
|
+
const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
|
|
311
|
+
const repository = { hostname: "github.com", repository: "owner/repo" };
|
|
312
|
+
const record = cache.remember(review, invocation, repository);
|
|
313
|
+
const persisted = cache.persist(record, sessionA);
|
|
314
|
+
const restored = new CompletedReviewCache();
|
|
315
|
+
expect(restored.restore(persisted, sessionA)).toBeTrue();
|
|
316
|
+
expect(restored.get(7, repository)).toEqual({ review, invocation, repository });
|
|
317
|
+
expect(
|
|
318
|
+
restored.restore(persisted, { id: sessionA.id, startedAt: "2026-07-14T00:00:00.000Z" }),
|
|
319
|
+
).toBeFalse();
|
|
320
|
+
expect(restored.restore({ ...persisted, schemaVersion: 1 }, sessionA)).toBeFalse();
|
|
321
|
+
expect(
|
|
322
|
+
restored.restore(
|
|
323
|
+
{ ...persisted, repository: { hostname: "invalid host", repository: "owner/repo" } },
|
|
324
|
+
sessionA,
|
|
325
|
+
),
|
|
326
|
+
).toBeFalse();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("restores referenced reviews without duplicating raw JSON", () => {
|
|
330
|
+
const cache = new CompletedReviewCache();
|
|
331
|
+
const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
|
|
332
|
+
const repository = { hostname: "github.com", repository: "owner/repo" };
|
|
333
|
+
const record = cache.remember(review, invocation, repository);
|
|
334
|
+
const persisted = cache.persist(record, sessionA, "review-message", review);
|
|
335
|
+
expect(persisted.review).toBeUndefined();
|
|
336
|
+
expect(cache.persist(record, sessionA, "wrong-message", { ...review, overview: "Different" }).review).toEqual(review);
|
|
337
|
+
const branch = [
|
|
338
|
+
{
|
|
339
|
+
type: "message",
|
|
340
|
+
id: "review-message",
|
|
341
|
+
message: { role: "assistant", content: [{ type: "text", text: JSON.stringify(review) }] },
|
|
342
|
+
},
|
|
343
|
+
{ type: "custom", id: "cache-entry", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted },
|
|
344
|
+
];
|
|
345
|
+
const restored = new CompletedReviewCache();
|
|
346
|
+
expect(restoreCompletedReviewBranch(restored, branch, sessionA)).toBe(1);
|
|
347
|
+
expect(restored.get(7, repository)).toEqual({ review, invocation, repository });
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("rebuilds cache state for reloads and session-tree navigation", () => {
|
|
351
|
+
const cache = new CompletedReviewCache();
|
|
352
|
+
const invocation = { mode: "force" as const, prNumber: 7, allowNonOpen: false, autoPost: autoOff };
|
|
353
|
+
const repository = { hostname: "github.com", repository: "owner/repo" };
|
|
354
|
+
const record = cache.remember(review, invocation, repository);
|
|
355
|
+
const persisted = cache.persist(record, sessionA);
|
|
356
|
+
const branch = [{ type: "custom", customType: COMPLETED_REVIEW_ENTRY_TYPE, data: persisted }];
|
|
357
|
+
|
|
358
|
+
expect(restoreCompletedReviewBranch(cache, branch, sessionA)).toBe(1);
|
|
359
|
+
expect(cache.get(7, repository)).toBeDefined();
|
|
360
|
+
expect(restoreCompletedReviewBranch(cache, [], sessionA)).toBe(0);
|
|
361
|
+
expect(cache.get(7, repository)).toBeUndefined();
|
|
362
|
+
expect(
|
|
363
|
+
restoreCompletedReviewBranch(cache, branch, { id: sessionA.id, startedAt: "different-instance" }),
|
|
364
|
+
).toBe(0);
|
|
365
|
+
expect(cache.get(7, repository)).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("does not retry publication without the repository binding used for caching", () => {
|
|
369
|
+
const extension = readFileSync(new URL("../extensions/review-table.ts", import.meta.url), "utf8");
|
|
370
|
+
const publisher = extension.slice(
|
|
371
|
+
extension.indexOf("async function maybePublishReview"),
|
|
372
|
+
extension.indexOf("export default function"),
|
|
373
|
+
);
|
|
374
|
+
expect(publisher).toContain("if (!expectedRepository)");
|
|
375
|
+
expect(publisher).toContain("no publish-only cache is available");
|
|
376
|
+
});
|
|
377
|
+
|
|
305
378
|
test("keeps stale protection by default and degrades an explicit override", () => {
|
|
306
379
|
const reviewed = "a".repeat(40);
|
|
307
380
|
const current = "b".repeat(40);
|
|
@@ -166,8 +166,14 @@ describe("extension telemetry boundaries", () => {
|
|
|
166
166
|
expect(renderer).toContain('pi.appendEntry("pr-review-telemetry", telemetry)');
|
|
167
167
|
expect(renderer).toContain("telemetryTracker.pauseForConfirmation()");
|
|
168
168
|
expect(renderer).toContain("telemetryTracker.resumeAfterConfirmation()");
|
|
169
|
-
|
|
170
|
-
renderer.indexOf("
|
|
169
|
+
const turnEnd = renderer.slice(
|
|
170
|
+
renderer.indexOf('pi.on("turn_end"'),
|
|
171
|
+
renderer.indexOf('pi.on("message_end"'),
|
|
171
172
|
);
|
|
173
|
+
const messageEnd = renderer.slice(renderer.indexOf('pi.on("message_end"'));
|
|
174
|
+
expect(messageEnd.indexOf('persistTelemetry("terminal_response")')).toBeLessThan(
|
|
175
|
+
messageEnd.indexOf("pendingCompletion ="),
|
|
176
|
+
);
|
|
177
|
+
expect(turnEnd.indexOf("pi.appendEntry(")).toBeLessThan(turnEnd.indexOf("await maybePublishReview"));
|
|
172
178
|
});
|
|
173
179
|
});
|