mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 15:04:13 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a804c9e83f | ||
|
|
d6bc8ddf8a | ||
|
|
86665d0984 | ||
|
|
6364776f60 | ||
|
|
e43c1b7fac | ||
|
|
23fae74fdb | ||
|
|
3c739a8cf3 |
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
"--rm",
|
"--rm",
|
||||||
"-e",
|
"-e",
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
"ghcr.io/github/github-mcp-server:sha-6d69797"
|
"ghcr.io/github/github-mcp-server:sha-721fd3e"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|||||||
25
FAQ.md
25
FAQ.md
@@ -51,14 +51,29 @@ allowed_tools: "Bash(git rebase:*)" # Use with caution
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### Why can't Claude run my tests or see CI results?
|
### Can Claude see my GitHub Actions CI results?
|
||||||
|
|
||||||
Claude cannot access GitHub Actions logs, test results, or other CI/CD outputs by default. It only has access to the repository files. If you need Claude to see test results, you can either:
|
Yes! Claude can access GitHub Actions workflow runs, job logs, and test results on the PR where it's tagged. To enable this:
|
||||||
|
|
||||||
1. Instruct Claude to run tests before making commits
|
1. Add `actions: read` permission to your workflow:
|
||||||
2. Copy and paste CI results into a comment for Claude to analyze
|
|
||||||
|
|
||||||
This limitation exists for security reasons but may be reconsidered in the future based on user feedback.
|
```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?
|
### Why does Claude only update one comment instead of creating new ones?
|
||||||
|
|
||||||
|
|||||||
154
README.md
154
README.md
@@ -30,7 +30,9 @@ This command will guide you through setting up the GitHub app and required secre
|
|||||||
**Requirements**: You must be a repository admin to complete these steps.
|
**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
|
1. Install the Claude GitHub app to your repository: https://github.com/apps/claude
|
||||||
2. Add `ANTHROPIC_API_KEY` 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))
|
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/`
|
3. Copy the workflow file from [`examples/claude.yml`](./examples/claude.yml) into your repository's `.github/workflows/`
|
||||||
|
|
||||||
## 📚 FAQ
|
## 📚 FAQ
|
||||||
@@ -60,6 +62,8 @@ jobs:
|
|||||||
- uses: anthropics/claude-code-action@beta
|
- uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
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 }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
@@ -74,33 +78,38 @@ jobs:
|
|||||||
# API_URL: https://api.example.com
|
# API_URL: https://api.example.com
|
||||||
# Optional: limit the number of conversation turns
|
# Optional: limit the number of conversation turns
|
||||||
# max_turns: "5"
|
# max_turns: "5"
|
||||||
|
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||||
|
# additional_permissions: |
|
||||||
|
# actions: read
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
| Input | Description | Required | Default |
|
| Input | Description | Required | Default |
|
||||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
| ------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||||
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_bedrock` | Use Amazon Bedrock 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 | "" |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | 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 | - |
|
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "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` |
|
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
| `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 | "" |
|
||||||
|
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||||
|
|
||||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||||
|
|
||||||
@@ -326,6 +335,7 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
|||||||
- When triggered on an **issue**: Always creates a new branch for the work
|
- When triggered on an **issue**: Always creates a new branch for the work
|
||||||
- When triggered on an **open PR**: Always pushes directly to the existing PR branch
|
- When triggered on an **open PR**: Always pushes directly to the existing PR branch
|
||||||
- When triggered on a **closed PR**: Creates a new branch since the original is no longer active
|
- When triggered on a **closed PR**: Creates a new branch since the original is no longer active
|
||||||
|
- **View GitHub Actions Results**: Can access workflow runs, job logs, and test results on the PR where it's tagged when `actions: read` permission is configured (see [Additional Permissions for CI/CD Integration](#additional-permissions-for-cicd-integration))
|
||||||
|
|
||||||
### What Claude Cannot Do
|
### What Claude Cannot Do
|
||||||
|
|
||||||
@@ -334,11 +344,79 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
|||||||
- **Post Multiple Comments**: Claude only acts by updating its initial comment
|
- **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
|
- **Execute Commands Outside Its Context**: Claude only has access to the repository and PR/issue context it's triggered in
|
||||||
- **Run Arbitrary Bash Commands**: By default, Claude cannot execute Bash commands unless explicitly allowed using the `allowed_tools` configuration
|
- **Run Arbitrary Bash Commands**: By default, Claude cannot execute Bash commands unless explicitly allowed using the `allowed_tools` configuration
|
||||||
- **View CI/CD Results**: Cannot access CI systems, test results, or build logs unless an additional tool or MCP server is configured
|
|
||||||
- **Perform Branch Operations**: Cannot merge branches, rebase, or perform other git operations beyond pushing commits
|
- **Perform Branch Operations**: Cannot merge branches, rebase, or perform other git operations beyond pushing commits
|
||||||
|
|
||||||
## Advanced Configuration
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Additional Permissions for CI/CD Integration
|
||||||
|
|
||||||
|
The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues.
|
||||||
|
|
||||||
|
#### Enabling GitHub Actions Access
|
||||||
|
|
||||||
|
To allow Claude to view workflow run results, job logs, and CI status:
|
||||||
|
|
||||||
|
1. **Grant the necessary permission to your GitHub token**:
|
||||||
|
|
||||||
|
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read # Add this line
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure the action with additional permissions**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
# ... other inputs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Claude will automatically get access to CI/CD tools**:
|
||||||
|
When you enable `actions: read`, Claude can use the following MCP tools:
|
||||||
|
- `mcp__github_ci__get_ci_status` - View workflow run statuses
|
||||||
|
- `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information
|
||||||
|
- `mcp__github_ci__download_job_log` - Download and analyze job logs
|
||||||
|
|
||||||
|
#### Example: Debugging Failed CI Runs
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Claude CI Helper
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read # Required for CI access
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-ci-helper:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
# Now Claude can respond to "@claude why did the CI fail?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes**:
|
||||||
|
|
||||||
|
- The GitHub token must have the `actions: read` permission in your workflow
|
||||||
|
- If the permission is missing, Claude will warn you and suggest adding it
|
||||||
|
- Currently, only `actions: read` is supported, but the format allows for future extensions
|
||||||
|
|
||||||
### Custom Environment Variables
|
### Custom Environment Variables
|
||||||
|
|
||||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
||||||
@@ -531,18 +609,21 @@ The [Claude Code GitHub app](https://github.com/apps/claude) requires these perm
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### ⚠️ ANTHROPIC_API_KEY Protection
|
### ⚠️ Authentication Protection
|
||||||
|
|
||||||
**CRITICAL: Never hardcode your Anthropic API key in workflow files!**
|
**CRITICAL: Never hardcode your Anthropic API key or OAuth token in workflow files!**
|
||||||
|
|
||||||
Your ANTHROPIC_API_KEY must always be stored in GitHub secrets to prevent unauthorized access:
|
Your authentication credentials must always be stored in GitHub secrets to prevent unauthorized access:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# CORRECT ✅
|
# CORRECT ✅
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# OR
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
# NEVER DO THIS ❌
|
# NEVER DO THIS ❌
|
||||||
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
||||||
|
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setting Up GitHub Secrets
|
### Setting Up GitHub Secrets
|
||||||
@@ -550,17 +631,18 @@ anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
|||||||
1. Go to your repository's Settings
|
1. Go to your repository's Settings
|
||||||
2. Click on "Secrets and variables" → "Actions"
|
2. Click on "Secrets and variables" → "Actions"
|
||||||
3. Click "New repository secret"
|
3. Click "New repository secret"
|
||||||
4. Name: `ANTHROPIC_API_KEY`
|
4. For authentication, choose one:
|
||||||
5. Value: Your Anthropic API key (starting with `sk-ant-`)
|
- API Key: Name: `ANTHROPIC_API_KEY`, Value: Your Anthropic API key (starting with `sk-ant-`)
|
||||||
6. Click "Add secret"
|
- 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 ANTHROPIC_API_KEY
|
### Best Practices for Authentication
|
||||||
|
|
||||||
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` in workflows
|
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` or `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` in workflows
|
||||||
2. ✅ Never commit API keys to version control
|
2. ✅ Never commit API keys or tokens to version control
|
||||||
3. ✅ Regularly rotate your API keys
|
3. ✅ Regularly rotate your API keys and tokens
|
||||||
4. ✅ Use environment secrets for organization-wide access
|
4. ✅ Use environment secrets for organization-wide access
|
||||||
5. ❌ Never share API keys in pull requests or issues
|
5. ❌ Never share API keys or tokens in pull requests or issues
|
||||||
6. ❌ Avoid logging workflow variables that might contain keys
|
6. ❌ Avoid logging workflow variables that might contain keys
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
|||||||
|
|
||||||
## Path to 1.0
|
## Path to 1.0
|
||||||
|
|
||||||
- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.
|
- ~**Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.~
|
||||||
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
|
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
|
||||||
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
|
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
|
||||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||||
|
|||||||
13
action.yml
13
action.yml
@@ -52,6 +52,10 @@ inputs:
|
|||||||
default: ""
|
default: ""
|
||||||
mcp_config:
|
mcp_config:
|
||||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||||
|
additional_permissions:
|
||||||
|
description: "Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
claude_env:
|
claude_env:
|
||||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||||
required: false
|
required: false
|
||||||
@@ -61,6 +65,9 @@ inputs:
|
|||||||
anthropic_api_key:
|
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)"
|
||||||
required: false
|
required: false
|
||||||
|
claude_code_oauth_token:
|
||||||
|
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||||
|
required: false
|
||||||
github_token:
|
github_token:
|
||||||
description: "GitHub token with repo and pull request permissions (optional if using GitHub App)"
|
description: "GitHub token with repo and pull request permissions (optional if using GitHub App)"
|
||||||
required: false
|
required: false
|
||||||
@@ -124,11 +131,13 @@ runs:
|
|||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
|
ACTIONS_TOKEN: ${{ github.token }}
|
||||||
|
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
uses: anthropics/claude-code-base-action@a835717b36becf75584224421f4094aae288cad7 # v0.0.31
|
uses: anthropics/claude-code-base-action@3560d21b41bd19b1d3ac6c9000af378903d8df0e # v0.0.32
|
||||||
with:
|
with:
|
||||||
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||||
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
||||||
@@ -141,11 +150,13 @@ runs:
|
|||||||
use_bedrock: ${{ inputs.use_bedrock }}
|
use_bedrock: ${{ inputs.use_bedrock }}
|
||||||
use_vertex: ${{ inputs.use_vertex }}
|
use_vertex: ${{ inputs.use_vertex }}
|
||||||
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
||||||
|
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
|
||||||
claude_env: ${{ inputs.claude_env }}
|
claude_env: ${{ inputs.claude_env }}
|
||||||
env:
|
env:
|
||||||
# Model configuration
|
# Model configuration
|
||||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||||
|
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||||
|
|||||||
@@ -33,4 +33,6 @@ jobs:
|
|||||||
uses: anthropics/claude-code-action@beta
|
uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Or use OAuth token instead:
|
||||||
|
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
timeout_minutes: "60"
|
timeout_minutes: "60"
|
||||||
|
|||||||
@@ -36,9 +36,21 @@ const BASE_ALLOWED_TOOLS = [
|
|||||||
];
|
];
|
||||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||||
|
|
||||||
export function buildAllowedToolsString(customAllowedTools?: string[]): string {
|
export function buildAllowedToolsString(
|
||||||
|
customAllowedTools?: string[],
|
||||||
|
includeActionsTools: boolean = false,
|
||||||
|
): string {
|
||||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||||
|
|
||||||
|
// 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(",");
|
let allAllowedTools = baseTools.join(",");
|
||||||
if (customAllowedTools && customAllowedTools.length > 0) {
|
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||||
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||||
@@ -665,8 +677,12 @@ export async function createPrompt(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Set allowed tools
|
// Set allowed tools
|
||||||
|
const hasActionsReadPermission =
|
||||||
|
context.inputs.additionalPermissions.get("actions") === "read" &&
|
||||||
|
context.isPR;
|
||||||
const allAllowedTools = buildAllowedToolsString(
|
const allAllowedTools = buildAllowedToolsString(
|
||||||
context.inputs.allowedTools,
|
context.inputs.allowedTools,
|
||||||
|
hasActionsReadPermission,
|
||||||
);
|
);
|
||||||
const allDisallowedTools = buildDisallowedToolsString(
|
const allDisallowedTools = buildDisallowedToolsString(
|
||||||
context.inputs.disallowedTools,
|
context.inputs.disallowedTools,
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ async function run() {
|
|||||||
additionalMcpConfig,
|
additionalMcpConfig,
|
||||||
claudeCommentId: commentId.toString(),
|
claudeCommentId: commentId.toString(),
|
||||||
allowedTools: context.inputs.allowedTools,
|
allowedTools: context.inputs.allowedTools,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
core.setOutput("mcp_config", mcpConfig);
|
core.setOutput("mcp_config", mcpConfig);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type ParsedGitHubContext = {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
branchPrefix: string;
|
branchPrefix: string;
|
||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
|
additionalPermissions: Map<string, string>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
|||||||
baseBranch: process.env.BASE_BRANCH,
|
baseBranch: process.env.BASE_BRANCH,
|
||||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||||
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
|
||||||
|
additionalPermissions: parseAdditionalPermissions(
|
||||||
|
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +129,25 @@ export function parseMultilineInput(s: string): string[] {
|
|||||||
.filter((tool) => tool.length > 0);
|
.filter((tool) => tool.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAdditionalPermissions(s: string): Map<string, string> {
|
||||||
|
const permissions = new Map<string, string>();
|
||||||
|
if (!s || !s.trim()) {
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = s.trim().split("\n");
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine) {
|
||||||
|
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
|
||||||
|
if (key && value) {
|
||||||
|
permissions.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
export function isIssuesEvent(
|
export function isIssuesEvent(
|
||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from "../../context";
|
} from "../../context";
|
||||||
import type { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
|
const CLAUDE_APP_BOT_ID = 209825114;
|
||||||
|
|
||||||
export async function createInitialComment(
|
export async function createInitialComment(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
@@ -36,11 +38,15 @@ export async function createInitialComment(
|
|||||||
repo,
|
repo,
|
||||||
issue_number: context.entityNumber,
|
issue_number: context.entityNumber,
|
||||||
});
|
});
|
||||||
const existingComment = comments.data.find(
|
const existingComment = comments.data.find((comment) => {
|
||||||
(comment) =>
|
const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
|
||||||
comment.user?.login.indexOf("claude[bot]") !== -1 ||
|
const botNameMatch =
|
||||||
comment.body === initialBody,
|
comment.user?.type === "Bot" &&
|
||||||
);
|
comment.user?.login.toLowerCase().includes("claude");
|
||||||
|
const bodyMatch = comment.body === initialBody;
|
||||||
|
|
||||||
|
return idMatch || botNameMatch || bodyMatch;
|
||||||
|
});
|
||||||
if (existingComment) {
|
if (existingComment) {
|
||||||
response = await octokit.rest.issues.updateComment({
|
response = await octokit.rest.issues.updateComment({
|
||||||
owner,
|
owner,
|
||||||
|
|||||||
@@ -1,47 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
type RetryOptions = {
|
|
||||||
maxAttempts?: number;
|
|
||||||
initialDelayMs?: number;
|
|
||||||
maxDelayMs?: number;
|
|
||||||
backoffFactor?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function retryWithBackoff<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
options: RetryOptions = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const {
|
|
||||||
maxAttempts = 3,
|
|
||||||
initialDelayMs = 5000,
|
|
||||||
maxDelayMs = 20000,
|
|
||||||
backoffFactor = 2,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
let delayMs = initialDelayMs;
|
|
||||||
let lastError: Error | undefined;
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
|
||||||
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
|
||||||
|
|
||||||
if (attempt < maxAttempts) {
|
|
||||||
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(`Operation failed after ${maxAttempts} attempts`);
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOidcToken(): Promise<string> {
|
async function getOidcToken(): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
275
src/mcp/github-actions-server.ts
Normal file
275
src/mcp/github-actions-server.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { mkdir, writeFile } from "fs/promises";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
|
const REPO_NAME = process.env.REPO_NAME;
|
||||||
|
const PR_NUMBER = process.env.PR_NUMBER;
|
||||||
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||||
|
const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp";
|
||||||
|
|
||||||
|
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
|
||||||
|
console.error(
|
||||||
|
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "GitHub CI Server",
|
||||||
|
version: "0.0.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.error("[GitHub CI Server] MCP Server instance created");
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_ci_status",
|
||||||
|
"Get CI status summary for this PR",
|
||||||
|
{
|
||||||
|
status: z
|
||||||
|
.enum([
|
||||||
|
"completed",
|
||||||
|
"action_required",
|
||||||
|
"cancelled",
|
||||||
|
"failure",
|
||||||
|
"neutral",
|
||||||
|
"skipped",
|
||||||
|
"stale",
|
||||||
|
"success",
|
||||||
|
"timed_out",
|
||||||
|
"in_progress",
|
||||||
|
"queued",
|
||||||
|
"requested",
|
||||||
|
"waiting",
|
||||||
|
"pending",
|
||||||
|
])
|
||||||
|
.optional()
|
||||||
|
.describe("Filter workflow runs by status"),
|
||||||
|
},
|
||||||
|
async ({ status }) => {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the PR to find the head SHA
|
||||||
|
const { data: prData } = await client.pulls.get({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
pull_number: parseInt(PR_NUMBER!, 10),
|
||||||
|
});
|
||||||
|
const headSha = prData.head.sha;
|
||||||
|
|
||||||
|
const { data: runsData } = await client.actions.listWorkflowRunsForRepo({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
head_sha: headSha,
|
||||||
|
...(status && { status }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process runs to create summary
|
||||||
|
const runs = runsData.workflow_runs || [];
|
||||||
|
const summary = {
|
||||||
|
total_runs: runs.length,
|
||||||
|
failed: 0,
|
||||||
|
passed: 0,
|
||||||
|
pending: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const processedRuns = runs.map((run: any) => {
|
||||||
|
// Update summary counts
|
||||||
|
if (run.status === "completed") {
|
||||||
|
if (run.conclusion === "success") {
|
||||||
|
summary.passed++;
|
||||||
|
} else if (run.conclusion === "failure") {
|
||||||
|
summary.failed++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summary.pending++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: run.id,
|
||||||
|
name: run.name,
|
||||||
|
status: run.status,
|
||||||
|
conclusion: run.conclusion,
|
||||||
|
html_url: run.html_url,
|
||||||
|
created_at: run.created_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
summary,
|
||||||
|
runs: processedRuns,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: errorMessage,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_workflow_run_details",
|
||||||
|
"Get job and step details for a workflow run",
|
||||||
|
{
|
||||||
|
run_id: z.number().describe("The workflow run ID"),
|
||||||
|
},
|
||||||
|
async ({ run_id }) => {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get jobs for this workflow run
|
||||||
|
const { data: jobsData } = await client.actions.listJobsForWorkflowRun({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
run_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processedJobs = jobsData.jobs.map((job: any) => {
|
||||||
|
// Extract failed steps
|
||||||
|
const failedSteps = (job.steps || [])
|
||||||
|
.filter((step: any) => step.conclusion === "failure")
|
||||||
|
.map((step: any) => ({
|
||||||
|
name: step.name,
|
||||||
|
number: step.number,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: job.id,
|
||||||
|
name: job.name,
|
||||||
|
conclusion: job.conclusion,
|
||||||
|
html_url: job.html_url,
|
||||||
|
failed_steps: failedSteps,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
jobs: processedJobs,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: errorMessage,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"download_job_log",
|
||||||
|
"Download job logs to disk",
|
||||||
|
{
|
||||||
|
job_id: z.number().describe("The job ID"),
|
||||||
|
},
|
||||||
|
async ({ job_id }) => {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({
|
||||||
|
auth: GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.actions.downloadJobLogsForWorkflowRun({
|
||||||
|
owner: REPO_OWNER!,
|
||||||
|
repo: REPO_NAME!,
|
||||||
|
job_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const logsText = response.data as unknown as string;
|
||||||
|
|
||||||
|
const logsDir = `${RUNNER_TEMP}/github-ci-logs`;
|
||||||
|
await mkdir(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
const logPath = `${logsDir}/job-${job_id}.log`;
|
||||||
|
await writeFile(logPath, logsText, "utf-8");
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
path: logPath,
|
||||||
|
size_bytes: Buffer.byteLength(logsText, "utf-8"),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, 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() {
|
||||||
|
try {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
|
||||||
|
process.on("exit", () => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runServer().catch(() => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import fetch from "node-fetch";
|
|||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -233,26 +234,50 @@ server.tool(
|
|||||||
|
|
||||||
// 6. Update the reference to point to the new commit
|
// 6. Update the reference to point to the new commit
|
||||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||||
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) {
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
const errorText = await updateRefResponse.text();
|
// on certain repos when updating git references. These appear to be transient
|
||||||
throw new Error(
|
// GitHub API issues that succeed on retry.
|
||||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
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 = {
|
const simplifiedResult = {
|
||||||
commit: {
|
commit: {
|
||||||
@@ -427,26 +452,50 @@ server.tool(
|
|||||||
|
|
||||||
// 6. Update the reference to point to the new commit
|
// 6. Update the reference to point to the new commit
|
||||||
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
|
||||||
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) {
|
// We're seeing intermittent 403 "Resource not accessible by integration" errors
|
||||||
const errorText = await updateRefResponse.text();
|
// on certain repos when updating git references. These appear to be transient
|
||||||
throw new Error(
|
// GitHub API issues that succeed on retry.
|
||||||
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
|
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 = {
|
const simplifiedResult = {
|
||||||
commit: {
|
commit: {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
|
import type { ParsedGitHubContext } from "../github/context";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
type PrepareConfigParams = {
|
type PrepareConfigParams = {
|
||||||
githubToken: string;
|
githubToken: string;
|
||||||
@@ -9,8 +11,41 @@ type PrepareConfigParams = {
|
|||||||
additionalMcpConfig?: string;
|
additionalMcpConfig?: string;
|
||||||
claudeCommentId?: string;
|
claudeCommentId?: string;
|
||||||
allowedTools: string[];
|
allowedTools: string[];
|
||||||
|
context: ParsedGitHubContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function checkActionsReadPermission(
|
||||||
|
token: string,
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = new Octokit({ auth: token });
|
||||||
|
|
||||||
|
// 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(
|
export async function prepareMcpConfig(
|
||||||
params: PrepareConfigParams,
|
params: PrepareConfigParams,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
@@ -22,6 +57,7 @@ export async function prepareMcpConfig(
|
|||||||
additionalMcpConfig,
|
additionalMcpConfig,
|
||||||
claudeCommentId,
|
claudeCommentId,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
|
context,
|
||||||
} = params;
|
} = params;
|
||||||
try {
|
try {
|
||||||
const allowedToolsList = allowedTools || [];
|
const allowedToolsList = allowedTools || [];
|
||||||
@@ -53,6 +89,42 @@ export async function prepareMcpConfig(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) {
|
if (hasGitHubMcpTools) {
|
||||||
baseMcpConfig.mcpServers.github = {
|
baseMcpConfig.mcpServers.github = {
|
||||||
command: "docker",
|
command: "docker",
|
||||||
@@ -62,7 +134,7 @@ export async function prepareMcpConfig(
|
|||||||
"--rm",
|
"--rm",
|
||||||
"-e",
|
"-e",
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
"ghcr.io/github/github-mcp-server:sha-6d69797", // https://github.com/github/github-mcp-server/releases/tag/v0.5.0
|
"ghcr.io/github/github-mcp-server:sha-721fd3e", // https://github.com/github/github-mcp-server/releases/tag/v0.6.0
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||||
|
|||||||
40
src/utils/retry.ts
Normal file
40
src/utils/retry.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type RetryOptions = {
|
||||||
|
maxAttempts?: number;
|
||||||
|
initialDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
backoffFactor?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function retryWithBackoff<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
options: RetryOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxAttempts = 3,
|
||||||
|
initialDelayMs = 5000,
|
||||||
|
maxDelayMs = 20000,
|
||||||
|
backoffFactor = 2,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let delayMs = initialDelayMs;
|
||||||
|
let lastError: Error | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
console.error(`Attempt ${attempt} failed:`, lastError.message);
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
console.log(`Retrying in ${delayMs / 1000} seconds...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Operation failed after ${maxAttempts} attempts`);
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
@@ -743,6 +743,36 @@ describe("buildAllowedToolsString", () => {
|
|||||||
expect(basePlusCustom).toContain("Tool2");
|
expect(basePlusCustom).toContain("Tool2");
|
||||||
expect(basePlusCustom).toContain("Tool3");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildDisallowedToolsString", () => {
|
describe("buildDisallowedToolsString", () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
import { describe, it, expect } from "bun:test";
|
||||||
import { parseMultilineInput } from "../../src/github/context";
|
import {
|
||||||
|
parseMultilineInput,
|
||||||
|
parseAdditionalPermissions,
|
||||||
|
} from "../../src/github/context";
|
||||||
|
|
||||||
describe("parseMultilineInput", () => {
|
describe("parseMultilineInput", () => {
|
||||||
it("should parse a comma-separated string", () => {
|
it("should parse a comma-separated string", () => {
|
||||||
@@ -55,3 +58,58 @@ Bash(bun typecheck)
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseAdditionalPermissions", () => {
|
||||||
|
it("should parse single permission", () => {
|
||||||
|
const input = "actions: read";
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse multiple permissions", () => {
|
||||||
|
const input = `actions: read
|
||||||
|
packages: write
|
||||||
|
contents: read`;
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.get("packages")).toBe("write");
|
||||||
|
expect(result.get("contents")).toBe("read");
|
||||||
|
expect(result.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty string", () => {
|
||||||
|
const input = "";
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle whitespace and empty lines", () => {
|
||||||
|
const input = `
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
packages: write
|
||||||
|
`;
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.get("packages")).toBe("write");
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore lines without colon separator", () => {
|
||||||
|
const input = `actions: read
|
||||||
|
invalid line
|
||||||
|
packages: write`;
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.get("packages")).toBe("write");
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should trim whitespace around keys and values", () => {
|
||||||
|
const input = " actions : read ";
|
||||||
|
const result = parseAdditionalPermissions(input);
|
||||||
|
expect(result.get("actions")).toBe("read");
|
||||||
|
expect(result.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
|
import type { ParsedGitHubContext } from "../src/github/context";
|
||||||
|
|
||||||
describe("prepareMcpConfig", () => {
|
describe("prepareMcpConfig", () => {
|
||||||
let consoleInfoSpy: any;
|
let consoleInfoSpy: any;
|
||||||
@@ -8,6 +9,41 @@ describe("prepareMcpConfig", () => {
|
|||||||
let setFailedSpy: any;
|
let setFailedSpy: any;
|
||||||
let processExitSpy: any;
|
let processExitSpy: any;
|
||||||
|
|
||||||
|
// Create a mock context for tests
|
||||||
|
const mockContext: ParsedGitHubContext = {
|
||||||
|
runId: "test-run-id",
|
||||||
|
eventName: "issue_comment",
|
||||||
|
eventAction: "created",
|
||||||
|
repository: {
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
full_name: "test-owner/test-repo",
|
||||||
|
},
|
||||||
|
actor: "test-actor",
|
||||||
|
payload: {} as any,
|
||||||
|
entityNumber: 123,
|
||||||
|
isPR: false,
|
||||||
|
inputs: {
|
||||||
|
triggerPhrase: "@claude",
|
||||||
|
assigneeTrigger: "",
|
||||||
|
labelTrigger: "",
|
||||||
|
allowedTools: [],
|
||||||
|
disallowedTools: [],
|
||||||
|
customInstructions: "",
|
||||||
|
directPrompt: "",
|
||||||
|
branchPrefix: "",
|
||||||
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPRContext: ParsedGitHubContext = {
|
||||||
|
...mockContext,
|
||||||
|
eventName: "pull_request",
|
||||||
|
isPR: true,
|
||||||
|
entityNumber: 456,
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||||
@@ -15,6 +51,11 @@ describe("prepareMcpConfig", () => {
|
|||||||
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
||||||
throw new Error("Process exit");
|
throw new Error("Process exit");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up required environment variables
|
||||||
|
if (!process.env.GITHUB_ACTION_PATH) {
|
||||||
|
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -31,6 +72,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -57,6 +99,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -78,6 +121,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
"mcp__github_file_ops__update_claude_comment",
|
"mcp__github_file_ops__update_claude_comment",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -93,6 +137,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: ["Edit", "Read", "Write"],
|
allowedTools: ["Edit", "Read", "Write"],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -109,6 +154,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: "",
|
additionalMcpConfig: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -126,6 +172,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: " \n\t ",
|
additionalMcpConfig: " \n\t ",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -158,6 +205,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -195,6 +243,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -232,6 +281,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: additionalConfig,
|
additionalMcpConfig: additionalConfig,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -251,6 +301,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: invalidJson,
|
additionalMcpConfig: invalidJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -271,6 +322,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: nonObjectJson,
|
additionalMcpConfig: nonObjectJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -294,6 +346,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: nullJson,
|
additionalMcpConfig: nullJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -317,6 +370,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: arrayJson,
|
additionalMcpConfig: arrayJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -363,6 +417,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: additionalConfig,
|
additionalMcpConfig: additionalConfig,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -384,6 +439,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -404,6 +460,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -411,4 +468,132 @@ describe("prepareMcpConfig", () => {
|
|||||||
|
|
||||||
process.env.GITHUB_WORKSPACE = oldEnv;
|
process.env.GITHUB_WORKSPACE = oldEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
|
||||||
|
const oldEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||||
|
|
||||||
|
const contextWithPermissions = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
additionalPermissions: new Map([["actions", "read"]]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||||
|
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
|
||||||
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not include github_ci server when context.isPR is false", async () => {
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not include github_ci server when actions:read permission is not granted", async () => {
|
||||||
|
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: mockPRContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should parse additional_permissions with multiple lines correctly", async () => {
|
||||||
|
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "workflow-token";
|
||||||
|
|
||||||
|
const contextWithPermissions = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
additionalPermissions: new Map([
|
||||||
|
["actions", "read"],
|
||||||
|
["future", "permission"],
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should warn when actions:read is requested but token lacks permission", async () => {
|
||||||
|
const oldTokenEnv = process.env.ACTIONS_TOKEN;
|
||||||
|
process.env.ACTIONS_TOKEN = "invalid-token";
|
||||||
|
|
||||||
|
const contextWithPermissions = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
additionalPermissions: new Map([["actions", "read"]]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||||
|
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
"The github_ci MCP server requires 'actions: read' permission",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
process.env.ACTIONS_TOKEN = oldTokenEnv;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const defaultInputs = {
|
|||||||
timeoutMinutes: 30,
|
timeoutMinutes: 30,
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map<string, string>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe("checkWritePermissions", () => {
|
|||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -66,6 +67,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
@@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -339,6 +343,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
branchPrefix: "claude/",
|
||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user