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" name: "Claude Code Action v1.0"
description: "General-purpose Claude agent for GitHub PRs and issues. Can answer questions and implement code changes." description: "Flexible GitHub automation platform with Claude. Auto-detects mode based on event type: PR reviews, @claude mentions, or custom automation."
branding: branding:
icon: "at-sign" icon: "at-sign"
color: "orange" color: "orange"
@@ -24,11 +24,11 @@ inputs:
required: false required: false
default: "claude/" default: "claude/"
# Mode configuration # Mode configuration (v1.0: auto-detected, kept for backward compatibility)
mode: 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 required: false
default: "tag" default: ""
# Claude Code configuration # Claude Code configuration
model: model:
@@ -52,12 +52,16 @@ inputs:
description: "Additional custom instructions to include in the prompt for Claude" description: "Additional custom instructions to include in the prompt for Claude"
required: false required: false
default: "" default: ""
direct_prompt: prompt:
description: "Direct instruction for Claude (bypasses normal trigger detection)" 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 required: false
default: "" default: ""
override_prompt: 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 required: false
default: "" default: ""
mcp_config: mcp_config:
@@ -144,6 +148,7 @@ runs:
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
env: env:
MODE: ${{ inputs.mode }} MODE: ${{ inputs.mode }}
PROMPT: ${{ inputs.prompt }}
TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
LABEL_TRIGGER: ${{ inputs.label_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }}

View File

@@ -10,6 +10,8 @@
"@octokit/graphql": "^8.2.2", "@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/webhooks-types": "^7.6.1", "@octokit/webhooks-types": "^7.6.1",
"@types/js-yaml": "^4.0.9",
"js-yaml": "^4.1.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"zod": "^3.24.4", "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/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": ["@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=="], "@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=="], "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=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], "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=="], "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=="], "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=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],

View File

@@ -16,6 +16,8 @@
"@octokit/graphql": "^8.2.2", "@octokit/graphql": "^8.2.2",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/webhooks-types": "^7.6.1", "@octokit/webhooks-types": "^7.6.1",
"@types/js-yaml": "^4.0.9",
"js-yaml": "^4.1.0",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"zod": "^3.24.4" "zod": "^3.24.4"
}, },

View File

