|
/** |
|
* Code Review Extension |
|
* |
|
* Provides a `/review` command focused on: |
|
* 1. Validating code correctness |
|
* 2. 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 `gh` CLI for PR operations (checkout, post comments) |
|
* - `/post-review` parses 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 += `\n**Suggestion:** ${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: |
|
1. **Validate correctness** — Find bugs, logic errors, and potential runtime issues |
|
2. **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 |
|
|
|
1. **Understand the change** — What is this trying to accomplish? |
|
2. **Trace the data flow** — Follow inputs through transformations to outputs |
|
3. **Consider edge cases** — What happens with empty, null, huge, or malformed inputs? |
|
4. **Check error paths** — Are errors handled correctly and propagated appropriately? |
|
5. **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 |
|
1. [Question about a design decision] |
|
2. [Question about intended behavior] |
|
3. [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": return `changes vs ${target.branch}`; |
|
case "commit": return `commit ${target.sha.slice(0, 7)}${target.title ? `: ${target.title}` : ""}`; |
|
case "pullRequest": return `PR #${target.prNumber}: ${target.title}`; |
|
case "file": return `files: ${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: |
|
1. What was reviewed (scope, files) |
|
2. Key findings: |
|
- Correctness issues found (with priority) |
|
- Design decisions surfaced |
|
3. Overall verdict |
|
4. 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 loader 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" |
|
); |
|
}, |
|
}); |
|
} |