|
name: 'Claude Code Review' |
|
|
|
on: |
|
pull_request: |
|
types: [labeled, synchronize, ready_for_review] |
|
paths: |
|
- 'app/**/**' |
|
|
|
# This section enables workflow concurrency and cancellation |
|
concurrency: |
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} |
|
cancel-in-progress: true |
|
|
|
jobs: |
|
claude_code_review: |
|
name: Run Claude Code Review |
|
runs-on: PIT-198-8cpu |
|
permissions: |
|
contents: read |
|
pull-requests: write |
|
steps: |
|
- name: Check for ready-for-review label and draft status |
|
id: check_label |
|
uses: actions/github-script@v6 |
|
with: |
|
github-token: ${{ github.token }} |
|
script: | |
|
// Get PR information to check draft status |
|
const { data: pr } = await github.rest.pulls.get({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
pull_number: context.issue.number |
|
}); |
|
|
|
// Check if the PR is in draft mode |
|
if (pr.draft) { |
|
console.log('PR is in draft mode, skipping review'); |
|
return core.setOutput('has_label', 'false'); |
|
} |
|
|
|
// For newly added label |
|
if (context.payload.action === 'labeled' && context.payload.label.name === 'ready-for-review') { |
|
console.log('PR was just labeled with ready-for-review and is not in draft mode'); |
|
return core.setOutput('has_label', 'true'); |
|
} |
|
|
|
// Otherwise, check if label exists |
|
const { data: labels } = await github.rest.issues.listLabelsOnIssue({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
issue_number: context.issue.number |
|
}); |
|
|
|
const hasLabel = labels.some(label => label.name === 'ready-for-review'); |
|
console.log(`PR sync: has ready-for-review label? ${hasLabel}, is draft? ${pr.draft}`); |
|
return core.setOutput('has_label', hasLabel ? 'true' : 'false'); |
|
|
|
# Checkout the code only if the label check passes |
|
- uses: actions/checkout@v4 |
|
if: steps.check_label.outputs.has_label == 'true' |
|
with: |
|
ref: ${{ github.head_ref }} |
|
|
|
- name: Install Claude Code |
|
if: steps.check_label.outputs.has_label == 'true' |
|
run: npm install -g @anthropic-ai/claude-code |
|
|
|
- name: Find previous comment |
|
if: steps.check_label.outputs.has_label == 'true' |
|
id: find_comment |
|
uses: actions/github-script@v6 |
|
with: |
|
github-token: ${{ github.token }} |
|
script: | |
|
const { data: comments } = await github.rest.issues.listComments({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
issue_number: context.issue.number |
|
}); |
|
|
|
// Find comment from the bot |
|
const botComment = comments.find(comment => |
|
comment.user.type === 'Bot' && |
|
comment.body.includes('Claude Code Review') |
|
); |
|
|
|
if (botComment) { |
|
console.log(`Found previous comment: ${botComment.id}`); |
|
return core.setOutput('comment_id', botComment.id); |
|
} else { |
|
console.log('No previous comment found'); |
|
return core.setOutput('comment_id', ''); |
|
} |
|
|
|
- name: Fetch PR diff |
|
if: steps.check_label.outputs.has_label == 'true' |
|
id: fetch_diff |
|
uses: actions/github-script@v6 |
|
with: |
|
github-token: ${{ github.token }} |
|
script: | |
|
const { data: pr } = await github.rest.pulls.get({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
pull_number: context.issue.number |
|
}); |
|
|
|
// Fetch the PR diff using the GitHub API with raw header |
|
const diff = await github.request({ |
|
url: pr.url, |
|
headers: { |
|
accept: 'application/vnd.github.v3.diff' |
|
} |
|
}); |
|
|
|
// Save diff to a file |
|
const fs = require('fs'); |
|
fs.writeFileSync('pr_diff.txt', diff.data); |
|
console.log('PR diff saved to pr_diff.txt'); |
|
|
|
- name: Run Claude Code review |
|
if: steps.check_label.outputs.has_label == 'true' |
|
id: claude_review |
|
env: |
|
ANTHROPIC_API_KEY: ${{ secrets.CLAUDE_API_KEY }} |
|
run: | |
|
cat > review_prompt.txt << 'EOT' |
|
You are doing a code review focused files changed in this PR. You should focus on the changes from this PR, but you can look at surrounding code as needed. |
|
|
|
Please analyze the code changes and highlight ONLY serious issues with a clear and objectively better resolution. |
|
|
|
FORMAT YOUR RESPONSE AS FOLLOWS: |
|
- If you find no issues or only minor issues, respond EXACTLY with "no issues found" |
|
- If you find serious issues, provide specific feedback for each issue: |
|
- File and line number |
|
- Description of the issue |
|
- A clear and specific recommendation for how to fix it |
|
- Example code for the solution if possible |
|
|
|
DO NOT comment on: |
|
- Minor optimizations |
|
- Speculative issues without clear evidence |
|
|
|
Here is the PR diff for you to review: |
|
```diff |
|
$(cat pr_diff.txt) |
|
``` |
|
EOT |
|
|
|
# Run Claude Code with JSON output format and capture output |
|
CLAUDE_OUTPUT=$(claude -p "$(cat review_prompt.txt)" --output-format json) |
|
|
|
# Echo the full JSON response for debugging |
|
echo "Full Claude JSON response:" |
|
echo "$CLAUDE_OUTPUT" | jq . |
|
|
|
# Extract the response text and cost from the JSON output |
|
RESULTS=$(echo "$CLAUDE_OUTPUT" | jq -r '.result') |
|
COST=$(echo "$CLAUDE_OUTPUT" | jq -r '.cost_usd' | awk '{printf "%.3f", $0}') |
|
|
|
echo "RESULTS<<EOF" >> $GITHUB_ENV |
|
echo "$RESULTS" >> $GITHUB_ENV |
|
echo "EOF" >> $GITHUB_ENV |
|
|
|
echo "COST=$COST" >> $GITHUB_ENV |
|
|
|
# Check if there are issues found |
|
if [[ "$RESULTS" == *"no issues found"* ]]; then |
|
echo "ISSUES_FOUND=false" >> $GITHUB_ENV |
|
else |
|
echo "ISSUES_FOUND=true" >> $GITHUB_ENV |
|
fi |
|
|
|
- name: Resolve previous comment if exists |
|
if: steps.check_label.outputs.has_label == 'true' && steps.find_comment.outputs.comment_id != '' |
|
uses: actions/github-script@v6 |
|
with: |
|
github-token: ${{ github.token }} |
|
script: | |
|
// First get the PR details to find all review threads |
|
const { data: pr } = await github.rest.pulls.get({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
pull_number: context.issue.number |
|
}); |
|
|
|
// Find the thread containing our comment using GraphQL |
|
const commentId = ${{ steps.find_comment.outputs.comment_id }}; |
|
console.log(`Looking for threads containing comment ${commentId}`); |
|
|
|
// Get review threads for this PR using GraphQL |
|
const graphqlQuery = ` |
|
query GetPullRequestThreads($owner: String!, $repo: String!, $prNumber: Int!) { |
|
repository(owner: $owner, name: $repo) { |
|
pullRequest(number: $prNumber) { |
|
reviewThreads(first: 100) { |
|
nodes { |
|
id |
|
isResolved |
|
comments(first: 10) { |
|
nodes { |
|
id |
|
databaseId |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
`; |
|
|
|
const variables = { |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
prNumber: context.issue.number |
|
}; |
|
|
|
try { |
|
// Use GraphQL to find threads |
|
const result = await github.graphql(graphqlQuery, variables); |
|
const threads = result.repository.pullRequest.reviewThreads.nodes; |
|
|
|
// Look for the thread containing our comment |
|
for (const thread of threads) { |
|
const hasComment = thread.comments.nodes.some( |
|
comment => comment.databaseId === commentId |
|
); |
|
|
|
if (hasComment && !thread.isResolved) { |
|
console.log(`Found thread containing comment ${commentId}, resolving it`); |
|
|
|
// Resolve the thread with GraphQL mutation |
|
const resolveResult = await github.graphql(` |
|
mutation ResolveThread($threadId: ID!) { |
|
resolveReviewThread(input: {threadId: $threadId}) { |
|
thread { |
|
id |
|
isResolved |
|
} |
|
} |
|
} |
|
`, { |
|
threadId: thread.id |
|
}); |
|
|
|
console.log(`Thread resolved: ${resolveResult.resolveReviewThread.thread.isResolved}`); |
|
return; |
|
} |
|
} |
|
|
|
// If we can't find the thread or resolve it, fall back to deleting the comment |
|
console.log("Couldn't find thread for comment or comment not in review thread, falling back to deletion"); |
|
await github.rest.issues.deleteComment({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
comment_id: commentId |
|
}); |
|
console.log('Deleted previous comment'); |
|
|
|
} catch (error) { |
|
console.error('Error resolving comment thread:', error); |
|
console.log('Falling back to comment deletion...'); |
|
|
|
try { |
|
await github.rest.issues.deleteComment({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
comment_id: commentId |
|
}); |
|
console.log('Deleted previous comment'); |
|
} catch (deleteError) { |
|
if (deleteError.status === 404) { |
|
console.log('Comment was already deleted or does not exist, continuing...'); |
|
} else { |
|
console.error('Error deleting comment:', deleteError); |
|
throw deleteError; |
|
} |
|
} |
|
} |
|
|
|
- name: Post review comment |
|
if: steps.check_label.outputs.has_label == 'true' && env.ISSUES_FOUND == 'true' |
|
uses: actions/github-script@v6 |
|
with: |
|
github-token: ${{ github.token }} |
|
script: | |
|
github.rest.issues.createComment({ |
|
owner: context.repo.owner, |
|
repo: context.repo.repo, |
|
issue_number: context.issue.number, |
|
body: `## Claude Code Review |
|
|
|
${process.env.RESULTS} |
|
|
|
_This review was generated automatically using Claude Code. API call cost: $${process.env.COST} USD_` |
|
}); |
|
console.log('Posted review comment with cost information'); |
|
|
|
- name: Handle review result |
|
if: steps.check_label.outputs.has_label == 'true' && env.ISSUES_FOUND == 'true' |
|
run: | |
|
echo "❌ Claude found some issues - see PR comment for details" |
|
exit 1 |