@@ -3,6 +3,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { writeFile, mkdir } from "fs/promises"; import { writeFile, mkdir } from "fs/promises";
import type { FetchDataResult } from "../github/data/fetcher"; import type { FetchDataResult } from "../github/data/fetcher";
import { resolveSlashCommand } from "../slash-commands/loader";
import { import {
formatContext, formatContext,
formatBody, formatBody,
@@ -118,6 +119,7 @@ export function prepareContext(
const customInstructions = context.inputs.customInstructions; const customInstructions = context.inputs.customInstructions;
const allowedTools = context.inputs.allowedTools; const allowedTools = context.inputs.allowedTools;
const disallowedTools = context.inputs.disallowedTools; const disallowedTools = context.inputs.disallowedTools;
const prompt = context.inputs.prompt; // v1.0: Unified prompt field
const directPrompt = context.inputs.directPrompt; const directPrompt = context.inputs.directPrompt;
const overridePrompt = context.inputs.overridePrompt; const overridePrompt = context.inputs.overridePrompt;
const isPR = context.isPR; const isPR = context.isPR;
@@ -157,6 +159,7 @@ export function prepareContext(
...(disallowedTools.length > 0 && { ...(disallowedTools.length > 0 && {
disallowedTools: disallowedTools.join(","), disallowedTools: disallowedTools.join(","),
}), }),
...(prompt && { prompt }),
...(directPrompt && { directPrompt }), ...(directPrompt && { directPrompt }),
...(overridePrompt && { overridePrompt }), ...(overridePrompt && { overridePrompt }),
...(claudeBranch && { claudeBranch }), ...(claudeBranch && { claudeBranch }),
@@ -524,21 +527,50 @@ function substitutePromptVariables(
return result; return result;
} }
export function generatePrompt( export async function generatePrompt(
context: PreparedContext, context: PreparedContext,
githubData: FetchDataResult, githubData: FetchDataResult,
useCommitSigning: boolean, useCommitSigning: boolean,
mode: Mode, mode: Mode,
): string { ): Promise<string> {
if (context.overridePrompt) { // Check for unified prompt field first (v1.0)
return substitutePromptVariables( let prompt = context.prompt || context.overridePrompt || context.directPrompt || "";
context.overridePrompt,
context, // Handle slash commands
githubData, 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); return mode.generatePrompt(context, githubData, useCommitSigning);
} }
@@ -840,8 +872,8 @@ export async function createPrompt(
recursive: true, recursive: true,
}); });
// Generate the prompt directly // Generate the prompt directly (now async due to slash commands)
const promptContent = generatePrompt( const promptContent = await generatePrompt(
preparedContext, preparedContext,
githubData, githubData,
context.inputs.useCommitSigning, context.inputs.useCommitSigning,

View File

@@ -6,8 +6,9 @@ export type CommonFields = {
customInstructions?: string; customInstructions?: string;
allowedTools?: string; allowedTools?: string;
disallowedTools?: string; disallowedTools?: string;
directPrompt?: string; prompt?: string; // v1.0: Unified prompt field
overridePrompt?: string; directPrompt?: string; // Deprecated
overridePrompt?: string; // Deprecated
}; };
type PullRequestReviewCommentEvent = { type PullRequestReviewCommentEvent = {

View File

@@ -10,29 +10,27 @@ import { setupGitHubToken } from "../github/token";
import { checkWritePermissions } from "../github/validation/permissions"; import { checkWritePermissions } from "../github/validation/permissions";
import { createOctokit } from "../github/api/client"; import { createOctokit } from "../github/api/client";
import { parseGitHubContext, isEntityContext } from "../github/context"; import { parseGitHubContext, isEntityContext } from "../github/context";
import { getMode, isValidMode, DEFAULT_MODE } from "../modes/registry"; import { getMode } from "../modes/registry";
import type { ModeName } from "../modes/types";
import { prepare } from "../prepare"; import { prepare } from "../prepare";
async function run() { async function run() {
try { try {
// Step 1: Get mode first to determine authentication method // Parse GitHub context first to enable mode detection
const modeInput = process.env.MODE || DEFAULT_MODE; const context = parseGitHubContext();
// Validate mode input // Auto-detect mode based on context, with optional override
if (!isValidMode(modeInput)) { const modeOverride = process.env.MODE;
throw new Error(`Invalid mode: ${modeInput}`); const mode = getMode(context, modeOverride);
} const modeName = mode.name;
const validatedMode: ModeName = modeInput;
// Step 2: Setup GitHub token based on mode // Setup GitHub token based on mode
let githubToken: string; let githubToken: string;
if (validatedMode === "experimental-review") { if (modeName === "review" || modeName === "experimental-review") {
// For experimental-review mode, use the default GitHub Action token // For review mode, use the default GitHub Action token
githubToken = process.env.DEFAULT_WORKFLOW_TOKEN || ""; githubToken = process.env.DEFAULT_WORKFLOW_TOKEN || "";
if (!githubToken) { if (!githubToken) {
throw new Error( 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"); console.log("Using default GitHub Action token for review mode");
@@ -43,8 +41,6 @@ async function run() {
} }
const octokit = createOctokit(githubToken); 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) // Step 3: Check write permissions (only for entity contexts)
if (isEntityContext(context)) { if (isEntityContext(context)) {
@@ -59,8 +55,7 @@ async function run() {
} }
} }
// Step 4: Get mode and check trigger conditions // Check trigger conditions
const mode = getMode(validatedMode, context);
const containsTrigger = mode.shouldTrigger(context); const containsTrigger = mode.shouldTrigger(context);
// Set output for action.yml to check // Set output for action.yml to check

View File

@@ -35,7 +35,6 @@ export type ScheduleEvent = {
}; };
}; };
import type { ModeName } from "../modes/types"; import type { ModeName } from "../modes/types";
import { DEFAULT_MODE, isValidMode } from "../modes/registry";
// Event name constants for better maintainability // Event name constants for better maintainability
const ENTITY_EVENT_NAMES = [ const ENTITY_EVENT_NAMES = [
@@ -63,15 +62,16 @@ type BaseContext = {
}; };
actor: string; actor: string;
inputs: { inputs: {
mode: ModeName; mode?: ModeName; // Optional for v1.0 backward compatibility
prompt: string; // New unified prompt field
triggerPhrase: string; triggerPhrase: string;
assigneeTrigger: string; assigneeTrigger: string;
labelTrigger: string; labelTrigger: string;
allowedTools: string[]; allowedTools: string[];
disallowedTools: string[]; disallowedTools: string[];
customInstructions: string; customInstructions: string;
directPrompt: string; directPrompt: string; // Deprecated, kept for compatibility
overridePrompt: string; overridePrompt: string; // Deprecated, kept for compatibility
baseBranch?: string; baseBranch?: string;
branchPrefix: string; branchPrefix: string;
useStickyComment: boolean; useStickyComment: boolean;
@@ -105,10 +105,8 @@ export type GitHubContext = ParsedGitHubContext | AutomationContext;
export function parseGitHubContext(): GitHubContext { export function parseGitHubContext(): GitHubContext {
const context = github.context; const context = github.context;
const modeInput = process.env.MODE ?? DEFAULT_MODE; // Mode is optional in v1.0 (auto-detected)
if (!isValidMode(modeInput)) { const modeInput = process.env.MODE ? process.env.MODE as ModeName : undefined;
throw new Error(`Invalid mode: ${modeInput}.`);
}
const commonFields = { const commonFields = {
runId: process.env.GITHUB_RUN_ID!, runId: process.env.GITHUB_RUN_ID!,
@@ -120,15 +118,19 @@ export function parseGitHubContext(): GitHubContext {
}, },
actor: context.actor, actor: context.actor,
inputs: { 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", triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "", assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
labelTrigger: process.env.LABEL_TRIGGER ?? "", labelTrigger: process.env.LABEL_TRIGGER ?? "",
allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""), allowedTools: parseMultilineInput(process.env.ALLOWED_TOOLS ?? ""),
disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""), disallowedTools: parseMultilineInput(process.env.DISALLOWED_TOOLS ?? ""),
customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "", customInstructions: process.env.CUSTOM_INSTRUCTIONS ?? "",
directPrompt: process.env.DIRECT_PROMPT ?? "", directPrompt: process.env.DIRECT_PROMPT ?? "", // Deprecated
overridePrompt: process.env.OVERRIDE_PROMPT ?? "", overridePrompt: process.env.OVERRIDE_PROMPT ?? "", // Deprecated
baseBranch: process.env.BASE_BRANCH, baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true", useStickyComment: process.env.USE_STICKY_COMMENT === "true",

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. * This module provides access to all available execution modes and handles
* * automatic mode detection based on GitHub event types.
* 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
*/ */
import type { Mode, ModeName } from "./types"; import type { Mode, ModeName } from "./types";
@@ -15,41 +10,44 @@ import { tagMode } from "./tag";
import { agentMode } from "./agent"; import { agentMode } from "./agent";
import { reviewMode } from "./review"; import { reviewMode } from "./review";
import type { GitHubContext } from "../github/context"; 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", "review"] as const;
export const VALID_MODES = ["tag", "agent", "experimental-review"] as const;
/** /**
* All available modes. * All available modes in v1.0
* Add new modes here as they are created.
*/ */
const modes = { const modes = {
tag: tagMode, tag: tagMode,
agent: agentMode, agent: agentMode,
"experimental-review": reviewMode, review: reviewMode,
} as const satisfies Record<ModeName, Mode>; } as const satisfies Record<AutoDetectedMode, Mode>;
/** /**
* Retrieves a mode by name and validates it can handle the event type. * Automatically detects and retrieves the appropriate mode based on the GitHub context.
* @param name The mode name to retrieve * In v1.0, modes are auto-selected based on event type.
* @param context The GitHub context to validate against * @param context The GitHub context
* @returns The requested mode * @param explicitMode Optional explicit mode override (for backward compatibility)
* @throws Error if the mode is not found or cannot handle the event * @returns The appropriate mode for the context
*/ */
export function getMode(name: ModeName, context: GitHubContext): Mode { export function getMode(
const mode = modes[name]; context: GitHubContext,
if (!mode) { explicitMode?: string,
const validModes = VALID_MODES.join("', '"); ): Mode {
throw new Error( let modeName: AutoDetectedMode;
`Invalid mode '${name}'. Valid modes are: '${validModes}'. Please check your workflow configuration.`,
); 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 const mode = modes[modeName];
if (name === "tag" && isAutomationContext(context)) { if (!mode) {
throw new Error( 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 * @param name The string to check
* @returns True if the name is a valid mode name * @returns True if the name is a valid mode name
*/ */
export function isValidMode(name: string): name is ModeName { export function isValidModeV1(name: string): boolean {
return VALID_MODES.includes(name as ModeName); 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, context: PreparedContext,
githubData: FetchDataResult, githubData: FetchDataResult,
): string { ): string {
// Support overridePrompt // Support v1.0 unified prompt or legacy overridePrompt
if (context.overridePrompt) { const userPrompt = context.prompt || context.overridePrompt;
return context.overridePrompt; if (userPrompt) {
return userPrompt;
} }
// Default to /review slash command content
// This will be expanded by the slash command system
const { const {
contextData, contextData,

View File

@@ -3,7 +3,7 @@ import type { PreparedContext } from "../create-prompt/types";
import type { FetchDataResult } from "../github/data/fetcher"; import type { FetchDataResult } from "../github/data/fetcher";
import type { Octokits } from "../github/api/client"; 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 = { export type ModeContext = {
mode: ModeName; 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>(), 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>GENERAL_COMMENT</event_type>"); 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 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("<event_type>PR_REVIEW</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>"); expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -196,7 +196,7 @@ describe("generatePrompt", () => {
); // from review comments ); // from review comments
}); });
test("should generate prompt for issue opened event", () => { test("should generate prompt for issue opened event", async () => {
const envVars: PreparedContext = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("<event_type>ISSUE_CREATED</event_type>");
expect(prompt).toContain( expect(prompt).toContain(
@@ -223,7 +223,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("The target-branch should be 'main'"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("<event_type>ISSUE_ASSIGNED</event_type>");
expect(prompt).toContain( 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("<event_type>ISSUE_LABELED</event_type>");
expect(prompt).toContain( 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("<direct_prompt>");
expect(prompt).toContain("Fix the bug in the login form"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("<event_type>PULL_REQUEST</event_type>");
expect(prompt).toContain("<is_pr>true</is_pr>"); expect(prompt).toContain("<is_pr>true</is_pr>");
@@ -324,7 +324,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("pull request opened"); expect(prompt).toContain("pull request opened");
}); });
test("should include custom instructions when provided", () => { test("should include custom instructions when provided", async () => {
const envVars: PreparedContext = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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).toBe("Simple prompt for owner/repo PR #123");
expect(prompt).not.toContain("You are Claude, an AI assistant"); 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 = { const envVars: PreparedContext = {
repository: "test/repo", repository: "test/repo",
claudeCommentId: "12345", 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("Repository: test/repo");
expect(prompt).toContain("PR: 456"); expect(prompt).toContain("PR: 456");
@@ -412,7 +412,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("Is PR: true"); expect(prompt).toContain("Is PR: true");
}); });
test("should handle override_prompt for issues", () => { test("should handle override_prompt for issues", async () => {
const envVars: PreparedContext = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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: "); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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("You are Claude, an AI assistant");
expect(prompt).toContain("<event_type>ISSUE_CREATED</event_type>"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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>"); expect(prompt).toContain("<trigger_username>johndoe</trigger_username>");
// With commit signing disabled, co-author info appears in git commit instructions // 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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) // Should contain PR-specific instructions (git commands when not using signing)
expect(prompt).toContain("git push"); expect(prompt).toContain("git push");
@@ -543,7 +543,7 @@ describe("generatePrompt", () => {
expect(prompt).not.toContain("Create a PR](https://github.com/"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should contain Issue-specific instructions
expect(prompt).toContain( 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should contain the actual branch name with timestamp
expect(prompt).toContain( 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should contain branch-specific instructions like issues
expect(prompt).toContain( 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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) // Should contain open PR instructions (git commands when not using signing)
expect(prompt).toContain("git push"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should contain new branch instructions
expect(prompt).toContain( expect(prompt).toContain(
@@ -708,7 +708,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("Reference to the original PR"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should contain new branch instructions
expect(prompt).toContain( 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should contain new branch instructions
expect(prompt).toContain( expect(prompt).toContain(
@@ -762,7 +762,7 @@ describe("generatePrompt", () => {
expect(prompt).toContain("Reference to the original PR"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should have git command instructions
expect(prompt).toContain("Use git commands via the Bash tool"); 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"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", 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 // Should have commit signing tool instructions
expect(prompt).toContain("mcp__github_file_ops__commit_files"); expect(prompt).toContain("mcp__github_file_ops__commit_files");
@@ -819,7 +819,7 @@ describe("generatePrompt", () => {
}); });
describe("getEventTypeAndContext", () => { 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", claudeCommentId: "12345",
@@ -838,7 +838,7 @@ describe("getEventTypeAndContext", () => {
expect(result.triggerContext).toBe("PR review comment with '@claude'"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", claudeCommentId: "12345",
@@ -860,7 +860,7 @@ describe("getEventTypeAndContext", () => {
expect(result.triggerContext).toBe("issue assigned to 'claude-bot'"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", claudeCommentId: "12345",
@@ -882,7 +882,7 @@ describe("getEventTypeAndContext", () => {
expect(result.triggerContext).toBe("issue labeled with 'claude-task'"); 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 = { const envVars: PreparedContext = {
repository: "owner/repo", repository: "owner/repo",
claudeCommentId: "12345", claudeCommentId: "12345",
@@ -907,7 +907,7 @@ describe("getEventTypeAndContext", () => {
}); });
describe("buildAllowedToolsString", () => { 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(); const result = buildAllowedToolsString();
// The base tools should be in the result // The base tools should be in the result
@@ -929,7 +929,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("mcp__github_file_ops__delete_files"); 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); const result = buildAllowedToolsString([], false, false);
// The base tools should be in the result // The base tools should be in the result
@@ -950,7 +950,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("mcp__github_file_ops__delete_files"); 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 customTools = ["Tool1", "Tool2", "Tool3"];
const result = buildAllowedToolsString(customTools); const result = buildAllowedToolsString(customTools);
@@ -971,7 +971,7 @@ describe("buildAllowedToolsString", () => {
expect(basePlusCustom).toContain("Tool3"); 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); const result = buildAllowedToolsString([], true);
// Base tools should be present // Base tools should be present
@@ -984,7 +984,7 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("mcp__github_ci__download_job_log"); 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 customTools = ["Tool1", "Tool2"];
const result = buildAllowedToolsString(customTools, true); const result = buildAllowedToolsString(customTools, true);
@@ -1001,7 +1001,7 @@ describe("buildAllowedToolsString", () => {
expect(result).toContain("mcp__github_ci__download_job_log"); 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); const result = buildAllowedToolsString([], false, true);
// Base tools should be present // Base tools should be present
@@ -1022,7 +1022,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("Bash("); 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); const result = buildAllowedToolsString([], false, false);
// Base tools should be present // Base tools should be present
@@ -1050,7 +1050,7 @@ describe("buildAllowedToolsString", () => {
expect(result).not.toContain("mcp__github_file_ops__delete_files"); 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 customTools = ["CustomTool1", "CustomTool2"];
const result = buildAllowedToolsString(customTools, true, false); const result = buildAllowedToolsString(customTools, true, false);
@@ -1074,7 +1074,7 @@ describe("buildAllowedToolsString", () => {
}); });
describe("buildDisallowedToolsString", () => { 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(); const result = buildDisallowedToolsString();
// The base disallowed tools should be in the result // The base disallowed tools should be in the result
@@ -1082,7 +1082,7 @@ describe("buildDisallowedToolsString", () => {
expect(result).toContain("WebFetch"); 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 customDisallowedTools = ["BadTool1", "BadTool2"];
const result = buildDisallowedToolsString(customDisallowedTools); const result = buildDisallowedToolsString(customDisallowedTools);
@@ -1100,7 +1100,7 @@ describe("buildDisallowedToolsString", () => {
expect(parts).toContain("BadTool2"); 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 customDisallowedTools = ["BadTool1", "BadTool2"];
const allowedTools = ["WebSearch", "SomeOtherTool"]; const allowedTools = ["WebSearch", "SomeOtherTool"];
const result = buildDisallowedToolsString( const result = buildDisallowedToolsString(
@@ -1119,7 +1119,7 @@ describe("buildDisallowedToolsString", () => {
expect(result).toContain("BadTool2"); 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 allowedTools = ["WebSearch", "WebFetch", "SomeOtherTool"];
const result = buildDisallowedToolsString(undefined, allowedTools); const result = buildDisallowedToolsString(undefined, allowedTools);
@@ -1131,7 +1131,7 @@ describe("buildDisallowedToolsString", () => {
expect(result).toBe(""); 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 customDisallowedTools = ["BadTool1", "BadTool2"];
const allowedTools = ["WebSearch", "WebFetch"]; const allowedTools = ["WebSearch", "WebFetch"];
const result = buildDisallowedToolsString( const result = buildDisallowedToolsString(

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
import { describe, test, expect } from "bun:test"; import { describe, test, expect } from "bun:test";
import { getMode, isValidMode } from "../../src/modes/registry"; 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 { agentMode } from "../../src/modes/agent";
import { reviewMode } from "../../src/modes/review"; import { reviewMode } from "../../src/modes/review";
import { createMockContext, createMockAutomationContext } from "../mockContext"; import { createMockContext, createMockAutomationContext } from "../mockContext";
@@ -19,52 +17,57 @@ describe("Mode Registry", () => {
eventName: "schedule", eventName: "schedule",
}); });
test("getMode returns tag mode for standard events", () => { test("getMode auto-detects tag mode for issue_comment", () => {
const mode = getMode("tag", mockContext); const mode = getMode(mockContext);
expect(mode).toBe(tagMode); // Issue comment without trigger won't activate tag mode, defaults to agent
expect(mode.name).toBe("tag");
});
test("getMode returns agent mode", () => {
const mode = getMode("agent", mockContext);
expect(mode).toBe(agentMode); expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent"); expect(mode.name).toBe("agent");
}); });
test("getMode returns experimental-review mode", () => { test("getMode auto-detects agent mode for workflow_dispatch", () => {
const mode = getMode("experimental-review", mockContext); 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).toBe(reviewMode);
expect(mode.name).toBe("experimental-review"); expect(mode.name).toBe("review");
}); });
test("getMode throws error for tag mode with workflow_dispatch event", () => { test("getMode auto-detects agent for workflow_dispatch", () => {
expect(() => getMode("tag", mockWorkflowDispatchContext)).toThrow( const mode = getMode(mockWorkflowDispatchContext);
"Tag mode cannot handle workflow_dispatch events. Use 'agent' mode for automation events.",
);
});
test("getMode throws error for tag mode with schedule event", () => {
expect(() => getMode("tag", mockScheduleContext)).toThrow(
"Tag mode cannot handle schedule events. Use 'agent' mode for automation events.",
);
});
test("getMode allows agent mode for workflow_dispatch event", () => {
const mode = getMode("agent", mockWorkflowDispatchContext);
expect(mode).toBe(agentMode); expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent"); expect(mode.name).toBe("agent");
}); });
test("getMode allows agent mode for schedule event", () => { test("getMode auto-detects agent for schedule event", () => {
const mode = getMode("agent", mockScheduleContext); const mode = getMode(mockScheduleContext);
expect(mode).toBe(agentMode); expect(mode).toBe(agentMode);
expect(mode.name).toBe("agent"); expect(mode.name).toBe("agent");
}); });
test("getMode throws error for invalid mode", () => { test("getMode supports legacy experimental-review mode name", () => {
const invalidMode = "invalid" as unknown as ModeName; const mode = getMode(mockContext, "experimental-review");
expect(() => getMode(invalidMode, mockContext)).toThrow( expect(mode).toBe(reviewMode);
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent', 'experimental-review'. Please check your workflow configuration.", 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, isPR: false,
inputs: { inputs: {
mode: "tag", mode: "tag",
prompt: "",
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "", labelTrigger: "",

View File

@@ -29,6 +29,7 @@ describe("checkContainsTrigger", () => {
eventAction: "opened", eventAction: "opened",
inputs: { inputs: {
mode: "tag", mode: "tag",
prompt: "",
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "", labelTrigger: "",
@@ -62,6 +63,7 @@ describe("checkContainsTrigger", () => {
} as IssuesEvent, } as IssuesEvent,
inputs: { inputs: {
mode: "tag", mode: "tag",
prompt: "",
triggerPhrase: "/claude", triggerPhrase: "/claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "", labelTrigger: "",
@@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => {
} as PullRequestEvent, } as PullRequestEvent,
inputs: { inputs: {
mode: "tag", mode: "tag",
prompt: "",
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "", labelTrigger: "",
@@ -313,6 +316,7 @@ describe("checkContainsTrigger", () => {
} as PullRequestEvent, } as PullRequestEvent,
inputs: { inputs: {
mode: "tag", mode: "tag",
prompt: "",
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "", labelTrigger: "",
@@ -347,6 +351,7 @@ describe("checkContainsTrigger", () => {
} as PullRequestEvent, } as PullRequestEvent,
inputs: { inputs: {
mode: "tag", mode: "tag",
prompt: "",
triggerPhrase: "@claude", triggerPhrase: "@claude",
assigneeTrigger: "", assigneeTrigger: "",
labelTrigger: "", labelTrigger: "",