mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87facd7051 | ||
|
|
a804c9e83f | ||
|
|
d6bc8ddf8a | ||
|
|
86665d0984 | ||
|
|
6364776f60 | ||
|
|
e43c1b7fac | ||
|
|
23fae74fdb | ||
|
|
3c739a8cf3 | ||
|
|
aa28d465c5 | ||
|
|
55b7205cd2 | ||
|
|
8fe405c45f | ||
|
|
73012199e4 | ||
|
|
459b56e54d | ||
|
|
00f9595fb4 | ||
|
|
79f2086fce | ||
|
|
bcb072b63f | ||
|
|
e3b3e531a7 | ||
|
|
a7665d3698 | ||
|
|
91c510a769 | ||
|
|
1e006bf2d0 | ||
|
|
ece712ea81 | ||
|
|
032008d3b6 | ||
|
|
b0d9b8c4cd | ||
|
|
c831be8f54 | ||
|
|
38254908ae | ||
|
|
882586e496 | ||
|
|
28aaa5404d | ||
|
|
ebbd9e9be4 |
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
|||||||
"--rm",
|
"--rm",
|
||||||
"-e",
|
"-e",
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
"ghcr.io/github/github-mcp-server:sha-6d69797"
|
"ghcr.io/github/github-mcp-server:sha-721fd3e"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Test fixtures should not be formatted to preserve exact output matching
|
||||||
|
test/fixtures/
|
||||||
29
FAQ.md
29
FAQ.md
@@ -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:
|
||||||
@@ -47,14 +51,29 @@ allowed_tools: "Bash(git rebase:*)" # Use with caution
|
|||||||
|
|
||||||
Claude doesn't create PRs by default. Instead, it pushes commits to a branch and provides a link to a pre-filled PR submission page. This approach ensures your repository's branch protection rules are still adhered to and gives you final control over PR creation.
|
Claude doesn't create PRs by default. Instead, it pushes commits to a branch and provides a link to a pre-filled PR submission page. This approach ensures your repository's branch protection rules are still adhered to and gives you final control over PR creation.
|
||||||
|
|
||||||
### Why can't Claude run my tests or see CI results?
|
### Can Claude see my GitHub Actions CI results?
|
||||||
|
|
||||||
Claude cannot access GitHub Actions logs, test results, or other CI/CD outputs by default. It only has access to the repository files. If you need Claude to see test results, you can either:
|
Yes! Claude can access GitHub Actions workflow runs, job logs, and test results on the PR where it's tagged. To enable this:
|
||||||
|
|
||||||
1. Instruct Claude to run tests before making commits
|
1. Add `actions: read` permission to your workflow:
|
||||||
2. Copy and paste CI results into a comment for Claude to analyze
|
|
||||||
|
|
||||||
This limitation exists for security reasons but may be reconsidered in the future based on user feedback.
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Configure the action with additional permissions:
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude will then be able to analyze CI failures and help debug workflow issues. For running tests locally before commits, you can still instruct Claude to do so in your request.
|
||||||
|
|
||||||
### Why does Claude only update one comment instead of creating new ones?
|
### Why does Claude only update one comment instead of creating new ones?
|
||||||
|
|
||||||
|
|||||||
153
README.md
153
README.md
@@ -30,7 +30,9 @@ This command will guide you through setting up the GitHub app and required secre
|
|||||||
**Requirements**: You must be a repository admin to complete these steps.
|
**Requirements**: You must be a repository admin to complete these steps.
|
||||||
|
|
||||||
1. Install the Claude GitHub app to your repository: https://github.com/apps/claude
|
1. Install the Claude GitHub app to your repository: https://github.com/apps/claude
|
||||||
2. Add `ANTHROPIC_API_KEY` to your repository secrets ([Learn how to use secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions))
|
2. Add authentication to your repository secrets ([Learn how to use secrets in GitHub Actions](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions)):
|
||||||
|
- Either `ANTHROPIC_API_KEY` for API key authentication
|
||||||
|
- Or `CLAUDE_CODE_OAUTH_TOKEN` for OAuth token authentication (Pro and Max users can generate this by running `claude setup-token` locally)
|
||||||
3. Copy the workflow file from [`examples/claude.yml`](./examples/claude.yml) into your repository's `.github/workflows/`
|
3. Copy the workflow file from [`examples/claude.yml`](./examples/claude.yml) into your repository's `.github/workflows/`
|
||||||
|
|
||||||
## 📚 FAQ
|
## 📚 FAQ
|
||||||
@@ -49,7 +51,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]
|
||||||
|
|
||||||
@@ -60,11 +62,15 @@ jobs:
|
|||||||
- uses: anthropics/claude-code-action@beta
|
- uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Or use OAuth token instead:
|
||||||
|
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# 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 +78,38 @@ 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 | - |
|
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | 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 | - |
|
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `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_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||||
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
||||||
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
| `use_bedrock` | Use Amazon Bedrock 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 | "" |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
|
||||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
| `disallowed_tools` | Tools that Claude should never use | No | "" |
|
||||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||||
|
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||||
|
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||||
|
| `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)
|
||||||
|
|
||||||
@@ -319,6 +335,7 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
|||||||
- When triggered on an **issue**: Always creates a new branch for the work
|
- When triggered on an **issue**: Always creates a new branch for the work
|
||||||
- When triggered on an **open PR**: Always pushes directly to the existing PR branch
|
- When triggered on an **open PR**: Always pushes directly to the existing PR branch
|
||||||
- When triggered on a **closed PR**: Creates a new branch since the original is no longer active
|
- When triggered on a **closed PR**: Creates a new branch since the original is no longer active
|
||||||
|
- **View GitHub Actions Results**: Can access workflow runs, job logs, and test results on the PR where it's tagged when `actions: read` permission is configured (see [Additional Permissions for CI/CD Integration](#additional-permissions-for-cicd-integration))
|
||||||
|
|
||||||
### What Claude Cannot Do
|
### What Claude Cannot Do
|
||||||
|
|
||||||
@@ -327,11 +344,79 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
|
|||||||
- **Post Multiple Comments**: Claude only acts by updating its initial comment
|
- **Post Multiple Comments**: Claude only acts by updating its initial comment
|
||||||
- **Execute Commands Outside Its Context**: Claude only has access to the repository and PR/issue context it's triggered in
|
- **Execute Commands Outside Its Context**: Claude only has access to the repository and PR/issue context it's triggered in
|
||||||
- **Run Arbitrary Bash Commands**: By default, Claude cannot execute Bash commands unless explicitly allowed using the `allowed_tools` configuration
|
- **Run Arbitrary Bash Commands**: By default, Claude cannot execute Bash commands unless explicitly allowed using the `allowed_tools` configuration
|
||||||
- **View CI/CD Results**: Cannot access CI systems, test results, or build logs unless an additional tool or MCP server is configured
|
|
||||||
- **Perform Branch Operations**: Cannot merge branches, rebase, or perform other git operations beyond pushing commits
|
- **Perform Branch Operations**: Cannot merge branches, rebase, or perform other git operations beyond pushing commits
|
||||||
|
|
||||||
## Advanced Configuration
|
## Advanced Configuration
|
||||||
|
|
||||||
|
### Additional Permissions for CI/CD Integration
|
||||||
|
|
||||||
|
The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues.
|
||||||
|
|
||||||
|
#### Enabling GitHub Actions Access
|
||||||
|
|
||||||
|
To allow Claude to view workflow run results, job logs, and CI status:
|
||||||
|
|
||||||
|
1. **Grant the necessary permission to your GitHub token**:
|
||||||
|
|
||||||
|
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read # Add this line
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure the action with additional permissions**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
# ... other inputs
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Claude will automatically get access to CI/CD tools**:
|
||||||
|
When you enable `actions: read`, Claude can use the following MCP tools:
|
||||||
|
- `mcp__github_ci__get_ci_status` - View workflow run statuses
|
||||||
|
- `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information
|
||||||
|
- `mcp__github_ci__download_job_log` - Download and analyze job logs
|
||||||
|
|
||||||
|
#### Example: Debugging Failed CI Runs
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Claude CI Helper
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
actions: read # Required for CI access
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-ci-helper:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: anthropics/claude-code-action@beta
|
||||||
|
with:
|
||||||
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
# Now Claude can respond to "@claude why did the CI fail?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes**:
|
||||||
|
|
||||||
|
- The GitHub token must have the `actions: read` permission in your workflow
|
||||||
|
- If the permission is missing, Claude will warn you and suggest adding it
|
||||||
|
- Currently, only `actions: read` is supported, but the format allows for future extensions
|
||||||
|
|
||||||
### Custom Environment Variables
|
### Custom Environment Variables
|
||||||
|
|
||||||
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:
|
||||||
@@ -524,18 +609,21 @@ The [Claude Code GitHub app](https://github.com/apps/claude) requires these perm
|
|||||||
|
|
||||||
All commits made by Claude through this action are automatically signed with commit signatures. This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
All commits made by Claude through this action are automatically signed with commit signatures. This ensures the authenticity and integrity of commits, providing a verifiable trail of changes made by the action.
|
||||||
|
|
||||||
### ⚠️ ANTHROPIC_API_KEY Protection
|
### ⚠️ Authentication Protection
|
||||||
|
|
||||||
**CRITICAL: Never hardcode your Anthropic API key in workflow files!**
|
**CRITICAL: Never hardcode your Anthropic API key or OAuth token in workflow files!**
|
||||||
|
|
||||||
Your ANTHROPIC_API_KEY must always be stored in GitHub secrets to prevent unauthorized access:
|
Your authentication credentials must always be stored in GitHub secrets to prevent unauthorized access:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# CORRECT ✅
|
# CORRECT ✅
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# OR
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
# NEVER DO THIS ❌
|
# NEVER DO THIS ❌
|
||||||
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
||||||
|
claude_code_oauth_token: "oauth_token_..." # Exposed and vulnerable!
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setting Up GitHub Secrets
|
### Setting Up GitHub Secrets
|
||||||
@@ -543,17 +631,18 @@ anthropic_api_key: "sk-ant-api03-..." # Exposed and vulnerable!
|
|||||||
1. Go to your repository's Settings
|
1. Go to your repository's Settings
|
||||||
2. Click on "Secrets and variables" → "Actions"
|
2. Click on "Secrets and variables" → "Actions"
|
||||||
3. Click "New repository secret"
|
3. Click "New repository secret"
|
||||||
4. Name: `ANTHROPIC_API_KEY`
|
4. For authentication, choose one:
|
||||||
5. Value: Your Anthropic API key (starting with `sk-ant-`)
|
- API Key: Name: `ANTHROPIC_API_KEY`, Value: Your Anthropic API key (starting with `sk-ant-`)
|
||||||
6. Click "Add secret"
|
- OAuth Token: Name: `CLAUDE_CODE_OAUTH_TOKEN`, Value: Your Claude Code OAuth token (Pro and Max users can generate this by running `claude setup-token` locally)
|
||||||
|
5. Click "Add secret"
|
||||||
|
|
||||||
### Best Practices for ANTHROPIC_API_KEY
|
### Best Practices for Authentication
|
||||||
|
|
||||||
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` in workflows
|
1. ✅ Always use `${{ secrets.ANTHROPIC_API_KEY }}` or `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` in workflows
|
||||||
2. ✅ Never commit API keys to version control
|
2. ✅ Never commit API keys or tokens to version control
|
||||||
3. ✅ Regularly rotate your API keys
|
3. ✅ Regularly rotate your API keys and tokens
|
||||||
4. ✅ Use environment secrets for organization-wide access
|
4. ✅ Use environment secrets for organization-wide access
|
||||||
5. ❌ Never share API keys in pull requests or issues
|
5. ❌ Never share API keys or tokens in pull requests or issues
|
||||||
6. ❌ Avoid logging workflow variables that might contain keys
|
6. ❌ Avoid logging workflow variables that might contain keys
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
55
action.yml
55
action.yml
@@ -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
|
||||||
@@ -50,6 +65,9 @@ inputs:
|
|||||||
anthropic_api_key:
|
anthropic_api_key:
|
||||||
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
description: "Anthropic API key (required for direct API, not needed for Bedrock/Vertex)"
|
||||||
required: false
|
required: false
|
||||||
|
claude_code_oauth_token:
|
||||||
|
description: "Claude Code OAuth token (alternative to anthropic_api_key)"
|
||||||
|
required: false
|
||||||
github_token:
|
github_token:
|
||||||
description: "GitHub token with repo and pull request permissions (optional if using GitHub App)"
|
description: "GitHub token with repo and pull request permissions (optional if using GitHub App)"
|
||||||
required: false
|
required: false
|
||||||
@@ -70,6 +88,14 @@ 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"
|
||||||
|
use_commit_signing:
|
||||||
|
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -98,7 +124,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 +134,15 @@ 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 }}
|
||||||
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
uses: anthropics/claude-code-base-action@56355f77b19f27378aaf141b9b7e08cc43b542f6 # v0.0.23
|
uses: anthropics/claude-code-base-action@3560d21b41bd19b1d3ac6c9000af378903d8df0e # v0.0.32
|
||||||
with:
|
with:
|
||||||
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||||
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
||||||
@@ -118,15 +150,18 @@ 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 }}
|
||||||
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
anthropic_api_key: ${{ inputs.anthropic_api_key }}
|
||||||
|
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
|
||||||
claude_env: ${{ inputs.claude_env }}
|
claude_env: ${{ inputs.claude_env }}
|
||||||
env:
|
env:
|
||||||
# Model configuration
|
# Model configuration
|
||||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||||
|
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
ANTHROPIC_BASE_URL: ${{ env.ANTHROPIC_BASE_URL }}
|
||||||
@@ -170,15 +205,25 @@ 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 }}
|
||||||
|
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||||
|
|
||||||
- name: Display Claude Code Report
|
- name: Display Claude Code Report
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||||
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 == ''
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
name: Claude Task Executor
|
|
||||||
|
|
||||||
on:
|
|
||||||
repository_dispatch:
|
|
||||||
types: [claude-task]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
id-token: write # Required for OIDC authentication
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
execute-claude-task:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Execute Claude Task
|
|
||||||
uses: anthropics/claude-code-action@main
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Base branch for creating task branches
|
|
||||||
base_branch: main
|
|
||||||
# Optional: Custom instructions for Claude
|
|
||||||
custom_instructions: |
|
|
||||||
Follow the CLAUDE.md guidelines strictly.
|
|
||||||
Commit changes with descriptive messages.
|
|
||||||
# Optional: Tool restrictions
|
|
||||||
allowed_tools: |
|
|
||||||
file_editor
|
|
||||||
bash_command
|
|
||||||
github_comment
|
|
||||||
mcp__github__create_or_update_file
|
|
||||||
# Optional: Anthropic API configuration
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
# Or use AWS Bedrock
|
|
||||||
# aws_access_key: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
# aws_secret_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
# aws_region: us-east-1
|
|
||||||
# Or use Google Vertex AI
|
|
||||||
# google_credentials: ${{ secrets.GOOGLE_CREDENTIALS }}
|
|
||||||
# vertex_project: my-project
|
|
||||||
# vertex_location: us-central1
|
|
||||||
# Example: Triggering this workflow from another service
|
|
||||||
#
|
|
||||||
# curl -X POST \
|
|
||||||
# https://api.github.com/repos/owner/repo/dispatches \
|
|
||||||
# -H "Authorization: token $GITHUB_TOKEN" \
|
|
||||||
# -H "Accept: application/vnd.github.v3+json" \
|
|
||||||
# -d '{
|
|
||||||
# "event_type": "claude-task",
|
|
||||||
# "client_payload": {
|
|
||||||
# "description": "Analyze the codebase and create a comprehensive test suite for the authentication module",
|
|
||||||
# "progress_endpoint": "https://api.example.com/claude/progress",
|
|
||||||
# "correlation_id": "task-auth-tests-2024-01-17"
|
|
||||||
# }
|
|
||||||
# }'
|
|
||||||
#
|
|
||||||
# The progress_endpoint will receive POST requests with:
|
|
||||||
# {
|
|
||||||
# "repository": "owner/repo",
|
|
||||||
# "run_id": "123456789",
|
|
||||||
# "correlation_id": "task-auth-tests-2024-01-17",
|
|
||||||
# "status": "in_progress" | "completed" | "failed",
|
|
||||||
# "message": "Current progress description",
|
|
||||||
# "completed_tasks": ["task1", "task2"],
|
|
||||||
# "current_task": "Working on task3",
|
|
||||||
# "timestamp": "2024-01-17T12:00:00Z"
|
|
||||||
# }
|
|
||||||
#
|
|
||||||
# Authentication: Progress updates include a GitHub OIDC token in the Authorization header
|
|
||||||
@@ -33,4 +33,6 @@ jobs:
|
|||||||
uses: anthropics/claude-code-action@beta
|
uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Or use OAuth token instead:
|
||||||
|
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
timeout_minutes: "60"
|
timeout_minutes: "60"
|
||||||
|
|||||||
118
pr-summary.md
118
pr-summary.md
@@ -1,118 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
Adds support for `repository_dispatch` events, enabling backend services to programmatically trigger Claude to perform tasks and receive progress updates via API.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Backend as Backend Service
|
|
||||||
participant GH as GitHub
|
|
||||||
participant Action as Claude Action
|
|
||||||
participant Claude as Claude
|
|
||||||
participant MCP as Progress MCP Server
|
|
||||||
participant API as Progress API
|
|
||||||
|
|
||||||
Backend->>GH: POST /repos/{owner}/{repo}/dispatches
|
|
||||||
Note over Backend,GH: Payload includes:<br/>- description (task)<br/>- progress_endpoint<br/>- correlation_id
|
|
||||||
|
|
||||||
GH->>Action: Trigger workflow<br/>(repository_dispatch)
|
|
||||||
|
|
||||||
Action->>Action: Parse dispatch payload
|
|
||||||
Note over Action: Extract task description,<br/>endpoint, correlation_id
|
|
||||||
|
|
||||||
Action->>MCP: Install Progress Server
|
|
||||||
Note over MCP: Configure with:<br/>- PROGRESS_ENDPOINT<br/>- CORRELATION_ID<br/>- GITHUB_RUN_ID
|
|
||||||
|
|
||||||
Action->>Claude: Execute task with<br/>MCP tools available
|
|
||||||
|
|
||||||
loop Task Execution
|
|
||||||
Claude->>MCP: update_claude_progress()
|
|
||||||
MCP->>MCP: Get OIDC token
|
|
||||||
MCP->>API: POST progress update
|
|
||||||
Note over API: Payload includes:<br/>- correlation_id<br/>- status<br/>- message<br/>- completed_tasks
|
|
||||||
API->>Backend: Forward update
|
|
||||||
end
|
|
||||||
|
|
||||||
Claude->>Action: Task complete
|
|
||||||
Action->>GH: Commit changes
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### 1. Repository Dispatch Support
|
|
||||||
|
|
||||||
- New event handler for `repository_dispatch` events
|
|
||||||
- Extracts task description, progress endpoint, and correlation ID from `client_payload`
|
|
||||||
- Bypasses GitHub UI interaction for fully programmatic operation
|
|
||||||
|
|
||||||
### 2. Progress Reporting MCP Server
|
|
||||||
|
|
||||||
- New MCP server (`progress-server.ts`) for sending progress updates
|
|
||||||
- OIDC authentication for secure API communication
|
|
||||||
- Includes correlation ID in all updates for request tracking
|
|
||||||
|
|
||||||
### 3. Simplified Dispatch Prompts
|
|
||||||
|
|
||||||
- Focused instructions for dispatch events (no PR/issue context)
|
|
||||||
- Clear directives: answer questions or implement changes
|
|
||||||
- Automatic progress updates at start and completion
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Triggering a Dispatch
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST \
|
|
||||||
https://api.github.com/repos/{owner}/{repo}/dispatches \
|
|
||||||
-H "Authorization: token $GITHUB_TOKEN" \
|
|
||||||
-H "Accept: application/vnd.github.v3+json" \
|
|
||||||
-d '{
|
|
||||||
"event_type": "claude-task",
|
|
||||||
"client_payload": {
|
|
||||||
"description": "Implement a new feature that...",
|
|
||||||
"progress_endpoint": "https://api.example.com/progress",
|
|
||||||
"correlation_id": "req-123-abc"
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Progress Update Payload
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"repository": "owner/repo",
|
|
||||||
"run_id": "123456789",
|
|
||||||
"correlation_id": "req-123-abc",
|
|
||||||
"status": "in_progress",
|
|
||||||
"message": "Implementing feature...",
|
|
||||||
"completed_tasks": ["Setup environment", "Created base structure"],
|
|
||||||
"current_task": "Writing tests",
|
|
||||||
"timestamp": "2024-01-17T12:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
- **OIDC Authentication**: All progress updates use GitHub OIDC tokens
|
|
||||||
- **Correlation IDs**: Included in request body (not URL) for security
|
|
||||||
- **Endpoint Validation**: Progress endpoint must be explicitly provided
|
|
||||||
- **No Credential Storage**: Tokens are generated per-request
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
To test the repository_dispatch flow:
|
|
||||||
|
|
||||||
1. Configure workflow with `repository_dispatch` trigger
|
|
||||||
2. Send dispatch event with required payload
|
|
||||||
3. Monitor GitHub Actions logs for execution
|
|
||||||
4. Verify progress updates at configured endpoint
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
- Added `repository_dispatch` event handling in `context.ts`
|
|
||||||
- Created new `progress-server.ts` MCP server
|
|
||||||
- Updated `isDispatch` flag across all event types
|
|
||||||
- Modified prompt generation for dispatch events
|
|
||||||
- Made `githubData` optional for dispatch workflows
|
|
||||||
- Added correlation ID support throughout the pipeline
|
|
||||||
@@ -30,15 +30,49 @@ const BASE_ALLOWED_TOOLS = [
|
|||||||
"LS",
|
"LS",
|
||||||
"Read",
|
"Read",
|
||||||
"Write",
|
"Write",
|
||||||
"mcp__github_file_ops__commit_files",
|
|
||||||
"mcp__github_file_ops__delete_files",
|
|
||||||
"mcp__github_file_ops__update_claude_comment",
|
|
||||||
];
|
];
|
||||||
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
|
||||||
|
|
||||||
export function buildAllowedToolsString(customAllowedTools?: string[]): string {
|
export function buildAllowedToolsString(
|
||||||
|
customAllowedTools?: string[],
|
||||||
|
includeActionsTools: boolean = false,
|
||||||
|
useCommitSigning: boolean = false,
|
||||||
|
): string {
|
||||||
let baseTools = [...BASE_ALLOWED_TOOLS];
|
let baseTools = [...BASE_ALLOWED_TOOLS];
|
||||||
|
|
||||||
|
// Always include the comment update tool from the comment server
|
||||||
|
baseTools.push("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Add commit signing tools if enabled
|
||||||
|
if (useCommitSigning) {
|
||||||
|
baseTools.push(
|
||||||
|
"mcp__github_file_ops__commit_files",
|
||||||
|
"mcp__github_file_ops__delete_files",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// When not using commit signing, add specific Bash git commands only
|
||||||
|
baseTools.push(
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git rm:*)",
|
||||||
|
"Bash(git config user.name:*)",
|
||||||
|
"Bash(git config user.email:*)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add GitHub Actions MCP tools if enabled
|
||||||
|
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 +115,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 +277,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 +289,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 +376,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":
|
||||||
@@ -347,9 +402,68 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCommitInstructions(
|
||||||
|
eventData: EventData,
|
||||||
|
githubData: FetchDataResult,
|
||||||
|
context: PreparedContext,
|
||||||
|
useCommitSigning: boolean,
|
||||||
|
): string {
|
||||||
|
const coAuthorLine =
|
||||||
|
(githubData.triggerDisplayName ?? context.triggerUsername !== "Unknown")
|
||||||
|
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (useCommitSigning) {
|
||||||
|
if (eventData.isPR && !eventData.claudeBranch) {
|
||||||
|
return `
|
||||||
|
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
|
||||||
|
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||||
|
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||||
|
- Use: "${coAuthorLine}"`;
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||||
|
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
|
||||||
|
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
||||||
|
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
||||||
|
- Use: "${coAuthorLine}"`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-signing instructions
|
||||||
|
if (eventData.isPR && !eventData.claudeBranch) {
|
||||||
|
return `
|
||||||
|
- Use git commands via the Bash tool to commit and push your changes:
|
||||||
|
- Stage files: Bash(git add <files>)
|
||||||
|
- Commit with a descriptive message: Bash(git commit -m "<message>")
|
||||||
|
${
|
||||||
|
coAuthorLine
|
||||||
|
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
|
||||||
|
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
- Push to the remote: Bash(git push origin HEAD)`;
|
||||||
|
} else {
|
||||||
|
const branchName = eventData.claudeBranch || eventData.baseBranch;
|
||||||
|
return `
|
||||||
|
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
||||||
|
- Use git commands via the Bash tool to commit and push your changes:
|
||||||
|
- Stage files: Bash(git add <files>)
|
||||||
|
- Commit with a descriptive message: Bash(git commit -m "<message>")
|
||||||
|
${
|
||||||
|
coAuthorLine
|
||||||
|
? `- When committing and the trigger user is not "Unknown", include a Co-authored-by trailer:
|
||||||
|
Bash(git commit -m "<message>\\n\\n${coAuthorLine}")`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
- Push to the remote: Bash(git push origin ${branchName})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function generatePrompt(
|
export function generatePrompt(
|
||||||
context: PreparedContext,
|
context: PreparedContext,
|
||||||
githubData: FetchDataResult,
|
githubData: FetchDataResult,
|
||||||
|
useCommitSigning: boolean,
|
||||||
): string {
|
): string {
|
||||||
const {
|
const {
|
||||||
contextData,
|
contextData,
|
||||||
@@ -438,9 +552,9 @@ ${sanitizeContent(context.directPrompt)}
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
${`<comment_tool_info>
|
${`<comment_tool_info>
|
||||||
IMPORTANT: You have been provided with the mcp__github_file_ops__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments.
|
IMPORTANT: You have been provided with the mcp__github_comment__update_claude_comment tool to update your comment. This tool automatically handles both issue and PR comments.
|
||||||
|
|
||||||
Tool usage example for mcp__github_file_ops__update_claude_comment:
|
Tool usage example for mcp__github_comment__update_claude_comment:
|
||||||
{
|
{
|
||||||
"body": "Your comment text here"
|
"body": "Your comment text here"
|
||||||
}
|
}
|
||||||
@@ -459,12 +573,13 @@ Follow these steps:
|
|||||||
1. Create a Todo List:
|
1. Create a Todo List:
|
||||||
- Use your GitHub comment to maintain a detailed task list based on the request.
|
- Use your GitHub comment to maintain a detailed task list based on the request.
|
||||||
- Format todos as a checklist (- [ ] for incomplete, - [x] for complete).
|
- Format todos as a checklist (- [ ] for incomplete, - [x] for complete).
|
||||||
- Update the comment using mcp__github_file_ops__update_claude_comment with each task completion.
|
- Update the comment using mcp__github_comment__update_claude_comment with each task completion.
|
||||||
|
|
||||||
2. Gather Context:
|
2. Gather Context:
|
||||||
- Analyze the pre-fetched data provided above.
|
- Analyze the pre-fetched data provided above.
|
||||||
- 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.
|
||||||
@@ -489,29 +604,16 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
|||||||
- Look for bugs, security issues, performance problems, and other issues
|
- Look for bugs, security issues, performance problems, and other issues
|
||||||
- Suggest improvements for readability and maintainability
|
- Suggest improvements for readability and maintainability
|
||||||
- Check for best practices and coding standards
|
- Check for best practices and coding standards
|
||||||
- Reference specific code sections with file paths and line numbers${eventData.isPR ? "\n - AFTER reading files and analyzing code, you MUST call mcp__github_file_ops__update_claude_comment to post your review" : ""}
|
- Reference specific code sections with file paths and line numbers${eventData.isPR ? `\n - AFTER reading files and analyzing code, you MUST call mcp__github_comment__update_claude_comment to post your review` : ""}
|
||||||
- Formulate a concise, technical, and helpful response based on the context.
|
- Formulate a concise, technical, and helpful response based on the context.
|
||||||
- Reference specific code with inline formatting or code blocks.
|
- Reference specific code with inline formatting or code blocks.
|
||||||
- Include relevant file paths and line numbers when applicable.
|
- Include relevant file paths and line numbers when applicable.
|
||||||
- ${eventData.isPR ? "IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_file_ops__update_claude_comment. This will be displayed as your PR review." : "Remember that this feedback must be posted to the GitHub comment using mcp__github_file_ops__update_claude_comment."}
|
- ${eventData.isPR ? `IMPORTANT: Submit your review feedback by updating the Claude comment using mcp__github_comment__update_claude_comment. This will be displayed as your PR review.` : `Remember that this feedback must be posted to the GitHub comment using mcp__github_comment__update_claude_comment.`}
|
||||||
|
|
||||||
B. For Straightforward Changes:
|
B. For Straightforward Changes:
|
||||||
- Use file system tools to make the change locally.
|
- Use file system tools to make the change locally.
|
||||||
- If you discover related tasks (e.g., updating tests), add them to the todo list.
|
- If you discover related tasks (e.g., updating tests), add them to the todo list.
|
||||||
- Mark each subtask as completed as you progress.
|
- Mark each subtask as completed as you progress.${getCommitInstructions(eventData, githubData, context, useCommitSigning)}
|
||||||
${
|
|
||||||
eventData.isPR && !eventData.claudeBranch
|
|
||||||
? `
|
|
||||||
- Push directly using mcp__github_file_ops__commit_files to the existing branch (works for both new and existing files).
|
|
||||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
|
||||||
- When pushing changes with this tool and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
|
||||||
- Use: "Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>"`
|
|
||||||
: `
|
|
||||||
- You are already on the correct branch (${eventData.claudeBranch || "the PR branch"}). Do not create a new branch.
|
|
||||||
- Push changes directly to the current branch using mcp__github_file_ops__commit_files (works for both new and existing files)
|
|
||||||
- Use mcp__github_file_ops__commit_files to commit files atomically in a single commit (supports single or multiple files).
|
|
||||||
- When pushing changes and the trigger user is not "Unknown", include a Co-authored-by trailer in the commit message.
|
|
||||||
- Use: "Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>"
|
|
||||||
${
|
${
|
||||||
eventData.claudeBranch
|
eventData.claudeBranch
|
||||||
? `- Provide a URL to create a PR manually in this format:
|
? `- Provide a URL to create a PR manually in this format:
|
||||||
@@ -529,7 +631,6 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
|||||||
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
- The signature: "Generated with [Claude Code](https://claude.ai/code)"
|
||||||
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
- Just include the markdown link with text "Create a PR" - do not add explanatory text before it like "You can create a PR using this link"`
|
||||||
: ""
|
: ""
|
||||||
}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
C. For Complex Changes:
|
C. For Complex Changes:
|
||||||
@@ -545,20 +646,31 @@ ${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was prov
|
|||||||
- Always update the GitHub comment to reflect the current todo state.
|
- Always update the GitHub comment to reflect the current todo state.
|
||||||
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
|
- When all todos are completed, remove the spinner and add a brief summary of what was accomplished, and what was not done.
|
||||||
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
|
- Note: If you see previous Claude comments with headers like "**Claude finished @user's task**" followed by "---", do not include this in your comment. The system adds this automatically.
|
||||||
- If you changed any files locally, you must update them in the remote branch via mcp__github_file_ops__commit_files before saying that you're done.
|
- If you changed any files locally, you must update them in the remote branch via ${useCommitSigning ? "mcp__github_file_ops__commit_files" : "git commands (add, commit, push)"} before saying that you're done.
|
||||||
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
|
${eventData.claudeBranch ? `- If you created anything in your branch, your comment must include the PR URL with prefilled title and body mentioned above.` : ""}
|
||||||
|
|
||||||
Important Notes:
|
Important Notes:
|
||||||
- All communication must happen through GitHub PR comments.
|
- All communication must happen through GitHub PR comments.
|
||||||
- Never create new comments. Only update the existing comment using mcp__github_file_ops__update_claude_comment.
|
- Never create new comments. Only update the existing comment using mcp__github_comment__update_claude_comment.
|
||||||
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? "\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_file_ops__update_claude_comment. Do NOT just respond with a normal response, the user will not see it." : ""}
|
- This includes ALL responses: code reviews, answers to questions, progress updates, and final results.${eventData.isPR ? `\n- PR CRITICAL: After reading files and forming your response, you MUST post it by calling mcp__github_comment__update_claude_comment. Do NOT just respond with a normal response, the user will not see it.` : ""}
|
||||||
- You communicate exclusively by editing your single comment - not through any other means.
|
- You communicate exclusively by editing your single comment - not through any other means.
|
||||||
- Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
- Use this spinner HTML when work is in progress: <img src="https://github.com/user-attachments/assets/5ac382c7-e004-429b-8e35-7feb3e8f9c6f" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />
|
||||||
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
|
${eventData.isPR && !eventData.claudeBranch ? `- Always push to the existing branch when triggered on a PR.` : `- IMPORTANT: You are already on the correct branch (${eventData.claudeBranch || "the created branch"}). Never create new branches when triggered on issues or closed/merged PRs.`}
|
||||||
- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
${
|
||||||
|
useCommitSigning
|
||||||
|
? `- Use mcp__github_file_ops__commit_files for making commits (works for both new and existing files, single or multiple). Use mcp__github_file_ops__delete_files for deleting files (supports deleting single or multiple files atomically), or mcp__github__delete_file for deleting a single file. Edit files locally, and the tool will read the content from the same path on disk.
|
||||||
Tool usage examples:
|
Tool usage examples:
|
||||||
- mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
|
- mcp__github_file_ops__commit_files: {"files": ["path/to/file1.js", "path/to/file2.py"], "message": "feat: add new feature"}
|
||||||
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}
|
- mcp__github_file_ops__delete_files: {"files": ["path/to/old.js"], "message": "chore: remove deprecated file"}`
|
||||||
|
: `- Use git commands via the Bash tool for version control (you have access to specific git commands only):
|
||||||
|
- Stage files: Bash(git add <files>)
|
||||||
|
- Commit changes: Bash(git commit -m "<message>")
|
||||||
|
- Push to remote: Bash(git push origin <branch>) (NEVER force push)
|
||||||
|
- Delete files: Bash(git rm <files>) followed by commit and push
|
||||||
|
- Check status: Bash(git status)
|
||||||
|
- View diff: Bash(git diff)
|
||||||
|
- Configure git user: Bash(git config user.name "...") and Bash(git config user.email "...")`
|
||||||
|
}
|
||||||
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
||||||
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
||||||
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
||||||
@@ -629,7 +741,11 @@ export async function createPrompt(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Generate the prompt
|
// Generate the prompt
|
||||||
const promptContent = generatePrompt(preparedContext, githubData);
|
const promptContent = generatePrompt(
|
||||||
|
preparedContext,
|
||||||
|
githubData,
|
||||||
|
context.inputs.useCommitSigning,
|
||||||
|
);
|
||||||
|
|
||||||
// Log the final prompt to console
|
// Log the final prompt to console
|
||||||
console.log("===== FINAL PROMPT =====");
|
console.log("===== FINAL PROMPT =====");
|
||||||
@@ -643,8 +759,13 @@ 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,
|
||||||
|
context.inputs.useCommitSigning,
|
||||||
);
|
);
|
||||||
const allDisallowedTools = buildDisallowedToolsString(
|
const allDisallowedTools = buildDisallowedToolsString(
|
||||||
context.inputs.disallowedTools,
|
context.inputs.disallowedTools,
|
||||||
|
|||||||
@@ -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
461
src/entrypoints/format-turns.ts
Executable 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();
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { checkWritePermissions } from "../github/validation/permissions";
|
|||||||
import { createInitialComment } from "../github/operations/comments/create-initial";
|
import { createInitialComment } from "../github/operations/comments/create-initial";
|
||||||
import { setupBranch } from "../github/operations/branch";
|
import { setupBranch } from "../github/operations/branch";
|
||||||
import { updateTrackingComment } from "../github/operations/comments/update-with-branch";
|
import { updateTrackingComment } from "../github/operations/comments/update-with-branch";
|
||||||
|
import { configureGitAuth } from "../github/operations/git-config";
|
||||||
import { prepareMcpConfig } from "../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../mcp/install-mcp-server";
|
||||||
import { createPrompt } from "../create-prompt";
|
import { createPrompt } from "../create-prompt";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
@@ -51,7 +52,8 @@ async function run() {
|
|||||||
await checkHumanActor(octokit.rest, context);
|
await checkHumanActor(octokit.rest, context);
|
||||||
|
|
||||||
// Step 6: Create initial tracking comment
|
// Step 6: Create initial tracking comment
|
||||||
const commentId = await createInitialComment(octokit.rest, context);
|
const commentData = await createInitialComment(octokit.rest, context);
|
||||||
|
const commentId = commentData.id;
|
||||||
|
|
||||||
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||||
const githubData = await fetchGitHubData({
|
const githubData = await fetchGitHubData({
|
||||||
@@ -75,7 +77,17 @@ async function run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 10: Create prompt file
|
// Step 10: Configure git authentication if not using commit signing
|
||||||
|
if (!context.inputs.useCommitSigning) {
|
||||||
|
try {
|
||||||
|
await configureGitAuth(githubToken, context, commentData.user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to configure git authentication:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 11: Create prompt file
|
||||||
await createPrompt(
|
await createPrompt(
|
||||||
commentId,
|
commentId,
|
||||||
branchInfo.baseBranch,
|
branchInfo.baseBranch,
|
||||||
@@ -84,7 +96,7 @@ async function run() {
|
|||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 11: Get MCP configuration
|
// Step 12: Get MCP configuration
|
||||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||||
const mcpConfig = await prepareMcpConfig({
|
const mcpConfig = await prepareMcpConfig({
|
||||||
githubToken,
|
githubToken,
|
||||||
@@ -94,6 +106,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) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
isPullRequestReviewCommentEvent,
|
isPullRequestReviewCommentEvent,
|
||||||
} from "../github/context";
|
} from "../github/context";
|
||||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||||
import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup";
|
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
@@ -88,13 +88,16 @@ async function run() {
|
|||||||
const currentBody = comment.body ?? "";
|
const currentBody = comment.body ?? "";
|
||||||
|
|
||||||
// Check if we need to add branch link for new branches
|
// Check if we need to add branch link for new branches
|
||||||
const { shouldDeleteBranch, branchLink } = await checkAndDeleteEmptyBranch(
|
const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
|
||||||
octokit,
|
const { shouldDeleteBranch, branchLink } =
|
||||||
owner,
|
await checkAndCommitOrDeleteBranch(
|
||||||
repo,
|
octokit,
|
||||||
claudeBranch,
|
owner,
|
||||||
baseBranch,
|
repo,
|
||||||
);
|
claudeBranch,
|
||||||
|
baseBranch,
|
||||||
|
useCommitSigning,
|
||||||
|
);
|
||||||
|
|
||||||
// Check if we need to add PR URL when we have a new branch
|
// Check if we need to add PR URL when we have a new branch
|
||||||
let prLink = "";
|
let prLink = "";
|
||||||
|
|||||||
@@ -29,11 +29,16 @@ 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>;
|
||||||
|
useCommitSigning: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,11 +58,18 @@ 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 ?? "",
|
||||||
|
),
|
||||||
|
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,6 +131,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 } {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import type { Octokits } from "../api/client";
|
import type { Octokits } from "../api/client";
|
||||||
import { GITHUB_SERVER_URL } from "../api/config";
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
import { $ } from "bun";
|
||||||
|
|
||||||
export async function checkAndDeleteEmptyBranch(
|
export async function checkAndCommitOrDeleteBranch(
|
||||||
octokit: Octokits,
|
octokit: Octokits,
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
claudeBranch: string | undefined,
|
claudeBranch: string | undefined,
|
||||||
baseBranch: string,
|
baseBranch: string,
|
||||||
|
useCommitSigning: boolean,
|
||||||
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
): Promise<{ shouldDeleteBranch: boolean; branchLink: string }> {
|
||||||
let branchLink = "";
|
let branchLink = "";
|
||||||
let shouldDeleteBranch = false;
|
let shouldDeleteBranch = false;
|
||||||
@@ -21,12 +23,58 @@ export async function checkAndDeleteEmptyBranch(
|
|||||||
basehead: `${baseBranch}...${claudeBranch}`,
|
basehead: `${baseBranch}...${claudeBranch}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If there are no commits, mark branch for deletion
|
// If there are no commits, check for uncommitted changes if not using commit signing
|
||||||
if (comparison.total_commits === 0) {
|
if (comparison.total_commits === 0) {
|
||||||
console.log(
|
if (!useCommitSigning) {
|
||||||
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
console.log(
|
||||||
);
|
`Branch ${claudeBranch} has no commits from Claude, checking for uncommitted changes...`,
|
||||||
shouldDeleteBranch = true;
|
);
|
||||||
|
|
||||||
|
// Check for uncommitted changes using git status
|
||||||
|
try {
|
||||||
|
const gitStatus = await $`git status --porcelain`.quiet();
|
||||||
|
const hasUncommittedChanges =
|
||||||
|
gitStatus.stdout.toString().trim().length > 0;
|
||||||
|
|
||||||
|
if (hasUncommittedChanges) {
|
||||||
|
console.log("Found uncommitted changes, committing them...");
|
||||||
|
|
||||||
|
// Add all changes
|
||||||
|
await $`git add -A`;
|
||||||
|
|
||||||
|
// Commit with a descriptive message
|
||||||
|
const runId = process.env.GITHUB_RUN_ID || "unknown";
|
||||||
|
const commitMessage = `Auto-commit: Save uncommitted changes from Claude\n\nRun ID: ${runId}`;
|
||||||
|
await $`git commit -m ${commitMessage}`;
|
||||||
|
|
||||||
|
// Push the changes
|
||||||
|
await $`git push origin ${claudeBranch}`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"✅ Successfully committed and pushed uncommitted changes",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set branch link since we now have commits
|
||||||
|
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||||
|
branchLink = `\n[View branch](${branchUrl})`;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"No uncommitted changes found, marking branch for deletion",
|
||||||
|
);
|
||||||
|
shouldDeleteBranch = true;
|
||||||
|
}
|
||||||
|
} catch (gitError) {
|
||||||
|
console.error("Error checking/committing changes:", gitError);
|
||||||
|
// If we can't check git status, assume the branch might have changes
|
||||||
|
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||||
|
branchLink = `\n[View branch](${branchUrl})`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`Branch ${claudeBranch} has no commits from Claude, will delete it`,
|
||||||
|
);
|
||||||
|
shouldDeleteBranch = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only add branch link if there are commits
|
// Only add branch link if there are commits
|
||||||
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
const branchUrl = `${GITHUB_SERVER_URL}/${owner}/${repo}/tree/${claudeBranch}`;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ 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";
|
||||||
|
|
||||||
|
const CLAUDE_APP_BOT_ID = 209825114;
|
||||||
|
|
||||||
export async function createInitialComment(
|
export async function createInitialComment(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
@@ -25,8 +28,43 @@ 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,
|
||||||
|
});
|
||||||
|
const existingComment = comments.data.find((comment) => {
|
||||||
|
const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
|
||||||
|
const botNameMatch =
|
||||||
|
comment.user?.type === "Bot" &&
|
||||||
|
comment.user?.login.toLowerCase().includes("claude");
|
||||||
|
const bodyMatch = comment.body === initialBody;
|
||||||
|
|
||||||
|
return idMatch || botNameMatch || bodyMatch;
|
||||||
|
});
|
||||||
|
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,
|
||||||
@@ -48,7 +86,7 @@ export async function createInitialComment(
|
|||||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||||
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
|
console.log(`✅ Created initial comment with ID: ${response.data.id}`);
|
||||||
return response.data.id;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in initial comment:", error);
|
console.error("Error in initial comment:", error);
|
||||||
|
|
||||||
@@ -64,7 +102,7 @@ export async function createInitialComment(
|
|||||||
const githubOutput = process.env.GITHUB_OUTPUT!;
|
const githubOutput = process.env.GITHUB_OUTPUT!;
|
||||||
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`);
|
||||||
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
|
console.log(`✅ Created fallback comment with ID: ${response.data.id}`);
|
||||||
return response.data.id;
|
return response.data;
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error("Error creating fallback comment:", fallbackError);
|
console.error("Error creating fallback comment:", fallbackError);
|
||||||
throw fallbackError;
|
throw fallbackError;
|
||||||
|
|||||||
56
src/github/operations/git-config.ts
Normal file
56
src/github/operations/git-config.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure git authentication for non-signing mode
|
||||||
|
* Sets up git user and authentication to work with GitHub App tokens
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { $ } from "bun";
|
||||||
|
import type { ParsedGitHubContext } from "../context";
|
||||||
|
import { GITHUB_SERVER_URL } from "../api/config";
|
||||||
|
|
||||||
|
type GitUser = {
|
||||||
|
login: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function configureGitAuth(
|
||||||
|
githubToken: string,
|
||||||
|
context: ParsedGitHubContext,
|
||||||
|
user: GitUser | null,
|
||||||
|
) {
|
||||||
|
console.log("Configuring git authentication for non-signing mode");
|
||||||
|
|
||||||
|
// Configure git user based on the comment creator
|
||||||
|
console.log("Configuring git user...");
|
||||||
|
if (user) {
|
||||||
|
const botName = user.login;
|
||||||
|
const botId = user.id;
|
||||||
|
console.log(`Setting git user as ${botName}...`);
|
||||||
|
await $`git config user.name "${botName}"`;
|
||||||
|
await $`git config user.email "${botId}+${botName}@users.noreply.github.com"`;
|
||||||
|
console.log(`✓ Set git user as ${botName}`);
|
||||||
|
} else {
|
||||||
|
console.log("No user data in comment, using default bot user");
|
||||||
|
await $`git config user.name "github-actions[bot]"`;
|
||||||
|
await $`git config user.email "41898282+github-actions[bot]@users.noreply.github.com"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the authorization header that actions/checkout sets
|
||||||
|
console.log("Removing existing git authentication headers...");
|
||||||
|
try {
|
||||||
|
await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||||
|
console.log("✓ Removed existing authentication headers");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("No existing authentication headers to remove");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the remote URL to include the token for authentication
|
||||||
|
console.log("Updating remote URL with authentication...");
|
||||||
|
const serverUrl = new URL(GITHUB_SERVER_URL);
|
||||||
|
const remoteUrl = `https://x-access-token:${githubToken}@${serverUrl.host}/${context.repository.owner}/${context.repository.repo}.git`;
|
||||||
|
await $`git remote set-url origin ${remoteUrl}`;
|
||||||
|
console.log("✓ Updated remote URL with authentication token");
|
||||||
|
|
||||||
|
console.log("Git authentication configured successfully");
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 || "";
|
||||||
|
|||||||
275
src/mcp/github-actions-server.ts
Normal file
275
src/mcp/github-actions-server.ts
Normal 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);
|
||||||
|
});
|
||||||
98
src/mcp/github-comment-server.ts
Normal file
98
src/mcp/github-comment-server.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// GitHub Comment MCP Server - Minimal server that only provides comment update functionality
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
|
|
||||||
|
// Get repository information from environment variables
|
||||||
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
|
const REPO_NAME = process.env.REPO_NAME;
|
||||||
|
|
||||||
|
if (!REPO_OWNER || !REPO_NAME) {
|
||||||
|
console.error(
|
||||||
|
"Error: REPO_OWNER and REPO_NAME environment variables are required",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "GitHub Comment Server",
|
||||||
|
version: "0.0.1",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"update_claude_comment",
|
||||||
|
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
||||||
|
{
|
||||||
|
body: z.string().describe("The updated comment content"),
|
||||||
|
},
|
||||||
|
async ({ body }) => {
|
||||||
|
try {
|
||||||
|
const githubToken = process.env.GITHUB_TOKEN;
|
||||||
|
const claudeCommentId = process.env.CLAUDE_COMMENT_ID;
|
||||||
|
const eventName = process.env.GITHUB_EVENT_NAME;
|
||||||
|
|
||||||
|
if (!githubToken) {
|
||||||
|
throw new Error("GITHUB_TOKEN environment variable is required");
|
||||||
|
}
|
||||||
|
if (!claudeCommentId) {
|
||||||
|
throw new Error("CLAUDE_COMMENT_ID environment variable is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const owner = REPO_OWNER;
|
||||||
|
const repo = REPO_NAME;
|
||||||
|
const commentId = parseInt(claudeCommentId, 10);
|
||||||
|
|
||||||
|
const octokit = new Octokit({
|
||||||
|
auth: githubToken,
|
||||||
|
baseUrl: GITHUB_API_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPullRequestReviewComment =
|
||||||
|
eventName === "pull_request_review_comment";
|
||||||
|
|
||||||
|
const result = await updateClaudeComment(octokit, {
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
commentId,
|
||||||
|
body,
|
||||||
|
isPullRequestReviewComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `Error: ${errorMessage}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: errorMessage,
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
async function runServer() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
process.on("exit", () => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
runServer().catch(console.error);
|
||||||
@@ -7,8 +7,7 @@ import { readFile } from "fs/promises";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { retryWithBackoff } from "../utils/retry";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
|
||||||
|
|
||||||
type GitHubRef = {
|
type GitHubRef = {
|
||||||
object: {
|
object: {
|
||||||
@@ -125,13 +124,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 +232,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 +450,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: {
|
||||||
@@ -441,70 +533,6 @@ server.tool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.tool(
|
|
||||||
"update_claude_comment",
|
|
||||||
"Update the Claude comment with progress and results (automatically handles both issue and PR comments)",
|
|
||||||
{
|
|
||||||
body: z.string().describe("The updated comment content"),
|
|
||||||
},
|
|
||||||
async ({ body }) => {
|
|
||||||
try {
|
|
||||||
const githubToken = process.env.GITHUB_TOKEN;
|
|
||||||
const claudeCommentId = process.env.CLAUDE_COMMENT_ID;
|
|
||||||
const eventName = process.env.GITHUB_EVENT_NAME;
|
|
||||||
|
|
||||||
if (!githubToken) {
|
|
||||||
throw new Error("GITHUB_TOKEN environment variable is required");
|
|
||||||
}
|
|
||||||
if (!claudeCommentId) {
|
|
||||||
throw new Error("CLAUDE_COMMENT_ID environment variable is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const owner = REPO_OWNER;
|
|
||||||
const repo = REPO_NAME;
|
|
||||||
const commentId = parseInt(claudeCommentId, 10);
|
|
||||||
|
|
||||||
const octokit = new Octokit({
|
|
||||||
auth: githubToken,
|
|
||||||
baseUrl: GITHUB_API_URL,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isPullRequestReviewComment =
|
|
||||||
eventName === "pull_request_review_comment";
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(octokit, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
commentId,
|
|
||||||
body,
|
|
||||||
isPullRequestReviewComment,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: JSON.stringify(result, null, 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Error: ${errorMessage}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
error: errorMessage,
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
async function runServer() {
|
async function runServer() {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
@@ -31,28 +67,83 @@ export async function prepareMcpConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
|
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
|
||||||
mcpServers: {
|
mcpServers: {},
|
||||||
github_file_ops: {
|
};
|
||||||
command: "bun",
|
|
||||||
args: [
|
// Always include comment server for updating Claude comments
|
||||||
"run",
|
baseMcpConfig.mcpServers.github_comment = {
|
||||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
|
command: "bun",
|
||||||
],
|
args: [
|
||||||
env: {
|
"run",
|
||||||
GITHUB_TOKEN: githubToken,
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||||
REPO_OWNER: owner,
|
],
|
||||||
REPO_NAME: repo,
|
env: {
|
||||||
BRANCH_NAME: branch,
|
GITHUB_TOKEN: githubToken,
|
||||||
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
REPO_OWNER: owner,
|
||||||
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
REPO_NAME: repo,
|
||||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
...(claudeCommentId && { CLAUDE_COMMENT_ID: claudeCommentId }),
|
||||||
IS_PR: process.env.IS_PR || "false",
|
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||||
GITHUB_API_URL: GITHUB_API_URL,
|
GITHUB_API_URL: GITHUB_API_URL,
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Include file ops server when commit signing is enabled
|
||||||
|
if (context.inputs.useCommitSigning) {
|
||||||
|
baseMcpConfig.mcpServers.github_file_ops = {
|
||||||
|
command: "bun",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-file-ops-server.ts`,
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
GITHUB_TOKEN: githubToken,
|
||||||
|
REPO_OWNER: owner,
|
||||||
|
REPO_NAME: repo,
|
||||||
|
BRANCH_NAME: branch,
|
||||||
|
REPO_DIR: process.env.GITHUB_WORKSPACE || process.cwd(),
|
||||||
|
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||||
|
IS_PR: process.env.IS_PR || "false",
|
||||||
|
GITHUB_API_URL: GITHUB_API_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add CI server if we have actions:read permission and we're in a PR context
|
||||||
|
const hasActionsReadPermission =
|
||||||
|
context.inputs.additionalPermissions.get("actions") === "read";
|
||||||
|
|
||||||
|
if (context.isPR && hasActionsReadPermission) {
|
||||||
|
// Verify the token actually has actions:read permission
|
||||||
|
const actuallyHasPermission = await checkActionsReadPermission(
|
||||||
|
process.env.ACTIONS_TOKEN || "",
|
||||||
|
owner,
|
||||||
|
repo,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!actuallyHasPermission) {
|
||||||
|
core.warning(
|
||||||
|
"The github_ci MCP server requires 'actions: read' permission. " +
|
||||||
|
"Please ensure your GitHub token has this permission. " +
|
||||||
|
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
baseMcpConfig.mcpServers.github_ci = {
|
||||||
|
command: "bun",
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||||
|
],
|
||||||
|
env: {
|
||||||
|
// Use workflow github token, not app token
|
||||||
|
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
|
||||||
|
REPO_OWNER: owner,
|
||||||
|
REPO_NAME: repo,
|
||||||
|
PR_NUMBER: context.entityNumber.toString(),
|
||||||
|
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (hasGitHubMcpTools) {
|
if (hasGitHubMcpTools) {
|
||||||
baseMcpConfig.mcpServers.github = {
|
baseMcpConfig.mcpServers.github = {
|
||||||
command: "docker",
|
command: "docker",
|
||||||
@@ -62,7 +153,7 @@ export async function prepareMcpConfig(
|
|||||||
"--rm",
|
"--rm",
|
||||||
"-e",
|
"-e",
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
"ghcr.io/github/github-mcp-server:sha-6d69797", // https://github.com/github/github-mcp-server/releases/tag/v0.5.0
|
"ghcr.io/github/github-mcp-server:sha-721fd3e", // https://github.com/github/github-mcp-server/releases/tag/v0.6.0
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||||
|
|||||||
40
src/utils/retry.ts
Normal file
40
src/utils/retry.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||||
import { checkAndDeleteEmptyBranch } from "../src/github/operations/branch-cleanup";
|
import { checkAndCommitOrDeleteBranch } from "../src/github/operations/branch-cleanup";
|
||||||
import type { Octokits } from "../src/github/api/client";
|
import type { Octokits } from "../src/github/api/client";
|
||||||
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
import { GITHUB_SERVER_URL } from "../src/github/api/config";
|
||||||
|
|
||||||
describe("checkAndDeleteEmptyBranch", () => {
|
describe("checkAndCommitOrDeleteBranch", () => {
|
||||||
let consoleLogSpy: any;
|
let consoleLogSpy: any;
|
||||||
let consoleErrorSpy: any;
|
let consoleErrorSpy: any;
|
||||||
|
|
||||||
@@ -43,12 +43,13 @@ describe("checkAndDeleteEmptyBranch", () => {
|
|||||||
|
|
||||||
test("should return no branch link and not delete when branch is undefined", async () => {
|
test("should return no branch link and not delete when branch is undefined", async () => {
|
||||||
const mockOctokit = createMockOctokit();
|
const mockOctokit = createMockOctokit();
|
||||||
const result = await checkAndDeleteEmptyBranch(
|
const result = await checkAndCommitOrDeleteBranch(
|
||||||
mockOctokit,
|
mockOctokit,
|
||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
undefined,
|
undefined,
|
||||||
"main",
|
"main",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.shouldDeleteBranch).toBe(false);
|
expect(result.shouldDeleteBranch).toBe(false);
|
||||||
@@ -56,14 +57,15 @@ describe("checkAndDeleteEmptyBranch", () => {
|
|||||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should delete branch and return no link when branch has no commits", async () => {
|
test("should mark branch for deletion when commit signing is enabled and no commits", async () => {
|
||||||
const mockOctokit = createMockOctokit({ total_commits: 0 });
|
const mockOctokit = createMockOctokit({ total_commits: 0 });
|
||||||
const result = await checkAndDeleteEmptyBranch(
|
const result = await checkAndCommitOrDeleteBranch(
|
||||||
mockOctokit,
|
mockOctokit,
|
||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
"claude/issue-123-20240101_123456",
|
"claude/issue-123-20240101_123456",
|
||||||
"main",
|
"main",
|
||||||
|
true, // commit signing enabled
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.shouldDeleteBranch).toBe(true);
|
expect(result.shouldDeleteBranch).toBe(true);
|
||||||
@@ -71,19 +73,17 @@ describe("checkAndDeleteEmptyBranch", () => {
|
|||||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||||
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it",
|
"Branch claude/issue-123-20240101_123456 has no commits from Claude, will delete it",
|
||||||
);
|
);
|
||||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
||||||
"✅ Deleted empty branch: claude/issue-123-20240101_123456",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not delete branch and return link when branch has commits", async () => {
|
test("should not delete branch and return link when branch has commits", async () => {
|
||||||
const mockOctokit = createMockOctokit({ total_commits: 3 });
|
const mockOctokit = createMockOctokit({ total_commits: 3 });
|
||||||
const result = await checkAndDeleteEmptyBranch(
|
const result = await checkAndCommitOrDeleteBranch(
|
||||||
mockOctokit,
|
mockOctokit,
|
||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
"claude/issue-123-20240101_123456",
|
"claude/issue-123-20240101_123456",
|
||||||
"main",
|
"main",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.shouldDeleteBranch).toBe(false);
|
expect(result.shouldDeleteBranch).toBe(false);
|
||||||
@@ -109,12 +109,13 @@ describe("checkAndDeleteEmptyBranch", () => {
|
|||||||
},
|
},
|
||||||
} as any as Octokits;
|
} as any as Octokits;
|
||||||
|
|
||||||
const result = await checkAndDeleteEmptyBranch(
|
const result = await checkAndCommitOrDeleteBranch(
|
||||||
mockOctokit,
|
mockOctokit,
|
||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
"claude/issue-123-20240101_123456",
|
"claude/issue-123-20240101_123456",
|
||||||
"main",
|
"main",
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.shouldDeleteBranch).toBe(false);
|
expect(result.shouldDeleteBranch).toBe(false);
|
||||||
@@ -131,12 +132,13 @@ describe("checkAndDeleteEmptyBranch", () => {
|
|||||||
const deleteError = new Error("Delete failed");
|
const deleteError = new Error("Delete failed");
|
||||||
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
|
const mockOctokit = createMockOctokit({ total_commits: 0 }, deleteError);
|
||||||
|
|
||||||
const result = await checkAndDeleteEmptyBranch(
|
const result = await checkAndCommitOrDeleteBranch(
|
||||||
mockOctokit,
|
mockOctokit,
|
||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
"claude/issue-123-20240101_123456",
|
"claude/issue-123-20240101_123456",
|
||||||
"main",
|
"main",
|
||||||
|
true, // commit signing enabled - will try to delete
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.shouldDeleteBranch).toBe(true);
|
expect(result.shouldDeleteBranch).toBe(true);
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||||
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
||||||
@@ -161,7 +161,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
||||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||||
@@ -187,7 +187,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -215,7 +215,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -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, false);
|
||||||
|
|
||||||
|
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",
|
||||||
@@ -242,7 +269,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("<direct_prompt>");
|
expect(prompt).toContain("<direct_prompt>");
|
||||||
expect(prompt).toContain("Fix the bug in the login form");
|
expect(prompt).toContain("Fix the bug in the login form");
|
||||||
@@ -265,7 +292,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||||
@@ -290,7 +317,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
|
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
|
||||||
});
|
});
|
||||||
@@ -312,11 +339,12 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||||
|
// With commit signing disabled, co-author info appears in git commit instructions
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
'Use: "Co-authored-by: johndoe <johndoe@users.noreply.github.com>"',
|
"Co-authored-by: johndoe <johndoe@users.noreply.github.com>",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -333,12 +361,10 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain PR-specific instructions
|
// Should contain PR-specific instructions (git commands when not using signing)
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain("git push");
|
||||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Always push to the existing branch when triggered on a PR",
|
"Always push to the existing branch when triggered on a PR",
|
||||||
);
|
);
|
||||||
@@ -366,7 +392,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain Issue-specific instructions
|
// Should contain Issue-specific instructions
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -405,7 +431,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain the actual branch name with timestamp
|
// Should contain the actual branch name with timestamp
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -435,7 +461,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain branch-specific instructions like issues
|
// Should contain branch-specific instructions like issues
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -473,12 +499,10 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain open PR instructions
|
// Should contain open PR instructions (git commands when not using signing)
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain("git push");
|
||||||
"Push directly using mcp__github_file_ops__commit_files to the existing branch",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Always push to the existing branch when triggered on a PR",
|
"Always push to the existing branch when triggered on a PR",
|
||||||
);
|
);
|
||||||
@@ -506,7 +530,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain new branch instructions
|
// Should contain new branch instructions
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -534,7 +558,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain new branch instructions
|
// Should contain new branch instructions
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -562,7 +586,7 @@ describe("generatePrompt", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
// Should contain new branch instructions
|
// Should contain new branch instructions
|
||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
@@ -571,6 +595,61 @@ describe("generatePrompt", () => {
|
|||||||
expect(prompt).toContain("Create a PR](https://github.com/");
|
expect(prompt).toContain("Create a PR](https://github.com/");
|
||||||
expect(prompt).toContain("Reference to the original PR");
|
expect(prompt).toContain("Reference to the original PR");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should include git commands when useCommitSigning is false", () => {
|
||||||
|
const envVars: PreparedContext = {
|
||||||
|
repository: "owner/repo",
|
||||||
|
claudeCommentId: "12345",
|
||||||
|
triggerPhrase: "@claude",
|
||||||
|
eventData: {
|
||||||
|
eventName: "issue_comment",
|
||||||
|
commentId: "67890",
|
||||||
|
isPR: true,
|
||||||
|
prNumber: "123",
|
||||||
|
commentBody: "@claude fix the bug",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = generatePrompt(envVars, mockGitHubData, false);
|
||||||
|
|
||||||
|
// Should have git command instructions
|
||||||
|
expect(prompt).toContain("Use git commands via the Bash tool");
|
||||||
|
expect(prompt).toContain("git add");
|
||||||
|
expect(prompt).toContain("git commit");
|
||||||
|
expect(prompt).toContain("git push");
|
||||||
|
|
||||||
|
// Should use the minimal comment tool
|
||||||
|
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Should not have commit signing tool references
|
||||||
|
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include commit signing tools when useCommitSigning is true", () => {
|
||||||
|
const envVars: PreparedContext = {
|
||||||
|
repository: "owner/repo",
|
||||||
|
claudeCommentId: "12345",
|
||||||
|
triggerPhrase: "@claude",
|
||||||
|
eventData: {
|
||||||
|
eventName: "issue_comment",
|
||||||
|
commentId: "67890",
|
||||||
|
isPR: true,
|
||||||
|
prNumber: "123",
|
||||||
|
commentBody: "@claude fix the bug",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const prompt = generatePrompt(envVars, mockGitHubData, true);
|
||||||
|
|
||||||
|
// Should have commit signing tool instructions
|
||||||
|
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(prompt).toContain("mcp__github_file_ops__delete_files");
|
||||||
|
// Comment tool should always be from comment server, not file ops
|
||||||
|
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Should not have git command instructions
|
||||||
|
expect(prompt).not.toContain("Use git commands via the Bash tool");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getEventTypeAndContext", () => {
|
describe("getEventTypeAndContext", () => {
|
||||||
@@ -614,10 +693,55 @@ 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", () => {
|
||||||
test("should return issue comment tool for regular events", () => {
|
test("should return correct tools for regular events (default no signing)", () => {
|
||||||
const result = buildAllowedToolsString();
|
const result = buildAllowedToolsString();
|
||||||
|
|
||||||
// The base tools should be in the result
|
// The base tools should be in the result
|
||||||
@@ -627,15 +751,20 @@ describe("buildAllowedToolsString", () => {
|
|||||||
expect(result).toContain("LS");
|
expect(result).toContain("LS");
|
||||||
expect(result).toContain("Read");
|
expect(result).toContain("Read");
|
||||||
expect(result).toContain("Write");
|
expect(result).toContain("Write");
|
||||||
expect(result).toContain("mcp__github_file_ops__update_claude_comment");
|
|
||||||
expect(result).not.toContain("mcp__github__update_issue_comment");
|
// Default is no commit signing, so should have specific Bash git commands
|
||||||
expect(result).not.toContain("mcp__github__update_pull_request_comment");
|
expect(result).toContain("Bash(git add:*)");
|
||||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
expect(result).toContain("Bash(git commit:*)");
|
||||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
expect(result).toContain("Bash(git push:*)");
|
||||||
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Should not have commit signing tools
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return PR comment tool for inline review comments", () => {
|
test("should return correct tools with default parameters", () => {
|
||||||
const result = buildAllowedToolsString();
|
const result = buildAllowedToolsString([], false, false);
|
||||||
|
|
||||||
// The base tools should be in the result
|
// The base tools should be in the result
|
||||||
expect(result).toContain("Edit");
|
expect(result).toContain("Edit");
|
||||||
@@ -644,11 +773,15 @@ describe("buildAllowedToolsString", () => {
|
|||||||
expect(result).toContain("LS");
|
expect(result).toContain("LS");
|
||||||
expect(result).toContain("Read");
|
expect(result).toContain("Read");
|
||||||
expect(result).toContain("Write");
|
expect(result).toContain("Write");
|
||||||
expect(result).toContain("mcp__github_file_ops__update_claude_comment");
|
|
||||||
expect(result).not.toContain("mcp__github__update_issue_comment");
|
// Should have specific Bash git commands for non-signing mode
|
||||||
expect(result).not.toContain("mcp__github__update_pull_request_comment");
|
expect(result).toContain("Bash(git add:*)");
|
||||||
expect(result).toContain("mcp__github_file_ops__commit_files");
|
expect(result).toContain("Bash(git commit:*)");
|
||||||
expect(result).toContain("mcp__github_file_ops__delete_files");
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Should not have commit signing tools
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should append custom tools when provided", () => {
|
test("should append custom tools when provided", () => {
|
||||||
@@ -671,6 +804,109 @@ 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include commit signing tools when useCommitSigning is true", () => {
|
||||||
|
const result = buildAllowedToolsString([], false, true);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
expect(result).toContain("Grep");
|
||||||
|
expect(result).toContain("LS");
|
||||||
|
expect(result).toContain("Read");
|
||||||
|
expect(result).toContain("Write");
|
||||||
|
|
||||||
|
// Commit signing tools should be included
|
||||||
|
expect(result).toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).toContain("mcp__github_file_ops__delete_files");
|
||||||
|
// Comment tool should always be from github_comment server
|
||||||
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Bash should NOT be included when using commit signing (except in comment tool name)
|
||||||
|
expect(result).not.toContain("Bash(");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should include specific Bash git commands when useCommitSigning is false", () => {
|
||||||
|
const result = buildAllowedToolsString([], false, false);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Glob");
|
||||||
|
expect(result).toContain("Grep");
|
||||||
|
expect(result).toContain("LS");
|
||||||
|
expect(result).toContain("Read");
|
||||||
|
expect(result).toContain("Write");
|
||||||
|
|
||||||
|
// Specific Bash git commands should be included
|
||||||
|
expect(result).toContain("Bash(git add:*)");
|
||||||
|
expect(result).toContain("Bash(git commit:*)");
|
||||||
|
expect(result).toContain("Bash(git push:*)");
|
||||||
|
expect(result).toContain("Bash(git status:*)");
|
||||||
|
expect(result).toContain("Bash(git diff:*)");
|
||||||
|
expect(result).toContain("Bash(git log:*)");
|
||||||
|
expect(result).toContain("Bash(git rm:*)");
|
||||||
|
expect(result).toContain("Bash(git config user.name:*)");
|
||||||
|
expect(result).toContain("Bash(git config user.email:*)");
|
||||||
|
|
||||||
|
// Comment tool from minimal server should be included
|
||||||
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Commit signing tools should NOT be included
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__delete_files");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle all combinations of options", () => {
|
||||||
|
const customTools = ["CustomTool1", "CustomTool2"];
|
||||||
|
const result = buildAllowedToolsString(customTools, true, false);
|
||||||
|
|
||||||
|
// Base tools should be present
|
||||||
|
expect(result).toContain("Edit");
|
||||||
|
expect(result).toContain("Bash(git add:*)");
|
||||||
|
|
||||||
|
// Custom tools should be included
|
||||||
|
expect(result).toContain("CustomTool1");
|
||||||
|
expect(result).toContain("CustomTool2");
|
||||||
|
|
||||||
|
// GitHub Actions tools should be included
|
||||||
|
expect(result).toContain("mcp__github_ci__get_ci_status");
|
||||||
|
|
||||||
|
// Comment tool from minimal server should be included
|
||||||
|
expect(result).toContain("mcp__github_comment__update_claude_comment");
|
||||||
|
|
||||||
|
// Commit signing tools should NOT be included
|
||||||
|
expect(result).not.toContain("mcp__github_file_ops__commit_files");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildDisallowedToolsString", () => {
|
describe("buildDisallowedToolsString", () => {
|
||||||
|
|||||||
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal file
95
test/fixtures/sample-turns-expected-output.md
vendored
Normal 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
196
test/fixtures/sample-turns.json
vendored
Normal 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
439
test/format-turns.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,58 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPRContext: ParsedGitHubContext = {
|
||||||
|
...mockContext,
|
||||||
|
eventName: "pull_request",
|
||||||
|
isPR: true,
|
||||||
|
entityNumber: 456,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockContextWithSigning: ParsedGitHubContext = {
|
||||||
|
...mockContext,
|
||||||
|
inputs: {
|
||||||
|
...mockContext.inputs,
|
||||||
|
useCommitSigning: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPRContextWithSigning: ParsedGitHubContext = {
|
||||||
|
...mockPRContext,
|
||||||
|
inputs: {
|
||||||
|
...mockPRContext.inputs,
|
||||||
|
useCommitSigning: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||||
@@ -15,6 +68,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(() => {
|
||||||
@@ -24,18 +82,50 @@ describe("prepareMcpConfig", () => {
|
|||||||
processExitSpy.mockRestore();
|
processExitSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return base config when no additional config is provided and no allowed_tools", async () => {
|
test("should return comment server when commit signing is disabled", async () => {
|
||||||
const result = await prepareMcpConfig({
|
const result = await prepareMcpConfig({
|
||||||
githubToken: "test-token",
|
githubToken: "test-token",
|
||||||
owner: "test-owner",
|
owner: "test-owner",
|
||||||
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);
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
expect(parsed.mcpServers).toBeDefined();
|
||||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
|
||||||
|
"test-token",
|
||||||
|
);
|
||||||
|
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
|
||||||
|
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return file ops server when commit signing is enabled", async () => {
|
||||||
|
const contextWithSigning = {
|
||||||
|
...mockContext,
|
||||||
|
inputs: {
|
||||||
|
...mockContext.inputs,
|
||||||
|
useCommitSigning: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await prepareMcpConfig({
|
||||||
|
githubToken: "test-token",
|
||||||
|
owner: "test-owner",
|
||||||
|
repo: "test-repo",
|
||||||
|
branch: "test-branch",
|
||||||
|
allowedTools: [],
|
||||||
|
context: contextWithSigning,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result);
|
||||||
|
expect(parsed.mcpServers).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
|
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
|
||||||
"test-token",
|
"test-token",
|
||||||
@@ -57,18 +147,28 @@ 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);
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
expect(parsed.mcpServers).toBeDefined();
|
||||||
expect(parsed.mcpServers.github).toBeDefined();
|
expect(parsed.mcpServers.github).toBeDefined();
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||||
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
||||||
"test-token",
|
"test-token",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not include github MCP server when only file_ops tools are allowed", async () => {
|
test("should not include github MCP server when only file_ops tools are allowed", async () => {
|
||||||
|
const contextWithSigning = {
|
||||||
|
...mockContext,
|
||||||
|
inputs: {
|
||||||
|
...mockContext.inputs,
|
||||||
|
useCommitSigning: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const result = await prepareMcpConfig({
|
const result = await prepareMcpConfig({
|
||||||
githubToken: "test-token",
|
githubToken: "test-token",
|
||||||
owner: "test-owner",
|
owner: "test-owner",
|
||||||
@@ -78,6 +178,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
"mcp__github_file_ops__update_claude_comment",
|
"mcp__github_file_ops__update_claude_comment",
|
||||||
],
|
],
|
||||||
|
context: contextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -86,19 +187,21 @@ describe("prepareMcpConfig", () => {
|
|||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include file_ops server even when no GitHub tools are allowed", async () => {
|
test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
|
||||||
const result = await prepareMcpConfig({
|
const result = await prepareMcpConfig({
|
||||||
githubToken: "test-token",
|
githubToken: "test-token",
|
||||||
owner: "test-owner",
|
owner: "test-owner",
|
||||||
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);
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
expect(parsed.mcpServers).toBeDefined();
|
||||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||||
|
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return base config when additional config is empty string", async () => {
|
test("should return base config when additional config is empty string", async () => {
|
||||||
@@ -109,12 +212,13 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: "",
|
additionalMcpConfig: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
expect(parsed.mcpServers).toBeDefined();
|
||||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,12 +230,13 @@ 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);
|
||||||
expect(parsed.mcpServers).toBeDefined();
|
expect(parsed.mcpServers).toBeDefined();
|
||||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,6 +263,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -195,6 +301,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
"mcp__github__create_issue",
|
"mcp__github__create_issue",
|
||||||
"mcp__github_file_ops__commit_files",
|
"mcp__github_file_ops__commit_files",
|
||||||
],
|
],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -232,6 +339,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: additionalConfig,
|
additionalMcpConfig: additionalConfig,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -251,6 +359,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: invalidJson,
|
additionalMcpConfig: invalidJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -271,6 +380,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: nonObjectJson,
|
additionalMcpConfig: nonObjectJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -294,6 +404,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: nullJson,
|
additionalMcpConfig: nullJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -317,6 +428,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: arrayJson,
|
additionalMcpConfig: arrayJson,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -363,6 +475,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
additionalMcpConfig: additionalConfig,
|
additionalMcpConfig: additionalConfig,
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -384,6 +497,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -404,6 +518,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
repo: "test-repo",
|
repo: "test-repo",
|
||||||
branch: "test-branch",
|
branch: "test-branch",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
|
context: mockContextWithSigning,
|
||||||
});
|
});
|
||||||
|
|
||||||
const parsed = JSON.parse(result);
|
const parsed = JSON.parse(result);
|
||||||
@@ -411,4 +526,133 @@ 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"]]),
|
||||||
|
useCommitSigning: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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: mockContextWithSigning,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: mockPRContextWithSigning,
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,10 @@ const defaultInputs = {
|
|||||||
useBedrock: false,
|
useBedrock: false,
|
||||||
useVertex: false,
|
useVertex: false,
|
||||||
timeoutMinutes: 30,
|
timeoutMinutes: 30,
|
||||||
|
branchPrefix: "claude/",
|
||||||
|
useStickyComment: false,
|
||||||
|
additionalPermissions: new Map<string, string>(),
|
||||||
|
useCommitSigning: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
@@ -128,6 +133,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",
|
||||||
|
|||||||
@@ -62,10 +62,15 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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,15 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -55,10 +61,15 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
@@ -107,6 +118,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 +276,15 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -259,10 +308,15 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -286,10 +340,15 @@ 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(),
|
||||||
|
useCommitSigning: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user