mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
45 Commits
test-ci-fa
...
test-auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a630fad41e | ||
|
|
130874e0b6 | ||
|
|
c72a45a95f | ||
|
|
bf04905b4c | ||
|
|
52736c6e60 | ||
|
|
91034c2a0e | ||
|
|
8d32355bcc | ||
|
|
4ec65ed46e | ||
|
|
9613b21ad7 | ||
|
|
68b7ca379c | ||
|
|
900322ca88 | ||
|
|
8f0a7fe9d3 | ||
|
|
db36412854 | ||
|
|
f05d669d5f | ||
|
|
e89411bb6f | ||
|
|
02e9ed3181 | ||
|
|
78b07473f5 | ||
|
|
f562ed53e2 | ||
|
|
a1507aefdc | ||
|
|
ae66eb6a64 | ||
|
|
432c7cc889 | ||
|
|
0b138d9d49 | ||
|
|
30530c9829 | ||
|
|
632f04bbcf | ||
|
|
c34e066a3b | ||
|
|
449c6791bd | ||
|
|
2b67ac084b | ||
|
|
76de8a48fc | ||
|
|
d91030de69 | ||
|
|
ab7f1d65d6 | ||
|
|
c13c2af69f | ||
|
|
0ac14b0d37 | ||
|
|
8c230f7e04 | ||
|
|
d24561da51 | ||
|
|
e3b5697276 | ||
|
|
eb146ef8b8 | ||
|
|
73948c338f | ||
|
|
30fb4ed5a6 | ||
|
|
24433f34e3 | ||
|
|
86e2835d40 | ||
|
|
a80505bbfb | ||
|
|
55e94369f7 | ||
|
|
af23644a50 | ||
|
|
98e6a902bf | ||
|
|
8b2bd6d04f |
49
.github/workflows/claude-auto-review-test.yml
vendored
49
.github/workflows/claude-auto-review-test.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: Claude PR Review (Fixed)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "PR number to review"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
auto-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Automatic PR Review
|
||||
uses: anthropics/claude-code-action@v1-dev
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
prompt: |
|
||||
Please review this pull request and provide comprehensive feedback.
|
||||
|
||||
Focus on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security implications
|
||||
- Test coverage
|
||||
- Documentation updates if needed
|
||||
|
||||
Provide constructive feedback with specific suggestions for improvement.
|
||||
Use inline comments to highlight specific areas of concern.
|
||||
|
||||
After your review, submit a proper GitHub review with your findings.
|
||||
# Only include essential tools for reviewing (no submitting)
|
||||
# This prevents multiple review submissions
|
||||
claude_args: "--allowedTools Bash,Read,Grep,mcp__github-comment-server__get_pull_request_diff,mcp__github-comment-server__get_pull_request_files,mcp__github-comment-server__get_pull_request"
|
||||
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -104,3 +104,5 @@ jobs:
|
||||
mcp_config: /tmp/mcp-config/mcp-servers.json
|
||||
timeout_minutes: "5"
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
175
AUTO_FIX_CHECKPOINT.md
Normal file
175
AUTO_FIX_CHECKPOINT.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# Auto-Fix CI Workflow Implementation Checkpoint
|
||||
|
||||
## Overview
|
||||
This document captures the learnings from implementing auto-fix CI workflows that allow Claude to automatically fix CI failures and post as claude[bot].
|
||||
|
||||
## Journey Summary
|
||||
|
||||
### Initial Goal
|
||||
Create an auto-fix CI workflow similar to Cursor's implementation that:
|
||||
1. Detects CI failures on PRs
|
||||
2. Automatically triggers Claude to fix the issues
|
||||
3. Creates branches with fixes
|
||||
4. Posts PR comments as claude[bot] (not github-actions[bot])
|
||||
|
||||
### Key Implementation Files
|
||||
|
||||
#### 1. Auto-Fix Workflow
|
||||
**File**: `.github/workflows/auto-fix-ci-inline.yml`
|
||||
- Triggers on `workflow_run` event when CI fails
|
||||
- Creates fix branch
|
||||
- Collects failure logs
|
||||
- Calls Claude Code Action with `/fix-ci` slash command
|
||||
- Posts PR comment with fix branch link
|
||||
|
||||
#### 2. Fix-CI Slash Command
|
||||
**File**: `.claude/commands/fix-ci.md`
|
||||
- Contains all instructions for analyzing and fixing CI failures
|
||||
- Handles test failures, type errors, linting issues
|
||||
- Commits and pushes fixes
|
||||
|
||||
#### 3. Claude Code Action Changes (v1-dev branch)
|
||||
**Modified Files**:
|
||||
- `src/entrypoints/prepare.ts` - Exposes GitHub token as output
|
||||
- `action.yml` - Adds github_token output definition
|
||||
|
||||
## Critical Discoveries
|
||||
|
||||
### 1. Authentication Architecture
|
||||
|
||||
#### How Tag Mode Works (Success Case)
|
||||
1. User comments "@claude" on PR → `issue_comment` event
|
||||
2. Action requests OIDC token with audience "claude-code-github-action"
|
||||
3. Token exchange at `api.anthropic.com/api/github/github-app-token-exchange`
|
||||
4. Backend validates event type is in allowed list
|
||||
5. Returns Claude App token → posts as claude[bot]
|
||||
|
||||
#### Why Workflow_Run Failed
|
||||
1. Auto-fix workflow triggers on `workflow_run` event
|
||||
2. OIDC token has `event_name: "workflow_run"` claim
|
||||
3. Backend's `allowed_events` list didn't include "workflow_run"
|
||||
4. Token exchange fails with "401 Unauthorized - Invalid OIDC token"
|
||||
5. Can't get Claude App token → falls back to github-actions[bot]
|
||||
|
||||
### 2. OIDC Token Claims
|
||||
GitHub Actions OIDC tokens include:
|
||||
- `event_name`: The triggering event (pull_request, issue_comment, workflow_run, etc.)
|
||||
- `repository`: The repo where action runs
|
||||
- `actor`: Who triggered the action
|
||||
- `job_workflow_ref`: Reference to the workflow file
|
||||
- And many other claims for verification
|
||||
|
||||
### 3. Backend Validation
|
||||
**File**: `anthropic/api/api/private_api/routes/github/github_app_token_exchange.py`
|
||||
|
||||
The backend validates:
|
||||
```python
|
||||
allowed_events = [
|
||||
"pull_request",
|
||||
"issue_comment",
|
||||
"pull_request_comment",
|
||||
"issues",
|
||||
"pull_request_review",
|
||||
"pull_request_review_comment",
|
||||
"repository_dispatch",
|
||||
"workflow_dispatch",
|
||||
"schedule",
|
||||
# "workflow_run" was missing!
|
||||
]
|
||||
```
|
||||
|
||||
### 4. Agent Mode vs Tag Mode
|
||||
- **Tag Mode**: Triggers on PR/issue events, creates tracking comments
|
||||
- **Agent Mode**: Triggers on automation events (workflow_dispatch, schedule, and now workflow_run)
|
||||
- Both modes can use Claude App token if event is in allowed list
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### Backend Change (PR Created)
|
||||
Add `"workflow_run"` to the `allowed_events` list in the Claude backend to enable OIDC token exchange for workflow_run events.
|
||||
|
||||
### Why This Works
|
||||
- No special handling needed for different event types
|
||||
- Backend treats all allowed events the same way
|
||||
- Just validates token, checks permissions, returns Claude App token
|
||||
- Event name only used for validation and logging/metrics
|
||||
|
||||
## Current Status
|
||||
|
||||
### Completed
|
||||
- ✅ Created auto-fix workflow and slash command
|
||||
- ✅ Modified Claude Code Action to expose GitHub token as output
|
||||
- ✅ Identified root cause of authentication failure
|
||||
- ✅ Created PR to add workflow_run to backend allowed events
|
||||
|
||||
### Waiting On
|
||||
- ⏳ Backend PR approval and deployment
|
||||
- ⏳ Testing with updated backend
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once the backend PR is merged and deployed:
|
||||
|
||||
### 1. Test Auto-Fix Workflow
|
||||
- Create a test PR with intentional CI failures
|
||||
- Verify auto-fix workflow triggers
|
||||
- Confirm Claude can authenticate via OIDC
|
||||
- Verify comments come from claude[bot]
|
||||
|
||||
### 2. Potential Improvements
|
||||
- Add more sophisticated CI failure detection
|
||||
- Handle different types of failures (tests, linting, types, build)
|
||||
- Add progress indicators in PR comments
|
||||
- Consider batching multiple fixes
|
||||
- Add retry logic for transient failures
|
||||
|
||||
### 3. Documentation
|
||||
- Document the auto-fix workflow setup
|
||||
- Create examples for different CI systems
|
||||
- Add troubleshooting guide
|
||||
|
||||
### 4. Extended Features
|
||||
- Support for multiple CI workflows
|
||||
- Customizable fix strategies per project
|
||||
- Integration with other GitHub Actions events
|
||||
- Support for monorepo structures
|
||||
|
||||
## Alternative Approaches (If Backend Change Blocked)
|
||||
|
||||
### Option 1: Repository Dispatch
|
||||
Instead of `workflow_run`, use `repository_dispatch`:
|
||||
- Original workflow triggers dispatch event on failure
|
||||
- Auto-fix workflow responds to dispatch event
|
||||
- Works today without backend changes
|
||||
|
||||
### Option 2: Direct PR Event
|
||||
Trigger on `pull_request` with conditional logic:
|
||||
- Check CI status in the workflow
|
||||
- Only run if CI failed
|
||||
- Keeps PR context for OIDC exchange
|
||||
|
||||
### Option 3: Custom GitHub App
|
||||
Create separate GitHub App for auto-fix:
|
||||
- Has its own authentication
|
||||
- Posts as custom bot (not claude[bot])
|
||||
- More complex but fully independent
|
||||
|
||||
## Key Learnings
|
||||
|
||||
1. **OIDC Context Matters**: The event context in OIDC tokens determines authentication success
|
||||
2. **Backend Validation is Simple**: Just a list check, no complex event-specific logic
|
||||
3. **Agent Mode is Powerful**: Designed for automation, just needed backend support
|
||||
4. **Token Flow is Critical**: Understanding the full auth flow helped identify the issue
|
||||
5. **Incremental Solutions Work**: Start simple, identify blockers, fix systematically
|
||||
|
||||
## Resources
|
||||
|
||||
- [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
|
||||
- [Claude Code Action Repository](https://github.com/anthropics/claude-code-action)
|
||||
- [Backend PR for workflow_run support](#) (Add link when available)
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2025-08-20*
|
||||
*Session Duration: ~6 hours*
|
||||
*Key Achievement: Identified and resolved Claude App authentication for workflow_run events*
|
||||
10
action.yml
10
action.yml
@@ -93,6 +93,9 @@ outputs:
|
||||
branch_name:
|
||||
description: "The branch created by Claude Code for this execution"
|
||||
value: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
github_token:
|
||||
description: "The GitHub token used by the action (Claude App token if available)"
|
||||
value: ${{ steps.prepare.outputs.github_token }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -130,6 +133,7 @@ runs:
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||
|
||||
- name: Install Base Action Dependencies
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
@@ -141,7 +145,8 @@ runs:
|
||||
echo "Base-action dependencies installed"
|
||||
cd -
|
||||
# Install Claude Code globally
|
||||
bun install -g @anthropic-ai/claude-code@1.0.72
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.84
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup Network Restrictions
|
||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||
@@ -168,6 +173,7 @@ runs:
|
||||
INPUT_TIMEOUT_MINUTES: ${{ inputs.timeout_minutes }}
|
||||
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 }}
|
||||
|
||||
# Model configuration
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
@@ -241,7 +247,7 @@ runs:
|
||||
fi
|
||||
|
||||
- name: Revoke app token
|
||||
if: always() && inputs.github_token == ''
|
||||
if: always() && inputs.github_token == '' && steps.prepare.outputs.skipped_due_to_workflow_validation_mismatch != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
curl -L \
|
||||
|
||||
@@ -85,7 +85,7 @@ runs:
|
||||
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: bun install -g @anthropic-ai/claude-code@1.0.72
|
||||
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.84
|
||||
|
||||
- name: Run Claude Code Action
|
||||
shell: bash
|
||||
|
||||
@@ -56,10 +56,16 @@ export function prepareRunConfig(
|
||||
}
|
||||
}
|
||||
|
||||
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: {},
|
||||
env: customEnv,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,9 +94,11 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
console.log(`Prompt file size: ${promptSize} bytes`);
|
||||
|
||||
// Log custom environment variables if any
|
||||
if (Object.keys(config.env).length > 0) {
|
||||
const envKeys = Object.keys(config.env).join(", ");
|
||||
console.log(`Custom environment variables: ${envKeys}`);
|
||||
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
|
||||
|
||||
@@ -79,4 +79,27 @@ export async function setupClaudeCodeSettings(
|
||||
console.log(`Slash commands directory not found or error copying: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy project subagents to Claude's agents directory
|
||||
// Use GITHUB_WORKSPACE if available (set by GitHub Actions), otherwise use current directory
|
||||
const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd();
|
||||
const projectAgentsDir = `${workspaceDir}/.claude/agents`;
|
||||
const claudeAgentsDir = `${home}/.claude/agents`;
|
||||
|
||||
try {
|
||||
await $`test -d ${projectAgentsDir}`.quiet();
|
||||
console.log(`Found project agents directory at ${projectAgentsDir}`);
|
||||
|
||||
await $`mkdir -p ${claudeAgentsDir}`.quiet();
|
||||
|
||||
await $`cp ${projectAgentsDir}/*.md ${claudeAgentsDir}/ 2>/dev/null || true`.quiet();
|
||||
|
||||
const agentFiles = await $`ls ${claudeAgentsDir}/*.md 2>/dev/null | wc -l`
|
||||
.quiet()
|
||||
.text();
|
||||
const agentCount = parseInt(agentFiles.trim()) || 0;
|
||||
console.log(`Copied ${agentCount} agent(s) to ${claudeAgentsDir}`);
|
||||
} catch (e) {
|
||||
console.log(`No project agents directory found at ${projectAgentsDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,4 +215,70 @@ describe("setupClaudeCodeSettings", () => {
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
expect(JSON.parse(settingsContent).enableAllProjectMcpServers).toBe(true);
|
||||
});
|
||||
|
||||
test("should copy project agents when .claude/agents directory exists", async () => {
|
||||
// Create a mock project structure with agents
|
||||
const projectDir = join(testHomeDir, "test-project");
|
||||
const projectAgentsDir = join(projectDir, ".claude", "agents");
|
||||
await mkdir(projectAgentsDir, { recursive: true });
|
||||
|
||||
// Create test agent files
|
||||
await writeFile(
|
||||
join(projectAgentsDir, "test-agent.md"),
|
||||
"---\nname: test-agent\ndescription: Test agent\n---\nTest agent content",
|
||||
);
|
||||
await writeFile(
|
||||
join(projectAgentsDir, "another-agent.md"),
|
||||
"---\nname: another-agent\n---\nAnother agent",
|
||||
);
|
||||
|
||||
// Set GITHUB_WORKSPACE to the test project directory
|
||||
const originalWorkspace = process.env.GITHUB_WORKSPACE;
|
||||
process.env.GITHUB_WORKSPACE = projectDir;
|
||||
|
||||
try {
|
||||
await setupClaudeCodeSettings(undefined, testHomeDir);
|
||||
|
||||
// Check that agents were copied
|
||||
const agentsDir = join(testHomeDir, ".claude", "agents");
|
||||
const files = await readdir(agentsDir);
|
||||
expect(files).toContain("test-agent.md");
|
||||
expect(files).toContain("another-agent.md");
|
||||
|
||||
// Verify content was copied correctly
|
||||
const content = await readFile(join(agentsDir, "test-agent.md"), "utf-8");
|
||||
expect(content).toContain("Test agent content");
|
||||
} finally {
|
||||
// Restore original GITHUB_WORKSPACE
|
||||
if (originalWorkspace !== undefined) {
|
||||
process.env.GITHUB_WORKSPACE = originalWorkspace;
|
||||
} else {
|
||||
delete process.env.GITHUB_WORKSPACE;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle missing project agents directory gracefully", async () => {
|
||||
// Set GITHUB_WORKSPACE to a directory without .claude/agents
|
||||
const projectDir = join(testHomeDir, "project-without-agents");
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
|
||||
const originalWorkspace = process.env.GITHUB_WORKSPACE;
|
||||
process.env.GITHUB_WORKSPACE = projectDir;
|
||||
|
||||
try {
|
||||
await setupClaudeCodeSettings(undefined, testHomeDir);
|
||||
|
||||
// Should complete without errors
|
||||
const settingsContent = await readFile(settingsPath, "utf-8");
|
||||
const settings = JSON.parse(settingsContent);
|
||||
expect(settings.enableAllProjectMcpServers).toBe(true);
|
||||
} finally {
|
||||
if (originalWorkspace !== undefined) {
|
||||
process.env.GITHUB_WORKSPACE = originalWorkspace;
|
||||
} else {
|
||||
delete process.env.GITHUB_WORKSPACE;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Claude PR Assistant
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
@@ -11,38 +11,53 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude-code-action:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Or use OAuth token instead:
|
||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
timeout_minutes: "60"
|
||||
# mode: tag # Default: responds to @claude mentions
|
||||
# Optional: Restrict network access to specific domains only
|
||||
# experimental_allowed_domains: |
|
||||
# .anthropic.com
|
||||
# .github.com
|
||||
# api.github.com
|
||||
# .githubusercontent.com
|
||||
# bun.sh
|
||||
# registry.npmjs.org
|
||||
# .blob.core.windows.net
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
||||
# model: "claude-opus-4-1-20250805"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
|
||||
@@ -750,7 +750,7 @@ export async function createPrompt(
|
||||
modeContext.claudeBranch,
|
||||
);
|
||||
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
@@ -769,7 +769,7 @@ export async function createPrompt(
|
||||
|
||||
// Write the prompt file
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
|
||||
59
src/entrypoints/collect-inputs.ts
Normal file
59
src/entrypoints/collect-inputs.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
export function collectActionInputsPresence(): void {
|
||||
const inputDefaults: Record<string, string> = {
|
||||
trigger_phrase: "@claude",
|
||||
assignee_trigger: "",
|
||||
label_trigger: "claude",
|
||||
base_branch: "",
|
||||
branch_prefix: "claude/",
|
||||
allowed_bots: "",
|
||||
mode: "tag",
|
||||
model: "",
|
||||
anthropic_model: "",
|
||||
fallback_model: "",
|
||||
allowed_tools: "",
|
||||
disallowed_tools: "",
|
||||
custom_instructions: "",
|
||||
direct_prompt: "",
|
||||
override_prompt: "",
|
||||
mcp_config: "",
|
||||
additional_permissions: "",
|
||||
claude_env: "",
|
||||
settings: "",
|
||||
anthropic_api_key: "",
|
||||
claude_code_oauth_token: "",
|
||||
github_token: "",
|
||||
max_turns: "",
|
||||
use_sticky_comment: "false",
|
||||
use_commit_signing: "false",
|
||||
experimental_allowed_domains: "",
|
||||
};
|
||||
|
||||
const allInputsJson = process.env.ALL_INPUTS;
|
||||
if (!allInputsJson) {
|
||||
console.log("ALL_INPUTS environment variable not found");
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
|
||||
let allInputs: Record<string, string>;
|
||||
try {
|
||||
allInputs = JSON.parse(allInputsJson);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse ALL_INPUTS JSON:", e);
|
||||
core.setOutput("action_inputs_present", JSON.stringify({}));
|
||||
return;
|
||||
}
|
||||
|
||||
const presentInputs: Record<string, boolean> = {};
|
||||
|
||||
for (const [name, defaultValue] of Object.entries(inputDefaults)) {
|
||||
const actualValue = allInputs[name] || "";
|
||||
|
||||
const isSet = actualValue !== defaultValue;
|
||||
presentInputs[name] = isSet;
|
||||
}
|
||||
|
||||
core.setOutput("action_inputs_present", JSON.stringify(presentInputs));
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import { createOctokit } from "../github/api/client";
|
||||
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import { getMode } from "../modes/registry";
|
||||
import { prepare } from "../prepare";
|
||||
import { collectActionInputsPresence } from "./collect-inputs";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
collectActionInputsPresence();
|
||||
|
||||
// Parse GitHub context first to enable mode detection
|
||||
const context = parseGitHubContext();
|
||||
|
||||
@@ -41,11 +44,18 @@ async function run() {
|
||||
// Check trigger conditions
|
||||
const containsTrigger = mode.shouldTrigger(context);
|
||||
|
||||
// Debug logging
|
||||
console.log(`Mode: ${mode.name}`);
|
||||
console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`);
|
||||
console.log(`Trigger result: ${containsTrigger}`);
|
||||
|
||||
// Set output for action.yml to check
|
||||
core.setOutput("contains_trigger", containsTrigger.toString());
|
||||
|
||||
if (!containsTrigger) {
|
||||
console.log("No trigger found, skipping remaining steps");
|
||||
// Still set github_token output even when skipping
|
||||
core.setOutput("github_token", githubToken);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -58,6 +68,9 @@ async function run() {
|
||||
});
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PullRequestEvent,
|
||||
PullRequestReviewEvent,
|
||||
PullRequestReviewCommentEvent,
|
||||
WorkflowRunEvent,
|
||||
} from "@octokit/webhooks-types";
|
||||
// Custom types for GitHub Actions events that aren't webhooks
|
||||
export type WorkflowDispatchEvent = {
|
||||
@@ -44,7 +45,11 @@ const ENTITY_EVENT_NAMES = [
|
||||
"pull_request_review_comment",
|
||||
] as const;
|
||||
|
||||
const AUTOMATION_EVENT_NAMES = ["workflow_dispatch", "schedule"] as const;
|
||||
const AUTOMATION_EVENT_NAMES = [
|
||||
"workflow_dispatch",
|
||||
"schedule",
|
||||
"workflow_run",
|
||||
] as const;
|
||||
|
||||
// Derive types from constants for better maintainability
|
||||
type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number];
|
||||
@@ -86,10 +91,10 @@ export type ParsedGitHubContext = BaseContext & {
|
||||
isPR: boolean;
|
||||
};
|
||||
|
||||
// Context for automation events (workflow_dispatch, schedule)
|
||||
// Context for automation events (workflow_dispatch, schedule, workflow_run)
|
||||
export type AutomationContext = BaseContext & {
|
||||
eventName: AutomationEventName;
|
||||
payload: WorkflowDispatchEvent | ScheduleEvent;
|
||||
payload: WorkflowDispatchEvent | ScheduleEvent | WorkflowRunEvent;
|
||||
};
|
||||
|
||||
// Union type for all contexts
|
||||
@@ -185,6 +190,13 @@ export function parseGitHubContext(): GitHubContext {
|
||||
payload: context.payload as unknown as ScheduleEvent,
|
||||
};
|
||||
}
|
||||
case "workflow_run": {
|
||||
return {
|
||||
...commonFields,
|
||||
eventName: "workflow_run",
|
||||
payload: context.payload as unknown as WorkflowRunEvent,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported event type: ${context.eventName}`);
|
||||
}
|
||||
|
||||
@@ -31,8 +31,30 @@ async function exchangeForAppToken(oidcToken: string): Promise<string> {
|
||||
const responseJson = (await response.json()) as {
|
||||
error?: {
|
||||
message?: string;
|
||||
details?: {
|
||||
error_code?: string;
|
||||
};
|
||||
};
|
||||
type?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
// Check for specific workflow validation error codes that should skip the action
|
||||
const errorCode = responseJson.error?.details?.error_code;
|
||||
|
||||
if (errorCode === "workflow_not_found_on_default_branch") {
|
||||
const message =
|
||||
responseJson.message ??
|
||||
responseJson.error?.message ??
|
||||
"Workflow validation failed";
|
||||
core.warning(`Skipping action due to workflow validation: ${message}`);
|
||||
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);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
|
||||
);
|
||||
@@ -77,8 +99,9 @@ export async function setupGitHubToken(): Promise<string> {
|
||||
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.`,
|
||||
`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);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,41 @@ export function sanitizeContent(content: string): string {
|
||||
content = stripMarkdownLinkTitles(content);
|
||||
content = stripHiddenAttributes(content);
|
||||
content = normalizeHtmlEntities(content);
|
||||
content = redactGitHubTokens(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
export function redactGitHubTokens(content: string): string {
|
||||
// GitHub Personal Access Tokens (classic): ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghp_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub OAuth tokens: gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bgho_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub installation tokens: ghs_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghs_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub refresh tokens: ghr_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (40 chars)
|
||||
content = content.replace(
|
||||
/\bghr_[A-Za-z0-9]{36}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
// GitHub fine-grained personal access tokens: github_pat_XXXXXXXXXX (up to 255 chars)
|
||||
content = content.replace(
|
||||
/\bgithub_pat_[A-Za-z0-9_]{11,221}\b/g,
|
||||
"[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { z } from "zod";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
@@ -54,11 +55,13 @@ server.tool(
|
||||
const isPullRequestReviewComment =
|
||||
eventName === "pull_request_review_comment";
|
||||
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
const result = await updateClaudeComment(octokit, {
|
||||
owner,
|
||||
repo,
|
||||
commentId,
|
||||
body,
|
||||
body: sanitizedBody,
|
||||
isPullRequestReviewComment,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { createOctokit } from "../github/api/client";
|
||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
||||
|
||||
// Get repository and PR information from environment variables
|
||||
const REPO_OWNER = process.env.REPO_OWNER;
|
||||
@@ -81,6 +82,9 @@ server.tool(
|
||||
|
||||
const octokit = createOctokit(githubToken).rest;
|
||||
|
||||
// Sanitize the comment body to remove any potential GitHub tokens
|
||||
const sanitizedBody = sanitizeContent(body);
|
||||
|
||||
// Validate that either line or both startLine and line are provided
|
||||
if (!line && !startLine) {
|
||||
throw new Error(
|
||||
@@ -104,7 +108,7 @@ server.tool(
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
body,
|
||||
body: sanitizedBody,
|
||||
path,
|
||||
side: side || "RIGHT",
|
||||
commit_id: commit_id || pr.data.head.sha,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as core from "@actions/core";
|
||||
import { GITHUB_API_URL, GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { isEntityContext } from "../github/context";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
type PrepareConfigParams = {
|
||||
@@ -9,10 +10,9 @@ type PrepareConfigParams = {
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
additionalMcpConfig?: string;
|
||||
claudeCommentId?: string;
|
||||
allowedTools: string[];
|
||||
context: ParsedGitHubContext;
|
||||
context: GitHubContext;
|
||||
};
|
||||
|
||||
async function checkActionsReadPermission(
|
||||
@@ -56,7 +56,6 @@ export async function prepareMcpConfig(
|
||||
repo,
|
||||
branch,
|
||||
baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId,
|
||||
allowedTools,
|
||||
context,
|
||||
@@ -68,6 +67,10 @@ export async function prepareMcpConfig(
|
||||
tool.startsWith("mcp__github__"),
|
||||
);
|
||||
|
||||
const hasInlineCommentTools = allowedToolsList.some((tool) =>
|
||||
tool.startsWith("mcp__github_inline_comment__"),
|
||||
);
|
||||
|
||||
const baseMcpConfig: { mcpServers: Record<string, unknown> } = {
|
||||
mcpServers: {},
|
||||
};
|
||||
@@ -111,10 +114,32 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Include inline comment server for PRs when requested via allowed tools
|
||||
if (
|
||||
isEntityContext(context) &&
|
||||
context.isPR &&
|
||||
(hasGitHubMcpTools || hasInlineCommentTools)
|
||||
) {
|
||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-inline-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
GITHUB_API_URL: GITHUB_API_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// CI server is included when we have a workflow token and context is a PR
|
||||
const hasWorkflowToken = !!process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
|
||||
if (context.isPR && hasWorkflowToken) {
|
||||
if (isEntityContext(context) && context.isPR && hasWorkflowToken) {
|
||||
// Verify the token actually has actions:read permission
|
||||
const actuallyHasPermission = await checkActionsReadPermission(
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN || "",
|
||||
@@ -166,38 +191,8 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Merge with additional MCP config if provided
|
||||
if (additionalMcpConfig && additionalMcpConfig.trim()) {
|
||||
try {
|
||||
const additionalConfig = JSON.parse(additionalMcpConfig);
|
||||
|
||||
// Validate that parsed JSON is an object
|
||||
if (typeof additionalConfig !== "object" || additionalConfig === null) {
|
||||
throw new Error("MCP config must be a valid JSON object");
|
||||
}
|
||||
|
||||
core.info(
|
||||
"Merging additional MCP server configuration with built-in servers",
|
||||
);
|
||||
|
||||
// Merge configurations with user config overriding built-in servers
|
||||
const mergedConfig = {
|
||||
...baseMcpConfig,
|
||||
...additionalConfig,
|
||||
mcpServers: {
|
||||
...baseMcpConfig.mcpServers,
|
||||
...additionalConfig.mcpServers,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(mergedConfig, null, 2);
|
||||
} catch (parseError) {
|
||||
core.warning(
|
||||
`Failed to parse additional MCP config: ${parseError}. Using base config only.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return only our GitHub servers config
|
||||
// User's config will be passed as separate --mcp-config flags
|
||||
return JSON.stringify(baseMcpConfig, null, 2);
|
||||
} catch (error) {
|
||||
core.setFailed(`Install MCP server failed with error: ${error}`);
|
||||
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
|
||||
/**
|
||||
* Agent mode implementation.
|
||||
@@ -40,82 +42,71 @@ export const agentMode: Mode = {
|
||||
},
|
||||
|
||||
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> {
|
||||
// Agent mode handles automation events and any event with explicit prompts
|
||||
|
||||
// TODO: handle by createPrompt (similar to tag and review modes)
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
// Write the prompt file - the base action requires a prompt_file parameter.
|
||||
// Use the unified prompt field from v1.0.
|
||||
|
||||
// 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}/claude-prompts/claude-prompt.txt`,
|
||||
`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
// Agent mode: User has full control via claudeArgs
|
||||
// No default tools are enforced - Claude Code's defaults will apply
|
||||
// Parse allowed tools from user's claude_args
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const allowedTools = parseAllowedTools(userClaudeArgs);
|
||||
|
||||
// Always include the GitHub comment server in agent mode
|
||||
// This ensures GitHub tools (PR reviews, comments, etc.) work out of the box
|
||||
// without requiring users to manually configure the MCP server
|
||||
const mcpConfig: any = {
|
||||
mcpServers: {
|
||||
"github-comment-server": {
|
||||
command: "bun",
|
||||
args: [
|
||||
"run",
|
||||
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-comment-server.ts`,
|
||||
],
|
||||
env: {
|
||||
GITHUB_TOKEN: githubToken || "",
|
||||
REPO_OWNER: context.repository.owner,
|
||||
REPO_NAME: context.repository.repo,
|
||||
GITHUB_EVENT_NAME: process.env.GITHUB_EVENT_NAME || "",
|
||||
GITHUB_API_URL:
|
||||
process.env.GITHUB_API_URL || "https://api.github.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// Detect current branch from GitHub environment
|
||||
const currentBranch =
|
||||
process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || "main";
|
||||
|
||||
// Add user-provided additional MCP config if any
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
if (additionalMcpConfig.trim()) {
|
||||
try {
|
||||
const additional = JSON.parse(additionalMcpConfig);
|
||||
if (additional && typeof additional === "object") {
|
||||
// Merge mcpServers if both have them
|
||||
if (additional.mcpServers && mcpConfig.mcpServers) {
|
||||
Object.assign(mcpConfig.mcpServers, additional.mcpServers);
|
||||
} else {
|
||||
Object.assign(mcpConfig, additional);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to parse additional MCP config: ${error}`);
|
||||
}
|
||||
// Get our GitHub MCP servers config
|
||||
const ourMcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: currentBranch,
|
||||
baseBranch: context.inputs.baseBranch || "main",
|
||||
claudeCommentId: undefined, // No tracking comment in agent mode
|
||||
allowedTools,
|
||||
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}'`;
|
||||
}
|
||||
|
||||
// Agent mode: pass through user's claude_args with MCP config
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const escapedMcpConfig = JSON.stringify(mcpConfig).replace(/'/g, "'\\''");
|
||||
const claudeArgs =
|
||||
`--mcp-config '${escapedMcpConfig}' ${userClaudeArgs}`.trim();
|
||||
// Add user's MCP_CONFIG env var as separate --mcp-config
|
||||
const userMcpConfig = process.env.MCP_CONFIG;
|
||||
if (userMcpConfig?.trim()) {
|
||||
const escapedUserConfig = userMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `${claudeArgs} --mcp-config '${escapedUserConfig}'`.trim();
|
||||
}
|
||||
|
||||
// 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: "",
|
||||
currentBranch: "",
|
||||
baseBranch: context.inputs.baseBranch || "main",
|
||||
currentBranch,
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: JSON.stringify(mcpConfig),
|
||||
mcpConfig: ourMcpConfig,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
22
src/modes/agent/parse-tools.ts
Normal file
22
src/modes/agent/parse-tools.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function parseAllowedTools(claudeArgs: string): string[] {
|
||||
// Match --allowedTools followed by the value
|
||||
// Handle both quoted and unquoted values
|
||||
const patterns = [
|
||||
/--allowedTools\s+"([^"]+)"/, // Double quoted
|
||||
/--allowedTools\s+'([^']+)'/, // Single quoted
|
||||
/--allowedTools\s+([^\s]+)/, // Unquoted
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = claudeArgs.match(pattern);
|
||||
if (match && match[1]) {
|
||||
// Don't return if the value starts with -- (another flag)
|
||||
if (match[1].startsWith("--")) {
|
||||
return [];
|
||||
}
|
||||
return match[1].split(",").map((t) => t.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -100,15 +100,13 @@ export const tagMode: Mode = {
|
||||
|
||||
await createPrompt(tagMode, modeContext, githubData, context);
|
||||
|
||||
// Get MCP configuration
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
// 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,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: commentId.toString(),
|
||||
allowedTools: [],
|
||||
context,
|
||||
@@ -150,14 +148,26 @@ export const tagMode: Mode = {
|
||||
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
|
||||
// Build complete claude_args with MCP config (as JSON string), tools, and user args
|
||||
// Note: Once Claude supports multiple --mcp-config flags, we can pass as file path
|
||||
// Escape single quotes in JSON to prevent shell injection
|
||||
const escapedMcpConfig = mcpConfig.replace(/'/g, "'\\''");
|
||||
let claudeArgs = `--mcp-config '${escapedMcpConfig}' `;
|
||||
claudeArgs += `--allowedTools "${tagModeTools.join(",")}" `;
|
||||
// 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 user's MCP_CONFIG env var as separate --mcp-config
|
||||
const userMcpConfig = process.env.MCP_CONFIG;
|
||||
if (userMcpConfig?.trim()) {
|
||||
const escapedUserConfig = userMcpConfig.replace(/'/g, "'\\''");
|
||||
claudeArgs = `${claudeArgs} --mcp-config '${escapedUserConfig}'`;
|
||||
}
|
||||
|
||||
// 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;
|
||||
claudeArgs += ` ${userClaudeArgs}`;
|
||||
}
|
||||
|
||||
core.setOutput("claude_args", claudeArgs.trim());
|
||||
@@ -165,7 +175,7 @@ export const tagMode: Mode = {
|
||||
return {
|
||||
commentId,
|
||||
branchInfo,
|
||||
mcpConfig,
|
||||
mcpConfig: ourMcpConfig,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -38,3 +38,4 @@ export async function retryWithBackoff<T>(
|
||||
console.error(`Operation failed after ${maxAttempts} attempts`);
|
||||
throw lastError;
|
||||
}
|
||||
// Test change to trigger CI
|
||||
|
||||
@@ -50,14 +50,6 @@ describe("prepareMcpConfig", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const mockPRContextWithSigning: ParsedGitHubContext = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
@@ -98,19 +90,9 @@ describe("prepareMcpConfig", () => {
|
||||
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
|
||||
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
|
||||
});
|
||||
|
||||
test("should return file ops server when commit signing is enabled", async () => {
|
||||
const contextWithSigning = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
test("should include file ops server when commit signing is enabled", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
@@ -118,19 +100,16 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithSigning,
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
expect(parsed.mcpServers.github_file_ops.env.REPO_OWNER).toBe("test-owner");
|
||||
expect(parsed.mcpServers.github_file_ops.env.REPO_NAME).toBe("test-repo");
|
||||
expect(parsed.mcpServers.github_file_ops.env.BRANCH_NAME).toBe(
|
||||
"test-branch",
|
||||
);
|
||||
@@ -143,49 +122,37 @@ describe("prepareMcpConfig", () => {
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
allowedTools: ["mcp__github__create_issue", "mcp__github__create_pr"],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github.command).toBe("docker");
|
||||
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not include github MCP server when only file_ops tools are allowed", async () => {
|
||||
const contextWithSigning = {
|
||||
...mockContext,
|
||||
inputs: {
|
||||
...mockContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
test("should include inline comment server for PRs when tools are allowed", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__update_claude_comment",
|
||||
],
|
||||
context: contextWithSigning,
|
||||
allowedTools: ["mcp__github_inline_comment__create_inline_comment"],
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
expect(parsed.mcpServers.github_inline_comment.env.PR_NUMBER).toBe("456");
|
||||
});
|
||||
|
||||
test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
|
||||
@@ -195,7 +162,7 @@ describe("prepareMcpConfig", () => {
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["Edit", "Read", "Write"],
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
@@ -206,301 +173,7 @@ describe("prepareMcpConfig", () => {
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
});
|
||||
|
||||
test("should return base config when additional config is empty string", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: "",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return base config when additional config is whitespace only", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: " \n\t ",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_comment).toBeDefined();
|
||||
expect(consoleWarningSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should merge valid additional config with base config", async () => {
|
||||
const additionalConfig = JSON.stringify({
|
||||
mcpServers: {
|
||||
custom_server: {
|
||||
command: "custom-command",
|
||||
args: ["arg1", "arg2"],
|
||||
env: {
|
||||
CUSTOM_ENV: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"Merging additional MCP server configuration with built-in servers",
|
||||
);
|
||||
expect(parsed.mcpServers.github).toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
expect(parsed.mcpServers.custom_server).toBeDefined();
|
||||
expect(parsed.mcpServers.custom_server.command).toBe("custom-command");
|
||||
expect(parsed.mcpServers.custom_server.args).toEqual(["arg1", "arg2"]);
|
||||
expect(parsed.mcpServers.custom_server.env.CUSTOM_ENV).toBe("custom-value");
|
||||
});
|
||||
|
||||
test("should override built-in servers when additional config has same server names", async () => {
|
||||
const additionalConfig = JSON.stringify({
|
||||
mcpServers: {
|
||||
github: {
|
||||
command: "overridden-command",
|
||||
args: ["overridden-arg"],
|
||||
env: {
|
||||
OVERRIDDEN_ENV: "overridden-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"Merging additional MCP server configuration with built-in servers",
|
||||
);
|
||||
expect(parsed.mcpServers.github.command).toBe("overridden-command");
|
||||
expect(parsed.mcpServers.github.args).toEqual(["overridden-arg"]);
|
||||
expect(parsed.mcpServers.github.env.OVERRIDDEN_ENV).toBe(
|
||||
"overridden-value",
|
||||
);
|
||||
expect(
|
||||
parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN,
|
||||
).toBeUndefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should merge additional root-level properties", async () => {
|
||||
const additionalConfig = JSON.stringify({
|
||||
customProperty: "custom-value",
|
||||
anotherProperty: {
|
||||
nested: "value",
|
||||
},
|
||||
mcpServers: {
|
||||
custom_server: {
|
||||
command: "custom",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.customProperty).toBe("custom-value");
|
||||
expect(parsed.anotherProperty).toEqual({ nested: "value" });
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.custom_server).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle invalid JSON gracefully", async () => {
|
||||
const invalidJson = "{ invalid json }";
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: invalidJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
||||
);
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle non-object JSON values", async () => {
|
||||
const nonObjectJson = JSON.stringify("string value");
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: nonObjectJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
||||
);
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("MCP config must be a valid JSON object"),
|
||||
);
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle null JSON value", async () => {
|
||||
const nullJson = JSON.stringify(null);
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: nullJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse additional MCP config:"),
|
||||
);
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("MCP config must be a valid JSON object"),
|
||||
);
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should handle array JSON value", async () => {
|
||||
const arrayJson = JSON.stringify([1, 2, 3]);
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: arrayJson,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
// Arrays are objects in JavaScript, so they pass the object check
|
||||
// But they'll fail when trying to spread or access mcpServers property
|
||||
expect(consoleInfoSpy).toHaveBeenCalledWith(
|
||||
"Merging additional MCP server configuration with built-in servers",
|
||||
);
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
// The array will be spread into the config (0: 1, 1: 2, 2: 3)
|
||||
expect(parsed[0]).toBe(1);
|
||||
expect(parsed[1]).toBe(2);
|
||||
expect(parsed[2]).toBe(3);
|
||||
});
|
||||
|
||||
test("should merge complex nested configurations", async () => {
|
||||
const additionalConfig = JSON.stringify({
|
||||
mcpServers: {
|
||||
server1: {
|
||||
command: "cmd1",
|
||||
env: { KEY1: "value1" },
|
||||
},
|
||||
server2: {
|
||||
command: "cmd2",
|
||||
env: { KEY2: "value2" },
|
||||
},
|
||||
github_file_ops: {
|
||||
command: "overridden",
|
||||
env: { CUSTOM: "value" },
|
||||
},
|
||||
},
|
||||
otherConfig: {
|
||||
nested: {
|
||||
deeply: "value",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.server1).toBeDefined();
|
||||
expect(parsed.mcpServers.server2).toBeDefined();
|
||||
expect(parsed.mcpServers.github).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops.command).toBe("overridden");
|
||||
expect(parsed.mcpServers.github_file_ops.env.CUSTOM).toBe("value");
|
||||
expect(parsed.otherConfig.nested.deeply).toBe("value");
|
||||
});
|
||||
|
||||
test("should preserve GITHUB_ACTION_PATH in file_ops server args", async () => {
|
||||
const oldEnv = process.env.GITHUB_ACTION_PATH;
|
||||
test("should set GITHUB_ACTION_PATH correctly", async () => {
|
||||
process.env.GITHUB_ACTION_PATH = "/test/action/path";
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
@@ -514,15 +187,12 @@ describe("prepareMcpConfig", () => {
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_file_ops.args[1]).toBe(
|
||||
expect(parsed.mcpServers.github_file_ops.args).toContain(
|
||||
"/test/action/path/src/mcp/github-file-ops-server.ts",
|
||||
);
|
||||
|
||||
process.env.GITHUB_ACTION_PATH = oldEnv;
|
||||
});
|
||||
|
||||
test("should use process.cwd() when GITHUB_WORKSPACE is not set", async () => {
|
||||
const oldEnv = process.env.GITHUB_WORKSPACE;
|
||||
test("should use current working directory when GITHUB_WORKSPACE is not set", async () => {
|
||||
delete process.env.GITHUB_WORKSPACE;
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
@@ -537,22 +207,11 @@ describe("prepareMcpConfig", () => {
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd());
|
||||
|
||||
process.env.GITHUB_WORKSPACE = oldEnv;
|
||||
});
|
||||
|
||||
test("should include github_ci server when context.isPR is true and workflow token is present", async () => {
|
||||
const oldEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
test("should include CI server when context.isPR is true and DEFAULT_WORKFLOW_TOKEN exists", async () => {
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
useCommitSigning: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
@@ -560,16 +219,15 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = oldEnv;
|
||||
delete process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
});
|
||||
|
||||
test("should not include github_ci server when context.isPR is false", async () => {
|
||||
@@ -580,16 +238,14 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockContextWithSigning,
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
});
|
||||
|
||||
test("should not include github_ci server when workflow token is not present", async () => {
|
||||
const oldTokenEnv = 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;
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
@@ -599,73 +255,10 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: mockPRContextWithSigning,
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
expect(parsed.mcpServers.github_file_ops).toBeDefined();
|
||||
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = oldTokenEnv;
|
||||
});
|
||||
|
||||
test("should include github_ci server when workflow token is present for PR context", async () => {
|
||||
const oldTokenEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
|
||||
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = oldTokenEnv;
|
||||
});
|
||||
|
||||
test("should warn when workflow token lacks actions:read permission", async () => {
|
||||
const oldTokenEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = "invalid-token";
|
||||
|
||||
const contextWithPermissions = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
context: contextWithPermissions,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).toBeDefined();
|
||||
expect(consoleWarningSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"The github_ci MCP server requires 'actions: read' permission",
|
||||
),
|
||||
);
|
||||
|
||||
process.env.DEFAULT_WORKFLOW_TOKEN = oldTokenEnv;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,6 +104,12 @@ describe("Agent Mode", () => {
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
|
||||
// Save original env vars and set test values
|
||||
const originalHeadRef = process.env.GITHUB_HEAD_REF;
|
||||
const originalRefName = process.env.GITHUB_REF_NAME;
|
||||
delete process.env.GITHUB_HEAD_REF;
|
||||
delete process.env.GITHUB_REF_NAME;
|
||||
|
||||
// Set CLAUDE_ARGS environment variable
|
||||
process.env.CLAUDE_ARGS = "--model claude-sonnet-4 --max-turns 10";
|
||||
|
||||
@@ -120,12 +126,12 @@ describe("Agent Mode", () => {
|
||||
expect(callArgs[1]).toContain("--mcp-config");
|
||||
expect(callArgs[1]).toContain("--model claude-sonnet-4 --max-turns 10");
|
||||
|
||||
// Verify return structure
|
||||
// Verify return structure - should use "main" as fallback when no env vars set
|
||||
expect(result).toEqual({
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: "",
|
||||
currentBranch: "",
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: expect.any(String),
|
||||
@@ -133,6 +139,10 @@ describe("Agent Mode", () => {
|
||||
|
||||
// Clean up
|
||||
delete process.env.CLAUDE_ARGS;
|
||||
if (originalHeadRef !== undefined)
|
||||
process.env.GITHUB_HEAD_REF = originalHeadRef;
|
||||
if (originalRefName !== undefined)
|
||||
process.env.GITHUB_REF_NAME = originalRefName;
|
||||
});
|
||||
|
||||
test("prepare method creates prompt file with correct content", async () => {
|
||||
|
||||
71
test/modes/parse-tools.test.ts
Normal file
71
test/modes/parse-tools.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { parseAllowedTools } from "../../src/modes/agent/parse-tools";
|
||||
|
||||
describe("parseAllowedTools", () => {
|
||||
test("parses unquoted tools", () => {
|
||||
const args = "--allowedTools mcp__github__*,mcp__github_comment__*";
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__*",
|
||||
"mcp__github_comment__*",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses double-quoted tools", () => {
|
||||
const args = '--allowedTools "mcp__github__*,mcp__github_comment__*"';
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__*",
|
||||
"mcp__github_comment__*",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses single-quoted tools", () => {
|
||||
const args = "--allowedTools 'mcp__github__*,mcp__github_comment__*'";
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__*",
|
||||
"mcp__github_comment__*",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns empty array when no allowedTools", () => {
|
||||
const args = "--someOtherFlag value";
|
||||
expect(parseAllowedTools(args)).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(parseAllowedTools("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles duplicate --allowedTools flags", () => {
|
||||
const args = "--allowedTools --allowedTools mcp__github__*";
|
||||
// Should not match the first one since the value is another flag
|
||||
expect(parseAllowedTools(args)).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles typo --alloedTools", () => {
|
||||
const args = "--alloedTools mcp__github__*";
|
||||
expect(parseAllowedTools(args)).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles multiple flags with allowedTools in middle", () => {
|
||||
const args =
|
||||
'--flag1 value1 --allowedTools "mcp__github__*" --flag2 value2';
|
||||
expect(parseAllowedTools(args)).toEqual(["mcp__github__*"]);
|
||||
});
|
||||
|
||||
test("trims whitespace from tool names", () => {
|
||||
const args = "--allowedTools 'mcp__github__* , mcp__github_comment__* '";
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__*",
|
||||
"mcp__github_comment__*",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles tools with special characters", () => {
|
||||
const args =
|
||||
'--allowedTools "mcp__github__create_issue,mcp__github_comment__update"';
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_comment__update",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeHtmlEntities,
|
||||
sanitizeContent,
|
||||
stripHtmlComments,
|
||||
redactGitHubTokens,
|
||||
} from "../src/github/utils/sanitizer";
|
||||
|
||||
describe("stripInvisibleCharacters", () => {
|
||||
@@ -242,6 +243,109 @@ describe("sanitizeContent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactGitHubTokens", () => {
|
||||
it("should redact personal access tokens (ghp_)", () => {
|
||||
const token = "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";
|
||||
expect(redactGitHubTokens(`Token: ${token}`)).toBe(
|
||||
"Token: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
expect(redactGitHubTokens(`Here's a token: ${token} in text`)).toBe(
|
||||
"Here's a token: [REDACTED_GITHUB_TOKEN] in text",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact OAuth tokens (gho_)", () => {
|
||||
const token = "gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
expect(redactGitHubTokens(`OAuth: ${token}`)).toBe(
|
||||
"OAuth: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact installation tokens (ghs_)", () => {
|
||||
const token = "ghs_xz7yzju2SZjGPa0dUNMAx0SH4xDOCS31LXQW";
|
||||
expect(redactGitHubTokens(`Install token: ${token}`)).toBe(
|
||||
"Install token: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact refresh tokens (ghr_)", () => {
|
||||
const token = "ghr_1B4a2e77838347a253e56d7b5253e7d11667";
|
||||
expect(redactGitHubTokens(`Refresh: ${token}`)).toBe(
|
||||
"Refresh: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should redact fine-grained tokens (github_pat_)", () => {
|
||||
const token =
|
||||
"github_pat_11ABCDEFG0example5of9_2nVwvsylpmOLboQwTPTLewDcE621dQ0AAaBBCCDDEEFFHH";
|
||||
expect(redactGitHubTokens(`Fine-grained: ${token}`)).toBe(
|
||||
"Fine-grained: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tokens in code blocks", () => {
|
||||
const content = `\`\`\`bash
|
||||
export GITHUB_TOKEN=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
|
||||
\`\`\``;
|
||||
const expected = `\`\`\`bash
|
||||
export GITHUB_TOKEN=[REDACTED_GITHUB_TOKEN]
|
||||
\`\`\``;
|
||||
expect(redactGitHubTokens(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle multiple tokens in one text", () => {
|
||||
const content =
|
||||
"Token 1: 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 and token 2: gho_16C7e42F292c6912E7710c838347Ae178B4a";
|
||||
expect(redactGitHubTokens(content)).toBe(
|
||||
"Token 1: [REDACTED_GITHUB_TOKEN] and token 2: [REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle tokens in URLs", () => {
|
||||
const content =
|
||||
"https://api.github.com/user?access_token=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";
|
||||
expect(redactGitHubTokens(content)).toBe(
|
||||
"https://api.github.com/user?access_token=[REDACTED_GITHUB_TOKEN]",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not redact partial matches or invalid tokens", () => {
|
||||
const content =
|
||||
"This is not a token: ghp_short or gho_toolong1234567890123456789012345678901234567890";
|
||||
expect(redactGitHubTokens(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should preserve normal text", () => {
|
||||
const content = "Normal text with no tokens";
|
||||
expect(redactGitHubTokens(content)).toBe(content);
|
||||
});
|
||||
|
||||
it("should handle edge cases", () => {
|
||||
expect(redactGitHubTokens("")).toBe("");
|
||||
expect(redactGitHubTokens("ghp_")).toBe("ghp_");
|
||||
expect(redactGitHubTokens("github_pat_short")).toBe("github_pat_short");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeContent with token redaction", () => {
|
||||
it("should redact tokens as part of full sanitization", () => {
|
||||
const content = `
|
||||
<!-- Hidden comment with token: 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 -->
|
||||
Here's some text with a token: gho_16C7e42F292c6912E7710c838347Ae178B4a
|
||||
And invisible chars: test\u200Btoken
|
||||
`;
|
||||
|
||||
const sanitized = sanitizeContent(content);
|
||||
|
||||
expect(sanitized).not.toContain("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");
|
||||
expect(sanitized).not.toContain("gho_16C7e42F292c6912E7710c838347Ae178B4a");
|
||||
expect(sanitized).not.toContain("<!-- Hidden comment");
|
||||
expect(sanitized).not.toContain("\u200B");
|
||||
expect(sanitized).toContain("[REDACTED_GITHUB_TOKEN]");
|
||||
expect(sanitized).toContain("Here's some text with a token:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripHtmlComments (legacy)", () => {
|
||||
it("should remove HTML comments", () => {
|
||||
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
||||
|
||||
Reference in New Issue
Block a user