Compare commits

..

13 Commits

Author SHA1 Message Date
Cole Davis
b12957001c Fall back to default on duplicate branch 2025-09-16 21:46:32 -04:00
Cole Davis
60112e2967 Still replace undefined values 2025-09-16 21:16:23 -04:00
Cole Davis
ddb3ef092b Merge createBranchTemplateVariables into generateBranchName 2025-09-16 21:05:17 -04:00
Cole Davis
35c0ca2406 Clean up comments, docstrings 2025-09-16 20:55:40 -04:00
Cole Davis
1a49e00b3e Add check for empty template-generated name 2025-09-16 20:50:52 -04:00
Cole Davis
e758a32de7 Only fetch first label 2025-09-16 20:33:16 -04:00
Cole Davis
6246ecdcb5 Remove more granular time template variables 2025-09-16 20:31:18 -04:00
Cole Davis
32e7aeee0e More concise description for branch_name_template 2025-09-16 20:26:12 -04:00
Cole Davis
40c2c1e7b4 Add description template variable 2025-09-15 14:22:56 -04:00
Cole Davis
9c4bc5dc35 Add label to template variables 2025-09-15 14:22:56 -04:00
Cole Davis
b5de2b9913 Use branch name template 2025-09-15 14:22:56 -04:00
Cole Davis
4991c0c947 Logging 2025-09-15 14:22:56 -04:00
Cole Davis
726d5808b5 Add branch-name-template config option 2025-09-15 14:22:56 -04:00
21 changed files with 489 additions and 586 deletions

38
.github/workflows/claude-test.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
# 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 }}

View File

