mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
2 Commits
v0.0.58
...
feat/ttyd-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7680c1d501 | ||
|
|
dee63efcf4 |
14
.github/workflows/claude.yml
vendored
14
.github/workflows/claude.yml
vendored
@@ -9,10 +9,21 @@ on:
|
|||||||
types: [opened, assigned]
|
types: [opened, assigned]
|
||||||
pull_request_review:
|
pull_request_review:
|
||||||
types: [submitted]
|
types: [submitted]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
cloudflare_tunnel_token:
|
||||||
|
description: 'Cloudflare tunnel token to expose Claude UI via browser'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
direct_prompt:
|
||||||
|
description: 'Direct instruction for Claude'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude:
|
claude:
|
||||||
if: |
|
if: |
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
(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_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
@@ -37,3 +48,6 @@ jobs:
|
|||||||
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
allowed_tools: "Bash(bun install),Bash(bun test:*),Bash(bun run format),Bash(bun typecheck)"
|
||||||
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
custom_instructions: "You have also been granted tools for editing files and running bun commands (install, run, test, typecheck) for testing your changes: bun install, bun test, bun run format, bun typecheck."
|
||||||
model: "claude-opus-4-1-20250805"
|
model: "claude-opus-4-1-20250805"
|
||||||
|
cloudflare_tunnel_token: ${{ github.event.inputs.cloudflare_tunnel_token }}
|
||||||
|
direct_prompt: ${{ github.event.inputs.direct_prompt }}
|
||||||
|
mode: ${{ github.event_name == 'workflow_dispatch' && 'agent' || 'tag' }}
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -14,19 +14,6 @@ A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs an
|
|||||||
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude completes tasks
|
||||||
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
- 🏃 **Runs on Your Infrastructure**: The action executes entirely on your own GitHub runner (Anthropic API calls go to your chosen provider)
|
||||||
|
|
||||||
## ⚠️ **BREAKING CHANGES COMING IN v1.0** ⚠️
|
|
||||||
|
|
||||||
**We're planning a major update that will significantly change how this action works.** The new version will:
|
|
||||||
|
|
||||||
- ✨ Automatically select the appropriate mode (no more `mode` input)
|
|
||||||
- 🔧 Simplify configuration with unified `prompt` and `claude_args`
|
|
||||||
- 🚀 Align more closely with the Claude Code SDK capabilities
|
|
||||||
- 💥 Remove multiple inputs like `direct_prompt`, `custom_instructions`, and others
|
|
||||||
|
|
||||||
**[→ Read the full v1.0 roadmap and provide feedback](https://github.com/anthropics/claude-code-action/discussions/428)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.
|
The easiest way to set up this action is through [Claude Code](https://claude.ai/code) in the terminal. Just open `claude` and run `/install-github-app`.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
|
|||||||
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services
|
||||||
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
|
- **Ability to disable commit signing** - Option to turn off GPG signing for environments where it's not required. This will enable Claude to use normal `git` bash commands for committing. This will likely become the default behavior once added.
|
||||||
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
|
- **Better code review behavior** - Support inline comments on specific lines, provide higher quality reviews with more actionable feedback
|
||||||
- ~**Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude~
|
- **Support triggering @claude from bot users** - Allow automation and bot accounts to invoke Claude
|
||||||
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
|
- **Customizable base prompts** - Full control over Claude's initial context with template variables like `$PR_COMMENTS`, `$PR_FILES`, etc. Users can replace our default prompt entirely while still accessing key contextual data
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
12
action.yml
12
action.yml
@@ -23,10 +23,6 @@ inputs:
|
|||||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||||
required: false
|
required: false
|
||||||
default: "claude/"
|
default: "claude/"
|
||||||
allowed_bots:
|
|
||||||
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
# Mode configuration
|
# Mode configuration
|
||||||
mode:
|
mode:
|
||||||
@@ -118,6 +114,10 @@ inputs:
|
|||||||
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
cloudflare_tunnel_token:
|
||||||
|
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
execution_file:
|
execution_file:
|
||||||
@@ -160,7 +160,6 @@ runs:
|
|||||||
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
OVERRIDE_PROMPT: ${{ inputs.override_prompt }}
|
||||||
MCP_CONFIG: ${{ inputs.mcp_config }}
|
MCP_CONFIG: ${{ inputs.mcp_config }}
|
||||||
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||||
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
|
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
|
||||||
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
|
||||||
@@ -177,7 +176,7 @@ runs:
|
|||||||
echo "Base-action dependencies installed"
|
echo "Base-action dependencies installed"
|
||||||
cd -
|
cd -
|
||||||
# Install Claude Code globally
|
# Install Claude Code globally
|
||||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.81
|
bun install -g @anthropic-ai/claude-code@1.0.70
|
||||||
|
|
||||||
- name: Setup Network Restrictions
|
- name: Setup Network Restrictions
|
||||||
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
|
||||||
@@ -211,6 +210,7 @@ runs:
|
|||||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ github.action_path }}/slash-commands
|
||||||
|
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
|
||||||
|
|
||||||
# Model configuration
|
# Model configuration
|
||||||
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
ANTHROPIC_MODEL: ${{ inputs.model || inputs.anthropic_model }}
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ inputs:
|
|||||||
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
description: "Whether to use Node.js dependency caching (set to true only for Node.js projects with lock files)"
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
|
cloudflare_tunnel_token:
|
||||||
|
description: "Cloudflare tunnel token to expose Claude UI via browser (optional)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
conclusion:
|
conclusion:
|
||||||
@@ -118,7 +122,7 @@ runs:
|
|||||||
|
|
||||||
- name: Install Claude Code
|
- name: Install Claude Code
|
||||||
shell: bash
|
shell: bash
|
||||||
run: curl -fsSL https://claude.ai/install.sh | bash -s 1.0.81
|
run: bun install -g @anthropic-ai/claude-code@1.0.70
|
||||||
|
|
||||||
- name: Run Claude Code Action
|
- name: Run Claude Code Action
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -147,6 +151,7 @@ runs:
|
|||||||
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
INPUT_CLAUDE_ENV: ${{ inputs.claude_env }}
|
||||||
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
INPUT_FALLBACK_MODEL: ${{ inputs.fallback_model }}
|
||||||
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
|
INPUT_EXPERIMENTAL_SLASH_COMMANDS_DIR: ${{ inputs.experimental_slash_commands_dir }}
|
||||||
|
INPUT_CLOUDFLARE_TUNNEL_TOKEN: ${{ inputs.cloudflare_tunnel_token }}
|
||||||
|
|
||||||
# Provider configuration
|
# Provider configuration
|
||||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt";
|
|||||||
import { runClaude } from "./run-claude";
|
import { runClaude } from "./run-claude";
|
||||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||||
import { validateEnvironmentVariables } from "./validate-env";
|
import { validateEnvironmentVariables } from "./validate-env";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@@ -21,7 +22,40 @@ async function run() {
|
|||||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
await runClaude(promptConfig.path, {
|
// Setup ttyd and cloudflared tunnel if token provided
|
||||||
|
let ttydProcess: any = null;
|
||||||
|
let cloudflaredProcess: any = null;
|
||||||
|
|
||||||
|
if (process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN) {
|
||||||
|
console.log("Setting up ttyd and cloudflared tunnel...");
|
||||||
|
|
||||||
|
// Start ttyd process in background
|
||||||
|
ttydProcess = spawn("ttyd", ["-p", "7681", "-i", "0.0.0.0", "claude"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
detached: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ttydProcess.on("error", (error: Error) => {
|
||||||
|
console.warn(`ttyd process error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start cloudflared tunnel
|
||||||
|
cloudflaredProcess = spawn("cloudflared", ["tunnel", "run", "--token", process.env.INPUT_CLOUDFLARE_TUNNEL_TOKEN], {
|
||||||
|
stdio: "inherit",
|
||||||
|
detached: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
cloudflaredProcess.on("error", (error: Error) => {
|
||||||
|
console.warn(`cloudflared process error: ${error.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Give processes time to start up
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
console.log("ttyd and cloudflared tunnel started");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runClaude(promptConfig.path, {
|
||||||
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
allowedTools: process.env.INPUT_ALLOWED_TOOLS,
|
||||||
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
disallowedTools: process.env.INPUT_DISALLOWED_TOOLS,
|
||||||
maxTurns: process.env.INPUT_MAX_TURNS,
|
maxTurns: process.env.INPUT_MAX_TURNS,
|
||||||
@@ -32,6 +66,23 @@ async function run() {
|
|||||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||||
model: process.env.ANTHROPIC_MODEL,
|
model: process.env.ANTHROPIC_MODEL,
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
// Clean up processes
|
||||||
|
if (ttydProcess) {
|
||||||
|
try {
|
||||||
|
ttydProcess.kill("SIGTERM");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to terminate ttyd process");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cloudflaredProcess) {
|
||||||
|
try {
|
||||||
|
cloudflaredProcess.kill("SIGTERM");
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to terminate cloudflared process");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setFailed(`Action failed with error: ${error}`);
|
core.setFailed(`Action failed with error: ${error}`);
|
||||||
core.setOutput("conclusion", "failure");
|
core.setOutput("conclusion", "failure");
|
||||||
|
|||||||
@@ -207,8 +207,15 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
|
|||||||
```yaml
|
```yaml
|
||||||
- uses: anthropics/claude-code-action@beta
|
- uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell"
|
allowed_tools: |
|
||||||
disallowed_tools: "TaskOutput,KillTask"
|
Bash(npm install)
|
||||||
|
Bash(npm run test)
|
||||||
|
Edit
|
||||||
|
Replace
|
||||||
|
NotebookEditCell
|
||||||
|
disallowed_tools: |
|
||||||
|
TaskOutput
|
||||||
|
KillTask
|
||||||
# ... other inputs
|
# ... other inputs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Access Control
|
## Access Control
|
||||||
|
|
||||||
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
- **Repository Access**: The action can only be triggered by users with write access to the repository
|
||||||
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
|
- **No Bot Triggers**: GitHub Apps and bots cannot trigger this action
|
||||||
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
|
||||||
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
|
||||||
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ jobs:
|
|||||||
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
|
||||||
# additional_permissions: |
|
# additional_permissions: |
|
||||||
# actions: read
|
# actions: read
|
||||||
# Optional: allow bot users to trigger the action
|
|
||||||
# allowed_bots: "dependabot[bot],renovate[bot]"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
@@ -78,7 +76,6 @@ jobs:
|
|||||||
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
|
||||||
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
|
||||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
|
||||||
|
|
||||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Claude Code
|
name: Claude PR Assistant
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
@@ -11,53 +11,38 @@ on:
|
|||||||
types: [submitted]
|
types: [submitted]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
claude:
|
claude-code-action:
|
||||||
if: |
|
if: |
|
||||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
(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_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: read
|
||||||
issues: write
|
issues: read
|
||||||
id-token: write
|
id-token: write
|
||||||
actions: read # Required for Claude to read CI results on PRs
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude PR Action
|
||||||
id: claude
|
|
||||||
uses: anthropics/claude-code-action@beta
|
uses: anthropics/claude-code-action@beta
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
# Or use OAuth token instead:
|
||||||
# This is an optional setting that allows Claude to read CI results on PRs
|
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
additional_permissions: |
|
timeout_minutes: "60"
|
||||||
actions: read
|
# mode: tag # Default: responds to @claude mentions
|
||||||
|
# Optional: Restrict network access to specific domains only
|
||||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4.1)
|
# experimental_allowed_domains: |
|
||||||
# model: "claude-opus-4-1-20250805"
|
# .anthropic.com
|
||||||
|
# .github.com
|
||||||
# Optional: Customize the trigger phrase (default: @claude)
|
# api.github.com
|
||||||
# trigger_phrase: "/claude"
|
# .githubusercontent.com
|
||||||
|
# bun.sh
|
||||||
# Optional: Trigger when specific user is assigned to an issue
|
# registry.npmjs.org
|
||||||
# assignee_trigger: "claude-bot"
|
# .blob.core.windows.net
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ type BaseContext = {
|
|||||||
useStickyComment: boolean;
|
useStickyComment: boolean;
|
||||||
additionalPermissions: Map<string, string>;
|
additionalPermissions: Map<string, string>;
|
||||||
useCommitSigning: boolean;
|
useCommitSigning: boolean;
|
||||||
allowedBots: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,7 +136,6 @@ export function parseGitHubContext(): GitHubContext {
|
|||||||
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
process.env.ADDITIONAL_PERMISSIONS ?? "",
|
||||||
),
|
),
|
||||||
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
|
||||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export async function setupGitHubToken(): Promise<string> {
|
|||||||
return appToken;
|
return appToken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
core.setFailed(
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,41 +58,6 @@ export function sanitizeContent(content: string): string {
|
|||||||
content = stripMarkdownLinkTitles(content);
|
content = stripMarkdownLinkTitles(content);
|
||||||
content = stripHiddenAttributes(content);
|
content = stripHiddenAttributes(content);
|
||||||
content = normalizeHtmlEntities(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;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,42 +21,9 @@ export async function checkHumanActor(
|
|||||||
|
|
||||||
console.log(`Actor type: ${actorType}`);
|
console.log(`Actor type: ${actorType}`);
|
||||||
|
|
||||||
// Check bot permissions if actor is not a User
|
|
||||||
if (actorType !== "User") {
|
if (actorType !== "User") {
|
||||||
const allowedBots = githubContext.inputs.allowedBots;
|
|
||||||
|
|
||||||
// Check if all bots are allowed
|
|
||||||
if (allowedBots.trim() === "*") {
|
|
||||||
console.log(
|
|
||||||
`All bots are allowed, skipping human actor check for: ${githubContext.actor}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse allowed bots list
|
|
||||||
const allowedBotsList = allowedBots
|
|
||||||
.split(",")
|
|
||||||
.map((bot) =>
|
|
||||||
bot
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\[bot\]$/, ""),
|
|
||||||
)
|
|
||||||
.filter((bot) => bot.length > 0);
|
|
||||||
|
|
||||||
const botName = githubContext.actor.toLowerCase().replace(/\[bot\]$/, "");
|
|
||||||
|
|
||||||
// Check if specific bot is allowed
|
|
||||||
if (allowedBotsList.includes(botName)) {
|
|
||||||
console.log(
|
|
||||||
`Bot ${botName} is in allowed list, skipping human actor check`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bot not allowed
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Workflow initiated by non-human actor: ${botName} (type: ${actorType}). Add bot to allowed_bots list or use '*' to allow all bots.`,
|
`Workflow initiated by non-human actor: ${githubContext.actor} (type: ${actorType}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ export async function checkWritePermissions(
|
|||||||
try {
|
try {
|
||||||
core.info(`Checking permissions for actor: ${actor}`);
|
core.info(`Checking permissions for actor: ${actor}`);
|
||||||
|
|
||||||
// Check if the actor is a GitHub App (bot user)
|
|
||||||
if (actor.endsWith("[bot]")) {
|
|
||||||
core.info(`Actor is a GitHub App: ${actor}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permissions directly using the permission endpoint
|
// Check permissions directly using the permission endpoint
|
||||||
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
const response = await octokit.repos.getCollaboratorPermissionLevel({
|
||||||
owner: repository.owner,
|
owner: repository.owner,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { z } from "zod";
|
|||||||
import { GITHUB_API_URL } from "../github/api/config";
|
import { GITHUB_API_URL } from "../github/api/config";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
import { updateClaudeComment } from "../github/operations/comments/update-claude-comment";
|
||||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
|
||||||
|
|
||||||
// Get repository information from environment variables
|
// Get repository information from environment variables
|
||||||
const REPO_OWNER = process.env.REPO_OWNER;
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
@@ -55,13 +54,11 @@ server.tool(
|
|||||||
const isPullRequestReviewComment =
|
const isPullRequestReviewComment =
|
||||||
eventName === "pull_request_review_comment";
|
eventName === "pull_request_review_comment";
|
||||||
|
|
||||||
const sanitizedBody = sanitizeContent(body);
|
|
||||||
|
|
||||||
const result = await updateClaudeComment(octokit, {
|
const result = await updateClaudeComment(octokit, {
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
commentId,
|
commentId,
|
||||||
body: sanitizedBody,
|
body,
|
||||||
isPullRequestReviewComment,
|
isPullRequestReviewComment,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
import { sanitizeContent } from "../github/utils/sanitizer";
|
|
||||||
|
|
||||||
// Get repository and PR information from environment variables
|
// Get repository and PR information from environment variables
|
||||||
const REPO_OWNER = process.env.REPO_OWNER;
|
const REPO_OWNER = process.env.REPO_OWNER;
|
||||||
@@ -42,14 +41,12 @@ server.tool(
|
|||||||
),
|
),
|
||||||
line: z
|
line: z
|
||||||
.number()
|
.number()
|
||||||
.nonnegative()
|
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"Line number for single-line comments (required if startLine is not provided)",
|
"Line number for single-line comments (required if startLine is not provided)",
|
||||||
),
|
),
|
||||||
startLine: z
|
startLine: z
|
||||||
.number()
|
.number()
|
||||||
.nonnegative()
|
|
||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
"Start line for multi-line comments (use with line parameter for the end line)",
|
"Start line for multi-line comments (use with line parameter for the end line)",
|
||||||
@@ -82,9 +79,6 @@ server.tool(
|
|||||||
|
|
||||||
const octokit = createOctokit(githubToken).rest;
|
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
|
// Validate that either line or both startLine and line are provided
|
||||||
if (!line && !startLine) {
|
if (!line && !startLine) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -108,7 +102,7 @@ server.tool(
|
|||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
pull_number,
|
pull_number,
|
||||||
body: sanitizedBody,
|
body,
|
||||||
path,
|
path,
|
||||||
side: side || "RIGHT",
|
side: side || "RIGHT",
|
||||||
commit_id: commit_id || pr.data.head.sha,
|
commit_id: commit_id || pr.data.head.sha,
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ export const agentMode: Mode = {
|
|||||||
...context.inputs.disallowedTools,
|
...context.inputs.disallowedTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
// Export as INPUT_ prefixed variables for the base action
|
||||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||||
|
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||||
|
|
||||||
// Agent mode uses a minimal MCP configuration
|
// Agent mode uses a minimal MCP configuration
|
||||||
// We don't need comment servers or PR-specific tools for automation
|
// We don't need comment servers or PR-specific tools for automation
|
||||||
|
|||||||
@@ -297,8 +297,9 @@ This ensures users get value from the review even before checking individual inl
|
|||||||
...context.inputs.disallowedTools,
|
...context.inputs.disallowedTools,
|
||||||
];
|
];
|
||||||
|
|
||||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
// Export as INPUT_ prefixed variables for the base action
|
||||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
core.exportVariable("INPUT_ALLOWED_TOOLS", allowedTools.join(","));
|
||||||
|
core.exportVariable("INPUT_DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||||
|
|
||||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||||
const mcpConfig = await prepareMcpConfig({
|
const mcpConfig = await prepareMcpConfig({
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
|
|
||||||
import { describe, test, expect } from "bun:test";
|
|
||||||
import { checkHumanActor } from "../src/github/validation/actor";
|
|
||||||
import type { Octokit } from "@octokit/rest";
|
|
||||||
import { createMockContext } from "./mockContext";
|
|
||||||
|
|
||||||
function createMockOctokit(userType: string): Octokit {
|
|
||||||
return {
|
|
||||||
users: {
|
|
||||||
getByUsername: async () => ({
|
|
||||||
data: {
|
|
||||||
type: userType,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
} as unknown as Octokit;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("checkHumanActor", () => {
|
|
||||||
test("should pass for human actor", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("User");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "human-user";
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkHumanActor(mockOctokit, context),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error for bot actor when not allowed", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("Bot");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "test-bot[bot]";
|
|
||||||
context.inputs.allowedBots = "";
|
|
||||||
|
|
||||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
||||||
"Workflow initiated by non-human actor: test-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass for bot actor when all bots allowed", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("Bot");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "test-bot[bot]";
|
|
||||||
context.inputs.allowedBots = "*";
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkHumanActor(mockOctokit, context),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass for specific bot when in allowed list", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("Bot");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "dependabot[bot]";
|
|
||||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkHumanActor(mockOctokit, context),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should pass for specific bot when in allowed list (without [bot])", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("Bot");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "dependabot[bot]";
|
|
||||||
context.inputs.allowedBots = "dependabot,renovate";
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkHumanActor(mockOctokit, context),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error for bot not in allowed list", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("Bot");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "other-bot[bot]";
|
|
||||||
context.inputs.allowedBots = "dependabot[bot],renovate[bot]";
|
|
||||||
|
|
||||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
||||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error for bot not in allowed list (without [bot])", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("Bot");
|
|
||||||
const context = createMockContext();
|
|
||||||
context.actor = "other-bot[bot]";
|
|
||||||
context.inputs.allowedBots = "dependabot,renovate";
|
|
||||||
|
|
||||||
await expect(checkHumanActor(mockOctokit, context)).rejects.toThrow(
|
|
||||||
"Workflow initiated by non-human actor: other-bot (type: Bot). Add bot to allowed_bots list or use '*' to allow all bots.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -37,7 +37,6 @@ describe("prepareMcpConfig", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ const defaultInputs = {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map<string, string>(),
|
additionalPermissions: new Map<string, string>(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRepository = {
|
const defaultRepository = {
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
import { describe, test, expect, beforeEach } from "bun:test";
|
||||||
import { agentMode } from "../../src/modes/agent";
|
import { agentMode } from "../../src/modes/agent";
|
||||||
import type { GitHubContext } from "../../src/github/context";
|
import type { GitHubContext } from "../../src/github/context";
|
||||||
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||||
import * as core from "@actions/core";
|
|
||||||
|
|
||||||
describe("Agent Mode", () => {
|
describe("Agent Mode", () => {
|
||||||
let mockContext: GitHubContext;
|
let mockContext: GitHubContext;
|
||||||
let exportVariableSpy: any;
|
|
||||||
let setOutputSpy: any;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockContext = createMockAutomationContext({
|
mockContext = createMockAutomationContext({
|
||||||
eventName: "workflow_dispatch",
|
eventName: "workflow_dispatch",
|
||||||
});
|
});
|
||||||
exportVariableSpy = spyOn(core, "exportVariable").mockImplementation(
|
|
||||||
() => {},
|
|
||||||
);
|
|
||||||
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
exportVariableSpy?.mockClear();
|
|
||||||
setOutputSpy?.mockClear();
|
|
||||||
exportVariableSpy?.mockRestore();
|
|
||||||
setOutputSpy?.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent mode has correct properties", () => {
|
test("agent mode has correct properties", () => {
|
||||||
@@ -70,67 +56,4 @@ describe("Agent Mode", () => {
|
|||||||
expect(agentMode.shouldTrigger(context)).toBe(false);
|
expect(agentMode.shouldTrigger(context)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prepare method sets up tools environment variables correctly", async () => {
|
|
||||||
// Clear any previous calls before this test
|
|
||||||
exportVariableSpy.mockClear();
|
|
||||||
setOutputSpy.mockClear();
|
|
||||||
|
|
||||||
const contextWithCustomTools = createMockAutomationContext({
|
|
||||||
eventName: "workflow_dispatch",
|
|
||||||
});
|
|
||||||
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
|
|
||||||
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
|
|
||||||
|
|
||||||
const mockOctokit = {} as any;
|
|
||||||
const result = await agentMode.prepare({
|
|
||||||
context: contextWithCustomTools,
|
|
||||||
octokit: mockOctokit,
|
|
||||||
githubToken: "test-token",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
|
|
||||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
|
||||||
"ALLOWED_TOOLS",
|
|
||||||
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
|
|
||||||
);
|
|
||||||
expect(exportVariableSpy).toHaveBeenCalledWith(
|
|
||||||
"DISALLOWED_TOOLS",
|
|
||||||
"WebSearch,WebFetch,BadTool",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify MCP config is set
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
|
||||||
|
|
||||||
// Verify return structure
|
|
||||||
expect(result).toEqual({
|
|
||||||
commentId: undefined,
|
|
||||||
branchInfo: {
|
|
||||||
baseBranch: "",
|
|
||||||
currentBranch: "",
|
|
||||||
claudeBranch: undefined,
|
|
||||||
},
|
|
||||||
mcpConfig: expect.any(String),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("prepare method creates prompt file with correct content", async () => {
|
|
||||||
const contextWithPrompts = createMockAutomationContext({
|
|
||||||
eventName: "workflow_dispatch",
|
|
||||||
});
|
|
||||||
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
|
|
||||||
contextWithPrompts.inputs.directPrompt =
|
|
||||||
"Direct prompt (should be ignored)";
|
|
||||||
|
|
||||||
const mockOctokit = {} as any;
|
|
||||||
await agentMode.prepare({
|
|
||||||
context: contextWithPrompts,
|
|
||||||
octokit: mockOctokit,
|
|
||||||
githubToken: "test-token",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: We can't easily test file creation in this unit test,
|
|
||||||
// but we can verify the method completes without errors
|
|
||||||
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ describe("checkWritePermissions", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,16 +126,6 @@ describe("checkWritePermissions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return true for bot user", async () => {
|
|
||||||
const mockOctokit = createMockOctokit("none");
|
|
||||||
const context = createContext();
|
|
||||||
context.actor = "test-bot[bot]";
|
|
||||||
|
|
||||||
const result = await checkWritePermissions(mockOctokit, context);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when permission check fails", async () => {
|
test("should throw error when permission check fails", async () => {
|
||||||
const error = new Error("API error");
|
const error = new Error("API error");
|
||||||
const mockOctokit = {
|
const mockOctokit = {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
normalizeHtmlEntities,
|
normalizeHtmlEntities,
|
||||||
sanitizeContent,
|
sanitizeContent,
|
||||||
stripHtmlComments,
|
stripHtmlComments,
|
||||||
redactGitHubTokens,
|
|
||||||
} from "../src/github/utils/sanitizer";
|
} from "../src/github/utils/sanitizer";
|
||||||
|
|
||||||
describe("stripInvisibleCharacters", () => {
|
describe("stripInvisibleCharacters", () => {
|
||||||
@@ -243,109 +242,6 @@ 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)", () => {
|
describe("stripHtmlComments (legacy)", () => {
|
||||||
it("should remove HTML comments", () => {
|
it("should remove HTML comments", () => {
|
||||||
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
expect(stripHtmlComments("Hello <!-- example -->World")).toBe(
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ describe("checkContainsTrigger", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -75,7 +74,6 @@ describe("checkContainsTrigger", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
@@ -293,7 +291,6 @@ describe("checkContainsTrigger", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -328,7 +325,6 @@ describe("checkContainsTrigger", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(true);
|
expect(checkContainsTrigger(context)).toBe(true);
|
||||||
@@ -363,7 +359,6 @@ describe("checkContainsTrigger", () => {
|
|||||||
useStickyComment: false,
|
useStickyComment: false,
|
||||||
additionalPermissions: new Map(),
|
additionalPermissions: new Map(),
|
||||||
useCommitSigning: false,
|
useCommitSigning: false,
|
||||||
allowedBots: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(checkContainsTrigger(context)).toBe(false);
|
expect(checkContainsTrigger(context)).toBe(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user