First attempt

This commit is contained in:
Mark Wylde
2025-05-30 20:02:39 +01:00
parent 180a1b6680
commit e474962b0d
9 changed files with 2442 additions and 134 deletions

140
src/claude/executor.ts Normal file
View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bun
import Anthropic from "@anthropic-ai/sdk";
import * as fs from "fs";
export interface ClaudeExecutorConfig {
apiKey?: string;
model?: string;
promptFile?: string;
prompt?: string;
maxTurns?: number;
timeoutMinutes?: number;
mcpConfig?: string;
allowedTools?: string;
disallowedTools?: string;
useBedrock?: boolean;
useVertex?: boolean;
}
export interface ClaudeExecutorResult {
conclusion: "success" | "failure";
executionFile?: string;
error?: string;
}
export class ClaudeExecutor {
private config: ClaudeExecutorConfig;
private anthropic?: Anthropic;
constructor(config: ClaudeExecutorConfig) {
this.config = config;
this.initializeClient();
}
private initializeClient() {
if (this.config.useBedrock || this.config.useVertex) {
throw new Error("Bedrock and Vertex AI not supported in simplified implementation");
}
if (!this.config.apiKey) {
throw new Error("Anthropic API key is required");
}
this.anthropic = new Anthropic({
apiKey: this.config.apiKey,
});
}
private async readPrompt(): Promise<string> {
if (this.config.prompt) {
return this.config.prompt;
}
if (this.config.promptFile) {
if (!fs.existsSync(this.config.promptFile)) {
throw new Error(`Prompt file not found: ${this.config.promptFile}`);
}
return fs.readFileSync(this.config.promptFile, "utf-8");
}
throw new Error("Either prompt or promptFile must be provided");
}
private parseTools(): { allowed: string[]; disallowed: string[] } {
const allowed = this.config.allowedTools
? this.config.allowedTools.split(",").map(t => t.trim()).filter(Boolean)
: [];
const disallowed = this.config.disallowedTools
? this.config.disallowedTools.split(",").map(t => t.trim()).filter(Boolean)
: [];
return { allowed, disallowed };
}
private createExecutionLog(result: any, error?: string): string {
const logData = {
conclusion: error ? "failure" : "success",
model: this.config.model || "claude-3-7-sonnet-20250219",
timestamp: new Date().toISOString(),
result,
error,
};
const logFile = "/tmp/claude-execution.json";
fs.writeFileSync(logFile, JSON.stringify(logData, null, 2));
return logFile;
}
async execute(): Promise<ClaudeExecutorResult> {
try {
const prompt = await this.readPrompt();
const tools = this.parseTools();
console.log(`Executing Claude with model: ${this.config.model || "claude-3-7-sonnet-20250219"}`);
console.log(`Allowed tools: ${tools.allowed.join(", ") || "none"}`);
console.log(`Disallowed tools: ${tools.disallowed.join(", ") || "none"}`);
if (!this.anthropic) {
throw new Error("Anthropic client not initialized");
}
// Create a simple message request
const response = await this.anthropic.messages.create({
model: this.config.model || "claude-3-7-sonnet-20250219",
max_tokens: 8192,
messages: [
{
role: "user",
content: prompt,
},
],
});
console.log("Claude response received successfully");
const executionFile = this.createExecutionLog(response);
return {
conclusion: "success",
executionFile,
};
} catch (error) {
console.error("Claude execution failed:", error);
const executionFile = this.createExecutionLog(null, String(error));
return {
conclusion: "failure",
executionFile,
error: String(error),
};
}
}
}
export async function runClaude(config: ClaudeExecutorConfig): Promise<ClaudeExecutorResult> {
const executor = new ClaudeExecutor(config);
return await executor.execute();
}

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bun
import * as core from "@actions/core";
import { runClaude, type ClaudeExecutorConfig } from "../claude/executor";
async function main() {
try {
const config: ClaudeExecutorConfig = {
apiKey: process.env.ANTHROPIC_API_KEY,
model: process.env.ANTHROPIC_MODEL || process.env.MODEL,
promptFile: process.env.PROMPT_FILE,
prompt: process.env.PROMPT,
maxTurns: process.env.MAX_TURNS ? parseInt(process.env.MAX_TURNS) : undefined,
timeoutMinutes: process.env.TIMEOUT_MINUTES ? parseInt(process.env.TIMEOUT_MINUTES) : 30,
mcpConfig: process.env.MCP_CONFIG,
allowedTools: process.env.ALLOWED_TOOLS,
disallowedTools: process.env.DISALLOWED_TOOLS,
useBedrock: process.env.USE_BEDROCK === "true",
useVertex: process.env.USE_VERTEX === "true",
};
console.log("Starting Claude execution...");
const result = await runClaude(config);
// Set outputs for GitHub Actions
core.setOutput("conclusion", result.conclusion);
if (result.executionFile) {
core.setOutput("execution_file", result.executionFile);
}
if (result.conclusion === "failure") {
core.setFailed(result.error || "Claude execution failed");
} else {
console.log("Claude execution completed successfully");
}
} catch (error) {
console.error("Failed to execute Claude:", error);
core.setFailed(`Failed to execute Claude: ${error}`);
}
}
main().catch((error) => {
console.error("Unhandled error:", error);
core.setFailed(`Unhandled error: ${error}`);
process.exit(1);
});

