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

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