mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-23 06:54:13 +08:00
feat: add repository_dispatch event support
- Add new progress MCP server for reporting task status via API
- Support repository_dispatch events with task description and progress endpoint
- Introduce isDispatch flag to unify dispatch event handling
- Make GitHub data optional for dispatch events without issues/PRs
- Update prompt generation with dispatch-specific instructions
Enables triggering Claude via repository_dispatch with:
{
"event_type": "claude_task",
"client_payload": {
"description": "Task description",
"progress_endpoint": "https://api.example.com/progress"
}
}
This commit is contained in:
@@ -30,7 +30,8 @@ async function run() {
|
||||
appendSystemPrompt: process.env.INPUT_APPEND_SYSTEM_PROMPT,
|
||||
claudeEnv: process.env.INPUT_CLAUDE_ENV,
|
||||
fallbackModel: process.env.INPUT_FALLBACK_MODEL,
|
||||
model: process.env.ANTHROPIC_MODEL,
|
||||
resumeEndpoint: process.env.INPUT_RESUME_ENDPOINT,
|
||||
streamConfig: process.env.INPUT_STREAM_CONFIG,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(`Action failed with error: ${error}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { promisify } from "util";
|
||||
import { unlink, writeFile, stat } from "fs/promises";
|
||||
import { createWriteStream } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { StreamHandler } from "./stream-handler";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -21,7 +22,15 @@ export type ClaudeOptions = {
|
||||
claudeEnv?: string;
|
||||
fallbackModel?: string;
|
||||
timeoutMinutes?: string;
|
||||
model?: string;
|
||||
resumeEndpoint?: string;
|
||||
streamConfig?: string;
|
||||
};
|
||||
|
||||
export type StreamConfig = {
|
||||
progress_endpoint?: string;
|
||||
headers?: Record<string, string>;
|
||||
resume_endpoint?: string;
|
||||
session_id?: string;
|
||||
};
|
||||
|
||||
type PreparedConfig = {
|
||||
@@ -95,9 +104,6 @@ export function prepareRunConfig(
|
||||
if (options.fallbackModel) {
|
||||
claudeArgs.push("--fallback-model", options.fallbackModel);
|
||||
}
|
||||
if (options.model) {
|
||||
claudeArgs.push("--model", options.model);
|
||||
}
|
||||
if (options.timeoutMinutes) {
|
||||
const timeoutMinutesNum = parseInt(options.timeoutMinutes, 10);
|
||||
if (isNaN(timeoutMinutesNum) || timeoutMinutesNum <= 0) {
|
||||
@@ -106,6 +112,25 @@ export function prepareRunConfig(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (options.resumeEndpoint) {
|
||||
claudeArgs.push("--teleport", options.resumeEndpoint);
|
||||
}
|
||||
// Parse stream config for session_id and resume_endpoint
|
||||
if (options.streamConfig) {
|
||||
try {
|
||||
const streamConfig: StreamConfig = JSON.parse(options.streamConfig);
|
||||
// Add --session-id if session_id is provided
|
||||
if (streamConfig.session_id) {
|
||||
claudeArgs.push("--session-id", streamConfig.session_id);
|
||||
}
|
||||
// Only add --teleport if we have both session_id AND resume_endpoint
|
||||
if (streamConfig.session_id && streamConfig.resume_endpoint) {
|
||||
claudeArgs.push("--teleport", streamConfig.session_id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream_config JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse custom environment variables
|
||||
const customEnv = parseCustomEnvVars(options.claudeEnv);
|
||||
@@ -120,6 +145,34 @@ export function prepareRunConfig(
|
||||
export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
const config = prepareRunConfig(promptPath, options);
|
||||
|
||||
// Set up streaming if endpoint is provided in stream config
|
||||
let streamHandler: StreamHandler | null = null;
|
||||
let streamConfig: StreamConfig | null = null;
|
||||
if (options.streamConfig) {
|
||||
try {
|
||||
streamConfig = JSON.parse(options.streamConfig);
|
||||
if (streamConfig?.progress_endpoint) {
|
||||
const customHeaders = streamConfig.headers || {};
|
||||
console.log("parsed headers", customHeaders);
|
||||
Object.keys(customHeaders).forEach((key) => {
|
||||
console.log(`Custom header: ${key} = ${customHeaders[key]}`);
|
||||
});
|
||||
streamHandler = new StreamHandler(
|
||||
streamConfig.progress_endpoint,
|
||||
customHeaders,
|
||||
);
|
||||
console.log(`Streaming output to: ${streamConfig.progress_endpoint}`);
|
||||
if (Object.keys(customHeaders).length > 0) {
|
||||
console.log(
|
||||
`Custom streaming headers: ${Object.keys(customHeaders).join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream_config JSON:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a named pipe
|
||||
try {
|
||||
await unlink(PIPE_PATH);
|
||||
@@ -162,12 +215,31 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
pipeStream.destroy();
|
||||
});
|
||||
|
||||
// Prepare environment variables
|
||||
const processEnv = {
|
||||
...process.env,
|
||||
...config.env,
|
||||
};
|
||||
|
||||
// If both session_id and resume_endpoint are provided, set environment variables
|
||||
if (streamConfig?.session_id && streamConfig?.resume_endpoint) {
|
||||
processEnv.TELEPORT_RESUME_URL = streamConfig.resume_endpoint;
|
||||
console.log(
|
||||
`Setting TELEPORT_RESUME_URL to: ${streamConfig.resume_endpoint}`,
|
||||
);
|
||||
|
||||
if (streamConfig.headers && Object.keys(streamConfig.headers).length > 0) {
|
||||
processEnv.TELEPORT_HEADERS = JSON.stringify(streamConfig.headers);
|
||||
console.log(`Setting TELEPORT_HEADERS for resume endpoint`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log the full Claude command being executed
|
||||
console.log(`Running Claude with args: ${config.claudeArgs.join(" ")}`);
|
||||
|
||||
const claudeProcess = spawn("claude", config.claudeArgs, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env,
|
||||
...config.env,
|
||||
},
|
||||
env: processEnv,
|
||||
});
|
||||
|
||||
// Handle Claude process errors
|
||||
@@ -178,32 +250,51 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
|
||||
// Capture output for parsing execution metrics
|
||||
let output = "";
|
||||
claudeProcess.stdout.on("data", (data) => {
|
||||
let lineBuffer = ""; // Buffer for incomplete lines
|
||||
|
||||
claudeProcess.stdout.on("data", async (data) => {
|
||||
const text = data.toString();
|
||||
output += text;
|
||||
|
||||
// Try to parse as JSON and pretty print if it's on a single line
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line: string, index: number) => {
|
||||
if (line.trim() === "") return;
|
||||
// Add new data to line buffer
|
||||
lineBuffer += text;
|
||||
|
||||
// Split into lines - the last element might be incomplete
|
||||
const lines = lineBuffer.split("\n");
|
||||
|
||||
// The last element is either empty (if text ended with \n) or incomplete
|
||||
lineBuffer = lines.pop() || "";
|
||||
|
||||
// Process complete lines
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
if (!line || line.trim() === "") continue;
|
||||
|
||||
// Try to parse as JSON and pretty print if it's on a single line
|
||||
try {
|
||||
// Check if this line is a JSON object
|
||||
const parsed = JSON.parse(line);
|
||||
const prettyJson = JSON.stringify(parsed, null, 2);
|
||||
process.stdout.write(prettyJson);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Send valid JSON to stream handler if available
|
||||
if (streamHandler) {
|
||||
try {
|
||||
// Send the original line (which is valid JSON) with newline for proper splitting
|
||||
const dataToSend = line + "\n";
|
||||
await streamHandler.addOutput(dataToSend);
|
||||
} catch (error) {
|
||||
core.warning(`Failed to stream output: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a JSON object, print as is
|
||||
process.stdout.write(line);
|
||||
if (index < lines.length - 1 || text.endsWith("\n")) {
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
// Don't send non-JSON lines to stream handler
|
||||
}
|
||||
});
|
||||
|
||||
output += text;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stdout errors
|
||||
@@ -257,8 +348,33 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
claudeProcess.on("close", (code) => {
|
||||
claudeProcess.on("close", async (code) => {
|
||||
if (!resolved) {
|
||||
// Process any remaining data in the line buffer
|
||||
if (lineBuffer.trim()) {
|
||||
// Try to parse and print the remaining line
|
||||
try {
|
||||
const parsed = JSON.parse(lineBuffer);
|
||||
const prettyJson = JSON.stringify(parsed, null, 2);
|
||||
process.stdout.write(prettyJson);
|
||||
process.stdout.write("\n");
|
||||
|
||||
// Send valid JSON to stream handler if available
|
||||
if (streamHandler) {
|
||||
try {
|
||||
const dataToSend = lineBuffer + "\n";
|
||||
await streamHandler.addOutput(dataToSend);
|
||||
} catch (error) {
|
||||
core.warning(`Failed to stream final output: ${error}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
process.stdout.write(lineBuffer);
|
||||
process.stdout.write("\n");
|
||||
// Don't send non-JSON lines to stream handler
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
resolved = true;
|
||||
resolve(code || 0);
|
||||
@@ -275,6 +391,15 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) {
|
||||
});
|
||||
});
|
||||
|
||||
// Clean up streaming
|
||||
if (streamHandler) {
|
||||
try {
|
||||
await streamHandler.close();
|
||||
} catch (error) {
|
||||
core.warning(`Failed to close stream handler: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up processes
|
||||
try {
|
||||
catProcess.kill("SIGTERM");
|
||||
|
||||
152
base-action/src/stream-handler.ts
Normal file
152
base-action/src/stream-handler.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as core from "@actions/core";
|
||||
|
||||
export function parseStreamHeaders(
|
||||
headersInput?: string,
|
||||
): Record<string, string> {
|
||||
if (!headersInput || headersInput.trim() === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(headersInput);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream headers as JSON:", e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export type TokenGetter = (audience: string) => Promise<string>;
|
||||
|
||||
export class StreamHandler {
|
||||
private endpoint: string;
|
||||
private customHeaders: Record<string, string>;
|
||||
private tokenGetter: TokenGetter;
|
||||
private token: string | null = null;
|
||||
private tokenFetchTime: number = 0;
|
||||
private buffer: string[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private isClosed = false;
|
||||
|
||||
private readonly TOKEN_LIFETIME_MS = 4 * 60 * 1000; // 4 minutes
|
||||
private readonly BATCH_SIZE = 10;
|
||||
private readonly BATCH_TIMEOUT_MS = 1000;
|
||||
private readonly REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
constructor(
|
||||
endpoint: string,
|
||||
customHeaders: Record<string, string> = {},
|
||||
tokenGetter?: TokenGetter,
|
||||
) {
|
||||
this.endpoint = endpoint;
|
||||
this.customHeaders = customHeaders;
|
||||
this.tokenGetter = tokenGetter || ((audience) => core.getIDToken(audience));
|
||||
}
|
||||
|
||||
async addOutput(data: string): Promise<void> {
|
||||
if (this.isClosed) return;
|
||||
|
||||
// Split by newlines and add to buffer
|
||||
const lines = data.split("\n").filter((line) => line.length > 0);
|
||||
this.buffer.push(...lines);
|
||||
|
||||
// Check if we should flush
|
||||
if (this.buffer.length >= this.BATCH_SIZE) {
|
||||
await this.flush();
|
||||
} else {
|
||||
// Set or reset the timer
|
||||
this.resetFlushTimer();
|
||||
}
|
||||
}
|
||||
|
||||
private resetFlushTimer(): void {
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
}
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flush().catch((err) => {
|
||||
core.warning(`Failed to flush stream buffer: ${err}`);
|
||||
});
|
||||
}, this.BATCH_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if we need a new token
|
||||
if (!this.token || now - this.tokenFetchTime >= this.TOKEN_LIFETIME_MS) {
|
||||
try {
|
||||
this.token = await this.tokenGetter("claude-code-github-action");
|
||||
this.tokenFetchTime = now;
|
||||
core.debug("Fetched new OIDC token for streaming");
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get OIDC token: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.buffer.length === 0) return;
|
||||
|
||||
// Clear the flush timer
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
// Get the current buffer and clear it
|
||||
const output = [...this.buffer];
|
||||
this.buffer = [];
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
|
||||
const payload = {
|
||||
timestamp: new Date().toISOString(),
|
||||
output: output,
|
||||
};
|
||||
|
||||
// Create an AbortController for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.REQUEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
await fetch(this.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...this.customHeaders,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't throw - we don't want to interrupt Claude's execution
|
||||
core.warning(`Failed to stream output: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
// Clear any pending timer
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
// Flush any remaining output
|
||||
if (this.buffer.length > 0) {
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
// Mark as closed after flushing
|
||||
this.isClosed = true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user