Compare commits

...

15 Commits

Author SHA1 Message Date
Ashwin Bhat
32a6163092 tmp 2025-07-15 15:51:39 -07:00
Ashwin Bhat
a9d9ad3612 feat: add settings input support (#276)
- Add settings input to action.yml that accepts JSON string or file path
- Pass settings parameter to claude-code-base-action
- Update README with comprehensive settings documentation
- Add link to official Claude Code settings documentation
- Document precedence rules for model and tool permissions

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-15 14:00:26 -07:00
GitHub Actions
4824494f4d chore: update claude-code-base-action to v0.0.35 2025-07-15 18:54:33 +00:00
Ashwin Bhat
c09fc691c5 docs: add custom GitHub App setup instructions (#267)
Add comprehensive section explaining how to create and use a custom GitHub App
instead of the official Claude app. This is particularly useful for users with
restrictive organization policies or those using AWS Bedrock/Google Vertex AI.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-14 17:17:56 -07:00
GitHub Actions
b3c6de94ea chore: update claude-code-base-action to v0.0.34 2025-07-14 15:59:55 +00:00
Jay Derinbogaz
b92e56a96b refactor: update branch naming convention for Kubernetes compatibility (#249)
* refactor: update branch naming convention for Kubernetes compatibility

- Changed timestamp format in branch names to a shorter, Kubernetes-compatible style (lowercase, hyphens only).
- Updated related tests to reflect new branch name format.
- Ensured branch names are limited to a maximum of 50 characters to comply with Kubernetes naming requirements.

* refactor: clean up timestamp formatting in branch naming logic

- Removed unnecessary whitespace and standardized string formatting for the Kubernetes-compatible timestamp in branch names.
- Ensured consistency in the use of double quotes for string literals.
2025-07-12 11:30:49 -07:00
David Wells
b6868bfc27 Expose the created branch for downstream usage (#237)
* Expose the created branch for downstream usage

* run bun format
2025-07-11 10:15:41 -07:00
Allen Li
0f9a2c4dc3 fix: add GITHUB_API_URL to all Octokit client instantiations (#243)
Not all Octokit client instantiations were respecting GITHUB_API_URL, so
these tools would fail on enterprise.
2025-07-11 07:46:23 -07:00
Ashwin Bhat
cefe963a6b feat: defer remote branch creation until first commit (#244)
* feat: defer remote branch creation until first commit

- For commit signing: branches are created remotely by github-file-ops-server on first commit
- For non-signing: branches are created locally with 'git checkout -b' and pushed when needed
- Consolidated duplicate branch creation logic in github-file-ops-server into a shared helper function
- Claude is unaware of these implementation details and simply sees it's on the correct branch
- No branch links are shown in initial comments since branches don't exist remotely yet

* fix: prevent broken branch links in final comment update

- Check if branch exists remotely before adding branch link
- Only add branch links for branches that actually exist on GitHub
- Add test coverage for non-existent remote branches
- Fixes issue where users would see broken branch links for local-only branches

* fix: don't show branch name in comment header when branch doesn't exist remotely

- Only pass branchName to updateCommentBody when branchLink exists
- Prevents showing branch names for branches that only exist locally
- Add test to verify branch name is not shown when branch doesn't exist

* tmp
2025-07-10 12:57:15 -07:00
GitHub Actions
eda5af4e69 chore: update claude-code-base-action to v0.0.33 2025-07-10 17:05:41 +00:00
Ashwin Bhat
87facd7051 feat: add use_commit_signing input with default false (#238)
* feat: add use_commit_signing input with default false

- Add new input 'use_commit_signing' to action.yml (defaults to false)
- Separate comment update functionality into standalone github-comment-server.ts
- Update MCP server configuration to conditionally load servers based on signing preference
- When commit signing is disabled, use specific Bash git commands (e.g., Bash(git add:*))
- When commit signing is enabled, use github-file-ops-server for atomic commits with signing
- Always include github-comment-server for comment updates regardless of signing mode
- Update prompt generation to provide appropriate instructions based on signing preference
- Add comprehensive test coverage for new functionality

This change simplifies the default setup for users who don't need commit signing,
while maintaining the option to enable it for those who require GitHub's commit
signature verification.

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

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

* feat: auto-commit uncommitted changes when commit signing is disabled

- Check for uncommitted changes after Claude finishes (non-signing mode only)
- Automatically commit and push any uncommitted work to preserve Claude's changes
- Update tests to avoid actual git operations during test runs
- Pass use_commit_signing flag to branch cleanup logic

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-09 16:28:36 -07:00
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
25 changed files with 1195 additions and 379 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?

223
README.md
View File

@@ -30,9 +30,91 @@ 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/`
### Using a Custom GitHub App
If you prefer not to install the official Claude app, you can create your own GitHub App to use with this action. This gives you complete control over permissions and access.
**When you may want to use a custom GitHub App:**
- You need more restrictive permissions than the official app
- Organization policies prevent installing third-party apps
- You're using AWS Bedrock or Google Vertex AI
**Steps to create and use a custom GitHub App:**
1. **Create a new GitHub App:**
- Go to https://github.com/settings/apps (for personal apps) or your organization's settings
- Click "New GitHub App"
- Configure the app with these minimum permissions:
- **Repository permissions:**
- Contents: Read & Write
- Issues: Read & Write
- Pull requests: Read & Write
- **Account permissions:** None required
- Set "Where can this GitHub App be installed?" to your preference
- Create the app
2. **Generate and download a private key:**
- After creating the app, scroll down to "Private keys"
- Click "Generate a private key"
- Download the `.pem` file (keep this secure!)
3. **Install the app on your repository:**
- Go to the app's settings page
- Click "Install App"
- Select the repositories where you want to use Claude
4. **Add the app credentials to your repository secrets:**
- Go to your repository's Settings → Secrets and variables → Actions
- Add these secrets:
- `APP_ID`: Your GitHub App's ID (found in the app settings)
- `APP_PRIVATE_KEY`: The contents of the downloaded `.pem` file
5. **Update your workflow to use the custom app:**
```yaml
name: Claude with Custom App
on:
issue_comment:
types: [created]
# ... other triggers
jobs:
claude-response:
runs-on: ubuntu-latest
steps:
# Generate a token from your custom app
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# Use Claude with your custom app's token
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.app-token.outputs.token }}
# ... other configuration
```
**Important notes:**
- The custom app must have read/write permissions for Issues, Pull Requests, and Contents
- Your app's token will have the exact permissions you configured, nothing more
For more information on creating GitHub Apps, see the [GitHub documentation](https://docs.github.com/en/apps/creating-github-apps).
## 📚 FAQ ## 📚 FAQ
Having issues or questions? Check out our [Frequently Asked Questions](./FAQ.md) for solutions to common problems and detailed explanations of Claude's capabilities and limitations. Having issues or questions? Check out our [Frequently Asked Questions](./FAQ.md) for solutions to common problems and detailed explanations of Claude's capabilities and limitations.
@@ -60,6 +142,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"
@@ -81,30 +165,32 @@ jobs:
## 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/` |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | | `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
\*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)
@@ -330,6 +416,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
@@ -338,7 +425,6 @@ 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
@@ -486,6 +572,65 @@ Use a specific Claude model:
# ... other inputs # ... other inputs
``` ```
### Claude Code Settings
You can provide Claude Code settings to customize behavior such as model selection, environment variables, permissions, and hooks. Settings can be provided either as a JSON string or a path to a settings file.
#### Option 1: Settings File
```yaml
- uses: anthropics/claude-code-action@beta
with:
settings: "path/to/settings.json"
# ... other inputs
```
#### Option 2: Inline Settings
```yaml
- uses: anthropics/claude-code-action@beta
with:
settings: |
{
"model": "claude-opus-4-20250514",
"env": {
"DEBUG": "true",
"API_URL": "https://api.example.com"
},
"permissions": {
"allow": ["Bash", "Read"],
"deny": ["WebFetch"]
},
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "echo Running bash command..."
}]
}]
}
}
# ... other inputs
```
The settings support all Claude Code settings options including:
- `model`: Override the default model
- `env`: Environment variables for the session
- `permissions`: Tool usage permissions
- `hooks`: Pre/post tool execution hooks
- And more...
For a complete list of available settings and their descriptions, see the [Claude Code settings documentation](https://docs.anthropic.com/en/docs/claude-code/settings).
**Notes**:
- The `enableAllProjectMcpServers` setting is always set to `true` by this action to ensure MCP servers work correctly.
- If both the `model` input parameter and a `model` in settings are provided, the `model` input parameter takes precedence.
- The `allowed_tools` and `disallowed_tools` input parameters take precedence over `permissions` in settings.
- In a future version, we may deprecate individual input parameters in favor of using the settings file for all configuration.
## Cloud Providers ## Cloud Providers
You can authenticate with Claude using any of these three methods: You can authenticate with Claude using any of these three methods:
@@ -604,18 +749,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
@@ -623,17 +771,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

@@ -60,11 +60,18 @@ inputs:
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
default: "" default: ""
settings:
description: "Claude Code settings as JSON string or path to settings JSON file"
required: false
default: ""
# Auth configuration # Auth configuration
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
@@ -89,11 +96,18 @@ inputs:
description: "Use just one comment to deliver issue/PR comments" description: "Use just one comment to deliver issue/PR comments"
required: false required: false
default: "false" default: "false"
use_commit_signing:
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
required: false
default: "false"
outputs: outputs:
execution_file: execution_file:
description: "Path to the Claude Code execution output file" description: "Path to the Claude Code execution output file"
value: ${{ steps.claude-code.outputs.execution_file }} value: ${{ steps.claude-code.outputs.execution_file }}
branch_name:
description: "The branch created by Claude Code for this execution"
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
runs: runs:
using: "composite" using: "composite"
@@ -130,11 +144,12 @@ runs:
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
ACTIONS_TOKEN: ${{ github.token }} ACTIONS_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
- 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@503cc7080e62d63d2cc1d80035ed04617d5efb47 # v0.0.35
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 }}
@@ -147,11 +162,14 @@ 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 }}
settings: ${{ inputs.settings }}
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 }}
@@ -196,6 +214,7 @@ runs:
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
- name: Display Claude Code Report - name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''

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

@@ -30,18 +30,40 @@ const BASE_ALLOWED_TOOLS = [
"LS", "LS",
"Read", "Read",
"Write", "Write",
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__delete_files",
"mcp__github_file_ops__update_claude_comment",
]; ];
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"]; const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
export function buildAllowedToolsString( export function buildAllowedToolsString(
customAllowedTools?: string[], customAllowedTools?: string[],
includeActionsTools: boolean = false, includeActionsTools: boolean = false,
useCommitSigning: boolean = false,
): string { ): string {
let baseTools = [...BASE_ALLOWED_TOOLS]; let baseTools = [...BASE_ALLOWED_TOOLS];
// Always include the comment update tool from the comment server
baseTools.push("mcp__github_comment__update_claude_comment");
// Add commit signing tools if enabled
if (useCommitSigning) {
baseTools.push(
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__delete_files",
);
} else {
// When not using commit signing, add specific Bash git commands only
baseTools.push(
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git rm:*)",
"Bash(git config user.name:*)",
"Bash(git config user.email:*)",
);
}
// Add GitHub Actions MCP tools if enabled // Add GitHub Actions MCP tools if enabled
if (includeActionsTools) { if (includeActionsTools) {
baseTools.push( baseTools.push(
@@ -380,9 +402,68 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
} }
} }
function getCommitInstructions(
eventData: EventData,
githubData: FetchDataResult,
context: PreparedContext,
useCommitSigning: boolean,
): string {
const coAuthorLine =
(githubData.triggerDisplayName ?? context.triggerUsername !== "Unknown")
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
: "";
if (useCommitSigning) {
if (eventData.isPR && !eventData.claudeBranch) {
return `
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
- Use: "${coAuthorLine}"`;
} else {
return `
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
- Use: "${coAuthorLine}"`;
}
} else {
// Non-signing instructions
if (eventData.isPR && !eventData.claudeBranch) {
return `
- Use git commands via the Bash tool to commit and push your changes:
- Stage files: Bash(git add <files>)
- Commit with a descriptive message: Bash(git commit -m "<message>")
${
coAuthorLine
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
: ""
}
- Push to the remote: Bash(git push origin HEAD)`;
} else {
const branchName = eventData.claudeBranch || eventData.baseBranch;
return `
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
- Use git commands via the Bash tool to commit and push your changes:
- Stage files: Bash(git add <files>)
- Commit with a descriptive message: Bash(git commit -m "<message>")
${
coAuthorLine
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
: ""
}
- Push to the remote: Bash(git push origin ${branchName})`;
}
}
}
export function generatePrompt( export function generatePrompt(
context: PreparedContext, context: PreparedContext,
githubData: FetchDataResult, githubData: FetchDataResult,
useCommitSigning: boolean,
): string { ): string {
const { const {
contextData, contextData,
@@ -471,9 +552,9 @@ ${sanitizeContent(context.directPrompt)}
: "" : ""
} }
${`<comment_tool_info> ${`<comment_tool_info>
IMPORTANT: You have been provided with the mcp__github_file_ops__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments. IMPORTANT: You have been provided with the mcp__github_comment__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments.
Tool usage example for mcp__github_file_ops__update_claude_comment: Tool usage example for mcp__github_comment__update_claude_comment:
{ {
"body": "Your comment text here" "body": "Your comment text here"
} }
@@ -492,7 +573,7 @@ Follow these steps:
1. Create a Todo List: 1. Create a Todo List:
- Use your GitHub comment to maintain a detailed task list based on the request. - Use your GitHub comment to maintain a detailed task list based on the request.
- Format todos as a checklist (- [ ] for incomplete, - [x] for complete). - Format todos as a checklist (- [ ] for incomplete, - [x] for complete).
- Update the comment using mcp__github_file_ops__update_claude_comment with each task completion. - Update the comment using mcp__github_comment__update_claude_comment with each task completion.
2. Gather Context: 2. Gather Context:
- Analyze the pre-fetched data provided above. - Analyze the pre-fetched data provided above.
@@ -523,29 +604,16 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
- Look for bugs, security issues, performance problems, and other issues - Look for bugs, security issues, performance problems, and other issues
- Suggest improvements for readability and maintainability - Suggest improvements for readability and maintainability
- Check for best practices and coding standards - Check for best practices and coding standards
- Reference specific code sections with file paths and line numbers${eventData.isPR ? "\n - AFTER reading files and analyzing code, you MUST call mcp__github_file_ops__update_claude_comment to post your review" : ""} - Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
- Formulate a concise, technical, and helpful response based on the context. - Formulate a concise, technical, and helpful response based on the context.
- Reference specific code with inline formatting or code blocks. - Reference specific code with inline formatting or code blocks.
- Include relevant file paths and line numbers when applicable. - Include relevant file paths and line numbers when applicable.
- ${eventData.isPR ? "IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_file_ops__update_claude_comment. This will be displayed as your PR review." : "Remember that this feedback must be posted to the GitHub comment using mcp__github_file_ops__update_claude_comment."} - ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
B. For Straightforward Changes: B. For Straightforward Changes:
- Use file system tools to make the change locally. - Use file system tools to make the change locally.
- If you discover related tasks (e.g., updating tests), add them to the todo list. - If you discover related tasks (e.g., updating tests), add them to the todo list.
- Mark each subtask as completed as you progress. - Mark each subtask as completed as you progress.${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
${
eventData.isPR && !eventData.claudeBranch
? `
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
- Use: "Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>"`
: `
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
- Use: "Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>"
${ ${
eventData.claudeBranch eventData.claudeBranch
? `- Provide a URL to create a PR manually in this format: ? `- Provide a URL to create a PR manually in this format:
@@ -563,7 +631,6 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
- The signature: "Generated with [Claude Code](https://claude.ai/code)" - The signature: "Generated with [Claude Code](https://claude.ai/code)"
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"` - Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
: "" : ""
}`
} }
C. For Complex Changes: C. For Complex Changes:
@@ -579,20 +646,31 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
- Always update the GitHub comment to reflect the current todo state. - Always update the GitHub comment to reflect the current todo state.
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done. - When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically. - Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
- If you changed any files locally, you must update them in the remote branch via mcp__github_file_ops__commit_files before saying that you're done. - If you changed any files locally, you must update them in the remote branch via ${useCommitSigning ? "mcp__github_file_ops__commit_files" : "git commands (add, commit, push)"} before saying that you're done.
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""} ${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
Important Notes: Important Notes:
- All communication must happen through GitHub PR comments. - All communication must happen through GitHub PR comments.
- Never create new comments. Only update the existing comment using mcp__github_file_ops__update_claude_comment. - Never create new comments. Only update the existing comment using mcp__github_comment__update_claude_comment.
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_file_ops__update_claude_comment. Do NOT just respond with a normal response, the user will not see it." : ""} - This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? `\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_comment__update_claude_comment. Do NOT just respond with a normal response, the user will not see it.` : ""}
- You communicate exclusively by editing your single comment - not through any other means. - You communicate exclusively by editing your single comment - not through any other means.
- Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" /> - Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`} ${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk. ${
useCommitSigning
? `- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
Tool usage examples: Tool usage examples:
- mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"} - mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"} - mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}`
: `- Use git commands via the Bash tool for version control (you have access to specific git commands only):
- Stage files: Bash(git add <files>)
- Commit changes: Bash(git commit -m "<message>")
- Push to remote: Bash(git push origin <branch>) (NEVER force push)
- Delete files: Bash(git rm <files>) followed by commit and push
- Check status: Bash(git status)
- View diff: Bash(git diff)
- Configure git user: Bash(git config user.name "...") and Bash(git config user.email "...")`
}
- Display the todo list as a checklist in the GitHub comment and mark things off as you go. - Display the todo list as a checklist in the GitHub comment and mark things off as you go.
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively. - REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
- Use h3 headers (###) for section titles in your comments, not h1 headers (#). - Use h3 headers (###) for section titles in your comments, not h1 headers (#).
@@ -663,7 +741,11 @@ export async function createPrompt(
}); });
// Generate the prompt // Generate the prompt
const promptContent = generatePrompt(preparedContext, githubData); const promptContent = generatePrompt(
preparedContext,
githubData,
context.inputs.useCommitSigning,
);
// Log the final prompt to console // Log the final prompt to console
console.log("===== FINAL PROMPT ====="); console.log("===== FINAL PROMPT =====");
@@ -683,6 +765,7 @@ export async function createPrompt(
const allAllowedTools = buildAllowedToolsString( const allAllowedTools = buildAllowedToolsString(
context.inputs.allowedTools, context.inputs.allowedTools,
hasActionsReadPermission, hasActionsReadPermission,
context.inputs.useCommitSigning,
); );
const allDisallowedTools = buildDisallowedToolsString( const allDisallowedTools = buildDisallowedToolsString(
context.inputs.disallowedTools, context.inputs.disallowedTools,

View File

@@ -12,7 +12,7 @@ import { checkHumanActor } from "../github/validation/actor";
import { checkWritePermissions } from "../github/validation/permissions"; import { checkWritePermissions } from "../github/validation/permissions";
import { createInitialComment } from "../github/operations/comments/create-initial"; import { createInitialComment } from "../github/operations/comments/create-initial";
import { setupBranch } from "../github/operations/branch"; import { setupBranch } from "../github/operations/branch";
import { updateTrackingComment } from "../github/operations/comments/update-with-branch"; import { configureGitAuth } from "../github/operations/git-config";
import { prepareMcpConfig } from "../mcp/install-mcp-server"; import { prepareMcpConfig } from "../mcp/install-mcp-server";
import { createPrompt } from "../create-prompt"; import { createPrompt } from "../create-prompt";
import { createOctokit } from "../github/api/client"; import { createOctokit } from "../github/api/client";
@@ -51,7 +51,8 @@ async function run() {
await checkHumanActor(octokit.rest, context); await checkHumanActor(octokit.rest, context);
// Step 6: Create initial tracking comment // Step 6: Create initial tracking comment
const commentId = await createInitialComment(octokit.rest, context); const commentData = await createInitialComment(octokit.rest, context);
const commentId = commentData.id;
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation) // Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
const githubData = await fetchGitHubData({ const githubData = await fetchGitHubData({
@@ -65,14 +66,14 @@ async function run() {
// Step 8: Setup branch // Step 8: Setup branch
const branchInfo = await setupBranch(octokit, githubData, context); const branchInfo = await setupBranch(octokit, githubData, context);
// Step 9: Update initial comment with branch link (only for issues that created a new branch) // Step 9: Configure git authentication if not using commit signing
if (branchInfo.claudeBranch) { if (!context.inputs.useCommitSigning) {
await updateTrackingComment( try {
octokit, await configureGitAuth(githubToken, context, commentData.user);
context, } catch (error) {
commentId, console.error("Failed to configure git authentication:", error);
branchInfo.claudeBranch, throw error;
); }
} }
// Step 10: Create prompt file // Step 10: Create prompt file

View File

@@ -11,7 +11,7 @@ import {
isPullRequestReviewCommentEvent, isPullRequestReviewCommentEvent,
} from "../github/context"; } from "../github/context";
import { GITHUB_SERVER_URL } from "../github/api/config"; import { GITHUB_SERVER_URL } from "../github/api/config";
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup"; import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
async function run() { async function run() {
@@ -88,13 +88,16 @@ async function run() {
const currentBody = comment.body ?? ""; const currentBody = comment.body ?? "";
// Check if we need to add branch link for new branches // Check if we need to add branch link for new branches
const { shouldDeleteBranch, branchLink } = await checkAndDeleteEmptyBranch( const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
octokit, const { shouldDeleteBranch, branchLink } =
owner, await checkAndCommitOrDeleteBranch(
repo, octokit,
claudeBranch, owner,
baseBranch, repo,
); claudeBranch,
baseBranch,
useCommitSigning,
);
// Check if we need to add PR URL when we have a new branch // Check if we need to add PR URL when we have a new branch
let prLink = ""; let prLink = "";
@@ -198,7 +201,7 @@ async function run() {
jobUrl, jobUrl,
branchLink, branchLink,
prLink, prLink,
branchName: shouldDeleteBranch ? undefined : claudeBranch, branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch,
triggerUsername, triggerUsername,
errorDetails, errorDetails,
}; };

View File

@@ -38,6 +38,7 @@ export type ParsedGitHubContext = {
branchPrefix: string; branchPrefix: string;
useStickyComment: boolean; useStickyComment: boolean;
additionalPermissions: Map<string, string>; additionalPermissions: Map<string, string>;
useCommitSigning: boolean;
}; };
}; };
@@ -68,6 +69,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
additionalPermissions: parseAdditionalPermissions( additionalPermissions: parseAdditionalPermissions(
process.env.ADDITIONAL_PERMISSIONS ?? "", process.env.ADDITIONAL_PERMISSIONS ?? "",
), ),
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
}, },
}; };

View File

@@ -1,17 +1,44 @@
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
import { GITHUB_SERVER_URL } from "../api/config"; import { GITHUB_SERVER_URL } from "../api/config";
import { $ } from "bun";
export async function checkAndDeleteEmptyBranch( export async function checkAndCommitOrDeleteBranch(
octokit: Octokits, octokit: Octokits,
owner: string, owner: string,
repo: string, repo: string,
claudeBranch: string | undefined, claudeBranch: string | undefined,
baseBranch: string, baseBranch: string,
useCommitSigning: boolean,
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> { ): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
let branchLink = ""; let branchLink = "";
let shouldDeleteBranch = false; let shouldDeleteBranch = false;
if (claudeBranch) { if (claudeBranch) {
// First check if the branch exists remotely
let branchExistsRemotely = false;
try {
await octokit.rest.repos.getBranch({
owner,
repo,
branch: claudeBranch,
});
branchExistsRemotely = true;
} catch (error: any) {
if (error.status === 404) {
console.log(`Branch ${claudeBranch} does not exist remotely`);
} else {
console.error("Error checking if branch exists:", error);
}
}
// Only proceed if branch exists remotely
if (!branchExistsRemotely) {
console.log(
`Branch ${claudeBranch} does not exist remotely, no branch link will be added`,
);
return { shouldDeleteBranch: false, branchLink: "" };
}
// Check if Claude made any commits to the branch // Check if Claude made any commits to the branch
try { try {
const { data: comparison } = const { data: comparison } =
@@ -21,20 +48,66 @@ export async function checkAndDeleteEmptyBranch(
basehead: `${baseBranch}...${claudeBranch}`, basehead: `${baseBranch}...${claudeBranch}`,
}); });
// If there are no commits, mark branch for deletion // If there are no commits, check for uncommitted changes if not using commit signing
if (comparison.total_commits === 0) { if (comparison.total_commits === 0) {
console.log( if (!useCommitSigning) {
`Branch ${claudeBranch} has no commits from Claude, will delete it`, console.log(
); `Branch ${claudeBranch} has no commits from Claude, checking for uncommitted changes...`,
shouldDeleteBranch = true; );
// Check for uncommitted changes using git status
try {
const gitStatus = await $`git status --porcelain`.quiet();
const hasUncommittedChanges =
gitStatus.stdout.toString().trim().length > 0;
if (hasUncommittedChanges) {
console.log("Found uncommitted changes, committing them...");
// Add all changes
await $`git add -A`;
// Commit with a descriptive message
const runId = process.env.GITHUB_RUN_ID || "unknown";
const commitMessage = `Auto-commit: Save uncommitted changes from Claude\n\nRun ID: ${runId}`;
await $`git commit -m ${commitMessage}`;
// Push the changes
await $`git push origin ${claudeBranch}`;
console.log(
"✅ Successfully committed and pushed uncommitted changes",
);
// Set branch link since we now have commits
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
} else {
console.log(
"No uncommitted changes found, marking branch for deletion",
);
shouldDeleteBranch = true;
}
} catch (gitError) {
console.error("Error checking/committing changes:", gitError);
// If we can't check git status, assume the branch might have changes
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`;
}
} else {
console.log(
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
);
shouldDeleteBranch = true;
}
} else { } else {
// Only add branch link if there are commits // Only add branch link if there are commits
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`; branchLink = `\n[View branch](${branchUrl})`;
} }
} catch (error) { } catch (error) {
console.error("Error checking for commits on Claude branch:", error); console.error("Error comparing commits on Claude branch:", error);
// If we can't check, assume the branch has commits to be safe // If we can't compare but the branch exists remotely, include the branch link
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`; const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
branchLink = `\n[View branch](${branchUrl})`; branchLink = `\n[View branch](${branchUrl})`;
} }

View File

@@ -84,23 +84,23 @@ export async function setupBranch(
sourceBranch = repoResponse.data.default_branch; sourceBranch = repoResponse.data.default_branch;
} }
// Creating a new branch for either an issue or closed/merged PR // Generate branch name for either an issue or closed/merged PR
const entityType = isPR ? "pr" : "issue"; const entityType = isPR ? "pr" : "issue";
console.log(
`Creating new branch for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
);
const timestamp = new Date() // Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
.toISOString() const now = new Date();
.replace(/[:-]/g, "") const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
.replace(/\.\d{3}Z/, "")
.split("T")
.join("_");
const newBranch = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`; // Ensure branch name is Kubernetes-compatible:
// - Lowercase only
// - Alphanumeric with hyphens
// - No underscores
// - Max 50 chars (to allow for prefixes)
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
const newBranch = branchName.toLowerCase().substring(0, 50);
try { try {
// Get the SHA of the source branch // Get the SHA of the source branch to verify it exists
const sourceBranchRef = await octokits.rest.git.getRef({ const sourceBranchRef = await octokits.rest.git.getRef({
owner, owner,
repo, repo,
@@ -108,23 +108,34 @@ export async function setupBranch(
}); });
const currentSHA = sourceBranchRef.data.object.sha; const currentSHA = sourceBranchRef.data.object.sha;
console.log(`Source branch SHA: ${currentSHA}`);
console.log(`Current SHA: ${currentSHA}`); // For commit signing, defer branch creation to the file ops server
if (context.inputs.useCommitSigning) {
console.log(
`Branch name generated: ${newBranch} (will be created by file ops server on first commit)`,
);
// Create branch using GitHub API // Set outputs for GitHub Actions
await octokits.rest.git.createRef({ core.setOutput("CLAUDE_BRANCH", newBranch);
owner, core.setOutput("BASE_BRANCH", sourceBranch);
repo, return {
ref: `refs/heads/${newBranch}`, baseBranch: sourceBranch,
sha: currentSHA, claudeBranch: newBranch,
}); currentBranch: sourceBranch, // Stay on source branch for now
};
}
// Checkout the new branch (shallow fetch for performance) // For non-signing case, create and checkout the branch locally only
await $`git fetch origin --depth=1 ${newBranch}`; console.log(
await $`git checkout ${newBranch}`; `Creating local branch ${newBranch} for ${entityType} #${entityNumber} from source branch: ${sourceBranch}...`,
);
// Create and checkout the new branch locally
await $`git checkout -b ${newBranch}`;
console.log( console.log(
`Successfully created and checked out new branch: ${newBranch}`, `Successfully created and checked out local branch: ${newBranch}`,
); );
// Set outputs for GitHub Actions // Set outputs for GitHub Actions
@@ -136,7 +147,7 @@ export async function setupBranch(
currentBranch: newBranch, currentBranch: newBranch,
}; };
} catch (error) { } catch (error) {
console.error("Error creating branch:", error); console.error("Error in branch setup:", error);
process.exit(1); process.exit(1);
} }
} }

