main
1/**
2 * Code Review Extension (inspired by Codex's review feature)
3 *
4 * Provides a `/review` command that prompts the agent to review code changes.
5 * Supports multiple review modes:
6 * - Review a GitHub pull request (checks out the PR locally)
7 * - Review against a base branch (PR style)
8 * - Review uncommitted changes
9 * - Review a specific commit
10 * - Custom review instructions
11 *
12 * Usage:
13 * - `/review` - show interactive selector
14 * - `/review 123` - review PR #123 (checks out locally)
15 * - `/review tektoncd/pipeline#123` - review PR #123 from tektoncd/pipeline
16 * - `/review https://github.com/owner/repo/pull/123` - review PR from URL
17 * - `/review pr 123` - review PR #123 (alternative syntax)
18 * - `/review uncommitted` - review uncommitted changes directly
19 * - `/review branch main` - review against main branch
20 * - `/review commit abc123` - review specific commit
21 * - `/review folder src docs` - review specific folders/files (snapshot, not diff)
22 * - `/review custom "check for security issues"` - custom instructions
23 *
24 * Project-specific review guidelines:
25 * - If a REVIEW_GUIDELINES.md file exists in the same directory as .pi,
26 * its contents are appended to the review prompt.
27 *
28 * Note: PR review requires a clean working tree (no uncommitted changes to tracked files).
29 */
30
31import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
32import { DynamicBorder, BorderedLoader } from "@mariozechner/pi-coding-agent";
33import { Container, type SelectItem, SelectList, Text, Key } from "@mariozechner/pi-tui";
34import path from "node:path";
35import { promises as fs, readdirSync, readFileSync } from "node:fs";
36import { execSync } from "node:child_process";
37
38// =============================================================================
39// Helper Functions for Saving Reviews
40// =============================================================================
41
42function getYearMonth(): string {
43 const now = new Date();
44 const year = now.getFullYear();
45 const month = String(now.getMonth() + 1).padStart(2, "0");
46 return `${year}-${month}`;
47}
48
49function formatTimestamp(date: Date): string {
50 return date.toISOString().replace("T", " ").slice(0, 19);
51}
52
53function extractOrgRepo(remoteUrl: string): { org: string; repo: string } | null {
54 // Extract org and repo from various git remote formats
55 // Examples:
56 // https://github.com/NixOS/nixpkgs.git -> { org: "nixos", repo: "nixpkgs" }
57 // git@github.com:tektoncd/pipeline.git -> { org: "tektoncd", repo: "pipeline" }
58 // https://github.com/kubernetes/kubernetes -> { org: "kubernetes", repo: "kubernetes" }
59
60 // Remove .git suffix
61 const cleanUrl = remoteUrl.replace(/\.git$/, "");
62
63 // Extract org/repo pattern
64 const match = cleanUrl.match(/[:/]([^/]+)\/([^/]+)$/);
65 if (match) {
66 // Lowercase for case-insensitive matching
67 const org = match[1].toLowerCase();
68 const repo = match[2].toLowerCase();
69 return { org, repo };
70 }
71
72 return null;
73}
74
75async function loadRepositorySpecificRules(cwd: string): Promise<string | null> {
76 try {
77 // Get git remote URL
78 const remoteUrl = execSync("git remote get-url origin", {
79 cwd,
80 encoding: "utf-8",
81 }).trim();
82
83 const orgRepo = extractOrgRepo(remoteUrl);
84 if (!orgRepo) {
85 return null;
86 }
87
88 const { org, repo } = orgRepo;
89 const home = process.env.HOME || process.env.USERPROFILE || "";
90 const skillsDir = path.join(home, ".config", "claude", "skills");
91
92 // Priority 1: Review skill with repo-specific file
93 // ~/.config/claude/skills/Review/repositories/<org>-<repo>.md
94 const reviewSkillDir = path.join(skillsDir, "Review");
95 const repoSpecificFile = path.join(reviewSkillDir, "repositories", `${org}-${repo}.md`);
96
97 try {
98 const content = await fs.readFile(repoSpecificFile, "utf-8");
99 const filename = path.relative(skillsDir, repoSpecificFile);
100 return `
101
102# Repository-Specific Review Guidelines: ${org}/${repo}
103
104From: ~/.config/claude/skills/${filename}
105
106${content}
107`;
108 } catch {
109 // File doesn't exist, continue to next priority
110 }
111
112 // Priority 2: Review skill general workflow
113 // ~/.config/claude/skills/Review/workflows/Review.md
114 const reviewWorkflow = path.join(reviewSkillDir, "workflows", "Review.md");
115 try {
116 const content = await fs.readFile(reviewWorkflow, "utf-8");
117 const filename = path.relative(skillsDir, reviewWorkflow);
118 return `
119
120# General Review Guidelines
121
122From: ~/.config/claude/skills/${filename}
123
124${content}
125`;
126 } catch {
127 // File doesn't exist, continue to next priority
128 }
129
130 // Priority 3: Review skill general guidelines
131 // ~/.config/claude/skills/Review/SKILL.md
132 const reviewSkill = path.join(reviewSkillDir, "SKILL.md");
133 try {
134 const content = await fs.readFile(reviewSkill, "utf-8");
135 const filename = path.relative(skillsDir, reviewSkill);
136 return `
137
138# General Review Guidelines
139
140From: ~/.config/claude/skills/${filename}
141
142${content}
143`;
144 } catch {
145 // File doesn't exist, continue to fallback
146 }
147
148 // Fallback: Legacy repo-specific skill (e.g., Nixpkgs skill)
149 // Try to find a skill directory matching the repository name
150 const skillDirs = await fs.readdir(skillsDir);
151 const matchingSkill = skillDirs.find(
152 (dir) => dir.toLowerCase() === repo
153 );
154
155 if (!matchingSkill) {
156 return null;
157 }
158
159 const skillDir = path.join(skillsDir, matchingSkill);
160
161 // Try to load review-specific files in priority order
162 const reviewFiles = [
163 path.join(skillDir, "review-checklist.md"),
164 path.join(skillDir, "workflows", "Review.md"),
165 path.join(skillDir, "SKILL.md"),
166 ];
167
168 for (const filepath of reviewFiles) {
169 try {
170 const content = await fs.readFile(filepath, "utf-8");
171 const filename = path.relative(skillsDir, filepath);
172
173 // For SKILL.md, try to extract review section
174 if (filepath.endsWith("SKILL.md")) {
175 const reviewSection = content.match(/## Review Best Practices[\s\S]*?(?=##\s+[A-Z]|$)/);
176 if (reviewSection) {
177 return `
178
179# Repository-Specific Review Guidelines: ${repo}
180
181From: ~/.config/claude/skills/${filename}
182
183${reviewSection[0]}
184`;
185 }
186 }
187
188 // For other files, include the whole content
189 return `
190
191# Repository-Specific Review Guidelines: ${repo}
192
193From: ~/.config/claude/skills/${filename}
194
195${content}
196`;
197 } catch {
198 // Try next file
199 continue;
200 }
201 }
202
203 // None found
204 return null;
205 } catch {
206 return null;
207 }
208}
209
210async function saveReviewMetadata(reviewData: {
211 type: string;
212 target: string;
213 sessionFile: string;
214 cwd: string;
215}): Promise<string> {
216 const home = process.env.HOME || process.env.USERPROFILE || "";
217 const yearMonth = getYearMonth();
218 const reviewsDir = path.join(home, ".local", "share", "ai", "reviews", yearMonth);
219
220 // Ensure directory exists
221 await fs.mkdir(reviewsDir, { recursive: true });
222
223 const timestamp = Date.now();
224 const filename = `review-${timestamp}.md`;
225 const filepath = path.join(reviewsDir, filename);
226
227 const now = new Date();
228
229 // Try to get git context
230 let gitBranch = "unknown";
231 let gitCommit = "unknown";
232 try {
233 gitBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: reviewData.cwd, encoding: "utf-8" }).trim();
234 gitCommit = execSync("git rev-parse HEAD", { cwd: reviewData.cwd, encoding: "utf-8" }).trim().slice(0, 8);
235 } catch {
236 // Ignore errors
237 }
238
239 const markdown = `# Code Review: ${reviewData.target}
240
241**Date:** ${formatTimestamp(now)}
242**Type:** ${reviewData.type}
243**Session:** ${reviewData.sessionFile}
244**Branch:** ${gitBranch}
245**Commit:** ${gitCommit}
246
247## Review Target
248
249${reviewData.target}
250
251## Notes
252
253Review session created. See session file for AI-generated review findings.
254
255To extract findings and create TODOs, use the org-todos extension.
256
257## Next Actions
258
259- [ ] Review AI findings
260- [ ] Create TODOs for critical/high issues
261- [ ] Apply suggested fixes
262- [ ] Re-review after changes
263`;
264
265 await fs.writeFile(filepath, markdown, "utf-8");
266 return filepath;
267}
268
269// State to track fresh session review (where we branched from).
270// Module-level state means only one review can be active at a time.
271// This is intentional - the UI and /end-review command assume a single active review.
272let reviewOriginId: string | undefined = undefined;
273// Temporary clone directory for cross-repo PR reviews (cleaned up on /end-review)
274let reviewCloneDir: string | undefined = undefined;
275
276const REVIEW_STATE_TYPE = "review-session";
277
278type ReviewSessionState = {
279 active: boolean;
280 originId?: string;
281 cloneDir?: string;
282};
283
284function setReviewWidget(ctx: ExtensionContext, active: boolean) {
285 if (!ctx.hasUI) return;
286 if (!active) {
287 ctx.ui.setWidget("review", undefined);
288 return;
289 }
290
291 ctx.ui.setWidget("review", (_tui, theme) => {
292 const text = new Text(theme.fg("warning", "Review session active, return with /end-review"), 0, 0);
293 return {
294 render(width: number) {
295 return text.render(width);
296 },
297 invalidate() {
298 text.invalidate();
299 },
300 };
301 });
302}
303
304function getReviewState(ctx: ExtensionContext): ReviewSessionState | undefined {
305 let state: ReviewSessionState | undefined;
306 for (const entry of ctx.sessionManager.getBranch()) {
307 if (entry.type === "custom" && entry.customType === REVIEW_STATE_TYPE) {
308 state = entry.data as ReviewSessionState | undefined;
309 }
310 }
311
312 return state;
313}
314
315function applyReviewState(ctx: ExtensionContext) {
316 const state = getReviewState(ctx);
317
318 if (state?.active && state.originId) {
319 reviewOriginId = state.originId;
320 reviewCloneDir = state.cloneDir;
321 setReviewWidget(ctx, true);
322 return;
323 }
324
325 reviewOriginId = undefined;
326 reviewCloneDir = undefined;
327 setReviewWidget(ctx, false);
328}
329
330/**
331 * Clean up review state and optionally remove the temporary clone directory.
332 */
333async function clearReviewState(ctx: ExtensionContext, pi: ExtensionAPI) {
334 setReviewWidget(ctx, false);
335 reviewOriginId = undefined;
336
337 // Clean up temporary clone directory for cross-repo PR reviews
338 if (reviewCloneDir) {
339 try {
340 await fs.rm(reviewCloneDir, { recursive: true, force: true });
341 ctx.ui.notify(`Cleaned up temporary clone: ${reviewCloneDir}`, "info");
342 } catch {
343 ctx.ui.notify(`Warning: Failed to clean up ${reviewCloneDir}`, "warning");
344 }
345 reviewCloneDir = undefined;
346 }
347
348 pi.appendEntry(REVIEW_STATE_TYPE, { active: false });
349}
350
351// Review target types (matching Codex's approach)
352type ReviewTarget =
353 | { type: "uncommitted" }
354 | { type: "baseBranch"; branch: string }
355 | { type: "commit"; sha: string; title?: string }
356 | { type: "custom"; instructions: string }
357 | { type: "pullRequest"; prNumber: number; baseBranch: string; title: string; repo?: string; cloneDir?: string }
358 | { type: "folder"; paths: string[] };
359
360// Prompts (adapted from Codex)
361const UNCOMMITTED_PROMPT =
362 "Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.";
363
364const BASE_BRANCH_PROMPT_WITH_MERGE_BASE =
365 "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.";
366
367const BASE_BRANCH_PROMPT_FALLBACK =
368 "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.";
369
370const COMMIT_PROMPT_WITH_TITLE =
371 'Review the code changes introduced by commit {sha} ("{title}"). Provide prioritized, actionable findings.';
372
373const COMMIT_PROMPT = "Review the code changes introduced by commit {sha}. Provide prioritized, actionable findings.";
374
375const PULL_REQUEST_PROMPT =
376 '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.';
377
378const PULL_REQUEST_PROMPT_FALLBACK =
379 '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.';
380
381const FOLDER_REVIEW_PROMPT =
382 "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.";
383
384// =============================================================================
385// Review Focus Areas (dynamically discovered from reviewer agents)
386// =============================================================================
387
388interface ReviewerAgent {
389 /** Focus key, e.g. "general", "security", "go" */
390 focus: string;
391 /** Agent name from frontmatter, e.g. "reviewer-security" */
392 agent: string;
393 /** Human-readable label, e.g. "Security" */
394 label: string;
395 /** Description from agent frontmatter */
396 description: string;
397}
398
399/**
400 * Discover reviewer agents from ~/.pi/agent/agents/reviewer*.md
401 * The base `reviewer.md` is treated as "general".
402 * Agents named `reviewer-<focus>.md` become focus areas.
403 * Descriptions are read from the agent frontmatter.
404 */
405function discoverReviewerAgents(): ReviewerAgent[] {
406 const home = process.env.HOME || process.env.USERPROFILE || "";
407 const agentsDir = path.join(home, ".pi", "agent", "agents");
408
409 let entries: string[];
410 try {
411 entries = readdirSync(agentsDir).filter(
412 (f) => f.startsWith("reviewer") && f.endsWith(".md"),
413 );
414 } catch {
415 return [];
416 }
417
418 const agents: ReviewerAgent[] = [];
419 for (const filename of entries.sort()) {
420 const filePath = path.join(agentsDir, filename);
421 let content: string;
422 try {
423 content = readFileSync(filePath, "utf-8");
424 } catch {
425 continue;
426 }
427
428 // Parse YAML frontmatter
429 const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
430 if (!fmMatch) continue;
431
432 const fm = fmMatch[1];
433 const nameMatch = fm.match(/^name:\s*(.+)$/m);
434 const descMatch = fm.match(/^description:\s*(.+)$/m);
435 if (!nameMatch) continue;
436
437 const agentName = nameMatch[1].trim();
438 const description = descMatch ? descMatch[1].trim() : agentName;
439
440 // Derive focus key: "reviewer" → "general", "reviewer-security" → "security"
441 let focus: string;
442 let label: string;
443 if (filename === "reviewer.md") {
444 focus = "general";
445 label = "General";
446 } else {
447 focus = filename.replace(/^reviewer-/, "").replace(/\.md$/, "");
448 label = focus.charAt(0).toUpperCase() + focus.slice(1);
449 }
450
451 agents.push({ focus, agent: agentName, label, description });
452 }
453
454 return agents;
455}
456
457async function loadProjectReviewGuidelines(cwd: string): Promise<string | null> {
458 let currentDir = path.resolve(cwd);
459
460 while (true) {
461 const piDir = path.join(currentDir, ".pi");
462 const guidelinesPath = path.join(currentDir, "REVIEW_GUIDELINES.md");
463
464 const piStats = await fs.stat(piDir).catch(() => null);
465 if (piStats?.isDirectory()) {
466 const guidelineStats = await fs.stat(guidelinesPath).catch(() => null);
467 if (guidelineStats?.isFile()) {
468 try {
469 const content = await fs.readFile(guidelinesPath, "utf8");
470 const trimmed = content.trim();
471 return trimmed ? trimmed : null;
472 } catch {
473 return null;
474 }
475 }
476 return null;
477 }
478
479 const parentDir = path.dirname(currentDir);
480 if (parentDir === currentDir) {
481 return null;
482 }
483 currentDir = parentDir;
484 }
485}
486
487/**
488 * Get the merge base between HEAD and a branch
489 */
490async function getMergeBase(
491 pi: ExtensionAPI,
492 branch: string,
493 cwd?: string,
494): Promise<string | null> {
495 const execOpts = cwd ? { cwd } : undefined;
496 try {
497 // First try to get the upstream tracking branch
498 const { stdout: upstream, code: upstreamCode } = await pi.exec("git", [
499 "rev-parse",
500 "--abbrev-ref",
501 `${branch}@{upstream}`,
502 ], execOpts);
503
504 if (upstreamCode === 0 && upstream.trim()) {
505 const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", upstream.trim()], execOpts);
506 if (code === 0 && mergeBase.trim()) {
507 return mergeBase.trim();
508 }
509 }
510
511 // Fall back to using the branch directly
512 const { stdout: mergeBase, code } = await pi.exec("git", ["merge-base", "HEAD", branch], execOpts);
513 if (code === 0 && mergeBase.trim()) {
514 return mergeBase.trim();
515 }
516
517 return null;
518 } catch {
519 return null;
520 }
521}
522
523/**
524 * Get list of local branches
525 */
526async function getLocalBranches(pi: ExtensionAPI): Promise<string[]> {
527 const { stdout, code } = await pi.exec("git", ["branch", "--format=%(refname:short)"]);
528 if (code !== 0) return [];
529 return stdout
530 .trim()
531 .split("\n")
532 .filter((b) => b.trim());
533}
534
535/**
536 * Get list of recent commits
537 */
538async function getRecentCommits(pi: ExtensionAPI, limit: number = 10): Promise<Array<{ sha: string; title: string }>> {
539 const { stdout, code } = await pi.exec("git", ["log", `--oneline`, `-n`, `${limit}`]);
540 if (code !== 0) return [];
541
542 return stdout
543 .trim()
544 .split("\n")
545 .filter((line) => line.trim())
546 .map((line) => {
547 const [sha, ...rest] = line.trim().split(" ");
548 return { sha, title: rest.join(" ") };
549 });
550}
551
552/**
553 * Check if there are uncommitted changes (staged, unstaged, or untracked)
554 */
555async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
556 const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
557 return code === 0 && stdout.trim().length > 0;
558}
559
560/**
561 * Check if there are changes that would prevent switching branches
562 * (staged or unstaged changes to tracked files - untracked files are fine)
563 */
564async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> {
565 // Check for staged or unstaged changes to tracked files
566 const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
567 if (code !== 0) return false;
568
569 // Filter out untracked files (lines starting with ??)
570 const lines = stdout.trim().split("\n").filter((line) => line.trim());
571 const trackedChanges = lines.filter((line) => !line.startsWith("??"));
572 return trackedChanges.length > 0;
573}
574
575/**
576 * Parsed PR reference with optional repository context
577 */
578type PrReference = {
579 number: number;
580 repo?: string; // "owner/repo" format, undefined means current repo
581};
582
583/**
584 * Parse a PR reference (URL, number, or owner/repo#number) and return the PR number + optional repo
585 */
586function parsePrReference(ref: string): PrReference | null {
587 const trimmed = ref.trim();
588
589 // Try as a number first
590 const num = parseInt(trimmed, 10);
591 if (!isNaN(num) && num > 0) {
592 return { number: num };
593 }
594
595 // Try to extract from GitHub URL
596 // Formats: https://github.com/owner/repo/pull/123
597 // github.com/owner/repo/pull/123
598 const urlMatch = trimmed.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/);
599 if (urlMatch) {
600 return { number: parseInt(urlMatch[2], 10), repo: urlMatch[1] };
601 }
602
603 // Try to extract from owner/repo#number format
604 // Format: tektoncd/pipeline#1234
605 const repoMatch = trimmed.match(/^([^/]+\/[^#]+)#(\d+)$/);
606 if (repoMatch) {
607 return { number: parseInt(repoMatch[2], 10), repo: repoMatch[1] };
608 }
609
610 return null;
611}
612
613/**
614 * Get PR information from GitHub CLI
615 */
616async function getPrInfo(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ baseBranch: string; title: string; headBranch: string } | null> {
617 const args = ["pr", "view", String(prNumber), "--json", "baseRefName,title,headRefName"];
618 if (repo) {
619 args.push("-R", repo);
620 }
621 const { stdout, code } = await pi.exec("gh", args);
622
623 if (code !== 0) return null;
624
625 try {
626 const data = JSON.parse(stdout);
627 return {
628 baseBranch: data.baseRefName,
629 title: data.title,
630 headBranch: data.headRefName,
631 };
632 } catch {
633 return null;
634 }
635}
636
637/**
638 * Checkout a PR using GitHub CLI
639 */
640async function checkoutPr(pi: ExtensionAPI, prNumber: number, repo?: string): Promise<{ success: boolean; error?: string }> {
641 const args = ["pr", "checkout", String(prNumber)];
642 if (repo) {
643 args.push("-R", repo);
644 }
645 const { stdout, stderr, code } = await pi.exec("gh", args);
646
647 if (code !== 0) {
648 return { success: false, error: stderr || stdout || "Failed to checkout PR" };
649 }
650
651 return { success: true };
652}
653
654/**
655 * Get the current branch name
656 */
657async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> {
658 const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]);
659 if (code === 0 && stdout.trim()) {
660 return stdout.trim();
661 }
662 return null;
663}
664
665/**
666 * Get the default branch (main or master)
667 */
668async function getDefaultBranch(pi: ExtensionAPI): Promise<string> {
669 // Try to get from remote HEAD
670 const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]);
671 if (code === 0 && stdout.trim()) {
672 return stdout.trim().replace("origin/", "");
673 }
674
675 // Fall back to checking if main or master exists
676 const branches = await getLocalBranches(pi);
677 if (branches.includes("main")) return "main";
678 if (branches.includes("master")) return "master";
679
680 return "main"; // Default fallback
681}
682
683/**
684 * Build the review prompt based on target
685 */
686async function buildReviewPrompt(pi: ExtensionAPI, target: ReviewTarget): Promise<string> {
687 switch (target.type) {
688 case "uncommitted":
689 return UNCOMMITTED_PROMPT;
690
691 case "baseBranch": {
692 const mergeBase = await getMergeBase(pi, target.branch);
693 if (mergeBase) {
694 return BASE_BRANCH_PROMPT_WITH_MERGE_BASE.replace(/{baseBranch}/g, target.branch).replace(
695 /{mergeBaseSha}/g,
696 mergeBase,
697 );
698 }
699 return BASE_BRANCH_PROMPT_FALLBACK.replace(/{branch}/g, target.branch);
700 }
701
702 case "commit":
703 if (target.title) {
704 return COMMIT_PROMPT_WITH_TITLE.replace("{sha}", target.sha).replace("{title}", target.title);
705 }
706 return COMMIT_PROMPT.replace("{sha}", target.sha);
707
708 case "custom":
709 return target.instructions;
710
711 case "pullRequest": {
712 const mergeBase = await getMergeBase(pi, target.baseBranch, target.cloneDir);
713 let prompt: string;
714 if (mergeBase) {
715 prompt = PULL_REQUEST_PROMPT
716 .replace(/{prNumber}/g, String(target.prNumber))
717 .replace(/{title}/g, target.title)
718 .replace(/{baseBranch}/g, target.baseBranch)
719 .replace(/{mergeBaseSha}/g, mergeBase);
720 } else {
721 prompt = PULL_REQUEST_PROMPT_FALLBACK
722 .replace(/{prNumber}/g, String(target.prNumber))
723 .replace(/{title}/g, target.title)
724 .replace(/{baseBranch}/g, target.baseBranch);
725 }
726 if (target.cloneDir) {
727 prompt += `\n\nIMPORTANT: This is a cross-repository PR. The repository has been cloned to \`${target.cloneDir}\`. You MUST \`cd ${target.cloneDir}\` before running any git or file commands. All file paths are relative to that directory.`;
728 }
729 return prompt;
730 }
731
732 case "folder":
733 return FOLDER_REVIEW_PROMPT.replace("{paths}", target.paths.join(", "));
734 }
735}
736
737/**
738 * Get user-facing hint for the review target
739 */
740function getUserFacingHint(target: ReviewTarget): string {
741 switch (target.type) {
742 case "uncommitted":
743 return "current changes";
744 case "baseBranch":
745 return `changes against '${target.branch}'`;
746 case "commit": {
747 const shortSha = target.sha.slice(0, 7);
748 return target.title ? `commit ${shortSha}: ${target.title}` : `commit ${shortSha}`;
749 }
750 case "custom":
751 return target.instructions.length > 40 ? target.instructions.slice(0, 37) + "..." : target.instructions;
752
753 case "pullRequest": {
754 const shortTitle = target.title.length > 30 ? target.title.slice(0, 27) + "..." : target.title;
755 const repoPrefix = target.repo ? `${target.repo}#` : "PR #";
756 return `${repoPrefix}${target.prNumber}: ${shortTitle}`;
757 }
758
759 case "folder": {
760 const joined = target.paths.join(", ");
761 return joined.length > 40 ? `folders: ${joined.slice(0, 37)}...` : `folders: ${joined}`;
762 }
763 }
764}
765
766// Review preset options for the selector
767const REVIEW_PRESETS = [
768 { value: "pullRequest", label: "Review a pull request", description: "(GitHub PR)" },
769 { value: "baseBranch", label: "Review against a base branch", description: "(local)" },
770 { value: "uncommitted", label: "Review uncommitted changes", description: "" },
771 { value: "commit", label: "Review a commit", description: "" },
772 { value: "folder", label: "Review a folder (or more)", description: "(snapshot, not diff)" },
773 { value: "custom", label: "Custom review instructions", description: "" },
774] as const;
775
776export default function reviewExtension(pi: ExtensionAPI) {
777 pi.on("session_start", (_event, ctx) => {
778 applyReviewState(ctx);
779 });
780
781 pi.on("session_switch", (_event, ctx) => {
782 applyReviewState(ctx);
783 });
784
785 pi.on("session_tree", (_event, ctx) => {
786 applyReviewState(ctx);
787 });
788
789 /**
790 * Determine the smart default review type based on git state
791 */
792 async function getSmartDefault(): Promise<"uncommitted" | "baseBranch" | "commit"> {
793 // Priority 1: If there are uncommitted changes, default to reviewing them
794 if (await hasUncommittedChanges(pi)) {
795 return "uncommitted";
796 }
797
798 // Priority 2: If on a feature branch (not the default branch), default to PR-style review
799 const currentBranch = await getCurrentBranch(pi);
800 const defaultBranch = await getDefaultBranch(pi);
801 if (currentBranch && currentBranch !== defaultBranch) {
802 return "baseBranch";
803 }
804
805 // Priority 3: Default to reviewing a specific commit
806 return "commit";
807 }
808
809 /**
810 * Show the review preset selector
811 */
812 async function showReviewSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
813 // Determine smart default and reorder items
814 const smartDefault = await getSmartDefault();
815 const items: SelectItem[] = REVIEW_PRESETS
816 .slice() // copy to avoid mutating original
817 .sort((a, b) => {
818 // Put smart default first
819 if (a.value === smartDefault) return -1;
820 if (b.value === smartDefault) return 1;
821 return 0;
822 })
823 .map((preset) => ({
824 value: preset.value,
825 label: preset.label,
826 description: preset.description,
827 }));
828
829 while (true) {
830 const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
831 const container = new Container();
832 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
833 container.addChild(new Text(theme.fg("accent", theme.bold("Select a review preset"))));
834
835 const selectList = new SelectList(items, Math.min(items.length, 10), {
836 selectedPrefix: (text) => theme.fg("accent", text),
837 selectedText: (text) => theme.fg("accent", text),
838 description: (text) => theme.fg("muted", text),
839 scrollInfo: (text) => theme.fg("dim", text),
840 noMatch: (text) => theme.fg("warning", text),
841 });
842
843 selectList.onSelect = (item) => done(item.value);
844 selectList.onCancel = () => done(null);
845
846 container.addChild(selectList);
847 container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
848 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
849
850 return {
851 render(width: number) {
852 return container.render(width);
853 },
854 invalidate() {
855 container.invalidate();
856 },
857 handleInput(data: string) {
858 selectList.handleInput(data);
859 tui.requestRender();
860 },
861 };
862 });
863
864 if (!result) return null;
865
866 // Handle each preset type
867 switch (result) {
868 case "uncommitted":
869 return { type: "uncommitted" };
870
871 case "baseBranch": {
872 const target = await showBranchSelector(ctx);
873 if (target) return target;
874 break;
875 }
876
877 case "commit": {
878 const target = await showCommitSelector(ctx);
879 if (target) return target;
880 break;
881 }
882
883 case "custom": {
884 const target = await showCustomInput(ctx);
885 if (target) return target;
886 break;
887 }
888
889 case "folder": {
890 const target = await showFolderInput(ctx);
891 if (target) return target;
892 break;
893 }
894
895 case "pullRequest": {
896 const target = await showPrInput(ctx);
897 if (target) return target;
898 break;
899 }
900
901 default:
902 return null;
903 }
904 }
905 }
906
907 /**
908 * Show branch selector for base branch review
909 */
910 async function showBranchSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
911 const branches = await getLocalBranches(pi);
912 const defaultBranch = await getDefaultBranch(pi);
913
914 if (branches.length === 0) {
915 ctx.ui.notify("No branches found", "error");
916 return null;
917 }
918
919 // Sort branches with default branch first
920 const sortedBranches = branches.sort((a, b) => {
921 if (a === defaultBranch) return -1;
922 if (b === defaultBranch) return 1;
923 return a.localeCompare(b);
924 });
925
926 const items: SelectItem[] = sortedBranches.map((branch) => ({
927 value: branch,
928 label: branch,
929 description: branch === defaultBranch ? "(default)" : "",
930 }));
931
932 const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
933 const container = new Container();
934 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
935 container.addChild(new Text(theme.fg("accent", theme.bold("Select base branch"))));
936
937 const selectList = new SelectList(items, Math.min(items.length, 10), {
938 selectedPrefix: (text) => theme.fg("accent", text),
939 selectedText: (text) => theme.fg("accent", text),
940 description: (text) => theme.fg("muted", text),
941 scrollInfo: (text) => theme.fg("dim", text),
942 noMatch: (text) => theme.fg("warning", text),
943 });
944
945 // Enable search
946 selectList.searchable = true;
947
948 selectList.onSelect = (item) => done(item.value);
949 selectList.onCancel = () => done(null);
950
951 container.addChild(selectList);
952 container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
953 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
954
955 return {
956 render(width: number) {
957 return container.render(width);
958 },
959 invalidate() {
960 container.invalidate();
961 },
962 handleInput(data: string) {
963 selectList.handleInput(data);
964 tui.requestRender();
965 },
966 };
967 });
968
969 if (!result) return null;
970 return { type: "baseBranch", branch: result };
971 }
972
973 /**
974 * Show commit selector
975 */
976 async function showCommitSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> {
977 const commits = await getRecentCommits(pi, 20);
978
979 if (commits.length === 0) {
980 ctx.ui.notify("No commits found", "error");
981 return null;
982 }
983
984 const items: SelectItem[] = commits.map((commit) => ({
985 value: commit.sha,
986 label: `${commit.sha.slice(0, 7)} ${commit.title}`,
987 description: "",
988 }));
989
990 const result = await ctx.ui.custom<{ sha: string; title: string } | null>((tui, theme, _kb, done) => {
991 const container = new Container();
992 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
993 container.addChild(new Text(theme.fg("accent", theme.bold("Select commit to review"))));
994
995 const selectList = new SelectList(items, Math.min(items.length, 10), {
996 selectedPrefix: (text) => theme.fg("accent", text),
997 selectedText: (text) => theme.fg("accent", text),
998 description: (text) => theme.fg("muted", text),
999 scrollInfo: (text) => theme.fg("dim", text),
1000 noMatch: (text) => theme.fg("warning", text),
1001 });
1002
1003 // Enable search
1004 selectList.searchable = true;
1005
1006 selectList.onSelect = (item) => {
1007 const commit = commits.find((c) => c.sha === item.value);
1008 if (commit) {
1009 done(commit);
1010 } else {
1011 done(null);
1012 }
1013 };
1014 selectList.onCancel = () => done(null);
1015
1016 container.addChild(selectList);
1017 container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
1018 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1019
1020 return {
1021 render(width: number) {
1022 return container.render(width);
1023 },
1024 invalidate() {
1025 container.invalidate();
1026 },
1027 handleInput(data: string) {
1028 selectList.handleInput(data);
1029 tui.requestRender();
1030 },
1031 };
1032 });
1033
1034 if (!result) return null;
1035 return { type: "commit", sha: result.sha, title: result.title };
1036 }
1037
1038 /**
1039 * Show custom instructions input
1040 */
1041 async function showCustomInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1042 const result = await ctx.ui.editor(
1043 "Enter review instructions:",
1044 "Review the code for security vulnerabilities and potential bugs...",
1045 );
1046
1047 if (!result?.trim()) return null;
1048 return { type: "custom", instructions: result.trim() };
1049 }
1050
1051 function parseReviewPaths(value: string): string[] {
1052 return value
1053 .split(/\s+/)
1054 .map((item) => item.trim())
1055 .filter((item) => item.length > 0);
1056 }
1057
1058 /**
1059 * Show folder input
1060 */
1061 async function showFolderInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1062 const result = await ctx.ui.editor(
1063 "Enter folders/files to review (space-separated or one per line):",
1064 ".",
1065 );
1066
1067 if (!result?.trim()) return null;
1068 const paths = parseReviewPaths(result);
1069 if (paths.length === 0) return null;
1070
1071 return { type: "folder", paths };
1072 }
1073
1074 /**
1075 * Show focus area selector for the review.
1076 * Dynamically discovers reviewer-*.md agents from ~/.pi/agent/agents/.
1077 * Returns the focus key ("general", "security", "full", etc.) or null if cancelled.
1078 */
1079 async function showFocusSelector(ctx: ExtensionContext): Promise<{ focus: string; agents: ReviewerAgent[] } | null> {
1080 // Discover all reviewer agents fresh each time (allows adding agents mid-session)
1081 const allAgents = discoverReviewerAgents();
1082
1083 if (allAgents.length === 0) {
1084 ctx.ui.notify("No reviewer agents found in ~/.pi/agent/agents/", "error");
1085 return null;
1086 }
1087
1088 const items: SelectItem[] = allAgents.map((ra) => ({
1089 value: ra.focus,
1090 label: ra.label,
1091 description: ra.description,
1092 }));
1093
1094 // Add "full" option at the end
1095 items.push({
1096 value: "full",
1097 label: "Full review",
1098 description: `All ${allAgents.length} reviewers in parallel`,
1099 });
1100
1101 const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1102 const container = new Container();
1103 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1104 container.addChild(new Text(theme.fg("accent", theme.bold("Select review focus"))));
1105
1106 const selectList = new SelectList(items, Math.min(items.length, 10), {
1107 selectedPrefix: (text) => theme.fg("accent", text),
1108 selectedText: (text) => theme.fg("accent", text),
1109 description: (text) => theme.fg("muted", text),
1110 scrollInfo: (text) => theme.fg("dim", text),
1111 noMatch: (text) => theme.fg("warning", text),
1112 });
1113
1114 selectList.onSelect = (item) => done(item.value);
1115 selectList.onCancel = () => done(null);
1116
1117 container.addChild(selectList);
1118 container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to go back")));
1119 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1120
1121 return {
1122 render(width: number) {
1123 return container.render(width);
1124 },
1125 invalidate() {
1126 container.invalidate();
1127 },
1128 handleInput(data: string) {
1129 selectList.handleInput(data);
1130 tui.requestRender();
1131 },
1132 };
1133 });
1134
1135 if (!result) return null;
1136 return { focus: result, agents: allAgents };
1137 }
1138
1139 /**
1140 * Fetch open PRs from GitHub for the selector
1141 */
1142 async function fetchOpenPRs(): Promise<Array<{ number: number; title: string; author: string; branch: string; base: string; isDraft: boolean; reviewDecision: string }>> {
1143 const { stdout, code } = await pi.exec("gh", [
1144 "pr", "list",
1145 "--state", "open",
1146 "--json", "number,title,author,headRefName,baseRefName,isDraft,reviewDecision",
1147 "--limit", "30",
1148 ]);
1149
1150 if (code !== 0) return [];
1151
1152 try {
1153 const data = JSON.parse(stdout);
1154 if (!Array.isArray(data)) return [];
1155 return data.map((item: any) => ({
1156 number: item.number ?? 0,
1157 title: item.title ?? "",
1158 author: item.author?.login ?? "",
1159 branch: item.headRefName ?? "",
1160 base: item.baseRefName ?? "",
1161 isDraft: item.isDraft ?? false,
1162 reviewDecision: item.reviewDecision ?? "",
1163 }));
1164 } catch {
1165 return [];
1166 }
1167 }
1168
1169 /**
1170 * Get review decision label for display
1171 */
1172 function reviewDecisionLabel(decision: string): string {
1173 switch (decision) {
1174 case "APPROVED": return " ✓approved";
1175 case "CHANGES_REQUESTED": return " ✗changes requested";
1176 case "REVIEW_REQUIRED": return " ⏳review needed";
1177 default: return "";
1178 }
1179 }
1180
1181 /**
1182 * Show PR selector with list of open PRs + manual entry option
1183 */
1184 async function showPrInput(ctx: ExtensionContext): Promise<ReviewTarget | null> {
1185 // Fetch open PRs
1186 ctx.ui.notify("Fetching open PRs...", "info");
1187 const prs = await fetchOpenPRs();
1188
1189 // Try to get current user for smart ordering
1190 let currentUser = "";
1191 const { stdout: userOut, code: userCode } = await pi.exec("gh", ["api", "user", "--jq", ".login"], { timeout: 5000 });
1192 if (userCode === 0) currentUser = userOut.trim();
1193
1194 // Sort: PRs needing your review first, then your own, then others
1195 const sorted = prs.slice().sort((a, b) => {
1196 const aScore = a.reviewDecision === "REVIEW_REQUIRED" && a.author !== currentUser ? 0
1197 : a.author === currentUser ? 1
1198 : 2;
1199 const bScore = b.reviewDecision === "REVIEW_REQUIRED" && b.author !== currentUser ? 0
1200 : b.author === currentUser ? 1
1201 : 2;
1202 return aScore - bScore;
1203 });
1204
1205 // Build select items
1206 const allItems: SelectItem[] = sorted.map((pr) => {
1207 const draft = pr.isDraft ? " [draft]" : "";
1208 const review = reviewDecisionLabel(pr.reviewDecision);
1209 return {
1210 value: String(pr.number),
1211 label: `#${pr.number} ${pr.title}${draft}${review}`,
1212 description: `@${pr.author} ${pr.branch} → ${pr.base}`,
1213 };
1214 });
1215
1216 // Add manual entry option at the bottom
1217 const manualItem: SelectItem = {
1218 value: "__manual__",
1219 label: "Enter PR number manually…",
1220 description: "(for cross-repo or closed PRs)",
1221 };
1222
1223 /** Fuzzy-match: all terms (split by space) must appear somewhere in the searchable text */
1224 function fuzzyMatch(item: SelectItem, query: string): boolean {
1225 if (!query) return true;
1226 const searchable = `${item.label} ${item.description || ""}`.toLowerCase();
1227 const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1228 return terms.every((term) => searchable.includes(term));
1229 }
1230
1231 // Show selector with search-as-you-type
1232 const selected = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1233 let searchQuery = "";
1234
1235 function getFilteredItems(): SelectItem[] {
1236 if (!searchQuery) return [...allItems, manualItem];
1237 const filtered = allItems.filter((item) => fuzzyMatch(item, searchQuery));
1238 filtered.push(manualItem);
1239 return filtered;
1240 }
1241
1242 let currentItems = getFilteredItems();
1243
1244 const container = new Container();
1245 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1246
1247 const headerText = new Text("", 0, 0);
1248 function updateHeader() {
1249 const title = theme.fg("accent", theme.bold("Select a pull request to review"));
1250 if (searchQuery) {
1251 headerText.setText(`${title} ${theme.fg("warning", `filter: ${searchQuery}`)}`);
1252 } else {
1253 headerText.setText(title);
1254 }
1255 }
1256 updateHeader();
1257 container.addChild(headerText);
1258
1259 let selectList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
1260 selectedPrefix: (text) => theme.fg("accent", text),
1261 selectedText: (text) => theme.fg("accent", text),
1262 description: (text) => theme.fg("muted", text),
1263 scrollInfo: (text) => theme.fg("dim", text),
1264 noMatch: (text) => theme.fg("warning", text),
1265 });
1266 selectList.onSelect = (item) => done(item.value);
1267 selectList.onCancel = () => done(null);
1268
1269 container.addChild(selectList);
1270 container.addChild(new Text(theme.fg("dim", "Type to filter • enter to select • esc to cancel")));
1271 container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
1272
1273 function rebuildList() {
1274 currentItems = getFilteredItems();
1275 const newList = new SelectList(currentItems, Math.min(currentItems.length, 12), {
1276 selectedPrefix: (text) => theme.fg("accent", text),
1277 selectedText: (text) => theme.fg("accent", text),
1278 description: (text) => theme.fg("muted", text),
1279 scrollInfo: (text) => theme.fg("dim", text),
1280 noMatch: (text) => theme.fg("warning", text),
1281 });
1282 newList.onSelect = (item) => done(item.value);
1283 newList.onCancel = () => done(null);
1284 // Replace in container (index 2: after border + header)
1285 const idx = container.children.indexOf(selectList);
1286 if (idx !== -1) container.children[idx] = newList;
1287 selectList = newList;
1288 updateHeader();
1289 }
1290
1291 return {
1292 render(width: number) { return container.render(width); },
1293 invalidate() { container.invalidate(); },
1294 handleInput(data: string) {
1295 // Backspace: remove last char from search
1296 if (data === "\x7f" || data === "\b") {
1297 if (searchQuery.length > 0) {
1298 searchQuery = searchQuery.slice(0, -1);
1299 rebuildList();
1300 tui.requestRender();
1301 }
1302 return;
1303 }
1304
1305 // Printable characters: append to search
1306 if (data.length === 1 && data >= " " && data <= "~") {
1307 searchQuery += data;
1308 rebuildList();
1309 tui.requestRender();
1310 return;
1311 }
1312
1313 // Everything else (arrows, enter, escape): pass to SelectList
1314 selectList.handleInput(data);
1315 tui.requestRender();
1316 },
1317 };
1318 });
1319
1320 if (!selected) return null;
1321
1322 // Build the ref string for handlePrCheckout
1323 let prRef: string;
1324 let prRepo: string | undefined;
1325 if (selected === "__manual__") {
1326 const input = await ctx.ui.editor(
1327 "Enter PR number, owner/repo#number, or URL (e.g. 123, tektoncd/pipeline#456, or https://github.com/owner/repo/pull/123):",
1328 "",
1329 );
1330 if (!input?.trim()) return null;
1331
1332 const parsed = parsePrReference(input);
1333 if (!parsed) {
1334 ctx.ui.notify("Invalid PR reference. Enter a number, owner/repo#number, or GitHub PR URL.", "error");
1335 return null;
1336 }
1337 prRef = input.trim();
1338 prRepo = parsed.repo;
1339 } else {
1340 prRef = selected;
1341 }
1342
1343 // Delegate to handlePrCheckout which handles both local and cross-repo PRs
1344 return await handlePrCheckout(ctx, prRef, prRepo);
1345 }
1346
1347 /**
1348 * Execute the review
1349 */
1350 async function executeReview(ctx: ExtensionCommandContext, target: ReviewTarget, useFreshSession: boolean, focus: string, reviewerAgents: ReviewerAgent[]): Promise<void> {
1351 // Check if we're already in a review
1352 if (reviewOriginId) {
1353 ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1354 return;
1355 }
1356
1357 // Track clone directory for cross-repo PR reviews
1358 const cloneDir = target.type === "pullRequest" ? target.cloneDir : undefined;
1359 reviewCloneDir = cloneDir;
1360
1361 // Handle fresh session mode
1362 if (useFreshSession) {
1363 // Store current position (where we'll return to)
1364 const originId = ctx.sessionManager.getLeafId() ?? undefined;
1365 if (!originId) {
1366 ctx.ui.notify("Failed to determine review origin. Try again from a session with messages.", "error");
1367 reviewCloneDir = undefined;
1368 return;
1369 }
1370 reviewOriginId = originId;
1371
1372 // Keep a local copy so session_tree events during navigation don't wipe it
1373 const lockedOriginId = originId;
1374
1375 // Find the first user message in the session
1376 const entries = ctx.sessionManager.getEntries();
1377 const firstUserMessage = entries.find(
1378 (e) => e.type === "message" && e.message.role === "user",
1379 );
1380
1381 if (!firstUserMessage) {
1382 ctx.ui.notify("No user message found in session", "error");
1383 reviewOriginId = undefined;
1384 reviewCloneDir = undefined;
1385 return;
1386 }
1387
1388 // Navigate to first user message to create a new branch from that point
1389 // Label it as "code-review" so it's visible in the tree
1390 try {
1391 const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" });
1392 if (result.cancelled) {
1393 reviewOriginId = undefined;
1394 reviewCloneDir = undefined;
1395 return;
1396 }
1397 } catch (error) {
1398 // Clean up state if navigation fails
1399 reviewOriginId = undefined;
1400 reviewCloneDir = undefined;
1401 ctx.ui.notify(`Failed to start review: ${error instanceof Error ? error.message : String(error)}`, "error");
1402 return;
1403 }
1404
1405 // Restore origin after navigation events (session_tree can reset it)
1406 reviewOriginId = lockedOriginId;
1407
1408 // Clear the editor (navigating to user message fills it with the message text)
1409 ctx.ui.setEditorText("");
1410
1411 // Show widget indicating review is active
1412 setReviewWidget(ctx, true);
1413
1414 // Persist review state so tree navigation can restore/reset it
1415 pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId, cloneDir });
1416 }
1417
1418 const reviewPrompt = await buildReviewPrompt(pi, target);
1419 const hint = getUserFacingHint(target);
1420 const projectGuidelines = await loadProjectReviewGuidelines(ctx.cwd);
1421 const repoRules = await loadRepositorySpecificRules(ctx.cwd);
1422
1423 // Build the task context that will be sent to subagent(s)
1424 let taskContext = reviewPrompt;
1425
1426 if (projectGuidelines) {
1427 taskContext += `\n\nThis project has additional instructions for code reviews:\n\n${projectGuidelines}`;
1428 }
1429
1430 if (repoRules) {
1431 taskContext += repoRules;
1432 }
1433
1434 // Look up the selected agent (or all agents for "full")
1435 const selectedAgent = reviewerAgents.find((ra) => ra.focus === focus);
1436 const focusLabel = focus === "full" ? "Full" : (selectedAgent?.label ?? focus);
1437 const modeHint = useFreshSession ? " (fresh session)" : "";
1438 ctx.ui.notify(`Starting ${focusLabel} review: ${hint}${modeHint}`, "info");
1439
1440 // Save review metadata to ai-storage
1441 try {
1442 const reviewPath = await saveReviewMetadata({
1443 type: target.type,
1444 target: `[${focusLabel}] ${hint}`,
1445 sessionFile: ctx.sessionManager.getSessionFile(),
1446 cwd: ctx.cwd,
1447 });
1448
1449 ctx.ui.notify(`Review metadata saved: ${path.basename(reviewPath)}`, "success");
1450 } catch (error: any) {
1451 // Don't fail the review if saving fails
1452 ctx.ui.notify(`Warning: Failed to save review metadata: ${error.message}`, "warning");
1453 }
1454
1455 // Dispatch to subagent(s) based on focus area
1456 if (focus === "full") {
1457 // Run all discovered reviewer agents in parallel
1458 const subagentTasks = reviewerAgents.map((ra) => ({
1459 agent: ra.agent,
1460 task: `${ra.label}-focused review: ${taskContext}`,
1461 }));
1462
1463 const focusNames = reviewerAgents.map((ra) => ra.focus).join("/");
1464 const tasksJson = JSON.stringify(subagentTasks);
1465 const subagentPrompt = `Run a full code review using parallel subagents. Dispatch the following reviewers using the subagent tool in parallel mode:
1466
1467${tasksJson}
1468
1469After all reviewers complete, present a **consolidated review report**:
14701. Group findings by file, deduplicating overlapping issues (keep the most specific)
14712. Note which reviewer (${focusNames}) flagged each issue
14723. Use the highest priority tag when reviewers disagree
14734. Provide a unified verdict: "correct" if no P0/P1 issues, "needs attention" otherwise`;
1474
1475 pi.sendUserMessage(subagentPrompt);
1476 } else if (selectedAgent) {
1477 // Single focused review — dispatch to one subagent
1478 const subagentPrompt = `Run a ${focusLabel.toLowerCase()}-focused code review using the subagent tool.
1479
1480Dispatch to the \`${selectedAgent.agent}\` agent with this task:
1481
1482${taskContext}
1483
1484Present the subagent's findings directly to the user.`;
1485
1486 pi.sendUserMessage(subagentPrompt);
1487 } else {
1488 ctx.ui.notify(`No reviewer agent found for focus "${focus}"`, "error");
1489 }
1490 }
1491
1492 /**
1493 * Parse command arguments for direct invocation
1494 * Returns the target or a special marker for PR that needs async handling
1495 */
1496 function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string; repo?: string } | null {
1497 if (!args?.trim()) return null;
1498
1499 const trimmed = args.trim();
1500
1501 // Check if it looks like a PR reference (URL, number, or owner/repo#number)
1502 // This allows `/review 123`, `/review https://...`, or `/review tektoncd/pipeline#1234`
1503 const prRef = parsePrReference(trimmed);
1504 if (prRef !== null) {
1505 return { type: "pr", ref: trimmed, repo: prRef.repo };
1506 }
1507
1508 const parts = trimmed.split(/\s+/);
1509 const subcommand = parts[0]?.toLowerCase();
1510
1511 switch (subcommand) {
1512 case "uncommitted":
1513 return { type: "uncommitted" };
1514
1515 case "branch": {
1516 const branch = parts[1];
1517 if (!branch) return null;
1518 return { type: "baseBranch", branch };
1519 }
1520
1521 case "commit": {
1522 const sha = parts[1];
1523 if (!sha) return null;
1524 const title = parts.slice(2).join(" ") || undefined;
1525 return { type: "commit", sha, title };
1526 }
1527
1528 case "custom": {
1529 const instructions = parts.slice(1).join(" ");
1530 if (!instructions) return null;
1531 return { type: "custom", instructions };
1532 }
1533
1534 case "folder": {
1535 const paths = parseReviewPaths(parts.slice(1).join(" "));
1536 if (paths.length === 0) return null;
1537 return { type: "folder", paths };
1538 }
1539
1540 case "pr": {
1541 const ref = parts[1];
1542 if (!ref) return { type: "pr", ref: "__select__" };
1543 const parsed = parsePrReference(ref);
1544 return { type: "pr", ref, repo: parsed?.repo };
1545 }
1546
1547 default:
1548 return null;
1549 }
1550 }
1551
1552 /**
1553 * Handle PR checkout and return a ReviewTarget (or null on failure)
1554 *
1555 * For cross-repo PRs (when repo is set), clones the repository to a temp
1556 * directory and checks out the PR there instead of in the current repo.
1557 */
1558 async function handlePrCheckout(ctx: ExtensionContext, ref: string, repo?: string): Promise<ReviewTarget | null> {
1559 const prRef = parsePrReference(ref);
1560 if (!prRef) {
1561 ctx.ui.notify("Invalid PR reference. Enter a number or GitHub PR URL.", "error");
1562 return null;
1563 }
1564
1565 // Use repo from the parsed reference if not explicitly provided
1566 const effectiveRepo = repo ?? prRef.repo;
1567 const repoLabel = effectiveRepo ? ` (${effectiveRepo})` : "";
1568 const isCrossRepo = !!effectiveRepo;
1569
1570 // For local repo PRs, check for pending changes (cross-repo clones to temp dir so no conflict)
1571 if (!isCrossRepo && await hasPendingChanges(pi)) {
1572 ctx.ui.notify("Cannot checkout PR: you have uncommitted changes. Please commit or stash them first.", "error");
1573 return null;
1574 }
1575
1576 // Get PR info
1577 ctx.ui.notify(`Fetching PR #${prRef.number} info${repoLabel}...`, "info");
1578 const prInfo = await getPrInfo(pi, prRef.number, effectiveRepo);
1579
1580 if (!prInfo) {
1581 ctx.ui.notify(`Could not find PR #${prRef.number}${repoLabel}. Make sure gh is authenticated and the PR exists.`, "error");
1582 return null;
1583 }
1584
1585 let cloneDir: string | undefined;
1586
1587 if (isCrossRepo) {
1588 // Clone the repository to a temp directory for cross-repo PRs
1589 const tmpBase = path.join(process.env.TMPDIR || "/tmp", "pi-review");
1590 await fs.mkdir(tmpBase, { recursive: true });
1591 const sanitizedRepo = effectiveRepo!.replace(/\//g, "-");
1592 cloneDir = path.join(tmpBase, `${sanitizedRepo}-pr-${prRef.number}`);
1593
1594 // Remove existing clone if present (stale from previous review)
1595 try {
1596 await fs.rm(cloneDir, { recursive: true, force: true });
1597 } catch {
1598 // Ignore
1599 }
1600
1601 ctx.ui.notify(`Cloning ${effectiveRepo} to ${cloneDir}...`, "info");
1602 const { stderr: cloneErr, code: cloneCode } = await pi.exec("gh", [
1603 "repo", "clone", effectiveRepo!, cloneDir,
1604 ]);
1605
1606 if (cloneCode !== 0) {
1607 ctx.ui.notify(`Failed to clone ${effectiveRepo}: ${cloneErr}`, "error");
1608 return null;
1609 }
1610
1611 // Checkout the PR inside the clone
1612 ctx.ui.notify(`Checking out PR #${prRef.number} in clone...`, "info");
1613 const { stderr: coErr, code: coCode } = await pi.exec("gh", [
1614 "pr", "checkout", String(prRef.number), "-R", effectiveRepo!,
1615 ], { cwd: cloneDir });
1616
1617 if (coCode !== 0) {
1618 ctx.ui.notify(`Failed to checkout PR: ${coErr}`, "error");
1619 // Clean up failed clone
1620 await fs.rm(cloneDir, { recursive: true, force: true }).catch(() => {});
1621 return null;
1622 }
1623
1624 ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch}) in ${cloneDir}`, "info");
1625 } else {
1626 // Local repo checkout
1627 ctx.ui.notify(`Checking out PR #${prRef.number}...`, "info");
1628 const checkoutResult = await checkoutPr(pi, prRef.number);
1629
1630 if (!checkoutResult.success) {
1631 ctx.ui.notify(`Failed to checkout PR: ${checkoutResult.error}`, "error");
1632 return null;
1633 }
1634
1635 ctx.ui.notify(`Checked out PR #${prRef.number} (${prInfo.headBranch})`, "info");
1636 }
1637
1638 return {
1639 type: "pullRequest",
1640 prNumber: prRef.number,
1641 baseBranch: prInfo.baseBranch,
1642 title: prInfo.title,
1643 repo: effectiveRepo,
1644 cloneDir,
1645 };
1646 }
1647
1648 // Register the /review command
1649 pi.registerCommand("review", {
1650 description: "Review code changes (PR, uncommitted, branch, commit, folder, or custom)",
1651 handler: async (args, ctx) => {
1652 if (!ctx.hasUI) {
1653 ctx.ui.notify("Review requires interactive mode", "error");
1654 return;
1655 }
1656
1657 // Check if we're already in a review
1658 if (reviewOriginId) {
1659 ctx.ui.notify("Already in a review. Use /end-review to finish first.", "warning");
1660 return;
1661 }
1662
1663 // Check if we're in a git repository
1664 const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
1665 if (code !== 0) {
1666 ctx.ui.notify("Not a git repository", "error");
1667 return;
1668 }
1669
1670 // Try to parse direct arguments
1671 let target: ReviewTarget | null = null;
1672 let fromSelector = false;
1673 const parsed = parseArgs(args);
1674
1675 if (parsed) {
1676 if (parsed.type === "pr") {
1677 if (parsed.ref === "__select__") {
1678 // `/review pr` with no number — show PR selector
1679 target = await showPrInput(ctx);
1680 } else {
1681 // `/review pr 123` or `/review 123` — direct checkout
1682 target = await handlePrCheckout(ctx, parsed.ref, parsed.repo);
1683 }
1684 if (!target) {
1685 ctx.ui.notify("PR review failed. Returning to review menu.", "warning");
1686 }
1687 } else {
1688 target = parsed;
1689 }
1690 }
1691
1692 // If no args or invalid args, show selector
1693 if (!target) {
1694 fromSelector = true;
1695 }
1696
1697 while (true) {
1698 if (!target && fromSelector) {
1699 target = await showReviewSelector(ctx);
1700 }
1701
1702 if (!target) {
1703 ctx.ui.notify("Review cancelled", "info");
1704 return;
1705 }
1706
1707 // Select review focus area
1708 const focusResult = await showFocusSelector(ctx);
1709
1710 if (!focusResult) {
1711 if (fromSelector) {
1712 target = null;
1713 continue;
1714 }
1715 ctx.ui.notify("Review cancelled", "info");
1716 return;
1717 }
1718
1719 // Determine if we should use fresh session mode
1720 // Check if this is a new session (no messages yet)
1721 const entries = ctx.sessionManager.getEntries();
1722 const messageCount = entries.filter((e) => e.type === "message").length;
1723
1724 let useFreshSession = false;
1725
1726 if (messageCount > 0) {
1727 // Existing session - ask user which mode they want
1728 const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]);
1729
1730 if (choice === undefined) {
1731 if (fromSelector) {
1732 target = null;
1733 continue;
1734 }
1735 ctx.ui.notify("Review cancelled", "info");
1736 return;
1737 }
1738
1739 useFreshSession = choice === "Empty branch";
1740 }
1741 // If messageCount === 0, useFreshSession stays false (current session mode)
1742
1743 await executeReview(ctx, target, useFreshSession, focusResult.focus, focusResult.agents);
1744 return;
1745 }
1746 },
1747 });
1748
1749 // Custom prompt for review summaries - focuses on capturing review findings
1750 const REVIEW_SUMMARY_PROMPT = `We are switching to a coding session to continue working on the code.
1751Create a structured summary of this review branch for context when returning later.
1752
1753You MUST summarize the code review that was performed in this branch so that the user can act on it.
1754
17551. What was reviewed (files, changes, scope)
17562. Key findings and their priority levels (P0-P3)
17573. The overall verdict (correct vs needs attention)
17584. Any action items or recommendations
1759
1760YOU MUST append a message with this EXACT format at the end of your summary:
1761
1762## Next Steps
17631. [What should happen next to act on the review]
1764
1765## Constraints & Preferences
1766- [Any constraints, preferences, or requirements mentioned]
1767- [Or "(none)" if none were mentioned]
1768
1769## Code Review Findings
1770
1771[P0] Short Title
1772
1773File: path/to/file.ext:line_number
1774
1775\`\`\`
1776affected code snippet
1777\`\`\`
1778
1779Preserve exact file paths, function names, and error messages.
1780`;
1781
1782 // Register the /end-review command
1783 pi.registerCommand("end-review", {
1784 description: "Complete review and return to original position",
1785 handler: async (args, ctx) => {
1786 if (!ctx.hasUI) {
1787 ctx.ui.notify("End-review requires interactive mode", "error");
1788 return;
1789 }
1790
1791 // Check if we're in a fresh session review
1792 if (!reviewOriginId) {
1793 const state = getReviewState(ctx);
1794 if (state?.active && state.originId) {
1795 reviewOriginId = state.originId;
1796 reviewCloneDir = state.cloneDir;
1797 } else if (state?.active) {
1798 await clearReviewState(ctx, pi);
1799 ctx.ui.notify("Review state was missing origin info; cleared review status.", "warning");
1800 return;
1801 } else {
1802 ctx.ui.notify("Not in a review branch (use /review first, or review was started in current session mode)", "info");
1803 return;
1804 }
1805 }
1806
1807 // Ask about summarization (Summarize is default/first option)
1808 const summaryChoice = await ctx.ui.select("Summarize review branch?", [
1809 "Summarize",
1810 "No summary",
1811 ]);
1812
1813 if (summaryChoice === undefined) {
1814 // User cancelled - keep state so they can call /end-review again
1815 ctx.ui.notify("Cancelled. Use /end-review to try again.", "info");
1816 return;
1817 }
1818
1819 const wantsSummary = summaryChoice === "Summarize";
1820 const originId = reviewOriginId;
1821
1822 if (wantsSummary) {
1823 // Show spinner while summarizing
1824 const result = await ctx.ui.custom<{ cancelled: boolean; error?: string } | null>((tui, theme, _kb, done) => {
1825 const loader = new BorderedLoader(tui, theme, "Summarizing review branch...");
1826 loader.onAbort = () => done(null);
1827
1828 ctx.navigateTree(originId!, {
1829 summarize: true,
1830 customInstructions: REVIEW_SUMMARY_PROMPT,
1831 replaceInstructions: true,
1832 })
1833 .then(done)
1834 .catch((err) => done({ cancelled: false, error: err instanceof Error ? err.message : String(err) }));
1835
1836 return loader;
1837 });
1838
1839 if (result === null) {
1840 // User aborted - keep state so they can try again
1841 ctx.ui.notify("Summarization cancelled. Use /end-review to try again.", "info");
1842 return;
1843 }
1844
1845 if (result.error) {
1846 // Real error - keep state so they can try again
1847 ctx.ui.notify(`Summarization failed: ${result.error}`, "error");
1848 return;
1849 }
1850
1851 // Clear state only on success
1852 await clearReviewState(ctx, pi);
1853
1854 if (result.cancelled) {
1855 ctx.ui.notify("Navigation cancelled", "info");
1856 return;
1857 }
1858
1859 // Pre-fill prompt if editor is empty
1860 if (!ctx.ui.getEditorText().trim()) {
1861 ctx.ui.setEditorText("Act on the code review");
1862 }
1863
1864 ctx.ui.notify("Review complete! Returned to original position.", "info");
1865 } else {
1866 // No summary - just navigate back
1867 try {
1868 const result = await ctx.navigateTree(originId!, { summarize: false });
1869
1870 if (result.cancelled) {
1871 // Keep state so they can try again
1872 ctx.ui.notify("Navigation cancelled. Use /end-review to try again.", "info");
1873 return;
1874 }
1875
1876 // Clear state only on success
1877 await clearReviewState(ctx, pi);
1878 ctx.ui.notify("Review complete! Returned to original position.", "info");
1879 } catch (error) {
1880 // Keep state so they can try again
1881 ctx.ui.notify(`Failed to return: ${error instanceof Error ? error.message : String(error)}`, "error");
1882 }
1883 }
1884 },
1885 });
1886}