mirror of
https://github.com/pacnpal/Claude-code-review.git
synced 2025-12-20 12:11:09 -05:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e6f2771a | ||
|
|
f7570c7b51 | ||
|
|
9b21586040 | ||
|
|
ffacd0a72a | ||
|
|
7000f3c840 | ||
|
|
9d3fe9dc92 | ||
|
|
91d2ef3fcf | ||
|
|
9a80f461e3 | ||
|
|
44d9ee380f | ||
|
|
9855ca990b | ||
|
|
aa1ffd4d99 | ||
|
|
b516288da6 | ||
|
|
7fae28e1ff | ||
|
|
5258f1bd84 | ||
|
|
0c9c65f6d5 | ||
|
|
9014acfb36 | ||
|
|
3d441dcd1d | ||
|
|
183d49dd6a | ||
|
|
4c895868e7 | ||
|
|
47ead35362 | ||
|
|
785bb103c6 | ||
|
|
d45693ae1c | ||
|
|
a9173964c2 | ||
|
|
acd58d30ee | ||
|
|
d1c6a98994 | ||
|
|
e6777037d5 | ||
|
|
c6c00d8c95 | ||
|
|
45e3331bcb | ||
|
|
dd1b18e57a | ||
|
|
1b84aff159 | ||
|
|
5fb151d172 | ||
|
|
4e1bd4b9e5 | ||
|
|
7276f1e2c5 | ||
|
|
2c57b7a5ba | ||
|
|
096a52cda5 | ||
|
|
0356960aa8 | ||
|
|
a5bd0e4bcf | ||
|
|
e49cf9d908 | ||
|
|
a4b8566177 | ||
|
|
fc5d87701c | ||
|
|
eb556a6d23 | ||
|
|
7e5914e90c | ||
|
|
6c6cbaba84 | ||
|
|
9f9953664f | ||
|
|
ada4f6b4ed | ||
|
|
a5a4083f00 | ||
|
|
782f8cccd0 | ||
|
|
6a73dbfff6 | ||
|
|
52219d4d98 | ||
|
|
c555f5511a | ||
|
|
bd3279eb92 |
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -25,6 +25,14 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Commit built files
|
||||
run: |
|
||||
git config --global user.name 'claude-code-review[bot]'
|
||||
git config --global user.email 'claude-code-review[bot]@users.noreply.github.com'
|
||||
git add -f dist
|
||||
git commit -m 'Add built files'
|
||||
git push origin HEAD:main
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
357
README.md
357
README.md
@@ -1,46 +1,156 @@
|
||||
# Claude Code Review Action
|
||||
|
||||
A GitHub Action that performs automated code reviews using Claude AI.
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A GitHub Action that performs automated code reviews using Claude Sonnet 4.5, Anthropic's latest AI model for code analysis.
|
||||
|
||||
## Why Use Claude Code Review?
|
||||
|
||||
- **Instant Feedback**: Get AI-powered code reviews immediately on every pull request
|
||||
- **Consistent Quality**: Apply consistent review standards across your entire codebase
|
||||
- **Save Time**: Catch common issues before human review, allowing reviewers to focus on architecture and logic
|
||||
- **Learn & Improve**: Get educational feedback that helps developers improve their coding skills
|
||||
- **24/7 Availability**: Reviews happen automatically, even outside business hours
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Why Use Claude Code Review?](#why-use-claude-code-review)
|
||||
- [Features](#features)
|
||||
- [Usage](#usage)
|
||||
- [Setup](#setup)
|
||||
- [Inputs](#inputs)
|
||||
- [Outputs](#outputs)
|
||||
- [Review Format](#review-format)
|
||||
- [Technical Details](#technical-details)
|
||||
- [Limitations](#limitations)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Development](#development)
|
||||
- [Contributing](#contributing)
|
||||
- [FAQ](#frequently-asked-questions)
|
||||
- [License](#license)
|
||||
- [Support](#support)
|
||||
|
||||
## Features
|
||||
- Analyzes code changes in pull requests
|
||||
- Provides detailed feedback on code quality
|
||||
- Identifies potential issues and suggests improvements
|
||||
- Checks for security issues and best practices
|
||||
|
||||
- 🤖 **AI-Powered Reviews**: Leverages Claude Sonnet 4.5 (claude-sonnet-4-5-20250929) for intelligent code analysis
|
||||
- 🔍 **Comprehensive Analysis**: Examines code changes in pull requests thoroughly
|
||||
- 💡 **Detailed Feedback**: Provides actionable feedback on code quality and structure
|
||||
- 🐛 **Bug Detection**: Identifies potential issues and suggests improvements
|
||||
- 🔒 **Security Scanning**: Checks for security vulnerabilities and risks
|
||||
- ⚡ **Performance Insights**: Highlights performance implications of code changes
|
||||
- 📋 **Best Practices**: Ensures adherence to coding standards and best practices
|
||||
- 🎯 **Severity Ratings**: Categorizes issues by severity (Critical/High/Medium/Low)
|
||||
- 🔄 **Automatic Retries**: Built-in retry logic with exponential backoff for reliability
|
||||
- ⏱️ **Rate Limit Handling**: Automatically manages API rate limits
|
||||
|
||||
## Usage
|
||||
|
||||
Add this to your GitHub workflow file (e.g. `.github/workflows/review.yml`):
|
||||
|
||||
```yaml
|
||||
name: Code Review
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
# Run on new/updated PRs
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
# Allow manual triggers for existing PRs
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'Pull Request Number'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
code-review:
|
||||
runs-on: ubuntu-latest
|
||||
environment: development_environment
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: your-username/claude-code-review-action@v1
|
||||
- name: Run Claude Review
|
||||
uses: pacnpal/claude-code-review@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
```
|
||||
### Manual Trigger
|
||||
|
||||
For existing pull requests, you can manually trigger the review:
|
||||
|
||||
1. Click on "Claude Code Review" Action under the Actions tab
|
||||
2. Click "Run Workflow"
|
||||
3. Fill in the branch and pull request number
|
||||
4. Click "Run Workflow"
|
||||
|
||||
### Disabling Auto-Review
|
||||
|
||||
You can disable automatic reviews and only use manual triggers by setting `auto-review: false`:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Review
|
||||
uses: pacnpal/claude-code-review@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
auto-review: false # Disables automatic reviews
|
||||
```
|
||||
|
||||
You can also make it conditional based on labels or other criteria:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Review
|
||||
uses: pacnpal/claude-code-review@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
pr-number: ${{ github.event.pull_request.number || inputs.pr_number }}
|
||||
auto-review: ${{ contains(github.event.pull_request.labels.*.name, 'needs-review') }}
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create repository secret `ANTHROPIC_API_KEY` with your Claude API key from Anthropic
|
||||
2. The `GITHUB_TOKEN` is automatically provided by GitHub Actions
|
||||
### Prerequisites
|
||||
|
||||
- A GitHub repository
|
||||
- An Anthropic API key ([Get one here](https://console.anthropic.com/))
|
||||
- GitHub Actions enabled in your repository
|
||||
|
||||
### Configuration Steps
|
||||
|
||||
1. **Add Anthropic API Key**:
|
||||
- Go to your repository Settings → Secrets and variables → Actions
|
||||
- Click "New repository secret"
|
||||
- Name: `ANTHROPIC_API_KEY`
|
||||
- Value: Your Anthropic API key
|
||||
- Click "Add secret"
|
||||
|
||||
2. **GitHub Token**:
|
||||
- The `GITHUB_TOKEN` is automatically provided by GitHub Actions
|
||||
- No additional configuration needed
|
||||
|
||||
3. **Set Permissions**:
|
||||
- Ensure your workflow has proper permissions (see Usage example above)
|
||||
- Required permissions: `contents: read` and `pull-requests: write`
|
||||
- For organization repositories, you may need to enable workflows to create/approve pull requests in Settings
|
||||
|
||||
4. **Optional: Environment Protection**:
|
||||
- For additional security, create a GitHub Environment (e.g., `development_environment`)
|
||||
- Add `ANTHROPIC_API_KEY` as an environment secret instead of repository secret
|
||||
- This provides additional control and approval gates for sensitive operations
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -49,6 +159,7 @@ jobs:
|
||||
| `github-token` | GitHub token for API access | Yes | N/A |
|
||||
| `anthropic-key` | Anthropic API key for Claude | Yes | N/A |
|
||||
| `pr-number` | Pull request number to review | Yes | N/A |
|
||||
| `auto-review` | Enable automatic code reviews (set to `false` to skip) | No | `true` |
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -75,6 +186,90 @@ Each issue found includes:
|
||||
- Specific recommendations
|
||||
- Code examples where helpful
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Model Configuration
|
||||
|
||||
- **Model**: Claude Sonnet 4.5 (`claude-sonnet-4-5-20250929`)
|
||||
- **Max Tokens**: 4096 tokens per review
|
||||
- **Temperature**: 0.7 for balanced creativity and consistency
|
||||
- **Context**: 10 lines of context around each change (git diff -U10)
|
||||
|
||||
### Reliability Features
|
||||
|
||||
**Automatic Retry Logic**:
|
||||
- Network failures are automatically retried up to 4 times
|
||||
- Exponential backoff strategy (2s, 4s, 8s, 16s delays)
|
||||
- Non-retryable errors (401, 403, 400) fail immediately
|
||||
|
||||
**Timeout Protection**:
|
||||
- API requests timeout after 2 minutes
|
||||
- Prevents hanging workflows on slow responses
|
||||
|
||||
**Rate Limit Handling**:
|
||||
- Automatically respects `Retry-After` headers
|
||||
- Graceful handling of 429 rate limit responses
|
||||
|
||||
### Diff Size Limits
|
||||
|
||||
- **Maximum Diff Size**: ~100KB (100,000 bytes)
|
||||
- Large diffs are automatically truncated with a notice
|
||||
- Helps stay within API token limits and ensures fast reviews
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Diff Size**: Changes larger than ~100KB will be truncated
|
||||
- **Review Timeout**: Reviews exceeding 2 minutes will timeout
|
||||
- **PR State**: Can review closed PRs, but a warning is logged
|
||||
- **File Types**: All text-based files are analyzed; binary files in diffs are ignored
|
||||
- **API Costs**: Each review consumes Anthropic API credits based on diff size
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: "Failed to get PR details"
|
||||
- **Solution**: Ensure `GITHUB_TOKEN` has `pull-requests: read` permission
|
||||
- **Solution**: Verify the PR number is correct
|
||||
|
||||
**Issue**: "anthropic-key does not match expected format"
|
||||
- **Solution**: Verify your API key starts with `sk-ant-`
|
||||
- **Solution**: Check for extra spaces or newlines in the secret
|
||||
|
||||
**Issue**: "Diff is empty - no changes found"
|
||||
- **Solution**: This is expected when comparing identical commits
|
||||
- **Solution**: Ensure the PR has actual code changes
|
||||
|
||||
**Issue**: "Rate limited" or 429 errors
|
||||
- **Solution**: The action will automatically retry with backoff
|
||||
- **Solution**: Consider reducing review frequency if you hit limits often
|
||||
- **Solution**: Check your Anthropic API rate limits and quotas
|
||||
|
||||
**Issue**: "API request timed out"
|
||||
- **Solution**: This may happen with very large diffs (>100KB)
|
||||
- **Solution**: Break large PRs into smaller, more focused changes
|
||||
- **Solution**: The action will automatically truncate diffs that are too large
|
||||
|
||||
**Issue**: Action fails with authentication errors
|
||||
- **Solution**: Verify `ANTHROPIC_API_KEY` is set correctly in repository secrets
|
||||
- **Solution**: Ensure the API key is active and not expired
|
||||
- **Solution**: Check that your Anthropic account has available credits
|
||||
|
||||
### Debug Mode
|
||||
|
||||
To enable verbose logging, add this to your workflow:
|
||||
|
||||
```yaml
|
||||
- name: Run Claude Review
|
||||
uses: pacnpal/claude-code-review@main
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
env:
|
||||
ACTIONS_STEP_DEBUG: true
|
||||
```
|
||||
|
||||
## Example Review
|
||||
|
||||
```markdown
|
||||
@@ -94,24 +289,53 @@ Each issue found includes:
|
||||
|
||||
## Development
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies:
|
||||
### Local Development Setup
|
||||
|
||||
1. **Clone the repository**:
|
||||
```bash
|
||||
git clone https://github.com/pacnpal/claude-code-review.git
|
||||
cd claude-code-review
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Make changes to `action.js`
|
||||
3. **Make your changes**:
|
||||
- Edit `action.js` for core functionality
|
||||
- The built output goes to `dist/index.js`
|
||||
|
||||
4. Build the action:
|
||||
4. **Build the action**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
This compiles `action.js` into `dist/index.js` using [@vercel/ncc](https://github.com/vercel/ncc)
|
||||
|
||||
5. Run tests:
|
||||
5. **Run tests**:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
claude-code-review/
|
||||
├── action.js # Main action logic
|
||||
├── action.yml # Action metadata
|
||||
├── dist/ # Built output (committed)
|
||||
│ └── index.js # Compiled action
|
||||
├── package.json # Dependencies
|
||||
└── README.md # Documentation
|
||||
```
|
||||
|
||||
### Testing Changes
|
||||
|
||||
- Test your changes in a fork before submitting a PR
|
||||
- Use the `workflow_dispatch` trigger for manual testing
|
||||
- Ensure `npm run build` completes without errors
|
||||
- Verify all tests pass with `npm test`
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please:
|
||||
@@ -122,12 +346,95 @@ Contributions are welcome! Please:
|
||||
4. Run tests
|
||||
5. Submit a pull request
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
### How much does it cost?
|
||||
|
||||
The action is free, but you'll need an Anthropic API subscription. Costs vary based on:
|
||||
- Diff size (larger diffs = more tokens)
|
||||
- Review frequency (more PRs = more API calls)
|
||||
- Current Claude API pricing (check [Anthropic's pricing](https://www.anthropic.com/pricing))
|
||||
|
||||
Typical small PR review (~1-2KB diff) costs a few cents.
|
||||
|
||||
### Can I use this with private repositories?
|
||||
|
||||
Yes! The action works with both public and private repositories. Just ensure:
|
||||
- GitHub Actions is enabled for your repository
|
||||
- You've added the `ANTHROPIC_API_KEY` secret
|
||||
- Workflow permissions are properly configured
|
||||
|
||||
### Will this slow down my CI/CD pipeline?
|
||||
|
||||
The action runs asynchronously and doesn't block other checks. Typical review times:
|
||||
- Small PRs (<10KB): 10-30 seconds
|
||||
- Medium PRs (10-50KB): 30-60 seconds
|
||||
- Large PRs (50-100KB): 60-120 seconds
|
||||
|
||||
You can also use `auto-review: false` to only run reviews on-demand.
|
||||
|
||||
### Can I customize the review criteria?
|
||||
|
||||
Currently, the review criteria are built into the action. Future versions may support custom prompts. You can:
|
||||
- Fork the repository and modify `action.js` to customize the prompt
|
||||
- Use the review as a baseline and add custom checks with other actions
|
||||
|
||||
### Does it support languages other than English?
|
||||
|
||||
The action's prompts are in English, but Claude can analyze code in any programming language. Review comments will be in English.
|
||||
|
||||
### What happens if my diff is too large?
|
||||
|
||||
Diffs larger than ~100KB are automatically truncated. A notice is added to the review indicating truncation. For large PRs:
|
||||
- Consider breaking them into smaller, focused changes
|
||||
- The most important changes (first ~100KB) will still be reviewed
|
||||
|
||||
### Can I run this on forked PRs?
|
||||
|
||||
Yes, but be cautious with secrets. Use GitHub's `pull_request_target` event with proper security:
|
||||
- Don't expose secrets to untrusted code
|
||||
- Consider requiring manual approval for fork PRs
|
||||
- Review GitHub's [security hardening guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
||||
|
||||
### How do I prevent reviews on draft PRs?
|
||||
|
||||
Add a condition to your workflow:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
code-review:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
# ... rest of job
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see the [LICENSE](LICENSE) file for details
|
||||
|
||||
## Support
|
||||
|
||||
- Open an issue for bugs/feature requests
|
||||
- Submit a PR to contribute
|
||||
- Contact maintainers for other questions
|
||||
### Getting Help
|
||||
|
||||
- 🐛 **Bug Reports**: [Open an issue](https://github.com/pacnpal/claude-code-review/issues/new) with details and reproduction steps
|
||||
- 💡 **Feature Requests**: [Create an issue](https://github.com/pacnpal/claude-code-review/issues/new) describing your use case
|
||||
- 🤝 **Contributions**: Submit a PR following our contribution guidelines
|
||||
- 📧 **Questions**: Open a discussion or contact the maintainers
|
||||
|
||||
### Resources
|
||||
|
||||
- [Anthropic Claude Documentation](https://docs.anthropic.com/)
|
||||
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
|
||||
- [Action Marketplace Listing](https://github.com/marketplace/actions/claude-code-review)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Built with:
|
||||
- [Anthropic Claude API](https://www.anthropic.com/claude) - AI-powered code analysis
|
||||
- [GitHub Actions](https://github.com/features/actions) - CI/CD automation platform
|
||||
- [@actions/core](https://github.com/actions/toolkit/tree/main/packages/core) - GitHub Actions toolkit
|
||||
- [@vercel/ncc](https://github.com/vercel/ncc) - Node.js bundler
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ by PacNPal** | [Report Bug](https://github.com/pacnpal/claude-code-review/issues) | [Request Feature](https://github.com/pacnpal/claude-code-review/issues)
|
||||
|
||||
453
action.js
453
action.js
@@ -3,17 +3,110 @@ const core = require('@actions/core');
|
||||
const github = require('@actions/github');
|
||||
const { exec } = require('@actions/exec');
|
||||
|
||||
async function getPRDetails(octokit, context, prNumber) {
|
||||
// Constants for retry logic
|
||||
const MAX_RETRIES = 4;
|
||||
const INITIAL_BACKOFF_MS = 2000;
|
||||
const MAX_DIFF_SIZE = 100000; // ~100KB to stay well under API limits
|
||||
const API_TIMEOUT_MS = 120000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a function with exponential backoff
|
||||
* @param {Function} fn - Async function to retry
|
||||
* @param {string} operation - Description of operation for logging
|
||||
* @param {number} maxRetries - Maximum number of retries
|
||||
* @returns {Promise<any>} Result of the function
|
||||
*/
|
||||
async function retryWithBackoff(fn, operation, maxRetries = MAX_RETRIES) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(`Getting details for PR #${prNumber}`);
|
||||
if (attempt > 0) {
|
||||
const backoffMs = INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1);
|
||||
core.info(`Retry attempt ${attempt}/${maxRetries} for ${operation} after ${backoffMs}ms`);
|
||||
await sleep(backoffMs);
|
||||
}
|
||||
|
||||
// Get PR info
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Don't retry on authentication or validation errors
|
||||
if (error.status === 401 || error.status === 403 || error.status === 400) {
|
||||
core.error(`${operation} failed with non-retryable error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
core.warning(`${operation} failed (attempt ${attempt + 1}/${maxRetries + 1}): ${error.message}`);
|
||||
} else {
|
||||
core.error(`${operation} failed after ${maxRetries + 1} attempts`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate input parameters
|
||||
* @param {string} token - GitHub token
|
||||
* @param {string} anthropicKey - Anthropic API key
|
||||
* @param {string|number} prNumber - PR number
|
||||
*/
|
||||
function validateInputs(token, anthropicKey, prNumber) {
|
||||
if (!token || token.trim() === '') {
|
||||
throw new Error('github-token is required and cannot be empty');
|
||||
}
|
||||
|
||||
if (!anthropicKey || anthropicKey.trim() === '') {
|
||||
throw new Error('anthropic-key is required and cannot be empty');
|
||||
}
|
||||
|
||||
if (!anthropicKey.startsWith('sk-ant-')) {
|
||||
core.warning('anthropic-key does not match expected format (should start with sk-ant-)');
|
||||
}
|
||||
|
||||
const prNum = parseInt(prNumber);
|
||||
if (isNaN(prNum) || prNum <= 0) {
|
||||
throw new Error(`Invalid PR number: ${prNumber}. Must be a positive integer.`);
|
||||
}
|
||||
|
||||
core.info('✓ Input validation passed');
|
||||
return prNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PR details from GitHub API with retry logic
|
||||
* @param {Object} octokit - GitHub API client
|
||||
* @param {Object} context - GitHub context
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Promise<Object>} PR details
|
||||
*/
|
||||
async function getPRDetails(octokit, context, prNumber) {
|
||||
core.startGroup('Fetching PR details');
|
||||
|
||||
try {
|
||||
core.info(`Getting details for PR #${prNumber}`);
|
||||
|
||||
const { data: pr } = await retryWithBackoff(
|
||||
async () => await octokit.rest.pulls.get({
|
||||
...context.repo,
|
||||
pull_number: parseInt(prNumber)
|
||||
});
|
||||
pull_number: prNumber
|
||||
}),
|
||||
'Get PR details'
|
||||
);
|
||||
|
||||
return {
|
||||
const result = {
|
||||
number: pr.number,
|
||||
base: {
|
||||
sha: pr.base.sha,
|
||||
@@ -22,59 +115,154 @@ async function getPRDetails(octokit, context, prNumber) {
|
||||
head: {
|
||||
sha: pr.head.sha,
|
||||
ref: pr.head.ref
|
||||
}
|
||||
},
|
||||
title: pr.title,
|
||||
state: pr.state
|
||||
};
|
||||
|
||||
core.info(`✓ Retrieved PR #${result.number}: "${result.title}"`);
|
||||
core.info(` Base: ${result.base.ref} (${result.base.sha.substring(0, 7)})`);
|
||||
core.info(` Head: ${result.head.ref} (${result.head.sha.substring(0, 7)})`);
|
||||
core.debug(`PR state: ${result.state}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get PR details: ${error.message}`);
|
||||
core.error(`Failed to get PR details: ${error.message}`);
|
||||
throw new Error(`Failed to get PR details for #${prNumber}: ${error.message}`);
|
||||
} finally {
|
||||
core.endGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup git configuration with retry logic
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function setupGitConfig() {
|
||||
// Configure git to fetch PR refs
|
||||
await exec('git', ['config', '--local', '--add', 'remote.origin.fetch', '+refs/pull/*/head:refs/remotes/origin/pr/*']);
|
||||
await exec('git', ['fetch', 'origin']);
|
||||
}
|
||||
|
||||
async function getDiff(baseSha, headSha) {
|
||||
let diffContent = '';
|
||||
core.startGroup('Setting up Git configuration');
|
||||
|
||||
try {
|
||||
core.info('Configuring git to fetch PR refs...');
|
||||
await retryWithBackoff(
|
||||
async () => await exec('git', ['config', '--local', '--add', 'remote.origin.fetch', '+refs/pull/*/head:refs/remotes/origin/pr/*']),
|
||||
'Git config fetch refs'
|
||||
);
|
||||
|
||||
core.info('Fetching from origin...');
|
||||
await retryWithBackoff(
|
||||
async () => await exec('git', ['fetch', 'origin']),
|
||||
'Git fetch origin'
|
||||
);
|
||||
|
||||
core.info('Setting git user identity...');
|
||||
await exec('git', ['config', '--global', 'user.name', 'claude-code-review[bot]']);
|
||||
await exec('git', ['config', '--global', 'user.email', 'claude-code-review[bot]@users.noreply.github.com']);
|
||||
|
||||
core.info('✓ Git configuration completed');
|
||||
} catch (error) {
|
||||
core.error(`Git configuration failed: ${error.message}`);
|
||||
throw new Error(`Failed to configure git: ${error.message}`);
|
||||
} finally {
|
||||
core.endGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diff between two commits with size validation
|
||||
* @param {string} baseSha - Base commit SHA
|
||||
* @param {string} headSha - Head commit SHA
|
||||
* @returns {Promise<string>} Diff content
|
||||
*/
|
||||
async function getDiff(baseSha, headSha) {
|
||||
core.startGroup('Generating diff');
|
||||
|
||||
let diffContent = '';
|
||||
let stderr = '';
|
||||
|
||||
try {
|
||||
core.info(`Generating diff between ${baseSha.substring(0, 7)} and ${headSha.substring(0, 7)}`);
|
||||
|
||||
// Get the full diff with context
|
||||
await exec('git', ['diff', '-U10', baseSha, headSha], {
|
||||
listeners: {
|
||||
stdout: (data) => {
|
||||
diffContent += data.toString();
|
||||
},
|
||||
stderr: (data) => {
|
||||
stderr += data.toString();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Filter for relevant files
|
||||
const lines = diffContent.split('\n');
|
||||
let filtered = '';
|
||||
let keep = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('diff --git')) {
|
||||
keep = false;
|
||||
// Check if file type should be included
|
||||
if (line.match(/\.(js|ts|py|cpp|h|java|cs)$/) &&
|
||||
!line.match(/(package-lock\.json|yarn\.lock|\.md|\.json)/)) {
|
||||
keep = true;
|
||||
}
|
||||
}
|
||||
if (keep) {
|
||||
filtered += line + '\n';
|
||||
}
|
||||
if (stderr) {
|
||||
core.debug(`Git diff stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
const diffSize = Buffer.byteLength(diffContent, 'utf8');
|
||||
core.info(`✓ Diff generated: ${diffSize} bytes`);
|
||||
|
||||
if (diffSize === 0) {
|
||||
core.warning('Diff is empty - no changes found');
|
||||
return '';
|
||||
}
|
||||
|
||||
if (diffSize > MAX_DIFF_SIZE) {
|
||||
core.warning(`Diff size (${diffSize} bytes) exceeds maximum (${MAX_DIFF_SIZE} bytes)`);
|
||||
const truncated = diffContent.substring(0, MAX_DIFF_SIZE);
|
||||
const lines = truncated.split('\n').length;
|
||||
core.warning(`Diff truncated to ${MAX_DIFF_SIZE} bytes (~${lines} lines)`);
|
||||
return truncated + '\n\n[... diff truncated due to size ...]';
|
||||
}
|
||||
|
||||
const lines = diffContent.split('\n').length;
|
||||
core.info(`Diff contains ${lines} lines`);
|
||||
|
||||
return diffContent;
|
||||
} catch (error) {
|
||||
core.error(`Failed to generate diff: ${error.message}`);
|
||||
if (stderr) {
|
||||
core.error(`Git stderr: ${stderr}`);
|
||||
}
|
||||
throw new Error(`Failed to generate diff: ${error.message}`);
|
||||
} finally {
|
||||
core.endGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create fetch with timeout
|
||||
* @param {string} url - URL to fetch
|
||||
* @param {Object} options - Fetch options
|
||||
* @param {number} timeout - Timeout in milliseconds
|
||||
* @returns {Promise<Response>} Fetch response
|
||||
*/
|
||||
async function fetchWithTimeout(url, options, timeout = API_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze code diff with Claude API including retry logic and proper error handling
|
||||
* @param {string} diffContent - Code diff to analyze
|
||||
* @param {string} anthropicKey - Anthropic API key
|
||||
* @returns {Promise<string|null>} Code review text
|
||||
*/
|
||||
async function analyzeWithClaude(diffContent, anthropicKey) {
|
||||
if (!diffContent.trim()) {
|
||||
core.startGroup('Analyzing with Claude AI');
|
||||
|
||||
if (!diffContent || !diffContent.trim()) {
|
||||
core.warning('Diff content is empty, skipping analysis');
|
||||
core.endGroup();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -94,7 +282,9 @@ For each issue found:
|
||||
- Provide specific recommendations for fixes
|
||||
- Include code examples where helpful
|
||||
|
||||
If no issues are found in a particular area, explicitly state that.
|
||||
- If no issues are found in a particular area, explicitly state that.
|
||||
- If it's a dependency update, evaluate with strict scrutiny the implications of the change.
|
||||
- No matter your findings, give a summary of the pull request.
|
||||
|
||||
Here is the code diff to review:
|
||||
|
||||
@@ -103,7 +293,11 @@ ${diffContent}
|
||||
\`\`\``;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
core.info('Sending request to Claude API...');
|
||||
core.debug(`Prompt length: ${prompt.length} characters`);
|
||||
|
||||
const review = await retryWithBackoff(async () => {
|
||||
const response = await fetchWithTimeout('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -111,7 +305,7 @@ ${diffContent}
|
||||
'anthropic-version': '2023-06-01'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-3-sonnet-20240229',
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 4096,
|
||||
temperature: 0.7,
|
||||
messages: [{
|
||||
@@ -119,93 +313,216 @@ ${diffContent}
|
||||
content: prompt
|
||||
}]
|
||||
})
|
||||
});
|
||||
}, API_TIMEOUT_MS);
|
||||
|
||||
// Check HTTP status
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorMessage;
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorMessage = errorData.error?.message || errorData.message || errorText;
|
||||
} catch {
|
||||
errorMessage = errorText;
|
||||
}
|
||||
|
||||
// Create error with status for retry logic
|
||||
const error = new Error(`API returned ${response.status}: ${errorMessage}`);
|
||||
error.status = response.status;
|
||||
|
||||
// Handle rate limiting
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get('retry-after');
|
||||
if (retryAfter) {
|
||||
core.warning(`Rate limited. Retry after ${retryAfter} seconds`);
|
||||
error.retryAfter = parseInt(retryAfter) * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.content?.[0]?.text) {
|
||||
throw new Error(`API Error: ${JSON.stringify(data)}`);
|
||||
throw new Error(`Invalid API response: missing content. Response: ${JSON.stringify(data).substring(0, 200)}`);
|
||||
}
|
||||
|
||||
return data.content[0].text;
|
||||
}, 'Claude API request', 3); // Use fewer retries for API calls
|
||||
|
||||
const reviewLength = review.length;
|
||||
core.info(`✓ Received review from Claude: ${reviewLength} characters`);
|
||||
|
||||
return review;
|
||||
} catch (error) {
|
||||
throw new Error(`Claude API error: ${error.message}`);
|
||||
if (error.name === 'AbortError') {
|
||||
core.error(`Claude API request timed out after ${API_TIMEOUT_MS}ms`);
|
||||
throw new Error(`Claude API request timed out after ${API_TIMEOUT_MS / 1000} seconds`);
|
||||
}
|
||||
|
||||
core.error(`Claude API error: ${error.message}`);
|
||||
throw new Error(`Failed to analyze with Claude: ${error.message}`);
|
||||
} finally {
|
||||
core.endGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post code review as a comment on the PR with retry logic
|
||||
* @param {Object} octokit - GitHub API client
|
||||
* @param {Object} context - GitHub context
|
||||
* @param {string} review - Review text
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function postReview(octokit, context, review, prNumber) {
|
||||
try {
|
||||
// Escape special characters for proper formatting
|
||||
const escapedReview = review
|
||||
.replace(/(?<=[\s\n])`([^`]+)`(?=[\s\n])/g, '\\`$1\\`')
|
||||
.replace(/```/g, '\\`\\`\\`')
|
||||
.replace(/\${/g, '\\${');
|
||||
core.startGroup('Posting review comment');
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
try {
|
||||
core.info(`Posting review to PR #${prNumber}...`);
|
||||
core.debug(`Review length: ${review.length} characters`);
|
||||
|
||||
// GitHub markdown handles most content correctly without escaping
|
||||
// Only ensure the review doesn't break the comment
|
||||
const body = `# 🤖 Claude Code Review\n\n${review}`;
|
||||
|
||||
const comment = await retryWithBackoff(
|
||||
async () => await octokit.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: prNumber,
|
||||
body: `# Claude Code Review\n\n${escapedReview}`
|
||||
});
|
||||
body: body
|
||||
}),
|
||||
'Post review comment'
|
||||
);
|
||||
|
||||
core.info(`✓ Review posted successfully`);
|
||||
core.debug(`Comment ID: ${comment.data.id}`);
|
||||
core.debug(`Comment URL: ${comment.data.html_url}`);
|
||||
|
||||
} catch (error) {
|
||||
core.error(`Failed to post review comment: ${error.message}`);
|
||||
throw new Error(`Failed to post review: ${error.message}`);
|
||||
} finally {
|
||||
core.endGroup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main execution function
|
||||
*/
|
||||
async function run() {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
core.info('🚀 Starting Claude Code Review Action');
|
||||
core.info(`Node version: ${process.version}`);
|
||||
core.info(`Platform: ${process.platform}`);
|
||||
|
||||
// Get inputs
|
||||
const token = core.getInput('github-token', { required: true });
|
||||
const anthropicKey = core.getInput('anthropic-key', { required: true });
|
||||
let prNumber = core.getInput('pr-number');
|
||||
const autoReview = core.getInput('auto-review', { required: false }) || 'true';
|
||||
|
||||
// Check if auto-review is disabled
|
||||
if (autoReview.toLowerCase() === 'false') {
|
||||
core.info('⏭️ Auto-review is disabled, skipping code review');
|
||||
core.setOutput('diff_size', '0');
|
||||
core.setOutput('review', 'Auto-review disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get PR number from event if not provided
|
||||
const context = github.context;
|
||||
if (!prNumber && context.eventName === 'pull_request') {
|
||||
prNumber = context.payload.pull_request?.number;
|
||||
}
|
||||
|
||||
// Validate inputs
|
||||
const validatedPrNumber = validateInputs(token, anthropicKey, prNumber);
|
||||
|
||||
// Mask sensitive data in logs
|
||||
core.setSecret(anthropicKey);
|
||||
|
||||
// Initialize GitHub client
|
||||
core.info('Initializing GitHub client...');
|
||||
const octokit = github.getOctokit(token);
|
||||
const context = github.context;
|
||||
|
||||
// Get PR number from event or input
|
||||
let prNumber;
|
||||
if (context.eventName === 'pull_request') {
|
||||
prNumber = context.payload.pull_request.number;
|
||||
} else {
|
||||
prNumber = core.getInput('pr-number', { required: true });
|
||||
}
|
||||
core.info(`Repository: ${context.repo.owner}/${context.repo.repo}`);
|
||||
core.info(`Event: ${context.eventName}`);
|
||||
|
||||
// Set up git configuration
|
||||
await setupGitConfig();
|
||||
|
||||
// Get PR details
|
||||
const pr = await getPRDetails(octokit, context, prNumber);
|
||||
console.log(`Retrieved details for PR #${pr.number}`);
|
||||
const pr = await getPRDetails(octokit, context, validatedPrNumber);
|
||||
|
||||
// Validate PR state
|
||||
if (pr.state === 'closed') {
|
||||
core.warning(`PR #${pr.number} is closed. Review will still be posted.`);
|
||||
}
|
||||
|
||||
// Generate diff
|
||||
console.log('Generating diff...');
|
||||
const diff = await getDiff(pr.base.sha, pr.head.sha);
|
||||
|
||||
if (!diff) {
|
||||
console.log('No relevant changes found');
|
||||
if (!diff || diff.trim() === '') {
|
||||
core.warning('No changes found in diff');
|
||||
core.setOutput('diff_size', '0');
|
||||
core.setOutput('review', 'No changes to review');
|
||||
core.info('✓ Action completed (no changes to review)');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('diff_size', diff.length.toString());
|
||||
const diffSize = Buffer.byteLength(diff, 'utf8');
|
||||
core.setOutput('diff_size', diffSize.toString());
|
||||
|
||||
// Analyze with Claude
|
||||
console.log('Analyzing with Claude...');
|
||||
const review = await analyzeWithClaude(diff, anthropicKey);
|
||||
|
||||
if (!review) {
|
||||
console.log('No review generated');
|
||||
core.warning('No review generated by Claude');
|
||||
core.setOutput('review', '');
|
||||
core.info('✓ Action completed (no review generated)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Post review
|
||||
console.log('Posting review...');
|
||||
await postReview(octokit, context, review, pr.number);
|
||||
|
||||
// Set outputs
|
||||
core.setOutput('review', review);
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
core.info(`✓ Claude Code Review completed successfully in ${duration}s`);
|
||||
|
||||
} catch (error) {
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
core.error(`✗ Action failed after ${duration}s`);
|
||||
core.error(`Error: ${error.message}`);
|
||||
|
||||
if (error.stack) {
|
||||
core.debug(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
|
||||
core.setFailed(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle unhandled rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
core.error('Unhandled Rejection at:', promise);
|
||||
core.error('Reason:', reason);
|
||||
core.setFailed(`Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
core.error('Uncaught Exception:', error);
|
||||
core.setFailed(`Uncaught exception: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
run();
|
||||
@@ -11,6 +11,10 @@ inputs:
|
||||
pr-number:
|
||||
description: 'Pull request number'
|
||||
required: true
|
||||
auto-review:
|
||||
description: 'Enable automatic code reviews (set to false to skip review)'
|
||||
required: false
|
||||
default: 'true'
|
||||
outputs:
|
||||
diff_size:
|
||||
description: 'Size of the relevant code changes'
|
||||
@@ -18,4 +22,4 @@ outputs:
|
||||
description: 'Generated code review'
|
||||
runs:
|
||||
using: 'node20'
|
||||
main: 'action.js'
|
||||
main: 'dist/index.js'
|
||||
32368
dist/index.js
vendored
Normal file
32368
dist/index.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3157
package-lock.json
generated
3157
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -18,11 +18,17 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.11.1",
|
||||
"@actions/exec": "^1.1.1",
|
||||
"@actions/github": "^6.0.0",
|
||||
"node-fetch": "^3.3.2"
|
||||
"@actions/github": "^6.0.1",
|
||||
"glob": "^11.0.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vercel/ncc": "^0.38.3",
|
||||
"jest": "^29.7.0"
|
||||
"@vercel/ncc": "^0.38.4",
|
||||
"jest": "^30.2.0",
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"overrides": {
|
||||
"js-yaml": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user