194 lines
7.1 KiB
YAML
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
|