add schedule & workflow dispatch paths. Also make prepare logic conditional (#353)

* feat: add agent mode for automation scenarios

- Add agent mode that always triggers without checking for mentions
- Implement Mode interface with support for mode-specific tool configuration
- Add getAllowedTools() and getDisallowedTools() methods to Mode interface
- Simplify tests by combining related test cases
- Update documentation and examples to include agent mode
- Fix TypeScript imports to prevent circular dependencies

Agent mode is designed for automation and workflow_dispatch scenarios
where Claude should always run without requiring trigger phrases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Minor update to readme (from @main to @beta)

* Since workflow_dispatch isn't in the base action, update the examples accordingly

* minor formatting issue

* Update to say beta instead of main

* Fix missed tracking comment to be false

* add schedule & workflow dispatch paths. Also make prepare logic conditional

* tests

* Add test workflow for workflow_dispatch functionality

* Update workflow to use correct branch reference

* remove test workflow dispatch file

* minor lint update

* update workflow dispatch agent example

* minor lint update

* refactor: simplify prepare logic with mode-specific implementations

* ensure tag mode can't work with workflow dispatch and schedule tasks

* simplify: remove workflow_dispatch/schedule from create-prompt

- Remove workflow_dispatch and schedule event handling from create-prompt
  since agent mode doesn't use the standard prompt generation flow
- Enforce mode compatibility at selection time in the registry instead
  of runtime validation in tag mode
- Add explanatory comment in agent mode about why prompt file is needed
- Update tests to reflect simplified event handling

This reduces code duplication and makes the separation between tag mode
(entity-based events) and agent mode (automation events) clearer.

* simplify PR by making agent mode only work with workflow dispatch and schedule events

* remove unnecessary changes

* remove unnecessary changes from PR

- Revert update-comment-link.ts changes (agent mode doesn't use this)
- Revert create-initial.ts changes (agent mode doesn't create comments)
- Remove unused default-branch.ts file
- Revert install-mcp-server.ts changes (agent mode uses minimal MCP)

These files are only used by tag mode for entity-based events, not needed
for workflow_dispatch/schedule support via agent mode.

* fix: handle optional entityNumber for TypeScript

- Add runtime checks in files that require entityNumber
- These files are only used by tag mode which always has entityNumber
- Agent mode (workflow_dispatch/schedule) doesn't use these files

* linting update

---------

Co-authored-by: km-anthropic <km-anthropic@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
km-anthropic
2025-07-29 11:52:45 -07:00
committed by GitHub
parent af32fd318a
commit ec0e9b4f87
15 changed files with 447 additions and 147 deletions

View File

@@ -125,8 +125,10 @@ export function prepareContext(
const isPR = context.isPR;
// Get PR/Issue number from entityNumber
const prNumber = isPR ? context.entityNumber.toString() : undefined;
const issueNumber = !isPR ? context.entityNumber.toString() : undefined;
const prNumber =
isPR && context.entityNumber ? context.entityNumber.toString() : undefined;
const issueNumber =
!isPR && context.entityNumber ? context.entityNumber.toString() : undefined;
// Extract trigger username and comment data based on event type
let triggerUsername: string | undefined;
@@ -801,15 +803,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,
);

View File

@@ -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 { getMode } from "../modes/registry";
import { createPrompt } from "../create-prompt";
import { prepare } from "../prepare";
async function run() {
try {
@@ -40,7 +34,7 @@ async function run() {
}
// 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 +45,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}`);

View File

@@ -24,6 +24,13 @@ async function run() {
const context = parseGitHubContext();
const { owner, repo } = context.repository;
// This script is only called for entity-based events
if (!context.entityNumber) {
throw new Error("update-comment-link requires an entity number");
}
const entityNumber = context.entityNumber;
const octokit = createOctokit(githubToken);
const serverUrl = GITHUB_SERVER_URL;
@@ -73,7 +80,7 @@ async function run() {
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo,
pull_number: context.entityNumber,
pull_number: entityNumber,
});
console.log(`PR state: ${pr.state}`);
console.log(`PR comments count: ${pr.comments}`);

View File

@@ -7,6 +7,33 @@ 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";
@@ -25,9 +52,11 @@ export type ParsedGitHubContext = {
| IssueCommentEvent
| PullRequestEvent
| PullRequestReviewEvent
| PullRequestReviewCommentEvent;
entityNumber: number;
isPR: boolean;
| PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| ScheduleEvent;
entityNumber?: number;
isPR?: boolean;
inputs: {
mode: ModeName;
triggerPhrase: string;
@@ -129,6 +158,20 @@ export function parseGitHubContext(): ParsedGitHubContext {
isPR: true,
};
}
case "workflow_dispatch": {
return {
...commonFields,
payload: context.payload as unknown as WorkflowDispatchEvent,
// No entityNumber or isPR for workflow_dispatch
};
}
case "schedule": {
return {
...commonFields,
payload: context.payload as unknown as ScheduleEvent,
// No entityNumber or isPR for schedule
};
}
default:
throw new Error(`Unsupported event type: ${context.eventName}`);
}

View File

@@ -22,6 +22,12 @@ export async function createInitialComment(
) {
const { owner, repo } = context.repository;
// This function is only called for entity-based events
if (!context.entityNumber) {
throw new Error("createInitialComment requires an entity number");
}
const entityNumber = context.entityNumber;
const jobRunLink = createJobRunLink(owner, repo, context.runId);
const initialBody = createCommentBody(jobRunLink);
@@ -36,7 +42,7 @@ export async function createInitialComment(
const comments = await octokit.rest.issues.listComments({
owner,
repo,
issue_number: context.entityNumber,
issue_number: entityNumber,
});
const existingComment = comments.data.find((comment) => {
const idMatch = comment.user?.id === CLAUDE_APP_BOT_ID;
@@ -59,7 +65,7 @@ export async function createInitialComment(
response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: context.entityNumber,
issue_number: entityNumber,
body: initialBody,
});
}
@@ -68,7 +74,7 @@ export async function createInitialComment(
response = await octokit.rest.pulls.createReplyForReviewComment({
owner,
repo,
pull_number: context.entityNumber,
pull_number: entityNumber,
comment_id: context.payload.comment.id,
body: initialBody,
});
@@ -77,7 +83,7 @@ export async function createInitialComment(
response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: context.entityNumber,
issue_number: entityNumber,
body: initialBody,
});
}
@@ -95,7 +101,7 @@ export async function createInitialComment(
const response = await octokit.rest.issues.createComment({
owner,
repo,
issue_number: context.entityNumber,
issue_number: entityNumber,
body: initialBody,
});

View File

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

View File

@@ -1,30 +1,31 @@
import type { Mode } from "../types";
import * as core from "@actions/core";
import { mkdir, writeFile } from "fs/promises";
import type { Mode, ModeOptions, ModeResult } from "../types";
/**
* 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 (
context.eventName === "workflow_dispatch" ||
context.eventName === "schedule"
);
},
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 +40,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),
};
},
};

View File

@@ -13,6 +13,7 @@
import type { Mode, ModeName } from "./types";
import { tagMode } from "./tag";
import { agentMode } from "./agent";
import type { ParsedGitHubContext } from "../github/context";
export const DEFAULT_MODE = "tag" as const;
export const VALID_MODES = ["tag", "agent"] as const;
@@ -27,12 +28,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: ParsedGitHubContext): Mode {
const mode = modes[name];
if (!mode) {
const validModes = VALID_MODES.join("', '");
@@ -40,6 +42,18 @@ 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" &&
(context.eventName === "workflow_dispatch" ||
context.eventName === "schedule")
) {
throw new Error(
`Tag mode cannot handle ${context.eventName} events. Use 'agent' mode for automation events.`,
);
}
return mode;
}

View File

@@ -1,5 +1,13 @@
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";
/**
* Tag mode implementation.
@@ -37,4 +45,76 @@ export const tagMode: Mode = {
shouldCreateTrackingComment() {
return true;
},
async prepare({
context,
octokit,
githubToken,
}: ModeOptions): Promise<ModeResult> {
// Tag mode handles entity-based events (issues, PRs, comments)
// 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;
// Fetch GitHub data - entity events always have entityNumber and isPR
if (!context.entityNumber || context.isPR === undefined) {
throw new Error("Entity events must have entityNumber and isPR defined");
}
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,
};
},
};

View File

@@ -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: ParsedGitHubContext;
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
View 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
View File

@@ -0,0 +1,20 @@
import type { ParsedGitHubContext } 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: ParsedGitHubContext;
octokit: Octokits;
mode: Mode;
githubToken: string;
};