feat: implement Claude Code GitHub Action v1.0 with auto-detection and slash commands (#421)

* feat: implement Claude Code GitHub Action v1.0 with auto-detection and slash commands

Major features:
- Mode auto-detection based on GitHub event type
- Unified prompt field replacing override_prompt and direct_prompt
- Slash command system with pre-built commands
- Full backward compatibility with v0.x

Key changes:
- Add mode detector for automatic mode selection
- Implement slash command loader with YAML frontmatter support
- Update action.yml with new prompt input
- Create pre-built slash commands for common tasks
- Update all tests for v1.0 compatibility

Breaking changes (with compatibility):
- Mode input now optional (auto-detected)
- override_prompt deprecated (use prompt)
- direct_prompt deprecated (use prompt)

* test + formatting fixes

* feat: simplify to two modes (tag and agent) for v1.0

BREAKING CHANGES:
- Remove review mode entirely - now handled via slash commands in agent mode
- Remove all deprecated backward compatibility fields (mode, anthropic_model, override_prompt, direct_prompt)
- Simplify mode detection: prompt overrides everything, then @claude mentions trigger tag mode, default is agent mode
- Remove slash command resolution from GitHub Action - Claude Code handles natively
- Remove variable substitution - prompts passed through as-is

Architecture changes:
- Only two modes now: tag (for @claude mentions) and agent (everything else)
- Agent mode is the default for all events including PRs
- Users configure behavior via prompts/slash commands (e.g. /review)
- GitHub Action is now a thin wrapper that passes prompts to Claude Code
- Mode names changed: 'experimental-review' → removed entirely

This aligns with the philosophy that the GitHub Action should do minimal work and delegate to Claude Code for all intelligent behavior.

* fix: address PR review comments for v1.0 simplification

- Remove duplicate prompt field spread (line 160)
- Remove async from generatePrompt since slash commands are handled by Claude Code
- Add detailed comment explaining why prompt → agent mode logic
- Remove entire slash-commands loader and directories as Claude Code handles natively
- Simplify prompt generation to just pass through to Claude Code

These changes align with v1.0 philosophy: GitHub Action is a thin wrapper
that delegates everything to Claude Code for native handling.

* chore: remove unused js-yaml dependencies

These were added for slash-command YAML parsing but are no longer
needed since we removed slash-command preprocessing entirely

* fix: remove experimental-review mode reference from MCP config

The inline comment server configuration was checking for deprecated
'mode' field. Since review mode is removed in v1.0, this conditional
block is no longer needed.

* prettify

* feat: add claudeArgs input for direct CLI argument passing

- Add claude_args input to both action.yml files
- Implement shell-style argument parsing with quote handling
- Pass arguments directly to Claude CLI for maximum flexibility
- Add comprehensive tests for argument parsing
- Log custom arguments for debugging

Users can now pass any Claude CLI arguments directly:
  claude_args: '--max-turns 3 --mcp-config /path/to/config.json'

This provides power users full control over Claude's behavior without
waiting for specific inputs to be added to the action.

* refactor: use industry-standard shell-quote for argument parsing

- Replace custom parseShellArgs with battle-tested shell-quote package
- Simplify code by removing unnecessary -p filtering (Claude handles it)
- Update tests to use shell-quote directly
- Add example workflow showing claude_args usage

This provides more robust argument parsing while reducing code complexity.

* bun format

* feat: add claudeArgs input for direct CLI argument passing

- Add claude_args input to action.yml for flexible CLI control
- Parse arguments with industry-standard shell-quote library
- Maintain proper argument order: -p [claudeArgs] [legacy] [BASE_ARGS]
- Keep tag mode defaults (needed for functionality)
- Agent mode has no defaults (full user control)
- Add comprehensive tests for new functionality
- Add example workflow showing usage

* format

* refactor: complete v1.0 simplification by removing all legacy inputs

- Remove all backward compatibility for v1.0 simplification
- Remove 10 legacy inputs from base-action/action.yml
- Remove 9 legacy inputs from main action.yml
- Simplify ClaudeOptions type to just timeoutMinutes and claudeArgs
- Remove all legacy option handling from prepareRunConfig
- Update tests to remove references to deleted fields
- Remove obsolete test file github/context.test.ts
- Clean up types to remove customInstructions, allowedTools, disallowedTools

Users now use claudeArgs exclusively for CLI control.

* fix: update MCP server tests after removing additionalPermissions

- Change github_ci server logic to check for workflow token presence
- Update test names to reflect new behavior
- Fix test that was incorrectly setting workflow token

* model version update

* Update package json

* remove deprecated workflow file (tests features we no longer support)

* Simplify agent mode and re-add additional_permissions input

- Agent mode now only triggers when explicit prompt is provided
- Removed automatic triggering for workflow_dispatch/schedule without prompt
- Re-added additional_permissions input for requesting GitHub permissions
- Fixed TypeScript types for mock context helpers to properly handle partial inputs
- Updated documentation to reflect simplified mode behavior

* Fix MCP config not being passed to Claude CLI

The MCP servers (including github_comment server) were configured but not passed to Claude. This caused the "update_claude_comment" tool to be unavailable.

Changes:
- Write MCP config to a file at $RUNNER_TEMP/claude-mcp-config.json
- Add mcp_config_file output from prepare.ts
- Pass MCP config file via --mcp-config flag in claude_args
- Use fs/promises writeFile to match codebase conventions

* Fix MCP tool availability and shell escaping in tag mode

Pass MCP config and allowed tools through claude_args to ensure tools like
mcp__github_comment__update_claude_comment are properly available to Claude CLI.

Key changes:
- Tag mode outputs claude_args with MCP config (as JSON string) and allowed tools
- Fixed shell escaping vulnerability when JSON contains single quotes
- Agent mode passes through user-provided claude_args unchanged
- Re-added mcp_config input for users to provide custom MCP servers
- Cleaned up misleading comments and unused file operations
- Clarified test workflow is for fork testing

Security fix: Properly escape single quotes in MCP config JSON to prevent
shell injection vulnerabilities.

Co-Authored-By: Claude <noreply@anthropic.com>

* bun format

* tests, typecheck, format

* registry test update

* Update agent mode to have github server as a default

* Fix agent mode to include GitHub MCP server with proper token

* Simplify review workflow - prevent multiple submissions

- Rename workflow to avoid conflicts
- Remove review submission tools
- Keep only essential tools for reading and analyzing PR

* Add GitHub MCP server and context prefix to agent mode

- Include main GitHub MCP server (Docker-based) by default
- Fetch and prefix GitHub context to prompts when in PR/issue context
- Users no longer need to manually configure GitHub tools

* Delete .github/workflows/claude-auto-review-test.yml

* Remove github_comment and inline_comment servers from agent mode defaults

- Agent mode now only includes the main GitHub MCP server by default
- Users can add additional servers via mcp_config if needed
- Reduces unnecessary MCP server overhead

* Remove all default MCP servers from agent mode

Agent mode now starts with no default servers - users must explicitly configure any MCP servers they need via mcp_config input

* Remove GitHub context prefixing and clean up agent mode

- Remove automatic GitHub context fetching and prefixing
- Remove unused imports (fetcher, formatter, context checks)
- Clean up comments
- Agent mode now simply passes through the user's prompt as-is

* Add GitHub MCP support to agent mode

- Parse --allowedTools from claude_args to detect when user wants GitHub MCPs
- Wire up github_inline_comment server in prepareMcpConfig for PR contexts
- Update agent mode to use prepareMcpConfig instead of manual config
- Add comprehensive tests for parseAllowedTools edge cases
- Fix TypeScript types to support both entity and automation contexts

* Format code with prettier

* Fix agent mode test to expect branch values

* Fix agent test to handle dynamic branch names from environment

* Better fix: Control environment variables in agent test for predictable behavior

* minor formatting

* Simplify MCP configuration to use multiple --mcp-config flags

- Remove MCP config merging logic from prepareMcpConfig
- Update agent and tag modes to pass multiple --mcp-config flags
- Let Claude handle config merging natively through multiple flags
- Fix TypeScript errors in test file

This approach is cleaner and relies on Claude's built-in support for multiple --mcp-config flags instead of manual JSON merging.

* feat: Copy project subagents to Claude runtime environment

Enables custom subagents defined in .claude/agents/ to work in GitHub Actions by:
- Checking for project agents in GITHUB_WORKSPACE/.claude/agents/
- Creating ~/.claude/agents/ directory if needed
- Copying all .md agent files to Claude's runtime location
- Following same pattern as slash commands for consistency

Includes comprehensive test coverage for the new functionality.

* formatting

* Add auto-fix CI workflows with slash command and inline approaches

- Add /fix-ci slash command for programmatic CI failure fixing
- Create auto-fix-ci.yml workflow using slash command approach
- Create auto-fix-ci-inline.yml workflow with full inline prompt
- Both workflows automatically analyze CI failures and create fix branches

* Add workflow_run event support and auto-fix CI workflows

- Add support for workflow_run event type in GitHub context
- Create /fix-ci slash command for programmatic CI failure fixing
- Add auto-fix-ci.yml workflow using slash command approach
- Add auto-fix-ci-inline.yml workflow with full inline prompt
- Both workflows automatically analyze CI failures and create fix branches
- Fix workflow syntax issues with optional chaining operator

* Use proper WorkflowRunEvent type instead of any

* bun formatting

* Remove auto-fix workflows and commands from v1-dev

These files should only exist in km-anthropic fork:
- .github/workflows/auto-fix-ci.yml
- .github/workflows/auto-fix-ci-inline.yml
- slash-commands/fix-ci.md
- .claude/commands/fix-ci.md

The workflow_run event support remains as it's useful for general automation.

* feat: Expose GitHub token as action output for external use

This allows workflows to use the Claude App token obtained by the action
for posting comments as claude[bot] instead of github-actions[bot].

Changes:
- Add github_token output to action.yml
- Export token from prepare.ts after authentication
- Allows workflows to use the same token Claude uses internally

* Debug: Add logging and always output github_token in prepare step

* Fix: Add git authentication to agent mode

Agent mode now fetches the authenticated user (claude[bot] when using Claude App token)
and configures git identity properly, matching the behavior of tag mode.

This fixes the issue where commits in agent mode were failing due to missing git identity.

* minor bun format

* remove unnecessary file

* fix: Add branch environment variable support to agent mode for signed commits

- Read CLAUDE_BRANCH and BASE_BRANCH env vars in agent mode
- Pass correct branch info to MCP file ops server
- Enables signed auto-fix workflows to create branches via API

* feat: Add auto-fix CI workflow examples

- Add auto-fix-ci example with inline git commits
- Add auto-fix-ci-signed example with signed commits via MCP
- Include corresponding slash commands for both workflows
- Examples demonstrate automated CI failure detection and fixing

* fix: Fix TypeScript error in agent mode git config

- Remove dependency on configureGitAuth which expects ParsedGitHubContext
- Implement git configuration directly for automation contexts
- Properly handle git authentication for agent mode

* fix: Align agent mode git config with existing patterns

- Use GITHUB_SERVER_URL from config module consistently
- Remove existing headers before setting new ones
- Use remote URL with embedded token like git-config.ts does
- Match the existing git authentication pattern in the codebase

* refactor: Use shared configureGitAuth function in agent mode

- Update configureGitAuth to accept GitHubContext instead of ParsedGitHubContext
- This allows both tag mode and agent mode to use the same function
- Removes code duplication and ensures consistent git configuration

* feat: Improve error message for 403 permission errors when committing

When the github_file_ops MCP server gets a 403 error, it now shows a cleaner
message suggesting to rebase from main/master branch to fix the issue.

* docs: Update documentation for v1.0 release (#476)

* docs: Update documentation for v1.0 release

- Integrate breaking changes naturally without alarming users
- Replace deprecated inputs (direct_prompt, custom_instructions, mode) with new unified approach
- Update all examples to use prompt and claude_args instead of deprecated inputs
- Add migration guides to help users transition from v0.x to v1.0
- Emphasize automatic mode detection as a key feature
- Update all workflow examples to @v1 from @beta
- Document how claude_args provides direct CLI control
- Update FAQ with automatic mode detection explanation
- Convert all tool configuration to use claude_args format

* fix: Apply prettier formatting to documentation files

* fix: Update all Claude model versions to latest and improve documentation accuracy

- Update all model references to claude-4-0-sonnet-20250805 (latest Sonnet 4)
- Update Bedrock models to anthropic.claude-4-0-sonnet-20250805-v1:0
- Update Vertex models to claude-4-0-sonnet@20250805
- Fix cloud-providers.md to use claude_args instead of deprecated model input
- Ensure all examples use @v1 instead of @beta
- Keep claude-opus-4-1-20250805 in examples where Opus is demonstrated
- Align all documentation with v1.0 patterns consistently

* feat: Add dedicated migration guide as requested in PR feedback

- Create comprehensive migration-guide.md with step-by-step instructions
- Add prominent links to migration guide in README.md
- Update usage.md to reference the separate migration guide
- Include before/after examples for all common scenarios
- Add checklist for systematic migration
- Address Ashwin's feedback about having a separate, clearly linked migration guide

* feat: Add comprehensive examples for hero use cases

- Add dedicated issue deduplication workflow example
- Add issue triage example (moved from .github/workflows)
- Update all examples to use v1-dev branch consistently
- Enable MCP tools in claude-auto-review.yml
- Consolidate PR review examples into single comprehensive example

Hero use cases now covered:
1. Code reviews (claude-auto-review.yml)
2. Issue triaging (issue-triage.yml)
3. Issue deduplication (issue-deduplication.yml)
4. Auto-fix CI failures (auto-fix-ci/auto-fix-ci.yml)

All examples updated to follow v1-dev paradigm with proper prompt and claude_args configuration.

* refactor: Remove timeout_minutes parameter from action (#482)

This change removes the custom timeout_minutes parameter from the action in favor of using GitHub Actions' native timeout-minutes feature.

Changes:
- Removed timeout_minutes input from action.yml and base-action/action.yml
- Removed all timeout handling logic from base-action/src/run-claude.ts
- Updated base-action/src/index.ts to remove timeoutMinutes parameter
- Removed timeout-related tests from base-action/test/run-claude.test.ts
- Removed timeout_minutes from all example workflow files (19 files)

Rationale:
- Simplifies the codebase by removing custom timeout logic
- Users can use GitHub Actions' native timeout-minutes at the job/step level
- Reduces complexity and maintenance burden
- Follows GitHub Actions best practices

BREAKING CHANGE: The timeout_minutes parameter is no longer supported. Users should use GitHub Actions' native timeout-minutes instead.

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

Co-authored-by: Claude <noreply@anthropic.com>

* refactor: Remove unused slash commands and agents copying logic

Removes experimental file copying features that had no default content:
- Removed experimental_slash_commands_dir parameter and related logic
- Removed automatic project agents copying from .claude/agents/
- Eliminated flaky error-prone cp operations with stderr suppression
- Removed 175 lines of unused code and associated tests

These features were infrastructure without default content that used
problematic error handling patterns (2>/dev/null || true) which could
hide real filesystem errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: Remove references to timeout_minutes parameter

The timeout_minutes parameter was removed in commit 986e40a but
documentation still referenced it. This updates:
- docs/usage.md: Removed timeout_minutes from inputs table
- base-action/README.md: Removed from inputs table and example

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Kashyap Murali <13315300+katchu11@users.noreply.github.com>
This commit is contained in:
km-anthropic
2025-08-25 12:51:37 -07:00
committed by GitHub
parent 9c7e1bac94
commit 0630ef383a
68 changed files with 2315 additions and 2387 deletions

View File

@@ -141,7 +141,7 @@ describe("generatePrompt", () => {
imageUrlMap: new Map<string, string>(),
};
test("should generate prompt for issue_comment event", () => {
test("should generate prompt for issue_comment event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -157,7 +157,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>");
@@ -172,7 +177,7 @@ describe("generatePrompt", () => {
expect(prompt).not.toContain("filename\tstatus\tadditions\tdeletions\tsha"); // since it's not a PR
});
test("should generate prompt for pull_request_review event", () => {
test("should generate prompt for pull_request_review event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -185,7 +190,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("<event_type>PR_REVIEW</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -196,7 +206,7 @@ describe("generatePrompt", () => {
); // from review comments
});
test("should generate prompt for issue opened event", () => {
test("should generate prompt for issue opened event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -211,7 +221,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
expect(prompt).toContain(
@@ -223,7 +238,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("The target-branch should be 'main'");
});
test("should generate prompt for issue assigned event", () => {
test("should generate prompt for issue assigned event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -239,7 +254,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("<event_type>ISSUE_ASSIGNED</event_type>");
expect(prompt).toContain(
@@ -250,7 +270,7 @@ describe("generatePrompt", () => {
);
});
test("should generate prompt for issue labeled event", () => {
test("should generate prompt for issue labeled event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -266,7 +286,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("<event_type>ISSUE_LABELED</event_type>");
expect(prompt).toContain(
@@ -277,33 +302,9 @@ describe("generatePrompt", () => {
);
});
test("should include direct prompt when provided", () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
directPrompt: "Fix the bug in the login form",
eventData: {
eventName: "issues",
eventAction: "opened",
isPR: false,
issueNumber: "789",
baseBranch: "main",
claudeBranch: "claude/issue-789-20240101-1200",
},
};
// Removed test - direct_prompt field no longer supported in v1.0
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
expect(prompt).toContain("<direct_prompt>");
expect(prompt).toContain("Fix the bug in the login form");
expect(prompt).toContain("</direct_prompt>");
expect(prompt).toContain(
"CRITICAL: Direct user instructions were provided in the <direct_prompt> tag above. These are HIGH PRIORITY instructions that OVERRIDE all other context and MUST be followed exactly as written.",
);
});
test("should generate prompt for pull_request event", () => {
test("should generate prompt for pull_request event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -316,7 +317,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -324,12 +330,11 @@ describe("generatePrompt", () => {
expect(prompt).toContain("pull request opened");
});
test("should include custom instructions when provided", () => {
test("should generate prompt for issue comment without custom fields", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
customInstructions: "Always use TypeScript",
eventData: {
eventName: "issue_comment",
commentId: "67890",
@@ -341,17 +346,24 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("CUSTOM INSTRUCTIONS:\nAlways use TypeScript");
// Verify prompt generates successfully without custom instructions
expect(prompt).toContain("@claude please fix this");
expect(prompt).not.toContain("CUSTOM INSTRUCTIONS");
});
test("should use override_prompt when provided", () => {
test("should use override_prompt when provided", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
overridePrompt: "Simple prompt for $REPOSITORY PR #$PR_NUMBER",
prompt: "Simple prompt for reviewing PR",
eventData: {
eventName: "pull_request",
eventAction: "opened",
@@ -360,19 +372,25 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toBe("Simple prompt for owner/repo PR #123");
// v1.0: Prompt is passed through as-is
expect(prompt).toBe("Simple prompt for reviewing PR");
expect(prompt).not.toContain("You are Claude, an AI assistant");
});
test("should substitute all variables in override_prompt", () => {
test("should pass through prompt without variable substitution", async () => {
const envVars: PreparedContext = {
repository: "test/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
triggerUsername: "john-doe",
overridePrompt: `Repository: $REPOSITORY
prompt: `Repository: $REPOSITORY
PR: $PR_NUMBER
Title: $PR_TITLE
Body: $PR_BODY
@@ -395,29 +413,30 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("Repository: test/repo");
expect(prompt).toContain("PR: 456");
expect(prompt).toContain("Title: Test PR");
expect(prompt).toContain("Body: This is a test PR");
expect(prompt).toContain("Comments: ");
expect(prompt).toContain("Review Comments: ");
expect(prompt).toContain("Changed Files: ");
expect(prompt).toContain("Trigger Comment: Please review this code");
expect(prompt).toContain("Username: john-doe");
expect(prompt).toContain("Branch: feature-branch");
expect(prompt).toContain("Base: main");
expect(prompt).toContain("Event: pull_request_review_comment");
expect(prompt).toContain("Is PR: true");
// v1.0: Variables are NOT substituted - prompt is passed as-is to Claude Code
expect(prompt).toContain("Repository: $REPOSITORY");
expect(prompt).toContain("PR: $PR_NUMBER");
expect(prompt).toContain("Title: $PR_TITLE");
expect(prompt).toContain("Body: $PR_BODY");
expect(prompt).toContain("Branch: $BRANCH_NAME");
expect(prompt).toContain("Base: $BASE_BRANCH");
expect(prompt).toContain("Username: $TRIGGER_USERNAME");
expect(prompt).toContain("Comment: $TRIGGER_COMMENT");
});
test("should handle override_prompt for issues", () => {
test("should handle override_prompt for issues", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
overridePrompt: "Issue #$ISSUE_NUMBER: $ISSUE_TITLE in $REPOSITORY",
prompt: "Review issue and provide feedback",
eventData: {
eventName: "issues",
eventAction: "opened",
@@ -442,18 +461,23 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, issueGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
issueGitHubData,
false,
mockTagMode,
);
expect(prompt).toBe("Issue #789: Bug: Login form broken in owner/repo");
// v1.0: Prompt is passed through as-is
expect(prompt).toBe("Review issue and provide feedback");
});
test("should handle empty values in override_prompt substitution", () => {
test("should handle prompt without substitution", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
overridePrompt:
"PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
prompt: "PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
eventData: {
eventName: "pull_request",
eventAction: "opened",
@@ -462,12 +486,20 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toBe("PR: 123, Issue: , Comment: ");
// v1.0: No substitution - passed as-is
expect(prompt).toBe(
"PR: $PR_NUMBER, Issue: $ISSUE_NUMBER, Comment: $TRIGGER_COMMENT",
);
});
test("should not substitute variables when override_prompt is not provided", () => {
test("should not substitute variables when override_prompt is not provided", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -482,13 +514,18 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>");
});
test("should include trigger username when provided", () => {
test("should include trigger username when provided", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -505,7 +542,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
// With commit signing disabled, co-author info appears in git commit instructions
@@ -514,7 +556,7 @@ describe("generatePrompt", () => {
);
});
test("should include PR-specific instructions only for PR events", () => {
test("should include PR-specific instructions only for PR events", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -527,7 +569,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain PR-specific instructions (git commands when not using signing)
expect(prompt).toContain("git push");
@@ -543,7 +590,7 @@ describe("generatePrompt", () => {
expect(prompt).not.toContain("Create a PR](https://github.com/");
});
test("should include Issue-specific instructions only for Issue events", () => {
test("should include Issue-specific instructions only for Issue events", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -558,7 +605,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain Issue-specific instructions
expect(prompt).toContain(
@@ -581,7 +633,7 @@ describe("generatePrompt", () => {
);
});
test("should use actual branch name for issue comments", () => {
test("should use actual branch name for issue comments", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -597,7 +649,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain the actual branch name with timestamp
expect(prompt).toContain(
@@ -611,7 +668,7 @@ describe("generatePrompt", () => {
);
});
test("should handle closed PR with new branch", () => {
test("should handle closed PR with new branch", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -627,7 +684,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain branch-specific instructions like issues
expect(prompt).toContain(
@@ -650,7 +712,7 @@ describe("generatePrompt", () => {
);
});
test("should handle open PR without new branch", () => {
test("should handle open PR without new branch", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -665,7 +727,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain open PR instructions (git commands when not using signing)
expect(prompt).toContain("git push");
@@ -681,7 +748,7 @@ describe("generatePrompt", () => {
);
});
test("should handle PR review on closed PR with new branch", () => {
test("should handle PR review on closed PR with new branch", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -696,7 +763,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain new branch instructions
expect(prompt).toContain(
@@ -708,7 +780,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("Reference to the original PR");
});
test("should handle PR review comment on closed PR with new branch", () => {
test("should handle PR review comment on closed PR with new branch", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -724,7 +796,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain new branch instructions
expect(prompt).toContain(
@@ -737,7 +814,7 @@ describe("generatePrompt", () => {
);
});
test("should handle pull_request event on closed PR with new branch", () => {
test("should handle pull_request event on closed PR with new branch", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -752,7 +829,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should contain new branch instructions
expect(prompt).toContain(
@@ -762,7 +844,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("Reference to the original PR");
});
test("should include git commands when useCommitSigning is false", () => {
test("should include git commands when useCommitSigning is false", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -776,7 +858,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
false,
mockTagMode,
);
// Should have git command instructions
expect(prompt).toContain("Use git commands via the Bash tool");
@@ -791,7 +878,7 @@ describe("generatePrompt", () => {
expect(prompt).not.toContain("mcp__github_file_ops__commit_files");
});
test("should include commit signing tools when useCommitSigning is true", () => {
test("should include commit signing tools when useCommitSigning is true", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -805,7 +892,12 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, true, mockTagMode);
const prompt = await generatePrompt(
envVars,
mockGitHubData,
true,
mockTagMode,
);
// Should have commit signing tool instructions
expect(prompt).toContain("mcp__github_file_ops__commit_files");
@@ -819,7 +911,7 @@ describe("generatePrompt", () => {
});
describe("getEventTypeAndContext", () => {
test("should return correct type and context for pull_request_review_comment", () => {
test("should return correct type and context for pull_request_review_comment", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -838,7 +930,7 @@ describe("getEventTypeAndContext", () => {
expect(result.triggerContext).toBe("PR review comment with '@claude'");
});
test("should return correct type and context for issue assigned", () => {
test("should return correct type and context for issue assigned", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -860,7 +952,7 @@ describe("getEventTypeAndContext", () => {
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'");
});
test("should return correct type and context for issue labeled", () => {
test("should return correct type and context for issue labeled", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -882,12 +974,12 @@ describe("getEventTypeAndContext", () => {
expect(result.triggerContext).toBe("issue labeled with 'claude-task'");
});
test("should return correct type and context for issue assigned without assigneeTrigger", () => {
test("should return correct type and context for issue assigned without assigneeTrigger", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
triggerPhrase: "@claude",
directPrompt: "Please assess this issue",
prompt: "Please assess this issue",
eventData: {
eventName: "issues",
eventAction: "assigned",
@@ -895,7 +987,7 @@ describe("getEventTypeAndContext", () => {
issueNumber: "999",
baseBranch: "main",
claudeBranch: "claude/issue-999-20240101-1200",
// No assigneeTrigger when using directPrompt
// No assigneeTrigger when using prompt
},
};
@@ -907,7 +999,7 @@ describe("getEventTypeAndContext", () => {
});
describe("buildAllowedToolsString", () => {
test("should return correct tools for regular events (default no signing)", () => {
test("should return correct tools for regular events (default no signing)", async () => {
const result = buildAllowedToolsString();
// The base tools should be in the result
@@ -929,7 +1021,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("mcp__github_file_ops__delete_files");
});
test("should return correct tools with default parameters", () => {
test("should return correct tools with default parameters", async () => {
const result = buildAllowedToolsString([], false, false);
// The base tools should be in the result
@@ -950,7 +1042,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("mcp__github_file_ops__delete_files");
});
test("should append custom tools when provided", () => {
test("should append custom tools when provided", async () => {
const customTools = ["Tool1", "Tool2", "Tool3"];
const result = buildAllowedToolsString(customTools);
@@ -971,7 +1063,7 @@ describe("buildAllowedToolsString", () => {
expect(basePlusCustom).toContain("Tool3");
});
test("should include GitHub Actions tools when includeActionsTools is true", () => {
test("should include GitHub Actions tools when includeActionsTools is true", async () => {
const result = buildAllowedToolsString([], true);
// Base tools should be present
@@ -984,7 +1076,7 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("mcp__github_ci__download_job_log");
});
test("should include both custom and Actions tools when both provided", () => {
test("should include both custom and Actions tools when both provided", async () => {
const customTools = ["Tool1", "Tool2"];
const result = buildAllowedToolsString(customTools, true);
@@ -1001,7 +1093,7 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("mcp__github_ci__download_job_log");
});
test("should include commit signing tools when useCommitSigning is true", () => {
test("should include commit signing tools when useCommitSigning is true", async () => {
const result = buildAllowedToolsString([], false, true);
// Base tools should be present
@@ -1022,7 +1114,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("Bash(");
});
test("should include specific Bash git commands when useCommitSigning is false", () => {
test("should include specific Bash git commands when useCommitSigning is false", async () => {
const result = buildAllowedToolsString([], false, false);
// Base tools should be present
@@ -1050,7 +1142,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("mcp__github_file_ops__delete_files");
});
test("should handle all combinations of options", () => {
test("should handle all combinations of options", async () => {
const customTools = ["CustomTool1", "CustomTool2"];
const result = buildAllowedToolsString(customTools, true, false);
@@ -1074,7 +1166,7 @@ describe("buildAllowedToolsString", () => {
});
describe("buildDisallowedToolsString", () => {
test("should return base disallowed tools when no custom tools provided", () => {
test("should return base disallowed tools when no custom tools provided", async () => {
const result = buildDisallowedToolsString();
// The base disallowed tools should be in the result
@@ -1082,7 +1174,7 @@ describe("buildDisallowedToolsString", () => {
expect(result).toContain("WebFetch");
});
test("should append custom disallowed tools when provided", () => {
test("should append custom disallowed tools when provided", async () => {
const customDisallowedTools = ["BadTool1", "BadTool2"];
const result = buildDisallowedToolsString(customDisallowedTools);
@@ -1100,7 +1192,7 @@ describe("buildDisallowedToolsString", () => {
expect(parts).toContain("BadTool2");
});
test("should remove hardcoded disallowed tools if they are in allowed tools", () => {
test("should remove hardcoded disallowed tools if they are in allowed tools", async () => {
const customDisallowedTools = ["BadTool1", "BadTool2"];
const allowedTools = ["WebSearch", "SomeOtherTool"];
const result = buildDisallowedToolsString(
@@ -1119,7 +1211,7 @@ describe("buildDisallowedToolsString", () => {
expect(result).toContain("BadTool2");
});
test("should remove all hardcoded disallowed tools if they are all in allowed tools", () => {
test("should remove all hardcoded disallowed tools if they are all in allowed tools", async () => {
const allowedTools = ["WebSearch", "WebFetch", "SomeOtherTool"];
const result = buildDisallowedToolsString(undefined, allowedTools);
@@ -1131,7 +1223,7 @@ describe("buildDisallowedToolsString", () => {
expect(result).toBe("");
});
test("should handle custom disallowed tools when all hardcoded tools are overridden", () => {
test("should handle custom disallowed tools when all hardcoded tools are overridden", async () => {
const customDisallowedTools = ["BadTool1", "BadTool2"];
const allowedTools = ["WebSearch", "WebFetch"];
const result = buildDisallowedToolsString(

View File

@@ -1,115 +0,0 @@
import { describe, it, expect } from "bun:test";
import {
parseMultilineInput,
parseAdditionalPermissions,
} 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([]);
});
});
describe("parseAdditionalPermissions", () => {
it("should parse single permission", () => {
const input = "actions: read";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
it("should parse multiple permissions", () => {
const input = `actions: read
packages: write
contents: read`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.get("contents")).toBe("read");
expect(result.size).toBe(3);
});
it("should handle empty string", () => {
const input = "";
const result = parseAdditionalPermissions(input);
expect(result.size).toBe(0);
});
it("should handle whitespace and empty lines", () => {
const input = `
actions: read
packages: write
`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should ignore lines without colon separator", () => {
const input = `actions: read
invalid line
packages: write`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should trim whitespace around keys and values", () => {
const input = " actions : read ";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
});

View File

@@ -24,18 +24,12 @@ describe("prepareMcpConfig", () => {
entityNumber: 123,
isPR: false,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
@@ -56,14 +50,6 @@ describe("prepareMcpConfig", () => {
},
};
const mockPRContextWithSigning: ParsedGitHubContext = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
useCommitSigning: true,
},
};
beforeEach(() => {
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
@@ -104,19 +90,9 @@ describe("prepareMcpConfig", () => {
expect(parsed.mcpServers.github_comment.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_comment.env.REPO_OWNER).toBe("test-owner");
expect(parsed.mcpServers.github_comment.env.REPO_NAME).toBe("test-repo");
});
test("should return file ops server when commit signing is enabled", async () => {
const contextWithSigning = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
test("should include file ops server when commit signing is enabled", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
@@ -124,19 +100,16 @@ describe("prepareMcpConfig", () => {
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithSigning,
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.github_file_ops.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_file_ops.env.REPO_OWNER).toBe("test-owner");
expect(parsed.mcpServers.github_file_ops.env.REPO_NAME).toBe("test-repo");
expect(parsed.mcpServers.github_file_ops.env.BRANCH_NAME).toBe(
"test-branch",
);
@@ -149,49 +122,37 @@ describe("prepareMcpConfig", () => {
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
allowedTools: ["mcp__github__create_issue", "mcp__github__create_pr"],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(parsed.mcpServers.github_file_ops).not.toBeDefined();
expect(parsed.mcpServers.github.command).toBe("docker");
expect(parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe(
"test-token",
);
});
test("should not include github MCP server when only file_ops tools are allowed", async () => {
const contextWithSigning = {
...mockContext,
inputs: {
...mockContext.inputs,
useCommitSigning: true,
},
};
test("should include inline comment server for PRs when tools are allowed", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [
"mcp__github_file_ops__commit_files",
"mcp__github_file_ops__update_claude_comment",
],
context: contextWithSigning,
allowedTools: ["mcp__github_inline_comment__create_inline_comment"],
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.github_inline_comment).toBeDefined();
expect(parsed.mcpServers.github_inline_comment.env.GITHUB_TOKEN).toBe(
"test-token",
);
expect(parsed.mcpServers.github_inline_comment.env.PR_NUMBER).toBe("456");
});
test("should include comment server when no GitHub tools are allowed and signing disabled", async () => {
@@ -201,7 +162,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: ["Edit", "Read", "Write"],
allowedTools: [],
context: mockContext,
});
@@ -212,301 +173,7 @@ describe("prepareMcpConfig", () => {
expect(parsed.mcpServers.github_comment).toBeDefined();
});
test("should return base config when additional config is empty string", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: "",
allowedTools: [],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled();
});
test("should return base config when additional config is whitespace only", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: " \n\t ",
allowedTools: [],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_comment).toBeDefined();
expect(consoleWarningSpy).not.toHaveBeenCalled();
});
test("should merge valid additional config with base config", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
custom_server: {
command: "custom-command",
args: ["arg1", "arg2"],
env: {
CUSTOM_ENV: "custom-value",
},
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github).toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
expect(parsed.mcpServers.custom_server).toBeDefined();
expect(parsed.mcpServers.custom_server.command).toBe("custom-command");
expect(parsed.mcpServers.custom_server.args).toEqual(["arg1", "arg2"]);
expect(parsed.mcpServers.custom_server.env.CUSTOM_ENV).toBe("custom-value");
});
test("should override built-in servers when additional config has same server names", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
github: {
command: "overridden-command",
args: ["overridden-arg"],
env: {
OVERRIDDEN_ENV: "overridden-value",
},
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [
"mcp__github__create_issue",
"mcp__github_file_ops__commit_files",
],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github.command).toBe("overridden-command");
expect(parsed.mcpServers.github.args).toEqual(["overridden-arg"]);
expect(parsed.mcpServers.github.env.OVERRIDDEN_ENV).toBe(
"overridden-value",
);
expect(
parsed.mcpServers.github.env.GITHUB_PERSONAL_ACCESS_TOKEN,
).toBeUndefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should merge additional root-level properties", async () => {
const additionalConfig = JSON.stringify({
customProperty: "custom-value",
anotherProperty: {
nested: "value",
},
mcpServers: {
custom_server: {
command: "custom",
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.customProperty).toBe("custom-value");
expect(parsed.anotherProperty).toEqual({ nested: "value" });
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.custom_server).toBeDefined();
});
test("should handle invalid JSON gracefully", async () => {
const invalidJson = "{ invalid json }";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: invalidJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle non-object JSON values", async () => {
const nonObjectJson = JSON.stringify("string value");
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: nonObjectJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP config must be a valid JSON object"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle null JSON value", async () => {
const nullJson = JSON.stringify(null);
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: nullJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("Failed to parse additional MCP config:"),
);
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining("MCP config must be a valid JSON object"),
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should handle array JSON value", async () => {
const arrayJson = JSON.stringify([1, 2, 3]);
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: arrayJson,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
// Arrays are objects in JavaScript, so they pass the object check
// But they'll fail when trying to spread or access mcpServers property
expect(consoleInfoSpy).toHaveBeenCalledWith(
"Merging additional MCP server configuration with built-in servers",
);
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
// The array will be spread into the config (0: 1, 1: 2, 2: 3)
expect(parsed[0]).toBe(1);
expect(parsed[1]).toBe(2);
expect(parsed[2]).toBe(3);
});
test("should merge complex nested configurations", async () => {
const additionalConfig = JSON.stringify({
mcpServers: {
server1: {
command: "cmd1",
env: { KEY1: "value1" },
},
server2: {
command: "cmd2",
env: { KEY2: "value2" },
},
github_file_ops: {
command: "overridden",
env: { CUSTOM: "value" },
},
},
otherConfig: {
nested: {
deeply: "value",
},
},
});
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
additionalMcpConfig: additionalConfig,
allowedTools: [],
context: mockContextWithSigning,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.server1).toBeDefined();
expect(parsed.mcpServers.server2).toBeDefined();
expect(parsed.mcpServers.github).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops.command).toBe("overridden");
expect(parsed.mcpServers.github_file_ops.env.CUSTOM).toBe("value");
expect(parsed.otherConfig.nested.deeply).toBe("value");
});
test("should preserve GITHUB_ACTION_PATH in file_ops server args", async () => {
const oldEnv = process.env.GITHUB_ACTION_PATH;
test("should set GITHUB_ACTION_PATH correctly", async () => {
process.env.GITHUB_ACTION_PATH = "/test/action/path";
const result = await prepareMcpConfig({
@@ -520,15 +187,12 @@ describe("prepareMcpConfig", () => {
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_file_ops.args[1]).toBe(
expect(parsed.mcpServers.github_file_ops.args).toContain(
"/test/action/path/src/mcp/github-file-ops-server.ts",
);
process.env.GITHUB_ACTION_PATH = oldEnv;
});
test("should use process.cwd() when GITHUB_WORKSPACE is not set", async () => {
const oldEnv = process.env.GITHUB_WORKSPACE;
test("should use current working directory when GITHUB_WORKSPACE is not set", async () => {
delete process.env.GITHUB_WORKSPACE;
const result = await prepareMcpConfig({
@@ -543,23 +207,11 @@ describe("prepareMcpConfig", () => {
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_file_ops.env.REPO_DIR).toBe(process.cwd());
process.env.GITHUB_WORKSPACE = oldEnv;
});
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
const oldEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
test("should include CI server when context.isPR is true and DEFAULT_WORKFLOW_TOKEN exists", async () => {
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
useCommitSigning: true,
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
@@ -567,16 +219,15 @@ describe("prepareMcpConfig", () => {
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithPermissions,
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.DEFAULT_WORKFLOW_TOKEN = oldEnv;
delete process.env.DEFAULT_WORKFLOW_TOKEN;
});
test("should not include github_ci server when context.isPR is false", async () => {
@@ -587,17 +238,15 @@ describe("prepareMcpConfig", () => {
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: mockContextWithSigning,
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should not include github_ci server when actions:read permission is not granted", async () => {
const oldTokenEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
test("should not include github_ci server when DEFAULT_WORKFLOW_TOKEN is missing", async () => {
delete process.env.DEFAULT_WORKFLOW_TOKEN;
const result = await prepareMcpConfig({
githubToken: "test-token",
@@ -606,78 +255,10 @@ describe("prepareMcpConfig", () => {
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: mockPRContextWithSigning,
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.DEFAULT_WORKFLOW_TOKEN = oldTokenEnv;
});
test("should parse additional_permissions with multiple lines correctly", async () => {
const oldTokenEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
process.env.DEFAULT_WORKFLOW_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([
["actions", "read"],
["future", "permission"],
]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
process.env.DEFAULT_WORKFLOW_TOKEN = oldTokenEnv;
});
test("should warn when actions:read is requested but token lacks permission", async () => {
const oldTokenEnv = process.env.DEFAULT_WORKFLOW_TOKEN;
process.env.DEFAULT_WORKFLOW_TOKEN = "invalid-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
baseBranch: "main",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining(
"The github_ci MCP server requires 'actions: read' permission",
),
);
process.env.DEFAULT_WORKFLOW_TOKEN = oldTokenEnv;
});
});

View File

@@ -11,22 +11,12 @@ import type {
} from "@octokit/webhooks-types";
const defaultInputs = {
mode: "tag" as const,
prompt: "",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
anthropicModel: "claude-3-7-sonnet-20250219",
allowedTools: [] as string[],
disallowedTools: [] as string[],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
useBedrock: false,
useVertex: false,
timeoutMinutes: 30,
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map<string, string>(),
useCommitSigning: false,
allowedBots: "",
};
@@ -37,8 +27,12 @@ const defaultRepository = {
full_name: "test-owner/test-repo",
};
type MockContextOverrides = Omit<Partial<ParsedGitHubContext>, "inputs"> & {
inputs?: Partial<ParsedGitHubContext["inputs"]>;
};
export const createMockContext = (
overrides: Partial<ParsedGitHubContext> = {},
overrides: MockContextOverrides = {},
): ParsedGitHubContext => {
const baseContext: ParsedGitHubContext = {
runId: "1234567890",
@@ -52,15 +46,19 @@ export const createMockContext = (
inputs: defaultInputs,
};
if (overrides.inputs) {
overrides.inputs = { ...defaultInputs, ...overrides.inputs };
}
const mergedInputs = overrides.inputs
? { ...defaultInputs, ...overrides.inputs }
: defaultInputs;
return { ...baseContext, ...overrides };
return { ...baseContext, ...overrides, inputs: mergedInputs };
};
type MockAutomationOverrides = Omit<Partial<AutomationContext>, "inputs"> & {
inputs?: Partial<AutomationContext["inputs"]>;
};
export const createMockAutomationContext = (
overrides: Partial<AutomationContext> = {},
overrides: MockAutomationOverrides = {},
): AutomationContext => {
const baseContext: AutomationContext = {
runId: "1234567890",
@@ -72,7 +70,11 @@ export const createMockAutomationContext = (
inputs: defaultInputs,
};
return { ...baseContext, ...overrides };
const mergedInputs = overrides.inputs
? { ...defaultInputs, ...overrides.inputs }
: defaultInputs;
return { ...baseContext, ...overrides, inputs: mergedInputs };
};
export const mockIssueOpenedContext: ParsedGitHubContext = {

View File

@@ -29,7 +29,7 @@ describe("Agent Mode", () => {
test("agent mode has correct properties", () => {
expect(agentMode.name).toBe("agent");
expect(agentMode.description).toBe(
"Automation mode for workflow_dispatch and schedule events",
"Direct automation mode for explicit prompts",
);
expect(agentMode.shouldCreateTrackingComment()).toBe(false);
expect(agentMode.getAllowedTools()).toEqual([]);
@@ -45,19 +45,19 @@ describe("Agent Mode", () => {
expect(Object.keys(context)).toEqual(["mode", "githubContext"]);
});
test("agent mode only triggers for workflow_dispatch and schedule events", () => {
// Should trigger for automation events
test("agent mode only triggers when prompt is provided", () => {
// Should NOT trigger for automation events without prompt
const workflowDispatchContext = createMockAutomationContext({
eventName: "workflow_dispatch",
});
expect(agentMode.shouldTrigger(workflowDispatchContext)).toBe(true);
expect(agentMode.shouldTrigger(workflowDispatchContext)).toBe(false);
const scheduleContext = createMockAutomationContext({
eventName: "schedule",
});
expect(agentMode.shouldTrigger(scheduleContext)).toBe(true);
expect(agentMode.shouldTrigger(scheduleContext)).toBe(false);
// Should NOT trigger for entity events
// Should NOT trigger for entity events without prompt
const entityEvents = [
"issue_comment",
"pull_request",
@@ -66,61 +66,91 @@ describe("Agent Mode", () => {
] as const;
entityEvents.forEach((eventName) => {
const context = createMockContext({ eventName });
expect(agentMode.shouldTrigger(context)).toBe(false);
const contextNoPrompt = createMockContext({ eventName });
expect(agentMode.shouldTrigger(contextNoPrompt)).toBe(false);
});
// Should trigger for ANY event when prompt is provided
const allEvents = [
"workflow_dispatch",
"schedule",
"issue_comment",
"pull_request",
"pull_request_review",
"issues",
] as const;
allEvents.forEach((eventName) => {
const contextWithPrompt =
eventName === "workflow_dispatch" || eventName === "schedule"
? createMockAutomationContext({
eventName,
inputs: { prompt: "Do something" },
})
: createMockContext({
eventName,
inputs: { prompt: "Do something" },
});
expect(agentMode.shouldTrigger(contextWithPrompt)).toBe(true);
});
});
test("prepare method sets up tools environment variables correctly", async () => {
test("prepare method passes through claude_args", async () => {
// Clear any previous calls before this test
exportVariableSpy.mockClear();
setOutputSpy.mockClear();
const contextWithCustomTools = createMockAutomationContext({
const contextWithCustomArgs = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithCustomTools.inputs.allowedTools = ["CustomTool1", "CustomTool2"];
contextWithCustomTools.inputs.disallowedTools = ["BadTool"];
// Save original env vars and set test values
const originalHeadRef = process.env.GITHUB_HEAD_REF;
const originalRefName = process.env.GITHUB_REF_NAME;
delete process.env.GITHUB_HEAD_REF;
delete process.env.GITHUB_REF_NAME;
// Set CLAUDE_ARGS environment variable
process.env.CLAUDE_ARGS = "--model claude-sonnet-4 --max-turns 10";
const mockOctokit = {} as any;
const result = await agentMode.prepare({
context: contextWithCustomTools,
context: contextWithCustomArgs,
octokit: mockOctokit,
githubToken: "test-token",
});
// Verify that both ALLOWED_TOOLS and DISALLOWED_TOOLS are set
expect(exportVariableSpy).toHaveBeenCalledWith(
"ALLOWED_TOOLS",
"Edit,MultiEdit,Glob,Grep,LS,Read,Write,CustomTool1,CustomTool2",
);
expect(exportVariableSpy).toHaveBeenCalledWith(
"DISALLOWED_TOOLS",
"WebSearch,WebFetch,BadTool",
);
// Verify claude_args includes MCP config and user args
const callArgs = setOutputSpy.mock.calls[0];
expect(callArgs[0]).toBe("claude_args");
expect(callArgs[1]).toContain("--mcp-config");
expect(callArgs[1]).toContain("--model claude-sonnet-4 --max-turns 10");
// Verify MCP config is set
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
// Verify return structure
// Verify return structure - should use "main" as fallback when no env vars set
expect(result).toEqual({
commentId: undefined,
branchInfo: {
baseBranch: "",
currentBranch: "",
baseBranch: "main",
currentBranch: "main",
claudeBranch: undefined,
},
mcpConfig: expect.any(String),
});
// Clean up
delete process.env.CLAUDE_ARGS;
if (originalHeadRef !== undefined)
process.env.GITHUB_HEAD_REF = originalHeadRef;
if (originalRefName !== undefined)
process.env.GITHUB_REF_NAME = originalRefName;
});
test("prepare method creates prompt file with correct content", async () => {
const contextWithPrompts = createMockAutomationContext({
eventName: "workflow_dispatch",
});
contextWithPrompts.inputs.overridePrompt = "Custom override prompt";
contextWithPrompts.inputs.directPrompt =
"Direct prompt (should be ignored)";
// In v1-dev, we only have the unified prompt field
contextWithPrompts.inputs.prompt = "Custom prompt content";
const mockOctokit = {} as any;
await agentMode.prepare({
@@ -131,6 +161,9 @@ describe("Agent Mode", () => {
// Note: We can't easily test file creation in this unit test,
// but we can verify the method completes without errors
expect(setOutputSpy).toHaveBeenCalledWith("mcp_config", expect.any(String));
// Agent mode now includes MCP config even with empty user args
const callArgs = setOutputSpy.mock.calls[0];
expect(callArgs[0]).toBe("claude_args");
expect(callArgs[1]).toContain("--mcp-config");
});
});

View File

@@ -0,0 +1,71 @@
import { describe, test, expect } from "bun:test";
import { parseAllowedTools } from "../../src/modes/agent/parse-tools";
describe("parseAllowedTools", () => {
test("parses unquoted tools", () => {
const args = "--allowedTools mcp__github__*,mcp__github_comment__*";
expect(parseAllowedTools(args)).toEqual([
"mcp__github__*",
"mcp__github_comment__*",
]);
});
test("parses double-quoted tools", () => {
const args = '--allowedTools "mcp__github__*,mcp__github_comment__*"';
expect(parseAllowedTools(args)).toEqual([
"mcp__github__*",
"mcp__github_comment__*",
]);
});
test("parses single-quoted tools", () => {
const args = "--allowedTools 'mcp__github__*,mcp__github_comment__*'";
expect(parseAllowedTools(args)).toEqual([
"mcp__github__*",
"mcp__github_comment__*",
]);
});
test("returns empty array when no allowedTools", () => {
const args = "--someOtherFlag value";
expect(parseAllowedTools(args)).toEqual([]);
});
test("handles empty string", () => {
expect(parseAllowedTools("")).toEqual([]);
});
test("handles duplicate --allowedTools flags", () => {
const args = "--allowedTools --allowedTools mcp__github__*";
// Should not match the first one since the value is another flag
expect(parseAllowedTools(args)).toEqual([]);
});
test("handles typo --alloedTools", () => {
const args = "--alloedTools mcp__github__*";
expect(parseAllowedTools(args)).toEqual([]);
});
test("handles multiple flags with allowedTools in middle", () => {
const args =
'--flag1 value1 --allowedTools "mcp__github__*" --flag2 value2';
expect(parseAllowedTools(args)).toEqual(["mcp__github__*"]);
});
test("trims whitespace from tool names", () => {
const args = "--allowedTools 'mcp__github__* , mcp__github_comment__* '";
expect(parseAllowedTools(args)).toEqual([
"mcp__github__*",
"mcp__github_comment__*",
]);
});
test("handles tools with special characters", () => {
const args =
'--allowedTools "mcp__github__create_issue,mcp__github_comment__update"';
expect(parseAllowedTools(args)).toEqual([
"mcp__github__create_issue",
"mcp__github_comment__update",
]);
});
});

View File

@@ -1,14 +1,18 @@
import { describe, test, expect } from "bun:test";
import { getMode, isValidMode } from "../../src/modes/registry";
import type { ModeName } from "../../src/modes/types";
import { tagMode } from "../../src/modes/tag";
import { agentMode } from "../../src/modes/agent";
import { reviewMode } from "../../src/modes/review";
import { tagMode } from "../../src/modes/tag";
import { createMockContext, createMockAutomationContext } from "../mockContext";
describe("Mode Registry", () => {
const mockContext = createMockContext({
eventName: "issue_comment",
payload: {
action: "created",
comment: {
body: "Test comment without trigger",
},
} as any,
});
const mockWorkflowDispatchContext = createMockAutomationContext({
@@ -19,62 +23,101 @@ describe("Mode Registry", () => {
eventName: "schedule",
});
test("getMode returns tag mode for standard events", () => {
const mode = getMode("tag", mockContext);
test("getMode auto-detects agent mode for issue_comment without trigger", () => {
const mode = getMode(mockContext);
// Agent mode is the default when no trigger is found
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode auto-detects agent mode for workflow_dispatch", () => {
const mode = getMode(mockWorkflowDispatchContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
// Removed test - explicit mode override no longer supported in v1.0
test("getMode auto-detects agent for workflow_dispatch", () => {
const mode = getMode(mockWorkflowDispatchContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode auto-detects agent for schedule event", () => {
const mode = getMode(mockScheduleContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
// Removed test - legacy mode names no longer supported in v1.0
test("getMode auto-detects agent mode for PR opened", () => {
const prContext = createMockContext({
eventName: "pull_request",
payload: { action: "opened" } as any,
isPR: true,
});
const mode = getMode(prContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode uses agent mode when prompt is provided, even with @claude mention", () => {
const contextWithPrompt = createMockContext({
eventName: "issue_comment",
payload: {
action: "created",
comment: {
body: "@claude please help",
},
} as any,
inputs: {
prompt: "/review",
} as any,
});
const mode = getMode(contextWithPrompt);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode uses tag mode for @claude mention without prompt", () => {
// Ensure PROMPT env var is not set (clean up from previous tests)
const originalPrompt = process.env.PROMPT;
delete process.env.PROMPT;
const contextWithMention = createMockContext({
eventName: "issue_comment",
payload: {
action: "created",
comment: {
body: "@claude please help",
},
} as any,
inputs: {
triggerPhrase: "@claude",
prompt: "",
} as any,
});
const mode = getMode(contextWithMention);
expect(mode).toBe(tagMode);
expect(mode.name).toBe("tag");
// Restore original value if it existed
if (originalPrompt !== undefined) {
process.env.PROMPT = originalPrompt;
}
});
test("getMode returns agent mode", () => {
const mode = getMode("agent", mockContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode returns experimental-review mode", () => {
const mode = getMode("experimental-review", mockContext);
expect(mode).toBe(reviewMode);
expect(mode.name).toBe("experimental-review");
});
test("getMode throws error for tag mode with workflow_dispatch event", () => {
expect(() => getMode("tag", mockWorkflowDispatchContext)).toThrow(
"Tag mode cannot handle workflow_dispatch events. Use 'agent' mode for automation events.",
);
});
test("getMode throws error for tag mode with schedule event", () => {
expect(() => getMode("tag", mockScheduleContext)).toThrow(
"Tag mode cannot handle schedule events. Use 'agent' mode for automation events.",
);
});
test("getMode allows agent mode for workflow_dispatch event", () => {
const mode = getMode("agent", mockWorkflowDispatchContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode allows agent mode for schedule event", () => {
const mode = getMode("agent", mockScheduleContext);
expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent");
});
test("getMode throws error for invalid mode", () => {
const invalidMode = "invalid" as unknown as ModeName;
expect(() => getMode(invalidMode, mockContext)).toThrow(
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent', 'experimental-review'. Please check your workflow configuration.",
);
});
// Removed test - explicit mode override no longer supported in v1.0
test("isValidMode returns true for all valid modes", () => {
expect(isValidMode("tag")).toBe(true);
expect(isValidMode("agent")).toBe(true);
expect(isValidMode("experimental-review")).toBe(true);
});
test("isValidMode returns false for invalid mode", () => {
expect(isValidMode("invalid")).toBe(false);
expect(isValidMode("review")).toBe(false);
});
});

View File

@@ -60,18 +60,12 @@ describe("checkWritePermissions", () => {
entityNumber: 1,
isPR: false,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
overridePrompt: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},

View File

@@ -220,13 +220,13 @@ describe("parseEnvVarsWithContext", () => {
).toThrow("BASE_BRANCH is required for issues event");
});
test("should allow issue assigned event with direct_prompt and no assigneeTrigger", () => {
test("should allow issue assigned event with 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
prompt: "Please assess this issue", // But prompt is provided
},
});
@@ -239,7 +239,7 @@ describe("parseEnvVarsWithContext", () => {
expect(result.eventData.eventName).toBe("issues");
expect(result.eventData.isPR).toBe(false);
expect(result.directPrompt).toBe("Please assess this issue");
expect(result.prompt).toBe("Please assess this issue");
if (
result.eventData.eventName === "issues" &&
result.eventData.eventAction === "assigned"
@@ -249,13 +249,13 @@ describe("parseEnvVarsWithContext", () => {
}
});
test("should throw error when neither assigneeTrigger nor directPrompt provided for issue assigned event", () => {
test("should throw error when neither assigneeTrigger nor prompt provided for issue assigned event", () => {
const contextWithoutTriggers = createMockContext({
...mockIssueAssignedContext,
inputs: {
...mockIssueAssignedContext.inputs,
assigneeTrigger: "", // No assignee trigger
directPrompt: "", // No direct prompt
prompt: "", // No prompt
},
});
@@ -270,33 +270,23 @@ describe("parseEnvVarsWithContext", () => {
});
});
describe("optional fields", () => {
test("should include custom instructions when provided", () => {
describe("context generation", () => {
test("should generate context without legacy fields", () => {
process.env = BASE_ENV;
const contextWithCustomInstructions = createMockContext({
const context = createMockContext({
...mockPullRequestCommentContext,
inputs: {
...mockPullRequestCommentContext.inputs,
customInstructions: "Be concise",
},
});
const result = prepareContext(contextWithCustomInstructions, "12345");
const result = prepareContext(context, "12345");
expect(result.customInstructions).toBe("Be concise");
});
test("should include allowed tools when provided", () => {
process.env = BASE_ENV;
const contextWithAllowedTools = createMockContext({
...mockPullRequestCommentContext,
inputs: {
...mockPullRequestCommentContext.inputs,
allowedTools: ["Tool1", "Tool2"],
},
});
const result = prepareContext(contextWithAllowedTools, "12345");
expect(result.allowedTools).toBe("Tool1,Tool2");
// Verify context is created without legacy fields
expect(result.repository).toBe("test-owner/test-repo");
expect(result.claudeCommentId).toBe("12345");
expect(result.triggerPhrase).toBe("/claude");
expect((result as any).customInstructions).toBeUndefined();
expect((result as any).allowedTools).toBeUndefined();
});
});
});

View File

@@ -22,24 +22,18 @@ import type {
import type { ParsedGitHubContext } from "../src/github/context";
describe("checkContainsTrigger", () => {
describe("direct prompt trigger", () => {
it("should return true when direct prompt is provided", () => {
describe("prompt trigger", () => {
it("should return true when prompt is provided", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "opened",
inputs: {
mode: "tag",
prompt: "Fix the bug in the login form",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "Fix the bug in the login form",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
@@ -47,7 +41,7 @@ describe("checkContainsTrigger", () => {
expect(checkContainsTrigger(context)).toBe(true);
});
it("should return false when direct prompt is empty", () => {
it("should return false when prompt is empty", () => {
const context = createMockContext({
eventName: "issues",
eventAction: "opened",
@@ -62,18 +56,12 @@ describe("checkContainsTrigger", () => {
},
} as IssuesEvent,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
@@ -280,18 +268,12 @@ describe("checkContainsTrigger", () => {
},
} as PullRequestEvent,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
@@ -315,18 +297,12 @@ describe("checkContainsTrigger", () => {
},
} as PullRequestEvent,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},
@@ -350,18 +326,12 @@ describe("checkContainsTrigger", () => {
},
} as PullRequestEvent,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
directPrompt: "",
overridePrompt: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
branchPrefix: "claude/",
useStickyComment: false,
additionalPermissions: new Map(),
useCommitSigning: false,
allowedBots: "",
},