CI: AI review workflow improvements (#14659)

Summary:
- Upgrade the Claude review workflow models and add complexity-based thinking budget selection for the main review run.
- Keep AI review comments tied to the reviewed commit and restructure long reviews so high-severity findings stay visible while details live in a collapsible section.
- Add early auto-trigger gating plus Codex review/comment workflows, including shared comment-building and parsing helpers.

## Testing

- Not run. The branch refresh was a no-op rebase onto current `upstream/main`, and no new code changes were made during this task.

Pull Request resolved: https://github.com/facebook/rocksdb/pull/14659

Reviewed By: pdillinger

Differential Revision: D102224743

Pulled By: xingbowang

fbshipit-source-id: 39c25494062cbcd52d3e85f8a859b5a3907c758e
This commit is contained in:
xingbowang
2026-04-30 09:37:58 -07:00
committed by meta-codesync[bot]
parent 1fd2c202c7
commit cc8d9ea04d
14 changed files with 2614 additions and 753 deletions
@@ -0,0 +1,27 @@
// Shared markdown builder for AI review comment bodies.
//
// Usage:
// const build = require('./build-ai-review-comment.js');
// return build({ icon, headerTitle, triggerLine, responseBody, footerLines
// });
module.exports = function buildAiReviewComment(
{icon, headerTitle, triggerLine, responseBody, footerLines}) {
return [
`## ${icon} ${headerTitle}`,
'',
triggerLine,
'',
'---',
'',
responseBody,
'',
'---',
'',
'<details>',
'<summary>️ About this response</summary>',
'',
...footerLines,
'</details>',
].join('\n');
};
+22 -19
View File
@@ -7,11 +7,25 @@
// Parameters:
// executionFile - path to the JSON execution log from claude-code-base-action
// conclusion - 'success' or 'failure' from the action output
// meta - { trigger, headSha, reviewer, isQuery, isPartial }
// meta - { trigger, autoMode, headSha, reviewer, isQuery, isPartial
// }
const fs = require('fs');
const buildComment = require('./build-ai-review-comment.js');
module.exports = function parseClaude({executionFile, conclusion, meta}) {
function getTriggerLine() {
if (meta.trigger !== 'auto') {
return `*Requested by @${meta.reviewer}*`;
}
const shortSha = meta.headSha.substring(0, 7);
if (meta.autoMode === 'early') {
return `*Auto-triggered after CI reached the early-review threshold — reviewing commit ${
shortSha}*`;
}
return `*Auto-triggered after CI passed — reviewing commit ${shortSha}*`;
}
let responseBody = '';
try {
@@ -80,25 +94,14 @@ module.exports = function parseClaude({executionFile, conclusion, meta}) {
const isPartial = !!meta.isPartial;
const icon = isPartial ? '🟡' : (conclusion === 'success' ? '✅' : '⚠️');
const headerTitle = meta.isQuery ? 'Claude Response' : 'Claude Code Review';
const triggerLine = meta.trigger === 'auto' ?
`*Auto-triggered after CI passed — reviewing commit ${
meta.headSha.substring(0, 7)}*` :
`*Requested by @${meta.reviewer}*`;
const triggerLine = getTriggerLine();
return [
`## ${icon} ${headerTitle}`,
'',
return buildComment({
icon,
headerTitle,
triggerLine,
'',
'---',
'',
responseBody,
'',
'---',
'',
'<details>',
'<summary>️ About this response</summary>',
'',
footerLines: [
'Generated by [Claude Code](https://github.com/anthropics/claude-code-action).',
'Review methodology: `claude_md/code_review.md`',
'',
@@ -110,6 +113,6 @@ module.exports = function parseClaude({executionFile, conclusion, meta}) {
'**Commands:**',
'- `/claude-review [context]` — Request a code review',
'- `/claude-query <question>` — Ask about the PR or codebase',
'</details>',
].join('\n');
],
});
};
+98
View File
@@ -0,0 +1,98 @@
// Parse Codex review artifacts and produce a markdown review comment.
//
// Usage from actions/github-script:
// const parse = require('./.github/scripts/parse-codex-review.js');
// const markdown = parse({ responseFile, recoveryFile, findingsFile, logFile,
// exitCode, meta });
//
// Parameters:
// responseFile - path to Codex final response output
// recoveryFile - path to recovery output formatted from review-findings.md
// findingsFile - path to incremental findings file written during review
// logFile - path to Codex stdout/stderr log
// exitCode - Codex process exit code
// meta - { trigger, autoMode, headSha, reviewer, isQuery, isPartial }
const fs = require('fs');
const buildComment = require('./build-ai-review-comment.js');
module.exports = function parseCodex(
{responseFile, recoveryFile, findingsFile, logFile, exitCode, meta}) {
function getTriggerLine() {
if (meta.trigger !== 'auto') {
return `*Requested by @${meta.reviewer}*`;
}
const shortSha = meta.headSha.substring(0, 7);
if (meta.autoMode === 'early') {
return `*Auto-triggered after CI reached the early-review threshold — reviewing commit ${
shortSha}*`;
}
return `*Auto-triggered after CI passed — reviewing commit ${shortSha}*`;
}
function readIfPresent(path) {
if (!path || !fs.existsSync(path)) {
return '';
}
const text = fs.readFileSync(path, 'utf8').trim();
return text;
}
function tailFile(path, maxChars = 12000) {
const text = readIfPresent(path);
if (!text) {
return '';
}
if (text.length <= maxChars) {
return text;
}
return text.slice(text.length - maxChars);
}
let responseBody = '';
const recovered = readIfPresent(recoveryFile);
const direct = readIfPresent(responseFile);
const findings = readIfPresent(findingsFile);
if (recovered) {
responseBody = recovered;
} else if (direct) {
responseBody = direct;
} else if (findings) {
responseBody =
'⚠️ **Review incomplete — Codex did not produce a final response.**\n\n' +
'Below are the incremental findings recovered from `review-findings.md`.\n\n---\n\n' +
findings;
} else {
const logTail = tailFile(logFile);
responseBody = logTail ?
`❌ **Codex review failed before producing findings.**\n\n\`\`\`\n${
logTail}\n\`\`\`` :
'❌ Codex review failed before producing any output.';
}
const isPartial = !!meta.isPartial;
const code = Number.parseInt(exitCode || '1', 10);
const icon = isPartial ? '🟡' : (code === 0 ? '✅' : '⚠️');
const triggerLine = getTriggerLine();
return buildComment({
icon,
headerTitle: meta.isQuery ? 'Codex Response' : 'Codex Code Review',
triggerLine,
responseBody,
footerLines: [
'Generated by Codex CLI.',
'Review methodology: `claude_md/code_review.md`',
'',
'**Limitations:**',
'- Codex may miss context from files not in the diff',
'- Large PRs may be truncated',
'- Always apply human judgment to AI suggestions',
'',
'**Commands:**',
'- `/codex-review [context]` — Request a code review',
'- `/codex-query <question>` — Ask about the PR or codebase',
],
});
};
+155 -8
View File
@@ -14,9 +14,31 @@
// marker - HTML comment marker for dedup (e.g. '<!-- claude-review -->')
// If an existing comment with this marker is found, it is updated.
// If not found, a new comment is created.
// legacyMarkers - optional list of legacy markers that should be migrated by
// being considered part of the same comment family.
// prunePrefix - optional marker prefix whose older comments should be
// superseded. If obsoleteTitle is set, older comments are
// collapsed instead of deleted.
// preserveLatest - optional count of active comments to keep when
// prunePrefix is set.
// obsoleteMarker - optional HTML marker used to detect already-obsolete
// comments.
// obsoleteTitle - optional heading to use when collapsing superseded
// comments into a details block.
module.exports = async function postPrComment(
{github, context, core, prNumber, body, marker}) {
module.exports = async function postPrComment({
github,
context,
core,
prNumber,
body,
marker,
legacyMarkers = [],
prunePrefix = '',
preserveLatest = 0,
obsoleteMarker = '',
obsoleteTitle = '',
}) {
if (!prNumber || !body) {
core.warning('Missing prNumber or body; skipping comment.');
return;
@@ -28,30 +50,155 @@ module.exports = async function postPrComment(
// Ensure marker is embedded in the body
const markedBody = body.includes(marker) ? body : `${marker}\n${body}`;
// Search for existing comment with this marker
const {data: comments} = await github.rest.issues.listComments({
async function listComments() {
return await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
const existing = comments.find(c => c.body.includes(marker));
}
function commentActivityTime(comment) {
const timestamp =
Date.parse(comment.updated_at || comment.created_at || '');
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function commentSortDescending(left, right) {
const timeDelta = commentActivityTime(right) - commentActivityTime(left);
if (timeDelta !== 0) {
return timeDelta;
}
return Number(right.id || 0) - Number(left.id || 0);
}
function isObsoleteComment(comment) {
return !!obsoleteMarker && typeof comment.body === 'string' &&
comment.body.includes(obsoleteMarker);
}
function buildObsoleteBody(comment) {
const originalBody =
typeof comment.body === 'string' && comment.body.trim() ?
comment.body :
'*No original review body preserved.*';
const title = obsoleteTitle || 'AI Review - OBSOLETE';
return `${obsoleteMarker}\n## ${
title}\n\n<details>\n<summary>Superseded by a newer AI review. Expand to see the original review.</summary>\n\n${
originalBody}\n\n</details>`;
}
async function deleteCommentIfPresent(comment, reason) {
try {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
});
core.info(`${reason} ${comment.id}`);
} catch (error) {
if (error.status === 404) {
core.info(`Comment ${comment.id} was already deleted by another run.`);
return;
}
throw error;
}
}
async function supersedeCommentIfPresent(comment, reason) {
if (!obsoleteTitle) {
await deleteCommentIfPresent(comment, reason);
return;
}
if (isObsoleteComment(comment)) {
return;
}
try {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: comment.id,
body: buildObsoleteBody(comment),
});
core.info(`${reason} ${comment.id}`);
} catch (error) {
if (error.status === 404) {
core.info(`Comment ${comment.id} was already deleted by another run.`);
return;
}
throw error;
}
}
let comments = await listComments();
const existing = comments.find(
comment =>
typeof comment.body === 'string' && comment.body.includes(marker));
let currentCommentId = null;
if (existing) {
await github.rest.issues.updateComment({
try {
const response = await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: markedBody,
});
currentCommentId = existing.id;
core.info(`Updated existing comment ${existing.id}`);
} else {
await github.rest.issues.createComment({
} catch (error) {
if (error.status !== 404) {
throw error;
}
core.info(
`Comment ${existing.id} disappeared before update; ` +
'creating a fresh comment instead.');
}
}
if (!currentCommentId) {
const response = await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: markedBody,
});
currentCommentId = response.data.id;
core.info('Created new PR comment');
}
if (prunePrefix || legacyMarkers.length > 0) {
comments = await listComments();
const relatedComments = comments.filter(
comment => comment.id !== currentCommentId &&
typeof comment.body === 'string' &&
((prunePrefix && comment.body.includes(prunePrefix)) ||
legacyMarkers.some(
legacyMarker => comment.body.includes(legacyMarker))));
const activeRelatedComments =
comments
.filter(
comment => typeof comment.body === 'string' &&
!isObsoleteComment(comment) &&
(comment.id === currentCommentId ||
relatedComments.some(
relatedComment => relatedComment.id === comment.id)))
.sort(commentSortDescending);
const keep =
new Set(activeRelatedComments.slice(0, Math.max(preserveLatest, 1))
.map(comment => comment.id));
const supersedeCandidates = obsoleteTitle ?
activeRelatedComments.filter(comment => !keep.has(comment.id)) :
relatedComments.filter(comment => !keep.has(comment.id));
for (const comment of supersedeCandidates) {
if (keep.has(comment.id)) {
continue;
}
await supersedeCommentIfPresent(comment, 'Superseded old AI review');
}
}
};
+347
View File
@@ -0,0 +1,347 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const postPrComment = require('./post-pr-comment.js');
const OBSOLETE_MARKER = '<!-- claude-review-obsolete -->';
const OBSOLETE_TITLE = 'Claude Code Review - OBSOLETE';
function makeComment(id, body, createdAt, updatedAt) {
return {
id,
body,
created_at: createdAt,
updated_at: updatedAt || createdAt,
};
}
function createHarness(initialComments, options = {}) {
let comments = initialComments.map(comment => ({...comment}));
let nextCommentId = options.nextCommentId || 1000;
let paginateCount = 0;
const calls = {
update: [],
create: [],
delete: [],
};
const github = {
paginate: async () => {
paginateCount++;
if (options.onPaginate) {
const updated = options.onPaginate({
paginateCount,
comments: comments.map(comment => ({...comment})),
});
if (updated) {
comments = updated.map(comment => ({...comment}));
}
}
return comments.map(comment => ({...comment}));
},
rest: {
issues: {
listComments: () => {
throw new Error('listComments should only be used through paginate');
},
updateComment: async ({comment_id, body}) => {
calls.update.push({comment_id, body});
const error =
options.updateErrors && options.updateErrors[comment_id];
if (error) {
throw error;
}
const index =
comments.findIndex(comment => comment.id === comment_id);
if (index === -1) {
const notFound = new Error(`Comment ${comment_id} not found`);
notFound.status = 404;
throw notFound;
}
const updated = {
...comments[index],
body,
updated_at: options.updateTimestamp || '2026-04-24T00:00:00Z',
};
comments[index] = updated;
return {data: {...updated}};
},
createComment: async ({issue_number, body}) => {
calls.create.push({issue_number, body});
const created = makeComment(
nextCommentId++, body,
options.createTimestamp || '2026-04-24T00:00:00Z');
comments.push(created);
return {data: {id: created.id}};
},
deleteComment: async ({comment_id}) => {
calls.delete.push({comment_id});
const error =
options.deleteErrors && options.deleteErrors[comment_id];
if (error) {
throw error;
}
comments = comments.filter(comment => comment.id !== comment_id);
},
},
},
};
return {
github,
calls,
getComments: () => comments.map(comment => ({...comment})),
};
}
function createCore() {
return {
info: () => {},
warning: () => {},
};
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function assertObsoleteComment(comment, originalBody) {
assert.ok(comment);
assert.match(comment.body, new RegExp(escapeRegExp(OBSOLETE_MARKER)));
assert.match(comment.body, new RegExp(`## ${escapeRegExp(OBSOLETE_TITLE)}`));
assert.match(comment.body, /<details>/);
assert.match(comment.body, /Superseded by a newer AI review/);
assert.match(comment.body, new RegExp(escapeRegExp(originalBody)));
}
const context = {
repo: {
owner: 'facebook',
repo: 'rocksdb',
},
};
test(
'creates a fresh review comment and supersedes legacy comments',
async () => {
const harness = createHarness(
[
makeComment(
1, '<!-- claude-review-auto -->\nold legacy', 'not-a-date'),
makeComment(
2, '<!-- claude-review-auto -->\nnew legacy',
'2026-04-21T00:00:00Z'),
],
{
updateTimestamp: '2026-04-24T00:00:00Z',
});
await postPrComment({
github: harness.github,
context,
core: createCore(),
prNumber: 14659,
body: 'review body',
marker: '<!-- claude-review-auto-run-500 -->',
legacyMarkers: ['<!-- claude-review-auto -->'],
prunePrefix: '<!-- claude-review-auto-',
preserveLatest: 1,
obsoleteMarker: OBSOLETE_MARKER,
obsoleteTitle: OBSOLETE_TITLE,
});
assert.equal(harness.calls.create.length, 1);
assert.deepEqual(
harness.calls.update.map(call => call.comment_id), [2, 1]);
assert.equal(harness.calls.delete.length, 0);
const comments = harness.getComments();
assert.equal(comments.length, 3);
assert.match(
comments.find(comment => comment.id === 1000).body,
/<!-- claude-review-auto-run-500 -->/);
assertObsoleteComment(
comments.find(comment => comment.id === 2),
'<!-- claude-review-auto -->\nnew legacy');
assertObsoleteComment(
comments.find(comment => comment.id === 1),
'<!-- claude-review-auto -->\nold legacy');
});
test(
'updates an exact-match comment and supersedes leftover legacy comments',
async () => {
const harness = createHarness(
[
makeComment(
3, '<!-- claude-review-auto-abcdef0 -->\ncurrent body',
'2026-04-24T00:00:00Z'),
makeComment(
4, '<!-- claude-review-auto -->\nlegacy body',
'2026-04-20T00:00:00Z'),
],
{
updateTimestamp: '2026-04-24T01:00:00Z',
});
await postPrComment({
github: harness.github,
context,
core: createCore(),
prNumber: 14659,
body: 'refreshed body',
marker: '<!-- claude-review-auto-abcdef0 -->',
legacyMarkers: ['<!-- claude-review-auto -->'],
prunePrefix: '<!-- claude-review-auto-',
preserveLatest: 1,
obsoleteMarker: OBSOLETE_MARKER,
obsoleteTitle: OBSOLETE_TITLE,
});
assert.deepEqual(
harness.calls.update.map(call => call.comment_id), [3, 4]);
assert.equal(harness.calls.create.length, 0);
assert.equal(harness.calls.delete.length, 0);
const comments = harness.getComments();
assert.match(
comments.find(comment => comment.id === 3).body,
/<!-- claude-review-auto-abcdef0 -->\nrefreshed body/);
assertObsoleteComment(
comments.find(comment => comment.id === 4),
'<!-- claude-review-auto -->\nlegacy body');
});
test(
'creates a new comment if the target disappears before update',
async () => {
const missing = new Error('gone');
missing.status = 404;
const harness = createHarness(
[
makeComment(
10, '<!-- claude-review-auto-abcdef0 -->\nold body',
'2026-04-20T00:00:00Z'),
],
{
updateErrors: {
10: missing,
},
});
await postPrComment({
github: harness.github,
context,
core: createCore(),
prNumber: 14659,
body: 'replacement body',
marker: '<!-- claude-review-auto-abcdef0 -->',
});
assert.equal(harness.calls.update.length, 1);
assert.equal(harness.calls.create.length, 1);
assert.match(
harness.calls.create[0].body, /<!-- claude-review-auto-abcdef0 -->/);
});
test(
'ignores 404 when superseding a comment already deleted by another run',
async () => {
const missing = new Error('gone');
missing.status = 404;
const harness = createHarness(
[
makeComment(
20, '<!-- claude-review-auto-oldest -->\noldest',
'2026-04-20T00:00:00Z'),
makeComment(
21, '<!-- claude-review-auto-newer -->\nnewer',
'2026-04-21T00:00:00Z'),
],
{
nextCommentId: 30,
createTimestamp: '2026-04-24T00:00:00Z',
deleteErrors: {
20: missing,
},
});
await postPrComment({
github: harness.github,
context,
core: createCore(),
prNumber: 14659,
body: 'fresh body',
marker: '<!-- claude-review-auto-latest -->',
prunePrefix: '<!-- claude-review-auto-',
preserveLatest: 1,
obsoleteMarker: OBSOLETE_MARKER,
obsoleteTitle: OBSOLETE_TITLE,
});
assert.equal(harness.calls.create.length, 1);
assert.equal(harness.calls.delete.length, 0);
assert.deepEqual(
harness.calls.update.map(call => call.comment_id), [21, 20]);
const comments = harness.getComments();
assertObsoleteComment(
comments.find(comment => comment.id === 21),
'<!-- claude-review-auto-newer -->\nnewer');
});
test(
'supersedes the current review when a newer concurrent one appears',
async () => {
const harness = createHarness(
[
makeComment(
40, '<!-- claude-review-auto-current -->\ncurrent',
'2026-04-20T00:00:00Z'),
makeComment(
41, '<!-- claude-review-auto-older -->\nolder',
'2026-04-19T00:00:00Z'),
],
{
updateTimestamp: '2026-04-24T00:00:00Z',
onPaginate: ({paginateCount, comments}) => {
if (paginateCount !== 2) {
return comments;
}
return comments.concat([
makeComment(
42, '<!-- claude-review-auto-newer -->\nnewer',
'2026-04-25T00:00:00Z'),
]);
},
});
await postPrComment({
github: harness.github,
context,
core: createCore(),
prNumber: 14659,
body: 'updated current body',
marker: '<!-- claude-review-auto-current -->',
prunePrefix: '<!-- claude-review-auto-',
preserveLatest: 1,
obsoleteMarker: OBSOLETE_MARKER,
obsoleteTitle: OBSOLETE_TITLE,
});
assert.deepEqual(
harness.calls.update.map(call => call.comment_id), [40, 40, 41]);
assert.equal(harness.calls.delete.length, 0);
const comments = harness.getComments();
assert.match(
comments.find(comment => comment.id === 42).body,
/<!-- claude-review-auto-newer -->\nnewer/);
assertObsoleteComment(
comments.find(comment => comment.id === 40),
'<!-- claude-review-auto-current -->\nupdated current body');
assertObsoleteComment(
comments.find(comment => comment.id === 41),
'<!-- claude-review-auto-older -->\nolder');
});
File diff suppressed because it is too large Load Diff
+185
View File
@@ -0,0 +1,185 @@
# Shared comment-posting workflow for AI reviews.
name: AI Review Comment
on:
workflow_call:
inputs:
provider:
description: Provider key, for example "claude" or "codex"
required: true
type: string
result_artifact_name:
description: Artifact name produced by the analysis workflow
required: true
type: string
comment_file:
description: Markdown comment file produced by the analysis workflow
required: true
type: string
permissions:
pull-requests: write
issues: write
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: true
- name: Download review artifact
id: download
uses: actions/download-artifact@v4
with:
name: ${{ inputs.result_artifact_name }}
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Post or update PR comment
if: steps.download.outcome == 'success'
env:
PROVIDER: ${{ inputs.provider }}
COMMENT_FILE: ${{ inputs.comment_file }}
RUN_ID: ${{ github.event.workflow_run.id }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync(process.env.COMMENT_FILE) ||
!fs.existsSync('pr_number.txt')) {
core.info('No review results found; skipping.');
return;
}
const post = require('./.github/scripts/post-pr-comment.js');
const provider = process.env.PROVIDER;
const providerTitle = provider === 'codex' ? 'Codex' : 'Claude';
const body = fs.readFileSync(process.env.COMMENT_FILE, 'utf8');
const prNumber = parseInt(
fs.readFileSync('pr_number.txt', 'utf8').trim(),
10
);
const trigger = fs.existsSync('trigger_type.txt') ?
fs.readFileSync('trigger_type.txt', 'utf8').trim() :
'auto';
const headSha = fs.existsSync('head_sha.txt') ?
fs.readFileSync('head_sha.txt', 'utf8').trim() :
'';
const shortSha = headSha ? headSha.substring(0, 7) : '';
const runId = process.env.RUN_ID;
let marker = '';
let legacyMarkers = [];
let prunePrefix = '';
let preserveLatest = 0;
let obsoleteMarker = '';
let obsoleteTitle = '';
if (trigger === 'auto') {
if (!shortSha) {
core.warning(
'Missing head_sha.txt for auto review; skipping comment.'
);
return;
}
marker = `<!-- ${provider}-review-auto-run-${runId} -->`;
legacyMarkers = [`<!-- ${provider}-review-auto -->`];
prunePrefix = `<!-- ${provider}-review-auto-`;
preserveLatest = 1;
obsoleteMarker = `<!-- ${provider}-review-obsolete -->`;
obsoleteTitle = `${providerTitle} Code Review - OBSOLETE`;
} else {
marker = `<!-- ${provider}-review-manual-run-${runId} -->`;
}
await post({
github,
context,
core,
prNumber,
body,
marker,
legacyMarkers,
prunePrefix,
preserveLatest,
obsoleteMarker,
obsoleteTitle,
});
- name: Add reaction to trigger comment
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync('trigger_type.txt')) return;
const trigger = fs.readFileSync('trigger_type.txt', 'utf8').trim();
if (trigger !== 'manual') return;
if (!fs.existsSync('comment_id.txt')) return;
const commentId = parseInt(
fs.readFileSync('comment_id.txt', 'utf8').trim(),
10
);
if (!commentId) return;
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
content: 'rocket'
});
failure-notice:
if: github.event.workflow_run.conclusion == 'failure'
runs-on: ubuntu-latest
steps:
- name: Download artifact for PR number
id: download
uses: actions/download-artifact@v4
with:
name: ${{ inputs.result_artifact_name }}
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: React with failure
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync('comment_id.txt')) return;
const commentId = parseInt(
fs.readFileSync('comment_id.txt', 'utf8').trim(),
10
);
if (!commentId) return;
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
content: 'confused'
});
unauthorized-notice:
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.event == 'issue_comment' &&
github.event.workflow_run.conclusion == 'skipped'
steps:
- name: Log skipped unauthorized request
uses: actions/github-script@v7
with:
script: |
core.info(
'Analysis workflow was skipped, which usually means the ' +
'issue_comment requester was not in the authorized list.'
);
+9 -130
View File
@@ -1,12 +1,8 @@
# Claude Code Review — Comment Posting Workflow
#
# Companion to claude-review.yml. Downloads the review artifact and posts
# it as a PR comment. This workflow has write permissions but never runs
# AI or processes untrusted code.
#
# Security: Separating analysis (has ANTHROPIC_API_KEY, no write perms)
# from posting (has GITHUB_TOKEN write perms, no AI) prevents a crafted
# PR from tricking Claude into exfiltrating tokens.
# Thin wrapper around .github/workflows/ai-review-comment.yml so the provider
# specific workflow name stays stable while the shared implementation lives in
# one reusable workflow.
name: Post Claude Review Comment
@@ -20,127 +16,10 @@ permissions:
issues: write
jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout scripts
uses: actions/checkout@v4
review-comment:
uses: ./.github/workflows/ai-review-comment.yml
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: true
- name: Download review artifact
id: download
uses: actions/download-artifact@v4
with:
name: claude-review-result
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Post or update PR comment
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync('claude-review-comment.md') || !fs.existsSync('pr_number.txt')) {
core.info('No review results found; skipping.');
return;
}
const body = fs.readFileSync('claude-review-comment.md', 'utf8');
const prNumber = parseInt(fs.readFileSync('pr_number.txt', 'utf8').trim());
const trigger = fs.existsSync('trigger_type.txt')
? fs.readFileSync('trigger_type.txt', 'utf8').trim()
: 'auto';
// Use different markers for auto vs manual so they don't
// overwrite each other. Auto-reviews update in place (one
// per PR). Manual reviews always create new comments.
const marker = trigger === 'auto'
? '<!-- claude-review-auto -->'
: `<!-- claude-review-manual-${Date.now()} -->`;
const post = require('./.github/scripts/post-pr-comment.js');
await post({
github,
context,
core,
prNumber,
body,
marker,
});
- name: Add reaction to trigger comment
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync('trigger_type.txt')) return;
const trigger = fs.readFileSync('trigger_type.txt', 'utf8').trim();
if (trigger !== 'manual') return;
if (!fs.existsSync('comment_id.txt')) return;
const commentId = parseInt(fs.readFileSync('comment_id.txt', 'utf8').trim());
if (!commentId) return;
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
content: 'rocket'
});
# Notify on failure (for manual triggers)
failure-notice:
if: github.event.workflow_run.conclusion == 'failure'
runs-on: ubuntu-latest
steps:
- name: Download artifact for PR number
id: download
uses: actions/download-artifact@v4
with:
name: claude-review-result
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: React with failure
if: steps.download.outcome == 'success'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
if (!fs.existsSync('comment_id.txt')) return;
const commentId = parseInt(fs.readFileSync('comment_id.txt', 'utf8').trim());
if (!commentId) return;
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: commentId,
content: 'confused'
});
# Unauthorized user notice
unauthorized-notice:
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.event == 'issue_comment' &&
github.event.workflow_run.conclusion == 'skipped'
steps:
- name: Check if unauthorized
id: check
uses: actions/github-script@v7
with:
script: |
// This job only runs when the analysis workflow was skipped,
// which happens when the user is not in the authorized list.
// We cannot easily get the original comment from workflow_run,
// so this is a best-effort notice via workflow logs.
core.info('Analysis workflow was skipped — likely unauthorized user.');
provider: claude
result_artifact_name: claude-review-result
comment_file: claude-review-comment.md
secrets: inherit
+39 -561
View File
@@ -1,20 +1,8 @@
# Claude Code Review — Analysis Workflow
#
# This workflow runs Claude to review PRs. It produces a markdown review
# as an artifact but does NOT post comments (no write permissions).
# The companion workflow (claude-review-comment.yml) handles posting.
#
# Triggers:
# 1. workflow_run: Auto-review after pr-jobs passes
# 2. issue_comment: Manual /claude-review or /claude-query by maintainers
# 3. workflow_dispatch: Manual testing
#
# Security:
# - This job has contents:read ONLY (no PR write, no issue write)
# - ANTHROPIC_API_KEY is used here but never coexists with write tokens
# - Claude uses read-only tools (View, GlobTool, GrepTool) plus Write
# (only for incremental findings file, not repo modifications)
# - Review methodology: claude_md/code_review.md
# Thin wrapper around .github/workflows/ai-review-analysis.yml so provider
# specific triggers and workflow_dispatch inputs stay stable while the shared
# implementation lives in one reusable workflow.
name: Claude Code Review
@@ -23,6 +11,11 @@ on:
workflows: ["facebook/rocksdb/pr-jobs"]
types: [completed]
# The early pull_request_target path is limited to same-repo PRs by the job
# condition below. Fork PRs skip this path and rely on workflow_run instead.
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
@@ -33,560 +26,45 @@ on:
required: true
type: number
model:
description: Claude model to use
description: Claude model to use (defaults to latest Opus)
required: false
type: choice
options:
- claude-opus-4-7
- claude-opus-4-6
- claude-sonnet-4-20250514
default: claude-opus-4-6
- claude-sonnet-4-6
default: claude-opus-4-7
thinking_budget:
description: Override MAX_THINKING_TOKENS (blank = auto-classify)
required: false
type: string
default: ""
permissions:
contents: read
# The shared analysis workflow polls check state, inspects prior runs, and
# reads PR metadata. Keep those permissions read-only in the caller.
actions: read
checks: read
pull-requests: read
jobs:
# ======================== Auto Review ======================== #
auto-review:
name: Auto Claude Review
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
review:
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request'
steps:
- name: Get PR info
id: pr_info
uses: actions/github-script@v7
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/ai-review-analysis.yml
with:
script: |
const headSha = context.payload.workflow_run.head_sha;
let prNumber = null;
const prs = context.payload.workflow_run.pull_requests;
if (prs && prs.length > 0) {
prNumber = prs[0].number;
} else {
// PR may not be registered yet if push and PR creation raced.
// Retry up to 5 times with 10s delay.
for (let attempt = 0; attempt < 5; attempt++) {
if (attempt > 0) {
core.info(`PR not found for SHA ${headSha}, retrying in 10s (attempt ${attempt + 1}/5)...`);
await new Promise(r => setTimeout(r, 10000));
}
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
sort: 'updated',
direction: 'desc',
per_page: 100
});
const match = pulls.find(p => p.head.sha === headSha);
if (match) {
prNumber = match.number;
break;
}
}
}
if (!prNumber) {
core.warning(
`Could not find PR for SHA ${headSha} after retries. ` +
`This can happen when the PR head advanced before this ` +
`workflow_run-triggered job started. Skipping stale review run.`
);
core.setOutput('skip', 'true');
core.setOutput('skip_reason', `stale_sha:${headSha}`);
return;
}
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('skip', 'false');
core.setOutput('pr_number', prNumber.toString());
core.setOutput('head_repo', pr.data.head.repo.clone_url);
core.setOutput('head_ref', pr.data.head.ref);
core.setOutput('head_sha', pr.data.head.sha);
core.setOutput('base_sha', pr.data.base.sha);
core.setOutput('base_ref', pr.data.base.ref);
core.setOutput('pr_title', pr.data.title);
core.setOutput('pr_body', pr.data.body || '');
core.setOutput('pr_author', pr.data.user.login);
- name: Skip stale run
if: steps.pr_info.outputs.skip == 'true'
run: |-
echo "Skipping stale auto-review run: ${{ steps.pr_info.outputs.skip_reason }}"
- name: Check for existing review
if: steps.pr_info.outputs.skip != 'true'
id: check_existing
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ steps.pr_info.outputs.pr_number }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
with:
script: |
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(process.env.PR_NUMBER),
per_page: 100
});
const shortSha = process.env.HEAD_SHA.substring(0, 7);
const existing = comments.data.find(c =>
c.body.includes('<!-- claude-review -->') &&
c.body.includes(shortSha)
);
core.setOutput('skip', existing ? 'true' : 'false');
- name: Checkout base repository
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
uses: actions/checkout@v4
with:
ref: ${{ steps.pr_info.outputs.base_ref }}
fetch-depth: 0
persist-credentials: false
- name: Generate diff and prompt
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
env:
HEAD_REPO: ${{ steps.pr_info.outputs.head_repo }}
HEAD_REF: ${{ steps.pr_info.outputs.head_ref }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
PR_BODY: ${{ steps.pr_info.outputs.pr_body }}
PR_AUTHOR: ${{ steps.pr_info.outputs.pr_author }}
run: |
git remote add fork "${HEAD_REPO}" || true
git fetch fork "${HEAD_REF}" --depth=100
git diff "${BASE_SHA}...${HEAD_SHA}" > /tmp/pr.diff
DIFF_STATS=$(git diff --stat "${BASE_SHA}...${HEAD_SHA}" | tail -1)
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
IS_TRUNCATED=false
if [ "$DIFF_SIZE" -gt 100000 ]; then
head -c 100000 /tmp/pr.diff > /tmp/pr_truncated.diff
mv /tmp/pr_truncated.diff /tmp/pr.diff
IS_TRUNCATED=true
fi
cat claude_md/ci_review_prompt.md > /tmp/prompt.txt
cat >> /tmp/prompt.txt << PROMPT_EOF
## Pull Request Information
- **Title:** ${PR_TITLE}
- **Author:** ${PR_AUTHOR}
- **Changes:** ${DIFF_STATS}
PROMPT_EOF
if [ "${IS_TRUNCATED}" = "true" ]; then
echo "- **Note:** Diff truncated due to size." >> /tmp/prompt.txt
fi
printf '\n## PR Description\n' >> /tmp/prompt.txt
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
printf '\n---\n\n## Diff to Review\n\n' >> /tmp/prompt.txt
cat /tmp/pr.diff >> /tmp/prompt.txt
- name: Run Claude
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
id: claude
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/prompt.txt
model: claude-opus-4-6
max_turns: "300"
timeout_minutes: "60"
allowed_tools: "View,GlobTool,GrepTool,Write,Task"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Recover partial review on turn limit
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
id: recover
uses: actions/github-script@v7
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
with:
script: |
const fs = require('fs');
const log = JSON.parse(fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
const result = log.find(m => m.type === 'result');
const hitLimit = result && result.subtype === 'error_max_turns';
const hasFindings = fs.existsSync('review-findings.md');
core.setOutput('hit_limit', hitLimit ? 'true' : 'false');
core.setOutput('has_findings', hasFindings ? 'true' : 'false');
core.setOutput('needs_recovery', (hitLimit && hasFindings) ? 'true' : 'false');
- name: Format partial findings
if: >-
steps.pr_info.outputs.skip != 'true' &&
steps.check_existing.outputs.skip != 'true' &&
steps.recover.outputs.needs_recovery == 'true'
id: format_recovery
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: claude_md/ci_recovery_prompt.md
model: claude-sonnet-4-20250514
max_turns: "10"
allowed_tools: "View,GlobTool,GrepTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Build review comment
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
uses: actions/github-script@v7
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
RECOVERY_FILE: ${{ steps.format_recovery.outputs.execution_file }}
CONCLUSION: ${{ steps.claude.outputs.conclusion }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
NEEDS_RECOVERY: ${{ steps.recover.outputs.needs_recovery }}
with:
script: |
const parse = require('./.github/scripts/parse-claude-review.js');
const fs = require('fs');
let execFile = process.env.EXECUTION_FILE;
if (process.env.NEEDS_RECOVERY === 'true') {
const rf = process.env.RECOVERY_FILE;
if (rf && fs.existsSync(rf)) {
execFile = rf;
}
}
const body = parse({
executionFile: execFile,
conclusion: process.env.CONCLUSION,
meta: {
trigger: 'auto',
headSha: process.env.HEAD_SHA,
reviewer: '',
isQuery: false,
isPartial: process.env.NEEDS_RECOVERY === 'true',
}
});
fs.writeFileSync('claude-review-comment.md', body);
- name: Save PR number
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
run: echo "${{ steps.pr_info.outputs.pr_number }}" > pr_number.txt
- name: Save trigger metadata
if: steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
run: echo "auto" > trigger_type.txt
- name: Upload review artifact
if: always() && steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: claude-review-result
path: |
claude-review-comment.md
pr_number.txt
trigger_type.txt
if-no-files-found: ignore
retention-days: 1
- name: Upload execution log
if: always() && steps.pr_info.outputs.skip != 'true' && steps.check_existing.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: claude-review-execution-log
path: ${{ steps.claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: warn
- name: Upload recovery execution log
if: always() && steps.pr_info.outputs.skip != 'true' && steps.recover.outputs.needs_recovery == 'true'
uses: actions/upload-artifact@v4
with:
name: claude-review-recovery-log
path: ${{ steps.format_recovery.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore
# ======================== Manual Review ======================== #
manual-review:
name: Manual Claude Review
runs-on: ubuntu-latest
permissions:
contents: read
if: >-
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(contains(github.event.comment.body, '/claude-review') || contains(github.event.comment.body, '/claude-query')) &&
contains(fromJSON('["pdillinger", "jaykorean", "hx235", "dannyhchen", "joshkang97", "zaidoon1", "omkarhgawde", "xingbowang", "anand1976", "virajthakur", "nmk70", "archang19", "mszeszko-meta", "yoshinorim", "cbi42"]'), github.event.comment.user.login)
)
steps:
- name: Detect command type
id: command
uses: actions/github-script@v7
with:
script: |
if (context.eventName === 'workflow_dispatch') {
core.setOutput('type', 'review');
core.setOutput('query', '');
return;
}
const body = context.payload.comment.body;
if (body.includes('/claude-review')) {
core.setOutput('type', 'review');
const match = body.match(/\/claude-review\s+([\s\S]*)/);
core.setOutput('query', match ? match[1].trim() : '');
} else if (body.includes('/claude-query')) {
const match = body.match(/\/claude-query\s+([\s\S]*)/);
const query = match ? match[1].trim() : '';
if (!query) {
core.setFailed('No query provided. Usage: /claude-query <your question>');
return;
}
core.setOutput('type', 'query');
core.setOutput('query', query);
} else {
core.setFailed('Unknown command');
}
- name: Get PR details
id: pr_info
uses: actions/github-script@v7
env:
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch'
? parseInt(process.env.INPUT_PR_NUMBER, 10)
: context.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
core.setOutput('pr_number', prNumber.toString());
core.setOutput('head_repo', pr.data.head.repo.clone_url);
core.setOutput('head_ref', pr.data.head.ref);
core.setOutput('head_sha', pr.data.head.sha);
core.setOutput('base_sha', pr.data.base.sha);
core.setOutput('base_ref', pr.data.base.ref);
core.setOutput('pr_title', pr.data.title);
core.setOutput('pr_body', pr.data.body || '');
core.setOutput('pr_author', pr.data.user.login);
- name: Checkout base repository
uses: actions/checkout@v4
with:
ref: ${{ steps.pr_info.outputs.base_ref }}
fetch-depth: 0
persist-credentials: false
- name: Generate diff and prompt
env:
HEAD_REPO: ${{ steps.pr_info.outputs.head_repo }}
HEAD_REF: ${{ steps.pr_info.outputs.head_ref }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
BASE_SHA: ${{ steps.pr_info.outputs.base_sha }}
PR_TITLE: ${{ steps.pr_info.outputs.pr_title }}
PR_BODY: ${{ steps.pr_info.outputs.pr_body }}
PR_AUTHOR: ${{ steps.pr_info.outputs.pr_author }}
DIFF_STATS: ""
COMMAND_TYPE: ${{ steps.command.outputs.type }}
ADDITIONAL_CONTEXT: ${{ steps.command.outputs.query }}
USER_QUERY: ${{ steps.command.outputs.query }}
run: |
git remote add fork "${HEAD_REPO}" || true
git fetch fork "${HEAD_REF}" --depth=100
git diff "${BASE_SHA}...${HEAD_SHA}" > /tmp/pr.diff
DIFF_STATS=$(git diff --stat "${BASE_SHA}...${HEAD_SHA}" | tail -1)
DIFF_SIZE=$(wc -c < /tmp/pr.diff)
IS_TRUNCATED=false
if [ "$DIFF_SIZE" -gt 100000 ]; then
head -c 100000 /tmp/pr.diff > /tmp/pr_truncated.diff
mv /tmp/pr_truncated.diff /tmp/pr.diff
IS_TRUNCATED=true
fi
if [ "${COMMAND_TYPE}" = "query" ]; then
# Query prompt
cat claude_md/ci_query_prompt.md > /tmp/prompt.txt
printf '\n---\n\n' >> /tmp/prompt.txt
cat >> /tmp/prompt.txt << PROMPT_EOF
## Pull Request Context
- **Title:** ${PR_TITLE}
- **Author:** ${PR_AUTHOR}
- **Changes:** ${DIFF_STATS}
## PR Description
PROMPT_EOF
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
printf '\n## Question\n' >> /tmp/prompt.txt
printf '%s\n' "${USER_QUERY}" >> /tmp/prompt.txt
printf '\n---\n## PR Diff (for reference)\n' >> /tmp/prompt.txt
cat /tmp/pr.diff >> /tmp/prompt.txt
else
# Review prompt
cat claude_md/ci_review_prompt.md > /tmp/prompt.txt
cat >> /tmp/prompt.txt << PROMPT_EOF
## Pull Request Information
- **Title:** ${PR_TITLE}
- **Author:** ${PR_AUTHOR}
- **Changes:** ${DIFF_STATS}
PROMPT_EOF
if [ "${IS_TRUNCATED}" = "true" ]; then
echo "- **Note:** Diff truncated due to size." >> /tmp/prompt.txt
fi
printf '\n## PR Description\n' >> /tmp/prompt.txt
printf '%s\n' "${PR_BODY}" >> /tmp/prompt.txt
if [ -n "${ADDITIONAL_CONTEXT}" ]; then
printf '\n## Additional Instructions from Reviewer\n' >> /tmp/prompt.txt
printf '%s\n' "${ADDITIONAL_CONTEXT}" >> /tmp/prompt.txt
fi
printf '\n---\n\n## Diff to Review\n\n' >> /tmp/prompt.txt
cat /tmp/pr.diff >> /tmp/prompt.txt
fi
- name: Run Claude
id: claude
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: /tmp/prompt.txt
model: ${{ github.event_name == 'workflow_dispatch' && inputs.model || 'claude-opus-4-6' }}
max_turns: "300"
timeout_minutes: "60"
allowed_tools: "View,GlobTool,GrepTool,Write,Task"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Recover partial review on turn limit
id: recover
uses: actions/github-script@v7
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
COMMAND_TYPE: ${{ steps.command.outputs.type }}
with:
script: |
const fs = require('fs');
// Skip recovery for queries — only reviews use incremental findings
if (process.env.COMMAND_TYPE === 'query') {
core.setOutput('needs_recovery', 'false');
return;
}
const log = JSON.parse(fs.readFileSync(process.env.EXECUTION_FILE, 'utf8'));
const result = log.find(m => m.type === 'result');
const hitLimit = result && result.subtype === 'error_max_turns';
const hasFindings = fs.existsSync('review-findings.md');
core.setOutput('hit_limit', hitLimit ? 'true' : 'false');
core.setOutput('has_findings', hasFindings ? 'true' : 'false');
core.setOutput('needs_recovery', (hitLimit && hasFindings) ? 'true' : 'false');
- name: Format partial findings
if: steps.recover.outputs.needs_recovery == 'true'
id: format_recovery
uses: anthropics/claude-code-base-action@beta
with:
prompt_file: claude_md/ci_recovery_prompt.md
model: claude-sonnet-4-20250514
max_turns: "10"
allowed_tools: "View,GlobTool,GrepTool"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
- name: Build review comment
uses: actions/github-script@v7
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
RECOVERY_FILE: ${{ steps.format_recovery.outputs.execution_file }}
CONCLUSION: ${{ steps.claude.outputs.conclusion }}
HEAD_SHA: ${{ steps.pr_info.outputs.head_sha }}
REVIEWER: ${{ github.event_name == 'workflow_dispatch' && github.actor || github.event.comment.user.login }}
COMMAND_TYPE: ${{ steps.command.outputs.type }}
NEEDS_RECOVERY: ${{ steps.recover.outputs.needs_recovery }}
with:
script: |
const parse = require('./.github/scripts/parse-claude-review.js');
const fs = require('fs');
const isReview = process.env.COMMAND_TYPE === 'review';
let execFile = process.env.EXECUTION_FILE;
if (process.env.NEEDS_RECOVERY === 'true') {
const rf = process.env.RECOVERY_FILE;
if (rf && fs.existsSync(rf)) {
execFile = rf;
}
}
const body = parse({
executionFile: execFile,
conclusion: process.env.CONCLUSION,
meta: {
trigger: 'manual',
headSha: process.env.HEAD_SHA,
reviewer: process.env.REVIEWER,
isQuery: !isReview,
isPartial: process.env.NEEDS_RECOVERY === 'true',
}
});
fs.writeFileSync('claude-review-comment.md', body);
- name: Save PR number
run: echo "${{ steps.pr_info.outputs.pr_number }}" > pr_number.txt
- name: Save trigger metadata
env:
REVIEWER: ${{ github.event_name == 'workflow_dispatch' && github.actor || github.event.comment.user.login }}
COMMENT_ID: ${{ github.event.comment.id }}
run: |
echo "manual" > trigger_type.txt
echo "${REVIEWER}" > reviewer.txt
echo "${COMMENT_ID}" > comment_id.txt
- name: Upload review artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: claude-review-result
path: |
claude-review-comment.md
pr_number.txt
trigger_type.txt
reviewer.txt
comment_id.txt
if-no-files-found: ignore
retention-days: 1
- name: Upload execution log
if: always()
uses: actions/upload-artifact@v4
with:
name: claude-review-execution-log
path: ${{ steps.claude.outputs.execution_file }}
retention-days: 7
if-no-files-found: warn
- name: Upload recovery execution log
if: always() && steps.recover.outputs.needs_recovery == 'true'
uses: actions/upload-artifact@v4
with:
name: claude-review-recovery-log
path: ${{ steps.format_recovery.outputs.execution_file }}
retention-days: 7
if-no-files-found: ignore
provider: claude
display_name: Claude
review_command: /claude-review
query_command: /claude-query
result_artifact_name: claude-review-result
comment_file: claude-review-comment.md
default_model: claude-opus-4-7
selected_model: ${{ github.event_name == 'workflow_dispatch' && inputs.model || '' }}
classifier_model: claude-sonnet-4-6
recovery_model: claude-sonnet-4-6
thinking_budget: ${{ github.event_name == 'workflow_dispatch' && inputs.thinking_budget || '' }}
dispatch_pr_number: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || '' }}
secrets: inherit
@@ -0,0 +1,25 @@
# Codex Code Review — Comment Posting Workflow
#
# Thin wrapper around .github/workflows/ai-review-comment.yml so the provider
# specific workflow name stays stable while the shared implementation lives in
# one reusable workflow.
name: Post Codex Review Comment
on:
workflow_run:
workflows: ["Codex Code Review"]
types: [completed]
permissions:
pull-requests: write
issues: write
jobs:
review-comment:
uses: ./.github/workflows/ai-review-comment.yml
with:
provider: codex
result_artifact_name: codex-review-result
comment_file: codex-review-comment.md
secrets: inherit
+68
View File
@@ -0,0 +1,68 @@
# Codex Code Review — Analysis Workflow
#
# Thin wrapper around .github/workflows/ai-review-analysis.yml so provider
# specific triggers and workflow_dispatch inputs stay stable while the shared
# implementation lives in one reusable workflow.
name: Codex Code Review
on:
workflow_run:
workflows: ["facebook/rocksdb/pr-jobs"]
types: [completed]
# The early pull_request_target path is limited to same-repo PRs by the job
# condition below. Fork PRs skip this path and rely on workflow_run instead.
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: PR number to review
required: true
type: number
model:
description: Codex model to use
required: false
type: choice
options:
- gpt-5.5
- gpt-5.3-codex
- gpt-5.2-codex
default: gpt-5.5
thinking_budget:
description: Override MAX_THINKING_TOKENS (blank = auto-classify)
required: false
type: string
default: ""
permissions:
contents: read
# The shared analysis workflow polls check state, inspects prior runs, and
# reads PR metadata. Keep those permissions read-only in the caller.
actions: read
checks: read
pull-requests: read
jobs:
review:
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/ai-review-analysis.yml
with:
provider: codex
display_name: Codex
review_command: /codex-review
query_command: /codex-query
result_artifact_name: codex-review-result
comment_file: codex-review-comment.md
default_model: gpt-5.5
selected_model: ${{ github.event_name == 'workflow_dispatch' && inputs.model || '' }}
thinking_budget: ${{ github.event_name == 'workflow_dispatch' && inputs.thinking_budget || '' }}
dispatch_pr_number: ${{ github.event_name == 'workflow_dispatch' && inputs.pr_number || '' }}
secrets: inherit
+61
View File
@@ -0,0 +1,61 @@
# PR Complexity Classifier — CI Triage Prompt
## Purpose
Quickly classify a RocksDB pull request as **simple** or **complex** so the
downstream reviewer can pick an appropriate thinking budget.
## Output Contract (STRICT)
Your final response MUST be exactly one of these two lowercase tokens, on a
line by itself, with no other text:
```
simple
```
or
```
complex
```
If you are unsure, output `complex`. The downstream review will use this token
to set its thinking budget — when in doubt, prefer the more thorough budget.
## Classification Heuristics
Treat a PR as **simple** when ALL of the following hold:
- Diff touches < ~200 lines of non-test code
- No changes to public headers under `include/rocksdb/`
- No changes to on-disk format (SST block layout, WAL record layout,
manifest, options file format, version edit encoding)
- No changes to compaction picker, write-path locking, recovery, or
WAL/memtable lifecycle
- No new public option, no new statistics counter, no new RPC/serialized
message
- Tests-only or docs-only changes
- Trivial refactors (rename, comment, dead-code removal, lint fixes)
- One-line bug fix with obvious root cause and a focused regression test
Treat a PR as **complex** when ANY of the following hold:
- Touches files in `db/`, `db/compaction/`, `db/blob/`, `cache/`,
`table/block_based/`, `file/`, `env/io_*` with > ~50 lines of logic change
- Modifies thread-safety/synchronization (mutexes, atomics, memory ordering,
refcounting, condvars)
- Changes serialization or on-disk format
- Changes public API surface in `include/rocksdb/`
- Adds a new feature flag, option, or compaction style
- Touches transactions (`utilities/transactions/`), backup/checkpoint,
user-defined timestamps, or remote compaction
- Cross-cutting refactor spanning > 5 directories
## How To Decide
1. Read the diff summary and changed-file list provided below.
2. Skim the diff hunks — focus on which subsystems are touched and whether
the change is mechanical vs. semantic.
3. Apply the heuristics above. Default to `complex` if the rules conflict.
4. Output the single token. No preamble. No explanation.
## Tool Use
You may use `View`, `GlobTool`, and `GrepTool` to peek at one or two files
if the diff snippet alone is ambiguous, but keep this fast — this is a
triage step, not a review. Do NOT spawn sub-agents.
+52 -12
View File
@@ -1,14 +1,54 @@
Read review-findings.md. This file contains partial code
review findings from a review session that ran out of turns before
finishing. Format them into a clean final review comment using the
output format defined in claude_md/code_review.md (Summary, Issues
Found, Cross-Component Analysis, Positive Observations).
Read review-findings.md. This file contains partial code review findings
from a session that ran out of turns before finishing. Reformat them into
a clean final review comment.
At the top of the review, add this notice:
> **Partial review** -- the analysis was interrupted before all
> perspectives were completed. The findings below cover the
> perspectives that were finished. You can request a full re-review
> with `/claude-review`.
## Required Output Structure
Write ONLY the formatted review as your final response -- no
commentary or explanation outside the review itself.
Use this exact structure so the PR page stays scrollable:
```markdown
## Summary
> **Partial review** — the analysis was interrupted before all perspectives
> were completed. Findings below cover only the perspectives that finished.
> You can request a fresh full review with `/claude-review`.
<!-- One or two sentences of overall assessment. -->
**High-severity findings (N):**
- **[file.cc:123]** One-line description. <!-- repeat per HIGH finding -->
<!-- If no HIGH findings were salvaged, write: -->
<!-- _No high-severity findings recovered._ -->
<details>
<summary>Recovered findings (click to expand)</summary>
### Findings
#### :red_circle: HIGH
... (H1, H2, ...)
#### :yellow_circle: MEDIUM
... (M1, M2, ...)
#### :green_circle: LOW / NIT
... (L1, L2, ...)
### Cross-Component Analysis
<!-- Whatever cross-component results were captured. -->
### Positive Observations
<!-- Optional. -->
</details>
```
## Rules
- The `## Summary`, the partial-review notice, and the HIGH bullet list MUST
stay outside the `<details>` block.
- All per-finding detail and analysis MUST live inside the `<details>` block.
- Do NOT nest `<details>` blocks.
- Do NOT add any text after the closing `</details>` — the comment-builder
appends its own footer.
- Output ONLY the formatted review. No commentary, no explanation.
+55
View File
@@ -481,6 +481,61 @@ Team lead synthesizes the debate into a consensus document:
conclusion — not a record of the analysis process.
- The reader should never see the reviewer arguing with itself.
**REQUIRED output structure (so the PR page stays scrollable):**
The final response (and contents of `review-findings.md`) MUST follow this
exact structure. The summary appears first so reviewers can see HIGH findings
at a glance; everything else is hidden behind a `<details>` block.
```markdown
## Summary
<!-- One or two sentences of overall assessment. -->
**High-severity findings (N):**
- **[file.cc:123]** One-line description of the issue. <!-- repeat per HIGH finding -->
<!-- If there are NO high-severity findings, write exactly: -->
<!-- _No high-severity findings._ -->
<details>
<summary>Full review (click to expand)</summary>
### Findings
#### :red_circle: HIGH
##### H1. <Title> — `file.cc:123`
- **Issue:** ...
- **Root cause:** ...
- **Suggested fix:** ...
#### :yellow_circle: MEDIUM
... (same structure: M1, M2, ...)
#### :green_circle: LOW / NIT
... (same structure: L1, L2, ...)
### Cross-Component Analysis
<!-- Execution-context table and assumption stress-test results. -->
### Positive Observations
<!-- Optional: good patterns, clever optimizations. -->
</details>
```
Rules for this structure:
- The top-level `## Summary` and the bullet list of HIGH findings MUST stay
outside the `<details>` block — they are always visible.
- Every detail (per-finding root cause, fix, debate outcomes, cross-component
analysis, positive observations) MUST live inside the `<details>` block.
- Do NOT nest a `<details>` inside another `<details>`.
- Do NOT add any text after the closing `</details>` — the comment-builder
appends its own footer.
- If the review is partial (recovery path), still produce the summary block
first, and put whatever was salvaged inside the `<details>` block.
## Review Checklist
### Context Phase (must be completed before agents spawn)