mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
31 Commits
testbranch
...
v0.0.19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41dd0aa695 | ||
|
|
55966a1dc0 | ||
|
|
b10f287695 | ||
|
|
56d8eac7ce | ||
|
|
25f9b8ef9e | ||
|
|
3bcfbe7385 | ||
|
|
bdd0c925cb | ||
|
|
37ec8e4781 | ||
|
|
e5b1633249 | ||
|
|
37483ba112 | ||
|
|
9b50f473cb | ||
|
|
47ea5c2a69 | ||
|
|
4bd9c2053a | ||
|
|
f862b5a16a | ||
|
|
424d1b8f87 | ||
|
|
1d5e695d0c | ||
|
|
8e8be41f15 | ||
|
|
c7957fda5d | ||
|
|
1990b0bdb3 | ||
|
|
699aa26b41 | ||
|
|
94c0c31c1b | ||
|
|
be799cbe7b | ||
|
|
bd71ac0e8f | ||
|
|
65b9bcde80 | ||
|
|
70245e56e3 | ||
|
|
1d4d6c4b93 | ||
|
|
e409c57d90 | ||
|
|
f6e5597633 | ||
|
|
0cd44e50dd | ||
|
|
a8a36ced96 | ||
|
|
180a1b6680 |
2
.github/workflows/claude-review.yml
vendored
2
.github/workflows/claude-review.yml
vendored
@@ -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"
|
||||||
|
|||||||
1
.github/workflows/claude.yml
vendored
1
.github/workflows/claude.yml
vendored
@@ -36,3 +36,4 @@ jobs:
|
|||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||||
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
||||||
|
model: "claude-opus-4-20250514"
|
||||||
|
|||||||
26
.github/workflows/issue-triage.yml
vendored
26
.github/workflows/issue-triage.yml
vendored
@@ -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
2
FAQ.md
@@ -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?
|
||||||
|
|
||||||
|
|||||||
99
README.md
99
README.md
@@ -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
20
ROADMAP.md
Normal 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.
|
||||||
24
action.yml
24
action.yml
@@ -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@ebd8558e902b3db132e89863de49565fcb9aec46 # v0.0.19
|
||||||
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 }}
|
||||||
|
|||||||
2
bun.lock
2
bun.lock
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -35,38 +36,35 @@ const BASE_ALLOWED_TOOLS = [
|
|||||||
];
|
];
|
||||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||||
|
|
||||||
export function buildAllowedToolsString(customAllowedTools?: string): string {
|
export function buildAllowedToolsString(customAllowedTools?: string[]): string {
|
||||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||||
|
|
||||||
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;
|
||||||
@@ -120,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 }),
|
||||||
};
|
};
|
||||||
@@ -418,6 +418,7 @@ ${
|
|||||||
}
|
}
|
||||||
<claude_comment_id>${context.claudeCommentId}</claude_comment_id>
|
<claude_comment_id>${context.claudeCommentId}</claude_comment_id>
|
||||||
<trigger_username>${context.triggerUsername ?? "Unknown"}</trigger_username>
|
<trigger_username>${context.triggerUsername ?? "Unknown"}</trigger_username>
|
||||||
|
<trigger_display_name>${githubData.triggerDisplayName ?? context.triggerUsername ?? "Unknown"}</trigger_display_name>
|
||||||
<trigger_phrase>${context.triggerPhrase}</trigger_phrase>
|
<trigger_phrase>${context.triggerPhrase}</trigger_phrase>
|
||||||
${
|
${
|
||||||
(eventData.eventName === "issue_comment" ||
|
(eventData.eventName === "issue_comment" ||
|
||||||
@@ -503,12 +504,14 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
|||||||
? `
|
? `
|
||||||
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
|
- 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).
|
- 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 TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>" line in the commit message.`
|
- 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.
|
- 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)
|
- 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).
|
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||||
- When pushing changes and TRIGGER_USERNAME is not "Unknown", include a "Co-authored-by: ${context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>" line in the commit message.
|
- 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:
|
||||||
@@ -621,7 +624,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);
|
||||||
@@ -632,15 +637,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.allowedTools,
|
context.inputs.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);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ async function run() {
|
|||||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||||
prNumber: context.entityNumber.toString(),
|
prNumber: context.entityNumber.toString(),
|
||||||
isPR: context.isPR,
|
isPR: context.isPR,
|
||||||
|
triggerUsername: context.actor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 8: Setup branch
|
// Step 8: Setup branch
|
||||||
@@ -84,13 +85,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,
|
||||||
commentId.toString(),
|
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);
|
||||||
|
|||||||
@@ -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`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -104,3 +104,11 @@ export const ISSUE_QUERY = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const USER_QUERY = `
|
||||||
|
query($login: String!) {
|
||||||
|
user(login: $login) {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
import type { Octokits } from "../api/client";
|
||||||
|
import { ISSUE_QUERY, PR_QUERY, USER_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;
|
||||||
repository: string;
|
repository: string;
|
||||||
prNumber: string;
|
prNumber: string;
|
||||||
isPR: boolean;
|
isPR: boolean;
|
||||||
|
triggerUsername?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubFileWithSHA = GitHubFile & {
|
export type GitHubFileWithSHA = GitHubFile & {
|
||||||
@@ -31,6 +32,7 @@ export type FetchDataResult = {
|
|||||||
changedFilesWithSHA: GitHubFileWithSHA[];
|
changedFilesWithSHA: GitHubFileWithSHA[];
|
||||||
reviewData: { nodes: GitHubReview[] } | null;
|
reviewData: { nodes: GitHubReview[] } | null;
|
||||||
imageUrlMap: Map<string, string>;
|
imageUrlMap: Map<string, string>;
|
||||||
|
triggerDisplayName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchGitHubData({
|
export async function fetchGitHubData({
|
||||||
@@ -38,6 +40,7 @@ export async function fetchGitHubData({
|
|||||||
repository,
|
repository,
|
||||||
prNumber,
|
prNumber,
|
||||||
isPR,
|
isPR,
|
||||||
|
triggerUsername,
|
||||||
}: FetchDataParams): Promise<FetchDataResult> {
|
}: FetchDataParams): Promise<FetchDataResult> {
|
||||||
const [owner, repo] = repository.split("/");
|
const [owner, repo] = repository.split("/");
|
||||||
if (!owner || !repo) {
|
if (!owner || !repo) {
|
||||||
@@ -101,6 +104,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}"`, {
|
||||||
@@ -183,6 +194,12 @@ export async function fetchGitHubData({
|
|||||||
allComments,
|
allComments,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch trigger user display name if username is provided
|
||||||
|
let triggerDisplayName: string | null | undefined;
|
||||||
|
if (triggerUsername) {
|
||||||
|
triggerDisplayName = await fetchUserDisplayName(octokits, triggerUsername);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contextData,
|
contextData,
|
||||||
comments,
|
comments,
|
||||||
@@ -190,5 +207,27 @@ export async function fetchGitHubData({
|
|||||||
changedFilesWithSHA,
|
changedFilesWithSHA,
|
||||||
reviewData,
|
reviewData,
|
||||||
imageUrlMap,
|
imageUrlMap,
|
||||||
|
triggerDisplayName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserQueryResponse = {
|
||||||
|
user: {
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchUserDisplayName(
|
||||||
|
octokits: Octokits,
|
||||||
|
login: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await octokits.graphql<UserQueryResponse>(USER_QUERY, {
|
||||||
|
login,
|
||||||
|
});
|
||||||
|
return result.user.name;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to fetch user display name for ${login}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
70
src/github/operations/comments/update-claude-comment.ts
Normal file
70
src/github/operations/comments/update-claude-comment.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Types for GitHub GraphQL query responses
|
// Types for GitHub GraphQL query responses
|
||||||
export type GitHubAuthor = {
|
export type GitHubAuthor = {
|
||||||
login: string;
|
login: string;
|
||||||
|
name?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GitHubComment = {
|
export type GitHubComment = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 { Octokit } from "@octokit/rest";
|
||||||
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -440,7 +441,6 @@ server.tool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update Claude comment tool
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"update_claude_comment",
|
"update_claude_comment",
|
||||||
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
||||||
@@ -464,62 +464,27 @@ server.tool(
|
|||||||
const repo = REPO_NAME;
|
const repo = REPO_NAME;
|
||||||
const commentId = parseInt(claudeCommentId, 10);
|
const commentId = parseInt(claudeCommentId, 10);
|
||||||
|
|
||||||
// Create Octokit instance
|
|
||||||
const octokit = new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: githubToken,
|
auth: githubToken,
|
||||||
|
baseUrl: GITHUB_API_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Determine if this is a PR review comment based on event type
|
|
||||||
const isPullRequestReviewComment =
|
const isPullRequestReviewComment =
|
||||||
eventName === "pull_request_review_comment";
|
eventName === "pull_request_review_comment";
|
||||||
|
|
||||||
let response;
|
const result = await updateClaudeComment(octokit, {
|
||||||
|
owner,
|
||||||
try {
|
repo,
|
||||||
if (isPullRequestReviewComment) {
|
commentId,
|
||||||
// Try PR review comment API first
|
body,
|
||||||
response = await octokit.rest.pulls.updateReviewComment({
|
isPullRequestReviewComment,
|
||||||
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(
|
text: JSON.stringify(result, null, 2),
|
||||||
{
|
|
||||||
id: response.data.id,
|
|
||||||
html_url: response.data.html_url,
|
|
||||||
updated_at: response.data.updated_at,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,29 +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,
|
|
||||||
claudeCommentId?: 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: [
|
||||||
@@ -39,12 +47,62 @@ export async function prepareMcpConfig(
|
|||||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||||
IS_PR: process.env.IS_PR || "false",
|
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);
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ describe("generatePrompt", () => {
|
|||||||
|
|
||||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
|
'Use: "Co-authored-by: johndoe <johndoe@users.noreply.github.com>"',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -652,7 +652,7 @@ describe("buildAllowedToolsString", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should append custom tools when provided", () => {
|
test("should append custom tools when provided", () => {
|
||||||
const customTools = "Tool1,Tool2,Tool3";
|
const customTools = ["Tool1", "Tool2", "Tool3"];
|
||||||
const result = buildAllowedToolsString(customTools);
|
const result = buildAllowedToolsString(customTools);
|
||||||
|
|
||||||
// Base tools should be present
|
// Base tools should be present
|
||||||
@@ -683,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
|
||||||
@@ -701,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,
|
||||||
@@ -720,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
|
||||||
@@ -732,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,
|
||||||
|
|||||||
414
test/install-mcp-server.test.ts
Normal file
414
test/install-mcp-server.test.ts
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ describe("checkWritePermissions", () => {
|
|||||||
inputs: {
|
inputs: {
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
allowedTools: "",
|
allowedTools: [],
|
||||||
disallowedTools: "",
|
disallowedTools: [],
|
||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
413
test/update-claude-comment.test.ts
Normal file
413
test/update-claude-comment.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user