Files
2026-05-26 12:43:29 +00:00

194 lines
7.1 KiB
YAML

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