mirror of
https://github.com/markwylde/claude-code-gitea-action.git
synced 2026-02-20 02:22:49 +08:00
Compare commits
30 Commits
main
...
merge/upst
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
246a22b0de | ||
|
|
54147e92b6 | ||
|
|
42d3e56b56 | ||
|
|
2c9043d65f | ||
|
|
957f0ddc6f | ||
|
|
4005d690a8 | ||
|
|
319e236b21 | ||
|
|
b4448a4e51 | ||
|
|
163b16a5a5 | ||
|
|
fd513046fa | ||
|
|
aaeb014ca6 | ||
|
|
56b03c7993 | ||
|
|
46a306ccf2 | ||
|
|
c6c6a613c8 | ||
|
|
2d1c93ebd2 | ||
|
|
87eac76ba0 | ||
|
|
96524bd1d8 | ||
|
|
0a1983379e | ||
|
|
90c7a171fc | ||
|
|
07ce5612a4 | ||
|
|
d2b03c9183 | ||
|
|
05a2e7ea87 | ||
|
|
4b26673a39 | ||
|
|
ccf7081358 | ||
|
|
5c040da573 | ||
|
|
e5b2574f8c | ||
|
|
799a5cd961 | ||
|
|
8406629c9f | ||
|
|
9714bd59a5 | ||
|
|
fb6df649ed |
17
CLAUDE.md
17
CLAUDE.md
@@ -56,3 +56,20 @@ src/
|
||||
- The action creates branches for issues and pushes to PR branches directly
|
||||
- All actions create OIDC tokens for secure authentication
|
||||
- Progress is tracked through dynamic comment updates with checkboxes
|
||||
|
||||
## MCP Tool Development
|
||||
|
||||
When adding new MCP tools:
|
||||
|
||||
1. **Add to MCP Server**: Implement the tool in the appropriate MCP server file (e.g., `src/mcp/local-git-ops-server.ts`)
|
||||
2. **Expose to Claude**: Add the tool name to `BASE_ALLOWED_TOOLS` array in `src/create-prompt/index.ts`
|
||||
3. **Tool Naming**: Follow the pattern `mcp__server_name__tool_name` (e.g., `mcp__local_git_ops__checkout_branch`)
|
||||
4. **Documentation**: Update the prompt's "What You CAN Do" section if the tool adds new capabilities
|
||||
|
||||
## Feature Development Reminders
|
||||
|
||||
When implementing new features that add action inputs, configuration options, or capabilities:
|
||||
|
||||
1. Always update README.md to document new inputs in the inputs table
|
||||
2. Update example workflows to show how new inputs can be used
|
||||
3. Add appropriate defaults and descriptions to action.yml
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
claude-code-action-coc@anthropic.com.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
116
CONTRIBUTING.md
116
CONTRIBUTING.md
@@ -1,116 +0,0 @@
|
||||
# Contributing to Claude Code Action
|
||||
|
||||
Thank you for your interest in contributing to Claude Code Action! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) runtime
|
||||
- [Docker](https://www.docker.com/) (for running GitHub Actions locally)
|
||||
- [act](https://github.com/nektos/act) (installed automatically by our test script)
|
||||
- An Anthropic API key (for testing)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Fork the repository on GitHub and clone your fork:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/claude-code-action.git
|
||||
cd claude-code-action
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
3. Set up your Anthropic API key:
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Available Scripts
|
||||
|
||||
- `bun test` - Run all tests
|
||||
- `bun run typecheck` - Type check the code
|
||||
- `bun run format` - Format code with Prettier
|
||||
- `bun run format:check` - Check code formatting
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests Locally
|
||||
|
||||
1. **Unit Tests**:
|
||||
|
||||
```bash
|
||||
bun test
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a new branch from `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
2. Make your changes and commit them:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
```
|
||||
|
||||
3. Run tests and formatting:
|
||||
|
||||
```bash
|
||||
bun test
|
||||
bun run typecheck
|
||||
bun run format:check
|
||||
```
|
||||
|
||||
4. Push your branch and create a Pull Request:
|
||||
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
5. Ensure all CI checks pass
|
||||
|
||||
6. Request review from maintainers
|
||||
|
||||
## Action Development
|
||||
|
||||
### Testing Your Changes
|
||||
|
||||
When modifying the action:
|
||||
|
||||
1. Test in a real GitHub Actions workflow by:
|
||||
- Creating a test repository
|
||||
- Using your branch as the action source:
|
||||
```yaml
|
||||
uses: your-username/claude-code-action@your-branch
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
- Use `console.log` for debugging in development
|
||||
- Check GitHub Actions logs for runtime issues
|
||||
- Use `act` with `-v` flag for verbose output:
|
||||
```bash
|
||||
act push -v --secret ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY"
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Docker Issues
|
||||
|
||||
Make sure Docker is running before using `act`. You can check with:
|
||||
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
175
FAQ.md
175
FAQ.md
@@ -1,175 +0,0 @@
|
||||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
This FAQ addresses common questions and gotchas when using the Claude Code GitHub Action.
|
||||
|
||||
## Triggering and Authentication
|
||||
|
||||
### Why doesn't tagging @claude from my automated workflow work?
|
||||
|
||||
The `github-actions` user cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user, or use a separate app token of your own. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow.
|
||||
|
||||
### Why does Claude say I don't have permission to trigger it?
|
||||
|
||||
Only users with **write permissions** to the repository can trigger Claude. This is a security feature to prevent unauthorized use. Make sure the user commenting has at least write access to the repository.
|
||||
|
||||
### Why can't I assign @claude to an issue on my repository?
|
||||
|
||||
If you're in a public repository, you should be able to assign to Claude without issue. If it's a private organization repository, you can only assign to users in your own organization, which Claude isn't. In this case, you'll need to make a custom user in that case.
|
||||
|
||||
### Why am I getting OIDC authentication errors?
|
||||
|
||||
If you're using the default GitHub App authentication, you must add the `id-token: write` permission to your workflow:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for OIDC authentication
|
||||
```
|
||||
|
||||
The OIDC token is required in order for the Claude GitHub app to function. If you wish to not use the GitHub app, you can instead provide a `github_token` input to the action for Claude to operate with. See the [Claude Code permissions documentation][perms] for more.
|
||||
|
||||
## Claude's Capabilities and Limitations
|
||||
|
||||
### Why won't Claude update workflow files when I ask it to?
|
||||
|
||||
The GitHub App for Claude doesn't have workflow write access for security reasons. This prevents Claude from modifying CI/CD configurations that could potentially create unintended consequences. This is something we may reconsider in the future.
|
||||
|
||||
### Why won't Claude rebase my branch?
|
||||
|
||||
By default, Claude only uses commit tools for non-destructive changes to the branch. Claude is configured to:
|
||||
|
||||
- Never push to branches other than where it was invoked (either its own branch or the PR branch)
|
||||
- Never force push or perform destructive operations
|
||||
|
||||
You can grant additional tools via the `allowed_tools` input if needed:
|
||||
|
||||
```yaml
|
||||
allowed_tools: "Bash(git rebase:*)" # Use with caution
|
||||
```
|
||||
|
||||
### Why won't Claude create a pull request?
|
||||
|
||||
Claude doesn't create PRs by default. Instead, it pushes commits to a branch and provides a link to a pre-filled PR submission page. This approach ensures your repository's branch protection rules are still adhered to and gives you final control over PR creation.
|
||||
|
||||
### Can Claude see my GitHub Actions CI results?
|
||||
|
||||
Yes! Claude can access GitHub Actions workflow runs, job logs, and test results on the PR where it's tagged. To enable this:
|
||||
|
||||
1. Add `actions: read` permission to your workflow:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
actions: read
|
||||
```
|
||||
|
||||
2. Configure the action with additional permissions:
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
```
|
||||
|
||||
Claude will then be able to analyze CI failures and help debug workflow issues. For running tests locally before commits, you can still instruct Claude to do so in your request.
|
||||
|
||||
### Why does Claude only update one comment instead of creating new ones?
|
||||
|
||||
Claude is configured to update a single comment to avoid cluttering PR/issue discussions. All of Claude's responses, including progress updates and final results, will appear in the same comment with checkboxes showing task progress.
|
||||
|
||||
## Branch and Commit Behavior
|
||||
|
||||
### Why did Claude create a new branch when commenting on a closed PR?
|
||||
|
||||
Claude's branch behavior depends on the context:
|
||||
|
||||
- **Open PRs**: Pushes directly to the existing PR branch
|
||||
- **Closed/Merged PRs**: Creates a new branch (cannot push to closed PR branches)
|
||||
- **Issues**: Always creates a new branch with a timestamp
|
||||
|
||||
### Why are my commits shallow/missing history?
|
||||
|
||||
For performance, Claude uses shallow clones:
|
||||
|
||||
- PRs: `--depth=20` (last 20 commits)
|
||||
- New branches: `--depth=1` (single commit)
|
||||
|
||||
If you need full history, you can configure this in your workflow before calling Claude in the `actions/checkout` step.
|
||||
|
||||
```
|
||||
- uses: actions/checkout@v4
|
||||
depth: 0 # will fetch full repo history
|
||||
```
|
||||
|
||||
## Configuration and Tools
|
||||
|
||||
### What's the difference between `direct_prompt` and `custom_instructions`?
|
||||
|
||||
These inputs serve different purposes in how Claude responds:
|
||||
|
||||
- **`direct_prompt`**: Bypasses trigger detection entirely. When provided, Claude executes this exact instruction regardless of comments or mentions. Perfect for automated workflows where you want Claude to perform a specific task on every run (e.g., "Update the API documentation based on changes in this PR").
|
||||
|
||||
- **`custom_instructions`**: Additional context added to Claude's system prompt while still respecting normal triggers. These instructions modify Claude's behavior but don't replace the triggering comment. Use this to give Claude standing instructions like "You have been granted additional tools for ...".
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
# Using direct_prompt - runs automatically without @claude mention
|
||||
direct_prompt: "Review this PR for security vulnerabilities"
|
||||
|
||||
# Using custom_instructions - still requires @claude trigger
|
||||
custom_instructions: "Focus on performance implications and suggest optimizations"
|
||||
```
|
||||
|
||||
### Why doesn't Claude execute my bash commands?
|
||||
|
||||
The Bash tool is **disabled by default** for security. To enable individual bash commands:
|
||||
|
||||
```yaml
|
||||
allowed_tools: "Bash(npm:*),Bash(git:*)" # Allows only npm and git commands
|
||||
```
|
||||
|
||||
### Can Claude work across multiple repositories?
|
||||
|
||||
No, Claude's GitHub app token is sandboxed to the current repository only. It cannot push to any other repositories. It can, however, read public repositories, but to get access to this, you must configure it with tools to do so.
|
||||
|
||||
## MCP Servers and Extended Functionality
|
||||
|
||||
### What MCP servers are available by default?
|
||||
|
||||
Claude Code Action automatically configures two MCP servers:
|
||||
|
||||
1. **GitHub MCP server**: For GitHub API operations
|
||||
2. **File operations server**: For advanced file manipulation
|
||||
|
||||
However, tools from these servers still need to be explicitly allowed via `allowed_tools`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### How can I debug what Claude is doing?
|
||||
|
||||
Check the GitHub Action log for Claude's run for the full execution trace.
|
||||
|
||||
### Why can't I trigger Claude with `@claude-mention` or `claude!`?
|
||||
|
||||
The trigger uses word boundaries, so `@claude` must be a complete word. Variations like `@claude-bot`, `@claude!`, or `claude@mention` won't work unless you customize the `trigger_phrase`.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always specify permissions explicitly** in your workflow file
|
||||
2. **Use GitHub Secrets** for API keys - never hardcode them
|
||||
3. **Be specific with `allowed_tools`** - only enable what's necessary
|
||||
4. **Test in a separate branch** before using on important PRs
|
||||
5. **Monitor Claude's token usage** to avoid hitting API limits
|
||||
6. **Review Claude's changes** carefully before merging
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues not covered here:
|
||||
|
||||
1. Check the [GitHub Issues](https://github.com/anthropics/claude-code-action/issues)
|
||||
2. Review the [example workflows](https://github.com/anthropics/claude-code-action#examples)
|
||||
|
||||
[perms]: https://docs.anthropic.com/en/docs/claude-code/settings#permissions
|
||||
234
MIGRATION.md
Normal file
234
MIGRATION.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Migration Guide: Pure Actions & Gitea Compatibility
|
||||
|
||||
This document outlines the changes made to migrate from GitHub App authentication to pure GitHub Actions and add Gitea compatibility.
|
||||
|
||||
## What Changed
|
||||
|
||||
### 1. Removed GitHub App Dependencies
|
||||
|
||||
- **Before**: Used OIDC token exchange with Anthropic's GitHub App service
|
||||
- **After**: Uses standard `GITHUB_TOKEN` from workflow environment
|
||||
- **Benefit**: No external dependencies, works with any Git provider
|
||||
|
||||
### 2. Self-Contained Implementation
|
||||
|
||||
- **Before**: Depended on external `anthropics/claude-code-base-action`
|
||||
- **After**: Includes built-in Claude execution engine
|
||||
- **Benefit**: Complete control over functionality, no external action dependencies
|
||||
|
||||
### 3. Gitea Compatibility
|
||||
|
||||
- **Before**: GitHub-specific triggers and authentication
|
||||
- **After**: Compatible with Gitea Actions (with some limitations)
|
||||
- **Benefit**: Works with self-hosted Gitea instances
|
||||
|
||||
## Required Changes for Existing Users
|
||||
|
||||
### Workflow Permissions
|
||||
|
||||
Update your workflow permissions:
|
||||
|
||||
```yaml
|
||||
# Before (GitHub App)
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
# After (Pure Actions)
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
```
|
||||
|
||||
### Required Token Input
|
||||
|
||||
Now required to explicitly provide a GitHub token:
|
||||
|
||||
```yaml
|
||||
# Before (optional)
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# After (required)
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
### 1. Basic Gitea Workflow
|
||||
|
||||
Use the example in `examples/gitea-claude.yml`:
|
||||
|
||||
```yaml
|
||||
name: Claude Assistant for Gitea
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
|
||||
jobs:
|
||||
claude-assistant:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Assistant
|
||||
uses: ./ # Adjust path as needed for your Gitea setup
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
### 2. Gitea Limitations
|
||||
|
||||
Be aware of these Gitea Actions limitations:
|
||||
|
||||
- **`issue_comment` on PRs**: May not trigger reliably in some Gitea versions
|
||||
- **`pull_request_review_comment`**: Limited support compared to GitHub
|
||||
- **GraphQL API**: Not supported - action automatically falls back to REST API
|
||||
- **Cross-repository access**: Token permissions may be more restrictive
|
||||
- **Workflow triggers**: Some advanced trigger conditions may not work
|
||||
- **Permission checking**: Simplified for Gitea compatibility
|
||||
|
||||
### 3. Gitea Workarounds
|
||||
|
||||
#### For PR Comments
|
||||
|
||||
Use `issue_comment` instead of `pull_request_review_comment`:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created] # This covers both issue and PR comments
|
||||
```
|
||||
|
||||
#### For Code Review Comments
|
||||
|
||||
Gitea has limited support for code review comment webhooks. Consider using:
|
||||
|
||||
- Regular issue comments on PRs
|
||||
- Manual trigger via issue assignment
|
||||
- Custom webhooks (advanced setup)
|
||||
|
||||
## Benefits of Migration
|
||||
|
||||
### 1. Simplified Authentication
|
||||
|
||||
- No OIDC token setup required
|
||||
- Uses standard workflow tokens
|
||||
- Works with custom GitHub tokens
|
||||
|
||||
### 2. Provider Independence
|
||||
|
||||
- No dependency on Anthropic's GitHub App service
|
||||
- Works with any Git provider supporting Actions
|
||||
- Self-contained functionality
|
||||
|
||||
### 3. Enhanced Control
|
||||
|
||||
- Direct control over Claude execution
|
||||
- Customizable tool management
|
||||
- Easier debugging and modifications
|
||||
|
||||
### 4. Gitea Support
|
||||
|
||||
- Compatible with self-hosted Gitea
|
||||
- Automatic fallback to REST API (no GraphQL dependency)
|
||||
- Simplified permission checking for Gitea environments
|
||||
- Reduced external dependencies
|
||||
- Standard Actions workflow patterns
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Token Permissions
|
||||
|
||||
**Error**: "GitHub token authentication failed"
|
||||
**Solution**: Ensure workflow has required permissions:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
```
|
||||
|
||||
#### 2. Gitea Trigger Issues
|
||||
|
||||
**Error**: Workflow not triggering on PR comments
|
||||
**Solution**: Use `issue_comment` instead of `pull_request_review_comment`
|
||||
|
||||
#### 3. Missing Dependencies
|
||||
|
||||
**Error**: "Module not found" or TypeScript errors
|
||||
**Solution**: Run `npm install` or `bun install` to update dependencies
|
||||
|
||||
### Gitea-Specific Issues
|
||||
|
||||
#### 1. Authentication Errors
|
||||
|
||||
**Error**: "Failed to check permissions: HttpError: Bad credentials"
|
||||
**Solution**: This is normal in Gitea environments. The action automatically detects Gitea and bypasses GitHub-specific permission checks.
|
||||
|
||||
#### 1a. User Profile API Errors
|
||||
|
||||
**Error**: "Prepare step failed with error: Visit Project" or "GET /users/{username} - 404"
|
||||
**Solution**: This occurs when Gitea's user profile API differs from GitHub's. The action automatically detects Gitea and skips user type validation.
|
||||
|
||||
#### 2. Limited Event Support
|
||||
|
||||
Some GitHub Events may not be fully supported in Gitea. Use basic triggers:
|
||||
|
||||
- `issue_comment` for comments
|
||||
- `issues` for issue events
|
||||
- `push` for code changes
|
||||
|
||||
#### 3. Token Scope Limitations
|
||||
|
||||
Gitea tokens may have different scope limitations. Ensure your Gitea instance allows:
|
||||
|
||||
- Repository write access
|
||||
- Issue/PR comment creation
|
||||
- Branch creation and updates
|
||||
|
||||
#### 4. GraphQL Not Supported
|
||||
|
||||
**Error**: GraphQL queries failing
|
||||
**Solution**: The action automatically detects Gitea and uses REST API instead of GraphQL. No manual configuration needed.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Update workflow permissions to include `write` access
|
||||
- [ ] Add `github_token` input to action configuration
|
||||
- [ ] Remove `id-token: write` permission if not used elsewhere
|
||||
- [ ] Test with GitHub Actions
|
||||
- [ ] Test with Gitea Actions (if applicable)
|
||||
- [ ] Update any custom triggers for Gitea compatibility
|
||||
- [ ] Verify token permissions in target environment
|
||||
|
||||
## Example Workflows
|
||||
|
||||
See the `examples/` directory for complete workflow examples:
|
||||
|
||||
- `claude.yml` - Updated GitHub Actions workflow
|
||||
- `gitea-claude.yml` - Gitea-compatible workflow
|
||||
515
README.md
515
README.md
@@ -1,127 +1,31 @@
|
||||

|
||||
# Claude Code Action for Gitea
|
||||
|
||||
# Claude Code Action
|
||||

|
||||
|
||||
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action listens for a trigger phrase in comments and activates Claude act on the request. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
|
||||
A Gitea action that provides a general-purpose [Claude Code](https://claude.ai/code) assistant for PRs and issues that can answer questions and implement code changes. It listens for a trigger phrase in comments and activates Claude to act on the request. Supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
|
||||
|
||||
> **Note**: This action is designed specifically for Gitea installations, using local git operations for optimal compatibility with Gitea's API capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Interactive Code Assistant**: Claude can answer questions about code, architecture, and programming
|
||||
- 🔍 **Code Review**: Analyzes PR changes and suggests improvements
|
||||
- ✨ **Code Implementation**: Can implement simple fixes, refactoring, and even new features
|
||||
- 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
|
||||
- 🛠️ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration)
|
||||
- 💬 **PR/Issue Integration**: Works seamlessly with Gitea comments and PR reviews
|
||||
- 🛠️ **Flexible Tool Access**: Access to Gitea APIs and file operations (additional tools can be enabled via configuration)
|
||||
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
||||
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
||||
|
||||
## Quickstart
|
||||
|
||||
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.
|
||||
|
||||
This command will guide you through setting up the GitHub app and required secrets.
|
||||
|
||||
**Note**:
|
||||
|
||||
- You must be a repository admin to install the GitHub app and add secrets
|
||||
- This quickstart method is only available for direct Anthropic API users. If you're using AWS Bedrock, please see the instructions below.
|
||||
|
||||
### Manual Setup (Direct API)
|
||||
## Setup
|
||||
|
||||
**Requirements**: You must be a repository admin to complete these steps.
|
||||
|
||||
1. Install the Claude GitHub app to your repository: https://github.com/apps/claude
|
||||
2. Add authentication to your repository secrets ([Learn how to use secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)):
|
||||
- Either `ANTHROPIC_API_KEY` for API key authentication
|
||||
- Or `CLAUDE_CODE_OAUTH_TOKEN` for OAuth token authentication (Pro and Max users can generate this by running `claude setup-token` locally)
|
||||
3. Copy the workflow file from [`examples/claude.yml`](./examples/claude.yml) into your repository's `.github/workflows/`
|
||||
|
||||
### Using a Custom GitHub App
|
||||
|
||||
If you prefer not to install the official Claude app, you can create your own GitHub App to use with this action. This gives you complete control over permissions and access.
|
||||
|
||||
**When you may want to use a custom GitHub App:**
|
||||
|
||||
- You need more restrictive permissions than the official app
|
||||
- Organization policies prevent installing third-party apps
|
||||
- You're using AWS Bedrock or Google Vertex AI
|
||||
|
||||
**Steps to create and use a custom GitHub App:**
|
||||
|
||||
1. **Create a new GitHub App:**
|
||||
|
||||
- Go to https://github.com/settings/apps (for personal apps) or your organization's settings
|
||||
- Click "New GitHub App"
|
||||
- Configure the app with these minimum permissions:
|
||||
- **Repository permissions:**
|
||||
- Contents: Read & Write
|
||||
- Issues: Read & Write
|
||||
- Pull requests: Read & Write
|
||||
- **Account permissions:** None required
|
||||
- Set "Where can this GitHub App be installed?" to your preference
|
||||
- Create the app
|
||||
|
||||
2. **Generate and download a private key:**
|
||||
|
||||
- After creating the app, scroll down to "Private keys"
|
||||
- Click "Generate a private key"
|
||||
- Download the `.pem` file (keep this secure!)
|
||||
|
||||
3. **Install the app on your repository:**
|
||||
|
||||
- Go to the app's settings page
|
||||
- Click "Install App"
|
||||
- Select the repositories where you want to use Claude
|
||||
|
||||
4. **Add the app credentials to your repository secrets:**
|
||||
|
||||
- Go to your repository's Settings → Secrets and variables → Actions
|
||||
- Add these secrets:
|
||||
- `APP_ID`: Your GitHub App's ID (found in the app settings)
|
||||
- `APP_PRIVATE_KEY`: The contents of the downloaded `.pem` file
|
||||
|
||||
5. **Update your workflow to use the custom app:**
|
||||
|
||||
```yaml
|
||||
name: Claude with Custom App
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# ... other triggers
|
||||
|
||||
jobs:
|
||||
claude-response:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Generate a token from your custom app
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
# Use Claude with your custom app's token
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ steps.app-token.outputs.token }}
|
||||
# ... other configuration
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
|
||||
- The custom app must have read/write permissions for Issues, Pull Requests, and Contents
|
||||
- Your app's token will have the exact permissions you configured, nothing more
|
||||
|
||||
For more information on creating GitHub Apps, see the [GitHub documentation](https://docs.github.com/en/apps/creating-github-apps).
|
||||
|
||||
## 📚 FAQ
|
||||
|
||||
Having issues or questions? Check out our [Frequently Asked Questions](./FAQ.md) for solutions to common problems and detailed explanations of Claude's capabilities and limitations.
|
||||
1. Add `ANTHROPIC_API_KEY` or `CLAUDE_CREDENTIALS` to your repository secrets
|
||||
2. Add `GITEA_TOKEN` to your repository secrets (a personal access token with repository read/write permissions)
|
||||
3. Copy the workflow file from [`examples/gitea-claude.yml`](./examples/gitea-claude.yml) into your repository's `.gitea/workflows/`
|
||||
|
||||
## Usage
|
||||
|
||||
Add a workflow file to your repository (e.g., `.github/workflows/claude.yml`):
|
||||
Add a workflow file to your repository (e.g., `.gitea/workflows/claude.yml`):
|
||||
|
||||
```yaml
|
||||
name: Claude Assistant
|
||||
@@ -139,195 +43,94 @@ jobs:
|
||||
claude-response:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
- uses: actions/checkout@v4
|
||||
- uses: markwylde/claude-code-gitea-action@v1.0.5
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Optional: set execution mode (default: tag)
|
||||
# mode: "tag"
|
||||
# Optional: add custom trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
# Optional: add assignee trigger for issues
|
||||
# assignee_trigger: "claude"
|
||||
# Optional: add label trigger for issues
|
||||
# label_trigger: "claude"
|
||||
# Optional: add custom environment variables (YAML format)
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
# DEBUG: true
|
||||
# API_URL: https://api.example.com
|
||||
# Optional: limit the number of conversation turns
|
||||
# max_turns: "5"
|
||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||
# additional_permissions: |
|
||||
# actions: read
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # if you want to use direct API
|
||||
claude_credentials: ${{ secrets.CLAUDE_CREDENTIALS }} # if you have a Claude Max subscription
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }} # could be another users token (specific Claude user?)
|
||||
claude_git_name: Claude # optional
|
||||
claude_git_email: claude@anthropic.com # optional
|
||||
```
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||
| `mode` | Execution mode: 'tag' (default - triggered by mentions/assignments), 'agent' (for automation with no trigger checking) | No | `tag` |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `override_prompt` | Complete replacement of Claude's prompt with custom template (supports variable substitution) | No | - |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
|
||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| Input | Description | Required | Default |
|
||||
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex). Set to 'use-oauth' when using claude_credentials | No\* | - |
|
||||
| `claude_credentials` | Claude OAuth credentials JSON for Claude AI Max subscription authentication | No | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `gitea_token` | Gitea token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `claude_git_name` | Git user.name for commits made by Claude | No | `Claude` |
|
||||
| `claude_git_email` | Git user.email for commits made by Claude | No | `claude@anthropic.com` |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
> **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration.
|
||||
|
||||
## Execution Modes
|
||||
## Claude Max Authentication
|
||||
|
||||
The action supports two execution modes, each optimized for different use cases:
|
||||
This action supports authentication using Claude Max OAuth credentials. This allows users with Claude Max subscriptions to use their existing authentication.
|
||||
|
||||
### Tag Mode (Default)
|
||||
### Setup
|
||||
|
||||
The traditional implementation mode that responds to @claude mentions, issue assignments, or labels.
|
||||
1. **Get OAuth Credentials**: Use Claude Code to generate OAuth credentials:
|
||||
|
||||
- **Triggers**: `@claude` mentions, issue assignment, label application
|
||||
- **Features**: Creates tracking comments with progress checkboxes, full implementation capabilities
|
||||
- **Use case**: General-purpose code implementation and Q&A
|
||||
```
|
||||
/auth-setup
|
||||
```
|
||||
|
||||
2. **Add Credentials to Repository**: Add the generated JSON credentials as a repository secret named `CLAUDE_CREDENTIALS`.
|
||||
|
||||
It should look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-xxx",
|
||||
"refreshToken": "sk-ant-xxx",
|
||||
"expiresAt": 1748707000000,
|
||||
"scopes": ["user:inference", "user:profile"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Configure Workflow**: Set up your workflow to use OAuth authentication:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
- uses: markwylde/claude-code-gitea-action@v1.0.5
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# mode: tag is the default
|
||||
anthropic_api_key: "use-oauth"
|
||||
claude_credentials: ${{ secrets.CLAUDE_CREDENTIALS }}
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
### Agent Mode
|
||||
When `anthropic_api_key` is set to `'use-oauth'`, the action will use the OAuth credentials provided in `claude_credentials` instead of a direct API key.
|
||||
|
||||
For automation and scheduled tasks without trigger checking.
|
||||
## Gitea Configuration
|
||||
|
||||
- **Triggers**: Always runs (no trigger checking)
|
||||
- **Features**: Perfect for scheduled tasks, works with `override_prompt`
|
||||
- **Use case**: Maintenance tasks, automated reporting, scheduled checks
|
||||
This action has been enhanced to work with Gitea installations. The main differences from GitHub are:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
mode: agent
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
override_prompt: |
|
||||
Check for outdated dependencies and create an issue if any are found.
|
||||
```
|
||||
1. **Local Git Operations**: Instead of using API-based file operations (which have limited support in Gitea), this action uses local git commands to create branches, commit files, and push changes.
|
||||
|
||||
See [`examples/claude-modes.yml`](./examples/claude-modes.yml) for complete examples of each mode.
|
||||
2. **API URL Configuration**: You must specify your Gitea server URL using the `gitea_api_url` input.
|
||||
|
||||
### Using Custom MCP Configuration
|
||||
### Gitea Setup Notes
|
||||
|
||||
The `mcp_config` input allows you to add custom MCP (Model Context Protocol) servers to extend Claude's capabilities. These servers merge with the built-in GitHub MCP servers.
|
||||
|
||||
#### Basic Example: Adding a Sequential Thinking Server
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-sequential-thinking"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed_tools: "mcp__sequential-thinking__sequentialthinking" # Important: Each MCP tool from your server must be listed here, comma-separated
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
#### Passing Secrets to MCP Servers
|
||||
|
||||
For MCP servers that require sensitive information like API keys or tokens, use GitHub Secrets in the environment variables:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"custom-api-server": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@example/api-server"],
|
||||
"env": {
|
||||
"API_KEY": "${{ secrets.CUSTOM_API_KEY }}",
|
||||
"BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
#### Using Python MCP Servers with uv
|
||||
|
||||
For Python-based MCP servers managed with `uv`, you need to specify the directory containing your server:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-python-server": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"${{ github.workspace }}/path/to/server/",
|
||||
"run",
|
||||
"server_file.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed_tools: "my-python-server__<tool_name>" # Replace <tool_name> with your server's tool names
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
For example, if your Python MCP server is at `mcp_servers/weather.py`, you would use:
|
||||
|
||||
```yaml
|
||||
"args":
|
||||
["--directory", "${{ github.workspace }}/mcp_servers/", "run", "weather.py"]
|
||||
```
|
||||
|
||||
**Important**:
|
||||
|
||||
- Always use GitHub Secrets (`${{ secrets.SECRET_NAME }}`) for sensitive values like API keys, tokens, or passwords. Never hardcode secrets directly in the workflow file.
|
||||
- Your custom servers will override any built-in servers with the same name.
|
||||
- Use a Gitea personal access token "GITEA_TOKEN"
|
||||
- The token needs repository read/write permissions
|
||||
- Claude will use local git operations for file changes and branch creation
|
||||
- Only PR creation and comment updates use the Gitea API
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -377,11 +180,11 @@ Claude can see and analyze images, making it easy to fix visual bugs or UI issue
|
||||
|
||||
### Custom Automations
|
||||
|
||||
These examples show how to configure Claude to act automatically based on GitHub events, without requiring manual @mentions.
|
||||
These examples show how to configure Claude to act automatically based on Gitea events, without requiring manual @mentions.
|
||||
|
||||
#### Supported GitHub Events
|
||||
#### Supported Gitea Events
|
||||
|
||||
This action supports the following GitHub events ([learn more GitHub event triggers](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)):
|
||||
This action supports the following Gitea events:
|
||||
|
||||
- `pull_request` - When PRs are opened or synchronized
|
||||
- `issue_comment` - When comments are created on issues or PRs
|
||||
@@ -403,7 +206,7 @@ on:
|
||||
- "src/api/**/*.ts"
|
||||
|
||||
steps:
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
- uses: markwylde/claude-code-gitea-action@v1.0.5
|
||||
with:
|
||||
direct_prompt: |
|
||||
Update the API documentation in README.md to reflect
|
||||
@@ -427,7 +230,7 @@ jobs:
|
||||
github.event.pull_request.user.login == 'developer1' ||
|
||||
github.event.pull_request.user.login == 'external-contributor'
|
||||
steps:
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
- uses: markwylde/claude-code-gitea-action@v1
|
||||
with:
|
||||
direct_prompt: |
|
||||
Please provide a thorough review of this pull request.
|
||||
@@ -475,7 +278,7 @@ The `override_prompt` feature supports these variables:
|
||||
4. **Branch Management**: Creates new PRs for human authors, pushes directly for Claude's own PRs
|
||||
5. **Communication**: Posts updates at every step to keep you informed
|
||||
|
||||
This action is built on top of [`anthropics/claude-code-base-action`](https://github.com/anthropics/claude-code-base-action).
|
||||
This action is built specifically for Gitea environments with local git operations support.
|
||||
|
||||
## Capabilities and Limitations
|
||||
|
||||
@@ -494,7 +297,7 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
||||
|
||||
### What Claude Cannot Do
|
||||
|
||||
- **Submit PR Reviews**: Claude cannot submit formal GitHub PR reviews
|
||||
- **Submit PR Reviews**: Claude cannot submit formal Gitea PR reviews
|
||||
- **Approve PRs**: For security reasons, Claude cannot approve pull requests
|
||||
- **Post Multiple Comments**: Claude only acts by updating its initial comment
|
||||
- **Execute Commands Outside Its Context**: Claude only has access to the repository and PR/issue context it's triggered in
|
||||
@@ -612,14 +415,14 @@ By default, Claude only has access to:
|
||||
|
||||
- File operations (reading, committing, editing files, read-only git commands)
|
||||
- Comment management (creating/updating comments)
|
||||
- Basic GitHub operations
|
||||
- Basic Gitea operations
|
||||
|
||||
Claude does **not** have access to execute arbitrary Bash commands by default. If you want Claude to run specific commands (e.g., npm install, npm test), you must explicitly allow them using the `allowed_tools` configuration:
|
||||
|
||||
**Note**: If your repository has a `.mcp.json` file in the root directory, Claude will automatically detect and use the MCP server tools defined there. However, these tools still need to be explicitly allowed via the `allowed_tools` configuration.
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
- uses: markwylde/claude-code-gitea-action@v1
|
||||
with:
|
||||
allowed_tools: |
|
||||
Bash(npm install)
|
||||
@@ -633,14 +436,14 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
**Note**: The base GitHub tools are always included. Use `allowed_tools` to add additional tools (including specific Bash commands), and `disallowed_tools` to prevent specific tools from being used.
|
||||
**Note**: The base Gitea tools are always included. Use `allowed_tools` to add additional tools (including specific Bash commands), and `disallowed_tools` to prevent specific tools from being used.
|
||||
|
||||
### Custom Model
|
||||
|
||||
Use a specific Claude model:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
- uses: markwylde/claude-code-gitea-action@v1
|
||||
with:
|
||||
# model: "claude-3-5-sonnet-20241022" # Optional: specify a different model
|
||||
# ... other inputs
|
||||
@@ -775,158 +578,29 @@ For a complete list of available settings and their descriptions, see the [Claud
|
||||
You can authenticate with Claude using any of these three methods:
|
||||
|
||||
1. Direct Anthropic API (default)
|
||||
2. Amazon Bedrock with OIDC authentication
|
||||
3. Google Vertex AI with OIDC authentication
|
||||
|
||||
For detailed setup instructions for AWS Bedrock and Google Vertex AI, see the [official documentation](https://docs.anthropic.com/en/docs/claude-code/github-actions#using-with-aws-bedrock-%26-google-vertex-ai).
|
||||
|
||||
**Note**:
|
||||
|
||||
- Bedrock and Vertex use OIDC authentication exclusively
|
||||
- AWS Bedrock automatically uses cross-region inference profiles for certain models
|
||||
- For cross-region inference profile models, you need to request and be granted access to the Claude models in all regions that the inference profile uses
|
||||
|
||||
### Model Configuration
|
||||
|
||||
Use provider-specific model names based on your chosen provider:
|
||||
|
||||
```yaml
|
||||
# For direct Anthropic API (default)
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# ... other inputs
|
||||
|
||||
# For Amazon Bedrock with OIDC
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
model: "anthropic.claude-3-7-sonnet-20250219-beta:0" # Cross-region inference
|
||||
use_bedrock: "true"
|
||||
# ... other inputs
|
||||
|
||||
# For Google Vertex AI with OIDC
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
model: "claude-3-7-sonnet@20250219"
|
||||
use_vertex: "true"
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
### OIDC Authentication for Bedrock and Vertex
|
||||
|
||||
Both AWS Bedrock and GCP Vertex AI require OIDC authentication.
|
||||
|
||||
```yaml
|
||||
# For AWS Bedrock with OIDC
|
||||
- name: Configure AWS Credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
model: "anthropic.claude-3-7-sonnet-20250219-beta:0"
|
||||
use_bedrock: "true"
|
||||
# ... other inputs
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
```
|
||||
|
||||
```yaml
|
||||
# For GCP Vertex AI with OIDC
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
model: "claude-3-7-sonnet@20250219"
|
||||
use_vertex: "true"
|
||||
# ... other inputs
|
||||
|
||||
permissions:
|
||||
id-token: write # Required for OIDC
|
||||
```
|
||||
2. Anthropic OAuth credentials (Claude Max subscription)
|
||||
|
||||
## Security
|
||||
|
||||
### Access Control
|
||||
|
||||
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
||||
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
|
||||
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
||||
- **No Bot Triggers**: Bots cannot trigger this action
|
||||
- **Token Permissions**: The Gitea token is scoped specifically to the repository it's operating in
|
||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||
|
||||
### GitHub App Permissions
|
||||
### Gitea Token Permissions
|
||||
|
||||
The [Claude Code GitHub app](https://github.com/apps/claude) requires these permissions:
|
||||
The Gitea personal access token requires these permissions:
|
||||
|
||||
- **Pull Requests**: Read and write to create PRs and push changes
|
||||
- **Issues**: Read and write to respond to issues
|
||||
- **Contents**: Read and write to modify repository files
|
||||
|
||||
### Commit Signing
|
||||
### Authentication Security
|
||||
|
||||
All commits made by Claude through this action are automatically signed with commit signatures. This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||
|
||||
### ⚠️ Authentication Protection
|
||||
|
||||
**CRITICAL: Never hardcode your Anthropic API key or OAuth token in workflow files!**
|
||||
|
||||
Your authentication credentials must always be stored in GitHub secrets to prevent unauthorized access:
|
||||
|
||||
```yaml
|
||||
# CORRECT ✅
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# OR
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# NEVER DO THIS ❌
|
||||
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
||||
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
|
||||
```
|
||||
|
||||
### Setting Up GitHub Secrets
|
||||
|
||||
1. Go to your repository's Settings
|
||||
2. Click on "Secrets and variables" → "Actions"
|
||||
3. Click "New repository secret"
|
||||
4. For authentication, choose one:
|
||||
- API Key: Name: `ANTHROPIC_API_KEY`, Value: Your Anthropic API key (starting with `sk-ant-`)
|
||||
- OAuth Token: Name: `CLAUDE_CODE_OAUTH_TOKEN`, Value: Your Claude Code OAuth token (Pro and Max users can generate this by running `claude setup-token` locally)
|
||||
5. Click "Add secret"
|
||||
|
||||
### Best Practices for Authentication
|
||||
|
||||
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` or `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` in workflows
|
||||
2. ✅ Never commit API keys or tokens to version control
|
||||
3. ✅ Regularly rotate your API keys and tokens
|
||||
4. ✅ Use environment secrets for organization-wide access
|
||||
5. ❌ Never share API keys or tokens in pull requests or issues
|
||||
6. ❌ Avoid logging workflow variables that might contain keys
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**⚠️ IMPORTANT: Never commit API keys directly to your repository! Always use GitHub Actions secrets.**
|
||||
**⚠️ IMPORTANT: Never commit API keys directly to your repository! Always use Gitea Actions secrets.**
|
||||
|
||||
To securely use your Anthropic API key:
|
||||
|
||||
@@ -953,12 +627,11 @@ anthropic_api_key: "sk-ant-..."
|
||||
**Always do this:**
|
||||
|
||||
```yaml
|
||||
# ✅ CORRECT - Uses GitHub secrets
|
||||
# ✅ CORRECT - Uses Gitea secrets
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
```
|
||||
|
||||
This applies to all sensitive values including API keys, access tokens, and credentials.
|
||||
We also recommend that you always use short-lived tokens when possible
|
||||
|
||||
## License
|
||||
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@@ -1,15 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
Thank you for helping us keep this action and the systems they interact with secure.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
This repository is maintained by [Anthropic](https://www.anthropic.com/).
|
||||
|
||||
The security of our systems and user data is Anthropic’s top priority. We appreciate the work of security researchers acting in good faith in identifying and reporting potential vulnerabilities.
|
||||
|
||||
Our security program is managed on HackerOne and we ask that any validated vulnerability in this functionality be reported through their [submission form](https://hackerone.com/anthropic-vdp/reports/new?type=team&report_type=vulnerability).
|
||||
|
||||
## Vulnerability Disclosure Program
|
||||
|
||||
Our Vulnerability Program Guidelines are defined on our [HackerOne program page](https://hackerone.com/anthropic-vdp).
|
||||
134
action.yml
134
action.yml
@@ -77,13 +77,13 @@ inputs:
|
||||
|
||||
# Auth configuration
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex). Set to 'use-oauth' when using claude_credentials"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||
claude_credentials:
|
||||
description: "Claude OAuth credentials JSON for Claude AI Max subscription authentication"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token with repo and pull request permissions (optional if using GitHub App)"
|
||||
gitea_token:
|
||||
description: "Gitea token with repo and pull request permissions (defaults to GITHUB_TOKEN)"
|
||||
required: false
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
@@ -102,18 +102,14 @@ inputs:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "30"
|
||||
use_sticky_comment:
|
||||
description: "Use just one comment to deliver issue/PR comments"
|
||||
claude_git_name:
|
||||
description: "Git user.name for commits made by Claude"
|
||||
required: false
|
||||
default: "false"
|
||||
use_commit_signing:
|
||||
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||
default: "Claude"
|
||||
claude_git_email:
|
||||
description: "Git user.email for commits made by Claude"
|
||||
required: false
|
||||
default: "false"
|
||||
experimental_allowed_domains:
|
||||
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||
required: false
|
||||
default: ""
|
||||
default: "claude@anthropic.com"
|
||||
|
||||
outputs:
|
||||
execution_file:
|
||||
@@ -153,35 +149,12 @@ runs:
|
||||
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
||||
DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.gitea_token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
ACTIONS_TOKEN: ${{ github.token }}
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
|
||||
- name: Install Base Action Dependencies
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Installing base-action dependencies..."
|
||||
cd ${GITHUB_ACTION_PATH}/base-action
|
||||
bun install
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
# Install Claude Code globally
|
||||
bun install -g @anthropic-ai/claude-code@1.0.61
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
shell: bash
|
||||
run: |
|
||||
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
|
||||
env:
|
||||
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
|
||||
GITEA_API_URL: ${{ env.GITHUB_SERVER_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CREDENTIALS: ${{ inputs.claude_credentials }}
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
@@ -192,47 +165,40 @@ runs:
|
||||
# Run the base-action
|
||||
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
|
||||
env:
|
||||
# Base-action inputs
|
||||
CLAUDE_CODE_ACTION: "1"
|
||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||
INPUT_ALLOWED_TOOLS: ${{ env.ALLOWED_TOOLS }}
|
||||
INPUT_DISALLOWED_TOOLS: ${{ env.DISALLOWED_TOOLS }}
|
||||
INPUT_MAX_TURNS: ${{ inputs.max_turns }}
|
||||
INPUT_MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }}
|
||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||
INPUT_SYSTEM_PROMPT: ""
|
||||
INPUT_APPEND_SYSTEM_PROMPT: ""
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||
|
||||
# Model configuration
|
||||
# Core configuration
|
||||
PROMPT_FILE: /tmp/claude-prompts/claude-prompt.txt
|
||||
ALLOWED_TOOLS: ${{ env.ALLOWED_TOOLS }}
|
||||
DISALLOWED_TOOLS: ${{ env.DISALLOWED_TOOLS }}
|
||||
TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||
DETAILED_PERMISSION_MESSAGES: "1"
|
||||
|
||||
# Provider configuration
|
||||
MCP_CONFIG: ${{ steps.prepare.outputs.mcp_config }}
|
||||
USE_BEDROCK: ${{ inputs.use_bedrock }}
|
||||
USE_VERTEX: ${{ inputs.use_vertex }}
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
CLAUDE_CODE_USE_BEDROCK: ${{ inputs.use_bedrock == 'true' && '1' || '' }}
|
||||
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use_vertex == 'true' && '1' || '' }}
|
||||
CLAUDE_CREDENTIALS: ${{ inputs.claude_credentials }}
|
||||
|
||||
# AWS configuration
|
||||
# GitHub token for repository access
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
GITEA_API_URL: ${{ env.GITHUB_SERVER_URL }}
|
||||
|
||||
# Git configuration
|
||||
CLAUDE_GIT_NAME: ${{ inputs.claude_git_name }}
|
||||
CLAUDE_GIT_EMAIL: ${{ inputs.claude_git_email }}
|
||||
|
||||
# Provider configuration (for future cloud provider support)
|
||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||
AWS_REGION: ${{ env.AWS_REGION }}
|
||||
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_SESSION_TOKEN: ${{ env.AWS_SESSION_TOKEN }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL || (env.AWS_REGION && format('https://bedrock-runtime.{0}.amazonaws.com', env.AWS_REGION)) }}
|
||||
ANTHROPIC_BEDROCK_BASE_URL: ${{ env.ANTHROPIC_BEDROCK_BASE_URL }}
|
||||
|
||||
# GCP configuration
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: ${{ env.ANTHROPIC_VERTEX_PROJECT_ID }}
|
||||
CLOUD_ML_REGION: ${{ env.CLOUD_ML_REGION }}
|
||||
GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }}
|
||||
ANTHROPIC_VERTEX_BASE_URL: ${{ env.ANTHROPIC_VERTEX_BASE_URL }}
|
||||
|
||||
# Model-specific regions for Vertex
|
||||
VERTEX_REGION_CLAUDE_3_5_HAIKU: ${{ env.VERTEX_REGION_CLAUDE_3_5_HAIKU }}
|
||||
VERTEX_REGION_CLAUDE_3_5_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_5_SONNET }}
|
||||
VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }}
|
||||
@@ -258,33 +224,17 @@ runs:
|
||||
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
|
||||
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
|
||||
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
GITEA_API_URL: ${{ env.GITHUB_SERVER_URL }}
|
||||
|
||||
- name: Display Claude Code Report
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||
shell: bash
|
||||
run: |
|
||||
# Try to format the turns, but if it fails, dump the raw JSON
|
||||
if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
|
||||
echo "Successfully formatted Claude Code report"
|
||||
else
|
||||
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ -f "${{ steps.claude-code.outputs.execution_file }}" ]; then
|
||||
echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "⚠️ Claude Code execution completed but no report file was generated" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Revoke app token
|
||||
if: always() && inputs.github_token == ''
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L \
|
||||
-X DELETE \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
${GITHUB_API_URL:-https://api.github.com}/installation/token
|
||||
|
||||
BIN
assets/preview.png
Normal file
BIN
assets/preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 418 KiB |
BIN
assets/spinner.gif
Normal file
BIN
assets/spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -19,10 +19,9 @@ jobs:
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -32,6 +31,7 @@ jobs:
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
43
examples/gitea-claude.yml
Normal file
43
examples/gitea-claude.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Claude Assistant for Gitea
|
||||
|
||||
on:
|
||||
# Trigger on issue comments (works on both issues and pull requests in Gitea)
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# Trigger on issues being opened or assigned
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
# Note: pull_request_review_comment has limited support in Gitea
|
||||
# Use issue_comment instead which covers PR comments
|
||||
|
||||
jobs:
|
||||
claude-assistant:
|
||||
# Basic trigger detection - check for @claude in comments or issue body
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
# Note: Gitea Actions may not require id-token: write for basic functionality
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Assistant
|
||||
uses: markwylde/claude-code-gitea-action
|
||||
with:
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
trigger_phrase: "@claude"
|
||||
# Optional: Customize for Gitea environment
|
||||
custom_instructions: |
|
||||
You are working in a Gitea environment. Be aware that:
|
||||
- Some GitHub Actions features may behave differently
|
||||
- Focus on core functionality and avoid advanced GitHub-specific features
|
||||
- Use standard git operations when possible
|
||||
1988
package-lock.json
generated
Normal file
1988
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,8 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/sdk": "^0.30.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/webhooks-types": "^7.6.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"zod": "^3.24.4"
|
||||
|
||||
63
src/claude/oauth-setup.ts
Normal file
63
src/claude/oauth-setup.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
interface OAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface ClaudeCredentialsInput {
|
||||
claudeAiOauth: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scopes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export async function setupOAuthCredentials(credentialsJson: string) {
|
||||
try {
|
||||
// Parse the credentials JSON
|
||||
const parsedCredentials: ClaudeCredentialsInput =
|
||||
JSON.parse(credentialsJson);
|
||||
|
||||
if (!parsedCredentials.claudeAiOauth) {
|
||||
throw new Error("Invalid credentials format: missing claudeAiOauth");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, expiresAt } =
|
||||
parsedCredentials.claudeAiOauth;
|
||||
|
||||
if (!accessToken || !refreshToken || !expiresAt) {
|
||||
throw new Error(
|
||||
"Invalid credentials format: missing required OAuth fields",
|
||||
);
|
||||
}
|
||||
|
||||
const claudeDir = join(homedir(), ".claude");
|
||||
const credentialsPath = join(claudeDir, ".credentials.json");
|
||||
|
||||
// Create the .claude directory if it doesn't exist
|
||||
await mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Create the credentials JSON structure
|
||||
const credentialsData = {
|
||||
claudeAiOauth: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt,
|
||||
scopes: ["user:inference", "user:profile"],
|
||||
},
|
||||
};
|
||||
|
||||
// Write the credentials file
|
||||
await writeFile(credentialsPath, JSON.stringify(credentialsData, null, 2));
|
||||
|
||||
console.log(`OAuth credentials written to ${credentialsPath}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Failed to setup OAuth credentials: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
} from "../github/context";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import type { Mode, ModeContext } from "../modes/types";
|
||||
import { GITEA_SERVER_URL } from "../github/api/config";
|
||||
export type { CommonFields, PreparedContext } from "./types";
|
||||
|
||||
const BASE_ALLOWED_TOOLS = [
|
||||
@@ -31,49 +30,43 @@ const BASE_ALLOWED_TOOLS = [
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
"mcp__local_git_ops__commit_files",
|
||||
"mcp__local_git_ops__delete_files",
|
||||
"mcp__local_git_ops__push_branch",
|
||||
"mcp__local_git_ops__create_pull_request",
|
||||
"mcp__local_git_ops__checkout_branch",
|
||||
"mcp__local_git_ops__create_branch",
|
||||
"mcp__local_git_ops__git_status",
|
||||
"mcp__gitea__get_issue",
|
||||
"mcp__gitea__get_issue_comments",
|
||||
"mcp__gitea__add_issue_comment",
|
||||
"mcp__gitea__update_issue_comment",
|
||||
"mcp__gitea__delete_issue_comment",
|
||||
"mcp__gitea__get_comment",
|
||||
"mcp__gitea__list_issues",
|
||||
"mcp__gitea__create_issue",
|
||||
"mcp__gitea__update_issue",
|
||||
"mcp__gitea__get_repository",
|
||||
"mcp__gitea__list_pull_requests",
|
||||
"mcp__gitea__get_pull_request",
|
||||
"mcp__gitea__create_pull_request",
|
||||
"mcp__gitea__update_pull_request",
|
||||
"mcp__gitea__update_pull_request_comment",
|
||||
"mcp__gitea__merge_pull_request",
|
||||
"mcp__gitea__update_pull_request_branch",
|
||||
"mcp__gitea__check_pull_request_merged",
|
||||
"mcp__gitea__set_issue_branch",
|
||||
"mcp__gitea__list_branches",
|
||||
"mcp__gitea__get_branch",
|
||||
"mcp__gitea__delete_file",
|
||||
];
|
||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||
|
||||
export function buildAllowedToolsString(
|
||||
customAllowedTools?: string[],
|
||||
includeActionsTools: boolean = false,
|
||||
useCommitSigning: boolean = false,
|
||||
): string {
|
||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||
|
||||
// Always include the comment update tool from the comment server
|
||||
baseTools.push("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Add commit signing tools if enabled
|
||||
if (useCommitSigning) {
|
||||
baseTools.push(
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
} else {
|
||||
// When not using commit signing, add specific Bash git commands only
|
||||
baseTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git rm:*)",
|
||||
"Bash(git config user.name:*)",
|
||||
"Bash(git config user.email:*)",
|
||||
);
|
||||
}
|
||||
|
||||
// Add GitHub Actions MCP tools if enabled
|
||||
if (includeActionsTools) {
|
||||
baseTools.push(
|
||||
"mcp__github_ci__get_ci_status",
|
||||
"mcp__github_ci__get_workflow_run_details",
|
||||
"mcp__github_ci__download_job_log",
|
||||
);
|
||||
}
|
||||
|
||||
let allAllowedTools = baseTools.join(",");
|
||||
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||
@@ -241,8 +234,6 @@ export function prepareContext(
|
||||
...(baseBranch && { baseBranch }),
|
||||
};
|
||||
break;
|
||||
} else if (!claudeBranch) {
|
||||
throw new Error("CLAUDE_BRANCH is required for issue_comment event");
|
||||
} else if (!baseBranch) {
|
||||
throw new Error("BASE_BRANCH is required for issue_comment event");
|
||||
} else if (!issueNumber) {
|
||||
@@ -255,10 +246,10 @@ export function prepareContext(
|
||||
eventName: "issue_comment",
|
||||
commentId,
|
||||
isPR: false,
|
||||
claudeBranch: claudeBranch,
|
||||
baseBranch,
|
||||
issueNumber,
|
||||
commentBody,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
break;
|
||||
|
||||
@@ -275,9 +266,6 @@ export function prepareContext(
|
||||
if (!baseBranch) {
|
||||
throw new Error("BASE_BRANCH is required for issues event");
|
||||
}
|
||||
if (!claudeBranch) {
|
||||
throw new Error("CLAUDE_BRANCH is required for issues event");
|
||||
}
|
||||
|
||||
if (eventAction === "assigned") {
|
||||
if (!assigneeTrigger && !directPrompt) {
|
||||
@@ -291,8 +279,8 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
...(assigneeTrigger && { assigneeTrigger }),
|
||||
assigneeTrigger,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
} else if (eventAction === "labeled") {
|
||||
if (!labelTrigger) {
|
||||
@@ -314,7 +302,7 @@ export function prepareContext(
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
...(claudeBranch && { claudeBranch }),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported issue action: ${eventAction}`);
|
||||
@@ -565,7 +553,7 @@ export function generatePrompt(
|
||||
? `
|
||||
|
||||
<images_info>
|
||||
Images have been downloaded from GitHub comments and saved to disk. Their file paths are included in the formatted comments and body above. You can use the Read tool to view these images.
|
||||
Images have been downloaded from Gitea comments and saved to disk. Their file paths are included in the formatted comments and body above. You can use the Read tool to view these images.
|
||||
</images_info>`
|
||||
: "";
|
||||
|
||||
@@ -573,7 +561,7 @@ Images have been downloaded from GitHub comments and saved to disk. Their file p
|
||||
? formatBody(contextData.body, imageUrlMap)
|
||||
: "No description provided";
|
||||
|
||||
let promptContent = `You are Claude, an AI assistant designed to help with GitHub issues and pull requests. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
|
||||
let promptContent = `You are Claude, an AI assistant designed to help with Gitea issues and pull requests. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
|
||||
|
||||
<formatted_context>
|
||||
${formattedContext}
|
||||
@@ -627,29 +615,44 @@ ${sanitizeContent(context.directPrompt)}
|
||||
</direct_prompt>`
|
||||
: ""
|
||||
}
|
||||
${`<comment_tool_info>
|
||||
IMPORTANT: You have been provided with the mcp__github_comment__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments.
|
||||
${
|
||||
eventData.eventName === "pull_request_review_comment"
|
||||
? `<comment_tool_info>
|
||||
IMPORTANT: For this inline PR review comment, you have been provided with ONLY the mcp__gitea__update_pull_request_comment tool to update this specific review comment.
|
||||
|
||||
Tool usage example for mcp__github_comment__update_claude_comment:
|
||||
Tool usage example for mcp__gitea__update_pull_request_comment:
|
||||
{
|
||||
"body": "Your comment text here"
|
||||
}
|
||||
Only the body parameter is required - the tool automatically knows which comment to update.
|
||||
</comment_tool_info>`}
|
||||
All four parameters (owner, repo, commentId, body) are required.
|
||||
</comment_tool_info>`
|
||||
: `<comment_tool_info>
|
||||
IMPORTANT: For this event type, you have been provided with ONLY the mcp__gitea__update_issue_comment tool to update comments.
|
||||
|
||||
Tool usage example for mcp__gitea__update_issue_comment:
|
||||
{
|
||||
"owner": "${context.repository.split("/")[0]}",
|
||||
"repo": "${context.repository.split("/")[1]}",
|
||||
"commentId": ${context.claudeCommentId},
|
||||
"body": "Your comment text here"
|
||||
}
|
||||
All four parameters (owner, repo, commentId, body) are required.
|
||||
</comment_tool_info>`
|
||||
}
|
||||
|
||||
Your task is to analyze the context, understand the request, and provide helpful responses and/or implement code changes as needed.
|
||||
|
||||
IMPORTANT CLARIFICATIONS:
|
||||
- When asked to "review" code, read the code and provide review feedback (do not implement changes unless explicitly asked)${eventData.isPR ? "\n- For PR reviews: Your review will be posted when you update the comment. Focus on providing comprehensive review feedback." : ""}
|
||||
- Your console outputs and tool results are NOT visible to the user
|
||||
- ALL communication happens through your GitHub comment - that's how users see your feedback, answers, and progress. your normal responses are not seen.
|
||||
- ALL communication happens through your Gitea comment - that's how users see your feedback, answers, and progress. your normal responses are not seen.
|
||||
|
||||
Follow these steps:
|
||||
|
||||
1. Create a Todo List:
|
||||
- Use your GitHub comment to maintain a detailed task list based on the request.
|
||||
- Use your Gitea comment to maintain a detailed task list based on the request.
|
||||
- Format todos as a checklist (- [ ] for incomplete, - [x] for complete).
|
||||
- Update the comment using mcp__github_comment__update_claude_comment with each task completion.
|
||||
- Update the comment using ${eventData.eventName === "pull_request_review_comment" ? "mcp__gitea__update_pull_request_comment" : "mcp__gitea__update_issue_comment"} with each task completion.
|
||||
|
||||
2. Gather Context:
|
||||
- Analyze the pre-fetched data provided above.
|
||||
@@ -657,7 +660,7 @@ Follow these steps:
|
||||
- For ISSUE_ASSIGNED: Read the entire issue body to understand the task.
|
||||
- For ISSUE_LABELED: Read the entire issue body to understand the task.
|
||||
${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""}
|
||||
${context.directPrompt ? ` - CRITICAL: Direct user instructions were provided in the <direct_prompt> tag above. These are HIGH PRIORITY instructions that OVERRIDE all other context and MUST be followed exactly as written.` : ""}
|
||||
${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any Gitea comment but a direct instruction to execute.` : ""}
|
||||
- IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions.
|
||||
- Other comments may contain requests from other users, but DO NOT act on those unless the trigger comment explicitly asks you to.
|
||||
- Use the Read tool to look at relevant files for better context.
|
||||
@@ -672,7 +675,20 @@ ${context.directPrompt ? ` - CRITICAL: Direct user instructions were provided
|
||||
- For implementation requests, assess if they are straightforward or complex.
|
||||
- Mark this todo as complete by checking the box.
|
||||
|
||||
4. Execute Actions:
|
||||
${
|
||||
!eventData.isPR || !eventData.claudeBranch
|
||||
? `
|
||||
4. Check for Existing Branch (for issues and closed PRs):
|
||||
- Before implementing changes, check if there's already a claude branch for this ${eventData.isPR ? "PR" : "issue"}.
|
||||
- Use the mcp__gitea__list_branches tool to list branches.
|
||||
- If found, use mcp__local_git_ops__checkout_branch to switch to the existing branch (set fetch_remote=true).
|
||||
- If not found, you'll create a new branch when making changes (see Execute Actions section).
|
||||
- Mark this todo as complete by checking the box.
|
||||
|
||||
5. Execute Actions:`
|
||||
: `
|
||||
4. Execute Actions:`
|
||||
}
|
||||
- Continually update your todo list as you discover new requirements or realize tasks can be broken down.
|
||||
|
||||
A. For Answering Questions and Code Reviews:
|
||||
@@ -680,33 +696,48 @@ ${context.directPrompt ? ` - CRITICAL: Direct user instructions were provided
|
||||
- Look for bugs, security issues, performance problems, and other issues
|
||||
- Suggest improvements for readability and maintainability
|
||||
- Check for best practices and coding standards
|
||||
- Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
|
||||
- Reference specific code sections with file paths and line numbers${eventData.isPR ? "\n - AFTER reading files and analyzing code, you MUST call mcp__gitea__update_issue_comment to post your review" : ""}
|
||||
- Formulate a concise, technical, and helpful response based on the context.
|
||||
- Reference specific code with inline formatting or code blocks.
|
||||
- Include relevant file paths and line numbers when applicable.
|
||||
- ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
|
||||
- ${eventData.isPR ? "IMPORTANT: Submit your review feedback by updating the Claude comment. This will be displayed as your PR review." : "Remember that this feedback must be posted to the Gitea comment."}
|
||||
|
||||
B. For Straightforward Changes:
|
||||
- Use file system tools to make the change locally.
|
||||
- If you discover related tasks (e.g., updating tests), add them to the todo list.
|
||||
- Mark each subtask as completed as you progress.${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
||||
- Mark each subtask as completed as you progress.
|
||||
${
|
||||
eventData.claudeBranch
|
||||
? `- Provide a URL to create a PR manually in this format:
|
||||
[Create a PR](${GITHUB_SERVER_URL}/${context.repository}/compare/${eventData.baseBranch}...<branch-name>?quick_pull=1&title=<url-encoded-title>&body=<url-encoded-body>)
|
||||
- IMPORTANT: Use THREE dots (...) between branch names, not two (..)
|
||||
Example: ${GITHUB_SERVER_URL}/${context.repository}/compare/main...feature-branch (correct)
|
||||
NOT: ${GITHUB_SERVER_URL}/${context.repository}/compare/main..feature-branch (incorrect)
|
||||
- IMPORTANT: Ensure all URL parameters are properly encoded - spaces should be encoded as %20, not left as spaces
|
||||
Example: Instead of "fix: update welcome message", use "fix%3A%20update%20welcome%20message"
|
||||
- The target-branch should be '${eventData.baseBranch}'.
|
||||
- The branch-name is the current branch: ${eventData.claudeBranch}
|
||||
- The body should include:
|
||||
- A clear description of the changes
|
||||
- Reference to the original ${eventData.isPR ? "PR" : "issue"}
|
||||
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
||||
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
||||
: ""
|
||||
eventData.isPR && !eventData.claudeBranch
|
||||
? `
|
||||
- Commit changes using mcp__local_git_ops__commit_files to the existing branch (works for both new and existing files).
|
||||
- Make sure commits follow the same convention as other commits in the repository.
|
||||
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
|
||||
- After pushing, you MUST create a PR using mcp__local_git_ops__create_pull_request.
|
||||
- When pushing changes with this tool and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.local>" line in the commit message.`
|
||||
: eventData.claudeBranch
|
||||
? `
|
||||
- You are already on the correct branch (${eventData.claudeBranch}). Do not create a new branch.
|
||||
- Commit changes using mcp__local_git_ops__commit_files (works for both new and existing files)
|
||||
- Make sure commits follow the same convention as other commits in the repository.
|
||||
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
|
||||
`
|
||||
: `
|
||||
- IMPORTANT: You are currently on the base branch (${eventData.baseBranch}). Before making changes, you should first check if there's already an existing claude branch for this ${eventData.isPR ? "PR" : "issue"}.
|
||||
- FIRST: Use Bash to run \`git branch -r | grep "claude/${eventData.isPR ? "pr" : "issue"}-${eventData.isPR ? eventData.prNumber : eventData.issueNumber}"\` to check for existing branches.
|
||||
- If an existing claude branch is found:
|
||||
- Use mcp__local_git_ops__checkout_branch to switch to the existing branch (set fetch_remote=true)
|
||||
- Continue working on that branch rather than creating a new one
|
||||
- If NO existing claude branch is found:
|
||||
- Create a new branch using mcp__local_git_ops__create_branch
|
||||
- Use a descriptive branch name following the pattern: claude/${eventData.isPR ? "pr" : "issue"}-${eventData.isPR ? eventData.prNumber : eventData.issueNumber}-<short-description>
|
||||
- Example: claude/issue-123-fix-login-bug or claude/issue-456-add-user-profile
|
||||
- After being on the correct branch (existing or new), commit changes using mcp__local_git_ops__commit_files (works for both new and existing files)
|
||||
- Use mcp__local_git_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||
- CRITICAL: After committing, you MUST push the branch to the remote repository using mcp__local_git_ops__push_branch
|
||||
- After pushing, you should create a PR using mcp__local_git_ops__create_pull_request unless one already exists for that branch.
|
||||
`
|
||||
}
|
||||
|
||||
C. For Complex Changes:
|
||||
@@ -718,17 +749,29 @@ ${context.directPrompt ? ` - CRITICAL: Direct user instructions were provided
|
||||
- Follow the same pushing strategy as for straightforward changes (see section B above).
|
||||
- Or explain why it's too complex: mark todo as completed in checklist with explanation.
|
||||
|
||||
5. Final Update:
|
||||
- Always update the GitHub comment to reflect the current todo state.
|
||||
${!eventData.isPR || !eventData.claudeBranch ? `6. Final Update:` : `5. Final Update:`}
|
||||
- Always update the Gitea comment to reflect the current todo state.
|
||||
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
|
||||
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
|
||||
- If you changed any files locally, you must update them in the remote branch via ${useCommitSigning ? "mcp__github_file_ops__commit_files" : "git commands (add, commit, push)"} before saying that you're done.
|
||||
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
|
||||
- If you changed any files locally, you must commit them using mcp__local_git_ops__commit_files AND push the branch using mcp__local_git_ops__push_branch before saying that you're done.
|
||||
${!eventData.isPR || !eventData.claudeBranch ? `- If you created a branch and made changes, you must create a PR using mcp__local_git_ops__create_pull_request.` : ""}
|
||||
|
||||
Important Notes:
|
||||
- All communication must happen through GitHub PR comments.
|
||||
- Never create new comments. Only update the existing comment using mcp__github_comment__update_claude_comment.
|
||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? `\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_comment__update_claude_comment. Do NOT just respond with a normal response, the user will not see it.` : ""}
|
||||
- All communication must happen through Gitea PR comments.
|
||||
- Never create new comments. Only update the existing comment using ${eventData.eventName === "pull_request_review_comment" ? "mcp__gitea__update_pull_request_comment" : "mcp__gitea__update_issue_comment"} with comment_id: ${context.claudeCommentId}.
|
||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__gitea__update_issue_comment. Do NOT just respond with a normal response, the user will not see it." : ""}
|
||||
- You communicate exclusively by editing your single comment - not through any other means.
|
||||
- Use this spinner HTML when work is in progress: <img src="https://raw.githubusercontent.com/markwylde/claude-code-gitea-action/refs/heads/gitea/assets/spinner.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
||||
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : eventData.claudeBranch ? `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch}). Do not create additional branches.` : `- IMPORTANT: You are currently on the base branch (${eventData.baseBranch}). First check for existing claude branches for this ${eventData.isPR ? "PR" : "issue"} and use them if found, otherwise create a new branch using mcp__local_git_ops__create_branch.`}
|
||||
- Use mcp__local_git_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__local_git_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__gitea__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
||||
Tool usage examples:
|
||||
- mcp__local_git_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
|
||||
- mcp__local_git_ops__push_branch: {"branch": "branch-name"} (REQUIRED after committing to push changes to remote)
|
||||
- mcp__local_git_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}
|
||||
- Display the todo list as a checklist in the Gitea comment and mark things off as you go.
|
||||
- All communication must happen through Gitea PR comments.
|
||||
- Never create new comments. Only update the existing comment using ${eventData.eventName === "pull_request_review_comment" ? "mcp__gitea__update_pull_request_comment" : "mcp__gitea__update_issue_comment"}.
|
||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__gitea__update_issue_comment. Do NOT just respond with a normal response, the user will not see it." : ""}
|
||||
- You communicate exclusively by editing your single comment - not through any other means.
|
||||
- Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
||||
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
|
||||
@@ -746,7 +789,7 @@ ${
|
||||
- Check status: Bash(git status)
|
||||
- View diff: Bash(git diff)`
|
||||
}
|
||||
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
||||
- Display the todo list as a checklist in the Gitea comment and mark things off as you go.
|
||||
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
||||
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
||||
- Your comment must always include the job run link (and branch link if there is one) at the bottom.
|
||||
@@ -761,20 +804,23 @@ What You CAN Do:
|
||||
- Implement code changes (simple to moderate complexity) when explicitly requested
|
||||
- Create pull requests for changes to human-authored code
|
||||
- Smart branch handling:
|
||||
- When triggered on an issue: Always create a new branch
|
||||
- When triggered on an open PR: Always push directly to the existing PR branch
|
||||
- When triggered on a closed PR: Create a new branch
|
||||
- When triggered on an issue: Create a new branch using mcp__local_git_ops__create_branch
|
||||
- When triggered on an open PR: Push directly to the existing PR branch
|
||||
- When triggered on a closed PR: Create a new branch using mcp__local_git_ops__create_branch
|
||||
- Create new branches when needed using the create_branch tool
|
||||
|
||||
What You CANNOT Do:
|
||||
- Submit formal GitHub PR reviews
|
||||
- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)
|
||||
- Perform advanced branch operations (cannot merge branches, rebase, or perform other complex git operations beyond creating, checking out, and pushing branches)
|
||||
- Modify files in the .gitea/workflows directory (Gitea App permissions do not allow workflow modifications)
|
||||
- View CI/CD results or workflow run outputs (cannot access Gitea Actions logs or test results)
|
||||
- Submit formal Gitea PR reviews
|
||||
- Approve pull requests (for security reasons)
|
||||
- Post multiple comments (you only update your initial comment)
|
||||
- Execute commands outside the repository context${useCommitSigning ? "\n- Run arbitrary Bash commands (unless explicitly allowed via allowed_tools configuration)" : ""}
|
||||
- Perform branch operations (cannot merge branches, rebase, or perform other git operations beyond creating and pushing commits)
|
||||
- Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications)
|
||||
- Execute commands outside the repository context
|
||||
|
||||
When users ask you to perform actions you cannot do, politely explain the limitation and, when applicable, direct them to the FAQ for more information and workarounds:
|
||||
"I'm unable to [specific action] due to [reason]. You can find more information and potential workarounds in the [FAQ](https://github.com/anthropics/claude-code-action/blob/main/FAQ.md)."
|
||||
"I'm unable to [specific action] due to [reason]. Please check the documentation for more information and potential workarounds."
|
||||
|
||||
If a user asks for something outside these capabilities (and you have no other tools provided), politely explain that you cannot perform that action and suggest an alternative approach if possible.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type IssueCommentEvent = {
|
||||
issueNumber: string;
|
||||
isPR: false;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
claudeBranch?: string;
|
||||
commentBody: string;
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ type IssueOpenedEvent = {
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
claudeBranch?: string;
|
||||
};
|
||||
|
||||
type IssueAssignedEvent = {
|
||||
@@ -65,8 +65,8 @@ type IssueAssignedEvent = {
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
assigneeTrigger?: string;
|
||||
claudeBranch?: string;
|
||||
assigneeTrigger: string;
|
||||
};
|
||||
|
||||
type IssueLabeledEvent = {
|
||||
@@ -75,7 +75,7 @@ type IssueLabeledEvent = {
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
claudeBranch?: string;
|
||||
labelTrigger: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,30 +7,42 @@
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { setupGitHubToken } from "../github/token";
|
||||
import { checkTriggerAction } from "../github/validation/trigger";
|
||||
import { checkHumanActor } from "../github/validation/actor";
|
||||
import { checkWritePermissions } from "../github/validation/permissions";
|
||||
import { createInitialComment } from "../github/operations/comments/create-initial";
|
||||
import { setupBranch } from "../github/operations/branch";
|
||||
import { configureGitAuth } from "../github/operations/git-config";
|
||||
import { updateTrackingComment } from "../github/operations/comments/update-with-branch";
|
||||
import { prepareMcpConfig } from "../mcp/install-mcp-server";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { createPrompt } from "../create-prompt";
|
||||
import { createClient } from "../github/api/client";
|
||||
import { fetchGitHubData } from "../github/data/fetcher";
|
||||
import { parseGitHubContext } from "../github/context";
|
||||
import { getMode } from "../modes/registry";
|
||||
import { createPrompt } from "../create-prompt";
|
||||
import { setupOAuthCredentials } from "../claude/oauth-setup";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
// Step 1: Setup GitHub token
|
||||
const githubToken = await setupGitHubToken();
|
||||
const octokit = createOctokit(githubToken);
|
||||
// Step 1: Setup OAuth credentials if provided
|
||||
const claudeCredentials = process.env.CLAUDE_CREDENTIALS;
|
||||
const anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
// Step 2: Parse GitHub context (once for all operations)
|
||||
if (claudeCredentials && anthropicApiKey === "use-oauth") {
|
||||
await setupOAuthCredentials(claudeCredentials);
|
||||
console.log(
|
||||
"OAuth credentials configured for Claude AI Max subscription",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Setup GitHub token
|
||||
const githubToken = await setupGitHubToken();
|
||||
const client = createClient(githubToken);
|
||||
|
||||
// Step 3: Parse GitHub context (once for all operations)
|
||||
const context = parseGitHubContext();
|
||||
|
||||
// Step 3: Check write permissions
|
||||
// Step 4: Check write permissions
|
||||
const hasWritePermissions = await checkWritePermissions(
|
||||
octokit.rest,
|
||||
client.api,
|
||||
context,
|
||||
);
|
||||
if (!hasWritePermissions) {
|
||||
@@ -39,76 +51,66 @@ async function run() {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Get mode and check trigger conditions
|
||||
const mode = getMode(context.inputs.mode);
|
||||
const containsTrigger = mode.shouldTrigger(context);
|
||||
// Step 5: Check trigger conditions
|
||||
const containsTrigger = await checkTriggerAction(context);
|
||||
|
||||
// Set output for action.yml to check
|
||||
// Set outputs that are always needed
|
||||
core.setOutput("contains_trigger", containsTrigger.toString());
|
||||
core.setOutput("GITHUB_TOKEN", githubToken);
|
||||
|
||||
if (!containsTrigger) {
|
||||
console.log("No trigger found, skipping remaining steps");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Check if actor is human
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
// Step 6: Check if actor is human
|
||||
await checkHumanActor(client.api, context);
|
||||
|
||||
// Step 6: Create initial tracking comment (mode-aware)
|
||||
// Some modes (e.g., agent mode) may not need tracking comments
|
||||
let commentId: number | undefined;
|
||||
let commentData:
|
||||
| Awaited<ReturnType<typeof createInitialComment>>
|
||||
| undefined;
|
||||
if (mode.shouldCreateTrackingComment()) {
|
||||
commentData = await createInitialComment(octokit.rest, context);
|
||||
commentId = commentData.id;
|
||||
}
|
||||
// Step 7: Create initial tracking comment
|
||||
const commentId = await createInitialComment(client.api, context);
|
||||
core.setOutput("claude_comment_id", commentId.toString());
|
||||
|
||||
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||
// Step 8: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||
const githubData = await fetchGitHubData({
|
||||
octokits: octokit,
|
||||
client: client,
|
||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||
prNumber: context.entityNumber.toString(),
|
||||
isPR: context.isPR,
|
||||
triggerUsername: context.actor,
|
||||
});
|
||||
|
||||
// Step 8: Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Step 9: Configure git authentication if not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, commentData?.user || null);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
// Step 9: Setup branch
|
||||
const branchInfo = await setupBranch(client, githubData, context);
|
||||
core.setOutput("BASE_BRANCH", branchInfo.baseBranch);
|
||||
if (branchInfo.claudeBranch) {
|
||||
core.setOutput("CLAUDE_BRANCH", branchInfo.claudeBranch);
|
||||
}
|
||||
|
||||
// Step 10: Create prompt file
|
||||
const modeContext = mode.prepareContext(context, {
|
||||
// Step 10: Update initial comment with branch link (only if a claude branch was created)
|
||||
if (branchInfo.claudeBranch) {
|
||||
await updateTrackingComment(
|
||||
client,
|
||||
context,
|
||||
commentId,
|
||||
branchInfo.claudeBranch,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 11: Create prompt file
|
||||
await createPrompt(
|
||||
commentId,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
await createPrompt(mode, modeContext, githubData, context);
|
||||
|
||||
// Step 11: Get MCP configuration
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: commentId?.toString() || "",
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
branchInfo.baseBranch,
|
||||
branchInfo.claudeBranch,
|
||||
githubData,
|
||||
context,
|
||||
});
|
||||
);
|
||||
|
||||
// Step 12: Get MCP configuration
|
||||
const mcpConfig = await prepareMcpConfig(
|
||||
githubToken,
|
||||
context.repository.owner,
|
||||
context.repository.repo,
|
||||
branchInfo.currentBranch,
|
||||
);
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { createClient } from "../github/api/client";
|
||||
import * as fs from "fs/promises";
|
||||
import {
|
||||
updateCommentBody,
|
||||
@@ -10,9 +10,14 @@ import {
|
||||
parseGitHubContext,
|
||||
isPullRequestReviewCommentEvent,
|
||||
} from "../github/context";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { GITEA_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
|
||||
import {
|
||||
branchHasChanges,
|
||||
fetchBranch,
|
||||
branchExists,
|
||||
remoteBranchExists,
|
||||
} from "../github/utils/local-git";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -24,10 +29,10 @@ async function run() {
|
||||
|
||||
const context = parseGitHubContext();
|
||||
const { owner, repo } = context.repository;
|
||||
const octokit = createOctokit(githubToken);
|
||||
const client = createClient(githubToken);
|
||||
|
||||
const serverUrl = GITHUB_SERVER_URL;
|
||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
||||
const serverUrl = GITEA_SERVER_URL;
|
||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_NUMBER}`;
|
||||
|
||||
let comment;
|
||||
let isPRReviewComment = false;
|
||||
@@ -38,12 +43,11 @@ async function run() {
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
// For PR review comments, use the pulls API
|
||||
console.log(`Fetching PR review comment ${commentId}`);
|
||||
const { data: prComment } = await octokit.rest.pulls.getReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
comment = prComment;
|
||||
const response = await client.api.customRequest(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
|
||||
);
|
||||
comment = response.data;
|
||||
isPRReviewComment = true;
|
||||
console.log("Successfully fetched as PR review comment");
|
||||
}
|
||||
@@ -51,12 +55,11 @@ async function run() {
|
||||
// For all other event types, use the issues API
|
||||
if (!comment) {
|
||||
console.log(`Fetching issue comment ${commentId}`);
|
||||
const { data: issueComment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
comment = issueComment;
|
||||
const response = await client.api.customRequest(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`,
|
||||
);
|
||||
comment = response.data;
|
||||
isPRReviewComment = false;
|
||||
console.log("Successfully fetched as issue comment");
|
||||
}
|
||||
@@ -70,14 +73,14 @@ async function run() {
|
||||
|
||||
// Try to get the PR info to understand the comment structure
|
||||
try {
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
const pr = await client.api.getPullRequest(
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.entityNumber,
|
||||
});
|
||||
console.log(`PR state: ${pr.state}`);
|
||||
console.log(`PR comments count: ${pr.comments}`);
|
||||
console.log(`PR review comments count: ${pr.review_comments}`);
|
||||
context.entityNumber,
|
||||
);
|
||||
console.log(`PR state: ${pr.data.state}`);
|
||||
console.log(`PR comments count: ${pr.data.comments}`);
|
||||
console.log(`PR review comments count: ${pr.data.review_comments}`);
|
||||
} catch {
|
||||
console.error("Could not fetch PR info for debugging");
|
||||
}
|
||||
@@ -88,16 +91,13 @@ async function run() {
|
||||
const currentBody = comment.body ?? "";
|
||||
|
||||
// Check if we need to add branch link for new branches
|
||||
const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
|
||||
const { shouldDeleteBranch, branchLink } =
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
);
|
||||
const { shouldDeleteBranch, branchLink } = await checkAndDeleteEmptyBranch(
|
||||
client,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
);
|
||||
|
||||
// Check if we need to add PR URL when we have a new branch
|
||||
let prLink = "";
|
||||
@@ -111,33 +111,154 @@ async function run() {
|
||||
const containsPRUrl = currentBody.match(prUrlPattern);
|
||||
|
||||
if (!containsPRUrl) {
|
||||
// Check if there are changes to the branch compared to the default branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseBranch}...${claudeBranch}`,
|
||||
});
|
||||
// Check if we're using Gitea or GitHub
|
||||
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
|
||||
const isGitea =
|
||||
giteaApiUrl &&
|
||||
giteaApiUrl !== "" &&
|
||||
!giteaApiUrl.includes("api.github.com") &&
|
||||
!giteaApiUrl.includes("github.com");
|
||||
|
||||
// If there are changes (commits or file changes), add the PR URL
|
||||
if (
|
||||
comparison.total_commits > 0 ||
|
||||
(comparison.files && comparison.files.length > 0)
|
||||
) {
|
||||
if (isGitea) {
|
||||
// Use local git commands for Gitea
|
||||
console.log(
|
||||
"Using local git commands for PR link check (Gitea mode)",
|
||||
);
|
||||
|
||||
try {
|
||||
// Fetch latest changes from remote
|
||||
await fetchBranch(claudeBranch);
|
||||
await fetchBranch(baseBranch);
|
||||
|
||||
// Check if branch exists and has changes
|
||||
const { hasChanges, branchSha, baseSha } = await branchHasChanges(
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
);
|
||||
|
||||
if (branchSha && baseSha) {
|
||||
if (hasChanges) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} appears to have changes (different SHA from base)`,
|
||||
);
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has same SHA as base, no PR link needed`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If we can't get SHAs, check if branch exists at all
|
||||
const localExists = await branchExists(claudeBranch);
|
||||
const remoteExists = await remoteBranchExists(claudeBranch);
|
||||
|
||||
if (localExists || remoteExists) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} exists but SHA comparison failed, adding PR link to be safe`,
|
||||
);
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} does not exist yet - no PR link needed`,
|
||||
);
|
||||
prLink = "";
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error checking branch with git commands:", error);
|
||||
// For errors, add PR link to be safe
|
||||
console.log("Adding PR link as fallback due to git command error");
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for changes in branch:", error);
|
||||
// Don't fail the entire update if we can't check for changes
|
||||
} else {
|
||||
// Use API calls for GitHub
|
||||
console.log("Using API calls for PR link check (GitHub mode)");
|
||||
|
||||
try {
|
||||
// Get the branch info to see if it exists and has commits
|
||||
const branchResponse = await client.api.getBranch(
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
);
|
||||
|
||||
// Get base branch info for comparison
|
||||
const baseResponse = await client.api.getBranch(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
);
|
||||
|
||||
const branchSha = branchResponse.data.commit.sha;
|
||||
const baseSha = baseResponse.data.commit.sha;
|
||||
|
||||
// If SHAs are different, assume there are changes and add PR link
|
||||
if (branchSha !== baseSha) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} appears to have changes (different SHA from base)`,
|
||||
);
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has same SHA as base, no PR link needed`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error checking branch:", error);
|
||||
|
||||
// Handle 404 specifically - branch doesn't exist
|
||||
if (error.status === 404) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} does not exist yet - no PR link needed`,
|
||||
);
|
||||
// Don't add PR link since branch doesn't exist
|
||||
prLink = "";
|
||||
} else {
|
||||
// For other errors, add PR link to be safe
|
||||
console.log("Adding PR link as fallback due to non-404 error");
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +291,7 @@ async function run() {
|
||||
if (Array.isArray(outputData) && outputData.length > 0) {
|
||||
const lastElement = outputData[outputData.length - 1];
|
||||
if (
|
||||
lastElement.type === "result" &&
|
||||
lastElement.role === "system" &&
|
||||
"cost_usd" in lastElement &&
|
||||
"duration_ms" in lastElement
|
||||
) {
|
||||
@@ -201,21 +322,31 @@ async function run() {
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch,
|
||||
branchName: shouldDeleteBranch ? undefined : claudeBranch,
|
||||
triggerUsername,
|
||||
errorDetails,
|
||||
};
|
||||
|
||||
const updatedBody = updateCommentBody(commentInput);
|
||||
|
||||
// Update the comment using the appropriate API
|
||||
try {
|
||||
await updateClaudeComment(octokit.rest, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: updatedBody,
|
||||
isPullRequestReviewComment: isPRReviewComment,
|
||||
});
|
||||
if (isPRReviewComment) {
|
||||
await client.api.customRequest(
|
||||
"PATCH",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
|
||||
{
|
||||
body: updatedBody,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await client.api.updateIssueComment(
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
updatedBody,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
|
||||
);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { graphql } from "@octokit/graphql";
|
||||
import { GITHUB_API_URL } from "./config";
|
||||
import { GiteaApiClient, createGiteaClient } from "./gitea-client";
|
||||
|
||||
export type Octokits = {
|
||||
rest: Octokit;
|
||||
graphql: typeof graphql;
|
||||
export type GitHubClient = {
|
||||
api: GiteaApiClient;
|
||||
};
|
||||
|
||||
export function createOctokit(token: string): Octokits {
|
||||
export function createClient(token: string): GitHubClient {
|
||||
// Use the GITEA_API_URL environment variable if provided
|
||||
const apiUrl = process.env.GITEA_API_URL;
|
||||
console.log(
|
||||
`Creating client with API URL: ${apiUrl || "default (https://api.github.com)"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
rest: new Octokit({
|
||||
auth: token,
|
||||
baseUrl: GITHUB_API_URL,
|
||||
}),
|
||||
graphql: graphql.defaults({
|
||||
baseUrl: GITHUB_API_URL,
|
||||
headers: {
|
||||
authorization: `token ${token}`,
|
||||
},
|
||||
}),
|
||||
api: apiUrl ? new GiteaApiClient(token, apiUrl) : createGiteaClient(token),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const GITHUB_API_URL =
|
||||
process.env.GITHUB_API_URL || "https://api.github.com";
|
||||
export const GITHUB_SERVER_URL =
|
||||
// Derive API URL from server URL for Gitea instances
|
||||
function deriveApiUrl(serverUrl: string): string {
|
||||
if (serverUrl.includes("github.com")) {
|
||||
return "https://api.github.com";
|
||||
}
|
||||
// For Gitea, add /api/v1 to the server URL to get the API URL
|
||||
return `${serverUrl}/api/v1`;
|
||||
}
|
||||
|
||||
export const GITEA_SERVER_URL =
|
||||
process.env.GITHUB_SERVER_URL || "https://github.com";
|
||||
|
||||
export const GITEA_API_URL =
|
||||
process.env.GITEA_API_URL || deriveApiUrl(GITEA_SERVER_URL);
|
||||
|
||||
318
src/github/api/gitea-client.ts
Normal file
318
src/github/api/gitea-client.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import fetch from "node-fetch";
|
||||
import { GITEA_API_URL } from "./config";
|
||||
|
||||
export interface GiteaApiResponse<T = any> {
|
||||
status: number;
|
||||
data: T;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GiteaApiError extends Error {
|
||||
status: number;
|
||||
response?: {
|
||||
data: any;
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export class GiteaApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string;
|
||||
|
||||
constructor(token: string, baseUrl: string = GITEA_API_URL) {
|
||||
this.token = token;
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, ""); // Remove trailing slashes
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
private async request<T = any>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body?: any,
|
||||
): Promise<GiteaApiResponse<T>> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
console.log(`Making ${method} request to: ${url}`);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${this.token}`,
|
||||
};
|
||||
|
||||
const options: any = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
let responseData: any = null;
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
// Only try to parse JSON if the response has JSON content type
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch (parseError) {
|
||||
console.warn(`Failed to parse JSON response: ${parseError}`);
|
||||
responseData = await response.text();
|
||||
}
|
||||
} else {
|
||||
responseData = await response.text();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
typeof responseData === "object" && responseData.message
|
||||
? responseData.message
|
||||
: responseData || response.statusText;
|
||||
|
||||
const error = new Error(
|
||||
`HTTP ${response.status}: ${errorMessage}`,
|
||||
) as GiteaApiError;
|
||||
error.status = response.status;
|
||||
error.response = {
|
||||
data: responseData,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data: responseData as T,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "status" in error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Request failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Repository operations
|
||||
async getRepo(owner: string, repo: string) {
|
||||
return this.request("GET", `/api/v1/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
// Simple test endpoint to verify API connectivity
|
||||
async testConnection() {
|
||||
return this.request("GET", "/api/v1/version");
|
||||
}
|
||||
|
||||
async getBranch(owner: string, repo: string, branch: string) {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
newBranch: string,
|
||||
fromBranch: string,
|
||||
) {
|
||||
return this.request("POST", `/api/v1/repos/${owner}/${repo}/branches`, {
|
||||
new_branch_name: newBranch,
|
||||
old_branch_name: fromBranch,
|
||||
});
|
||||
}
|
||||
|
||||
async listBranches(owner: string, repo: string) {
|
||||
return this.request("GET", `/api/v1/repos/${owner}/${repo}/branches`);
|
||||
}
|
||||
|
||||
// Issue operations
|
||||
async getIssue(owner: string, repo: string, issueNumber: number) {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listIssueComments(owner: string, repo: string, issueNumber: number) {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
|
||||
);
|
||||
}
|
||||
|
||||
async createIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
issueNumber: number,
|
||||
body: string,
|
||||
) {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/api/v1/repos/${owner}/${repo}/issues/${issueNumber}/comments`,
|
||||
{
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async updateIssueComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: number,
|
||||
body: string,
|
||||
) {
|
||||
return this.request(
|
||||
"PATCH",
|
||||
`/api/v1/repos/${owner}/${repo}/issues/comments/${commentId}`,
|
||||
{
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Pull request operations
|
||||
async getPullRequest(owner: string, repo: string, prNumber: number) {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
async listPullRequestFiles(owner: string, repo: string, prNumber: number) {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/files`,
|
||||
);
|
||||
}
|
||||
|
||||
async listPullRequestComments(owner: string, repo: string, prNumber: number) {
|
||||
return this.request(
|
||||
"GET",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
|
||||
);
|
||||
}
|
||||
|
||||
async createPullRequestComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
body: string,
|
||||
) {
|
||||
return this.request(
|
||||
"POST",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
|
||||
{
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// File operations
|
||||
async getFileContents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
ref?: string,
|
||||
) {
|
||||
let endpoint = `/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`;
|
||||
if (ref) {
|
||||
endpoint += `?ref=${encodeURIComponent(ref)}`;
|
||||
}
|
||||
return this.request("GET", endpoint);
|
||||
}
|
||||
|
||||
async createFile(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
content: string,
|
||||
message: string,
|
||||
branch?: string,
|
||||
) {
|
||||
const body: any = {
|
||||
message,
|
||||
content: Buffer.from(content).toString("base64"),
|
||||
};
|
||||
|
||||
if (branch) {
|
||||
body.branch = branch;
|
||||
}
|
||||
|
||||
return this.request(
|
||||
"POST",
|
||||
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
async updateFile(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
content: string,
|
||||
message: string,
|
||||
sha: string,
|
||||
branch?: string,
|
||||
) {
|
||||
const body: any = {
|
||||
message,
|
||||
content: Buffer.from(content).toString("base64"),
|
||||
sha,
|
||||
};
|
||||
|
||||
if (branch) {
|
||||
body.branch = branch;
|
||||
}
|
||||
|
||||
return this.request(
|
||||
"PUT",
|
||||
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
async deleteFile(
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
message: string,
|
||||
sha: string,
|
||||
branch?: string,
|
||||
) {
|
||||
const body: any = {
|
||||
message,
|
||||
sha,
|
||||
};
|
||||
|
||||
if (branch) {
|
||||
body.branch = branch;
|
||||
}
|
||||
|
||||
return this.request(
|
||||
"DELETE",
|
||||
`/api/v1/repos/${owner}/${repo}/contents/${encodeURIComponent(path)}`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
// Generic request method for other operations
|
||||
async customRequest<T = any>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body?: any,
|
||||
): Promise<GiteaApiResponse<T>> {
|
||||
return this.request<T>(method, endpoint, body);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGiteaClient(token: string): GiteaApiClient {
|
||||
return new GiteaApiClient(token);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
}
|
||||
|
||||
const commonFields = {
|
||||
runId: process.env.GITHUB_RUN_ID!,
|
||||
runId: process.env.GITHUB_RUN_NUMBER!,
|
||||
eventName: context.eventName,
|
||||
eventAction: context.payload.action,
|
||||
repository: {
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
||||
import { execSync } from "child_process";
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubIssue,
|
||||
GitHubComment,
|
||||
GitHubFile,
|
||||
GitHubIssue,
|
||||
GitHubPullRequest,
|
||||
GitHubReview,
|
||||
IssueQueryResponse,
|
||||
PullRequestQueryResponse,
|
||||
} from "../types";
|
||||
import type { CommentWithImages } from "../utils/image-downloader";
|
||||
import type { GitHubClient } from "../api/client";
|
||||
import { downloadCommentImages } from "../utils/image-downloader";
|
||||
import type { CommentWithImages } from "../utils/image-downloader";
|
||||
|
||||
type FetchDataParams = {
|
||||
octokits: Octokits;
|
||||
client: GitHubClient;
|
||||
repository: string;
|
||||
prNumber: string;
|
||||
isPR: boolean;
|
||||
triggerUsername?: string;
|
||||
};
|
||||
|
||||
export type GitHubFileWithSHA = GitHubFile & {
|
||||
@@ -32,15 +28,13 @@ export type FetchDataResult = {
|
||||
changedFilesWithSHA: GitHubFileWithSHA[];
|
||||
reviewData: { nodes: GitHubReview[] } | null;
|
||||
imageUrlMap: Map<string, string>;
|
||||
triggerDisplayName?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchGitHubData({
|
||||
octokits,
|
||||
client,
|
||||
repository,
|
||||
prNumber,
|
||||
isPR,
|
||||
triggerUsername,
|
||||
}: FetchDataParams): Promise<FetchDataResult> {
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
@@ -53,46 +47,104 @@ export async function fetchGitHubData({
|
||||
let reviewData: { nodes: GitHubReview[] } | null = null;
|
||||
|
||||
try {
|
||||
// Use REST API for all requests (works with both GitHub and Gitea)
|
||||
if (isPR) {
|
||||
// Fetch PR data with all comments and file information
|
||||
const prResult = await octokits.graphql<PullRequestQueryResponse>(
|
||||
PR_QUERY,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
number: parseInt(prNumber),
|
||||
},
|
||||
console.log(`Fetching PR #${prNumber} data using REST API`);
|
||||
const prResponse = await client.api.getPullRequest(
|
||||
owner,
|
||||
repo,
|
||||
parseInt(prNumber),
|
||||
);
|
||||
|
||||
if (prResult.repository.pullRequest) {
|
||||
const pullRequest = prResult.repository.pullRequest;
|
||||
contextData = pullRequest;
|
||||
changedFiles = pullRequest.files.nodes || [];
|
||||
comments = pullRequest.comments?.nodes || [];
|
||||
reviewData = pullRequest.reviews || [];
|
||||
contextData = {
|
||||
title: prResponse.data.title,
|
||||
body: prResponse.data.body || "",
|
||||
author: { login: prResponse.data.user?.login || "" },
|
||||
baseRefName: prResponse.data.base.ref,
|
||||
headRefName: prResponse.data.head.ref,
|
||||
headRefOid: prResponse.data.head.sha,
|
||||
createdAt: prResponse.data.created_at,
|
||||
additions: prResponse.data.additions || 0,
|
||||
deletions: prResponse.data.deletions || 0,
|
||||
state: prResponse.data.state.toUpperCase(),
|
||||
commits: { totalCount: 0, nodes: [] },
|
||||
files: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
};
|
||||
|
||||
console.log(`Successfully fetched PR #${prNumber} data`);
|
||||
} else {
|
||||
throw new Error(`PR #${prNumber} not found`);
|
||||
// Fetch comments separately
|
||||
try {
|
||||
const commentsResponse = await client.api.listIssueComments(
|
||||
owner,
|
||||
repo,
|
||||
parseInt(prNumber),
|
||||
);
|
||||
comments = commentsResponse.data.map((comment: any) => ({
|
||||
id: comment.id.toString(),
|
||||
databaseId: comment.id.toString(),
|
||||
body: comment.body || "",
|
||||
author: { login: comment.user?.login || "" },
|
||||
createdAt: comment.created_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch PR comments:", error);
|
||||
comments = []; // Ensure we have an empty array
|
||||
}
|
||||
} else {
|
||||
// Fetch issue data
|
||||
const issueResult = await octokits.graphql<IssueQueryResponse>(
|
||||
ISSUE_QUERY,
|
||||
{
|
||||
|
||||
// Try to fetch files
|
||||
try {
|
||||
const filesResponse = await client.api.listPullRequestFiles(
|
||||
owner,
|
||||
repo,
|
||||
number: parseInt(prNumber),
|
||||
},
|
||||
parseInt(prNumber),
|
||||
);
|
||||
changedFiles = filesResponse.data.map((file: any) => ({
|
||||
path: file.filename,
|
||||
additions: file.additions || 0,
|
||||
deletions: file.deletions || 0,
|
||||
changeType: file.status || "modified",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch PR files:", error);
|
||||
changedFiles = []; // Ensure we have an empty array
|
||||
}
|
||||
|
||||
reviewData = { nodes: [] }; // Simplified for Gitea
|
||||
} else {
|
||||
console.log(`Fetching issue #${prNumber} data using REST API`);
|
||||
const issueResponse = await client.api.getIssue(
|
||||
owner,
|
||||
repo,
|
||||
parseInt(prNumber),
|
||||
);
|
||||
|
||||
if (issueResult.repository.issue) {
|
||||
contextData = issueResult.repository.issue;
|
||||
comments = contextData?.comments?.nodes || [];
|
||||
contextData = {
|
||||
title: issueResponse.data.title,
|
||||
body: issueResponse.data.body || "",
|
||||
author: { login: issueResponse.data.user?.login || "" },
|
||||
createdAt: issueResponse.data.created_at,
|
||||
state: issueResponse.data.state.toUpperCase(),
|
||||
comments: { nodes: [] },
|
||||
};
|
||||
|
||||
console.log(`Successfully fetched issue #${prNumber} data`);
|
||||
} else {
|
||||
throw new Error(`Issue #${prNumber} not found`);
|
||||
// Fetch comments
|
||||
try {
|
||||
const commentsResponse = await client.api.listIssueComments(
|
||||
owner,
|
||||
repo,
|
||||
parseInt(prNumber),
|
||||
);
|
||||
comments = commentsResponse.data.map((comment: any) => ({
|
||||
id: comment.id.toString(),
|
||||
databaseId: comment.id.toString(),
|
||||
body: comment.body || "",
|
||||
author: { login: comment.user?.login || "" },
|
||||
createdAt: comment.created_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("Failed to fetch issue comments:", error);
|
||||
comments = []; // Ensure we have an empty array
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -104,17 +156,9 @@ export async function fetchGitHubData({
|
||||
let changedFilesWithSHA: GitHubFileWithSHA[] = [];
|
||||
if (isPR && changedFiles.length > 0) {
|
||||
changedFilesWithSHA = changedFiles.map((file) => {
|
||||
// Don't compute SHA for deleted files
|
||||
if (file.changeType === "DELETED") {
|
||||
return {
|
||||
...file,
|
||||
sha: "deleted",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Use git hash-object to compute the SHA for the current file content
|
||||
const sha = execFileSync("git", ["hash-object", file.path], {
|
||||
const sha = execSync(`git hash-object "${file.path}"`, {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
return {
|
||||
@@ -188,18 +232,12 @@ export async function fetchGitHubData({
|
||||
];
|
||||
|
||||
const imageUrlMap = await downloadCommentImages(
|
||||
octokits,
|
||||
client,
|
||||
owner,
|
||||
repo,
|
||||
allComments,
|
||||
);
|
||||
|
||||
// Fetch trigger user display name if username is provided
|
||||
let triggerDisplayName: string | null | undefined;
|
||||
if (triggerUsername) {
|
||||
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
||||
}
|
||||
|
||||
return {
|
||||
contextData,
|
||||
comments,
|
||||
@@ -207,27 +245,5 @@ export async function fetchGitHubData({
|
||||
changedFilesWithSHA,
|
||||
reviewData,
|
||||
imageUrlMap,
|
||||
triggerDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserQueryResponse = {
|
||||
user: {
|
||||
name: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchUserDisplayName(
|
||||
octokits: Octokits,
|
||||
login: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const result = await octokits.graphql<UserQueryResponse>(USER_QUERY, {
|
||||
login,
|
||||
});
|
||||
return result.user.name;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch user display name for ${login}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +1,145 @@
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { $ } from "bun";
|
||||
import type { GitHubClient } from "../api/client";
|
||||
import { GITEA_SERVER_URL } from "../api/config";
|
||||
import {
|
||||
branchHasChanges,
|
||||
fetchBranch,
|
||||
branchExists,
|
||||
remoteBranchExists,
|
||||
} from "../utils/local-git";
|
||||
|
||||
export async function checkAndCommitOrDeleteBranch(
|
||||
octokit: Octokits,
|
||||
export async function checkAndDeleteEmptyBranch(
|
||||
client: GitHubClient,
|
||||
owner: string,
|
||||
repo: string,
|
||||
claudeBranch: string | undefined,
|
||||
baseBranch: string,
|
||||
useCommitSigning: boolean,
|
||||
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
||||
let branchLink = "";
|
||||
let shouldDeleteBranch = false;
|
||||
|
||||
if (claudeBranch) {
|
||||
// First check if the branch exists remotely
|
||||
let branchExistsRemotely = false;
|
||||
try {
|
||||
await octokit.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: claudeBranch,
|
||||
});
|
||||
branchExistsRemotely = true;
|
||||
} catch (error: any) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Branch ${claudeBranch} does not exist remotely`);
|
||||
} else {
|
||||
console.error("Error checking if branch exists:", error);
|
||||
}
|
||||
}
|
||||
// Check if we're using Gitea or GitHub
|
||||
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
|
||||
const isGitea =
|
||||
giteaApiUrl &&
|
||||
giteaApiUrl !== "" &&
|
||||
!giteaApiUrl.includes("api.github.com") &&
|
||||
!giteaApiUrl.includes("github.com");
|
||||
|
||||
// Only proceed if branch exists remotely
|
||||
if (!branchExistsRemotely) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} does not exist remotely, no branch link will be added`,
|
||||
);
|
||||
return { shouldDeleteBranch: false, branchLink: "" };
|
||||
}
|
||||
if (isGitea) {
|
||||
// Use local git operations for Gitea
|
||||
console.log("Using local git commands for branch check (Gitea mode)");
|
||||
|
||||
// Check if Claude made any commits to the branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseBranch}...${claudeBranch}`,
|
||||
});
|
||||
try {
|
||||
// Fetch latest changes from remote
|
||||
await fetchBranch(claudeBranch);
|
||||
await fetchBranch(baseBranch);
|
||||
|
||||
// If there are no commits, check for uncommitted changes if not using commit signing
|
||||
if (comparison.total_commits === 0) {
|
||||
if (!useCommitSigning) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, checking for uncommitted changes...`,
|
||||
);
|
||||
// Check if branch exists and has changes
|
||||
const { hasChanges, branchSha, baseSha } = await branchHasChanges(
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
);
|
||||
|
||||
// Check for uncommitted changes using git status
|
||||
try {
|
||||
const gitStatus = await $`git status --porcelain`.quiet();
|
||||
const hasUncommittedChanges =
|
||||
gitStatus.stdout.toString().trim().length > 0;
|
||||
|
||||
if (hasUncommittedChanges) {
|
||||
console.log("Found uncommitted changes, committing them...");
|
||||
|
||||
// Add all changes
|
||||
await $`git add -A`;
|
||||
|
||||
// Commit with a descriptive message
|
||||
const runId = process.env.GITHUB_RUN_ID || "unknown";
|
||||
const commitMessage = `Auto-commit: Save uncommitted changes from Claude\n\nRun ID: ${runId}`;
|
||||
await $`git commit -m ${commitMessage}`;
|
||||
|
||||
// Push the changes
|
||||
await $`git push origin ${claudeBranch}`;
|
||||
|
||||
console.log(
|
||||
"✅ Successfully committed and pushed uncommitted changes",
|
||||
);
|
||||
|
||||
// Set branch link since we now have commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
"No uncommitted changes found, marking branch for deletion",
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
}
|
||||
} catch (gitError) {
|
||||
console.error("Error checking/committing changes:", gitError);
|
||||
// If we can't check git status, assume the branch might have changes
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
if (branchSha && baseSha) {
|
||||
if (hasChanges) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} appears to have commits (different SHA from base)`,
|
||||
);
|
||||
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has same SHA as base, marking for deletion`,
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
}
|
||||
} else {
|
||||
// If we can't get SHAs, check if branch exists at all
|
||||
const localExists = await branchExists(claudeBranch);
|
||||
const remoteExists = await remoteBranchExists(claudeBranch);
|
||||
|
||||
if (localExists || remoteExists) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} exists but SHA comparison failed, assuming it has commits`,
|
||||
);
|
||||
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} does not exist yet - this is normal during workflow`,
|
||||
);
|
||||
branchLink = "";
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error checking branch with git commands:", error);
|
||||
// For errors, assume the branch has commits to be safe
|
||||
console.log("Assuming branch exists due to git command error");
|
||||
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
} else {
|
||||
// Use API calls for GitHub
|
||||
console.log("Using API calls for branch check (GitHub mode)");
|
||||
|
||||
try {
|
||||
// Get the branch info to see if it exists and has commits
|
||||
const branchResponse = await client.api.getBranch(
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
);
|
||||
|
||||
// Get base branch info for comparison
|
||||
const baseResponse = await client.api.getBranch(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
);
|
||||
|
||||
const branchSha = branchResponse.data.commit.sha;
|
||||
const baseSha = baseResponse.data.commit.sha;
|
||||
|
||||
// If SHAs are different, assume there are commits
|
||||
if (branchSha !== baseSha) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
||||
`Branch ${claudeBranch} appears to have commits (different SHA from base)`,
|
||||
);
|
||||
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} else {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} has same SHA as base, marking for deletion`,
|
||||
);
|
||||
shouldDeleteBranch = true;
|
||||
}
|
||||
} else {
|
||||
// Only add branch link if there are commits
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
} catch (error: any) {
|
||||
console.error("Error checking branch:", error);
|
||||
|
||||
// Handle 404 specifically - branch doesn't exist
|
||||
if (error.status === 404) {
|
||||
console.log(
|
||||
`Branch ${claudeBranch} does not exist yet - this is normal during workflow`,
|
||||
);
|
||||
// Don't add branch link since branch doesn't exist
|
||||
branchLink = "";
|
||||
} else {
|
||||
// For other errors, assume the branch has commits to be safe
|
||||
console.log("Assuming branch exists due to non-404 error");
|
||||
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error comparing commits on Claude branch:", error);
|
||||
// If we can't compare but the branch exists remotely, include the branch link
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||
branchLink = `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the branch if it has no commits
|
||||
if (shouldDeleteBranch && claudeBranch) {
|
||||
try {
|
||||
await octokit.rest.git.deleteRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${claudeBranch}`,
|
||||
});
|
||||
console.log(`✅ Deleted empty branch: ${claudeBranch}`);
|
||||
} catch (deleteError) {
|
||||
console.error(`Failed to delete branch ${claudeBranch}:`, deleteError);
|
||||
// Continue even if deletion fails
|
||||
}
|
||||
console.log(
|
||||
`Skipping branch deletion - not reliably supported across all Git platforms: ${claudeBranch}`,
|
||||
);
|
||||
// Skip deletion to avoid compatibility issues
|
||||
}
|
||||
|
||||
return { shouldDeleteBranch, branchLink };
|
||||
|
||||
@@ -10,7 +10,7 @@ import { $ } from "bun";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
import type { Octokits } from "../api/client";
|
||||
import type { GitHubClient } from "../api/client";
|
||||
import type { FetchDataResult } from "../data/fetcher";
|
||||
|
||||
export type BranchInfo = {
|
||||
@@ -20,15 +20,27 @@ export type BranchInfo = {
|
||||
};
|
||||
|
||||
export async function setupBranch(
|
||||
octokits: Octokits,
|
||||
client: GitHubClient,
|
||||
githubData: FetchDataResult,
|
||||
context: ParsedGitHubContext,
|
||||
): Promise<BranchInfo> {
|
||||
const { owner, repo } = context.repository;
|
||||
const entityNumber = context.entityNumber;
|
||||
const { baseBranch, branchPrefix } = context.inputs;
|
||||
const { baseBranch } = context.inputs;
|
||||
const isPR = context.isPR;
|
||||
|
||||
// Determine base branch - use baseBranch if provided, otherwise fetch default
|
||||
let sourceBranch: string;
|
||||
|
||||
if (baseBranch) {
|
||||
// Use provided base branch for source
|
||||
sourceBranch = baseBranch;
|
||||
} else {
|
||||
// No base branch provided, fetch the default branch to use as source
|
||||
const repoResponse = await client.api.getRepo(owner, repo);
|
||||
sourceBranch = repoResponse.data.default_branch;
|
||||
}
|
||||
|
||||
if (isPR) {
|
||||
const prData = githubData.contextData as GitHubPullRequest;
|
||||
const prState = prData.state;
|
||||
@@ -36,26 +48,28 @@ export async function setupBranch(
|
||||
// Check if PR is closed or merged
|
||||
if (prState === "CLOSED" || prState === "MERGED") {
|
||||
console.log(
|
||||
`PR #${entityNumber} is ${prState}, creating new branch from source...`,
|
||||
`PR #${entityNumber} is ${prState}, will let Claude create a new branch when needed`,
|
||||
);
|
||||
// Fall through to create a new branch like we do for issues
|
||||
|
||||
// Check out the base branch and let Claude create branches as needed
|
||||
await $`git fetch origin ${sourceBranch}`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
await $`git pull origin ${sourceBranch}`;
|
||||
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
currentBranch: sourceBranch,
|
||||
};
|
||||
} else {
|
||||
// Handle open PR: Checkout the PR branch
|
||||
console.log("This is an open PR, checking out PR branch...");
|
||||
|
||||
const branchName = prData.headRefName;
|
||||
|
||||
// Determine optimal fetch depth based on PR commit count, with a minimum of 20
|
||||
const commitCount = prData.commits.totalCount;
|
||||
const fetchDepth = Math.max(commitCount, 20);
|
||||
|
||||
console.log(
|
||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
||||
);
|
||||
|
||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
||||
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
||||
await $`git checkout ${branchName} --`;
|
||||
// Execute git commands to checkout PR branch (shallow fetch for performance)
|
||||
// Fetch the branch with a depth of 20 to avoid fetching too much history, while still allowing for some context
|
||||
await $`git fetch origin --depth=20 ${branchName}`;
|
||||
await $`git checkout ${branchName}`;
|
||||
|
||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||
|
||||
@@ -69,95 +83,57 @@ export async function setupBranch(
|
||||
}
|
||||
}
|
||||
|
||||
// Determine source branch - use baseBranch if provided, otherwise fetch default
|
||||
let sourceBranch: string;
|
||||
|
||||
if (baseBranch) {
|
||||
// Use provided base branch for source
|
||||
sourceBranch = baseBranch;
|
||||
} else {
|
||||
// No base branch provided, fetch the default branch to use as source
|
||||
const repoResponse = await octokits.rest.repos.get({
|
||||
owner,
|
||||
repo,
|
||||
});
|
||||
sourceBranch = repoResponse.data.default_branch;
|
||||
}
|
||||
|
||||
// Generate branch name for either an issue or closed/merged PR
|
||||
const entityType = isPR ? "pr" : "issue";
|
||||
|
||||
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
|
||||
const now = new Date();
|
||||
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
// Ensure branch name is Kubernetes-compatible:
|
||||
// - Lowercase only
|
||||
// - Alphanumeric with hyphens
|
||||
// - No underscores
|
||||
// - Max 50 chars (to allow for prefixes)
|
||||
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
||||
const newBranch = branchName.toLowerCase().substring(0, 50);
|
||||
// For issues, check out the base branch and let Claude create branches as needed
|
||||
console.log(
|
||||
`Setting up base branch ${sourceBranch} for issue #${entityNumber}, Claude will create branch when needed...`,
|
||||
);
|
||||
|
||||
try {
|
||||
// Get the SHA of the source branch to verify it exists
|
||||
const sourceBranchRef = await octokits.rest.git.getRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: `heads/${sourceBranch}`,
|
||||
});
|
||||
// Ensure we're in the repository directory
|
||||
const repoDir = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
console.log(`Working in directory: ${repoDir}`);
|
||||
|
||||
const currentSHA = sourceBranchRef.data.object.sha;
|
||||
console.log(`Source branch SHA: ${currentSHA}`);
|
||||
// Check if we're in a git repository
|
||||
console.log(`Checking if we're in a git repository...`);
|
||||
await $`git status`;
|
||||
|
||||
// For commit signing, defer branch creation to the file ops server
|
||||
if (context.inputs.useCommitSigning) {
|
||||
console.log(
|
||||
`Branch name generated: ${newBranch} (will be created by file ops server on first commit)`,
|
||||
);
|
||||
// Ensure we have the latest version of the source branch
|
||||
console.log(`Fetching latest ${sourceBranch}...`);
|
||||
await $`git fetch origin ${sourceBranch}`;
|
||||
|
||||
// Ensure we're on the source branch
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("BASE_BRANCH", sourceBranch);
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
claudeBranch: newBranch,
|
||||
currentBranch: sourceBranch, // Stay on source branch for now
|
||||
};
|
||||
}
|
||||
|
||||
// For non-signing case, create and checkout the branch locally only
|
||||
console.log(
|
||||
`Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
|
||||
);
|
||||
|
||||
// Fetch and checkout the source branch first to ensure we branch from the correct base
|
||||
console.log(`Fetching and checking out source branch: ${sourceBranch}`);
|
||||
await $`git fetch origin ${sourceBranch} --depth=1`;
|
||||
// Checkout the source branch
|
||||
console.log(`Checking out ${sourceBranch}...`);
|
||||
await $`git checkout ${sourceBranch}`;
|
||||
|
||||
// Create and checkout the new branch from the source branch
|
||||
await $`git checkout -b ${newBranch}`;
|
||||
// Pull latest changes
|
||||
console.log(`Pulling latest changes for ${sourceBranch}...`);
|
||||
await $`git pull origin ${sourceBranch}`;
|
||||
|
||||
// Verify the branch was checked out
|
||||
const currentBranch = await $`git branch --show-current`;
|
||||
const branchName = currentBranch.text().trim();
|
||||
console.log(`Current branch: ${branchName}`);
|
||||
|
||||
if (branchName === sourceBranch) {
|
||||
console.log(`✅ Successfully checked out base branch: ${sourceBranch}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Branch checkout failed. Expected ${sourceBranch}, got ${branchName}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Successfully created and checked out local branch: ${newBranch}`,
|
||||
`Branch setup completed, ready for Claude to create branches as needed`,
|
||||
);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("BASE_BRANCH", sourceBranch);
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
claudeBranch: newBranch,
|
||||
currentBranch: newBranch,
|
||||
currentBranch: sourceBranch,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error in branch setup:", error);
|
||||
console.error("Error setting up branch:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
import { GITEA_SERVER_URL } from "../api/config";
|
||||
|
||||
export type ExecutionDetails = {
|
||||
cost_usd?: number;
|
||||
@@ -160,7 +160,7 @@ export function updateCommentBody(input: CommentUpdateInput): string {
|
||||
// Extract owner/repo from jobUrl
|
||||
const repoMatch = jobUrl.match(/github\.com\/([^\/]+)\/([^\/]+)\//);
|
||||
if (repoMatch) {
|
||||
branchUrl = `${GITHUB_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/tree/${finalBranchName}`;
|
||||
branchUrl = `${GITEA_SERVER_URL}/${repoMatch[1]}/${repoMatch[2]}/src/branch/${finalBranchName}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { GITHUB_SERVER_URL } from "../../api/config";
|
||||
import { GITEA_SERVER_URL } from "../../api/config";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
export const SPINNER_HTML =
|
||||
'<img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />';
|
||||
function getSpinnerHtml(): string {
|
||||
return `<img src="https://raw.githubusercontent.com/markwylde/claude-code-gitea-action/refs/heads/gitea/assets/spinner.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`;
|
||||
}
|
||||
|
||||
export const SPINNER_HTML = getSpinnerHtml();
|
||||
|
||||
export function createJobRunLink(
|
||||
owner: string,
|
||||
repo: string,
|
||||
runId: string,
|
||||
): string {
|
||||
const jobRunUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`;
|
||||
const jobRunUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/actions/runs/${runId}`;
|
||||
return `[View job run](${jobRunUrl})`;
|
||||
}
|
||||
|
||||
@@ -17,7 +22,7 @@ export function createBranchLink(
|
||||
repo: string,
|
||||
branchName: string,
|
||||
): string {
|
||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${branchName}`;
|
||||
const branchUrl = `${GITEA_SERVER_URL}/${owner}/${repo}/src/branch/${branchName}/`;
|
||||
return `\n[View branch](${branchUrl})`;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,12 @@ import { appendFileSync } from "fs";
|
||||
import { createJobRunLink, createCommentBody } from "./common";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
isPullRequestEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
const CLAUDE_APP_BOT_ID = 209825114;
|
||||
import type { GiteaApiClient } from "../../api/gitea-client";
|
||||
|
||||
export async function createInitialComment(
|
||||
octokit: Octokit,
|
||||
api: GiteaApiClient,
|
||||
context: ParsedGitHubContext,
|
||||
) {
|
||||
const { owner, repo } = context.repository;
|
||||
@@ -28,81 +25,53 @@ export async function createInitialComment(
|
||||
try {
|
||||
let response;
|
||||
|
||||
if (
|
||||
context.inputs.useStickyComment &&
|
||||
context.isPR &&
|
||||
isPullRequestEvent(context)
|
||||
) {
|
||||
const comments = await octokit.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
});
|
||||
const existingComment = comments.data.find((comment) => {
|
||||
const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
|
||||
const botNameMatch =
|
||||
comment.user?.type === "Bot" &&
|
||||
comment.user?.login.toLowerCase().includes("claude");
|
||||
const bodyMatch = comment.body === initialBody;
|
||||
console.log(
|
||||
`Creating comment for ${context.isPR ? "PR" : "issue"} #${context.entityNumber}`,
|
||||
);
|
||||
console.log(`Repository: ${owner}/${repo}`);
|
||||
|
||||
return idMatch || botNameMatch || bodyMatch;
|
||||
});
|
||||
if (existingComment) {
|
||||
response = await octokit.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
console.log(`Creating PR review comment reply`);
|
||||
response = await api.customRequest(
|
||||
"POST",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/${context.entityNumber}/comments/${context.payload.comment.id}/replies`,
|
||||
{
|
||||
body: initialBody,
|
||||
});
|
||||
} else {
|
||||
// Create new comment if no existing one found
|
||||
response = await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
}
|
||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||
response = await octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.entityNumber,
|
||||
comment_id: context.payload.comment.id,
|
||||
body: initialBody,
|
||||
});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// For all other cases (issues, issue comments, or missing comment_id)
|
||||
response = await octokit.rest.issues.createComment({
|
||||
console.log(`Creating issue comment via API`);
|
||||
response = await api.createIssueComment(
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
context.entityNumber,
|
||||
initialBody,
|
||||
);
|
||||
}
|
||||
|
||||
// Output the comment ID for downstream steps using GITHUB_OUTPUT
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
|
||||
return response.data;
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
console.error("Error in initial comment:", error);
|
||||
|
||||
// Always fall back to regular issue comment if anything fails
|
||||
try {
|
||||
const response = await octokit.rest.issues.createComment({
|
||||
const response = await api.createIssueComment(
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.entityNumber,
|
||||
body: initialBody,
|
||||
});
|
||||
context.entityNumber,
|
||||
initialBody,
|
||||
);
|
||||
|
||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
|
||||
return response.data;
|
||||
return response.data.id;
|
||||
} catch (fallbackError) {
|
||||
console.error("Error creating fallback comment:", fallbackError);
|
||||
throw fallbackError;
|
||||
|
||||
@@ -10,15 +10,14 @@ import {
|
||||
createBranchLink,
|
||||
createCommentBody,
|
||||
} from "./common";
|
||||
import { type Octokits } from "../../api/client";
|
||||
import { type GitHubClient } from "../../api/client";
|
||||
import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../../context";
|
||||
import { updateClaudeComment } from "./update-claude-comment";
|
||||
|
||||
export async function updateTrackingComment(
|
||||
octokit: Octokits,
|
||||
client: GitHubClient,
|
||||
context: ParsedGitHubContext,
|
||||
commentId: number,
|
||||
branch?: string,
|
||||
@@ -37,19 +36,21 @@ export async function updateTrackingComment(
|
||||
|
||||
// Update the existing comment with the branch link
|
||||
try {
|
||||
const isPRReviewComment = isPullRequestReviewCommentEvent(context);
|
||||
|
||||
await updateClaudeComment(octokit.rest, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: updatedBody,
|
||||
isPullRequestReviewComment: isPRReviewComment,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with branch link`,
|
||||
);
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
// For PR review comments (inline comments), use the pulls API
|
||||
await client.api.customRequest(
|
||||
"PATCH",
|
||||
`/api/v1/repos/${owner}/${repo}/pulls/comments/${commentId}`,
|
||||
{
|
||||
body: updatedBody,
|
||||
},
|
||||
);
|
||||
console.log(`✅ Updated PR review comment ${commentId} with branch link`);
|
||||
} else {
|
||||
// For all other comments, use the issues API
|
||||
await client.api.updateIssueComment(owner, repo, commentId, updatedBody);
|
||||
console.log(`✅ Updated issue comment ${commentId} with branch link`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating comment with branch link:", error);
|
||||
throw error;
|
||||
|
||||
@@ -1,56 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
|
||||
async function getOidcToken(): Promise<string> {
|
||||
try {
|
||||
const oidcToken = await core.getIDToken("claude-code-github-action");
|
||||
|
||||
return oidcToken;
|
||||
} catch (error) {
|
||||
console.error("Failed to get OIDC token:", error);
|
||||
throw new Error(
|
||||
"Could not fetch an OIDC token. Did you remember to add `id-token: write` to your workflow permissions?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
const response = await fetch(
|
||||
"https://api.anthropic.com/api/github/github-app-token-exchange",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseJson = (await response.json()) as {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
console.error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
|
||||
);
|
||||
throw new Error(`${responseJson?.error?.message ?? "Unknown error"}`);
|
||||
}
|
||||
|
||||
const appTokenData = (await response.json()) as {
|
||||
token?: string;
|
||||
app_token?: string;
|
||||
};
|
||||
const appToken = appTokenData.token || appTokenData.app_token;
|
||||
|
||||
if (!appToken) {
|
||||
throw new Error("App token not found in response");
|
||||
}
|
||||
|
||||
return appToken;
|
||||
}
|
||||
|
||||
export async function setupGitHubToken(): Promise<string> {
|
||||
try {
|
||||
@@ -63,22 +13,21 @@ export async function setupGitHubToken(): Promise<string> {
|
||||
return providedToken;
|
||||
}
|
||||
|
||||
console.log("Requesting OIDC token...");
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
// Use the standard GITHUB_TOKEN from the workflow environment
|
||||
const workflowToken = process.env.GITHUB_TOKEN;
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken),
|
||||
if (workflowToken) {
|
||||
console.log("Using workflow GITHUB_TOKEN for authentication");
|
||||
core.setOutput("GITHUB_TOKEN", workflowToken);
|
||||
return workflowToken;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"No GitHub token available. Please provide a gitea_token input or ensure GITHUB_TOKEN is available in the workflow environment.",
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
console.log("Using GITHUB_TOKEN from OIDC");
|
||||
core.setOutput("GITHUB_TOKEN", appToken);
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
core.setFailed(
|
||||
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
`Failed to setup GitHub token: ${error}.\n\nPlease provide a \`gitea_token\` in the \`with\` section of the action in your workflow yml file, or ensure the workflow has access to the default GITHUB_TOKEN.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { GITHUB_SERVER_URL } from "../api/config";
|
||||
|
||||
const IMAGE_REGEX = new RegExp(
|
||||
`!\\[[^\\]]*\\]\\((${GITHUB_SERVER_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/user-attachments\\/assets\\/[^)]+)\\)`,
|
||||
"g",
|
||||
);
|
||||
import type { GitHubClient } from "../api/client";
|
||||
|
||||
type IssueComment = {
|
||||
type: "issue_comment";
|
||||
@@ -47,186 +39,15 @@ export type CommentWithImages =
|
||||
| PullRequestBody;
|
||||
|
||||
export async function downloadCommentImages(
|
||||
octokits: Octokits,
|
||||
owner: string,
|
||||
repo: string,
|
||||
comments: CommentWithImages[],
|
||||
_client: GitHubClient,
|
||||
_owner: string,
|
||||
_repo: string,
|
||||
_comments: CommentWithImages[],
|
||||
): Promise<Map<string, string>> {
|
||||
const urlToPathMap = new Map<string, string>();
|
||||
const downloadsDir = "/tmp/github-images";
|
||||
|
||||
await fs.mkdir(downloadsDir, { recursive: true });
|
||||
|
||||
const commentsWithImages: Array<{
|
||||
comment: CommentWithImages;
|
||||
urls: string[];
|
||||
}> = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const imageMatches = [...comment.body.matchAll(IMAGE_REGEX)];
|
||||
const urls = imageMatches.map((match) => match[1] as string);
|
||||
|
||||
if (urls.length > 0) {
|
||||
commentsWithImages.push({ comment, urls });
|
||||
const id =
|
||||
comment.type === "issue_body"
|
||||
? comment.issueNumber
|
||||
: comment.type === "pr_body"
|
||||
? comment.pullNumber
|
||||
: comment.id;
|
||||
console.log(`Found ${urls.length} image(s) in ${comment.type} ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each comment with images
|
||||
for (const { comment, urls } of commentsWithImages) {
|
||||
try {
|
||||
let bodyHtml: string | undefined;
|
||||
|
||||
// Get the HTML version based on comment type
|
||||
switch (comment.type) {
|
||||
case "issue_comment": {
|
||||
const response = await octokits.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: parseInt(comment.id),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "review_comment": {
|
||||
const response = await octokits.rest.pulls.getReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: parseInt(comment.id),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "review_body": {
|
||||
const response = await octokits.rest.pulls.getReview({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: parseInt(comment.pullNumber),
|
||||
review_id: parseInt(comment.id),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "issue_body": {
|
||||
const response = await octokits.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: parseInt(comment.issueNumber),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
bodyHtml = response.data.body_html;
|
||||
break;
|
||||
}
|
||||
case "pr_body": {
|
||||
const response = await octokits.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: parseInt(comment.pullNumber),
|
||||
mediaType: {
|
||||
format: "full+json",
|
||||
},
|
||||
});
|
||||
// Type here seems to be wrong
|
||||
bodyHtml = (response.data as any).body_html;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bodyHtml) {
|
||||
const id =
|
||||
comment.type === "issue_body"
|
||||
? comment.issueNumber
|
||||
: comment.type === "pr_body"
|
||||
? comment.pullNumber
|
||||
: comment.id;
|
||||
console.warn(`No HTML body found for ${comment.type} ${id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract signed URLs from HTML
|
||||
const signedUrlRegex =
|
||||
/https:\/\/private-user-images\.githubusercontent\.com\/[^"]+\?jwt=[^"]+/g;
|
||||
const signedUrls = bodyHtml.match(signedUrlRegex) || [];
|
||||
|
||||
// Download each image
|
||||
for (let i = 0; i < Math.min(signedUrls.length, urls.length); i++) {
|
||||
const signedUrl = signedUrls[i];
|
||||
const originalUrl = urls[i];
|
||||
|
||||
if (!signedUrl || !originalUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we've already downloaded this URL
|
||||
if (urlToPathMap.has(originalUrl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileExtension = getImageExtension(originalUrl);
|
||||
const filename = `image-${Date.now()}-${i}${fileExtension}`;
|
||||
const localPath = path.join(downloadsDir, filename);
|
||||
|
||||
try {
|
||||
console.log(`Downloading ${originalUrl}...`);
|
||||
|
||||
const imageResponse = await fetch(signedUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(
|
||||
`HTTP ${imageResponse.status}: ${imageResponse.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageResponse.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
await fs.writeFile(localPath, buffer);
|
||||
console.log(`✓ Saved: ${localPath}`);
|
||||
|
||||
urlToPathMap.set(originalUrl, localPath);
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to download ${originalUrl}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const id =
|
||||
comment.type === "issue_body"
|
||||
? comment.issueNumber
|
||||
: comment.type === "pr_body"
|
||||
? comment.pullNumber
|
||||
: comment.id;
|
||||
console.error(
|
||||
`Failed to process images for ${comment.type} ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return urlToPathMap;
|
||||
}
|
||||
|
||||
function getImageExtension(url: string): string {
|
||||
const urlParts = url.split("/");
|
||||
const filename = urlParts[urlParts.length - 1];
|
||||
if (!filename) {
|
||||
throw new Error("Invalid URL: No filename found");
|
||||
}
|
||||
|
||||
const match = filename.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i);
|
||||
return match ? match[0] : ".png";
|
||||
// Temporarily simplified - return empty map to avoid Octokit dependencies
|
||||
// TODO: Implement image downloading with direct Gitea API calls if needed
|
||||
console.log(
|
||||
"Image downloading temporarily disabled during Octokit migration",
|
||||
);
|
||||
return new Map<string, string>();
|
||||
}
|
||||
|
||||
96
src/github/utils/local-git.ts
Normal file
96
src/github/utils/local-git.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun";
|
||||
|
||||
/**
|
||||
* Check if a branch exists locally using git commands
|
||||
*/
|
||||
export async function branchExists(branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await $`git show-ref --verify --quiet refs/heads/${branchName}`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a remote branch exists using git commands
|
||||
*/
|
||||
export async function remoteBranchExists(branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await $`git show-ref --verify --quiet refs/remotes/origin/${branchName}`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SHA of a branch using git commands
|
||||
*/
|
||||
export async function getBranchSha(branchName: string): Promise<string | null> {
|
||||
try {
|
||||
// Try local branch first
|
||||
if (await branchExists(branchName)) {
|
||||
const result = await $`git rev-parse refs/heads/${branchName}`;
|
||||
return result.text().trim();
|
||||
}
|
||||
|
||||
// Try remote branch if local doesn't exist
|
||||
if (await remoteBranchExists(branchName)) {
|
||||
const result = await $`git rev-parse refs/remotes/origin/${branchName}`;
|
||||
return result.text().trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting SHA for branch ${branchName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch has commits different from base branch
|
||||
*/
|
||||
export async function branchHasChanges(
|
||||
branchName: string,
|
||||
baseBranch: string,
|
||||
): Promise<{
|
||||
hasChanges: boolean;
|
||||
branchSha: string | null;
|
||||
baseSha: string | null;
|
||||
}> {
|
||||
try {
|
||||
const branchSha = await getBranchSha(branchName);
|
||||
const baseSha = await getBranchSha(baseBranch);
|
||||
|
||||
if (!branchSha || !baseSha) {
|
||||
return { hasChanges: false, branchSha, baseSha };
|
||||
}
|
||||
|
||||
const hasChanges = branchSha !== baseSha;
|
||||
return { hasChanges, branchSha, baseSha };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error comparing branches ${branchName} and ${baseBranch}:`,
|
||||
error,
|
||||
);
|
||||
return { hasChanges: false, branchSha: null, baseSha: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest changes from remote to ensure we have up-to-date branch info
|
||||
*/
|
||||
export async function fetchBranch(branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await $`git fetch origin ${branchName}`;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Could not fetch branch ${branchName} from remote (may not exist yet)`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,53 @@
|
||||
* Prevents automated tools or bots from triggering Claude
|
||||
*/
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { GiteaApiClient } from "../api/gitea-client";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export async function checkHumanActor(
|
||||
octokit: Octokit,
|
||||
api: GiteaApiClient,
|
||||
githubContext: ParsedGitHubContext,
|
||||
) {
|
||||
// Fetch user information from GitHub API
|
||||
const { data: userData } = await octokit.users.getByUsername({
|
||||
username: githubContext.actor,
|
||||
});
|
||||
// Check if we're in a Gitea environment
|
||||
const isGitea =
|
||||
process.env.GITEA_API_URL &&
|
||||
!process.env.GITEA_API_URL.includes("api.github.com");
|
||||
|
||||
const actorType = userData.type;
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
if (actorType !== "User") {
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
if (isGitea) {
|
||||
console.log(
|
||||
`Detected Gitea environment, skipping actor type validation for: ${githubContext.actor}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Verified human actor: ${githubContext.actor}`);
|
||||
try {
|
||||
// Fetch user information from GitHub API
|
||||
const response = await api.customRequest(
|
||||
"GET",
|
||||
`/api/v1/users/${githubContext.actor}`,
|
||||
);
|
||||
const userData = response.data;
|
||||
|
||||
const actorType = userData.type;
|
||||
|
||||
console.log(`Actor type: ${actorType}`);
|
||||
|
||||
if (actorType !== "User") {
|
||||
throw new Error(
|
||||
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Verified human actor: ${githubContext.actor}`);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to check actor type for ${githubContext.actor}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
// For compatibility, assume human actor if API call fails
|
||||
console.log(
|
||||
`Assuming human actor due to API failure: ${githubContext.actor}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,71 @@
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type { GiteaApiClient } from "../api/gitea-client";
|
||||
|
||||
/**
|
||||
* Check if the actor has write permissions to the repository
|
||||
* @param octokit - The Octokit REST client
|
||||
* @param api - The Gitea API client
|
||||
* @param context - The GitHub context
|
||||
* @returns true if the actor has write permissions, false otherwise
|
||||
*/
|
||||
export async function checkWritePermissions(
|
||||
octokit: Octokit,
|
||||
api: GiteaApiClient,
|
||||
context: ParsedGitHubContext,
|
||||
): Promise<boolean> {
|
||||
const { repository, actor } = context;
|
||||
|
||||
try {
|
||||
core.info(`Checking permissions for actor: ${actor}`);
|
||||
core.info(
|
||||
`Environment check - GITEA_API_URL: ${process.env.GITEA_API_URL || "undefined"}`,
|
||||
);
|
||||
core.info(`API client base URL: ${api.getBaseUrl?.() || "undefined"}`);
|
||||
|
||||
// For Gitea compatibility, check if we're in a non-GitHub environment
|
||||
const giteaApiUrl = process.env.GITEA_API_URL?.trim();
|
||||
const isGitea =
|
||||
giteaApiUrl &&
|
||||
giteaApiUrl !== "" &&
|
||||
!giteaApiUrl.includes("api.github.com") &&
|
||||
!giteaApiUrl.includes("github.com");
|
||||
|
||||
if (isGitea) {
|
||||
core.info(
|
||||
`Detected Gitea environment (${giteaApiUrl}), assuming actor has permissions`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check if the API client base URL suggests we're using Gitea
|
||||
const apiUrl = api.getBaseUrl?.() || "";
|
||||
if (
|
||||
apiUrl &&
|
||||
!apiUrl.includes("api.github.com") &&
|
||||
!apiUrl.includes("github.com")
|
||||
) {
|
||||
core.info(
|
||||
`Detected non-GitHub API URL (${apiUrl}), assuming actor has permissions`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we're still here, we might be using GitHub's API, so attempt the permissions check
|
||||
core.info(
|
||||
`Proceeding with GitHub-style permission check for actor: ${actor}`,
|
||||
);
|
||||
|
||||
// However, if the API client is clearly pointing to a non-GitHub URL, skip the check
|
||||
if (apiUrl && apiUrl !== "https://api.github.com") {
|
||||
core.info(
|
||||
`API URL ${apiUrl} doesn't look like GitHub, assuming permissions and skipping check`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check permissions directly using the permission endpoint
|
||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||
owner: repository.owner,
|
||||
repo: repository.repo,
|
||||
username: actor,
|
||||
});
|
||||
const response = await api.customRequest(
|
||||
"GET",
|
||||
`/api/v1/repos/${repository.owner}/${repository.repo}/collaborators/${actor}/permission`,
|
||||
);
|
||||
|
||||
const permissionLevel = response.data.permission;
|
||||
core.info(`Permission level retrieved: ${permissionLevel}`);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as core from "@actions/core";
|
||||
import {
|
||||
isIssuesEvent,
|
||||
isIssuesAssignedEvent,
|
||||
isIssueCommentEvent,
|
||||
isPullRequestEvent,
|
||||
isPullRequestReviewEvent,
|
||||
@@ -13,9 +12,13 @@ import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
const {
|
||||
inputs: { assigneeTrigger, labelTrigger, triggerPhrase, directPrompt },
|
||||
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
|
||||
} = context;
|
||||
|
||||
console.log(
|
||||
`Checking trigger: event=${context.eventName}, action=${context.eventAction}, phrase='${triggerPhrase}', assignee='${assigneeTrigger}', direct='${directPrompt}'`,
|
||||
);
|
||||
|
||||
// If direct prompt is provided, always trigger
|
||||
if (directPrompt) {
|
||||
console.log(`Direct prompt provided, triggering action`);
|
||||
@@ -23,10 +26,14 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
}
|
||||
|
||||
// Check for assignee trigger
|
||||
if (isIssuesAssignedEvent(context)) {
|
||||
if (isIssuesEvent(context) && context.eventAction === "assigned") {
|
||||
// Remove @ symbol from assignee_trigger if present
|
||||
let triggerUser = assigneeTrigger.replace(/^@/, "");
|
||||
const assigneeUsername = context.payload.assignee?.login || "";
|
||||
let triggerUser = assigneeTrigger?.replace(/^@/, "") || "";
|
||||
const assigneeUsername = context.payload.issue.assignee?.login || "";
|
||||
|
||||
console.log(
|
||||
`Checking assignee trigger: user='${triggerUser}', assignee='${assigneeUsername}'`,
|
||||
);
|
||||
|
||||
if (triggerUser && assigneeUsername === triggerUser) {
|
||||
console.log(`Issue assigned to trigger user '${triggerUser}'`);
|
||||
@@ -34,16 +41,6 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for label trigger
|
||||
if (isIssuesEvent(context) && context.eventAction === "labeled") {
|
||||
const labelName = (context.payload as any).label?.name || "";
|
||||
|
||||
if (labelTrigger && labelName === labelTrigger) {
|
||||
console.log(`Issue labeled with trigger label '${labelTrigger}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue body and title trigger on issue creation
|
||||
if (isIssuesEvent(context) && context.eventAction === "opened") {
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
1280
src/mcp/gitea-mcp-server.ts
Normal file
1280
src/mcp/gitea-mcp-server.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,631 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// GitHub File Operations MCP Server
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import fetch from "node-fetch";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
|
||||
type GitHubRef = {
|
||||
object: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GitHubCommit = {
|
||||
tree: {
|
||||
sha: string;
|
||||
};
|
||||
};
|
||||
|
||||
type GitHubTree = {
|
||||
sha: string;
|
||||
};
|
||||
|
||||
type GitHubNewCommit = {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: {
|
||||
name: string;
|
||||
date: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const BRANCH_NAME = process.env.BRANCH_NAME;
|
||||
const REPO_DIR = process.env.REPO_DIR || process.cwd();
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !BRANCH_NAME) {
|
||||
console.error(
|
||||
"Error: REPO_OWNER, REPO_NAME, and BRANCH_NAME environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "GitHub File Operations Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// Helper function to get or create branch reference
|
||||
async function getOrCreateBranchRef(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
githubToken: string,
|
||||
): Promise<string> {
|
||||
// Try to get the branch reference
|
||||
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
const refResponse = await fetch(refUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (refResponse.ok) {
|
||||
const refData = (await refResponse.json()) as GitHubRef;
|
||||
return refData.object.sha;
|
||||
}
|
||||
|
||||
if (refResponse.status !== 404) {
|
||||
throw new Error(`Failed to get branch reference: ${refResponse.status}`);
|
||||
}
|
||||
|
||||
const baseBranch = process.env.BASE_BRANCH!;
|
||||
|
||||
// Get the SHA of the base branch
|
||||
const baseRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${baseBranch}`;
|
||||
const baseRefResponse = await fetch(baseRefUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
let baseSha: string;
|
||||
|
||||
if (!baseRefResponse.ok) {
|
||||
// If base branch doesn't exist, try default branch
|
||||
const repoUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
|
||||
const repoResponse = await fetch(repoUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
throw new Error(`Failed to get repository info: ${repoResponse.status}`);
|
||||
}
|
||||
|
||||
const repoData = (await repoResponse.json()) as {
|
||||
default_branch: string;
|
||||
};
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
// Try default branch
|
||||
const defaultRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${defaultBranch}`;
|
||||
const defaultRefResponse = await fetch(defaultRefUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!defaultRefResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to get default branch reference: ${defaultRefResponse.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultRefData = (await defaultRefResponse.json()) as GitHubRef;
|
||||
baseSha = defaultRefData.object.sha;
|
||||
} else {
|
||||
const baseRefData = (await baseRefResponse.json()) as GitHubRef;
|
||||
baseSha = baseRefData.object.sha;
|
||||
}
|
||||
|
||||
// Create the new branch using the same pattern as octokit
|
||||
const createRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs`;
|
||||
const createRefResponse = await fetch(createRefUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${branch}`,
|
||||
sha: baseSha,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!createRefResponse.ok) {
|
||||
const errorText = await createRefResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create branch: ${createRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Successfully created branch ${branch}`);
|
||||
return baseSha;
|
||||
}
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
"Commit one or more files to a repository in a single commit (this will commit them atomically in the remote repository)",
|
||||
{
|
||||
files: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
async ({ files, message }) => {
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const branch = BRANCH_NAME;
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
const processedFiles = files.map((filePath) => {
|
||||
if (filePath.startsWith("/")) {
|
||||
return filePath.slice(1);
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
|
||||
// 1. Get the branch reference (create if doesn't exist)
|
||||
const baseSha = await getOrCreateBranchRef(
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
githubToken,
|
||||
);
|
||||
|
||||
// 2. Get the base commit
|
||||
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
||||
const commitResponse = await fetch(commitUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!commitResponse.ok) {
|
||||
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
|
||||
}
|
||||
|
||||
const commitData = (await commitResponse.json()) as GitHubCommit;
|
||||
const baseTreeSha = commitData.tree.sha;
|
||||
|
||||
// 3. Create tree entries for all files
|
||||
const treeEntries = await Promise.all(
|
||||
processedFiles.map(async (filePath) => {
|
||||
const fullPath = filePath.startsWith("/")
|
||||
? filePath
|
||||
: join(REPO_DIR, filePath);
|
||||
|
||||
// Check if file is binary (images, etc.)
|
||||
const isBinaryFile =
|
||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
||||
filePath,
|
||||
);
|
||||
|
||||
if (isBinaryFile) {
|
||||
// For binary files, create a blob first using the Blobs API
|
||||
const binaryContent = await readFile(fullPath);
|
||||
|
||||
// Create blob using Blobs API (supports encoding parameter)
|
||||
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
|
||||
const blobResponse = await fetch(blobUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: binaryContent.toString("base64"),
|
||||
encoding: "base64",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!blobResponse.ok) {
|
||||
const errorText = await blobResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const blobData = (await blobResponse.json()) as { sha: string };
|
||||
|
||||
// Return tree entry with blob SHA
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
sha: blobData.sha,
|
||||
};
|
||||
} else {
|
||||
// For text files, include content directly in tree
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
return {
|
||||
path: filePath,
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
content: content,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Create a new tree
|
||||
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
|
||||
const treeResponse = await fetch(treeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
base_tree: baseTreeSha,
|
||||
tree: treeEntries,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!treeResponse.ok) {
|
||||
const errorText = await treeResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const treeData = (await treeResponse.json()) as GitHubTree;
|
||||
|
||||
// 5. Create a new commit
|
||||
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
|
||||
const newCommitResponse = await fetch(newCommitUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
tree: treeData.sha,
|
||||
parents: [baseSha],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!newCommitResponse.ok) {
|
||||
const errorText = await newCommitResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
||||
|
||||
// 6. Update the reference to point to the new commit
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
|
||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||
// on certain repos when updating git references. These appear to be transient
|
||||
// GitHub API issues that succeed on retry.
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
const error = new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
|
||||
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||
if (updateRefResponse.status === 403) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For non-403 errors, fail immediately without retry
|
||||
console.error("Non-retryable error:", updateRefResponse.status);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000, // Start with 1 second delay
|
||||
maxDelayMs: 5000, // Max 5 seconds delay
|
||||
backoffFactor: 2, // Double the delay each time
|
||||
},
|
||||
);
|
||||
|
||||
const simplifiedResult = {
|
||||
commit: {
|
||||
sha: newCommitData.sha,
|
||||
message: newCommitData.message,
|
||||
author: newCommitData.author.name,
|
||||
date: newCommitData.author.date,
|
||||
},
|
||||
files: processedFiles.map((path) => ({ path })),
|
||||
tree: {
|
||||
sha: treeData.sha,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(simplifiedResult, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete files tool
|
||||
server.tool(
|
||||
"delete_files",
|
||||
"Delete one or more files from a repository in a single commit",
|
||||
{
|
||||
paths: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths to delete relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
async ({ paths, message }) => {
|
||||
const owner = REPO_OWNER;
|
||||
const repo = REPO_NAME;
|
||||
const branch = BRANCH_NAME;
|
||||
try {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||
}
|
||||
|
||||
// Convert absolute paths to relative if they match CWD
|
||||
const cwd = process.cwd();
|
||||
const processedPaths = paths.map((filePath) => {
|
||||
if (filePath.startsWith("/")) {
|
||||
if (filePath.startsWith(cwd)) {
|
||||
// Strip CWD from absolute path
|
||||
return filePath.slice(cwd.length + 1);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Path '${filePath}' must be relative to repository root or within current working directory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return filePath;
|
||||
});
|
||||
|
||||
// 1. Get the branch reference (create if doesn't exist)
|
||||
const baseSha = await getOrCreateBranchRef(
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
githubToken,
|
||||
);
|
||||
|
||||
// 2. Get the base commit
|
||||
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
|
||||
const commitResponse = await fetch(commitUrl, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
|
||||
if (!commitResponse.ok) {
|
||||
throw new Error(`Failed to get base commit: ${commitResponse.status}`);
|
||||
}
|
||||
|
||||
const commitData = (await commitResponse.json()) as GitHubCommit;
|
||||
const baseTreeSha = commitData.tree.sha;
|
||||
|
||||
// 3. Create tree entries for file deletions (setting SHA to null)
|
||||
const treeEntries = processedPaths.map((path) => ({
|
||||
path: path,
|
||||
mode: "100644",
|
||||
type: "blob" as const,
|
||||
sha: null,
|
||||
}));
|
||||
|
||||
// 4. Create a new tree with deletions
|
||||
const treeUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/trees`;
|
||||
const treeResponse = await fetch(treeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
base_tree: baseTreeSha,
|
||||
tree: treeEntries,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!treeResponse.ok) {
|
||||
const errorText = await treeResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create tree: ${treeResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const treeData = (await treeResponse.json()) as GitHubTree;
|
||||
|
||||
// 5. Create a new commit
|
||||
const newCommitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits`;
|
||||
const newCommitResponse = await fetch(newCommitUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
tree: treeData.sha,
|
||||
parents: [baseSha],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!newCommitResponse.ok) {
|
||||
const errorText = await newCommitResponse.text();
|
||||
throw new Error(
|
||||
`Failed to create commit: ${newCommitResponse.status} - ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const newCommitData = (await newCommitResponse.json()) as GitHubNewCommit;
|
||||
|
||||
// 6. Update the reference to point to the new commit
|
||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||
|
||||
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||
// on certain repos when updating git references. These appear to be transient
|
||||
// GitHub API issues that succeed on retry.
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
const updateRefResponse = await fetch(updateRefUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sha: newCommitData.sha,
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!updateRefResponse.ok) {
|
||||
const errorText = await updateRefResponse.text();
|
||||
const error = new Error(
|
||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
||||
);
|
||||
|
||||
// Only retry on 403 errors - these are the intermittent failures we're targeting
|
||||
if (updateRefResponse.status === 403) {
|
||||
console.log("Received 403 error, will retry...");
|
||||
throw error;
|
||||
}
|
||||
|
||||
// For non-403 errors, fail immediately without retry
|
||||
console.error("Non-retryable error:", updateRefResponse.status);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
initialDelayMs: 1000, // Start with 1 second delay
|
||||
maxDelayMs: 5000, // Max 5 seconds delay
|
||||
backoffFactor: 2, // Double the delay each time
|
||||
},
|
||||
);
|
||||
|
||||
const simplifiedResult = {
|
||||
commit: {
|
||||
sha: newCommitData.sha,
|
||||
message: newCommitData.message,
|
||||
author: newCommitData.author.name,
|
||||
date: newCommitData.author.date,
|
||||
},
|
||||
deletedFiles: processedPaths.map((path) => ({ path })),
|
||||
tree: {
|
||||
sha: treeData.sha,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(simplifiedResult, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function runServer() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
process.on("exit", () => {
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
runServer().catch(console.error);
|
||||
@@ -1,203 +1,71 @@
|
||||
import * as core from "@actions/core";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
type PrepareConfigParams = {
|
||||
githubToken: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
additionalMcpConfig?: string;
|
||||
claudeCommentId?: string;
|
||||
allowedTools: string[];
|
||||
context: ParsedGitHubContext;
|
||||
};
|
||||
|
||||
async function checkActionsReadPermission(
|
||||
token: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const client = new Octokit({ auth: token, baseUrl: GITHUB_API_URL });
|
||||
|
||||
// Try to list workflow runs - this requires actions:read
|
||||
// We use per_page=1 to minimize the response size
|
||||
await client.actions.listWorkflowRunsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Check if it's a permission error
|
||||
if (
|
||||
error.status === 403 &&
|
||||
error.message?.includes("Resource not accessible")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other errors (network issues, etc), log but don't fail
|
||||
core.debug(`Failed to check actions permission: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareMcpConfig(
|
||||
params: PrepareConfigParams,
|
||||
githubToken: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<string> {
|
||||
const {
|
||||
githubToken,
|
||||
owner,
|
||||
repo,
|
||||
branch,
|
||||
baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId,
|
||||
allowedTools,
|
||||
context,
|
||||
} = params;
|
||||
console.log("[MCP-INSTALL] Preparing MCP configuration...");
|
||||
console.log(`[MCP-INSTALL] Owner: ${owner}`);
|
||||
console.log(`[MCP-INSTALL] Repo: ${repo}`);
|
||||
console.log(`[MCP-INSTALL] Branch: ${branch}`);
|
||||
console.log(
|
||||
`[MCP-INSTALL] GitHub token: ${githubToken ? "***" : "undefined"}`,
|
||||
);
|
||||
console.log(
|
||||
`[MCP-INSTALL] GITHUB_ACTION_PATH: ${process.env.GITHUB_ACTION_PATH}`,
|
||||
);
|
||||
console.log(
|
||||
`[MCP-INSTALL] GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const allowedToolsList = allowedTools || [];
|
||||
|
||||
const hasGitHubMcpTools = allowedToolsList.some((tool) =>
|
||||
tool.startsWith("mcp__github__"),
|
||||
);
|
||||
|
||||
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
// Always include comment server for updating Claude comments
|
||||
baseMcpConfig.mcpServers.github_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
gitea: {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/gitea-mcp-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
BRANCH_NAME: branch,
|
||||
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
||||
GITEA_API_URL:
|
||||
process.env.GITEA_API_URL || "https://api.github.com",
|
||||
},
|
||||
},
|
||||
local_git_ops: {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/local-git-ops-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
BRANCH_NAME: branch,
|
||||
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
||||
GITEA_API_URL:
|
||||
process.env.GITEA_API_URL || "https://api.github.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Include file ops server when commit signing is enabled
|
||||
if (context.inputs.useCommitSigning) {
|
||||
baseMcpConfig.mcpServers.github_file_ops = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
BRANCH_NAME: branch,
|
||||
BASE_BRANCH: baseBranch,
|
||||
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
IS_PR: process.env.IS_PR || "false",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
const configString = JSON.stringify(mcpConfig, null, 2);
|
||||
console.log("[MCP-INSTALL] Generated MCP configuration:");
|
||||
console.log(configString);
|
||||
console.log("[MCP-INSTALL] MCP config generation completed successfully");
|
||||
|
||||
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read";
|
||||
|
||||
if (context.isPR && hasActionsReadPermission) {
|
||||
// Verify the token actually has actions:read permission
|
||||
const actuallyHasPermission = await checkActionsReadPermission(
|
||||
process.env.ACTIONS_TOKEN || "",
|
||||
owner,
|
||||
repo,
|
||||
);
|
||||
|
||||
if (!actuallyHasPermission) {
|
||||
core.warning(
|
||||
"The github_ci MCP server requires 'actions: read' permission. " +
|
||||
"Please ensure your GitHub token has this permission. " +
|
||||
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
||||
);
|
||||
}
|
||||
baseMcpConfig.mcpServers.github_ci = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||
],
|
||||
env: {
|
||||
// Use workflow github token, not app token
|
||||
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber.toString(),
|
||||
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (hasGitHubMcpTools) {
|
||||
baseMcpConfig.mcpServers.github = {
|
||||
command: "docker",
|
||||
args: [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-721fd3e", // https://github.com/github/github-mcp-server/releases/tag/v0.6.0
|
||||
],
|
||||
env: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Merge with additional MCP config if provided
|
||||
if (additionalMcpConfig && additionalMcpConfig.trim()) {
|
||||
try {
|
||||
const additionalConfig = JSON.parse(additionalMcpConfig);
|
||||
|
||||
// Validate that parsed JSON is an object
|
||||
if (typeof additionalConfig !== "object" || additionalConfig === null) {
|
||||
throw new Error("MCP config must be a valid JSON object");
|
||||
}
|
||||
|
||||
core.info(
|
||||
"Merging additional MCP server configuration with built-in servers",
|
||||
);
|
||||
|
||||
// Merge configurations with user config overriding built-in servers
|
||||
const mergedConfig = {
|
||||
...baseMcpConfig,
|
||||
...additionalConfig,
|
||||
mcpServers: {
|
||||
...baseMcpConfig.mcpServers,
|
||||
...additionalConfig.mcpServers,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(mergedConfig, null, 2);
|
||||
} catch (parseError) {
|
||||
core.warning(
|
||||
`Failed to parse additional MCP config: ${parseError}. Using base config only.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(baseMcpConfig, null, 2);
|
||||
return configString;
|
||||
} catch (error) {
|
||||
console.error("[MCP-INSTALL] MCP config generation failed:", error);
|
||||
core.setFailed(`Install MCP server failed with error: ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
509
src/mcp/local-git-ops-server.ts
Normal file
509
src/mcp/local-git-ops-server.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env node
|
||||
// Local Git Operations MCP Server
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
const REPO_NAME = process.env.REPO_NAME;
|
||||
const BRANCH_NAME = process.env.BRANCH_NAME;
|
||||
const REPO_DIR = process.env.REPO_DIR || process.cwd();
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL || "https://api.github.com";
|
||||
|
||||
console.log(`[LOCAL-GIT-MCP] Starting Local Git Operations MCP Server`);
|
||||
console.log(`[LOCAL-GIT-MCP] REPO_OWNER: ${REPO_OWNER}`);
|
||||
console.log(`[LOCAL-GIT-MCP] REPO_NAME: ${REPO_NAME}`);
|
||||
console.log(`[LOCAL-GIT-MCP] BRANCH_NAME: ${BRANCH_NAME}`);
|
||||
console.log(`[LOCAL-GIT-MCP] REPO_DIR: ${REPO_DIR}`);
|
||||
console.log(`[LOCAL-GIT-MCP] GITEA_API_URL: ${GITEA_API_URL}`);
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] GITHUB_TOKEN: ${GITHUB_TOKEN ? "***" : "undefined"}`,
|
||||
);
|
||||
|
||||
if (!REPO_OWNER || !REPO_NAME || !BRANCH_NAME) {
|
||||
console.error(
|
||||
"[LOCAL-GIT-MCP] Error: REPO_OWNER, REPO_NAME, and BRANCH_NAME environment variables are required",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: "Local Git Operations Server",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// Helper function to run git commands
|
||||
function runGitCommand(command: string): string {
|
||||
try {
|
||||
console.log(`[LOCAL-GIT-MCP] Running git command: ${command}`);
|
||||
console.log(`[LOCAL-GIT-MCP] Working directory: ${REPO_DIR}`);
|
||||
const result = execSync(command, {
|
||||
cwd: REPO_DIR,
|
||||
encoding: "utf8",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
console.log(`[LOCAL-GIT-MCP] Git command result: ${result.trim()}`);
|
||||
return result.trim();
|
||||
} catch (error: any) {
|
||||
console.error(`[LOCAL-GIT-MCP] Git command failed: ${command}`);
|
||||
console.error(`[LOCAL-GIT-MCP] Error: ${error.message}`);
|
||||
if (error.stdout) console.error(`[LOCAL-GIT-MCP] Stdout: ${error.stdout}`);
|
||||
if (error.stderr) console.error(`[LOCAL-GIT-MCP] Stderr: ${error.stderr}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to ensure git user is configured
|
||||
function ensureGitUserConfigured(): void {
|
||||
const gitName = process.env.CLAUDE_GIT_NAME || "Claude";
|
||||
const gitEmail = process.env.CLAUDE_GIT_EMAIL || "claude@anthropic.com";
|
||||
|
||||
try {
|
||||
// Check if user.email is already configured
|
||||
runGitCommand("git config user.email");
|
||||
console.log(`[LOCAL-GIT-MCP] Git user.email already configured`);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Git user.email not configured, setting to: ${gitEmail}`,
|
||||
);
|
||||
runGitCommand(`git config user.email "${gitEmail}"`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user.name is already configured
|
||||
runGitCommand("git config user.name");
|
||||
console.log(`[LOCAL-GIT-MCP] Git user.name already configured`);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Git user.name not configured, setting to: ${gitName}`,
|
||||
);
|
||||
runGitCommand(`git config user.name "${gitName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create branch tool
|
||||
server.tool(
|
||||
"create_branch",
|
||||
"Create a new branch from a base branch using local git operations",
|
||||
{
|
||||
branch_name: z.string().describe("Name of the branch to create"),
|
||||
base_branch: z
|
||||
.string()
|
||||
.describe("Base branch to create from (e.g., 'main')"),
|
||||
},
|
||||
async ({ branch_name, base_branch }) => {
|
||||
try {
|
||||
// Ensure we're on the base branch and it's up to date
|
||||
runGitCommand(`git checkout ${base_branch}`);
|
||||
runGitCommand(`git pull origin ${base_branch}`);
|
||||
|
||||
// Create and checkout the new branch
|
||||
runGitCommand(`git checkout -b ${branch_name}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully created and checked out branch: ${branch_name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating branch: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Checkout branch tool
|
||||
server.tool(
|
||||
"checkout_branch",
|
||||
"Checkout an existing branch using local git operations",
|
||||
{
|
||||
branch_name: z.string().describe("Name of the existing branch to checkout"),
|
||||
create_if_missing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Create branch if it doesn't exist locally (defaults to false)",
|
||||
),
|
||||
fetch_remote: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Fetch from remote if branch doesn't exist locally (defaults to true)",
|
||||
),
|
||||
},
|
||||
async ({ branch_name, create_if_missing = false, fetch_remote = true }) => {
|
||||
try {
|
||||
// Check if branch exists locally
|
||||
let branchExists = false;
|
||||
try {
|
||||
runGitCommand(`git rev-parse --verify ${branch_name}`);
|
||||
branchExists = true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Branch ${branch_name} doesn't exist locally`,
|
||||
);
|
||||
}
|
||||
|
||||
// If branch doesn't exist locally, try to fetch from remote
|
||||
if (!branchExists && fetch_remote) {
|
||||
try {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Attempting to fetch ${branch_name} from remote`,
|
||||
);
|
||||
runGitCommand(`git fetch origin ${branch_name}:${branch_name}`);
|
||||
branchExists = true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Branch ${branch_name} doesn't exist on remote`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If branch still doesn't exist and create_if_missing is true, create it
|
||||
if (!branchExists && create_if_missing) {
|
||||
console.log(`[LOCAL-GIT-MCP] Creating new branch ${branch_name}`);
|
||||
runGitCommand(`git checkout -b ${branch_name}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully created and checked out new branch: ${branch_name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// If branch doesn't exist and we can't/won't create it, throw error
|
||||
if (!branchExists) {
|
||||
throw new Error(
|
||||
`Branch '${branch_name}' does not exist locally or on remote. Use create_if_missing=true to create it.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Checkout the existing branch
|
||||
runGitCommand(`git checkout ${branch_name}`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully checked out branch: ${branch_name}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error checking out branch: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Commit files tool
|
||||
server.tool(
|
||||
"commit_files",
|
||||
"Commit one or more files to the current branch using local git operations",
|
||||
{
|
||||
files: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths relative to repository root (e.g. ["src/main.js", "README.md"]). All files must exist locally.',
|
||||
),
|
||||
message: z.string().describe("Commit message"),
|
||||
},
|
||||
async ({ files, message }) => {
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] commit_files called with files: ${JSON.stringify(files)}, message: ${message}`,
|
||||
);
|
||||
try {
|
||||
// Ensure git user is configured before committing
|
||||
ensureGitUserConfigured();
|
||||
|
||||
// Add the specified files
|
||||
console.log(`[LOCAL-GIT-MCP] Adding ${files.length} files to git...`);
|
||||
for (const file of files) {
|
||||
const filePath = file.startsWith("/") ? file.slice(1) : file;
|
||||
console.log(`[LOCAL-GIT-MCP] Adding file: ${filePath}`);
|
||||
runGitCommand(`git add "${filePath}"`);
|
||||
}
|
||||
|
||||
// Commit the changes
|
||||
console.log(`[LOCAL-GIT-MCP] Committing with message: ${message}`);
|
||||
runGitCommand(`git commit -m "${message}"`);
|
||||
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Successfully committed ${files.length} files`,
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully committed ${files.length} file(s): ${files.join(", ")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(`[LOCAL-GIT-MCP] Error committing files: ${errorMessage}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error committing files: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Push branch tool
|
||||
server.tool(
|
||||
"push_branch",
|
||||
"Push the current branch to remote origin",
|
||||
{
|
||||
force: z.boolean().optional().describe("Force push (use with caution)"),
|
||||
},
|
||||
async ({ force = false }) => {
|
||||
try {
|
||||
// Get current branch name
|
||||
const currentBranch = runGitCommand("git rev-parse --abbrev-ref HEAD");
|
||||
|
||||
// Push the branch
|
||||
const pushCommand = force
|
||||
? `git push -f origin ${currentBranch}`
|
||||
: `git push origin ${currentBranch}`;
|
||||
|
||||
runGitCommand(pushCommand);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully pushed branch: ${currentBranch}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error pushing branch: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create pull request tool (uses Gitea API)
|
||||
server.tool(
|
||||
"create_pull_request",
|
||||
"Create a pull request using Gitea API",
|
||||
{
|
||||
title: z.string().describe("Pull request title"),
|
||||
body: z.string().describe("Pull request body/description"),
|
||||
base_branch: z.string().describe("Base branch (e.g., 'main')"),
|
||||
head_branch: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Head branch (defaults to current branch)"),
|
||||
},
|
||||
async ({ title, body, base_branch, head_branch }) => {
|
||||
try {
|
||||
if (!GITHUB_TOKEN) {
|
||||
throw new Error(
|
||||
"GITHUB_TOKEN environment variable is required for PR creation",
|
||||
);
|
||||
}
|
||||
|
||||
// Get current branch if head_branch not specified
|
||||
const currentBranch =
|
||||
head_branch || runGitCommand("git rev-parse --abbrev-ref HEAD");
|
||||
|
||||
// Create PR using Gitea API
|
||||
const response = await fetch(
|
||||
`${GITEA_API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/pulls`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
base: base_branch,
|
||||
head: currentBranch,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to create PR: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const prData = await response.json();
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully created pull request #${prData.number}: ${prData.html_url}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error creating pull request: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete files tool
|
||||
server.tool(
|
||||
"delete_files",
|
||||
"Delete one or more files and commit the deletion using local git operations",
|
||||
{
|
||||
files: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
'Array of file paths relative to repository root (e.g. ["src/old-file.js", "docs/deprecated.md"])',
|
||||
),
|
||||
message: z.string().describe("Commit message for the deletion"),
|
||||
},
|
||||
async ({ files, message }) => {
|
||||
try {
|
||||
// Remove the specified files
|
||||
for (const file of files) {
|
||||
const filePath = file.startsWith("/") ? file.slice(1) : file;
|
||||
runGitCommand(`git rm "${filePath}"`);
|
||||
}
|
||||
|
||||
// Commit the deletions
|
||||
runGitCommand(`git commit -m "${message}"`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully deleted and committed ${files.length} file(s): ${files.join(", ")}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error deleting files: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get git status tool
|
||||
server.tool("git_status", "Get the current git status", {}, async () => {
|
||||
console.log(`[LOCAL-GIT-MCP] git_status called`);
|
||||
try {
|
||||
const status = runGitCommand("git status --porcelain");
|
||||
const currentBranch = runGitCommand("git rev-parse --abbrev-ref HEAD");
|
||||
|
||||
console.log(`[LOCAL-GIT-MCP] Current branch: ${currentBranch}`);
|
||||
console.log(
|
||||
`[LOCAL-GIT-MCP] Git status: ${status || "Working tree clean"}`,
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Current branch: ${currentBranch}\nStatus:\n${status || "Working tree clean"}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[LOCAL-GIT-MCP] Error getting git status: ${errorMessage}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error getting git status: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
error: errorMessage,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function runServer() {
|
||||
console.log(`[LOCAL-GIT-MCP] Starting MCP server transport...`);
|
||||
const transport = new StdioServerTransport();
|
||||
console.log(`[LOCAL-GIT-MCP] Connecting to transport...`);
|
||||
await server.connect(transport);
|
||||
console.log(`[LOCAL-GIT-MCP] MCP server connected and ready!`);
|
||||
process.on("exit", () => {
|
||||
console.log(`[LOCAL-GIT-MCP] Server shutting down...`);
|
||||
server.close();
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[LOCAL-GIT-MCP] Calling runServer()...`);
|
||||
runServer().catch((error) => {
|
||||
console.error(`[LOCAL-GIT-MCP] Server startup failed:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { checkAndCommitOrDeleteBranch } from "../src/github/operations/branch-cleanup";
|
||||
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
|
||||
import type { Octokits } from "../src/github/api/client";
|
||||
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
||||
import { GITEA_SERVER_URL } from "../src/github/api/config";
|
||||
|
||||
describe("checkAndCommitOrDeleteBranch", () => {
|
||||
describe("checkAndDeleteEmptyBranch", () => {
|
||||
let consoleLogSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
@@ -21,7 +21,6 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
const createMockOctokit = (
|
||||
compareResponse?: any,
|
||||
deleteRefError?: Error,
|
||||
branchExists: boolean = true,
|
||||
): Octokits => {
|
||||
return {
|
||||
rest: {
|
||||
@@ -29,14 +28,6 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
compareCommitsWithBasehead: async () => ({
|
||||
data: compareResponse || { total_commits: 0 },
|
||||
}),
|
||||
getBranch: async () => {
|
||||
if (!branchExists) {
|
||||
const error: any = new Error("Not Found");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return { data: {} };
|
||||
},
|
||||
},
|
||||
git: {
|
||||
deleteRef: async () => {
|
||||
@@ -52,13 +43,12 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
|
||||
test("should return no branch link and not delete when branch is undefined", async () => {
|
||||
const mockOctokit = createMockOctokit();
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
undefined,
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
@@ -66,38 +56,39 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should mark branch for deletion when commit signing is enabled and no commits", async () => {
|
||||
test("should delete branch and return no link when branch has no commits", async () => {
|
||||
const mockOctokit = createMockOctokit({ total_commits: 0 });
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"main",
|
||||
true, // commit signing enabled
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101-1234 has no commits from Claude, will delete it",
|
||||
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"✅ Deleted empty branch: claude/issue-123-20240101_123456",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not delete branch and return link when branch has commits", async () => {
|
||||
const mockOctokit = createMockOctokit({ total_commits: 3 });
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe(
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101-1234)`,
|
||||
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123-20240101_123456)`,
|
||||
);
|
||||
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("has no commits"),
|
||||
@@ -111,7 +102,6 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
compareCommitsWithBasehead: async () => {
|
||||
throw new Error("API error");
|
||||
},
|
||||
getBranch: async () => ({ data: {} }), // Branch exists
|
||||
},
|
||||
git: {
|
||||
deleteRef: async () => ({ data: {} }),
|
||||
@@ -119,21 +109,20 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
},
|
||||
} as any as Octokits;
|
||||
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe(
|
||||
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101-1234)`,
|
||||
`\n[View branch](${GITEA_SERVER_URL}/owner/repo/src/branch/claude/issue-123-20240101_123456)`,
|
||||
);
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error comparing commits on Claude branch:",
|
||||
"Error checking for commits on Claude branch:",
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
@@ -142,46 +131,19 @@ describe("checkAndCommitOrDeleteBranch", () => {
|
||||
const deleteError = new Error("Delete failed");
|
||||
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
|
||||
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
const result = await checkAndDeleteEmptyBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"claude/issue-123-20240101_123456",
|
||||
"main",
|
||||
true, // commit signing enabled - will try to delete
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(true);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to delete branch claude/issue-123-20240101-1234:",
|
||||
"Failed to delete branch claude/issue-123-20240101_123456:",
|
||||
deleteError,
|
||||
);
|
||||
});
|
||||
|
||||
test("should return no branch link when branch doesn't exist remotely", async () => {
|
||||
const mockOctokit = createMockOctokit(
|
||||
{ total_commits: 0 },
|
||||
undefined,
|
||||
false, // branch doesn't exist
|
||||
);
|
||||
|
||||
const result = await checkAndCommitOrDeleteBranch(
|
||||
mockOctokit,
|
||||
"owner",
|
||||
"repo",
|
||||
"claude/issue-123-20240101-1234",
|
||||
"main",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.shouldDeleteBranch).toBe(false);
|
||||
expect(result.branchLink).toBe("");
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101-1234 does not exist remotely",
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
"Branch claude/issue-123-20240101-1234 does not exist remotely, no branch link will be added",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
updateCommentBody,
|
||||
type CommentUpdateInput,
|
||||
} from "../src/github/operations/comment-logic";
|
||||
import { updateCommentBody } from "../src/github/operations/comment-logic";
|
||||
|
||||
describe("updateCommentBody", () => {
|
||||
const baseInput = {
|
||||
@@ -103,12 +100,12 @@ describe("updateCommentBody", () => {
|
||||
it("adds branch name with link to header when provided", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
branchName: "claude/issue-123-20240101-1200",
|
||||
branchName: "claude/issue-123-20240101_120000",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`claude/issue-123-20240101-1200`](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
|
||||
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -116,12 +113,12 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
branchLink:
|
||||
"\n[View branch](https://github.com/owner/repo/tree/branch-name)",
|
||||
"\n[View branch](https://github.com/owner/repo/src/branch/branch-name)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`branch-name`](https://github.com/owner/repo/tree/branch-name)",
|
||||
"• [`branch-name`](https://github.com/owner/repo/src/branch/branch-name)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,13 +126,13 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody:
|
||||
"Some comment with [View branch](https://github.com/owner/repo/tree/branch-name)",
|
||||
"Some comment with [View branch](https://github.com/owner/repo/src/branch/branch-name)",
|
||||
branchName: "new-branch-name",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
expect(result).toContain(
|
||||
"• [`new-branch-name`](https://github.com/owner/repo/tree/new-branch-name)",
|
||||
"• [`new-branch-name`](https://github.com/owner/repo/src/branch/new-branch-name)",
|
||||
);
|
||||
expect(result).not.toContain("View branch");
|
||||
});
|
||||
@@ -336,7 +333,7 @@ describe("updateCommentBody", () => {
|
||||
);
|
||||
expect(result).toContain("—— [View job]");
|
||||
expect(result).toContain(
|
||||
"• [`claude-branch-123`](https://github.com/owner/repo/tree/claude-branch-123)",
|
||||
"• [`claude-branch-123`](https://github.com/owner/repo/src/branch/claude-branch-123)",
|
||||
);
|
||||
expect(result).toContain("• [Create PR ➔]");
|
||||
|
||||
@@ -384,9 +381,9 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: "Claude Code is working… <img src='spinner.gif' />",
|
||||
branchName: "claude/pr-456-20240101-1200",
|
||||
branchName: "claude/pr-456-20240101_120000",
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101-1200)",
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
triggerUsername: "jane-doe",
|
||||
};
|
||||
|
||||
@@ -394,7 +391,7 @@ describe("updateCommentBody", () => {
|
||||
|
||||
// Should include the PR link in the formatted style
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101-1200)",
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)",
|
||||
);
|
||||
expect(result).toContain("**Claude finished @jane-doe's task**");
|
||||
});
|
||||
@@ -403,44 +400,22 @@ describe("updateCommentBody", () => {
|
||||
const input = {
|
||||
...baseInput,
|
||||
currentBody: "Claude Code is working…",
|
||||
branchName: "claude/issue-123-20240101-1200",
|
||||
branchName: "claude/issue-123-20240101_120000",
|
||||
branchLink:
|
||||
"\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
|
||||
"\n[View branch](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
|
||||
prLink:
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101-1200)",
|
||||
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
|
||||
// Should include both links in formatted style
|
||||
expect(result).toContain(
|
||||
"• [`claude/issue-123-20240101-1200`](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
|
||||
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/src/branch/claude/issue-123-20240101_120000)",
|
||||
);
|
||||
expect(result).toContain(
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101-1200)",
|
||||
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not show branch name when branch doesn't exist remotely", () => {
|
||||
const input: CommentUpdateInput = {
|
||||
currentBody: "@claude can you help with this?",
|
||||
actionFailed: false,
|
||||
executionDetails: { duration_ms: 90000 },
|
||||
jobUrl: "https://github.com/owner/repo/actions/runs/123",
|
||||
branchLink: "", // Empty branch link means branch doesn't exist remotely
|
||||
branchName: undefined, // Should be undefined when branchLink is empty
|
||||
triggerUsername: "claude",
|
||||
prLink: "",
|
||||
};
|
||||
|
||||
const result = updateCommentBody(input);
|
||||
|
||||
expect(result).toContain("Claude finished @claude's task in 1m 30s");
|
||||
expect(result).toContain(
|
||||
"[View job](https://github.com/owner/repo/actions/runs/123)",
|
||||
);
|
||||
expect(result).not.toContain("claude/issue-123");
|
||||
expect(result).not.toContain("tree/claude/issue-123");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildDisallowedToolsString,
|
||||
} from "../src/create-prompt";
|
||||
import type { PreparedContext } from "../src/create-prompt";
|
||||
import type { EventData } from "../src/create-prompt/types";
|
||||
|
||||
describe("generatePrompt", () => {
|
||||
const mockGitHubData = {
|
||||
@@ -127,13 +128,13 @@ describe("generatePrompt", () => {
|
||||
commentId: "67890",
|
||||
isPR: false,
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
||||
claudeBranch: "claude/issue-67890-20240101_120000",
|
||||
issueNumber: "67890",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
||||
@@ -161,7 +162,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -183,11 +184,11 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
claudeBranch: "claude/issue-789-20240101_120000",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -210,12 +211,12 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "develop",
|
||||
claudeBranch: "claude/issue-999-20240101-1200",
|
||||
claudeBranch: "claude/issue-999-20240101_120000",
|
||||
assigneeTrigger: "claude-bot",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -226,33 +227,6 @@ describe("generatePrompt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should generate prompt for issue labeled event", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber: "888",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-888-20240101-1200",
|
||||
labelTrigger: "claude-task",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
"<trigger_context>issue labeled with 'claude-task'</trigger_context>",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"[Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
});
|
||||
|
||||
test("should include direct prompt when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
@@ -265,17 +239,17 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
claudeBranch: "claude/issue-789-20240101_120000",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<direct_prompt>");
|
||||
expect(prompt).toContain("Fix the bug in the login form");
|
||||
expect(prompt).toContain("</direct_prompt>");
|
||||
expect(prompt).toContain(
|
||||
"CRITICAL: Direct user instructions were provided in the <direct_prompt> tag above. These are HIGH PRIORITY instructions that OVERRIDE all other context and MUST be followed exactly as written.",
|
||||
"DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -292,7 +266,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -312,158 +286,16 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
||||
claudeBranch: "claude/issue-67890-20240101_120000",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
|
||||
});
|
||||
|
||||
test("should use override_prompt when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
overridePrompt: "Simple prompt for $REPOSITORY PR #$PR_NUMBER",
|
||||
eventData: {
|
||||
eventName: "pull_request",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toBe("Simple prompt for owner/repo PR #123");
|
||||
expect(prompt).not.toContain("You are Claude, an AI assistant");
|
||||
});
|
||||
|
||||
test("should substitute all variables in override_prompt", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "test/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
triggerUsername: "john-doe",
|
||||
overridePrompt: `Repository: $REPOSITORY
|
||||
PR: $PR_NUMBER
|
||||
Title: $PR_TITLE
|
||||
Body: $PR_BODY
|
||||
Comments: $PR_COMMENTS
|
||||
Review Comments: $REVIEW_COMMENTS
|
||||
Changed Files: $CHANGED_FILES
|
||||
Trigger Comment: $TRIGGER_COMMENT
|
||||
Username: $TRIGGER_USERNAME
|
||||
Branch: $BRANCH_NAME
|
||||
Base: $BASE_BRANCH
|
||||
Event: $EVENT_TYPE
|
||||
Is PR: $IS_PR`,
|
||||
eventData: {
|
||||
eventName: "pull_request_review_comment",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Please review this code",
|
||||
claudeBranch: "feature-branch",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("Repository: test/repo");
|
||||
expect(prompt).toContain("PR: 456");
|
||||
expect(prompt).toContain("Title: Test PR");
|
||||
expect(prompt).toContain("Body: This is a test PR");
|
||||
expect(prompt).toContain("Comments: ");
|
||||
expect(prompt).toContain("Review Comments: ");
|
||||
expect(prompt).toContain("Changed Files: ");
|
||||
expect(prompt).toContain("Trigger Comment: Please review this code");
|
||||
expect(prompt).toContain("Username: john-doe");
|
||||
expect(prompt).toContain("Branch: feature-branch");
|
||||
expect(prompt).toContain("Base: main");
|
||||
expect(prompt).toContain("Event: pull_request_review_comment");
|
||||
expect(prompt).toContain("Is PR: true");
|
||||
});
|
||||
|
||||
test("should handle override_prompt for issues", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
overridePrompt: "Issue #$ISSUE_NUMBER: $ISSUE_TITLE in $REPOSITORY",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "opened",
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const issueGitHubData = {
|
||||
...mockGitHubData,
|
||||
contextData: {
|
||||
title: "Bug: Login form broken",
|
||||
body: "The login form is not working",
|
||||
author: { login: "testuser" },
|
||||
state: "OPEN",
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
comments: {
|
||||
nodes: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, issueGitHubData, false);
|
||||
|
||||
expect(prompt).toBe("Issue #789: Bug: Login form broken in owner/repo");
|
||||
});
|
||||
|
||||
test("should handle empty values in override_prompt substitution", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
overridePrompt:
|
||||
"PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
|
||||
eventData: {
|
||||
eventName: "pull_request",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toBe("PR: 123, Issue: , Comment: ");
|
||||
});
|
||||
|
||||
test("should not substitute variables when override_prompt is not provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "opened",
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-123-20240101-1200",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
});
|
||||
|
||||
test("should include trigger username when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
@@ -476,17 +308,16 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-67890-20240101-1200",
|
||||
claudeBranch: "claude/issue-67890-20240101_120000",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||
// With commit signing disabled, co-author info appears in git commit instructions
|
||||
expect(prompt).toContain(
|
||||
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
|
||||
"Co-authored-by: johndoe <johndoe@users.noreply.local>",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -503,10 +334,12 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain PR-specific instructions (git commands when not using signing)
|
||||
expect(prompt).toContain("git push");
|
||||
// Should contain PR-specific instructions
|
||||
expect(prompt).toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
@@ -530,27 +363,27 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "789",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-789-20240101-1200",
|
||||
claudeBranch: "claude/issue-789-20240101_120000",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain Issue-specific instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/issue-789-20240101-1200)",
|
||||
"You are already on the correct branch (claude/issue-789-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101-1200)",
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain(
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
"If you created a branch and made changes, your comment must include the PR URL",
|
||||
);
|
||||
|
||||
// Should NOT contain PR-specific instructions
|
||||
expect(prompt).not.toContain(
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
);
|
||||
expect(prompt).not.toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
@@ -568,22 +401,22 @@ describe("generatePrompt", () => {
|
||||
isPR: false,
|
||||
issueNumber: "123",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-123-20240101-1200",
|
||||
claudeBranch: "claude/issue-123-20240101_120000",
|
||||
commentBody: "@claude please fix this",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain the actual branch name with timestamp
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/issue-123-20240101-1200)",
|
||||
"You are already on the correct branch (claude/issue-123-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101-1200)",
|
||||
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"The branch-name is the current branch: claude/issue-123-20240101-1200",
|
||||
"The branch-name is the current branch: claude/issue-123-20240101_120000",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -598,31 +431,28 @@ describe("generatePrompt", () => {
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "@claude please fix this",
|
||||
claudeBranch: "claude/pr-456-20240101-1200",
|
||||
claudeBranch: "claude/pr-456-20240101_120000",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain branch-specific instructions like issues
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-456-20240101-1200)",
|
||||
"You are already on the correct branch (claude/pr-456-20240101_120000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"The branch-name is the current branch: claude/pr-456-20240101-1200",
|
||||
"The branch-name is the current branch: claude/pr-456-20240101_120000",
|
||||
);
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
expect(prompt).toContain(
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
);
|
||||
|
||||
// Should NOT contain open PR instructions
|
||||
expect(prompt).not.toContain(
|
||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -641,10 +471,12 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain open PR instructions (git commands when not using signing)
|
||||
expect(prompt).toContain("git push");
|
||||
// Should contain open PR instructions
|
||||
expect(prompt).toContain(
|
||||
"Commit changes using mcp__local_git_ops__commit_files to the existing branch",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
@@ -667,16 +499,16 @@ describe("generatePrompt", () => {
|
||||
isPR: true,
|
||||
prNumber: "789",
|
||||
commentBody: "@claude please update this",
|
||||
claudeBranch: "claude/pr-789-20240101-1230",
|
||||
claudeBranch: "claude/pr-789-20240101_123000",
|
||||
baseBranch: "develop",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-789-20240101-1230)",
|
||||
"You are already on the correct branch (claude/pr-789-20240101_123000)",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"Create a PR](https://github.com/owner/repo/compare/develop",
|
||||
@@ -695,22 +527,19 @@ describe("generatePrompt", () => {
|
||||
prNumber: "999",
|
||||
commentId: "review-comment-123",
|
||||
commentBody: "@claude fix this issue",
|
||||
claudeBranch: "claude/pr-999-20240101-1400",
|
||||
claudeBranch: "claude/pr-999-20240101_140000",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-999-20240101-1400)",
|
||||
"You are already on the correct branch (claude/pr-999-20240101_140000)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
expect(prompt).toContain(
|
||||
"If you created anything in your branch, your comment must include the PR URL",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle pull_request event on closed PR with new branch", () => {
|
||||
@@ -723,75 +552,20 @@ describe("generatePrompt", () => {
|
||||
eventAction: "closed",
|
||||
isPR: true,
|
||||
prNumber: "555",
|
||||
claudeBranch: "claude/pr-555-20240101-1500",
|
||||
claudeBranch: "claude/pr-555-20240101_150000",
|
||||
baseBranch: "main",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
"You are already on the correct branch (claude/pr-555-20240101-1500)",
|
||||
"You are already on the correct branch (claude/pr-555-20240101_150000)",
|
||||
);
|
||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||
expect(prompt).toContain("Reference to the original PR");
|
||||
});
|
||||
|
||||
test("should include git commands when useCommitSigning is false", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
commentBody: "@claude fix the bug",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||
|
||||
// Should have git command instructions
|
||||
expect(prompt).toContain("Use git commands via the Bash tool");
|
||||
expect(prompt).toContain("git add");
|
||||
expect(prompt).toContain("git commit");
|
||||
expect(prompt).toContain("git push");
|
||||
|
||||
// Should use the minimal comment tool
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have commit signing tool references
|
||||
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
|
||||
});
|
||||
|
||||
test("should include commit signing tools when useCommitSigning is true", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issue_comment",
|
||||
commentId: "67890",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
commentBody: "@claude fix the bug",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, true);
|
||||
|
||||
// Should have commit signing tool instructions
|
||||
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(prompt).toContain("mcp__github_file_ops__delete_files");
|
||||
// Comment tool should always be from comment server, not file ops
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have git command instructions
|
||||
expect(prompt).not.toContain("Use git commands via the Bash tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventTypeAndContext", () => {
|
||||
@@ -825,7 +599,7 @@ describe("getEventTypeAndContext", () => {
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-999-20240101-1200",
|
||||
claudeBranch: "claude/issue-999-20240101_120000",
|
||||
assigneeTrigger: "claude-bot",
|
||||
},
|
||||
};
|
||||
@@ -835,56 +609,19 @@ describe("getEventTypeAndContext", () => {
|
||||
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
||||
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
|
||||
});
|
||||
|
||||
test("should return correct type and context for issue labeled", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber: "888",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-888-20240101-1200",
|
||||
labelTrigger: "claude-task",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(envVars);
|
||||
|
||||
expect(result.eventType).toBe("ISSUE_LABELED");
|
||||
expect(result.triggerContext).toBe("issue labeled with 'claude-task'");
|
||||
});
|
||||
|
||||
test("should return correct type and context for issue assigned without assigneeTrigger", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
directPrompt: "Please assess this issue",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "assigned",
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-999-20240101-1200",
|
||||
// No assigneeTrigger when using directPrompt
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(envVars);
|
||||
|
||||
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
||||
expect(result.triggerContext).toBe("issue assigned event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAllowedToolsString", () => {
|
||||
test("should return correct tools for regular events (default no signing)", () => {
|
||||
const result = buildAllowedToolsString();
|
||||
test("should return issue comment tool for regular events", () => {
|
||||
const mockEventData: EventData = {
|
||||
eventName: "issue_comment",
|
||||
commentId: "123",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Test comment",
|
||||
};
|
||||
|
||||
const result = buildAllowedToolsString(mockEventData);
|
||||
|
||||
// The base tools should be in the result
|
||||
expect(result).toContain("Edit");
|
||||
@@ -893,20 +630,22 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
|
||||
// Default is no commit signing, so should have specific Bash git commands
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
expect(result).toContain("Bash(git commit:*)");
|
||||
expect(result).toContain("Bash(git push:*)");
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have commit signing tools
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||
expect(result).toContain("mcp__github__update_issue_comment");
|
||||
expect(result).not.toContain("mcp__github__update_pull_request_comment");
|
||||
expect(result).toContain("mcp__local_git_ops__commit_files");
|
||||
expect(result).toContain("mcp__local_git_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should return correct tools with default parameters", () => {
|
||||
const result = buildAllowedToolsString([], false, false);
|
||||
test("should return PR comment tool for inline review comments", () => {
|
||||
const mockEventData: EventData = {
|
||||
eventName: "pull_request_review_comment",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Test review comment",
|
||||
commentId: "789",
|
||||
};
|
||||
|
||||
const result = buildAllowedToolsString(mockEventData);
|
||||
|
||||
// The base tools should be in the result
|
||||
expect(result).toContain("Edit");
|
||||
@@ -915,20 +654,23 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
|
||||
// Should have specific Bash git commands for non-signing mode
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
expect(result).toContain("Bash(git commit:*)");
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not have commit signing tools
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||
expect(result).not.toContain("mcp__github__update_issue_comment");
|
||||
expect(result).toContain("mcp__github__update_pull_request_comment");
|
||||
expect(result).toContain("mcp__local_git_ops__commit_files");
|
||||
expect(result).toContain("mcp__local_git_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should append custom tools when provided", () => {
|
||||
const customTools = ["Tool1", "Tool2", "Tool3"];
|
||||
const result = buildAllowedToolsString(customTools);
|
||||
const mockEventData: EventData = {
|
||||
eventName: "issue_comment",
|
||||
commentId: "123",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
commentBody: "Test comment",
|
||||
};
|
||||
|
||||
const customTools = "Tool1,Tool2,Tool3";
|
||||
const result = buildAllowedToolsString(mockEventData, customTools);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
@@ -946,109 +688,6 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(basePlusCustom).toContain("Tool2");
|
||||
expect(basePlusCustom).toContain("Tool3");
|
||||
});
|
||||
|
||||
test("should include GitHub Actions tools when includeActionsTools is true", () => {
|
||||
const result = buildAllowedToolsString([], true);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
|
||||
// GitHub Actions tools should be included
|
||||
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||
});
|
||||
|
||||
test("should include both custom and Actions tools when both provided", () => {
|
||||
const customTools = ["Tool1", "Tool2"];
|
||||
const result = buildAllowedToolsString(customTools, true);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
|
||||
// Custom tools should be included
|
||||
expect(result).toContain("Tool1");
|
||||
expect(result).toContain("Tool2");
|
||||
|
||||
// GitHub Actions tools should be included
|
||||
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
|
||||
expect(result).toContain("mcp__github_ci__download_job_log");
|
||||
});
|
||||
|
||||
test("should include commit signing tools when useCommitSigning is true", () => {
|
||||
const result = buildAllowedToolsString([], false, true);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Grep");
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
|
||||
// Commit signing tools should be included
|
||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||
// Comment tool should always be from github_comment server
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Bash should NOT be included when using commit signing (except in comment tool name)
|
||||
expect(result).not.toContain("Bash(");
|
||||
});
|
||||
|
||||
test("should include specific Bash git commands when useCommitSigning is false", () => {
|
||||
const result = buildAllowedToolsString([], false, false);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Glob");
|
||||
expect(result).toContain("Grep");
|
||||
expect(result).toContain("LS");
|
||||
expect(result).toContain("Read");
|
||||
expect(result).toContain("Write");
|
||||
|
||||
// Specific Bash git commands should be included
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
expect(result).toContain("Bash(git commit:*)");
|
||||
expect(result).toContain("Bash(git push:*)");
|
||||
expect(result).toContain("Bash(git status:*)");
|
||||
expect(result).toContain("Bash(git diff:*)");
|
||||
expect(result).toContain("Bash(git log:*)");
|
||||
expect(result).toContain("Bash(git rm:*)");
|
||||
expect(result).toContain("Bash(git config user.name:*)");
|
||||
expect(result).toContain("Bash(git config user.email:*)");
|
||||
|
||||
// Comment tool from minimal server should be included
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Commit signing tools should NOT be included
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||
});
|
||||
|
||||
test("should handle all combinations of options", () => {
|
||||
const customTools = ["CustomTool1", "CustomTool2"];
|
||||
const result = buildAllowedToolsString(customTools, true, false);
|
||||
|
||||
// Base tools should be present
|
||||
expect(result).toContain("Edit");
|
||||
expect(result).toContain("Bash(git add:*)");
|
||||
|
||||
// Custom tools should be included
|
||||
expect(result).toContain("CustomTool1");
|
||||
expect(result).toContain("CustomTool2");
|
||||
|
||||
// GitHub Actions tools should be included
|
||||
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||
|
||||
// Comment tool from minimal server should be included
|
||||
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Commit signing tools should NOT be included
|
||||
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDisallowedToolsString", () => {
|
||||
@@ -1061,7 +700,7 @@ describe("buildDisallowedToolsString", () => {
|
||||
});
|
||||
|
||||
test("should append custom disallowed tools when provided", () => {
|
||||
const customDisallowedTools = ["BadTool1", "BadTool2"];
|
||||
const customDisallowedTools = "BadTool1,BadTool2";
|
||||
const result = buildDisallowedToolsString(customDisallowedTools);
|
||||
|
||||
// Base disallowed tools should be present
|
||||
@@ -1079,8 +718,8 @@ describe("buildDisallowedToolsString", () => {
|
||||
});
|
||||
|
||||
test("should remove hardcoded disallowed tools if they are in allowed tools", () => {
|
||||
const customDisallowedTools = ["BadTool1", "BadTool2"];
|
||||
const allowedTools = ["WebSearch", "SomeOtherTool"];
|
||||
const customDisallowedTools = "BadTool1,BadTool2";
|
||||
const allowedTools = "WebSearch,SomeOtherTool";
|
||||
const result = buildDisallowedToolsString(
|
||||
customDisallowedTools,
|
||||
allowedTools,
|
||||
@@ -1098,7 +737,7 @@ describe("buildDisallowedToolsString", () => {
|
||||
});
|
||||
|
||||
test("should remove all hardcoded disallowed tools if they are all in allowed tools", () => {
|
||||
const allowedTools = ["WebSearch", "WebFetch", "SomeOtherTool"];
|
||||
const allowedTools = "WebSearch,WebFetch,SomeOtherTool";
|
||||
const result = buildDisallowedToolsString(undefined, allowedTools);
|
||||
|
||||
// Both hardcoded disallowed tools should be removed
|
||||
@@ -1110,8 +749,8 @@ describe("buildDisallowedToolsString", () => {
|
||||
});
|
||||
|
||||
test("should handle custom disallowed tools when all hardcoded tools are overridden", () => {
|
||||
const customDisallowedTools = ["BadTool1", "BadTool2"];
|
||||
const allowedTools = ["WebSearch", "WebFetch"];
|
||||
const customDisallowedTools = "BadTool1,BadTool2";
|
||||
const allowedTools = "WebSearch,WebFetch";
|
||||
const result = buildDisallowedToolsString(
|
||||
customDisallowedTools,
|
||||
allowedTools,
|
||||
|
||||
Reference in New Issue
Block a user