#!/usr/bin/env node const { execSync } = require('child_process'); const https = require('https'); function run(cmd) { try { return execSync(cmd, { encoding: 'utf8' }); } catch (e) { return ''; } } const GITHUB_SHA = process.env.GITHUB_SHA || run('git rev-parse HEAD').trim(); const REPO = process.env.GITHUB_REPOSITORY || (run('git remote get-url origin').trim().replace(/.*github.com[:\/]/, '').replace(/\.git$/, '')); const GITHUB_TOKEN = process.env.GITHUB_TOKEN; const OPENAI_API_KEY = process.env.OPENAI_API_KEY; const MAX_DIFF_CHARS = parseInt(process.env.MAX_DIFF_CHARS || '20000', 10); const DEFAULT_ASSIGNEE = process.env.DEFAULT_ASSIGNEE || ''; const DEFAULT_LABELS = (process.env.DEFAULT_LABELS || 'ai-review').split(',').map(s => s.trim()).filter(Boolean); const OPENAI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini'; if (!GITHUB_TOKEN) { console.error('GITHUB_TOKEN not provided'); process.exit(1); } if (!OPENAI_API_KEY) { console.error('OPENAI_API_KEY not provided'); process.exit(1); } // Compute diff vs previous commit (if exists) let diff = ''; try { execSync(`git rev-parse ${GITHUB_SHA}^`, { stdio: 'ignore' }); diff = run(`git diff --no-prefix --unified=0 ${GITHUB_SHA}^ ${GITHUB_SHA}`); } catch (e) { diff = run(`git show ${GITHUB_SHA}`); } if (!diff) { console.log('No diff to analyze.'); process.exit(0); } if (diff.length > MAX_DIFF_CHARS) diff = diff.slice(0, MAX_DIFF_CHARS) + '\n...[truncated]'; // Build prompt requesting ONLY JSON output const prompt = ` You are an automated code reviewer. Analyze the following git diff and determine whether there is a problem that requires opening a GitHub issue. Return ONLY a single JSON object (no extra text, no surrounding backticks) with these fields: - problem_found: true or false - title: short issue title (string) — present if problem_found is true - body: detailed issue body (string) — present if problem_found is true - labels: array of label strings (can be empty) - assignees: array of usernames to assign (can be empty) Diff: ${diff} `; // Call OpenAI Chat Completions API (v1/chat/completions) function openaiChat(prompt, model) { return new Promise((resolve, reject) => { const payload = JSON.stringify({ model, messages: [ { role: 'system', content: 'You are a precise code-review assistant. Answer only with JSON as requested.' }, { role: 'user', content: prompt } ], max_tokens: 800, temperature: 0.0 }); const req = https.request({ hostname: 'api.openai.com', path: '/v1/chat/completions', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'Authorization': `Bearer ${OPENAI_API_KEY}` } }, res => { let data = ''; res.on('data', d => data += d); res.on('end', () => { try { const parsed = JSON.parse(data); const content = (parsed.choices && parsed.choices[0] && (parsed.choices[0].message?.content || parsed.choices[0].text)) || ''; resolve(content); } catch (err) { reject(new Error('OpenAI response parse error: ' + err.message + '\nRaw:' + data)); } }); }); req.on('error', reject); req.write(payload); req.end(); }); } function createIssue(title, body, labels = [], assignees = []) { return new Promise((resolve, reject) => { const post = JSON.stringify({ title, body, labels, assignees }); const [owner, repo] = REPO.split('/'); const options = { hostname: 'api.github.com', path: `/repos/${owner}/${repo}/issues`, method: 'POST', headers: { 'User-Agent': 'ai-code-review-action', 'Accept': 'application/vnd.github+json', 'Authorization': `Bearer ${GITHUB_TOKEN}`, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(post) } }; const req = https.request(options, res => { let data = ''; res.on('data', d => data += d); res.on('end', () => { try { const parsed = JSON.parse(data); resolve(parsed); } catch (err) { reject(new Error('GitHub response parse error: ' + err.message + '\nRaw:' + data)); } }); }); req.on('error', reject); req.write(post); req.end(); }); } (async () => { try { console.log('Calling OpenAI to analyze diff (truncated to %d chars)', MAX_DIFF_CHARS); const aiReply = await openaiChat(prompt, OPENAI_MODEL); console.log('AI raw reply snippet:', aiReply.slice(0, 1000)); // Try to extract JSON object from AI reply const jsonMatch = aiReply.match(/\{[\s\S]*\}$/); const jsonText = jsonMatch ? jsonMatch[0] : aiReply; let aiObj = null; try { aiObj = JSON.parse(jsonText); } catch (err) { console.error('Could not parse AI response as JSON:', err.message); console.error('Full AI reply:', aiReply); process.exit(0); // assume no problem to avoid false positives } if (!aiObj.problem_found) { console.log('AI determined no problem.'); process.exit(0); } const title = aiObj.title || 'Automated code review found an issue'; const body = aiObj.body || 'AI reported an issue. Please review the diff and details.'; const labels = (Array.isArray(aiObj.labels) && aiObj.labels.length) ? aiObj.labels : DEFAULT_LABELS; const assignees = (Array.isArray(aiObj.assignees) && aiObj.assignees.length) ? aiObj.assignees : (DEFAULT_ASSIGNEE ? [DEFAULT_ASSIGNEE] : []); console.log('Creating issue:', title); const created = await createIssue(title, body, labels, assignees); console.log('Issue created:', created.html_url || created.url); } catch (err) { console.error('Error in AI review script:', err); process.exit(1); } })();