mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
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:
@@ -145,6 +145,8 @@ jobs:
|
|||||||
# Or use OAuth token instead:
|
# Or use OAuth token instead:
|
||||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Optional: set execution mode (default: tag)
|
||||||
|
# mode: "tag"
|
||||||
# Optional: add custom trigger phrase (default: @claude)
|
# Optional: add custom trigger phrase (default: @claude)
|
||||||
# trigger_phrase: "/claude"
|
# trigger_phrase: "/claude"
|
||||||
# Optional: add assignee trigger for issues
|
# Optional: add assignee trigger for issues
|
||||||
@@ -167,6 +169,7 @@ jobs:
|
|||||||
|
|
||||||
| Input | Description | Required | Default |
|
| 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\* | - |
|
| `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\* | - |
|
| `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 | - |
|
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: "claude/"
|
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
|
# Claude Code configuration
|
||||||
model:
|
model:
|
||||||
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||||
@@ -137,6 +143,7 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
|
bun run ${GITHUB_ACTION_PATH}/src/entrypoints/prepare.ts
|
||||||
env:
|
env:
|
||||||
|
MODE: ${{ inputs.mode }}
|
||||||
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 }}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ jobs:
|
|||||||
# Or use OAuth token instead:
|
# Or use OAuth token instead:
|
||||||
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
timeout_minutes: "60"
|
timeout_minutes: "60"
|
||||||
|
# mode: tag # Default: responds to @claude mentions
|
||||||
# Optional: Restrict network access to specific domains only
|
# Optional: Restrict network access to specific domains only
|
||||||
# experimental_allowed_domains: |
|
# experimental_allowed_domains: |
|
||||||
# .anthropic.com
|
# .anthropic.com
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import type { ParsedGitHubContext } from "../github/context";
|
import type { ParsedGitHubContext } from "../github/context";
|
||||||
import type { CommonFields, PreparedContext, EventData } from "./types";
|
import type { CommonFields, PreparedContext, EventData } from "./types";
|
||||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||||
|
import type { Mode, ModeContext } from "../modes/types";
|
||||||
export type { CommonFields, PreparedContext } from "./types";
|
export type { CommonFields, PreparedContext } from "./types";
|
||||||
|
|
||||||
const BASE_ALLOWED_TOOLS = [
|
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(
|
export async function createPrompt(
|
||||||
claudeCommentId: number,
|
mode: Mode,
|
||||||
baseBranch: string | undefined,
|
modeContext: ModeContext,
|
||||||
claudeBranch: string | undefined,
|
|
||||||
githubData: FetchDataResult,
|
githubData: FetchDataResult,
|
||||||
context: ParsedGitHubContext,
|
context: ParsedGitHubContext,
|
||||||
) {
|
) {
|
||||||
try {
|
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(
|
const preparedContext = prepareContext(
|
||||||
context,
|
context,
|
||||||
claudeCommentId.toString(),
|
modeContext.commentId?.toString() || "",
|
||||||
baseBranch,
|
modeContext.baseBranch,
|
||||||
claudeBranch,
|
modeContext.claudeBranch,
|
||||||
);
|
);
|
||||||
|
|
||||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate the prompt
|
// Generate the prompt directly
|
||||||
const promptContent = generatePrompt(
|
const promptContent = generatePrompt(
|
||||||
preparedContext,
|
preparedContext,
|
||||||
githubData,
|
githubData,
|
||||||
|
|||||||
@@ -3,21 +3,21 @@
|
|||||||
import { readFileSync, existsSync } from "fs";
|
import { readFileSync, existsSync } from "fs";
|
||||||
import { exit } from "process";
|
import { exit } from "process";
|
||||||
|
|
||||||
export interface ToolUse {
|
export type ToolUse = {
|
||||||
type: string;
|
type: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
input?: Record<string, any>;
|
input?: Record<string, any>;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface ToolResult {
|
export type ToolResult = {
|
||||||
type: string;
|
type: string;
|
||||||
tool_use_id?: string;
|
tool_use_id?: string;
|
||||||
content?: any;
|
content?: any;
|
||||||
is_error?: boolean;
|
is_error?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface ContentItem {
|
export type ContentItem = {
|
||||||
type: string;
|
type: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
tool_use_id?: string;
|
tool_use_id?: string;
|
||||||
@@ -26,17 +26,17 @@ export interface ContentItem {
|
|||||||
name?: string;
|
name?: string;
|
||||||
input?: Record<string, any>;
|
input?: Record<string, any>;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface Message {
|
export type Message = {
|
||||||
content: ContentItem[];
|
content: ContentItem[];
|
||||||
usage?: {
|
usage?: {
|
||||||
input_tokens?: number;
|
input_tokens?: number;
|
||||||
output_tokens?: number;
|
output_tokens?: number;
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface Turn {
|
export type Turn = {
|
||||||
type: string;
|
type: string;
|
||||||
subtype?: string;
|
subtype?: string;
|
||||||
message?: Message;
|
message?: Message;
|
||||||
@@ -44,16 +44,16 @@ export interface Turn {
|
|||||||
cost_usd?: number;
|
cost_usd?: number;
|
||||||
duration_ms?: number;
|
duration_ms?: number;
|
||||||
result?: string;
|
result?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface GroupedContent {
|
export type GroupedContent = {
|
||||||
type: string;
|
type: string;
|
||||||
tools_count?: number;
|
tools_count?: number;
|
||||||
data?: Turn;
|
data?: Turn;
|
||||||
text_parts?: string[];
|
text_parts?: string[];
|
||||||
tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[];
|
tool_calls?: { tool_use: ToolUse; tool_result?: ToolResult }[];
|
||||||
usage?: Record<string, number>;
|
usage?: Record<string, number>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function detectContentType(content: any): string {
|
export function detectContentType(content: any): string {
|
||||||
const contentStr = String(content).trim();
|
const contentStr = String(content).trim();
|
||||||
|
|||||||
@@ -7,17 +7,17 @@
|
|||||||
|
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { setupGitHubToken } from "../github/token";
|
import { setupGitHubToken } from "../github/token";
|
||||||
import { checkTriggerAction } from "../github/validation/trigger";
|
|
||||||
import { checkHumanActor } from "../github/validation/actor";
|
import { checkHumanActor } from "../github/validation/actor";
|
||||||
import { checkWritePermissions } from "../github/validation/permissions";
|
import { checkWritePermissions } from "../github/validation/permissions";
|
||||||
import { createInitialComment } from "../github/operations/comments/create-initial";
|
import { createInitialComment } from "../github/operations/comments/create-initial";
|
||||||
import { setupBranch } from "../github/operations/branch";
|
import { setupBranch } from "../github/operations/branch";
|
||||||
import { configureGitAuth } from "../github/operations/git-config";
|
import { configureGitAuth } from "../github/operations/git-config";
|
||||||
import { prepareMcpConfig } from "../mcp/install-mcp-server";
|
import { prepareMcpConfig } from "../mcp/install-mcp-server";
|
||||||
import { createPrompt } from "../create-prompt";
|
|
||||||
import { createOctokit } from "../github/api/client";
|
import { createOctokit } from "../github/api/client";
|
||||||
import { fetchGitHubData } from "../github/data/fetcher";
|
import { fetchGitHubData } from "../github/data/fetcher";
|
||||||
import { parseGitHubContext } from "../github/context";
|
import { parseGitHubContext } from "../github/context";
|
||||||
|
import { getMode } from "../modes/registry";
|
||||||
|
import { createPrompt } from "../create-prompt";
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
try {
|
try {
|
||||||
@@ -39,8 +39,12 @@ async function run() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Check trigger conditions
|
// Step 4: Get mode and check trigger conditions
|
||||||
const containsTrigger = await checkTriggerAction(context);
|
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) {
|
if (!containsTrigger) {
|
||||||
console.log("No trigger found, skipping remaining steps");
|
console.log("No trigger found, skipping remaining steps");
|
||||||
@@ -50,9 +54,16 @@ async function run() {
|
|||||||
// Step 5: Check if actor is human
|
// Step 5: Check if actor is human
|
||||||
await checkHumanActor(octokit.rest, context);
|
await checkHumanActor(octokit.rest, context);
|
||||||
|
|
||||||
// Step 6: Create initial tracking comment
|
// Step 6: Create initial tracking comment (mode-aware)
|
||||||
const commentData = await createInitialComment(octokit.rest, context);
|
// Some modes (e.g., future review/freeform modes) may not need tracking comments
|
||||||
const commentId = commentData.id;
|
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)
|
// Step 7: Fetch GitHub data (once for both branch setup and prompt creation)
|
||||||
const githubData = await fetchGitHubData({
|
const githubData = await fetchGitHubData({
|
||||||
@@ -69,7 +80,7 @@ async function run() {
|
|||||||
// Step 9: Configure git authentication if not using commit signing
|
// Step 9: Configure git authentication if not using commit signing
|
||||||
if (!context.inputs.useCommitSigning) {
|
if (!context.inputs.useCommitSigning) {
|
||||||
try {
|
try {
|
||||||
await configureGitAuth(githubToken, context, commentData.user);
|
await configureGitAuth(githubToken, context, commentData?.user || null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to configure git authentication:", error);
|
console.error("Failed to configure git authentication:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -77,13 +88,13 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 10: Create prompt file
|
// Step 10: Create prompt file
|
||||||
await createPrompt(
|
const modeContext = mode.prepareContext(context, {
|
||||||
commentId,
|
commentId,
|
||||||
branchInfo.baseBranch,
|
baseBranch: branchInfo.baseBranch,
|
||||||
branchInfo.claudeBranch,
|
claudeBranch: branchInfo.claudeBranch,
|
||||||
githubData,
|
});
|
||||||
context,
|
|
||||||
);
|
await createPrompt(mode, modeContext, githubData, context);
|
||||||
|
|
||||||
// Step 11: Get MCP configuration
|
// Step 11: Get MCP configuration
|
||||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||||
@@ -94,7 +105,7 @@ async function run() {
|
|||||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||||
baseBranch: branchInfo.baseBranch,
|
baseBranch: branchInfo.baseBranch,
|
||||||
additionalMcpConfig,
|
additionalMcpConfig,
|
||||||
claudeCommentId: commentId.toString(),
|
claudeCommentId: commentId?.toString() || "",
|
||||||
allowedTools: context.inputs.allowedTools,
|
allowedTools: context.inputs.allowedTools,
|
||||||
context,
|
context,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
PullRequestReviewEvent,
|
PullRequestReviewEvent,
|
||||||
PullRequestReviewCommentEvent,
|
PullRequestReviewCommentEvent,
|
||||||
} from "@octokit/webhooks-types";
|
} from "@octokit/webhooks-types";
|
||||||
|
import type { ModeName } from "../modes/registry";
|
||||||
|
import { DEFAULT_MODE } from "../modes/registry";
|
||||||
|
import { isValidMode } from "../modes/registry";
|
||||||
|
|
||||||
export type ParsedGitHubContext = {
|
export type ParsedGitHubContext = {
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -27,6 +30,7 @@ export type ParsedGitHubContext = {
|
|||||||
entityNumber: number;
|
entityNumber: number;
|
||||||
isPR: boolean;
|
isPR: boolean;
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: ModeName;
|
||||||
triggerPhrase: string;
|
triggerPhrase: string;
|
||||||
assigneeTrigger: string;
|
assigneeTrigger: string;
|
||||||
labelTrigger: string;
|
labelTrigger: string;
|
||||||
@@ -46,6 +50,11 @@ export type ParsedGitHubContext = {
|
|||||||
export function parseGitHubContext(): ParsedGitHubContext {
|
export function parseGitHubContext(): ParsedGitHubContext {
|
||||||
const context = github.context;
|
const context = github.context;
|
||||||
|
|
||||||
|
const modeInput = process.env.MODE ?? DEFAULT_MODE;
|
||||||
|
if (!isValidMode(modeInput)) {
|
||||||
|
throw new Error(`Invalid mode: ${modeInput}.`);
|
||||||
|
}
|
||||||
|
|
||||||
const commonFields = {
|
const commonFields = {
|
||||||
runId: process.env.GITHUB_RUN_ID!,
|
runId: process.env.GITHUB_RUN_ID!,
|
||||||
eventName: context.eventName,
|
eventName: context.eventName,
|
||||||
@@ -57,6 +66,7 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
|||||||
},
|
},
|
||||||
actor: context.actor,
|
actor: context.actor,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: modeInput as ModeName,
|
||||||
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 ?? "",
|
||||||
|
|||||||
52
src/modes/registry.ts
Normal file
52
src/modes/registry.ts
Normal 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
40
src/modes/tag/index.ts
Normal 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
56
src/modes/types.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -24,6 +24,7 @@ describe("prepareMcpConfig", () => {
|
|||||||
entityNumber: 123,
|
entityNumber: 123,
|
||||||
isPR: false,
|
isPR: false,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "@octokit/webhooks-types";
|
} from "@octokit/webhooks-types";
|
||||||
|
|
||||||
const defaultInputs = {
|
const defaultInputs = {
|
||||||
|
mode: "tag" as const,
|
||||||
triggerPhrase: "/claude",
|
triggerPhrase: "/claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
|
|||||||
28
test/modes/registry.test.ts
Normal file
28
test/modes/registry.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
test/modes/tag.test.ts
Normal file
92
test/modes/tag.test.ts
Normal file
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,7 @@ describe("checkWritePermissions", () => {
|
|||||||
entityNumber: 1,
|
entityNumber: 1,
|
||||||
isPR: false,
|
isPR: false,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
eventName: "issues",
|
eventName: "issues",
|
||||||
eventAction: "opened",
|
eventAction: "opened",
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "/claude",
|
triggerPhrase: "/claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
@@ -60,6 +61,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
},
|
},
|
||||||
} as IssuesEvent,
|
} as IssuesEvent,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "/claude",
|
triggerPhrase: "/claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
@@ -276,6 +278,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
},
|
},
|
||||||
} as PullRequestEvent,
|
} as PullRequestEvent,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
@@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
},
|
},
|
||||||
} as PullRequestEvent,
|
} as PullRequestEvent,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
@@ -342,6 +346,7 @@ describe("checkContainsTrigger", () => {
|
|||||||
},
|
},
|
||||||
} as PullRequestEvent,
|
} as PullRequestEvent,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
mode: "tag",
|
||||||
triggerPhrase: "@claude",
|
triggerPhrase: "@claude",
|
||||||
assigneeTrigger: "",
|
assigneeTrigger: "",
|
||||||
labelTrigger: "",
|
labelTrigger: "",
|
||||||
|
|||||||
Reference in New Issue
Block a user