| /** | |
- Code Review Extension | |
- | |
- Provides a
/reviewcommand focused on: | | -
- Validating code correctness | |
-
- Surfacing important design decisions | |
- | |
- Usage: | |
-
/review- show interactive selector | |
-
/review uncommitted- review uncommitted changes | |
-
/review branch main- review against main branch | |
-
/review commit abc123- review specific commit | |
-
/review pr 123- review PR #123 (checks out locally) | |
-
/review file src/foo.ts- review specific files (snapshot) | |
-
/review custom "focus on error handling"- custom focus | |
-
/post-review- post findings to GitHub PR (interactive selection) | |
-
/post-review 123- post findings to specific PR | |
-
/post-review P1- only post P1 priority findings | |
-
/post-review P0 P1- only post P0 and P1 findings | |
-
/post-review design- only post design findings | |
-
/post-review 123 P1 correctness- combine PR number and filters | |
-
/resume-review- restore review session after /reload | |
-
/end-review- complete review and return to original position | |
- | |
- Project-specific guidelines: | |
-
- If REVIEW_GUIDELINES.md exists in project root, it's included in the prompt. | |
- | |
- GitHub Integration: | |
-
- Uses
ghCLI for PR operations (checkout, post comments) | |
- Uses
-
/post-reviewparses review findings and posts as PR review with inline comments | | / | | | | import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; | | import { Text } from "@mariozechner/pi-tui"; | | import path from "node:path"; | | import { promises as fs } from "node:fs"; | | | | // State for fresh session review tracking | | let reviewOriginId: string | undefined = undefined; | | let currentPrNumber: number | undefined = undefined; | | let currentPrBaseBranch: string | undefined = undefined; | | const REVIEW_STATE_TYPE = "review-session"; | | | | type ReviewSessionState = { | | active: boolean; | | originId?: string; | | prNumber?: number; | | prBaseBranch?: string; | | }; | | | | // Types for PR review comments | | type ReviewFinding = { | | type: "correctness" | "design"; | | priority?: "P0" | "P1" | "P2" | "P3"; | | file: string; | | line?: number; | | issue: string; | | details: string; | | suggestion?: string; | | }; | | | | type ParsedReview = { | | findings: ReviewFinding[]; | | summary?: 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 text = new Text(theme.fg("warning", "Review session active β use /end-review to return"), 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; | | currentPrNumber = state.prNumber; | | currentPrBaseBranch = state.prBaseBranch; | | setReviewWidget(ctx, true); | | return; | | } | | reviewOriginId = undefined; | | currentPrNumber = undefined; | | currentPrBaseBranch = undefined; | | setReviewWidget(ctx, false); | | } | | | | // Parse review findings from assistant messages | | function parseReviewFindings(messages: string[]): ParsedReview { | | const findings: ReviewFinding[] = []; | | let summary: string | undefined; | | | | const combinedText = messages.join("\n\n"); | | | | // Extract findings using regex patterns matching the review output format | | // Format: [CORRECTNESS] or [DESIGN] followed by File:path:line| | const findingPattern = /**[(CORRECTNESS|DESIGN)]**(?:\s**[(P[0-3])]**)?\s*\n+**File:**\s*([^]+)\s*\n+\*\*Issue:\*\*\s*([^\n]+)\s*\n+\*\*Details:\*\*\s*([\s\S]*?)(?=\*\*Suggestion:\*\*|\*\*\[(?:CORRECTNESS|DESIGN)\]\*\*|## Summary|$)\s*(?:\*\*Suggestion:\*\*\s*([\s\S]*?))?(?=\*\*\[(?:CORRECTNESS|DESIGN)\]\*\*|## Summary|$)/gi; | | | | let match; | | while ((match = findingPattern.exec(combinedText)) !== null) { | | const [, type, priority, filePath, issue, details, suggestion] = match; | | | | // Parse file:line format | | const fileMatch = filePath.match(/^(.+?)(?::(\d+))?$/); | | if (fileMatch) { | | findings.push({ | | type: type.toLowerCase() as "correctness" | "design", | | priority: priority as "P0" | "P1" | "P2" | "P3" | undefined, | | file: fileMatch[1].trim(), | | line: fileMatch[2] ? parseInt(fileMatch[2], 10) : undefined, | | issue: issue.trim(), | | details: details.trim(), | | suggestion: suggestion?.trim(), | | }); | | } | | } | | | | // Extract summary section | | const summaryMatch = combinedText.match(/## Summary\s*([\s\S]*?)(?=## Key Questions|$)/i); | | if (summaryMatch) { | | summary = summaryMatch[1].trim(); | | } | | | | return { findings, summary }; | | } | | | | // Get the latest assistant messages from the session for parsing | | function getAssistantMessages(ctx: ExtensionContext, searchAll = false): string[] { | | const messages: string[] = []; | | const allAssistantMessages: string[] = []; | | const entries = ctx.sessionManager.getBranch(); | | | | // Get messages from current review session (after the review prompt) | | let foundReviewPrompt = false; | | for (const entry of entries) { | | if (entry.type === "message") { | | const msg = entry.message; | | | | // Collect all assistant messages as fallback | | if (msg.role === "assistant") { | | const extractText = (content: any): string[] => { | | if (Array.isArray(content)) { | | return content.filter(p => p.type === "text").map(p => p.text); | | } else if (typeof content === "string") { | | return [content]; | | } | | return []; | | }; | | allAssistantMessages.push(...extractText(msg.content)); | | } | | | | if (msg.role === "user") { | | const content = typeof msg.content === "string" ? msg.content : | | Array.isArray(msg.content) ? msg.content.filter(p => p.type === "text").map(p => p.text).join("\n") : ""; | | if (content.includes("# Code Review Guidelines") || content.includes("Review the") && content.includes("changes")) { | | foundReviewPrompt = true; | | continue; | | } | | } | | if (foundReviewPrompt && msg.role === "assistant") { | | if (Array.isArray(msg.content)) { | | for (const part of msg.content) { | | if (part.type === "text") { | | messages.push(part.text); | | } | | } | | } else if (typeof msg.content === "string") { | | messages.push(msg.content); | | } | | } | | } | | } | | | | // If we didn't find a review prompt or searchAll is true, return all assistant messages | | // that contain review-like content | | if (messages.length === 0 || searchAll) { | | // Filter to only messages that look like they contain review findings | | const reviewMessages = allAssistantMessages.filter(m => | | m.includes("[CORRECTNESS]") || | | m.includes("[DESIGN]") || | | m.includes("**File:**") || | | m.includes("## Summary") | | ); | | if (reviewMessages.length > 0) { | | return reviewMessages; | | } | | // Last resort: return all assistant messages | | return allAssistantMessages; | | } | | | | return messages; | | } | | | | // Format a finding as a GitHub review comment body | | function formatFindingAsComment(finding: ReviewFinding): string { | | let body = ""; | | | | // Header with type and priority | | const typeEmoji = finding.type === "correctness" ? "π" : "π‘"; | | const priorityBadge = finding.priority ?[${finding.priority}]: ""; | | body +=${typeEmoji} ${finding.type.toUpperCase()}${priorityBadge}\n\n; | | | | // Issue | | body +=Issue: ${finding.issue}\n\n; | | | | // Details | | body +=${finding.details}\n; | | | | // Suggestion | | if (finding.suggestion) { | | body +=\nSuggestion: ${finding.suggestion}; | | } | | | | return body; | | } | | | | // Get the diff position for a file:line in the PR diff | | async function getDiffPosition( | | pi: ExtensionAPI, | | prNumber: number, | | file: string, | | line: number | | ): Promise<number | null> { | | // Get the PR diff | | const { stdout, code } = await pi.exec("gh", [ | | "api", | |repos/{owner}/{repo}/pulls/${prNumber}, | | "-H", "Accept: application/vnd.github.diff", | | ]); | | | | if (code !== 0) return null; | | | | // Parse diff to find position | | // The position is the line number in the diff, not the file | | const lines = stdout.split("\n"); | | let currentFile = ""; | | let position = 0; | | let inFile = false; | | let newLineNumber = 0; | | | | for (const diffLine of lines) { | | if (diffLine.startsWith("diff --git")) { | | // Extract filename from diff header | | const match = diffLine.match(/b\/(.+)$/); | | currentFile = match ? match[1] : ""; | | inFile = currentFile === file || file.endsWith(currentFile) || currentFile.endsWith(file); | | position = 0; | | newLineNumber = 0; | | } else if (diffLine.startsWith("@@") && inFile) { | | // Parse hunk header: @@ -old,count +new,count @@ | | const hunkMatch = diffLine.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | | if (hunkMatch) { | | newLineNumber = parseInt(hunkMatch[1], 10) - 1; | | } | | position++; | | } else if (inFile && (diffLine.startsWith("+") || diffLine.startsWith("-") || diffLine.startsWith(" "))) { | | position++; | | if (!diffLine.startsWith("-")) { | | newLineNumber++; | | if (newLineNumber === line) { | | return position; | | } | | } | | } | | } | | | | return null; | | } | | | | // Review targets | | type ReviewTarget = | | | { type: "uncommitted" } | | | { type: "baseBranch"; branch: string } | | | { type: "commit"; sha: string; title?: string } | | | { type: "pullRequest"; prNumber: number; baseBranch: string; title: string } | | | { type: "file"; paths: string[] } | | | { type: "custom"; instructions: string }; | | | | // The review rubric focused on correctness and design decisions | | const REVIEW_RUBRIC =# Code Review Guidelines | | | | You are reviewing code changes with two primary goals: | |
- Validate correctness β Find bugs, logic errors, and potential runtime issues | |
- Surface design decisions β Identify architectural choices that deserve discussion | | | |
What to Look For | #
| | |
Correctness Issues (MUST flag) |
|
- Logic errors, off-by-one bugs, incorrect conditions | |
- Null/undefined handling gaps | |
- Race conditions, deadlocks, resource leaks | |
- Error handling that swallows or mishandles errors | |
- Type mismatches or unsafe casts | |
- Security vulnerabilities (SQL injection, XSS, path traversal, etc.) | |
- Incorrect API usage or protocol violations | |
- Edge cases that would cause failures | | | |
Design Decisions (SHOULD surface) |
|
- New abstractions, interfaces, or patterns introduced | |
- Changes to data models or schemas | |
- New dependencies or significant library choices | |
- Performance trade-offs (time vs space, caching strategies) | |
- Error handling strategies (fail-fast vs graceful degradation) | |
- API design choices (naming, signatures, contracts) | |
- Coupling/cohesion changes | |
- Backwards compatibility implications | |
- Testing strategy choices | | | |
Review Process | #
| | |
- Understand the change β What is this trying to accomplish? | |
- Trace the data flow β Follow inputs through transformations to outputs | |
- Consider edge cases β What happens with empty, null, huge, or malformed inputs? | |
- Check error paths β Are errors handled correctly and propagated appropriately? | |
- Evaluate the design β Is this the right abstraction? Will it scale? Is it maintainable? | | | |
Output Format | #
| | |
For each finding, provide: |
| | | [CORRECTNESS] or [DESIGN] tag | | | | File: `path/to/file.ts:line` | | | | Issue: One-sentence summary | | | | Details: Brief explanation (1-2 paragraphs max) | |
- Why this is a problem (for correctness) or why it deserves discussion (for design) | |
- Specific scenario where it would fail (for correctness) | | | | Suggestion: How to fix it or what to consider | | | | --- | | | |
Priority Levels (for correctness issues only): |
|
- [P0] β Will cause data loss, security breach, or crash in production | |
- [P1] β Will cause incorrect behavior in common scenarios | |
- [P2] β Will cause incorrect behavior in edge cases | |
- [P3] β Code smell or potential future issue | | | |
At the end, provide: |
| | |
Summary | #
| | | Correctness: [PASS / NEEDS FIXES] β Brief assessment | | Design: [List 2-3 most important design decisions that should be discussed] | | | |
Key Questions for the Author | #
|
- [Question about a design decision] | |
- [Question about intended behavior] | |
- [Question about trade-off made] |
|
|
|
--- |
|
|
|
Focus on findings the author would want to know about. Skip trivial style issues unless they impact readability or correctness.
; | | | | // Prompt templates | | const UNCOMMITTED_PROMPT =Review the current uncommitted changes (staged, unstaged, and untracked files). | | | | Run `git status` and `git diff` to see the changes, then review them.; | | | | const BASE_BRANCH_PROMPT =Review the code changes against the base branch '{branch}'. | | | | First find the merge base: `git merge-base HEAD {branch}` | | Then review the diff: `git diff <merge-base-sha>` | | | | Focus on what would be merged into {branch}.; | | | | const COMMIT_PROMPT =Review commit {sha}{titlePart}. | | | | Run `git show {sha}` to see the changes, then review them.; | | | | const PR_PROMPT =Review pull request #{prNumber} "{title}" against {baseBranch}. | | | | First find the merge base: `git merge-base HEAD {baseBranch}` | | Then review the diff: `git diff <merge-base-sha>` | | | | This represents what would be merged.; | | | | const FILE_PROMPT =Review the code in these files (snapshot review, not a diff): | | | | {paths} | | | | Read each file and review for correctness issues and design patterns.; | | | | async function loadProjectGuidelines(cwd: string): Promise<string | null> { | | const guidelinesPath = path.join(cwd, "REVIEW_GUIDELINES.md"); | | try { | | const content = await fs.readFile(guidelinesPath, "utf8"); | | return content.trim() || null; | | } catch { | | return null; | | } | | } | | | | 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()); | | } | | | | async function getRecentCommits(pi: ExtensionAPI, limit = 15): 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(" ") }; | | }); | | } | | | | async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> { | | const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); | | return code === 0 && stdout.trim().length > 0; | | } | | | | async function hasPendingChanges(pi: ExtensionAPI): Promise<boolean> { | | const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]); | | if (code !== 0) return false; | | const lines = stdout.trim().split("\n").filter(line => line.trim()); | | return lines.filter(line => !line.startsWith("??")).length > 0; | | } | | | | async function getCurrentBranch(pi: ExtensionAPI): Promise<string | null> { | | const { stdout, code } = await pi.exec("git", ["branch", "--show-current"]); | | return code === 0 && stdout.trim() ? stdout.trim() : null; | | } | | | | async function getDefaultBranch(pi: ExtensionAPI): Promise<string> { | | const { stdout, code } = await pi.exec("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"]); | | if (code === 0 && stdout.trim()) { | | return stdout.trim().replace("origin/", ""); | | } | | const branches = await getLocalBranches(pi); | | if (branches.includes("main")) return "main"; | | if (branches.includes("master")) return "master"; | | return "main"; | | } | | | | function parsePrReference(ref: string): number | null { | | const trimmed = ref.trim(); | | const num = parseInt(trimmed, 10); | | if (!isNaN(num) && num > 0) return num; | | const urlMatch = trimmed.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); | | if (urlMatch) return parseInt(urlMatch[1], 10); | | return null; | | } | | | | 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; | | } | | } | | | | 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 }; | | } | | | | function buildPrompt(target: ReviewTarget): string { | | switch (target.type) { | | case "uncommitted": | | return UNCOMMITTED_PROMPT; | | case "baseBranch": | | return BASE_BRANCH_PROMPT.replace(/{branch}/g, target.branch); | | case "commit": { | | const titlePart = target.title ?("${target.title}"): ""; | | return COMMIT_PROMPT.replace("{sha}", target.sha).replace("{titlePart}", titlePart); | | } | | case "pullRequest": | | return PR_PROMPT | | .replace("{prNumber}", String(target.prNumber)) | | .replace("{title}", target.title) | | .replace("{baseBranch}", target.baseBranch); | | case "file": | | return FILE_PROMPT.replace("{paths}", target.paths.map(p =>- ${p}).join("\n")); | | case "custom": | | return target.instructions; | | } | | } | | | | function getTargetDescription(target: ReviewTarget): string { | | switch (target.type) { | | case "uncommitted": return "uncommitted changes"; | | case "baseBranch": returnchanges vs ${target.branch}; | | case "commit": returncommit ${target.sha.slice(0, 7)}${target.title ?: ${target.title}: ""}; | | case "pullRequest": returnPR #${target.prNumber}: ${target.title}; | | case "file": returnfiles: ${target.paths.join(", ")}; | | case "custom": return target.instructions.slice(0, 40) + (target.instructions.length > 40 ? "..." : ""); | | } | | } | | | | const REVIEW_PRESETS = [ | | { value: "uncommitted", label: "Review uncommitted changes", description: "" }, | | { value: "baseBranch", label: "Review against a branch", description: "(PR-style diff)" }, | | { value: "commit", label: "Review a specific commit", description: "" }, | | { value: "pullRequest", label: "Review a GitHub PR", description: "(checks out locally)" }, | | { value: "file", label: "Review specific files", description: "(snapshot)" }, | | { value: "custom", label: "Custom review focus", description: "" }, | | ] as const; | | | | export default function reviewExtension(pi: ExtensionAPI) { | | // Restore review state on session events | | pi.on("session_start", (_event, ctx) => applyReviewState(ctx)); | | pi.on("session_switch", (_event, ctx) => applyReviewState(ctx)); | | pi.on("session_tree", (_event, ctx) => applyReviewState(ctx)); | | | | async function getSmartDefault(): Promise<string> { | | if (await hasUncommittedChanges(pi)) return "uncommitted"; | | const current = await getCurrentBranch(pi); | | const defaultBranch = await getDefaultBranch(pi); | | if (current && current !== defaultBranch) return "baseBranch"; | | return "commit"; | | } | | | | async function showSelector(ctx: ExtensionContext): Promise<ReviewTarget | null> { | | const smartDefault = await getSmartDefault(); | | | | // Sort presets with smart default first | | const sortedPresets = REVIEW_PRESETS | | .slice() | | .sort((a, b) => { | | if (a.value === smartDefault) return -1; | | if (b.value === smartDefault) return 1; | | return 0; | | }); | | | | // Use simple ctx.ui.select() for better compatibility with non-TUI environments (e.g., Emacs RPC mode) | | const options = sortedPresets.map(p => p.label); | | | | while (true) { | | const selected = await ctx.ui.select("Select review type:", options); | | if (selected === undefined) return null; | | | | // Find the preset by label | | const preset = sortedPresets.find(p => p.label === selected); | | if (!preset) return null; | | | | switch (preset.value) { | | case "uncommitted": | | return { type: "uncommitted" }; | | | | case "baseBranch": { | | const branches = await getLocalBranches(pi); | | const defaultBranch = await getDefaultBranch(pi); | | const sorted = branches.sort((a, b) => { | | if (a === defaultBranch) return -1; | | if (b === defaultBranch) return 1; | | return a.localeCompare(b); | | }); | | | | // Add "(default)" suffix to default branch for visibility | | const branchOptions = sorted.map(b => b === defaultBranch ?${b} (default): b); | | | | const selectedBranch = await ctx.ui.select("Select base branch:", branchOptions); | | if (selectedBranch === undefined) continue; // Go back to main menu | | | | // Strip "(default)" suffix if present | | const branch = selectedBranch.replace(" (default)", ""); | | return { type: "baseBranch", branch }; | | } | | | | case "commit": { | | const commits = await getRecentCommits(pi, 20); | | if (!commits.length) { | | ctx.ui.notify("No commits found", "error"); | | continue; | | } | | | | const commitOptions = commits.map(c =>${c.sha.slice(0, 7)} ${c.title}); | | | | const selectedCommit = await ctx.ui.select("Select commit:", commitOptions); | | if (selectedCommit === undefined) continue; // Go back to main menu | | | | // Find the commit by matching the option string | | const commit = commits.find(c =>${c.sha.slice(0, 7)} ${c.title}=== selectedCommit); | | if (commit) return { type: "commit", sha: commit.sha, title: commit.title }; | | continue; | | } | | | | case "pullRequest": { | | if (await hasPendingChanges(pi)) { | | ctx.ui.notify("Cannot checkout PR: uncommitted changes exist. Commit or stash first.", "error"); | | continue; | | } | | | | const prRef = await ctx.ui.input("PR number or URL:", "123"); | | if (!prRef?.trim()) continue; | | | | const prNumber = parsePrReference(prRef); | | if (!prNumber) { | | ctx.ui.notify("Invalid PR reference", "error"); | | continue; | | } | | | | ctx.ui.notify(Fetching PR #${prNumber}..., "info"); | | const prInfo = await getPrInfo(pi, prNumber); | | if (!prInfo) { | | ctx.ui.notify(Could not find PR #${prNumber}. Is gh authenticated?, "error"); | | continue; | | } | | | | ctx.ui.notify(Checking out PR #${prNumber}..., "info"); | | const checkout = await checkoutPr(pi, prNumber); | | if (!checkout.success) { | | ctx.ui.notify(Checkout failed: ${checkout.error}, "error"); | | continue; | | } | | | | ctx.ui.notify(Checked out PR #${prNumber}, "info"); | | return { type: "pullRequest", prNumber, baseBranch: prInfo.baseBranch, title: prInfo.title }; | | } | | | | case "file": { | | const input = await ctx.ui.input("Files/folders to review (space-separated):", "src/"); | | if (!input?.trim()) continue; | | const paths = input.trim().split(/\s+/).filter(p => p); | | if (paths.length) return { type: "file", paths }; | | continue; | | } | | | | case "custom": { | | const instructions = await ctx.ui.editor("Review focus:", "Check for security issues and race conditions"); | | if (instructions?.trim()) return { type: "custom", instructions: instructions.trim() }; | | continue; | | } | | } | | } | | } | | | | function parseArgs(args: string | undefined): ReviewTarget | { type: "pr"; ref: string } | null { | | if (!args?.trim()) return null; | | const parts = args.trim().split(/\s+/); | | const cmd = parts[0]?.toLowerCase(); | | | | switch (cmd) { | | case "uncommitted": | | return { type: "uncommitted" }; | | case "branch": { | | const branch = parts[1]; | | return branch ? { type: "baseBranch", branch } : null; | | } | | case "commit": { | | const sha = parts[1]; | | if (!sha) return null; | | const title = parts.slice(2).join(" ") || undefined; | | return { type: "commit", sha, title }; | | } | | case "pr": { | | const ref = parts[1]; | | return ref ? { type: "pr", ref } : null; | | } | | case "file": { | | const paths = parts.slice(1); | | return paths.length ? { type: "file", paths } : null; | | } | | case "custom": { | | const instructions = parts.slice(1).join(" "); | | return instructions ? { type: "custom", instructions } : null; | | } | | default: | | return null; | | } | | } | | | | async function handlePrCheckout(ctx: ExtensionContext, ref: string): Promise<ReviewTarget | null> { | | if (await hasPendingChanges(pi)) { | | ctx.ui.notify("Cannot checkout PR: uncommitted changes. Commit or stash first.", "error"); | | return null; | | } | | | | const prNumber = parsePrReference(ref); | | if (!prNumber) { | | ctx.ui.notify("Invalid PR reference", "error"); | | return null; | | } | | | | ctx.ui.notify(Fetching PR #${prNumber}..., "info"); | | const prInfo = await getPrInfo(pi, prNumber); | | if (!prInfo) { | | ctx.ui.notify(Could not find PR #${prNumber}, "error"); | | return null; | | } | | | | ctx.ui.notify(Checking out PR #${prNumber}..., "info"); | | const checkout = await checkoutPr(pi, prNumber); | | if (!checkout.success) { | | ctx.ui.notify(Checkout failed: ${checkout.error}, "error"); | | return null; | | } | | | | ctx.ui.notify(Checked out PR #${prNumber}, "info"); | | return { type: "pullRequest", prNumber, baseBranch: prInfo.baseBranch, title: prInfo.title }; | | } | | | | async function executeReview(ctx: ExtensionCommandContext, target: ReviewTarget, useFreshSession: boolean): Promise<void> { | | if (reviewOriginId) { | | ctx.ui.notify("Already in a review. Use /end-review first.", "warning"); | | return; | | } | | | | if (useFreshSession) { | | const originId = ctx.sessionManager.getLeafId() ?? undefined; | | if (!originId) { | | ctx.ui.notify("Failed to determine origin. Try from a session with messages.", "error"); | | return; | | } | | reviewOriginId = originId; | | const lockedOriginId = originId; | | | | const entries = ctx.sessionManager.getEntries(); | | const firstUserMessage = entries.find(e => e.type === "message" && e.message.role === "user"); | | if (!firstUserMessage) { | | ctx.ui.notify("No user message found in session", "error"); | | reviewOriginId = undefined; | | return; | | } | | | | try { | | const result = await ctx.navigateTree(firstUserMessage.id, { summarize: false, label: "code-review" }); | | if (result.cancelled) { | | reviewOriginId = undefined; | | return; | | } | | } catch (error) { | | reviewOriginId = undefined; | | ctx.ui.notify(Failed to start review: ${error instanceof Error ? error.message : String(error)}, "error"); | | return; | | } | | | | reviewOriginId = lockedOriginId; | | ctx.ui.setEditorText(""); | | setReviewWidget(ctx, true); | | | | // Track PR info if this is a PR review | | if (target.type === "pullRequest") { | | currentPrNumber = target.prNumber; | | currentPrBaseBranch = target.baseBranch; | | pi.appendEntry(REVIEW_STATE_TYPE, { | | active: true, | | originId: lockedOriginId, | | prNumber: target.prNumber, | | prBaseBranch: target.baseBranch, | | }); | | } else { | | currentPrNumber = undefined; | | currentPrBaseBranch = undefined; | | pi.appendEntry(REVIEW_STATE_TYPE, { active: true, originId: lockedOriginId }); | | } | | } | | | | const prompt = buildPrompt(target); | | const projectGuidelines = await loadProjectGuidelines(ctx.cwd); | | | | let fullPrompt =${REVIEW_RUBRIC}\n\n---\n\n${prompt}; | | if (projectGuidelines) { | | fullPrompt +=\n\n---\n\n## Project-Specific Guidelines\n\n${projectGuidelines}; | | } | | | | const desc = getTargetDescription(target); | | const mode = useFreshSession ? " (fresh session)" : ""; | | ctx.ui.notify(Starting review: ${desc}${mode}, "info"); | | | | pi.sendUserMessage(fullPrompt); | | } | | | | // Register /review command | | pi.registerCommand("review", { | | description: "Review code for correctness and design decisions", | | handler: async (args, ctx) => { | | if (!ctx.hasUI) { | | ctx.ui.notify("Review requires interactive mode", "error"); | | return; | | } | | | | if (reviewOriginId) { | | ctx.ui.notify("Already in a review. Use /end-review first.", "warning"); | | return; | | } | | | | const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]); | | if (code !== 0) { | | ctx.ui.notify("Not a git repository", "error"); | | return; | | } | | | | let target: ReviewTarget | null = null; | | let fromSelector = false; | | const parsed = parseArgs(args); | | | | if (parsed) { | | if (parsed.type === "pr") { | | target = await handlePrCheckout(ctx, parsed.ref); | | } else { | | target = parsed; | | } | | } | | | | if (!target) fromSelector = true; | | | | while (true) { | | if (!target && fromSelector) { | | target = await showSelector(ctx); | | } | | | | if (!target) { | | ctx.ui.notify("Review cancelled", "info"); | | return; | | } | | | | const entries = ctx.sessionManager.getEntries(); | | const messageCount = entries.filter(e => e.type === "message").length; | | | | let useFreshSession = false; | | if (messageCount > 0) { | | const choice = await ctx.ui.select("Start review in:", ["Empty branch", "Current session"]); | | if (choice === undefined) { | | if (fromSelector) { | | target = null; | | continue; | | } | | ctx.ui.notify("Review cancelled", "info"); | | return; | | } | | useFreshSession = choice === "Empty branch"; | | } | | | | await executeReview(ctx, target, useFreshSession); | | return; | | } | | }, | | }); | | | | // Register /resume-review command to restore state after reload | | pi.registerCommand("resume-review", { | | description: "Resume a review session (restores state after /reload)", | | handler: async (args, ctx) => { | | if (!ctx.hasUI) { | | ctx.ui.notify("Requires interactive mode", "error"); | | return; | | } | | | | // Check if already in a review | | if (reviewOriginId) { | | ctx.ui.notify("Already in an active review session", "info"); | | return; | | } | | | | // Try to restore state from session | | const state = getReviewState(ctx); | | if (state?.active) { | | reviewOriginId = state.originId; | | currentPrNumber = state.prNumber; | | currentPrBaseBranch = state.prBaseBranch; | | setReviewWidget(ctx, true); | | | | let msg = "Review session restored"; | | if (currentPrNumber) { | | msg +=(PR #${currentPrNumber}); | | } | | ctx.ui.notify(msg, "success"); | | return; | | } | | | | // No active review state, but check if there are review findings in the session | | // Search all messages since old reviews didn't save state | | const messages = getAssistantMessages(ctx, true); | | if (messages.length > 0) { | | const parsed = parseReviewFindings(messages); | | if (parsed.findings.length > 0) { | | ctx.ui.notify( | |Found ${parsed.findings.length} review findings in conversation. Use /post-review <pr-number> to post them., | | "success" | | ); | | return; | | } | | } | | | | ctx.ui.notify("No review findings found in this session. Make sure you're in the right session branch.", "warning"); | | }, | | }); | | | | // Review summary prompt | | const REVIEW_SUMMARY_PROMPT =Summarize this code review for future reference. | | | | Include: | | - What was reviewed (scope, files) | |
- Key findings: | |
- Correctness issues found (with priority) | |
- Design decisions surfaced | |
- Overall verdict | |
- Action items |
|
|
|
Format as a structured summary that can be acted upon later.
; | | | | // Register /end-review command | | pi.registerCommand("end-review", { | | description: "Complete review and return to original position", | | handler: async (args, ctx) => { | | if (!ctx.hasUI) { | | ctx.ui.notify("Requires interactive mode", "error"); | | return; | | } | | | | if (!reviewOriginId) { | | const state = getReviewState(ctx); | | if (state?.active && state.originId) { | | reviewOriginId = state.originId; | | } else if (state?.active) { | | setReviewWidget(ctx, false); | | pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); | | ctx.ui.notify("Review state cleared", "warning"); | | return; | | } else { | | ctx.ui.notify("Not in a review session", "info"); | | return; | | } | | } | | | | const choice = await ctx.ui.select("Summarize review?", ["Summarize", "No summary"]); | | if (choice === undefined) { | | ctx.ui.notify("Cancelled. Use /end-review to try again.", "info"); | | return; | | } | | | | const wantsSummary = choice === "Summarize"; | | const originId = reviewOriginId; | | | | if (wantsSummary) { | | // Show notification instead of custom UI for better compatibility with non-TUI environments | | ctx.ui.notify("Summarizing review...", "info"); | | | | try { | | const result = await ctx.navigateTree(originId!, { | | summarize: true, | | customInstructions: REVIEW_SUMMARY_PROMPT, | | replaceInstructions: true, | | }); | | | | setReviewWidget(ctx, false); | | reviewOriginId = undefined; | | currentPrNumber = undefined; | | currentPrBaseBranch = undefined; | | pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); | | | | if (result.cancelled) { | | ctx.ui.notify("Navigation cancelled", "info"); | | return; | | } | | | | try { | | if (!ctx.ui.getEditorText().trim()) { | | ctx.ui.setEditorText("Address the review findings"); | | } | | } catch { | | // getEditorText/setEditorText may not be available in all environments | | } | | | | ctx.ui.notify("Review complete!", "info"); | | } catch (error) { | | ctx.ui.notify(Failed: ${error instanceof Error ? error.message : String(error)}, "error"); | | } | | } else { | | try { | | const result = await ctx.navigateTree(originId!, { summarize: false }); | | if (result.cancelled) { | | ctx.ui.notify("Navigation cancelled", "info"); | | return; | | } | | | | setReviewWidget(ctx, false); | | reviewOriginId = undefined; | | currentPrNumber = undefined; | | currentPrBaseBranch = undefined; | | pi.appendEntry(REVIEW_STATE_TYPE, { active: false }); | | ctx.ui.notify("Review complete!", "info"); | | } catch (error) { | | ctx.ui.notify(Failed: ${error instanceof Error ? error.message : String(error)}, "error"); | | } | | } | | }, | | }); | | | | // Register /post-review command for posting findings to GitHub PR | | pi.registerCommand("post-review", { | | description: "Post review findings as GitHub PR comments. Use /post-review [PR#] [P0|P1|P2|P3|design|correctness]", | | handler: async (args, ctx) => { | | if (!ctx.hasUI) { | | ctx.ui.notify("Requires interactive mode", "error"); | | return; | | } | | | | // Try to restore state from session if not in memory (e.g., after /reload) | | if (!currentPrNumber) { | | const state = getReviewState(ctx); | | if (state?.prNumber) { | | currentPrNumber = state.prNumber; | | currentPrBaseBranch = state.prBaseBranch; | | reviewOriginId = state.originId; | | if (state.active) { | | setReviewWidget(ctx, true); | | } | | } | | } | | | | // Parse args: [PR number] [priority/type filters...] | | // e.g., "/post-review 123 P1 P0" or "/post-review P1" or "/post-review design" | | let prNumber = currentPrNumber; | | let baseBranch = currentPrBaseBranch; | | const priorityFilters: Set<string> = new Set(); | | const typeFilters: Set<string> = new Set(); | | | | if (args?.trim()) { | | const parts = args.trim().split(/\s+/); | | for (const part of parts) { | | const upper = part.toUpperCase(); | | if (/^P[0-3]$/i.test(part)) { | | priorityFilters.add(upper); | | } else if (part.toLowerCase() === "design") { | | typeFilters.add("design"); | | } else if (part.toLowerCase() === "correctness") { | | typeFilters.add("correctness"); | | } else { | | // Try to parse as PR number | | const parsed = parsePrReference(part); | | if (parsed) { | | prNumber = parsed; | | const prInfo = await getPrInfo(pi, prNumber); | | if (prInfo) { | | baseBranch = prInfo.baseBranch; | | } | | } | | } | | } | | } | | | | if (!prNumber) { | | ctx.ui.notify("No PR specified. Use /post-review <pr-number> or run from a PR review session.", "error"); | | return; | | } | | | | // Check gh CLI is available | | const { code: ghCode } = await pi.exec("gh", ["auth", "status"]); | | if (ghCode !== 0) { | | ctx.ui.notify("GitHub CLI not authenticated. Run: gh auth login", "error"); | | return; | | } | | | | // Parse review findings from session | | let messages = getAssistantMessages(ctx); | | let parsed = parseReviewFindings(messages); | | | | // If no findings found, try searching all messages (for old reviews without state) | | if (parsed.findings.length === 0) { | | messages = getAssistantMessages(ctx, true); | | parsed = parseReviewFindings(messages); | | } | | | | if (messages.length === 0) { | | ctx.ui.notify("No review messages found in session", "error"); | | return; | | } | | | | if (parsed.findings.length === 0) { | | ctx.ui.notify("No structured findings found. Make sure the review output includes **[CORRECTNESS]** or **[DESIGN]** tags.", "warning"); | | return; | | } | | | | // Apply priority/type filters | | let filteredFindings = parsed.findings; | | if (priorityFilters.size > 0 || typeFilters.size > 0) { | | filteredFindings = parsed.findings.filter(f => { | | const matchesPriority = priorityFilters.size === 0 || (f.priority && priorityFilters.has(f.priority)); | | const matchesType = typeFilters.size === 0 || typeFilters.has(f.type); | | return matchesPriority && matchesType; | | }); | | } | | | | if (filteredFindings.length === 0) { | | const filterDesc = [...priorityFilters, ...typeFilters].join(", ") || "specified filters"; | | ctx.ui.notify(No findings match ${filterDesc}, "warning"); | | return; | | } | | | | ctx.ui.notify(Found ${filteredFindings.length} findings (of ${parsed.findings.length} total), "info"); | | | | // Let user select which findings to post | | const findingOptions = filteredFindings.map((f, i) => { | | const priority = f.priority ?[${f.priority}]: ""; | | const type = f.type === "correctness" ? "π" : "π‘"; | | const loc = f.line ?${f.file}:${f.line}: f.file; | | return${type} ${priority} ${loc}: ${f.issue.slice(0, 50)}${f.issue.length > 50 ? "..." : ""}; | | }); | | | | // Add "All" and "Select individually" options | | const selectionMode = await ctx.ui.select( | |Post ${filteredFindings.length} findings:, | | ["Post all", "Select which to post", "Cancel"] | | ); | | | | if (selectionMode === undefined || selectionMode === "Cancel") { | | ctx.ui.notify("Cancelled", "info"); | | return; | | } | | | | let selectedFindings: ReviewFinding[] = []; | | | | if (selectionMode === "Post all") { | | selectedFindings = filteredFindings; | | } else { | | // Let user pick individual findings | | for (let i = 0; i < filteredFindings.length; i++) { | | const finding = filteredFindings[i]; | | const preview = formatFindingAsComment(finding); | | const loc = finding.line ?${finding.file}:${finding.line}: finding.file; | | | | const action = await ctx.ui.select( | |[${i + 1}/${filteredFindings.length}] ${loc}, | | ["Include", "Edit & include", "Skip"] | | ); | | | | if (action === undefined) { | | ctx.ui.notify("Cancelled", "info"); | | return; | | } | | | | if (action === "Skip") { | | continue; | | } | | | | if (action === "Edit & include") { | | // Let user edit the comment | | const edited = await ctx.ui.editor( | |Edit comment for ${loc}:, | | preview | | ); | | | | if (edited === undefined) { | | continue; // Skip this one | | } | | | | // Create a modified finding with the edited content | | selectedFindings.push({ | | ...finding, | | // Store the edited body directly - we'll use it later | | _editedBody: edited.trim(), | | } as ReviewFinding & { _editedBody?: string }); | | } else { | | selectedFindings.push(finding); | | } | | } | | } | | | | if (selectedFindings.length === 0) { | | ctx.ui.notify("No findings selected", "info"); | | return; | | } | | | | // Ask for review action | | const reviewAction = await ctx.ui.select("Post review as:", [ | | "Comment (no approval status)", | | "Request changes", | | "Approve with comments", | | ]); | | | | if (reviewAction === undefined) { | | ctx.ui.notify("Cancelled", "info"); | | return; | | } | | | | const event = reviewAction === "Request changes" ? "REQUEST_CHANGES" | | : reviewAction === "Approve with comments" ? "APPROVE" | | : "COMMENT"; | | | | // Optionally edit the review summary | | let reviewBody = ""; | | if (parsed.summary) { | | reviewBody =## Review Summary\n\n${parsed.summary}; | | } | | | | const editSummary = await ctx.ui.confirm( | | "Edit summary?", | | "Do you want to edit the review summary before posting?" | | ); | | | | if (editSummary) { | | const editedSummary = await ctx.ui.editor( | | "Edit review summary:", | | reviewBody ||Code review with ${selectedFindings.length} findings.| | ); | | if (editedSummary !== undefined) { | | reviewBody = editedSummary.trim(); | | } | | } | | | | if (!reviewBody) { | | reviewBody =Code review completed with ${selectedFindings.length} findings.; | | } | | | | // Build review comments | | const comments: Array<{ path: string; line?: number; body: string }> = []; | | const generalComments: string[] = []; | | | | for (const finding of selectedFindings) { | | // Use edited body if available, otherwise format normally | | const body = (finding as any)._editedBody || formatFindingAsComment(finding); | | | | if (finding.line) { | | comments.push({ | | path: finding.file, | | line: finding.line, | | body, | | }); | | } else { | | // No line number - add to general comments | | generalComments.push(${finding.file}\n\n${body}); | | } | | } | | | | // Add general comments to review body | | if (generalComments.length > 0) { | | reviewBody += (reviewBody ? "\n\n---\n\n" : "") + "## Additional Findings\n\n" + generalComments.join("\n\n---\n\n"); | | } | | | | // Post using gh api | | // First, we need to get the latest commit SHA for the PR | | const { stdout: prData, code: prCode } = await pi.exec("gh", [ | | "pr", "view", String(prNumber), "--json", "headRefOid" | | ]); | | | | if (prCode !== 0) { | | ctx.ui.notify(Failed to get PR info: ${prData}, "error"); | | return; | | } | | | | let commitSha: string; | | try { | | const prJson = JSON.parse(prData); | | commitSha = prJson.headRefOid; | | } catch { | | ctx.ui.notify("Failed to parse PR info", "error"); | | return; | | } | | | | // Build the review payload | | // For line comments, we need to use the pull request review API | | // which requires the commit_id and the position in the diff (not line number) | | | | // For simplicity, we'll post line-specific comments using the | | // newer "line" parameter which works with multi-line comments API | | const reviewPayload: { | | body: string; | | event: string; | | commit_id: string; | | comments?: Array<{ path: string; line: number; body: string; side: string }>; | | } = { | | body: reviewBody, | | event, | | commit_id: commitSha, | | }; | | | | // Add line comments if we have any | | if (comments.length > 0) { | | reviewPayload.comments = comments | | .filter(c => c.line !== undefined) | | .map(c => ({ | | path: c.path, | | line: c.line!, | | body: c.body, | | side: "RIGHT", // Comment on the new version of the file | | })); | | } | | | | // Post the review | | ctx.ui.notify("Posting review to GitHub...", "info"); | | | | const payloadJson = JSON.stringify(reviewPayload); | | | | // Write payload to temp file since pi.exec doesn't support stdin | | const tempFile =/tmp/pi-review-${Date.now()}.json; | | await fs.writeFile(tempFile, payloadJson); | | | | const { stdout: reviewResult, stderr: reviewError, code: reviewCode } = await pi.exec("gh", [ | | "api", | | "--method", "POST", | |repos/{owner}/{repo}/pulls/${prNumber}/reviews, | | "--input", tempFile, | | ]); | | | | // Clean up temp file | | await fs.unlink(tempFile).catch(() => {}); | | | | if (reviewCode !== 0) { | | // Try to extract error message | | const errorMsg = reviewError || reviewResult || "Unknown error"; | | | | // Common error: line not part of diff | | if (errorMsg.includes("pull_request_review_thread.line") || errorMsg.includes("not part of the diff")) { | | ctx.ui.notify("Some line comments failed (lines not in diff). Posting as general review...", "warning"); | | | | // Retry without line comments | | const fallbackPayload = { | | body: reviewBody + "\n\n---\n\n## Line-specific Findings\n\n" + | | comments.map(c =>${c.path}:${c.line}\n\n${c.body}).join("\n\n---\n\n"), | | event, | | commit_id: commitSha, | | }; | | | | const fallbackFile =/tmp/pi-review-fallback-${Date.now()}.json; | | await fs.writeFile(fallbackFile, JSON.stringify(fallbackPayload)); | | | | const { code: fallbackCode } = await pi.exec("gh", [ | | "api", | | "--method", "POST", | |repos/{owner}/{repo}/pulls/${prNumber}/reviews, | | "--input", fallbackFile, | | ]); | | | | await fs.unlink(fallbackFile).catch(() => {}); | | | | if (fallbackCode !== 0) { | | ctx.ui.notify("Failed to post review. Check gh auth and permissions.", "error"); | | return; | | } | | } else { | | ctx.ui.notify(Failed to post review: ${errorMsg}, "error"); | | return; | | } | | } | | | | const commentCount = reviewPayload.comments?.length || 0; | | ctx.ui.notify( | |β Posted review to PR #${prNumber} (${commentCount} inline comments, ${event.toLowerCase().replace("_", " ")})`, | | "success" | | ); | | }, | | }); | | } |