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

This commit is contained in:
km-anthropic
2025-07-28 11:28:06 -07:00
parent a53ce607e4
commit 999dd8a3b6
13 changed files with 479 additions and 90 deletions

View File

@@ -0,0 +1,49 @@
name: Claude Automation Workflow
on:
workflow_dispatch:
inputs:
task:
description: "Task for Claude to perform"
required: true
type: string
default: "Update dependencies and run tests"
branch:
description: "Branch to work on"
required: false
type: string
default: "main"
jobs:
claude-automation:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || 'main' }}
- name: Run Claude Code in Agent Mode
uses: anthropics/claude-code-action@beta
with:
mode: agent
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
override_prompt: |
You are running in an automated workflow context.
Task: ${{ github.event.inputs.task }}
Current branch: ${{ github.event.inputs.branch || 'main' }}
Please complete the requested task. Remember:
- You have full access to the repository
- You can make commits and push changes
- Focus on the specific task provided
- Provide clear feedback about what you're doing

View File

@@ -34,6 +34,16 @@ const BASE_ALLOWED_TOOLS = [
];
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
function getEntityNumberXml(eventData: EventData): string {
if (eventData.isPR && "prNumber" in eventData) {
return `<pr_number>${eventData.prNumber}</pr_number>`;
}
if ("issueNumber" in eventData) {
return `<issue_number>${eventData.issueNumber}</issue_number>`;
}
return "";
}
export function buildAllowedToolsString(
customAllowedTools?: string[],
includeActionsTools: boolean = false,
@@ -125,8 +135,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;
@@ -338,6 +350,24 @@ export function prepareContext(
};
break;
case "workflow_dispatch":
eventData = {
eventName: "workflow_dispatch",
isPR: false,
...(baseBranch && { baseBranch }),
...(claudeBranch && { claudeBranch }),
};
break;
case "schedule":
eventData = {
eventName: "schedule",
isPR: false,
...(baseBranch && { baseBranch }),
...(claudeBranch && { claudeBranch }),
};
break;
default:
throw new Error(`Unsupported event type: ${eventName}`);
}
@@ -400,6 +430,18 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
: `pull request event`,
};
case "workflow_dispatch":
return {
eventType: "WORKFLOW_DISPATCH",
triggerContext: `workflow dispatch event`,
};
case "schedule":
return {
eventType: "SCHEDULE",
triggerContext: `scheduled automation event`,
};
default:
throw new Error(`Unexpected event type`);
}
@@ -407,11 +449,12 @@ export function getEventTypeAndContext(envVars: PreparedContext): {
function getCommitInstructions(
eventData: EventData,
githubData: FetchDataResult,
githubData: FetchDataResult | null,
context: PreparedContext,
useCommitSigning: boolean,
): string {
const coAuthorLine =
githubData &&
(githubData.triggerDisplayName ?? context.triggerUsername !== "Unknown")
? `Co-authored-by: ${githubData.triggerDisplayName ?? context.triggerUsername} <${context.triggerUsername}@users.noreply.github.com>`
: "";
@@ -466,8 +509,26 @@ function getCommitInstructions(
function substitutePromptVariables(
template: string,
context: PreparedContext,
githubData: FetchDataResult,
githubData: FetchDataResult | null,
): string {
// Handle automation events without GitHub data
if (!githubData) {
const { eventData } = context;
const variables: Record<string, string> = {
EVENT_TYPE: eventData.eventName,
REPOSITORY: context.repository,
TRIGGER_USERNAME: context.triggerUsername ?? "automation",
CURRENT_BRANCH: eventData.claudeBranch || eventData.baseBranch || "main",
BASE_BRANCH: eventData.baseBranch || "main",
};
return Object.entries(variables).reduce(
(prompt, [key, value]) =>
prompt.replace(new RegExp(`{{${key}}}`, "g"), value),
template,
);
}
const { contextData, comments, reviewData, changedFilesWithSHA } = githubData;
const { eventData } = context;
@@ -528,7 +589,7 @@ function substitutePromptVariables(
export function generatePrompt(
context: PreparedContext,
githubData: FetchDataResult,
githubData: FetchDataResult | null,
useCommitSigning: boolean,
): string {
if (context.overridePrompt) {
@@ -539,6 +600,59 @@ export function generatePrompt(
);
}
const { eventData } = context;
// Handle automation events that don't have GitHub data
if (!githubData) {
// For automation events, we have minimal context
const { eventType, triggerContext } = getEventTypeAndContext(context);
let promptContent = `You are Claude, an AI assistant designed to help with GitHub ${eventData.eventName === "workflow_dispatch" ? "workflow dispatch" : "scheduled automation"} tasks. Think carefully as you analyze the context and respond appropriately. Here's the context for your current task:
<formatted_context>
Repository: ${context.repository}
Event Type: ${eventData.eventName}
Current Branch: ${eventData.claudeBranch || eventData.baseBranch || "main"}
Base Branch: ${eventData.baseBranch || "main"}
Actor: ${context.triggerUsername ?? "automation"}
</formatted_context>
<event_type>${eventType}</event_type>
<is_pr>false</is_pr>
<trigger_context>${triggerContext}</trigger_context>
<repository>${context.repository}</repository>
<trigger_username>${context.triggerUsername ?? "automation"}</trigger_username>
${
context.directPrompt
? `<direct_prompt>
IMPORTANT: The following are direct instructions from the automation workflow:
${sanitizeContent(context.directPrompt)}
</direct_prompt>`
: ""
}
<instructions>
You have been triggered by an automated workflow. Follow these guidelines:
1. **Context**: You are running in an automated context without a specific issue or PR.
2. **Branch**: You are currently on the ${eventData.claudeBranch || eventData.baseBranch || "main"} branch.
3. **Tools**: You have access to file system and Git tools to make changes.
${
useCommitSigning
? "4. **Commits**: Use the MCP file operations server for signed commits."
: "4. **Commits**: You can commit changes directly using Git."
}
5. **Scope**: Focus on the task described${context.directPrompt ? " in the direct_prompt above" : ""}.
Please proceed with the automated task.
</instructions>
${context.customInstructions || ""}`;
return promptContent;
}
const {
contextData,
comments,
@@ -546,11 +660,10 @@ export function generatePrompt(
reviewData,
imageUrlMap,
} = githubData;
const { eventData } = context;
const { eventType, triggerContext } = getEventTypeAndContext(context);
const formattedContext = formatContext(contextData, eventData.isPR);
const formattedContext = formatContext(contextData, eventData.isPR ?? false);
const formattedComments = formatComments(comments, imageUrlMap);
const formattedReviewComments = eventData.isPR
? formatReviewComments(reviewData, imageUrlMap)
@@ -596,17 +709,13 @@ ${eventData.isPR ? formattedChangedFiles || "No files changed" : ""}
</changed_files>${imagesInfo}
<event_type>${eventType}</event_type>
<is_pr>${eventData.isPR ? "true" : "false"}</is_pr>
<is_pr>${(eventData.isPR ?? false) ? "true" : "false"}</is_pr>
<trigger_context>${triggerContext}</trigger_context>
<repository>${context.repository}</repository>
${
eventData.isPR
? `<pr_number>${eventData.prNumber}</pr_number>`
: `<issue_number>${eventData.issueNumber ?? ""}</issue_number>`
}
${getEntityNumberXml(eventData)}
<claude_comment_id>${context.claudeCommentId}</claude_comment_id>
<trigger_username>${context.triggerUsername ?? "Unknown"}</trigger_username>
<trigger_display_name>${githubData.triggerDisplayName ?? context.triggerUsername ?? "Unknown"}</trigger_display_name>
<trigger_display_name>${githubData?.triggerDisplayName ?? context.triggerUsername ?? "Unknown"}</trigger_display_name>
<trigger_phrase>${context.triggerPhrase}</trigger_phrase>
${
(eventData.eventName === "issue_comment" ||
@@ -797,7 +906,7 @@ f. If you are unable to complete certain steps, such as running a linter or test
export async function createPrompt(
mode: Mode,
modeContext: ModeContext,
githubData: FetchDataResult,
githubData: FetchDataResult | null,
context: ParsedGitHubContext,
) {
try {

View File

@@ -88,6 +88,20 @@ type PullRequestEvent = {
baseBranch?: string;
};
type WorkflowDispatchEvent = {
eventName: "workflow_dispatch";
isPR?: false;
baseBranch?: string;
claudeBranch?: string;
};
type ScheduleEvent = {
eventName: "schedule";
isPR?: false;
baseBranch?: string;
claudeBranch?: string;
};
// Union type for all possible event types
export type EventData =
| PullRequestReviewCommentEvent
@@ -97,7 +111,9 @@ export type EventData =
| IssueOpenedEvent
| IssueAssignedEvent
| IssueLabeledEvent
| PullRequestEvent;
| PullRequestEvent
| WorkflowDispatchEvent
| ScheduleEvent;
// Combined type with separate eventData field
export type PreparedContext = CommonFields & {

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 {
@@ -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 entityNumber");
}
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,34 @@ 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 +53,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 +159,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;
// Entity events should always have entityNumber
if (!context.entityNumber) {
throw new Error("createInitialComment requires an entityNumber");
}
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

@@ -0,0 +1,13 @@
import type { Octokit } from "@octokit/rest";
export async function getDefaultBranch(
rest: Octokit,
owner: string,
repo: string,
): Promise<string> {
const repoResponse = await rest.repos.get({
owner,
repo,
});
return repoResponse.data.default_branch;
}

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

@@ -0,0 +1,70 @@
/**
* Prepare logic for automation events (workflow_dispatch, schedule)
* These events don't have associated GitHub entities and require minimal setup
*/
import * as core from "@actions/core";
import { prepareMcpConfig } from "../mcp/install-mcp-server";
import { createPrompt } from "../create-prompt";
import { getDefaultBranch } from "../github/operations/default-branch";
import type { PrepareOptions, PrepareResult } from "./types";
export async function prepareAutomationEvent({
context,
octokit,
mode,
githubToken,
}: PrepareOptions): Promise<PrepareResult> {
// For automation events, we skip:
// - Human actor check (it's automation)
// - Tracking comment (no issue/PR to comment on)
// - GitHub data fetching (no entity to fetch)
// - Branch setup (use default branch or current branch)
// Get the default branch or use the one specified in inputs
const baseBranch =
context.inputs.baseBranch ||
(await getDefaultBranch(
octokit.rest,
context.repository.owner,
context.repository.repo,
));
// For automation events, we stay on the current branch (typically main/master)
const branchInfo = {
baseBranch,
currentBranch: baseBranch,
claudeBranch: undefined,
};
// Create prompt file with minimal context
const modeContext = mode.prepareContext(context, {
baseBranch: branchInfo.baseBranch,
claudeBranch: branchInfo.claudeBranch,
});
// Pass null for githubData since automation events don't have associated entities
await createPrompt(mode, modeContext, null, 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.currentBranch,
baseBranch: branchInfo.baseBranch,
additionalMcpConfig,
claudeCommentId: "",
allowedTools: context.inputs.allowedTools,
context,
});
core.setOutput("mcp_config", mcpConfig);
return {
commentId: undefined,
branchInfo,
mcpConfig,
};
}

View File

@@ -0,0 +1,89 @@
/**
* Prepare logic for entity-based events (issues, PRs, comments)
* These events have associated GitHub entities that need to be fetched and managed
*/
import * as core from "@actions/core";
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 type { PrepareOptions, PrepareResult } from "./types";
export async function prepareEntityEvent({
context,
octokit,
mode,
githubToken,
}: PrepareOptions): Promise<PrepareResult> {
// Check if actor is human
await checkHumanActor(octokit.rest, context);
// Create initial tracking comment (mode-aware)
let commentId: number | undefined;
let commentData: Awaited<ReturnType<typeof createInitialComment>> | undefined;
if (mode.shouldCreateTrackingComment()) {
commentData = await createInitialComment(octokit.rest, context);
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 || null);
} catch (error) {
console.error("Failed to configure git authentication:", error);
throw error;
}
}
// Create prompt file
const modeContext = mode.prepareContext(context, {
commentId,
baseBranch: branchInfo.baseBranch,
claudeBranch: branchInfo.claudeBranch,
});
await createPrompt(mode, 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,
};
}

21
src/prepare/index.ts Normal file
View File

@@ -0,0 +1,21 @@
/**
* Main prepare module that routes to appropriate prepare logic based on event type
*/
import type { PrepareOptions, PrepareResult } from "./types";
import { prepareEntityEvent } from "./entity-events";
import { prepareAutomationEvent } from "./automation-events";
const AUTOMATION_EVENTS = ["workflow_dispatch", "schedule"];
export async function prepare(options: PrepareOptions): Promise<PrepareResult> {
const { context } = options;
if (AUTOMATION_EVENTS.includes(context.eventName)) {
console.log(`Preparing automation event: ${context.eventName}`);
return prepareAutomationEvent(options);
}
console.log(`Preparing entity-based event: ${context.eventName}`);
return prepareEntityEvent(options);
}

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;
};