View File

@@ -86,7 +86,7 @@ export async function createInitialComment(
const githubOutput = process.env.GITHUB_OUTPUT!; const githubOutput = process.env.GITHUB_OUTPUT!;
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`); appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
console.log(`✅ Created initial comment with ID: ${response.data.id}`); console.log(`✅ Created initial comment with ID: ${response.data.id}`);
return response.data.id; return response.data;
} catch (error) { } catch (error) {
console.error("Error in initial comment:", error); console.error("Error in initial comment:", error);
@@ -102,7 +102,7 @@ export async function createInitialComment(
const githubOutput = process.env.GITHUB_OUTPUT!; const githubOutput = process.env.GITHUB_OUTPUT!;
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`); appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
console.log(`✅ Created fallback comment with ID: ${response.data.id}`); console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
return response.data.id; return response.data;
} catch (fallbackError) { } catch (fallbackError) {
console.error("Error creating fallback comment:", fallbackError); console.error("Error creating fallback comment:", fallbackError);
throw fallbackError; throw fallbackError;

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bun
/**
* Configure git authentication for non-signing mode
* Sets up git user and authentication to work with GitHub App tokens
*/
import { $ } from "bun";
import type { ParsedGitHubContext } from "../context";
import { GITHUB_SERVER_URL } from "../api/config";
type GitUser = {
login: string;
id: number;
};
export async function configureGitAuth(
githubToken: string,
context: ParsedGitHubContext,
user: GitUser | null,
) {
console.log("Configuring git authentication for non-signing mode");
// Configure git user based on the comment creator
console.log("Configuring git user...");
if (user) {
const botName = user.login;
const botId = user.id;
console.log(`Setting git user as ${botName}...`);
await $`git config user.name "${botName}"`;
await $`git config user.email "${botId}+${botName}@users.noreply.github.com"`;
console.log(`✓ Set git user as ${botName}`);
} else {
console.log("No user data in comment, using default bot user");
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "41898282+github-actions[bot]@users.noreply.github.com"`;
}
// Remove the authorization header that actions/checkout sets
console.log("Removing existing git authentication headers...");
try {
await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
console.log("✓ Removed existing authentication headers");
} catch (e) {
console.log("No existing authentication headers to remove");
}
// Update the remote URL to include the token for authentication
console.log("Updating remote URL with authentication...");
const serverUrl = new URL(GITHUB_SERVER_URL);
const remoteUrl = `https://x-access-token:${githubToken}@${serverUrl.host}/${context.repository.owner}/${context.repository.repo}.git`;
await $`git remote set-url origin ${remoteUrl}`;
console.log("✓ Updated remote URL with authentication token");
console.log("Git authentication configured successfully");
}

View File

@@ -3,6 +3,7 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod"; import { z } from "zod";
import { GITHUB_API_URL } from "../github/api/config";
import { mkdir, writeFile } from "fs/promises"; import { mkdir, writeFile } from "fs/promises";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
@@ -54,6 +55,7 @@ server.tool(
try { try {
const client = new Octokit({ const client = new Octokit({
auth: GITHUB_TOKEN, auth: GITHUB_TOKEN,
baseUrl: GITHUB_API_URL,
}); });
// Get the PR to find the head SHA // Get the PR to find the head SHA
@@ -142,6 +144,7 @@ server.tool(
try { try {
const client = new Octokit({ const client = new Octokit({
auth: GITHUB_TOKEN, auth: GITHUB_TOKEN,
baseUrl: GITHUB_API_URL,
}); });
// Get jobs for this workflow run // Get jobs for this workflow run
@@ -209,6 +212,7 @@ server.tool(
try { try {
const client = new Octokit({ const client = new Octokit({
auth: GITHUB_TOKEN, auth: GITHUB_TOKEN,
baseUrl: GITHUB_API_URL,
}); });
const response = await client.actions.downloadJobLogsForWorkflowRun({ const response = await client.actions.downloadJobLogsForWorkflowRun({

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
// GitHub Comment MCP Server - Minimal server that only provides comment update functionality
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { GITHUB_API_URL } from "../github/api/config";
import { Octokit } from "@octokit/rest";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
// Get repository information from environment variables
const REPO_OWNER = process.env.REPO_OWNER;
const REPO_NAME = process.env.REPO_NAME;
if (!REPO_OWNER || !REPO_NAME) {
console.error(
"Error: REPO_OWNER and REPO_NAME environment variables are required",
);
process.exit(1);
}
const server = new McpServer({
name: "GitHub Comment Server",
version: "0.0.1",
});
server.tool(
"update_claude_comment",
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
{
body: z.string().describe("The updated comment content"),
},
async ({ body }) => {
try {
const githubToken = process.env.GITHUB_TOKEN;
const claudeCommentId = process.env.CLAUDE_COMMENT_ID;
const eventName = process.env.GITHUB_EVENT_NAME;
if (!githubToken) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
if (!claudeCommentId) {
throw new Error("CLAUDE_COMMENT_ID environment variable is required");
}
const owner = REPO_OWNER;
const repo = REPO_NAME;
const commentId = parseInt(claudeCommentId, 10);
const octokit = new Octokit({
auth: githubToken,
baseUrl: GITHUB_API_URL,
});
const isPullRequestReviewComment =
eventName === "pull_request_review_comment";
const result = await updateClaudeComment(octokit, {
owner,
repo,
commentId,
body,
isPullRequestReviewComment,
});
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() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("exit", () => {
server.close();
});
}
runServer().catch(console.error);

View File

@@ -7,9 +7,8 @@ import { readFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
import fetch from "node-fetch"; 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 { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
import { retryWithBackoff } from "../utils/retry"; import { retryWithBackoff } from "../utils/retry";
import { Octokit } from "@octokit/rest";
type GitHubRef = { type GitHubRef = {
object: { object: {
@@ -54,6 +53,112 @@ const server = new McpServer({
version: "0.0.1", version: "0.0.1",
}); });
// Helper function to get or create branch reference
async function getOrCreateBranchRef(
owner: string,
repo: string,
branch: string,
githubToken: string,
): Promise<string> {
// Create Octokit instance
const octokit = new Octokit({
auth: githubToken,
baseUrl: GITHUB_API_URL,
});
// Try to get the branch reference
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const refResponse = await fetch(refUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (refResponse.ok) {
const refData = (await refResponse.json()) as GitHubRef;
return refData.object.sha;
}
if (refResponse.status !== 404) {
throw new Error(`Failed to get branch reference: ${refResponse.status}`);
}
// Get base branch from environment or determine it
const baseBranch = process.env.BASE_BRANCH || "main";
// Get the SHA of the base branch
const baseRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${baseBranch}`;
const baseRefResponse = await fetch(baseRefUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
let baseSha: string;
if (!baseRefResponse.ok) {
// If base branch doesn't exist, try default branch
const repoUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}`;
const repoResponse = await fetch(repoUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!repoResponse.ok) {
throw new Error(`Failed to get repository info: ${repoResponse.status}`);
}
const repoData = (await repoResponse.json()) as {
default_branch: string;
};
const defaultBranch = repoData.default_branch;
// Try default branch
const defaultRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${defaultBranch}`;
const defaultRefResponse = await fetch(defaultRefUrl, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!defaultRefResponse.ok) {
throw new Error(
`Failed to get default branch reference: ${defaultRefResponse.status}`,
);
}
const defaultRefData = (await defaultRefResponse.json()) as GitHubRef;
baseSha = defaultRefData.object.sha;
} else {
const baseRefData = (await baseRefResponse.json()) as GitHubRef;
baseSha = baseRefData.object.sha;
}
// Create the new branch using Octokit
try {
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branch}`,
sha: baseSha,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to create branch: ${errorMessage}`);
}
return baseSha;
}
// Commit files tool // Commit files tool
server.tool( server.tool(
"commit_files", "commit_files",
@@ -83,24 +188,13 @@ server.tool(
return filePath; return filePath;
}); });
// 1. Get the branch reference // 1. Get the branch reference (create if doesn't exist)
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; const baseSha = await getOrCreateBranchRef(
const refResponse = await fetch(refUrl, { owner,
headers: { repo,
Accept: "application/vnd.github+json", branch,
Authorization: `Bearer ${githubToken}`, githubToken,
"X-GitHub-Api-Version": "2022-11-28", );
},
});
if (!refResponse.ok) {
throw new Error(
`Failed to get branch reference: ${refResponse.status}`,
);
}
const refData = (await refResponse.json()) as GitHubRef;
const baseSha = refData.object.sha;
// 2. Get the base commit // 2. Get the base commit
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`; const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
@@ -262,7 +356,6 @@ server.tool(
// Only retry on 403 errors - these are the intermittent failures we're targeting // Only retry on 403 errors - these are the intermittent failures we're targeting
if (updateRefResponse.status === 403) { if (updateRefResponse.status === 403) {
console.log("Received 403 error, will retry...");
throw error; throw error;
} }
@@ -355,24 +448,13 @@ server.tool(
return filePath; return filePath;
}); });
// 1. Get the branch reference // 1. Get the branch reference (create if doesn't exist)
const refUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; const baseSha = await getOrCreateBranchRef(
const refResponse = await fetch(refUrl, { owner,
headers: { repo,
Accept: "application/vnd.github+json", branch,
Authorization: `Bearer ${githubToken}`, githubToken,
"X-GitHub-Api-Version": "2022-11-28", );
},
});
if (!refResponse.ok) {
throw new Error(
`Failed to get branch reference: ${refResponse.status}`,
);
}
const refData = (await refResponse.json()) as GitHubRef;
const baseSha = refData.object.sha;
// 2. Get the base commit // 2. Get the base commit
const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`; const commitUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/commits/${baseSha}`;
@@ -480,7 +562,6 @@ server.tool(
// Only retry on 403 errors - these are the intermittent failures we're targeting // Only retry on 403 errors - these are the intermittent failures we're targeting
if (updateRefResponse.status === 403) { if (updateRefResponse.status === 403) {
console.log("Received 403 error, will retry...");
throw error; throw error;
} }
@@ -535,70 +616,6 @@ server.tool(
}, },
); );
server.tool(
"update_claude_comment",
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
{
body: z.string().describe("The updated comment content"),
},
async ({ body }) => {
try {
const githubToken = process.env.GITHUB_TOKEN;
const claudeCommentId = process.env.CLAUDE_COMMENT_ID;
const eventName = process.env.GITHUB_EVENT_NAME;
if (!githubToken) {
throw new Error("GITHUB_TOKEN environment variable is required");
}
if (!claudeCommentId) {
throw new Error("CLAUDE_COMMENT_ID environment variable is required");
}
const owner = REPO_OWNER;
const repo = REPO_NAME;
const commentId = parseInt(claudeCommentId, 10);
const octokit = new Octokit({
auth: githubToken,
baseUrl: GITHUB_API_URL,
});
const isPullRequestReviewComment =
eventName === "pull_request_review_comment";
const result = await updateClaudeComment(octokit, {
owner,
repo,
commentId,
body,
isPullRequestReviewComment,
});
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() { async function runServer() {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();
await server.connect(transport); await server.connect(transport);

View File

@@ -20,7 +20,7 @@ async function checkActionsReadPermission(
repo: string, repo: string,
): Promise<boolean> { ): Promise<boolean> {
try { try {
const client = new Octokit({ auth: token }); const client = new Octokit({ auth: token, baseUrl: GITHUB_API_URL });
// Try to list workflow runs - this requires actions:read // Try to list workflow runs - this requires actions:read
// We use per_page=1 to minimize the response size // We use per_page=1 to minimize the response size
@@ -67,28 +67,48 @@ export async function prepareMcpConfig(
); );
const baseMcpConfig: { mcpServers: Record<string, unknown> } = { const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
mcpServers: { mcpServers: {},
github_file_ops: { };
command: "bun",
args: [ // Always include comment server for updating Claude comments
"run", baseMcpConfig.mcpServers.github_comment = {
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`, command: "bun",
], args: [
env: { "run",
GITHUB_TOKEN: githubToken, `${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
REPO_OWNER: owner, ],
REPO_NAME: repo, env: {
BRANCH_NAME: branch, GITHUB_TOKEN: githubToken,
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(), REPO_OWNER: owner,
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }), REPO_NAME: repo,
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "", ...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
IS_PR: process.env.IS_PR || "false", GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
GITHUB_API_URL: GITHUB_API_URL, GITHUB_API_URL: GITHUB_API_URL,
},
},
}, },
}; };
// Include file ops server when commit signing is enabled
if (context.inputs.useCommitSigning) {
baseMcpConfig.mcpServers.github_file_ops = {
command: "bun",
args: [
"run",
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
],
env: {
GITHUB_TOKEN: githubToken,
REPO_OWNER: owner,
REPO_NAME: repo,
BRANCH_NAME: branch,
BASE_BRANCH: process.env.BASE_BRANCH || "",
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
IS_PR: process.env.IS_PR || "false",
GITHUB_API_URL: GITHUB_API_URL,
},
};
}
// Only add CI server if we have actions:read permission and we're in a PR context // Only add CI server if we have actions:read permission and we're in a PR context
const hasActionsReadPermission = const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read"; context.inputs.additionalPermissions.get("actions") === "read";
@@ -134,7 +154,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,

View File

@@ -1,9 +1,9 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup"; import { checkAndCommitOrDeleteBranch } from "../src/github/operations/branch-cleanup";
import type { Octokits } from "../src/github/api/client"; import type { Octokits } from "../src/github/api/client";
import { GITHUB_SERVER_URL } from "../src/github/api/config"; import { GITHUB_SERVER_URL } from "../src/github/api/config";
describe("checkAndDeleteEmptyBranch", () => { describe("checkAndCommitOrDeleteBranch", () => {
let consoleLogSpy: any; let consoleLogSpy: any;
let consoleErrorSpy: any; let consoleErrorSpy: any;
@@ -21,6 +21,7 @@ describe("checkAndDeleteEmptyBranch", () => {
const createMockOctokit = ( const createMockOctokit = (
compareResponse?: any, compareResponse?: any,
deleteRefError?: Error, deleteRefError?: Error,
branchExists: boolean = true,
): Octokits => { ): Octokits => {
return { return {
rest: { rest: {
@@ -28,6 +29,14 @@ describe("checkAndDeleteEmptyBranch", () => {
compareCommitsWithBasehead: async () => ({ compareCommitsWithBasehead: async () => ({
data: compareResponse || { total_commits: 0 }, data: compareResponse || { total_commits: 0 },
}), }),
getBranch: async () => {
if (!branchExists) {
const error: any = new Error("Not Found");
error.status = 404;
throw error;
}
return { data: {} };
},
}, },
git: { git: {
deleteRef: async () => { deleteRef: async () => {
@@ -43,12 +52,13 @@ describe("checkAndDeleteEmptyBranch", () => {
test("should return no branch link and not delete when branch is undefined", async () => { test("should return no branch link and not delete when branch is undefined", async () => {
const mockOctokit = createMockOctokit(); const mockOctokit = createMockOctokit();
const result = await checkAndDeleteEmptyBranch( const result = await checkAndCommitOrDeleteBranch(
mockOctokit, mockOctokit,
"owner", "owner",
"repo", "repo",
undefined, undefined,
"main", "main",
false,
); );
expect(result.shouldDeleteBranch).toBe(false); expect(result.shouldDeleteBranch).toBe(false);
@@ -56,39 +66,38 @@ describe("checkAndDeleteEmptyBranch", () => {
expect(consoleLogSpy).not.toHaveBeenCalled(); expect(consoleLogSpy).not.toHaveBeenCalled();
}); });
test("should delete branch and return no link when branch has no commits", async () => { test("should mark branch for deletion when commit signing is enabled and no commits", async () => {
const mockOctokit = createMockOctokit({ total_commits: 0 }); const mockOctokit = createMockOctokit({ total_commits: 0 });
const result = await checkAndDeleteEmptyBranch( const result = await checkAndCommitOrDeleteBranch(
mockOctokit, mockOctokit,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123-20240101-1234",
"main", "main",
true, // commit signing enabled
); );
expect(result.shouldDeleteBranch).toBe(true); expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe(""); expect(result.branchLink).toBe("");
expect(consoleLogSpy).toHaveBeenCalledWith( expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it", "Branch claude/issue-123-20240101-1234 has no commits from Claude, will delete it",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"✅ Deleted empty branch: claude/issue-123-20240101_123456",
); );
}); });
test("should not delete branch and return link when branch has commits", async () => { test("should not delete branch and return link when branch has commits", async () => {
const mockOctokit = createMockOctokit({ total_commits: 3 }); const mockOctokit = createMockOctokit({ total_commits: 3 });
const result = await checkAndDeleteEmptyBranch( const result = await checkAndCommitOrDeleteBranch(
mockOctokit, mockOctokit,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123-20240101-1234",
"main", "main",
false,
); );
expect(result.shouldDeleteBranch).toBe(false); expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe( expect(result.branchLink).toBe(
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101_123456)`, `\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101-1234)`,
); );
expect(consoleLogSpy).not.toHaveBeenCalledWith( expect(consoleLogSpy).not.toHaveBeenCalledWith(
expect.stringContaining("has no commits"), expect.stringContaining("has no commits"),
@@ -102,6 +111,7 @@ describe("checkAndDeleteEmptyBranch", () => {
compareCommitsWithBasehead: async () => { compareCommitsWithBasehead: async () => {
throw new Error("API error"); throw new Error("API error");
}, },
getBranch: async () => ({ data: {} }), // Branch exists
}, },
git: { git: {
deleteRef: async () => ({ data: {} }), deleteRef: async () => ({ data: {} }),
@@ -109,20 +119,21 @@ describe("checkAndDeleteEmptyBranch", () => {
}, },
} as any as Octokits; } as any as Octokits;
const result = await checkAndDeleteEmptyBranch( const result = await checkAndCommitOrDeleteBranch(
mockOctokit, mockOctokit,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123-20240101-1234",
"main", "main",
false,
); );
expect(result.shouldDeleteBranch).toBe(false); expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe( expect(result.branchLink).toBe(
`\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101_123456)`, `\n[View branch](${GITHUB_SERVER_URL}/owner/repo/tree/claude/issue-123-20240101-1234)`,
); );
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error checking for commits on Claude branch:", "Error comparing commits on Claude branch:",
expect.any(Error), expect.any(Error),
); );
}); });
@@ -131,19 +142,46 @@ describe("checkAndDeleteEmptyBranch", () => {
const deleteError = new Error("Delete failed"); const deleteError = new Error("Delete failed");
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError); const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
const result = await checkAndDeleteEmptyBranch( const result = await checkAndCommitOrDeleteBranch(
mockOctokit, mockOctokit,
"owner", "owner",
"repo", "repo",
"claude/issue-123-20240101_123456", "claude/issue-123-20240101-1234",
"main", "main",
true, // commit signing enabled - will try to delete
); );
expect(result.shouldDeleteBranch).toBe(true); expect(result.shouldDeleteBranch).toBe(true);
expect(result.branchLink).toBe(""); expect(result.branchLink).toBe("");
expect(consoleErrorSpy).toHaveBeenCalledWith( expect(consoleErrorSpy).toHaveBeenCalledWith(
"Failed to delete branch claude/issue-123-20240101_123456:", "Failed to delete branch claude/issue-123-20240101-1234:",
deleteError, deleteError,
); );
}); });
test("should return no branch link when branch doesn't exist remotely", async () => {
const mockOctokit = createMockOctokit(
{ total_commits: 0 },
undefined,
false, // branch doesn't exist
);
const result = await checkAndCommitOrDeleteBranch(
mockOctokit,
"owner",
"repo",
"claude/issue-123-20240101-1234",
"main",
false,
);
expect(result.shouldDeleteBranch).toBe(false);
expect(result.branchLink).toBe("");
expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123-20240101-1234 does not exist remotely",
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"Branch claude/issue-123-20240101-1234 does not exist remotely, no branch link will be added",
);
});
}); });

View File

@@ -1,5 +1,8 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { updateCommentBody } from "../src/github/operations/comment-logic"; import {
updateCommentBody,
type CommentUpdateInput,
} from "../src/github/operations/comment-logic";
describe("updateCommentBody", () => { describe("updateCommentBody", () => {
const baseInput = { const baseInput = {
@@ -100,12 +103,12 @@ describe("updateCommentBody", () => {
it("adds branch name with link to header when provided", () => { it("adds branch name with link to header when provided", () => {
const input = { const input = {
...baseInput, ...baseInput,
branchName: "claude/issue-123-20240101_120000", branchName: "claude/issue-123-20240101-1200",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
expect(result).toContain( expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)", "• [`claude/issue-123-20240101-1200`](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
); );
}); });
@@ -381,9 +384,9 @@ describe("updateCommentBody", () => {
const input = { const input = {
...baseInput, ...baseInput,
currentBody: "Claude Code is working… <img src='spinner.gif' />", currentBody: "Claude Code is working… <img src='spinner.gif' />",
branchName: "claude/pr-456-20240101_120000", branchName: "claude/pr-456-20240101-1200",
prLink: prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)", "\n[Create a PR](https://github.com/owner/repo/compare/main...claude/pr-456-20240101-1200)",
triggerUsername: "jane-doe", triggerUsername: "jane-doe",
}; };
@@ -391,7 +394,7 @@ describe("updateCommentBody", () => {
// Should include the PR link in the formatted style // Should include the PR link in the formatted style
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101_120000)", "• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/pr-456-20240101-1200)",
); );
expect(result).toContain("**Claude finished @jane-doe's task**"); expect(result).toContain("**Claude finished @jane-doe's task**");
}); });
@@ -400,22 +403,44 @@ describe("updateCommentBody", () => {
const input = { const input = {
...baseInput, ...baseInput,
currentBody: "Claude Code is working…", currentBody: "Claude Code is working…",
branchName: "claude/issue-123-20240101_120000", branchName: "claude/issue-123-20240101-1200",
branchLink: branchLink:
"\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)", "\n[View branch](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
prLink: prLink:
"\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)", "\n[Create a PR](https://github.com/owner/repo/compare/main...claude/issue-123-20240101-1200)",
}; };
const result = updateCommentBody(input); const result = updateCommentBody(input);
// Should include both links in formatted style // Should include both links in formatted style
expect(result).toContain( expect(result).toContain(
"• [`claude/issue-123-20240101_120000`](https://github.com/owner/repo/tree/claude/issue-123-20240101_120000)", "• [`claude/issue-123-20240101-1200`](https://github.com/owner/repo/tree/claude/issue-123-20240101-1200)",
); );
expect(result).toContain( expect(result).toContain(
"• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101_120000)", "• [Create PR ➔](https://github.com/owner/repo/compare/main...claude/issue-123-20240101-1200)",
); );
}); });
it("should not show branch name when branch doesn't exist remotely", () => {
const input: CommentUpdateInput = {
currentBody: "@claude can you help with this?",
actionFailed: false,
executionDetails: { duration_ms: 90000 },
jobUrl: "https://github.com/owner/repo/actions/runs/123",
branchLink: "", // Empty branch link means branch doesn't exist remotely
branchName: undefined, // Should be undefined when branchLink is empty
triggerUsername: "claude",
prLink: "",
};
const result = updateCommentBody(input);
expect(result).toContain("Claude finished @claude's task in 1m 30s");
expect(result).toContain(
"[View job](https://github.com/owner/repo/actions/runs/123)",
);
expect(result).not.toContain("claude/issue-123");
expect(result).not.toContain("tree/claude/issue-123");
});
}); });
}); });

View File

@@ -127,13 +127,13 @@ describe("generatePrompt", () => {
commentId: "67890", commentId: "67890",
isPR: false, isPR: false,
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000", claudeBranch: "claude/issue-67890-20240101-1200",
issueNumber: "67890", issueNumber: "67890",
commentBody: "@claude please fix this", commentBody: "@claude please fix this",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("You are Claude, an AI assistant"); expect(prompt).toContain("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>"); expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
@@ -161,7 +161,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>"); expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>"); expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -183,11 +183,11 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "789", issueNumber: "789",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000", claudeBranch: "claude/issue-789-20240101-1200",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>"); expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
expect(prompt).toContain( expect(prompt).toContain(
@@ -210,12 +210,12 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "999", issueNumber: "999",
baseBranch: "develop", baseBranch: "develop",
claudeBranch: "claude/issue-999-20240101_120000", claudeBranch: "claude/issue-999-20240101-1200",
assigneeTrigger: "claude-bot", assigneeTrigger: "claude-bot",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>"); expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
expect(prompt).toContain( expect(prompt).toContain(
@@ -237,12 +237,12 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "888", issueNumber: "888",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-888-20240101_120000", claudeBranch: "claude/issue-888-20240101-1200",
labelTrigger: "claude-task", labelTrigger: "claude-task",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>"); expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
expect(prompt).toContain( expect(prompt).toContain(
@@ -265,11 +265,11 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "789", issueNumber: "789",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000", claudeBranch: "claude/issue-789-20240101-1200",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<direct_prompt>"); expect(prompt).toContain("<direct_prompt>");
expect(prompt).toContain("Fix the bug in the login form"); expect(prompt).toContain("Fix the bug in the login form");
@@ -292,7 +292,7 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>"); expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>"); expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -312,12 +312,12 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "123", issueNumber: "123",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000", claudeBranch: "claude/issue-67890-20240101-1200",
commentBody: "@claude please fix this", commentBody: "@claude please fix this",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript"); expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
}); });
@@ -334,16 +334,17 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "123", issueNumber: "123",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-67890-20240101_120000", claudeBranch: "claude/issue-67890-20240101-1200",
commentBody: "@claude please fix this", commentBody: "@claude please fix this",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>"); expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
// With commit signing disabled, co-author info appears in git commit instructions
expect(prompt).toContain( expect(prompt).toContain(
'Use: "Co-authored-by: johndoe <johndoe@users.noreply.github.com>"', "Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
); );
}); });
@@ -360,12 +361,10 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain PR-specific instructions // Should contain PR-specific instructions (git commands when not using signing)
expect(prompt).toContain( expect(prompt).toContain("git push");
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
);
expect(prompt).toContain( expect(prompt).toContain(
"Always push to the existing branch when triggered on a PR", "Always push to the existing branch when triggered on a PR",
); );
@@ -389,18 +388,18 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "789", issueNumber: "789",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-789-20240101_120000", claudeBranch: "claude/issue-789-20240101-1200",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain Issue-specific instructions // Should contain Issue-specific instructions
expect(prompt).toContain( expect(prompt).toContain(
"You are already on the correct branch (claude/issue-789-20240101_120000)", "You are already on the correct branch (claude/issue-789-20240101-1200)",
); );
expect(prompt).toContain( expect(prompt).toContain(
"IMPORTANT: You are already on the correct branch (claude/issue-789-20240101_120000)", "IMPORTANT: You are already on the correct branch (claude/issue-789-20240101-1200)",
); );
expect(prompt).toContain("Create a PR](https://github.com/"); expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain( expect(prompt).toContain(
@@ -427,22 +426,22 @@ describe("generatePrompt", () => {
isPR: false, isPR: false,
issueNumber: "123", issueNumber: "123",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-123-20240101_120000", claudeBranch: "claude/issue-123-20240101-1200",
commentBody: "@claude please fix this", commentBody: "@claude please fix this",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain the actual branch name with timestamp // Should contain the actual branch name with timestamp
expect(prompt).toContain( expect(prompt).toContain(
"You are already on the correct branch (claude/issue-123-20240101_120000)", "You are already on the correct branch (claude/issue-123-20240101-1200)",
); );
expect(prompt).toContain( expect(prompt).toContain(
"IMPORTANT: You are already on the correct branch (claude/issue-123-20240101_120000)", "IMPORTANT: You are already on the correct branch (claude/issue-123-20240101-1200)",
); );
expect(prompt).toContain( expect(prompt).toContain(
"The branch-name is the current branch: claude/issue-123-20240101_120000", "The branch-name is the current branch: claude/issue-123-20240101-1200",
); );
}); });
@@ -457,22 +456,22 @@ describe("generatePrompt", () => {
isPR: true, isPR: true,
prNumber: "456", prNumber: "456",
commentBody: "@claude please fix this", commentBody: "@claude please fix this",
claudeBranch: "claude/pr-456-20240101_120000", claudeBranch: "claude/pr-456-20240101-1200",
baseBranch: "main", baseBranch: "main",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain branch-specific instructions like issues // Should contain branch-specific instructions like issues
expect(prompt).toContain( expect(prompt).toContain(
"You are already on the correct branch (claude/pr-456-20240101_120000)", "You are already on the correct branch (claude/pr-456-20240101-1200)",
); );
expect(prompt).toContain( expect(prompt).toContain(
"Create a PR](https://github.com/owner/repo/compare/main", "Create a PR](https://github.com/owner/repo/compare/main",
); );
expect(prompt).toContain( expect(prompt).toContain(
"The branch-name is the current branch: claude/pr-456-20240101_120000", "The branch-name is the current branch: claude/pr-456-20240101-1200",
); );
expect(prompt).toContain("Reference to the original PR"); expect(prompt).toContain("Reference to the original PR");
expect(prompt).toContain( expect(prompt).toContain(
@@ -500,12 +499,10 @@ describe("generatePrompt", () => {
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain open PR instructions // Should contain open PR instructions (git commands when not using signing)
expect(prompt).toContain( expect(prompt).toContain("git push");
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
);
expect(prompt).toContain( expect(prompt).toContain(
"Always push to the existing branch when triggered on a PR", "Always push to the existing branch when triggered on a PR",
); );
@@ -528,16 +525,16 @@ describe("generatePrompt", () => {
isPR: true, isPR: true,
prNumber: "789", prNumber: "789",
commentBody: "@claude please update this", commentBody: "@claude please update this",
claudeBranch: "claude/pr-789-20240101_123000", claudeBranch: "claude/pr-789-20240101-1230",
baseBranch: "develop", baseBranch: "develop",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain new branch instructions // Should contain new branch instructions
expect(prompt).toContain( expect(prompt).toContain(
"You are already on the correct branch (claude/pr-789-20240101_123000)", "You are already on the correct branch (claude/pr-789-20240101-1230)",
); );
expect(prompt).toContain( expect(prompt).toContain(
"Create a PR](https://github.com/owner/repo/compare/develop", "Create a PR](https://github.com/owner/repo/compare/develop",
@@ -556,16 +553,16 @@ describe("generatePrompt", () => {
prNumber: "999", prNumber: "999",
commentId: "review-comment-123", commentId: "review-comment-123",
commentBody: "@claude fix this issue", commentBody: "@claude fix this issue",
claudeBranch: "claude/pr-999-20240101_140000", claudeBranch: "claude/pr-999-20240101-1400",
baseBranch: "main", baseBranch: "main",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain new branch instructions // Should contain new branch instructions
expect(prompt).toContain( expect(prompt).toContain(
"You are already on the correct branch (claude/pr-999-20240101_140000)", "You are already on the correct branch (claude/pr-999-20240101-1400)",
); );
expect(prompt).toContain("Create a PR](https://github.com/"); expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain("Reference to the original PR"); expect(prompt).toContain("Reference to the original PR");
@@ -584,20 +581,75 @@ describe("generatePrompt", () => {
eventAction: "closed", eventAction: "closed",
isPR: true, isPR: true,
prNumber: "555", prNumber: "555",
claudeBranch: "claude/pr-555-20240101_150000", claudeBranch: "claude/pr-555-20240101-1500",
baseBranch: "main", baseBranch: "main",
}, },
}; };
const prompt = generatePrompt(envVars, mockGitHubData); const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should contain new branch instructions // Should contain new branch instructions
expect(prompt).toContain( expect(prompt).toContain(
"You are already on the correct branch (claude/pr-555-20240101_150000)", "You are already on the correct branch (claude/pr-555-20240101-1500)",
); );
expect(prompt).toContain("Create a PR](https://github.com/"); expect(prompt).toContain("Create a PR](https://github.com/");
expect(prompt).toContain("Reference to the original PR"); expect(prompt).toContain("Reference to the original PR");
}); });
test("should include git commands when useCommitSigning is false", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: true,
prNumber: "123",
commentBody: "@claude fix the bug",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false);
// Should have git command instructions
expect(prompt).toContain("Use git commands via the Bash tool");
expect(prompt).toContain("git add");
expect(prompt).toContain("git commit");
expect(prompt).toContain("git push");
// Should use the minimal comment tool
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
// Should not have commit signing tool references
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
});
test("should include commit signing tools when useCommitSigning is true", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issue_comment",
commentId: "67890",
isPR: true,
prNumber: "123",
commentBody: "@claude fix the bug",
},
};
const prompt = generatePrompt(envVars, mockGitHubData, true);
// Should have commit signing tool instructions
expect(prompt).toContain("mcp__github_file_ops__commit_files");
expect(prompt).toContain("mcp__github_file_ops__delete_files");
// Comment tool should always be from comment server, not file ops
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
// Should not have git command instructions
expect(prompt).not.toContain("Use git commands via the Bash tool");
});
}); });
describe("getEventTypeAndContext", () => { describe("getEventTypeAndContext", () => {
@@ -631,7 +683,7 @@ describe("getEventTypeAndContext", () => {
isPR: false, isPR: false,
issueNumber: "999", issueNumber: "999",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-999-20240101_120000", claudeBranch: "claude/issue-999-20240101-1200",
assigneeTrigger: "claude-bot", assigneeTrigger: "claude-bot",
}, },
}; };
@@ -653,7 +705,7 @@ describe("getEventTypeAndContext", () => {
isPR: false, isPR: false,
issueNumber: "888", issueNumber: "888",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-888-20240101_120000", claudeBranch: "claude/issue-888-20240101-1200",
labelTrigger: "claude-task", labelTrigger: "claude-task",
}, },
}; };
@@ -676,7 +728,7 @@ describe("getEventTypeAndContext", () => {
isPR: false, isPR: false,
issueNumber: "999", issueNumber: "999",
baseBranch: "main", baseBranch: "main",
claudeBranch: "claude/issue-999-20240101_120000", claudeBranch: "claude/issue-999-20240101-1200",
// No assigneeTrigger when using directPrompt // No assigneeTrigger when using directPrompt
}, },
}; };
@@ -689,7 +741,7 @@ describe("getEventTypeAndContext", () => {
}); });
describe("buildAllowedToolsString", () => { describe("buildAllowedToolsString", () => {
test("should return issue comment tool for regular events", () => { test("should return correct tools for regular events (default no signing)", () => {
const result = buildAllowedToolsString(); const result = buildAllowedToolsString();
// The base tools should be in the result // The base tools should be in the result
@@ -699,15 +751,20 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("LS"); expect(result).toContain("LS");
expect(result).toContain("Read"); expect(result).toContain("Read");
expect(result).toContain("Write"); expect(result).toContain("Write");
expect(result).toContain("mcp__github_file_ops__update_claude_comment");
expect(result).not.toContain("mcp__github__update_issue_comment"); // Default is no commit signing, so should have specific Bash git commands
expect(result).not.toContain("mcp__github__update_pull_request_comment"); expect(result).toContain("Bash(git add:*)");
expect(result).toContain("mcp__github_file_ops__commit_files"); expect(result).toContain("Bash(git commit:*)");
expect(result).toContain("mcp__github_file_ops__delete_files"); expect(result).toContain("Bash(git push:*)");
expect(result).toContain("mcp__github_comment__update_claude_comment");
// Should not have commit signing tools
expect(result).not.toContain("mcp__github_file_ops__commit_files");
expect(result).not.toContain("mcp__github_file_ops__delete_files");
}); });
test("should return PR comment tool for inline review comments", () => { test("should return correct tools with default parameters", () => {
const result = buildAllowedToolsString(); const result = buildAllowedToolsString([], false, false);
// The base tools should be in the result // The base tools should be in the result
expect(result).toContain("Edit"); expect(result).toContain("Edit");
@@ -716,11 +773,15 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("LS"); expect(result).toContain("LS");
expect(result).toContain("Read"); expect(result).toContain("Read");
expect(result).toContain("Write"); expect(result).toContain("Write");
expect(result).toContain("mcp__github_file_ops__update_claude_comment");
expect(result).not.toContain("mcp__github__update_issue_comment"); // Should have specific Bash git commands for non-signing mode
expect(result).not.toContain("mcp__github__update_pull_request_comment"); expect(result).toContain("Bash(git add:*)");
expect(result).toContain("mcp__github_file_ops__commit_files"); expect(result).toContain("Bash(git commit:*)");
expect(result).toContain("mcp__github_file_ops__delete_files"); expect(result).toContain("mcp__github_comment__update_claude_comment");
// Should not have commit signing tools
expect(result).not.toContain("mcp__github_file_ops__commit_files");
expect(result).not.toContain("mcp__github_file_ops__delete_files");
}); });
test("should append custom tools when provided", () => { test("should append custom tools when provided", () => {
@@ -773,6 +834,79 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("mcp__github_ci__get_workflow_run_details"); expect(result).toContain("mcp__github_ci__get_workflow_run_details");
expect(result).toContain("mcp__github_ci__download_job_log"); expect(result).toContain("mcp__github_ci__download_job_log");
}); });
test("should include commit signing tools when useCommitSigning is true", () => {
const result = buildAllowedToolsString([], false, true);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Glob");
expect(result).toContain("Grep");
expect(result).toContain("LS");
expect(result).toContain("Read");
expect(result).toContain("Write");
// Commit signing tools should be included
expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files");
// Comment tool should always be from github_comment server
expect(result).toContain("mcp__github_comment__update_claude_comment");
// Bash should NOT be included when using commit signing (except in comment tool name)
expect(result).not.toContain("Bash(");
});
test("should include specific Bash git commands when useCommitSigning is false", () => {
const result = buildAllowedToolsString([], false, false);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Glob");
expect(result).toContain("Grep");
expect(result).toContain("LS");
expect(result).toContain("Read");
expect(result).toContain("Write");
// Specific Bash git commands should be included
expect(result).toContain("Bash(git add:*)");
expect(result).toContain("Bash(git commit:*)");
expect(result).toContain("Bash(git push:*)");
expect(result).toContain("Bash(git status:*)");
expect(result).toContain("Bash(git diff:*)");
expect(result).toContain("Bash(git log:*)");
expect(result).toContain("Bash(git rm:*)");
expect(result).toContain("Bash(git config user.name:*)");
expect(result).toContain("Bash(git config user.email:*)");
// Comment tool from minimal server should be included
expect(result).toContain("mcp__github_comment__update_claude_comment");
// Commit signing tools should NOT be included
expect(result).not.toContain("mcp__github_file_ops__commit_files");
expect(result).not.toContain("mcp__github_file_ops__delete_files");
});
test("should handle all combinations of options", () => {
const customTools = ["CustomTool1", "CustomTool2"];
const result = buildAllowedToolsString(customTools, true, false);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Bash(git add:*)");
// Custom tools should be included
expect(result).toContain("CustomTool1");
expect(result).toContain("CustomTool2");
// GitHub Actions tools should be included
expect(result).toContain("mcp__github_ci__get_ci_status");
// Comment tool from minimal server should be included
expect(result).toContain("mcp__github_comment__update_claude_comment");
// Commit signing tools should NOT be included
expect(result).not.toContain("mcp__github_file_ops__commit_files");
});
}); });
describe("buildDisallowedToolsString", () => { describe("buildDisallowedToolsString", () => {

View File

@@ -34,6 +34,7 @@ describe("prepareMcpConfig", () => {
branchPrefix: "", branchPrefix: "",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false,
}, },
}; };
@@ -44,6 +45,22 @@ describe("prepareMcpConfig", () => {
entityNumber: 456, entityNumber: 456,
}; };
const mockContextWithSigning: ParsedGitHubContext = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
const mockPRContextWithSigning: ParsedGitHubContext = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
useCommitSigning: true,
},
};
beforeEach(() => { beforeEach(() => {
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {}); consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
@@ -65,7 +82,7 @@ describe("prepareMcpConfig", () => {
processExitSpy.mockRestore(); processExitSpy.mockRestore();
}); });
test("should return base config when no additional config is provided and no allowed_tools", async () => { test("should return comment server when commit signing is disabled", async () => {
const result = await prepareMcpConfig({ const result = await prepareMcpConfig({
githubToken: "test-token", githubToken: "test-token",
owner: "test-owner", owner: "test-owner",
@@ -78,6 +95,37 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined(); expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined(); expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
});
test("should return file ops server when commit signing is enabled", async () => {
const contextWithSigning = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined(); expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe( expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
"test-token", "test-token",
@@ -105,13 +153,22 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined(); expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).toBeDefined(); expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined(); expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe( expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
"test-token", "test-token",
); );
}); });
test("should not include github MCP server when only file_ops tools are allowed", async () => { test("should not include github MCP server when only file_ops tools are allowed", async () => {
const contextWithSigning = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
const result = await prepareMcpConfig({ const result = await prepareMcpConfig({
githubToken: "test-token", githubToken: "test-token",
owner: "test-owner", owner: "test-owner",
@@ -121,7 +178,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, context: contextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -130,7 +187,7 @@ describe("prepareMcpConfig", () => {
expect(parsed.mcpServers.github_file_ops).toBeDefined(); expect(parsed.mcpServers.github_file_ops).toBeDefined();
}); });
test("should include file_ops server even when no GitHub tools are allowed", async () => { test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
const result = await prepareMcpConfig({ const result = await prepareMcpConfig({
githubToken: "test-token", githubToken: "test-token",
owner: "test-owner", owner: "test-owner",
@@ -143,7 +200,8 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined(); expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined(); expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined(); expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
}); });
test("should return base config when additional config is empty string", async () => { test("should return base config when additional config is empty string", async () => {
@@ -160,7 +218,7 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined(); expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined(); expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined(); expect(parsed.mcpServers.github_comment).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled(); expect(consoleWarningSpy).not.toHaveBeenCalled();
}); });
@@ -178,7 +236,7 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined(); expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined(); expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined(); expect(parsed.mcpServers.github_comment).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled(); expect(consoleWarningSpy).not.toHaveBeenCalled();
}); });
@@ -205,7 +263,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, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -243,7 +301,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, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -281,7 +339,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: additionalConfig, additionalMcpConfig: additionalConfig,
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -301,7 +359,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: invalidJson, additionalMcpConfig: invalidJson,
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -322,7 +380,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: nonObjectJson, additionalMcpConfig: nonObjectJson,
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -346,7 +404,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: nullJson, additionalMcpConfig: nullJson,
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -370,7 +428,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: arrayJson, additionalMcpConfig: arrayJson,
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -417,7 +475,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: additionalConfig, additionalMcpConfig: additionalConfig,
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -439,7 +497,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -460,7 +518,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -478,6 +536,7 @@ describe("prepareMcpConfig", () => {
inputs: { inputs: {
...mockPRContext.inputs, ...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]), additionalPermissions: new Map([["actions", "read"]]),
useCommitSigning: true,
}, },
}; };
@@ -506,7 +565,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext, context: mockContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -524,7 +583,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockPRContext, context: mockPRContextWithSigning,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);

View File

@@ -22,6 +22,7 @@ const defaultInputs = {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map<string, string>(), additionalPermissions: new Map<string, string>(),
useCommitSigning: false,
}; };
const defaultRepository = { const defaultRepository = {

View File

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

View File

@@ -35,7 +35,7 @@ describe("parseEnvVarsWithContext", () => {
process.env = { process.env = {
...BASE_ENV, ...BASE_ENV,
BASE_BRANCH: "main", BASE_BRANCH: "main",
CLAUDE_BRANCH: "claude/issue-67890-20240101_120000", CLAUDE_BRANCH: "claude/issue-67890-20240101-1200",
}; };
}); });
@@ -44,7 +44,7 @@ describe("parseEnvVarsWithContext", () => {
mockIssueCommentContext, mockIssueCommentContext,
"12345", "12345",
"main", "main",
"claude/issue-67890-20240101_120000", "claude/issue-67890-20240101-1200",
); );
expect(result.repository).toBe("test-owner/test-repo"); expect(result.repository).toBe("test-owner/test-repo");
@@ -60,7 +60,7 @@ describe("parseEnvVarsWithContext", () => {
expect(result.eventData.issueNumber).toBe("55"); expect(result.eventData.issueNumber).toBe("55");
expect(result.eventData.commentId).toBe("12345678"); expect(result.eventData.commentId).toBe("12345678");
expect(result.eventData.claudeBranch).toBe( expect(result.eventData.claudeBranch).toBe(
"claude/issue-67890-20240101_120000", "claude/issue-67890-20240101-1200",
); );
expect(result.eventData.baseBranch).toBe("main"); expect(result.eventData.baseBranch).toBe("main");
expect(result.eventData.commentBody).toBe( expect(result.eventData.commentBody).toBe(
@@ -81,7 +81,7 @@ describe("parseEnvVarsWithContext", () => {
mockIssueCommentContext, mockIssueCommentContext,
"12345", "12345",
undefined, undefined,
"claude/issue-67890-20240101_120000", "claude/issue-67890-20240101-1200",
), ),
).toThrow("BASE_BRANCH is required for issue_comment event"); ).toThrow("BASE_BRANCH is required for issue_comment event");
}); });
@@ -152,7 +152,7 @@ describe("parseEnvVarsWithContext", () => {
process.env = { process.env = {
...BASE_ENV, ...BASE_ENV,
BASE_BRANCH: "main", BASE_BRANCH: "main",
CLAUDE_BRANCH: "claude/issue-42-20240101_120000", CLAUDE_BRANCH: "claude/issue-42-20240101-1200",
}; };
}); });
@@ -161,7 +161,7 @@ describe("parseEnvVarsWithContext", () => {
mockIssueOpenedContext, mockIssueOpenedContext,
"12345", "12345",
"main", "main",
"claude/issue-42-20240101_120000", "claude/issue-42-20240101-1200",
); );
expect(result.eventData.eventName).toBe("issues"); expect(result.eventData.eventName).toBe("issues");
@@ -174,7 +174,7 @@ describe("parseEnvVarsWithContext", () => {
expect(result.eventData.issueNumber).toBe("42"); expect(result.eventData.issueNumber).toBe("42");
expect(result.eventData.baseBranch).toBe("main"); expect(result.eventData.baseBranch).toBe("main");
expect(result.eventData.claudeBranch).toBe( expect(result.eventData.claudeBranch).toBe(
"claude/issue-42-20240101_120000", "claude/issue-42-20240101-1200",
); );
} }
}); });
@@ -184,7 +184,7 @@ describe("parseEnvVarsWithContext", () => {
mockIssueAssignedContext, mockIssueAssignedContext,
"12345", "12345",
"main", "main",
"claude/issue-123-20240101_120000", "claude/issue-123-20240101-1200",
); );
expect(result.eventData.eventName).toBe("issues"); expect(result.eventData.eventName).toBe("issues");
@@ -197,7 +197,7 @@ describe("parseEnvVarsWithContext", () => {
expect(result.eventData.issueNumber).toBe("123"); expect(result.eventData.issueNumber).toBe("123");
expect(result.eventData.baseBranch).toBe("main"); expect(result.eventData.baseBranch).toBe("main");
expect(result.eventData.claudeBranch).toBe( expect(result.eventData.claudeBranch).toBe(
"claude/issue-123-20240101_120000", "claude/issue-123-20240101-1200",
); );
expect(result.eventData.assigneeTrigger).toBe("@claude-bot"); expect(result.eventData.assigneeTrigger).toBe("@claude-bot");
} }
@@ -215,7 +215,7 @@ describe("parseEnvVarsWithContext", () => {
mockIssueOpenedContext, mockIssueOpenedContext,
"12345", "12345",
undefined, undefined,
"claude/issue-42-20240101_120000", "claude/issue-42-20240101-1200",
), ),
).toThrow("BASE_BRANCH is required for issues event"); ).toThrow("BASE_BRANCH is required for issues event");
}); });
@@ -234,7 +234,7 @@ describe("parseEnvVarsWithContext", () => {
contextWithDirectPrompt, contextWithDirectPrompt,
"12345", "12345",
"main", "main",
"claude/issue-123-20240101_120000", "claude/issue-123-20240101-1200",
); );
expect(result.eventData.eventName).toBe("issues"); expect(result.eventData.eventName).toBe("issues");
@@ -264,7 +264,7 @@ describe("parseEnvVarsWithContext", () => {
contextWithoutTriggers, contextWithoutTriggers,
"12345", "12345",
"main", "main",
"claude/issue-123-20240101_120000", "claude/issue-123-20240101-1200",
), ),
).toThrow("ASSIGNEE_TRIGGER is required for issue assigned event"); ).toThrow("ASSIGNEE_TRIGGER is required for issue assigned event");
}); });

View File

@@ -38,6 +38,7 @@ describe("checkContainsTrigger", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false,
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -68,6 +69,7 @@ describe("checkContainsTrigger", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false,
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);
@@ -282,6 +284,7 @@ describe("checkContainsTrigger", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false,
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -313,6 +316,7 @@ describe("checkContainsTrigger", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false,
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -344,6 +348,7 @@ describe("checkContainsTrigger", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(), additionalPermissions: new Map(),
useCommitSigning: false,
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);