Compare commits

..

7 Commits

Author SHA1 Message Date
Ashwin Bhat
a804c9e83f feat: add OAuth token authentication support (#236)
* feat: add OAuth token authentication support

Add claude_code_oauth_token as an alternative authentication method to anthropic_api_key.
This provides more flexibility for users who prefer OAuth authentication.

- Add claude_code_oauth_token input to action.yml
- Pass OAuth token through to claude-code-base-action
- Update README with OAuth token documentation and examples
- Update security best practices to cover both authentication methods
- Add OAuth example to examples/claude.yml

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: add OAuth token generation instructions for Pro/Max users

Update README to mention that Pro and Max users can generate OAuth tokens
by running `claude setup-token` locally. This provides clearer guidance
for users who want to use OAuth authentication instead of API keys.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update CI capabilities documentation

- Move GitHub Actions access from limitations to capabilities in README
- Update FAQ to explain how to enable CI/CD access with actions:read permission
- Clarify that Claude can access workflow results on PRs where it's tagged

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-07 16:07:22 -07:00
GitHub Actions
d6bc8ddf8a chore: update claude-code-base-action to v0.0.32 2025-07-07 22:54:31 +00:00
Ashwin Bhat
86665d0984 feat: forward NODE_VERSION environment variable to base action (#230)
This allows users to override the default Node version by setting the
NODE_VERSION environment variable in their workflow.

Fixes #229

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ashwin Bhat <ashwin-ant@users.noreply.github.com>
2025-07-06 16:21:00 -07:00
Tomohiro Ishibashi
6364776f60 fix: update MCP server image to version 0.6.0 (#234)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-05 22:12:48 -07:00
Rodrigo Yokota
e43c1b7fac fix(github): fixing claude login user name (#227)
* fix(github): fixing claude login user name

* fix: improving bot user identification conditions

* fix: making a const out of claude bot id
2025-07-04 11:14:14 -07:00
Ashwin Bhat
23fae74fdb Add GitHub Actions MCP server for viewing workflow results (#231)
* actions server

* tmp

* Replace view_actions_results with additional_permissions input

- Changed input from boolean view_actions_results to a more flexible additional_permissions format
- Uses newline-separated colon format similar to claude_env (e.g., "actions: read")
- Maintains permission checking to warn users when their token lacks required permissions
- Updated all tests to use the new format

This allows for future extensibility while currently supporting only "actions: read" permission.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update GitHub Actions MCP server with RUNNER_TEMP and status filtering

- Use RUNNER_TEMP environment variable for log storage directory (defaults to /tmp)
- Add status parameter to get_ci_status tool to filter workflow runs
- Supported statuses: completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending
- Pass RUNNER_TEMP from install-mcp-server.ts to the MCP server environment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add GitHub Actions MCP tools to allowed tools when actions:read is granted

- Automatically include github_ci MCP server tools in allowed tools list when actions:read permission is granted
- Added mcp__github_ci__get_ci_status, mcp__github_ci__get_workflow_run_details, mcp__github_ci__download_job_log
- Simplified permission checking to avoid duplicate parsing logic
- Added tests for the new functionality

This ensures Claude can use the Actions tools when the server is enabled.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor additional permissions parsing to parseGitHubContext

- Moved additional permissions parsing from individual functions to centralized parseGitHubContext
- Added parseAdditionalPermissions function to handle newline-separated colon format
- Removed redundant additionalPermissions parameter from prepareMcpConfig
- Updated tests to use permissions from context instead of passing as parameter
- Added comprehensive tests for parseAdditionalPermissions function

This centralizes all input parsing logic in one place for better maintainability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove unnecessary hasActionsReadPermission parameter from createPrompt

- Removed hasActionsReadPermission parameter since createPrompt has access to context
- Calculate hasActionsReadPermission directly from context.inputs.additionalPermissions inside createPrompt
- Simplified prepare.ts by removing intermediate permission check

This completes the refactoring to centralize all permission handling through the context object.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: Add documentation for additional_permissions feature

- Document the new additional_permissions input that replaces view_actions_results
- Add dedicated section explaining CI/CD integration with actions:read permission
- Include example workflow showing how to grant GitHub token permissions
- Update main workflow example to show optional additional_permissions usage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* roadmap

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-03 18:58:02 -07:00
Ashwin Bhat
3c739a8cf3 Add retry logic for intermittent 403 errors in MCP file operations (#232)
- Extract retry logic to shared utility in src/utils/retry.ts
- Update token.ts to use shared retry utility
- Add retry with exponential backoff to git reference updates
- Only retry on 403 errors, fail immediately on other errors
- Use shorter delays (1-5s) for transient GitHub API failures

This handles intermittent 403 'Resource not accessible by integration'
errors transparently without requiring workflow permission changes. These
errors appear to be transient GitHub API issues that succeed on retry.
2025-07-03 15:56:19 -07:00
21 changed files with 963 additions and 131 deletions

View File

@@ -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
View File

@@ -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?

110
README.md
View File

@@ -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,13 +78,17 @@ 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\* | - |
| `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 | - | | `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | 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 | - | | `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
@@ -101,6 +109,7 @@ jobs:
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | | `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/` | | `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 | "" | | `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

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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 } {

View File

@@ -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,

View File

@@ -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 {

View 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);
});

View File

@@ -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,6 +234,12 @@ 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}`;
// 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, { const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH", method: "PATCH",
headers: { headers: {
@@ -249,11 +256,29 @@ server.tool(
if (!updateRefResponse.ok) { if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text(); const errorText = await updateRefResponse.text();
throw new Error( const error = new Error(
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`, `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: {
sha: newCommitData.sha, sha: newCommitData.sha,
@@ -427,6 +452,12 @@ 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}`;
// 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, { const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH", method: "PATCH",
headers: { headers: {
@@ -443,11 +474,29 @@ server.tool(
if (!updateRefResponse.ok) { if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text(); const errorText = await updateRefResponse.text();
throw new Error( const error = new Error(
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`, `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: {
sha: newCommitData.sha, sha: newCommitData.sha,

View File

@@ -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
View 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;
}

View File

@@ -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", () => {

View File

@@ -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);
});
});

View File

@@ -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;
});
}); });

View File

@@ -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 = {

View File

@@ -69,6 +69,7 @@ describe("checkWritePermissions", () => {
directPrompt: "", directPrompt: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });

View File

@@ -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);