ci: add AI review workflow
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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
|
||||
Reference in New Issue
Block a user