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)
This commit is contained in:
km-anthropic
2025-08-05 21:21:41 -07:00
parent 188d526721
commit 9a665625f7
18 changed files with 506 additions and 185 deletions

View File

@@ -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 }}

View File

@@ -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=="],

View File

@@ -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"
},

View File

@@ -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<string> {
// 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,

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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",

77
src/modes/detector.ts Normal file
View File

@@ -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;
}
}

View File

@@ -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<ModeName, Mode>;
review: reviewMode,
} as const satisfies Record<AutoDetectedMode, Mode>;
/**
* 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);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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<string, any>;
description?: string;
}
export interface SlashCommand {
name: string;
metadata: SlashCommandMetadata;
content: string;
}
export interface ResolvedCommand {
expandedPrompt: string;
tools?: string[];
settings?: Record<string, any>;
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMMANDS_DIR = join(__dirname, "../../slash-commands");
export async function resolveSlashCommand(
prompt: string,
variables?: Record<string, string | undefined>,
): Promise<ResolvedCommand> {
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<SlashCommand | null> {
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<string[]> {
const commands: string[] = [];
async function scanDirectory(dir: string, prefix = ""): Promise<void> {
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 };
}

View File

@@ -141,7 +141,7 @@ describe("generatePrompt", () => {
imageUrlMap: new Map<string, string>(),
};
test("should generate prompt for issue_comment event", () => {
test("should generate prompt for issue_comment event", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -157,7 +157,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("<event_type>GENERAL_COMMENT</event_type>");
@@ -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("<event_type>PR_REVIEW</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -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("<event_type>ISSUE_CREATED</event_type>");
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("<event_type>ISSUE_ASSIGNED</event_type>");
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("<event_type>ISSUE_LABELED</event_type>");
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("<direct_prompt>");
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("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -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("<event_type>ISSUE_CREATED</event_type>");
});
test("should include trigger username when provided", () => {
test("should include trigger username when provided", async () => {
const envVars: PreparedContext = {
repository: "owner/repo",
claudeCommentId: "12345",
@@ -505,7 +505,7 @@ describe("generatePrompt", () => {
},
};
const prompt = generatePrompt(envVars, mockGitHubData, false, mockTagMode);
const prompt = await generatePrompt(envVars, mockGitHubData, false, mockTagMode);
expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
// With commit signing disabled, co-author info appears in git commit instructions
@@ -514,7 +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(

View File

@@ -25,6 +25,7 @@ describe("prepareMcpConfig", () => {
isPR: false,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",

View File

@@ -12,6 +12,7 @@ import type {
const defaultInputs = {
mode: "tag" as const,
prompt: "",
triggerPhrase: "/claude",
assigneeTrigger: "",
labelTrigger: "",

View File

@@ -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.",
);
});

View File

@@ -61,6 +61,7 @@ describe("checkWritePermissions", () => {
isPR: false,
inputs: {
mode: "tag",
prompt: "",
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",

View File

@@ -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: "",