scai
Version:
> AI-powered CLI tool for commit messages **and** pull request reviews — using local models.
272 lines (271 loc) • 11.3 kB
JavaScript
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);
}
}