Compare commits

..

3 Commits

Author SHA1 Message Date
claude[bot]
6325e51611 fix: rewrite branch.test.ts to match actual implementation
- Remove tests for non-existent cleanupBranch function
- Fix types: use Octokits instead of Octokit, ParsedGitHubContext instead of EntityContext
- Fix assertions: test actual BranchInfo return type (baseBranch, claudeBranch, currentBranch)
- Add comprehensive test coverage for:
  - Issue branch creation (default/custom base branch, commit signing)
  - PR branch handling (open/closed/merged states)
  - Error handling (branch not found, repository fetch failures)
  - Branch naming (kubernetes-compatible, custom prefixes)
- Use proper Bun test mocking patterns instead of Jest
- Match function signatures and behavior from actual setupBranch implementation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: kashyap murali <km-anthropic@users.noreply.github.com>
2025-09-10 04:42:56 +00:00
km-anthropic
266d8536dc Add branch operation tests
- Add comprehensive test suite for branch operations
- Cover setupBranch and cleanupBranch functions
- Include tests for PR and issue contexts
- Add error handling tests
2025-09-09 21:20:38 -07:00
km-anthropic
550d5c7843 fix: use agent mode for issues events with explicit prompts
Fixes #528 - Issues events now correctly use agent mode when an explicit
prompt is provided in the workflow YAML, matching the behavior of PR events.

Previously, issues events would always use tag mode (with tracking comments)
even when a prompt was provided, creating inconsistent behavior compared to
pull request events which correctly used agent mode for automation.

The fix adds a check for explicit prompts before checking for triggers,
ensuring consistent mode selection across all event types.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 18:57:11 -07:00
23 changed files with 258 additions and 475 deletions

View File

@@ -78,7 +78,7 @@ jobs:
4. Select appropriate labels from the available labels list provided above: 4. Select appropriate labels from the available labels list provided above:
- Choose labels that accurately reflect the issue's nature - Choose labels that accurately reflect the issue's nature
- Be specific but comprehensive - Be specific but comprehensive
- IMPORTANT: Add a priority label (P1, P2, or P3) based on the label descriptions from gh label list - Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
- Consider platform labels (android, ios) if applicable - Consider platform labels (android, ios) if applicable
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue. - If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
@@ -97,12 +97,10 @@ jobs:
EOF EOF
- name: Run Claude Code for Issue Triage - name: Run Claude Code for Issue Triage
uses: anthropics/claude-code-action@v1 uses: anthropics/claude-code-base-action@v1
with: with:
prompt: $(cat /tmp/claude-prompts/triage-prompt.txt) prompt: $(cat /tmp/claude-prompts/triage-prompt.txt)
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
allowed_non_write_users: "*"
claude_args: | claude_args: |
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues --allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues
--mcp-config /tmp/mcp-config/mcp-servers.json --mcp-config /tmp/mcp-config/mcp-servers.json

View File

@@ -27,10 +27,6 @@ inputs:
description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots." description: "Comma-separated list of allowed bot usernames, or '*' to allow all bots. Empty string (default) allows no bots."
required: false required: false
default: "" default: ""
allowed_non_write_users:
description: "Comma-separated list of usernames to allow without write permissions, or '*' to allow all users. Only works when github_token input is provided. WARNING: Use with extreme caution - this bypasses security checks and should only be used for workflows with very limited permissions (e.g., issue labeling)."
required: false
default: ""
# Claude Code configuration # Claude Code configuration
prompt: prompt:
@@ -77,14 +73,6 @@ inputs:
description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands" description: "Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands"
required: false required: false
default: "false" default: "false"
bot_id:
description: "GitHub user ID to use for git operations (defaults to Claude's bot ID)"
required: false
default: "41898282" # Claude's bot ID - see src/github/constants.ts
bot_name:
description: "GitHub username to use for git operations (defaults to Claude's bot name)"
required: false
default: "claude[bot]"
track_progress: track_progress:
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events." description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
required: false required: false
@@ -152,13 +140,10 @@ runs:
BRANCH_PREFIX: ${{ inputs.branch_prefix }} BRANCH_PREFIX: ${{ inputs.branch_prefix }}
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
ALLOWED_BOTS: ${{ inputs.allowed_bots }} ALLOWED_BOTS: ${{ inputs.allowed_bots }}
ALLOWED_NON_WRITE_USERS: ${{ inputs.allowed_non_write_users }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} DEFAULT_WORKFLOW_TOKEN: ${{ github.token }}
USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }} USE_COMMIT_SIGNING: ${{ inputs.use_commit_signing }}
BOT_ID: ${{ inputs.bot_id }}
BOT_NAME: ${{ inputs.bot_name }}
TRACK_PROGRESS: ${{ inputs.track_progress }} TRACK_PROGRESS: ${{ inputs.track_progress }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }} ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
CLAUDE_ARGS: ${{ inputs.claude_args }} CLAUDE_ARGS: ${{ inputs.claude_args }}
@@ -177,7 +162,7 @@ runs:
# Install Claude Code if no custom executable is provided # Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..." echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.108 curl -fsSL https://claude.ai/install.sh | bash -s 1.0.103
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"

