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 ``; } 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 };