UNPKG

scai

Version:

> AI-powered CLI tool for commit messages **and** pull request reviews — using local models.

272 lines (271 loc) 11.3 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'; function truncate(str, length) { return str.length > length ? str.slice(0, length - 3) + '...' : str; } // Fetch open PRs with review requested export async function getPullRequestsForReview(token, owner, repo, username, branch = 'main', filterForUser = true) { const prs = await fetchOpenPullRequests(token, owner, repo); const filtered = []; const failedPRs = []; for (const pr of prs) { const shouldInclude = !pr.draft && !pr.merged_at && (!filterForUser || pr.requested_reviewers?.some(r => r.login === username)); if (shouldInclude) { 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); } } } // After collecting filtered PRs if (filtered.length === 0) { if (filterForUser) { console.log(`ℹ️ No open pull requests found for review by '${username}'.`); } else { console.log(`ℹ️ No open pull requests found.`); } } if (failedPRs.length > 0) { const failedList = failedPRs.map(pr => `#${pr.number}`).join(', '); console.warn(`⚠️ Skipped ${failedPRs.length} PR(s): ${failedList}`); } return filtered; } // 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: truncate(pr.title, 50), Author: pr.user || '—', Status: pr.draft ? 'Draft' : 'Open', Created: pr.created_at?.split('T')[0] || '', Reviewers: pr.requested_reviewers?.map(r => r.login).join(', ') || '—', })); console.log(chalk.blue("\n📦 Open Pull Requests:")); console.log(columnify(rows, { columnSplitter: ' ', headingTransform: (h) => chalk.cyan(h.toUpperCase()), config: { Title: { maxWidth: 50 }, Reviewers: { maxWidth: 30 } } })); 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("\n🔍 Choose review method:"); console.log('1) Review whole PR at once'); console.log('2) Review chunk by chunk'); rl.question(`👉 Choose an option [1-2]: `, (answer) => { rl.close(); resolve(answer === '2' ? 'chunk' : 'whole'); }); }); } // Prompt for review approval function askReviewApproval(suggestion) { 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) { 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'; return { filePath, content: fullChunk, }; }); } // 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; } // Review a single chunk export async function reviewChunk(chunk, chunkIndex, totalChunks) { console.log(chalk.yellow(`\n🔍 Reviewing chunk ${chunkIndex + 1} of ${totalChunks}:`)); console.log(`File: ${chunk.filePath}`); const coloredDiff = chunk.content.split('\n').map(colorDiffLine).join('\n'); console.log(coloredDiff); const suggestion = await reviewModule.run({ content: chunk.content, filepath: chunk.filePath }); console.log("\n💡 AI-suggested review:\n"); console.log(suggestion.content); const reviewChoice = await askReviewApproval(suggestion.content); if (reviewChoice === 'edit') { return await promptEditReview(suggestion.content); } return reviewChoice; } // Main command to review PR export async function reviewPullRequestCmd(branch = 'main', showAll = false) { try { const token = await ensureGitHubAuth(); const username = await getGitHubUsername(token); const { owner, repo } = getRepoDetails(); 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.gray(pr.body)); } const reviewMethod = await askReviewMethod(); if (reviewMethod === 'whole') { const suggestion = await reviewModule.run({ content: diff, filepath: 'Whole PR Diff' }); console.log(suggestion.content); const finalReviewChoice = await askReviewApproval(suggestion.content); let reviewText = ''; 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(suggestion.content); await submitReview(pr.number, reviewText, 'COMMENT'); } } else { const chunks = chunkDiff(diff); console.log(chalk.cyan(`🔍 Total Chunks: ${chunks.length}`)); for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; const reviewResult = await reviewChunk(chunk, i, chunks.length); if (reviewResult === 'approve') { await submitReview(pr.number, 'Approved chunk', 'APPROVE'); } else if (reviewResult === 'reject') { await submitReview(pr.number, 'Changes requested for chunk', 'REQUEST_CHANGES'); } else if (reviewResult === 'custom') { const customReview = await promptCustomReview(); await submitReview(pr.number, customReview, 'COMMENT'); } else { await submitReview(pr.number, reviewResult, 'COMMENT'); } } } } catch (err) { console.error("❌ Error reviewing PR:", err.message); } }