From a58dc37018fe4d142b2ee81750d04e1ad49f5416 Mon Sep 17 00:00:00 2001 From: km-anthropic Date: Wed, 23 Jul 2025 20:35:11 -0700 Subject: [PATCH] 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 --- README.md | 3 ++ action.yml | 7 +++ examples/claude.yml | 1 + src/create-prompt/index.ts | 20 ++++--- src/entrypoints/format-turns.ts | 24 ++++----- src/entrypoints/prepare.ts | 41 +++++++++------ src/github/context.ts | 10 ++++ src/modes/registry.ts | 52 +++++++++++++++++++ src/modes/tag/index.ts | 40 ++++++++++++++ src/modes/types.ts | 56 ++++++++++++++++++++ test/install-mcp-server.test.ts | 1 + test/mockContext.ts | 1 + test/modes/registry.test.ts | 28 ++++++++++ test/modes/tag.test.ts | 92 +++++++++++++++++++++++++++++++++ test/permissions.test.ts | 1 + test/trigger-validation.test.ts | 5 ++ 16 files changed, 348 insertions(+), 34 deletions(-) create mode 100644 src/modes/registry.ts create mode 100644 src/modes/tag/index.ts create mode 100644 src/modes/types.ts create mode 100644 test/modes/registry.test.ts create mode 100644 test/modes/tag.test.ts diff --git a/README.md b/README.md index af38239..646387f 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,8 @@ jobs: # Or use OAuth token instead: # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }} + # Optional: set execution mode (default: tag) + # mode: "tag" # Optional: add custom trigger phrase (default: @claude) # trigger_phrase: "/claude" # Optional: add assignee trigger for issues @@ -167,6 +169,7 @@ jobs: | Input | Description | Required | Default | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | +| `mode` | Execution mode for the action. Currently supports 'tag' (default). Future modes: 'review', 'freeform' | No | `tag` | | `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | | `claude_code_oauth_token` | Claude Code OAuth token (alternative to anthropic_api_key) | No\* | - | | `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - | diff --git a/action.yml b/action.yml index 50e7da9..0704ba5 100644 --- a/action.yml +++ b/action.yml @@ -24,6 +24,12 @@ inputs: required: false default: "claude/" + # Mode configuration + mode: + description: "Execution mode for the action. Currently only 'tag' mode is supported (traditional implementation triggered by mentions/assignments)" + required: false + default: "tag" + # Claude Code configuration model: description: "Model to use (provider-specific format required for Bedrock/Vertex)" @@ -137,6 +143,7 @@ runs: run: | bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts env: + MODE: ${{ inputs.mode }} TRIGGER_PHRASE: ${{ inputs.trigger_phrase }} ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }} LABEL_TRIGGER: ${{ inputs.label_trigger }} diff --git a/examples/claude.yml b/examples/claude.yml index c6e9cfd..53c207a 100644 --- a/examples/claude.yml +++ b/examples/claude.yml @@ -36,6 +36,7 @@ jobs: # Or use OAuth token instead: # claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} timeout_minutes: "60" + # mode: tag # Default: responds to @claude mentions # Optional: Restrict network access to specific domains only # experimental_allowed_domains: | # .anthropic.com diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 28f23ca..0da4374 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -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, diff --git a/src/entrypoints/format-turns.ts b/src/entrypoints/format-turns.ts index d136810..01ae9d6 100755 --- a/src/entrypoints/format-turns.ts +++ b/src/entrypoints/format-turns.ts @@ -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; 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; 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; -} +}; export function detectContentType(content: any): string { const contentStr = String(content).trim(); diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index d5e968f..3e5a956 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -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> + | 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, }); diff --git a/src/github/context.ts b/src/github/context.ts index 66b2582..961ac7e 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -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 ?? "", diff --git a/src/modes/registry.ts b/src/modes/registry.ts new file mode 100644 index 0000000..37aadd4 --- /dev/null +++ b/src/modes/registry.ts @@ -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; + +/** + * 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); +} diff --git a/src/modes/tag/index.ts b/src/modes/tag/index.ts new file mode 100644 index 0000000..e2b14b3 --- /dev/null +++ b/src/modes/tag/index.ts @@ -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; + }, +}; diff --git a/src/modes/types.ts b/src/modes/types.ts new file mode 100644 index 0000000..2cb2a75 --- /dev/null +++ b/src/modes/types.ts @@ -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; +}; diff --git a/test/install-mcp-server.test.ts b/test/install-mcp-server.test.ts index 7d0239c..ac8c11e 100644 --- a/test/install-mcp-server.test.ts +++ b/test/install-mcp-server.test.ts @@ -24,6 +24,7 @@ describe("prepareMcpConfig", () => { entityNumber: 123, isPR: false, inputs: { + mode: "tag", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", diff --git a/test/mockContext.ts b/test/mockContext.ts index 2cdd713..7d00f13 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -8,6 +8,7 @@ import type { } from "@octokit/webhooks-types"; const defaultInputs = { + mode: "tag" as const, triggerPhrase: "/claude", assigneeTrigger: "", labelTrigger: "", diff --git a/test/modes/registry.test.ts b/test/modes/registry.test.ts new file mode 100644 index 0000000..699c3f3 --- /dev/null +++ b/test/modes/registry.test.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from "bun:test"; +import { getMode, isValidMode, type ModeName } from "../../src/modes/registry"; +import { tagMode } from "../../src/modes/tag"; + +describe("Mode Registry", () => { + test("getMode returns tag mode by default", () => { + const mode = getMode("tag"); + expect(mode).toBe(tagMode); + expect(mode.name).toBe("tag"); + }); + + test("getMode throws error for invalid mode", () => { + const invalidMode = "invalid" as unknown as ModeName; + expect(() => getMode(invalidMode)).toThrow( + "Invalid mode 'invalid'. Valid modes are: 'tag'. Please check your workflow configuration.", + ); + }); + + test("isValidMode returns true for tag mode", () => { + expect(isValidMode("tag")).toBe(true); + }); + + test("isValidMode returns false for invalid mode", () => { + expect(isValidMode("invalid")).toBe(false); + expect(isValidMode("review")).toBe(false); + expect(isValidMode("freeform")).toBe(false); + }); +}); diff --git a/test/modes/tag.test.ts b/test/modes/tag.test.ts new file mode 100644 index 0000000..d592463 --- /dev/null +++ b/test/modes/tag.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { tagMode } from "../../src/modes/tag"; +import type { ParsedGitHubContext } from "../../src/github/context"; +import type { IssueCommentEvent } from "@octokit/webhooks-types"; +import { createMockContext } from "../mockContext"; + +describe("Tag Mode", () => { + let mockContext: ParsedGitHubContext; + + beforeEach(() => { + mockContext = createMockContext({ + eventName: "issue_comment", + isPR: false, + }); + }); + + test("tag mode has correct properties", () => { + expect(tagMode.name).toBe("tag"); + expect(tagMode.description).toBe( + "Traditional implementation mode triggered by @claude mentions", + ); + expect(tagMode.shouldCreateTrackingComment()).toBe(true); + }); + + test("shouldTrigger delegates to checkContainsTrigger", () => { + const contextWithTrigger = createMockContext({ + eventName: "issue_comment", + isPR: false, + inputs: { + ...createMockContext().inputs, + triggerPhrase: "@claude", + }, + payload: { + comment: { + body: "Hey @claude, can you help?", + }, + } as IssueCommentEvent, + }); + + expect(tagMode.shouldTrigger(contextWithTrigger)).toBe(true); + + const contextWithoutTrigger = createMockContext({ + eventName: "issue_comment", + isPR: false, + inputs: { + ...createMockContext().inputs, + triggerPhrase: "@claude", + }, + payload: { + comment: { + body: "This is just a regular comment", + }, + } as IssueCommentEvent, + }); + + expect(tagMode.shouldTrigger(contextWithoutTrigger)).toBe(false); + }); + + test("prepareContext includes all required data", () => { + const data = { + commentId: 123, + baseBranch: "main", + claudeBranch: "claude/fix-bug", + }; + + const context = tagMode.prepareContext(mockContext, data); + + expect(context.mode).toBe("tag"); + expect(context.githubContext).toBe(mockContext); + expect(context.commentId).toBe(123); + expect(context.baseBranch).toBe("main"); + expect(context.claudeBranch).toBe("claude/fix-bug"); + }); + + test("prepareContext works without data", () => { + const context = tagMode.prepareContext(mockContext); + + expect(context.mode).toBe("tag"); + expect(context.githubContext).toBe(mockContext); + expect(context.commentId).toBeUndefined(); + expect(context.baseBranch).toBeUndefined(); + expect(context.claudeBranch).toBeUndefined(); + }); + + test("getAllowedTools returns empty array", () => { + expect(tagMode.getAllowedTools()).toEqual([]); + }); + + test("getDisallowedTools returns empty array", () => { + expect(tagMode.getDisallowedTools()).toEqual([]); + }); +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index 868f6c0..2caaaf8 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -60,6 +60,7 @@ describe("checkWritePermissions", () => { entityNumber: 1, isPR: false, inputs: { + mode: "tag", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", diff --git a/test/trigger-validation.test.ts b/test/trigger-validation.test.ts index 9f1471c..6d3ca3c 100644 --- a/test/trigger-validation.test.ts +++ b/test/trigger-validation.test.ts @@ -28,6 +28,7 @@ describe("checkContainsTrigger", () => { eventName: "issues", eventAction: "opened", inputs: { + mode: "tag", triggerPhrase: "/claude", assigneeTrigger: "", labelTrigger: "", @@ -60,6 +61,7 @@ describe("checkContainsTrigger", () => { }, } as IssuesEvent, inputs: { + mode: "tag", triggerPhrase: "/claude", assigneeTrigger: "", labelTrigger: "", @@ -276,6 +278,7 @@ describe("checkContainsTrigger", () => { }, } as PullRequestEvent, inputs: { + mode: "tag", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", @@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => { }, } as PullRequestEvent, inputs: { + mode: "tag", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "", @@ -342,6 +346,7 @@ describe("checkContainsTrigger", () => { }, } as PullRequestEvent, inputs: { + mode: "tag", triggerPhrase: "@claude", assigneeTrigger: "", labelTrigger: "",