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
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';
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);
}
}