@deep-assistant/hive-mind
Version:
AI-powered issue solver and hive mind for collaborative problem solving
544 lines (469 loc) โข 21.5 kB
JavaScript
// Early exit paths - handle these before loading all modules to speed up testing
const earlyArgs = process.argv.slice(2);
if (earlyArgs.includes('--version')) {
// Quick version output without loading modules
const { readFileSync } = await import('fs');
const { dirname, join } = await import('path');
const { fileURLToPath } = await import('url');
const { getGitVersion } = await import('./git.lib.mjs');
const { execSync } = await import('child_process');
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packagePath = join(__dirname, '..', 'package.json');
try {
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
const currentVersion = packageJson.version;
const version = await getGitVersion(execSync, currentVersion);
console.log(version);
} catch (versionError) {
// Fallback to hardcoded version if all else fails
console.log('0.10.4');
}
process.exit(0);
}
if (earlyArgs.includes('--help') || earlyArgs.includes('-h')) {
// Show help and exit
console.log('Usage: review.mjs <pr-url> [options]');
console.log('\nOptions:');
console.log(' --version Show version number');
console.log(' --help, -h Show help');
console.log(' --resume, -r Resume from a previous session ID');
console.log(' --dry-run, -n Prepare everything but do not execute Claude');
console.log(' --model, -m Model to use (opus or sonnet) [default: opus]');
console.log(' --focus, -f Focus areas for review [default: all]');
console.log(' --approve If review passes, approve the PR');
console.log(' --verbose, -v Enable verbose logging');
process.exit(0);
}
// Use use-m to dynamically import modules for cross-runtime compatibility
const { use } = eval(await (await fetch('https://unpkg.com/use-m/use.js')).text());
// Use command-stream for consistent $ behavior across runtimes
const { $ } = await use('command-stream');
const yargs = (await use('yargs@latest')).default;
const os = (await use('os')).default;
const path = (await use('path')).default;
const fs = (await use('fs')).promises;
// Import shared functions from lib.mjs to follow DRY principle
import { log, setLogFile, getLogFile } from './lib.mjs';
import { reportError } from './sentry.lib.mjs';
// Import Claude execution functions
import { executeClaude, validateClaudeConnection } from './claude.lib.mjs';
// Configure command line arguments - GitHub PR URL as positional argument
const argv = yargs(process.argv.slice(2))
.usage('Usage: $0 <pr-url> [options]')
.positional('pr-url', {
type: 'string',
description: 'The GitHub pull request URL to review'
})
.option('resume', {
type: 'string',
description: 'Resume from a previous session ID (when limit was reached)',
alias: 'r'
})
.option('dry-run', {
type: 'boolean',
description: 'Prepare everything but do not execute Claude',
alias: 'n'
})
.option('model', {
type: 'string',
description: 'Model to use (opus or sonnet)',
alias: 'm',
default: 'opus',
choices: ['opus', 'sonnet']
})
.option('focus', {
type: 'string',
description: 'Focus areas for review (security, performance, logic, style, tests)',
alias: 'f',
default: 'all'
})
.option('approve', {
type: 'boolean',
description: 'If review passes, approve the PR',
default: false
})
.option('verbose', {
type: 'boolean',
description: 'Enable verbose logging for debugging',
alias: 'v',
default: false
})
.demandCommand(1, 'The GitHub pull request URL is required')
.help('h')
.alias('h', 'help')
.argv;
const prUrl = argv._[0];
// Set global verbose mode for log function
global.verboseMode = argv.verbose;
// Create permanent log file immediately with timestamp
const scriptDir = path.dirname(process.argv[1]);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFilePath = path.join(scriptDir, `review-${timestamp}.log`);
setLogFile(logFilePath);
// Create the log file immediately
await fs.writeFile(logFilePath, `# Review.mjs Log - ${new Date().toISOString()}\n\n`);
await log(`๐ Log file: ${logFilePath}`);
await log(` (All output will be logged here)\n`);
// Validate GitHub PR URL format
if (!prUrl.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/\d+$/)) {
await log('Error: Please provide a valid GitHub pull request URL (e.g., https://github.com/owner/repo/pull/123)', { level: 'error' });
process.exit(1);
}
const claudePath = process.env.CLAUDE_PATH || 'claude';
// Extract repository and PR number from URL
const urlParts = prUrl.split('/');
const owner = urlParts[3];
const repo = urlParts[4];
const prNumber = urlParts[6];
// Create or find temporary directory for cloning the repository
let tempDir;
let isResuming = argv.resume;
if (isResuming) {
// When resuming, try to find existing directory or create a new one
const scriptDir = path.dirname(process.argv[1]);
const sessionLogPattern = path.join(scriptDir, `${argv.resume}.log`);
try {
// Check if session log exists to verify session is valid
await fs.access(sessionLogPattern);
await log(`๐ Resuming session ${argv.resume} (session log found)`);
// For resumed sessions, create new temp directory since old one may be cleaned up
tempDir = path.join(os.tmpdir(), `gh-pr-reviewer-resume-${argv.resume}-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
await log(`Creating new temporary directory for resumed session: ${tempDir}`);
} catch (err) {
reportError(err, {
context: 'resume_session_lookup',
sessionId: argv.resume
});
await log(`Warning: Session log for ${argv.resume} not found, but continuing with resume attempt`);
tempDir = path.join(os.tmpdir(), `gh-pr-reviewer-resume-${argv.resume}-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
await log(`Creating temporary directory for resumed session: ${tempDir}`);
}
} else {
tempDir = path.join(os.tmpdir(), `gh-pr-reviewer-${Date.now()}`);
await fs.mkdir(tempDir, { recursive: true });
await log(`Creating temporary directory: ${tempDir}\n`);
}
try {
// Get PR details first
await log(`๐ Getting pull request details...`);
const prDetailsResult = await $`gh pr view ${prUrl} --json title,body,headRefName,baseRefName,author,number,state,files`;
if (prDetailsResult.code !== 0) {
await log(`Error: Failed to get PR details`, { level: 'error' });
await log(prDetailsResult.stderr ? prDetailsResult.stderr.toString() : 'Unknown error', { level: 'error' });
process.exit(1);
}
const prDetails = JSON.parse(prDetailsResult.stdout.toString());
await log(`\n๐ Pull Request: #${prDetails.number} - ${prDetails.title}`);
await log(`๐ค Author: ${prDetails.author.login}`);
await log(`๐ฟ Branch: ${prDetails.headRefName} โ ${prDetails.baseRefName}`);
await log(`๐ State: ${prDetails.state}`);
await log(`๐ Files changed: ${prDetails.files.length}`);
// Clone the repository using gh tool with authentication
await log(`\nCloning repository ${owner}/${repo} using gh tool...\n`);
const cloneResult = await $`gh repo clone ${owner}/${repo} ${tempDir}`;
// Verify clone was successful
if (cloneResult.code !== 0) {
await log(`Error: Failed to clone repository`, { level: 'error' });
await log(cloneResult.stderr ? cloneResult.stderr.toString() : 'Unknown error', { level: 'error' });
process.exit(1);
}
await log(`โ
Repository cloned successfully to ${tempDir}\n`);
// Set up git authentication using gh
const authSetupResult = await $`cd ${tempDir} && gh auth setup-git 2>&1`;
if (authSetupResult.code !== 0) {
await log('Note: gh auth setup-git had issues, continuing anyway\n');
}
// Fetch and checkout the PR branch
await log(`๐ Fetching and checking out PR branch: ${prDetails.headRefName}`);
const fetchResult = await $`cd ${tempDir} && gh pr checkout ${prNumber}`;
if (fetchResult.code !== 0) {
await log(`Error: Failed to checkout PR branch`, { level: 'error' });
await log(fetchResult.stderr ? fetchResult.stderr.toString() : 'Unknown error', { level: 'error' });
process.exit(1);
}
await log(`โ
Successfully checked out PR branch\n`);
// Get the diff for the PR
await log(`๐ Getting PR diff...`);
const diffResult = await $`gh pr diff ${prUrl}`;
if (diffResult.code !== 0) {
await log(`Error: Failed to get PR diff`, { level: 'error' });
await log(diffResult.stderr ? diffResult.stderr.toString() : 'Unknown error', { level: 'error' });
process.exit(1);
}
const prDiff = diffResult.stdout.toString();
await log(`โ
Got PR diff (${prDiff.length} characters)\n`);
// Save diff to a file for reference
const diffFile = path.join(tempDir, 'pr-diff.patch');
await fs.writeFile(diffFile, prDiff);
await log(`๐ Diff saved to: ${diffFile}\n`);
const prompt = `Pull request to review: ${prUrl}
PR Number: ${prNumber}
Repository: ${owner}/${repo}
Working directory: ${tempDir}
Diff file: ${diffFile}
Focus areas: ${argv.focus}
Auto-approve if passes: ${argv.approve}
Review this pull request thoroughly.`;
const systemPrompt = `You are an expert code reviewer for pull requests.
0. General guidelines.
- When you execute commands, always save their logs to files for easy reading if the output gets large.
- When running commands, do not set a timeout yourself โ let them run as long as needed.
- When a code or log file has more than 2500 lines, read it in chunks of 2500 lines.
- When reviewing, be thorough but constructive.
- When suggesting improvements, provide specific code examples.
1. Initial analysis.
- When you start, read the PR description using gh pr view ${prUrl}.
- When you need the diff, read it from ${diffFile} or use gh pr diff ${prNumber}.
- When you need file context, explore files in ${tempDir}.
- When you check tests, run them if possible using existing test commands.
- When you review commits, use gh pr view ${prNumber} --json commits.
2. Review focus areas.
${argv.focus === 'all' ? `- Review all aspects: logic, security, performance, style, tests, documentation.` : `- Focus specifically on: ${argv.focus}`}
- When reviewing logic, check for edge cases and error handling.
- When reviewing security, look for vulnerabilities and unsafe patterns.
- When reviewing performance, identify bottlenecks and inefficiencies.
- When reviewing style, ensure consistency with project conventions.
- When reviewing tests, verify coverage and test quality.
3. Providing feedback.
- When you find issues, create review comments using gh pr review ${prNumber} --comment.
- When suggesting changes, use gh pr review ${prNumber} --comment with specific line references.
- When code needs changes, provide suggestions with exact code snippets.
- When adding line comments, use the format: path/to/file.ext:LINE_NUMBER
- When creating suggestions, use GitHub's suggestion format in comments:
\`\`\`suggestion
improved code here
\`\`\`
4. Review submission.
- When review is complete, submit it using gh pr review ${prNumber} --${argv.approve ? 'approve' : 'comment'} --body "review summary".
- When requesting changes, use gh pr review ${prNumber} --request-changes --body "summary of required changes".
- When approving, only do so if code meets all quality standards.
- When commenting, be specific about line numbers and files.
5. Line-specific comments.
- When adding comments to specific lines, use gh api to post review comments.
- When referencing lines, use the commit SHA from the PR.
- When suggesting code changes, include the suggestion block format.
- Example for line comment:
gh api repos/${owner}/${repo}/pulls/${prNumber}/comments \\
--method POST \\
--field path="file.js" \\
--field line=42 \\
--field body="Comment text with suggestion" \\
--field commit_id="SHA"
6. Best practices.
- When reviewing, check for breaking changes.
- When examining dependencies, verify versions and security.
- When looking at tests, ensure they actually test the changes.
- When reviewing documentation, verify it matches the code.
- When finding issues, prioritize them by severity.
- When suggesting improvements, explain why they're beneficial.`;
// Properly escape prompts for shell usage - escape quotes and preserve newlines
const escapedPrompt = prompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
const escapedSystemPrompt = systemPrompt.replace(/"/g, '\\"').replace(/\$/g, '\\$');
// Execute claude command from the cloned repository directory
await log(`\n๐ค Executing Claude (${argv.model.toUpperCase()}) for PR review...`);
// Use command-stream's async iteration for real-time streaming with file logging
let commandFailed = false;
let sessionId = null;
let limitReached = false;
let messageCount = 0;
let toolUseCount = 0;
let lastMessage = '';
// Build claude command with optional resume flag
let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${argv.model}`;
if (argv.resume) {
await log(`๐ Resuming from session: ${argv.resume}`);
claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
}
claudeArgs += ` -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
// Print the command being executed (with cd for reproducibility)
const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
await log(`๐ Command details:`);
await log(` ๐ Working directory: ${tempDir}`);
await log(` ๐ PR branch: ${prDetails.headRefName}`);
await log(` ๐ค Model: Claude ${argv.model.toUpperCase()}`);
await log(`\n๐ Full command:`);
await log(` ${fullCommand}`);
await log('');
// If dry-run, exit here
if (argv.dryRun) {
await log(`โ
Command preparation complete`);
await log(`๐ Repository cloned to: ${tempDir}`);
await log(`๐ PR branch checked out: ${prDetails.headRefName}`);
await log(`\n๐ก To execute manually:`);
await log(` (cd "${tempDir}" && ${claudePath} ${claudeArgs})`);
process.exit(0);
}
// Change to the temporary directory and execute
process.chdir(tempDir);
// Build the actual command for execution
let execCommand;
if (argv.resume) {
execCommand = $({ mirror: false })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${argv.model} -p "${escapedPrompt}" --append-system-prompt "${escapedSystemPrompt}"`;
} else {
execCommand = $({ stdin: prompt, mirror: false })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --append-system-prompt "${escapedSystemPrompt}" --model ${argv.model}`;
}
for await (const chunk of execCommand.stream()) {
if (chunk.type === 'stdout') {
const data = chunk.data.toString();
let json;
try {
json = JSON.parse(data);
await log(JSON.stringify(json, null, 2));
} catch (error) {
// JSON parse errors are expected for non-JSON output
// Only report in verbose mode
if (global.verboseMode) {
reportError(error, {
context: 'parse_claude_output',
level: 'debug'
});
}
await log(data);
continue;
}
// Extract session ID on first message
if (!sessionId && json.session_id) {
sessionId = json.session_id;
await log(`๐ง Session ID: ${sessionId}`);
// Try to rename log file to include session ID
try {
const currentLogFile = getLogFile();
const sessionLogFile = path.join(scriptDir, `${sessionId}.log`);
await fs.rename(currentLogFile, sessionLogFile);
setLogFile(sessionLogFile);
await log(`๐ Log renamed to: ${sessionLogFile}`);
} catch (renameError) {
reportError(renameError, {
context: 'rename_log_file',
level: 'warning'
});
// If rename fails, keep original filename
await log(`๐ Keeping log file: ${getLogFile()}`);
}
await log('');
}
// Display user-friendly progress
if (json.type === 'message' && json.message) {
messageCount++;
// Extract text content from message
if (json.message.content && Array.isArray(json.message.content)) {
for (const item of json.message.content) {
if (item.type === 'text' && item.text) {
lastMessage = item.text.substring(0, 100); // First 100 chars
if (item.text.includes('limit reached')) {
limitReached = true;
}
}
}
}
// Show progress indicator (console only, not logged)
process.stdout.write(`\r๐ Messages: ${messageCount} | ๐ง Tool uses: ${toolUseCount} | Last: ${lastMessage}...`);
} else if (json.type === 'tool_use') {
toolUseCount++;
const toolName = json.tool_use?.name || 'unknown';
// Log tool use
await log(`[TOOL USE] ${toolName}`);
// Show progress in console (without logging)
process.stdout.write(`\r๐ง Using tool: ${toolName} (${toolUseCount} total)... `);
} else if (json.type === 'system' && json.subtype === 'init') {
await log('๐ Claude session started');
await log(`๐ Model: Claude ${argv.model.toUpperCase()}`);
await log('\n๐ Processing review...\n');
}
} else if (chunk.type === 'stderr') {
const data = chunk.data.toString();
// Only show actual errors, not verbose output
if (data.includes('Error') || data.includes('error')) {
await log(`\nโ ๏ธ ${data}`, { level: 'error' });
}
// Log stderr
await log(`STDERR: ${data}`);
} else if (chunk.type === 'exit') {
if (chunk.code !== 0) {
commandFailed = true;
await log(`\n\nโ Claude command failed with exit code ${chunk.code}`, { level: 'error' });
}
}
}
// Clear the progress line
process.stdout.write('\r' + ' '.repeat(100) + '\r');
if (commandFailed) {
await log('\nโ Command execution failed. Check the log file for details.');
await log(`๐ Log file: ${logFile}`);
process.exit(1);
}
await log('\n\nโ
Claude review completed');
await log(`๐ Total messages: ${messageCount}, Tool uses: ${toolUseCount}`);
// Show summary of session and log file
await log('\n=== Review Summary ===');
if (sessionId) {
await log(`โ
Session ID: ${sessionId}`);
await log(`โ
Complete log file: ${logFile}`);
if (limitReached) {
await log(`\nโฐ LIMIT REACHED DETECTED!`);
await log(`\n๐ To resume when limit resets, use:\n`);
await log(`./review.mjs "${prUrl}" --resume ${sessionId}`);
await log(`\n This will continue from where it left off with full context.\n`);
} else {
// Check if review was submitted
await log(`\n๐ Checking for submitted review...`);
try {
// Get reviews for the PR
const reviewsResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}/reviews --jq '.[] | select(.user.login == "'$(gh api user --jq .login)'") | {state, submitted_at}'`;
if (reviewsResult.code === 0 && reviewsResult.stdout.toString().trim()) {
await log(`โ
Review has been submitted to PR #${prNumber}`);
await log(`๐ View at: ${prUrl}`);
} else {
await log(`โน๏ธ Review may be pending or saved as draft`);
}
} catch (error) {
reportError(error, {
context: 'verify_review_status',
prNumber,
level: 'warning'
});
await log(`โ ๏ธ Could not verify review status`);
}
// Show command to resume session in interactive mode
await log(`\n๐ก To continue this session in Claude Code interactive mode:\n`);
await log(` (cd ${tempDir} && claude --resume ${sessionId})`);
await log(``);
}
} else {
await log(`โ No session ID extracted`);
await log(`๐ Log file available: ${logFile}`);
}
await log(`\nโจ Review process complete. Check the PR for review comments.`);
await log(`๐ Pull Request: ${prUrl}`);
} catch (error) {
reportError(error, {
context: 'review_execution',
prUrl: argv._[0]
});
await log('Error executing review:', error.message, { level: 'error' });
process.exit(1);
} finally {
// Clean up temporary directory (but not when resuming or when limit reached)
if (!argv.resume && !limitReached) {
try {
process.stdout.write('\n๐งน Cleaning up...');
await fs.rm(tempDir, { recursive: true, force: true });
await log(' โ
');
} catch (cleanupError) {
reportError(cleanupError, {
context: 'cleanup_temp_dir',
level: 'warning',
tempDir
});
await log(' โ ๏ธ (failed)');
}
} else if (argv.resume) {
await log(`\n๐ Keeping directory for resumed session: ${tempDir}`);
} else if (limitReached) {
await log(`\n๐ Keeping directory for future resume: ${tempDir}`);
}
}