@@ -67,7 +67,7 @@ jobs:
uses: ./base-action
with:
prompt: |
Run the command `echo $HOME` to check the home directory path
Use Bash to echo "This should not work"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: |
{
@@ -163,7 +163,7 @@ jobs:
uses: ./base-action
with:
prompt: |
Run the command `echo $HOME` to check the home directory path
Use Bash to echo "This should not work from file"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: "test-settings.json"

View File

@@ -2,50 +2,50 @@
# Claude Code Action
A fair dinkum [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This ripper of an action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. She'll be right with multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
A general-purpose [Claude Code](https://claude.ai/code) action for GitHub PRs and issues that can answer questions and implement code changes. This action intelligently detects when to activate based on your workflow context—whether responding to @claude mentions, issue assignments, or executing automation tasks with explicit prompts. It supports multiple authentication methods including Anthropic direct API, Amazon Bedrock, and Google Vertex AI.
## Features
- 🎯 **Smart Mode Detection**: Automatically selects the right execution mode based on your workflow context—no mucking about with configuration
- 🤖 **Interactive Code Mate**: Claude can answer questions about code, architecture, and programming like a proper coding buddy
- 🔍 **Code Review**: Analyses PR changes and suggests improvements (fair dinkum advice, no worries)
-**Code Implementation**: Can implement simple fixes, refactoring, and even new features—she's a champion
- 💬 **PR/Issue Integration**: Works like a dream with GitHub comments and PR reviews
- 🎯 **Intelligent Mode Detection**: Automatically selects the appropriate execution mode based on your workflow context—no configuration needed
- 🤖 **Interactive Code Assistant**: Claude can answer questions about code, architecture, and programming
- 🔍 **Code Review**: Analyzes PR changes and suggests improvements
-**Code Implementation**: Can implement simple fixes, refactoring, and even new features
- 💬 **PR/Issue Integration**: Works seamlessly with GitHub comments and PR reviews
- 🛠️ **Flexible Tool Access**: Access to GitHub APIs and file operations (additional tools can be enabled via configuration)
- 📋 **Progress Tracking**: Visual progress indicators with checkboxes that dynamically update as Claude gets stuck into 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)
- ⚙️ **Simplified Configuration**: Unified `prompt` and `claude_args` inputs provide clean, powerful configuration aligned with Claude Code SDK
## 📦 Upgrading from v0.x?
**Check out our [Migration Guide](./docs/migration-guide.md)** for step-by-step instructions on updating your workflows to v1.0. The new version simplifies configuration while maintaining compatibility with most existing setups—no dramas!
**See our [Migration Guide](./docs/migration-guide.md)** for step-by-step instructions on updating your workflows to v1.0. The new version simplifies configuration while maintaining compatibility with most existing setups.
## Getting Started
## Quickstart
The easiest way to get this beauty up and running 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`.
This command will walk you through setting up the GitHub app and required secrets—piece of cake!
This command will guide you through setting up the GitHub app and required secrets.
**Heads up**:
**Note**:
- You'll need to be a repository admin to install the GitHub app and add secrets
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock or Google Vertex AI setup, have a squiz at [docs/cloud-providers.md](./docs/cloud-providers.md).
- You must be a repository admin to install the GitHub app and add secrets
- This quickstart method is only available for direct Anthropic API users. For AWS Bedrock or Google Vertex AI setup, see [docs/cloud-providers.md](./docs/cloud-providers.md).
## 📚 Solutions & Use Cases
Looking for specific automation patterns? Have a gander at our **[Solutions Guide](./docs/solutions.md)** for complete working examples including:
Looking for specific automation patterns? Check our **[Solutions Guide](./docs/solutions.md)** for complete working examples including:
- **🔍 Automatic PR Code Review** - Full review automation (top-notch stuff)
- **🔍 Automatic PR Code Review** - Full review automation
- **📂 Path-Specific Reviews** - Trigger on critical file changes
- **👥 External Contributor Reviews** - Special handling for new contributors (treat 'em right)
- **📝 Custom Review Checklists** - Enforce team standards (keep everyone on track)
- **👥 External Contributor Reviews** - Special handling for new contributors
- **📝 Custom Review Checklists** - Enforce team standards
- **🔄 Scheduled Maintenance** - Automated repository health checks
- **🏷️ Issue Triage & Labelling** - Automatic categorisation
- **🏷️ Issue Triage & Labeling** - Automatic categorization
- **📖 Documentation Sync** - Keep docs updated with code changes
- **🔒 Security-Focused Reviews** - OWASP-aligned security analysis (keeping it secure, mate)
- **🔒 Security-Focused Reviews** - OWASP-aligned security analysis
- **📊 DIY Progress Tracking** - Create tracking comments in automation mode
Each solution includes complete working examples, configuration details, and expected outcomes—the full box and dice.
Each solution includes complete working examples, configuration details, and expected outcomes.
## Documentation
@@ -63,7 +63,7 @@ Each solution includes complete working examples, configuration details, and exp
## 📚 FAQ
Got issues or questions? Have a look at our [Frequently Asked Questions](./docs/faq.md) for solutions to common problems and detailed explanations of Claude's capabilities and limitations.
Having issues or questions? Check out our [Frequently Asked Questions](./docs/faq.md) for solutions to common problems and detailed explanations of Claude's capabilities and limitations.
## License

View File

@@ -23,6 +23,10 @@ inputs:
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
required: false
default: "claude/"
branch_name_template:
description: "Template for branch naming. Available variables: {{prefix}}, {{entityType}}, {{entityNumber}}, {{timestamp}}, {{sha}}, {{label}}, {{description}}. {{label}} will be first label from the issue/PR, or {{entityType}} as a fallback. {{description}} will be the first 3 words of the issue/PR title in kebab-case. Default: '{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}'"
required: false
default: ""
allowed_bots:
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
required: false
@@ -150,6 +154,7 @@ runs:
LABEL_TRIGGER: ${{ inputs.label_trigger }}
BASE_BRANCH: ${{ inputs.base_branch }}
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
BRANCH_NAME_TEMPLATE: ${{ inputs.branch_name_template }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }}
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
@@ -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 2.0.10
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.113
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
@@ -259,7 +264,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_target' || github.event_name == 'pull_request_review_comment' }}
IS_PR: ${{ github.event.issue.pull_request != null || 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 || '' }}

View File

@@ -99,7 +99,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 2.0.10
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.113
else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH

View File

@@ -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` or `pull_request_target` - When PRs are opened or synchronized
- `pull_request` - 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

View File

@@ -13,10 +13,6 @@
- **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:

View File

@@ -59,6 +59,7 @@ jobs:
| `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` |
@@ -89,7 +90,6 @@ 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)

View File

@@ -384,7 +384,6 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
};
case "pull_request":
case "pull_request_target":
return {
eventType: "PULL_REQUEST",
triggerContext: eventData.eventAction
@@ -709,7 +708,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/docs/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/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.

View File

@@ -78,7 +78,8 @@ type IssueLabeledEvent = {
labelTrigger: string;
};
type PullRequestBaseEvent = {
type PullRequestEvent = {
eventName: "pull_request";
eventAction?: string; // opened, synchronize, etc.
isPR: true;
prNumber: string;
@@ -86,14 +87,6 @@ type PullRequestBaseEvent = {
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
@@ -103,8 +96,7 @@ export type EventData =
| IssueOpenedEvent
| IssueAssignedEvent
| IssueLabeledEvent
| PullRequestEvent
| PullRequestTargetEvent;
| PullRequestEvent;
// Combined type with separate eventData field
export type PreparedContext = CommonFields & {

View File

@@ -16,6 +16,11 @@ export const PR_QUERY = `
additions
deletions
state
labels(first: 1) {
nodes {
name
}
}
commits(first: 100) {
totalCount
nodes {
@@ -97,6 +102,11 @@ export const ISSUE_QUERY = `
}
createdAt
state
labels(first: 1) {
nodes {
name
}
}
comments(first: 100) {
nodes {
id

View File

@@ -88,6 +88,7 @@ type BaseContext = {
labelTrigger: string;
baseBranch?: string;
branchPrefix: string;
branchNameTemplate?: string;
useStickyComment: boolean;
useCommitSigning: boolean;
botId: string;
@@ -143,6 +144,7 @@ export function parseGitHubContext(): GitHubContext {
labelTrigger: process.env.LABEL_TRIGGER ?? "",
baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
branchNameTemplate: process.env.BRANCH_NAME_TEMPLATE,
useStickyComment: process.env.USE_STICKY_COMMENT === "true",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
@@ -174,8 +176,7 @@ export function parseGitHubContext(): GitHubContext {
isPR: Boolean(payload.issue.pull_request),
};
}
case "pull_request":
case "pull_request_target": {
case "pull_request": {
const payload = context.payload as PullRequestEvent;
return {
...commonFields,

View File

@@ -12,6 +12,15 @@ import type { ParsedGitHubContext } from "../context";
import type { GitHubPullRequest } from "../types";
import type { Octokits } from "../api/client";
import type { FetchDataResult } from "../data/fetcher";
import { generateBranchName } from "../../utils/branch-template";
/**
* Extracts the first label from GitHub data, or returns undefined if no labels exist
*/
function extractFirstLabel(githubData: FetchDataResult): string | undefined {
const labels = githubData.contextData.labels?.nodes;
return labels && labels.length > 0 ? labels[0]?.name : undefined;
}
export type BranchInfo = {
baseBranch: string;
@@ -26,7 +35,7 @@ export async function setupBranch(
): Promise<BranchInfo> {
const { owner, repo } = context.repository;
const entityNumber = context.entityNumber;
const { baseBranch, branchPrefix } = context.inputs;
const { baseBranch, branchPrefix, branchNameTemplate } = context.inputs;
const isPR = context.isPR;
if (isPR) {
@@ -87,17 +96,8 @@ export async function setupBranch(
// Generate branch name for either an issue or closed/merged PR
const entityType = isPR ? "pr" : "issue";
// Create Kubernetes-compatible timestamp: lowercase, hyphens only, shorter format
const now = new Date();
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
// Ensure branch name is Kubernetes-compatible:
// - Lowercase only
// - Alphanumeric with hyphens
// - No underscores
// - Max 50 chars (to allow for prefixes)
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
const newBranch = branchName.toLowerCase().substring(0, 50);
// Get the SHA of the source branch to use in template
let sourceSHA: string | undefined;
try {
// Get the SHA of the source branch to verify it exists
@@ -107,8 +107,46 @@ export async function setupBranch(
ref: `heads/${sourceBranch}`,
});
const currentSHA = sourceBranchRef.data.object.sha;
console.log(`Source branch SHA: ${currentSHA}`);
sourceSHA = sourceBranchRef.data.object.sha;
console.log(`Source branch SHA: ${sourceSHA}`);
// Extract first label from GitHub data
const firstLabel = extractFirstLabel(githubData);
// Extract title from GitHub data
const title = githubData.contextData.title;
// Generate branch name using template or default format
let newBranch = generateBranchName(
branchNameTemplate,
branchPrefix,
entityType,
entityNumber,
sourceSHA,
firstLabel,
title,
);
// Check if generated branch already exists on remote
try {
await $`git ls-remote --exit-code origin refs/heads/${newBranch}`.quiet();
// If we get here, branch exists (exit code 0)
console.log(
`Branch '${newBranch}' already exists, falling back to default format`,
);
newBranch = generateBranchName(
undefined, // Force default template
branchPrefix,
entityType,
entityNumber,
sourceSHA,
firstLabel,
title,
);
} catch {
// Branch doesn't exist (non-zero exit code), continue with generated name
}
// For commit signing, defer branch creation to the file ops server
if (context.inputs.useCommitSigning) {

View File

@@ -61,6 +61,11 @@ export type GitHubPullRequest = {
additions: number;
deletions: number;
state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
commits: {
totalCount: number;
nodes: Array<{
@@ -84,6 +89,11 @@ export type GitHubIssue = {
author: GitHubAuthor;
createdAt: string;
state: string;
labels: {
nodes: Array<{
name: string;
}>;
};
comments: {
nodes: GitHubComment[];
};

View File

@@ -1,10 +1,10 @@
export function parseAllowedTools(claudeArgs: string): string[] {
// Match --allowedTools or --allowed-tools followed by the value
// Match --allowedTools followed by the value
// Handle both quoted and unquoted values
const patterns = [
/--(?:allowedTools|allowed-tools)\s+"([^"]+)"/, // Double quoted
/--(?:allowedTools|allowed-tools)\s+'([^']+)'/, // Single quoted
/--(?:allowedTools|allowed-tools)\s+([^\s]+)/, // Unquoted
/--allowedTools\s+"([^"]+)"/, // Double quoted
/--allowedTools\s+'([^']+)'/, // Single quoted
/--allowedTools\s+([^\s]+)/, // Unquoted
];
for (const pattern of patterns) {

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bun
/**
* Branch name template parsing and variable substitution utilities
*/
/**
* Extracts the first three words from a title and converts them to kebab-case
*/
function extractDescription(title: string): string {
if (!title || title.trim() === "") {
return "";
}
return title
.trim() // Remove leading/trailing whitespace
.split(/\s+/) // Split on whitespace
.slice(0, 3) // Take first 3 words
.join("-") // Join with hyphens
.toLowerCase() // Convert to lowercase
.replace(/[^a-z0-9-]/g, "") // Remove non-alphanumeric except hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single
.replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
}
export interface BranchTemplateVariables {
prefix: string;
entityType: string;
entityNumber: number;
timestamp: string;
sha?: string;
label?: string;
description?: string;
}
/**
* Replaces template variables in a branch name template
* Template format: {{variableName}}
*/
export function applyBranchTemplate(
template: string,
variables: BranchTemplateVariables,
): string {
let result = template;
// Replace each variable
Object.entries(variables).forEach(([key, value]) => {
const placeholder = `{{${key}}}`;
const replacement = value ? String(value) : "";
result = result.replaceAll(placeholder, replacement);
});
return result;
}
/**
* Generates a branch name from the provided `template` and set of `variables`. Uses a default format if the template is empty or produces an empty result.
*/
export function generateBranchName(
template: string | undefined,
branchPrefix: string,
entityType: string,
entityNumber: number,
sha?: string,
label?: string,
title?: string,
): string {
const now = new Date();
const variables: BranchTemplateVariables = {
prefix: branchPrefix,
entityType,
entityNumber,
timestamp: `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`,
sha: sha?.substring(0, 8), // First 8 characters of SHA
label: label || entityType, // Fall back to entityType if no label
description: title ? extractDescription(title) : undefined,
};
if (template?.trim()) {
const branchName = applyBranchTemplate(template, variables);
// Some templates could produce empty results- validate
if (branchName.trim().length > 0) return branchName;
console.log(
`Branch template '${template}' generated empty result, falling back to default format`,
);
}
const branchName = `${branchPrefix}${entityType}-${entityNumber}-${variables.timestamp}`;
// Kubernetes compatible: lowercase, max 50 chars, alphanumeric and hyphens only
return branchName.toLowerCase().substring(0, 50);
}

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env bun
import { describe, it, expect } from "bun:test";
import {
applyBranchTemplate,
generateBranchName,
} from "../src/utils/branch-template";
describe("branch template utilities", () => {
describe("applyBranchTemplate", () => {
it("should replace all template variables", () => {
const template =
"{{prefix}}{{entityType}}-{{entityNumber}}-{{timestamp}}";
const variables = {
prefix: "feat/",
entityType: "issue",
entityNumber: 123,
timestamp: "20240301-1430",
sha: "abcd1234",
};
const result = applyBranchTemplate(template, variables);
expect(result).toBe("feat/issue-123-20240301-1430");
});
it("should handle custom templates with multiple variables", () => {
const template =
"{{prefix}}fix/{{entityType}}_{{entityNumber}}_{{timestamp}}_{{sha}}";
const variables = {
prefix: "claude-",
entityType: "pr",
entityNumber: 456,
timestamp: "20240301-1430",
sha: "abcd1234",
};
const result = applyBranchTemplate(template, variables);
expect(result).toBe("claude-fix/pr_456_20240301-1430_abcd1234");
});
it("should handle templates with missing variables gracefully", () => {
const template = "{{prefix}}{{entityType}}-{{missing}}-{{entityNumber}}";
const variables = {
prefix: "feat/",
entityType: "issue",
entityNumber: 123,
timestamp: "20240301-1430",
};
const result = applyBranchTemplate(template, variables);
expect(result).toBe("feat/issue-{{missing}}-123");
});
});
describe("generateBranchName", () => {
it("should use custom template when provided", () => {
const template = "{{prefix}}custom-{{entityType}}_{{entityNumber}}";
const result = generateBranchName(template, "feature/", "issue", 123);
expect(result).toBe("feature/custom-issue_123");
});
it("should use default format when template is empty", () => {
const result = generateBranchName("", "claude/", "issue", 123);
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
});
it("should use default format when template is undefined", () => {
const result = generateBranchName(undefined, "claude/", "pr", 456);
expect(result).toMatch(/^claude\/pr-456-\d{8}-\d{4}$/);
});
it("should preserve custom template formatting (no automatic lowercase/truncation)", () => {
const template = "{{prefix}}UPPERCASE_Branch-Name_{{entityNumber}}";
const result = generateBranchName(template, "Feature/", "issue", 123);
expect(result).toBe("Feature/UPPERCASE_Branch-Name_123");
});
it("should not truncate custom template results", () => {
const template =
"{{prefix}}very-long-branch-name-that-exceeds-the-maximum-allowed-length-{{entityNumber}}";
const result = generateBranchName(template, "feature/", "issue", 123);
expect(result).toBe(
"feature/very-long-branch-name-that-exceeds-the-maximum-allowed-length-123",
);
});
it("should apply Kubernetes-compatible transformations to default template only", () => {
const result = generateBranchName(undefined, "Feature/", "issue", 123);
expect(result).toMatch(/^feature\/issue-123-\d{8}-\d{4}$/);
expect(result.length).toBeLessThanOrEqual(50);
});
it("should handle SHA in template", () => {
const template = "{{prefix}}{{entityType}}-{{entityNumber}}-{{sha}}";
const result = generateBranchName(
template,
"fix/",
"pr",
789,
"abcdef123456",
);
expect(result).toBe("fix/pr-789-abcdef12");
});
it("should use label in template when provided", () => {
const template = "{{prefix}}{{label}}/{{entityNumber}}";
const result = generateBranchName(
template,
"feature/",
"issue",
123,
undefined,
"bug",
);
expect(result).toBe("feature/bug/123");
});
it("should fallback to entityType when label template is used but no label provided", () => {
const template = "{{prefix}}{{label}}-{{entityNumber}}";
const result = generateBranchName(template, "fix/", "pr", 456);
expect(result).toBe("fix/pr-456");
});
it("should handle template with both label and entityType", () => {
const template = "{{prefix}}{{label}}-{{entityType}}_{{entityNumber}}";
const result = generateBranchName(
template,
"dev/",
"issue",
789,
undefined,
"enhancement",
);
expect(result).toBe("dev/enhancement-issue_789");
});
it("should use description in template when provided", () => {
const template = "{{prefix}}{{description}}/{{entityNumber}}";
const result = generateBranchName(
template,
"feature/",
"issue",
123,
undefined,
undefined,
"Fix login bug with OAuth",
);
expect(result).toBe("feature/fix-login-bug/123");
});
it("should handle template with multiple variables including description", () => {
const template =
"{{prefix}}{{label}}/{{description}}-{{entityType}}_{{entityNumber}}";
const result = generateBranchName(
template,
"dev/",
"issue",
456,
undefined,
"bug",
"User authentication fails completely",
);
expect(result).toBe("dev/bug/user-authentication-fails-issue_456");
});
it("should handle description with special characters in template", () => {
const template = "{{prefix}}{{description}}-{{entityNumber}}";
const result = generateBranchName(
template,
"fix/",
"pr",
789,
undefined,
undefined,
"Add: User Registration & Email Validation",
);
expect(result).toBe("fix/add-user-registration-789");
});
it("should handle empty description in template", () => {
const template = "{{prefix}}{{description}}-{{entityNumber}}";
const result = generateBranchName(
template,
"test/",
"issue",
101,
undefined,
undefined,
"",
);
expect(result).toBe("test/-101");
});
it("should fallback to default format when template produces empty result", () => {
const template = "{{description}}"; // Will be empty if no title provided
const result = generateBranchName(template, "claude/", "issue", 123);
expect(result).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
expect(result.length).toBeLessThanOrEqual(50);
});
it("should fallback to default format when template produces only whitespace", () => {
const template = " {{description}} "; // Will be " " if description is empty
const result = generateBranchName(
template,
"fix/",
"pr",
456,
undefined,
undefined,
"",
);
expect(result).toMatch(/^fix\/pr-456-\d{8}-\d{4}$/);
expect(result.length).toBeLessThanOrEqual(50);
});
});
});

View File

@@ -61,6 +61,7 @@ describe("generatePrompt", () => {
body: "This is a test PR",
author: { login: "testuser" },
state: "OPEN",
labels: { nodes: [] },
createdAt: "2023-01-01T00:00:00Z",
additions: 15,
deletions: 5,
@@ -475,6 +476,7 @@ describe("generatePrompt", () => {
body: "The login form is not working",
author: { login: "testuser" },
state: "OPEN",
labels: { nodes: [] },
createdAt: "2023-01-01T00:00:00Z",
comments: {
nodes: [],

View File

@@ -28,6 +28,9 @@ describe("formatContext", () => {
additions: 50,
deletions: 30,
state: "OPEN",
labels: {
nodes: [],
},
commits: {
totalCount: 3,
nodes: [],
@@ -63,6 +66,9 @@ Changed Files: 2 files`,
author: { login: "test-user" },
createdAt: "2023-01-01T00:00:00Z",
state: "OPEN",
labels: {
nodes: [],
},
comments: {
nodes: [],
},

View File

@@ -68,20 +68,4 @@ 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__*",
]);
});
});

View File

@@ -1,504 +0,0 @@
#!/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",
),
);
});
});
});