diff --git a/CLAUDE.md b/CLAUDE.md index e05c2ee9c..6c636bdde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ bun run format:check # Check formatting ## What This Is -A GitHub Action that lets Claude respond to `@claude` mentions on issues/PRs (tag mode) or run tasks via `prompt` input (agent mode). Mode is auto-detected: if `prompt` is provided, it's agent mode; if triggered by a comment/issue event with `@claude`, it's tag mode. See `src/modes/registry.ts`. +A GitHub Action that lets Claude respond to `@claude` mentions on issues/PRs (tag mode) or run tasks via `prompt` input (agent mode). Mode is auto-detected: if `prompt` is provided, it's agent mode; if triggered by a comment/issue event with `@claude`, it's tag mode. See `src/modes/detector.ts`. ## How It Runs @@ -23,9 +23,9 @@ Single entrypoint: `src/entrypoints/run.ts` orchestrates everything — prepare **Auth priority**: `github_token` input (user-provided) > GitHub App OIDC token (default). The `claude_code_oauth_token` and `anthropic_api_key` are for the Claude API, not GitHub. Token setup lives in `src/github/token.ts`. -**Mode lifecycle**: Modes implement `shouldTrigger()` → `prepare()` → `prepareContext()` → `getSystemPrompt()`. The registry in `src/modes/registry.ts` picks the mode based on event type and inputs. To add a new mode, implement the `Mode` type from `src/modes/types.ts` and register it. +**Mode lifecycle**: `detectMode()` in `src/modes/detector.ts` picks the mode name ("tag" or "agent"). Trigger checking and prepare dispatch are inlined in `run.ts`: tag mode calls `prepareTagMode()` from `src/modes/tag/`, agent mode calls `prepareAgentMode()` from `src/modes/agent/`. -**Prompt construction**: `src/prepare/` builds the prompt by fetching GitHub data (`src/github/data/fetcher.ts`), formatting it as markdown (`src/github/data/formatter.ts`), and writing it to a temp file. The prompt includes issue/PR body, comments, diff, and CI status. This is the most important part of the action — it's what Claude sees. +**Prompt construction**: Tag mode's `prepareTagMode()` builds the prompt by fetching GitHub data (`src/github/data/fetcher.ts`), formatting it as markdown (`src/github/data/formatter.ts`), and writing it to a temp file via `createPrompt()`. Agent mode writes the user's prompt directly. The prompt includes issue/PR body, comments, diff, and CI status. This is the most important part of the action — it's what Claude sees. ## Things That Will Bite You diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 66149eac3..4624c198f 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -20,7 +20,6 @@ import { import type { ParsedGitHubContext } from "../github/context"; import type { CommonFields, PreparedContext, EventData } from "./types"; import { GITHUB_SERVER_URL } from "../github/api/config"; -import type { Mode, ModeContext } from "../modes/types"; import { extractUserRequest } from "../utils/extract-user-request"; export type { CommonFields, PreparedContext } from "./types"; @@ -458,9 +457,31 @@ export function generatePrompt( context: PreparedContext, githubData: FetchDataResult, useCommitSigning: boolean, - mode: Mode, + modeName: "tag" | "agent", ): string { - return mode.generatePrompt(context, githubData, useCommitSigning); + if (modeName === "agent") { + return context.prompt || `Repository: ${context.repository}`; + } + + // Tag mode + const defaultPrompt = generateDefaultPrompt( + context, + githubData, + useCommitSigning, + ); + + if (context.githubContext?.inputs?.prompt) { + return ( + defaultPrompt + + ` + + +${context.githubContext.inputs.prompt} +` + ); + } + + return defaultPrompt; } /** @@ -901,28 +922,20 @@ function extractUserRequestFromContext( } export async function createPrompt( - mode: Mode, - modeContext: ModeContext, + commentId: number, + baseBranch: string | undefined, + claudeBranch: string | undefined, githubData: FetchDataResult, context: ParsedGitHubContext, ) { try { - // Prepare the context for prompt generation - let claudeCommentId: string = ""; - if (mode.name === "tag") { - if (!modeContext.commentId) { - throw new Error( - `${mode.name} mode requires a comment ID for prompt generation`, - ); - } - claudeCommentId = modeContext.commentId.toString(); - } + const claudeCommentId = commentId.toString(); const preparedContext = prepareContext( context, claudeCommentId, - modeContext.baseBranch, - modeContext.claudeBranch, + baseBranch, + claudeBranch, ); await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { @@ -934,7 +947,7 @@ export async function createPrompt( preparedContext, githubData, context.inputs.useCommitSigning, - mode, + "tag", ); // Log the final prompt to console @@ -967,19 +980,12 @@ export async function createPrompt( // Set allowed tools const hasActionsReadPermission = false; - // Get mode-specific tools - const modeAllowedTools = mode.getAllowedTools(); - const modeDisallowedTools = mode.getDisallowedTools(); - const allAllowedTools = buildAllowedToolsString( - modeAllowedTools, + [], hasActionsReadPermission, context.inputs.useCommitSigning, ); - const allDisallowedTools = buildDisallowedToolsString( - modeDisallowedTools, - modeAllowedTools, - ); + const allDisallowedTools = buildDisallowedToolsString([], []); core.exportVariable("ALLOWED_TOOLS", allAllowedTools); core.exportVariable("DISALLOWED_TOOLS", allDisallowedTools); diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index af0ce9d26..a0b0aad6b 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -10,8 +10,10 @@ import { setupGitHubToken } from "../github/token"; import { checkWritePermissions } from "../github/validation/permissions"; import { createOctokit } from "../github/api/client"; import { parseGitHubContext, isEntityContext } from "../github/context"; -import { getMode } from "../modes/registry"; -import { prepare } from "../prepare"; +import { detectMode } from "../modes/detector"; +import { prepareTagMode } from "../modes/tag"; +import { prepareAgentMode } from "../modes/agent"; +import { checkContainsTrigger } from "../github/validation/trigger"; import { collectActionInputsPresence } from "./collect-inputs"; async function run() { @@ -22,7 +24,10 @@ async function run() { const context = parseGitHubContext(); // Auto-detect mode based on context - const mode = getMode(context); + const modeName = detectMode(context); + console.log( + `Auto-detected mode: ${modeName} for event: ${context.eventName}`, + ); // Setup GitHub token const githubToken = await setupGitHubToken(); @@ -46,10 +51,13 @@ async function run() { } // Check trigger conditions - const containsTrigger = mode.shouldTrigger(context); + const containsTrigger = + modeName === "tag" + ? isEntityContext(context) && checkContainsTrigger(context) + : !!context.inputs?.prompt; // Debug logging - console.log(`Mode: ${mode.name}`); + console.log(`Mode: ${modeName}`); console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`); console.log(`Trigger result: ${containsTrigger}`); @@ -63,31 +71,20 @@ async function run() { return; } - // Step 5: Use the new modular prepare function - const result = await prepare({ - context, - octokit, - mode, - githubToken, - }); + // Run prepare + console.log( + `Preparing with mode: ${modeName} for event: ${context.eventName}`, + ); + if (modeName === "tag") { + await prepareTagMode({ context, octokit, githubToken }); + } else { + await prepareAgentMode({ context, octokit, githubToken }); + } // MCP config is handled by individual modes (tag/agent) and included in their claude_args output // Expose the GitHub token (Claude App token) as an output core.setOutput("github_token", githubToken); - - // Step 6: Get system prompt from mode if available - if (mode.getSystemPrompt) { - const modeContext = mode.prepareContext(context, { - commentId: result.commentId, - baseBranch: result.branchInfo.baseBranch, - claudeBranch: result.branchInfo.claudeBranch, - }); - const systemPrompt = mode.getSystemPrompt(modeContext); - if (systemPrompt) { - core.exportVariable("APPEND_SYSTEM_PROMPT", systemPrompt); - } - } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); core.setFailed(`Prepare step failed with error: ${errorMessage}`); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index 871fa1915..54eae0139 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -17,8 +17,10 @@ import { createOctokit } from "../github/api/client"; import type { Octokits } from "../github/api/client"; import { parseGitHubContext, isEntityContext } from "../github/context"; import type { GitHubContext } from "../github/context"; -import { getMode } from "../modes/registry"; -import { prepare } from "../prepare"; +import { detectMode } from "../modes/detector"; +import { prepareTagMode } from "../modes/tag"; +import { prepareAgentMode } from "../modes/agent"; +import { checkContainsTrigger } from "../github/validation/trigger"; import { collectActionInputsPresence } from "./collect-inputs"; import { updateCommentLink } from "./update-comment-link"; import { formatTurnsFromData } from "./format-turns"; @@ -138,7 +140,10 @@ async function run() { // Phase 1: Prepare const actionInputsPresent = collectActionInputsPresence(); context = parseGitHubContext(); - const mode = getMode(context); + const modeName = detectMode(context); + console.log( + `Auto-detected mode: ${modeName} for event: ${context.eventName}`, + ); try { githubToken = await setupGitHubToken(); @@ -173,8 +178,11 @@ async function run() { } // Check trigger conditions - const containsTrigger = mode.shouldTrigger(context); - console.log(`Mode: ${mode.name}`); + const containsTrigger = + modeName === "tag" + ? isEntityContext(context) && checkContainsTrigger(context) + : !!context.inputs?.prompt; + console.log(`Mode: ${modeName}`); console.log(`Context prompt: ${context.inputs?.prompt || "NO PROMPT"}`); console.log(`Trigger result: ${containsTrigger}`); @@ -185,31 +193,19 @@ async function run() { } // Run prepare - const prepareResult = await prepare({ - context, - octokit, - mode, - githubToken, - }); + console.log( + `Preparing with mode: ${modeName} for event: ${context.eventName}`, + ); + const prepareResult = + modeName === "tag" + ? await prepareTagMode({ context, octokit, githubToken }) + : await prepareAgentMode({ context, octokit, githubToken }); commentId = prepareResult.commentId; claudeBranch = prepareResult.branchInfo.claudeBranch; baseBranch = prepareResult.branchInfo.baseBranch; prepareCompleted = true; - // Set system prompt if available - if (mode.getSystemPrompt) { - const modeContext = mode.prepareContext(context, { - commentId: prepareResult.commentId, - baseBranch: prepareResult.branchInfo.baseBranch, - claudeBranch: prepareResult.branchInfo.claudeBranch, - }); - const systemPrompt = mode.getSystemPrompt(modeContext); - if (systemPrompt) { - core.exportVariable("APPEND_SYSTEM_PROMPT", systemPrompt); - } - } - // Phase 2: Install Claude Code CLI await installClaudeCode(); diff --git a/src/modes/agent/index.ts b/src/modes/agent/index.ts index 0e9376c61..e6047379c 100644 --- a/src/modes/agent/index.ts +++ b/src/modes/agent/index.ts @@ -1,7 +1,4 @@ -import * as core from "@actions/core"; import { mkdir, writeFile } from "fs/promises"; -import type { Mode, ModeOptions, ModeResult } from "../types"; -import type { PreparedContext } from "../../create-prompt/types"; import { prepareMcpConfig } from "../../mcp/install-mcp-server"; import { parseAllowedTools } from "./parse-tools"; import { @@ -10,211 +7,128 @@ import { } from "../../github/operations/git-config"; import { checkHumanActor } from "../../github/validation/actor"; import type { GitHubContext } from "../../github/context"; -import { isEntityContext } from "../../github/context"; +import type { Octokits } from "../../github/api/client"; /** - * Extract GitHub context as environment variables for agent mode - */ -function extractGitHubContext(context: GitHubContext): Record { - const envVars: Record = {}; - - // Basic repository info - envVars.GITHUB_REPOSITORY = context.repository.full_name; - envVars.GITHUB_TRIGGER_ACTOR = context.actor; - envVars.GITHUB_EVENT_NAME = context.eventName; - - // Entity-specific context (PR/issue numbers, branches, etc.) - if (isEntityContext(context)) { - if (context.isPR) { - envVars.GITHUB_PR_NUMBER = String(context.entityNumber); - - // Extract branch info from payload if available - if ( - context.payload && - "pull_request" in context.payload && - context.payload.pull_request - ) { - envVars.GITHUB_BASE_REF = context.payload.pull_request.base?.ref || ""; - envVars.GITHUB_HEAD_REF = context.payload.pull_request.head?.ref || ""; - } - } else { - envVars.GITHUB_ISSUE_NUMBER = String(context.entityNumber); - } - } - - return envVars; -} - -/** - * Agent mode implementation. + * Prepares the agent mode execution context. * - * This mode runs whenever an explicit prompt is provided in the workflow configuration. + * Agent mode runs whenever an explicit prompt is provided in the workflow configuration. * It bypasses the standard @claude mention checking and comment tracking used by tag mode, * providing direct access to Claude Code for automation workflows. */ -export const agentMode: Mode = { - name: "agent", - description: "Direct automation mode for explicit prompts", - - shouldTrigger(context) { - // Only trigger when an explicit prompt is provided - return !!context.inputs?.prompt; - }, - - prepareContext(context) { - // Agent mode doesn't use comment tracking or branch management - return { - mode: "agent", - githubContext: context, +export async function prepareAgentMode({ + context, + octokit, + githubToken, +}: { + context: GitHubContext; + octokit: Octokits; + githubToken: string; +}) { + // Check if actor is human (prevents bot-triggered loops) + await checkHumanActor(octokit.rest, context); + + // Configure git authentication for agent mode (same as tag mode) + // SSH signing takes precedence if provided + const useSshSigning = !!context.inputs.sshSigningKey; + const useApiCommitSigning = context.inputs.useCommitSigning && !useSshSigning; + + if (useSshSigning) { + // Setup SSH signing for commits + await setupSshSigning(context.inputs.sshSigningKey); + + // Still configure git auth for push operations (user/email and remote URL) + const user = { + login: context.inputs.botName, + id: parseInt(context.inputs.botId), }; - }, - - getAllowedTools() { - return []; - }, - - getDisallowedTools() { - return []; - }, - - shouldCreateTrackingComment() { - return false; - }, - - async prepare({ - context, - octokit, - githubToken, - }: ModeOptions): Promise { - // Check if actor is human (prevents bot-triggered loops) - await checkHumanActor(octokit.rest, context); - - // Configure git authentication for agent mode (same as tag mode) - // SSH signing takes precedence if provided - const useSshSigning = !!context.inputs.sshSigningKey; - const useApiCommitSigning = - context.inputs.useCommitSigning && !useSshSigning; - - if (useSshSigning) { - // Setup SSH signing for commits - await setupSshSigning(context.inputs.sshSigningKey); - - // Still configure git auth for push operations (user/email and remote URL) - const user = { - login: context.inputs.botName, - id: parseInt(context.inputs.botId), - }; - try { - await configureGitAuth(githubToken, context, user); - } catch (error) { - console.error("Failed to configure git authentication:", error); - // Continue anyway - git operations may still work with default config - } - } else if (!useApiCommitSigning) { - // Use bot_id and bot_name from inputs directly - const user = { - login: context.inputs.botName, - id: parseInt(context.inputs.botId), - }; - - try { - // Use the shared git configuration function - await configureGitAuth(githubToken, context, user); - } catch (error) { - console.error("Failed to configure git authentication:", error); - // Continue anyway - git operations may still work with default config - } + try { + await configureGitAuth(githubToken, context, user); + } catch (error) { + console.error("Failed to configure git authentication:", error); + // Continue anyway - git operations may still work with default config } + } else if (!useApiCommitSigning) { + // Use bot_id and bot_name from inputs directly + const user = { + login: context.inputs.botName, + id: parseInt(context.inputs.botId), + }; - // Create prompt directory - await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { - recursive: true, - }); - - // Write the prompt file - use the user's prompt directly - const promptContent = - context.inputs.prompt || - `Repository: ${context.repository.owner}/${context.repository.repo}`; - - await writeFile( - `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, - promptContent, - ); - - // Parse allowed tools from user's claude_args - const userClaudeArgs = process.env.CLAUDE_ARGS || ""; - const allowedTools = parseAllowedTools(userClaudeArgs); - - // Check for branch info from environment variables (useful for auto-fix workflows) - const claudeBranch = process.env.CLAUDE_BRANCH || undefined; - const baseBranch = - process.env.BASE_BRANCH || context.inputs.baseBranch || "main"; - - // Detect current branch from GitHub environment - const currentBranch = - claudeBranch || - process.env.GITHUB_HEAD_REF || - process.env.GITHUB_REF_NAME || - "main"; - - // Get our GitHub MCP servers config - const ourMcpConfig = await prepareMcpConfig({ - githubToken, - owner: context.repository.owner, - repo: context.repository.repo, - branch: currentBranch, - baseBranch: baseBranch, - claudeCommentId: undefined, // No tracking comment in agent mode - allowedTools, - mode: "agent", - context, - }); - - // Build final claude_args with multiple --mcp-config flags - let claudeArgs = ""; - - // Add our GitHub servers config if we have any - const ourConfig = JSON.parse(ourMcpConfig); - if (ourConfig.mcpServers && Object.keys(ourConfig.mcpServers).length > 0) { - const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''"); - claudeArgs = `--mcp-config '${escapedOurConfig}'`; + try { + // Use the shared git configuration function + await configureGitAuth(githubToken, context, user); + } catch (error) { + console.error("Failed to configure git authentication:", error); + // Continue anyway - git operations may still work with default config } + } - // Append user's claude_args (which may have more --mcp-config flags) - claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim(); - - return { - commentId: undefined, - branchInfo: { - baseBranch: baseBranch, - currentBranch: baseBranch, // Use base branch as current when creating new branch - claudeBranch: claudeBranch, - }, - mcpConfig: ourMcpConfig, - claudeArgs, - }; - }, - - generatePrompt(context: PreparedContext): string { - // Inject GitHub context as environment variables - if (context.githubContext) { - const envVars = extractGitHubContext(context.githubContext); - for (const [key, value] of Object.entries(envVars)) { - core.exportVariable(key, value); - } - } + // Create prompt directory + await mkdir(`${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts`, { + recursive: true, + }); + + // Write the prompt file - use the user's prompt directly + const promptContent = + context.inputs.prompt || + `Repository: ${context.repository.owner}/${context.repository.repo}`; + + await writeFile( + `${process.env.RUNNER_TEMP || "/tmp"}/claude-prompts/claude-prompt.txt`, + promptContent, + ); + + // Parse allowed tools from user's claude_args + const userClaudeArgs = process.env.CLAUDE_ARGS || ""; + const allowedTools = parseAllowedTools(userClaudeArgs); + + // Check for branch info from environment variables (useful for auto-fix workflows) + const claudeBranch = process.env.CLAUDE_BRANCH || undefined; + const baseBranch = + process.env.BASE_BRANCH || context.inputs.baseBranch || "main"; + + // Detect current branch from GitHub environment + const currentBranch = + claudeBranch || + process.env.GITHUB_HEAD_REF || + process.env.GITHUB_REF_NAME || + "main"; + + // Get our GitHub MCP servers config + const ourMcpConfig = await prepareMcpConfig({ + githubToken, + owner: context.repository.owner, + repo: context.repository.repo, + branch: currentBranch, + baseBranch: baseBranch, + claudeCommentId: undefined, // No tracking comment in agent mode + allowedTools, + mode: "agent", + context, + }); - // Agent mode uses prompt field - if (context.prompt) { - return context.prompt; - } + // Build final claude_args with multiple --mcp-config flags + let claudeArgs = ""; + + // Add our GitHub servers config if we have any + const ourConfig = JSON.parse(ourMcpConfig); + if (ourConfig.mcpServers && Object.keys(ourConfig.mcpServers).length > 0) { + const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''"); + claudeArgs = `--mcp-config '${escapedOurConfig}'`; + } - // Minimal fallback - repository is a string in PreparedContext - return `Repository: ${context.repository}`; - }, + // Append user's claude_args (which may have more --mcp-config flags) + claudeArgs = `${claudeArgs} ${userClaudeArgs}`.trim(); - getSystemPrompt() { - // Agent mode doesn't need additional system prompts - return undefined; - }, -}; + return { + commentId: undefined, + branchInfo: { + baseBranch: baseBranch, + currentBranch: baseBranch, // Use base branch as current when creating new branch + claudeBranch: claudeBranch, + }, + mcpConfig: ourMcpConfig, + claudeArgs, + }; +} diff --git a/src/modes/detector.ts b/src/modes/detector.ts index 8e30aff4f..c15ce88a1 100644 --- a/src/modes/detector.ts +++ b/src/modes/detector.ts @@ -80,17 +80,6 @@ export function detectMode(context: GitHubContext): AutoDetectedMode { return "agent"; } -export function getModeDescription(mode: AutoDetectedMode): string { - switch (mode) { - case "tag": - return "Interactive mode triggered by @claude mentions"; - case "agent": - return "Direct automation mode for explicit prompts"; - default: - return "Unknown mode"; - } -} - function validateTrackProgressEvent(context: GitHubContext): void { // track_progress is only valid for pull_request and issue events const validEvents = [ @@ -123,21 +112,3 @@ function validateTrackProgressEvent(context: GitHubContext): void { } } } - -export function shouldUseTrackingComment(mode: AutoDetectedMode): boolean { - return mode === "tag"; -} - -export function getDefaultPromptForMode( - mode: AutoDetectedMode, - context: GitHubContext, -): string | undefined { - switch (mode) { - case "tag": - return undefined; - case "agent": - return context.inputs?.prompt; - default: - return undefined; - } -} diff --git a/src/modes/registry.ts b/src/modes/registry.ts deleted file mode 100644 index 9df69980c..000000000 --- a/src/modes/registry.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Mode Registry for claude-code-action v1.0 - * - * This module provides access to all available execution modes and handles - * automatic mode detection based on GitHub event types. - */ - -import type { Mode, ModeName } from "./types"; -import { tagMode } from "./tag"; -import { agentMode } from "./agent"; -import type { GitHubContext } from "../github/context"; -import { detectMode, type AutoDetectedMode } from "./detector"; - -export const VALID_MODES = ["tag", "agent"] as const; - -/** - * All available modes in v1.0 - */ -const modes = { - tag: tagMode, - agent: agentMode, -} as const satisfies Record; - -/** - * Automatically detects and retrieves the appropriate mode based on the GitHub context. - * In v1.0, modes are auto-selected based on event type. - * @param context The GitHub context - * @returns The appropriate mode for the context - */ -export function getMode(context: GitHubContext): Mode { - const modeName = detectMode(context); - console.log( - `Auto-detected mode: ${modeName} for event: ${context.eventName}`, - ); - - const mode = modes[modeName]; - if (!mode) { - throw new Error( - `Mode '${modeName}' not found. This should not happen. Please report this issue.`, - ); - } - - return mode; -} - -/** - * Type guard to check if a string is a valid mode name. - * @param name The string to check - * @returns True if the name is a valid mode name - */ -export function isValidMode(name: string): name is ModeName { - const validModes = ["tag", "agent"]; - return validModes.includes(name); -} diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts index 89f688810..0adf746c2 100644 --- a/src/modes/tag/index.ts +++ b/src/modes/tag/index.ts @@ -1,5 +1,3 @@ -import type { Mode, ModeOptions, ModeResult } from "../types"; -import { checkContainsTrigger } from "../../github/validation/trigger"; import { checkHumanActor } from "../../github/validation/actor"; import { createInitialComment } from "../../github/operations/comments/create-initial"; import { setupBranch } from "../../github/operations/branch"; @@ -14,241 +12,177 @@ import { extractOriginalTitle, extractOriginalBody, } from "../../github/data/fetcher"; -import { createPrompt, generateDefaultPrompt } from "../../create-prompt"; +import { createPrompt } from "../../create-prompt"; import { isEntityContext } from "../../github/context"; -import type { PreparedContext } from "../../create-prompt/types"; -import type { FetchDataResult } from "../../github/data/fetcher"; +import type { GitHubContext } from "../../github/context"; +import type { Octokits } from "../../github/api/client"; import { parseAllowedTools } from "../agent/parse-tools"; /** - * Tag mode implementation. + * Prepares the tag mode execution context. * - * The traditional implementation mode that responds to @claude mentions, - * issue assignments, or labels. Creates tracking comments showing progress - * and has full implementation capabilities. + * Tag mode responds to @claude mentions, issue assignments, or labels. + * Creates tracking comments showing progress and has full implementation capabilities. */ -export const tagMode: Mode = { - name: "tag", - description: "Traditional implementation mode triggered by @claude mentions", - - shouldTrigger(context) { - // Tag mode only handles entity events - if (!isEntityContext(context)) { - return false; - } - return checkContainsTrigger(context); - }, - - prepareContext(context, data) { - return { - mode: "tag", - githubContext: context, - commentId: data?.commentId, - baseBranch: data?.baseBranch, - claudeBranch: data?.claudeBranch, +export async function prepareTagMode({ + context, + octokit, + githubToken, +}: { + context: GitHubContext; + octokit: Octokits; + githubToken: string; +}) { + // Tag mode only handles entity-based events + if (!isEntityContext(context)) { + throw new Error("Tag mode requires entity context"); + } + + // Check if actor is human + await checkHumanActor(octokit.rest, context); + + // Create initial tracking comment + const commentData = await createInitialComment(octokit.rest, context); + const commentId = commentData.id; + + const triggerTime = extractTriggerTimestamp(context); + const originalTitle = extractOriginalTitle(context); + const originalBody = extractOriginalBody(context); + + const githubData = await fetchGitHubData({ + octokits: octokit, + repository: `${context.repository.owner}/${context.repository.repo}`, + prNumber: context.entityNumber.toString(), + isPR: context.isPR, + triggerUsername: context.actor, + triggerTime, + originalTitle, + originalBody, + includeCommentsByActor: context.inputs.includeCommentsByActor, + excludeCommentsByActor: context.inputs.excludeCommentsByActor, + }); + + // Setup branch + const branchInfo = await setupBranch(octokit, githubData, context); + + // Configure git authentication + // SSH signing takes precedence if provided + const useSshSigning = !!context.inputs.sshSigningKey; + const useApiCommitSigning = context.inputs.useCommitSigning && !useSshSigning; + + if (useSshSigning) { + // Setup SSH signing for commits + await setupSshSigning(context.inputs.sshSigningKey); + + // Still configure git auth for push operations (user/email and remote URL) + const user = { + login: context.inputs.botName, + id: parseInt(context.inputs.botId), }; - }, - - getAllowedTools() { - return []; - }, - - getDisallowedTools() { - return []; - }, - - shouldCreateTrackingComment() { - return true; - }, - - async prepare({ - context, - octokit, - githubToken, - }: ModeOptions): Promise { - // Tag mode only handles entity-based events - if (!isEntityContext(context)) { - throw new Error("Tag mode requires entity context"); + try { + await configureGitAuth(githubToken, context, user); + } catch (error) { + console.error("Failed to configure git authentication:", error); + throw error; } + } else if (!useApiCommitSigning) { + // Use bot_id and bot_name from inputs directly + const user = { + login: context.inputs.botName, + id: parseInt(context.inputs.botId), + }; - // Check if actor is human - await checkHumanActor(octokit.rest, context); - - // Create initial tracking comment - const commentData = await createInitialComment(octokit.rest, context); - const commentId = commentData.id; - - const triggerTime = extractTriggerTimestamp(context); - const originalTitle = extractOriginalTitle(context); - const originalBody = extractOriginalBody(context); - - const githubData = await fetchGitHubData({ - octokits: octokit, - repository: `${context.repository.owner}/${context.repository.repo}`, - prNumber: context.entityNumber.toString(), - isPR: context.isPR, - triggerUsername: context.actor, - triggerTime, - originalTitle, - originalBody, - includeCommentsByActor: context.inputs.includeCommentsByActor, - excludeCommentsByActor: context.inputs.excludeCommentsByActor, - }); - - // Setup branch - const branchInfo = await setupBranch(octokit, githubData, context); - - // Configure git authentication - // SSH signing takes precedence if provided - const useSshSigning = !!context.inputs.sshSigningKey; - const useApiCommitSigning = - context.inputs.useCommitSigning && !useSshSigning; - - if (useSshSigning) { - // Setup SSH signing for commits - await setupSshSigning(context.inputs.sshSigningKey); - - // Still configure git auth for push operations (user/email and remote URL) - const user = { - login: context.inputs.botName, - id: parseInt(context.inputs.botId), - }; - try { - await configureGitAuth(githubToken, context, user); - } catch (error) { - console.error("Failed to configure git authentication:", error); - throw error; - } - } else if (!useApiCommitSigning) { - // Use bot_id and bot_name from inputs directly - const user = { - login: context.inputs.botName, - id: parseInt(context.inputs.botId), - }; - - try { - await configureGitAuth(githubToken, context, user); - } catch (error) { - console.error("Failed to configure git authentication:", error); - throw error; - } + try { + await configureGitAuth(githubToken, context, user); + } catch (error) { + console.error("Failed to configure git authentication:", error); + throw error; } - - // Create prompt file - const modeContext = this.prepareContext(context, { - commentId, - baseBranch: branchInfo.baseBranch, - claudeBranch: branchInfo.claudeBranch, - }); - - await createPrompt(tagMode, modeContext, githubData, context); - - const userClaudeArgs = process.env.CLAUDE_ARGS || ""; - const userAllowedMCPTools = parseAllowedTools(userClaudeArgs).filter( - (tool) => tool.startsWith("mcp__github_"), + } + + // Create prompt file + await createPrompt( + commentId, + branchInfo.baseBranch, + branchInfo.claudeBranch, + githubData, + context, + ); + + const userClaudeArgs = process.env.CLAUDE_ARGS || ""; + const userAllowedMCPTools = parseAllowedTools(userClaudeArgs).filter((tool) => + tool.startsWith("mcp__github_"), + ); + + // Build claude_args for tag mode with required tools + // Tag mode REQUIRES these tools to function properly + const tagModeTools = [ + "Edit", + "MultiEdit", + "Glob", + "Grep", + "LS", + "Read", + "Write", + "mcp__github_comment__update_claude_comment", + "mcp__github_ci__get_ci_status", + "mcp__github_ci__get_workflow_run_details", + "mcp__github_ci__download_job_log", + ...userAllowedMCPTools, + ]; + + // Add git commands when using git CLI (no API commit signing, or SSH signing) + // SSH signing still uses git CLI, just with signing enabled + if (!useApiCommitSigning) { + tagModeTools.push( + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git rm:*)", ); - - // Build claude_args for tag mode with required tools - // Tag mode REQUIRES these tools to function properly - const tagModeTools = [ - "Edit", - "MultiEdit", - "Glob", - "Grep", - "LS", - "Read", - "Write", - "mcp__github_comment__update_claude_comment", - "mcp__github_ci__get_ci_status", - "mcp__github_ci__get_workflow_run_details", - "mcp__github_ci__download_job_log", - ...userAllowedMCPTools, - ]; - - // Add git commands when using git CLI (no API commit signing, or SSH signing) - // SSH signing still uses git CLI, just with signing enabled - if (!useApiCommitSigning) { - tagModeTools.push( - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(git status:*)", - "Bash(git diff:*)", - "Bash(git log:*)", - "Bash(git rm:*)", - ); - } else { - // When using API commit signing, use MCP file ops tools - tagModeTools.push( - "mcp__github_file_ops__commit_files", - "mcp__github_file_ops__delete_files", - ); - } - - // Get our GitHub MCP servers configuration - const ourMcpConfig = await prepareMcpConfig({ - githubToken, - owner: context.repository.owner, - repo: context.repository.repo, - branch: branchInfo.claudeBranch || branchInfo.currentBranch, - baseBranch: branchInfo.baseBranch, - claudeCommentId: commentId.toString(), - allowedTools: Array.from(new Set(tagModeTools)), - mode: "tag", - context, - }); - - // Build complete claude_args with multiple --mcp-config flags - let claudeArgs = ""; - - // Add our GitHub servers config - const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''"); - claudeArgs = `--mcp-config '${escapedOurConfig}'`; - - // Add required tools for tag mode - claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`; - - // Append user's claude_args (which may have more --mcp-config flags) - if (userClaudeArgs) { - claudeArgs += ` ${userClaudeArgs}`; - } - - return { - commentId, - branchInfo, - mcpConfig: ourMcpConfig, - claudeArgs: claudeArgs.trim(), - }; - }, - - generatePrompt( - context: PreparedContext, - githubData: FetchDataResult, - useCommitSigning: boolean, - ): string { - const defaultPrompt = generateDefaultPrompt( - context, - githubData, - useCommitSigning, + } else { + // When using API commit signing, use MCP file ops tools + tagModeTools.push( + "mcp__github_file_ops__commit_files", + "mcp__github_file_ops__delete_files", ); + } - // If a custom prompt is provided, inject it into the tag mode prompt - if (context.githubContext?.inputs?.prompt) { - return ( - defaultPrompt + - ` - - -${context.githubContext.inputs.prompt} -` - ); - } - - return defaultPrompt; - }, - - getSystemPrompt() { - // Tag mode doesn't need additional system prompts - return undefined; - }, -}; + // Get our GitHub MCP servers configuration + const ourMcpConfig = await prepareMcpConfig({ + githubToken, + owner: context.repository.owner, + repo: context.repository.repo, + branch: branchInfo.claudeBranch || branchInfo.currentBranch, + baseBranch: branchInfo.baseBranch, + claudeCommentId: commentId.toString(), + allowedTools: Array.from(new Set(tagModeTools)), + mode: "tag", + context, + }); + + // Build complete claude_args with multiple --mcp-config flags + let claudeArgs = ""; + + // Add our GitHub servers config + const escapedOurConfig = ourMcpConfig.replace(/'/g, "'\\''"); + claudeArgs = `--mcp-config '${escapedOurConfig}'`; + + // Add required tools for tag mode + claudeArgs += ` --allowedTools "${tagModeTools.join(",")}"`; + + // Append user's claude_args (which may have more --mcp-config flags) + if (userClaudeArgs) { + claudeArgs += ` ${userClaudeArgs}`; + } + + return { + commentId, + branchInfo, + mcpConfig: ourMcpConfig, + claudeArgs: claudeArgs.trim(), + }; +} diff --git a/src/modes/types.ts b/src/modes/types.ts deleted file mode 100644 index 3d653ccd3..000000000 --- a/src/modes/types.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { GitHubContext } from "../github/context"; -import type { PreparedContext } from "../create-prompt/types"; -import type { FetchDataResult } from "../github/data/fetcher"; -import type { Octokits } from "../github/api/client"; - -export type ModeName = "tag" | "agent"; - -export type ModeContext = { - mode: ModeName; - githubContext: GitHubContext; - commentId?: number; - baseBranch?: string; - claudeBranch?: string; -}; - -export type ModeData = { - commentId?: number; - baseBranch?: string; - claudeBranch?: string; -}; - -/** - * Mode interface for claude-code-action execution modes. - * Each mode defines its own behavior for trigger detection, prompt generation, - * and tracking comment creation. - * - * Current modes include: - * - 'tag': Interactive mode triggered by @claude mentions - * - 'agent': Direct automation mode triggered by explicit prompts - */ -export type Mode = { - name: ModeName; - description: string; - - /** - * Determines if this mode should trigger based on the GitHub context - */ - shouldTrigger(context: GitHubContext): boolean; - - /** - * Prepares the mode context with any additional data needed for prompt generation - */ - prepareContext(context: GitHubContext, data?: ModeData): ModeContext; - - /** - * Returns the list of tools that should be allowed for this mode - */ - getAllowedTools(): string[]; - - /** - * Returns the list of tools that should be disallowed for this mode - */ - getDisallowedTools(): string[]; - - /** - * Determines if this mode should create a tracking comment - */ - shouldCreateTrackingComment(): boolean; - - /** - * Generates the prompt for this mode. - * @returns The complete prompt string - */ - generatePrompt( - context: PreparedContext, - githubData: FetchDataResult, - useCommitSigning: boolean, - ): string; - - /** - * Prepares the GitHub environment for this mode. - * Each mode decides how to handle different event types. - * @returns PrepareResult with commentId, branchInfo, and mcpConfig - */ - prepare(options: ModeOptions): Promise; - - /** - * Returns an optional system prompt to append to Claude's base system prompt. - * This allows modes to add mode-specific instructions. - * @returns The system prompt string or undefined if no additional prompt is needed - */ - getSystemPrompt?(context: ModeContext): string | undefined; -}; - -// Define types for mode prepare method -export type ModeOptions = { - context: GitHubContext; - octokit: Octokits; - githubToken: string; -}; - -export type ModeResult = { - commentId?: number; - branchInfo: { - baseBranch: string; - claudeBranch?: string; - currentBranch: string; - }; - mcpConfig: string; - claudeArgs: string; -}; diff --git a/src/prepare/index.ts b/src/prepare/index.ts deleted file mode 100644 index 6f4230192..000000000 --- a/src/prepare/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Main prepare module that delegates to the mode's prepare method - */ - -import type { PrepareOptions, PrepareResult } from "./types"; - -export async function prepare(options: PrepareOptions): Promise { - const { mode, context, octokit, githubToken } = options; - - console.log( - `Preparing with mode: ${mode.name} for event: ${context.eventName}`, - ); - - // Delegate to the mode's prepare method - return mode.prepare({ - context, - octokit, - githubToken, - }); -} diff --git a/src/prepare/types.ts b/src/prepare/types.ts deleted file mode 100644 index 72ece515b..000000000 --- a/src/prepare/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { GitHubContext } from "../github/context"; -import type { Octokits } from "../github/api/client"; -import type { Mode } from "../modes/types"; - -export type PrepareResult = { - commentId?: number; - branchInfo: { - baseBranch: string; - claudeBranch?: string; - currentBranch: string; - }; - mcpConfig: string; - claudeArgs: string; -}; - -export type PrepareOptions = { - context: GitHubContext; - octokit: Octokits; - mode: Mode; - githubToken: string; -}; diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index ca2468175..0a5ea8253 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -3,60 +3,13 @@ import { describe, test, expect } from "bun:test"; import { generatePrompt, - generateDefaultPrompt, getEventTypeAndContext, buildAllowedToolsString, buildDisallowedToolsString, } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt"; -import type { Mode } from "../src/modes/types"; describe("generatePrompt", () => { - // Create a mock tag mode that uses the default prompt - const mockTagMode: Mode = { - name: "tag", - description: "Tag mode", - shouldTrigger: () => true, - prepareContext: (context) => ({ mode: "tag", githubContext: context }), - getAllowedTools: () => [], - getDisallowedTools: () => [], - shouldCreateTrackingComment: () => true, - generatePrompt: (context, githubData, useCommitSigning) => - generateDefaultPrompt(context, githubData, useCommitSigning), - prepare: async () => ({ - commentId: 123, - branchInfo: { - baseBranch: "main", - currentBranch: "main", - claudeBranch: undefined, - }, - mcpConfig: "{}", - claudeArgs: "", - }), - }; - - // Create a mock agent mode that passes through prompts - const mockAgentMode: Mode = { - name: "agent", - description: "Agent mode", - shouldTrigger: () => true, - prepareContext: (context) => ({ mode: "agent", githubContext: context }), - getAllowedTools: () => [], - getDisallowedTools: () => [], - shouldCreateTrackingComment: () => false, - generatePrompt: (context) => context.prompt || "", - prepare: async () => ({ - commentId: undefined, - branchInfo: { - baseBranch: "main", - currentBranch: "main", - claudeBranch: undefined, - }, - mcpConfig: "{}", - claudeArgs: "", - }), - }; - const mockGitHubData = { contextData: { title: "Test PR", @@ -181,12 +134,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("You are Claude, an AI assistant"); expect(prompt).toContain("GENERAL_COMMENT"); @@ -214,12 +162,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("PR_REVIEW"); expect(prompt).toContain("true"); @@ -245,12 +188,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("ISSUE_CREATED"); expect(prompt).toContain( @@ -278,12 +216,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("ISSUE_ASSIGNED"); expect(prompt).toContain( @@ -310,12 +243,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("ISSUE_LABELED"); expect(prompt).toContain( @@ -341,12 +269,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("PULL_REQUEST"); expect(prompt).toContain("true"); @@ -370,12 +293,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Verify prompt generates successfully without custom instructions expect(prompt).toContain("@claude please fix this"); @@ -400,7 +318,7 @@ describe("generatePrompt", () => { envVars, mockGitHubData, false, - mockAgentMode, + "agent", ); // Agent mode: Prompt is passed through as-is @@ -441,7 +359,7 @@ describe("generatePrompt", () => { envVars, mockGitHubData, false, - mockAgentMode, + "agent", ); // v1.0: Variables are NOT substituted - prompt is passed as-is to Claude Code @@ -490,7 +408,7 @@ describe("generatePrompt", () => { envVars, issueGitHubData, false, - mockAgentMode, + "agent", ); // Agent mode: Prompt is passed through as-is @@ -515,7 +433,7 @@ describe("generatePrompt", () => { envVars, mockGitHubData, false, - mockAgentMode, + "agent", ); // Agent mode: No substitution - passed as-is @@ -539,12 +457,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("You are Claude, an AI assistant"); expect(prompt).toContain("ISSUE_CREATED"); @@ -567,12 +480,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); expect(prompt).toContain("johndoe"); // With commit signing disabled, co-author info appears in git commit instructions @@ -594,12 +502,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain PR-specific instructions (git commands when not using signing) expect(prompt).toContain("git push"); @@ -630,12 +533,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain Issue-specific instructions expect(prompt).toContain( @@ -674,12 +572,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain the actual branch name with timestamp expect(prompt).toContain( @@ -709,12 +602,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain branch-specific instructions like issues expect(prompt).toContain( @@ -752,12 +640,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain open PR instructions (git commands when not using signing) expect(prompt).toContain("git push"); @@ -788,12 +671,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain new branch instructions expect(prompt).toContain( @@ -821,12 +699,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain new branch instructions expect(prompt).toContain( @@ -854,12 +727,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain new branch instructions expect(prompt).toContain( @@ -883,12 +751,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, false, "tag"); // Should have git command instructions expect(prompt).toContain("Use git commands via the Bash tool"); @@ -917,12 +780,7 @@ describe("generatePrompt", () => { }, }; - const prompt = await generatePrompt( - envVars, - mockGitHubData, - true, - mockTagMode, - ); + const prompt = await generatePrompt(envVars, mockGitHubData, true, "tag"); // Should have commit signing tool instructions expect(prompt).toContain("mcp__github_file_ops__commit_files"); diff --git a/test/modes/agent.test.ts b/test/modes/agent.test.ts index b7224069b..be6f0d2b1 100644 --- a/test/modes/agent.test.ts +++ b/test/modes/agent.test.ts @@ -7,22 +7,17 @@ import { spyOn, mock, } from "bun:test"; -import { agentMode } from "../../src/modes/agent"; -import type { GitHubContext } from "../../src/github/context"; -import { createMockContext, createMockAutomationContext } from "../mockContext"; +import { prepareAgentMode } from "../../src/modes/agent"; +import { createMockAutomationContext } from "../mockContext"; import * as core from "@actions/core"; import * as gitConfig from "../../src/github/operations/git-config"; describe("Agent Mode", () => { - let mockContext: GitHubContext; let exportVariableSpy: any; let setOutputSpy: any; let configureGitAuthSpy: any; beforeEach(() => { - mockContext = createMockAutomationContext({ - eventName: "workflow_dispatch", - }); exportVariableSpy = spyOn(core, "exportVariable").mockImplementation( () => {}, ); @@ -45,84 +40,11 @@ describe("Agent Mode", () => { configureGitAuthSpy?.mockRestore(); }); - test("agent mode has correct properties", () => { - expect(agentMode.name).toBe("agent"); - expect(agentMode.description).toBe( - "Direct automation mode for explicit prompts", - ); - expect(agentMode.shouldCreateTrackingComment()).toBe(false); - expect(agentMode.getAllowedTools()).toEqual([]); - expect(agentMode.getDisallowedTools()).toEqual([]); - }); - - test("prepareContext returns minimal data", () => { - const context = agentMode.prepareContext(mockContext); - - expect(context.mode).toBe("agent"); - expect(context.githubContext).toBe(mockContext); - // Agent mode doesn't use comment tracking or branch management - expect(Object.keys(context)).toEqual(["mode", "githubContext"]); - }); - - 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(false); - - const scheduleContext = createMockAutomationContext({ - eventName: "schedule", - }); - 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 - const entityEvents = [ - "issue_comment", - "pull_request", - "pull_request_review", - "issues", - ] as const; - - entityEvents.forEach((eventName) => { - const contextNoPrompt = createMockContext({ eventName }); - expect(agentMode.shouldTrigger(contextNoPrompt)).toBe(false); - }); - - // Should trigger for ANY event when prompt is provided - const allEvents = [ - "workflow_dispatch", - "repository_dispatch", - "schedule", - "issue_comment", - "pull_request", - "pull_request_review", - "issues", - ] as const; - - allEvents.forEach((eventName) => { - const contextWithPrompt = - eventName === "workflow_dispatch" || - eventName === "repository_dispatch" || - eventName === "schedule" - ? createMockAutomationContext({ - eventName, - inputs: { prompt: "Do something" }, - }) - : createMockContext({ - eventName, - inputs: { prompt: "Do something" }, - }); - expect(agentMode.shouldTrigger(contextWithPrompt)).toBe(true); - }); + test("prepareAgentMode is exported as a function", () => { + expect(typeof prepareAgentMode).toBe("function"); }); - test("prepare method passes through claude_args", async () => { + test("prepare passes through claude_args", async () => { // Clear any previous calls before this test exportVariableSpy.mockClear(); setOutputSpy.mockClear(); @@ -156,7 +78,7 @@ describe("Agent Mode", () => { }, }, } as any; - const result = await agentMode.prepare({ + const result = await prepareAgentMode({ context: contextWithCustomArgs, octokit: mockOctokit, githubToken: "test-token", @@ -186,7 +108,7 @@ describe("Agent Mode", () => { process.env.GITHUB_REF_NAME = originalRefName; }); - test("prepare method rejects bot actors without allowed_bots", async () => { + test("prepare rejects bot actors without allowed_bots", async () => { const contextWithPrompts = createMockAutomationContext({ eventName: "workflow_dispatch", }); @@ -206,7 +128,7 @@ describe("Agent Mode", () => { } as any; await expect( - agentMode.prepare({ + prepareAgentMode({ context: contextWithPrompts, octokit: mockOctokit, githubToken: "test-token", @@ -216,7 +138,7 @@ describe("Agent Mode", () => { ); }); - test("prepare method allows bot actors when in allowed_bots list", async () => { + test("prepare allows bot actors when in allowed_bots list", async () => { const contextWithPrompts = createMockAutomationContext({ eventName: "workflow_dispatch", }); @@ -237,7 +159,7 @@ describe("Agent Mode", () => { // Should not throw - bot is in allowed list await expect( - agentMode.prepare({ + prepareAgentMode({ context: contextWithPrompts, octokit: mockOctokit, githubToken: "test-token", @@ -245,7 +167,7 @@ describe("Agent Mode", () => { ).resolves.toBeDefined(); }); - test("prepare method creates prompt file with correct content", async () => { + test("prepare creates prompt file with correct content", async () => { const contextWithPrompts = createMockAutomationContext({ eventName: "workflow_dispatch", }); @@ -268,7 +190,7 @@ describe("Agent Mode", () => { }, }, } as any; - const result = await agentMode.prepare({ + const result = await prepareAgentMode({ context: contextWithPrompts, octokit: mockOctokit, githubToken: "test-token", diff --git a/test/modes/registry.test.ts b/test/modes/registry.test.ts deleted file mode 100644 index 7c585b27f..000000000 --- a/test/modes/registry.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import { getMode, isValidMode } from "../../src/modes/registry"; -import { agentMode } from "../../src/modes/agent"; -import { tagMode } from "../../src/modes/tag"; -import { - createMockContext, - createMockAutomationContext, - mockRepositoryDispatchContext, -} 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({ - eventName: "workflow_dispatch", - }); - - const mockScheduleContext = createMockAutomationContext({ - eventName: "schedule", - }); - - 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"); - }); - - 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 - - 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; - } - }); - - // 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); - }); - - test("isValidMode returns false for invalid mode", () => { - expect(isValidMode("invalid")).toBe(false); - expect(isValidMode("review")).toBe(false); - }); -}); diff --git a/test/modes/tag.test.ts b/test/modes/tag.test.ts index d592463f5..d68d7fc20 100644 --- a/test/modes/tag.test.ts +++ b/test/modes/tag.test.ts @@ -1,92 +1,8 @@ -import { describe, test, expect, beforeEach } from "bun:test"; -import { tagMode } from "../../src/modes/tag"; -import type { ParsedGitHubContext } from "../../src/github/context"; -import type { IssueCommentEvent } from "@octokit/webhooks-types"; -import { createMockContext } from "../mockContext"; +import { describe, test, expect } from "bun:test"; +import { prepareTagMode } from "../../src/modes/tag"; describe("Tag Mode", () => { - let mockContext: ParsedGitHubContext; - - beforeEach(() => { - mockContext = createMockContext({ - eventName: "issue_comment", - isPR: false, - }); - }); - - test("tag mode has correct properties", () => { - expect(tagMode.name).toBe("tag"); - expect(tagMode.description).toBe( - "Traditional implementation mode triggered by @claude mentions", - ); - expect(tagMode.shouldCreateTrackingComment()).toBe(true); - }); - - test("shouldTrigger delegates to checkContainsTrigger", () => { - const contextWithTrigger = createMockContext({ - eventName: "issue_comment", - isPR: false, - inputs: { - ...createMockContext().inputs, - triggerPhrase: "@claude", - }, - payload: { - comment: { - body: "Hey @claude, can you help?", - }, - } as IssueCommentEvent, - }); - - expect(tagMode.shouldTrigger(contextWithTrigger)).toBe(true); - - const contextWithoutTrigger = createMockContext({ - eventName: "issue_comment", - isPR: false, - inputs: { - ...createMockContext().inputs, - triggerPhrase: "@claude", - }, - payload: { - comment: { - body: "This is just a regular comment", - }, - } as IssueCommentEvent, - }); - - expect(tagMode.shouldTrigger(contextWithoutTrigger)).toBe(false); - }); - - test("prepareContext includes all required data", () => { - const data = { - commentId: 123, - baseBranch: "main", - claudeBranch: "claude/fix-bug", - }; - - const context = tagMode.prepareContext(mockContext, data); - - expect(context.mode).toBe("tag"); - expect(context.githubContext).toBe(mockContext); - expect(context.commentId).toBe(123); - expect(context.baseBranch).toBe("main"); - expect(context.claudeBranch).toBe("claude/fix-bug"); - }); - - test("prepareContext works without data", () => { - const context = tagMode.prepareContext(mockContext); - - expect(context.mode).toBe("tag"); - expect(context.githubContext).toBe(mockContext); - expect(context.commentId).toBeUndefined(); - expect(context.baseBranch).toBeUndefined(); - expect(context.claudeBranch).toBeUndefined(); - }); - - test("getAllowedTools returns empty array", () => { - expect(tagMode.getAllowedTools()).toEqual([]); - }); - - test("getDisallowedTools returns empty array", () => { - expect(tagMode.getDisallowedTools()).toEqual([]); + test("prepareTagMode is exported as a function", () => { + expect(typeof prepareTagMode).toBe("function"); }); }); diff --git a/test/pull-request-target.test.ts b/test/pull-request-target.test.ts index 7f4fd342d..59c747f28 100644 --- a/test/pull-request-target.test.ts +++ b/test/pull-request-target.test.ts @@ -1,38 +1,10 @@ #!/usr/bin/env bun import { describe, test, expect } from "bun:test"; -import { - getEventTypeAndContext, - generatePrompt, - generateDefaultPrompt, -} from "../src/create-prompt"; +import { getEventTypeAndContext, generatePrompt } from "../src/create-prompt"; import type { PreparedContext } from "../src/create-prompt"; -import type { Mode } from "../src/modes/types"; describe("pull_request_target event support", () => { - // Mock tag mode for testing - const mockTagMode: Mode = { - name: "tag", - description: "Tag mode", - shouldTrigger: () => true, - prepareContext: (context) => ({ mode: "tag", githubContext: context }), - getAllowedTools: () => [], - getDisallowedTools: () => [], - shouldCreateTrackingComment: () => true, - generatePrompt: (context, githubData, useCommitSigning) => - generateDefaultPrompt(context, githubData, useCommitSigning), - prepare: async () => ({ - commentId: 123, - branchInfo: { - baseBranch: "main", - currentBranch: "main", - claudeBranch: undefined, - }, - mcpConfig: "{}", - claudeArgs: "", - }), - }; - const mockGitHubData = { contextData: { title: "External PR via pull_request_target", @@ -126,12 +98,7 @@ describe("pull_request_target event support", () => { }, }; - const prompt = generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = generatePrompt(envVars, mockGitHubData, false, "tag"); // Should contain pull request event type and metadata expect(prompt).toContain("PULL_REQUEST"); @@ -165,12 +132,7 @@ describe("pull_request_target event support", () => { }, }; - const prompt = generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = generatePrompt(envVars, mockGitHubData, false, "tag"); // Should include git commands for non-commit-signing mode expect(prompt).toContain("git push"); @@ -196,7 +158,7 @@ describe("pull_request_target event support", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, true, mockTagMode); + const prompt = generatePrompt(envVars, mockGitHubData, true, "tag"); // Should include commit signing tools expect(prompt).toContain("mcp__github_file_ops__commit_files"); @@ -246,13 +208,13 @@ describe("pull_request_target event support", () => { pullRequestContext, mockGitHubData, false, - mockTagMode, + "tag", ); const pullRequestTargetPrompt = generatePrompt( pullRequestTargetContext, mockGitHubData, false, - mockTagMode, + "tag", ); // Both should have the same event type and structure @@ -293,37 +255,7 @@ describe("pull_request_target event support", () => { }, }; - // Use agent mode which passes through the prompt as-is - const mockAgentMode: Mode = { - name: "agent", - description: "Agent mode", - shouldTrigger: () => true, - prepareContext: (context) => ({ - mode: "agent", - githubContext: context, - }), - getAllowedTools: () => [], - getDisallowedTools: () => [], - shouldCreateTrackingComment: () => true, - generatePrompt: (context) => context.prompt || "default prompt", - prepare: async () => ({ - commentId: 123, - branchInfo: { - baseBranch: "main", - currentBranch: "main", - claudeBranch: undefined, - }, - mcpConfig: "{}", - claudeArgs: "", - }), - }; - - const prompt = generatePrompt( - envVars, - mockGitHubData, - false, - mockAgentMode, - ); + const prompt = generatePrompt(envVars, mockGitHubData, false, "agent"); expect(prompt).toBe( "Review this pull_request_target PR for security issues", @@ -343,12 +275,7 @@ describe("pull_request_target event support", () => { }, }; - const prompt = generatePrompt( - envVars, - mockGitHubData, - false, - mockTagMode, - ); + const prompt = generatePrompt(envVars, mockGitHubData, false, "tag"); // Should generate default prompt structure expect(prompt).toContain("PULL_REQUEST"); @@ -418,7 +345,7 @@ describe("pull_request_target event support", () => { // Should not throw when generating prompt expect(() => { - generatePrompt(minimalContext, mockGitHubData, false, mockTagMode); + generatePrompt(minimalContext, mockGitHubData, false, "tag"); }).not.toThrow(); }); @@ -476,13 +403,13 @@ describe("pull_request_target event support", () => { internalPR, mockGitHubData, false, - mockTagMode, + "tag", ); const externalPrompt = generatePrompt( externalPR, mockGitHubData, false, - mockTagMode, + "tag", ); // Should have same tool access patterns