mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
Compare commits
36 Commits
v0.0.47
...
km/discrim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
665606ccf4 | ||
|
|
624adc1976 | ||
|
|
8207a5f128 | ||
|
|
aaae231799 | ||
|
|
68e711968d | ||
|
|
5bdfd090e0 | ||
|
|
9c8ca262b6 | ||
|
|
21680fe730 | ||
|
|
b1033b2ba1 | ||
|
|
f1ce8b3d62 | ||
|
|
ec0e9b4f87 | ||
|
|
af32fd318a | ||
|
|
5146c656e8 | ||
|
|
5da8dc78e6 | ||
|
|
1c2e7e788d | ||
|
|
b85d457dc6 | ||
|
|
3ebb5202a2 | ||
|
|
3402c5355d | ||
|
|
26d6ecc65d | ||
|
|
859e93f18e | ||
|
|
96970dfa2d | ||
|
|
11d4e4c175 | ||
|
|
bde5e40caf | ||
|
|
6dbeb928d1 | ||
|
|
cc8c99b7b4 | ||
|
|
aa2ef8067e | ||
|
|
a433016152 | ||
|
|
7a53e0529b | ||
|
|
dfe00d37c1 | ||
|
|
999dd8a3b6 | ||
|
|
a53ce607e4 | ||
|
|
02c804a6be | ||
|
|
cda7d07f95 | ||
|
|
65bfefd6c4 | ||
|
|
4a6d3cf183 | ||
|
|
3fef2d3f20 |
40
examples/workflow-dispatch-agent.yml
Normal file
40
examples/workflow-dispatch-agent.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Claude Commit Analysis
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
analysis_type:
|
||||
description: "Type of analysis to perform"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- summarize-commit
|
||||
- security-review
|
||||
default: "summarize-commit"
|
||||
|
||||
jobs:
|
||||
analyze-commit:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2 # Need at least 2 commits to analyze the latest
|
||||
|
||||
- name: Run Claude Analysis
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
mode: agent
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
override_prompt: |
|
||||
Analyze the latest commit in this repository.
|
||||
|
||||
${{ github.event.inputs.analysis_type == 'summarize-commit' && 'Task: Provide a clear, concise summary of what changed in the latest commit. Include the commit message, files changed, and the purpose of the changes.' || '' }}
|
||||
|
||||
${{ github.event.inputs.analysis_type == 'security-review' && 'Task: Review the latest commit for potential security vulnerabilities. Check for exposed secrets, insecure coding patterns, dependency vulnerabilities, or any other security concerns. Provide specific recommendations if issues are found.' || '' }}
|
||||
@@ -801,15 +801,18 @@ export async function createPrompt(
|
||||
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
|
||||
let claudeCommentId: string = "";
|
||||
if (mode.name === "tag") {
|
||||
if (!modeContext.commentId) {
|
||||
throw new Error("Tag mode requires a comment ID for prompt generation");
|
||||
}
|
||||
claudeCommentId = modeContext.commentId.toString();
|
||||
}
|
||||
|
||||
// Prepare the context for prompt generation
|
||||
const preparedContext = prepareContext(
|
||||
context,
|
||||
modeContext.commentId?.toString() || "",
|
||||
claudeCommentId,
|
||||
modeContext.baseBranch,
|
||||
modeContext.claudeBranch,
|
||||
);
|
||||
|
||||
@@ -7,17 +7,11 @@
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { setupGitHubToken } from "../github/token";
|
||||
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 { createOctokit } from "../github/api/client";
|
||||
import { fetchGitHubData } from "../github/data/fetcher";
|
||||
import { parseGitHubContext } from "../github/context";
|
||||
import { parseGitHubContext, isEntityContext } from "../github/context";
|
||||
import { getMode } from "../modes/registry";
|
||||
import { createPrompt } from "../create-prompt";
|
||||
import { prepare } from "../prepare";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
@@ -28,19 +22,21 @@ async function run() {
|
||||
// Step 2: Parse GitHub context (once for all operations)
|
||||
const context = parseGitHubContext();
|
||||
|
||||
// Step 3: Check write permissions
|
||||
const hasWritePermissions = await checkWritePermissions(
|
||||
octokit.rest,
|
||||
context,
|
||||
);
|
||||
if (!hasWritePermissions) {
|
||||
throw new Error(
|
||||
"Actor does not have write permissions to the repository",
|
||||
// Step 3: Check write permissions (only for entity contexts)
|
||||
if (isEntityContext(context)) {
|
||||
const hasWritePermissions = await checkWritePermissions(
|
||||
octokit.rest,
|
||||
context,
|
||||
);
|
||||
if (!hasWritePermissions) {
|
||||
throw new Error(
|
||||
"Actor does not have write permissions to the repository",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Get mode and check trigger conditions
|
||||
const mode = getMode(context.inputs.mode);
|
||||
const mode = getMode(context.inputs.mode, context);
|
||||
const containsTrigger = mode.shouldTrigger(context);
|
||||
|
||||
// Set output for action.yml to check
|
||||
@@ -51,65 +47,16 @@ async function run() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Check if actor is human
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
// Step 6: Create initial tracking comment (mode-aware)
|
||||
// Some modes (e.g., agent mode) 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({
|
||||
octokits: octokit,
|
||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||
prNumber: context.entityNumber.toString(),
|
||||
isPR: context.isPR,
|
||||
triggerUsername: context.actor,
|
||||
});
|
||||
|
||||
// Step 8: Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Step 9: Configure git authentication if not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, commentData?.user || null);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 10: Create prompt file
|
||||
const modeContext = mode.prepareContext(context, {
|
||||
commentId,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
await createPrompt(mode, modeContext, githubData, context);
|
||||
|
||||
// Step 11: Get MCP configuration
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: commentId?.toString() || "",
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
// Step 5: Use the new modular prepare function
|
||||
const result = await prepare({
|
||||
context,
|
||||
octokit,
|
||||
mode,
|
||||
githubToken,
|
||||
});
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
|
||||
// Set the MCP config output
|
||||
core.setOutput("mcp_config", result.mcpConfig);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
core.setFailed(`Prepare step failed with error: ${errorMessage}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
parseGitHubContext,
|
||||
isPullRequestReviewCommentEvent,
|
||||
isEntityContext,
|
||||
} from "../github/context";
|
||||
import { GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup";
|
||||
@@ -23,7 +24,14 @@ async function run() {
|
||||
const triggerUsername = process.env.TRIGGER_USERNAME;
|
||||
|
||||
const context = parseGitHubContext();
|
||||
|
||||
// This script is only called for entity-based events
|
||||
if (!isEntityContext(context)) {
|
||||
throw new Error("update-comment-link requires an entity context");
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repository;
|
||||
|
||||
const octokit = createOctokit(githubToken);
|
||||
|
||||
const serverUrl = GITHUB_SERVER_URL;
|
||||
|
||||
@@ -7,12 +7,54 @@ import type {
|
||||
PullRequestReviewEvent,
|
||||
PullRequestReviewCommentEvent,
|
||||
} from "@octokit/webhooks-types";
|
||||
// Custom types for GitHub Actions events that aren't webhooks
|
||||
export type WorkflowDispatchEvent = {
|
||||
action?: never;
|
||||
inputs?: Record<string, any>;
|
||||
ref?: string;
|
||||
repository: {
|
||||
name: string;
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
};
|
||||
sender: {
|
||||
login: string;
|
||||
};
|
||||
workflow: string;
|
||||
};
|
||||
|
||||
export type ScheduleEvent = {
|
||||
action?: never;
|
||||
schedule?: string;
|
||||
repository: {
|
||||
name: string;
|
||||
owner: {
|
||||
login: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
import type { ModeName } from "../modes/types";
|
||||
import { DEFAULT_MODE, isValidMode } from "../modes/registry";
|
||||
|
||||
export type ParsedGitHubContext = {
|
||||
// Event name constants for better maintainability
|
||||
const ENTITY_EVENT_NAMES = [
|
||||
"issues",
|
||||
"issue_comment",
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"pull_request_review_comment",
|
||||
] as const;
|
||||
|
||||
const AUTOMATION_EVENT_NAMES = ["workflow_dispatch", "schedule"] as const;
|
||||
|
||||
// Derive types from constants for better maintainability
|
||||
type EntityEventName = (typeof ENTITY_EVENT_NAMES)[number];
|
||||
type AutomationEventName = (typeof AUTOMATION_EVENT_NAMES)[number];
|
||||
|
||||
// Common fields shared by all context types
|
||||
type BaseContext = {
|
||||
runId: string;
|
||||
eventName: string;
|
||||
eventAction?: string;
|
||||
repository: {
|
||||
owner: string;
|
||||
@@ -20,14 +62,6 @@ export type ParsedGitHubContext = {
|
||||
full_name: string;
|
||||
};
|
||||
actor: string;
|
||||
payload:
|
||||
| IssuesEvent
|
||||
| IssueCommentEvent
|
||||
| PullRequestEvent
|
||||
| PullRequestReviewEvent
|
||||
| PullRequestReviewCommentEvent;
|
||||
entityNumber: number;
|
||||
isPR: boolean;
|
||||
inputs: {
|
||||
mode: ModeName;
|
||||
triggerPhrase: string;
|
||||
@@ -46,7 +80,29 @@ export type ParsedGitHubContext = {
|
||||
};
|
||||
};
|
||||
|
||||
export function parseGitHubContext(): ParsedGitHubContext {
|
||||
// Context for entity-based events (issues, PRs, comments)
|
||||
export type ParsedGitHubContext = BaseContext & {
|
||||
eventName: EntityEventName;
|
||||
payload:
|
||||
| IssuesEvent
|
||||
| IssueCommentEvent
|
||||
| PullRequestEvent
|
||||
| PullRequestReviewEvent
|
||||
| PullRequestReviewCommentEvent;
|
||||
entityNumber: number;
|
||||
isPR: boolean;
|
||||
};
|
||||
|
||||
// Context for automation events (workflow_dispatch, schedule)
|
||||
export type AutomationContext = BaseContext & {
|
||||
eventName: AutomationEventName;
|
||||
payload: WorkflowDispatchEvent | ScheduleEvent;
|
||||
};
|
||||
|
||||
// Union type for all contexts
|
||||
export type GitHubContext = ParsedGitHubContext | AutomationContext;
|
||||
|
||||
export function parseGitHubContext(): GitHubContext {
|
||||
const context = github.context;
|
||||
|
||||
const modeInput = process.env.MODE ?? DEFAULT_MODE;
|
||||
@@ -56,7 +112,6 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
|
||||
const commonFields = {
|
||||
runId: process.env.GITHUB_RUN_ID!,
|
||||
eventName: context.eventName,
|
||||
eventAction: context.payload.action,
|
||||
repository: {
|
||||
owner: context.repo.owner,
|
||||
@@ -86,49 +141,69 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
|
||||
switch (context.eventName) {
|
||||
case "issues": {
|
||||
const payload = context.payload as IssuesEvent;
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as IssuesEvent,
|
||||
entityNumber: (context.payload as IssuesEvent).issue.number,
|
||||
eventName: "issues",
|
||||
payload,
|
||||
entityNumber: payload.issue.number,
|
||||
isPR: false,
|
||||
};
|
||||
}
|
||||
case "issue_comment": {
|
||||
const payload = context.payload as IssueCommentEvent;
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as IssueCommentEvent,
|
||||
entityNumber: (context.payload as IssueCommentEvent).issue.number,
|
||||
isPR: Boolean(
|
||||
(context.payload as IssueCommentEvent).issue.pull_request,
|
||||
),
|
||||
eventName: "issue_comment",
|
||||
payload,
|
||||
entityNumber: payload.issue.number,
|
||||
isPR: Boolean(payload.issue.pull_request),
|
||||
};
|
||||
}
|
||||
case "pull_request": {
|
||||
const payload = context.payload as PullRequestEvent;
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as PullRequestEvent,
|
||||
entityNumber: (context.payload as PullRequestEvent).pull_request.number,
|
||||
eventName: "pull_request",
|
||||
payload,
|
||||
entityNumber: payload.pull_request.number,
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
case "pull_request_review": {
|
||||
const payload = context.payload as PullRequestReviewEvent;
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as PullRequestReviewEvent,
|
||||
entityNumber: (context.payload as PullRequestReviewEvent).pull_request
|
||||
.number,
|
||||
eventName: "pull_request_review",
|
||||
payload,
|
||||
entityNumber: payload.pull_request.number,
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
case "pull_request_review_comment": {
|
||||
const payload = context.payload as PullRequestReviewCommentEvent;
|
||||
return {
|
||||
...commonFields,
|
||||
payload: context.payload as PullRequestReviewCommentEvent,
|
||||
entityNumber: (context.payload as PullRequestReviewCommentEvent)
|
||||
.pull_request.number,
|
||||
eventName: "pull_request_review_comment",
|
||||
payload,
|
||||
entityNumber: payload.pull_request.number,
|
||||
isPR: true,
|
||||
};
|
||||
}
|
||||
case "workflow_dispatch": {
|
||||
return {
|
||||
...commonFields,
|
||||
eventName: "workflow_dispatch",
|
||||
payload: context.payload as unknown as WorkflowDispatchEvent,
|
||||
};
|
||||
}
|
||||
case "schedule": {
|
||||
return {
|
||||
...commonFields,
|
||||
eventName: "schedule",
|
||||
payload: context.payload as unknown as ScheduleEvent,
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported event type: ${context.eventName}`);
|
||||
}
|
||||
@@ -162,37 +237,53 @@ export function parseAdditionalPermissions(s: string): Map<string, string> {
|
||||
}
|
||||
|
||||
export function isIssuesEvent(
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||
return context.eventName === "issues";
|
||||
}
|
||||
|
||||
export function isIssueCommentEvent(
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssueCommentEvent } {
|
||||
return context.eventName === "issue_comment";
|
||||
}
|
||||
|
||||
export function isPullRequestEvent(
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: PullRequestEvent } {
|
||||
return context.eventName === "pull_request";
|
||||
}
|
||||
|
||||
export function isPullRequestReviewEvent(
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: PullRequestReviewEvent } {
|
||||
return context.eventName === "pull_request_review";
|
||||
}
|
||||
|
||||
export function isPullRequestReviewCommentEvent(
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: PullRequestReviewCommentEvent } {
|
||||
return context.eventName === "pull_request_review_comment";
|
||||
}
|
||||
|
||||
export function isIssuesAssignedEvent(
|
||||
context: ParsedGitHubContext,
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesAssignedEvent } {
|
||||
return isIssuesEvent(context) && context.eventAction === "assigned";
|
||||
}
|
||||
|
||||
// Type guard to check if context is an entity context (has entityNumber and isPR)
|
||||
export function isEntityContext(
|
||||
context: GitHubContext,
|
||||
): context is ParsedGitHubContext {
|
||||
return ENTITY_EVENT_NAMES.includes(context.eventName as EntityEventName);
|
||||
}
|
||||
|
||||
// Type guard to check if context is an automation context
|
||||
export function isAutomationContext(
|
||||
context: GitHubContext,
|
||||
): context is AutomationContext {
|
||||
return AUTOMATION_EVENT_NAMES.includes(
|
||||
context.eventName as AutomationEventName,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as core from "@actions/core";
|
||||
import { GITHUB_API_URL, GITHUB_SERVER_URL } from "../github/api/config";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function prepareMcpConfig(
|
||||
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
|
||||
REPO_OWNER: owner,
|
||||
REPO_NAME: repo,
|
||||
PR_NUMBER: context.entityNumber.toString(),
|
||||
PR_NUMBER: context.entityNumber?.toString() || "",
|
||||
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
|
||||
},
|
||||
};
|
||||
@@ -157,12 +157,9 @@ export async function prepareMcpConfig(
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-efef8ae", // https://github.com/github/github-mcp-server/releases/tag/v0.9.0
|
||||
"-e",
|
||||
"GITHUB_HOST",
|
||||
],
|
||||
env: {
|
||||
GITHUB_PERSONAL_ACCESS_TOKEN: githubToken,
|
||||
GITHUB_HOST: GITHUB_SERVER_URL,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
import type { Mode } from "../types";
|
||||
import * as core from "@actions/core";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { isAutomationContext } from "../../github/context";
|
||||
|
||||
/**
|
||||
* Agent mode implementation.
|
||||
*
|
||||
* This mode is designed for automation and workflow_dispatch scenarios.
|
||||
* It always triggers (no checking), allows highly flexible configurations,
|
||||
* and works well with override_prompt for custom workflows.
|
||||
*
|
||||
* In the future, this mode could restrict certain tools for safety in automation contexts,
|
||||
* e.g., disallowing WebSearch or limiting file system operations.
|
||||
* This mode is specifically designed for automation events (workflow_dispatch and schedule).
|
||||
* It bypasses the standard trigger checking and comment tracking used by tag mode,
|
||||
* making it ideal for scheduled tasks and manual workflow runs.
|
||||
*/
|
||||
export const agentMode: Mode = {
|
||||
name: "agent",
|
||||
description: "Automation mode that always runs without trigger checking",
|
||||
description: "Automation mode for workflow_dispatch and schedule events",
|
||||
|
||||
shouldTrigger() {
|
||||
return true;
|
||||
shouldTrigger(context) {
|
||||
// Only trigger for automation events
|
||||
return isAutomationContext(context);
|
||||
},
|
||||
|
||||
prepareContext(context, data) {
|
||||
prepareContext(context) {
|
||||
// Agent mode doesn't use comment tracking or branch management
|
||||
return {
|
||||
mode: "agent",
|
||||
githubContext: context,
|
||||
commentId: data?.commentId,
|
||||
baseBranch: data?.baseBranch,
|
||||
claudeBranch: data?.claudeBranch,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -39,4 +38,80 @@ export const agentMode: Mode = {
|
||||
shouldCreateTrackingComment() {
|
||||
return false;
|
||||
},
|
||||
|
||||
async prepare({ context }: ModeOptions): Promise<ModeResult> {
|
||||
// Agent mode handles automation events (workflow_dispatch, schedule) only
|
||||
|
||||
// Create prompt directory
|
||||
await mkdir(`${process.env.RUNNER_TEMP}/claude-prompts`, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Write the prompt file - the base action requires a prompt_file parameter,
|
||||
// so we must create this file even though agent mode typically uses
|
||||
// override_prompt or direct_prompt. If neither is provided, we write
|
||||
// a minimal prompt with just the repository information.
|
||||
const promptContent =
|
||||
context.inputs.overridePrompt ||
|
||||
context.inputs.directPrompt ||
|
||||
`Repository: ${context.repository.owner}/${context.repository.repo}`;
|
||||
|
||||
await writeFile(
|
||||
`${process.env.RUNNER_TEMP}/claude-prompts/claude-prompt.txt`,
|
||||
promptContent,
|
||||
);
|
||||
|
||||
// Export tool environment variables for agent mode
|
||||
const baseTools = [
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"Read",
|
||||
"Write",
|
||||
];
|
||||
|
||||
// Add user-specified tools
|
||||
const allowedTools = [...baseTools, ...context.inputs.allowedTools];
|
||||
const disallowedTools = [
|
||||
"WebSearch",
|
||||
"WebFetch",
|
||||
...context.inputs.disallowedTools,
|
||||
];
|
||||
|
||||
core.exportVariable("ALLOWED_TOOLS", allowedTools.join(","));
|
||||
core.exportVariable("DISALLOWED_TOOLS", disallowedTools.join(","));
|
||||
|
||||
// Agent mode uses a minimal MCP configuration
|
||||
// We don't need comment servers or PR-specific tools for automation
|
||||
const mcpConfig: any = {
|
||||
mcpServers: {},
|
||||
};
|
||||
|
||||
// Add user-provided additional MCP config if any
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
if (additionalMcpConfig.trim()) {
|
||||
try {
|
||||
const additional = JSON.parse(additionalMcpConfig);
|
||||
if (additional && typeof additional === "object") {
|
||||
Object.assign(mcpConfig, additional);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Failed to parse additional MCP config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.setOutput("mcp_config", JSON.stringify(mcpConfig));
|
||||
|
||||
return {
|
||||
commentId: undefined,
|
||||
branchInfo: {
|
||||
baseBranch: "",
|
||||
currentBranch: "",
|
||||
claudeBranch: undefined,
|
||||
},
|
||||
mcpConfig: JSON.stringify(mcpConfig),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import type { Mode, ModeName } from "./types";
|
||||
import { tagMode } from "./tag";
|
||||
import { agentMode } from "./agent";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import { isAutomationContext } from "../github/context";
|
||||
|
||||
export const DEFAULT_MODE = "tag" as const;
|
||||
export const VALID_MODES = ["tag", "agent"] as const;
|
||||
@@ -27,12 +29,13 @@ const modes = {
|
||||
} as const satisfies Record<ModeName, Mode>;
|
||||
|
||||
/**
|
||||
* Retrieves a mode by name.
|
||||
* 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
|
||||
* @throws Error if the mode is not found or cannot handle the event
|
||||
*/
|
||||
export function getMode(name: ModeName): Mode {
|
||||
export function getMode(name: ModeName, context: GitHubContext): Mode {
|
||||
const mode = modes[name];
|
||||
if (!mode) {
|
||||
const validModes = VALID_MODES.join("', '");
|
||||
@@ -40,6 +43,14 @@ export function getMode(name: ModeName): Mode {
|
||||
`Invalid mode '${name}'. Valid modes are: '${validModes}'. Please check your workflow configuration.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate mode can handle the event type
|
||||
if (name === "tag" && isAutomationContext(context)) {
|
||||
throw new Error(
|
||||
`Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events.`,
|
||||
);
|
||||
}
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { Mode } from "../types";
|
||||
import * as core from "@actions/core";
|
||||
import type { Mode, ModeOptions, ModeResult } from "../types";
|
||||
import { checkContainsTrigger } from "../../github/validation/trigger";
|
||||
import { checkHumanActor } from "../../github/validation/actor";
|
||||
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 { fetchGitHubData } from "../../github/data/fetcher";
|
||||
import { createPrompt } from "../../create-prompt";
|
||||
import { isEntityContext } from "../../github/context";
|
||||
|
||||
/**
|
||||
* Tag mode implementation.
|
||||
@@ -13,6 +22,10 @@ export const tagMode: Mode = {
|
||||
description: "Traditional implementation mode triggered by @claude mentions",
|
||||
|
||||
shouldTrigger(context) {
|
||||
// Tag mode only handles entity events
|
||||
if (!isEntityContext(context)) {
|
||||
return false;
|
||||
}
|
||||
return checkContainsTrigger(context);
|
||||
},
|
||||
|
||||
@@ -37,4 +50,74 @@ export const tagMode: Mode = {
|
||||
shouldCreateTrackingComment() {
|
||||
return true;
|
||||
},
|
||||
|
||||
async prepare({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
}: ModeOptions): Promise<ModeResult> {
|
||||
// Tag mode only handles entity-based events
|
||||
if (!isEntityContext(context)) {
|
||||
throw new Error("Tag mode requires entity context");
|
||||
}
|
||||
|
||||
// Check if actor is human
|
||||
await checkHumanActor(octokit.rest, context);
|
||||
|
||||
// Create initial tracking comment
|
||||
const commentData = await createInitialComment(octokit.rest, context);
|
||||
const commentId = commentData.id;
|
||||
|
||||
const githubData = await fetchGitHubData({
|
||||
octokits: octokit,
|
||||
repository: `${context.repository.owner}/${context.repository.repo}`,
|
||||
prNumber: context.entityNumber.toString(),
|
||||
isPR: context.isPR,
|
||||
triggerUsername: context.actor,
|
||||
});
|
||||
|
||||
// Setup branch
|
||||
const branchInfo = await setupBranch(octokit, githubData, context);
|
||||
|
||||
// Configure git authentication if not using commit signing
|
||||
if (!context.inputs.useCommitSigning) {
|
||||
try {
|
||||
await configureGitAuth(githubToken, context, commentData.user);
|
||||
} catch (error) {
|
||||
console.error("Failed to configure git authentication:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create prompt file
|
||||
const modeContext = this.prepareContext(context, {
|
||||
commentId,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
claudeBranch: branchInfo.claudeBranch,
|
||||
});
|
||||
|
||||
await createPrompt(tagMode, modeContext, githubData, context);
|
||||
|
||||
// Get MCP configuration
|
||||
const additionalMcpConfig = process.env.MCP_CONFIG || "";
|
||||
const mcpConfig = await prepareMcpConfig({
|
||||
githubToken,
|
||||
owner: context.repository.owner,
|
||||
repo: context.repository.repo,
|
||||
branch: branchInfo.claudeBranch || branchInfo.currentBranch,
|
||||
baseBranch: branchInfo.baseBranch,
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: commentId.toString(),
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
context,
|
||||
});
|
||||
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
|
||||
return {
|
||||
commentId,
|
||||
branchInfo,
|
||||
mcpConfig,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import type { GitHubContext } from "../github/context";
|
||||
|
||||
export type ModeName = "tag" | "agent";
|
||||
|
||||
export type ModeContext = {
|
||||
mode: ModeName;
|
||||
githubContext: ParsedGitHubContext;
|
||||
githubContext: GitHubContext;
|
||||
commentId?: number;
|
||||
baseBranch?: string;
|
||||
claudeBranch?: string;
|
||||
@@ -32,12 +32,12 @@ export type Mode = {
|
||||
/**
|
||||
* Determines if this mode should trigger based on the GitHub context
|
||||
*/
|
||||
shouldTrigger(context: ParsedGitHubContext): boolean;
|
||||
shouldTrigger(context: GitHubContext): boolean;
|
||||
|
||||
/**
|
||||
* Prepares the mode context with any additional data needed for prompt generation
|
||||
*/
|
||||
prepareContext(context: ParsedGitHubContext, data?: ModeData): ModeContext;
|
||||
prepareContext(context: GitHubContext, data?: ModeData): ModeContext;
|
||||
|
||||
/**
|
||||
* Returns the list of tools that should be allowed for this mode
|
||||
@@ -53,4 +53,28 @@ export type Mode = {
|
||||
* Determines if this mode should create a tracking comment
|
||||
*/
|
||||
shouldCreateTrackingComment(): boolean;
|
||||
|
||||
/**
|
||||
* Prepares the GitHub environment for this mode.
|
||||
* Each mode decides how to handle different event types.
|
||||
* @returns PrepareResult with commentId, branchInfo, and mcpConfig
|
||||
*/
|
||||
prepare(options: ModeOptions): Promise<ModeResult>;
|
||||
};
|
||||
|
||||
// Define types for mode prepare method to avoid circular dependencies
|
||||
export type ModeOptions = {
|
||||
context: GitHubContext;
|
||||
octokit: any; // We'll use any to avoid circular dependency with Octokits
|
||||
githubToken: string;
|
||||
};
|
||||
|
||||
export type ModeResult = {
|
||||
commentId?: number;
|
||||
branchInfo: {
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
};
|
||||
|
||||
20
src/prepare/index.ts
Normal file
20
src/prepare/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Main prepare module that delegates to the mode's prepare method
|
||||
*/
|
||||
|
||||
import type { PrepareOptions, PrepareResult } from "./types";
|
||||
|
||||
export async function prepare(options: PrepareOptions): Promise<PrepareResult> {
|
||||
const { mode, context, octokit, githubToken } = options;
|
||||
|
||||
console.log(
|
||||
`Preparing with mode: ${mode.name} for event: ${context.eventName}`,
|
||||
);
|
||||
|
||||
// Delegate to the mode's prepare method
|
||||
return mode.prepare({
|
||||
context,
|
||||
octokit,
|
||||
githubToken,
|
||||
});
|
||||
}
|
||||
20
src/prepare/types.ts
Normal file
20
src/prepare/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { GitHubContext } from "../github/context";
|
||||
import type { Octokits } from "../github/api/client";
|
||||
import type { Mode } from "../modes/types";
|
||||
|
||||
export type PrepareResult = {
|
||||
commentId?: number;
|
||||
branchInfo: {
|
||||
baseBranch: string;
|
||||
claudeBranch?: string;
|
||||
currentBranch: string;
|
||||
};
|
||||
mcpConfig: string;
|
||||
};
|
||||
|
||||
export type PrepareOptions = {
|
||||
context: GitHubContext;
|
||||
octokit: Octokits;
|
||||
mode: Mode;
|
||||
githubToken: string;
|
||||
};
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ParsedGitHubContext } from "../src/github/context";
|
||||
import type {
|
||||
ParsedGitHubContext,
|
||||
AutomationContext,
|
||||
} from "../src/github/context";
|
||||
import type {
|
||||
IssuesEvent,
|
||||
IssueCommentEvent,
|
||||
@@ -38,7 +41,7 @@ export const createMockContext = (
|
||||
): ParsedGitHubContext => {
|
||||
const baseContext: ParsedGitHubContext = {
|
||||
runId: "1234567890",
|
||||
eventName: "",
|
||||
eventName: "issue_comment", // Default to a valid entity event
|
||||
eventAction: "",
|
||||
repository: defaultRepository,
|
||||
actor: "test-actor",
|
||||
@@ -55,6 +58,22 @@ export const createMockContext = (
|
||||
return { ...baseContext, ...overrides };
|
||||
};
|
||||
|
||||
export const createMockAutomationContext = (
|
||||
overrides: Partial<AutomationContext> = {},
|
||||
): AutomationContext => {
|
||||
const baseContext: AutomationContext = {
|
||||
runId: "1234567890",
|
||||
eventName: "workflow_dispatch",
|
||||
eventAction: undefined,
|
||||
repository: defaultRepository,
|
||||
actor: "test-actor",
|
||||
payload: {} as any,
|
||||
inputs: defaultInputs,
|
||||
};
|
||||
|
||||
return { ...baseContext, ...overrides };
|
||||
};
|
||||
|
||||
export const mockIssueOpenedContext: ParsedGitHubContext = {
|
||||
runId: "1234567890",
|
||||
eventName: "issues",
|
||||
|
||||
@@ -1,82 +1,59 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { agentMode } from "../../src/modes/agent";
|
||||
import type { ParsedGitHubContext } from "../../src/github/context";
|
||||
import { createMockContext } from "../mockContext";
|
||||
import type { GitHubContext } from "../../src/github/context";
|
||||
import { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||
|
||||
describe("Agent Mode", () => {
|
||||
let mockContext: ParsedGitHubContext;
|
||||
let mockContext: GitHubContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockContext({
|
||||
mockContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
isPR: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("agent mode has correct properties and behavior", () => {
|
||||
// Basic properties
|
||||
test("agent mode has correct properties", () => {
|
||||
expect(agentMode.name).toBe("agent");
|
||||
expect(agentMode.description).toBe(
|
||||
"Automation mode that always runs without trigger checking",
|
||||
"Automation mode for workflow_dispatch and schedule events",
|
||||
);
|
||||
expect(agentMode.shouldCreateTrackingComment()).toBe(false);
|
||||
|
||||
// Tool methods return empty arrays
|
||||
expect(agentMode.getAllowedTools()).toEqual([]);
|
||||
expect(agentMode.getDisallowedTools()).toEqual([]);
|
||||
|
||||
// Always triggers regardless of context
|
||||
const contextWithoutTrigger = createMockContext({
|
||||
eventName: "workflow_dispatch",
|
||||
isPR: false,
|
||||
inputs: {
|
||||
...createMockContext().inputs,
|
||||
triggerPhrase: "@claude",
|
||||
},
|
||||
payload: {} as any,
|
||||
});
|
||||
expect(agentMode.shouldTrigger(contextWithoutTrigger)).toBe(true);
|
||||
});
|
||||
|
||||
test("prepareContext includes all required data", () => {
|
||||
const data = {
|
||||
commentId: 789,
|
||||
baseBranch: "develop",
|
||||
claudeBranch: "claude/automated-task",
|
||||
};
|
||||
|
||||
const context = agentMode.prepareContext(mockContext, data);
|
||||
|
||||
expect(context.mode).toBe("agent");
|
||||
expect(context.githubContext).toBe(mockContext);
|
||||
expect(context.commentId).toBe(789);
|
||||
expect(context.baseBranch).toBe("develop");
|
||||
expect(context.claudeBranch).toBe("claude/automated-task");
|
||||
});
|
||||
|
||||
test("prepareContext works without data", () => {
|
||||
test("prepareContext returns minimal data", () => {
|
||||
const context = agentMode.prepareContext(mockContext);
|
||||
|
||||
expect(context.mode).toBe("agent");
|
||||
expect(context.githubContext).toBe(mockContext);
|
||||
expect(context.commentId).toBeUndefined();
|
||||
expect(context.baseBranch).toBeUndefined();
|
||||
expect(context.claudeBranch).toBeUndefined();
|
||||
// Agent mode doesn't use comment tracking or branch management
|
||||
expect(Object.keys(context)).toEqual(["mode", "githubContext"]);
|
||||
});
|
||||
|
||||
test("agent mode triggers for all event types", () => {
|
||||
const events = [
|
||||
"push",
|
||||
"schedule",
|
||||
"workflow_dispatch",
|
||||
"repository_dispatch",
|
||||
test("agent mode only triggers for workflow_dispatch and schedule events", () => {
|
||||
// Should trigger for automation events
|
||||
const workflowDispatchContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
expect(agentMode.shouldTrigger(workflowDispatchContext)).toBe(true);
|
||||
|
||||
const scheduleContext = createMockAutomationContext({
|
||||
eventName: "schedule",
|
||||
});
|
||||
expect(agentMode.shouldTrigger(scheduleContext)).toBe(true);
|
||||
|
||||
// Should NOT trigger for entity events
|
||||
const entityEvents = [
|
||||
"issue_comment",
|
||||
"pull_request",
|
||||
];
|
||||
"pull_request_review",
|
||||
"issues",
|
||||
] as const;
|
||||
|
||||
events.forEach((eventName) => {
|
||||
const context = createMockContext({ eventName, isPR: false });
|
||||
expect(agentMode.shouldTrigger(context)).toBe(true);
|
||||
entityEvents.forEach((eventName) => {
|
||||
const context = createMockContext({ eventName });
|
||||
expect(agentMode.shouldTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,23 +3,60 @@ 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 { createMockContext, createMockAutomationContext } from "../mockContext";
|
||||
|
||||
describe("Mode Registry", () => {
|
||||
test("getMode returns tag mode by default", () => {
|
||||
const mode = getMode("tag");
|
||||
const mockContext = createMockContext({
|
||||
eventName: "issue_comment",
|
||||
});
|
||||
|
||||
const mockWorkflowDispatchContext = createMockAutomationContext({
|
||||
eventName: "workflow_dispatch",
|
||||
});
|
||||
|
||||
const mockScheduleContext = createMockAutomationContext({
|
||||
eventName: "schedule",
|
||||
});
|
||||
|
||||
test("getMode returns tag mode for standard events", () => {
|
||||
const mode = getMode("tag", mockContext);
|
||||
expect(mode).toBe(tagMode);
|
||||
expect(mode.name).toBe("tag");
|
||||
});
|
||||
|
||||
test("getMode returns agent mode", () => {
|
||||
const mode = getMode("agent");
|
||||
const mode = getMode("agent", mockContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode throws error for tag mode with workflow_dispatch event", () => {
|
||||
expect(() => getMode("tag", mockWorkflowDispatchContext)).toThrow(
|
||||
"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.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode allows agent mode for schedule event", () => {
|
||||
const mode = getMode("agent", mockScheduleContext);
|
||||
expect(mode).toBe(agentMode);
|
||||
expect(mode.name).toBe("agent");
|
||||
});
|
||||
|
||||
test("getMode throws error for invalid mode", () => {
|
||||
const invalidMode = "invalid" as unknown as ModeName;
|
||||
expect(() => getMode(invalidMode)).toThrow(
|
||||
expect(() => getMode(invalidMode, mockContext)).toThrow(
|
||||
"Invalid mode 'invalid'. Valid modes are: 'tag', 'agent'. Please check your workflow configuration.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -299,15 +299,4 @@ describe("parseEnvVarsWithContext", () => {
|
||||
expect(result.allowedTools).toBe("Tool1,Tool2");
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for unsupported event type", () => {
|
||||
process.env = BASE_ENV;
|
||||
const unsupportedContext = createMockContext({
|
||||
eventName: "unsupported_event",
|
||||
eventAction: "whatever",
|
||||
});
|
||||
expect(() => prepareContext(unsupportedContext, "12345")).toThrow(
|
||||
"Unsupported event type: unsupported_event",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -474,17 +474,6 @@ describe("checkContainsTrigger", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-matching events", () => {
|
||||
it("should return false for non-matching event type", () => {
|
||||
const context = createMockContext({
|
||||
eventName: "push",
|
||||
eventAction: "created",
|
||||
payload: {} as any,
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeRegExp", () => {
|
||||
|
||||
Reference in New Issue
Block a user