UNPKG

scai

Version:

> **AI-powered CLI for local code analysis, commit message suggestions, and natural-language queries.** 100% local, private, GDPR-friendly, made in Denmark/EU with ❤️.

608 lines (607 loc) 25.7 kB
import readline from 'readline'; import { fetchOpenPullRequests, getGitHubUsername, submitReview } from '../github/github.js'; import { getRepoDetails } from '../github/repo.js'; import { ensureGitHubAuth } from '../github/auth.js'; import { reviewModule } from '../pipeline/modules/reviewModule.js'; import chalk from 'chalk'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { spawnSync } from 'child_process'; import columnify from 'columnify'; import { Spinner } from '../lib/spinner.js'; import { openDiffInVSCode } from '../utils/vscode.js'; // --- Helper functions --- function truncate(str, length) { return str.length > length ? str.slice(0, length - 3) + '...' : str; } // --- Fetch PRs with review requests --- export async function getPullRequestsForReview(token, owner, repo, username, branch = 'main', filterForUser = true) { const spinner = new Spinner('Fetching pull requests and diffs...'); spinner.start(); const filtered = []; const failedPRs = []; try { const prs = await fetchOpenPullRequests(token, owner, repo); for (const pr of prs) { const shouldInclude = !pr.draft && !pr.merged_at && (!filterForUser || pr.requested_reviewers?.some((r) => r.login === username)); if (!shouldInclude) continue; try { const prNumber = pr.number; const prRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, { headers: { Authorization: `token ${token}`, Accept: 'application/vnd.github.v3+json', }, }); if (!prRes.ok) throw new Error(`Failed to fetch full PR #${prNumber}`); const fullPR = await prRes.json(); pr.body = fullPR.body ?? ''; const diffRes = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}.diff`, { headers: { Authorization: `token ${token}`, Accept: 'application/vnd.github.v3.diff', }, }); if (!diffRes.ok) throw new Error(`Failed to fetch diff for PR #${prNumber}`); const diff = await diffRes.text(); filtered.push({ pr, diff }); } catch (err) { console.warn(`⚠️ Skipping PR #${pr.number}: ${err.message}`); failedPRs.push(pr); } } if (filtered.length === 0) { const msg = filterForUser ? `No open pull requests found for review by '${username}'.` : `No open pull requests found.`; spinner.succeed(msg); } else { spinner.succeed(`Fetched ${filtered.length} PR(s) with diffs.`); } if (failedPRs.length > 0) { const failedList = failedPRs.map((pr) => `#${pr.number}`).join(', '); console.warn(`⚠️ Skipped ${failedPRs.length} PR(s): ${failedList}`); } return filtered; } catch (err) { spinner.fail(`Error fetching pull requests: ${err.message}`); return []; } } // --- Prompt user to select PR --- function askUserToPickPR(prs) { return new Promise((resolve) => { if (prs.length === 0) { console.log('⚠️ No pull requests with review requested.'); return resolve(null); } const rows = prs.map((pr, i) => ({ '#': i + 1, ID: `#${pr.number}`, Title: chalk.gray(truncate(pr.title, 50)), Author: chalk.magentaBright(pr.user || '—'), Created: pr.created_at?.split('T')[0] || '', 'Requested Reviewers': pr.requested_reviewers?.length ? pr.requested_reviewers.join(', ') : chalk.gray('—'), 'Actual Reviewers': pr.reviewers?.length ? chalk.cyan(pr.reviewers.join(', ')) : chalk.gray('—'), Reviews: pr.review_status === 'Approved' ? chalk.green('Approved') : pr.review_status === 'Changes Requested' ? chalk.red('Changes Requested') : chalk.gray('—'), })); console.log(chalk.blue('\n📦 Open Pull Requests:')); console.log(columnify(rows, { columnSplitter: ' ', headingTransform: (h) => chalk.cyan(h.toUpperCase()), config: { '#': { maxWidth: 4 }, Title: { maxWidth: 50 }, 'Requested Reviewers': { maxWidth: 30 }, 'Actual Reviewers': { maxWidth: 30 }, Reviews: { maxWidth: 20 }, }, })); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`\n👉 Choose a PR to review [1-${prs.length}]: `, (answer) => { rl.close(); const index = parseInt(answer, 10); if (!isNaN(index) && index >= 1 && index <= prs.length) { resolve(index - 1); } else { console.log('⚠️ Invalid selection.'); resolve(null); } }); }); } // Prompt for review method function askReviewMethod() { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); console.log(chalk.bold("\n🔍 Choose review method:\n")); console.log('1) Review whole PR at once'); console.log('2) Review chunk by chunk\n'); rl.question(`👉 Choose an option [1-2]: `, (answer) => { rl.close(); resolve(answer === '2' ? 'chunk' : 'whole'); }); }); } // Prompt for review approval function askReviewApproval() { return new Promise((resolve) => { console.log('\n---'); console.log('1) ✅ Approve'); console.log('2) ❌ Reject'); console.log('3) ✍️ Edit'); console.log('4) ⌨️ Write your own review'); console.log('5) 🚫 Cancel'); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`\n👉 Choose an option [1-5]: `, (answer) => { rl.close(); if (answer === '1') resolve('approve'); else if (answer === '2') resolve('reject'); else if (answer === '3') resolve('edit'); else if (answer === '4') resolve('custom'); else resolve('cancel'); }); }); } // Prompt for custom review function promptCustomReview() { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('\n📝 Enter your custom review:\n> ', (input) => { rl.close(); resolve(input.trim()); }); }); } // Prompt for editing review export async function promptEditReview(suggestedReview) { const tmpFilePath = path.join(os.tmpdir(), 'scai-review.txt'); fs.writeFileSync(tmpFilePath, `# Edit your review below.\n# Lines starting with '#' will be ignored.\n\n${suggestedReview}`); const editor = process.env.EDITOR || (process.platform === 'win32' ? 'notepad' : 'vi'); spawnSync(editor, [tmpFilePath], { stdio: 'inherit' }); const editedContent = fs.readFileSync(tmpFilePath, 'utf-8'); return editedContent .split('\n') .filter(line => !line.trim().startsWith('#')) .join('\n') .trim() || suggestedReview; } // Split diff into file-based chunks function chunkDiff(diff, review_id) { const rawChunks = diff.split(/^diff --git /m).filter(Boolean); return rawChunks.map(chunk => { const fullChunk = 'diff --git ' + chunk; const filePathMatch = fullChunk.match(/^diff --git a\/(.+?) b\//); const filePath = filePathMatch ? filePathMatch[1] : 'unknown'; const hunks = []; let currentHunk = null; // This counts diff lines for *this file only* (context/+/- lines after first @@) let positionCounter = 0; const lines = fullChunk.split('\n'); lines.forEach(line => { const hunkHeaderMatch = line.match(/^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/); if (hunkHeaderMatch) { if (currentHunk) hunks.push(currentHunk); const oldStart = parseInt(hunkHeaderMatch[1], 10); const newStart = parseInt(hunkHeaderMatch[3], 10); const oldLines = parseInt(hunkHeaderMatch[2] || '0', 10); const newLines = parseInt(hunkHeaderMatch[4] || '0', 10); currentHunk = { oldStart, newStart, oldLines, newLines, lines: [], }; return; // don’t count @@ header line in positionCounter } if (currentHunk) { // Each line after @@ counts towards the diff position positionCounter++; let lineType = 'context'; if (line.startsWith('+')) lineType = 'add'; if (line.startsWith('-')) lineType = 'del'; currentHunk.lines.push({ line, type: lineType, lineNumberOld: lineType === 'del' ? currentHunk.oldStart++ : undefined, lineNumberNew: lineType === 'add' ? currentHunk.newStart++ : undefined, position: positionCounter, // <-- key for GitHub inline API review_id, }); } }); if (currentHunk) hunks.push(currentHunk); return { filePath, content: fullChunk, hunks, review_id, }; }); } // Color lines in diff function colorDiffLine(line) { if (line.startsWith('+')) return chalk.green(line); if (line.startsWith('-')) return chalk.red(line); if (line.startsWith('@@')) return chalk.yellow(line); return line; } function parseAISuggestions(aiOutput) { return aiOutput .split(/\n\d+\.\s/) // Split on "1. ", "2. ", "3. " .map(s => s.trim()) .filter(Boolean) .map(s => s.replace(/^💬\s*/, '')); } async function promptAIReviewSuggestions(aiOutput, chunkContent) { // Strip first line if it's a summary like "Here are 4 suggestions:" const lines = aiOutput.split('\n'); if (lines.length > 3 && /^here (are|is).*:?\s*$/i.test(lines[0])) { aiOutput = lines.slice(1).join('\n').trim(); } let suggestions = parseAISuggestions(aiOutput); let selected = null; while (!selected) { const colorFuncs = [ chalk.cyan, chalk.green, chalk.yellow, chalk.magenta, chalk.blue, chalk.red ]; const rows = suggestions.map((s, i) => ({ No: String(i + 1).padStart(2), Suggestion: colorFuncs[i % colorFuncs.length](s) // cycle through colors })); const rendered = columnify(rows, { columns: ['No', 'Suggestion'], showHeaders: false, columnSplitter: ' ', config: { No: { align: 'right', dataTransform: (val) => chalk.cyan.bold(`${val}.`) }, Suggestion: { maxWidth: 80, dataTransform: (val) => chalk.white(val) } } }); console.log('\n' + chalk.yellow(chalk.bold('--- Review Suggestions ---')) + '\n'); console.log(rendered.replace(/\n/g, '\n\n')); console.log(); console.log(chalk.gray('Select an option above or:')); console.log(chalk.cyan(' r)') + ' Regenerate suggestions'); console.log(chalk.cyan(' c)') + ' Write custom review'); console.log(chalk.cyan(' s)') + ' Skip this chunk'); console.log(chalk.cyan(' q)') + ' Cancel review'); const range = `1-${suggestions.length}`; const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const answer = await new Promise(resolve => rl.question(chalk.bold(`\n👉 Choose [${range},r,c,s,q]: `), resolve)); rl.close(); const trimmed = answer.trim().toLowerCase(); if (['1', '2', '3'].includes(trimmed)) { const idx = parseInt(trimmed, 10) - 1; selected = suggestions[idx]; const rlEdit = readline.createInterface({ input: process.stdin, output: process.stdout }); const editAnswer = await new Promise(resolve => rlEdit.question('✍️ Edit this suggestion before submitting? [y/N]: ', resolve)); rlEdit.close(); if (editAnswer.trim().toLowerCase() === 'y') { selected = await promptEditReview(selected); } } else if (trimmed === 'r') { console.log(chalk.yellow('\nRegenerating suggestions...\n')); const ioInput = { query: 'regenerate_chunk_review', content: chunkContent }; const newSuggestion = await reviewModule.run(ioInput); const newOutput = typeof newSuggestion.data === 'string' ? newSuggestion.data : newSuggestion.content?.toString() || aiOutput; suggestions = parseAISuggestions(newOutput); } else if (trimmed === 'c') { selected = await promptCustomReview(); } else if (trimmed === 's' || trimmed === '' || trimmed === ' ') { return "skip"; } else if (trimmed === 'q') { console.log(chalk.red('\nReview cancelled.\n')); return "cancel"; } else { console.log(chalk.red('\n⚠️ Invalid input. Try again.\n')); } } console.log(chalk.green('\n✅ Selected suggestion:\n'), selected, '\n'); const action = await askReviewApproval(); if (action === 'approve') return selected; if (action === 'reject') return selected; if (action === 'edit') return await promptEditReview(selected); if (action === 'custom') return await promptCustomReview(); if (action === 'cancel') { console.log(chalk.yellow('Review cancelled.\n')); return "cancel"; } return null; } export async function reviewChunk(chunk, chunkIndex, totalChunks) { console.log(chalk.gray('\n' + '━'.repeat(60))); console.log(`📄 ${chalk.bold('File')}: ${chalk.cyan(chunk.filePath)}`); console.log(`🔢 ${chalk.bold('Chunk')}: ${chunkIndex + 1} of ${totalChunks}`); // Build colored diff const lines = chunk.content.split('\n'); const coloredDiff = lines.map(colorDiffLine).join('\n'); // 1️⃣ Run the AI review using ModuleIO const ioInput = { query: 'review_chunk', content: { chunkContent: chunk.content, filepath: chunk.filePath } }; const suggestion = await reviewModule.run(ioInput); const aiOutput = typeof suggestion.data === 'string' ? suggestion.data.trim() : suggestion.content?.toString() || '1. AI review summary not available.'; // 2️⃣ Show the diff console.log(`\n${chalk.bold('--- Diff ---')}\n`); console.log(coloredDiff); // 3️⃣ Prompt user to pick/skip/cancel const selectedReview = await promptAIReviewSuggestions(aiOutput, chunk.content); if (selectedReview === "cancel") { return { choice: "cancel", summary: "" }; } if (selectedReview === "skip") { await waitForSpaceOrQ(); return { choice: "skip", summary: "" }; } return { choice: selectedReview ?? "", summary: selectedReview ?? "" }; } function waitForSpaceOrQ() { return new Promise(resolve => { process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write('\n⏭️ (Press [space] to skip, [q] to quit, or any other key to show menu)\n'); function onKeyPress(chunk) { const key = chunk.toString(); if (key === ' ' || key === 'q' || key === 'Q') { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.removeListener('data', onKeyPress); resolve(); } } process.stdin.on('data', onKeyPress); }); } export async function promptChunkReviewMenu() { return new Promise((resolve) => { console.log('\nReview options for this chunk:'); console.log(' 1) 💬 Approve and post AI review as comment'); console.log(' 2) ✍️ Edit the review before posting'); console.log(' 3) ⌨️ Write a custom comment'); console.log(' 4) ❌ Mark this chunk as needing changes'); console.log(' 5) ⏭️ Approve this chunk without commenting'); console.log(chalk.gray(' (Press [space] to skip and approve chunk, [q] to quit review, or any other key to show menu)\n')); // Fallback to menu input if key was not space/q function askWithReadline() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question('👉 Choose [1–5]: ', (answer) => { rl.close(); switch (answer.trim()) { case '1': return resolve('approve'); case '2': return resolve('edit'); case '3': return resolve('custom'); case '4': return resolve('reject'); case '5': return resolve('skip'); default: console.log('⚠️ Invalid option. Skipping chunk.'); return resolve('skip'); } }); } // Raw key listener for quick actions function onKeyPress(key) { const keyStr = key.toString().toLowerCase(); process.stdin.setRawMode(false); process.stdin.pause(); if (keyStr === ' ') { return resolve('skip'); } else if (keyStr === 'q') { console.log('\n👋 Exiting review.'); process.exit(0); } else { // flush any remaining input process.stdin.removeAllListeners('data'); askWithReadline(); } } // Prepare for keypress process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.once('data', onKeyPress); }); } // Helper to ask if the user wants to open the diff in VSCode async function askOpenInVSCode() { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(chalk.cyan('\n💡 Open this PR diff in VS Code? [Y/n]: '), (answer) => { rl.close(); resolve(answer.trim().toLowerCase() !== 'n'); }); }); } // Main review command export async function reviewPullRequestCmd(branch = "main", showAll = false) { try { const { owner, repo } = await getRepoDetails(); const token = await ensureGitHubAuth(); const username = await getGitHubUsername(token); const prsWithReviewRequested = await getPullRequestsForReview(token, owner, repo, username, branch, !showAll); if (prsWithReviewRequested.length === 0) return; const selectedIndex = await askUserToPickPR(prsWithReviewRequested.map((p) => p.pr)); if (selectedIndex === null) return; const { pr, diff } = prsWithReviewRequested[selectedIndex]; if (pr.body) { console.log(chalk.magentaBright("\n📝 PR Description:\n") + chalk.white(pr.body)); } // ✅ Ask user if they want to view diff in VS Code const openInVSCode = await askOpenInVSCode(); if (openInVSCode) { console.log(chalk.gray(`\n🧭 Opening PR #${pr.number} diff in VS Code...`)); await openDiffInVSCode(`${pr.title || "pull_request"}.diff`, diff); } // === Continue AI Review === const chunks = chunkDiff(diff, pr.number.toString()); const reviewMethod = chunks.length > 1 ? await askReviewMethod() : "chunk"; const reviewComments = []; if (reviewMethod === "whole") { const ioInput = { query: 'review_whole_pr', content: { diff, description: "Whole PR Diff" } }; const result = await reviewModule.run(ioInput); const rawOutput = typeof result.data === 'string' ? result.data : result.content?.toString() || '1. AI review summary not available.'; console.log(chalk.yellowBright("Raw AI output:\n"), rawOutput); let suggestions = rawOutput.split('\n').filter(Boolean); // or your old logic if (suggestions.length > 3 && /here (are|is) \d+ suggestions/i.test(suggestions[0])) { suggestions = suggestions.slice(1); } const finalReviewChoice = await askReviewApproval(); let reviewText = suggestions[0] || ''; if (finalReviewChoice === "approve") { reviewText = "PR approved"; await submitReview(pr.number, reviewText, "APPROVE"); } else if (finalReviewChoice === "reject") { reviewText = "Changes requested"; await submitReview(pr.number, reviewText, "REQUEST_CHANGES"); } else if (finalReviewChoice === "custom") { reviewText = await promptCustomReview(); await submitReview(pr.number, reviewText, "COMMENT"); } else if (finalReviewChoice === "edit") { reviewText = await promptEditReview(reviewText); await submitReview(pr.number, reviewText, "COMMENT"); } } else { console.log(chalk.cyan(`🔍 Total Chunks: ${chunks.length}`)); let allApproved = true; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const { choice, summary } = await reviewChunk(chunk, i, chunks.length); if (choice === "cancel") { console.log(chalk.red(`🚫 Review cancelled at chunk ${i + 1}`)); return; } if (choice === "skip") { console.log(chalk.gray(`⏭️ Skipped chunk ${i + 1}`)); continue; } const firstLineWithPosition = chunk.hunks .flatMap((h) => h.lines) .find((line) => line.position !== undefined); if (!firstLineWithPosition) { console.warn(`⚠️ Could not find valid position for inline comment in chunk ${i + 1}. Skipping comment.`); continue; } let commentBody = summary; if (choice === "custom") { commentBody = await promptCustomReview(); } else if (typeof choice === "string" && !["approve", "reject", "custom"].includes(choice)) { commentBody = choice; } reviewComments.push({ path: chunk.filePath, body: commentBody, position: firstLineWithPosition.position, }); if (choice === "reject") allApproved = false; } console.log(chalk.blueBright("\n📝 Review Comments Preview:")); reviewComments.forEach((comment, idx) => { console.log(`${idx + 1}. ${comment.path}:${comment.position}${comment.body}`); }); const shouldApprove = allApproved; const hasInlineComments = reviewComments.length > 0; let reviewState; let reviewBody; if (shouldApprove) { reviewState = "APPROVE"; reviewBody = hasInlineComments ? "PR approved after inline comments." : "✅ Reviewed."; } else { reviewState = "REQUEST_CHANGES"; reviewBody = "⛔ Requested changes based on review."; } console.log(shouldApprove ? hasInlineComments ? chalk.green("📝 Submitting inline comments with approval.") : chalk.green("✔️ All chunks approved. Submitting final PR approval.") : chalk.red("❌ Not all chunks were approved. Changes requested.")); await submitReview(pr.number, reviewBody, reviewState, reviewComments); } } catch (err) { console.error("❌ Error reviewing PR:", err.message); } }