Add mode support (#333)

* Add mode support

* update "as any" with proper "as unknwon as ModeName" casting

* Add documentation to README and registry.ts

* Add  tests for differen event types, integration flows, and error conditions

* Clean up some tests

* Minor test fix

* Minor formatting test + switch from interface to type

* correct the order of mkdir call

* always configureGitAuth as there's already a fallback to handle null users by using the bot ID

* simplify registry setup

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
This commit is contained in:
km-anthropic
2025-07-23 20:35:11 -07:00
committed by GitHub
parent 963754fa12
commit a58dc37018
16 changed files with 348 additions and 34 deletions

View File

@@ -20,6 +20,7 @@ import {
import type { ParsedGitHubContext } from "../github/context";
import type { CommonFields, PreparedContext, EventData } from "./types";
import { GITHUB_SERVER_URL } from "../github/api/config";
import type { Mode, ModeContext } from "../modes/types";
export type { CommonFields, PreparedContext } from "./types";
const BASE_ALLOWED_TOOLS = [
@@ -788,25 +789,30 @@ f. If you are unable to complete certain steps, such as running a linter or test
}
export async function createPrompt(
claudeCommentId: number,
baseBranch: string | undefined,
claudeBranch: string | undefined,
mode: Mode,
modeContext: ModeContext,
githubData: FetchDataResult,
context: ParsedGitHubContext,
) {
try {
// Tag mode requires a comment ID
if (mode.name === "tag" && !modeContext.commentId) {
throw new Error("Tag mode requires a comment ID for prompt generation");
}
// Prepare the context for prompt generation
const preparedContext = prepareContext(
context,
claudeCommentId.toString(),
baseBranch,
claudeBranch,
modeContext.commentId?.toString() || "",
modeContext.baseBranch,
modeContext.claudeBranch,
);
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
recursive: true,
});
// Generate the prompt
// Generate the prompt directly
const promptContent = generatePrompt(
preparedContext,
githubData,

View File

@@ -3,21 +3,21 @@
import { readFileSync, existsSync } from "fs";
import { exit } from "process";
export interface ToolUse {
export type ToolUse = {
type: string;
name?: string;
input?: Record<string, any>;
id?: string;
}
};
export interface ToolResult {
export type ToolResult = {
type: string;
tool_use_id?: string;
content?: any;
is_error?: boolean;
}
};
export interface ContentItem {
export type ContentItem = {
type: string;
text?: string;
tool_use_id?: string;
@@ -26,17 +26,17 @@ export interface ContentItem {
name?: string;
input?: Record<string, any>;
id?: string;
}
};
export interface Message {
export type Message = {
content: ContentItem[];
usage?: {
input_tokens?: number;
output_tokens?: number;
};
}
};
export interface Turn {
export type Turn = {
type: string;
subtype?: string;
message?: Message;
@@ -44,16 +44,16 @@ export interface Turn {
cost_usd?: number;
duration_ms?: number;
result?: string;
}
};
export interface GroupedContent {
export type GroupedContent = {
type: string;
tools_count?: number;
data?: Turn;
text_parts?: string[];
tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[];
usage?: Record<string, number>;
}
};
export function detectContentType(content: any): string {
const contentStr = String(content).trim();

View File

@@ -7,17 +7,17 @@
import * as core from "@actions/core";
import { setupGitHubToken } from "../github/token";
import { checkTriggerAction } from "../github/validation/trigger";
import { checkHumanActor } from "../github/validation/actor";
import { checkWritePermissions } from "../github/validation/permissions";
import { createInitialComment } from "../github/operations/comments/create-initial";
import { setupBranch } from "../github/operations/branch";
import { configureGitAuth } from "../github/operations/git-config";
import { prepareMcpConfig } from "../mcp/install-mcp-server";
import { createPrompt } from "../create-prompt";
import { createOctokit } from "../github/api/client";
import { fetchGitHubData } from "../github/data/fetcher";
import { parseGitHubContext } from "../github/context";
import { getMode } from "../modes/registry";
import { createPrompt } from "../create-prompt";
async function run() {
try {
@@ -39,8 +39,12 @@ async function run() {
);
}
// Step 4: Check trigger conditions
const containsTrigger = await checkTriggerAction(context);
// Step 4: Get mode and check trigger conditions
const mode = getMode(context.inputs.mode);
const containsTrigger = mode.shouldTrigger(context);
// Set output for action.yml to check
core.setOutput("contains_trigger", containsTrigger.toString());
if (!containsTrigger) {
console.log("No trigger found, skipping remaining steps");
@@ -50,9 +54,16 @@ async function run() {
// Step 5: Check if actor is human
await checkHumanActor(octokit.rest, context);
// Step 6: Create initial tracking comment
const commentData = await createInitialComment(octokit.rest, context);
const commentId = commentData.id;
// Step 6: Create initial tracking comment (mode-aware)
// Some modes (e.g., future review/freeform modes) may not need tracking comments
let commentId: number | undefined;
let commentData:
| Awaited<ReturnType<typeof createInitialComment>>
| undefined;
if (mode.shouldCreateTrackingComment()) {
commentData = await createInitialComment(octokit.rest, context);
commentId = commentData.id;
}
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
const githubData = await fetchGitHubData({
@@ -69,7 +80,7 @@ async function run() {
// Step 9: Configure git authentication if not using commit signing
if (!context.inputs.useCommitSigning) {
try {
await configureGitAuth(githubToken, context, commentData.user);
await configureGitAuth(githubToken, context, commentData?.user || null);
} catch (error) {
console.error("Failed to configure git authentication:", error);
throw error;
@@ -77,13 +88,13 @@ async function run() {
}
// Step 10: Create prompt file
await createPrompt(
const modeContext = mode.prepareContext(context, {
commentId,
branchInfo.baseBranch,
branchInfo.claudeBranch,
githubData,
context,
);
baseBranch: branchInfo.baseBranch,
claudeBranch: branchInfo.claudeBranch,
});
await createPrompt(mode, modeContext, githubData, context);
// Step 11: Get MCP configuration
const additionalMcpConfig = process.env.MCP_CONFIG || "";
@@ -94,7 +105,7 @@ async function run() {
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
baseBranch: branchInfo.baseBranch,
additionalMcpConfig,
claudeCommentId: commentId.toString(),
claudeCommentId: commentId?.toString() || "",
allowedTools: context.inputs.allowedTools,
context,
});

View File

@@ -7,6 +7,9 @@ import type {
PullRequestReviewEvent,
PullRequestReviewCommentEvent,
} from "@octokit/webhooks-types";
import type { ModeName } from "../modes/registry";
import { DEFAULT_MODE } from "../modes/registry";
import { isValidMode } from "../modes/registry";
export type ParsedGitHubContext = {
runId: string;
@@ -27,6 +30,7 @@ export type ParsedGitHubContext = {
entityNumber: number;
isPR: boolean;
inputs: {
mode: ModeName;
triggerPhrase: string;
assigneeTrigger: string;
labelTrigger: string;
@@ -46,6 +50,11 @@ export type ParsedGitHubContext = {
export function parseGitHubContext(): ParsedGitHubContext {
const context = github.context;
const modeInput = process.env.MODE ?? DEFAULT_MODE;
if (!isValidMode(modeInput)) {
throw new Error(`Invalid mode: ${modeInput}.`);
}
const commonFields = {
runId: process.env.GITHUB_RUN_ID!,
eventName: context.eventName,
@@ -57,6 +66,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
},
actor: context.actor,
inputs: {
mode: modeInput as ModeName,
triggerPhrase: process.env.TRIGGER_PHRASE ?? "@claude",
assigneeTrigger: process.env.ASSIGNEE_TRIGGER ?? "",
labelTrigger: process.env.LABEL_TRIGGER ?? "",

52
src/modes/registry.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Mode Registry for claude-code-action
*
* 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/review/)
* 3. Import and add it to the modes object below
* 4. Update action.yml description to mention the new mode
*/
import type { Mode } from "./types";
import { tagMode } from "./tag/index";
export const DEFAULT_MODE = "tag" as const;
export const VALID_MODES = ["tag"] as const;
export type ModeName = (typeof VALID_MODES)[number];
/**
* All available modes.
* Add new modes here as they are created.
*/
const modes = {
tag: tagMode,
} as const satisfies Record<ModeName, Mode>;
/**
* Retrieves a mode by name.
* @param name The mode name to retrieve
* @returns The requested mode
* @throws Error if the mode is not found
*/
export function getMode(name: ModeName): 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.`,
);
}
return mode;
}
/**
* Type guard to check if a string is a valid mode name.
* @param name The string to check
* @returns True if the name is a valid mode name
*/
export function isValidMode(name: string): name is ModeName {
return VALID_MODES.includes(name as ModeName);
}

40
src/modes/tag/index.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { Mode } from "../types";
import { checkContainsTrigger } from "../../github/validation/trigger";
/**
* Tag mode implementation.
*
* The traditional implementation mode that responds to @claude mentions,
* issue assignments, or labels. Creates tracking comments showing progress
* and has full implementation capabilities.
*/
export const tagMode: Mode = {
name: "tag",
description: "Traditional implementation mode triggered by @claude mentions",
shouldTrigger(context) {
return checkContainsTrigger(context);
},
prepareContext(context, data) {
return {
mode: "tag",
githubContext: context,
commentId: data?.commentId,
baseBranch: data?.baseBranch,
claudeBranch: data?.claudeBranch,
};
},
getAllowedTools() {
return [];
},
getDisallowedTools() {
return [];
},
shouldCreateTrackingComment() {
return true;
},
};

56
src/modes/types.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { ParsedGitHubContext } from "../github/context";
import type { ModeName } from "./registry";
export type ModeContext = {
mode: ModeName;
githubContext: ParsedGitHubContext;
commentId?: number;
baseBranch?: string;
claudeBranch?: string;
};
export type ModeData = {
commentId?: number;
baseBranch?: string;
claudeBranch?: string;
};
/**
* Mode interface for claude-code-action execution modes.
* Each mode defines its own behavior for trigger detection, prompt generation,
* and tracking comment creation.
*
* Future modes might include:
* - 'review': Optimized for code reviews without tracking comments
* - 'freeform': For automation with no trigger checking
*/
export type Mode = {
name: ModeName;
description: string;
/**
* Determines if this mode should trigger based on the GitHub context
*/
shouldTrigger(context: ParsedGitHubContext): boolean;
/**
* Prepares the mode context with any additional data needed for prompt generation
*/
prepareContext(context: ParsedGitHubContext, data?: ModeData): ModeContext;
/**
* Returns additional tools that should be allowed for this mode
* (base GitHub tools are always included)
*/
getAllowedTools(): string[];
/**
* Returns tools that should be disallowed for this mode
*/
getDisallowedTools(): string[];
/**
* Determines if this mode should create a tracking comment
*/
shouldCreateTrackingComment(): boolean;
};