mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
47 Commits
ashwin/bra
...
ashwin/inl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d4c86b8ff | ||
|
|
3ffb87fe89 | ||
|
|
d4c09790f5 | ||
|
|
5033c581bb | ||
|
|
f8749bd14b | ||
|
|
f30f5eecfc | ||
|
|
fc4013af38 | ||
|
|
96524b7ffe | ||
|
|
fd20c95358 | ||
|
|
d808160c26 | ||
|
|
3eacedbeb7 | ||
|
|
f52f12eba5 | ||
|
|
4a85933f25 | ||
|
|
ba6edd55ef | ||
|
|
06461dddff | ||
|
|
c2a94eead0 | ||
|
|
1c0c3eaced | ||
|
|
23d2d6c6b4 | ||
|
|
e8bad57227 | ||
|
|
0a6d62601b | ||
|
|
777ffcbfc9 | ||
|
|
dc58efed33 | ||
|
|
e5437bfbc5 | ||
|
|
b2dd1006a0 | ||
|
|
ac1a3207f3 | ||
|
|
521d069da7 | ||
|
|
7e4b782d5f | ||
|
|
4fb0ef3be0 | ||
|
|
14ac8aa20e | ||
|
|
90d189f3ab | ||
|
|
9c09b26b2d | ||
|
|
2086c977a5 | ||
|
|
851ef5b84e | ||
|
|
1ce8153c18 | ||
|
|
00391ab25e | ||
|
|
426380f01b | ||
|
|
77f51d2905 | ||
|
|
7e5b42b197 | ||
|
|
1b7c7a77d3 | ||
|
|
bd70a3ef2b | ||
|
|
f4954b5256 | ||
|
|
93f8ab56c2 | ||
|
|
93028b410e | ||
|
|
838d4d9d25 | ||
|
|
7ed3b616d5 | ||
|
|
09ea2f00e1 | ||
|
|
455b943dd7 |
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
|
||||
2
.github/workflows/claude-review.yml
vendored
2
.github/workflows/claude-review.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
38
.github/workflows/claude-test.yml
vendored
38
.github/workflows/claude-test.yml
vendored
@@ -1,38 +0,0 @@
|
||||
# Test workflow for km-anthropic fork (v1-dev branch)
|
||||
# This tests the fork implementation, not the main repo
|
||||
name: Claude Code (Fork Test)
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
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') ||
|
||||
contains(github.event.issue.title, '@claude')
|
||||
))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write # Required for OIDC token exchange
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Claude Code
|
||||
uses: km-anthropic/claude-code-action@v1-dev
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
next_version: ${{ steps.next_version.outputs.next_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout base-action repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: anthropics/claude-code-base-action
|
||||
token: ${{ secrets.CLAUDE_CODE_BASE_ACTION_PAT }}
|
||||
|
||||
4
.github/workflows/test-settings.yml
vendored
4
.github/workflows/test-settings.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "This should not work"
|
||||
Run the command `echo $HOME` to check the home directory path
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
settings: |
|
||||
{
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
uses: ./base-action
|
||||
with:
|
||||
prompt: |
|
||||
Use Bash to echo "This should not work from file"
|
||||
Run the command `echo $HOME` to check the home directory path
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
settings: "test-settings.json"
|
||||
|
||||
|
||||
10
action.yml
10
action.yml
@@ -101,6 +101,10 @@ inputs:
|
||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||
required: false
|
||||
default: ""
|
||||
plugins:
|
||||
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
execution_file:
|
||||
@@ -163,6 +167,7 @@ runs:
|
||||
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
|
||||
CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||
ALL_INPUTS: ${{ toJson(inputs) }}
|
||||
PLUGINS: ${{ inputs.plugins }}
|
||||
|
||||
- name: Install Base Action Dependencies
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
@@ -177,7 +182,7 @@ runs:
|
||||
# Install Claude Code if no custom executable is provided
|
||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||
echo "Installing Claude Code..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.113
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.27
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
else
|
||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||
@@ -213,6 +218,7 @@ runs:
|
||||
INPUT_ACTION_INPUTS_PRESENT: ${{ steps.prepare.outputs.action_inputs_present }}
|
||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||
|
||||
# Model configuration
|
||||
GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }}
|
||||
@@ -259,7 +265,7 @@ runs:
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
TRIGGER_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
CLAUDE_BRANCH: ${{ steps.prepare.outputs.CLAUDE_BRANCH }}
|
||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }}
|
||||
IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_target' || github.event_name == 'pull_request_review_comment' }}
|
||||
BASE_BRANCH: ${{ steps.prepare.outputs.BASE_BRANCH }}
|
||||
CLAUDE_SUCCESS: ${{ steps.claude-code.outputs.conclusion == 'success' }}
|
||||
OUTPUT_FILE: ${{ steps.claude-code.outputs.execution_file || '' }}
|
||||
|
||||
@@ -336,7 +336,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ inputs:
|
||||
description: "Optional path to a custom Bun executable. If provided, skips automatic Bun installation and uses this executable instead. WARNING: Using an incompatible version may cause problems if the action requires specific Bun features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
|
||||
required: false
|
||||
default: ""
|
||||
plugins:
|
||||
description: "Comma-separated list of Claude Code plugin names to install (e.g., 'plugin1,plugin2,plugin3')"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
outputs:
|
||||
conclusion:
|
||||
@@ -99,7 +103,7 @@ runs:
|
||||
run: |
|
||||
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
|
||||
echo "Installing Claude Code..."
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.113
|
||||
curl -fsSL https://claude.ai/install.sh | bash -s 2.0.27
|
||||
else
|
||||
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
|
||||
# Add the directory containing the custom executable to PATH
|
||||
@@ -126,6 +130,7 @@ runs:
|
||||
INPUT_CLAUDE_ARGS: ${{ inputs.claude_args }}
|
||||
INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE: ${{ inputs.path_to_claude_code_executable }}
|
||||
INPUT_PATH_TO_BUN_EXECUTABLE: ${{ inputs.path_to_bun_executable }}
|
||||
INPUT_PLUGINS: ${{ inputs.plugins }}
|
||||
|
||||
# Provider configuration
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-7aced2b"
|
||||
"ghcr.io/github/github-mcp-server:sha-23fa0dd"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { preparePrompt } from "./prepare-prompt";
|
||||
import { runClaude } from "./run-claude";
|
||||
import { setupClaudeCodeSettings } from "./setup-claude-code-settings";
|
||||
import { validateEnvironmentVariables } from "./validate-env";
|
||||
import { installPlugins } from "./install-plugins";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -15,6 +16,12 @@ async function run() {
|
||||
undefined, // homeDir
|
||||
);
|
||||
|
||||
// Install Claude Code plugins if specified
|
||||
await installPlugins(
|
||||
process.env.INPUT_PLUGINS,
|
||||
process.env.INPUT_PATH_TO_CLAUDE_CODE_EXECUTABLE,
|
||||
);
|
||||
|
||||
const promptConfig = await preparePrompt({
|
||||
prompt: process.env.INPUT_PROMPT || "",
|
||||
promptFile: process.env.INPUT_PROMPT_FILE || "",
|
||||
|
||||
155
base-action/src/install-plugins.ts
Normal file
155
base-action/src/install-plugins.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
|
||||
const PLUGIN_NAME_REGEX = /^[@a-zA-Z0-9_\-\/\.]+$/;
|
||||
const MAX_PLUGIN_NAME_LENGTH = 512;
|
||||
const CLAUDE_CODE_MARKETPLACE_URL =
|
||||
"https://github.com/anthropics/claude-code.git";
|
||||
const PATH_TRAVERSAL_REGEX =
|
||||
/\.\.\/|\/\.\.|\.\/|\/\.|(?:^|\/)\.\.$|(?:^|\/)\.$|\.\.(?![0-9])/;
|
||||
|
||||
/**
|
||||
* Validates a plugin name for security issues
|
||||
* @param pluginName - The plugin name to validate
|
||||
* @throws {Error} If the plugin name is invalid
|
||||
*/
|
||||
function validatePluginName(pluginName: string): void {
|
||||
// Normalize Unicode to prevent homoglyph attacks (e.g., fullwidth dots, Unicode slashes)
|
||||
const normalized = pluginName.normalize("NFC");
|
||||
|
||||
if (normalized.length > MAX_PLUGIN_NAME_LENGTH) {
|
||||
throw new Error(`Plugin name too long: ${normalized.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
if (!PLUGIN_NAME_REGEX.test(normalized)) {
|
||||
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
||||
}
|
||||
|
||||
// Prevent path traversal attacks with single efficient regex check
|
||||
if (PATH_TRAVERSAL_REGEX.test(normalized)) {
|
||||
throw new Error(`Invalid plugin name format: ${pluginName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comma-separated list of plugin names and return an array of trimmed, non-empty plugin names
|
||||
* Validates plugin names to prevent command injection and path traversal attacks
|
||||
* Allows: letters, numbers, @, -, _, /, . (common npm/scoped package characters)
|
||||
* Disallows: path traversal (../, ./), shell metacharacters, and consecutive dots
|
||||
*/
|
||||
function parsePlugins(plugins?: string): string[] {
|
||||
const trimmedPlugins = plugins?.trim();
|
||||
|
||||
if (!trimmedPlugins) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Split by comma and process each plugin
|
||||
return trimmedPlugins
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => {
|
||||
if (p.length === 0) return false;
|
||||
|
||||
validatePluginName(p);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a Claude Code CLI command with proper error handling
|
||||
* @param claudeExecutable - Path to the Claude executable
|
||||
* @param args - Command arguments to pass to the executable
|
||||
* @param errorContext - Context string for error messages (e.g., "Failed to install plugin 'foo'")
|
||||
* @returns Promise that resolves when the command completes successfully
|
||||
* @throws {Error} If the command fails to execute
|
||||
*/
|
||||
async function executeClaudeCommand(
|
||||
claudeExecutable: string,
|
||||
args: string[],
|
||||
errorContext: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess: ChildProcess = spawn(claudeExecutable, args, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
childProcess.on("close", (code: number | null) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else if (code === null) {
|
||||
reject(new Error(`${errorContext}: process terminated by signal`));
|
||||
} else {
|
||||
reject(new Error(`${errorContext} (exit code: ${code})`));
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on("error", (err: Error) => {
|
||||
reject(new Error(`${errorContext}: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs a single Claude Code plugin
|
||||
*/
|
||||
async function installPlugin(
|
||||
pluginName: string,
|
||||
claudeExecutable: string,
|
||||
): Promise<void> {
|
||||
return executeClaudeCommand(
|
||||
claudeExecutable,
|
||||
["plugin", "install", pluginName],
|
||||
`Failed to install plugin '${pluginName}'`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the Claude Code marketplace
|
||||
* @param claudeExecutable - Path to the Claude executable
|
||||
* @returns Promise that resolves when the marketplace add command completes
|
||||
* @throws {Error} If the command fails to execute
|
||||
*/
|
||||
async function addMarketplace(claudeExecutable: string): Promise<void> {
|
||||
console.log("Adding Claude Code marketplace...");
|
||||
|
||||
return executeClaudeCommand(
|
||||
claudeExecutable,
|
||||
["plugin", "marketplace", "add", CLAUDE_CODE_MARKETPLACE_URL],
|
||||
"Failed to add marketplace",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs Claude Code plugins from a comma-separated list
|
||||
* @param pluginsInput - Comma-separated list of plugin names, or undefined/empty to skip installation
|
||||
* @param claudeExecutable - Path to the Claude executable (defaults to "claude")
|
||||
* @returns Promise that resolves when all plugins are installed
|
||||
* @throws {Error} If any plugin fails validation or installation (stops on first error)
|
||||
*/
|
||||
export async function installPlugins(
|
||||
pluginsInput: string | undefined,
|
||||
claudeExecutable?: string,
|
||||
): Promise<void> {
|
||||
const plugins = parsePlugins(pluginsInput);
|
||||
|
||||
if (plugins.length === 0) {
|
||||
console.log("No plugins to install");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve executable path with explicit fallback
|
||||
const resolvedExecutable = claudeExecutable || "claude";
|
||||
|
||||
// Add marketplace before installing plugins
|
||||
await addMarketplace(resolvedExecutable);
|
||||
|
||||
console.log(`Installing ${plugins.length} plugin(s)...`);
|
||||
|
||||
for (const plugin of plugins) {
|
||||
console.log(`Installing plugin: ${plugin}`);
|
||||
await installPlugin(plugin, resolvedExecutable);
|
||||
console.log(`✓ Successfully installed: ${plugin}`);
|
||||
}
|
||||
|
||||
console.log("All plugins installed successfully");
|
||||
}
|
||||
449
base-action/test/install-plugins.test.ts
Normal file
449
base-action/test/install-plugins.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect, mock, spyOn, afterEach } from "bun:test";
|
||||
import { installPlugins } from "../src/install-plugins";
|
||||
import * as childProcess from "child_process";
|
||||
|
||||
describe("installPlugins", () => {
|
||||
let spawnSpy: ReturnType<typeof spyOn> | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original spawn after each test
|
||||
if (spawnSpy) {
|
||||
spawnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
function createMockSpawn(
|
||||
exitCode: number | null = 0,
|
||||
shouldError: boolean = false,
|
||||
) {
|
||||
const mockProcess = {
|
||||
on: mock((event: string, handler: Function) => {
|
||||
if (event === "close" && !shouldError) {
|
||||
// Simulate successful close
|
||||
setTimeout(() => handler(exitCode), 0);
|
||||
} else if (event === "error" && shouldError) {
|
||||
// Simulate error
|
||||
setTimeout(() => handler(new Error("spawn error")), 0);
|
||||
}
|
||||
return mockProcess;
|
||||
}),
|
||||
};
|
||||
|
||||
spawnSpy = spyOn(childProcess, "spawn").mockImplementation(
|
||||
() => mockProcess as any,
|
||||
);
|
||||
return spawnSpy;
|
||||
}
|
||||
|
||||
test("should not call spawn when no plugins are specified", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not call spawn when plugins is undefined", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins(undefined);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not call spawn when plugins is only whitespace", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins(" ");
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should install a single plugin with default executable", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("test-plugin");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
// Second call: install plugin
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "test-plugin"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should install multiple plugins sequentially", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("plugin1,plugin2,plugin3");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(4);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
// Subsequent calls: install plugins
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "plugin1"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"claude",
|
||||
["plugin", "install", "plugin2"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
"claude",
|
||||
["plugin", "install", "plugin3"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should use custom claude executable path when provided", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("test-plugin", "/custom/path/to/claude");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/custom/path/to/claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
// Second call: install plugin
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/custom/path/to/claude",
|
||||
["plugin", "install", "test-plugin"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should trim whitespace from plugin names before installation", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins(" plugin1 , plugin2 ");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "plugin1"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"claude",
|
||||
["plugin", "install", "plugin2"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should skip empty entries in plugin list", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("plugin1,,plugin2");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "plugin1"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"claude",
|
||||
["plugin", "install", "plugin2"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle plugin installation error and throw", async () => {
|
||||
createMockSpawn(1, false); // Exit code 1
|
||||
|
||||
await expect(installPlugins("failing-plugin")).rejects.toThrow(
|
||||
"Failed to add marketplace (exit code: 1)",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle null exit code (process terminated by signal)", async () => {
|
||||
createMockSpawn(null, false); // Exit code null (terminated by signal)
|
||||
|
||||
await expect(installPlugins("terminated-plugin")).rejects.toThrow(
|
||||
"Failed to add marketplace: process terminated by signal",
|
||||
);
|
||||
});
|
||||
|
||||
test("should stop installation on first error", async () => {
|
||||
const spy = createMockSpawn(1, false); // Exit code 1
|
||||
|
||||
await expect(installPlugins("plugin1,plugin2,plugin3")).rejects.toThrow(
|
||||
"Failed to add marketplace (exit code: 1)",
|
||||
);
|
||||
|
||||
// Should only try to add marketplace before failing
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should handle plugins with special characters in names", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("org/plugin-name,@scope/plugin");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "org/plugin-name"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"claude",
|
||||
["plugin", "install", "@scope/plugin"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle spawn errors", async () => {
|
||||
createMockSpawn(0, true); // Trigger error event
|
||||
|
||||
await expect(installPlugins("test-plugin")).rejects.toThrow(
|
||||
"Failed to add marketplace: spawn error",
|
||||
);
|
||||
});
|
||||
|
||||
test("should install plugins with custom executable and multiple plugins", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("plugin-a,plugin-b", "/usr/local/bin/claude-custom");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"/usr/local/bin/claude-custom",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/usr/local/bin/claude-custom",
|
||||
["plugin", "install", "plugin-a"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"/usr/local/bin/claude-custom",
|
||||
["plugin", "install", "plugin-b"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should reject plugin names with command injection attempts", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
// Should throw due to invalid characters (semicolon and spaces)
|
||||
await expect(installPlugins("plugin-name; rm -rf /")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
// Mock should never be called because validation fails first
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject plugin names with path traversal using ../", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins("../../../malicious-plugin")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject plugin names with path traversal using ./", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins("./../../@scope/package")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject plugin names with consecutive dots", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins(".../.../package")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject plugin names with hidden path traversal", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins("package/../other")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should accept plugin names with single dots in version numbers", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("plugin-v1.0.2");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "plugin-v1.0.2"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should accept plugin names with multiple dots in semantic versions", async () => {
|
||||
const spy = createMockSpawn();
|
||||
await installPlugins("@scope/plugin-v1.0.0-beta.1");
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
// First call: add marketplace
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"claude",
|
||||
[
|
||||
"plugin",
|
||||
"marketplace",
|
||||
"add",
|
||||
"https://github.com/anthropics/claude-code.git",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
expect(spy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"claude",
|
||||
["plugin", "install", "@scope/plugin-v1.0.0-beta.1"],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("should reject Unicode homoglyph path traversal attempts", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
// Using fullwidth dots (U+FF0E) and fullwidth solidus (U+FF0F)
|
||||
await expect(installPlugins("../malicious")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject path traversal at end of path", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins("package/..")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject single dot directory reference", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins("package/.")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should reject path traversal in middle of path", async () => {
|
||||
const spy = createMockSpawn();
|
||||
|
||||
await expect(installPlugins("package/../other")).rejects.toThrow(
|
||||
"Invalid plugin name format",
|
||||
);
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ The action automatically detects which mode to use based on your configuration:
|
||||
|
||||
This action supports the following GitHub events ([learn more GitHub event triggers](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)):
|
||||
|
||||
- `pull_request` - When PRs are opened or synchronized
|
||||
- `pull_request` or `pull_request_target` - When PRs are opened or synchronized
|
||||
- `issue_comment` - When comments are created on issues or PRs
|
||||
- `pull_request_comment` - When comments are made on PR diffs
|
||||
- `issues` - When issues are opened or assigned
|
||||
|
||||
@@ -127,7 +127,7 @@ For performance, Claude uses shallow clones:
|
||||
If you need full history, you can configure this in your workflow before calling Claude in the `actions/checkout` step.
|
||||
|
||||
```
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
depth: 0 # will fetch full repo history
|
||||
```
|
||||
|
||||
|
||||
@@ -13,13 +13,28 @@
|
||||
- **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
|
||||
|
||||
## ⚠️ Prompt Injection Risks
|
||||
|
||||
**Beware of potential hidden markdown when tagging Claude on untrusted content.** External contributors may include hidden instructions through HTML comments, invisible characters, hidden attributes, or other techniques. The action sanitizes content by stripping HTML comments, invisible characters, markdown image alt text, hidden HTML attributes, and HTML entities, but new bypass techniques may emerge. We recommend reviewing the raw content of all input coming from external contributors before allowing Claude to process it.
|
||||
|
||||
## GitHub App Permissions
|
||||
|
||||
The [Claude Code GitHub app](https://github.com/apps/claude) requires these permissions:
|
||||
The [Claude Code GitHub app](https://github.com/apps/claude) requests the following permissions:
|
||||
|
||||
- **Pull Requests**: Read and write to create PRs and push changes
|
||||
- **Issues**: Read and write to respond to issues
|
||||
- **Contents**: Read and write to modify repository files
|
||||
### Currently Used Permissions
|
||||
|
||||
- **Contents** (Read & Write): For reading repository files and creating branches
|
||||
- **Pull Requests** (Read & Write): For reading PR data and creating/updating pull requests
|
||||
- **Issues** (Read & Write): For reading issue data and updating issue comments
|
||||
|
||||
### Permissions for Future Features
|
||||
|
||||
The following permissions are requested but not yet actively used. These will enable planned features in future releases:
|
||||
|
||||
- **Discussions** (Read & Write): For interaction with GitHub Discussions
|
||||
- **Actions** (Read): For accessing workflow run data and logs
|
||||
- **Checks** (Read): For reading check run results
|
||||
- **Workflows** (Read & Write): For triggering and managing GitHub Actions workflows
|
||||
|
||||
## Commit Signing
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -268,7 +268,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -456,7 +456,7 @@ jobs:
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -513,7 +513,7 @@ jobs:
|
||||
security-events: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ jobs:
|
||||
# --max-turns 10
|
||||
# --model claude-4-0-sonnet-20250805
|
||||
|
||||
# Optional: install Claude Code plugins
|
||||
# plugins: "plugin1,plugin2,plugin3"
|
||||
|
||||
# Optional: add custom trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
# Optional: add assignee trigger for issues
|
||||
@@ -47,33 +50,33 @@ jobs:
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Description | Required | Default |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||
| `claude_args` | Additional arguments to pass directly to Claude CLI (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | 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 | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| Input | Description | Required | Default |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - |
|
||||
| `prompt` | Instructions for Claude. Can be a direct prompt or custom template for automation workflows | No | - |
|
||||
| `track_progress` | Force tag mode with tracking comments. Only works with specific PR/issue events. Preserves GitHub context | No | `false` |
|
||||
| `claude_args` | Additional [arguments to pass directly to Claude CLI](https://docs.claude.com/en/docs/claude-code/cli-reference#cli-flags) (e.g., `--max-turns 10 --model claude-4-0-sonnet-20250805`) | No | "" |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
|
||||
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
|
||||
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
|
||||
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
|
||||
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
|
||||
| `settings` | Claude Code settings as JSON string or path to settings JSON file | 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 | "" |
|
||||
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
|
||||
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
|
||||
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |
|
||||
| `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
|
||||
| `allowed_non_write_users` | **⚠️ RISKY**: Comma-separated list of usernames to allow without write permissions, or '\*' for all users. Only works with `github_token` input. See [Security](./security.md) | No | "" |
|
||||
| `path_to_claude_code_executable` | Optional path to a custom Claude Code executable. Skips automatic installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `path_to_bun_executable` | Optional path to a custom Bun executable. Skips automatic Bun installation. Useful for Nix, custom containers, or specialized environments | No | "" |
|
||||
| `plugins` | Comma-separated list of Claude Code plugin names to install (e.g., `plugin1,plugin2,plugin3`). Plugins are installed before Claude Code execution | No | "" |
|
||||
|
||||
### Deprecated Inputs
|
||||
|
||||
@@ -90,6 +93,7 @@ These inputs are deprecated and will be removed in a future version:
|
||||
| `fallback_model` | **DEPRECATED**: Use `claude_args` with fallback configuration | Configure fallback in `claude_args` or `settings` |
|
||||
| `allowed_tools` | **DEPRECATED**: Use `claude_args` with `--allowedTools` instead | Use `claude_args: "--allowedTools Edit,Read,Write"` |
|
||||
| `disallowed_tools` | **DEPRECATED**: Use `claude_args` with `--disallowedTools` instead | Use `claude_args: "--disallowedTools WebSearch"` |
|
||||
| `mcp_config` | **DEPRECATED**: Use `claude_args` with `--mcp-config` instead | Use `claude_args: "--mcp-config '{...}'"` |
|
||||
| `claude_env` | **DEPRECATED**: Use `settings` with env configuration | Configure environment in `settings` JSON |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_branch }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 2 # Need at least 2 commits to analyze the latest
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
|
||||
};
|
||||
|
||||
case "pull_request":
|
||||
case "pull_request_target":
|
||||
return {
|
||||
eventType: "PULL_REQUEST",
|
||||
triggerContext: eventData.eventAction
|
||||
@@ -683,7 +684,7 @@ ${
|
||||
- Display the todo list as a checklist in the GitHub comment and mark things off as you go.
|
||||
- REPOSITORY SETUP INSTRUCTIONS: The repository's CLAUDE.md file(s) contain critical repo-specific setup instructions, development guidelines, and preferences. Always read and follow these files, particularly the root CLAUDE.md, as they provide essential context for working with the codebase effectively.
|
||||
- Use h3 headers (###) for section titles in your comments, not h1 headers (#).
|
||||
- Your comment must always include the job run link (and branch link if there is one) at the bottom.
|
||||
- Your comment must always include the job run link in the format "[View job run](${GITHUB_SERVER_URL}/${context.repository}/actions/runs/${process.env.GITHUB_RUN_ID})" at the bottom of your response (branch link if there is one should also be included there).
|
||||
|
||||
CAPABILITIES AND LIMITATIONS:
|
||||
When users ask you to do something, be aware of what you can and cannot do. This section helps you understand how to respond when users request actions outside your scope.
|
||||
@@ -708,7 +709,7 @@ What You CANNOT Do:
|
||||
- Modify files in the .github/workflows directory (GitHub App permissions do not allow workflow modifications)
|
||||
|
||||
When users ask you to perform actions you cannot do, politely explain the limitation and, when applicable, direct them to the FAQ for more information and workarounds:
|
||||
"I'm unable to [specific action] due to [reason]. You can find more information and potential workarounds in the [FAQ](https://github.com/anthropics/claude-code-action/blob/main/FAQ.md)."
|
||||
"I'm unable to [specific action] due to [reason]. You can find more information and potential workarounds in the [FAQ](https://github.com/anthropics/claude-code-action/blob/main/docs/faq.md)."
|
||||
|
||||
If a user asks for something outside these capabilities (and you have no other tools provided), politely explain that you cannot perform that action and suggest an alternative approach if possible.
|
||||
|
||||
|
||||
@@ -78,8 +78,7 @@ type IssueLabeledEvent = {
|
||||
labelTrigger: string;
|
||||
};
|
||||
|
||||
type PullRequestEvent = {
|
||||
eventName: "pull_request";
|
||||
type PullRequestBaseEvent = {
|
||||
eventAction?: string; // opened, synchronize, etc.
|
||||
isPR: true;
|
||||
prNumber: string;
|
||||
@@ -87,6 +86,14 @@ type PullRequestEvent = {
|
||||
baseBranch?: string;
|
||||
};
|
||||
|
||||
type PullRequestEvent = PullRequestBaseEvent & {
|
||||
eventName: "pull_request";
|
||||
};
|
||||
|
||||
type PullRequestTargetEvent = PullRequestBaseEvent & {
|
||||
eventName: "pull_request_target";
|
||||
};
|
||||
|
||||
// Union type for all possible event types
|
||||
export type EventData =
|
||||
| PullRequestReviewCommentEvent
|
||||
@@ -96,7 +103,8 @@ export type EventData =
|
||||
| IssueOpenedEvent
|
||||
| IssueAssignedEvent
|
||||
| IssueLabeledEvent
|
||||
| PullRequestEvent;
|
||||
| PullRequestEvent
|
||||
| PullRequestTargetEvent;
|
||||
|
||||
// Combined type with separate eventData field
|
||||
export type PreparedContext = CommonFields & {
|
||||
|
||||
@@ -95,6 +95,7 @@ type BaseContext = {
|
||||
allowedBots: string;
|
||||
allowedNonWriteUsers: string;
|
||||
trackProgress: boolean;
|
||||
plugins: string[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,6 +151,10 @@ export function parseGitHubContext(): GitHubContext {
|
||||
allowedBots: process.env.ALLOWED_BOTS ?? "",
|
||||
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
|
||||
trackProgress: process.env.TRACK_PROGRESS === "true",
|
||||
plugins: (process.env.PLUGINS || "")
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -174,7 +179,8 @@ export function parseGitHubContext(): GitHubContext {
|
||||
isPR: Boolean(payload.issue.pull_request),
|
||||
};
|
||||
}
|
||||
case "pull_request": {
|
||||
case "pull_request":
|
||||
case "pull_request_target": {
|
||||
const payload = context.payload as PullRequestEvent;
|
||||
return {
|
||||
...commonFields,
|
||||
|
||||
@@ -134,11 +134,17 @@ export async function prepareMcpConfig(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if code-review plugin is in the plugins list
|
||||
const hasCodeReviewPlugin = context.inputs.plugins.includes(
|
||||
"code-review@claude-code-plugins",
|
||||
);
|
||||
|
||||
// Include inline comment server for PRs when requested via allowed tools
|
||||
// or when code-review plugin is specified (needs inline comment access for reviews)
|
||||
if (
|
||||
isEntityContext(context) &&
|
||||
context.isPR &&
|
||||
(hasGitHubMcpTools || hasInlineCommentTools)
|
||||
(hasGitHubMcpTools || hasInlineCommentTools || hasCodeReviewPlugin)
|
||||
) {
|
||||
baseMcpConfig.mcpServers.github_inline_comment = {
|
||||
command: "bun",
|
||||
@@ -209,7 +215,7 @@ export async function prepareMcpConfig(
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"-e",
|
||||
"GITHUB_HOST",
|
||||
"ghcr.io/github/github-mcp-server:sha-efef8ae", // https://github.com/github/github-mcp-server/releases/tag/v0.9.0
|
||||
"ghcr.io/github/github-mcp-server:sha-23fa0dd", // https://github.com/github/github-mcp-server/releases/tag/v0.17.1
|
||||
],
|
||||
env: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||
|
||||
@@ -114,6 +114,14 @@ export const agentMode: Mode = {
|
||||
const userClaudeArgs = process.env.CLAUDE_ARGS || "";
|
||||
const allowedTools = parseAllowedTools(userClaudeArgs);
|
||||
|
||||
// Add inline comment tool if code-review plugin is present
|
||||
const hasCodeReviewPlugin = context.inputs.plugins.includes(
|
||||
"code-review@claude-code-plugins",
|
||||
);
|
||||
if (hasCodeReviewPlugin && isEntityContext(context) && context.isPR) {
|
||||
allowedTools.push("mcp__github_inline_comment__create_inline_comment");
|
||||
}
|
||||
|
||||
// Check for branch info from environment variables (useful for auto-fix workflows)
|
||||
const claudeBranch = process.env.CLAUDE_BRANCH || undefined;
|
||||
const baseBranch =
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export function parseAllowedTools(claudeArgs: string): string[] {
|
||||
// Match --allowedTools followed by the value
|
||||
// Match --allowedTools or --allowed-tools followed by the value
|
||||
// Handle both quoted and unquoted values
|
||||
const patterns = [
|
||||
/--allowedTools\s+"([^"]+)"/, // Double quoted
|
||||
/--allowedTools\s+'([^']+)'/, // Single quoted
|
||||
/--allowedTools\s+([^\s]+)/, // Unquoted
|
||||
/--(?:allowedTools|allowed-tools)\s+"([^"]+)"/, // Double quoted
|
||||
/--(?:allowedTools|allowed-tools)\s+'([^']+)'/, // Single quoted
|
||||
/--(?:allowedTools|allowed-tools)\s+([^\s]+)/, // Unquoted
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("prepareMcpConfig", () => {
|
||||
allowedBots: "",
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -276,4 +277,111 @@ describe("prepareMcpConfig", () => {
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_ci).not.toBeDefined();
|
||||
});
|
||||
|
||||
test("should include inline comment server in agent mode when code-review plugin is specified", async () => {
|
||||
const contextWithCodeReviewPlugin: ParsedGitHubContext = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
plugins: ["code-review@claude-code-plugins"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
mode: "agent",
|
||||
context: contextWithCodeReviewPlugin,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
||||
expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe(
|
||||
"test-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("should not include inline comment server in agent mode when code-review plugin is not specified", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
mode: "agent",
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
|
||||
});
|
||||
|
||||
test("should include inline comment server in agent mode when code-review plugin is in a list of plugins", async () => {
|
||||
const contextWithMultiplePlugins: ParsedGitHubContext = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
plugins: ["plugin1", "code-review@claude-code-plugins", "plugin2"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
mode: "agent",
|
||||
context: contextWithMultiplePlugins,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
||||
});
|
||||
|
||||
test("should not include inline comment server in agent mode when plugins contain similar but not exact match", async () => {
|
||||
const contextWithSimilarPlugin: ParsedGitHubContext = {
|
||||
...mockPRContext,
|
||||
inputs: {
|
||||
...mockPRContext.inputs,
|
||||
plugins: ["code-review-other", "review@claude-code-plugins"],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: [],
|
||||
mode: "agent",
|
||||
context: contextWithSimilarPlugin,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_inline_comment).not.toBeDefined();
|
||||
});
|
||||
|
||||
test("should include inline comment server in agent mode when explicit inline comment tools are provided (backward compatibility)", async () => {
|
||||
const result = await prepareMcpConfig({
|
||||
githubToken: "test-token",
|
||||
owner: "test-owner",
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
baseBranch: "main",
|
||||
allowedTools: ["mcp__github_inline_comment__create_inline_comment"],
|
||||
mode: "agent",
|
||||
context: mockPRContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ const defaultInputs = {
|
||||
allowedBots: "",
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("detectMode with enhanced routing", () => {
|
||||
allowedBots: "",
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
plugins: [],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -68,4 +68,20 @@ describe("parseAllowedTools", () => {
|
||||
"mcp__github_comment__update",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses kebab-case --allowed-tools", () => {
|
||||
const args = "--allowed-tools mcp__github__*,mcp__github_comment__*";
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__*",
|
||||
"mcp__github_comment__*",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses quoted kebab-case --allowed-tools", () => {
|
||||
const args = '--allowed-tools "mcp__github__*,mcp__github_comment__*"';
|
||||
expect(parseAllowedTools(args)).toEqual([
|
||||
"mcp__github__*",
|
||||
"mcp__github_comment__*",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ describe("checkWritePermissions", () => {
|
||||
allowedBots: "",
|
||||
allowedNonWriteUsers: "",
|
||||
trackProgress: false,
|
||||
plugins: [],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
504
test/pull-request-target.test.ts
Normal file
504
test/pull-request-target.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import {
|
||||
getEventTypeAndContext,
|
||||
generatePrompt,
|
||||
generateDefaultPrompt,
|
||||
} from "../src/create-prompt";
|
||||
import type { PreparedContext } from "../src/create-prompt";
|
||||
import type { Mode } from "../src/modes/types";
|
||||
|
||||
describe("pull_request_target event support", () => {
|
||||
// Mock tag mode for testing
|
||||
const mockTagMode: Mode = {
|
||||
name: "tag",
|
||||
description: "Tag mode",
|
||||
shouldTrigger: () => true,
|
||||
prepareContext: (context) => ({ mode: "tag", githubContext: context }),
|
||||
getAllowedTools: () => [],
|
||||
getDisallowedTools: () => [],
|
||||
shouldCreateTrackingComment: () => true,
|
||||
generatePrompt: (context, githubData, useCommitSigning) =>
|
||||
generateDefaultPrompt(context, githubData, useCommitSigning),
|
||||
prepare: async () => ({
|
||||
commentId: 123,
|
||||
branchInfo: {
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: "{}",
|
||||
}),
|
||||
};
|
||||
|
||||
const mockGitHubData = {
|
||||
contextData: {
|
||||
title: "External PR via pull_request_target",
|
||||
body: "This PR comes from a forked repository",
|
||||
author: { login: "external-contributor" },
|
||||
state: "OPEN",
|
||||
createdAt: "2023-01-01T00:00:00Z",
|
||||
additions: 25,
|
||||
deletions: 3,
|
||||
baseRefName: "main",
|
||||
headRefName: "feature-branch",
|
||||
headRefOid: "abc123",
|
||||
commits: {
|
||||
totalCount: 2,
|
||||
nodes: [
|
||||
{
|
||||
commit: {
|
||||
oid: "commit1",
|
||||
message: "Initial feature implementation",
|
||||
author: {
|
||||
name: "External Dev",
|
||||
email: "external@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
commit: {
|
||||
oid: "commit2",
|
||||
message: "Fix typos and formatting",
|
||||
author: {
|
||||
name: "External Dev",
|
||||
email: "external@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
files: {
|
||||
nodes: [
|
||||
{
|
||||
path: "src/feature.ts",
|
||||
additions: 20,
|
||||
deletions: 2,
|
||||
changeType: "MODIFIED",
|
||||
},
|
||||
{
|
||||
path: "tests/feature.test.ts",
|
||||
additions: 5,
|
||||
deletions: 1,
|
||||
changeType: "ADDED",
|
||||
},
|
||||
],
|
||||
},
|
||||
comments: { nodes: [] },
|
||||
reviews: { nodes: [] },
|
||||
},
|
||||
comments: [],
|
||||
changedFiles: [],
|
||||
changedFilesWithSHA: [
|
||||
{
|
||||
path: "src/feature.ts",
|
||||
additions: 20,
|
||||
deletions: 2,
|
||||
changeType: "MODIFIED",
|
||||
sha: "abc123",
|
||||
},
|
||||
{
|
||||
path: "tests/feature.test.ts",
|
||||
additions: 5,
|
||||
deletions: 1,
|
||||
changeType: "ADDED",
|
||||
sha: "abc123",
|
||||
},
|
||||
],
|
||||
reviewData: { nodes: [] },
|
||||
imageUrlMap: new Map<string, string>(),
|
||||
};
|
||||
|
||||
describe("prompt generation for pull_request_target", () => {
|
||||
test("should generate correct prompt for pull_request_target event", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
|
||||
// Should contain pull request event type and metadata
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
expect(prompt).toContain("<is_pr>true</is_pr>");
|
||||
expect(prompt).toContain("<pr_number>123</pr_number>");
|
||||
expect(prompt).toContain(
|
||||
"<trigger_context>pull request opened</trigger_context>",
|
||||
);
|
||||
|
||||
// Should contain PR-specific information
|
||||
expect(prompt).toContain(
|
||||
"- src/feature.ts (MODIFIED) +20/-2 SHA: abc123",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"- tests/feature.test.ts (ADDED) +5/-1 SHA: abc123",
|
||||
);
|
||||
expect(prompt).toContain("external-contributor");
|
||||
expect(prompt).toContain("<repository>owner/repo</repository>");
|
||||
});
|
||||
|
||||
test("should handle pull_request_target with commit signing disabled", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "synchronize",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
|
||||
// Should include git commands for non-commit-signing mode
|
||||
expect(prompt).toContain("git push");
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not include commit signing tools
|
||||
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
|
||||
});
|
||||
|
||||
test("should handle pull_request_target with commit signing enabled", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "synchronize",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData, true, mockTagMode);
|
||||
|
||||
// Should include commit signing tools
|
||||
expect(prompt).toContain("mcp__github_file_ops__commit_files");
|
||||
expect(prompt).toContain("mcp__github_file_ops__delete_files");
|
||||
expect(prompt).toContain("mcp__github_comment__update_claude_comment");
|
||||
|
||||
// Should not include git command instructions
|
||||
expect(prompt).not.toContain("Use git commands via the Bash tool");
|
||||
});
|
||||
|
||||
test("should treat pull_request_target same as pull_request in prompt generation", () => {
|
||||
const baseContext: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
// Generate prompt for pull_request
|
||||
const pullRequestContext: PreparedContext = {
|
||||
...baseContext,
|
||||
eventData: {
|
||||
...baseContext.eventData,
|
||||
eventName: "pull_request",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
// Generate prompt for pull_request_target
|
||||
const pullRequestTargetContext: PreparedContext = {
|
||||
...baseContext,
|
||||
eventData: {
|
||||
...baseContext.eventData,
|
||||
eventName: "pull_request_target",
|
||||
isPR: true,
|
||||
prNumber: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const pullRequestPrompt = generatePrompt(
|
||||
pullRequestContext,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const pullRequestTargetPrompt = generatePrompt(
|
||||
pullRequestTargetContext,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
|
||||
// Both should have the same event type and structure
|
||||
expect(pullRequestPrompt).toContain(
|
||||
"<event_type>PULL_REQUEST</event_type>",
|
||||
);
|
||||
expect(pullRequestTargetPrompt).toContain(
|
||||
"<event_type>PULL_REQUEST</event_type>",
|
||||
);
|
||||
|
||||
expect(pullRequestPrompt).toContain(
|
||||
"<trigger_context>pull request opened</trigger_context>",
|
||||
);
|
||||
expect(pullRequestTargetPrompt).toContain(
|
||||
"<trigger_context>pull request opened</trigger_context>",
|
||||
);
|
||||
|
||||
// Both should contain PR-specific instructions
|
||||
expect(pullRequestPrompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
expect(pullRequestTargetPrompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle pull_request_target in agent mode with custom prompt", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "test/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
prompt: "Review this pull_request_target PR for security issues",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "789",
|
||||
},
|
||||
};
|
||||
|
||||
// Use agent mode which passes through the prompt as-is
|
||||
const mockAgentMode: Mode = {
|
||||
name: "agent",
|
||||
description: "Agent mode",
|
||||
shouldTrigger: () => true,
|
||||
prepareContext: (context) => ({
|
||||
mode: "agent",
|
||||
githubContext: context,
|
||||
}),
|
||||
getAllowedTools: () => [],
|
||||
getDisallowedTools: () => [],
|
||||
shouldCreateTrackingComment: () => true,
|
||||
generatePrompt: (context) => context.prompt || "default prompt",
|
||||
prepare: async () => ({
|
||||
commentId: 123,
|
||||
branchInfo: {
|
||||
baseBranch: "main",
|
||||
currentBranch: "main",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: "{}",
|
||||
}),
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockAgentMode,
|
||||
);
|
||||
|
||||
expect(prompt).toBe(
|
||||
"Review this pull_request_target PR for security issues",
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle pull_request_target with no custom prompt", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "test/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "synchronize",
|
||||
isPR: true,
|
||||
prNumber: "456",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(
|
||||
envVars,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
|
||||
// Should generate default prompt structure
|
||||
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
|
||||
expect(prompt).toContain("<pr_number>456</pr_number>");
|
||||
expect(prompt).toContain(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pull_request_target vs pull_request behavior consistency", () => {
|
||||
test("should produce identical event processing for both event types", () => {
|
||||
const baseEventData = {
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "100",
|
||||
};
|
||||
|
||||
const pullRequestEvent: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
...baseEventData,
|
||||
eventName: "pull_request",
|
||||
isPR: true,
|
||||
prNumber: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const pullRequestTargetEvent: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
...baseEventData,
|
||||
eventName: "pull_request_target",
|
||||
isPR: true,
|
||||
prNumber: "100",
|
||||
},
|
||||
};
|
||||
|
||||
// Both should have identical event type detection
|
||||
const prResult = getEventTypeAndContext(pullRequestEvent);
|
||||
const prtResult = getEventTypeAndContext(pullRequestTargetEvent);
|
||||
|
||||
expect(prResult.eventType).toBe(prtResult.eventType);
|
||||
expect(prResult.triggerContext).toBe(prtResult.triggerContext);
|
||||
});
|
||||
|
||||
test("should handle edge cases in pull_request_target events", () => {
|
||||
// Test with minimal event data
|
||||
const minimalContext: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
isPR: true,
|
||||
prNumber: "1",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(minimalContext);
|
||||
expect(result.eventType).toBe("PULL_REQUEST");
|
||||
expect(result.triggerContext).toBe("pull request event");
|
||||
|
||||
// Should not throw when generating prompt
|
||||
expect(() => {
|
||||
generatePrompt(minimalContext, mockGitHubData, false, mockTagMode);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle all valid pull_request_target actions", () => {
|
||||
const actions = ["opened", "synchronize", "reopened", "closed", "edited"];
|
||||
|
||||
actions.forEach((action) => {
|
||||
const context: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: action,
|
||||
isPR: true,
|
||||
prNumber: "1",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(context);
|
||||
expect(result.eventType).toBe("PULL_REQUEST");
|
||||
expect(result.triggerContext).toBe(`pull request ${action}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("security considerations for pull_request_target", () => {
|
||||
test("should maintain same prompt structure regardless of event source", () => {
|
||||
// Test that external PRs don't get different treatment in prompts
|
||||
const internalPR: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "1",
|
||||
},
|
||||
};
|
||||
|
||||
const externalPR: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "pull_request_target",
|
||||
eventAction: "opened",
|
||||
isPR: true,
|
||||
prNumber: "1",
|
||||
},
|
||||
};
|
||||
|
||||
const internalPrompt = generatePrompt(
|
||||
internalPR,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
const externalPrompt = generatePrompt(
|
||||
externalPR,
|
||||
mockGitHubData,
|
||||
false,
|
||||
mockTagMode,
|
||||
);
|
||||
|
||||
// Should have same tool access patterns
|
||||
expect(
|
||||
internalPrompt.includes("mcp__github_comment__update_claude_comment"),
|
||||
).toBe(
|
||||
externalPrompt.includes("mcp__github_comment__update_claude_comment"),
|
||||
);
|
||||
|
||||
// Should have same branch handling instructions
|
||||
expect(
|
||||
internalPrompt.includes(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
),
|
||||
).toBe(
|
||||
externalPrompt.includes(
|
||||
"Always push to the existing branch when triggered on a PR",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user