-
Notifications
You must be signed in to change notification settings - Fork 219
Expand file tree
/
Copy pathreview.ts
More file actions
2087 lines (1747 loc) · 65.5 KB
/
Copy pathreview.ts
File metadata and controls
2087 lines (1747 loc) · 65.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Code Review Extension (inspired by Codex's review feature)
*
* Provides a `/review` command that prompts the agent to review code changes.
* Supports multiple review modes:
* - Review a GitHub pull request (checks out the PR locally)
* - Review against a base branch (PR style)
* - Review uncommitted changes
* - Review a specific commit
* - Shared custom review instructions (applied to all review modes when configured)
*
* Usage:
* - `/review` - show interactive selector
* - `/review pr 123` - review PR #123 (checks out locally)
* - `/review pr https://github.com/owner/repo/pull/123` - review PR from URL
* - `/review uncommitted` - review uncommitted changes directly
* - `/review branch main` - review against main branch
* - `/review commit abc123` - review specific commit
* - `/review folder src docs` - review specific folders/files (snapshot, not diff)
* - `/review` selector includes Add/Remove custom review instructions (applies to all modes)
* - `/review --extra "focus on performance regressions"` - add extra review instruction (works with any mode)
*
* Project-specific review guidelines:
* - If a REVIEW_GUIDELINES.md file exists in the same directory as .pi,
* its contents are appended to the review prompt.
*
* Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
*/
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import { DynamicBorder, BorderedLoader } from "@earendil-works/pi-coding-agent";
import {
Container,
fuzzyFilter,
Input,
type SelectItem,
SelectList,
Spacer,
Text,
} from "@earendil-works/pi-tui";
import path from "node:path";
import { promises as fs } from "node:fs";
// State to track fresh session review (where we branched from).
// Module-level state means only one review can be active at a time.
// This is intentional - the UI and /end-review command assume a single active review.
let reviewOriginId: string | undefined = undefined;
let endReviewInProgress = false;
let reviewLoopFixingEnabled = false;
let reviewCustomInstructions: string | undefined = undefined;
let reviewLoopInProgress = false;
const REVIEW_STATE_TYPE = "review-session";
const REVIEW_ANCHOR_TYPE = "review-anchor";
const REVIEW_SETTINGS_TYPE = "review-settings";
const REVIEW_LOOP_MAX_ITERATIONS = 10;
const REVIEW_LOOP_START_TIMEOUT_MS = 15000;
const REVIEW_LOOP_START_POLL_MS = 50;
type ReviewSessionState = {
active: boolean;
originId?: string;
};
type ReviewSettingsState = {
loopFixingEnabled?: boolean;
customInstructions?: string;
};
function setReviewWidget(ctx: ExtensionContext, active: boolean) {
if (!ctx.hasUI) return;
if (!active) {
ctx.ui.setWidget("review", undefined);
return;
}
ctx.ui.setWidget("review", (_tui, theme) => {
const message = reviewLoopInProgress
? "Review session active (loop fixing running)"
: reviewLoopFixingEnabled
? "Review session active (loop fixing enabled), return with /end-review"
: "Review session active, return with /end-review";
const text = new Text(theme.fg("warning", message), 0, 0);
return {
render(width: number) {
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
}
function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
let state: ReviewSessionState | undefined;
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
state = entry.data as ReviewSessionState | undefined;
}
}
return state;
}
function applyReviewState(ctx: ExtensionContext) {
const state = getReviewState(ctx);
if (state?.active && state.originId) {
reviewOriginId = state.originId;
setReviewWidget(ctx, true);
return;
}
reviewOriginId = undefined;
setReviewWidget(ctx, false);
}
function getReviewSettings(ctx: ExtensionContext): ReviewSettingsState {
let state: ReviewSettingsState | undefined;
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type === "custom" && entry.customType === REVIEW_SETTINGS_TYPE) {
state = entry.data as ReviewSettingsState | undefined;
}
}
return {
loopFixingEnabled: state?.loopFixingEnabled === true,
customInstructions: state?.customInstructions?.trim() || undefined,
};
}
function applyReviewSettings(ctx: ExtensionContext) {
const state = getReviewSettings(ctx);
reviewLoopFixingEnabled = state.loopFixingEnabled === true;
reviewCustomInstructions = state.customInstructions?.trim() || undefined;
}
function parseMarkdownHeading(line: string): { level: number; title: string } | null {
const headingMatch = line.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
if (!headingMatch) {
return null;
}
const rawTitle = headingMatch[2].replace(/\s+#+\s*$/, "").trim();
return {
level: headingMatch[1].length,
title: rawTitle,
};
}
function getFindingsSectionBounds(lines: string[]): { start: number; end: number } | null {
let start = -1;
let findingsHeadingLevel: number | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const heading = parseMarkdownHeading(line);
if (heading && /^findings\b/i.test(heading.title)) {
start = i + 1;
findingsHeadingLevel = heading.level;
break;
}
if (/^\s*findings\s*:?\s*$/i.test(line)) {
start = i + 1;
break;
}
}
if (start < 0) {
return null;
}
let end = lines.length;
for (let i = start; i < lines.length; i++) {
const line = lines[i];
const heading = parseMarkdownHeading(line);
if (heading) {
const normalizedTitle = heading.title.replace(/[*_`]/g, "").trim();
if (/^(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedTitle)) {
end = i;
break;
}
if (/\[P[0-3]\]/i.test(heading.title)) {
continue;
}
if (findingsHeadingLevel !== null && heading.level <= findingsHeadingLevel) {
end = i;
break;
}
}
if (/^\s*(review scope|verdict|overall verdict|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(line)) {
end = i;
break;
}
}
return { start, end };
}
function isLikelyFindingLine(line: string): boolean {
if (!/\[P[0-3]\]/i.test(line)) {
return false;
}
if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+priority\s+tag\b/i.test(line)) {
return false;
}
if (/^\s*(?:[-*+]|(?:\d+)[.)]|#{1,6})\s+\[P[0-3]\]\s*-\s*(?:drop everything|urgent|normal|low|nice to have)\b/i.test(line)) {
return false;
}
const allPriorityTags = line.match(/\[P[0-3]\]/gi) ?? [];
if (allPriorityTags.length > 1) {
return false;
}
if (/^\s*(?:[-*+]|(?:\d+)[.)])\s+/.test(line)) {
return true;
}
if (/^\s*#{1,6}\s+/.test(line)) {
return true;
}
if (/^\s*(?:\*\*|__)?\[P[0-3]\](?:\*\*|__)?(?=\s|:|-)/i.test(line)) {
return true;
}
return false;
}
function normalizeVerdictValue(value: string): string {
return value
.trim()
.replace(/^[-*+]\s*/, "")
.replace(/^['"`]+|['"`]+$/g, "")
.toLowerCase();
}
function isNeedsAttentionVerdictValue(value: string): boolean {
const normalized = normalizeVerdictValue(value);
if (!normalized.includes("needs attention")) {
return false;
}
if (/\bnot\s+needs\s+attention\b/.test(normalized)) {
return false;
}
// Reject rubric/choice phrasing like "correct or needs attention", but
// keep legitimate verdict text that may contain unrelated "or".
if (/\bcorrect\b/.test(normalized) && /\bor\b/.test(normalized)) {
return false;
}
return true;
}
function hasNeedsAttentionVerdict(messageText: string): boolean {
const lines = messageText.split(/\r?\n/);
for (const line of lines) {
const inlineMatch = line.match(/^\s*(?:[*-+]\s*)?(?:overall\s+)?verdict\s*:\s*(.+)$/i);
if (inlineMatch && isNeedsAttentionVerdictValue(inlineMatch[1])) {
return true;
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const heading = parseMarkdownHeading(line);
let verdictLevel: number | null = null;
if (heading) {
const normalizedHeading = heading.title.replace(/[*_`]/g, "").trim();
if (!/^(?:overall\s+)?verdict\b/i.test(normalizedHeading)) {
continue;
}
verdictLevel = heading.level;
} else if (!/^\s*(?:overall\s+)?verdict\s*:?\s*$/i.test(line)) {
continue;
}
for (let j = i + 1; j < lines.length; j++) {
const verdictLine = lines[j];
const nextHeading = parseMarkdownHeading(verdictLine);
if (nextHeading) {
const normalizedNextHeading = nextHeading.title.replace(/[*_`]/g, "").trim();
if (verdictLevel === null || nextHeading.level <= verdictLevel) {
break;
}
if (/^(review scope|findings|fix queue|constraints(?:\s*&\s*preferences)?)\b:?/i.test(normalizedNextHeading)) {
break;
}
}
const trimmed = verdictLine.trim();
if (!trimmed) {
continue;
}
if (isNeedsAttentionVerdictValue(trimmed)) {
return true;
}
if (/\bcorrect\b/i.test(normalizeVerdictValue(trimmed))) {
break;
}
}
}
return false;
}
function hasBlockingReviewFindings(messageText: string): boolean {
const lines = messageText.split(/\r?\n/);
const bounds = getFindingsSectionBounds(lines);
const candidateLines = bounds ? lines.slice(bounds.start, bounds.end) : lines;
let inCodeFence = false;
let foundTaggedFinding = false;
for (const line of candidateLines) {
if (/^\s*```/.test(line)) {
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
continue;
}
if (!isLikelyFindingLine(line)) {
continue;
}
foundTaggedFinding = true;
if (/\[(P0|P1|P2)\]/i.test(line)) {
return true;
}
}
if (foundTaggedFinding) {
return false;
}
return hasNeedsAttentionVerdict(messageText);
}
// Review target types (matching Codex's approach)
type ReviewTarget =
| { type: "uncommitted" }
| { type: "baseBranch"; branch: string }
| { type: "commit"; sha: string; title?: string }
| { type: "pullRequest"; prNumber: number; baseBranch: string; title: string }
| { type: "folder"; paths: string[] };
// Prompts (adapted from Codex)
const UNCOMMITTED_PROMPT =
"Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
const LOCAL_CHANGES_REVIEW_INSTRUCTIONS =
"Also include local working-tree changes (staged, unstaged, and untracked files) from this branch. Use `git status --porcelain`, `git diff`, `git diff --staged`, and `git ls-files --others --exclude-standard` so local fixes are part of this review cycle.";
const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
"Review the code changes against the base branch '{baseBranch}'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes relative to {baseBranch}. Provide prioritized, actionable findings.";
const BASE_BRANCH_PROMPT_FALLBACK =
"Review the code changes against the base branch '{branch}'. Start by finding the merge diff between the current branch and {branch}'s upstream e.g. (`git merge-base HEAD \"$(git rev-parse --abbrev-ref \"{branch}@{upstream}\")\"`), then run `git diff` against that SHA to see what changes we would merge into the {branch} branch. Provide prioritized, actionable findings.";
const COMMIT_PROMPT_WITH_TITLE =
'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
const COMMIT_PROMPT = "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
const PULL_REQUEST_PROMPT =
'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. The merge base commit for this comparison is {mergeBaseSha}. Run `git diff {mergeBaseSha}` to inspect the changes that would be merged. Provide prioritized, actionable findings.';
const PULL_REQUEST_PROMPT_FALLBACK =
'Review pull request #{prNumber} ("{title}") against the base branch \'{baseBranch}\'. Start by finding the merge base between the current branch and {baseBranch} (e.g., `git merge-base HEAD {baseBranch}`), then run `git diff` against that SHA to see the changes that would be merged. Provide prioritized, actionable findings.';
const FOLDER_REVIEW_PROMPT =
"Review the code in the following paths: {paths}. This is a snapshot review (not a diff). Read the files directly in these paths and provide prioritized, actionable findings.";
// The detailed review rubric (adapted from Codex's review_prompt.md)
const REVIEW_RUBRIC = `# Review Guidelines
You are acting as a code reviewer for a proposed code change made by another engineer.
Below are default guidelines for determining what to flag. These are not the final word — if you encounter more specific guidelines elsewhere (in a developer message, user message, file, or project review guidelines appended below), those override these general instructions.
## Determining what to flag
Flag issues that:
1. Meaningfully impact the accuracy, performance, security, or maintainability of the code.
2. Are discrete and actionable (not general issues or multiple combined issues).
3. Don't demand rigor inconsistent with the rest of the codebase.
4. Were introduced in the changes being reviewed (not pre-existing bugs).
5. The author would likely fix if aware of them.
6. Don't rely on unstated assumptions about the codebase or author's intent.
7. Have provable impact on other parts of the code — it is not enough to speculate that a change may disrupt another part, you must identify the parts that are provably affected.
8. Are clearly not intentional changes by the author.
9. Be particularly careful with untrusted user input and follow the specific guidelines to review.
10. Treat silent local error recovery (especially parsing/IO/network fallbacks) as high-signal review candidates unless there is explicit boundary-level justification.
## Untrusted User Input
1. Be careful with open redirects, they must always be checked to only go to trusted domains (?next_page=...)
2. Always flag SQL that is not parametrized
3. In systems with user supplied URL input, http fetches always need to be protected against access to local resources (intercept DNS resolver!)
4. Escape, don't sanitize if you have the option (eg: HTML escaping)
## Comment guidelines
1. Be clear about why the issue is a problem.
2. Communicate severity appropriately - don't exaggerate.
3. Be brief - at most 1 paragraph.
4. Keep code snippets under 3 lines, wrapped in inline code or code blocks.
5. Use \`\`\`suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block). Preserve the exact leading whitespace of the replaced lines.
6. Explicitly state scenarios/environments where the issue arises.
7. Use a matter-of-fact tone - helpful AI assistant, not accusatory.
8. Write for quick comprehension without close reading.
9. Avoid excessive flattery or unhelpful phrases like "Great job...".
## Review priorities
1. Surface critical non-blocking human callouts (migrations, dependency churn, auth/permissions, compatibility, destructive operations) at the end.
2. Prefer simple, direct solutions over wrappers or abstractions without clear value.
3. Treat back pressure handling as critical to system stability.
4. Apply system-level thinking; flag changes that increase operational risk or on-call wakeups.
5. Ensure that errors are always checked against codes or stable identifiers, never error messages.
## Fail-fast error handling (strict)
When reviewing added or modified error handling, default to fail-fast behavior.
1. Evaluate every new or changed \`try/catch\`: identify what can fail and why local handling is correct at that exact layer.
2. Prefer propagation over local recovery. If the current scope cannot fully recover while preserving correctness, rethrow (optionally with context) instead of returning fallbacks.
3. Flag catch blocks that hide failure signals (e.g. returning \`null\`/\`[]\`/\`false\`, swallowing JSON parse failures, logging-and-continue, or “best effort” silent recovery).
4. JSON parsing/decoding should fail loudly by default. Quiet fallback parsing is only acceptable with an explicit compatibility requirement and clear tested behavior.
5. Boundary handlers (HTTP routes, CLI entrypoints, supervisors) may translate errors, but must not pretend success or silently degrade.
6. If a catch exists only to satisfy lint/style without real handling, treat it as a bug.
7. When uncertain, prefer crashing fast over silent degradation.
## Required human callouts (non-blocking, at the very end)
After findings/verdict, you MUST append this final section:
## Human Reviewer Callouts (Non-Blocking)
Include only applicable callouts (no yes/no lines):
- **This change adds a database migration:** <files/details>
- **This change introduces a new dependency:** <package(s)/details>
- **This change changes a dependency (or the lockfile):** <files/package(s)/details>
- **This change modifies auth/permission behavior:** <what changed and where>
- **This change introduces backwards-incompatible public schema/API/contract changes:** <what changed and where>
- **This change includes irreversible or destructive operations:** <operation and scope>
Rules for this section:
1. These are informational callouts for the human reviewer, not fix items.
2. Do not include them in Findings unless there is an independent defect.
3. These callouts alone must not change the verdict.
4. Only include callouts that apply to the reviewed change.
5. Keep each emitted callout bold exactly as written.
6. If none apply, write "- (none)".
## Priority levels
Tag each finding with a priority level in the title:
- [P0] - Drop everything to fix. Blocking release/operations. Only for universal issues that do not depend on assumptions about inputs.
- [P1] - Urgent. Should be addressed in the next cycle.
- [P2] - Normal. To be fixed eventually.
- [P3] - Low. Nice to have.
## Output format
Provide your findings in a clear, structured format:
1. List each finding with its priority tag, file location, and explanation.
2. Findings must reference locations that overlap with the actual diff — don't flag pre-existing code.
3. Keep line references as short as possible (avoid ranges over 5-10 lines; pick the most suitable subrange).
4. Provide an overall verdict: "correct" (no blocking issues) or "needs attention" (has blocking issues).
5. Ignore trivial style issues unless they obscure meaning or violate documented standards.
6. Do not generate a full PR fix — only flag issues and optionally provide short suggestion blocks.
7. End with the required "Human Reviewer Callouts (Non-Blocking)" section and all applicable bold callouts (no yes/no).
Output all findings the author would fix if they knew about them. If there are no qualifying findings, explicitly state the code looks good. Don't stop at the first finding - list every qualifying issue. Then append the required non-blocking callouts section.`;
async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
let currentDir = path.resolve(cwd);
while (true) {
const piDir = path.join(currentDir, ".pi");
const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
const piStats = await fs.stat(piDir).catch(() => null);
if (piStats?.isDirectory()) {
const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
if (guidelineStats?.isFile()) {
try {
const content = await fs.readFile(guidelinesPath, "utf8");
const trimmed = content.trim();
return trimmed ? trimmed : null;
} catch {
return null;
}
}
return null;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
return null;
}
currentDir = parentDir;
}
}
/**
* Get the merge base between HEAD and a branch
*/
async function getMergeBase(
pi: ExtensionAPI,
branch: string,
): Promise<string | null> {
try {
// First try to get the upstream tracking branch
const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
"rev-parse",
"--abbrev-ref",
`${branch}@{upstream}`,
]);
if (upstreamCode === 0 && upstream.trim()) {
const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()]);
if (code === 0 && mergeBase.trim()) {
return mergeBase.trim();
}
}
// Fall back to using the branch directly
const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch]);
if (code === 0 && mergeBase.trim()) {
return mergeBase.trim();
}
return null;
} catch {
return null;
}
}
/**
* Get list of local branches
*/
async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
if (code !== 0) return [];
return stdout
.trim()
.split("\n")
.filter((b) => b.trim());
}
/**
* Get list of recent commits
*/
async function getRecentCommits(pi: ExtensionAPI, limit: number = 10): Promise<Array<{ sha: string; title: string }>> {
const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
if (code !== 0) return [];
return stdout
.trim()
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [sha, ...rest] = line.trim().split(" ");
return { sha, title: rest.join(" ") };
});
}
/**
* Check if there are uncommitted changes (staged, unstaged, or untracked)
*/
async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
return code === 0 && stdout.trim().length > 0;
}
/**
* Check if there are changes that would prevent switching branches
* (staged or unstaged changes to tracked files - untracked files are fine)
*/
async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
// Check for staged or unstaged changes to tracked files
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
if (code !== 0) return false;
// Filter out untracked files (lines starting with ??)
const lines = stdout.trim().split("\n").filter((line) => line.trim());
const trackedChanges = lines.filter((line) => !line.startsWith("??"));
return trackedChanges.length > 0;
}
/**
* Parse a PR reference (URL or number) and return the PR number
*/
function parsePrReference(ref: string): number | null {
const trimmed = ref.trim();
// Try as a number first
const num = parseInt(trimmed, 10);
if (!isNaN(num) && num > 0) {
return num;
}
// Try to extract from GitHub URL
// Formats: https://github.com/owner/repo/pull/123
// github.com/owner/repo/pull/123
const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/);
if (urlMatch) {
return parseInt(urlMatch[1], 10);
}
return null;
}
/**
* Get PR information from GitHub CLI
*/
async function getPrInfo(pi: ExtensionAPI, prNumber: number): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
const { stdout, code } = await pi.exec("gh", [
"pr", "view", String(prNumber),
"--json", "baseRefName,title,headRefName",
]);
if (code !== 0) return null;
try {
const data = JSON.parse(stdout);
return {
baseBranch: data.baseRefName,
title: data.title,
headBranch: data.headRefName,
};
} catch {
return null;
}
}
/**
* Checkout a PR using GitHub CLI
*/
async function checkoutPr(pi: ExtensionAPI, prNumber: number): Promise<{ success: boolean; error?: string }> {
const { stdout, stderr, code } = await pi.exec("gh", ["pr", "checkout", String(prNumber)]);
if (code !== 0) {
return { success: false, error: stderr || stdout || "Failed to checkout PR" };
}
return { success: true };
}
/**
* Get the current branch name
*/
async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
if (code === 0 && stdout.trim()) {
return stdout.trim();
}
return null;
}
/**
* Get the default branch (main or master)
*/
async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
// Try to get from remote HEAD
const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
if (code === 0 && stdout.trim()) {
return stdout.trim().replace("origin/", "");
}
// Fall back to checking if main or master exists
const branches = await getLocalBranches(pi);
if (branches.includes("main")) return "main";
if (branches.includes("master")) return "master";
return "main"; // Default fallback
}
/**
* Build the review prompt based on target
*/
async function buildReviewPrompt(
pi: ExtensionAPI,
target: ReviewTarget,
options?: { includeLocalChanges?: boolean },
): Promise<string> {
const includeLocalChanges = options?.includeLocalChanges === true;
switch (target.type) {
case "uncommitted":
return UNCOMMITTED_PROMPT;
case "baseBranch": {
const mergeBase = await getMergeBase(pi, target.branch);
const basePrompt = mergeBase
? BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(/{mergeBaseSha}/g, mergeBase)
: BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt;
}
case "commit":
if (target.title) {
return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
}
return COMMIT_PROMPT.replace("{sha}", target.sha);
case "pullRequest": {
const mergeBase = await getMergeBase(pi, target.baseBranch);
const basePrompt = mergeBase
? PULL_REQUEST_PROMPT
.replace(/{prNumber}/g, String(target.prNumber))
.replace(/{title}/g, target.title)
.replace(/{baseBranch}/g, target.baseBranch)
.replace(/{mergeBaseSha}/g, mergeBase)
: PULL_REQUEST_PROMPT_FALLBACK
.replace(/{prNumber}/g, String(target.prNumber))
.replace(/{title}/g, target.title)
.replace(/{baseBranch}/g, target.baseBranch);
return includeLocalChanges ? `${basePrompt} ${LOCAL_CHANGES_REVIEW_INSTRUCTIONS}` : basePrompt;
}
case "folder":
return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
}
}
/**
* Get user-facing hint for the review target
*/
function getUserFacingHint(target: ReviewTarget): string {
switch (target.type) {
case "uncommitted":
return "current changes";
case "baseBranch":
return `changes against '${target.branch}'`;
case "commit": {
const shortSha = target.sha.slice(0, 7);
return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
}
case "pullRequest": {
const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
return `PR #${target.prNumber}: ${shortTitle}`;
}
case "folder": {
const joined = target.paths.join(", ");
return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
}
}
}
type AssistantSnapshot = {
id: string;
text: string;
stopReason?: string;
};
function extractAssistantTextContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
const textParts = content
.filter(
(part): part is { type: "text"; text: string } =>
Boolean(part && typeof part === "object" && "type" in part && part.type === "text" && "text" in part),
)
.map((part) => part.text);
return textParts.join("\n").trim();
}
function getLastAssistantSnapshot(ctx: ExtensionContext): AssistantSnapshot | null {
const entries = ctx.sessionManager.getBranch();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type !== "message" || entry.message.role !== "assistant") {
continue;
}
const assistantMessage = entry.message as { content?: unknown; stopReason?: string };
return {
id: entry.id,
text: extractAssistantTextContent(assistantMessage.content),
stopReason: assistantMessage.stopReason,
};
}
return null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForLoopTurnToStart(ctx: ExtensionContext, previousAssistantId?: string): Promise<boolean> {
const deadline = Date.now() + REVIEW_LOOP_START_TIMEOUT_MS;
while (Date.now() < deadline) {
const lastAssistantId = getLastAssistantSnapshot(ctx)?.id;
if (!ctx.isIdle() || ctx.hasPendingMessages() || (lastAssistantId && lastAssistantId !== previousAssistantId)) {
return true;
}
await sleep(REVIEW_LOOP_START_POLL_MS);
}
return false;
}
// Review preset options for the selector (keep this order stable)
const REVIEW_PRESETS = [
{ value: "uncommitted", label: "Review uncommitted changes", description: "" },
{ value: "baseBranch", label: "Review against a base branch", description: "(local)" },
{ value: "commit", label: "Review a commit", description: "" },
{ value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
{ value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
] as const;
const TOGGLE_LOOP_FIXING_VALUE = "toggleLoopFixing" as const;
const TOGGLE_CUSTOM_INSTRUCTIONS_VALUE = "toggleCustomInstructions" as const;
type ReviewPresetValue =
| (typeof REVIEW_PRESETS)[number]["value"]
| typeof TOGGLE_LOOP_FIXING_VALUE
| typeof TOGGLE_CUSTOM_INSTRUCTIONS_VALUE;
export default function reviewExtension(pi: ExtensionAPI) {
function persistReviewSettings() {
pi.appendEntry(REVIEW_SETTINGS_TYPE, {
loopFixingEnabled: reviewLoopFixingEnabled,
customInstructions: reviewCustomInstructions,
});
}
function setReviewLoopFixingEnabled(enabled: boolean) {
reviewLoopFixingEnabled = enabled;
persistReviewSettings();
}
function setReviewCustomInstructions(instructions: string | undefined) {
reviewCustomInstructions = instructions?.trim() || undefined;
persistReviewSettings();
}
function applyAllReviewState(ctx: ExtensionContext) {
applyReviewSettings(ctx);
applyReviewState(ctx);
}
pi.on("session_start", (_event, ctx) => {
applyAllReviewState(ctx);
});
pi.on("session_tree", (_event, ctx) => {
applyAllReviewState(ctx);
});
/**
* Determine the smart default review type based on git state
*/
async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
// Priority 1: If there are uncommitted changes, default to reviewing them
if (await hasUncommittedChanges(pi)) {
return "uncommitted";
}
// Priority 2: If on a feature branch (not the default branch), default to PR-style review
const currentBranch = await getCurrentBranch(pi);
const defaultBranch = await getDefaultBranch(pi);
if (currentBranch && currentBranch !== defaultBranch) {
return "baseBranch";
}
// Priority 3: Default to reviewing a specific commit
return "commit";
}
/**
* Show the review preset selector
*/
async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
// Determine smart default (but keep the list order stable)
const smartDefault = await getSmartDefault();
const presetItems: SelectItem[] = REVIEW_PRESETS.map((preset) => ({
value: preset.value,
label: preset.label,
description: preset.description,
}));
const smartDefaultIndex = presetItems.findIndex((item) => item.value === smartDefault);
while (true) {
const customInstructionsLabel = reviewCustomInstructions
? "Remove custom review instructions"
: "Add custom review instructions";
const customInstructionsDescription = reviewCustomInstructions
? "(currently set)"
: "(applies to all review modes)";
const loopToggleLabel = reviewLoopFixingEnabled ? "Disable Loop Fixing" : "Enable Loop Fixing";
const loopToggleDescription = reviewLoopFixingEnabled ? "(currently on)" : "(currently off)";
const items: SelectItem[] = [
...presetItems,
{
value: TOGGLE_CUSTOM_INSTRUCTIONS_VALUE,
label: customInstructionsLabel,
description: customInstructionsDescription,
},
{ value: TOGGLE_LOOP_FIXING_VALUE, label: loopToggleLabel, description: loopToggleDescription },
];
const result = await ctx.ui.custom<ReviewPresetValue | null>((tui, theme, _kb, done) => {
const container = new Container();
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
const selectList = new SelectList(items, Math.min(items.length, 10), {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
scrollInfo: (text) => theme.fg("dim", text),
noMatch: (text) => theme.fg("warning", text),
});
// Preselect the smart default without reordering the list
if (smartDefaultIndex >= 0) {
selectList.setSelectedIndex(smartDefaultIndex);
}
selectList.onSelect = (item) => done(item.value as ReviewPresetValue);
selectList.onCancel = () => done(null);
container.addChild(selectList);
container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
return {
render(width: number) {
return container.render(width);
},
invalidate() {
container.invalidate();
},
handleInput(data: string) {
selectList.handleInput(data);
tui.requestRender();
},
};
});
if (!result) return null;
if (result === TOGGLE_LOOP_FIXING_VALUE) {
const nextEnabled = !reviewLoopFixingEnabled;
setReviewLoopFixingEnabled(nextEnabled);
ctx.ui.notify(nextEnabled ? "Loop fixing enabled" : "Loop fixing disabled", "info");
continue;
}
if (result === TOGGLE_CUSTOM_INSTRUCTIONS_VALUE) {
if (reviewCustomInstructions) {
setReviewCustomInstructions(undefined);
ctx.ui.notify("Custom review instructions removed", "info");
continue;
}
const customInstructions = await ctx.ui.editor(
"Enter custom review instructions (applies to all review modes):",
"",
);
if (!customInstructions?.trim()) {
ctx.ui.notify("Custom review instructions not changed", "info");
continue;
}
setReviewCustomInstructions(customInstructions);
ctx.ui.notify("Custom review instructions saved", "info");
continue;
}
// Handle each preset type