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.57
...
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
|
||||||
bun install -g @anthropic-ai/claude-code@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: bun install -g @anthropic-ai/claude-code@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)
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -41,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)",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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