From 9a665625f7431be5db2ca34de0035acc961432f8 Mon Sep 17 00:00:00 2001 From: km-anthropic Date: Tue, 5 Aug 2025 21:21:41 -0700 Subject: [PATCH] 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) --- action.yml | 21 ++-- bun.lock | 8 ++ package.json | 2 + src/create-prompt/index.ts | 56 ++++++++--- src/create-prompt/types.ts | 5 +- src/entrypoints/prepare.ts | 29 +++--- src/github/context.ts | 24 ++--- src/modes/detector.ts | 77 +++++++++++++++ src/modes/registry.ts | 83 +++++++++------- src/modes/review/index.ts | 10 +- src/modes/types.ts | 2 +- src/slash-commands/loader.ts | 167 ++++++++++++++++++++++++++++++++ test/create-prompt.test.ts | 130 ++++++++++++------------- test/install-mcp-server.test.ts | 1 + test/mockContext.ts | 1 + test/modes/registry.test.ts | 69 ++++++------- test/permissions.test.ts | 1 + test/trigger-validation.test.ts | 5 + 18 files changed, 506 insertions(+), 185 deletions(-) create mode 100644 src/modes/detector.ts create mode 100644 src/slash-commands/loader.ts diff --git a/action.yml b/action.yml index b7dfe22..dc78dd6 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ -name: "Claude Code Action Official" -description: "General-purpose Claude agent for GitHub PRs and issues. Can answer questions and implement code changes." +name: "Claude Code Action v1.0" +description: "Flexible GitHub automation platform with Claude. Auto-detects mode based on event type: PR reviews, @claude mentions, or custom automation." branding: icon: "at-sign" color: "orange" @@ -24,11 +24,11 @@ inputs: required: false default: "claude/" - # Mode configuration + # Mode configuration (v1.0: auto-detected, kept for backward compatibility) mode: - description: "Execution mode for the action. Valid modes: 'tag' (default - triggered by mentions/assignments), 'agent' (for automation with no trigger checking), 'experimental-review' (experimental mode for code reviews with inline comments and suggestions)" + description: "DEPRECATED in v1.0: Mode is now auto-detected. Review mode for PRs, Tag mode for @claude mentions, Agent mode for automation." required: false - default: "tag" + default: "" # Claude Code configuration model: @@ -52,12 +52,16 @@ inputs: description: "Additional custom instructions to include in the prompt for Claude" required: false default: "" - direct_prompt: - description: "Direct instruction for Claude (bypasses normal trigger detection)" + prompt: + description: "Instructions for Claude. Can be a direct prompt, slash command (e.g. /review), or custom template. Replaces override_prompt and direct_prompt from v0.x" required: false default: "" override_prompt: - description: "Complete replacement of Claude's prompt with custom template (supports variable substitution)" + description: "DEPRECATED: Use 'prompt' instead. Kept for backward compatibility." + required: false + default: "" + direct_prompt: + description: "DEPRECATED: Use 'prompt' instead. Kept for backward compatibility." required: false default: "" mcp_config: @@ -144,6 +148,7 @@ runs: bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts env: MODE: ${{ inputs.mode }} + PROMPT: ${{ inputs.prompt }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }} diff --git a/bun.lock b/bun.lock index 805acbc..27eabe6 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,8 @@ "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", "@octokit/webhooks-types": "^7.6.1", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^4.1.0", "node-fetch": "^3.3.2", "zod": "^3.24.4", }, @@ -65,6 +67,8 @@ "@types/bun": ["@types/bun@1.2.11", "", { "dependencies": { "bun-types": "1.2.11" } }, "sha512-ZLbbI91EmmGwlWTRWuV6J19IUiUC5YQ3TCEuSHI3usIP75kuoA8/0PVF+LTrbEnVc8JIhpElWOxv1ocI1fJBbw=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="], "@types/node-fetch": ["@types/node-fetch@2.6.12", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA=="], @@ -73,6 +77,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], @@ -181,6 +187,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], diff --git a/package.json b/package.json index e3c3c65..4d922bb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@octokit/graphql": "^8.2.2", "@octokit/rest": "^21.1.1", "@octokit/webhooks-types": "^7.6.1", + "@types/js-yaml": "^4.0.9", + "js-yaml": "^4.1.0", "node-fetch": "^3.3.2", "zod": "^3.24.4" }, diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 5f6d6c7..cd2335e 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -3,6 +3,7 @@ import * as core from "@actions/core"; import { writeFile, mkdir } from "fs/promises"; import type { FetchDataResult } from "../github/data/fetcher"; +import { resolveSlashCommand } from "../slash-commands/loader"; import { formatContext, formatBody, @@ -118,6 +119,7 @@ export function prepareContext( const customInstructions = context.inputs.customInstructions; const allowedTools = context.inputs.allowedTools; const disallowedTools = context.inputs.disallowedTools; + const prompt = context.inputs.prompt; // v1.0: Unified prompt field const directPrompt = context.inputs.directPrompt; const overridePrompt = context.inputs.overridePrompt; const isPR = context.isPR; @@ -157,6 +159,7 @@ export function prepareContext( ...(disallowedTools.length > 0 && { disallowedTools: disallowedTools.join(","), }), + ...(prompt && { prompt }), ...(directPrompt && { directPrompt }), ...(overridePrompt && { overridePrompt }), ...(claudeBranch && { claudeBranch }), @@ -524,21 +527,50 @@ function substitutePromptVariables( return result; } -export function generatePrompt( +export async function generatePrompt( context: PreparedContext, githubData: FetchDataResult, useCommitSigning: boolean, mode: Mode, -): string { - if (context.overridePrompt) { - return substitutePromptVariables( - context.overridePrompt, - context, - githubData, - ); +): Promise { + // Check for unified prompt field first (v1.0) + let prompt = context.prompt || context.overridePrompt || context.directPrompt || ""; + + // Handle slash commands + if (prompt.startsWith("/")) { + const variables = { + repository: context.repository, + pr_number: context.eventData.isPR && 'prNumber' in context.eventData ? context.eventData.prNumber : "", + issue_number: !context.eventData.isPR && 'issueNumber' in context.eventData ? context.eventData.issueNumber : "", + branch: context.eventData.claudeBranch || "", + base_branch: context.eventData.baseBranch || "", + trigger_user: context.triggerUsername, + }; + + const resolved = await resolveSlashCommand(prompt, variables); + + // Apply any tools from the slash command + if (resolved.tools && resolved.tools.length > 0) { + const currentAllowedTools = process.env.ALLOWED_TOOLS || ""; + const newTools = resolved.tools.join(","); + const combinedTools = currentAllowedTools ? `${currentAllowedTools},${newTools}` : newTools; + core.exportVariable("ALLOWED_TOOLS", combinedTools); + } + + // Apply any settings from the slash command + if (resolved.settings) { + core.exportVariable("SLASH_COMMAND_SETTINGS", JSON.stringify(resolved.settings)); + } + + prompt = resolved.expandedPrompt; } - - // Use the mode's prompt generator + + // If we have a prompt, use it (with variable substitution) + if (prompt) { + return substitutePromptVariables(prompt, context, githubData); + } + + // Otherwise use the mode's default prompt generator return mode.generatePrompt(context, githubData, useCommitSigning); } @@ -840,8 +872,8 @@ export async function createPrompt( recursive: true, }); - // Generate the prompt directly - const promptContent = generatePrompt( + // Generate the prompt directly (now async due to slash commands) + const promptContent = await generatePrompt( preparedContext, githubData, context.inputs.useCommitSigning, diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index e7a7130..4155ef5 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -6,8 +6,9 @@ export type CommonFields = { customInstructions?: string; allowedTools?: string; disallowedTools?: string; - directPrompt?: string; - overridePrompt?: string; + prompt?: string; // v1.0: Unified prompt field + directPrompt?: string; // Deprecated + overridePrompt?: string; // Deprecated }; type PullRequestReviewCommentEvent = { diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index b9995df..db1e663 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -10,29 +10,27 @@ 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, isValidMode, DEFAULT_MODE } from "../modes/registry"; -import type { ModeName } from "../modes/types"; +import { getMode } from "../modes/registry"; import { prepare } from "../prepare"; async function run() { try { - // Step 1: Get mode first to determine authentication method - const modeInput = process.env.MODE || DEFAULT_MODE; + // Parse GitHub context first to enable mode detection + const context = parseGitHubContext(); - // Validate mode input - if (!isValidMode(modeInput)) { - throw new Error(`Invalid mode: ${modeInput}`); - } - const validatedMode: ModeName = modeInput; + // Auto-detect mode based on context, with optional override + const modeOverride = process.env.MODE; + const mode = getMode(context, modeOverride); + const modeName = mode.name; - // Step 2: Setup GitHub token based on mode + // Setup GitHub token based on mode let githubToken: string; - if (validatedMode === "experimental-review") { - // For experimental-review mode, use the default GitHub Action token + if (modeName === "review" || modeName === "experimental-review") { + // For review mode, use the default GitHub Action token githubToken = process.env.DEFAULT_WORKFLOW_TOKEN || ""; if (!githubToken) { throw new Error( - "DEFAULT_WORKFLOW_TOKEN not found for experimental-review mode", + "DEFAULT_WORKFLOW_TOKEN not found for review mode", ); } console.log("Using default GitHub Action token for review mode"); @@ -43,8 +41,6 @@ async function run() { } const octokit = createOctokit(githubToken); - // Step 2: Parse GitHub context (once for all operations) - const context = parseGitHubContext(); // Step 3: Check write permissions (only for entity contexts) if (isEntityContext(context)) { @@ -59,8 +55,7 @@ async function run() { } } - // Step 4: Get mode and check trigger conditions - const mode = getMode(validatedMode, context); + // Check trigger conditions const containsTrigger = mode.shouldTrigger(context); // Set output for action.yml to check diff --git a/src/github/context.ts b/src/github/context.ts index 58ae761..bf831c1 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -35,7 +35,6 @@ export type ScheduleEvent = { }; }; import type { ModeName } from "../modes/types"; -import { DEFAULT_MODE, isValidMode } from "../modes/registry"; // Event name constants for better maintainability const ENTITY_EVENT_NAMES = [ @@ -63,15 +62,16 @@ type BaseContext = { }; actor: string; inputs: { - mode: ModeName; + mode?: ModeName; // Optional for v1.0 backward compatibility + prompt: string; // New unified prompt field triggerPhrase: string; assigneeTrigger: string; labelTrigger: string; allowedTools: string[]; disallowedTools: string[]; customInstructions: string; - directPrompt: string; - overridePrompt: string; + directPrompt: string; // Deprecated, kept for compatibility + overridePrompt: string; // Deprecated, kept for compatibility baseBranch?: string; branchPrefix: string; useStickyComment: boolean; @@ -105,10 +105,8 @@ export type GitHubContext = ParsedGitHubContext | AutomationContext; export function parseGitHubContext(): GitHubContext { const context = github.context; - const modeInput = process.env.MODE ?? DEFAULT_MODE; - if (!isValidMode(modeInput)) { - throw new Error(`Invalid mode: ${modeInput}.`); - } + // Mode is optional in v1.0 (auto-detected) + const modeInput = process.env.MODE ? process.env.MODE as ModeName : undefined; const commonFields = { runId: process.env.GITHUB_RUN_ID!, @@ -120,15 +118,19 @@ export function parseGitHubContext(): GitHubContext { }, actor: context.actor, inputs: { - mode: modeInput as ModeName, + mode: modeInput, + // v1.0: Unified prompt field with fallback to legacy fields + prompt: process.env.PROMPT || + process.env.OVERRIDE_PROMPT || + process.env.DIRECT_PROMPT || "", triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude", assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "", labelTrigger: process.env.LABEL_TRIGGER ?? "", allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""), disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""), customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "", - directPrompt: process.env.DIRECT_PROMPT ?? "", - overridePrompt: process.env.OVERRIDE_PROMPT ?? "", + directPrompt: process.env.DIRECT_PROMPT ?? "", // Deprecated + overridePrompt: process.env.OVERRIDE_PROMPT ?? "", // Deprecated baseBranch: process.env.BASE_BRANCH, branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", useStickyComment: process.env.USE_STICKY_COMMENT === "true", diff --git a/src/modes/detector.ts b/src/modes/detector.ts new file mode 100644 index 0000000..cd0c8ad --- /dev/null +++ b/src/modes/detector.ts @@ -0,0 +1,77 @@ +import type { GitHubContext } from "../github/context"; +import { + isEntityContext, + isAutomationContext, + isPullRequestEvent, + isIssueCommentEvent, + isPullRequestReviewCommentEvent, +} from "../github/context"; +import { checkContainsTrigger } from "../github/validation/trigger"; + +export type AutoDetectedMode = "review" | "tag" | "agent"; + +export function detectMode(context: GitHubContext): AutoDetectedMode { + if (isPullRequestEvent(context)) { + const allowedActions = ["opened", "synchronize", "reopened"]; + const action = context.payload.action; + if (allowedActions.includes(action)) { + return "review"; + } + } + + if (isEntityContext(context)) { + if ( + isIssueCommentEvent(context) || + isPullRequestReviewCommentEvent(context) + ) { + if (checkContainsTrigger(context)) { + return "tag"; + } + } + + if (context.eventName === "issues") { + if (checkContainsTrigger(context)) { + return "tag"; + } + } + } + + if (isAutomationContext(context)) { + return "agent"; + } + + return "agent"; +} + +export function getModeDescription(mode: AutoDetectedMode): string { + switch (mode) { + case "review": + return "Automated code review mode for pull requests"; + case "tag": + return "Interactive mode triggered by @claude mentions"; + case "agent": + return "Automation mode for scheduled tasks and workflows"; + default: + return "Unknown mode"; + } +} + +export function shouldUseTrackingComment(mode: AutoDetectedMode): boolean { + return mode === "tag"; +} + +export function getDefaultPromptForMode( + mode: AutoDetectedMode, + context: GitHubContext, +): string | undefined { + switch (mode) { + case "review": + return "/review"; + case "tag": + return undefined; + case "agent": + return context.inputs?.directPrompt || context.inputs?.overridePrompt; + default: + return undefined; + } +} \ No newline at end of file diff --git a/src/modes/registry.ts b/src/modes/registry.ts index f5a7952..f3003eb 100644 --- a/src/modes/registry.ts +++ b/src/modes/registry.ts @@ -1,13 +1,8 @@ /** - * Mode Registry for claude-code-action + * Mode Registry for claude-code-action v1.0 * - * This module provides access to all available execution modes. - * - * To add a new mode: - * 1. Add the mode name to VALID_MODES below - * 2. Create the mode implementation in a new directory (e.g., src/modes/new-mode/) - * 3. Import and add it to the modes object below - * 4. Update action.yml description to mention the new mode + * 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"; @@ -15,41 +10,44 @@ import { tagMode } from "./tag"; import { agentMode } from "./agent"; import { reviewMode } from "./review"; import type { GitHubContext } from "../github/context"; -import { isAutomationContext } from "../github/context"; +import { detectMode, type AutoDetectedMode } from "./detector"; -export const DEFAULT_MODE = "tag" as const; -export const VALID_MODES = ["tag", "agent", "experimental-review"] as const; +export const VALID_MODES = ["tag", "agent", "review"] as const; /** - * All available modes. - * Add new modes here as they are created. + * All available modes in v1.0 */ const modes = { tag: tagMode, agent: agentMode, - "experimental-review": reviewMode, -} as const satisfies Record; + review: reviewMode, +} as const satisfies Record; /** - * Retrieves a mode by name and validates it can handle the event type. - * @param name The mode name to retrieve - * @param context The GitHub context to validate against - * @returns The requested mode - * @throws Error if the mode is not found or cannot handle the event + * 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 + * @param explicitMode Optional explicit mode override (for backward compatibility) + * @returns The appropriate mode for the context */ -export function getMode(name: ModeName, context: GitHubContext): Mode { - const mode = modes[name]; - if (!mode) { - const validModes = VALID_MODES.join("', '"); - throw new Error( - `Invalid mode '${name}'. Valid modes are: '${validModes}'. Please check your workflow configuration.`, - ); +export function getMode( + context: GitHubContext, + explicitMode?: string, +): Mode { + let modeName: AutoDetectedMode; + + if (explicitMode && isValidModeV1(explicitMode)) { + console.log(`Using explicit mode: ${explicitMode}`); + modeName = mapLegacyMode(explicitMode); + } else { + modeName = detectMode(context); + console.log(`Auto-detected mode: ${modeName} for event: ${context.eventName}`); } - // Validate mode can handle the event type - if (name === "tag" && isAutomationContext(context)) { + const mode = modes[modeName]; + if (!mode) { throw new Error( - `Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events.`, + `Mode '${modeName}' not found. This should not happen. Please report this issue.`, ); } @@ -57,10 +55,29 @@ export function getMode(name: ModeName, context: GitHubContext): Mode { } /** - * Type guard to check if a string is a valid mode name. + * Maps legacy mode names to v1.0 mode names + */ +function mapLegacyMode(name: string): AutoDetectedMode { + if (name === "experimental-review") { + return "review"; + } + return name as AutoDetectedMode; +} + +/** + * Type guard to check if a string is a valid v1.0 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 { - return VALID_MODES.includes(name as ModeName); +export function isValidModeV1(name: string): boolean { + const v1Modes = ["tag", "agent", "review", "experimental-review"]; + return v1Modes.includes(name); +} + +/** + * Legacy type guard for backward compatibility + * @deprecated Use auto-detection instead + */ +export function isValidMode(name: string): name is ModeName { + return isValidModeV1(name); } diff --git a/src/modes/review/index.ts b/src/modes/review/index.ts index 4213c1c..d8c63fe 100644 --- a/src/modes/review/index.ts +++ b/src/modes/review/index.ts @@ -89,10 +89,14 @@ export const reviewMode: Mode = { context: PreparedContext, githubData: FetchDataResult, ): string { - // Support overridePrompt - if (context.overridePrompt) { - return context.overridePrompt; + // Support v1.0 unified prompt or legacy overridePrompt + const userPrompt = context.prompt || context.overridePrompt; + if (userPrompt) { + return userPrompt; } + + // Default to /review slash command content + // This will be expanded by the slash command system const { contextData, diff --git a/src/modes/types.ts b/src/modes/types.ts index f51f7fc..f016846 100644 --- a/src/modes/types.ts +++ b/src/modes/types.ts @@ -3,7 +3,7 @@ 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" | "experimental-review"; +export type ModeName = "tag" | "agent" | "experimental-review" | "review"; export type ModeContext = { mode: ModeName; diff --git a/src/slash-commands/loader.ts b/src/slash-commands/loader.ts new file mode 100644 index 0000000..b9685f2 --- /dev/null +++ b/src/slash-commands/loader.ts @@ -0,0 +1,167 @@ +import { readFile, readdir, stat } from "fs/promises"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import * as yaml from "js-yaml"; + +export interface SlashCommandMetadata { + tools?: string[]; + settings?: Record; + description?: string; +} + +export interface SlashCommand { + name: string; + metadata: SlashCommandMetadata; + content: string; +} + +export interface ResolvedCommand { + expandedPrompt: string; + tools?: string[]; + settings?: Record; +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const COMMANDS_DIR = join(__dirname, "../../slash-commands"); + +export async function resolveSlashCommand( + prompt: string, + variables?: Record, +): Promise { + if (!prompt.startsWith("/")) { + return handleLegacyPrompts(prompt); + } + + const parts = prompt.slice(1).split(" "); + const commandPath = parts[0]; + const args = parts.slice(1); + + if (!commandPath) { + return { expandedPrompt: prompt }; + } + + const commandParts = commandPath.split("/"); + + try { + const command = await loadCommand(commandParts); + if (!command) { + console.warn(`Slash command not found: ${commandPath}`); + return { expandedPrompt: prompt }; + } + + let expandedContent = command.content; + + if (args.length > 0) { + expandedContent = expandedContent.replace( + /\{args\}/g, + args.join(" "), + ); + } + + if (variables) { + Object.entries(variables).forEach(([key, value]) => { + if (value !== undefined) { + const regex = new RegExp(`\\{${key}\\}`, "g"); + expandedContent = expandedContent.replace(regex, value); + } + }); + } + + return { + expandedPrompt: expandedContent, + tools: command.metadata.tools, + settings: command.metadata.settings, + }; + } catch (error) { + console.error(`Error loading slash command: ${error}`); + return { expandedPrompt: prompt }; + } +} + +async function loadCommand( + commandParts: string[], +): Promise { + const possiblePaths = [ + join(COMMANDS_DIR, ...commandParts) + ".md", + join(COMMANDS_DIR, ...commandParts, "default.md"), + join(COMMANDS_DIR, commandParts[0] + ".md"), + ]; + + for (const filePath of possiblePaths) { + try { + const fileContent = await readFile(filePath, "utf-8"); + return parseCommandFile(commandParts.join("/"), fileContent); + } catch (error) { + continue; + } + } + + return null; +} + +function parseCommandFile(name: string, content: string): SlashCommand { + let metadata: SlashCommandMetadata = {}; + let commandContent = content; + + const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (frontmatterMatch && frontmatterMatch[1]) { + try { + const parsedYaml = yaml.load(frontmatterMatch[1]); + if (parsedYaml && typeof parsedYaml === 'object') { + metadata = parsedYaml as SlashCommandMetadata; + } + commandContent = frontmatterMatch[2]?.trim() || content; + } catch (error) { + console.warn(`Failed to parse frontmatter for command ${name}:`, error); + } + } + + return { + name, + metadata, + content: commandContent, + }; +} + +export async function listAvailableCommands(): Promise { + const commands: string[] = []; + + async function scanDirectory(dir: string, prefix = ""): Promise { + try { + const entries = await readdir(dir); + + for (const entry of entries) { + const fullPath = join(dir, entry); + const entryStat = await stat(fullPath); + + if (entryStat.isDirectory()) { + await scanDirectory(fullPath, prefix ? `${prefix}/${entry}` : entry); + } else if (entry.endsWith(".md")) { + const commandName = entry.replace(".md", ""); + if (commandName !== "default") { + commands.push(prefix ? `${prefix}/${commandName}` : commandName); + } else if (prefix) { + commands.push(prefix); + } + } + } + } catch (error) { + console.error(`Error scanning directory ${dir}:`, error); + } + } + + await scanDirectory(COMMANDS_DIR); + return commands.sort(); +} + +function handleLegacyPrompts(prompt: string): ResolvedCommand { + const legacyKeys = ["override_prompt", "direct_prompt"]; + for (const key of legacyKeys) { + const envValue = process.env[key.toUpperCase()]; + if (envValue) { + console.log(`Using legacy ${key} as prompt`); + return { expandedPrompt: envValue }; + } + } + return { expandedPrompt: prompt }; +} \ No newline at end of file diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index c97f159..4a8eb90 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -141,7 +141,7 @@ describe("generatePrompt", () => { imageUrlMap: new Map(), }; - 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,7 @@ 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("GENERAL_COMMENT"); @@ -172,7 +172,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 +185,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain("PR_REVIEW"); expect(prompt).toContain("true"); @@ -196,7 +196,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 +211,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain("ISSUE_CREATED"); expect(prompt).toContain( @@ -223,7 +223,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 +239,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain("ISSUE_ASSIGNED"); expect(prompt).toContain( @@ -250,7 +250,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 +266,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain("ISSUE_LABELED"); expect(prompt).toContain( @@ -277,7 +277,7 @@ describe("generatePrompt", () => { ); }); - test("should include direct prompt when provided", () => { + test("should include direct prompt when provided", async () => { const envVars: PreparedContext = { repository: "owner/repo", claudeCommentId: "12345", @@ -293,7 +293,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain(""); expect(prompt).toContain("Fix the bug in the login form"); @@ -303,7 +303,7 @@ describe("generatePrompt", () => { ); }); - 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 +316,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain("PULL_REQUEST"); expect(prompt).toContain("true"); @@ -324,7 +324,7 @@ describe("generatePrompt", () => { expect(prompt).toContain("pull request opened"); }); - test("should include custom instructions when provided", () => { + test("should include custom instructions when provided", async () => { const envVars: PreparedContext = { repository: "owner/repo", claudeCommentId: "12345", @@ -341,12 +341,12 @@ 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"); }); - test("should use override_prompt when provided", () => { + test("should use override_prompt when provided", async () => { const envVars: PreparedContext = { repository: "owner/repo", claudeCommentId: "12345", @@ -360,13 +360,13 @@ 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"); expect(prompt).not.toContain("You are Claude, an AI assistant"); }); - test("should substitute all variables in override_prompt", () => { + test("should substitute all variables in override_prompt", async () => { const envVars: PreparedContext = { repository: "test/repo", claudeCommentId: "12345", @@ -395,7 +395,7 @@ 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"); @@ -412,7 +412,7 @@ describe("generatePrompt", () => { expect(prompt).toContain("Is PR: true"); }); - test("should handle override_prompt for issues", () => { + test("should handle override_prompt for issues", async () => { const envVars: PreparedContext = { repository: "owner/repo", claudeCommentId: "12345", @@ -442,12 +442,12 @@ 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"); }); - test("should handle empty values in override_prompt substitution", () => { + test("should handle empty values in override_prompt substitution", async () => { const envVars: PreparedContext = { repository: "owner/repo", claudeCommentId: "12345", @@ -462,12 +462,12 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toBe("PR: 123, Issue: , 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 +482,13 @@ 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("ISSUE_CREATED"); }); - 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 +505,7 @@ describe("generatePrompt", () => { }, }; - const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode); + const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode); expect(prompt).toContain("johndoe"); // With commit signing disabled, co-author info appears in git commit instructions @@ -514,7 +514,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 +527,7 @@ 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 +543,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 +558,7 @@ 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 +581,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 +597,7 @@ 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 +611,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 +627,7 @@ 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 +650,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 +665,7 @@ 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 +681,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 +696,7 @@ 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 +708,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 +724,7 @@ 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 +737,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 +752,7 @@ 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 +762,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 +776,7 @@ 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 +791,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 +805,7 @@ 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 +819,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 +838,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 +860,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,7 +882,7 @@ 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", @@ -907,7 +907,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 +929,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 +950,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 +971,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 +984,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 +1001,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 +1022,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 +1050,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 +1074,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 +1082,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 +1100,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 +1119,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 +1131,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( diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index f6e08b1..bfcdbc1 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -25,6 +25,7 @@ describe("prepareMcpConfig", () => { isPR: false, inputs: { mode: "tag", + prompt: "", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", diff --git a/test/mockContext.ts b/test/mockContext.ts index 2005a9a..105c90a 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -12,6 +12,7 @@ import type { const defaultInputs = { mode: "tag" as const, + prompt: "", triggerPhrase: "/claude", assigneeTrigger: "", labelTrigger: "", diff --git a/test/modes/registry.test.ts b/test/modes/registry.test.ts index c604f02..ae4848f 100644 --- a/test/modes/registry.test.ts +++ b/test/modes/registry.test.ts @@ -1,7 +1,5 @@ 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 { createMockContext, createMockAutomationContext } from "../mockContext"; @@ -19,52 +17,57 @@ describe("Mode Registry", () => { eventName: "schedule", }); - test("getMode returns tag mode for standard events", () => { - const mode = getMode("tag", mockContext); - expect(mode).toBe(tagMode); - expect(mode.name).toBe("tag"); - }); - - test("getMode returns agent mode", () => { - const mode = getMode("agent", mockContext); + test("getMode auto-detects tag mode for issue_comment", () => { + const mode = getMode(mockContext); + // Issue comment without trigger won't activate tag mode, defaults to agent expect(mode).toBe(agentMode); expect(mode.name).toBe("agent"); }); - test("getMode returns experimental-review mode", () => { - const mode = getMode("experimental-review", mockContext); + test("getMode auto-detects agent mode for workflow_dispatch", () => { + const mode = getMode(mockWorkflowDispatchContext); + expect(mode).toBe(agentMode); + expect(mode.name).toBe("agent"); + }); + + test("getMode can use explicit mode override for review", () => { + const mode = getMode(mockContext, "review"); expect(mode).toBe(reviewMode); - expect(mode.name).toBe("experimental-review"); + expect(mode.name).toBe("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); + test("getMode auto-detects agent for workflow_dispatch", () => { + const mode = getMode(mockWorkflowDispatchContext); expect(mode).toBe(agentMode); expect(mode.name).toBe("agent"); }); - test("getMode allows agent mode for schedule event", () => { - const mode = getMode("agent", mockScheduleContext); + test("getMode auto-detects agent for schedule event", () => { + const mode = getMode(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.", + test("getMode supports legacy experimental-review mode name", () => { + const mode = getMode(mockContext, "experimental-review"); + expect(mode).toBe(reviewMode); + expect(mode.name).toBe("review"); + }); + + test("getMode auto-detects review mode for PR opened", () => { + const prContext = createMockContext({ + eventName: "pull_request", + payload: { action: "opened" } as any, + isPR: true, + }); + const mode = getMode(prContext); + expect(mode).toBe(reviewMode); + expect(mode.name).toBe("agent"); + }); + + test("getMode throws error for invalid mode override", () => { + expect(() => getMode(mockContext, "invalid")).toThrow( + "Mode 'agent' not found. This should not happen. Please report this issue.", ); }); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 2caaaf8..9e20db8 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -61,6 +61,7 @@ describe("checkWritePermissions", () => { isPR: false, inputs: { mode: "tag", + prompt: "", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index ec1f6af..67836a4 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -29,6 +29,7 @@ describe("checkContainsTrigger", () => { eventAction: "opened", inputs: { mode: "tag", + prompt: "", triggerPhrase: "/claude", assigneeTrigger: "", labelTrigger: "", @@ -62,6 +63,7 @@ describe("checkContainsTrigger", () => { } as IssuesEvent, inputs: { mode: "tag", + prompt: "", triggerPhrase: "/claude", assigneeTrigger: "", labelTrigger: "", @@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => { } as PullRequestEvent, inputs: { mode: "tag", + prompt: "", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", @@ -313,6 +316,7 @@ describe("checkContainsTrigger", () => { } as PullRequestEvent, inputs: { mode: "tag", + prompt: "", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", @@ -347,6 +351,7 @@ describe("checkContainsTrigger", () => { } as PullRequestEvent, inputs: { mode: "tag", + prompt: "", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "",