mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
350 lines
16 KiB
JavaScript
350 lines
16 KiB
JavaScript
const { createHash } = require('node:crypto');
|
|
const { execFileSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const SIGNATURE = '*HAPI Bot*';
|
|
const VERSION = 2;
|
|
const SEVERITIES = ['Blocker', 'Major', 'Minor', 'Nit'];
|
|
const DECISIONS = ['pass', 'needs_changes', 'needs_clarification'];
|
|
|
|
function hash(value) {
|
|
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
}
|
|
|
|
function policyDigest(workspace) {
|
|
return hash([
|
|
VERSION,
|
|
fs.readFileSync(path.join(workspace, '.github/prompts/codex-pr-review.md'), 'utf8'),
|
|
fs.readFileSync(__filename, 'utf8'),
|
|
]);
|
|
}
|
|
|
|
function snapshotPull(pull) {
|
|
return {
|
|
number: pull.number,
|
|
title: pull.title,
|
|
body: pull.body ?? '',
|
|
head_sha: pull.head.sha,
|
|
base_sha: pull.base.sha,
|
|
base_ref: pull.base.ref,
|
|
author: pull.user?.login ?? '',
|
|
url: pull.html_url,
|
|
};
|
|
}
|
|
|
|
function inputId(pull, digest) {
|
|
// A moving base tip alone does not invalidate a review of a fixed head.
|
|
// Retargeting the PR does: it changes the intended merge-base comparison.
|
|
return hash([pull.head_sha, pull.base_ref, pull.title, pull.body, digest]);
|
|
}
|
|
|
|
function marker(id) {
|
|
return `<!-- hapi-pr-review:v${VERSION}:${id} -->`;
|
|
}
|
|
|
|
function botReviews(reviews, logins = '') {
|
|
const allowed = (logins || 'github-actions[bot]').split(',').map(login => login.trim()).filter(Boolean);
|
|
return reviews.filter(review =>
|
|
review.state !== 'PENDING'
|
|
&& review.user?.type === 'Bot'
|
|
&& allowed.includes(review.user.login)
|
|
&& (review.body || '').includes(SIGNATURE)
|
|
).sort((left, right) =>
|
|
Date.parse(right.submitted_at || right.created_at || 0)
|
|
- Date.parse(left.submitted_at || left.created_at || 0)
|
|
|| right.id - left.id
|
|
);
|
|
}
|
|
|
|
function hasReview(reviews, snapshot) {
|
|
const footer = `${marker(snapshot.input_id)}\n${SIGNATURE}`;
|
|
return reviews.some(review =>
|
|
review.commit_id === snapshot.pull.head_sha
|
|
&& (review.body || '').trimEnd().endsWith(footer)
|
|
);
|
|
}
|
|
|
|
function skipReason(pull) {
|
|
if (pull.state !== 'open') return 'PR is no longer open';
|
|
if (pull.draft) return 'PR is a draft';
|
|
if (pull.labels?.some(label => label.name === 'bot-skip')) return 'PR has bot-skip label';
|
|
return null;
|
|
}
|
|
|
|
function requestContext(context) {
|
|
const pullNumber = context.payload.pull_request.number;
|
|
if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) throw new Error('Invalid PR number');
|
|
return { ...context.repo, pull_number: pullNumber };
|
|
}
|
|
|
|
function skip(core, reason) {
|
|
core.info(`Skipping PR review: ${reason}.`);
|
|
core.setOutput('skip_reason', reason);
|
|
return { skipped: true, reason };
|
|
}
|
|
|
|
async function listBotReviews(github, request, env) {
|
|
return botReviews(await github.paginate(github.rest.pulls.listReviews, {
|
|
...request,
|
|
per_page: 100,
|
|
}), env.HAPI_BOT_LOGINS);
|
|
}
|
|
|
|
async function prepareReview({ github, context, core, env = process.env }) {
|
|
core.setOutput('should_review', 'false');
|
|
const request = requestContext(context);
|
|
const { data: pull } = await github.rest.pulls.get(request);
|
|
const reason = skipReason(pull);
|
|
if (reason) return skip(core, reason);
|
|
|
|
const digest = policyDigest(env.GITHUB_WORKSPACE);
|
|
const snapshot = {
|
|
version: VERSION,
|
|
repository: `${request.owner}/${request.repo}`,
|
|
pull: snapshotPull(pull),
|
|
policy_digest: digest,
|
|
};
|
|
for (const sha of [snapshot.pull.head_sha, snapshot.pull.base_sha]) {
|
|
if (!/^[a-f0-9]{40}$/i.test(sha)) throw new Error('Invalid review commit SHA');
|
|
}
|
|
snapshot.input_id = inputId(snapshot.pull, digest);
|
|
const reviews = await listBotReviews(github, request, env);
|
|
if (hasReview(reviews, snapshot)) return skip(core, 'these inputs have already been reviewed');
|
|
|
|
const latest = reviews[0];
|
|
snapshot.mode = !latest ? 'initial'
|
|
: latest.commit_id === snapshot.pull.head_sha ? 'follow_up_context' : 'follow_up_commits';
|
|
// Legacy reviews remain useful evidence, but cannot satisfy the new input marker.
|
|
snapshot.previous_review = latest ? {
|
|
id: latest.id,
|
|
commit_id: latest.commit_id,
|
|
body: latest.body,
|
|
} : null;
|
|
const contextPath = path.join(env.RUNNER_TEMP, 'hapi-pr-review-context.json');
|
|
fs.writeFileSync(contextPath, JSON.stringify(snapshot, null, 2));
|
|
core.setOutput('context_path', contextPath);
|
|
core.setOutput('current_head_sha', snapshot.pull.head_sha);
|
|
core.setOutput('base_sha', snapshot.pull.base_sha);
|
|
core.setOutput('should_review', 'true');
|
|
return snapshot;
|
|
}
|
|
|
|
function requireObject(value, label) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(`${label} must be an object`);
|
|
}
|
|
}
|
|
|
|
function requireText(value, label) {
|
|
if (typeof value !== 'string' || !value.trim()) throw new Error(`${label} must be non-empty text`);
|
|
}
|
|
|
|
function requireTexts(value, label) {
|
|
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
|
|
value.forEach((item, index) => requireText(item, `${label}[${index}]`));
|
|
}
|
|
|
|
function validateAssessment(value, label, allowSkipped) {
|
|
requireObject(value, label);
|
|
const statuses = allowSkipped ? [...DECISIONS, 'not_reviewed'] : DECISIONS;
|
|
if (!statuses.includes(value.status)) throw new Error(`${label}.status is invalid`);
|
|
requireText(value.summary, `${label}.summary`);
|
|
requireTexts(value.evidence, `${label}.evidence`);
|
|
requireTexts(value.suggestions, `${label}.suggestions`);
|
|
if (value.status === 'not_reviewed') {
|
|
if (value.evidence.length || value.suggestions.length) {
|
|
throw new Error(`${label}: a skipped stage cannot contain evidence or suggestions`);
|
|
}
|
|
} else if (value.status !== 'needs_clarification' && value.evidence.length === 0) {
|
|
throw new Error(`${label}: a decision requires evidence`);
|
|
}
|
|
if (value.status === 'needs_changes' && value.suggestions.length === 0) {
|
|
throw new Error(`${label}: needs_changes requires an actionable suggestion`);
|
|
}
|
|
}
|
|
|
|
function validateResult(raw) {
|
|
const result = JSON.parse(raw);
|
|
requireObject(result, 'result');
|
|
if (!['en', 'zh'].includes(result.language)) throw new Error('language must be en or zh');
|
|
validateAssessment(result.requirement, 'requirement', false);
|
|
validateAssessment(result.approach, 'approach', true);
|
|
requireObject(result.code, 'code');
|
|
if (!['reviewed', 'not_reviewed'].includes(result.code.status)) throw new Error('code.status is invalid');
|
|
requireText(result.code.summary, 'code.summary');
|
|
if (!Array.isArray(result.code.findings)) throw new Error('code.findings must be an array');
|
|
requireTexts(result.questions, 'questions');
|
|
requireTexts(result.testing, 'testing');
|
|
if (result.questions.length > 4) throw new Error('At most four questions are allowed');
|
|
|
|
const requirementPassed = result.requirement.status === 'pass';
|
|
if (requirementPassed === (result.approach.status === 'not_reviewed')) {
|
|
throw new Error('approach must be reviewed if and only if requirement passes');
|
|
}
|
|
const approachPassed = requirementPassed && result.approach.status === 'pass';
|
|
if (approachPassed !== (result.code.status === 'reviewed')) {
|
|
throw new Error('code must be reviewed if and only if both preceding stages pass');
|
|
}
|
|
if (!approachPassed && result.code.findings.length) {
|
|
throw new Error('A skipped code review cannot contain findings');
|
|
}
|
|
if ([result.requirement, result.approach].some(stage => stage.status === 'needs_clarification')
|
|
&& result.questions.length === 0) {
|
|
throw new Error('needs_clarification requires a concrete question');
|
|
}
|
|
|
|
for (const [index, finding] of result.code.findings.entries()) {
|
|
const label = `code.findings[${index}]`;
|
|
requireObject(finding, label);
|
|
if (!SEVERITIES.includes(finding.severity)) throw new Error(`${label}.severity is invalid`);
|
|
for (const key of ['title', 'body', 'path', 'suggestion']) requireText(finding[key], `${label}.${key}`);
|
|
if (!Number.isSafeInteger(finding.line) || finding.line < 1) throw new Error(`${label}.line is invalid`);
|
|
if (!['LEFT', 'RIGHT'].includes(finding.side)) throw new Error(`${label}.side is invalid`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// With zero context, every line in each hunk range is changed. Keep the new
|
|
// filename for renamed files on both sides, matching GitHub review comments.
|
|
function changedLines(diff) {
|
|
const files = new Map();
|
|
let oldPath;
|
|
let newPath;
|
|
let inHunks = false;
|
|
const decodePath = value => {
|
|
// Git appends a tab to ambiguous unquoted headers (e.g. spaces).
|
|
// Literal tabs in filenames are C-quoted instead.
|
|
const pathname = value.split('\t', 1)[0];
|
|
const decoded = pathname.startsWith('"') ? JSON.parse(pathname) : pathname;
|
|
return decoded === '/dev/null' ? null : decoded.replace(/^[ab]\//, '');
|
|
};
|
|
for (const line of diff.split('\n')) {
|
|
if (line.startsWith('diff --git ')) {
|
|
oldPath = newPath = null;
|
|
inHunks = false;
|
|
} else if (!inHunks && line.startsWith('--- ')) {
|
|
oldPath = decodePath(line.slice(4));
|
|
} else if (!inHunks && line.startsWith('+++ ')) {
|
|
newPath = decodePath(line.slice(4));
|
|
} else {
|
|
const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
if (!hunk) continue;
|
|
inHunks = true;
|
|
const filename = newPath ?? oldPath;
|
|
if (!filename) throw new Error('Diff hunk has no filename');
|
|
const ranges = files.get(filename) ?? { LEFT: [], RIGHT: [] };
|
|
for (const [side, start, count] of [
|
|
['LEFT', Number(hunk[1]), Number(hunk[2] ?? 1)],
|
|
['RIGHT', Number(hunk[3]), Number(hunk[4] ?? 1)],
|
|
]) {
|
|
if (count) ranges[side].push([start, start + count - 1]);
|
|
}
|
|
files.set(filename, ranges);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function readDiff(snapshot, workspace) {
|
|
const options = { cwd: workspace, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 };
|
|
const base = execFileSync('git', ['merge-base', snapshot.pull.base_sha, snapshot.pull.head_sha], options).trim();
|
|
return execFileSync('git', [
|
|
'-c', 'core.quotePath=false', 'diff', '--no-ext-diff', '--no-textconv', '--no-color',
|
|
'--unified=0', '--inter-hunk-context=0', '--find-renames', base, snapshot.pull.head_sha, '--',
|
|
], options);
|
|
}
|
|
|
|
const LABELS = {
|
|
en: {
|
|
mode: 'Review mode', initial: 'initial', follow_up_context: 'follow-up after context changes',
|
|
follow_up_commits: 'follow-up after new commits',
|
|
requirement: 'Requirement', approach: 'Approach', code: 'Code', testing: 'Testing', questions: 'Questions',
|
|
pass: 'Pass', needs_changes: 'Needs changes', needs_clarification: 'Needs clarification',
|
|
not_reviewed: 'Not reviewed', reviewed: 'Reviewed', evidence: 'Evidence', suggestions: 'Suggestions',
|
|
fix: 'Suggested fix', noFindings: 'No reportable code issues found.',
|
|
notRun: 'Not run (automation; PR code execution is prohibited).',
|
|
},
|
|
zh: {
|
|
mode: '评审模式', initial: '首次评审', follow_up_context: '说明更新后的复评',
|
|
follow_up_commits: '新提交后的复评',
|
|
requirement: '需求合理性', approach: '实现方向', code: '代码实现', testing: '验证情况', questions: '待澄清问题',
|
|
pass: '通过', needs_changes: '需要调整', needs_clarification: '需要澄清',
|
|
not_reviewed: '未评审', reviewed: '已评审', evidence: '依据', suggestions: '建议',
|
|
fix: '修复建议', noFindings: '未发现可报告的代码缺陷。',
|
|
notRun: '未运行测试(自动评审禁止执行 PR 代码)。',
|
|
},
|
|
};
|
|
|
|
function renderReview(result, snapshot) {
|
|
const labels = LABELS[result.language];
|
|
const parts = [`${labels.mode}: ${labels[snapshot.mode]}`];
|
|
for (const key of ['requirement', 'approach']) {
|
|
const stage = result[key];
|
|
parts.push(`**${labels[key]} — ${labels[stage.status]}**`, stage.summary);
|
|
if (stage.evidence.length) parts.push(`**${labels.evidence}**\n\n${stage.evidence.map(item => `- ${item}`).join('\n')}`);
|
|
if (stage.suggestions.length) parts.push(`**${labels.suggestions}**\n\n${stage.suggestions.map(item => `- ${item}`).join('\n')}`);
|
|
}
|
|
parts.push(`**${labels.code} — ${labels[result.code.status]}**`, result.code.summary);
|
|
const findings = [...result.code.findings].sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
const comments = findings.map(finding => ({
|
|
path: finding.path,
|
|
line: finding.line,
|
|
side: finding.side,
|
|
body: `**[${finding.severity}] ${finding.title}**\n\n${finding.body}\n\n**${labels.fix}**\n\n${finding.suggestion}`,
|
|
}));
|
|
if (findings.length) {
|
|
parts.push(findings.map(finding => `- [${finding.severity}] ${finding.title} — \`${finding.path}:${finding.line}\``).join('\n'));
|
|
} else if (result.code.status === 'reviewed') {
|
|
parts.push(labels.noFindings);
|
|
}
|
|
if (result.questions.length) parts.push(`**${labels.questions}**\n\n${result.questions.map(item => `- ${item}`).join('\n')}`);
|
|
parts.push(`**${labels.testing}**`, labels.notRun);
|
|
if (result.testing.length) parts.push(result.testing.map(item => `- ${item}`).join('\n'));
|
|
parts.push(`${marker(snapshot.input_id)}\n${SIGNATURE}`);
|
|
return { event: 'COMMENT', commit_id: snapshot.pull.head_sha, body: parts.join('\n\n'), comments };
|
|
}
|
|
|
|
async function publishReview({ github, context, core, env = process.env, getDiff = readDiff }) {
|
|
core.setOutput('published', 'false');
|
|
const request = requestContext(context);
|
|
const snapshot = JSON.parse(fs.readFileSync(env.REVIEW_CONTEXT_PATH, 'utf8'));
|
|
const digest = policyDigest(env.GITHUB_WORKSPACE);
|
|
if (snapshot.version !== VERSION || snapshot.repository !== `${request.owner}/${request.repo}`
|
|
|| snapshot.pull?.number !== request.pull_number || snapshot.policy_digest !== digest
|
|
|| inputId(snapshot.pull, digest) !== snapshot.input_id
|
|
|| !['initial', 'follow_up_context', 'follow_up_commits'].includes(snapshot.mode)) {
|
|
throw new Error('Invalid or modified review context');
|
|
}
|
|
const staleReason = pull => skipReason(pull)
|
|
?? (inputId(snapshotPull(pull), digest) !== snapshot.input_id ? 'PR inputs changed during review' : null);
|
|
const { data: live } = await github.rest.pulls.get(request);
|
|
const reason = staleReason(live);
|
|
if (reason) return skip(core, reason);
|
|
if (hasReview(await listBotReviews(github, request, env), snapshot)) {
|
|
return skip(core, 'these inputs have already been reviewed');
|
|
}
|
|
|
|
const result = validateResult(env.REVIEW_RESULT);
|
|
if (result.code.findings.length) {
|
|
const lines = changedLines(getDiff(snapshot, env.GITHUB_WORKSPACE));
|
|
for (const finding of result.code.findings) {
|
|
if (!lines.get(finding.path)?.[finding.side].some(([start, end]) => finding.line >= start && finding.line <= end)) {
|
|
throw new Error(`Finding is not on a changed line: ${finding.path}:${finding.line} (${finding.side})`);
|
|
}
|
|
}
|
|
}
|
|
const payload = renderReview(result, snapshot);
|
|
// Keep the final freshness check adjacent to the single atomic write.
|
|
const { data: latest } = await github.rest.pulls.get(request);
|
|
const latestReason = staleReason(latest);
|
|
if (latestReason) return skip(core, latestReason);
|
|
const { data: review } = await github.rest.pulls.createReview({ ...request, ...payload });
|
|
core.setOutput('published', 'true');
|
|
core.setOutput('review_url', review.html_url);
|
|
await core.summary.addRaw(payload.body).write();
|
|
return review;
|
|
}
|
|
|
|
module.exports = { inputId, marker, botReviews, hasReview, prepareReview, validateResult, changedLines, readDiff, renderReview, publishReview };
|