name: AI Review on: pull_request: types: [opened, synchronize, reopened] permissions: contents: read pull-requests: read issues: write jobs: ai-review: runs-on: linux timeout-minutes: 10 steps: - name: Review pull request diff env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY_VAR: ${{ vars.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} OPENAI_BASE_URL_VAR: ${{ vars.OPENAI_BASE_URL }} OPENAI_MODEL: ${{ secrets.OPENAI_MODEL }} OPENAI_MODEL_VAR: ${{ vars.OPENAI_MODEL }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_API_URL: ${{ github.api_url }} GITEA_REPOSITORY: ${{ github.repository }} GITEA_SHA: ${{ github.sha }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} shell: bash run: | set -euo pipefail OPENAI_API_KEY="${OPENAI_API_KEY:-${OPENAI_API_KEY_VAR:-}}" OPENAI_BASE_URL="${OPENAI_BASE_URL:-${OPENAI_BASE_URL_VAR:-https://api.openai.com/v1}}" OPENAI_MODEL="${OPENAI_MODEL:-${OPENAI_MODEL_VAR:-gpt-5}}" OPENAI_BASE_URL="${OPENAI_BASE_URL%/}" export OPENAI_API_KEY OPENAI_BASE_URL OPENAI_MODEL if [ -z "${OPENAI_API_KEY:-}" ]; then echo "OPENAI_API_KEY is not configured. Add it in repository Settings -> Actions -> Secrets." exit 1 fi if [ -z "${GITEA_TOKEN:-}" ]; then echo "GITEA_TOKEN is not available. Check Actions token permissions for this repository." exit 1 fi if [ -z "${PR_NUMBER:-}" ] || [ "${PR_NUMBER}" = "null" ]; then echo "This workflow only reviews pull requests." exit 0 fi curl --fail-with-body -L \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Accept: text/plain" \ "${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/pulls/${PR_NUMBER}.diff" \ -o pr.diff diff_bytes="$(wc -c < pr.diff | tr -d ' ')" if [ "${diff_bytes}" -eq 0 ]; then printf 'No diff content found for PR #%s.\n' "${PR_NUMBER}" > review.md should_fail=false else node <<'NODE' const fs = require('fs'); const diff = fs.readFileSync('pr.diff', 'utf8'); const title = process.env.PR_TITLE || ''; const maxDiffChars = 120000; const truncated = diff.length > maxDiffChars; const inputDiff = truncated ? diff.slice(0, maxDiffChars) : diff; const payload = { model: process.env.OPENAI_MODEL || 'gpt-5', input: [ { role: 'system', content: [ { type: 'input_text', text: [ 'You are a senior code reviewer for a Gitea pull request.', 'Focus on correctness, security, data loss, concurrency, backward compatibility, and missing tests.', 'Ignore formatting-only comments unless they hide a real bug.', 'Return strict JSON only. Do not use markdown fences.', 'Schema: {"summary": string, "findings": [{"severity": "critical"|"high"|"medium"|"low", "file": string, "line": number|null, "title": string, "detail": string}], "should_fail": boolean}.', 'Set should_fail true only for critical or high confidence high severity issues.' ].join(' ') } ] }, { role: 'user', content: [ { type: 'input_text', text: `PR title: ${title}\nDiff was truncated: ${truncated}\n\n${inputDiff}` } ] } ] }; fs.writeFileSync('openai-request.json', JSON.stringify(payload)); NODE curl --fail-with-body "${OPENAI_BASE_URL}/responses" \ -H "Authorization: Bearer ${OPENAI_API_KEY}" \ -H "Content-Type: application/json" \ -d @openai-request.json \ -o openai-response.json node <<'NODE' const fs = require('fs'); const response = JSON.parse(fs.readFileSync('openai-response.json', 'utf8')); function collectText(node, out = []) { if (!node) return out; if (typeof node === 'string') { out.push(node); } else if (Array.isArray(node)) { for (const item of node) collectText(item, out); } else if (typeof node === 'object') { if (typeof node.output_text === 'string') out.push(node.output_text); if (typeof node.text === 'string') out.push(node.text); if (node.content) collectText(node.content, out); if (node.output) collectText(node.output, out); } return out; } const raw = collectText(response).join('\n').trim(); let review; try { review = JSON.parse(raw); } catch { review = { summary: raw || 'AI review returned no readable output.', findings: [], should_fail: false }; } const findings = Array.isArray(review.findings) ? review.findings : []; const lines = []; lines.push('## AI Review'); lines.push(''); lines.push(review.summary || 'Review completed.'); lines.push(''); if (findings.length === 0) { lines.push('No high-confidence issues found.'); } else { lines.push('Findings:'); for (const finding of findings.slice(0, 20)) { const severity = finding.severity || 'medium'; const file = finding.file || 'unknown file'; const line = finding.line ? `:${finding.line}` : ''; const title = finding.title || 'Potential issue'; const detail = finding.detail || ''; lines.push(`- [${severity}] ${file}${line} - ${title}`); if (detail) lines.push(` ${detail}`); } } fs.writeFileSync('review.md', lines.join('\n') + '\n'); fs.writeFileSync('should-fail.txt', review.should_fail ? 'true' : 'false'); NODE should_fail="$(cat should-fail.txt)" fi node <<'NODE' const fs = require('fs'); fs.writeFileSync('comment.json', JSON.stringify({ body: fs.readFileSync('review.md', 'utf8') })); NODE curl --fail-with-body -X POST \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d @comment.json \ "${GITEA_API_URL}/repos/${GITEA_REPOSITORY}/issues/${PR_NUMBER}/comments" if [ "${should_fail}" = "true" ]; then echo "AI review found high severity issues." exit 1 fi