Compare commits

...

25 Commits

Author SHA1 Message Date
Ashwin Bhat
7925dd1804 tmp 2025-07-04 10:23:17 -07:00
Ashwin Bhat
23fae74fdb Add GitHub Actions MCP server for viewing workflow results (#231)
* actions server

* tmp

* Replace view_actions_results with additional_permissions input

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

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

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

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

* Update GitHub Actions MCP server with RUNNER_TEMP and status filtering

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

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

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

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

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

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

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

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

* Refactor additional permissions parsing to parseGitHubContext

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

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

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

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

* Remove unnecessary hasActionsReadPermission parameter from createPrompt

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

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

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

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

* docs: Add documentation for additional_permissions feature

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

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

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

* roadmap

---------

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

This handles intermittent 403 'Resource not accessible by integration'
errors transparently without requiring workflow permission changes. These
errors appear to be transient GitHub API issues that succeed on retry.
2025-07-03 15:56:19 -07:00
GitHub Actions
aa28d465c5 chore: update claude-code-base-action to v0.0.31 2025-07-03 21:42:32 +00:00
Ashwin Bhat
55b7205cd2 feat: add fallback_model input to enable automatic model fallback (#228)
- Add fallback_model input to action.yml matching claude-code-base-action
- Pass fallback_model through to the base action
- Document the new input in README.md inputs table
- Enables automatic fallback when primary model is unavailable

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-03 11:09:10 -07:00
Piotr Padlewski
8fe405c45f feat: add formatted output for Claude Code execution reports (#18)
* feat: add formatted output for Claude Code execution reports

- Write turns formatter
- Modify GitHub Action to call formatter instead of dumping raw JSON
- Add comprehensive unit tests (30 tests) covering all functionality
- Add integration test with sample data for output consistency
- Support syntax highlighting for multiple content types (JSON, Python, bash, etc.)
- Include turn grouping logic and token usage tracking
- Provide CLI interface for standalone formatter usage

🤖 Generated with [Claude Code](https://claude.ai/code)
Note: seriously I have never written any line of ts code in my life, so
please make sure this is fine as I don't give any guarantees

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

* Add fallback

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-07-03 10:59:12 -07:00
Ashwin Bhat
73012199e4 fix sticky comment variable name (#226)
* fix sticky comment variable name

* fix condition
2025-07-03 09:18:28 -07:00
GitHub Actions
459b56e54d chore: update claude-code-base-action to v0.0.30 2025-07-03 04:27:02 +00:00
GitHub Actions
00f9595fb4 chore: update claude-code-base-action to v0.0.29 2025-07-01 21:38:26 +00:00
Dmitriy Shekhovtsov
79f2086fce feat: add sticky_comment input to reduce GitHub comment spam (#211)
* feat: no claude spam

* feat: add silent property

* feat: add silent property

* feat: add silent property

* chore: call me a sticky comment

* chore: applying review comments

* chore: apply review comments

* format

* reword

---------

Co-authored-by: Ashwin Bhat <ashwin@anthropic.com>
2025-07-01 10:37:39 -07:00
Julien Tanay
bcb072b63f docs: add FAQ entry about assigning in a private repo (#218) 2025-07-01 07:17:03 -07:00
GitHub Actions
e3b3e531a7 chore: update claude-code-base-action to v0.0.28 2025-07-01 02:57:05 +00:00
Derek Bredensteiner
a7665d3698 fix: resolve CI issues - formatting and TypeScript errors (#217)
* fixed file ingestion

* working binary files

* added replaced baseUrl

* fix: add type assertion for GitHub blob API response

Fixes TypeScript error where blobData was of type 'unknown' by adding
proper type assertion for the blob creation response.

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

Co-Authored-By: Andrew Grosser <dioptre@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Andrew Grosser <dioptre@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-30 19:56:37 -07:00
taku.tsunose
91c510a769 fix: add missing LABEL_TRIGGER environment variable to prepare step (#209)
The label_trigger input was defined but not passed as an environment variable
to the prepare step, causing it to be undefined in the prepare script.
This adds the missing LABEL_TRIGGER environment variable mapping.

Co-authored-by: taku.tsunose <taku.tsunose@takutsunosenoMacBook-Pro.local>
2025-06-27 09:25:00 -07:00
GitHub Actions
1e006bf2d0 chore: update claude-code-base-action to v0.0.27 2025-06-26 01:00:41 +00:00
Stefano Amorelli
ece712ea81 chore(README): add base branch parameter (#201) 2025-06-25 14:21:46 -07:00
Stefano Amorelli
032008d3b6 feat(config): add branch prefix configuration (#197) 2025-06-25 14:01:25 -07:00
Tomohiro Ishibashi
b0d9b8c4cd Add label trigger functionality to Claude Code Action (#177)
- introduced a new input parameter `label_trigger` in `action.yml` to allow triggering actions based on specific labels applied to issues.
- Enhanced the context preparation and event handling in the code to support the new labled event.
2025-06-25 10:25:26 -07:00
GitHub Actions
c831be8f54 chore: update claude-code-base-action to v0.0.26 2025-06-24 23:47:06 +00:00
Tomohiro Ishibashi
38254908ae fix: allow direct_prompt with issue assignment without requiring assignee_trigger (#192)
- Modified validation logic to only require assignee_trigger when direct_prompt is not provided
- Made assigneeTrigger optional in IssueAssignedEvent type definition
- Enhanced context generation to handle missing assigneeTrigger gracefully
- Added comprehensive test coverage for the new behavior

This enables direct_prompt workflows on issue assignment events without
requiring assignee_trigger configuration, fixing the error:
"ASSIGNEE_TRIGGER is required for issue assigned event"

Fixes #113

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-23 17:41:25 -07:00
Tomohiro Ishibashi
882586e496 chore: remove unwanted files added in commit 91f620f (#193)
Remove example-dispatch-workflow.yml and pr-summary.md that were
unintentionally added to the root directory in commit 91f620f.
These files should not be in the repository root.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-23 17:39:14 -07:00
GitHub Actions
28aaa5404d chore: update claude-code-base-action to v0.0.25 2025-06-24 00:35:11 +00:00
GitHub Actions
ebbd9e9be4 chore: update claude-code-base-action to v0.0.24 2025-06-20 21:50:00 +00:00
GitHub Actions
237de9d329 chore: update claude-code-base-action to v0.0.23 2025-06-20 15:38:21 +00:00
Ashwin Bhat
91f620f8c2 docs: remove references to non-existent test-local.sh script (#187)
All tests for this repo can be run with `bun test` - the test-local.sh script was a holdover from the base action repo.

Fixes #172

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Ashwin Bhat <ashwin-ant@users.noreply.github.com>
2025-06-19 07:52:42 -07:00
29 changed files with 2497 additions and 143 deletions

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
# Test fixtures should not be formatted to preserve exact output matching
test/fixtures/

View File

@@ -50,20 +50,6 @@ Thank you for your interest in contributing to Claude Code Action! This document
bun test bun test
``` ```
2. **Integration Tests** (using GitHub Actions locally):
```bash
./test-local.sh
```
This script:
- Installs `act` if not present (requires Homebrew on macOS)
- Runs the GitHub Action workflow locally using Docker
- Requires your `ANTHROPIC_API_KEY` to be set
On Apple Silicon Macs, the script automatically adds the `--container-architecture linux/amd64` flag to avoid compatibility issues.
## Pull Request Process ## Pull Request Process
1. Create a new branch from `main`: 1. Create a new branch from `main`:
@@ -103,13 +89,7 @@ Thank you for your interest in contributing to Claude Code Action! This document
When modifying the action: When modifying the action:
1. Test locally with the test script: 1. Test in a real GitHub Actions workflow by:
```bash
./test-local.sh
```
2. Test in a real GitHub Actions workflow by:
- Creating a test repository - Creating a test repository
- Using your branch as the action source: - Using your branch as the action source:
```yaml ```yaml

4
FAQ.md
View File

@@ -12,6 +12,10 @@ The `github-actions` user cannot trigger subsequent GitHub Actions workflows. Th
Only users with **write permissions** to the repository can trigger Claude. This is a security feature to prevent unauthorized use. Make sure the user commenting has at least write access to the repository. Only users with **write permissions** to the repository can trigger Claude. This is a security feature to prevent unauthorized use. Make sure the user commenting has at least write access to the repository.
### Why can't I assign @claude to an issue on my repository?
If you're in a public repository, you should be able to assign to Claude without issue. If it's a private organization repository, you can only assign to users in your own organization, which Claude isn't. In this case, you'll need to make a custom user in that case.
### Why am I getting OIDC authentication errors? ### Why am I getting OIDC authentication errors?
If you're using the default GitHub App authentication, you must add the `id-token: write` permission to your workflow: If you're using the default GitHub App authentication, you must add the `id-token: write` permission to your workflow:

118
README.md
View File

@@ -49,7 +49,7 @@ on:
pull_request_review_comment: pull_request_review_comment:
types: [created] types: [created]
issues: issues:
types: [opened, assigned] types: [opened, assigned, labeled]
pull_request_review: pull_request_review:
types: [submitted] types: [submitted]
@@ -65,6 +65,8 @@ 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 label trigger for issues
# label_trigger: "claude"
# Optional: add custom environment variables (YAML format) # Optional: add custom environment variables (YAML format)
# claude_env: | # claude_env: |
# NODE_ENV: test # NODE_ENV: test
@@ -72,28 +74,37 @@ jobs:
# API_URL: https://api.example.com # API_URL: https://api.example.com
# Optional: limit the number of conversation turns # Optional: limit the number of conversation turns
# max_turns: "5" # max_turns: "5"
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
# additional_permissions: |
# actions: read
``` ```
## Inputs ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | | `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - | | `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - | | `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
| `timeout_minutes` | Timeout in minutes for execution | No | `30` | | `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - | | `timeout_minutes` | Timeout in minutes for execution | No | `30` |
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - | | `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | 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 | - |
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` | | `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` | | `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" | | `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
| `disallowed_tools` | Tools that Claude should never use | No | "" | | `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | | `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" | | `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | | `disallowed_tools` | Tools that Claude should never use | No | "" |
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | | `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | 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 | - |
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex) \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
@@ -332,6 +343,75 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
## Advanced Configuration ## Advanced Configuration
### Additional Permissions for CI/CD Integration
The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues.
#### Enabling GitHub Actions Access
To allow Claude to view workflow run results, job logs, and CI status:
1. **Grant the necessary permission to your GitHub token**:
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
```yaml
permissions:
contents: write
pull-requests: write
issues: write
actions: read # Add this line
```
2. **Configure the action with additional permissions**:
```yaml
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: |
actions: read
# ... other inputs
```
3. **Claude will automatically get access to CI/CD tools**:
When you enable `actions: read`, Claude can use the following MCP tools:
- `mcp__github_ci__get_ci_status` - View workflow run statuses
- `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information
- `mcp__github_ci__download_job_log` - Download and analyze job logs
#### Example: Debugging Failed CI Runs
```yaml
name: Claude CI Helper
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
actions: read # Required for CI access
jobs:
claude-ci-helper:
runs-on: ubuntu-latest
steps:
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: |
actions: read
# Now Claude can respond to "@claude why did the CI fail?"
```
**Important Notes**:
- The GitHub token must have the `actions: read` permission in your workflow
- If the permission is missing, Claude will warn you and suggest adding it
- Currently, only `actions: read` is supported, but the format allows for future extensions
### Custom Environment Variables ### Custom Environment Variables
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables: You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:

View File

@@ -4,7 +4,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
## Path to 1.0 ## Path to 1.0
- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like. - ~**Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.~
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session - **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files - **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services - **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services

View File

@@ -12,9 +12,17 @@ inputs:
assignee_trigger: assignee_trigger:
description: "The assignee username that triggers the action (e.g. @claude)" description: "The assignee username that triggers the action (e.g. @claude)"
required: false required: false
label_trigger:
description: "The label that triggers the action (e.g. claude)"
required: false
default: "claude"
base_branch: base_branch:
description: "The branch to use as the base/source when creating new branches (defaults to repository default branch)" description: "The branch to use as the base/source when creating new branches (defaults to repository default branch)"
required: false required: false
branch_prefix:
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
required: false
default: "claude/"
# Claude Code configuration # Claude Code configuration
model: model:
@@ -23,6 +31,9 @@ inputs:
anthropic_model: anthropic_model:
description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)" description: "DEPRECATED: Use 'model' instead. Model to use (provider-specific format required for Bedrock/Vertex)"
required: false required: false
fallback_model:
description: "Enable automatic fallback to specified model when primary model is unavailable"
required: false
allowed_tools: allowed_tools:
description: "Additional tools for Claude to use (the base GitHub tools will always be included)" description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
required: false required: false
@@ -41,6 +52,10 @@ inputs:
default: "" default: ""
mcp_config: mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers" description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
additional_permissions:
description: "Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results"
required: false
default: ""
claude_env: claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)" description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false required: false
@@ -70,6 +85,10 @@ inputs:
description: "Timeout in minutes for execution" description: "Timeout in minutes for execution"
required: false required: false
default: "30" default: "30"
use_sticky_comment:
description: "Use just one comment to deliver issue/PR comments"
required: false
default: "false"
outputs: outputs:
execution_file: execution_file:
@@ -98,7 +117,9 @@ runs:
env: env:
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
LABEL_TRIGGER: ${{ inputs.label_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }} BASE_BRANCH: ${{ inputs.base_branch }}
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
ALLOWED_TOOLS: ${{ inputs.allowed_tools }} ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }} DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }} CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
@@ -106,11 +127,14 @@ runs:
MCP_CONFIG: ${{ inputs.mcp_config }} 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 }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
ACTIONS_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
- name: Run Claude Code - name: Run Claude Code
id: claude-code id: claude-code
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'
uses: anthropics/claude-code-base-action@bb2ef1d9768b9e94083d377778120f8f27958a72 # v0.0.22 uses: anthropics/claude-code-base-action@a835717b36becf75584224421f4094aae288cad7 # v0.0.31
with: with:
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
allowed_tools: ${{ env.ALLOWED_TOOLS }} allowed_tools: ${{ env.ALLOWED_TOOLS }}
@@ -118,6 +142,7 @@ runs:
timeout_minutes: ${{ inputs.timeout_minutes }} timeout_minutes: ${{ inputs.timeout_minutes }}
max_turns: ${{ inputs.max_turns }} max_turns: ${{ inputs.max_turns }}
model: ${{ inputs.model || inputs.anthropic_model }} model: ${{ inputs.model || inputs.anthropic_model }}
fallback_model: ${{ inputs.fallback_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 }}
@@ -170,15 +195,24 @@ runs:
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }} TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
- name: Display Claude Code Report - name: Display Claude Code Report
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
shell: bash shell: bash
run: | run: |
echo "## Claude Code Report" >> $GITHUB_STEP_SUMMARY # Try to format the turns, but if it fails, dump the raw JSON
echo '```json' >> $GITHUB_STEP_SUMMARY if bun run ${{ github.action_path }}/src/entrypoints/format-turns.ts "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY 2>/dev/null; then
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY echo "Successfully formatted Claude Code report"
echo '```' >> $GITHUB_STEP_SUMMARY else
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```json' >> $GITHUB_STEP_SUMMARY
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
- name: Revoke app token - name: Revoke app token
if: always() && inputs.github_token == '' if: always() && inputs.github_token == ''

View File

@@ -36,9 +36,21 @@ const BASE_ALLOWED_TOOLS = [
]; ];
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"]; const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
export function buildAllowedToolsString(customAllowedTools?: string[]): string { export function buildAllowedToolsString(
customAllowedTools?: string[],
includeActionsTools: boolean = false,
): string {
let baseTools = [...BASE_ALLOWED_TOOLS]; let baseTools = [...BASE_ALLOWED_TOOLS];
// Add GitHub Actions MCP tools if enabled
if (includeActionsTools) {
baseTools.push(
"mcp__github_ci__get_ci_status",
"mcp__github_ci__get_workflow_run_details",
"mcp__github_ci__download_job_log",
);
}
let allAllowedTools = baseTools.join(","); let allAllowedTools = baseTools.join(",");
if (customAllowedTools && customAllowedTools.length > 0) { if (customAllowedTools && customAllowedTools.length > 0) {
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`; allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
@@ -81,6 +93,7 @@ export function prepareContext(
const eventAction = context.eventAction; const eventAction = context.eventAction;
const triggerPhrase = context.inputs.triggerPhrase || "@claude"; const triggerPhrase = context.inputs.triggerPhrase || "@claude";
const assigneeTrigger = context.inputs.assigneeTrigger; const assigneeTrigger = context.inputs.assigneeTrigger;
const labelTrigger = context.inputs.labelTrigger;
const customInstructions = context.inputs.customInstructions; const customInstructions = context.inputs.customInstructions;
const allowedTools = context.inputs.allowedTools; const allowedTools = context.inputs.allowedTools;
const disallowedTools = context.inputs.disallowedTools; const disallowedTools = context.inputs.disallowedTools;
@@ -242,7 +255,7 @@ export function prepareContext(
} }
if (eventAction === "assigned") { if (eventAction === "assigned") {
if (!assigneeTrigger) { if (!assigneeTrigger && !directPrompt) {
throw new Error( throw new Error(
"ASSIGNEE_TRIGGER is required for issue assigned event", "ASSIGNEE_TRIGGER is required for issue assigned event",
); );
@@ -254,7 +267,20 @@ export function prepareContext(
issueNumber, issueNumber,
baseBranch, baseBranch,
claudeBranch, claudeBranch,
assigneeTrigger, ...(assigneeTrigger && { assigneeTrigger }),
};
} else if (eventAction === "labeled") {
if (!labelTrigger) {
throw new Error("LABEL_TRIGGER is required for issue labeled event");
}
eventData = {
eventName: "issues",
eventAction: "labeled",
isPR: false,
issueNumber,
baseBranch,
claudeBranch,
labelTrigger,
}; };
} else if (eventAction === "opened") { } else if (eventAction === "opened") {
eventData = { eventData = {
@@ -328,10 +354,17 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
eventType: "ISSUE_CREATED", eventType: "ISSUE_CREATED",
triggerContext: `new issue with '${envVars.triggerPhrase}' in body`, triggerContext: `new issue with '${envVars.triggerPhrase}' in body`,
}; };
} else if (eventData.eventAction === "labeled") {
return {
eventType: "ISSUE_LABELED",
triggerContext: `issue labeled with '${eventData.labelTrigger}'`,
};
} }
return { return {
eventType: "ISSUE_ASSIGNED", eventType: "ISSUE_ASSIGNED",
triggerContext: `issue assigned to '${eventData.assigneeTrigger}'`, triggerContext: eventData.assigneeTrigger
? `issue assigned to '${eventData.assigneeTrigger}'`
: `issue assigned event`,
}; };
case "pull_request": case "pull_request":
@@ -465,6 +498,7 @@ Follow these steps:
- Analyze the pre-fetched data provided above. - Analyze the pre-fetched data provided above.
- For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase. - For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase.
- For ISSUE_ASSIGNED: Read the entire issue body to understand the task. - For ISSUE_ASSIGNED: Read the entire issue body to understand the task.
- For ISSUE_LABELED: Read the entire issue body to understand the task.
${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""} ${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""}
${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""} ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""}
- IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions. - IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions.
@@ -643,8 +677,12 @@ export async function createPrompt(
); );
// Set allowed tools // Set allowed tools
const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read" &&
context.isPR;
const allAllowedTools = buildAllowedToolsString( const allAllowedTools = buildAllowedToolsString(
context.inputs.allowedTools, context.inputs.allowedTools,
hasActionsReadPermission,
); );
const allDisallowedTools = buildDisallowedToolsString( const allDisallowedTools = buildDisallowedToolsString(
context.inputs.disallowedTools, context.inputs.disallowedTools,

View File

@@ -65,7 +65,17 @@ type IssueAssignedEvent = {
issueNumber: string; issueNumber: string;
baseBranch: string; baseBranch: string;
claudeBranch: string; claudeBranch: string;
assigneeTrigger: string; assigneeTrigger?: string;
};
type IssueLabeledEvent = {
eventName: "issues";
eventAction: "labeled";
isPR: false;
issueNumber: string;
baseBranch: string;
claudeBranch: string;
labelTrigger: string;
}; };
type PullRequestEvent = { type PullRequestEvent = {
@@ -85,6 +95,7 @@ export type EventData =
| IssueCommentEvent | IssueCommentEvent
| IssueOpenedEvent | IssueOpenedEvent
| IssueAssignedEvent | IssueAssignedEvent
| IssueLabeledEvent
| PullRequestEvent; | PullRequestEvent;
// Combined type with separate eventData field // Combined type with separate eventData field

461
src/entrypoints/format-turns.ts Executable file
View File

@@ -0,0 +1,461 @@
#!/usr/bin/env bun
import { readFileSync, existsSync } from "fs";
import { exit } from "process";
export interface ToolUse {
type: string;
name?: string;
input?: Record<string, any>;
id?: string;
}
export interface ToolResult {
type: string;
tool_use_id?: string;
content?: any;
is_error?: boolean;
}
export interface ContentItem {
type: string;
text?: string;
tool_use_id?: string;
content?: any;
is_error?: boolean;
name?: string;
input?: Record<string, any>;
id?: string;
}
export interface Message {
content: ContentItem[];
usage?: {
input_tokens?: number;
output_tokens?: number;
};
}
export interface Turn {
type: string;
subtype?: string;
message?: Message;
tools?: any[];
cost_usd?: number;
duration_ms?: number;
result?: string;
}
export interface GroupedContent {
type: string;
tools_count?: number;
data?: Turn;
text_parts?: string[];
tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[];
usage?: Record<string, number>;
}
export function detectContentType(content: any): string {
const contentStr = String(content).trim();
// Check for JSON
if (contentStr.startsWith("{") && contentStr.endsWith("}")) {
try {
JSON.parse(contentStr);
return "json";
} catch {
// Fall through
}
}
if (contentStr.startsWith("[") && contentStr.endsWith("]")) {
try {
JSON.parse(contentStr);
return "json";
} catch {
// Fall through
}
}
// Check for code-like content
const codeKeywords = [
"def ",
"class ",
"import ",
"from ",
"function ",
"const ",
"let ",
"var ",
];
if (codeKeywords.some((keyword) => contentStr.includes(keyword))) {
if (
contentStr.includes("def ") ||
contentStr.includes("import ") ||
contentStr.includes("from ")
) {
return "python";
} else if (
["function ", "const ", "let ", "var ", "=>"].some((js) =>
contentStr.includes(js),
)
) {
return "javascript";
} else {
return "python"; // default for code
}
}
// Check for shell/bash output
const shellIndicators = ["ls -", "cd ", "mkdir ", "rm ", "$ ", "# "];
if (
contentStr.startsWith("/") ||
contentStr.includes("Error:") ||
contentStr.startsWith("total ") ||
shellIndicators.some((indicator) => contentStr.includes(indicator))
) {
return "bash";
}
// Check for diff format
if (
contentStr.startsWith("@@") ||
contentStr.includes("+++ ") ||
contentStr.includes("--- ")
) {
return "diff";
}
// Check for HTML/XML
if (contentStr.startsWith("<") && contentStr.endsWith(">")) {
return "html";
}
// Check for markdown
const mdIndicators = ["# ", "## ", "### ", "- ", "* ", "```"];
if (mdIndicators.some((indicator) => contentStr.includes(indicator))) {
return "markdown";
}
// Default to plain text
return "text";
}
export function formatResultContent(content: any): string {
if (!content) {
return "*(No output)*\n\n";
}
let contentStr: string;
// Check if content is a list with "type": "text" structure
try {
let parsedContent: any;
if (typeof content === "string") {
parsedContent = JSON.parse(content);
} else {
parsedContent = content;
}
if (
Array.isArray(parsedContent) &&
parsedContent.length > 0 &&
typeof parsedContent[0] === "object" &&
parsedContent[0]?.type === "text"
) {
// Extract the text field from the first item
contentStr = parsedContent[0]?.text || "";
} else {
contentStr = String(content).trim();
}
} catch {
contentStr = String(content).trim();
}
// Truncate very long results
if (contentStr.length > 3000) {
contentStr = contentStr.substring(0, 2997) + "...";
}
// Detect content type
const contentType = detectContentType(contentStr);
// Handle JSON content specially - pretty print it
if (contentType === "json") {
try {
// Try to parse and pretty print JSON
const parsed = JSON.parse(contentStr);
contentStr = JSON.stringify(parsed, null, 2);
} catch {
// Keep original if parsing fails
}
}
// Format with appropriate syntax highlighting
if (
contentType === "text" &&
contentStr.length < 100 &&
!contentStr.includes("\n")
) {
// Short text results don't need code blocks
return `**→** ${contentStr}\n\n`;
} else {
return `**Result:**\n\`\`\`${contentType}\n${contentStr}\n\`\`\`\n\n`;
}
}
export function formatToolWithResult(
toolUse: ToolUse,
toolResult?: ToolResult,
): string {
const toolName = toolUse.name || "unknown_tool";
const toolInput = toolUse.input || {};
let result = `### 🔧 \`${toolName}\`\n\n`;
// Add parameters if they exist and are not empty
if (Object.keys(toolInput).length > 0) {
result += "**Parameters:**\n```json\n";
result += JSON.stringify(toolInput, null, 2);
result += "\n```\n\n";
}
// Add result if available
if (toolResult) {
const content = toolResult.content || "";
const isError = toolResult.is_error || false;
if (isError) {
result += `❌ **Error:** \`${content}\`\n\n`;
} else {
result += formatResultContent(content);
}
}
return result;
}
export function groupTurnsNaturally(data: Turn[]): GroupedContent[] {
const groupedContent: GroupedContent[] = [];
const toolResultsMap = new Map<string, ToolResult>();
// First pass: collect all tool results by tool_use_id
for (const turn of data) {
if (turn.type === "user") {
const content = turn.message?.content || [];
for (const item of content) {
if (item.type === "tool_result" && item.tool_use_id) {
toolResultsMap.set(item.tool_use_id, {
type: item.type,
tool_use_id: item.tool_use_id,
content: item.content,
is_error: item.is_error,
});
}
}
}
}
// Second pass: process turns and group naturally
for (const turn of data) {
const turnType = turn.type || "unknown";
if (turnType === "system") {
const subtype = turn.subtype || "";
if (subtype === "init") {
const tools = turn.tools || [];
groupedContent.push({
type: "system_init",
tools_count: tools.length,
});
} else {
groupedContent.push({
type: "system_other",
data: turn,
});
}
} else if (turnType === "assistant") {
const message = turn.message || { content: [] };
const content = message.content || [];
const usage = message.usage || {};
// Process content items
const textParts: string[] = [];
const toolCalls: { tool_use: ToolUse; tool_result?: ToolResult }[] = [];
for (const item of content) {
const itemType = item.type || "";
if (itemType === "text") {
textParts.push(item.text || "");
} else if (itemType === "tool_use") {
const toolUseId = item.id;
const toolResult = toolUseId
? toolResultsMap.get(toolUseId)
: undefined;
toolCalls.push({
tool_use: {
type: item.type,
name: item.name,
input: item.input,
id: item.id,
},
tool_result: toolResult,
});
}
}
if (textParts.length > 0 || toolCalls.length > 0) {
groupedContent.push({
type: "assistant_action",
text_parts: textParts,
tool_calls: toolCalls,
usage: usage,
});
}
} else if (turnType === "user") {
// Handle user messages that aren't tool results
const message = turn.message || { content: [] };
const content = message.content || [];
const textParts: string[] = [];
for (const item of content) {
if (item.type === "text") {
textParts.push(item.text || "");
}
}
if (textParts.length > 0) {
groupedContent.push({
type: "user_message",
text_parts: textParts,
});
}
} else if (turnType === "result") {
groupedContent.push({
type: "final_result",
data: turn,
});
}
}
return groupedContent;
}
export function formatGroupedContent(groupedContent: GroupedContent[]): string {
let markdown = "## Claude Code Report\n\n";
for (const item of groupedContent) {
const itemType = item.type;
if (itemType === "system_init") {
markdown += `## 🚀 System Initialization\n\n**Available Tools:** ${item.tools_count} tools loaded\n\n---\n\n`;
} else if (itemType === "system_other") {
markdown += `## ⚙️ System Message\n\n${JSON.stringify(item.data, null, 2)}\n\n---\n\n`;
} else if (itemType === "assistant_action") {
// Add text content first (if any) - no header needed
for (const text of item.text_parts || []) {
if (text.trim()) {
markdown += `${text}\n\n`;
}
}
// Add tool calls with their results
for (const toolCall of item.tool_calls || []) {
markdown += formatToolWithResult(
toolCall.tool_use,
toolCall.tool_result,
);
}
// Add usage info if available
const usage = item.usage || {};
if (Object.keys(usage).length > 0) {
const inputTokens = usage.input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
markdown += `*Token usage: ${inputTokens} input, ${outputTokens} output*\n\n`;
}
// Only add separator if this section had content
if (
(item.text_parts && item.text_parts.length > 0) ||
(item.tool_calls && item.tool_calls.length > 0)
) {
markdown += "---\n\n";
}
} else if (itemType === "user_message") {
markdown += "## 👤 User\n\n";
for (const text of item.text_parts || []) {
if (text.trim()) {
markdown += `${text}\n\n`;
}
}
markdown += "---\n\n";
} else if (itemType === "final_result") {
const data = item.data || {};
const cost = (data as any).cost_usd || 0;
const duration = (data as any).duration_ms || 0;
const resultText = (data as any).result || "";
markdown += "## ✅ Final Result\n\n";
if (resultText) {
markdown += `${resultText}\n\n`;
}
markdown += `**Cost:** $${cost.toFixed(4)} | **Duration:** ${(duration / 1000).toFixed(1)}s\n\n`;
}
}
return markdown;
}
export function formatTurnsFromData(data: Turn[]): string {
// Group turns naturally
const groupedContent = groupTurnsNaturally(data);
// Generate markdown
const markdown = formatGroupedContent(groupedContent);
return markdown;
}
function main(): void {
// Get the JSON file path from command line arguments
const args = process.argv.slice(2);
if (args.length === 0) {
console.error("Usage: format-turns.ts <json-file>");
exit(1);
}
const jsonFile = args[0];
if (!jsonFile) {
console.error("Error: No JSON file provided");
exit(1);
}
if (!existsSync(jsonFile)) {
console.error(`Error: ${jsonFile} not found`);
exit(1);
}
try {
// Read the JSON file
const fileContent = readFileSync(jsonFile, "utf-8");
const data: Turn[] = JSON.parse(fileContent);
// Group turns naturally
const groupedContent = groupTurnsNaturally(data);
// Generate markdown
const markdown = formatGroupedContent(groupedContent);
// Print to stdout (so it can be captured by shell)
console.log(markdown);
} catch (error) {
console.error(`Error processing file: ${error}`);
exit(1);
}
}
if (import.meta.main) {
main();
}

View File

@@ -94,6 +94,7 @@ async function run() {
additionalMcpConfig, additionalMcpConfig,
claudeCommentId: commentId.toString(), claudeCommentId: commentId.toString(),
allowedTools: context.inputs.allowedTools, allowedTools: context.inputs.allowedTools,
context,
}); });
core.setOutput("mcp_config", mcpConfig); core.setOutput("mcp_config", mcpConfig);
} catch (error) { } catch (error) {

View File

@@ -29,11 +29,15 @@ export type ParsedGitHubContext = {
inputs: { inputs: {
triggerPhrase: string; triggerPhrase: string;
assigneeTrigger: string; assigneeTrigger: string;
labelTrigger: string;
allowedTools: string[]; allowedTools: string[];
disallowedTools: string[]; disallowedTools: string[];
customInstructions: string; customInstructions: string;
directPrompt: string; directPrompt: string;
baseBranch?: string; baseBranch?: string;
branchPrefix: string;
useStickyComment: boolean;
additionalPermissions: Map<string, string>;
}; };
}; };
@@ -53,11 +57,17 @@ 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 ?? "",
labelTrigger: process.env.LABEL_TRIGGER ?? "",
allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""), allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""),
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""), disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
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,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
additionalPermissions: parseAdditionalPermissions(
process.env.ADDITIONAL_PERMISSIONS ?? "",
),
}, },
}; };
@@ -119,6 +129,25 @@ export function parseMultilineInput(s: string): string[] {
.filter((tool) => tool.length > 0); .filter((tool) => tool.length > 0);
} }
export function parseAdditionalPermissions(s: string): Map<string, string> {
const permissions = new Map<string, string>();
if (!s || !s.trim()) {
return permissions;
}
const lines = s.trim().split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
if (key && value) {
permissions.set(key, value);
}
}
}
return permissions;
}
export function isIssuesEvent( export function isIssuesEvent(
context: ParsedGitHubContext, context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: IssuesEvent } { ): context is ParsedGitHubContext & { payload: IssuesEvent } {

View File

@@ -26,7 +26,7 @@ export async function setupBranch(
): Promise<BranchInfo> { ): Promise<BranchInfo> {
const { owner, repo } = context.repository; const { owner, repo } = context.repository;
const entityNumber = context.entityNumber; const entityNumber = context.entityNumber;
const { baseBranch } = context.inputs; const { baseBranch, branchPrefix } = context.inputs;
const isPR = context.isPR; const isPR = context.isPR;
if (isPR) { if (isPR) {
@@ -97,7 +97,7 @@ export async function setupBranch(
.split("T") .split("T")
.join("_"); .join("_");
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`; const newBranch = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
try { try {
// Get the SHA of the source branch // Get the SHA of the source branch

View File

@@ -9,6 +9,7 @@ import { appendFileSync } from "fs";
import { createJobRunLink, createCommentBody } from "./common"; import { createJobRunLink, createCommentBody } from "./common";
import { import {
isPullRequestReviewCommentEvent, isPullRequestReviewCommentEvent,
isPullRequestEvent,
type ParsedGitHubContext, type ParsedGitHubContext,
} from "../../context"; } from "../../context";
import type { Octokit } from "@octokit/rest"; import type { Octokit } from "@octokit/rest";
@@ -25,8 +26,44 @@ export async function createInitialComment(
try { try {
let response; let response;
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id if (
if (isPullRequestReviewCommentEvent(context)) { context.inputs.useStickyComment &&
context.isPR &&
isPullRequestEvent(context)
) {
const comments = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: context.entityNumber,
});
console.log("users");
comments.data.forEach((comment) => {
console.log(comment.user);
});
const existingComment = comments.data.find(
(comment) =>
comment.user?.login.indexOf("claude[bot]") !== -1 ||
comment.body === initialBody,
);
if (existingComment) {
response = await octokit.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body: initialBody,
});
} else {
// Create new comment if no existing one found
response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: context.entityNumber,
body: initialBody,
});
}
} else if (isPullRequestReviewCommentEvent(context)) {
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
response = await octokit.rest.pulls.createReplyForReviewComment({ response = await octokit.rest.pulls.createReplyForReviewComment({
owner, owner,
repo, repo,

View File

@@ -1,47 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import * as core from "@actions/core"; import * as core from "@actions/core";
import { retryWithBackoff } from "../utils/retry";
type RetryOptions = {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffFactor?: number;
};
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 5000,
maxDelayMs = 20000,
backoffFactor = 2,
} = options;
let delayMs = initialDelayMs;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt} failed:`, lastError.message);
if (attempt < maxAttempts) {
console.log(`Retrying in ${delayMs / 1000} seconds...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
}
}
}
console.error(`Operation failed after ${maxAttempts} attempts`);
throw lastError;
}
async function getOidcToken(): Promise<string> { async function getOidcToken(): Promise<string> {
try { try {

View File

@@ -13,7 +13,7 @@ import type { ParsedGitHubContext } from "../context";
export function checkContainsTrigger(context: ParsedGitHubContext): boolean { export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
const { const {
inputs: { assigneeTrigger, triggerPhrase, directPrompt }, inputs: { assigneeTrigger, labelTrigger, triggerPhrase, directPrompt },
} = context; } = context;
// If direct prompt is provided, always trigger // If direct prompt is provided, always trigger
@@ -34,6 +34,16 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
} }
} }
// Check for label trigger
if (isIssuesEvent(context) && context.eventAction === "labeled") {
const labelName = (context.payload as any).label?.name || "";
if (labelTrigger && labelName === labelTrigger) {
console.log(`Issue labeled with trigger label '${labelTrigger}'`);
return true;
}
}
// Check for issue body and title trigger on issue creation // Check for issue body and title trigger on issue creation
if (isIssuesEvent(context) && context.eventAction === "opened") { if (isIssuesEvent(context) && context.eventAction === "opened") {
const issueBody = context.payload.issue.body || ""; const issueBody = context.payload.issue.body || "";

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { mkdir, writeFile } from "fs/promises";
import { Octokit } from "@octokit/rest";
const REPO_OWNER = process.env.REPO_OWNER;
const REPO_NAME = process.env.REPO_NAME;
const PR_NUMBER = process.env.PR_NUMBER;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp";
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
console.error(
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
);
process.exit(1);
}
const server = new McpServer({
name: "GitHub CI Server",
version: "0.0.1",
});
console.error("[GitHub CI Server] MCP Server instance created");
server.tool(
"get_ci_status",
"Get CI status summary for this PR",
{
status: z
.enum([
"completed",
"action_required",
"cancelled",
"failure",
"neutral",
"skipped",
"stale",
"success",
"timed_out",
"in_progress",
"queued",
"requested",
"waiting",
"pending",
])
.optional()
.describe("Filter workflow runs by status"),
},
async ({ status }) => {
try {
const client = new Octokit({
auth: GITHUB_TOKEN,
});
// Get the PR to find the head SHA
const { data: prData } = await client.pulls.get({
owner: REPO_OWNER!,
repo: REPO_NAME!,
pull_number: parseInt(PR_NUMBER!, 10),
});
const headSha = prData.head.sha;
const { data: runsData } = await client.actions.listWorkflowRunsForRepo({
owner: REPO_OWNER!,
repo: REPO_NAME!,
head_sha: headSha,
...(status && { status }),
});
// Process runs to create summary
const runs = runsData.workflow_runs || [];
const summary = {
total_runs: runs.length,
failed: 0,
passed: 0,
pending: 0,
};
const processedRuns = runs.map((run: any) => {
// Update summary counts
if (run.status === "completed") {
if (run.conclusion === "success") {
summary.passed++;
} else if (run.conclusion === "failure") {
summary.failed++;
}
} else {
summary.pending++;
}
return {
id: run.id,
name: run.name,
status: run.status,
conclusion: run.conclusion,
html_url: run.html_url,
created_at: run.created_at,
};
});
const result = {
summary,
runs: processedRuns,
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
server.tool(
"get_workflow_run_details",
"Get job and step details for a workflow run",
{
run_id: z.number().describe("The workflow run ID"),
},
async ({ run_id }) => {
try {
const client = new Octokit({
auth: GITHUB_TOKEN,
});
// Get jobs for this workflow run
const { data: jobsData } = await client.actions.listJobsForWorkflowRun({
owner: REPO_OWNER!,
repo: REPO_NAME!,
run_id,
});
const processedJobs = jobsData.jobs.map((job: any) => {
// Extract failed steps
const failedSteps = (job.steps || [])
.filter((step: any) => step.conclusion === "failure")
.map((step: any) => ({
name: step.name,
number: step.number,
}));
return {
id: job.id,
name: job.name,
conclusion: job.conclusion,
html_url: job.html_url,
failed_steps: failedSteps,
};
});
const result = {
jobs: processedJobs,
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
server.tool(
"download_job_log",
"Download job logs to disk",
{
job_id: z.number().describe("The job ID"),
},
async ({ job_id }) => {
try {
const client = new Octokit({
auth: GITHUB_TOKEN,
});
const response = await client.actions.downloadJobLogsForWorkflowRun({
owner: REPO_OWNER!,
repo: REPO_NAME!,
job_id,
});
const logsText = response.data as unknown as string;
const logsDir = `${RUNNER_TEMP}/github-ci-logs`;
await mkdir(logsDir, { recursive: true });
const logPath = `${logsDir}/job-${job_id}.log`;
await writeFile(logPath, logsText, "utf-8");
const result = {
path: logPath,
size_bytes: Buffer.byteLength(logsText, "utf-8"),
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
async function runServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("exit", () => {
server.close();
});
} catch (error) {
throw error;
}
}
runServer().catch(() => {
process.exit(1);
});

View File

@@ -9,6 +9,7 @@ import fetch from "node-fetch";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
import { retryWithBackoff } from "../utils/retry";
type GitHubRef = { type GitHubRef = {
object: { object: {
@@ -125,13 +126,58 @@ server.tool(
? filePath ? filePath
: join(REPO_DIR, filePath); : join(REPO_DIR, filePath);
const content = await readFile(fullPath, "utf-8"); // Check if file is binary (images, etc.)
return { const isBinaryFile =
path: filePath, /\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
mode: "100644", filePath,
type: "blob", );
content: content,
}; if (isBinaryFile) {
// For binary files, create a blob first using the Blobs API
const binaryContent = await readFile(fullPath);
// Create blob using Blobs API (supports encoding parameter)
const blobUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/blobs`;
const blobResponse = await fetch(blobUrl, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
content: binaryContent.toString("base64"),
encoding: "base64",
}),
});
if (!blobResponse.ok) {
const errorText = await blobResponse.text();
throw new Error(
`Failed to create blob for ${filePath}: ${blobResponse.status} - ${errorText}`,
);
}
const blobData = (await blobResponse.json()) as { sha: string };
// Return tree entry with blob SHA
return {
path: filePath,
mode: "100644",
type: "blob",
sha: blobData.sha,
};
} else {
// For text files, include content directly in tree
const content = await readFile(fullPath, "utf-8");
return {
path: filePath,
mode: "100644",
type: "blob",
content: content,
};
}
}), }),
); );
@@ -188,26 +234,50 @@ server.tool(
// 6. Update the reference to point to the new commit // 6. Update the reference to point to the new commit
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
sha: newCommitData.sha,
force: false,
}),
});
if (!updateRefResponse.ok) { // We're seeing intermittent 403 "Resource not accessible by integration" errors
const errorText = await updateRefResponse.text(); // on certain repos when updating git references. These appear to be transient
throw new Error( // GitHub API issues that succeed on retry.
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`, await retryWithBackoff(
); async () => {
} const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
sha: newCommitData.sha,
force: false,
}),
});
if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text();
const error = new Error(
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
);
// Only retry on 403 errors - these are the intermittent failures we're targeting
if (updateRefResponse.status === 403) {
console.log("Received 403 error, will retry...");
throw error;
}
// For non-403 errors, fail immediately without retry
console.error("Non-retryable error:", updateRefResponse.status);
throw error;
}
},
{
maxAttempts: 3,
initialDelayMs: 1000, // Start with 1 second delay
maxDelayMs: 5000, // Max 5 seconds delay
backoffFactor: 2, // Double the delay each time
},
);
const simplifiedResult = { const simplifiedResult = {
commit: { commit: {
@@ -382,26 +452,50 @@ server.tool(
// 6. Update the reference to point to the new commit // 6. Update the reference to point to the new commit
const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`; const updateRefUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/git/refs/heads/${branch}`;
const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
sha: newCommitData.sha,
force: false,
}),
});
if (!updateRefResponse.ok) { // We're seeing intermittent 403 "Resource not accessible by integration" errors
const errorText = await updateRefResponse.text(); // on certain repos when updating git references. These appear to be transient
throw new Error( // GitHub API issues that succeed on retry.
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`, await retryWithBackoff(
); async () => {
} const updateRefResponse = await fetch(updateRefUrl, {
method: "PATCH",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${githubToken}`,
"X-GitHub-Api-Version": "2022-11-28",
"Content-Type": "application/json",
},
body: JSON.stringify({
sha: newCommitData.sha,
force: false,
}),
});
if (!updateRefResponse.ok) {
const errorText = await updateRefResponse.text();
const error = new Error(
`Failed to update reference: ${updateRefResponse.status} - ${errorText}`,
);
// Only retry on 403 errors - these are the intermittent failures we're targeting
if (updateRefResponse.status === 403) {
console.log("Received 403 error, will retry...");
throw error;
}
// For non-403 errors, fail immediately without retry
console.error("Non-retryable error:", updateRefResponse.status);
throw error;
}
},
{
maxAttempts: 3,
initialDelayMs: 1000, // Start with 1 second delay
maxDelayMs: 5000, // Max 5 seconds delay
backoffFactor: 2, // Double the delay each time
},
);
const simplifiedResult = { const simplifiedResult = {
commit: { commit: {

View File

@@ -1,5 +1,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import type { ParsedGitHubContext } from "../github/context";
import { Octokit } from "@octokit/rest";
type PrepareConfigParams = { type PrepareConfigParams = {
githubToken: string; githubToken: string;
@@ -9,8 +11,41 @@ type PrepareConfigParams = {
additionalMcpConfig?: string; additionalMcpConfig?: string;
claudeCommentId?: string; claudeCommentId?: string;
allowedTools: string[]; allowedTools: string[];
context: ParsedGitHubContext;
}; };
async function checkActionsReadPermission(
token: string,
owner: string,
repo: string,
): Promise<boolean> {
try {
const client = new Octokit({ auth: token });
// Try to list workflow runs - this requires actions:read
// We use per_page=1 to minimize the response size
await client.actions.listWorkflowRunsForRepo({
owner,
repo,
per_page: 1,
});
return true;
} catch (error: any) {
// Check if it's a permission error
if (
error.status === 403 &&
error.message?.includes("Resource not accessible")
) {
return false;
}
// For other errors (network issues, etc), log but don't fail
core.debug(`Failed to check actions permission: ${error.message}`);
return false;
}
}
export async function prepareMcpConfig( export async function prepareMcpConfig(
params: PrepareConfigParams, params: PrepareConfigParams,
): Promise<string> { ): Promise<string> {
@@ -22,6 +57,7 @@ export async function prepareMcpConfig(
additionalMcpConfig, additionalMcpConfig,
claudeCommentId, claudeCommentId,
allowedTools, allowedTools,
context,
} = params; } = params;
try { try {
const allowedToolsList = allowedTools || []; const allowedToolsList = allowedTools || [];
@@ -53,6 +89,42 @@ export async function prepareMcpConfig(
}, },
}; };
// Only add CI server if we have actions:read permission and we're in a PR context
const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read";
if (context.isPR && hasActionsReadPermission) {
// Verify the token actually has actions:read permission
const actuallyHasPermission = await checkActionsReadPermission(
process.env.ACTIONS_TOKEN || "",
owner,
repo,
);
if (!actuallyHasPermission) {
core.warning(
"The github_ci MCP server requires 'actions: read' permission. " +
"Please ensure your GitHub token has this permission. " +
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
);
}
baseMcpConfig.mcpServers.github_ci = {
command: "bun",
args: [
"run",
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
],
env: {
// Use workflow github token, not app token
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
REPO_OWNER: owner,
REPO_NAME: repo,
PR_NUMBER: context.entityNumber.toString(),
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
},
};
}
if (hasGitHubMcpTools) { if (hasGitHubMcpTools) {
baseMcpConfig.mcpServers.github = { baseMcpConfig.mcpServers.github = {
command: "docker", command: "docker",

40
src/utils/retry.ts Normal file
View File

@@ -0,0 +1,40 @@
export type RetryOptions = {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffFactor?: number;
};
export async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 5000,
maxDelayMs = 20000,
backoffFactor = 2,
} = options;
let delayMs = initialDelayMs;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt} failed:`, lastError.message);
if (attempt < maxAttempts) {
console.log(`Retrying in ${delayMs / 1000} seconds...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
}
}
}
console.error(`Operation failed after ${maxAttempts} attempts`);
throw lastError;
}

View File

@@ -226,6 +226,33 @@ describe("generatePrompt", () => {
); );
}); });
test("should generate prompt for issue labeled event", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "labeled",
isPR: false,
issueNumber: "888",
baseBranch: "main",
claudeBranch: "claude/issue-888-20240101_120000",
labelTrigger: "claude-task",
},
};
const prompt = generatePrompt(envVars, mockGitHubData);
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
expect(prompt).toContain(
"<trigger_context>issue labeled with 'claude-task'</trigger_context>",
);
expect(prompt).toContain(
"[Create a PR](https://github.com/owner/repo/compare/main",
);
});
test("should include direct prompt when provided", () => { test("should include direct prompt when provided", () => {
const envVars: PreparedContext = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
@@ -614,6 +641,51 @@ describe("getEventTypeAndContext", () => {
expect(result.eventType).toBe("ISSUE_ASSIGNED"); expect(result.eventType).toBe("ISSUE_ASSIGNED");
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'"); expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
}); });
test("should return correct type and context for issue labeled", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
eventData: {
eventName: "issues",
eventAction: "labeled",
isPR: false,
issueNumber: "888",
baseBranch: "main",
claudeBranch: "claude/issue-888-20240101_120000",
labelTrigger: "claude-task",
},
};
const result = getEventTypeAndContext(envVars);
expect(result.eventType).toBe("ISSUE_LABELED");
expect(result.triggerContext).toBe("issue labeled with 'claude-task'");
});
test("should return correct type and context for issue assigned without assigneeTrigger", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
directPrompt: "Please assess this issue",
eventData: {
eventName: "issues",
eventAction: "assigned",
isPR: false,
issueNumber: "999",
baseBranch: "main",
claudeBranch: "claude/issue-999-20240101_120000",
// No assigneeTrigger when using directPrompt
},
};
const result = getEventTypeAndContext(envVars);
expect(result.eventType).toBe("ISSUE_ASSIGNED");
expect(result.triggerContext).toBe("issue assigned event");
});
}); });
describe("buildAllowedToolsString", () => { describe("buildAllowedToolsString", () => {
@@ -671,6 +743,36 @@ describe("buildAllowedToolsString", () => {
expect(basePlusCustom).toContain("Tool2"); expect(basePlusCustom).toContain("Tool2");
expect(basePlusCustom).toContain("Tool3"); expect(basePlusCustom).toContain("Tool3");
}); });
test("should include GitHub Actions tools when includeActionsTools is true", () => {
const result = buildAllowedToolsString([], true);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Glob");
// GitHub Actions tools should be included
expect(result).toContain("mcp__github_ci__get_ci_status");
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
expect(result).toContain("mcp__github_ci__download_job_log");
});
test("should include both custom and Actions tools when both provided", () => {
const customTools = ["Tool1", "Tool2"];
const result = buildAllowedToolsString(customTools, true);
// Base tools should be present
expect(result).toContain("Edit");
// Custom tools should be included
expect(result).toContain("Tool1");
expect(result).toContain("Tool2");
// GitHub Actions tools should be included
expect(result).toContain("mcp__github_ci__get_ci_status");
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
expect(result).toContain("mcp__github_ci__download_job_log");
});
}); });
describe("buildDisallowedToolsString", () => { describe("buildDisallowedToolsString", () => {

View File

@@ -0,0 +1,95 @@
## Claude Code Report
## 🚀 System Initialization
**Available Tools:** 8 tools loaded
---
I'll help you with this task. Let me start by examining the file to understand what needs to be changed.
### 🔧 `Read`
**Parameters:**
```json
{
"file_path": "/path/to/sample/file.py"
}
```
**Result:**
```python
def example_function():
print("Debug message") # This should be removed
return "Hello World"
if __name__ == "__main__":
result = example_function()
print(result)
```
*Token usage: 100 input, 75 output*
---
I can see the debug print statement that needs to be removed. Let me fix this by editing the file.
### 🔧 `Edit`
**Parameters:**
```json
{
"file_path": "/path/to/sample/file.py",
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
"new_string": "def example_function():\n return \"Hello World\""
}
```
**→** File successfully edited. The debug print statement has been removed.
*Token usage: 200 input, 50 output*
---
Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change.
### 🔧 `mcp__github__add_pull_request_review_comment`
**Parameters:**
```json
{
"owner": "example-org",
"repo": "example-repo",
"pull_number": 123,
"body": "Removed debug print statement as requested.",
"commit_id": "abc123def456",
"path": "sample/file.py",
"line": 2
}
```
**→** Successfully posted review comment to PR #123
*Token usage: 150 input, 80 output*
---
Great! I've successfully completed the requested task:
1. ✅ Located the debug print statement in the file
2. ✅ Removed the print statement while preserving the function logic
3. ✅ Added a review comment documenting the change
The debug print statement has been removed as requested by the reviewers.
*Token usage: 180 input, 60 output*
---
## ✅ Final Result
Successfully removed debug print statement from file and added review comment to document the change.
**Cost:** $0.0347 | **Duration:** 18.8s

196
test/fixtures/sample-turns.json vendored Normal file
View File

@@ -0,0 +1,196 @@
[
{
"type": "system",
"subtype": "init",
"session_id": "sample-session-id",
"tools": [
"Task",
"Bash",
"Read",
"Edit",
"Write",
"mcp__github__get_file_contents",
"mcp__github__create_or_update_file",
"mcp__github__add_pull_request_review_comment"
],
"mcp_servers": [
{
"name": "github",
"status": "connected"
}
]
},
{
"type": "assistant",
"message": {
"id": "msg_sample123",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "I'll help you with this task. Let me start by examining the file to understand what needs to be changed."
},
{
"type": "tool_use",
"id": "tool_call_1",
"name": "Read",
"input": {
"file_path": "/path/to/sample/file.py"
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 100,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 50,
"output_tokens": 75
}
},
"session_id": "sample-session-id"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_1",
"content": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"\n\nif __name__ == \"__main__\":\n result = example_function()\n print(result)",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_sample124",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "I can see the debug print statement that needs to be removed. Let me fix this by editing the file."
},
{
"type": "tool_use",
"id": "tool_call_2",
"name": "Edit",
"input": {
"file_path": "/path/to/sample/file.py",
"old_string": "def example_function():\n print(\"Debug message\") # This should be removed\n return \"Hello World\"",
"new_string": "def example_function():\n return \"Hello World\""
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 200,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 100,
"output_tokens": 50
}
},
"session_id": "sample-session-id"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_2",
"content": "File successfully edited. The debug print statement has been removed.",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_sample125",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "Perfect! I've successfully removed the debug print statement from the function. Let me now create a pull request comment to document this change."
},
{
"type": "tool_use",
"id": "tool_call_3",
"name": "mcp__github__add_pull_request_review_comment",
"input": {
"owner": "example-org",
"repo": "example-repo",
"pull_number": 123,
"body": "Removed debug print statement as requested.",
"commit_id": "abc123def456",
"path": "sample/file.py",
"line": 2
}
}
],
"stop_reason": "tool_use",
"stop_sequence": null,
"usage": {
"input_tokens": 150,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 75,
"output_tokens": 80
}
},
"session_id": "sample-session-id"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "tool_call_3",
"content": "Successfully posted review comment to PR #123",
"is_error": false
}
]
}
},
{
"type": "assistant",
"message": {
"id": "msg_sample126",
"type": "message",
"role": "assistant",
"model": "claude-test-model",
"content": [
{
"type": "text",
"text": "Great! I've successfully completed the requested task:\n\n1. ✅ Located the debug print statement in the file\n2. ✅ Removed the print statement while preserving the function logic\n3. ✅ Added a review comment documenting the change\n\nThe debug print statement has been removed as requested by the reviewers."
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {
"input_tokens": 180,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 90,
"output_tokens": 60
}
},
"session_id": "sample-session-id"
},
{
"type": "result",
"cost_usd": 0.0347,
"duration_ms": 18750,
"result": "Successfully removed debug print statement from file and added review comment to document the change."
}
]

439
test/format-turns.test.ts Normal file
View File

@@ -0,0 +1,439 @@
import { expect, test, describe } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
import {
formatTurnsFromData,
groupTurnsNaturally,
formatGroupedContent,
detectContentType,
formatResultContent,
formatToolWithResult,
type Turn,
type ToolUse,
type ToolResult,
} from "../src/entrypoints/format-turns";
describe("detectContentType", () => {
test("detects JSON objects", () => {
expect(detectContentType('{"key": "value"}')).toBe("json");
expect(detectContentType('{"number": 42}')).toBe("json");
});
test("detects JSON arrays", () => {
expect(detectContentType("[1, 2, 3]")).toBe("json");
expect(detectContentType('["a", "b"]')).toBe("json");
});
test("detects Python code", () => {
expect(detectContentType("def hello():\n pass")).toBe("python");
expect(detectContentType("import os")).toBe("python");
expect(detectContentType("from math import pi")).toBe("python");
});
test("detects JavaScript code", () => {
expect(detectContentType("function test() {}")).toBe("javascript");
expect(detectContentType("const x = 5")).toBe("javascript");
expect(detectContentType("let y = 10")).toBe("javascript");
expect(detectContentType("const fn = () => console.log()")).toBe(
"javascript",
);
});
test("detects bash/shell content", () => {
expect(detectContentType("/usr/bin/test")).toBe("bash");
expect(detectContentType("Error: command not found")).toBe("bash");
expect(detectContentType("ls -la")).toBe("bash");
expect(detectContentType("$ echo hello")).toBe("bash");
});
test("detects diff format", () => {
expect(detectContentType("@@ -1,3 +1,3 @@")).toBe("diff");
expect(detectContentType("+++ file.txt")).toBe("diff");
expect(detectContentType("--- file.txt")).toBe("diff");
});
test("detects HTML/XML", () => {
expect(detectContentType("<div>hello</div>")).toBe("html");
expect(detectContentType("<xml>content</xml>")).toBe("html");
});
test("detects markdown", () => {
expect(detectContentType("- List item")).toBe("markdown");
expect(detectContentType("* List item")).toBe("markdown");
expect(detectContentType("```code```")).toBe("markdown");
});
test("defaults to text", () => {
expect(detectContentType("plain text")).toBe("text");
expect(detectContentType("just some words")).toBe("text");
});
});
describe("formatResultContent", () => {
test("handles empty content", () => {
expect(formatResultContent("")).toBe("*(No output)*\n\n");
expect(formatResultContent(null)).toBe("*(No output)*\n\n");
expect(formatResultContent(undefined)).toBe("*(No output)*\n\n");
});
test("formats short text without code blocks", () => {
const result = formatResultContent("success");
expect(result).toBe("**→** success\n\n");
});
test("formats long text with code blocks", () => {
const longText =
"This is a longer piece of text that should be formatted in a code block because it exceeds the short text threshold";
const result = formatResultContent(longText);
expect(result).toContain("**Result:**");
expect(result).toContain("```text");
expect(result).toContain(longText);
});
test("pretty prints JSON content", () => {
const jsonContent = '{"key": "value", "number": 42}';
const result = formatResultContent(jsonContent);
expect(result).toContain("```json");
expect(result).toContain('"key": "value"');
expect(result).toContain('"number": 42');
});
test("truncates very long content", () => {
const veryLongContent = "A".repeat(4000);
const result = formatResultContent(veryLongContent);
expect(result).toContain("...");
// Should not contain the full long content
expect(result.length).toBeLessThan(veryLongContent.length);
});
test("handles type:text structure", () => {
const structuredContent = [{ type: "text", text: "Hello world" }];
const result = formatResultContent(JSON.stringify(structuredContent));
expect(result).toBe("**→** Hello world\n\n");
});
});
describe("formatToolWithResult", () => {
test("formats tool with parameters and result", () => {
const toolUse: ToolUse = {
type: "tool_use",
name: "read_file",
input: { file_path: "/path/to/file.txt" },
id: "tool_123",
};
const toolResult: ToolResult = {
type: "tool_result",
tool_use_id: "tool_123",
content: "File content here",
is_error: false,
};
const result = formatToolWithResult(toolUse, toolResult);
expect(result).toContain("### 🔧 `read_file`");
expect(result).toContain("**Parameters:**");
expect(result).toContain('"file_path": "/path/to/file.txt"');
expect(result).toContain("**→** File content here");
});
test("formats tool with error result", () => {
const toolUse: ToolUse = {
type: "tool_use",
name: "failing_tool",
input: { param: "value" },
};
const toolResult: ToolResult = {
type: "tool_result",
content: "Permission denied",
is_error: true,
};
const result = formatToolWithResult(toolUse, toolResult);
expect(result).toContain("### 🔧 `failing_tool`");
expect(result).toContain("❌ **Error:** `Permission denied`");
});
test("formats tool without parameters", () => {
const toolUse: ToolUse = {
type: "tool_use",
name: "simple_tool",
};
const result = formatToolWithResult(toolUse);
expect(result).toContain("### 🔧 `simple_tool`");
expect(result).not.toContain("**Parameters:**");
});
test("handles unknown tool name", () => {
const toolUse: ToolUse = {
type: "tool_use",
};
const result = formatToolWithResult(toolUse);
expect(result).toContain("### 🔧 `unknown_tool`");
});
});
describe("groupTurnsNaturally", () => {
test("groups system initialization", () => {
const data: Turn[] = [
{
type: "system",
subtype: "init",
tools: [{ name: "tool1" }, { name: "tool2" }],
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("system_init");
expect(result[0]?.tools_count).toBe(2);
});
test("groups assistant actions with tool calls", () => {
const data: Turn[] = [
{
type: "assistant",
message: {
content: [
{ type: "text", text: "I'll help you" },
{
type: "tool_use",
id: "tool_123",
name: "read_file",
input: { file_path: "/test.txt" },
},
],
usage: { input_tokens: 100, output_tokens: 50 },
},
},
{
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_123",
content: "file content",
is_error: false,
},
],
},
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("assistant_action");
expect(result[0]?.text_parts).toEqual(["I'll help you"]);
expect(result[0]?.tool_calls).toHaveLength(1);
expect(result[0]?.tool_calls?.[0]?.tool_use.name).toBe("read_file");
expect(result[0]?.tool_calls?.[0]?.tool_result?.content).toBe(
"file content",
);
expect(result[0]?.usage).toEqual({ input_tokens: 100, output_tokens: 50 });
});
test("groups user messages", () => {
const data: Turn[] = [
{
type: "user",
message: {
content: [{ type: "text", text: "Please help me" }],
},
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("user_message");
expect(result[0]?.text_parts).toEqual(["Please help me"]);
});
test("groups final results", () => {
const data: Turn[] = [
{
type: "result",
cost_usd: 0.1234,
duration_ms: 5000,
result: "Task completed",
},
];
const result = groupTurnsNaturally(data);
expect(result).toHaveLength(1);
expect(result[0]?.type).toBe("final_result");
expect(result[0]?.data).toEqual(data[0]!);
});
});
describe("formatGroupedContent", () => {
test("formats system initialization", () => {
const groupedContent = [
{
type: "system_init",
tools_count: 3,
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("## Claude Code Report");
expect(result).toContain("## 🚀 System Initialization");
expect(result).toContain("**Available Tools:** 3 tools loaded");
});
test("formats assistant actions", () => {
const groupedContent = [
{
type: "assistant_action",
text_parts: ["I'll help you with that"],
tool_calls: [
{
tool_use: {
type: "tool_use",
name: "test_tool",
input: { param: "value" },
},
tool_result: {
type: "tool_result",
content: "result",
is_error: false,
},
},
],
usage: { input_tokens: 100, output_tokens: 50 },
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("I'll help you with that");
expect(result).toContain("### 🔧 `test_tool`");
expect(result).toContain("*Token usage: 100 input, 50 output*");
});
test("formats user messages", () => {
const groupedContent = [
{
type: "user_message",
text_parts: ["Help me please"],
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("## 👤 User");
expect(result).toContain("Help me please");
});
test("formats final results", () => {
const groupedContent = [
{
type: "final_result",
data: {
type: "result",
cost_usd: 0.1234,
duration_ms: 5678,
result: "Success!",
} as Turn,
},
];
const result = formatGroupedContent(groupedContent);
expect(result).toContain("## ✅ Final Result");
expect(result).toContain("Success!");
expect(result).toContain("**Cost:** $0.1234");
expect(result).toContain("**Duration:** 5.7s");
});
});
describe("formatTurnsFromData", () => {
test("handles empty data", () => {
const result = formatTurnsFromData([]);
expect(result).toBe("## Claude Code Report\n\n");
});
test("formats complete conversation", () => {
const data: Turn[] = [
{
type: "system",
subtype: "init",
tools: [{ name: "tool1" }],
},
{
type: "assistant",
message: {
content: [
{ type: "text", text: "I'll help you" },
{
type: "tool_use",
id: "tool_123",
name: "read_file",
input: { file_path: "/test.txt" },
},
],
},
},
{
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "tool_123",
content: "file content",
is_error: false,
},
],
},
},
{
type: "result",
cost_usd: 0.05,
duration_ms: 2000,
result: "Done",
},
];
const result = formatTurnsFromData(data);
expect(result).toContain("## Claude Code Report");
expect(result).toContain("## 🚀 System Initialization");
expect(result).toContain("I'll help you");
expect(result).toContain("### 🔧 `read_file`");
expect(result).toContain("## ✅ Final Result");
expect(result).toContain("Done");
});
});
describe("integration tests", () => {
test("formats real conversation data correctly", () => {
// Load the sample JSON data
const jsonPath = join(__dirname, "fixtures", "sample-turns.json");
const expectedPath = join(
__dirname,
"fixtures",
"sample-turns-expected-output.md",
);
const jsonData = JSON.parse(readFileSync(jsonPath, "utf-8"));
const expectedOutput = readFileSync(expectedPath, "utf-8").trim();
// Format the data using our function
const actualOutput = formatTurnsFromData(jsonData).trim();
// Compare the outputs
expect(actualOutput).toBe(expectedOutput);
});
});

View File

@@ -1,5 +1,8 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { parseMultilineInput } from "../../src/github/context"; import {
parseMultilineInput,
parseAdditionalPermissions,
} from "../../src/github/context";
describe("parseMultilineInput", () => { describe("parseMultilineInput", () => {
it("should parse a comma-separated string", () => { it("should parse a comma-separated string", () => {
@@ -55,3 +58,58 @@ Bash(bun typecheck)
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
}); });
describe("parseAdditionalPermissions", () => {
it("should parse single permission", () => {
const input = "actions: read";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
it("should parse multiple permissions", () => {
const input = `actions: read
packages: write
contents: read`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.get("contents")).toBe("read");
expect(result.size).toBe(3);
});
it("should handle empty string", () => {
const input = "";
const result = parseAdditionalPermissions(input);
expect(result.size).toBe(0);
});
it("should handle whitespace and empty lines", () => {
const input = `
actions: read
packages: write
`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should ignore lines without colon separator", () => {
const input = `actions: read
invalid line
packages: write`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should trim whitespace around keys and values", () => {
const input = " actions : read ";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
});

View File

@@ -1,6 +1,7 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { prepareMcpConfig } from "../src/mcp/install-mcp-server"; import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../src/github/context";
describe("prepareMcpConfig", () => { describe("prepareMcpConfig", () => {
let consoleInfoSpy: any; let consoleInfoSpy: any;
@@ -8,6 +9,41 @@ describe("prepareMcpConfig", () => {
let setFailedSpy: any; let setFailedSpy: any;
let processExitSpy: any; let processExitSpy: any;
// Create a mock context for tests
const mockContext: ParsedGitHubContext = {
runId: "test-run-id",
eventName: "issue_comment",
eventAction: "created",
repository: {
owner: "test-owner",
repo: "test-repo",
full_name: "test-owner/test-repo",
},
actor: "test-actor",
payload: {} as any,
entityNumber: 123,
isPR: false,
inputs: {
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
branchPrefix: "",
useStickyComment: false,
additionalPermissions: new Map(),
},
};
const mockPRContext: ParsedGitHubContext = {
...mockContext,
eventName: "pull_request",
isPR: true,
entityNumber: 456,
};
beforeEach(() => { beforeEach(() => {
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {}); consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
@@ -15,6 +51,11 @@ describe("prepareMcpConfig", () => {
processExitSpy = spyOn(process, "exit").mockImplementation(() => { processExitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process exit"); throw new Error("Process exit");
}); });
// Set up required environment variables
if (!process.env.GITHUB_ACTION_PATH) {
process.env.GITHUB_ACTION_PATH = "/test/action/path";
}
}); });
afterEach(() => { afterEach(() => {
@@ -31,6 +72,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -57,6 +99,7 @@ describe("prepareMcpConfig", () => {
"mcp__github__create_issue", "mcp__github__create_issue",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -78,6 +121,7 @@ describe("prepareMcpConfig", () => {
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
"mcp__github_file_ops__update_claude_comment", "mcp__github_file_ops__update_claude_comment",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -93,6 +137,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: ["Edit", "Read", "Write"], allowedTools: ["Edit", "Read", "Write"],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -109,6 +154,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: "", additionalMcpConfig: "",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -126,6 +172,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: " \n\t ", additionalMcpConfig: " \n\t ",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -158,6 +205,7 @@ describe("prepareMcpConfig", () => {
"mcp__github__create_issue", "mcp__github__create_issue",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -195,6 +243,7 @@ describe("prepareMcpConfig", () => {
"mcp__github__create_issue", "mcp__github__create_issue",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -232,6 +281,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: additionalConfig, additionalMcpConfig: additionalConfig,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -251,6 +301,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: invalidJson, additionalMcpConfig: invalidJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -271,6 +322,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: nonObjectJson, additionalMcpConfig: nonObjectJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -294,6 +346,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: nullJson, additionalMcpConfig: nullJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -317,6 +370,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: arrayJson, additionalMcpConfig: arrayJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -363,6 +417,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: additionalConfig, additionalMcpConfig: additionalConfig,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -384,6 +439,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -404,6 +460,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -411,4 +468,132 @@ describe("prepareMcpConfig", () => {
process.env.GITHUB_WORKSPACE = oldEnv; process.env.GITHUB_WORKSPACE = oldEnv;
}); });
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
const oldEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.ACTIONS_TOKEN = oldEnv;
});
test("should not include github_ci server when context.isPR is false", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should not include github_ci server when actions:read permission is not granted", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
test("should parse additional_permissions with multiple lines correctly", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([
["actions", "read"],
["future", "permission"],
]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
test("should warn when actions:read is requested but token lacks permission", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "invalid-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining(
"The github_ci MCP server requires 'actions: read' permission",
),
);
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
}); });

View File

@@ -10,6 +10,7 @@ import type {
const defaultInputs = { const defaultInputs = {
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
anthropicModel: "claude-3-7-sonnet-20250219", anthropicModel: "claude-3-7-sonnet-20250219",
allowedTools: [] as string[], allowedTools: [] as string[],
disallowedTools: [] as string[], disallowedTools: [] as string[],
@@ -18,6 +19,9 @@ const defaultInputs = {
useBedrock: false, useBedrock: false,
useVertex: false, useVertex: false,
timeoutMinutes: 30, timeoutMinutes: 30,
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map<string, string>(),
}; };
const defaultRepository = { const defaultRepository = {
@@ -128,6 +132,46 @@ export const mockIssueAssignedContext: ParsedGitHubContext = {
inputs: { ...defaultInputs, assigneeTrigger: "@claude-bot" }, inputs: { ...defaultInputs, assigneeTrigger: "@claude-bot" },
}; };
export const mockIssueLabeledContext: ParsedGitHubContext = {
runId: "1234567890",
eventName: "issues",
eventAction: "labeled",
repository: defaultRepository,
actor: "admin-user",
payload: {
action: "labeled",
issue: {
number: 1234,
title: "Enhancement: Improve search functionality",
body: "The current search is too slow and needs optimization",
user: {
login: "alice-wonder",
id: 54321,
avatar_url: "https://avatars.githubusercontent.com/u/54321",
html_url: "https://github.com/alice-wonder",
},
assignee: null,
},
label: {
id: 987654321,
name: "claude-task",
color: "f29513",
description: "Label for Claude AI interactions",
},
repository: {
name: "test-repo",
full_name: "test-owner/test-repo",
private: false,
owner: {
login: "test-owner",
},
},
} as IssuesEvent,
entityNumber: 1234,
isPR: false,
inputs: { ...defaultInputs, labelTrigger: "claude-task" },
};
// Issue comment on issue event // Issue comment on issue event
export const mockIssueCommentContext: ParsedGitHubContext = { export const mockIssueCommentContext: ParsedGitHubContext = {
runId: "1234567890", runId: "1234567890",

View File

@@ -62,10 +62,14 @@ describe("checkWritePermissions", () => {
inputs: { inputs: {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
customInstructions: "", customInstructions: "",
directPrompt: "", directPrompt: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });

View File

@@ -219,6 +219,55 @@ describe("parseEnvVarsWithContext", () => {
), ),
).toThrow("BASE_BRANCH is required for issues event"); ).toThrow("BASE_BRANCH is required for issues event");
}); });
test("should allow issue assigned event with direct_prompt and no assigneeTrigger", () => {
const contextWithDirectPrompt = createMockContext({
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "", // No assignee trigger
directPrompt: "Please assess this issue", // But direct prompt is provided
},
});
const result = prepareContext(
contextWithDirectPrompt,
"12345",
"main",
"claude/issue-123-20240101_120000",
);
expect(result.eventData.eventName).toBe("issues");
expect(result.eventData.isPR).toBe(false);
expect(result.directPrompt).toBe("Please assess this issue");
if (
result.eventData.eventName === "issues" &&
result.eventData.eventAction === "assigned"
) {
expect(result.eventData.issueNumber).toBe("123");
expect(result.eventData.assigneeTrigger).toBeUndefined();
}
});
test("should throw error when neither assigneeTrigger nor directPrompt provided for issue assigned event", () => {
const contextWithoutTriggers = createMockContext({
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "", // No assignee trigger
directPrompt: "", // No direct prompt
},
});
expect(() =>
prepareContext(
contextWithoutTriggers,
"12345",
"main",
"claude/issue-123-20240101_120000",
),
).toThrow("ASSIGNEE_TRIGGER is required for issue assigned event");
});
}); });
describe("optional fields", () => { describe("optional fields", () => {

View File

@@ -6,6 +6,7 @@ import { describe, it, expect } from "bun:test";
import { import {
createMockContext, createMockContext,
mockIssueAssignedContext, mockIssueAssignedContext,
mockIssueLabeledContext,
mockIssueCommentContext, mockIssueCommentContext,
mockIssueOpenedContext, mockIssueOpenedContext,
mockPullRequestReviewContext, mockPullRequestReviewContext,
@@ -29,10 +30,14 @@ describe("checkContainsTrigger", () => {
inputs: { inputs: {
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
directPrompt: "Fix the bug in the login form", directPrompt: "Fix the bug in the login form",
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
customInstructions: "", customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -55,10 +60,14 @@ describe("checkContainsTrigger", () => {
inputs: { inputs: {
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
customInstructions: "", customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);
@@ -107,6 +116,39 @@ describe("checkContainsTrigger", () => {
}); });
}); });
describe("label trigger", () => {
it("should return true when issue is labeled with the trigger label", () => {
const context = mockIssueLabeledContext;
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when issue is labeled with a different label", () => {
const context = {
...mockIssueLabeledContext,
payload: {
...mockIssueLabeledContext.payload,
label: {
...(mockIssueLabeledContext.payload as any).label,
name: "bug",
},
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
it("should return false for non-labeled events", () => {
const context = {
...mockIssueLabeledContext,
eventAction: "opened",
payload: {
...mockIssueLabeledContext.payload,
action: "opened",
},
} as ParsedGitHubContext;
expect(checkContainsTrigger(context)).toBe(false);
});
});
describe("issue body and title trigger", () => { describe("issue body and title trigger", () => {
it("should return true when issue body contains trigger phrase", () => { it("should return true when issue body contains trigger phrase", () => {
const context = mockIssueOpenedContext; const context = mockIssueOpenedContext;
@@ -232,10 +274,14 @@ describe("checkContainsTrigger", () => {
inputs: { inputs: {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
customInstructions: "", customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -259,10 +305,14 @@ describe("checkContainsTrigger", () => {
inputs: { inputs: {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
customInstructions: "", customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -286,10 +336,14 @@ describe("checkContainsTrigger", () => {
inputs: { inputs: {
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "",
directPrompt: "", directPrompt: "",
allowedTools: [], allowedTools: [],
disallowedTools: [], disallowedTools: [],
customInstructions: "", customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);