View File

@@ -2,95 +2,8 @@
import * as core from "@actions/core";
type RetryOptions = {
maxAttempts?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffFactor?: number;
};
async function retryWithBackoff<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
initialDelayMs = 5000,
maxDelayMs = 20000,
backoffFactor = 2,
} = options;
let delayMs = initialDelayMs;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
console.log(`Attempt ${attempt} of ${maxAttempts}...`);
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Attempt ${attempt} failed:`, lastError.message);
if (attempt < maxAttempts) {
console.log(`Retrying in ${delayMs / 1000} seconds...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
delayMs = Math.min(delayMs * backoffFactor, maxDelayMs);
}
}
}
console.error(`Operation failed after ${maxAttempts} attempts`);
throw lastError;
}
async function getOidcToken(): Promise<string> {
try {
const oidcToken = await core.getIDToken("claude-code-github-action");
return oidcToken;
} catch (error) {
console.error("Failed to get OIDC token:", error);
throw new Error(
"Could not fetch an OIDC token. Did you remember to add `id-token: write` to your workflow permissions?",
);
}
}
async function exchangeForAppToken(oidcToken: string): Promise<string> {
const response = await fetch(
"https://api.anthropic.com/api/github/github-app-token-exchange",
{
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
},
},
);
if (!response.ok) {
const responseJson = (await response.json()) as {
error?: {
message?: string;
};
};
console.error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson?.error?.message ?? "Unknown error"}`,
);
throw new Error(`${responseJson?.error?.message ?? "Unknown error"}`);
}
const appTokenData = (await response.json()) as {
token?: string;
app_token?: string;
};
const appToken = appTokenData.token || appTokenData.app_token;
if (!appToken) {
throw new Error("App token not found in response");
}
return appToken;
}
export async function setupGitHubToken(): Promise<string> {
try {
@@ -103,22 +16,19 @@ export async function setupGitHubToken(): Promise<string> {
return providedToken;
}
console.log("Requesting OIDC token...");
const oidcToken = await retryWithBackoff(() => getOidcToken());
console.log("OIDC token successfully obtained");
// Use the standard GITHUB_TOKEN from the workflow environment
const workflowToken = process.env.GITHUB_TOKEN;
if (workflowToken) {
console.log("Using workflow GITHUB_TOKEN for authentication");
core.setOutput("GITHUB_TOKEN", workflowToken);
return workflowToken;
}
console.log("Exchanging OIDC token for app token...");
const appToken = await retryWithBackoff(() =>
exchangeForAppToken(oidcToken),
);
console.log("App token successfully obtained");
console.log("Using GITHUB_TOKEN from OIDC");
core.setOutput("GITHUB_TOKEN", appToken);
return appToken;
throw new Error("No GitHub token available. Please provide a github_token input or ensure GITHUB_TOKEN is available in the workflow environment.");
} catch (error) {
core.setFailed(
`Failed to setup GitHub token: ${error}.\n\nIf you instead wish to use this action with a custom GitHub token or custom GitHub app, provide a \`github_token\` in the \`uses\` section of the app in your workflow yml file.`,
`Failed to setup GitHub token: ${error}.\n\nPlease provide a \`github_token\` in the \`with\` section of the action in your workflow yml file, or ensure the workflow has access to the default GITHUB_TOKEN.`,
);
process.exit(1);
}