mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-09-19 15:40:33 +08:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece24efbb4 | ||
|
|
fea6d7077e | ||
|
|
68cfeead18 | ||
|
|
f5088835af | ||
|
|
ea36d6abde | ||
|
|
c22f7c3f9d | ||
|
|
f669191d7d | ||
|
|
8c383c5de3 | ||
|
|
1bb0e7464b | ||
|
|
23ed4cb53d | ||
|
|
21e3fe0542 | ||
|
|
b433f16b30 | ||
|
|
7695f7866a | ||
|
|
d5b01b6843 | ||
|
|
6c61301d8e | ||
|
|
db388438c1 | ||
|
|
b113f49a56 | ||
|
|
7057f3318b | ||
|
|
f09dc9a6a3 | ||
|
|
006aaf2935 | ||
|
|
9a3c761f54 | ||
|
|
6867bb3ab0 | ||
|
|
98af40b63c | ||
|
|
4ce5f178c2 | ||
|
|
fab4258c6e | ||
|
|
70e16deb18 | ||
|
|
0ed5eeaa54 | ||
|
|
01e756b34e | ||
|
|
ff34ce0ff0 | ||
|
|
2817c54db8 | ||
|
|
d01eedd981 | ||
|
|
49046e0709 | ||
|
|
32ac7269f2 | ||
|
|
fe72061e16 | ||
|
|
231bd75b71 | ||
|
|
4126f9d975 | ||
|
|
ba45bb9506 | ||
|
|
0c704179b5 | ||
|
|
f64219702d | ||
|
|
8341a564b0 | ||
|
|
2804b4174b | ||
|
|
2316a9a8db | ||
|
|
49cfcf8107 | ||
|
|
e208124d29 | ||
|
|
ba60ef7ba2 | ||
|
|
f3c892ca8d | ||
|
|
6e896a06bb |
@@ -0,0 +1,37 @@
|
||||
# Orchestrates all CI workflows - runs on PRs, pushes to main, and manual dispatch
|
||||
# Individual test workflows are called as reusable workflows
|
||||
name: CI All
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
test-base-action:
|
||||
uses: ./.github/workflows/test-base-action.yml
|
||||
secrets: inherit # Required for ANTHROPIC_API_KEY
|
||||
|
||||
test-custom-executables:
|
||||
uses: ./.github/workflows/test-custom-executables.yml
|
||||
secrets: inherit
|
||||
|
||||
test-mcp-servers:
|
||||
uses: ./.github/workflows/test-mcp-servers.yml
|
||||
secrets: inherit
|
||||
|
||||
test-settings:
|
||||
uses: ./.github/workflows/test-settings.yml
|
||||
secrets: inherit
|
||||
|
||||
test-structured-output:
|
||||
uses: ./.github/workflows/test-structured-output.yml
|
||||
secrets: inherit
|
||||
@@ -1,15 +1,14 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -24,7 +23,7 @@ jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
@@ -39,7 +38,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -25,3 +25,4 @@ jobs:
|
||||
prompt: "/review-pr REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }}"
|
||||
claude_args: |
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment"
|
||||
--model "claude-opus-4-6"
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -36,4 +36,4 @@ jobs:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
claude_args: |
|
||||
--allowedTools "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||
--model "claude-opus-4-5"
|
||||
--model "claude-opus-4-6"
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -8,10 +8,23 @@ on:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_run:
|
||||
workflows: ["CI All"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
# Run if: manual dispatch OR (CI All succeeded AND commit is a version bump)
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
startsWith(github.event.workflow_run.head_commit.message, 'chore: bump Claude Code to'))
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -19,7 +32,7 @@ jobs:
|
||||
next_version: ${{ steps.next_version.outputs.next_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -84,14 +97,15 @@ jobs:
|
||||
|
||||
update-major-tag:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
# Skip for dry runs (workflow_run events are never dry runs)
|
||||
if: github.event_name == 'workflow_run' || !inputs.dry_run
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -116,7 +130,7 @@ jobs:
|
||||
# environment: production
|
||||
# steps:
|
||||
# - name: Checkout base-action repo
|
||||
# uses: actions/checkout@v5
|
||||
# uses: actions/checkout@v6
|
||||
# with:
|
||||
# repository: anthropics/claude-code-base-action
|
||||
# token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
name: Test Claude Code Action
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -11,6 +8,7 @@ on:
|
||||
description: "Test prompt for Claude"
|
||||
required: false
|
||||
default: "List the files in the current directory starting with 'package'"
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-inline-prompt:
|
||||
@@ -118,61 +116,3 @@ jobs:
|
||||
echo "❌ Execution log file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test-agent-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
- name: Test with Agent SDK
|
||||
id: sdk-test
|
||||
uses: ./base-action
|
||||
env:
|
||||
USE_AGENT_SDK: "true"
|
||||
with:
|
||||
prompt: ${{ github.event.inputs.test_prompt || 'List the files in the current directory starting with "package"' }}
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
allowed_tools: "LS,Read"
|
||||
|
||||
- name: Verify SDK output
|
||||
run: |
|
||||
OUTPUT_FILE="${{ steps.sdk-test.outputs.execution_file }}"
|
||||
CONCLUSION="${{ steps.sdk-test.outputs.conclusion }}"
|
||||
|
||||
echo "Conclusion: $CONCLUSION"
|
||||
echo "Output file: $OUTPUT_FILE"
|
||||
|
||||
if [ "$CONCLUSION" = "success" ]; then
|
||||
echo "✅ Action completed successfully with Agent SDK"
|
||||
else
|
||||
echo "❌ Action failed with Agent SDK"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$OUTPUT_FILE" ]; then
|
||||
if [ -s "$OUTPUT_FILE" ]; then
|
||||
echo "✅ Execution log file created successfully with content"
|
||||
echo "Validating JSON format:"
|
||||
if jq . "$OUTPUT_FILE" > /dev/null 2>&1; then
|
||||
echo "✅ Output is valid JSON"
|
||||
# Verify SDK output contains total_cost_usd (SDK field name)
|
||||
if jq -e '.[] | select(.type == "result") | .total_cost_usd' "$OUTPUT_FILE" > /dev/null 2>&1; then
|
||||
echo "✅ SDK output contains total_cost_usd field"
|
||||
else
|
||||
echo "❌ SDK output missing total_cost_usd field"
|
||||
exit 1
|
||||
fi
|
||||
echo "Content preview:"
|
||||
head -c 500 "$OUTPUT_FILE"
|
||||
else
|
||||
echo "❌ Output is not valid JSON"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Execution log file is empty"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ Execution log file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Test Custom Executables
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-custom-executables:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Test MCP Servers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-mcp-integration:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Test Settings Feature
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
test-settings-inline-allow:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Test Structured Outputs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,136 +1,44 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Tools
|
||||
|
||||
- Runtime: Bun 1.2.11
|
||||
- TypeScript with strict configuration
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Available npm/bun scripts from package.json:
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Test
|
||||
bun test
|
||||
|
||||
# Formatting
|
||||
bun run format # Format code with prettier
|
||||
bun run format:check # Check code formatting
|
||||
|
||||
# Type checking
|
||||
bun run typecheck # Run TypeScript type checker
|
||||
bun test # Run tests
|
||||
bun run typecheck # TypeScript type checking
|
||||
bun run format # Format with prettier
|
||||
bun run format:check # Check formatting
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
## What This Is
|
||||
|
||||
This is a GitHub Action that enables Claude to interact with GitHub PRs and issues. The action operates in two main phases:
|
||||
A GitHub Action that lets Claude respond to `@claude` mentions on issues/PRs (tag mode) or run tasks via `prompt` input (agent mode). Mode is auto-detected: if `prompt` is provided, it's agent mode; if triggered by a comment/issue event with `@claude`, it's tag mode. See `src/modes/detector.ts`.
|
||||
|
||||
### Phase 1: Preparation (`src/entrypoints/prepare.ts`)
|
||||
## How It Runs
|
||||
|
||||
1. **Authentication Setup**: Establishes GitHub token via OIDC or GitHub App
|
||||
2. **Permission Validation**: Verifies actor has write permissions
|
||||
3. **Trigger Detection**: Uses mode-specific logic to determine if Claude should respond
|
||||
4. **Context Creation**: Prepares GitHub context and initial tracking comment
|
||||
Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare (auth, permissions, trigger check, branch/comment creation), install Claude Code CLI, execute Claude via `base-action/` functions (imported directly, not subprocess), then cleanup (update tracking comment, write step summary). SSH signing cleanup and token revocation are separate `always()` steps in `action.yml`.
|
||||
|
||||
### Phase 2: Execution (`base-action/`)
|
||||
`base-action/` is also published standalone as `@anthropic-ai/claude-code-base-action`. Don't break its public API. It reads config from `INPUT_`-prefixed env vars (set by `action.yml`), not from action inputs directly.
|
||||
|
||||
The `base-action/` directory contains the core Claude Code execution logic, which serves a dual purpose:
|
||||
## Key Concepts
|
||||
|
||||
- **Standalone Action**: Published separately as `@anthropic-ai/claude-code-base-action` for direct use
|
||||
- **Inner Logic**: Used internally by this GitHub Action after preparation phase completes
|
||||
**Auth priority**: `github_token` input (user-provided) > GitHub App OIDC token (default). The `claude_code_oauth_token` and `anthropic_api_key` are for the Claude API, not GitHub. Token setup lives in `src/github/token.ts`.
|
||||
|
||||
Execution steps:
|
||||
**Mode lifecycle**: `detectMode()` in `src/modes/detector.ts` picks the mode name ("tag" or "agent"). Trigger checking and prepare dispatch are inlined in `run.ts`: tag mode calls `prepareTagMode()` from `src/modes/tag/`, agent mode calls `prepareAgentMode()` from `src/modes/agent/`.
|
||||
|
||||
1. **MCP Server Setup**: Installs and configures GitHub MCP server for tool access
|
||||
2. **Prompt Generation**: Creates context-rich prompts from GitHub data
|
||||
3. **Claude Integration**: Executes via multiple providers (Anthropic API, AWS Bedrock, Google Vertex AI)
|
||||
4. **Result Processing**: Updates comments and creates branches/PRs as needed
|
||||
**Prompt construction**: Tag mode's `prepareTagMode()` builds the prompt by fetching GitHub data (`src/github/data/fetcher.ts`), formatting it as markdown (`src/github/data/formatter.ts`), and writing it to a temp file via `createPrompt()`. Agent mode writes the user's prompt directly. The prompt includes issue/PR body, comments, diff, and CI status. This is the most important part of the action — it's what Claude sees.
|
||||
|
||||
### Key Architectural Components
|
||||
## Things That Will Bite You
|
||||
|
||||
#### Mode System (`src/modes/`)
|
||||
|
||||
- **Tag Mode** (`tag/`): Responds to `@claude` mentions and issue assignments
|
||||
- **Agent Mode** (`agent/`): Direct execution when explicit prompt is provided
|
||||
- Extensible registry pattern in `modes/registry.ts`
|
||||
|
||||
#### GitHub Integration (`src/github/`)
|
||||
|
||||
- **Context Parsing** (`context.ts`): Unified GitHub event handling
|
||||
- **Data Fetching** (`data/fetcher.ts`): Retrieves PR/issue data via GraphQL/REST
|
||||
- **Data Formatting** (`data/formatter.ts`): Converts GitHub data to Claude-readable format
|
||||
- **Branch Operations** (`operations/branch.ts`): Handles branch creation and cleanup
|
||||
- **Comment Management** (`operations/comments/`): Creates and updates tracking comments
|
||||
|
||||
#### MCP Server Integration (`src/mcp/`)
|
||||
|
||||
- **GitHub Actions Server** (`github-actions-server.ts`): Workflow and CI access
|
||||
- **GitHub Comment Server** (`github-comment-server.ts`): Comment operations
|
||||
- **GitHub File Operations** (`github-file-ops-server.ts`): File system access
|
||||
- Auto-installation and configuration in `install-mcp-server.ts`
|
||||
|
||||
#### Authentication & Security (`src/github/`)
|
||||
|
||||
- **Token Management** (`token.ts`): OIDC token exchange and GitHub App authentication
|
||||
- **Permission Validation** (`validation/permissions.ts`): Write access verification
|
||||
- **Actor Validation** (`validation/actor.ts`): Human vs bot detection
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── entrypoints/ # Action entry points
|
||||
│ ├── prepare.ts # Main preparation logic
|
||||
│ ├── update-comment-link.ts # Post-execution comment updates
|
||||
│ └── format-turns.ts # Claude conversation formatting
|
||||
├── github/ # GitHub integration layer
|
||||
│ ├── api/ # REST/GraphQL clients
|
||||
│ ├── data/ # Data fetching and formatting
|
||||
│ ├── operations/ # Branch, comment, git operations
|
||||
│ ├── validation/ # Permission and trigger validation
|
||||
│ └── utils/ # Image downloading, sanitization
|
||||
├── modes/ # Execution modes
|
||||
│ ├── tag/ # @claude mention mode
|
||||
│ ├── agent/ # Automation mode
|
||||
│ └── registry.ts # Mode selection logic
|
||||
├── mcp/ # MCP server implementations
|
||||
├── prepare/ # Preparation orchestration
|
||||
└── utils/ # Shared utilities
|
||||
```
|
||||
|
||||
## Important Implementation Notes
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
- Uses GitHub OIDC token exchange for secure authentication
|
||||
- Supports custom GitHub Apps via `APP_ID` and `APP_PRIVATE_KEY`
|
||||
- Falls back to official Claude GitHub App if no custom app provided
|
||||
|
||||
### MCP Server Architecture
|
||||
|
||||
- Each MCP server has specific GitHub API access patterns
|
||||
- Servers are auto-installed in `~/.claude/mcp/github-{type}-server/`
|
||||
- Configuration merged with user-provided MCP config via `mcp_config` input
|
||||
|
||||
### Mode System Design
|
||||
|
||||
- Modes implement `Mode` interface with `shouldTrigger()` and `prepare()` methods
|
||||
- Registry validates mode compatibility with GitHub event types
|
||||
- Agent mode triggers when explicit prompt is provided
|
||||
|
||||
### Comment Threading
|
||||
|
||||
- Single tracking comment updated throughout execution
|
||||
- Progress indicated via dynamic checkboxes
|
||||
- Links to job runs and created branches/PRs
|
||||
- Sticky comment option for consolidated PR comments
|
||||
- **Strict TypeScript**: `noUnusedLocals` and `noUnusedParameters` are enabled. Typecheck will fail on unused variables.
|
||||
- **Discriminated unions for GitHub context**: `GitHubContext` is a union type — call `isEntityContext(context)` before accessing entity-specific fields like `context.issue` or `context.pullRequest`.
|
||||
- **Token lifecycle matters**: The GitHub App token is obtained early and revoked in a separate `always()` step in `action.yml`. If you move token revocation into `run.ts`, it won't run if the process crashes. Same for SSH signing cleanup.
|
||||
- **Error phase attribution**: The catch block in `run.ts` uses `prepareCompleted` to distinguish prepare failures from execution failures. The tracking comment shows different messages for each.
|
||||
- **`action.yml` outputs reference step IDs**: Outputs like `execution_file`, `branch_name`, `github_token` reference `steps.run.outputs.*`. If you rename the step ID, update the outputs section too.
|
||||
- **Integration testing** happens in a separate repo (`install-test`), not here. The tests in this repo are unit tests.
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- Use Bun-specific TypeScript configuration with `moduleResolution: "bundler"`
|
||||
- Strict TypeScript with `noUnusedLocals` and `noUnusedParameters` enabled
|
||||
- Prefer explicit error handling with detailed error messages
|
||||
- Use discriminated unions for GitHub context types
|
||||
- Implement retry logic for GitHub API operations via `utils/retry.ts`
|
||||
- Runtime is Bun, not Node. Use `bun test`, not `jest`.
|
||||
- `moduleResolution: "bundler"` — imports don't need `.js` extensions.
|
||||
- GitHub API calls should use retry logic (`src/utils/retry.ts`).
|
||||
- MCP servers are auto-installed at runtime to `~/.claude/mcp/github-{type}-server/`.
|
||||
|
||||
+36
-110
@@ -35,6 +35,14 @@ inputs:
|
||||
description: "Comma-separated list of usernames to allow without write permissions, or '*' to allow all users. Only works when github_token input is provided. WARNING: Use with extreme caution - this bypasses security checks and should only be used for workflows with very limited permissions (e.g., issue labeling)."
|
||||
required: false
|
||||
default: ""
|
||||
include_comments_by_actor:
|
||||
description: "Comma-separated list of actor usernames to INCLUDE in comments. Supports wildcards: '*[bot]' matches all bots, 'dependabot[bot]' matches specific bot. Empty (default) includes all actors."
|
||||
required: false
|
||||
default: ""
|
||||
exclude_comments_by_actor:
|
||||
description: "Comma-separated list of actor usernames to EXCLUDE from comments. Supports wildcards: '*[bot]' matches all bots, 'renovate[bot]' matches specific bot. Empty (default) excludes none. If actor is in both lists, exclusion takes priority."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
# Claude Code configuration
|
||||
prompt:
|
||||
@@ -129,28 +137,29 @@ inputs:
|
||||
outputs:
|
||||
execution_file:
|
||||
description: "Path to the Claude Code execution output file"
|
||||
value: ${{ steps.claude-code.outputs.execution_file }}
|
||||
value: ${{ steps.run.outputs.execution_file }}
|
||||
branch_name:
|
||||
description: "The branch created by Claude Code for this execution"
|
||||
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
value: ${{ steps.run.outputs.branch_name }}
|
||||
github_token:
|
||||
description: "The GitHub token used by the action (Claude App token if available)"
|
||||
value: ${{ steps.prepare.outputs.github_token }}
|
||||
value: ${{ steps.run.outputs.github_token }}
|
||||
structured_output:
|
||||
description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name"
|
||||
value: ${{ steps.claude-code.outputs.structured_output }}
|
||||
value: ${{ steps.run.outputs.structured_output }}
|
||||
session_id:
|
||||
description: "The Claude Code session ID that can be used with --resume to continue this conversation"
|
||||
value: ${{ steps.claude-code.outputs.session_id }}
|
||||
value: ${{ steps.run.outputs.session_id }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Bun
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
bun-version: 1.3.6
|
||||
token: ${{ inputs.github_token || github.token }}
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -167,14 +176,15 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd ${GITHUB_ACTION_PATH}
|
||||
bun install
|
||||
bun install --production
|
||||
|
||||
- name: Prepare action
|
||||
id: prepare
|
||||
- name: Run Claude Code Action
|
||||
id: run
|
||||
shell: bash
|
||||
run: |
|
||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
|
||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/run.ts
|
||||
env:
|
||||
# Prepare inputs
|
||||
MODE: ${{ inputs.mode }}
|
||||
PROMPT: ${{ inputs.prompt }}
|
||||
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
@@ -186,6 +196,8 @@ runs:
|
||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
||||
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
|
||||
INCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.include_comments_by_actor }}
|
||||
EXCLUDE_COMMENTS_BY_ACTOR: ${{ inputs.exclude_comments_by_actor }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||
@@ -199,73 +211,19 @@ runs:
|
||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Install Base Action Dependencies
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
echo "Installing base-action dependencies..."
|
||||
cd ${GITHUB_ACTION_PATH}/base-action
|
||||
bun install
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
|
||||
# Install Claude Code if no custom executable is provided
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.9"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
if command -v timeout &> /dev/null; then
|
||||
# Use --foreground to kill entire process group on timeout, --kill-after to send SIGKILL if SIGTERM fails
|
||||
timeout --foreground --kill-after=10 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
|
||||
else
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
|
||||
fi
|
||||
if [ $attempt -eq 3 ]; then
|
||||
echo "Failed to install Claude Code after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Installation failed, retrying..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Claude Code installed successfully"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
else
|
||||
echo "Using custom Claude Code executable: $PATH_TO_CLAUDE_CODE_EXECUTABLE"
|
||||
# Add the directory containing the custom executable to PATH
|
||||
CLAUDE_DIR=$(dirname "$PATH_TO_CLAUDE_CODE_EXECUTABLE")
|
||||
echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
# Run the base-action
|
||||
bun run ${GITHUB_ACTION_PATH}/base-action/src/index.ts
|
||||
env:
|
||||
# Base-action inputs
|
||||
CLAUDE_CODE_ACTION: "1"
|
||||
INPUT_PROMPT_FILE: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||
INPUT_SETTINGS: ${{ inputs.settings }}
|
||||
INPUT_CLAUDE_ARGS: ${{ steps.prepare.outputs.claude_args }}
|
||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||
INPUT_SHOW_FULL_OUTPUT: ${{ inputs.show_full_output }}
|
||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||
INPUT_PLUGIN_MARKETPLACES: ${{ inputs.plugin_marketplaces }}
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
|
||||
# Model configuration
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
NODE_VERSION: ${{ env.NODE_VERSION }}
|
||||
DETAILED_PERMISSION_MESSAGES: "1"
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
@@ -302,48 +260,16 @@ runs:
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_MODEL }}
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
||||
|
||||
- name: Update comment with job link
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always()
|
||||
shell: bash
|
||||
run: |
|
||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts
|
||||
env:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
|
||||
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
|
||||
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
||||
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
|
||||
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_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }}
|
||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
|
||||
TRACK_PROGRESS: ${{ inputs.track_progress }}
|
||||
|
||||
- name: Display Claude Code Report
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != ''
|
||||
shell: bash
|
||||
run: |
|
||||
# Try to format the turns, but if it fails, dump the raw JSON
|
||||
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 "Successfully formatted Claude Code report"
|
||||
else
|
||||
echo "## Claude Code Report (Raw Output)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Failed to format output (please report). Here's the raw JSON:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```json' >> $GITHUB_STEP_SUMMARY
|
||||
cat "${{ steps.claude-code.outputs.execution_file }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
# Telemetry configuration
|
||||
CLAUDE_CODE_ENABLE_TELEMETRY: ${{ env.CLAUDE_CODE_ENABLE_TELEMETRY }}
|
||||
OTEL_METRICS_EXPORTER: ${{ env.OTEL_METRICS_EXPORTER }}
|
||||
OTEL_LOGS_EXPORTER: ${{ env.OTEL_LOGS_EXPORTER }}
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ env.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ env.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ env.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
OTEL_METRIC_EXPORT_INTERVAL: ${{ env.OTEL_METRIC_EXPORT_INTERVAL }}
|
||||
OTEL_LOGS_EXPORT_INTERVAL: ${{ env.OTEL_LOGS_EXPORT_INTERVAL }}
|
||||
OTEL_RESOURCE_ATTRIBUTES: ${{ env.OTEL_RESOURCE_ATTRIBUTES }}
|
||||
|
||||
- name: Cleanup SSH signing key
|
||||
if: always() && inputs.ssh_signing_key != ''
|
||||
@@ -352,12 +278,12 @@ runs:
|
||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/cleanup-ssh-signing.ts
|
||||
|
||||
- name: Revoke app token
|
||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||
if: always() && inputs.github_token == '' && steps.run.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L \
|
||||
-X DELETE \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ steps.prepare.outputs.GITHUB_TOKEN }}" \
|
||||
-H "Authorization: Bearer ${{ steps.run.outputs.github_token }}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
${GITHUB_API_URL:-https://api.github.com}/installation/token
|
||||
|
||||
@@ -27,7 +27,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
||||
### Key Design Patterns
|
||||
|
||||
- Uses Bun runtime for development and execution
|
||||
- Named pipes for IPC between prompt input and Claude process
|
||||
- JSON streaming output format for execution logs
|
||||
- Composite action pattern to orchestrate multiple steps
|
||||
- Provider-agnostic design supporting Anthropic API, AWS Bedrock, and Google Vertex AI
|
||||
@@ -54,7 +53,6 @@ This is a GitHub Action that allows running Claude Code within GitHub workflows.
|
||||
|
||||
## Important Technical Details
|
||||
|
||||
- Uses `mkfifo` to create named pipes for prompt input
|
||||
- Outputs execution logs as JSON to `/tmp/claude-execution-output.json`
|
||||
- Timeout enforcement via `timeout` command wrapper
|
||||
- Strict TypeScript configuration with Bun-specific settings
|
||||
|
||||
@@ -339,7 +339,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
+15
-4
@@ -97,9 +97,9 @@ runs:
|
||||
|
||||
- name: Install Bun
|
||||
if: inputs.path_to_bun_executable == ''
|
||||
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # https://github.com/oven-sh/setup-bun/releases/tag/v2.0.2
|
||||
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # https://github.com/oven-sh/setup-bun/releases/tag/v2.1.2
|
||||
with:
|
||||
bun-version: 1.2.11
|
||||
bun-version: 1.3.6
|
||||
|
||||
- name: Setup Custom Bun Path
|
||||
if: inputs.path_to_bun_executable != ''
|
||||
@@ -116,7 +116,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
cd ${GITHUB_ACTION_PATH}
|
||||
bun install
|
||||
bun install --production
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
@@ -124,7 +124,7 @@ runs:
|
||||
PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
run: |
|
||||
if [ -z "$PATH_TO_CLAUDE_CODE_EXECUTABLE" ]; then
|
||||
CLAUDE_CODE_VERSION="2.1.9"
|
||||
CLAUDE_CODE_VERSION="2.1.42"
|
||||
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
|
||||
for attempt in 1 2 3; do
|
||||
echo "Installation attempt $attempt..."
|
||||
@@ -202,3 +202,14 @@ runs:
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: ${{ env.ANTHROPIC_DEFAULT_SONNET_MODEL }}
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: ${{ env.ANTHROPIC_DEFAULT_HAIKU_MODEL }}
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: ${{ env.ANTHROPIC_DEFAULT_OPUS_MODEL }}
|
||||
|
||||
# Telemetry configuration
|
||||
CLAUDE_CODE_ENABLE_TELEMETRY: ${{ env.CLAUDE_CODE_ENABLE_TELEMETRY }}
|
||||
OTEL_METRICS_EXPORTER: ${{ env.OTEL_METRICS_EXPORTER }}
|
||||
OTEL_LOGS_EXPORTER: ${{ env.OTEL_LOGS_EXPORTER }}
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ env.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ env.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ env.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
OTEL_METRIC_EXPORT_INTERVAL: ${{ env.OTEL_METRIC_EXPORT_INTERVAL }}
|
||||
OTEL_LOGS_EXPORT_INTERVAL: ${{ env.OTEL_LOGS_EXPORT_INTERVAL }}
|
||||
OTEL_RESOURCE_ATTRIBUTES: ${{ env.OTEL_RESOURCE_ATTRIBUTES }}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "@anthropic-ai/claude-code-base-action",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.9",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.42",
|
||||
"shell-quote": "^1.8.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.9", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-b4JD6ZKCZeVDqpWBnb+zJISWi3HzlweNlV7Oy/uo5G2XAfUV2M5AJ/tomKZCvZsvmr1fYbmmfyde3GL2h0pksA=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.42", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-/CugP7AjP57Dqtl2sbsDtxdbpQoPKIhjyF5WrTViGu4NHQdM+UikrRs4MhZ2jeotiC5R7iK9ZUN9SiBgcZ8oLw=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.9",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.42",
|
||||
"shell-quote": "^1.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function run() {
|
||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||
});
|
||||
|
||||
await runClaude(promptConfig.path, {
|
||||
const result = await runClaude(promptConfig.path, {
|
||||
claudeArgs: process.env.INPUT_CLAUDE_ARGS,
|
||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||
@@ -36,13 +36,24 @@ async function run() {
|
||||
mcpConfig: process.env.INPUT_MCP_CONFIG,
|
||||
systemPrompt: process.env.INPUT_SYSTEM_PROMPT,
|
||||
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
pathToClaudeCodeExecutable:
|
||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
||||
});
|
||||
|
||||
// Set outputs for the standalone base-action
|
||||
core.setOutput("conclusion", result.conclusion);
|
||||
if (result.executionFile) {
|
||||
core.setOutput("execution_file", result.executionFile);
|
||||
}
|
||||
if (result.sessionId) {
|
||||
core.setOutput("session_id", result.sessionId);
|
||||
}
|
||||
if (result.structuredOutput) {
|
||||
core.setOutput("structured_output", result.structuredOutput);
|
||||
}
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
@@ -212,7 +212,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
env.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
// Ensure SDK path uses the same entrypoint as the CLI path
|
||||
// Set the entrypoint for Claude Code to identify this as the GitHub Action
|
||||
env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action";
|
||||
|
||||
// Build system prompt option - default to claude_code preset
|
||||
|
||||
@@ -9,6 +9,13 @@ import type {
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ParsedSdkOptions } from "./parse-sdk-options";
|
||||
|
||||
export type ClaudeRunResult = {
|
||||
executionFile?: string;
|
||||
sessionId?: string;
|
||||
conclusion: "success" | "failure";
|
||||
structuredOutput?: string;
|
||||
};
|
||||
|
||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||
|
||||
/** Filename for the user request file, written by prompt generation */
|
||||
@@ -129,7 +136,7 @@ function sanitizeSdkOutput(
|
||||
export async function runClaudeWithSdk(
|
||||
promptPath: string,
|
||||
{ sdkOptions, showFullOutput, hasJsonSchema }: ParsedSdkOptions,
|
||||
): Promise<void> {
|
||||
): Promise<ClaudeRunResult> {
|
||||
// Create prompt configuration - may be a string or multi-block message
|
||||
const prompt = await createPromptConfig(promptPath, showFullOutput);
|
||||
|
||||
@@ -165,27 +172,38 @@ export async function runClaudeWithSdk(
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SDK execution error:", error);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
throw new Error(`SDK execution error: ${error}`);
|
||||
}
|
||||
|
||||
const result: ClaudeRunResult = {
|
||||
conclusion: "failure",
|
||||
};
|
||||
|
||||
// Write execution file
|
||||
try {
|
||||
await writeFile(EXECUTION_FILE, JSON.stringify(messages, null, 2));
|
||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
result.executionFile = EXECUTION_FILE;
|
||||
} catch (error) {
|
||||
core.warning(`Failed to write execution file: ${error}`);
|
||||
}
|
||||
|
||||
// Extract session_id from system.init message
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && "subtype" in m && m.subtype === "init",
|
||||
);
|
||||
if (initMessage && "session_id" in initMessage && initMessage.session_id) {
|
||||
result.sessionId = initMessage.session_id as string;
|
||||
core.info(`Set session_id: ${result.sessionId}`);
|
||||
}
|
||||
|
||||
if (!resultMessage) {
|
||||
core.setOutput("conclusion", "failure");
|
||||
core.error("No result message received from Claude");
|
||||
process.exit(1);
|
||||
throw new Error("No result message received from Claude");
|
||||
}
|
||||
|
||||
const isSuccess = resultMessage.subtype === "success";
|
||||
core.setOutput("conclusion", isSuccess ? "success" : "failure");
|
||||
result.conclusion = isSuccess ? "success" : "failure";
|
||||
|
||||
// Handle structured output
|
||||
if (hasJsonSchema) {
|
||||
@@ -194,10 +212,7 @@ export async function runClaudeWithSdk(
|
||||
"structured_output" in resultMessage &&
|
||||
resultMessage.structured_output
|
||||
) {
|
||||
const structuredOutputJson = JSON.stringify(
|
||||
resultMessage.structured_output,
|
||||
);
|
||||
core.setOutput("structured_output", structuredOutputJson);
|
||||
result.structuredOutput = JSON.stringify(resultMessage.structured_output);
|
||||
core.info(
|
||||
`Set structured_output with ${Object.keys(resultMessage.structured_output as object).length} field(s)`,
|
||||
);
|
||||
@@ -205,8 +220,10 @@ export async function runClaudeWithSdk(
|
||||
core.setFailed(
|
||||
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
||||
);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
result.conclusion = "failure";
|
||||
throw new Error(
|
||||
`--json-schema was provided but Claude did not return structured_output. Result subtype: ${resultMessage.subtype}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +231,14 @@ export async function runClaudeWithSdk(
|
||||
if ("errors" in resultMessage && resultMessage.errors) {
|
||||
core.error(`Execution failed: ${resultMessage.errors.join(", ")}`);
|
||||
}
|
||||
process.exit(1);
|
||||
throw new Error(
|
||||
`Claude execution failed: ${
|
||||
"errors" in resultMessage && resultMessage.errors
|
||||
? resultMessage.errors.join(", ")
|
||||
: "unknown error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,72 +1,7 @@
|
||||
import * as core from "@actions/core";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { unlink, writeFile, stat, readFile } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { parse as parseShellArgs } from "shell-quote";
|
||||
import { runClaudeWithSdk } from "./run-claude-sdk";
|
||||
import type { ClaudeRunResult } from "./run-claude-sdk";
|
||||
import { parseSdkOptions } from "./parse-sdk-options";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const PIPE_PATH = `${process.env.RUNNER_TEMP}/claude_prompt_pipe`;
|
||||
const EXECUTION_FILE = `${process.env.RUNNER_TEMP}/claude-execution-output.json`;
|
||||
const BASE_ARGS = ["--verbose", "--output-format", "stream-json"];
|
||||
|
||||
/**
|
||||
* Sanitizes JSON output to remove sensitive information when full output is disabled
|
||||
* Returns a safe summary message or null if the message should be completely suppressed
|
||||
*/
|
||||
function sanitizeJsonOutput(
|
||||
jsonObj: any,
|
||||
showFullOutput: boolean,
|
||||
): string | null {
|
||||
if (showFullOutput) {
|
||||
// In full output mode, return the full JSON
|
||||
return JSON.stringify(jsonObj, null, 2);
|
||||
}
|
||||
|
||||
// In non-full-output mode, provide minimal safe output
|
||||
const type = jsonObj.type;
|
||||
const subtype = jsonObj.subtype;
|
||||
|
||||
// System initialization - safe to show
|
||||
if (type === "system" && subtype === "init") {
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
message: "Claude Code initialized",
|
||||
model: jsonObj.model || "unknown",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// Result messages - Always show the final result
|
||||
if (type === "result") {
|
||||
// These messages contain the final result and should always be visible
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "result",
|
||||
subtype: jsonObj.subtype,
|
||||
is_error: jsonObj.is_error,
|
||||
duration_ms: jsonObj.duration_ms,
|
||||
num_turns: jsonObj.num_turns,
|
||||
total_cost_usd: jsonObj.total_cost_usd,
|
||||
permission_denials: jsonObj.permission_denials,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
// For any other message types, suppress completely in non-full-output mode
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ClaudeOptions = {
|
||||
claudeArgs?: string;
|
||||
model?: string;
|
||||
@@ -77,363 +12,14 @@ export type ClaudeOptions = {
|
||||
mcpConfig?: string;
|
||||
systemPrompt?: string;
|
||||
appendSystemPrompt?: string;
|
||||
claudeEnv?: string;
|
||||
fallbackModel?: string;
|
||||
showFullOutput?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
claudeArgs: string[];
|
||||
promptPath: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
export function prepareRunConfig(
|
||||
export async function runClaude(
|
||||
promptPath: string,
|
||||
options: ClaudeOptions,
|
||||
): PreparedConfig {
|
||||
// Build Claude CLI arguments:
|
||||
// 1. Prompt flag (always first)
|
||||
// 2. User's claudeArgs (full control)
|
||||
// 3. BASE_ARGS (always last, cannot be overridden)
|
||||
|
||||
const claudeArgs = ["-p"];
|
||||
|
||||
// Parse and add user's custom Claude arguments
|
||||
if (options.claudeArgs?.trim()) {
|
||||
const parsed = parseShellArgs(options.claudeArgs);
|
||||
const customArgs = parsed.filter(
|
||||
(arg): arg is string => typeof arg === "string",
|
||||
);
|
||||
claudeArgs.push(...customArgs);
|
||||
}
|
||||
|
||||
// BASE_ARGS are always appended last (cannot be overridden)
|
||||
claudeArgs.push(...BASE_ARGS);
|
||||
|
||||
const customEnv: Record<string, string> = {};
|
||||
|
||||
if (process.env.INPUT_ACTION_INPUTS_PRESENT) {
|
||||
customEnv.GITHUB_ACTION_INPUTS = process.env.INPUT_ACTION_INPUTS_PRESENT;
|
||||
}
|
||||
|
||||
return {
|
||||
claudeArgs,
|
||||
promptPath,
|
||||
env: customEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses session_id from execution file and sets GitHub Action output
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetSessionId(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
session_id?: string;
|
||||
}[];
|
||||
|
||||
// Find the system.init message which contains session_id
|
||||
const initMessage = messages.find(
|
||||
(m) => m.type === "system" && m.subtype === "init",
|
||||
);
|
||||
|
||||
if (initMessage?.session_id) {
|
||||
core.setOutput("session_id", initMessage.session_id);
|
||||
core.info(`Set session_id: ${initMessage.session_id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the action if session_id extraction fails
|
||||
core.warning(`Failed to extract session_id: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses structured_output from execution file and sets GitHub Action outputs
|
||||
* Only runs if --json-schema was explicitly provided in claude_args
|
||||
* Exported for testing
|
||||
*/
|
||||
export async function parseAndSetStructuredOutputs(
|
||||
executionFile: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const content = await readFile(executionFile, "utf-8");
|
||||
const messages = JSON.parse(content) as {
|
||||
type: string;
|
||||
structured_output?: Record<string, unknown>;
|
||||
}[];
|
||||
|
||||
// Search backwards - result is typically last or second-to-last message
|
||||
const result = messages.findLast(
|
||||
(m) => m.type === "result" && m.structured_output,
|
||||
);
|
||||
|
||||
if (!result?.structured_output) {
|
||||
throw new Error(
|
||||
`--json-schema was provided but Claude did not return structured_output.\n` +
|
||||
`Found ${messages.length} messages. Result exists: ${!!result}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Set the complete structured output as a single JSON string
|
||||
// This works around GitHub Actions limitation that composite actions can't have dynamic outputs
|
||||
const structuredOutputJson = JSON.stringify(result.structured_output);
|
||||
core.setOutput("structured_output", structuredOutputJson);
|
||||
core.info(
|
||||
`Set structured_output with ${Object.keys(result.structured_output).length} field(s)`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error; // Preserve original error and stack trace
|
||||
}
|
||||
throw new Error(`Failed to parse structured outputs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
// Feature flag: use SDK path by default, set USE_AGENT_SDK=false to use CLI
|
||||
const useAgentSdk = process.env.USE_AGENT_SDK !== "false";
|
||||
console.log(
|
||||
`Using ${useAgentSdk ? "Agent SDK" : "CLI"} path (USE_AGENT_SDK=${process.env.USE_AGENT_SDK ?? "unset"})`,
|
||||
);
|
||||
|
||||
if (useAgentSdk) {
|
||||
const parsedOptions = parseSdkOptions(options);
|
||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||
}
|
||||
|
||||
const config = prepareRunConfig(promptPath, options);
|
||||
|
||||
// Detect if --json-schema is present in claude args
|
||||
const hasJsonSchema = options.claudeArgs?.includes("--json-schema") ?? false;
|
||||
|
||||
// Create a named pipe
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
|
||||
// Create the named pipe
|
||||
await execAsync(`mkfifo "${PIPE_PATH}"`);
|
||||
|
||||
// Log prompt file size
|
||||
let promptSize = "unknown";
|
||||
try {
|
||||
const stats = await stat(config.promptPath);
|
||||
promptSize = stats.size.toString();
|
||||
} catch (e) {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||
|
||||
// Log custom environment variables if any
|
||||
const customEnvKeys = Object.keys(config.env).filter(
|
||||
(key) => key !== "CLAUDE_ACTION_INPUTS_PRESENT",
|
||||
);
|
||||
if (customEnvKeys.length > 0) {
|
||||
console.log(`Custom environment variables: ${customEnvKeys.join(", ")}`);
|
||||
}
|
||||
|
||||
// Log custom arguments if any
|
||||
if (options.claudeArgs && options.claudeArgs.trim() !== "") {
|
||||
console.log(`Custom Claude arguments: ${options.claudeArgs}`);
|
||||
}
|
||||
|
||||
// Output to console
|
||||
console.log(`Running Claude with prompt from file: ${config.promptPath}`);
|
||||
console.log(`Full command: claude ${config.claudeArgs.join(" ")}`);
|
||||
|
||||
// Start sending prompt to pipe in background
|
||||
const catProcess = spawn("cat", [config.promptPath], {
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
const pipeStream = createWriteStream(PIPE_PATH);
|
||||
catProcess.stdout.pipe(pipeStream);
|
||||
|
||||
catProcess.on("error", (error) => {
|
||||
console.error("Error reading prompt file:", error);
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Use custom executable path if provided, otherwise default to "claude"
|
||||
const claudeExecutable = options.pathToClaudeCodeExecutable || "claude";
|
||||
|
||||
const claudeProcess = spawn(claudeExecutable, config.claudeArgs, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
...config.env,
|
||||
},
|
||||
});
|
||||
|
||||
// Handle Claude process errors
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Error spawning Claude process:", error);
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Determine if full output should be shown
|
||||
// Show full output if explicitly set to "true" OR if GitHub Actions debug mode is enabled
|
||||
const isDebugMode = process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
let showFullOutput = options.showFullOutput === "true" || isDebugMode;
|
||||
|
||||
if (isDebugMode && options.showFullOutput !== "false") {
|
||||
console.log("Debug mode detected - showing full output");
|
||||
showFullOutput = true;
|
||||
} else if (!showFullOutput) {
|
||||
console.log("Running Claude Code (full output hidden for security)...");
|
||||
console.log(
|
||||
"Rerun in debug mode or enable `show_full_output: true` in your workflow file for full output.",
|
||||
);
|
||||
}
|
||||
|
||||
// Capture output for parsing execution metrics
|
||||
let output = "";
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
// Try to parse as JSON and handle based on verbose setting
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line: string, index: number) => {
|
||||
if (line.trim() === "") return;
|
||||
|
||||
try {
|
||||
// Check if this line is a JSON object
|
||||
const parsed = JSON.parse(line);
|
||||
const sanitizedOutput = sanitizeJsonOutput(parsed, showFullOutput);
|
||||
|
||||
if (sanitizedOutput) {
|
||||
process.stdout.write(sanitizedOutput);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON object
|
||||
if (showFullOutput) {
|
||||
// In full output mode, print as is
|
||||
process.stdout.write(line);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
}
|
||||
// In non-full-output mode, suppress non-JSON output
|
||||
}
|
||||
});
|
||||
|
||||
output += text;
|
||||
});
|
||||
|
||||
// Handle stdout errors
|
||||
claudeProcess.stdout.on("error", (error) => {
|
||||
console.error("Error reading Claude stdout:", error);
|
||||
});
|
||||
|
||||
// Pipe from named pipe to Claude
|
||||
const pipeProcess = spawn("cat", [PIPE_PATH]);
|
||||
pipeProcess.stdout.pipe(claudeProcess.stdin);
|
||||
|
||||
// Handle pipe process errors
|
||||
pipeProcess.on("error", (error) => {
|
||||
console.error("Error reading from named pipe:", error);
|
||||
claudeProcess.kill("SIGTERM");
|
||||
});
|
||||
|
||||
// Wait for Claude to finish
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
claudeProcess.on("close", (code) => {
|
||||
resolve(code || 0);
|
||||
});
|
||||
|
||||
claudeProcess.on("error", (error) => {
|
||||
console.error("Claude process error:", error);
|
||||
resolve(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up processes
|
||||
try {
|
||||
catProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
try {
|
||||
pipeProcess.kill("SIGTERM");
|
||||
} catch (e) {
|
||||
// Process may already be dead
|
||||
}
|
||||
|
||||
// Clean up pipe file
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
} catch (e) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
|
||||
// Set conclusion based on exit code
|
||||
if (exitCode === 0) {
|
||||
// Try to process the output and save execution metrics
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
|
||||
// Process output.txt into JSON and save to execution file
|
||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
|
||||
console.log(`Log saved to ${EXECUTION_FILE}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to process output for execution metrics: ${e}`);
|
||||
}
|
||||
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
|
||||
// Extract and set session_id
|
||||
await parseAndSetSessionId(EXECUTION_FILE);
|
||||
|
||||
// Parse and set structured outputs only if user provided --json-schema in claude_args
|
||||
if (hasJsonSchema) {
|
||||
try {
|
||||
await parseAndSetStructuredOutputs(EXECUTION_FILE);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(errorMessage);
|
||||
core.setOutput("conclusion", "failure");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Set conclusion to success if we reached here
|
||||
core.setOutput("conclusion", "success");
|
||||
} else {
|
||||
core.setOutput("conclusion", "failure");
|
||||
|
||||
// Still try to save execution file if we have output
|
||||
if (output) {
|
||||
try {
|
||||
await writeFile("output.txt", output);
|
||||
// Increase maxBuffer from Node.js default of 1MB to 10MB to handle large Claude outputs
|
||||
const { stdout: jsonOutput } = await execAsync("jq -s '.' output.txt", {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
await writeFile(EXECUTION_FILE, jsonOutput);
|
||||
core.setOutput("execution_file", EXECUTION_FILE);
|
||||
} catch (e) {
|
||||
// Ignore errors when processing output during failure
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
): Promise<ClaudeRunResult> {
|
||||
const parsedOptions = parseSdkOptions(options);
|
||||
return runClaudeWithSdk(promptPath, parsedOptions);
|
||||
}
|
||||
|
||||
@@ -312,4 +312,59 @@ describe("parseSdkOptions", () => {
|
||||
expect(result.hasJsonSchema).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment variables passthrough", () => {
|
||||
test("should include OTEL environment variables in sdkOptions.env", () => {
|
||||
// Set up test environment variables
|
||||
const originalEnv = { ...process.env };
|
||||
process.env.CLAUDE_CODE_ENABLE_TELEMETRY = "1";
|
||||
process.env.OTEL_METRICS_EXPORTER = "otlp";
|
||||
process.env.OTEL_LOGS_EXPORTER = "otlp";
|
||||
process.env.OTEL_EXPORTER_OTLP_PROTOCOL = "http/json";
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://example.com";
|
||||
process.env.OTEL_EXPORTER_OTLP_HEADERS =
|
||||
"Authorization=Bearer test-token";
|
||||
process.env.OTEL_METRIC_EXPORT_INTERVAL = "10000";
|
||||
process.env.OTEL_LOGS_EXPORT_INTERVAL = "5000";
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = "department=test";
|
||||
|
||||
try {
|
||||
const options: ClaudeOptions = {};
|
||||
const result = parseSdkOptions(options);
|
||||
|
||||
// Verify OTEL env vars are passed through to sdkOptions.env
|
||||
expect(result.sdkOptions.env?.CLAUDE_CODE_ENABLE_TELEMETRY).toBe("1");
|
||||
expect(result.sdkOptions.env?.OTEL_METRICS_EXPORTER).toBe("otlp");
|
||||
expect(result.sdkOptions.env?.OTEL_LOGS_EXPORTER).toBe("otlp");
|
||||
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_PROTOCOL).toBe(
|
||||
"http/json",
|
||||
);
|
||||
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_ENDPOINT).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(result.sdkOptions.env?.OTEL_EXPORTER_OTLP_HEADERS).toBe(
|
||||
"Authorization=Bearer test-token",
|
||||
);
|
||||
expect(result.sdkOptions.env?.OTEL_METRIC_EXPORT_INTERVAL).toBe(
|
||||
"10000",
|
||||
);
|
||||
expect(result.sdkOptions.env?.OTEL_LOGS_EXPORT_INTERVAL).toBe("5000");
|
||||
expect(result.sdkOptions.env?.OTEL_RESOURCE_ATTRIBUTES).toBe(
|
||||
"department=test",
|
||||
);
|
||||
} finally {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("should set CLAUDE_CODE_ENTRYPOINT in sdkOptions.env", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const result = parseSdkOptions(options);
|
||||
|
||||
expect(result.sdkOptions.env?.CLAUDE_CODE_ENTRYPOINT).toBe(
|
||||
"claude-code-github-action",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { prepareRunConfig, type ClaudeOptions } from "../src/run-claude";
|
||||
|
||||
describe("prepareRunConfig", () => {
|
||||
test("should prepare config with basic arguments", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include promptPath", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/tmp/test-prompt.txt");
|
||||
});
|
||||
|
||||
test("should use provided prompt path", () => {
|
||||
const options: ClaudeOptions = {};
|
||||
const prepared = prepareRunConfig("/custom/prompt/path.txt", options);
|
||||
|
||||
expect(prepared.promptPath).toBe("/custom/prompt/path.txt");
|
||||
});
|
||||
|
||||
describe("claudeArgs handling", () => {
|
||||
test("should parse and include custom claude arguments", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "--max-turns 10 --model claude-3-opus-20240229",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--max-turns",
|
||||
"10",
|
||||
"--model",
|
||||
"claude-3-opus-20240229",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle empty claudeArgs", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: "",
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle claudeArgs with quoted strings", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs: '--system-prompt "You are a helpful assistant"',
|
||||
};
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toEqual([
|
||||
"-p",
|
||||
"--system-prompt",
|
||||
"You are a helpful assistant",
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]);
|
||||
});
|
||||
|
||||
test("should include json-schema flag when provided", () => {
|
||||
const options: ClaudeOptions = {
|
||||
claudeArgs:
|
||||
'--json-schema \'{"type":"object","properties":{"result":{"type":"boolean"}}}\'',
|
||||
};
|
||||
|
||||
const prepared = prepareRunConfig("/tmp/test-prompt.txt", options);
|
||||
|
||||
expect(prepared.claudeArgs).toContain("--json-schema");
|
||||
expect(prepared.claudeArgs).toContain(
|
||||
'{"type":"object","properties":{"result":{"type":"boolean"}}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test";
|
||||
import { writeFile, unlink } from "fs/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import {
|
||||
parseAndSetStructuredOutputs,
|
||||
parseAndSetSessionId,
|
||||
} from "../src/run-claude";
|
||||
import * as core from "@actions/core";
|
||||
|
||||
// Mock execution file path
|
||||
const TEST_EXECUTION_FILE = join(tmpdir(), "test-execution-output.json");
|
||||
|
||||
// Helper to create mock execution file with structured output
|
||||
async function createMockExecutionFile(
|
||||
structuredOutput?: Record<string, unknown>,
|
||||
includeResult: boolean = true,
|
||||
): Promise<void> {
|
||||
const messages: any[] = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "turn", content: "test" },
|
||||
];
|
||||
|
||||
if (includeResult) {
|
||||
messages.push({
|
||||
type: "result",
|
||||
cost_usd: 0.01,
|
||||
duration_ms: 1000,
|
||||
structured_output: structuredOutput,
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
}
|
||||
|
||||
// Spy on core functions
|
||||
let setOutputSpy: any;
|
||||
let infoSpy: any;
|
||||
let warningSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
||||
infoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
warningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("parseAndSetStructuredOutputs", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should set structured_output with valid data", async () => {
|
||||
await createMockExecutionFile({
|
||||
is_flaky: true,
|
||||
confidence: 0.85,
|
||||
summary: "Test looks flaky",
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith(
|
||||
"structured_output",
|
||||
'{"is_flaky":true,"confidence":0.85,"summary":"Test looks flaky"}',
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Set structured_output with 3 field(s)",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle arrays and nested objects", async () => {
|
||||
await createMockExecutionFile({
|
||||
items: ["a", "b", "c"],
|
||||
config: { key: "value", nested: { deep: true } },
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("structured_output");
|
||||
const parsed = JSON.parse(callArgs[1]);
|
||||
expect(parsed).toEqual({
|
||||
items: ["a", "b", "c"],
|
||||
config: { key: "value", nested: { deep: true } },
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle special characters in field names", async () => {
|
||||
await createMockExecutionFile({
|
||||
"test-result": "passed",
|
||||
"item.count": 10,
|
||||
"user@email": "test",
|
||||
});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
const parsed = JSON.parse(callArgs[1]);
|
||||
expect(parsed["test-result"]).toBe("passed");
|
||||
expect(parsed["item.count"]).toBe(10);
|
||||
expect(parsed["user@email"]).toBe("test");
|
||||
});
|
||||
|
||||
test("should throw error when result exists but structured_output is undefined", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01, duration_ms: 1000 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow(
|
||||
"--json-schema was provided but Claude did not return structured_output",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error when no result message exists", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "turn", content: "test" },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow(
|
||||
"--json-schema was provided but Claude did not return structured_output",
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error with malformed JSON", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs(TEST_EXECUTION_FILE),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should throw error when file does not exist", async () => {
|
||||
await expect(
|
||||
parseAndSetStructuredOutputs("/nonexistent/file.json"),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should handle empty structured_output object", async () => {
|
||||
await createMockExecutionFile({});
|
||||
|
||||
await parseAndSetStructuredOutputs(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("structured_output", "{}");
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
"Set structured_output with 0 field(s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAndSetSessionId", () => {
|
||||
afterEach(async () => {
|
||||
setOutputSpy?.mockRestore();
|
||||
infoSpy?.mockRestore();
|
||||
warningSpy?.mockRestore();
|
||||
try {
|
||||
await unlink(TEST_EXECUTION_FILE);
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
test("should extract session_id from system.init message", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init", session_id: "test-session-123" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123");
|
||||
expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123");
|
||||
});
|
||||
|
||||
test("should handle missing session_id gracefully", async () => {
|
||||
const messages = [
|
||||
{ type: "system", subtype: "init" },
|
||||
{ type: "result", cost_usd: 0.01 },
|
||||
];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle missing system.init message gracefully", async () => {
|
||||
const messages = [{ type: "result", cost_usd: 0.01 }];
|
||||
await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages));
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle malformed JSON gracefully with warning", async () => {
|
||||
await writeFile(TEST_EXECUTION_FILE, "{ invalid json");
|
||||
|
||||
await parseAndSetSessionId(TEST_EXECUTION_FILE);
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle non-existent file gracefully with warning", async () => {
|
||||
await parseAndSetSessionId("/nonexistent/file.json");
|
||||
|
||||
expect(setOutputSpy).not.toHaveBeenCalled();
|
||||
expect(warningSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.9",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.42",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
"@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.9", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-b4JD6ZKCZeVDqpWBnb+zJISWi3HzlweNlV7Oy/uo5G2XAfUV2M5AJ/tomKZCvZsvmr1fYbmmfyde3GL2h0pksA=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.42", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-/CugP7AjP57Dqtl2sbsDtxdbpQoPKIhjyF5WrTViGu4NHQdM+UikrRs4MhZ2jeotiC5R7iK9ZUN9SiBgcZ8oLw=="],
|
||||
|
||||
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
|
||||
|
||||
|
||||
@@ -172,9 +172,14 @@ jobs:
|
||||
|
||||
**Important Notes**:
|
||||
|
||||
- The GitHub token must have the `actions: read` permission in your workflow
|
||||
- The GitHub token must have the corresponding permission in your workflow
|
||||
- If the permission is missing, Claude will warn you and suggest adding it
|
||||
- Currently, only `actions: read` is supported, but the format allows for future extensions
|
||||
- The following additional permissions can be requested beyond the defaults:
|
||||
- `actions: read`
|
||||
- `checks: read`
|
||||
- `discussions: read` or `discussions: write`
|
||||
- `workflows: read` or `workflows: write`
|
||||
- Standard permissions (`contents: write`, `pull_requests: write`, `issues: write`) are always included and do not need to be specified
|
||||
|
||||
## Custom Environment Variables
|
||||
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ For performance, Claude uses shallow clones:
|
||||
If you need full history, you can configure this in your workflow before calling Claude in the `actions/checkout` step.
|
||||
|
||||
```
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
depth: 0 # will fetch full repo history
|
||||
```
|
||||
|
||||
|
||||
+8
-8
@@ -35,7 +35,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -456,7 +456,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -513,7 +513,7 @@ jobs:
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2 # Need at least 2 commits to analyze the latest
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.10.1",
|
||||
"@actions/github": "^6.0.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.9",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.42",
|
||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||
"@octokit/graphql": "^8.2.2",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
|
||||
+33
-27
@@ -20,7 +20,6 @@ import {
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import type { Mode, ModeContext } from "../modes/types";
|
||||
import { extractUserRequest } from "../utils/extract-user-request";
|
||||
export type { CommonFields, PreparedContext } from "./types";
|
||||
|
||||
@@ -458,9 +457,31 @@ export function generatePrompt(
|
||||
context: PreparedContext,
|
||||
githubData: FetchDataResult,
|
||||
useCommitSigning: boolean,
|
||||
mode: Mode,
|
||||
modeName: "tag" | "agent",
|
||||
): string {
|
||||
return mode.generatePrompt(context, githubData, useCommitSigning);
|
||||
if (modeName === "agent") {
|
||||
return context.prompt || `Repository: ${context.repository}`;
|
||||
}
|
||||
|
||||
// Tag mode
|
||||
const defaultPrompt = generateDefaultPrompt(
|
||||
context,
|
||||
githubData,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
if (context.githubContext?.inputs?.prompt) {
|
||||
return (
|
||||
defaultPrompt +
|
||||
`
|
||||
|
||||
<custom_instructions>
|
||||
${context.githubContext.inputs.prompt}
|
||||
</custom_instructions>`
|
||||
);
|
||||
}
|
||||
|
||||
return defaultPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -901,28 +922,20 @@ function extractUserRequestFromContext(
|
||||
}
|
||||
|
||||
export async function createPrompt(
|
||||
mode: Mode,
|
||||
modeContext: ModeContext,
|
||||
commentId: number,
|
||||
baseBranch: string | undefined,
|
||||
claudeBranch: string | undefined,
|
||||
githubData: FetchDataResult,
|
||||
context: ParsedGitHubContext,
|
||||
) {
|
||||
try {
|
||||
// Prepare the context for prompt generation
|
||||
let claudeCommentId: string = "";
|
||||
if (mode.name === "tag") {
|
||||
if (!modeContext.commentId) {
|
||||
throw new Error(
|
||||
`${mode.name} mode requires a comment ID for prompt generation`,
|
||||
);
|
||||
}
|
||||
claudeCommentId = modeContext.commentId.toString();
|
||||
}
|
||||
const claudeCommentId = commentId.toString();
|
||||
|
||||
const preparedContext = prepareContext(
|
||||
context,
|
||||
claudeCommentId,
|
||||
modeContext.baseBranch,
|
||||
modeContext.claudeBranch,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
);
|
||||
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
@@ -934,7 +947,7 @@ export async function createPrompt(
|
||||
preparedContext,
|
||||
githubData,
|
||||
context.inputs.useCommitSigning,
|
||||
mode,
|
||||
"tag",
|
||||
);
|
||||
|
||||
// Log the final prompt to console
|
||||
@@ -967,19 +980,12 @@ export async function createPrompt(
|
||||
// Set allowed tools
|
||||
const hasActionsReadPermission = false;
|
||||
|
||||
// Get mode-specific tools
|
||||
const modeAllowedTools = mode.getAllowedTools();
|
||||
const modeDisallowedTools = mode.getDisallowedTools();
|
||||
|
||||
const allAllowedTools = buildAllowedToolsString(
|
||||
modeAllowedTools,
|
||||
[],
|
||||
hasActionsReadPermission,
|
||||
context.inputs.useCommitSigning,
|
||||
);
|
||||
const allDisallowedTools = buildDisallowedToolsString(
|
||||
modeDisallowedTools,
|
||||
modeAllowedTools,
|
||||
);
|
||||
const allDisallowedTools = buildDisallowedToolsString([], []);
|
||||
|
||||
core.exportVariable("ALLOWED_TOOLS", allAllowedTools);
|
||||
core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
export function collectActionInputsPresence(): void {
|
||||
export function collectActionInputsPresence(): string {
|
||||
const inputDefaults: Record<string, string> = {
|
||||
trigger_phrase: "@claude",
|
||||
assignee_trigger: "",
|
||||
@@ -32,8 +30,7 @@ export function collectActionInputsPresence(): void {
|
||||
const allInputsJson = process.env.ALL_INPUTS;
|
||||
if (!allInputsJson) {
|
||||
console.log("ALL_INPUTS environment variable not found");
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
return JSON.stringify({});
|
||||
}
|
||||
|
||||
let allInputs: Record<string, string>;
|
||||
@@ -41,8 +38,7 @@ export function collectActionInputsPresence(): void {
|
||||
allInputs = JSON.parse(allInputsJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse ALL_INPUTS JSON:", e);
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
return JSON.stringify({});
|
||||
}
|
||||
|
||||
const presentInputs: Record<string, boolean> = {};
|
||||
@@ -54,5 +50,5 @@ export function collectActionInputsPresence(): void {
|
||||
presentInputs[name] = isSet;
|
||||
}
|
||||
|
||||
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
|
||||
return JSON.stringify(presentInputs);
|
||||
}
|
||||
|
||||
+22
-25
@@ -10,8 +10,10 @@ import { setupGitHubToken } from "../github/token";
|
||||
import { checkWritePermissions } from "../github/validation/permissions";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import { getMode } from "../modes/registry";
|
||||
import { prepare } from "../prepare";
|
||||
import { detectMode } from "../modes/detector";
|
||||
import { prepareTagMode } from "../modes/tag";
|
||||
import { prepareAgentMode } from "../modes/agent";
|
||||
import { checkContainsTrigger } from "../github/validation/trigger";
|
||||
import { collectActionInputsPresence } from "./collect-inputs";
|
||||
|
||||
async function run() {
|
||||
@@ -22,7 +24,10 @@ async function run() {
|
||||
const context = parseGitHubContext();
|
||||
|
||||
// Auto-detect mode based on context
|
||||
const mode = getMode(context);
|
||||
const modeName = detectMode(context);
|
||||
console.log(
|
||||
`Auto-detected mode: ${modeName} for event: ${context.eventName}`,
|
||||
);
|
||||
|
||||
// Setup GitHub token
|
||||
const githubToken = await setupGitHubToken();
|
||||
@@ -46,10 +51,13 @@ async function run() {
|
||||
}
|
||||
|
||||
// Check trigger conditions
|
||||
const containsTrigger = mode.shouldTrigger(context);
|
||||
const containsTrigger =
|
||||
modeName === "tag"
|
||||
? isEntityContext(context) && checkContainsTrigger(context)
|
||||
: !!context.inputs?.prompt;
|
||||
|
||||
// Debug logging
|
||||
console.log(`Mode: ${mode.name}`);
|
||||
console.log(`Mode: ${modeName}`);
|
||||
console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`);
|
||||
console.log(`Trigger result: ${containsTrigger}`);
|
||||
|
||||
@@ -63,31 +71,20 @@ async function run() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Use the new modular prepare function
|
||||
const result = await prepare({
|
||||
context,
|
||||
octokit,
|
||||
mode,
|
||||
githubToken,
|
||||
});
|
||||
// Run prepare
|
||||
console.log(
|
||||
`Preparing with mode: ${modeName} for event: ${context.eventName}`,
|
||||
);
|
||||
if (modeName === "tag") {
|
||||
await prepareTagMode({ context, octokit, githubToken });
|
||||
} else {
|
||||
await prepareAgentMode({ context, octokit, githubToken });
|
||||
}
|
||||
|
||||
// MCP config is handled by individual modes (tag/agent) and included in their claude_args output
|
||||
|
||||
// Expose the GitHub token (Claude App token) as an output
|
||||
core.setOutput("github_token", githubToken);
|
||||
|
||||
// Step 6: Get system prompt from mode if available
|
||||
if (mode.getSystemPrompt) {
|
||||
const modeContext = mode.prepareContext(context, {
|
||||
commentId: result.commentId,
|
||||
baseBranch: result.branchInfo.baseBranch,
|
||||
claudeBranch: result.branchInfo.claudeBranch,
|
||||
});
|
||||
const systemPrompt = mode.getSystemPrompt(modeContext);
|
||||
if (systemPrompt) {
|
||||
core.exportVariable("APPEND_SYSTEM_PROMPT", systemPrompt);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(`Prepare step failed with error: ${errorMessage}`);
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Unified entrypoint for the Claude Code Action.
|
||||
* Merges all previously separate action.yml steps (prepare, install, run, cleanup)
|
||||
* into a single TypeScript orchestrator.
|
||||
*/
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { dirname } from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { appendFile } from "fs/promises";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { setupGitHubToken, WorkflowValidationSkipError } from "../github/token";
|
||||
import { checkWritePermissions } from "../github/validation/permissions";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { detectMode } from "../modes/detector";
|
||||
import { prepareTagMode } from "../modes/tag";
|
||||
import { prepareAgentMode } from "../modes/agent";
|
||||
import { checkContainsTrigger } from "../github/validation/trigger";
|
||||
import { collectActionInputsPresence } from "./collect-inputs";
|
||||
import { updateCommentLink } from "./update-comment-link";
|
||||
import { formatTurnsFromData } from "./format-turns";
|
||||
import type { Turn } from "./format-turns";
|
||||
// Base-action imports (used directly instead of subprocess)
|
||||
import { validateEnvironmentVariables } from "../../base-action/src/validate-env";
|
||||
import { setupClaudeCodeSettings } from "../../base-action/src/setup-claude-code-settings";
|
||||
import { installPlugins } from "../../base-action/src/install-plugins";
|
||||
import { preparePrompt } from "../../base-action/src/prepare-prompt";
|
||||
import { runClaude } from "../../base-action/src/run-claude";
|
||||
import type { ClaudeRunResult } from "../../base-action/src/run-claude-sdk";
|
||||
|
||||
/**
|
||||
* Install Claude Code CLI, handling retry logic and custom executable paths.
|
||||
*/
|
||||
async function installClaudeCode(): Promise<void> {
|
||||
const customExecutable = process.env.PATH_TO_CLAUDE_CODE_EXECUTABLE;
|
||||
if (customExecutable) {
|
||||
console.log(`Using custom Claude Code executable: ${customExecutable}`);
|
||||
const claudeDir = dirname(customExecutable);
|
||||
// Add to PATH by appending to GITHUB_PATH
|
||||
const githubPath = process.env.GITHUB_PATH;
|
||||
if (githubPath) {
|
||||
await appendFile(githubPath, `${claudeDir}\n`);
|
||||
}
|
||||
// Also add to current process PATH
|
||||
process.env.PATH = `${claudeDir}:${process.env.PATH}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const claudeCodeVersion = "2.1.42";
|
||||
console.log(`Installing Claude Code v${claudeCodeVersion}...`);
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
console.log(`Installation attempt ${attempt}...`);
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
"bash",
|
||||
[
|
||||
"-c",
|
||||
`curl -fsSL https://claude.ai/install.sh | bash -s -- ${claudeCodeVersion}`,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Install failed with exit code ${code}`));
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
console.log("Claude Code installed successfully");
|
||||
// Add to PATH
|
||||
const homeBin = `${process.env.HOME}/.local/bin`;
|
||||
const githubPath = process.env.GITHUB_PATH;
|
||||
if (githubPath) {
|
||||
await appendFile(githubPath, `${homeBin}\n`);
|
||||
}
|
||||
process.env.PATH = `${homeBin}:${process.env.PATH}`;
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === 3) {
|
||||
throw new Error(
|
||||
`Failed to install Claude Code after 3 attempts: ${error}`,
|
||||
);
|
||||
}
|
||||
console.log("Installation failed, retrying...");
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the step summary from Claude's execution output file.
|
||||
*/
|
||||
async function writeStepSummary(executionFile: string): Promise<void> {
|
||||
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
|
||||
if (!summaryFile) return;
|
||||
|
||||
try {
|
||||
const fileContent = readFileSync(executionFile, "utf-8");
|
||||
const data: Turn[] = JSON.parse(fileContent);
|
||||
const markdown = formatTurnsFromData(data);
|
||||
await appendFile(summaryFile, markdown);
|
||||
console.log("Successfully formatted Claude Code report");
|
||||
} catch (error) {
|
||||
console.error(`Failed to format output: ${error}`);
|
||||
// Fall back to raw JSON
|
||||
try {
|
||||
let fallback = "## Claude Code Report (Raw Output)\n\n";
|
||||
fallback +=
|
||||
"Failed to format output (please report). Here's the raw JSON:\n\n";
|
||||
fallback += "```json\n";
|
||||
fallback += readFileSync(executionFile, "utf-8");
|
||||
fallback += "\n```\n";
|
||||
await appendFile(summaryFile, fallback);
|
||||
} catch {
|
||||
console.error("Failed to write raw output to step summary");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let githubToken: string | undefined;
|
||||
let commentId: number | undefined;
|
||||
let claudeBranch: string | undefined;
|
||||
let baseBranch: string | undefined;
|
||||
let executionFile: string | undefined;
|
||||
let claudeSuccess = false;
|
||||
let prepareSuccess = true;
|
||||
let prepareError: string | undefined;
|
||||
let context: GitHubContext | undefined;
|
||||
let octokit: Octokits | undefined;
|
||||
// Track whether we've completed prepare phase, so we can attribute errors correctly
|
||||
let prepareCompleted = false;
|
||||
try {
|
||||
// Phase 1: Prepare
|
||||
const actionInputsPresent = collectActionInputsPresence();
|
||||
context = parseGitHubContext();
|
||||
const modeName = detectMode(context);
|
||||
console.log(
|
||||
`Auto-detected mode: ${modeName} for event: ${context.eventName}`,
|
||||
);
|
||||
|
||||
try {
|
||||
githubToken = await setupGitHubToken();
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowValidationSkipError) {
|
||||
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
||||
console.log("Exiting due to workflow validation skip");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
octokit = createOctokit(githubToken);
|
||||
|
||||
// Set GITHUB_TOKEN and GH_TOKEN in process env for downstream usage
|
||||
process.env.GITHUB_TOKEN = githubToken;
|
||||
process.env.GH_TOKEN = githubToken;
|
||||
|
||||
// Check write permissions (only for entity contexts)
|
||||
if (isEntityContext(context)) {
|
||||
const hasWritePermissions = await checkWritePermissions(
|
||||
octokit.rest,
|
||||
context,
|
||||
context.inputs.allowedNonWriteUsers,
|
||||
!!process.env.OVERRIDE_GITHUB_TOKEN,
|
||||
);
|
||||
if (!hasWritePermissions) {
|
||||
throw new Error(
|
||||
"Actor does not have write permissions to the repository",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check trigger conditions
|
||||
const containsTrigger =
|
||||
modeName === "tag"
|
||||
? isEntityContext(context) && checkContainsTrigger(context)
|
||||
: !!context.inputs?.prompt;
|
||||
console.log(`Mode: ${modeName}`);
|
||||
console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`);
|
||||
console.log(`Trigger result: ${containsTrigger}`);
|
||||
|
||||
if (!containsTrigger) {
|
||||
console.log("No trigger found, skipping remaining steps");
|
||||
core.setOutput("github_token", githubToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Run prepare
|
||||
console.log(
|
||||
`Preparing with mode: ${modeName} for event: ${context.eventName}`,
|
||||
);
|
||||
const prepareResult =
|
||||
modeName === "tag"
|
||||
? await prepareTagMode({ context, octokit, githubToken })
|
||||
: await prepareAgentMode({ context, octokit, githubToken });
|
||||
|
||||
commentId = prepareResult.commentId;
|
||||
claudeBranch = prepareResult.branchInfo.claudeBranch;
|
||||
baseBranch = prepareResult.branchInfo.baseBranch;
|
||||
prepareCompleted = true;
|
||||
|
||||
// Phase 2: Install Claude Code CLI
|
||||
await installClaudeCode();
|
||||
|
||||
// Phase 3: Run Claude (import base-action directly)
|
||||
// Set env vars needed by the base-action code
|
||||
process.env.INPUT_ACTION_INPUTS_PRESENT = actionInputsPresent;
|
||||
process.env.CLAUDE_CODE_ACTION = "1";
|
||||
process.env.DETAILED_PERMISSION_MESSAGES = "1";
|
||||
|
||||
validateEnvironmentVariables();
|
||||
|
||||
await setupClaudeCodeSettings(process.env.INPUT_SETTINGS);
|
||||
|
||||
await installPlugins(
|
||||
process.env.INPUT_PLUGIN_MARKETPLACES,
|
||||
process.env.INPUT_PLUGINS,
|
||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||
);
|
||||
|
||||
const promptFile =
|
||||
process.env.INPUT_PROMPT_FILE ||
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`;
|
||||
const promptConfig = await preparePrompt({
|
||||
prompt: "",
|
||||
promptFile,
|
||||
});
|
||||
|
||||
const claudeResult: ClaudeRunResult = await runClaude(promptConfig.path, {
|
||||
claudeArgs: prepareResult.claudeArgs,
|
||||
appendSystemPrompt: process.env.APPEND_SYSTEM_PROMPT,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
pathToClaudeCodeExecutable:
|
||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||
showFullOutput: process.env.INPUT_SHOW_FULL_OUTPUT,
|
||||
});
|
||||
|
||||
claudeSuccess = claudeResult.conclusion === "success";
|
||||
executionFile = claudeResult.executionFile;
|
||||
|
||||
// Set action-level outputs
|
||||
if (claudeResult.executionFile) {
|
||||
core.setOutput("execution_file", claudeResult.executionFile);
|
||||
}
|
||||
if (claudeResult.sessionId) {
|
||||
core.setOutput("session_id", claudeResult.sessionId);
|
||||
}
|
||||
if (claudeResult.structuredOutput) {
|
||||
core.setOutput("structured_output", claudeResult.structuredOutput);
|
||||
}
|
||||
core.setOutput("conclusion", claudeResult.conclusion);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
// Only mark as prepare failure if we haven't completed the prepare phase
|
||||
if (!prepareCompleted) {
|
||||
prepareSuccess = false;
|
||||
prepareError = errorMessage;
|
||||
}
|
||||
core.setFailed(`Action failed with error: ${errorMessage}`);
|
||||
} finally {
|
||||
// Phase 4: Cleanup (always runs)
|
||||
|
||||
// Update tracking comment
|
||||
if (
|
||||
commentId &&
|
||||
context &&
|
||||
isEntityContext(context) &&
|
||||
githubToken &&
|
||||
octokit
|
||||
) {
|
||||
try {
|
||||
await updateCommentLink({
|
||||
commentId,
|
||||
githubToken,
|
||||
claudeBranch,
|
||||
baseBranch: baseBranch || "main",
|
||||
triggerUsername: context.actor,
|
||||
context,
|
||||
octokit,
|
||||
claudeSuccess,
|
||||
outputFile: executionFile,
|
||||
prepareSuccess,
|
||||
prepareError,
|
||||
useCommitSigning: context.inputs.useCommitSigning,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating comment with job link:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Write step summary
|
||||
if (executionFile && existsSync(executionFile)) {
|
||||
await writeStepSummary(executionFile);
|
||||
}
|
||||
|
||||
// Set remaining action-level outputs
|
||||
core.setOutput("branch_name", claudeBranch);
|
||||
core.setOutput("github_token", githubToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
run();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
import * as fs from "fs/promises";
|
||||
import {
|
||||
updateCommentBody,
|
||||
@@ -11,229 +12,257 @@ import {
|
||||
isPullRequestReviewCommentEvent,
|
||||
isEntityContext,
|
||||
} from "../github/context";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
|
||||
export type UpdateCommentLinkParams = {
|
||||
commentId: number;
|
||||
githubToken: string;
|
||||
claudeBranch?: string;
|
||||
baseBranch: string;
|
||||
triggerUsername?: string;
|
||||
context: ParsedGitHubContext;
|
||||
octokit: Octokits;
|
||||
claudeSuccess: boolean;
|
||||
outputFile?: string;
|
||||
prepareSuccess: boolean;
|
||||
prepareError?: string;
|
||||
useCommitSigning: boolean;
|
||||
};
|
||||
|
||||
export async function updateCommentLink(
|
||||
params: UpdateCommentLinkParams,
|
||||
): Promise<void> {
|
||||
const {
|
||||
commentId,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
triggerUsername,
|
||||
context,
|
||||
octokit,
|
||||
useCommitSigning,
|
||||
} = params;
|
||||
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const serverUrl = GITHUB_SERVER_URL;
|
||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
||||
|
||||
let comment;
|
||||
let isPRReviewComment = false;
|
||||
|
||||
try {
|
||||
// GitHub has separate ID namespaces for review comments and issue comments
|
||||
// We need to use the correct API based on the event type
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
// For PR review comments, use the pulls API
|
||||
console.log(`Fetching PR review comment ${commentId}`);
|
||||
const { data: prComment } = await octokit.rest.pulls.getReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
comment = prComment;
|
||||
isPRReviewComment = true;
|
||||
console.log("Successfully fetched as PR review comment");
|
||||
}
|
||||
|
||||
// For all other event types, use the issues API
|
||||
if (!comment) {
|
||||
console.log(`Fetching issue comment ${commentId}`);
|
||||
const { data: issueComment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
comment = issueComment;
|
||||
isPRReviewComment = false;
|
||||
console.log("Successfully fetched as issue comment");
|
||||
}
|
||||
} catch (finalError) {
|
||||
// If all attempts fail, try to determine more information about the comment
|
||||
console.error("Failed to fetch comment. Debug info:");
|
||||
console.error(`Comment ID: ${commentId}`);
|
||||
console.error(`Event name: ${context.eventName}`);
|
||||
console.error(`Entity number: ${context.entityNumber}`);
|
||||
console.error(`Repository: ${context.repository.full_name}`);
|
||||
|
||||
// Try to get the PR info to understand the comment structure
|
||||
try {
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.entityNumber,
|
||||
});
|
||||
console.log(`PR state: ${pr.state}`);
|
||||
console.log(`PR comments count: ${pr.comments}`);
|
||||
console.log(`PR review comments count: ${pr.review_comments}`);
|
||||
} catch {
|
||||
console.error("Could not fetch PR info for debugging");
|
||||
}
|
||||
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
const currentBody = comment.body ?? "";
|
||||
|
||||
// Check if we need to add branch link for new branches
|
||||
const { shouldDeleteBranch, branchLink } = await checkAndCommitOrDeleteBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
// Check if we need to add PR URL when we have a new branch
|
||||
let prLink = "";
|
||||
// If claudeBranch is set, it means we created a new branch (for issues or closed/merged PRs)
|
||||
if (claudeBranch && !shouldDeleteBranch) {
|
||||
// Check if comment already contains a PR URL
|
||||
const serverUrlPattern = serverUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const prUrlPattern = new RegExp(
|
||||
`${serverUrlPattern}\\/.+\\/compare\\/${baseBranch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.\\.\\.`,
|
||||
);
|
||||
const containsPRUrl = currentBody.match(prUrlPattern);
|
||||
|
||||
if (!containsPRUrl) {
|
||||
// Check if there are changes to the branch compared to the default branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseBranch}...${claudeBranch}`,
|
||||
});
|
||||
|
||||
// If there are changes (commits or file changes), add the PR URL
|
||||
if (
|
||||
comparison.total_commits > 0 ||
|
||||
(comparison.files && comparison.files.length > 0)
|
||||
) {
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for changes in branch:", error);
|
||||
// Don't fail the entire update if we can't check for changes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if action failed and read output file for execution details
|
||||
let executionDetails: {
|
||||
total_cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
} | null = null;
|
||||
let actionFailed = false;
|
||||
let errorDetails: string | undefined;
|
||||
|
||||
if (!params.prepareSuccess && params.prepareError) {
|
||||
actionFailed = true;
|
||||
errorDetails = params.prepareError;
|
||||
} else {
|
||||
// Check for existence of output file and parse it if available
|
||||
try {
|
||||
if (params.outputFile) {
|
||||
const fileContent = await fs.readFile(params.outputFile, "utf8");
|
||||
const outputData = JSON.parse(fileContent);
|
||||
|
||||
// Output file is an array, get the last element which contains execution details
|
||||
if (Array.isArray(outputData) && outputData.length > 0) {
|
||||
const lastElement = outputData[outputData.length - 1];
|
||||
if (
|
||||
lastElement.type === "result" &&
|
||||
"total_cost_usd" in lastElement &&
|
||||
"duration_ms" in lastElement
|
||||
) {
|
||||
executionDetails = {
|
||||
total_cost_usd: lastElement.total_cost_usd,
|
||||
duration_ms: lastElement.duration_ms,
|
||||
duration_api_ms: lastElement.duration_api_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actionFailed = !params.claudeSuccess;
|
||||
} catch (error) {
|
||||
console.error("Error reading output file:", error);
|
||||
actionFailed = !params.claudeSuccess;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare input for updateCommentBody function
|
||||
const commentInput: CommentUpdateInput = {
|
||||
currentBody,
|
||||
actionFailed,
|
||||
executionDetails,
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch,
|
||||
triggerUsername,
|
||||
errorDetails,
|
||||
};
|
||||
|
||||
const updatedBody = updateCommentBody(commentInput);
|
||||
|
||||
try {
|
||||
await updateClaudeComment(octokit.rest, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: updatedBody,
|
||||
isPullRequestReviewComment: isPRReviewComment,
|
||||
});
|
||||
console.log(
|
||||
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
|
||||
);
|
||||
} catch (updateError) {
|
||||
console.error(
|
||||
`Failed to update ${isPRReviewComment ? "PR review" : "issue"} comment:`,
|
||||
updateError,
|
||||
);
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!);
|
||||
const githubToken = process.env.GITHUB_TOKEN!;
|
||||
const claudeBranch = process.env.CLAUDE_BRANCH;
|
||||
const baseBranch = process.env.BASE_BRANCH || "main";
|
||||
const triggerUsername = process.env.TRIGGER_USERNAME;
|
||||
|
||||
const context = parseGitHubContext();
|
||||
|
||||
// This script is only called for entity-based events
|
||||
if (!isEntityContext(context)) {
|
||||
throw new Error("update-comment-link requires an entity context");
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const githubToken = process.env.GITHUB_TOKEN!;
|
||||
const octokit = createOctokit(githubToken);
|
||||
|
||||
const serverUrl = GITHUB_SERVER_URL;
|
||||
const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
||||
|
||||
let comment;
|
||||
let isPRReviewComment = false;
|
||||
|
||||
try {
|
||||
// GitHub has separate ID namespaces for review comments and issue comments
|
||||
// We need to use the correct API based on the event type
|
||||
if (isPullRequestReviewCommentEvent(context)) {
|
||||
// For PR review comments, use the pulls API
|
||||
console.log(`Fetching PR review comment ${commentId}`);
|
||||
const { data: prComment } = await octokit.rest.pulls.getReviewComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
comment = prComment;
|
||||
isPRReviewComment = true;
|
||||
console.log("Successfully fetched as PR review comment");
|
||||
}
|
||||
|
||||
// For all other event types, use the issues API
|
||||
if (!comment) {
|
||||
console.log(`Fetching issue comment ${commentId}`);
|
||||
const { data: issueComment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
comment = issueComment;
|
||||
isPRReviewComment = false;
|
||||
console.log("Successfully fetched as issue comment");
|
||||
}
|
||||
} catch (finalError) {
|
||||
// If all attempts fail, try to determine more information about the comment
|
||||
console.error("Failed to fetch comment. Debug info:");
|
||||
console.error(`Comment ID: ${commentId}`);
|
||||
console.error(`Event name: ${context.eventName}`);
|
||||
console.error(`Entity number: ${context.entityNumber}`);
|
||||
console.error(`Repository: ${context.repository.full_name}`);
|
||||
|
||||
// Try to get the PR info to understand the comment structure
|
||||
try {
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: context.entityNumber,
|
||||
});
|
||||
console.log(`PR state: ${pr.state}`);
|
||||
console.log(`PR comments count: ${pr.comments}`);
|
||||
console.log(`PR review comments count: ${pr.review_comments}`);
|
||||
} catch {
|
||||
console.error("Could not fetch PR info for debugging");
|
||||
}
|
||||
|
||||
throw finalError;
|
||||
}
|
||||
|
||||
const currentBody = comment.body ?? "";
|
||||
|
||||
// Check if we need to add branch link for new branches
|
||||
const useCommitSigning = process.env.USE_COMMIT_SIGNING === "true";
|
||||
const { shouldDeleteBranch, branchLink } =
|
||||
await checkAndCommitOrDeleteBranch(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
claudeBranch,
|
||||
baseBranch,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
// Check if we need to add PR URL when we have a new branch
|
||||
let prLink = "";
|
||||
// If claudeBranch is set, it means we created a new branch (for issues or closed/merged PRs)
|
||||
if (claudeBranch && !shouldDeleteBranch) {
|
||||
// Check if comment already contains a PR URL
|
||||
const serverUrlPattern = serverUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const prUrlPattern = new RegExp(
|
||||
`${serverUrlPattern}\\/.+\\/compare\\/${baseBranch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.\\.\\.`,
|
||||
);
|
||||
const containsPRUrl = currentBody.match(prUrlPattern);
|
||||
|
||||
if (!containsPRUrl) {
|
||||
// Check if there are changes to the branch compared to the default branch
|
||||
try {
|
||||
const { data: comparison } =
|
||||
await octokit.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${baseBranch}...${claudeBranch}`,
|
||||
});
|
||||
|
||||
// If there are changes (commits or file changes), add the PR URL
|
||||
if (
|
||||
comparison.total_commits > 0 ||
|
||||
(comparison.files && comparison.files.length > 0)
|
||||
) {
|
||||
const entityType = context.isPR ? "PR" : "Issue";
|
||||
const prTitle = encodeURIComponent(
|
||||
`${entityType} #${context.entityNumber}: Changes from Claude`,
|
||||
);
|
||||
const prBody = encodeURIComponent(
|
||||
`This PR addresses ${entityType.toLowerCase()} #${context.entityNumber}\n\nGenerated with [Claude Code](https://claude.ai/code)`,
|
||||
);
|
||||
const prUrl = `${serverUrl}/${owner}/${repo}/compare/${baseBranch}...${claudeBranch}?quick_pull=1&title=${prTitle}&body=${prBody}`;
|
||||
prLink = `\n[Create a PR](${prUrl})`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for changes in branch:", error);
|
||||
// Don't fail the entire update if we can't check for changes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if action failed and read output file for execution details
|
||||
let executionDetails: {
|
||||
total_cost_usd?: number;
|
||||
duration_ms?: number;
|
||||
duration_api_ms?: number;
|
||||
} | null = null;
|
||||
let actionFailed = false;
|
||||
let errorDetails: string | undefined;
|
||||
|
||||
// First check if prepare step failed
|
||||
const prepareSuccess = process.env.PREPARE_SUCCESS !== "false";
|
||||
const prepareError = process.env.PREPARE_ERROR;
|
||||
|
||||
if (!prepareSuccess && prepareError) {
|
||||
actionFailed = true;
|
||||
errorDetails = prepareError;
|
||||
} else {
|
||||
// Check for existence of output file and parse it if available
|
||||
try {
|
||||
const outputFile = process.env.OUTPUT_FILE;
|
||||
if (outputFile) {
|
||||
const fileContent = await fs.readFile(outputFile, "utf8");
|
||||
const outputData = JSON.parse(fileContent);
|
||||
|
||||
// Output file is an array, get the last element which contains execution details
|
||||
if (Array.isArray(outputData) && outputData.length > 0) {
|
||||
const lastElement = outputData[outputData.length - 1];
|
||||
if (
|
||||
lastElement.type === "result" &&
|
||||
"total_cost_usd" in lastElement &&
|
||||
"duration_ms" in lastElement
|
||||
) {
|
||||
executionDetails = {
|
||||
total_cost_usd: lastElement.total_cost_usd,
|
||||
duration_ms: lastElement.duration_ms,
|
||||
duration_api_ms: lastElement.duration_api_ms,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the Claude action failed
|
||||
const claudeSuccess = process.env.CLAUDE_SUCCESS !== "false";
|
||||
actionFailed = !claudeSuccess;
|
||||
} catch (error) {
|
||||
console.error("Error reading output file:", error);
|
||||
// If we can't read the file, check for any failure markers
|
||||
actionFailed = process.env.CLAUDE_SUCCESS === "false";
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare input for updateCommentBody function
|
||||
const commentInput: CommentUpdateInput = {
|
||||
currentBody,
|
||||
actionFailed,
|
||||
executionDetails,
|
||||
jobUrl,
|
||||
branchLink,
|
||||
prLink,
|
||||
branchName: shouldDeleteBranch || !branchLink ? undefined : claudeBranch,
|
||||
triggerUsername,
|
||||
errorDetails,
|
||||
};
|
||||
|
||||
const updatedBody = updateCommentBody(commentInput);
|
||||
|
||||
try {
|
||||
await updateClaudeComment(octokit.rest, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body: updatedBody,
|
||||
isPullRequestReviewComment: isPRReviewComment,
|
||||
});
|
||||
console.log(
|
||||
`✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`,
|
||||
);
|
||||
} catch (updateError) {
|
||||
console.error(
|
||||
`Failed to update ${isPRReviewComment ? "PR review" : "issue"} comment:`,
|
||||
updateError,
|
||||
);
|
||||
throw updateError;
|
||||
}
|
||||
await updateCommentLink({
|
||||
commentId: parseInt(process.env.CLAUDE_COMMENT_ID!),
|
||||
githubToken,
|
||||
claudeBranch: process.env.CLAUDE_BRANCH,
|
||||
baseBranch: process.env.BASE_BRANCH || "main",
|
||||
triggerUsername: process.env.TRIGGER_USERNAME,
|
||||
context,
|
||||
octokit,
|
||||
claudeSuccess: process.env.CLAUDE_SUCCESS !== "false",
|
||||
outputFile: process.env.OUTPUT_FILE,
|
||||
prepareSuccess: process.env.PREPARE_SUCCESS !== "false",
|
||||
prepareError: process.env.PREPARE_ERROR,
|
||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
@@ -242,4 +271,6 @@ async function run() {
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
if (import.meta.main) {
|
||||
run();
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ type BaseContext = {
|
||||
allowedNonWriteUsers: string;
|
||||
trackProgress: boolean;
|
||||
includeFixLinks: boolean;
|
||||
includeCommentsByActor: string;
|
||||
excludeCommentsByActor: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -156,6 +158,8 @@ export function parseGitHubContext(): GitHubContext {
|
||||
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
||||
trackProgress: process.env.TRACK_PROGRESS === "true",
|
||||
includeFixLinks: process.env.INCLUDE_FIX_LINKS === "true",
|
||||
includeCommentsByActor: process.env.INCLUDE_COMMENTS_BY_ACTOR ?? "",
|
||||
excludeCommentsByActor: process.env.EXCLUDE_COMMENTS_BY_ACTOR ?? "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+114
-11
@@ -20,6 +20,10 @@ import type {
|
||||
} from "../types";
|
||||
import type { CommentWithImages } from "../utils/image-downloader";
|
||||
import { downloadCommentImages } from "../utils/image-downloader";
|
||||
import {
|
||||
parseActorFilter,
|
||||
shouldIncludeCommentByActor,
|
||||
} from "../utils/actor-filter";
|
||||
|
||||
/**
|
||||
* Extracts the trigger timestamp from the GitHub webhook payload.
|
||||
@@ -67,6 +71,33 @@ export function extractOriginalTitle(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the original body from the GitHub webhook payload.
|
||||
* This is the body as it existed when the trigger event occurred,
|
||||
* preventing TOCTOU attacks where an attacker edits the body after
|
||||
* the trigger but before the action reads it.
|
||||
*
|
||||
* @param context - Parsed GitHub context from webhook
|
||||
* @returns The original body string, null (no body), or undefined (not available)
|
||||
*/
|
||||
export function extractOriginalBody(
|
||||
context: ParsedGitHubContext,
|
||||
): string | null | undefined {
|
||||
if (isIssueCommentEvent(context)) {
|
||||
return context.payload.issue?.body;
|
||||
} else if (isPullRequestEvent(context)) {
|
||||
return context.payload.pull_request?.body;
|
||||
} else if (isPullRequestReviewEvent(context)) {
|
||||
return context.payload.pull_request?.body;
|
||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||
return context.payload.pull_request?.body;
|
||||
} else if (isIssuesEvent(context)) {
|
||||
return context.payload.issue?.body;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters comments to only include those that existed in their final state before the trigger time.
|
||||
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
||||
@@ -166,6 +197,35 @@ export function isBodySafeToUse(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters comments by actor username based on include/exclude patterns
|
||||
* @param comments - Array of comments to filter
|
||||
* @param includeActors - Comma-separated actors to include
|
||||
* @param excludeActors - Comma-separated actors to exclude
|
||||
* @returns Filtered array of comments
|
||||
*/
|
||||
export function filterCommentsByActor<T extends { author: { login: string } }>(
|
||||
comments: T[],
|
||||
includeActors: string = "",
|
||||
excludeActors: string = "",
|
||||
): T[] {
|
||||
const includeParsed = parseActorFilter(includeActors);
|
||||
const excludeParsed = parseActorFilter(excludeActors);
|
||||
|
||||
// No filters = return all
|
||||
if (includeParsed.length === 0 && excludeParsed.length === 0) {
|
||||
return comments;
|
||||
}
|
||||
|
||||
return comments.filter((comment) =>
|
||||
shouldIncludeCommentByActor(
|
||||
comment.author.login,
|
||||
includeParsed,
|
||||
excludeParsed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
type FetchDataParams = {
|
||||
octokits: Octokits;
|
||||
repository: string;
|
||||
@@ -174,6 +234,9 @@ type FetchDataParams = {
|
||||
triggerUsername?: string;
|
||||
triggerTime?: string;
|
||||
originalTitle?: string;
|
||||
originalBody?: string | null;
|
||||
includeCommentsByActor?: string;
|
||||
excludeCommentsByActor?: string;
|
||||
};
|
||||
|
||||
export type GitHubFileWithSHA = GitHubFile & {
|
||||
@@ -198,6 +261,9 @@ export async function fetchGitHubData({
|
||||
triggerUsername,
|
||||
triggerTime,
|
||||
originalTitle,
|
||||
originalBody,
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
}: FetchDataParams): Promise<FetchDataResult> {
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
@@ -225,9 +291,13 @@ export async function fetchGitHubData({
|
||||
const pullRequest = prResult.repository.pullRequest;
|
||||
contextData = pullRequest;
|
||||
changedFiles = pullRequest.files.nodes || [];
|
||||
comments = filterCommentsToTriggerTime(
|
||||
pullRequest.comments?.nodes || [],
|
||||
triggerTime,
|
||||
comments = filterCommentsByActor(
|
||||
filterCommentsToTriggerTime(
|
||||
pullRequest.comments?.nodes || [],
|
||||
triggerTime,
|
||||
),
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
reviewData = pullRequest.reviews || [];
|
||||
|
||||
@@ -248,9 +318,13 @@ export async function fetchGitHubData({
|
||||
|
||||
if (issueResult.repository.issue) {
|
||||
contextData = issueResult.repository.issue;
|
||||
comments = filterCommentsToTriggerTime(
|
||||
contextData?.comments?.nodes || [],
|
||||
triggerTime,
|
||||
comments = filterCommentsByActor(
|
||||
filterCommentsToTriggerTime(
|
||||
contextData?.comments?.nodes || [],
|
||||
triggerTime,
|
||||
),
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
|
||||
console.log(`Successfully fetched issue #${prNumber} data`);
|
||||
@@ -318,7 +392,27 @@ export async function fetchGitHubData({
|
||||
body: r.body,
|
||||
}));
|
||||
|
||||
// Filter review comments to trigger time
|
||||
// Filter review comments to trigger time and by actor
|
||||
if (reviewData && reviewData.nodes) {
|
||||
// Filter reviews by actor
|
||||
reviewData.nodes = filterCommentsByActor(
|
||||
reviewData.nodes,
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
|
||||
// Also filter inline review comments within each review
|
||||
reviewData.nodes.forEach((review) => {
|
||||
if (review.comments?.nodes) {
|
||||
review.comments.nodes = filterCommentsByActor(
|
||||
review.comments.nodes,
|
||||
includeCommentsByActor,
|
||||
excludeCommentsByActor,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const allReviewComments =
|
||||
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
|
||||
const filteredReviewComments = filterCommentsToTriggerTime(
|
||||
@@ -334,12 +428,21 @@ export async function fetchGitHubData({
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Add the main issue/PR body if it has content and wasn't edited after trigger
|
||||
// This prevents a TOCTOU race condition where an attacker could edit the body
|
||||
// between when an authorized user triggered Claude and when Claude processes the request
|
||||
// Use the original body from the webhook payload if provided (TOCTOU protection).
|
||||
// The webhook payload captures the body at event time, before any attacker edits.
|
||||
if (originalBody !== undefined) {
|
||||
contextData.body = originalBody ?? "";
|
||||
}
|
||||
|
||||
// Add the main issue/PR body if it has content and wasn't edited after trigger.
|
||||
// When originalBody is provided, the body is already safe (from webhook payload).
|
||||
// Otherwise, fall back to timestamp-based validation.
|
||||
let mainBody: CommentWithImages[] = [];
|
||||
if (contextData.body) {
|
||||
if (isBodySafeToUse(contextData, triggerTime)) {
|
||||
if (
|
||||
originalBody !== undefined ||
|
||||
isBodySafeToUse(contextData, triggerTime)
|
||||
) {
|
||||
mainBody = [
|
||||
{
|
||||
...(isPR
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
import { $ } from "bun";
|
||||
import { execFileSync } from "child_process";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../context";
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
import type { Octokits } from "../api/client";
|
||||
@@ -265,9 +264,6 @@ export async function setupBranch(
|
||||
execGit(["fetch", "origin", sourceBranch, "--depth=1"]);
|
||||
execGit(["checkout", sourceBranch, "--"]);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("BASE_BRANCH", sourceBranch);
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
claudeBranch: newBranch,
|
||||
@@ -294,9 +290,6 @@ export async function setupBranch(
|
||||
`Successfully created and checked out local branch: ${newBranch}`,
|
||||
);
|
||||
|
||||
// Set outputs for GitHub Actions
|
||||
core.setOutput("CLAUDE_BRANCH", newBranch);
|
||||
core.setOutput("BASE_BRANCH", sourceBranch);
|
||||
return {
|
||||
baseBranch: sourceBranch,
|
||||
claudeBranch: newBranch,
|
||||
|
||||
@@ -42,13 +42,55 @@ export async function configureGitAuth(
|
||||
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
|
||||
console.log(`✓ Set git user as ${botName}`);
|
||||
|
||||
// Remove the authorization header that actions/checkout sets
|
||||
// Remove the authorization header that actions/checkout sets.
|
||||
// Newer versions of actions/checkout store credentials in external config files
|
||||
// referenced via includeIf, so --unset-all on local config may not find them.
|
||||
// We clear both: local config entries and any includeIf credential files.
|
||||
console.log("Removing existing git authentication headers...");
|
||||
try {
|
||||
await $`git config --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
console.log("✓ Removed existing authentication headers");
|
||||
} catch (e) {
|
||||
console.log("No existing authentication headers to remove");
|
||||
console.log("✓ Removed existing authentication headers from local config");
|
||||
} catch {
|
||||
console.log("No existing authentication headers in local config to remove");
|
||||
}
|
||||
|
||||
// Clear extraheader from includeIf credential config files set by actions/checkout
|
||||
try {
|
||||
const configOutput = await $`git config --list --show-origin`.text();
|
||||
const extraheaderPattern = `http.${GITHUB_SERVER_URL}/.extraheader=`;
|
||||
console.log(
|
||||
`Scanning git config origins for extraheader pattern: ${extraheaderPattern}`,
|
||||
);
|
||||
let foundExternalEntries = false;
|
||||
for (const line of configOutput.split("\n")) {
|
||||
if (!line.includes(extraheaderPattern)) continue;
|
||||
// Format: "file:<path>\t<key>=<value>"
|
||||
const match = line.match(/^file:(.+?)\t/);
|
||||
if (!match) {
|
||||
console.log(
|
||||
`Found extraheader but could not parse origin: ${line.substring(0, 80)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const configFile = match[1]!;
|
||||
// Skip the local .git/config — we already handled that above
|
||||
if (configFile.endsWith("/.git/config")) {
|
||||
console.log("Skipping .git/config (already handled)");
|
||||
continue;
|
||||
}
|
||||
foundExternalEntries = true;
|
||||
try {
|
||||
await $`git config --file ${configFile} --unset-all http.${GITHUB_SERVER_URL}/.extraheader`;
|
||||
console.log(`✓ Removed authentication headers from ${configFile}`);
|
||||
} catch {
|
||||
console.log(`Could not unset extraheader from ${configFile}`);
|
||||
}
|
||||
}
|
||||
if (!foundExternalEntries) {
|
||||
console.log("No external credential config files found");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Could not enumerate git config origins: ${error}`);
|
||||
}
|
||||
|
||||
// Update the remote URL to include the token for authentication
|
||||
@@ -82,8 +124,13 @@ export async function setupSshSigning(sshSigningKey: string): Promise<void> {
|
||||
const sshDir = join(homedir(), ".ssh");
|
||||
await mkdir(sshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Ensure key ends with newline (required for ssh-keygen to parse it)
|
||||
const normalizedKey = sshSigningKey.endsWith("\n")
|
||||
? sshSigningKey
|
||||
: sshSigningKey + "\n";
|
||||
|
||||
// Write the signing key atomically with secure permissions (600)
|
||||
await writeFile(SSH_SIGNING_KEY_PATH, sshSigningKey, { mode: 0o600 });
|
||||
await writeFile(SSH_SIGNING_KEY_PATH, normalizedKey, { mode: 0o600 });
|
||||
console.log(`✓ SSH signing key written to ${SSH_SIGNING_KEY_PATH}`);
|
||||
|
||||
// Configure git to use SSH signing
|
||||
|
||||
+80
-37
@@ -3,6 +3,13 @@
|
||||
import * as core from "@actions/core";
|
||||
import { retryWithBackoff } from "../utils/retry";
|
||||
|
||||
export class WorkflowValidationSkipError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkflowValidationSkipError";
|
||||
}
|
||||
}
|
||||
|
||||
async function getOidcToken(): Promise<string> {
|
||||
try {
|
||||
const oidcToken = await core.getIDToken("claude-code-github-action");
|
||||
@@ -16,15 +23,60 @@ async function getOidcToken(): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
const DEFAULT_PERMISSIONS: Record<string, string> = {
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
};
|
||||
|
||||
export function parseAdditionalPermissions():
|
||||
| Record<string, string>
|
||||
| undefined {
|
||||
const raw = process.env.ADDITIONAL_PERMISSIONS;
|
||||
if (!raw || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const additional: Record<string, string> = {};
|
||||
for (const line of raw.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const colonIndex = trimmed.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const key = trimmed.slice(0, colonIndex).trim();
|
||||
const value = trimmed.slice(colonIndex + 1).trim();
|
||||
if (key && value) {
|
||||
additional[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(additional).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { ...DEFAULT_PERMISSIONS, ...additional };
|
||||
}
|
||||
|
||||
async function exchangeForAppToken(
|
||||
oidcToken: string,
|
||||
permissions?: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
};
|
||||
const fetchOptions: RequestInit = {
|
||||
method: "POST",
|
||||
headers,
|
||||
};
|
||||
|
||||
if (permissions) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
fetchOptions.body = JSON.stringify({ permissions });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.anthropic.com/api/github/github-app-token-exchange",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
},
|
||||
},
|
||||
fetchOptions,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -51,8 +103,7 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
console.log(
|
||||
"Action skipped due to workflow validation error. This is expected when adding Claude Code workflows to new repositories or on PRs with workflow changes. If you're seeing this, your workflow will begin working once you merge your PR.",
|
||||
);
|
||||
core.setOutput("skipped_due_to_workflow_validation_mismatch", "true");
|
||||
process.exit(0);
|
||||
throw new WorkflowValidationSkipError(message);
|
||||
}
|
||||
|
||||
console.error(
|
||||
@@ -75,34 +126,26 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function setupGitHubToken(): Promise<string> {
|
||||
try {
|
||||
// Check if GitHub token was provided as override
|
||||
const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
// Check if GitHub token was provided as override
|
||||
const providedToken = process.env.OVERRIDE_GITHUB_TOKEN;
|
||||
|
||||
if (providedToken) {
|
||||
console.log("Using provided GITHUB_TOKEN for authentication");
|
||||
core.setOutput("GITHUB_TOKEN", providedToken);
|
||||
return providedToken;
|
||||
}
|
||||
|
||||
console.log("Requesting OIDC token...");
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken),
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
console.log("Using GITHUB_TOKEN from OIDC");
|
||||
core.setOutput("GITHUB_TOKEN", appToken);
|
||||
return appToken;
|
||||
} catch (error) {
|
||||
// Only set failed if we get here - workflow validation errors will exit(0) before this
|
||||
core.setFailed(
|
||||
`Failed to setup GitHub token: ${error}\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
|
||||
);
|
||||
process.exit(1);
|
||||
if (providedToken) {
|
||||
console.log("Using provided GITHUB_TOKEN for authentication");
|
||||
return providedToken;
|
||||
}
|
||||
|
||||
console.log("Requesting OIDC token...");
|
||||
const oidcToken = await retryWithBackoff(() => getOidcToken());
|
||||
console.log("OIDC token successfully obtained");
|
||||
|
||||
const permissions = parseAdditionalPermissions();
|
||||
|
||||
console.log("Exchanging OIDC token for app token...");
|
||||
const appToken = await retryWithBackoff(() =>
|
||||
exchangeForAppToken(oidcToken, permissions),
|
||||
);
|
||||
console.log("App token successfully obtained");
|
||||
|
||||
console.log("Using GITHUB_TOKEN from OIDC");
|
||||
return appToken;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Parses actor filter string into array of patterns
|
||||
* @param filterString - Comma-separated actor names (e.g., "user1,user2,*[bot]")
|
||||
* @returns Array of actor patterns
|
||||
*/
|
||||
export function parseActorFilter(filterString: string): string[] {
|
||||
if (!filterString.trim()) return [];
|
||||
return filterString
|
||||
.split(",")
|
||||
.map((actor) => actor.trim())
|
||||
.filter((actor) => actor.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an actor matches a pattern
|
||||
* Supports wildcards: "*[bot]" matches all bots, "dependabot[bot]" matches specific
|
||||
* @param actor - Actor username to check
|
||||
* @param pattern - Pattern to match against
|
||||
* @returns true if actor matches pattern
|
||||
*/
|
||||
export function actorMatchesPattern(actor: string, pattern: string): boolean {
|
||||
// Exact match
|
||||
if (actor === pattern) return true;
|
||||
|
||||
// Wildcard bot pattern: "*[bot]" matches any username ending with [bot]
|
||||
if (pattern === "*[bot]" && actor.endsWith("[bot]")) return true;
|
||||
|
||||
// No match
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a comment should be included based on actor filters
|
||||
* @param actor - Comment author username
|
||||
* @param includeActors - Array of actors to include (empty = include all)
|
||||
* @param excludeActors - Array of actors to exclude (empty = exclude none)
|
||||
* @returns true if comment should be included
|
||||
*/
|
||||
export function shouldIncludeCommentByActor(
|
||||
actor: string,
|
||||
includeActors: string[],
|
||||
excludeActors: string[],
|
||||
): boolean {
|
||||
// Check exclusion first (exclusion takes priority)
|
||||
if (excludeActors.length > 0) {
|
||||
for (const pattern of excludeActors) {
|
||||
if (actorMatchesPattern(actor, pattern)) {
|
||||
return false; // Excluded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check inclusion
|
||||
if (includeActors.length > 0) {
|
||||
for (const pattern of includeActors) {
|
||||
if (actorMatchesPattern(actor, pattern)) {
|
||||
return true; // Explicitly included
|
||||
}
|
||||
}
|
||||
return false; // Not in include list
|
||||
}
|
||||
|
||||
// No filters or passed all checks
|
||||
return true;
|
||||
}
|
||||
@@ -177,25 +177,27 @@ export async function prepareMcpConfig(
|
||||
if (!actuallyHasPermission) {
|
||||
core.warning(
|
||||
"The github_ci MCP server requires 'actions: read' permission. " +
|
||||
"Please ensure your GitHub token has this permission. " +
|
||||
"Skipping CI server installation. " +
|
||||
"To enable CI status checks, add 'actions: read' to your workflow permissions. " +
|
||||
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
|
||||
);
|
||||
} else {
|
||||
baseMcpConfig.mcpServers.github_ci = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||
],
|
||||
env: {
|
||||
// Use workflow github token, not app token
|
||||
GITHUB_TOKEN: process.env.DEFAULT_WORKFLOW_TOKEN,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||
},
|
||||
};
|
||||
}
|
||||
baseMcpConfig.mcpServers.github_ci = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
|
||||
],
|
||||
env: {
|
||||
// Use workflow github token, not app token
|
||||
GITHUB_TOKEN: process.env.DEFAULT_WORKFLOW_TOKEN,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (hasGitHubMcpTools) {
|
||||
|
||||
+106
-193
@@ -1,7 +1,4 @@
|
||||
import * as core from "@actions/core";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import type { PreparedContext } from "../../create-prompt/types";
|
||||
import { prepareMcpConfig } from "../../mcp/install-mcp-server";
|
||||
import { parseAllowedTools } from "./parse-tools";
|
||||
import {
|
||||
@@ -10,212 +7,128 @@ import {
|
||||
} from "../../github/operations/git-config";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
import type { GitHubContext } from "../../github/context";
|
||||
import { isEntityContext } from "../../github/context";
|
||||
import type { Octokits } from "../../github/api/client";
|
||||
|
||||
/**
|
||||
* Extract GitHub context as environment variables for agent mode
|
||||
*/
|
||||
function extractGitHubContext(context: GitHubContext): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
|
||||
// Basic repository info
|
||||
envVars.GITHUB_REPOSITORY = context.repository.full_name;
|
||||
envVars.GITHUB_TRIGGER_ACTOR = context.actor;
|
||||
envVars.GITHUB_EVENT_NAME = context.eventName;
|
||||
|
||||
// Entity-specific context (PR/issue numbers, branches, etc.)
|
||||
if (isEntityContext(context)) {
|
||||
if (context.isPR) {
|
||||
envVars.GITHUB_PR_NUMBER = String(context.entityNumber);
|
||||
|
||||
// Extract branch info from payload if available
|
||||
if (
|
||||
context.payload &&
|
||||
"pull_request" in context.payload &&
|
||||
context.payload.pull_request
|
||||
) {
|
||||
envVars.GITHUB_BASE_REF = context.payload.pull_request.base?.ref || "";
|
||||
envVars.GITHUB_HEAD_REF = context.payload.pull_request.head?.ref || "";
|
||||
}
|
||||
} else {
|
||||
envVars.GITHUB_ISSUE_NUMBER = String(context.entityNumber);
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent mode implementation.
|
||||
* Prepares the agent mode execution context.
|
||||
*
|
||||
* This mode runs whenever an explicit prompt is provided in the workflow configuration.
|
||||
* Agent mode runs whenever an explicit prompt is provided in the workflow configuration.
|
||||
* It bypasses the standard @claude mention checking and comment tracking used by tag mode,
|
||||
* providing direct access to Claude Code for automation workflows.
|
||||
*/
|
||||
export const agentMode: Mode = {
|
||||
name: "agent",
|
||||
description: "Direct automation mode for explicit prompts",
|
||||
export async function prepareAgentMode({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
}: {
|
||||
context: GitHubContext;
|
||||
octokit: Octokits;
|
||||
githubToken: string;
|
||||
}) {
|
||||
// Check if actor is human (prevents bot-triggered loops)
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
shouldTrigger(context) {
|
||||
// Only trigger when an explicit prompt is provided
|
||||
return !!context.inputs?.prompt;
|
||||
},
|
||||
// Configure git authentication for agent mode (same as tag mode)
|
||||
// SSH signing takes precedence if provided
|
||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||
const useApiCommitSigning = context.inputs.useCommitSigning && !useSshSigning;
|
||||
|
||||
prepareContext(context) {
|
||||
// Agent mode doesn't use comment tracking or branch management
|
||||
return {
|
||||
mode: "agent",
|
||||
githubContext: context,
|
||||
if (useSshSigning) {
|
||||
// Setup SSH signing for commits
|
||||
await setupSshSigning(context.inputs.sshSigningKey);
|
||||
|
||||
// Still configure git auth for push operations (user/email and remote URL)
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
// Continue anyway - git operations may still work with default config
|
||||
}
|
||||
} else if (!useApiCommitSigning) {
|
||||
// Use bot_id and bot_name from inputs directly
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
},
|
||||
|
||||
getAllowedTools() {
|
||||
return [];
|
||||
},
|
||||
try {
|
||||
// Use the shared git configuration function
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
// Continue anyway - git operations may still work with default config
|
||||
}
|
||||
}
|
||||
|
||||
getDisallowedTools() {
|
||||
return [];
|
||||
},
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
shouldCreateTrackingComment() {
|
||||
return false;
|
||||
},
|
||||
// Write the prompt file - use the user's prompt directly
|
||||
const promptContent =
|
||||
context.inputs.prompt ||
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||
|
||||
async prepare({
|
||||
context,
|
||||
octokit,
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
// Parse allowed tools from user's claude_args
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const allowedTools = parseAllowedTools(userClaudeArgs);
|
||||
|
||||
// Check for branch info from environment variables (useful for auto-fix workflows)
|
||||
const claudeBranch = process.env.CLAUDE_BRANCH || undefined;
|
||||
const baseBranch =
|
||||
process.env.BASE_BRANCH || context.inputs.baseBranch || "main";
|
||||
|
||||
// Detect current branch from GitHub environment
|
||||
const currentBranch =
|
||||
claudeBranch ||
|
||||
process.env.GITHUB_HEAD_REF ||
|
||||
process.env.GITHUB_REF_NAME ||
|
||||
"main";
|
||||
|
||||
// Get our GitHub MCP servers config
|
||||
const ourMcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
}: ModeOptions): Promise<ModeResult> {
|
||||
// Check if actor is human (prevents bot-triggered loops)
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: currentBranch,
|
||||
baseBranch: baseBranch,
|
||||
claudeCommentId: undefined, // No tracking comment in agent mode
|
||||
allowedTools,
|
||||
mode: "agent",
|
||||
context,
|
||||
});
|
||||
|
||||
// Configure git authentication for agent mode (same as tag mode)
|
||||
// SSH signing takes precedence if provided
|
||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||
const useApiCommitSigning =
|
||||
context.inputs.useCommitSigning && !useSshSigning;
|
||||
// Build final claude_args with multiple --mcp-config flags
|
||||
let claudeArgs = "";
|
||||
|
||||
if (useSshSigning) {
|
||||
// Setup SSH signing for commits
|
||||
await setupSshSigning(context.inputs.sshSigningKey);
|
||||
// Add our GitHub servers config if we have any
|
||||
const ourConfig = JSON.parse(ourMcpConfig);
|
||||
if (ourConfig.mcpServers && Object.keys(ourConfig.mcpServers).length > 0) {
|
||||
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||
}
|
||||
|
||||
// Still configure git auth for push operations (user/email and remote URL)
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
// Continue anyway - git operations may still work with default config
|
||||
}
|
||||
} else if (!useApiCommitSigning) {
|
||||
// Use bot_id and bot_name from inputs directly
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
// Append user's claude_args (which may have more --mcp-config flags)
|
||||
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();
|
||||
|
||||
try {
|
||||
// Use the shared git configuration function
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
// Continue anyway - git operations may still work with default config
|
||||
}
|
||||
}
|
||||
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Write the prompt file - use the user's prompt directly
|
||||
const promptContent =
|
||||
context.inputs.prompt ||
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
// Parse allowed tools from user's claude_args
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const allowedTools = parseAllowedTools(userClaudeArgs);
|
||||
|
||||
// Check for branch info from environment variables (useful for auto-fix workflows)
|
||||
const claudeBranch = process.env.CLAUDE_BRANCH || undefined;
|
||||
const baseBranch =
|
||||
process.env.BASE_BRANCH || context.inputs.baseBranch || "main";
|
||||
|
||||
// Detect current branch from GitHub environment
|
||||
const currentBranch =
|
||||
claudeBranch ||
|
||||
process.env.GITHUB_HEAD_REF ||
|
||||
process.env.GITHUB_REF_NAME ||
|
||||
"main";
|
||||
|
||||
// Get our GitHub MCP servers config
|
||||
const ourMcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: currentBranch,
|
||||
return {
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: baseBranch,
|
||||
claudeCommentId: undefined, // No tracking comment in agent mode
|
||||
allowedTools,
|
||||
mode: "agent",
|
||||
context,
|
||||
});
|
||||
|
||||
// Build final claude_args with multiple --mcp-config flags
|
||||
let claudeArgs = "";
|
||||
|
||||
// Add our GitHub servers config if we have any
|
||||
const ourConfig = JSON.parse(ourMcpConfig);
|
||||
if (ourConfig.mcpServers && Object.keys(ourConfig.mcpServers).length > 0) {
|
||||
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||
}
|
||||
|
||||
// Append user's claude_args (which may have more --mcp-config flags)
|
||||
claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim();
|
||||
|
||||
core.setOutput("claude_args", claudeArgs);
|
||||
|
||||
return {
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: baseBranch,
|
||||
currentBranch: baseBranch, // Use base branch as current when creating new branch
|
||||
claudeBranch: claudeBranch,
|
||||
},
|
||||
mcpConfig: ourMcpConfig,
|
||||
};
|
||||
},
|
||||
|
||||
generatePrompt(context: PreparedContext): string {
|
||||
// Inject GitHub context as environment variables
|
||||
if (context.githubContext) {
|
||||
const envVars = extractGitHubContext(context.githubContext);
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
core.exportVariable(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent mode uses prompt field
|
||||
if (context.prompt) {
|
||||
return context.prompt;
|
||||
}
|
||||
|
||||
// Minimal fallback - repository is a string in PreparedContext
|
||||
return `Repository: ${context.repository}`;
|
||||
},
|
||||
|
||||
getSystemPrompt() {
|
||||
// Agent mode doesn't need additional system prompts
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
currentBranch: baseBranch, // Use base branch as current when creating new branch
|
||||
claudeBranch: claudeBranch,
|
||||
},
|
||||
mcpConfig: ourMcpConfig,
|
||||
claudeArgs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,17 +80,6 @@ export function detectMode(context: GitHubContext): AutoDetectedMode {
|
||||
return "agent";
|
||||
}
|
||||
|
||||
export function getModeDescription(mode: AutoDetectedMode): string {
|
||||
switch (mode) {
|
||||
case "tag":
|
||||
return "Interactive mode triggered by @claude mentions";
|
||||
case "agent":
|
||||
return "Direct automation mode for explicit prompts";
|
||||
default:
|
||||
return "Unknown mode";
|
||||
}
|
||||
}
|
||||
|
||||
function validateTrackProgressEvent(context: GitHubContext): void {
|
||||
// track_progress is only valid for pull_request and issue events
|
||||
const validEvents = [
|
||||
@@ -123,21 +112,3 @@ function validateTrackProgressEvent(context: GitHubContext): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUseTrackingComment(mode: AutoDetectedMode): boolean {
|
||||
return mode === "tag";
|
||||
}
|
||||
|
||||
export function getDefaultPromptForMode(
|
||||
mode: AutoDetectedMode,
|
||||
context: GitHubContext,
|
||||
): string | undefined {
|
||||
switch (mode) {
|
||||
case "tag":
|
||||
return undefined;
|
||||
case "agent":
|
||||
return context.inputs?.prompt;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Mode Registry for claude-code-action v1.0
|
||||
*
|
||||
* This module provides access to all available execution modes and handles
|
||||
* automatic mode detection based on GitHub event types.
|
||||
*/
|
||||
|
||||
import type { Mode, ModeName } from "./types";
|
||||
import { tagMode } from "./tag";
|
||||
import { agentMode } from "./agent";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { detectMode, type AutoDetectedMode } from "./detector";
|
||||
|
||||
export const VALID_MODES = ["tag", "agent"] as const;
|
||||
|
||||
/**
|
||||
* All available modes in v1.0
|
||||
*/
|
||||
const modes = {
|
||||
tag: tagMode,
|
||||
agent: agentMode,
|
||||
} as const satisfies Record<AutoDetectedMode, Mode>;
|
||||
|
||||
/**
|
||||
* Automatically detects and retrieves the appropriate mode based on the GitHub context.
|
||||
* In v1.0, modes are auto-selected based on event type.
|
||||
* @param context The GitHub context
|
||||
* @returns The appropriate mode for the context
|
||||
*/
|
||||
export function getMode(context: GitHubContext): Mode {
|
||||
const modeName = detectMode(context);
|
||||
console.log(
|
||||
`Auto-detected mode: ${modeName} for event: ${context.eventName}`,
|
||||
);
|
||||
|
||||
const mode = modes[modeName];
|
||||
if (!mode) {
|
||||
throw new Error(
|
||||
`Mode '${modeName}' not found. This should not happen. Please report this issue.`,
|
||||
);
|
||||
}
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid mode name.
|
||||
* @param name The string to check
|
||||
* @returns True if the name is a valid mode name
|
||||
*/
|
||||
export function isValidMode(name: string): name is ModeName {
|
||||
const validModes = ["tag", "agent"];
|
||||
return validModes.includes(name);
|
||||
}
|
||||
+157
-220
@@ -1,6 +1,3 @@
|
||||
import * as core from "@actions/core";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { checkContainsTrigger } from "../../github/validation/trigger";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
import { createInitialComment } from "../../github/operations/comments/create-initial";
|
||||
import { setupBranch } from "../../github/operations/branch";
|
||||
@@ -13,239 +10,179 @@ import {
|
||||
fetchGitHubData,
|
||||
extractTriggerTimestamp,
|
||||
extractOriginalTitle,
|
||||
extractOriginalBody,
|
||||
} from "../../github/data/fetcher";
|
||||
import { createPrompt, generateDefaultPrompt } from "../../create-prompt";
|
||||
import { createPrompt } from "../../create-prompt";
|
||||
import { isEntityContext } from "../../github/context";
|
||||
import type { PreparedContext } from "../../create-prompt/types";
|
||||
import type { FetchDataResult } from "../../github/data/fetcher";
|
||||
import type { GitHubContext } from "../../github/context";
|
||||
import type { Octokits } from "../../github/api/client";
|
||||
import { parseAllowedTools } from "../agent/parse-tools";
|
||||
|
||||
/**
|
||||
* Tag mode implementation.
|
||||
* Prepares the tag mode execution context.
|
||||
*
|
||||
* The traditional implementation mode that responds to @claude mentions,
|
||||
* issue assignments, or labels. Creates tracking comments showing progress
|
||||
* and has full implementation capabilities.
|
||||
* Tag mode responds to @claude mentions, issue assignments, or labels.
|
||||
* Creates tracking comments showing progress and has full implementation capabilities.
|
||||
*/
|
||||
export const tagMode: Mode = {
|
||||
name: "tag",
|
||||
description: "Traditional implementation mode triggered by @claude mentions",
|
||||
export async function prepareTagMode({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
}: {
|
||||
context: GitHubContext;
|
||||
octokit: Octokits;
|
||||
githubToken: string;
|
||||
}) {
|
||||
// Tag mode only handles entity-based events
|
||||
if (!isEntityContext(context)) {
|
||||
throw new Error("Tag mode requires entity context");
|
||||
}
|
||||
|
||||
shouldTrigger(context) {
|
||||
// Tag mode only handles entity events
|
||||
if (!isEntityContext(context)) {
|
||||
return false;
|
||||
}
|
||||
return checkContainsTrigger(context);
|
||||
},
|
||||
// Check if actor is human
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
prepareContext(context, data) {
|
||||
return {
|
||||
mode: "tag",
|
||||
githubContext: context,
|
||||
commentId: data?.commentId,
|
||||
baseBranch: data?.baseBranch,
|
||||
claudeBranch: data?.claudeBranch,
|
||||
// Create initial tracking comment
|
||||
const commentData = await createInitialComment(octokit.rest, context);
|
||||
const commentId = commentData.id;
|
||||
|
||||
const triggerTime = extractTriggerTimestamp(context);
|
||||
const originalTitle = extractOriginalTitle(context);
|
||||
const originalBody = extractOriginalBody(context);
|
||||
|
||||
const githubData = await fetchGitHubData({
|
||||
octokits: octokit,
|
||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||
prNumber: context.entityNumber.toString(),
|
||||
isPR: context.isPR,
|
||||
triggerUsername: context.actor,
|
||||
triggerTime,
|
||||
originalTitle,
|
||||
originalBody,
|
||||
includeCommentsByActor: context.inputs.includeCommentsByActor,
|
||||
excludeCommentsByActor: context.inputs.excludeCommentsByActor,
|
||||
});
|
||||
|
||||
// Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Configure git authentication
|
||||
// SSH signing takes precedence if provided
|
||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||
const useApiCommitSigning = context.inputs.useCommitSigning && !useSshSigning;
|
||||
|
||||
if (useSshSigning) {
|
||||
// Setup SSH signing for commits
|
||||
await setupSshSigning(context.inputs.sshSigningKey);
|
||||
|
||||
// Still configure git auth for push operations (user/email and remote URL)
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
} else if (!useApiCommitSigning) {
|
||||
// Use bot_id and bot_name from inputs directly
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
},
|
||||
|
||||
getAllowedTools() {
|
||||
return [];
|
||||
},
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getDisallowedTools() {
|
||||
return [];
|
||||
},
|
||||
|
||||
shouldCreateTrackingComment() {
|
||||
return true;
|
||||
},
|
||||
|
||||
async prepare({
|
||||
// Create prompt file
|
||||
await createPrompt(
|
||||
commentId,
|
||||
branchInfo.baseBranch,
|
||||
branchInfo.claudeBranch,
|
||||
githubData,
|
||||
context,
|
||||
octokit,
|
||||
);
|
||||
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const userAllowedMCPTools = parseAllowedTools(userClaudeArgs).filter((tool) =>
|
||||
tool.startsWith("mcp__github_"),
|
||||
);
|
||||
|
||||
// Build claude_args for tag mode with required tools
|
||||
// Tag mode REQUIRES these tools to function properly
|
||||
const tagModeTools = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
"mcp__github_comment__update_claude_comment",
|
||||
"mcp__github_ci__get_ci_status",
|
||||
"mcp__github_ci__get_workflow_run_details",
|
||||
"mcp__github_ci__download_job_log",
|
||||
...userAllowedMCPTools,
|
||||
];
|
||||
|
||||
// Add git commands when using git CLI (no API commit signing, or SSH signing)
|
||||
// SSH signing still uses git CLI, just with signing enabled
|
||||
if (!useApiCommitSigning) {
|
||||
tagModeTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git rm:*)",
|
||||
);
|
||||
} else {
|
||||
// When using API commit signing, use MCP file ops tools
|
||||
tagModeTools.push(
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
}
|
||||
|
||||
// Get our GitHub MCP servers configuration
|
||||
const ourMcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
}: ModeOptions): Promise<ModeResult> {
|
||||
// Tag mode only handles entity-based events
|
||||
if (!isEntityContext(context)) {
|
||||
throw new Error("Tag mode requires entity context");
|
||||
}
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeCommentId: commentId.toString(),
|
||||
allowedTools: Array.from(new Set(tagModeTools)),
|
||||
mode: "tag",
|
||||
context,
|
||||
});
|
||||
|
||||
// Check if actor is human
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
// Build complete claude_args with multiple --mcp-config flags
|
||||
let claudeArgs = "";
|
||||
|
||||
// Create initial tracking comment
|
||||
const commentData = await createInitialComment(octokit.rest, context);
|
||||
const commentId = commentData.id;
|
||||
// Add our GitHub servers config
|
||||
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||
|
||||
const triggerTime = extractTriggerTimestamp(context);
|
||||
const originalTitle = extractOriginalTitle(context);
|
||||
// Add required tools for tag mode
|
||||
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
||||
|
||||
const githubData = await fetchGitHubData({
|
||||
octokits: octokit,
|
||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||
prNumber: context.entityNumber.toString(),
|
||||
isPR: context.isPR,
|
||||
triggerUsername: context.actor,
|
||||
triggerTime,
|
||||
originalTitle,
|
||||
});
|
||||
// Append user's claude_args (which may have more --mcp-config flags)
|
||||
if (userClaudeArgs) {
|
||||
claudeArgs += ` ${userClaudeArgs}`;
|
||||
}
|
||||
|
||||
// Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Configure git authentication
|
||||
// SSH signing takes precedence if provided
|
||||
const useSshSigning = !!context.inputs.sshSigningKey;
|
||||
const useApiCommitSigning =
|
||||
context.inputs.useCommitSigning && !useSshSigning;
|
||||
|
||||
if (useSshSigning) {
|
||||
// Setup SSH signing for commits
|
||||
await setupSshSigning(context.inputs.sshSigningKey);
|
||||
|
||||
// Still configure git auth for push operations (user/email and remote URL)
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
} else if (!useApiCommitSigning) {
|
||||
// Use bot_id and bot_name from inputs directly
|
||||
const user = {
|
||||
login: context.inputs.botName,
|
||||
id: parseInt(context.inputs.botId),
|
||||
};
|
||||
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create prompt file
|
||||
const modeContext = this.prepareContext(context, {
|
||||
commentId,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
await createPrompt(tagMode, modeContext, githubData, context);
|
||||
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const userAllowedMCPTools = parseAllowedTools(userClaudeArgs).filter(
|
||||
(tool) => tool.startsWith("mcp__github_"),
|
||||
);
|
||||
|
||||
// Build claude_args for tag mode with required tools
|
||||
// Tag mode REQUIRES these tools to function properly
|
||||
const tagModeTools = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
"mcp__github_comment__update_claude_comment",
|
||||
"mcp__github_ci__get_ci_status",
|
||||
"mcp__github_ci__get_workflow_run_details",
|
||||
"mcp__github_ci__download_job_log",
|
||||
...userAllowedMCPTools,
|
||||
];
|
||||
|
||||
// Add git commands when using git CLI (no API commit signing, or SSH signing)
|
||||
// SSH signing still uses git CLI, just with signing enabled
|
||||
if (!useApiCommitSigning) {
|
||||
tagModeTools.push(
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git rm:*)",
|
||||
);
|
||||
} else {
|
||||
// When using API commit signing, use MCP file ops tools
|
||||
tagModeTools.push(
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__delete_files",
|
||||
);
|
||||
}
|
||||
|
||||
// Get our GitHub MCP servers configuration
|
||||
const ourMcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeCommentId: commentId.toString(),
|
||||
allowedTools: Array.from(new Set(tagModeTools)),
|
||||
mode: "tag",
|
||||
context,
|
||||
});
|
||||
|
||||
// Build complete claude_args with multiple --mcp-config flags
|
||||
let claudeArgs = "";
|
||||
|
||||
// Add our GitHub servers config
|
||||
const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `--mcp-config '${escapedOurConfig}'`;
|
||||
|
||||
// Add required tools for tag mode
|
||||
claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`;
|
||||
|
||||
// Append user's claude_args (which may have more --mcp-config flags)
|
||||
if (userClaudeArgs) {
|
||||
claudeArgs += ` ${userClaudeArgs}`;
|
||||
}
|
||||
|
||||
core.setOutput("claude_args", claudeArgs.trim());
|
||||
|
||||
return {
|
||||
commentId,
|
||||
branchInfo,
|
||||
mcpConfig: ourMcpConfig,
|
||||
};
|
||||
},
|
||||
|
||||
generatePrompt(
|
||||
context: PreparedContext,
|
||||
githubData: FetchDataResult,
|
||||
useCommitSigning: boolean,
|
||||
): string {
|
||||
const defaultPrompt = generateDefaultPrompt(
|
||||
context,
|
||||
githubData,
|
||||
useCommitSigning,
|
||||
);
|
||||
|
||||
// If a custom prompt is provided, inject it into the tag mode prompt
|
||||
if (context.githubContext?.inputs?.prompt) {
|
||||
return (
|
||||
defaultPrompt +
|
||||
`
|
||||
|
||||
<custom_instructions>
|
||||
${context.githubContext.inputs.prompt}
|
||||
</custom_instructions>`
|
||||
);
|
||||
}
|
||||
|
||||
return defaultPrompt;
|
||||
},
|
||||
|
||||
getSystemPrompt() {
|
||||
// Tag mode doesn't need additional system prompts
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
return {
|
||||
commentId,
|
||||
branchInfo,
|
||||
mcpConfig: ourMcpConfig,
|
||||
claudeArgs: claudeArgs.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import type { PreparedContext } from "../create-prompt/types";
|
||||
import type { FetchDataResult } from "../github/data/fetcher";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
|
||||
export type ModeName = "tag" | "agent";
|
||||
|
||||
export type ModeContext = {
|
||||
mode: ModeName;
|
||||
githubContext: GitHubContext;
|
||||
commentId?: number;
|
||||
baseBranch?: string;
|
||||
claudeBranch?: string;
|
||||
};
|
||||
|
||||
export type ModeData = {
|
||||
commentId?: number;
|
||||
baseBranch?: string;
|
||||
claudeBranch?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mode interface for claude-code-action execution modes.
|
||||
* Each mode defines its own behavior for trigger detection, prompt generation,
|
||||
* and tracking comment creation.
|
||||
*
|
||||
* Current modes include:
|
||||
* - 'tag': Interactive mode triggered by @claude mentions
|
||||
* - 'agent': Direct automation mode triggered by explicit prompts
|
||||
*/
|
||||
export type Mode = {
|
||||
name: ModeName;
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Determines if this mode should trigger based on the GitHub context
|
||||
*/
|
||||
shouldTrigger(context: GitHubContext): boolean;
|
||||
|
||||
/**
|
||||
* Prepares the mode context with any additional data needed for prompt generation
|
||||
*/
|
||||
prepareContext(context: GitHubContext, data?: ModeData): ModeContext;
|
||||
|
||||
/**
|
||||
* Returns the list of tools that should be allowed for this mode
|
||||
*/
|
||||
getAllowedTools(): string[];
|
||||
|
||||
/**
|
||||
* Returns the list of tools that should be disallowed for this mode
|
||||
*/
|
||||
getDisallowedTools(): string[];
|
||||
|
||||
/**
|
||||
* Determines if this mode should create a tracking comment
|
||||
*/
|
||||
shouldCreateTrackingComment(): boolean;
|
||||
|
||||
/**
|
||||
* Generates the prompt for this mode.
|
||||
* @returns The complete prompt string
|
||||
*/
|
||||
generatePrompt(
|
||||
context: PreparedContext,
|
||||
githubData: FetchDataResult,
|
||||
useCommitSigning: boolean,
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Prepares the GitHub environment for this mode.
|
||||
* Each mode decides how to handle different event types.
|
||||
* @returns PrepareResult with commentId, branchInfo, and mcpConfig
|
||||
*/
|
||||
prepare(options: ModeOptions): Promise<ModeResult>;
|
||||
|
||||
/**
|
||||
* Returns an optional system prompt to append to Claude's base system prompt.
|
||||
* This allows modes to add mode-specific instructions.
|
||||
* @returns The system prompt string or undefined if no additional prompt is needed
|
||||
*/
|
||||
getSystemPrompt?(context: ModeContext): string | undefined;
|
||||
};
|
||||
|
||||
// Define types for mode prepare method
|
||||
export type ModeOptions = {
|
||||
context: GitHubContext;
|
||||
octokit: Octokits;
|
||||
githubToken: string;
|
||||
};
|
||||
|
||||
export type ModeResult = {
|
||||
commentId?: number;
|
||||
branchInfo: {
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* Main prepare module that delegates to the mode's prepare method
|
||||
*/
|
||||
|
||||
import type { PrepareOptions, PrepareResult } from "./types";
|
||||
|
||||
export async function prepare(options: PrepareOptions): Promise<PrepareResult> {
|
||||
const { mode, context, octokit, githubToken } = options;
|
||||
|
||||
console.log(
|
||||
`Preparing with mode: ${mode.name} for event: ${context.eventName}`,
|
||||
);
|
||||
|
||||
// Delegate to the mode's prepare method
|
||||
return mode.prepare({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
import type { Mode } from "../modes/types";
|
||||
|
||||
export type PrepareResult = {
|
||||
commentId?: number;
|
||||
branchInfo: {
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
};
|
||||
|
||||
export type PrepareOptions = {
|
||||
context: GitHubContext;
|
||||
octokit: Octokits;
|
||||
mode: Mode;
|
||||
githubToken: string;
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseActorFilter,
|
||||
actorMatchesPattern,
|
||||
shouldIncludeCommentByActor,
|
||||
} from "../src/github/utils/actor-filter";
|
||||
|
||||
describe("parseActorFilter", () => {
|
||||
test("parses comma-separated actors", () => {
|
||||
expect(parseActorFilter("user1,user2,bot[bot]")).toEqual([
|
||||
"user1",
|
||||
"user2",
|
||||
"bot[bot]",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(parseActorFilter("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles whitespace-only string", () => {
|
||||
expect(parseActorFilter(" ")).toEqual([]);
|
||||
});
|
||||
|
||||
test("trims whitespace", () => {
|
||||
expect(parseActorFilter(" user1 , user2 ")).toEqual(["user1", "user2"]);
|
||||
});
|
||||
|
||||
test("filters out empty entries", () => {
|
||||
expect(parseActorFilter("user1,,user2")).toEqual(["user1", "user2"]);
|
||||
});
|
||||
|
||||
test("handles single actor", () => {
|
||||
expect(parseActorFilter("user1")).toEqual(["user1"]);
|
||||
});
|
||||
|
||||
test("handles wildcard bot pattern", () => {
|
||||
expect(parseActorFilter("*[bot]")).toEqual(["*[bot]"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actorMatchesPattern", () => {
|
||||
test("matches exact username", () => {
|
||||
expect(actorMatchesPattern("john-doe", "john-doe")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match different username", () => {
|
||||
expect(actorMatchesPattern("john-doe", "jane-doe")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches wildcard bot pattern", () => {
|
||||
expect(actorMatchesPattern("dependabot[bot]", "*[bot]")).toBe(true);
|
||||
expect(actorMatchesPattern("renovate[bot]", "*[bot]")).toBe(true);
|
||||
expect(actorMatchesPattern("github-actions[bot]", "*[bot]")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match non-bot with wildcard", () => {
|
||||
expect(actorMatchesPattern("john-doe", "*[bot]")).toBe(false);
|
||||
expect(actorMatchesPattern("user-bot", "*[bot]")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches specific bot", () => {
|
||||
expect(actorMatchesPattern("dependabot[bot]", "dependabot[bot]")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(actorMatchesPattern("renovate[bot]", "renovate[bot]")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match different specific bot", () => {
|
||||
expect(actorMatchesPattern("dependabot[bot]", "renovate[bot]")).toBe(false);
|
||||
});
|
||||
|
||||
test("is case sensitive", () => {
|
||||
expect(actorMatchesPattern("User1", "user1")).toBe(false);
|
||||
expect(actorMatchesPattern("user1", "User1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIncludeCommentByActor", () => {
|
||||
test("includes all when no filters", () => {
|
||||
expect(shouldIncludeCommentByActor("user1", [], [])).toBe(true);
|
||||
expect(shouldIncludeCommentByActor("bot[bot]", [], [])).toBe(true);
|
||||
});
|
||||
|
||||
test("excludes when in exclude list", () => {
|
||||
expect(shouldIncludeCommentByActor("bot[bot]", [], ["*[bot]"])).toBe(false);
|
||||
expect(shouldIncludeCommentByActor("user1", [], ["user1"])).toBe(false);
|
||||
});
|
||||
|
||||
test("includes when not in exclude list", () => {
|
||||
expect(shouldIncludeCommentByActor("user1", [], ["user2"])).toBe(true);
|
||||
expect(shouldIncludeCommentByActor("user1", [], ["*[bot]"])).toBe(true);
|
||||
});
|
||||
|
||||
test("includes when in include list", () => {
|
||||
expect(shouldIncludeCommentByActor("user1", ["user1", "user2"], [])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldIncludeCommentByActor("user2", ["user1", "user2"], [])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("excludes when not in include list", () => {
|
||||
expect(shouldIncludeCommentByActor("user3", ["user1", "user2"], [])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("exclusion takes priority over inclusion", () => {
|
||||
expect(shouldIncludeCommentByActor("user1", ["user1"], ["user1"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
shouldIncludeCommentByActor("bot[bot]", ["*[bot]"], ["*[bot]"]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("handles wildcard in include list", () => {
|
||||
expect(shouldIncludeCommentByActor("dependabot[bot]", ["*[bot]"], [])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldIncludeCommentByActor("renovate[bot]", ["*[bot]"], [])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldIncludeCommentByActor("user1", ["*[bot]"], [])).toBe(false);
|
||||
});
|
||||
|
||||
test("handles wildcard in exclude list", () => {
|
||||
expect(shouldIncludeCommentByActor("dependabot[bot]", [], ["*[bot]"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldIncludeCommentByActor("renovate[bot]", [], ["*[bot]"])).toBe(
|
||||
false,
|
||||
);
|
||||
expect(shouldIncludeCommentByActor("user1", [], ["*[bot]"])).toBe(true);
|
||||
});
|
||||
|
||||
test("handles mixed include and exclude lists", () => {
|
||||
// Include user1 and user2, but exclude user2
|
||||
expect(
|
||||
shouldIncludeCommentByActor("user1", ["user1", "user2"], ["user2"]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIncludeCommentByActor("user2", ["user1", "user2"], ["user2"]),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIncludeCommentByActor("user3", ["user1", "user2"], ["user2"]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("handles complex bot filtering", () => {
|
||||
// Include all bots but exclude dependabot
|
||||
expect(
|
||||
shouldIncludeCommentByActor(
|
||||
"renovate[bot]",
|
||||
["*[bot]"],
|
||||
["dependabot[bot]"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldIncludeCommentByActor(
|
||||
"dependabot[bot]",
|
||||
["*[bot]"],
|
||||
["dependabot[bot]"],
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldIncludeCommentByActor("user1", ["*[bot]"], ["dependabot[bot]"]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+23
-163
@@ -3,58 +3,13 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
generatePrompt,
|
||||
generateDefaultPrompt,
|
||||
getEventTypeAndContext,
|
||||
buildAllowedToolsString,
|
||||
buildDisallowedToolsString,
|
||||
} from "../src/create-prompt";
|
||||
import type { PreparedContext } from "../src/create-prompt";
|
||||
import type { Mode } from "../src/modes/types";
|
||||
|
||||
describe("generatePrompt", () => {
|
||||
// Create a mock tag mode that uses the default prompt
|
||||
const mockTagMode: Mode = {
|
||||
name: "tag",
|
||||
description: "Tag mode",
|
||||
shouldTrigger: () => true,
|
||||
prepareContext: (context) => ({ mode: "tag", githubContext: context }),
|
||||
getAllowedTools: () => [],
|
||||
getDisallowedTools: () => [],
|
||||
shouldCreateTrackingComment: () => true,
|
||||
generatePrompt: (context, githubData, useCommitSigning) =>
|
||||
generateDefaultPrompt(context, githubData, useCommitSigning),
|
||||
prepare: async () => ({
|
||||
commentId: 123,
|
||||
branchInfo: {
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: "{}",
|
||||
}),
|
||||
};
|
||||
|
||||
// Create a mock agent mode that passes through prompts
|
||||
const mockAgentMode: Mode = {
|
||||
name: "agent",
|
||||
description: "Agent mode",
|
||||
shouldTrigger: () => true,
|
||||
prepareContext: (context) => ({ mode: "agent", githubContext: context }),
|
||||
getAllowedTools: () => [],
|
||||
getDisallowedTools: () => [],
|
||||
shouldCreateTrackingComment: () => false,
|
||||
generatePrompt: (context) => context.prompt || "",
|
||||
prepare: async () => ({
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: "{}",
|
||||
}),
|
||||
};
|
||||
|
||||
const mockGitHubData = {
|
||||
contextData: {
|
||||
title: "Test PR",
|
||||
@@ -179,12 +134,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
|
||||
@@ -212,12 +162,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -243,12 +188,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -276,12 +216,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -308,12 +243,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
@@ -339,12 +269,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
@@ -368,12 +293,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Verify prompt generates successfully without custom instructions
|
||||
expect(prompt).toContain("@claude please fix this");
|
||||
@@ -398,7 +318,7 @@ describe("generatePrompt", () => {
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockAgentMode,
|
||||
"agent",
|
||||
);
|
||||
|
||||
// Agent mode: Prompt is passed through as-is
|
||||
@@ -439,7 +359,7 @@ describe("generatePrompt", () => {
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockAgentMode,
|
||||
"agent",
|
||||
);
|
||||
|
||||
// v1.0: Variables are NOT substituted - prompt is passed as-is to Claude Code
|
||||
@@ -488,7 +408,7 @@ describe("generatePrompt", () => {
|
||||
envVars,
|
||||
issueGitHubData,
|
||||
false,
|
||||
mockAgentMode,
|
||||
"agent",
|
||||
);
|
||||
|
||||
// Agent mode: Prompt is passed through as-is
|
||||
@@ -513,7 +433,7 @@ describe("generatePrompt", () => {
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockAgentMode,
|
||||
"agent",
|
||||
);
|
||||
|
||||
// Agent mode: No substitution - passed as-is
|
||||
@@ -537,12 +457,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("You are Claude, an AI assistant");
|
||||
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
|
||||
@@ -565,12 +480,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
|
||||
// With commit signing disabled, co-author info appears in git commit instructions
|
||||
@@ -592,12 +502,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain PR-specific instructions (git commands when not using signing)
|
||||
expect(prompt).toContain("git push");
|
||||
@@ -628,12 +533,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain Issue-specific instructions
|
||||
expect(prompt).toContain(
|
||||
@@ -672,12 +572,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain the actual branch name with timestamp
|
||||
expect(prompt).toContain(
|
||||
@@ -707,12 +602,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain branch-specific instructions like issues
|
||||
expect(prompt).toContain(
|
||||
@@ -750,12 +640,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain open PR instructions (git commands when not using signing)
|
||||
expect(prompt).toContain("git push");
|
||||
@@ -786,12 +671,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
@@ -819,12 +699,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
@@ -852,12 +727,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain new branch instructions
|
||||
expect(prompt).toContain(
|
||||
@@ -881,12 +751,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should have git command instructions
|
||||
expect(prompt).toContain("Use git commands via the Bash tool");
|
||||
@@ -915,12 +780,7 @@ describe("generatePrompt", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = await generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
true,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = await generatePrompt(envVars, mockGitHubData, true, "tag");
|
||||
|
||||
// Should have commit signing tool instructions
|
||||
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||
|
||||
+329
-1
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, jest } from "bun:test";
|
||||
import { describe, expect, it, jest, test } from "bun:test";
|
||||
import {
|
||||
extractTriggerTimestamp,
|
||||
extractOriginalTitle,
|
||||
extractOriginalBody,
|
||||
fetchGitHubData,
|
||||
filterCommentsToTriggerTime,
|
||||
filterReviewsToTriggerTime,
|
||||
@@ -106,6 +107,55 @@ describe("extractOriginalTitle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOriginalBody", () => {
|
||||
it("should extract body from IssueCommentEvent on PR", () => {
|
||||
const body = extractOriginalBody(mockPullRequestCommentContext);
|
||||
expect(body).toBe("This PR fixes the memory leak issue reported in #788");
|
||||
});
|
||||
|
||||
it("should extract body from PullRequestReviewEvent", () => {
|
||||
const body = extractOriginalBody(mockPullRequestReviewContext);
|
||||
expect(body).toBe(
|
||||
"This PR improves error handling across all API endpoints",
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract body from PullRequestReviewCommentEvent", () => {
|
||||
const body = extractOriginalBody(mockPullRequestReviewCommentContext);
|
||||
expect(body).toBe(
|
||||
"This PR optimizes the search algorithm for better performance",
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract body from pull_request event", () => {
|
||||
const body = extractOriginalBody(mockPullRequestOpenedContext);
|
||||
expect(body).toBe(
|
||||
"## Summary\n\nThis PR adds JWT-based authentication to the API.\n\n## Changes\n\n- Added auth middleware\n- Added login endpoint\n- Added JWT token generation\n\n/claude please review the security aspects",
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract body from issues event", () => {
|
||||
const body = extractOriginalBody(mockIssueOpenedContext);
|
||||
expect(body).toBe(
|
||||
"## Description\n\nThe application crashes immediately after launching.\n\n## Steps to reproduce\n\n1. Install the app\n2. Launch it\n3. See crash\n\n/claude please help me fix this",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined for event without body", () => {
|
||||
const context = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
payload: {
|
||||
comment: {
|
||||
id: 123,
|
||||
body: "test",
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
const body = extractOriginalBody(context);
|
||||
expect(body).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterCommentsToTriggerTime", () => {
|
||||
const createMockComment = (
|
||||
createdAt: string,
|
||||
@@ -1099,4 +1149,282 @@ describe("fetchGitHubData integration with time filtering", () => {
|
||||
"Original Title (from webhook at trigger time)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use originalBody when provided instead of fetched body", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 123,
|
||||
title: "Test PR",
|
||||
body: "Malicious body injected after trigger",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
state: "OPEN",
|
||||
commits: { totalCount: 1, nodes: [] },
|
||||
files: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "123",
|
||||
isPR: true,
|
||||
triggerUsername: "trigger-user",
|
||||
originalBody: "Original safe body from webhook",
|
||||
});
|
||||
|
||||
expect(result.contextData.body).toBe("Original safe body from webhook");
|
||||
});
|
||||
|
||||
it("should use fetched body when originalBody is not provided", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 123,
|
||||
title: "Test PR",
|
||||
body: "Fetched body from GraphQL",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
state: "OPEN",
|
||||
commits: { totalCount: 1, nodes: [] },
|
||||
files: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "123",
|
||||
isPR: true,
|
||||
triggerUsername: "trigger-user",
|
||||
});
|
||||
|
||||
expect(result.contextData.body).toBe("Fetched body from GraphQL");
|
||||
});
|
||||
|
||||
it("should use original body from webhook even if body was edited after trigger (TOCTOU prevention)", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
pullRequest: {
|
||||
number: 123,
|
||||
title: "Test PR",
|
||||
body: "Malicious body (edited after trigger via GraphQL)",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||
additions: 10,
|
||||
deletions: 5,
|
||||
state: "OPEN",
|
||||
commits: { totalCount: 1, nodes: [] },
|
||||
files: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "123",
|
||||
isPR: true,
|
||||
triggerUsername: "trigger-user",
|
||||
triggerTime: "2024-01-15T12:00:00Z",
|
||||
originalBody: "Original safe body (from webhook at trigger time)",
|
||||
});
|
||||
|
||||
// Body should be from webhook, not the malicious GraphQL-fetched version
|
||||
expect(result.contextData.body).toBe(
|
||||
"Original safe body (from webhook at trigger time)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle null originalBody by setting body to empty string", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
issue: {
|
||||
number: 123,
|
||||
title: "Test Issue",
|
||||
body: "Some body from GraphQL",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
state: "OPEN",
|
||||
labels: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "123",
|
||||
isPR: false,
|
||||
triggerUsername: "trigger-user",
|
||||
originalBody: null,
|
||||
});
|
||||
|
||||
// null originalBody means the issue had no body at trigger time
|
||||
expect(result.contextData.body).toBe("");
|
||||
});
|
||||
|
||||
it("should use null originalBody over malicious GraphQL body edited after trigger", async () => {
|
||||
const mockOctokits = {
|
||||
graphql: jest.fn().mockResolvedValue({
|
||||
repository: {
|
||||
issue: {
|
||||
number: 123,
|
||||
title: "Test Issue",
|
||||
body: "Malicious body added after trigger",
|
||||
author: { login: "author" },
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
|
||||
state: "OPEN",
|
||||
labels: { nodes: [] },
|
||||
comments: { nodes: [] },
|
||||
},
|
||||
},
|
||||
user: { login: "trigger-user" },
|
||||
}),
|
||||
rest: jest.fn() as any,
|
||||
};
|
||||
|
||||
const result = await fetchGitHubData({
|
||||
octokits: mockOctokits as any,
|
||||
repository: "test-owner/test-repo",
|
||||
prNumber: "123",
|
||||
isPR: false,
|
||||
triggerUsername: "trigger-user",
|
||||
triggerTime: "2024-01-15T12:00:00Z",
|
||||
originalBody: null,
|
||||
});
|
||||
|
||||
// Webhook says no body at trigger time — attacker-added GraphQL body must not be used
|
||||
expect(result.contextData.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterCommentsByActor", () => {
|
||||
test("filters out excluded actors", () => {
|
||||
const comments = [
|
||||
{ author: { login: "user1" }, body: "comment1" },
|
||||
{ author: { login: "bot[bot]" }, body: "comment2" },
|
||||
{ author: { login: "user2" }, body: "comment3" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "", "*[bot]");
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((c: any) => c.author.login)).toEqual([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("includes only specified actors", () => {
|
||||
const comments = [
|
||||
{ author: { login: "user1" }, body: "comment1" },
|
||||
{ author: { login: "user2" }, body: "comment2" },
|
||||
{ author: { login: "user3" }, body: "comment3" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "user1,user2", "");
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((c: any) => c.author.login)).toEqual([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns all when no filters", () => {
|
||||
const comments = [
|
||||
{ author: { login: "user1" }, body: "comment1" },
|
||||
{ author: { login: "user2" }, body: "comment2" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "", "");
|
||||
expect(filtered).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("exclusion takes priority", () => {
|
||||
const comments = [
|
||||
{ author: { login: "user1" }, body: "comment1" },
|
||||
{ author: { login: "user2" }, body: "comment2" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "user1,user2", "user1");
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0].author.login).toBe("user2");
|
||||
});
|
||||
|
||||
test("filters multiple bot types", () => {
|
||||
const comments = [
|
||||
{ author: { login: "user1" }, body: "comment1" },
|
||||
{ author: { login: "dependabot[bot]" }, body: "comment2" },
|
||||
{ author: { login: "renovate[bot]" }, body: "comment3" },
|
||||
{ author: { login: "user2" }, body: "comment4" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "", "*[bot]");
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((c: any) => c.author.login)).toEqual([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
});
|
||||
|
||||
test("filters specific bot only", () => {
|
||||
const comments = [
|
||||
{ author: { login: "dependabot[bot]" }, body: "comment1" },
|
||||
{ author: { login: "renovate[bot]" }, body: "comment2" },
|
||||
{ author: { login: "user1" }, body: "comment3" },
|
||||
];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "", "dependabot[bot]");
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((c: any) => c.author.login)).toEqual([
|
||||
"renovate[bot]",
|
||||
"user1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles empty comment array", () => {
|
||||
const comments: any[] = [];
|
||||
|
||||
const { filterCommentsByActor } = require("../src/github/data/fetcher");
|
||||
const filtered = filterCommentsByActor(comments, "user1", "");
|
||||
expect(filtered).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ describe("prepareMcpConfig", () => {
|
||||
let consoleWarningSpy: any;
|
||||
let setFailedSpy: any;
|
||||
let processExitSpy: any;
|
||||
let fetchSpy: any;
|
||||
|
||||
// Create a mock context for tests
|
||||
const mockContext: ParsedGitHubContext = {
|
||||
@@ -39,6 +40,8 @@ describe("prepareMcpConfig", () => {
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
includeFixLinks: true,
|
||||
includeCommentsByActor: "",
|
||||
excludeCommentsByActor: "",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,6 +67,10 @@ describe("prepareMcpConfig", () => {
|
||||
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("Process exit");
|
||||
});
|
||||
// Mock fetch so checkActionsReadPermission succeeds (returns 200 for actions API)
|
||||
fetchSpy = spyOn(global, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ workflow_runs: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
// Set up required environment variables
|
||||
if (!process.env.GITHUB_ACTION_PATH) {
|
||||
@@ -76,6 +83,7 @@ describe("prepareMcpConfig", () => {
|
||||
consoleWarningSpy.mockRestore();
|
||||
setFailedSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should return comment server when commit signing is disabled", async () => {
|
||||
@@ -261,6 +269,33 @@ describe("prepareMcpConfig", () => {
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
});
|
||||
|
||||
test("should not include github_ci server when actions:read permission is missing", async () => {
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
|
||||
// Simulate 403 from actions API
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ message: "Resource not accessible by integration" }),
|
||||
{ status: 403 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
mode: "tag",
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
|
||||
delete process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
});
|
||||
|
||||
test("should not include github_ci server when DEFAULT_WORKFLOW_TOKEN is missing", async () => {
|
||||
delete process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
|
||||
|
||||
+14
-2
@@ -27,6 +27,8 @@ const defaultInputs = {
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
includeFixLinks: true,
|
||||
includeCommentsByActor: "",
|
||||
excludeCommentsByActor: "",
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
@@ -55,7 +57,12 @@ export const createMockContext = (
|
||||
};
|
||||
|
||||
const mergedInputs = overrides.inputs
|
||||
? { ...defaultInputs, ...overrides.inputs }
|
||||
? {
|
||||
...defaultInputs,
|
||||
...overrides.inputs,
|
||||
includeCommentsByActor: overrides.inputs.includeCommentsByActor ?? "",
|
||||
excludeCommentsByActor: overrides.inputs.excludeCommentsByActor ?? "",
|
||||
}
|
||||
: defaultInputs;
|
||||
|
||||
return { ...baseContext, ...overrides, inputs: mergedInputs };
|
||||
@@ -79,7 +86,12 @@ export const createMockAutomationContext = (
|
||||
};
|
||||
|
||||
const mergedInputs = overrides.inputs
|
||||
? { ...defaultInputs, ...overrides.inputs }
|
||||
? {
|
||||
...defaultInputs,
|
||||
...overrides.inputs,
|
||||
includeCommentsByActor: overrides.inputs.includeCommentsByActor ?? "",
|
||||
excludeCommentsByActor: overrides.inputs.excludeCommentsByActor ?? "",
|
||||
}
|
||||
: { ...defaultInputs };
|
||||
|
||||
return { ...baseContext, ...overrides, inputs: mergedInputs };
|
||||
|
||||
+16
-97
@@ -7,22 +7,17 @@ import {
|
||||
spyOn,
|
||||
mock,
|
||||
} from "bun:test";
|
||||
import { agentMode } from "../../src/modes/agent";
|
||||
import type { GitHubContext } from "../../src/github/context";
|
||||
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||
import { prepareAgentMode } from "../../src/modes/agent";
|
||||
import { createMockAutomationContext } from "../mockContext";
|
||||
import * as core from "@actions/core";
|
||||
import * as gitConfig from "../../src/github/operations/git-config";
|
||||
|
||||
describe("Agent Mode", () => {
|
||||
let mockContext: GitHubContext;
|
||||
let exportVariableSpy: any;
|
||||
let setOutputSpy: any;
|
||||
let configureGitAuthSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
|
||||
() => {},
|
||||
);
|
||||
@@ -45,84 +40,11 @@ describe("Agent Mode", () => {
|
||||
configureGitAuthSpy?.mockRestore();
|
||||
});
|
||||
|
||||
test("agent mode has correct properties", () => {
|
||||
expect(agentMode.name).toBe("agent");
|
||||
expect(agentMode.description).toBe(
|
||||
"Direct automation mode for explicit prompts",
|
||||
);
|
||||
expect(agentMode.shouldCreateTrackingComment()).toBe(false);
|
||||
expect(agentMode.getAllowedTools()).toEqual([]);
|
||||
expect(agentMode.getDisallowedTools()).toEqual([]);
|
||||
test("prepareAgentMode is exported as a function", () => {
|
||||
expect(typeof prepareAgentMode).toBe("function");
|
||||
});
|
||||
|
||||
test("prepareContext returns minimal data", () => {
|
||||
const context = agentMode.prepareContext(mockContext);
|
||||
|
||||
expect(context.mode).toBe("agent");
|
||||
expect(context.githubContext).toBe(mockContext);
|
||||
// Agent mode doesn't use comment tracking or branch management
|
||||
expect(Object.keys(context)).toEqual(["mode", "githubContext"]);
|
||||
});
|
||||
|
||||
test("agent mode only triggers when prompt is provided", () => {
|
||||
// Should NOT trigger for automation events without prompt
|
||||
const workflowDispatchContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
expect(agentMode.shouldTrigger(workflowDispatchContext)).toBe(false);
|
||||
|
||||
const scheduleContext = createMockAutomationContext({
|
||||
eventName: "schedule",
|
||||
});
|
||||
expect(agentMode.shouldTrigger(scheduleContext)).toBe(false);
|
||||
|
||||
const repositoryDispatchContext = createMockAutomationContext({
|
||||
eventName: "repository_dispatch",
|
||||
});
|
||||
expect(agentMode.shouldTrigger(repositoryDispatchContext)).toBe(false);
|
||||
|
||||
// Should NOT trigger for entity events without prompt
|
||||
const entityEvents = [
|
||||
"issue_comment",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"issues",
|
||||
] as const;
|
||||
|
||||
entityEvents.forEach((eventName) => {
|
||||
const contextNoPrompt = createMockContext({ eventName });
|
||||
expect(agentMode.shouldTrigger(contextNoPrompt)).toBe(false);
|
||||
});
|
||||
|
||||
// Should trigger for ANY event when prompt is provided
|
||||
const allEvents = [
|
||||
"workflow_dispatch",
|
||||
"repository_dispatch",
|
||||
"schedule",
|
||||
"issue_comment",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"issues",
|
||||
] as const;
|
||||
|
||||
allEvents.forEach((eventName) => {
|
||||
const contextWithPrompt =
|
||||
eventName === "workflow_dispatch" ||
|
||||
eventName === "repository_dispatch" ||
|
||||
eventName === "schedule"
|
||||
? createMockAutomationContext({
|
||||
eventName,
|
||||
inputs: { prompt: "Do something" },
|
||||
})
|
||||
: createMockContext({
|
||||
eventName,
|
||||
inputs: { prompt: "Do something" },
|
||||
});
|
||||
expect(agentMode.shouldTrigger(contextWithPrompt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("prepare method passes through claude_args", async () => {
|
||||
test("prepare passes through claude_args", async () => {
|
||||
// Clear any previous calls before this test
|
||||
exportVariableSpy.mockClear();
|
||||
setOutputSpy.mockClear();
|
||||
@@ -156,17 +78,15 @@ describe("Agent Mode", () => {
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const result = await agentMode.prepare({
|
||||
const result = await prepareAgentMode({
|
||||
context: contextWithCustomArgs,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
});
|
||||
|
||||
// Verify claude_args includes user args (no MCP config in agent mode without allowed tools)
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("claude_args");
|
||||
expect(callArgs[1]).toBe("--model claude-sonnet-4 --max-turns 10");
|
||||
expect(callArgs[1]).not.toContain("--mcp-config");
|
||||
expect(result.claudeArgs).toBe("--model claude-sonnet-4 --max-turns 10");
|
||||
expect(result.claudeArgs).not.toContain("--mcp-config");
|
||||
|
||||
// Verify return structure - should use "main" as fallback when no env vars set
|
||||
expect(result).toEqual({
|
||||
@@ -177,6 +97,7 @@ describe("Agent Mode", () => {
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: expect.any(String),
|
||||
claudeArgs: "--model claude-sonnet-4 --max-turns 10",
|
||||
});
|
||||
|
||||
// Clean up
|
||||
@@ -187,7 +108,7 @@ describe("Agent Mode", () => {
|
||||
process.env.GITHUB_REF_NAME = originalRefName;
|
||||
});
|
||||
|
||||
test("prepare method rejects bot actors without allowed_bots", async () => {
|
||||
test("prepare rejects bot actors without allowed_bots", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
@@ -207,7 +128,7 @@ describe("Agent Mode", () => {
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
agentMode.prepare({
|
||||
prepareAgentMode({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
@@ -217,7 +138,7 @@ describe("Agent Mode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("prepare method allows bot actors when in allowed_bots list", async () => {
|
||||
test("prepare allows bot actors when in allowed_bots list", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
@@ -238,7 +159,7 @@ describe("Agent Mode", () => {
|
||||
|
||||
// Should not throw - bot is in allowed list
|
||||
await expect(
|
||||
agentMode.prepare({
|
||||
prepareAgentMode({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
@@ -246,7 +167,7 @@ describe("Agent Mode", () => {
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
test("prepare creates prompt file with correct content", async () => {
|
||||
const contextWithPrompts = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
@@ -269,7 +190,7 @@ describe("Agent Mode", () => {
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
await agentMode.prepare({
|
||||
const result = await prepareAgentMode({
|
||||
context: contextWithPrompts,
|
||||
octokit: mockOctokit,
|
||||
githubToken: "test-token",
|
||||
@@ -279,9 +200,7 @@ describe("Agent Mode", () => {
|
||||
// but we can verify the method completes without errors
|
||||
// With our conditional MCP logic, agent mode with no allowed tools
|
||||
// should not include any MCP config
|
||||
const callArgs = setOutputSpy.mock.calls[0];
|
||||
expect(callArgs[0]).toBe("claude_args");
|
||||
// Should be empty or just whitespace when no MCP servers are included
|
||||
expect(callArgs[1]).not.toContain("--mcp-config");
|
||||
expect(result.claudeArgs).not.toContain("--mcp-config");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ describe("detectMode with enhanced routing", () => {
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
includeFixLinks: true,
|
||||
includeCommentsByActor: "",
|
||||
excludeCommentsByActor: "",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getMode, isValidMode } from "../../src/modes/registry";
|
||||
import { agentMode } from "../../src/modes/agent";
|
||||
import { tagMode } from "../../src/modes/tag";
|
||||
import {
|
||||
createMockContext,
|
||||
createMockAutomationContext,
|
||||
mockRepositoryDispatchContext,
|
||||
} from "../mockContext";
|
||||
|
||||
describe("Mode Registry", () => {
|
||||
const mockContext = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
payload: {
|
||||
action: "created",
|
||||
comment: {
|
||||
body: "Test comment without trigger",
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const mockWorkflowDispatchContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
|
||||
const mockScheduleContext = createMockAutomationContext({
|
||||
eventName: "schedule",
|
||||
});
|
||||
|
||||
test("getMode auto-detects agent mode for issue_comment without trigger", () => {
|
||||
const mode = getMode(mockContext);
|
||||
// Agent mode is the default when no trigger is found
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode auto-detects agent mode for workflow_dispatch", () => {
|
||||
const mode = getMode(mockWorkflowDispatchContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
// Removed test - explicit mode override no longer supported in v1.0
|
||||
|
||||
test("getMode auto-detects agent for workflow_dispatch", () => {
|
||||
const mode = getMode(mockWorkflowDispatchContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode auto-detects agent for schedule event", () => {
|
||||
const mode = getMode(mockScheduleContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode auto-detects agent for repository_dispatch event", () => {
|
||||
const mode = getMode(mockRepositoryDispatchContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode auto-detects agent for repository_dispatch with client_payload", () => {
|
||||
const contextWithPayload = createMockAutomationContext({
|
||||
eventName: "repository_dispatch",
|
||||
payload: {
|
||||
action: "trigger-analysis",
|
||||
client_payload: {
|
||||
source: "external-system",
|
||||
metadata: { priority: "high" },
|
||||
},
|
||||
repository: {
|
||||
name: "test-repo",
|
||||
owner: { login: "test-owner" },
|
||||
},
|
||||
sender: { login: "automation-user" },
|
||||
},
|
||||
});
|
||||
|
||||
const mode = getMode(contextWithPayload);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
// Removed test - legacy mode names no longer supported in v1.0
|
||||
|
||||
test("getMode auto-detects agent mode for PR opened", () => {
|
||||
const prContext = createMockContext({
|
||||
eventName: "pull_request",
|
||||
payload: { action: "opened" } as any,
|
||||
isPR: true,
|
||||
});
|
||||
const mode = getMode(prContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode uses agent mode when prompt is provided, even with @claude mention", () => {
|
||||
const contextWithPrompt = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
payload: {
|
||||
action: "created",
|
||||
comment: {
|
||||
body: "@claude please help",
|
||||
},
|
||||
} as any,
|
||||
inputs: {
|
||||
prompt: "/review",
|
||||
} as any,
|
||||
});
|
||||
const mode = getMode(contextWithPrompt);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode uses tag mode for @claude mention without prompt", () => {
|
||||
// Ensure PROMPT env var is not set (clean up from previous tests)
|
||||
const originalPrompt = process.env.PROMPT;
|
||||
delete process.env.PROMPT;
|
||||
|
||||
const contextWithMention = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
payload: {
|
||||
action: "created",
|
||||
comment: {
|
||||
body: "@claude please help",
|
||||
},
|
||||
} as any,
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
prompt: "",
|
||||
} as any,
|
||||
});
|
||||
const mode = getMode(contextWithMention);
|
||||
expect(mode).toBe(tagMode);
|
||||
expect(mode.name).toBe("tag");
|
||||
|
||||
// Restore original value if it existed
|
||||
if (originalPrompt !== undefined) {
|
||||
process.env.PROMPT = originalPrompt;
|
||||
}
|
||||
});
|
||||
|
||||
// Removed test - explicit mode override no longer supported in v1.0
|
||||
|
||||
test("isValidMode returns true for all valid modes", () => {
|
||||
expect(isValidMode("tag")).toBe(true);
|
||||
expect(isValidMode("agent")).toBe(true);
|
||||
});
|
||||
|
||||
test("isValidMode returns false for invalid mode", () => {
|
||||
expect(isValidMode("invalid")).toBe(false);
|
||||
expect(isValidMode("review")).toBe(false);
|
||||
});
|
||||
});
|
||||
+4
-88
@@ -1,92 +1,8 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { tagMode } from "../../src/modes/tag";
|
||||
import type { ParsedGitHubContext } from "../../src/github/context";
|
||||
import type { IssueCommentEvent } from "@octokit/webhooks-types";
|
||||
import { createMockContext } from "../mockContext";
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { prepareTagMode } from "../../src/modes/tag";
|
||||
|
||||
describe("Tag Mode", () => {
|
||||
let mockContext: ParsedGitHubContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
isPR: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("tag mode has correct properties", () => {
|
||||
expect(tagMode.name).toBe("tag");
|
||||
expect(tagMode.description).toBe(
|
||||
"Traditional implementation mode triggered by @claude mentions",
|
||||
);
|
||||
expect(tagMode.shouldCreateTrackingComment()).toBe(true);
|
||||
});
|
||||
|
||||
test("shouldTrigger delegates to checkContainsTrigger", () => {
|
||||
const contextWithTrigger = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
isPR: false,
|
||||
inputs: {
|
||||
...createMockContext().inputs,
|
||||
triggerPhrase: "@claude",
|
||||
},
|
||||
payload: {
|
||||
comment: {
|
||||
body: "Hey @claude, can you help?",
|
||||
},
|
||||
} as IssueCommentEvent,
|
||||
});
|
||||
|
||||
expect(tagMode.shouldTrigger(contextWithTrigger)).toBe(true);
|
||||
|
||||
const contextWithoutTrigger = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
isPR: false,
|
||||
inputs: {
|
||||
...createMockContext().inputs,
|
||||
triggerPhrase: "@claude",
|
||||
},
|
||||
payload: {
|
||||
comment: {
|
||||
body: "This is just a regular comment",
|
||||
},
|
||||
} as IssueCommentEvent,
|
||||
});
|
||||
|
||||
expect(tagMode.shouldTrigger(contextWithoutTrigger)).toBe(false);
|
||||
});
|
||||
|
||||
test("prepareContext includes all required data", () => {
|
||||
const data = {
|
||||
commentId: 123,
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/fix-bug",
|
||||
};
|
||||
|
||||
const context = tagMode.prepareContext(mockContext, data);
|
||||
|
||||
expect(context.mode).toBe("tag");
|
||||
expect(context.githubContext).toBe(mockContext);
|
||||
expect(context.commentId).toBe(123);
|
||||
expect(context.baseBranch).toBe("main");
|
||||
expect(context.claudeBranch).toBe("claude/fix-bug");
|
||||
});
|
||||
|
||||
test("prepareContext works without data", () => {
|
||||
const context = tagMode.prepareContext(mockContext);
|
||||
|
||||
expect(context.mode).toBe("tag");
|
||||
expect(context.githubContext).toBe(mockContext);
|
||||
expect(context.commentId).toBeUndefined();
|
||||
expect(context.baseBranch).toBeUndefined();
|
||||
expect(context.claudeBranch).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getAllowedTools returns empty array", () => {
|
||||
expect(tagMode.getAllowedTools()).toEqual([]);
|
||||
});
|
||||
|
||||
test("getDisallowedTools returns empty array", () => {
|
||||
expect(tagMode.getDisallowedTools()).toEqual([]);
|
||||
test("prepareTagMode is exported as a function", () => {
|
||||
expect(typeof prepareTagMode).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { parseAdditionalPermissions } from "../src/github/token";
|
||||
|
||||
describe("parseAdditionalPermissions", () => {
|
||||
let originalEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.ADDITIONAL_PERMISSIONS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ADDITIONAL_PERMISSIONS;
|
||||
} else {
|
||||
process.env.ADDITIONAL_PERMISSIONS = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns undefined when env var is not set", () => {
|
||||
delete process.env.ADDITIONAL_PERMISSIONS;
|
||||
expect(parseAdditionalPermissions()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when env var is empty string", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "";
|
||||
expect(parseAdditionalPermissions()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when env var is only whitespace", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = " \n \n ";
|
||||
expect(parseAdditionalPermissions()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses single permission and merges with defaults", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "actions: read";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses multiple permissions", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "actions: read\nworkflows: write";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
workflows: "write",
|
||||
});
|
||||
});
|
||||
|
||||
test("additional permissions can override defaults", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = "contents: read";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "read",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles extra whitespace around keys and values", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS = " actions : read ";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips empty lines", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS =
|
||||
"actions: read\n\n\nworkflows: write\n\n";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
workflows: "write",
|
||||
});
|
||||
});
|
||||
|
||||
test("skips lines without colons", () => {
|
||||
process.env.ADDITIONAL_PERMISSIONS =
|
||||
"actions: read\ninvalid line\nworkflows: write";
|
||||
expect(parseAdditionalPermissions()).toEqual({
|
||||
contents: "write",
|
||||
pull_requests: "write",
|
||||
issues: "write",
|
||||
actions: "read",
|
||||
workflows: "write",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,8 @@ describe("checkWritePermissions", () => {
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
includeFixLinks: true,
|
||||
includeCommentsByActor: "",
|
||||
excludeCommentsByActor: "",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,37 +1,10 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getEventTypeAndContext,
|
||||
generatePrompt,
|
||||
generateDefaultPrompt,
|
||||
} from "../src/create-prompt";
|
||||
import { getEventTypeAndContext, generatePrompt } from "../src/create-prompt";
|
||||
import type { PreparedContext } from "../src/create-prompt";
|
||||
import type { Mode } from "../src/modes/types";
|
||||
|
||||
describe("pull_request_target event support", () => {
|
||||
// Mock tag mode for testing
|
||||
const mockTagMode: Mode = {
|
||||
name: "tag",
|
||||
description: "Tag mode",
|
||||
shouldTrigger: () => true,
|
||||
prepareContext: (context) => ({ mode: "tag", githubContext: context }),
|
||||
getAllowedTools: () => [],
|
||||
getDisallowedTools: () => [],
|
||||
shouldCreateTrackingComment: () => true,
|
||||
generatePrompt: (context, githubData, useCommitSigning) =>
|
||||
generateDefaultPrompt(context, githubData, useCommitSigning),
|
||||
prepare: async () => ({
|
||||
commentId: 123,
|
||||
branchInfo: {
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: "{}",
|
||||
}),
|
||||
};
|
||||
|
||||
const mockGitHubData = {
|
||||
contextData: {
|
||||
title: "External PR via pull_request_target",
|
||||
@@ -125,12 +98,7 @@ describe("pull_request_target event support", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should contain pull request event type and metadata
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
@@ -164,12 +132,7 @@ describe("pull_request_target event support", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should include git commands for non-commit-signing mode
|
||||
expect(prompt).toContain("git push");
|
||||
@@ -195,7 +158,7 @@ describe("pull_request_target event support", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, true, mockTagMode);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, true, "tag");
|
||||
|
||||
// Should include commit signing tools
|
||||
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||
@@ -245,13 +208,13 @@ describe("pull_request_target event support", () => {
|
||||
pullRequestContext,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
"tag",
|
||||
);
|
||||
const pullRequestTargetPrompt = generatePrompt(
|
||||
pullRequestTargetContext,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
"tag",
|
||||
);
|
||||
|
||||
// Both should have the same event type and structure
|
||||
@@ -292,36 +255,7 @@ describe("pull_request_target event support", () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Use agent mode which passes through the prompt as-is
|
||||
const mockAgentMode: Mode = {
|
||||
name: "agent",
|
||||
description: "Agent mode",
|
||||
shouldTrigger: () => true,
|
||||
prepareContext: (context) => ({
|
||||
mode: "agent",
|
||||
githubContext: context,
|
||||
}),
|
||||
getAllowedTools: () => [],
|
||||
getDisallowedTools: () => [],
|
||||
shouldCreateTrackingComment: () => true,
|
||||
generatePrompt: (context) => context.prompt || "default prompt",
|
||||
prepare: async () => ({
|
||||
commentId: 123,
|
||||
branchInfo: {
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: "{}",
|
||||
}),
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockAgentMode,
|
||||
);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false, "agent");
|
||||
|
||||
expect(prompt).toBe(
|
||||
"Review this pull_request_target PR for security issues",
|
||||
@@ -341,12 +275,7 @@ describe("pull_request_target event support", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, false, "tag");
|
||||
|
||||
// Should generate default prompt structure
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
@@ -416,7 +345,7 @@ describe("pull_request_target event support", () => {
|
||||
|
||||
// Should not throw when generating prompt
|
||||
expect(() => {
|
||||
generatePrompt(minimalContext, mockGitHubData, false, mockTagMode);
|
||||
generatePrompt(minimalContext, mockGitHubData, false, "tag");
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -474,13 +403,13 @@ describe("pull_request_target event support", () => {
|
||||
internalPR,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
"tag",
|
||||
);
|
||||
const externalPrompt = generatePrompt(
|
||||
externalPR,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
"tag",
|
||||
);
|
||||
|
||||
// Should have same tool access patterns
|
||||
|
||||
@@ -55,6 +55,47 @@ describe("SSH Signing", () => {
|
||||
expect(permissions).toBe(0o600);
|
||||
});
|
||||
|
||||
test("should normalize key to have trailing newline", async () => {
|
||||
// ssh-keygen requires a trailing newline to parse the key
|
||||
const keyWithoutNewline =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----";
|
||||
const keyWithNewline = keyWithoutNewline + "\n";
|
||||
|
||||
// Create directory
|
||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Normalize the key (same logic as setupSshSigning)
|
||||
const normalizedKey = keyWithoutNewline.endsWith("\n")
|
||||
? keyWithoutNewline
|
||||
: keyWithoutNewline + "\n";
|
||||
|
||||
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
// Verify the written key ends with newline
|
||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||
expect(keyContent).toBe(keyWithNewline);
|
||||
expect(keyContent.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
test("should not add extra newline if key already has one", async () => {
|
||||
const keyWithNewline =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-content\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||
|
||||
await mkdir(testSshDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Normalize the key (same logic as setupSshSigning)
|
||||
const normalizedKey = keyWithNewline.endsWith("\n")
|
||||
? keyWithNewline
|
||||
: keyWithNewline + "\n";
|
||||
|
||||
await writeFile(testKeyPath, normalizedKey, { mode: 0o600 });
|
||||
|
||||
// Verify no double newline
|
||||
const keyContent = await readFile(testKeyPath, "utf-8");
|
||||
expect(keyContent).toBe(keyWithNewline);
|
||||
expect(keyContent.endsWith("\n\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("should create .ssh directory with secure permissions", async () => {
|
||||
// Clean up first
|
||||
await rm(testSshDir, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user