mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
1 Commits
v0.0.28
...
ashwin/mul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e73dc5d4 |
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-7aced2b"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|||||||
138
.github/workflows/release.yml
vendored
138
.github/workflows/release.yml
vendored
@@ -1,138 +0,0 @@
|
|||||||
name: Create Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
description: "Dry run (only show what would be created)"
|
|
||||||
required: false
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
create-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
outputs:
|
|
||||||
next_version: ${{ steps.next_version.outputs.next_version }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Get latest tag
|
|
||||||
id: get_latest_tag
|
|
||||||
run: |
|
|
||||||
# Get only version tags (v + number pattern)
|
|
||||||
latest_tag=$(git tag -l 'v[0-9]*' | sort -V | tail -1 || echo "v0.0.0")
|
|
||||||
if [ -z "$latest_tag" ]; then
|
|
||||||
latest_tag="v0.0.0"
|
|
||||||
fi
|
|
||||||
echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT
|
|
||||||
echo "Latest tag: $latest_tag"
|
|
||||||
|
|
||||||
- name: Calculate next version
|
|
||||||
id: next_version
|
|
||||||
run: |
|
|
||||||
latest_tag="${{ steps.get_latest_tag.outputs.latest_tag }}"
|
|
||||||
# Remove 'v' prefix and split by dots
|
|
||||||
version=${latest_tag#v}
|
|
||||||
IFS='.' read -ra VERSION_PARTS <<< "$version"
|
|
||||||
|
|
||||||
# Increment patch version
|
|
||||||
major=${VERSION_PARTS[0]:-0}
|
|
||||||
minor=${VERSION_PARTS[1]:-0}
|
|
||||||
patch=${VERSION_PARTS[2]:-0}
|
|
||||||
patch=$((patch + 1))
|
|
||||||
|
|
||||||
next_version="v${major}.${minor}.${patch}"
|
|
||||||
echo "next_version=$next_version" >> $GITHUB_OUTPUT
|
|
||||||
echo "Next version: $next_version"
|
|
||||||
|
|
||||||
- name: Display dry run info
|
|
||||||
if: ${{ inputs.dry_run }}
|
|
||||||
run: |
|
|
||||||
echo "🔍 DRY RUN MODE"
|
|
||||||
echo "Would create tag: ${{ steps.next_version.outputs.next_version }}"
|
|
||||||
echo "From commit: ${{ github.sha }}"
|
|
||||||
echo "Previous tag: ${{ steps.get_latest_tag.outputs.latest_tag }}"
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
if: ${{ !inputs.dry_run }}
|
|
||||||
run: |
|
|
||||||
next_version="${{ steps.next_version.outputs.next_version }}"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
|
|
||||||
git tag -a "$next_version" -m "Release $next_version"
|
|
||||||
git push origin "$next_version"
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
if: ${{ !inputs.dry_run }}
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
next_version="${{ steps.next_version.outputs.next_version }}"
|
|
||||||
|
|
||||||
gh release create "$next_version" \
|
|
||||||
--title "$next_version" \
|
|
||||||
--generate-notes \
|
|
||||||
--latest=false # We want to keep beta as the latest
|
|
||||||
|
|
||||||
update-beta-tag:
|
|
||||||
needs: create-release
|
|
||||||
if: ${{ !inputs.dry_run }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Update beta tag
|
|
||||||
run: |
|
|
||||||
# Get the latest version tag
|
|
||||||
VERSION=$(git tag -l 'v[0-9]*' | sort -V | tail -1)
|
|
||||||
|
|
||||||
# Update the beta tag to point to this release
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag -fa beta -m "Update beta tag to ${VERSION}"
|
|
||||||
git push origin beta --force
|
|
||||||
|
|
||||||
- name: Update beta release to be latest
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
# Update beta release to be marked as latest
|
|
||||||
gh release edit beta --latest
|
|
||||||
|
|
||||||
update-major-tag:
|
|
||||||
needs: create-release
|
|
||||||
if: ${{ !inputs.dry_run }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Update major version tag
|
|
||||||
run: |
|
|
||||||
next_version="${{ needs.create-release.outputs.next_version }}"
|
|
||||||
# Extract major version (e.g., v0 from v0.0.20)
|
|
||||||
major_version=$(echo "$next_version" | cut -d. -f1)
|
|
||||||
|
|
||||||
# Update the major version tag to point to this release
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag -fa "$major_version" -m "Update $major_version tag to $next_version"
|
|
||||||
git push origin "$major_version" --force
|
|
||||||
|
|
||||||
echo "Updated $major_version tag to point to $next_version"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Test fixtures should not be formatted to preserve exact output matching
|
|
||||||
test/fixtures/
|
|
||||||
@@ -50,6 +50,20 @@ Thank you for your interest in contributing to Claude Code Action! This document
|
|||||||
bun test
|
bun test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
2. **Integration Tests** (using GitHub Actions locally):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./test-local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This script:
|
||||||
|
|
||||||
|
- Installs `act` if not present (requires Homebrew on macOS)
|
||||||
|
- Runs the GitHub Action workflow locally using Docker
|
||||||
|
- Requires your `ANTHROPIC_API_KEY` to be set
|
||||||
|
|
||||||
|
On Apple Silicon Macs, the script automatically adds the `--container-architecture linux/amd64` flag to avoid compatibility issues.
|
||||||
|
|
||||||
## Pull Request Process
|
## Pull Request Process
|
||||||
|
|
||||||
1. Create a new branch from `main`:
|
1. Create a new branch from `main`:
|
||||||
@@ -89,7 +103,13 @@ Thank you for your interest in contributing to Claude Code Action! This document
|
|||||||
|
|
||||||
When modifying the action:
|
When modifying the action:
|
||||||
|
|
||||||
1. Test in a real GitHub Actions workflow by:
|
1. Test locally with the test script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./test-local.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Test in a real GitHub Actions workflow by:
|
||||||
- Creating a test repository
|
- Creating a test repository
|
||||||
- Using your branch as the action source:
|
- Using your branch as the action source:
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
4
FAQ.md
4
FAQ.md
@@ -12,10 +12,6 @@ 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:
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -49,7 +49,7 @@ on:
|
|||||||
pull_request_review_comment:
|
pull_request_review_comment:
|
||||||
types: [created]
|
types: [created]
|
||||||
issues:
|
issues:
|
||||||
types: [opened, assigned, labeled]
|
types: [opened, assigned]
|
||||||
pull_request_review:
|
pull_request_review:
|
||||||
types: [submitted]
|
types: [submitted]
|
||||||
|
|
||||||
@@ -65,8 +65,6 @@ jobs:
|
|||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# Optional: add assignee trigger for issues
|
# Optional: add assignee trigger for issues
|
||||||
# assignee_trigger: "claude"
|
# assignee_trigger: "claude"
|
||||||
# Optional: add label trigger for issues
|
|
||||||
# label_trigger: "claude"
|
|
||||||
# Optional: add custom environment variables (YAML format)
|
# Optional: add custom environment variables (YAML format)
|
||||||
# claude_env: |
|
# claude_env: |
|
||||||
# NODE_ENV: test
|
# NODE_ENV: test
|
||||||
@@ -82,13 +80,10 @@ jobs:
|
|||||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
|
||||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | 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 | - |
|
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||||
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
|
||||||
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
|
|
||||||
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
|
||||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||||
@@ -97,9 +92,7 @@ jobs:
|
|||||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||||
| `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` |
|
| `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 | "" |
|
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
||||||
|
|
||||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||||
@@ -156,40 +149,6 @@ For MCP servers that require sensitive information like API keys or tokens, use
|
|||||||
# ... other inputs
|
# ... other inputs
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Using Python MCP Servers with uv
|
|
||||||
|
|
||||||
For Python-based MCP servers managed with `uv`, you need to specify the directory containing your server:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- uses: anthropics/claude-code-action@beta
|
|
||||||
with:
|
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
||||||
mcp_config: |
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"my-python-server": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "uv",
|
|
||||||
"args": [
|
|
||||||
"--directory",
|
|
||||||
"${{ github.workspace }}/path/to/server/",
|
|
||||||
"run",
|
|
||||||
"server_file.py"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allowed_tools: "my-python-server__<tool_name>" # Replace <tool_name> with your server's tool names
|
|
||||||
# ... other inputs
|
|
||||||
```
|
|
||||||
|
|
||||||
For example, if your Python MCP server is at `mcp_servers/weather.py`, you would use:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
"args":
|
|
||||||
["--directory", "${{ github.workspace }}/mcp_servers/", "run", "weather.py"]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**:
|
**Important**:
|
||||||
|
|
||||||
- Always use GitHub Secrets (`${{ secrets.SECRET_NAME }}`) for sensitive values like API keys, tokens, or passwords. Never hardcode secrets directly in the workflow file.
|
- Always use GitHub Secrets (`${{ secrets.SECRET_NAME }}`) for sensitive values like API keys, tokens, or passwords. Never hardcode secrets directly in the workflow file.
|
||||||
|
|||||||
38
action.yml
38
action.yml
@@ -12,17 +12,9 @@ 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:
|
||||||
@@ -31,9 +23,6 @@ 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
|
||||||
@@ -81,10 +70,6 @@ inputs:
|
|||||||
description: "Timeout in minutes for execution"
|
description: "Timeout in minutes for execution"
|
||||||
required: false
|
required: false
|
||||||
default: "30"
|
default: "30"
|
||||||
use_sticky_comment:
|
|
||||||
description: "Use just one comment to deliver issue/PR comments"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -113,9 +98,7 @@ 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 }}
|
||||||
@@ -123,12 +106,11 @@ 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 }}
|
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude-code
|
id: claude-code
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||||
uses: anthropics/claude-code-base-action@a835717b36becf75584224421f4094aae288cad7 # v0.0.31
|
uses: anthropics/claude-code-base-action@ebd8558e902b3db132e89863de49565fcb9aec46 # v0.0.19
|
||||||
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 }}
|
||||||
@@ -136,7 +118,6 @@ 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 }}
|
||||||
@@ -189,24 +170,15 @@ runs:
|
|||||||
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
|
TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }}
|
||||||
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
|
PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }}
|
||||||
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
|
||||||
|
|
||||||
- name: Display Claude Code Report
|
- name: Display Claude Code Report
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
# Try to format the turns, but if it fails, dump the raw JSON
|
echo "## Claude Code Report" >> $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
|
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||||
echo "Successfully formatted Claude Code report"
|
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||||
else
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||||
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 == ''
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ 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;
|
||||||
@@ -243,7 +242,7 @@ export function prepareContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventAction === "assigned") {
|
if (eventAction === "assigned") {
|
||||||
if (!assigneeTrigger && !directPrompt) {
|
if (!assigneeTrigger) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"ASSIGNEE_TRIGGER is required for issue assigned event",
|
"ASSIGNEE_TRIGGER is required for issue assigned event",
|
||||||
);
|
);
|
||||||
@@ -255,20 +254,7 @@ 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 = {
|
||||||
@@ -342,17 +328,10 @@ 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: eventData.assigneeTrigger
|
triggerContext: `issue assigned to '${eventData.assigneeTrigger}'`,
|
||||||
? `issue assigned to '${eventData.assigneeTrigger}'`
|
|
||||||
: `issue assigned event`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
case "pull_request":
|
case "pull_request":
|
||||||
@@ -486,7 +465,6 @@ Follow these steps:
|
|||||||
- Analyze the pre-fetched data provided above.
|
- Analyze the pre-fetched data provided above.
|
||||||
- For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase.
|
- For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase.
|
||||||
- For ISSUE_ASSIGNED: Read the entire issue body to understand the task.
|
- For ISSUE_ASSIGNED: Read the entire issue body to understand the task.
|
||||||
- For ISSUE_LABELED: Read the entire issue body to understand the task.
|
|
||||||
${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""}
|
${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""}
|
||||||
${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""}
|
${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""}
|
||||||
- IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions.
|
- IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions.
|
||||||
|
|||||||
@@ -65,17 +65,7 @@ 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 = {
|
||||||
@@ -95,7 +85,6 @@ export type EventData =
|
|||||||
| IssueCommentEvent
|
| IssueCommentEvent
|
||||||
| IssueOpenedEvent
|
| IssueOpenedEvent
|
||||||
| IssueAssignedEvent
|
| IssueAssignedEvent
|
||||||
| IssueLabeledEvent
|
|
||||||
| PullRequestEvent;
|
| PullRequestEvent;
|
||||||
|
|
||||||
// Combined type with separate eventData field
|
// Combined type with separate eventData field
|
||||||
|
|||||||
@@ -1,461 +0,0 @@
|
|||||||
#!/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();
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import * as github from "@actions/github";
|
import * as github from "@actions/github";
|
||||||
import type {
|
import type {
|
||||||
IssuesEvent,
|
IssuesEvent,
|
||||||
IssuesAssignedEvent,
|
|
||||||
IssueCommentEvent,
|
IssueCommentEvent,
|
||||||
PullRequestEvent,
|
PullRequestEvent,
|
||||||
PullRequestReviewEvent,
|
PullRequestReviewEvent,
|
||||||
@@ -29,14 +28,11 @@ 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;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,14 +52,11 @@ 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",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -154,9 +147,3 @@ export function isPullRequestReviewCommentEvent(
|
|||||||
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
|
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
|
||||||
return context.eventName === "pull_request_review_comment";
|
return context.eventName === "pull_request_review_comment";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isIssuesAssignedEvent(
|
|
||||||
context: ParsedGitHubContext,
|
|
||||||
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
|
|
||||||
return isIssuesEvent(context) && context.eventAction === "assigned";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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, branchPrefix } = context.inputs;
|
const { baseBranch } = context.inputs;
|
||||||
const isPR = context.isPR;
|
const isPR = context.isPR;
|
||||||
|
|
||||||
if (isPR) {
|
if (isPR) {
|
||||||
@@ -45,16 +45,9 @@ export async function setupBranch(
|
|||||||
|
|
||||||
const branchName = prData.headRefName;
|
const branchName = prData.headRefName;
|
||||||
|
|
||||||
// Determine optimal fetch depth based on PR commit count, with a minimum of 20
|
// Execute git commands to checkout PR branch (shallow fetch for performance)
|
||||||
const commitCount = prData.commits.totalCount;
|
// Fetch the branch with a depth of 20 to avoid fetching too much history, while still allowing for some context
|
||||||
const fetchDepth = Math.max(commitCount, 20);
|
await $`git fetch origin --depth=20 ${branchName}`;
|
||||||
|
|
||||||
console.log(
|
|
||||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
|
||||||
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
|
||||||
await $`git checkout ${branchName}`;
|
await $`git checkout ${branchName}`;
|
||||||
|
|
||||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||||
@@ -97,7 +90,7 @@ export async function setupBranch(
|
|||||||
.split("T")
|
.split("T")
|
||||||
.join("_");
|
.join("_");
|
||||||
|
|
||||||
const newBranch = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the SHA of the source branch
|
// Get the SHA of the source branch
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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";
|
||||||
@@ -26,39 +25,8 @@ export async function createInitialComment(
|
|||||||
try {
|
try {
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
if (
|
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
||||||
context.inputs.useStickyComment &&
|
if (isPullRequestReviewCommentEvent(context)) {
|
||||||
context.isPR &&
|
|
||||||
isPullRequestEvent(context)
|
|
||||||
) {
|
|
||||||
const comments = await octokit.rest.issues.listComments({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: context.entityNumber,
|
|
||||||
});
|
|
||||||
const existingComment = comments.data.find(
|
|
||||||
(comment) =>
|
|
||||||
comment.user?.login.indexOf("claude[bot]") !== -1 ||
|
|
||||||
comment.body === initialBody,
|
|
||||||
);
|
|
||||||
if (existingComment) {
|
|
||||||
response = await octokit.rest.issues.updateComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: existingComment.id,
|
|
||||||
body: initialBody,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new comment if no existing one found
|
|
||||||
response = await octokit.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: context.entityNumber,
|
|
||||||
body: initialBody,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
|
||||||
// Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id
|
|
||||||
response = await octokit.rest.pulls.createReplyForReviewComment({
|
response = await octokit.rest.pulls.createReplyForReviewComment({
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import {
|
import {
|
||||||
isIssuesEvent,
|
isIssuesEvent,
|
||||||
isIssuesAssignedEvent,
|
|
||||||
isIssueCommentEvent,
|
isIssueCommentEvent,
|
||||||
isPullRequestEvent,
|
isPullRequestEvent,
|
||||||
isPullRequestReviewEvent,
|
isPullRequestReviewEvent,
|
||||||
@@ -13,7 +12,7 @@ import type { ParsedGitHubContext } from "../context";
|
|||||||
|
|
||||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||||
const {
|
const {
|
||||||
inputs: { assigneeTrigger, labelTrigger, triggerPhrase, directPrompt },
|
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
// If direct prompt is provided, always trigger
|
// If direct prompt is provided, always trigger
|
||||||
@@ -23,10 +22,10 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for assignee trigger
|
// Check for assignee trigger
|
||||||
if (isIssuesAssignedEvent(context)) {
|
if (isIssuesEvent(context) && context.eventAction === "assigned") {
|
||||||
// Remove @ symbol from assignee_trigger if present
|
// Remove @ symbol from assignee_trigger if present
|
||||||
let triggerUser = assigneeTrigger.replace(/^@/, "");
|
let triggerUser = assigneeTrigger.replace(/^@/, "");
|
||||||
const assigneeUsername = context.payload.assignee?.login || "";
|
const assigneeUsername = context.payload.issue.assignee?.login || "";
|
||||||
|
|
||||||
if (triggerUser && assigneeUsername === triggerUser) {
|
if (triggerUser && assigneeUsername === triggerUser) {
|
||||||
console.log(`Issue assigned to trigger user '${triggerUser}'`);
|
console.log(`Issue assigned to trigger user '${triggerUser}'`);
|
||||||
@@ -34,16 +33,6 @@ 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 || "";
|
||||||
|
|||||||
@@ -125,58 +125,13 @@ server.tool(
|
|||||||
? filePath
|
? filePath
|
||||||
: join(REPO_DIR, filePath);
|
: join(REPO_DIR, filePath);
|
||||||
|
|
||||||
// Check if file is binary (images, etc.)
|
const content = await readFile(fullPath, "utf-8");
|
||||||
const isBinaryFile =
|
return {
|
||||||
/\.(png|jpg|jpeg|gif|webp|ico|pdf|zip|tar|gz|exe|bin|woff|woff2|ttf|eot)$/i.test(
|
path: filePath,
|
||||||
filePath,
|
mode: "100644",
|
||||||
);
|
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,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-e9f748f", // https://github.com/github/github-mcp-server/releases/tag/v0.4.0
|
||||||
],
|
],
|
||||||
env: {
|
env: {
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||||
|
|||||||
@@ -226,33 +226,6 @@ describe("generatePrompt", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should generate prompt for issue labeled event", () => {
|
|
||||||
const envVars: PreparedContext = {
|
|
||||||
repository: "owner/repo",
|
|
||||||
claudeCommentId: "12345",
|
|
||||||
triggerPhrase: "@claude",
|
|
||||||
eventData: {
|
|
||||||
eventName: "issues",
|
|
||||||
eventAction: "labeled",
|
|
||||||
isPR: false,
|
|
||||||
issueNumber: "888",
|
|
||||||
baseBranch: "main",
|
|
||||||
claudeBranch: "claude/issue-888-20240101_120000",
|
|
||||||
labelTrigger: "claude-task",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
|
||||||
|
|
||||||
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
|
|
||||||
expect(prompt).toContain(
|
|
||||||
"<trigger_context>issue labeled with 'claude-task'</trigger_context>",
|
|
||||||
);
|
|
||||||
expect(prompt).toContain(
|
|
||||||
"[Create a PR](https://github.com/owner/repo/compare/main",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should include direct prompt when provided", () => {
|
test("should include direct prompt when provided", () => {
|
||||||
const envVars: PreparedContext = {
|
const envVars: PreparedContext = {
|
||||||
repository: "owner/repo",
|
repository: "owner/repo",
|
||||||
@@ -641,51 +614,6 @@ 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", () => {
|
||||||
|
|||||||
95
test/fixtures/sample-turns-expected-output.md
vendored
95
test/fixtures/sample-turns-expected-output.md
vendored
@@ -1,95 +0,0 @@
|
|||||||
## 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
196
test/fixtures/sample-turns.json
vendored
@@ -1,196 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,6 @@ 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[],
|
||||||
@@ -19,8 +18,6 @@ const defaultInputs = {
|
|||||||
useBedrock: false,
|
useBedrock: false,
|
||||||
useVertex: false,
|
useVertex: false,
|
||||||
timeoutMinutes: 30,
|
timeoutMinutes: 30,
|
||||||
branchPrefix: "claude/",
|
|
||||||
useStickyComment: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
@@ -94,12 +91,6 @@ export const mockIssueAssignedContext: ParsedGitHubContext = {
|
|||||||
actor: "admin-user",
|
actor: "admin-user",
|
||||||
payload: {
|
payload: {
|
||||||
action: "assigned",
|
action: "assigned",
|
||||||
assignee: {
|
|
||||||
login: "claude-bot",
|
|
||||||
id: 11111,
|
|
||||||
avatar_url: "https://avatars.githubusercontent.com/u/11111",
|
|
||||||
html_url: "https://github.com/claude-bot",
|
|
||||||
},
|
|
||||||
issue: {
|
issue: {
|
||||||
number: 123,
|
number: 123,
|
||||||
title: "Feature: Add dark mode support",
|
title: "Feature: Add dark mode support",
|
||||||
@@ -131,46 +122,6 @@ 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,13 +62,10 @@ describe("checkWritePermissions", () => {
|
|||||||
inputs: {
|
inputs: {
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
disallowedTools: [],
|
disallowedTools: [],
|
||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
branchPrefix: "claude/",
|
|
||||||
useStickyComment: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -219,55 +219,6 @@ 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,7 +6,6 @@ import { describe, it, expect } from "bun:test";
|
|||||||
import {
|
import {
|
||||||
createMockContext,
|
createMockContext,
|
||||||
mockIssueAssignedContext,
|
mockIssueAssignedContext,
|
||||||
mockIssueLabeledContext,
|
|
||||||
mockIssueCommentContext,
|
mockIssueCommentContext,
|
||||||
mockIssueOpenedContext,
|
mockIssueOpenedContext,
|
||||||
mockPullRequestReviewContext,
|
mockPullRequestReviewContext,
|
||||||
@@ -30,13 +29,10 @@ 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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -59,13 +55,10 @@ describe("checkContainsTrigger", () => {
|
|||||||
inputs: {
|
inputs: {
|
||||||
triggerPhrase: "/claude",
|
triggerPhrase: "/claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
|
||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
disallowedTools: [],
|
disallowedTools: [],
|
||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
|
||||||
useStickyComment: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
@@ -94,11 +87,6 @@ describe("checkContainsTrigger", () => {
|
|||||||
...mockIssueAssignedContext,
|
...mockIssueAssignedContext,
|
||||||
payload: {
|
payload: {
|
||||||
...mockIssueAssignedContext.payload,
|
...mockIssueAssignedContext.payload,
|
||||||
assignee: {
|
|
||||||
...(mockIssueAssignedContext.payload as IssuesAssignedEvent)
|
|
||||||
.assignee,
|
|
||||||
login: "otherUser",
|
|
||||||
},
|
|
||||||
issue: {
|
issue: {
|
||||||
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue,
|
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue,
|
||||||
assignee: {
|
assignee: {
|
||||||
@@ -114,39 +102,6 @@ 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;
|
||||||
@@ -272,13 +227,10 @@ describe("checkContainsTrigger", () => {
|
|||||||
inputs: {
|
inputs: {
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
|
||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
disallowedTools: [],
|
disallowedTools: [],
|
||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
|
||||||
useStickyComment: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -302,13 +254,10 @@ describe("checkContainsTrigger", () => {
|
|||||||
inputs: {
|
inputs: {
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
|
||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
disallowedTools: [],
|
disallowedTools: [],
|
||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
|
||||||
useStickyComment: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -332,13 +281,10 @@ describe("checkContainsTrigger", () => {
|
|||||||
inputs: {
|
inputs: {
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
|
||||||
directPrompt: "",
|
directPrompt: "",
|
||||||
allowedTools: [],
|
allowedTools: [],
|
||||||
disallowedTools: [],
|
disallowedTools: [],
|
||||||
customInstructions: "",
|
customInstructions: "",
|
||||||
branchPrefix: "claude/",
|
|
||||||
useStickyComment: false,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user