From 95c2fe79b1ce9e6c5cc5acdcdec0cd98911db9fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hamit=20=C5=9Eim=C5=9Fek?= <117159961+lightningcell@users.noreply.github.com> Date: Fri, 31 Oct 2025 23:39:37 +0300 Subject: [PATCH] Add AI-powered code review GitHub Action Introduces a workflow that uses an OpenAI model to analyze code changes on the development branch. If an issue is detected in the diff, the script automatically creates a GitHub issue with relevant details, labels, and assignees. --- .github/scripts/ai-review.js | 169 +++++++++++++++++++++++++++ .github/workflows/ai-code-review.yml | 41 +++++++ 2 files changed, 210 insertions(+) create mode 100644 .github/scripts/ai-review.js create mode 100644 .github/workflows/ai-code-review.yml diff --git a/.github/scripts/ai-review.js b/.github/scripts/ai-review.js new file mode 100644 index 0000000..9a24975 --- /dev/null +++ b/.github/scripts/ai-review.js @@ -0,0 +1,169 @@ +#!/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); + } +})(); \ No newline at end of file diff --git a/.github/workflows/ai-code-review.yml b/.github/workflows/ai-code-review.yml new file mode 100644 index 0000000..81f1c89 --- /dev/null +++ b/.github/workflows/ai-code-review.yml @@ -0,0 +1,41 @@ +name: AI Code Review + +on: + push: + branches: + - development + +permissions: + contents: read + issues: write + +env: + DEFAULT_ASSIGNEE: "lightningcell" + DEFAULT_LABELS: "ai-review" + MAX_DIFF_CHARS: "20000" + OPENAI_MODEL: "gpt-4o-mini" + +jobs: + ai-review: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Run AI review script + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + DEFAULT_ASSIGNEE: ${{ env.DEFAULT_ASSIGNEE }} + DEFAULT_LABELS: ${{ env.DEFAULT_LABELS }} + MAX_DIFF_CHARS: ${{ env.MAX_DIFF_CHARS }} + OPENAI_MODEL: ${{ env.OPENAI_MODEL }} + run: | + node .github/scripts/ai-review.js \ No newline at end of file