mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 23:14:13 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece712ea81 | ||
|
|
032008d3b6 | ||
|
|
b0d9b8c4cd | ||
|
|
c831be8f54 | ||
|
|
38254908ae | ||
|
|
882586e496 | ||
|
|
28aaa5404d | ||
|
|
ebbd9e9be4 | ||
|
|
237de9d329 | ||
|
|
91f620f8c2 | ||
|
|
3486c33ebf | ||
|
|
13ccdab2f8 | ||
|
|
bcf2fe94f8 | ||
|
|
2dab3f2afe | ||
|
|
1b94b9e5a8 | ||
|
|
e0d3fec39f | ||
|
|
3c748dc927 | ||
|
|
ffb2927088 | ||
|
|
def1b3a94e | ||
|
|
67d7753c80 | ||
|
|
a8d323af27 |
2
.github/workflows/issue-triage.yml
vendored
2
.github/workflows/issue-triage.yml
vendored
@@ -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-6d69797"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
138
.github/workflows/release.yml
vendored
Normal file
138
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run (only show what would be created)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
next_version: ${{ steps.next_version.outputs.next_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get latest tag
|
||||
id: get_latest_tag
|
||||
run: |
|
||||
# Get only version tags (v + number pattern)
|
||||
latest_tag=$(git tag -l 'v[0-9]*' | sort -V | tail -1 || echo "v0.0.0")
|
||||
if [ -z "$latest_tag" ]; then
|
||||
latest_tag="v0.0.0"
|
||||
fi
|
||||
echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT
|
||||
echo "Latest tag: $latest_tag"
|
||||
|
||||
- name: Calculate next version
|
||||
id: next_version
|
||||
run: |
|
||||
latest_tag="${{ steps.get_latest_tag.outputs.latest_tag }}"
|
||||
# Remove 'v' prefix and split by dots
|
||||
version=${latest_tag#v}
|
||||
IFS='.' read -ra VERSION_PARTS <<< "$version"
|
||||
|
||||
# Increment patch version
|
||||
major=${VERSION_PARTS[0]:-0}
|
||||
minor=${VERSION_PARTS[1]:-0}
|
||||
patch=${VERSION_PARTS[2]:-0}
|
||||
patch=$((patch + 1))
|
||||
|
||||
next_version="v${major}.${minor}.${patch}"
|
||||
echo "next_version=$next_version" >> $GITHUB_OUTPUT
|
||||
echo "Next version: $next_version"
|
||||
|
||||
- name: Display dry run info
|
||||
if: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
echo "🔍 DRY RUN MODE"
|
||||
echo "Would create tag: ${{ steps.next_version.outputs.next_version }}"
|
||||
echo "From commit: ${{ github.sha }}"
|
||||
echo "Previous tag: ${{ steps.get_latest_tag.outputs.latest_tag }}"
|
||||
|
||||
- name: Create and push tag
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: |
|
||||
next_version="${{ steps.next_version.outputs.next_version }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git tag -a "$next_version" -m "Release $next_version"
|
||||
git push origin "$next_version"
|
||||
|
||||
- name: Create Release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
next_version="${{ steps.next_version.outputs.next_version }}"
|
||||
|
||||
gh release create "$next_version" \
|
||||
--title "$next_version" \
|
||||
--generate-notes \
|
||||
--latest=false # We want to keep beta as the latest
|
||||
|
||||
update-beta-tag:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update beta tag
|
||||
run: |
|
||||
# Get the latest version tag
|
||||
VERSION=$(git tag -l 'v[0-9]*' | sort -V | tail -1)
|
||||
|
||||
# Update the beta tag to point to this release
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -fa beta -m "Update beta tag to ${VERSION}"
|
||||
git push origin beta --force
|
||||
|
||||
- name: Update beta release to be latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Update beta release to be marked as latest
|
||||
gh release edit beta --latest
|
||||
|
||||
update-major-tag:
|
||||
needs: create-release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Update major version tag
|
||||
run: |
|
||||
next_version="${{ needs.create-release.outputs.next_version }}"
|
||||
# Extract major version (e.g., v0 from v0.0.20)
|
||||
major_version=$(echo "$next_version" | cut -d. -f1)
|
||||
|
||||
# Update the major version tag to point to this release
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -fa "$major_version" -m "Update $major_version tag to $next_version"
|
||||
git push origin "$major_version" --force
|
||||
|
||||
echo "Updated $major_version tag to point to $next_version"
|
||||
@@ -50,20 +50,6 @@ Thank you for your interest in contributing to Claude Code Action! This document
|
||||
bun test
|
||||
```
|
||||
|
||||
2. **Integration Tests** (using GitHub Actions locally):
|
||||
|
||||
```bash
|
||||
./test-local.sh
|
||||
```
|
||||
|
||||
This script:
|
||||
|
||||
- Installs `act` if not present (requires Homebrew on macOS)
|
||||
- Runs the GitHub Action workflow locally using Docker
|
||||
- Requires your `ANTHROPIC_API_KEY` to be set
|
||||
|
||||
On Apple Silicon Macs, the script automatically adds the `--container-architecture linux/amd64` flag to avoid compatibility issues.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Create a new branch from `main`:
|
||||
@@ -103,13 +89,7 @@ Thank you for your interest in contributing to Claude Code Action! This document
|
||||
|
||||
When modifying the action:
|
||||
|
||||
1. Test locally with the test script:
|
||||
|
||||
```bash
|
||||
./test-local.sh
|
||||
```
|
||||
|
||||
2. Test in a real GitHub Actions workflow by:
|
||||
1. Test in a real GitHub Actions workflow by:
|
||||
- Creating a test repository
|
||||
- Using your branch as the action source:
|
||||
```yaml
|
||||
|
||||
52
README.md
52
README.md
@@ -49,7 +49,7 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
types: [opened, assigned, labeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
@@ -65,6 +65,8 @@ jobs:
|
||||
# trigger_phrase: "/claude"
|
||||
# Optional: add assignee trigger for issues
|
||||
# assignee_trigger: "claude"
|
||||
# Optional: add label trigger for issues
|
||||
# label_trigger: "claude"
|
||||
# Optional: add custom environment variables (YAML format)
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
@@ -80,6 +82,7 @@ jobs:
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
|
||||
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
|
||||
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
|
||||
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
|
||||
| `timeout_minutes` | Timeout in minutes for execution | No | `30` |
|
||||
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
|
||||
@@ -92,7 +95,9 @@ jobs:
|
||||
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
|
||||
| `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/` |
|
||||
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
|
||||
|
||||
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
|
||||
@@ -149,6 +154,40 @@ For MCP servers that require sensitive information like API keys or tokens, use
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
#### Using Python MCP Servers with uv
|
||||
|
||||
For Python-based MCP servers managed with `uv`, you need to specify the directory containing your server:
|
||||
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
mcp_config: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-python-server": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"${{ github.workspace }}/path/to/server/",
|
||||
"run",
|
||||
"server_file.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed_tools: "my-python-server__<tool_name>" # Replace <tool_name> with your server's tool names
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
For example, if your Python MCP server is at `mcp_servers/weather.py`, you would use:
|
||||
|
||||
```yaml
|
||||
"args":
|
||||
["--directory", "${{ github.workspace }}/mcp_servers/", "run", "weather.py"]
|
||||
```
|
||||
|
||||
**Important**:
|
||||
|
||||
- Always use GitHub Secrets (`${{ secrets.SECRET_NAME }}`) for sensitive values like API keys, tokens, or passwords. Never hardcode secrets directly in the workflow file.
|
||||
@@ -347,8 +386,15 @@ Claude does **not** have access to execute arbitrary Bash commands by default. I
|
||||
```yaml
|
||||
- uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
allowed_tools: "Bash(npm install),Bash(npm run test),Edit,Replace,NotebookEditCell"
|
||||
disallowed_tools: "TaskOutput,KillTask"
|
||||
allowed_tools: |
|
||||
Bash(npm install)
|
||||
Bash(npm run test)
|
||||
Edit
|
||||
Replace
|
||||
NotebookEditCell
|
||||
disallowed_tools: |
|
||||
TaskOutput
|
||||
KillTask
|
||||
# ... other inputs
|
||||
```
|
||||
|
||||
|
||||
11
action.yml
11
action.yml
@@ -12,9 +12,17 @@ inputs:
|
||||
assignee_trigger:
|
||||
description: "The assignee username that triggers the action (e.g. @claude)"
|
||||
required: false
|
||||
label_trigger:
|
||||
description: "The label that triggers the action (e.g. claude)"
|
||||
required: false
|
||||
default: "claude"
|
||||
base_branch:
|
||||
description: "The branch to use as the base/source when creating new branches (defaults to repository default branch)"
|
||||
required: false
|
||||
branch_prefix:
|
||||
description: "The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format)"
|
||||
required: false
|
||||
default: "claude/"
|
||||
|
||||
# Claude Code configuration
|
||||
model:
|
||||
@@ -99,6 +107,7 @@ runs:
|
||||
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
BASE_BRANCH: ${{ inputs.base_branch }}
|
||||
BRANCH_PREFIX: ${{ inputs.branch_prefix }}
|
||||
ALLOWED_TOOLS: ${{ inputs.allowed_tools }}
|
||||
DISALLOWED_TOOLS: ${{ inputs.disallowed_tools }}
|
||||
CUSTOM_INSTRUCTIONS: ${{ inputs.custom_instructions }}
|
||||
@@ -110,7 +119,7 @@ runs:
|
||||
- name: Run Claude Code
|
||||
id: claude-code
|
||||
if: steps.prepare.outputs.contains_trigger == 'true'
|
||||
uses: anthropics/claude-code-base-action@ebd8558e902b3db132e89863de49565fcb9aec46 # v0.0.19
|
||||
uses: anthropics/claude-code-base-action@ba0557c14198bf2fbafbfe80932dde39e574a14c # v0.0.26
|
||||
with:
|
||||
prompt_file: ${{ runner.temp }}/claude-prompts/claude-prompt.txt
|
||||
allowed_tools: ${{ env.ALLOWED_TOOLS }}
|
||||
|
||||
@@ -81,6 +81,7 @@ export function prepareContext(
|
||||
const eventAction = context.eventAction;
|
||||
const triggerPhrase = context.inputs.triggerPhrase || "@claude";
|
||||
const assigneeTrigger = context.inputs.assigneeTrigger;
|
||||
const labelTrigger = context.inputs.labelTrigger;
|
||||
const customInstructions = context.inputs.customInstructions;
|
||||
const allowedTools = context.inputs.allowedTools;
|
||||
const disallowedTools = context.inputs.disallowedTools;
|
||||
@@ -242,7 +243,7 @@ export function prepareContext(
|
||||
}
|
||||
|
||||
if (eventAction === "assigned") {
|
||||
if (!assigneeTrigger) {
|
||||
if (!assigneeTrigger && !directPrompt) {
|
||||
throw new Error(
|
||||
"ASSIGNEE_TRIGGER is required for issue assigned event",
|
||||
);
|
||||
@@ -254,7 +255,20 @@ export function prepareContext(
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
assigneeTrigger,
|
||||
...(assigneeTrigger && { assigneeTrigger }),
|
||||
};
|
||||
} else if (eventAction === "labeled") {
|
||||
if (!labelTrigger) {
|
||||
throw new Error("LABEL_TRIGGER is required for issue labeled event");
|
||||
}
|
||||
eventData = {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber,
|
||||
baseBranch,
|
||||
claudeBranch,
|
||||
labelTrigger,
|
||||
};
|
||||
} else if (eventAction === "opened") {
|
||||
eventData = {
|
||||
@@ -328,10 +342,17 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
|
||||
eventType: "ISSUE_CREATED",
|
||||
triggerContext: `new issue with '${envVars.triggerPhrase}' in body`,
|
||||
};
|
||||
} else if (eventData.eventAction === "labeled") {
|
||||
return {
|
||||
eventType: "ISSUE_LABELED",
|
||||
triggerContext: `issue labeled with '${eventData.labelTrigger}'`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
eventType: "ISSUE_ASSIGNED",
|
||||
triggerContext: `issue assigned to '${eventData.assigneeTrigger}'`,
|
||||
triggerContext: eventData.assigneeTrigger
|
||||
? `issue assigned to '${eventData.assigneeTrigger}'`
|
||||
: `issue assigned event`,
|
||||
};
|
||||
|
||||
case "pull_request":
|
||||
@@ -465,6 +486,7 @@ Follow these steps:
|
||||
- Analyze the pre-fetched data provided above.
|
||||
- For ISSUE_CREATED: Read the issue body to find the request after the trigger phrase.
|
||||
- For ISSUE_ASSIGNED: Read the entire issue body to understand the task.
|
||||
- For ISSUE_LABELED: Read the entire issue body to understand the task.
|
||||
${eventData.eventName === "issue_comment" || eventData.eventName === "pull_request_review_comment" || eventData.eventName === "pull_request_review" ? ` - For comment/review events: Your instructions are in the <trigger_comment> tag above.` : ""}
|
||||
${context.directPrompt ? ` - DIRECT INSTRUCTION: A direct instruction was provided and is shown in the <direct_prompt> tag above. This is not from any GitHub comment but a direct instruction to execute.` : ""}
|
||||
- IMPORTANT: Only the comment/issue containing '${context.triggerPhrase}' has your instructions.
|
||||
|
||||
@@ -65,7 +65,17 @@ type IssueAssignedEvent = {
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
assigneeTrigger: string;
|
||||
assigneeTrigger?: string;
|
||||
};
|
||||
|
||||
type IssueLabeledEvent = {
|
||||
eventName: "issues";
|
||||
eventAction: "labeled";
|
||||
isPR: false;
|
||||
issueNumber: string;
|
||||
baseBranch: string;
|
||||
claudeBranch: string;
|
||||
labelTrigger: string;
|
||||
};
|
||||
|
||||
type PullRequestEvent = {
|
||||
@@ -85,6 +95,7 @@ export type EventData =
|
||||
| IssueCommentEvent
|
||||
| IssueOpenedEvent
|
||||
| IssueAssignedEvent
|
||||
| IssueLabeledEvent
|
||||
| PullRequestEvent;
|
||||
|
||||
// Combined type with separate eventData field
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as github from "@actions/github";
|
||||
import type {
|
||||
IssuesEvent,
|
||||
IssuesAssignedEvent,
|
||||
IssueCommentEvent,
|
||||
PullRequestEvent,
|
||||
PullRequestReviewEvent,
|
||||
@@ -28,11 +29,13 @@ export type ParsedGitHubContext = {
|
||||
inputs: {
|
||||
triggerPhrase: string;
|
||||
assigneeTrigger: string;
|
||||
labelTrigger: string;
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
customInstructions: string;
|
||||
directPrompt: string;
|
||||
baseBranch?: string;
|
||||
branchPrefix: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -52,17 +55,13 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
inputs: {
|
||||
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
|
||||
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
|
||||
allowedTools: (process.env.ALLOWED_TOOLS ?? "")
|
||||
.split(",")
|
||||
.map((tool) => tool.trim())
|
||||
.filter((tool) => tool.length > 0),
|
||||
disallowedTools: (process.env.DISALLOWED_TOOLS ?? "")
|
||||
.split(",")
|
||||
.map((tool) => tool.trim())
|
||||
.filter((tool) => tool.length > 0),
|
||||
labelTrigger: process.env.LABEL_TRIGGER ?? "",
|
||||
allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""),
|
||||
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
|
||||
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
|
||||
directPrompt: process.env.DIRECT_PROMPT ?? "",
|
||||
baseBranch: process.env.BASE_BRANCH,
|
||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,6 +115,14 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMultilineInput(s: string): string[] {
|
||||
return s
|
||||
.split(/,|[\n\r]+/)
|
||||
.map((tool) => tool.replace(/#.+$/, ""))
|
||||
.map((tool) => tool.trim())
|
||||
.filter((tool) => tool.length > 0);
|
||||
}
|
||||
|
||||
export function isIssuesEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||
@@ -145,3 +152,9 @@ export function isPullRequestReviewCommentEvent(
|
||||
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
|
||||
return context.eventName === "pull_request_review_comment";
|
||||
}
|
||||
|
||||
export function isIssuesAssignedEvent(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
|
||||
return isIssuesEvent(context) && context.eventAction === "assigned";
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function setupBranch(
|
||||
): Promise<BranchInfo> {
|
||||
const { owner, repo } = context.repository;
|
||||
const entityNumber = context.entityNumber;
|
||||
const { baseBranch } = context.inputs;
|
||||
const { baseBranch, branchPrefix } = context.inputs;
|
||||
const isPR = context.isPR;
|
||||
|
||||
if (isPR) {
|
||||
@@ -45,9 +45,16 @@ export async function setupBranch(
|
||||
|
||||
const branchName = prData.headRefName;
|
||||
|
||||
// Execute git commands to checkout PR branch (shallow fetch for performance)
|
||||
// Fetch the branch with a depth of 20 to avoid fetching too much history, while still allowing for some context
|
||||
await $`git fetch origin --depth=20 ${branchName}`;
|
||||
// Determine optimal fetch depth based on PR commit count, with a minimum of 20
|
||||
const commitCount = prData.commits.totalCount;
|
||||
const fetchDepth = Math.max(commitCount, 20);
|
||||
|
||||
console.log(
|
||||
`PR #${entityNumber}: ${commitCount} commits, using fetch depth ${fetchDepth}`,
|
||||
);
|
||||
|
||||
// Execute git commands to checkout PR branch (dynamic depth based on PR size)
|
||||
await $`git fetch origin --depth=${fetchDepth} ${branchName}`;
|
||||
await $`git checkout ${branchName}`;
|
||||
|
||||
console.log(`Successfully checked out PR branch for PR #${entityNumber}`);
|
||||
@@ -90,7 +97,7 @@ export async function setupBranch(
|
||||
.split("T")
|
||||
.join("_");
|
||||
|
||||
const newBranch = `claude/${entityType}-${entityNumber}-${timestamp}`;
|
||||
const newBranch = `${branchPrefix}${entityType}-${entityNumber}-${timestamp}`;
|
||||
|
||||
try {
|
||||
// Get the SHA of the source branch
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as core from "@actions/core";
|
||||
import {
|
||||
isIssuesEvent,
|
||||
isIssuesAssignedEvent,
|
||||
isIssueCommentEvent,
|
||||
isPullRequestEvent,
|
||||
isPullRequestReviewEvent,
|
||||
@@ -12,7 +13,7 @@ import type { ParsedGitHubContext } from "../context";
|
||||
|
||||
export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
const {
|
||||
inputs: { assigneeTrigger, triggerPhrase, directPrompt },
|
||||
inputs: { assigneeTrigger, labelTrigger, triggerPhrase, directPrompt },
|
||||
} = context;
|
||||
|
||||
// If direct prompt is provided, always trigger
|
||||
@@ -22,10 +23,10 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
}
|
||||
|
||||
// Check for assignee trigger
|
||||
if (isIssuesEvent(context) && context.eventAction === "assigned") {
|
||||
if (isIssuesAssignedEvent(context)) {
|
||||
// Remove @ symbol from assignee_trigger if present
|
||||
let triggerUser = assigneeTrigger.replace(/^@/, "");
|
||||
const assigneeUsername = context.payload.issue.assignee?.login || "";
|
||||
const assigneeUsername = context.payload.assignee?.login || "";
|
||||
|
||||
if (triggerUser && assigneeUsername === triggerUser) {
|
||||
console.log(`Issue assigned to trigger user '${triggerUser}'`);
|
||||
@@ -33,6 +34,16 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for label trigger
|
||||
if (isIssuesEvent(context) && context.eventAction === "labeled") {
|
||||
const labelName = (context.payload as any).label?.name || "";
|
||||
|
||||
if (labelTrigger && labelName === labelTrigger) {
|
||||
console.log(`Issue labeled with trigger label '${labelTrigger}'`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for issue body and title trigger on issue creation
|
||||
if (isIssuesEvent(context) && context.eventAction === "opened") {
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function prepareMcpConfig(
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-e9f748f", // https://github.com/github/github-mcp-server/releases/tag/v0.4.0
|
||||
"ghcr.io/github/github-mcp-server:sha-6d69797", // https://github.com/github/github-mcp-server/releases/tag/v0.5.0
|
||||
],
|
||||
env: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||
|
||||
@@ -226,6 +226,33 @@ describe("generatePrompt", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should generate prompt for issue labeled event", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber: "888",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-888-20240101_120000",
|
||||
labelTrigger: "claude-task",
|
||||
},
|
||||
};
|
||||
|
||||
const prompt = generatePrompt(envVars, mockGitHubData);
|
||||
|
||||
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
|
||||
expect(prompt).toContain(
|
||||
"<trigger_context>issue labeled with 'claude-task'</trigger_context>",
|
||||
);
|
||||
expect(prompt).toContain(
|
||||
"[Create a PR](https://github.com/owner/repo/compare/main",
|
||||
);
|
||||
});
|
||||
|
||||
test("should include direct prompt when provided", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
@@ -614,6 +641,51 @@ describe("getEventTypeAndContext", () => {
|
||||
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
||||
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
|
||||
});
|
||||
|
||||
test("should return correct type and context for issue labeled", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
isPR: false,
|
||||
issueNumber: "888",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-888-20240101_120000",
|
||||
labelTrigger: "claude-task",
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(envVars);
|
||||
|
||||
expect(result.eventType).toBe("ISSUE_LABELED");
|
||||
expect(result.triggerContext).toBe("issue labeled with 'claude-task'");
|
||||
});
|
||||
|
||||
test("should return correct type and context for issue assigned without assigneeTrigger", () => {
|
||||
const envVars: PreparedContext = {
|
||||
repository: "owner/repo",
|
||||
claudeCommentId: "12345",
|
||||
triggerPhrase: "@claude",
|
||||
directPrompt: "Please assess this issue",
|
||||
eventData: {
|
||||
eventName: "issues",
|
||||
eventAction: "assigned",
|
||||
isPR: false,
|
||||
issueNumber: "999",
|
||||
baseBranch: "main",
|
||||
claudeBranch: "claude/issue-999-20240101_120000",
|
||||
// No assigneeTrigger when using directPrompt
|
||||
},
|
||||
};
|
||||
|
||||
const result = getEventTypeAndContext(envVars);
|
||||
|
||||
expect(result.eventType).toBe("ISSUE_ASSIGNED");
|
||||
expect(result.triggerContext).toBe("issue assigned event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAllowedToolsString", () => {
|
||||
|
||||
57
test/github/context.test.ts
Normal file
57
test/github/context.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { parseMultilineInput } from "../../src/github/context";
|
||||
|
||||
describe("parseMultilineInput", () => {
|
||||
it("should parse a comma-separated string", () => {
|
||||
const input = `Bash(bun install),Bash(bun test:*),Bash(bun typecheck)`;
|
||||
const result = parseMultilineInput(input);
|
||||
expect(result).toEqual([
|
||||
"Bash(bun install)",
|
||||
"Bash(bun test:*)",
|
||||
"Bash(bun typecheck)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse multiline string", () => {
|
||||
const input = `Bash(bun install)
|
||||
Bash(bun test:*)
|
||||
Bash(bun typecheck)`;
|
||||
const result = parseMultilineInput(input);
|
||||
expect(result).toEqual([
|
||||
"Bash(bun install)",
|
||||
"Bash(bun test:*)",
|
||||
"Bash(bun typecheck)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse comma-separated multiline line", () => {
|
||||
const input = `Bash(bun install),Bash(bun test:*)
|
||||
Bash(bun typecheck)`;
|
||||
const result = parseMultilineInput(input);
|
||||
expect(result).toEqual([
|
||||
"Bash(bun install)",
|
||||
"Bash(bun test:*)",
|
||||
"Bash(bun typecheck)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should ignore comments", () => {
|
||||
const input = `Bash(bun install),
|
||||
Bash(bun test:*) # For testing
|
||||
# For type checking
|
||||
Bash(bun typecheck)
|
||||
`;
|
||||
const result = parseMultilineInput(input);
|
||||
expect(result).toEqual([
|
||||
"Bash(bun install)",
|
||||
"Bash(bun test:*)",
|
||||
"Bash(bun typecheck)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should parse an empty string", () => {
|
||||
const input = "";
|
||||
const result = parseMultilineInput(input);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
const defaultInputs = {
|
||||
triggerPhrase: "/claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
anthropicModel: "claude-3-7-sonnet-20250219",
|
||||
allowedTools: [] as string[],
|
||||
disallowedTools: [] as string[],
|
||||
@@ -18,6 +19,7 @@ const defaultInputs = {
|
||||
useBedrock: false,
|
||||
useVertex: false,
|
||||
timeoutMinutes: 30,
|
||||
branchPrefix: "claude/",
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
@@ -91,6 +93,12 @@ export const mockIssueAssignedContext: ParsedGitHubContext = {
|
||||
actor: "admin-user",
|
||||
payload: {
|
||||
action: "assigned",
|
||||
assignee: {
|
||||
login: "claude-bot",
|
||||
id: 11111,
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/11111",
|
||||
html_url: "https://github.com/claude-bot",
|
||||
},
|
||||
issue: {
|
||||
number: 123,
|
||||
title: "Feature: Add dark mode support",
|
||||
@@ -122,6 +130,46 @@ export const mockIssueAssignedContext: ParsedGitHubContext = {
|
||||
inputs: { ...defaultInputs, assigneeTrigger: "@claude-bot" },
|
||||
};
|
||||
|
||||
export const mockIssueLabeledContext: ParsedGitHubContext = {
|
||||
runId: "1234567890",
|
||||
eventName: "issues",
|
||||
eventAction: "labeled",
|
||||
repository: defaultRepository,
|
||||
actor: "admin-user",
|
||||
payload: {
|
||||
action: "labeled",
|
||||
issue: {
|
||||
number: 1234,
|
||||
title: "Enhancement: Improve search functionality",
|
||||
body: "The current search is too slow and needs optimization",
|
||||
user: {
|
||||
login: "alice-wonder",
|
||||
id: 54321,
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/54321",
|
||||
html_url: "https://github.com/alice-wonder",
|
||||
},
|
||||
assignee: null,
|
||||
},
|
||||
label: {
|
||||
id: 987654321,
|
||||
name: "claude-task",
|
||||
color: "f29513",
|
||||
description: "Label for Claude AI interactions",
|
||||
},
|
||||
repository: {
|
||||
name: "test-repo",
|
||||
full_name: "test-owner/test-repo",
|
||||
private: false,
|
||||
owner: {
|
||||
login: "test-owner",
|
||||
},
|
||||
},
|
||||
} as IssuesEvent,
|
||||
entityNumber: 1234,
|
||||
isPR: false,
|
||||
inputs: { ...defaultInputs, labelTrigger: "claude-task" },
|
||||
};
|
||||
|
||||
// Issue comment on issue event
|
||||
export const mockIssueCommentContext: ParsedGitHubContext = {
|
||||
runId: "1234567890",
|
||||
|
||||
@@ -62,10 +62,12 @@ describe("checkWritePermissions", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
directPrompt: "",
|
||||
branchPrefix: "claude/",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -219,6 +219,55 @@ describe("parseEnvVarsWithContext", () => {
|
||||
),
|
||||
).toThrow("BASE_BRANCH is required for issues event");
|
||||
});
|
||||
|
||||
test("should allow issue assigned event with direct_prompt and no assigneeTrigger", () => {
|
||||
const contextWithDirectPrompt = createMockContext({
|
||||
...mockIssueAssignedContext,
|
||||
inputs: {
|
||||
...mockIssueAssignedContext.inputs,
|
||||
assigneeTrigger: "", // No assignee trigger
|
||||
directPrompt: "Please assess this issue", // But direct prompt is provided
|
||||
},
|
||||
});
|
||||
|
||||
const result = prepareContext(
|
||||
contextWithDirectPrompt,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-123-20240101_120000",
|
||||
);
|
||||
|
||||
expect(result.eventData.eventName).toBe("issues");
|
||||
expect(result.eventData.isPR).toBe(false);
|
||||
expect(result.directPrompt).toBe("Please assess this issue");
|
||||
if (
|
||||
result.eventData.eventName === "issues" &&
|
||||
result.eventData.eventAction === "assigned"
|
||||
) {
|
||||
expect(result.eventData.issueNumber).toBe("123");
|
||||
expect(result.eventData.assigneeTrigger).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should throw error when neither assigneeTrigger nor directPrompt provided for issue assigned event", () => {
|
||||
const contextWithoutTriggers = createMockContext({
|
||||
...mockIssueAssignedContext,
|
||||
inputs: {
|
||||
...mockIssueAssignedContext.inputs,
|
||||
assigneeTrigger: "", // No assignee trigger
|
||||
directPrompt: "", // No direct prompt
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
prepareContext(
|
||||
contextWithoutTriggers,
|
||||
"12345",
|
||||
"main",
|
||||
"claude/issue-123-20240101_120000",
|
||||
),
|
||||
).toThrow("ASSIGNEE_TRIGGER is required for issue assigned event");
|
||||
});
|
||||
});
|
||||
|
||||
describe("optional fields", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, it, expect } from "bun:test";
|
||||
import {
|
||||
createMockContext,
|
||||
mockIssueAssignedContext,
|
||||
mockIssueLabeledContext,
|
||||
mockIssueCommentContext,
|
||||
mockIssueOpenedContext,
|
||||
mockPullRequestReviewContext,
|
||||
@@ -29,10 +30,12 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "/claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "Fix the bug in the login form",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -55,10 +58,12 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "/claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -87,6 +92,11 @@ describe("checkContainsTrigger", () => {
|
||||
...mockIssueAssignedContext,
|
||||
payload: {
|
||||
...mockIssueAssignedContext.payload,
|
||||
assignee: {
|
||||
...(mockIssueAssignedContext.payload as IssuesAssignedEvent)
|
||||
.assignee,
|
||||
login: "otherUser",
|
||||
},
|
||||
issue: {
|
||||
...(mockIssueAssignedContext.payload as IssuesAssignedEvent).issue,
|
||||
assignee: {
|
||||
@@ -102,6 +112,39 @@ describe("checkContainsTrigger", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("label trigger", () => {
|
||||
it("should return true when issue is labeled with the trigger label", () => {
|
||||
const context = mockIssueLabeledContext;
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when issue is labeled with a different label", () => {
|
||||
const context = {
|
||||
...mockIssueLabeledContext,
|
||||
payload: {
|
||||
...mockIssueLabeledContext.payload,
|
||||
label: {
|
||||
...(mockIssueLabeledContext.payload as any).label,
|
||||
name: "bug",
|
||||
},
|
||||
},
|
||||
} as ParsedGitHubContext;
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-labeled events", () => {
|
||||
const context = {
|
||||
...mockIssueLabeledContext,
|
||||
eventAction: "opened",
|
||||
payload: {
|
||||
...mockIssueLabeledContext.payload,
|
||||
action: "opened",
|
||||
},
|
||||
} as ParsedGitHubContext;
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue body and title trigger", () => {
|
||||
it("should return true when issue body contains trigger phrase", () => {
|
||||
const context = mockIssueOpenedContext;
|
||||
@@ -227,10 +270,12 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -254,10 +299,12 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -281,10 +328,12 @@ describe("checkContainsTrigger", () => {
|
||||
inputs: {
|
||||
triggerPhrase: "@claude",
|
||||
assigneeTrigger: "",
|
||||
labelTrigger: "",
|
||||
directPrompt: "",
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user