Compare commits

..

29 Commits

Author SHA1 Message Date
GitHub Actions
b10f287695 chore: update claude-code-base-action to v0.0.18 2025-06-11 23:01:51 +00:00
GitHub Actions
56d8eac7ce chore: update claude-code-base-action to v0.0.17 2025-06-11 22:03:34 +00:00
Ashwin Bhat
25f9b8ef9e fix: add baseUrl to Octokit initialization in update_claude_comment (#157)
* fix: add baseUrl to Octokit initialization in update_claude_comment

Fixes Bad credentials error on GitHub Enterprise Server by passing
GITHUB_API_URL as baseUrl when initializing Octokit, consistent with
other Octokit instances in the codebase.

Fixes #156
Related to #107

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* fix: pass GITHUB_API_URL as env var to MCP server

Update the MCP server initialization to pass GITHUB_API_URL as an
environment variable, allowing it to work correctly with GitHub
Enterprise Server instances.

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* fix: import GITHUB_API_URL from config in install-mcp-server

Use the centralized GITHUB_API_URL constant from src/github/api/config.ts instead of reading directly from process.env when passing environment variables to the MCP server. This ensures consistency with how the API URL is handled throughout the codebase.

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* fix

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-11 17:45:05 -04:00
Ashwin Bhat
3bcfbe7385 feat: add MultiEdit to base_allowed_tools (#155)
Add MultiEdit tool to the BASE_ALLOWED_TOOLS array to enable Claude Code to use the MultiEdit tool for making multiple edits to a single file in one operation.

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-10 19:36:52 -04:00
GitHub Actions
bdd0c925cb chore: update claude-code-base-action to v0.0.14 2025-06-10 19:08:55 +00:00
atsushi-ishibashi
37ec8e4781 fix: set disallowed_tools as env when runing prepare.ts (#151) 2025-06-10 08:59:55 -04:00
Ashwin Bhat
e5b1633249 feat: add roadmap for Claude Code GitHub Action v1.0 (#150)
Add ROADMAP.md documenting planned features and improvements for reaching v1.0:
- GitHub Action CI results visibility
- Cross-repo support
- Workflow file modification capabilities
- Additional event trigger support
- Configurable commit signing
- Enhanced code review features
- Bot user trigger support
- Customizable base prompts

The roadmap provides transparency on development priorities and invites
community feedback and contributions.
2025-06-09 18:58:08 -04:00
Ashwin Bhat
37483ba112 feat: add max_turns parameter support (#149)
* feat: add max_turns parameter support

- Add max_turns input to action.yml with proper description
- Pass max_turns parameter through to claude-code-base-action
- Update README with documentation and examples for max_turns usage
- Add comprehensive tests to verify max_turns configuration
- Add yaml dependency for test parsing

Closes #148

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* chore: remove max-turns test and yaml dependency

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* chore: revert package.json and bun.lock changes

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* Update action.yml

* prettier

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-09 13:28:22 -04:00
Sepehr Sobhani
9b50f473cb Update allowed tools align with what is available in github-mcp-server (#145) 2025-06-08 16:24:25 -04:00
GitHub Actions
47ea5c2a69 chore: update claude-code-base-action to v0.0.13 2025-06-06 19:44:49 +00:00
GitHub Actions
4bd9c2053a chore: update claude-code-base-action to v0.0.12 2025-06-06 15:30:07 +00:00
GitHub Actions
f862b5a16a chore: update claude-code-base-action to v0.0.11 2025-06-05 23:19:03 +00:00
Ashwin Bhat
424d1b8f87 update package name (#135) 2025-06-05 13:41:23 -07:00
David Dworken
1d5e695d0c Update package name to reference under the @Anthropic-AI NPM org (#134) 2025-06-05 13:28:36 -07:00
Ashwin Bhat
8e8be41f15 fix: replace github.action_path with GITHUB_ACTION_PATH for containerized workflows (#133)
This change fixes an issue where the action fails in containerized workflow 
jobs. By using ${GITHUB_ACTION_PATH} instead of ${{ github.action_path }}, 
the action can properly locate its resources in container environments.

Fixes #132

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-05 10:10:43 -07:00
Ashwin Bhat
c7957fda5d chore: update claude-code-base-action to v0.0.10 (#131) 2025-06-05 09:59:42 -07:00
Minsu Lee
1990b0bdb3 Update temp directory paths to use runner temp directory (#129)
* Update temp directory paths to use `runner` temp directory

* Update temp directory paths to use `runner` temp directory
2025-06-05 09:53:56 -07:00
Ashwin Bhat
699aa26b41 fix: only load GitHub MCP server when its tools are allowed (#124)
* fix: only load GitHub MCP server when its tools are allowed

- Add allowedTools parameter to prepareMcpConfig
- Check for mcp__github__ and mcp__github_file_ops__ tool prefixes
- Only include MCP servers when their tools are in allowed_tools
- Maintain backward compatibility when allowed_tools is not specified
- Update tests to reflect the new conditional loading behavior

This optimizes resource usage by not loading unnecessary MCP servers
when their tools are not allowed in the configuration.

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* fix: always load github_file_ops server regardless of allowed_tools

- Only apply conditional loading to the github MCP server
- Always load github_file_ops server as it contains essential tools
- Update tests to reflect this behavior

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* refactor: move allowedTools/disallowedTools parsing to parseGitHubContext

- Change allowedTools and disallowedTools from string to string[] in ParsedGitHubContext type
- Parse comma-separated environment variables into arrays in parseGitHubContext function
- Update create-prompt and install-mcp-server to use pre-parsed arrays
- Update all affected test files to use array syntax
- Eliminate duplicate parsing logic across the codebase

* style: apply prettier formatting

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-04 11:56:56 -07:00
Ashwin Bhat
94c0c31c1b chore: switch to upstream github-mcp-server v0.4.0 (#126)
* chore: switch to upstream github-mcp-server v0.4.0

Switch from anthropics fork to github/github-mcp-server at commit e9f748f
(version 0.4.0) as requested.

Release link: https://github.com/github/github-mcp-server/releases/tag/v0.4.0

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* fix comment

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-04 11:42:57 -07:00
Robb Walters
be799cbe7b fix: skip SHA computation for deleted files (#115) 2025-06-03 13:40:52 -07:00
Ashwin Bhat
bd71ac0e8f fix: wrap github MCP config with mcpServers in issue-triage workflow (#118)
Update the MCP configuration structure to properly wrap the github
server configuration within an mcpServers object, matching the
expected format for MCP config files.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-03 11:31:04 -07:00
Ashwin Bhat
65b9bcde80 chore: update claude-code-base-action to v0.0.9 (#116) 2025-06-02 21:42:23 -07:00
Ashwin Bhat
70245e56e3 feat: add claude_env input for custom environment variables (#102)
* feat: add claude_env input for custom environment variables

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* docs: add claude_env input documentation with clear syntax examples

Added comprehensive documentation for the new claude_env input including:
- Entry in the Inputs table with description
- Example in the basic workflow configuration
- Detailed section in Advanced Configuration with practical use cases

This makes it clear how users can pass custom environment variables
to Claude Code execution in YAML format for CI/test setups.

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-02 20:52:34 -07:00
Ashwin Bhat
1d4d6c4b93 feat: add unified update_claude_comment tool (#98)
* feat: add unified update_claude_comment tool

- Add new update_claude_comment tool that automatically handles both issue and PR comments
- Remove individual update_issue_comment and update_pull_request_comment tools
- Pass CLAUDE_COMMENT_ID, GITHUB_EVENT_NAME, and IS_PR to MCP server environment
- Simplify Claude's comment update workflow by removing need for owner/repo/commentId params
- Update prompts and tests to use the new unified tool

* feat: add unified update_claude_comment tool

- Add new update_claude_comment tool that automatically handles both issue and PR comments
- Remove individual update_issue_comment and update_pull_request_comment tools
- Pass CLAUDE_COMMENT_ID, GITHUB_EVENT_NAME, and IS_PR to MCP server environment
- Use Octokit instead of raw fetch for better type safety and error handling
- Simplify Claude's comment update workflow by removing need for owner/repo/commentId params
- Update prompts and tests to use the new unified tool

* refactor: extract update_claude_comment logic to standalone testable function

- Create new updateClaudeComment function in operations/comments
- Add comprehensive unit tests following image-downloader pattern
- Update MCP server to use extracted function
- Refactor update-comment-link.ts and update-with-branch.ts to eliminate duplication
- All tests passing (10 new tests for update-claude-comment)

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* prettier

* tsc

* clean up comments

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-02 12:15:25 -07:00
Ashwin Bhat
e409c57d90 feat: add mcp_config input that merges with existing mcp server (#96)
* feat: add mcp_config input that merges with existing mcp server

- Add mcp_config input parameter to action.yml 
- Modify prepareMcpConfig() to accept and merge additional config
- Provided config overrides built-in servers in case of naming collisions
- Pass MCP_CONFIG environment variable from action to prepare step

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* refactor: improve MCP config validation and merging logic

- Add JSON validation to ensure parsed config is an object
- Simplify merge logic with explicit mcpServers merging
- Enhance error logging with config preview for debugging

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* refactor: improve MCP config logging per review feedback

- Remove configPreview from error logging to avoid cluttering output
- Add informational log when merging MCP server configurations
- Simplify error message for failed config parsing

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* test: add comprehensive unit tests for prepareMcpConfig

Add tests covering:
- Basic functionality with no additional config
- Valid JSON merging scenarios
- Invalid JSON handling
- Empty/null config handling
- Server name collision scenarios
- Complex nested configurations
- Environment variable handling

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* docs: add mcp_config example with sequential-thinking server

- Add mcp_config to inputs table
- Add example section showing how to use mcp_config with sequential-thinking MCP server
- Include clear explanation that custom servers override built-in servers

Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>

* readme

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: ashwin-ant <ashwin-ant@users.noreply.github.com>
2025-06-02 09:03:45 -07:00
YutaSaito
f6e5597633 fix: specify baseUrl in Octokit (#107) (#108) 2025-06-01 21:07:44 -07:00
Andrew Munsell
0cd44e50dd fix: Update condition for final message in output (#106)
The last message in the Claude Code output does not have a role, but instead has a field "type" set to "result". With the current condition, the duration is never appended to the header of the comments.
2025-06-01 18:33:02 -07:00
Ashwin Bhat
a8a36ced96 fix mistake in FAQ (#100) 2025-05-30 12:33:15 -07:00
Ashwin Bhat
180a1b6680 switch to opus for this repo's claude workflow (#97)
* switch to opus for this repo's claude workflow

* prettier
2025-05-30 08:14:11 -07:00
26 changed files with 1327 additions and 207 deletions

View File

@@ -29,4 +29,4 @@ jobs:
Be constructive and specific in your feedback. Give inline comments where applicable. Be constructive and specific in your feedback. Give inline comments where applicable.
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
allowed_tools: "mcp__github__add_pull_request_review_comment" allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"

View File

@@ -23,18 +23,20 @@ jobs:
mkdir -p /tmp/mcp-config mkdir -p /tmp/mcp-config
cat > /tmp/mcp-config/mcp-servers.json << 'EOF' cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
{ {
"github": { "mcpServers": {
"command": "docker", "github": {
"args": [ "command": "docker",
"run", "args": [
"-i", "run",
"--rm", "-i",
"-e", "--rm",
"GITHUB_PERSONAL_ACCESS_TOKEN", "-e",
"ghcr.io/github/github-mcp-server:sha-7aced2b" "GITHUB_PERSONAL_ACCESS_TOKEN",
], "ghcr.io/github/github-mcp-server:sha-7aced2b"
"env": { ],
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}" "env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
}
} }
} }
} }

2
FAQ.md
View File

@@ -6,7 +6,7 @@ This FAQ addresses common questions and gotchas when using the Claude Code GitHu
### Why doesn't tagging @claude from my automated workflow work? ### Why doesn't tagging @claude from my automated workflow work?
The `github-actions` user (and other GitHub Apps/bots) cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow. The `github-actions` user cannot trigger subsequent GitHub Actions workflows. This is a GitHub security feature to prevent infinite loops. To make this work, you need to use a Personal Access Token (PAT) instead, which will act as a regular user, or use a separate app token of your own. When posting a comment on an issue or PR from your workflow, use your PAT instead of the `GITHUB_TOKEN` generated in your workflow.
### Why does Claude say I don't have permission to trigger it? ### Why does Claude say I don't have permission to trigger it?

View File

@@ -65,6 +65,13 @@ jobs:
# trigger_phrase: "/claude" # trigger_phrase: "/claude"
# Optional: add assignee trigger for issues # Optional: add assignee trigger for issues
# assignee_trigger: "claude" # assignee_trigger: "claude"
# Optional: add custom environment variables (YAML format)
# claude_env: |
# NODE_ENV: test
# DEBUG: true
# API_URL: https://api.example.com
# Optional: limit the number of conversation turns
# max_turns: "5"
``` ```
## Inputs ## Inputs
@@ -73,6 +80,7 @@ jobs:
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | | --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
| `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 | - | | `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 | - |
| `timeout_minutes` | Timeout in minutes for execution | No | `30` | | `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 | - | | `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - | | `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
@@ -82,13 +90,70 @@ jobs:
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" | | `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
| `disallowed_tools` | Tools that Claude should never use | No | "" | | `disallowed_tools` | Tools that Claude should never use | No | "" |
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | | `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | | `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` | | `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 | "" |
\*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)
> **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration. > **Note**: This action is currently in beta. Features and APIs may change as we continue to improve the integration.
### Using Custom MCP Configuration
The `mcp_config` input allows you to add custom MCP (Model Context Protocol) servers to extend Claude's capabilities. These servers merge with the built-in GitHub MCP servers.
#### Basic Example: Adding a Sequential Thinking Server
```yaml
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
mcp_config: |
{
"mcpServers": {
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}
allowed_tools: "mcp__sequential-thinking__sequentialthinking" # Important: Each MCP tool from your server must be listed here, comma-separated
# ... other inputs
```
#### Passing Secrets to MCP Servers
For MCP servers that require sensitive information like API keys or tokens, use GitHub Secrets in the environment variables:
```yaml
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
mcp_config: |
{
"mcpServers": {
"custom-api-server": {
"command": "npx",
"args": ["-y", "@example/api-server"],
"env": {
"API_KEY": "${{ secrets.CUSTOM_API_KEY }}",
"BASE_URL": "https://api.example.com"
}
}
}
}
# ... other inputs
```
**Important**:
- Always use GitHub Secrets (`${{ secrets.SECRET_NAME }}`) for sensitive values like API keys, tokens, or passwords. Never hardcode secrets directly in the workflow file.
- Your custom servers will override any built-in servers with the same name.
## Examples ## Examples
### Ways to Tag @claude ### Ways to Tag @claude
@@ -233,6 +298,40 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
## Advanced Configuration ## Advanced Configuration
### Custom Environment Variables
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
```yaml
- uses: anthropics/claude-code-action@beta
with:
claude_env: |
NODE_ENV: test
CI: true
DATABASE_URL: postgres://test:test@localhost:5432/test_db
# ... other inputs
```
The `claude_env` input accepts YAML format where each line defines a key-value pair. These environment variables will be available to Claude Code during execution, allowing it to run tests, build processes, or other commands that depend on specific environment configurations.
### Limiting Conversation Turns
You can use the `max_turns` parameter to limit the number of back-and-forth exchanges Claude can have during task execution. This is useful for:
- Controlling costs by preventing runaway conversations
- Setting time boundaries for automated workflows
- Ensuring predictable behavior in CI/CD pipelines
```yaml
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
max_turns: "5" # Limit to 5 conversation turns
# ... other inputs
```
When the turn limit is reached, Claude will stop execution gracefully. Choose a value that gives Claude enough turns to complete typical tasks while preventing excessive usage.
### Custom Tools ### Custom Tools
By default, Claude only has access to: By default, Claude only has access to:

20
ROADMAP.md Normal file
View File

@@ -0,0 +1,20 @@
# Claude Code GitHub Action Roadmap
Thank you for trying out the beta of our GitHub Action! This document outlines our path to `v1.0`. Items are not necessarily in priority order.
## Path to 1.0
- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
---
**Note:** This roadmap represents our current vision for reaching `v1.0` and is subject to change based on user feedback and development priorities.
We welcome feedback on these planned features! If you're interested in contributing to any of these features, please open an issue to discuss implementation details with us. We're also open to suggestions for new features not listed here.

View File

@@ -39,6 +39,12 @@ inputs:
description: "Direct instruction for Claude (bypasses normal trigger detection)" description: "Direct instruction for Claude (bypasses normal trigger detection)"
required: false required: false
default: "" default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false
default: ""
# Auth configuration # Auth configuration
anthropic_api_key: anthropic_api_key:
@@ -56,6 +62,10 @@ inputs:
required: false required: false
default: "false" default: "false"
max_turns:
description: "Maximum number of conversation turns"
required: false
default: ""
timeout_minutes: timeout_minutes:
description: "Timeout in minutes for execution" description: "Timeout in minutes for execution"
required: false required: false
@@ -77,38 +87,42 @@ runs:
- name: Install Dependencies - name: Install Dependencies
shell: bash shell: bash
run: | run: |
cd ${{ github.action_path }} cd ${GITHUB_ACTION_PATH}
bun install bun install
- name: Prepare action - name: Prepare action
id: prepare id: prepare
shell: bash shell: bash
run: | run: |
bun run ${{ github.action_path }}/src/entrypoints/prepare.ts bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
env: env:
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }} BASE_BRANCH: ${{ inputs.base_branch }}
ALLOWED_TOOLS: ${{ inputs.allowed_tools }} ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }} CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
DIRECT_PROMPT: ${{ inputs.direct_prompt }} DIRECT_PROMPT: ${{ inputs.direct_prompt }}
MCP_CONFIG: ${{ inputs.mcp_config }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
- 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@c8e31bd52d9a149b3f8309d7978c6edaa282688d # v0.0.8 uses: anthropics/claude-code-base-action@3933d9c3c25f2b027392a370be6f0bbd5989b271 # v0.0.18
with: with:
prompt_file: /tmp/claude-prompts/claude-prompt.txt prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
allowed_tools: ${{ env.ALLOWED_TOOLS }} allowed_tools: ${{ env.ALLOWED_TOOLS }}
disallowed_tools: ${{ env.DISALLOWED_TOOLS }} disallowed_tools: ${{ env.DISALLOWED_TOOLS }}
timeout_minutes: ${{ inputs.timeout_minutes }} timeout_minutes: ${{ inputs.timeout_minutes }}
max_turns: ${{ inputs.max_turns }}
model: ${{ inputs.model || inputs.anthropic_model }} model: ${{ inputs.model || inputs.anthropic_model }}
mcp_config: ${{ steps.prepare.outputs.mcp_config }} mcp_config: ${{ steps.prepare.outputs.mcp_config }}
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_env: ${{ inputs.claude_env }}
env: env:
# Model configuration # Model configuration
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }} ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
@@ -139,7 +153,7 @@ runs:
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
shell: bash shell: bash
run: | run: |
bun run ${{ github.action_path }}/src/entrypoints/update-comment-link.ts bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
env: env:
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}

View File

@@ -2,7 +2,7 @@
"lockfileVersion": 1, "lockfileVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "claude-pr-action", "name": "@anthropic-ai/claude-code-action",
"dependencies": { "dependencies": {
"@actions/core": "^1.10.1", "@actions/core": "^1.10.1",
"@actions/github": "^6.0.1", "@actions/github": "^6.0.1",

View File

@@ -35,4 +35,4 @@ jobs:
Provide constructive feedback with specific suggestions for improvement. Provide constructive feedback with specific suggestions for improvement.
Use inline comments to highlight specific areas of concern. Use inline comments to highlight specific areas of concern.
# allowed_tools: "mcp__github__add_pull_request_review_comment" # allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"

View File

@@ -1,5 +1,5 @@
{ {
"name": "claude-pr-action", "name": "@anthropic-ai/claude-code-action",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -24,6 +24,7 @@ export type { CommonFields, PreparedContext } from "./types";
const BASE_ALLOWED_TOOLS = [ const BASE_ALLOWED_TOOLS = [
"Edit", "Edit",
"MultiEdit",
"Glob", "Glob",
"Grep", "Grep",
"LS", "LS",
@@ -31,53 +32,39 @@ const BASE_ALLOWED_TOOLS = [
"Write", "Write",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
"mcp__github_file_ops__delete_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[]): string {
eventData: EventData,
customAllowedTools?: string,
): string {
let baseTools = [...BASE_ALLOWED_TOOLS]; let baseTools = [...BASE_ALLOWED_TOOLS];
// Add the appropriate comment tool based on event type
if (eventData.eventName === "pull_request_review_comment") {
// For inline PR review comments, only use PR comment tool
baseTools.push("mcp__github__update_pull_request_comment");
} else {
// For all other events (issue comments, PR reviews, issues), use issue comment tool
baseTools.push("mcp__github__update_issue_comment");
}
let allAllowedTools = baseTools.join(","); let allAllowedTools = baseTools.join(",");
if (customAllowedTools) { if (customAllowedTools && customAllowedTools.length > 0) {
allAllowedTools = `${allAllowedTools},${customAllowedTools}`; allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
} }
return allAllowedTools; return allAllowedTools;
} }
export function buildDisallowedToolsString( export function buildDisallowedToolsString(
customDisallowedTools?: string, customDisallowedTools?: string[],
allowedTools?: string, allowedTools?: string[],
): string { ): string {
let disallowedTools = [...DISALLOWED_TOOLS]; let disallowedTools = [...DISALLOWED_TOOLS];
// If user has explicitly allowed some hardcoded disallowed tools, remove them from disallowed list // If user has explicitly allowed some hardcoded disallowed tools, remove them from disallowed list
if (allowedTools) { if (allowedTools && allowedTools.length > 0) {
const allowedToolsArray = allowedTools
.split(",")
.map((tool) => tool.trim());
disallowedTools = disallowedTools.filter( disallowedTools = disallowedTools.filter(
(tool) => !allowedToolsArray.includes(tool), (tool) => !allowedTools.includes(tool),
); );
} }
let allDisallowedTools = disallowedTools.join(","); let allDisallowedTools = disallowedTools.join(",");
if (customDisallowedTools) { if (customDisallowedTools && customDisallowedTools.length > 0) {
if (allDisallowedTools) { if (allDisallowedTools) {
allDisallowedTools = `${allDisallowedTools},${customDisallowedTools}`; allDisallowedTools = `${allDisallowedTools},${customDisallowedTools.join(",")}`;
} else { } else {
allDisallowedTools = customDisallowedTools; allDisallowedTools = customDisallowedTools.join(",");
} }
} }
return allDisallowedTools; return allDisallowedTools;
@@ -131,8 +118,10 @@ export function prepareContext(
triggerPhrase, triggerPhrase,
...(triggerUsername && { triggerUsername }), ...(triggerUsername && { triggerUsername }),
...(customInstructions && { customInstructions }), ...(customInstructions && { customInstructions }),
...(allowedTools && { allowedTools }), ...(allowedTools.length > 0 && { allowedTools: allowedTools.join(",") }),
...(disallowedTools && { disallowedTools }), ...(disallowedTools.length > 0 && {
disallowedTools: disallowedTools.join(","),
}),
...(directPrompt && { directPrompt }), ...(directPrompt && { directPrompt }),
...(claudeBranch && { claudeBranch }), ...(claudeBranch && { claudeBranch }),
}; };
@@ -447,33 +436,15 @@ ${sanitizeContent(context.directPrompt)}
</direct_prompt>` </direct_prompt>`
: "" : ""
} }
${ ${`<comment_tool_info>
eventData.eventName === "pull_request_review_comment" 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.
? `<comment_tool_info>
IMPORTANT: For this inline PR review comment, you have been provided with ONLY the mcp__github__update_pull_request_comment tool to update this specific review comment.
Tool usage example for mcp__github__update_pull_request_comment: Tool usage example for mcp__github_file_ops__update_claude_comment:
{ {
"owner": "${context.repository.split("/")[0]}",
"repo": "${context.repository.split("/")[1]}",
"commentId": ${eventData.commentId || context.claudeCommentId},
"body": "Your comment text here" "body": "Your comment text here"
} }
All four parameters (owner, repo, commentId, body) are required. Only the body parameter is required - the tool automatically knows which comment to update.
</comment_tool_info>` </comment_tool_info>`}
: `<comment_tool_info>
IMPORTANT: For this event type, you have been provided with ONLY the mcp__github__update_issue_comment tool to update comments.
Tool usage example for mcp__github__update_issue_comment:
{
"owner": "${context.repository.split("/")[0]}",
"repo": "${context.repository.split("/")[1]}",
"commentId": ${context.claudeCommentId},
"body": "Your comment text here"
}
All four parameters (owner, repo, commentId, body) are required.
</comment_tool_info>`
}
Your task is to analyze the context, understand the request, and provide helpful responses and/or implement code changes as needed. Your task is to analyze the context, understand the request, and provide helpful responses and/or implement code changes as needed.
@@ -487,7 +458,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 ${eventData.eventName === "pull_request_review_comment" ? "mcp__github__update_pull_request_comment" : "mcp__github__update_issue_comment"} with each task completion. - Update the comment using mcp__github_file_ops__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.
@@ -517,11 +488,11 @@ ${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__update_issue_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_file_ops__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. This will be displayed as your PR review." : "Remember that this feedback must be posted to the GitHub comment."} - ${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."}
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.
@@ -576,8 +547,8 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
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 ${eventData.eventName === "pull_request_review_comment" ? "mcp__github__update_pull_request_comment" : "mcp__github__update_issue_comment"} with comment_id: ${context.claudeCommentId}. - Never create new comments. Only update the existing comment using mcp__github_file_ops__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__update_issue_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_file_ops__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.`}
@@ -650,7 +621,9 @@ export async function createPrompt(
claudeBranch, claudeBranch,
); );
await mkdir("/tmp/claude-prompts", { recursive: true }); await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
recursive: true,
});
// Generate the prompt // Generate the prompt
const promptContent = generatePrompt(preparedContext, githubData); const promptContent = generatePrompt(preparedContext, githubData);
@@ -661,16 +634,18 @@ export async function createPrompt(
console.log("======================="); console.log("=======================");
// Write the prompt file // Write the prompt file
await writeFile("/tmp/claude-prompts/claude-prompt.txt", promptContent); await writeFile(
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
promptContent,
);
// Set allowed tools // Set allowed tools
const allAllowedTools = buildAllowedToolsString( const allAllowedTools = buildAllowedToolsString(
preparedContext.eventData, context.inputs.allowedTools,
preparedContext.allowedTools,
); );
const allDisallowedTools = buildDisallowedToolsString( const allDisallowedTools = buildDisallowedToolsString(
preparedContext.disallowedTools, context.inputs.disallowedTools,
preparedContext.allowedTools, context.inputs.allowedTools,
); );
core.exportVariable("ALLOWED_TOOLS", allAllowedTools); core.exportVariable("ALLOWED_TOOLS", allAllowedTools);

View File

@@ -84,12 +84,16 @@ async function run() {
); );
// Step 11: Get MCP configuration // Step 11: Get MCP configuration
const mcpConfig = await prepareMcpConfig( const additionalMcpConfig = process.env.MCP_CONFIG || "";
const mcpConfig = await prepareMcpConfig({
githubToken, githubToken,
context.repository.owner, owner: context.repository.owner,
context.repository.repo, repo: context.repository.repo,
branchInfo.currentBranch, branch: branchInfo.currentBranch,
); additionalMcpConfig,
claudeCommentId: commentId.toString(),
allowedTools: context.inputs.allowedTools,
});
core.setOutput("mcp_config", mcpConfig); core.setOutput("mcp_config", mcpConfig);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);

View File

@@ -12,6 +12,7 @@ import {
} 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 { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
async function run() { async function run() {
try { try {
@@ -166,7 +167,7 @@ async function run() {
if (Array.isArray(outputData) && outputData.length > 0) { if (Array.isArray(outputData) && outputData.length > 0) {
const lastElement = outputData[outputData.length - 1]; const lastElement = outputData[outputData.length - 1];
if ( if (
lastElement.role === "system" && lastElement.type === "result" &&
"cost_usd" in lastElement && "cost_usd" in lastElement &&
"duration_ms" in lastElement "duration_ms" in lastElement
) { ) {
@@ -204,23 +205,14 @@ async function run() {
const updatedBody = updateCommentBody(commentInput); const updatedBody = updateCommentBody(commentInput);
// Update the comment using the appropriate API
try { try {
if (isPRReviewComment) { await updateClaudeComment(octokit.rest, {
await octokit.rest.pulls.updateReviewComment({ owner,
owner, repo,
repo, commentId,
comment_id: commentId, body: updatedBody,
body: updatedBody, isPullRequestReviewComment: isPRReviewComment,
}); });
} else {
await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body: updatedBody,
});
}
console.log( console.log(
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`, `✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
); );

View File

@@ -9,7 +9,10 @@ export type Octokits = {
export function createOctokit(token: string): Octokits { export function createOctokit(token: string): Octokits {
return { return {
rest: new Octokit({ auth: token }), rest: new Octokit({
auth: token,
baseUrl: GITHUB_API_URL,
}),
graphql: graphql.defaults({ graphql: graphql.defaults({
baseUrl: GITHUB_API_URL, baseUrl: GITHUB_API_URL,
headers: { headers: {

View File

@@ -28,8 +28,8 @@ export type ParsedGitHubContext = {
inputs: { inputs: {
triggerPhrase: string; triggerPhrase: string;
assigneeTrigger: string; assigneeTrigger: string;
allowedTools: string; allowedTools: string[];
disallowedTools: string; disallowedTools: string[];
customInstructions: string; customInstructions: string;
directPrompt: string; directPrompt: string;
baseBranch?: string; baseBranch?: string;
@@ -52,8 +52,14 @@ export function parseGitHubContext(): ParsedGitHubContext {
inputs: { inputs: {
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude", triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "", assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
allowedTools: process.env.ALLOWED_TOOLS ?? "", allowedTools: (process.env.ALLOWED_TOOLS ?? "")
disallowedTools: process.env.DISALLOWED_TOOLS ?? "", .split(",")
.map((tool) => tool.trim())
.filter((tool) => tool.length > 0),
disallowedTools: (process.env.DISALLOWED_TOOLS ?? "")
.split(",")
.map((tool) => tool.trim())
.filter((tool) => tool.length > 0),
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "", customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
directPrompt: process.env.DIRECT_PROMPT ?? "", directPrompt: process.env.DIRECT_PROMPT ?? "",
baseBranch: process.env.BASE_BRANCH, baseBranch: process.env.BASE_BRANCH,

View File

@@ -1,17 +1,17 @@
import { execSync } from "child_process"; import { execSync } from "child_process";
import type { Octokits } from "../api/client";
import { ISSUE_QUERY, PR_QUERY } from "../api/queries/github";
import type { import type {
GitHubPullRequest,
GitHubIssue,
GitHubComment, GitHubComment,
GitHubFile, GitHubFile,
GitHubIssue,
GitHubPullRequest,
GitHubReview, GitHubReview,
PullRequestQueryResponse,
IssueQueryResponse, IssueQueryResponse,
PullRequestQueryResponse,
} from "../types"; } from "../types";
import { PR_QUERY, ISSUE_QUERY } from "../api/queries/github";
import type { Octokits } from "../api/client";
import { downloadCommentImages } from "../utils/image-downloader";
import type { CommentWithImages } from "../utils/image-downloader"; import type { CommentWithImages } from "../utils/image-downloader";
import { downloadCommentImages } from "../utils/image-downloader";
type FetchDataParams = { type FetchDataParams = {
octokits: Octokits; octokits: Octokits;
@@ -101,6 +101,14 @@ export async function fetchGitHubData({
let changedFilesWithSHA: GitHubFileWithSHA[] = []; let changedFilesWithSHA: GitHubFileWithSHA[] = [];
if (isPR && changedFiles.length > 0) { if (isPR && changedFiles.length > 0) {
changedFilesWithSHA = changedFiles.map((file) => { changedFilesWithSHA = changedFiles.map((file) => {
// Don't compute SHA for deleted files
if (file.changeType === "DELETED") {
return {
...file,
sha: "deleted",
};
}
try { try {
// Use git hash-object to compute the SHA for the current file content // Use git hash-object to compute the SHA for the current file content
const sha = execSync(`git hash-object "${file.path}"`, { const sha = execSync(`git hash-object "${file.path}"`, {

View File

@@ -0,0 +1,70 @@
import { Octokit } from "@octokit/rest";
export type UpdateClaudeCommentParams = {
owner: string;
repo: string;
commentId: number;
body: string;
isPullRequestReviewComment: boolean;
};
export type UpdateClaudeCommentResult = {
id: number;
html_url: string;
updated_at: string;
};
/**
* Updates a Claude comment on GitHub (either an issue/PR comment or a PR review comment)
*
* @param octokit - Authenticated Octokit instance
* @param params - Parameters for updating the comment
* @returns The updated comment details
* @throws Error if the update fails
*/
export async function updateClaudeComment(
octokit: Octokit,
params: UpdateClaudeCommentParams,
): Promise<UpdateClaudeCommentResult> {
const { owner, repo, commentId, body, isPullRequestReviewComment } = params;
let response;
try {
if (isPullRequestReviewComment) {
// Try PR review comment API first
response = await octokit.rest.pulls.updateReviewComment({
owner,
repo,
comment_id: commentId,
body,
});
} else {
// Use issue comment API (works for both issues and PR general comments)
response = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
}
} catch (error: any) {
// If PR review comment update fails with 404, fall back to issue comment API
if (isPullRequestReviewComment && error.status === 404) {
response = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
} else {
throw error;
}
}
return {
id: response.data.id,
html_url: response.data.html_url,
updated_at: response.data.updated_at,
};
}

View File

@@ -15,6 +15,7 @@ import {
isPullRequestReviewCommentEvent, isPullRequestReviewCommentEvent,
type ParsedGitHubContext, type ParsedGitHubContext,
} from "../../context"; } from "../../context";
import { updateClaudeComment } from "./update-claude-comment";
export async function updateTrackingComment( export async function updateTrackingComment(
octokit: Octokits, octokit: Octokits,
@@ -36,25 +37,19 @@ export async function updateTrackingComment(
// Update the existing comment with the branch link // Update the existing comment with the branch link
try { try {
if (isPullRequestReviewCommentEvent(context)) { const isPRReviewComment = isPullRequestReviewCommentEvent(context);
// For PR review comments (inline comments), use the pulls API
await octokit.rest.pulls.updateReviewComment({ await updateClaudeComment(octokit.rest, {
owner, owner,
repo, repo,
comment_id: commentId, commentId,
body: updatedBody, body: updatedBody,
}); isPullRequestReviewComment: isPRReviewComment,
console.log(`✅ Updated PR review comment ${commentId} with branch link`); });
} else {
// For all other comments, use the issues API console.log(
await octokit.rest.issues.updateComment({ `✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with branch link`,
owner, );
repo,
comment_id: commentId,
body: updatedBody,
});
console.log(`✅ Updated issue comment ${commentId} with branch link`);
}
} catch (error) { } catch (error) {
console.error("Error updating comment with branch link:", error); console.error("Error updating comment with branch link:", error);
throw error; throw error;

View File

@@ -7,6 +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";
type GitHubRef = { type GitHubRef = {
object: { object: {
@@ -439,6 +441,70 @@ 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

@@ -1,28 +1,37 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { GITHUB_API_URL } from "../github/api/config";
type PrepareConfigParams = {
githubToken: string;
owner: string;
repo: string;
branch: string;
additionalMcpConfig?: string;
claudeCommentId?: string;
allowedTools: string[];
};
export async function prepareMcpConfig( export async function prepareMcpConfig(
githubToken: string, params: PrepareConfigParams,
owner: string,
repo: string,
branch: string,
): Promise<string> { ): Promise<string> {
const {
githubToken,
owner,
repo,
branch,
additionalMcpConfig,
claudeCommentId,
allowedTools,
} = params;
try { try {
const mcpConfig = { const allowedToolsList = allowedTools || [];
const hasGitHubMcpTools = allowedToolsList.some((tool) =>
tool.startsWith("mcp__github__"),
);
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
mcpServers: { mcpServers: {
github: {
command: "docker",
args: [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/anthropics/github-mcp-server:sha-7382253",
],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
},
},
github_file_ops: { github_file_ops: {
command: "bun", command: "bun",
args: [ args: [
@@ -35,12 +44,65 @@ export async function prepareMcpConfig(
REPO_NAME: repo, REPO_NAME: repo,
BRANCH_NAME: branch, BRANCH_NAME: branch,
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(), REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
IS_PR: process.env.IS_PR || "false",
GITHUB_API_URL: GITHUB_API_URL,
}, },
}, },
}, },
}; };
return JSON.stringify(mcpConfig, null, 2); if (hasGitHubMcpTools) {
baseMcpConfig.mcpServers.github = {
command: "docker",
args: [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-e9f748f", // https://github.com/github/github-mcp-server/releases/tag/v0.4.0
],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
},
};
}
// Merge with additional MCP config if provided
if (additionalMcpConfig && additionalMcpConfig.trim()) {
try {
const additionalConfig = JSON.parse(additionalMcpConfig);
// Validate that parsed JSON is an object
if (typeof additionalConfig !== "object" || additionalConfig === null) {
throw new Error("MCP config must be a valid JSON object");
}
core.info(
"Merging additional MCP server configuration with built-in servers",
);
// Merge configurations with user config overriding built-in servers
const mergedConfig = {
...baseMcpConfig,
...additionalConfig,
mcpServers: {
...baseMcpConfig.mcpServers,
...additionalConfig.mcpServers,
},
};
return JSON.stringify(mergedConfig, null, 2);
} catch (parseError) {
core.warning(
`Failed to parse additional MCP config: ${parseError}. Using base config only.`,
);
}
}
return JSON.stringify(baseMcpConfig, null, 2);
} catch (error) { } catch (error) {
core.setFailed(`Install MCP server failed with error: ${error}`); core.setFailed(`Install MCP server failed with error: ${error}`);
process.exit(1); process.exit(1);

View File

@@ -8,7 +8,6 @@ import {
buildDisallowedToolsString, buildDisallowedToolsString,
} from "../src/create-prompt"; } from "../src/create-prompt";
import type { PreparedContext } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt";
import type { EventData } from "../src/create-prompt/types";
describe("generatePrompt", () => { describe("generatePrompt", () => {
const mockGitHubData = { const mockGitHubData = {
@@ -619,15 +618,7 @@ describe("getEventTypeAndContext", () => {
describe("buildAllowedToolsString", () => { describe("buildAllowedToolsString", () => {
test("should return issue comment tool for regular events", () => { test("should return issue comment tool for regular events", () => {
const mockEventData: EventData = { const result = buildAllowedToolsString();
eventName: "issue_comment",
commentId: "123",
isPR: true,
prNumber: "456",
commentBody: "Test comment",
};
const result = buildAllowedToolsString(mockEventData);
// The base tools should be in the result // The base tools should be in the result
expect(result).toContain("Edit"); expect(result).toContain("Edit");
@@ -636,22 +627,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__update_issue_comment"); expect(result).toContain("mcp__github_file_ops__update_claude_comment");
expect(result).not.toContain("mcp__github__update_issue_comment");
expect(result).not.toContain("mcp__github__update_pull_request_comment"); expect(result).not.toContain("mcp__github__update_pull_request_comment");
expect(result).toContain("mcp__github_file_ops__commit_files"); expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files"); expect(result).toContain("mcp__github_file_ops__delete_files");
}); });
test("should return PR comment tool for inline review comments", () => { test("should return PR comment tool for inline review comments", () => {
const mockEventData: EventData = { const result = buildAllowedToolsString();
eventName: "pull_request_review_comment",
isPR: true,
prNumber: "456",
commentBody: "Test review comment",
commentId: "789",
};
const result = buildAllowedToolsString(mockEventData);
// The base tools should be in the result // The base tools should be in the result
expect(result).toContain("Edit"); expect(result).toContain("Edit");
@@ -660,23 +644,16 @@ 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"); expect(result).not.toContain("mcp__github__update_issue_comment");
expect(result).toContain("mcp__github__update_pull_request_comment"); expect(result).not.toContain("mcp__github__update_pull_request_comment");
expect(result).toContain("mcp__github_file_ops__commit_files"); expect(result).toContain("mcp__github_file_ops__commit_files");
expect(result).toContain("mcp__github_file_ops__delete_files"); expect(result).toContain("mcp__github_file_ops__delete_files");
}); });
test("should append custom tools when provided", () => { test("should append custom tools when provided", () => {
const mockEventData: EventData = { const customTools = ["Tool1", "Tool2", "Tool3"];
eventName: "issue_comment", const result = buildAllowedToolsString(customTools);
commentId: "123",
isPR: true,
prNumber: "456",
commentBody: "Test comment",
};
const customTools = "Tool1,Tool2,Tool3";
const result = buildAllowedToolsString(mockEventData, customTools);
// Base tools should be present // Base tools should be present
expect(result).toContain("Edit"); expect(result).toContain("Edit");
@@ -706,7 +683,7 @@ describe("buildDisallowedToolsString", () => {
}); });
test("should append custom disallowed tools when provided", () => { test("should append custom disallowed tools when provided", () => {
const customDisallowedTools = "BadTool1,BadTool2"; const customDisallowedTools = ["BadTool1", "BadTool2"];
const result = buildDisallowedToolsString(customDisallowedTools); const result = buildDisallowedToolsString(customDisallowedTools);
// Base disallowed tools should be present // Base disallowed tools should be present
@@ -724,8 +701,8 @@ describe("buildDisallowedToolsString", () => {
}); });
test("should remove hardcoded disallowed tools if they are in allowed tools", () => { test("should remove hardcoded disallowed tools if they are in allowed tools", () => {
const customDisallowedTools = "BadTool1,BadTool2"; const customDisallowedTools = ["BadTool1", "BadTool2"];
const allowedTools = "WebSearch,SomeOtherTool"; const allowedTools = ["WebSearch", "SomeOtherTool"];
const result = buildDisallowedToolsString( const result = buildDisallowedToolsString(
customDisallowedTools, customDisallowedTools,
allowedTools, allowedTools,
@@ -743,7 +720,7 @@ describe("buildDisallowedToolsString", () => {
}); });
test("should remove all hardcoded disallowed tools if they are all in allowed tools", () => { test("should remove all hardcoded disallowed tools if they are all in allowed tools", () => {
const allowedTools = "WebSearch,WebFetch,SomeOtherTool"; const allowedTools = ["WebSearch", "WebFetch", "SomeOtherTool"];
const result = buildDisallowedToolsString(undefined, allowedTools); const result = buildDisallowedToolsString(undefined, allowedTools);
// Both hardcoded disallowed tools should be removed // Both hardcoded disallowed tools should be removed
@@ -755,8 +732,8 @@ describe("buildDisallowedToolsString", () => {
}); });
test("should handle custom disallowed tools when all hardcoded tools are overridden", () => { test("should handle custom disallowed tools when all hardcoded tools are overridden", () => {
const customDisallowedTools = "BadTool1,BadTool2"; const customDisallowedTools = ["BadTool1", "BadTool2"];
const allowedTools = "WebSearch,WebFetch"; const allowedTools = ["WebSearch", "WebFetch"];
const result = buildDisallowedToolsString( const result = buildDisallowedToolsString(
customDisallowedTools, customDisallowedTools,
allowedTools, allowedTools,

View File

@@ -0,0 +1,414 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
import * as core from "@actions/core";
describe("prepareMcpConfig", () => {
let consoleInfoSpy: any;
let consoleWarningSpy: any;
let setFailedSpy: any;
let processExitSpy: any;
beforeEach(() => {
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
setFailedSpy = spyOn(core, "setFailed").mockImplementation(() => {});
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process exit");
});
});
afterEach(() => {
consoleInfoSpy.mockRestore();
consoleWarningSpy.mockRestore();
setFailedSpy.mockRestore();
processExitSpy.mockRestore();
});
test("should return base config when no additional config is provided and no allowed_tools", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_file_ops.env.REPO_OWNER).toBe("test-owner");
expect(parsed.mcpServers.github_file_ops.env.REPO_NAME).toBe("test-repo");
expect(parsed.mcpServers.github_file_ops.env.BRANCH_NAME).toBe(
"test-branch",
);
});
test("should include github MCP server when mcp__github__ tools are allowed", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
"test-token",
);
});
test("should not include github MCP server when only file_ops tools are allowed", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__update_claude_comment",
],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should include file_ops server even when no GitHub tools are allowed", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: ["Edit", "Read", "Write"],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should return base config when additional config is empty string", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: "",
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled();
});
test("should return base config when additional config is whitespace only", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: " \n\t ",
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled();
});
test("should merge valid additional config with base config", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
custom_server: {
command: "custom-command",
args: ["arg1", "arg2"],
env: {
CUSTOM_ENV: "custom-value",
},
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: additionalConfig,
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
});
const parsed = JSON.parse(result);
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.custom_server).toBeDefined();
expect(parsed.mcpServers.custom_server.command).toBe("custom-command");
expect(parsed.mcpServers.custom_server.args).toEqual(["arg1", "arg2"]);
expect(parsed.mcpServers.custom_server.env.CUSTOM_ENV).toBe("custom-value");
});
test("should override built-in servers when additional config has same server names", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
github: {
command: "overridden-command",
args: ["overridden-arg"],
env: {
OVERRIDDEN_ENV: "overridden-value",
},
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: additionalConfig,
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
});
const parsed = JSON.parse(result);
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github.command).toBe("overridden-command");
expect(parsed.mcpServers.github.args).toEqual(["overridden-arg"]);
expect(parsed.mcpServers.github.env.OVERRIDDEN_ENV).toBe(
"overridden-value",
);
expect(
parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN,
).toBeUndefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should merge additional root-level properties", async () => {
const additionalConfig = JSON.stringify({
customProperty: "custom-value",
anotherProperty: {
nested: "value",
},
mcpServers: {
custom_server: {
command: "custom",
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: additionalConfig,
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.customProperty).toBe("custom-value");
expect(parsed.anotherProperty).toEqual({ nested: "value" });
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.custom_server).toBeDefined();
});
test("should handle invalid JSON gracefully", async () => {
const invalidJson = "{ invalid json }";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: invalidJson,
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle non-object JSON values", async () => {
const nonObjectJson = JSON.stringify("string value");
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: nonObjectJson,
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP config must be a valid JSON object"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle null JSON value", async () => {
const nullJson = JSON.stringify(null);
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: nullJson,
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP config must be a valid JSON object"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle array JSON value", async () => {
const arrayJson = JSON.stringify([1, 2, 3]);
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: arrayJson,
allowedTools: [],
});
const parsed = JSON.parse(result);
// Arrays are objects in JavaScript, so they pass the object check
// But they'll fail when trying to spread or access mcpServers property
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
// The array will be spread into the config (0: 1, 1: 2, 2: 3)
expect(parsed[0]).toBe(1);
expect(parsed[1]).toBe(2);
expect(parsed[2]).toBe(3);
});
test("should merge complex nested configurations", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
server1: {
command: "cmd1",
env: { KEY1: "value1" },
},
server2: {
command: "cmd2",
env: { KEY2: "value2" },
},
github_file_ops: {
command: "overridden",
env: { CUSTOM: "value" },
},
},
otherConfig: {
nested: {
deeply: "value",
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
additionalMcpConfig: additionalConfig,
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.server1).toBeDefined();
expect(parsed.mcpServers.server2).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops.command).toBe("overridden");
expect(parsed.mcpServers.github_file_ops.env.CUSTOM).toBe("value");
expect(parsed.otherConfig.nested.deeply).toBe("value");
});
test("should preserve GITHUB_ACTION_PATH in file_ops server args", async () => {
const oldEnv = process.env.GITHUB_ACTION_PATH;
process.env.GITHUB_ACTION_PATH = "/test/action/path";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_file_ops.args[1]).toBe(
"/test/action/path/src/mcp/github-file-ops-server.ts",
);
process.env.GITHUB_ACTION_PATH = oldEnv;
});
test("should use process.cwd() when GITHUB_WORKSPACE is not set", async () => {
const oldEnv = process.env.GITHUB_WORKSPACE;
delete process.env.GITHUB_WORKSPACE;
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd());
process.env.GITHUB_WORKSPACE = oldEnv;
});
});

View File

@@ -11,8 +11,8 @@ const defaultInputs = {
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
anthropicModel: "claude-3-7-sonnet-20250219", anthropicModel: "claude-3-7-sonnet-20250219",
allowedTools: "", allowedTools: [] as string[],
disallowedTools: "", disallowedTools: [] as string[],
customInstructions: "", customInstructions: "",
directPrompt: "", directPrompt: "",
useBedrock: false, useBedrock: false,

View File

@@ -62,8 +62,8 @@ describe("checkWritePermissions", () => {
inputs: { inputs: {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
allowedTools: "", allowedTools: [],
disallowedTools: "", disallowedTools: [],
customInstructions: "", customInstructions: "",
directPrompt: "", directPrompt: "",
}, },

View File

@@ -242,7 +242,7 @@ describe("parseEnvVarsWithContext", () => {
...mockPullRequestCommentContext, ...mockPullRequestCommentContext,
inputs: { inputs: {
...mockPullRequestCommentContext.inputs, ...mockPullRequestCommentContext.inputs,
allowedTools: "Tool1,Tool2", allowedTools: ["Tool1", "Tool2"],
}, },
}); });
const result = prepareContext(contextWithAllowedTools, "12345"); const result = prepareContext(contextWithAllowedTools, "12345");

View File

@@ -30,8 +30,8 @@ describe("checkContainsTrigger", () => {
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
directPrompt: "Fix the bug in the login form", directPrompt: "Fix the bug in the login form",
allowedTools: "", allowedTools: [],
disallowedTools: "", disallowedTools: [],
customInstructions: "", customInstructions: "",
}, },
}); });
@@ -56,8 +56,8 @@ describe("checkContainsTrigger", () => {
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: "", allowedTools: [],
disallowedTools: "", disallowedTools: [],
customInstructions: "", customInstructions: "",
}, },
}); });
@@ -228,8 +228,8 @@ describe("checkContainsTrigger", () => {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: "", allowedTools: [],
disallowedTools: "", disallowedTools: [],
customInstructions: "", customInstructions: "",
}, },
}); });
@@ -255,8 +255,8 @@ describe("checkContainsTrigger", () => {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: "", allowedTools: [],
disallowedTools: "", disallowedTools: [],
customInstructions: "", customInstructions: "",
}, },
}); });
@@ -282,8 +282,8 @@ describe("checkContainsTrigger", () => {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: "", allowedTools: [],
disallowedTools: "", disallowedTools: [],
customInstructions: "", customInstructions: "",
}, },
}); });

View File

@@ -0,0 +1,413 @@
import { describe, test, expect, jest, beforeEach } from "bun:test";
import { Octokit } from "@octokit/rest";
import {
updateClaudeComment,
type UpdateClaudeCommentParams,
} from "../src/github/operations/comments/update-claude-comment";
describe("updateClaudeComment", () => {
let mockOctokit: Octokit;
beforeEach(() => {
mockOctokit = {
rest: {
issues: {
updateComment: jest.fn(),
},
pulls: {
updateReviewComment: jest.fn(),
},
},
} as any as Octokit;
});
test("should update issue comment successfully", async () => {
const mockResponse = {
data: {
id: 123456,
html_url: "https://github.com/owner/repo/issues/1#issuecomment-123456",
updated_at: "2024-01-01T00:00:00Z",
body: "Updated comment",
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 123456,
body: "Updated comment",
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 123456,
body: "Updated comment",
});
expect(result).toEqual({
id: 123456,
html_url: "https://github.com/owner/repo/issues/1#issuecomment-123456",
updated_at: "2024-01-01T00:00:00Z",
});
});
test("should update PR comment successfully", async () => {
const mockResponse = {
data: {
id: 789012,
html_url: "https://github.com/owner/repo/pull/2#issuecomment-789012",
updated_at: "2024-01-02T00:00:00Z",
body: "Updated PR comment",
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 789012,
body: "Updated PR comment",
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 789012,
body: "Updated PR comment",
});
expect(result).toEqual({
id: 789012,
html_url: "https://github.com/owner/repo/pull/2#issuecomment-789012",
updated_at: "2024-01-02T00:00:00Z",
});
});
test("should update PR review comment successfully", async () => {
const mockResponse = {
data: {
id: 345678,
html_url: "https://github.com/owner/repo/pull/3#discussion_r345678",
updated_at: "2024-01-03T00:00:00Z",
body: "Updated review comment",
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 345678,
body: "Updated review comment",
isPullRequestReviewComment: true,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 345678,
body: "Updated review comment",
});
expect(result).toEqual({
id: 345678,
html_url: "https://github.com/owner/repo/pull/3#discussion_r345678",
updated_at: "2024-01-03T00:00:00Z",
});
});
test("should fallback to issue comment API when PR review comment update fails with 404", async () => {
const mockError = new Error("Not Found") as any;
mockError.status = 404;
const mockResponse = {
data: {
id: 456789,
html_url: "https://github.com/owner/repo/pull/4#issuecomment-456789",
updated_at: "2024-01-04T00:00:00Z",
body: "Updated via fallback",
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockRejectedValue(mockError);
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 456789,
body: "Updated via fallback",
isPullRequestReviewComment: true,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 456789,
body: "Updated via fallback",
});
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 456789,
body: "Updated via fallback",
});
expect(result).toEqual({
id: 456789,
html_url: "https://github.com/owner/repo/pull/4#issuecomment-456789",
updated_at: "2024-01-04T00:00:00Z",
});
});
test("should propagate error when PR review comment update fails with non-404 error", async () => {
const mockError = new Error("Internal Server Error") as any;
mockError.status = 500;
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockRejectedValue(mockError);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 567890,
body: "This will fail",
isPullRequestReviewComment: true,
};
await expect(updateClaudeComment(mockOctokit, params)).rejects.toEqual(
mockError,
);
expect(mockOctokit.rest.pulls.updateReviewComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 567890,
body: "This will fail",
});
// Ensure fallback wasn't attempted
expect(mockOctokit.rest.issues.updateComment).not.toHaveBeenCalled();
});
test("should propagate error when issue comment update fails", async () => {
const mockError = new Error("Forbidden");
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockRejectedValue(mockError);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 678901,
body: "This will also fail",
isPullRequestReviewComment: false,
};
await expect(updateClaudeComment(mockOctokit, params)).rejects.toEqual(
mockError,
);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 678901,
body: "This will also fail",
});
});
test("should handle empty body", async () => {
const mockResponse = {
data: {
id: 111222,
html_url: "https://github.com/owner/repo/issues/5#issuecomment-111222",
updated_at: "2024-01-05T00:00:00Z",
body: "",
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 111222,
body: "",
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(result).toEqual({
id: 111222,
html_url: "https://github.com/owner/repo/issues/5#issuecomment-111222",
updated_at: "2024-01-05T00:00:00Z",
});
});
test("should handle very long body", async () => {
const longBody = "x".repeat(10000);
const mockResponse = {
data: {
id: 333444,
html_url: "https://github.com/owner/repo/issues/6#issuecomment-333444",
updated_at: "2024-01-06T00:00:00Z",
body: longBody,
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 333444,
body: longBody,
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 333444,
body: longBody,
});
expect(result).toEqual({
id: 333444,
html_url: "https://github.com/owner/repo/issues/6#issuecomment-333444",
updated_at: "2024-01-06T00:00:00Z",
});
});
test("should handle markdown formatting in body", async () => {
const markdownBody = `
# Header
- List item 1
- List item 2
\`\`\`typescript
const code = "example";
\`\`\`
[Link](https://example.com)
`.trim();
const mockResponse = {
data: {
id: 555666,
html_url: "https://github.com/owner/repo/issues/7#issuecomment-555666",
updated_at: "2024-01-07T00:00:00Z",
body: markdownBody,
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.issues.updateComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 555666,
body: markdownBody,
isPullRequestReviewComment: false,
};
const result = await updateClaudeComment(mockOctokit, params);
expect(mockOctokit.rest.issues.updateComment).toHaveBeenCalledWith({
owner: "testowner",
repo: "testrepo",
comment_id: 555666,
body: markdownBody,
});
expect(result).toEqual({
id: 555666,
html_url: "https://github.com/owner/repo/issues/7#issuecomment-555666",
updated_at: "2024-01-07T00:00:00Z",
});
});
test("should handle different response data fields", async () => {
const mockResponse = {
data: {
id: 777888,
html_url: "https://github.com/owner/repo/pull/8#discussion_r777888",
updated_at: "2024-01-08T12:30:45Z",
body: "Updated",
// Additional fields that might be in the response
created_at: "2024-01-01T00:00:00Z",
user: { login: "bot" },
node_id: "MDI0OlB1bGxSZXF1ZXN0UmV2aWV3Q29tbWVudDc3Nzg4OA==",
},
};
// @ts-expect-error Mock implementation doesn't match full type signature
mockOctokit.rest.pulls.updateReviewComment = jest
.fn()
.mockResolvedValue(mockResponse);
const params: UpdateClaudeCommentParams = {
owner: "testowner",
repo: "testrepo",
commentId: 777888,
body: "Updated",
isPullRequestReviewComment: true,
};
const result = await updateClaudeComment(mockOctokit, params);
// Should only return the specific fields we care about
expect(result).toEqual({
id: 777888,
html_url: "https://github.com/owner/repo/pull/8#discussion_r777888",
updated_at: "2024-01-08T12:30:45Z",
});
});
});