View File

@@ -99,7 +99,7 @@ runs:
run: | run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
echo "Installing Claude Code..." echo "Installing Claude Code..."
curl -fsSL https://claude.ai/install.sh | bash -s 1.0.108 curl -fsSL https://claude.ai/install.sh | bash -s 1.0.103
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH

View File

@@ -21,7 +21,7 @@ This action supports the following GitHub events ([learn more GitHub event trigg
- `issues` - When issues are opened or assigned - `issues` - When issues are opened or assigned
- `pull_request_review` - When PR reviews are submitted - `pull_request_review` - When PR reviews are submitted
- `pull_request_review_comment` - When comments are made on PR reviews - `pull_request_review_comment` - When comments are made on PR reviews
- `repository_dispatch` - Custom events triggered via API - `repository_dispatch` - Custom events triggered via API (coming soon)
- `workflow_dispatch` - Manual workflow triggers (coming soon) - `workflow_dispatch` - Manual workflow triggers (coming soon)
## Automated Documentation Updates ## Automated Documentation Updates

View File

@@ -28,33 +28,6 @@ permissions:
The OIDC token is required in order for the Claude GitHub app to function. If you wish to not use the GitHub app, you can instead provide a `github_token` input to the action for Claude to operate with. See the [Claude Code permissions documentation][perms] for more. The OIDC token is required in order for the Claude GitHub app to function. If you wish to not use the GitHub app, you can instead provide a `github_token` input to the action for Claude to operate with. See the [Claude Code permissions documentation][perms] for more.
### Why am I getting '403 Resource not accessible by integration' errors?
This error occurs when the action tries to fetch the authenticated user information using a GitHub App installation token. GitHub App tokens have limited access and cannot access the `/user` endpoint, which causes this 403 error.
**Solution**: The action now includes `bot_id` and `bot_name` inputs that default to Claude's bot credentials. This avoids the need to fetch user information from the API.
For the default claude[bot]:
```yaml
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# bot_id and bot_name have sensible defaults, no need to specify
```
For custom bots, specify both:
```yaml
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_id: "12345678" # Your bot's GitHub user ID
bot_name: "my-bot" # Your bot's username
```
This issue typically only affects agent/automation mode workflows. Interactive workflows (with @claude mentions) don't encounter this issue as they use the comment author's information.
## Claude's Capabilities and Limitations ## Claude's Capabilities and Limitations
### Why won't Claude update workflow files when I ask it to? ### Why won't Claude update workflow files when I ask it to?

View File

@@ -14,19 +14,18 @@ This guide helps you migrate from Claude Code Action v0.x to v1.0. The new versi
The following inputs have been deprecated and replaced: The following inputs have been deprecated and replaced:
| Deprecated Input | Replacement | Notes | | Deprecated Input | Replacement | Notes |
| --------------------- | ------------------------------------ | --------------------------------------------- | | --------------------- | -------------------------------- | --------------------------------------------- |
| `mode` | Auto-detected | Action automatically chooses based on context | | `mode` | Auto-detected | Action automatically chooses based on context |
| `direct_prompt` | `prompt` | Direct drop-in replacement | | `direct_prompt` | `prompt` | Direct drop-in replacement |
| `override_prompt` | `prompt` | Use GitHub context variables instead | | `override_prompt` | `prompt` | Use GitHub context variables instead |
| `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments | | `custom_instructions` | `claude_args: --system-prompt` | Move to CLI arguments |
| `max_turns` | `claude_args: --max-turns` | Use CLI format | | `max_turns` | `claude_args: --max-turns` | Use CLI format |
| `model` | `claude_args: --model` | Specify via CLI | | `model` | `claude_args: --model` | Specify via CLI |
| `allowed_tools` | `claude_args: --allowedTools` | Use CLI format | | `allowed_tools` | `claude_args: --allowedTools` | Use CLI format |
| `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format | | `disallowed_tools` | `claude_args: --disallowedTools` | Use CLI format |
| `claude_env` | `settings` with env object | Use settings JSON | | `claude_env` | `settings` with env object | Use settings JSON |
| `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments | | `mcp_config` | `claude_args: --mcp-config` | Pass MCP config via CLI arguments |
| `timeout_minutes` | Use GitHub Actions `timeout-minutes` | Configure at job level instead of input level |
## Migration Examples ## Migration Examples
@@ -199,30 +198,6 @@ The `track_progress` input only works with these GitHub events:
} }
``` ```
### Timeout Configuration
**Before (v0.x):**
```yaml
- uses: anthropics/claude-code-action@beta
with:
timeout_minutes: 30
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
```
**After (v1.0):**
```yaml
jobs:
claude-task:
runs-on: ubuntu-latest
timeout-minutes: 30 # Moved to job level
steps:
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
```
## How Mode Detection Works ## How Mode Detection Works
The action now automatically detects the appropriate mode: The action now automatically detects the appropriate mode:
@@ -337,7 +312,6 @@ You can also pass MCP configuration from a file:
- [ ] Convert `disallowed_tools` to `claude_args` with `--disallowedTools` - [ ] Convert `disallowed_tools` to `claude_args` with `--disallowedTools`
- [ ] Move `claude_env` to `settings` JSON format - [ ] Move `claude_env` to `settings` JSON format
- [ ] Move `mcp_config` to `claude_args` with `--mcp-config` - [ ] Move `mcp_config` to `claude_args` with `--mcp-config`
- [ ] Replace `timeout_minutes` with GitHub Actions `timeout-minutes` at job level
- [ ] **Optional**: Add `track_progress: true` if you need tracking comments in automation mode - [ ] **Optional**: Add `track_progress: true` if you need tracking comments in automation mode
- [ ] Test workflow in a non-production environment - [ ] Test workflow in a non-production environment

View File

@@ -4,11 +4,6 @@
- **Repository Access**: The action can only be triggered by users with write access to the repository - **Repository Access**: The action can only be triggered by users with write access to the repository
- **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots - **Bot User Control**: By default, GitHub Apps and bots cannot trigger this action for security reasons. Use the `allowed_bots` parameter to enable specific bots or all bots
- **⚠️ Non-Write User Access (RISKY)**: The `allowed_non_write_users` parameter allows bypassing the write permission requirement. **This is a significant security risk and should only be used for workflows with extremely limited permissions** (e.g., issue labeling workflows that only have `issues: write` permission). This feature:
- Only works when `github_token` is provided as input (not with GitHub App authentication)
- Accepts either a comma-separated list of specific usernames or `*` to allow all users
- **Should be used with extreme caution** as it bypasses the primary security mechanism of this action
- Is designed for automation workflows where user permissions are already restricted by the workflow's permission scope
- **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in - **Token Permissions**: The GitHub app receives only a short-lived token scoped specifically to the repository it's operating in
- **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered - **No Cross-Repository Access**: Each action invocation is limited to the repository where it was triggered
- **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions - **Limited Scope**: The token cannot access other repositories or perform actions beyond the configured permissions

View File

@@ -47,31 +47,28 @@ jobs:
## Inputs ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------- | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | | `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\* | - | | `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 | - | | `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` | | `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 | "" | | `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 | - | | `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` | | `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 | - | | `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_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` | | `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 | "" | | `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 | - | | `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 | - | | `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` | | `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/` | | `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 | "" | | `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 | "" | | `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" | | `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` | | `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` | | `allowed_bots` | Comma-separated list of allowed bot usernames, or '\*' to allow all bots. Empty string (default) allows no bots | No | "" |
| `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 | "" |
### Deprecated Inputs ### Deprecated Inputs

View File

@@ -10,6 +10,7 @@ jobs:
permissions: permissions:
contents: read contents: read
issues: write issues: write
id-token: write
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -71,6 +72,5 @@ jobs:
- It's okay to not add any labels if none are clearly applicable - It's okay to not add any labels if none are clearly applicable
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
claude_args: | claude_args: |
--allowedTools "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues" --allowedTools "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"

View File

@@ -30,13 +30,9 @@ async function run() {
// Step 3: Check write permissions (only for entity contexts) // Step 3: Check write permissions (only for entity contexts)
if (isEntityContext(context)) { if (isEntityContext(context)) {
// Check if github_token was provided as input (not from app)
const githubTokenProvided = !!process.env.OVERRIDE_GITHUB_TOKEN;
const hasWritePermissions = await checkWritePermissions( const hasWritePermissions = await checkWritePermissions(
octokit.rest, octokit.rest,
context, context,
context.inputs.allowedNonWriteUsers,
githubTokenProvided,
); );
if (!hasWritePermissions) { if (!hasWritePermissions) {
throw new Error( throw new Error(

View File

@@ -1,13 +0,0 @@
/**
* GitHub-related constants used throughout the application
*/
/**
* Claude App bot user ID
*/
export const CLAUDE_APP_BOT_ID = 41898282;
/**
* Claude bot username
*/
export const CLAUDE_BOT_LOGIN = "claude[bot]";

View File

@@ -8,7 +8,6 @@ import type {
PullRequestReviewCommentEvent, PullRequestReviewCommentEvent,
WorkflowRunEvent, WorkflowRunEvent,
} from "@octokit/webhooks-types"; } from "@octokit/webhooks-types";
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "./constants";
// Custom types for GitHub Actions events that aren't webhooks // Custom types for GitHub Actions events that aren't webhooks
export type WorkflowDispatchEvent = { export type WorkflowDispatchEvent = {
action?: never; action?: never;
@@ -26,20 +25,6 @@ export type WorkflowDispatchEvent = {
workflow: string; workflow: string;
}; };
export type RepositoryDispatchEvent = {
action: string;
client_payload?: Record<string, any>;
repository: {
name: string;
owner: {
login: string;
};
};
sender: {
login: string;
};
};
export type ScheduleEvent = { export type ScheduleEvent = {
action?: never; action?: never;
schedule?: string; schedule?: string;
@@ -62,7 +47,6 @@ const ENTITY_EVENT_NAMES = [
const AUTOMATION_EVENT_NAMES = [ const AUTOMATION_EVENT_NAMES = [
"workflow_dispatch", "workflow_dispatch",
"repository_dispatch",
"schedule", "schedule",
"workflow_run", "workflow_run",
] as const; ] as const;
@@ -90,10 +74,7 @@ type BaseContext = {
branchPrefix: string; branchPrefix: string;
useStickyComment: boolean; useStickyComment: boolean;
useCommitSigning: boolean; useCommitSigning: boolean;
botId: string;
botName: string;
allowedBots: string; allowedBots: string;
allowedNonWriteUsers: string;
trackProgress: boolean; trackProgress: boolean;
}; };
}; };
@@ -111,14 +92,10 @@ export type ParsedGitHubContext = BaseContext & {
isPR: boolean; isPR: boolean;
}; };
// Context for automation events (workflow_dispatch, repository_dispatch, schedule, workflow_run) // Context for automation events (workflow_dispatch, schedule, workflow_run)
export type AutomationContext = BaseContext & { export type AutomationContext = BaseContext & {
eventName: AutomationEventName; eventName: AutomationEventName;
payload: payload: WorkflowDispatchEvent | ScheduleEvent | WorkflowRunEvent;
| WorkflowDispatchEvent
| RepositoryDispatchEvent
| ScheduleEvent
| WorkflowRunEvent;
}; };
// Union type for all contexts // Union type for all contexts
@@ -145,10 +122,7 @@ export function parseGitHubContext(): GitHubContext {
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true", useStickyComment: process.env.USE_STICKY_COMMENT === "true",
useCommitSigning: process.env.USE_COMMIT_SIGNING === "true", useCommitSigning: process.env.USE_COMMIT_SIGNING === "true",
botId: process.env.BOT_ID ?? String(CLAUDE_APP_BOT_ID),
botName: process.env.BOT_NAME ?? CLAUDE_BOT_LOGIN,
allowedBots: process.env.ALLOWED_BOTS ?? "", allowedBots: process.env.ALLOWED_BOTS ?? "",
allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "",
trackProgress: process.env.TRACK_PROGRESS === "true", trackProgress: process.env.TRACK_PROGRESS === "true",
}, },
}; };
@@ -211,13 +185,6 @@ export function parseGitHubContext(): GitHubContext {
payload: context.payload as unknown as WorkflowDispatchEvent, payload: context.payload as unknown as WorkflowDispatchEvent,
}; };
} }
case "repository_dispatch": {
return {
...commonFields,
eventName: "repository_dispatch",
payload: context.payload as unknown as RepositoryDispatchEvent,
};
}
case "schedule": { case "schedule": {
return { return {
...commonFields, ...commonFields,

View File

@@ -0,0 +1,178 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { mock } from "bun:test";
import { setupBranch, type BranchInfo } from "../branch";
import type { Octokits } from "../../api/client";
import type { FetchDataResult } from "../../data/fetcher";
import type { ParsedGitHubContext } from "../../context";
import type { GitHubPullRequest, GitHubIssue } from "../../types";
// Mock process.exit to prevent tests from actually exiting
const mockExit = mock(() => {});
const originalExit = process.exit;
describe("setupBranch", () => {
let mockOctokits: Octokits;
let mockContext: ParsedGitHubContext;
let mockGithubData: FetchDataResult;
beforeEach(() => {
// Replace process.exit temporarily
(process as any).exit = mockExit;
mockExit.mockClear();
// Simple mock objects
mockOctokits = {
rest: {
repos: {
get: mock(() => Promise.resolve({ data: { default_branch: "main" } })),
},
git: {
getRef: mock(() => Promise.resolve({
data: { object: { sha: "abc123def456" } }
})),
},
},
graphql: mock(() => Promise.resolve({})),
} as any;
mockContext = {
repository: {
owner: "test-owner",
repo: "test-repo",
full_name: "test-owner/test-repo",
},
isPR: false,
entityNumber: 123,
inputs: {
branchPrefix: "claude/",
useCommitSigning: false,
},
} as ParsedGitHubContext;
// Default mock data for issues
mockGithubData = {
contextData: {
title: "Test Issue",
body: "Test issue body",
state: "OPEN",
} as GitHubIssue,
comments: [],
changedFiles: [],
changedFilesWithSHA: [],
reviewData: null,
};
});
afterEach(() => {
// Restore original process.exit
process.exit = originalExit;
});
describe("Issue branch creation", () => {
test("should create new branch for issue using default branch as source", async () => {
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.baseBranch).toBe("main");
expect(result.claudeBranch).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
expect(result.currentBranch).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
});
test("should use provided base branch as source", async () => {
mockContext.inputs.baseBranch = "develop";
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.baseBranch).toBe("develop");
expect(result.claudeBranch).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
});
test("should handle commit signing mode", async () => {
mockContext.inputs.useCommitSigning = true;
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.baseBranch).toBe("main");
expect(result.currentBranch).toBe("main"); // Should stay on source branch
expect(result.claudeBranch).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
});
});
describe("PR branch handling", () => {
beforeEach(() => {
mockContext.isPR = true;
mockGithubData.contextData = {
title: "Test PR",
body: "Test PR body",
state: "OPEN",
baseRefName: "main",
headRefName: "feature/test",
commits: { totalCount: 5 },
} as GitHubPullRequest;
});
test("should checkout existing PR branch for open PR", async () => {
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.baseBranch).toBe("main");
expect(result.currentBranch).toBe("feature/test");
expect(result.claudeBranch).toBeUndefined(); // No claude branch for open PRs
});
test("should create new branch for closed PR", async () => {
const closedPR = mockGithubData.contextData as GitHubPullRequest;
closedPR.state = "CLOSED";
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.baseBranch).toBe("main");
expect(result.claudeBranch).toMatch(/^claude\/pr-123-\d{8}-\d{4}$/);
expect(result.currentBranch).toMatch(/^claude\/pr-123-\d{8}-\d{4}$/);
});
test("should create new branch for merged PR", async () => {
const mergedPR = mockGithubData.contextData as GitHubPullRequest;
mergedPR.state = "MERGED";
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.baseBranch).toBe("main");
expect(result.claudeBranch).toMatch(/^claude\/pr-123-\d{8}-\d{4}$/);
});
});
describe("Error handling", () => {
test("should exit with code 1 when source branch doesn't exist", async () => {
mockOctokits.rest.git.getRef = mock(() => Promise.reject(new Error("Branch not found")));
await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(mockExit).toHaveBeenCalledWith(1);
});
test("should exit with code 1 when repository fetch fails", async () => {
mockOctokits.rest.repos.get = mock(() => Promise.reject(new Error("Repository not found")));
await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(mockExit).toHaveBeenCalledWith(1);
});
});
describe("Branch naming", () => {
test("should generate kubernetes-compatible branch names", async () => {
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
// Branch name should be lowercase, use hyphens, and include timestamp
expect(result.claudeBranch).toMatch(/^claude\/issue-123-\d{8}-\d{4}$/);
expect(result.claudeBranch?.length).toBeLessThanOrEqual(50);
});
test("should use custom branch prefix", async () => {
mockContext.inputs.branchPrefix = "ai/";
const result = await setupBranch(mockOctokits, mockGithubData, mockContext);
expect(result.claudeBranch).toMatch(/^ai\/issue-123-\d{8}-\d{4}$/);
});
});
});

View File

@@ -17,7 +17,7 @@ type GitUser = {
export async function configureGitAuth( export async function configureGitAuth(
githubToken: string, githubToken: string,
context: GitHubContext, context: GitHubContext,
user: GitUser, user: GitUser | null,
) { ) {
console.log("Configuring git authentication for non-signing mode"); console.log("Configuring git authentication for non-signing mode");
@@ -28,14 +28,20 @@ export async function configureGitAuth(
? "users.noreply.github.com" ? "users.noreply.github.com"
: `users.noreply.${serverUrl.hostname}`; : `users.noreply.${serverUrl.hostname}`;
// Configure git user // Configure git user based on the comment creator
console.log("Configuring git user..."); console.log("Configuring git user...");
const botName = user.login; if (user) {
const botId = user.id; const botName = user.login;
console.log(`Setting git user as ${botName}...`); const botId = user.id;
await $`git config user.name "${botName}"`; console.log(`Setting git user as ${botName}...`);
await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`; await $`git config user.name "${botName}"`;
console.log(`✓ Set git user as ${botName}`); await $`git config user.email "${botId}+${botName}@${noreplyDomain}"`;
console.log(`✓ Set git user as ${botName}`);
} else {
console.log("No user data in comment, using default bot user");
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "41898282+github-actions[bot]@${noreplyDomain}"`;
}
// Remove the authorization header that actions/checkout sets // Remove the authorization header that actions/checkout sets
console.log("Removing existing git authentication headers..."); console.log("Removing existing git authentication headers...");

View File

@@ -6,43 +6,17 @@ import type { Octokit } from "@octokit/rest";
* Check if the actor has write permissions to the repository * Check if the actor has write permissions to the repository
* @param octokit - The Octokit REST client * @param octokit - The Octokit REST client
* @param context - The GitHub context * @param context - The GitHub context
* @param allowedNonWriteUsers - Comma-separated list of users allowed without write permissions, or '*' for all
* @param githubTokenProvided - Whether github_token was provided as input (not from app)
* @returns true if the actor has write permissions, false otherwise * @returns true if the actor has write permissions, false otherwise
*/ */
export async function checkWritePermissions( export async function checkWritePermissions(
octokit: Octokit, octokit: Octokit,
context: ParsedGitHubContext, context: ParsedGitHubContext,
allowedNonWriteUsers?: string,
githubTokenProvided?: boolean,
): Promise<boolean> { ): Promise<boolean> {
const { repository, actor } = context; const { repository, actor } = context;
try { try {
core.info(`Checking permissions for actor: ${actor}`); core.info(`Checking permissions for actor: ${actor}`);
// Check if we should bypass permission checks for this user
if (allowedNonWriteUsers && githubTokenProvided) {
const allowedUsers = allowedNonWriteUsers.trim();
if (allowedUsers === "*") {
core.warning(
`⚠️ SECURITY WARNING: Bypassing write permission check for ${actor} due to allowed_non_write_users='*'. This should only be used for workflows with very limited permissions.`,
);
return true;
} else if (allowedUsers) {
const allowedUserList = allowedUsers
.split(",")
.map((u) => u.trim())
.filter((u) => u.length > 0);
if (allowedUserList.includes(actor)) {
core.warning(
`⚠️ SECURITY WARNING: Bypassing write permission check for ${actor} due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.`,
);
return true;
}
}
}
// Check if the actor is a GitHub App (bot user) // Check if the actor is a GitHub App (bot user)
if (actor.endsWith("[bot]")) { if (actor.endsWith("[bot]")) {
core.info(`Actor is a GitHub App: ${actor}`); core.info(`Actor is a GitHub App: ${actor}`);

View File

@@ -74,6 +74,10 @@ export async function prepareMcpConfig(
tool.startsWith("mcp__github_inline_comment__"), tool.startsWith("mcp__github_inline_comment__"),
); );
const hasGitHubCommentTools = allowedToolsList.some((tool) =>
tool.startsWith("mcp__github_comment__"),
);
const hasGitHubCITools = allowedToolsList.some((tool) => const hasGitHubCITools = allowedToolsList.some((tool) =>
tool.startsWith("mcp__github_ci__"), tool.startsWith("mcp__github_ci__"),
); );
@@ -85,7 +89,7 @@ export async function prepareMcpConfig(
// Include comment server: // Include comment server:
// - Always in tag mode (for updating Claude comments) // - Always in tag mode (for updating Claude comments)
// - Only with explicit tools in agent mode // - Only with explicit tools in agent mode
const shouldIncludeCommentServer = !isAgentMode; const shouldIncludeCommentServer = !isAgentMode || hasGitHubCommentTools;
if (shouldIncludeCommentServer) { if (shouldIncludeCommentServer) {
baseMcpConfig.mcpServers.github_comment = { baseMcpConfig.mcpServers.github_comment = {

View File

@@ -77,16 +77,22 @@ export const agentMode: Mode = {
return false; return false;
}, },
async prepare({ context, githubToken }: ModeOptions): Promise<ModeResult> { async prepare({
context,
githubToken,
octokit,
}: ModeOptions): Promise<ModeResult> {
// Configure git authentication for agent mode (same as tag mode) // Configure git authentication for agent mode (same as tag mode)
if (!context.inputs.useCommitSigning) { if (!context.inputs.useCommitSigning) {
// Use bot_id and bot_name from inputs directly
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
};
try { try {
// Get the authenticated user (will be claude[bot] when using Claude App token)
const { data: authenticatedUser } =
await octokit.rest.users.getAuthenticated();
const user = {
login: authenticatedUser.login,
id: authenticatedUser.id,
};
// Use the shared git configuration function // Use the shared git configuration function
await configureGitAuth(githubToken, context, user); await configureGitAuth(githubToken, context, user);
} catch (error) { } catch (error) {

View File

@@ -89,14 +89,8 @@ export const tagMode: Mode = {
// Configure git authentication if not using commit signing // Configure git authentication if not using commit signing
if (!context.inputs.useCommitSigning) { if (!context.inputs.useCommitSigning) {
// Use bot_id and bot_name from inputs directly
const user = {
login: context.inputs.botName,
id: parseInt(context.inputs.botId),
};
try { try {
await configureGitAuth(githubToken, context, user); await configureGitAuth(githubToken, context, commentData.user);
} catch (error) { } catch (error) {
console.error("Failed to configure git authentication:", error); console.error("Failed to configure git authentication:", error);
throw error; throw error;

View File

@@ -2,7 +2,6 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { prepareMcpConfig } from "../src/mcp/install-mcp-server"; import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../src/github/context"; import type { ParsedGitHubContext } from "../src/github/context";
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
describe("prepareMcpConfig", () => { describe("prepareMcpConfig", () => {
let consoleInfoSpy: any; let consoleInfoSpy: any;
@@ -32,10 +31,7 @@ describe("prepareMcpConfig", () => {
branchPrefix: "", branchPrefix: "",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
}, },
}; };

View File

@@ -1,7 +1,6 @@
import type { import type {
ParsedGitHubContext, ParsedGitHubContext,
AutomationContext, AutomationContext,
RepositoryDispatchEvent,
} from "../src/github/context"; } from "../src/github/context";
import type { import type {
IssuesEvent, IssuesEvent,
@@ -10,7 +9,6 @@ import type {
PullRequestReviewEvent, PullRequestReviewEvent,
PullRequestReviewCommentEvent, PullRequestReviewCommentEvent,
} from "@octokit/webhooks-types"; } from "@octokit/webhooks-types";
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
const defaultInputs = { const defaultInputs = {
prompt: "", prompt: "",
@@ -20,10 +18,7 @@ const defaultInputs = {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
}; };
@@ -83,33 +78,6 @@ export const createMockAutomationContext = (
return { ...baseContext, ...overrides, inputs: mergedInputs }; return { ...baseContext, ...overrides, inputs: mergedInputs };
}; };
export const mockRepositoryDispatchContext: AutomationContext = {
runId: "1234567890",
eventName: "repository_dispatch",
eventAction: undefined,
repository: defaultRepository,
actor: "automation-user",
payload: {
action: "trigger-analysis",
client_payload: {
source: "issue-detective",
issue_number: 42,
repository_name: "test-owner/test-repo",
analysis_type: "bug-report",
},
repository: {
name: "test-repo",
owner: {
login: "test-owner",
},
},
sender: {
login: "automation-user",
},
} as RepositoryDispatchEvent,
inputs: defaultInputs,
};
export const mockIssueOpenedContext: ParsedGitHubContext = { export const mockIssueOpenedContext: ParsedGitHubContext = {
runId: "1234567890", runId: "1234567890",
eventName: "issues", eventName: "issues",

View File

@@ -1,23 +1,13 @@
import { import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
describe,
test,
expect,
beforeEach,
afterEach,
spyOn,
mock,
} from "bun:test";
import { agentMode } from "../../src/modes/agent"; import { agentMode } from "../../src/modes/agent";
import type { GitHubContext } from "../../src/github/context"; import type { GitHubContext } from "../../src/github/context";
import { createMockContext, createMockAutomationContext } from "../mockContext"; import { createMockContext, createMockAutomationContext } from "../mockContext";
import * as core from "@actions/core"; import * as core from "@actions/core";
import * as gitConfig from "../../src/github/operations/git-config";
describe("Agent Mode", () => { describe("Agent Mode", () => {
let mockContext: GitHubContext; let mockContext: GitHubContext;
let exportVariableSpy: any; let exportVariableSpy: any;
let setOutputSpy: any; let setOutputSpy: any;
let configureGitAuthSpy: any;
beforeEach(() => { beforeEach(() => {
mockContext = createMockAutomationContext({ mockContext = createMockAutomationContext({
@@ -27,22 +17,13 @@ describe("Agent Mode", () => {
() => {}, () => {},
); );
setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {});
// Mock configureGitAuth to prevent actual git commands from running
configureGitAuthSpy = spyOn(
gitConfig,
"configureGitAuth",
).mockImplementation(async () => {
// Do nothing - prevent actual git config modifications
});
}); });
afterEach(() => { afterEach(() => {
exportVariableSpy?.mockClear(); exportVariableSpy?.mockClear();
setOutputSpy?.mockClear(); setOutputSpy?.mockClear();
configureGitAuthSpy?.mockClear();
exportVariableSpy?.mockRestore(); exportVariableSpy?.mockRestore();
setOutputSpy?.mockRestore(); setOutputSpy?.mockRestore();
configureGitAuthSpy?.mockRestore();
}); });
test("agent mode has correct properties", () => { test("agent mode has correct properties", () => {
@@ -76,11 +57,6 @@ describe("Agent Mode", () => {
}); });
expect(agentMode.shouldTrigger(scheduleContext)).toBe(false); expect(agentMode.shouldTrigger(scheduleContext)).toBe(false);
const repositoryDispatchContext = createMockAutomationContext({
eventName: "repository_dispatch",
});
expect(agentMode.shouldTrigger(repositoryDispatchContext)).toBe(false);
// Should NOT trigger for entity events without prompt // Should NOT trigger for entity events without prompt
const entityEvents = [ const entityEvents = [
"issue_comment", "issue_comment",
@@ -97,7 +73,6 @@ describe("Agent Mode", () => {
// Should trigger for ANY event when prompt is provided // Should trigger for ANY event when prompt is provided
const allEvents = [ const allEvents = [
"workflow_dispatch", "workflow_dispatch",
"repository_dispatch",
"schedule", "schedule",
"issue_comment", "issue_comment",
"pull_request", "pull_request",
@@ -107,9 +82,7 @@ describe("Agent Mode", () => {
allEvents.forEach((eventName) => { allEvents.forEach((eventName) => {
const contextWithPrompt = const contextWithPrompt =
eventName === "workflow_dispatch" || eventName === "workflow_dispatch" || eventName === "schedule"
eventName === "repository_dispatch" ||
eventName === "schedule"
? createMockAutomationContext({ ? createMockAutomationContext({
eventName, eventName,
inputs: { prompt: "Do something" }, inputs: { prompt: "Do something" },
@@ -140,22 +113,7 @@ describe("Agent Mode", () => {
// Set CLAUDE_ARGS environment variable // Set CLAUDE_ARGS environment variable
process.env.CLAUDE_ARGS = "--model claude-sonnet-4 --max-turns 10"; process.env.CLAUDE_ARGS = "--model claude-sonnet-4 --max-turns 10";
const mockOctokit = { const mockOctokit = {} as any;
rest: {
users: {
getAuthenticated: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
}),
),
getByUsername: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
}),
),
},
},
} as any;
const result = await agentMode.prepare({ const result = await agentMode.prepare({
context: contextWithCustomArgs, context: contextWithCustomArgs,
octokit: mockOctokit, octokit: mockOctokit,
@@ -194,22 +152,7 @@ describe("Agent Mode", () => {
// In v1-dev, we only have the unified prompt field // In v1-dev, we only have the unified prompt field
contextWithPrompts.inputs.prompt = "Custom prompt content"; contextWithPrompts.inputs.prompt = "Custom prompt content";
const mockOctokit = { const mockOctokit = {} as any;
rest: {
users: {
getAuthenticated: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
}),
),
getByUsername: mock(() =>
Promise.resolve({
data: { login: "test-user", id: 12345 },
}),
),
},
},
} as any;
await agentMode.prepare({ await agentMode.prepare({
context: contextWithPrompts, context: contextWithPrompts,
octokit: mockOctokit, octokit: mockOctokit,

View File

@@ -2,11 +2,7 @@ import { describe, test, expect } from "bun:test";
import { getMode, isValidMode } from "../../src/modes/registry"; import { getMode, isValidMode } from "../../src/modes/registry";
import { agentMode } from "../../src/modes/agent"; import { agentMode } from "../../src/modes/agent";
import { tagMode } from "../../src/modes/tag"; import { tagMode } from "../../src/modes/tag";
import { import { createMockContext, createMockAutomationContext } from "../mockContext";
createMockContext,
createMockAutomationContext,
mockRepositoryDispatchContext,
} from "../mockContext";
describe("Mode Registry", () => { describe("Mode Registry", () => {
const mockContext = createMockContext({ const mockContext = createMockContext({
@@ -54,34 +50,6 @@ describe("Mode Registry", () => {
expect(mode.name).toBe("agent"); expect(mode.name).toBe("agent");
}); });
test("getMode auto-detects agent for repository_dispatch event", () => {
const mode = getMode(mockRepositoryDispatchContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode auto-detects agent for repository_dispatch with client_payload", () => {
const contextWithPayload = createMockAutomationContext({
eventName: "repository_dispatch",
payload: {
action: "trigger-analysis",
client_payload: {
source: "external-system",
metadata: { priority: "high" },
},
repository: {
name: "test-repo",
owner: { login: "test-owner" },
},
sender: { login: "automation-user" },
},
});
const mode = getMode(contextWithPayload);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
// Removed test - legacy mode names no longer supported in v1.0 // Removed test - legacy mode names no longer supported in v1.0
test("getMode auto-detects agent mode for PR opened", () => { test("getMode auto-detects agent mode for PR opened", () => {

View File

@@ -2,7 +2,6 @@ import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { checkWritePermissions } from "../src/github/validation/permissions"; import { checkWritePermissions } from "../src/github/validation/permissions";
import type { ParsedGitHubContext } from "../src/github/context"; import type { ParsedGitHubContext } from "../src/github/context";
import { CLAUDE_APP_BOT_ID, CLAUDE_BOT_LOGIN } from "../src/github/constants";
describe("checkWritePermissions", () => { describe("checkWritePermissions", () => {
let coreInfoSpy: any; let coreInfoSpy: any;
@@ -68,10 +67,7 @@ describe("checkWritePermissions", () => {
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
useCommitSigning: false, useCommitSigning: false,
botId: String(CLAUDE_APP_BOT_ID),
botName: CLAUDE_BOT_LOGIN,
allowedBots: "", allowedBots: "",
allowedNonWriteUsers: "",
trackProgress: false, trackProgress: false,
}, },
}); });
@@ -176,126 +172,4 @@ describe("checkWritePermissions", () => {
username: "test-user", username: "test-user",
}); });
}); });
describe("allowed_non_write_users bypass", () => {
test("should bypass permission check for specific user when github_token provided", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"test-user,other-user",
true,
);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
"⚠️ SECURITY WARNING: Bypassing write permission check for test-user due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.",
);
});
test("should bypass permission check for all users with wildcard", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"*",
true,
);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
"⚠️ SECURITY WARNING: Bypassing write permission check for test-user due to allowed_non_write_users='*'. This should only be used for workflows with very limited permissions.",
);
});
test("should NOT bypass permission check when user not in allowed list", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"other-user,another-user",
true,
);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should NOT bypass permission check when github_token not provided", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"test-user",
false,
);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should NOT bypass permission check when allowed_non_write_users is empty", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
"",
true,
);
expect(result).toBe(false);
expect(coreWarningSpy).toHaveBeenCalledWith(
"Actor has insufficient permissions: read",
);
});
test("should handle whitespace in allowed_non_write_users list", async () => {
const mockOctokit = createMockOctokit("read");
const context = createContext();
const result = await checkWritePermissions(
mockOctokit,
context,
" test-user , other-user ",
true,
);
expect(result).toBe(true);
expect(coreWarningSpy).toHaveBeenCalledWith(
"⚠️ SECURITY WARNING: Bypassing write permission check for test-user due to allowed_non_write_users configuration. This should only be used for workflows with very limited permissions.",
);
});
test("should bypass for bot users even when allowed_non_write_users is set", async () => {
const mockOctokit = createMockOctokit("none");
const context = createContext();
context.actor = "test-bot[bot]";
const result = await checkWritePermissions(
mockOctokit,
context,
"some-user",
true,
);
expect(result).toBe(true);
expect(coreInfoSpy).toHaveBeenCalledWith(
"Actor is a GitHub App: test-bot[bot]",
);
});
});
}); });