diff --git a/CLAUDE.md b/CLAUDE.md index 7834fc2d6..f9c96d622 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,53 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## ⚠️ CRITICAL: Repository Context + +This is a **FORK** of `anthropics/claude-code-action`. This repository is `dot-do/claude-code-action`. + +### Pull Request Rules + +**NEVER create PRs against `anthropics/claude-code-action`** + +- ✅ **CORRECT**: Create PRs in `dot-do/claude-code-action` (this repository) +- ❌ **WRONG**: Create PRs in `anthropics/claude-code-action` (upstream) + +When using `gh pr create`, ALWAYS specify the repository explicitly: + +```bash +# CORRECT - Specify our fork explicitly +gh pr create --repo dot-do/claude-code-action --base main --title "..." --body "..." + +# WRONG - Default behavior may target upstream +gh pr create --title "..." --body "..." +``` + +### Repository Structure + +``` +origin → https://github.com/dot-do/claude-code-action.git (OUR FORK) +upstream → https://github.com/anthropics/claude-code-action.git (UPSTREAM) +``` + +**Default behavior**: `gh pr create` targets the upstream repository when working in a fork. This is NOT what we want. + +### Syncing with Upstream + +To pull changes from upstream Anthropic repository: + +```bash +git fetch upstream +git merge upstream/main +git push origin main +``` + +### Why This Matters + +- **Upstream pollution**: Creating PRs in upstream confuses Anthropic maintainers +- **Wrong codebase**: Our fork has custom modifications specific to `.do` platform +- **Permission issues**: We don't have merge rights in upstream repository +- **Workflow disruption**: PRs in wrong repo waste time and create confusion + ## Development Tools - Runtime: Bun 1.2.11 diff --git a/action.yml b/action.yml index 4dce179c2..cd1527e1e 100644 --- a/action.yml +++ b/action.yml @@ -246,7 +246,7 @@ runs: VERTEX_REGION_CLAUDE_3_7_SONNET: ${{ env.VERTEX_REGION_CLAUDE_3_7_SONNET }} - name: Update comment with job link - if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && always() + if: steps.prepare.outputs.contains_trigger == 'true' && steps.prepare.outputs.claude_comment_id && !cancelled() shell: bash run: | bun run ${GITHUB_ACTION_PATH}/src/entrypoints/update-comment-link.ts diff --git a/base-action/src/index.ts b/base-action/src/index.ts index bd61825a0..e8f4bfd51 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -2,14 +2,28 @@ import * as core from "@actions/core"; import { preparePrompt } from "./prepare-prompt"; -import { runClaude } from "./run-claude"; +import { runClaudeWithRetry } from "./run-claude"; import { setupClaudeCodeSettings } from "./setup-claude-code-settings"; import { validateEnvironmentVariables } from "./validate-env"; +import { startProxyServer, getProxyUrl, shouldUseProxy } from "./proxy-server"; async function run() { try { validateEnvironmentVariables(); + // Start local HTTP proxy server for claude-lb (supports multiple modes) + const proxyPort = await startProxyServer(); + + // Only route through proxy if credentials are available + if (shouldUseProxy()) { + process.env.ANTHROPIC_BASE_URL = getProxyUrl(); + console.log(`\n🔀 Claude API requests routed through claude-lb proxy`); + console.log(` Benefits: Centralized monitoring, observability, and multi-provider failover`); + } else { + console.log(`\n⚡️ Using direct Anthropic API (no proxy)`); + console.log(` To enable monitoring, set ANTHROPIC_API_KEY in secrets`); + } + await setupClaudeCodeSettings( process.env.INPUT_SETTINGS, undefined, // homeDir @@ -20,7 +34,7 @@ async function run() { promptFile: process.env.INPUT_PROMPT_FILE || "", }); - await runClaude(promptConfig.path, { + await runClaudeWithRetry(promptConfig.path, { claudeArgs: process.env.INPUT_CLAUDE_ARGS, allowedTools: process.env.INPUT_ALLOWED_TOOLS, disallowedTools: process.env.INPUT_DISALLOWED_TOOLS, diff --git a/base-action/src/proxy-server.ts b/base-action/src/proxy-server.ts new file mode 100644 index 000000000..48554674b --- /dev/null +++ b/base-action/src/proxy-server.ts @@ -0,0 +1,217 @@ +/** + * HTTP Proxy Server for Claude API via Cloudflare AI Gateway + * + * Intercepts Claude CLI requests and forwards them directly to Cloudflare AI Gateway, + * which routes to AWS Bedrock or Anthropic API based on availability. + * + * This enables: + * - Direct routing through AI Gateway (no intermediate worker hop) + * - Centralized monitoring and observability via Cloudflare AI Gateway + * - Intelligent failover: Bedrock first (uses $100k credits), then Anthropic + * - Zero-delay failover on ANY Bedrock error (429, 430, 500, 403, etc.) + * + * REQUIREMENTS: + * - AWS_BEARER_TOKEN_BEDROCK must be set (for Bedrock access) + * - ANTHROPIC_API_KEY must be set (for failover) + */ + +const PROXY_PORT = 18765; // Local proxy port +const AI_GATEWAY_ACCOUNT = 'b6641681fe423910342b9ffa1364c76d'; +const AI_GATEWAY_ID = 'claude-gateway'; +const AI_GATEWAY_BASE = `https://gateway.ai.cloudflare.com/v1/${AI_GATEWAY_ACCOUNT}/${AI_GATEWAY_ID}`; + +interface ProxyStats { + requests: number; + successes: number; + failures: number; + bedrockSuccesses: number; + anthropicFailovers: number; + lastError?: string; +} + +const stats: ProxyStats = { + requests: 0, + successes: 0, + failures: 0, + bedrockSuccesses: 0, + anthropicFailovers: 0, +}; + +/** + * Validate required credentials are present + * Throws error if either is missing + */ +function validateCredentials(): void { + const bedrockToken = process.env.AWS_BEARER_TOKEN_BEDROCK?.trim(); + const anthropicKey = process.env.ANTHROPIC_API_KEY?.trim(); + + const missing: string[] = []; + if (!bedrockToken) missing.push('AWS_BEARER_TOKEN_BEDROCK'); + if (!anthropicKey) missing.push('ANTHROPIC_API_KEY'); + + if (missing.length > 0) { + throw new Error( + `Missing required credentials: ${missing.join(', ')}\n` + + 'Both AWS_BEARER_TOKEN_BEDROCK and ANTHROPIC_API_KEY must be set as GitHub org secrets.' + ); + } +} + +/** + * Forward request to AWS Bedrock via AI Gateway + */ +async function forwardToBedrock(body: string, headers: Headers): Promise { + const bedrockToken = process.env.AWS_BEARER_TOKEN_BEDROCK!; // Validated at startup + + // AI Gateway URL for Bedrock + const bedrockUrl = `${AI_GATEWAY_BASE}/aws-bedrock/bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-sonnet-4-5-v1:0/invoke`; + + const bedrockHeaders = new Headers({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${bedrockToken}`, + 'anthropic-version': headers.get('anthropic-version') || '2023-06-01', + }); + + console.log('🔵 Attempting Bedrock via AI Gateway...'); + + const response = await fetch(bedrockUrl, { + method: 'POST', + headers: bedrockHeaders, + body + }); + + if (response.ok) { + console.log('✅ Bedrock request succeeded via AI Gateway'); + } else { + console.warn(`⚠️ Bedrock returned ${response.status}`); + } + + return response; +} + +/** + * Forward request to Anthropic via AI Gateway + */ +async function forwardToAnthropic(body: string, headers: Headers): Promise { + const anthropicKey = process.env.ANTHROPIC_API_KEY!; // Validated at startup + + // AI Gateway URL for Anthropic + const anthropicUrl = `${AI_GATEWAY_BASE}/anthropic/v1/messages`; + + const anthropicHeaders = new Headers({ + 'Content-Type': 'application/json', + 'x-api-key': anthropicKey, + 'anthropic-version': headers.get('anthropic-version') || '2023-06-01', + }); + + console.log('🟢 Attempting Anthropic via AI Gateway...'); + + const response = await fetch(anthropicUrl, { + method: 'POST', + headers: anthropicHeaders, + body + }); + + if (response.ok) { + console.log('✅ Anthropic request succeeded via AI Gateway'); + } else { + console.error(`❌ Anthropic returned ${response.status}`); + } + + return response; +} + +export async function startProxyServer(): Promise { + // Validate credentials at startup - fail fast if misconfigured + validateCredentials(); + + const server = Bun.serve({ + port: PROXY_PORT, + hostname: '127.0.0.1', + idleTimeout: 255, // Max allowed by Bun (4.25 minutes) for long-running Claude API requests + + async fetch(req: Request): Promise { + const url = new URL(req.url); + + // Health check endpoint + if (url.pathname === '/health') { + return Response.json(stats); + } + + // Only proxy /v1/messages + if (url.pathname !== '/v1/messages' || req.method !== 'POST') { + return new Response('Not Found', { status: 404 }); + } + + stats.requests++; + + const body = await req.text(); + + // Always try Bedrock first (uses $100k credits) + try { + const bedrockResponse = await forwardToBedrock(body, req.headers); + + if (bedrockResponse.ok) { + stats.successes++; + stats.bedrockSuccesses++; + return bedrockResponse; + } + + // Any Bedrock error triggers immediate failover to Anthropic + console.log(`⚠️ Bedrock returned ${bedrockResponse.status} - failing over to Anthropic`); + } catch (error) { + console.error('❌ Bedrock request error:', error); + } + + // Immediate failover to Anthropic (zero delay) + console.log('🔄 Failing over to Anthropic...'); + stats.anthropicFailovers++; + + try { + const anthropicResponse = await forwardToAnthropic(body, req.headers); + + if (anthropicResponse.ok) { + stats.successes++; + } else { + stats.failures++; + stats.lastError = `Anthropic returned ${anthropicResponse.status}`; + } + + return anthropicResponse; + } catch (error) { + console.error('❌ Anthropic request error:', error); + stats.failures++; + stats.lastError = String(error); + + return new Response( + JSON.stringify({ + error: 'Both Bedrock and Anthropic failed', + message: String(error) + }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + } + }); + + console.log(`✅ Proxy server listening on http://127.0.0.1:${server.port}`); + console.log(` Forwarding to Cloudflare AI Gateway (${AI_GATEWAY_BASE})`); + console.log(' 🔒 Bedrock-first with Anthropic failover'); + console.log(` 📊 Health endpoint: http://127.0.0.1:${server.port}/health`); + + return server.port; +} + +export function getProxyUrl(): string { + return `http://127.0.0.1:${PROXY_PORT}`; +} + +export function shouldUseProxy(): boolean { + // Proxy should be used if both credentials are available + try { + validateCredentials(); + return true; + } catch { + return false; + } +} diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index 58c58c01c..9b5c4ceeb 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -140,9 +140,21 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { // Capture output for parsing execution metrics let output = ""; + let hasRateLimitError = false; + claudeProcess.stdout.on("data", (data) => { const text = data.toString(); + // Check for rate limit errors (429, throttling, etc.) + if ( + text.includes("429") || + text.includes("Too many requests") || + text.includes("rate limit") || + text.includes("throttl") + ) { + hasRateLimitError = true; + } + // 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) => { @@ -235,6 +247,13 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { core.setOutput("conclusion", "success"); core.setOutput("execution_file", EXECUTION_FILE); } else { + // If this was a rate limit error, throw to allow retry + if (hasRateLimitError) { + throw new Error( + `Claude CLI failed with rate limit error (exit code ${exitCode})`, + ); + } + core.setOutput("conclusion", "failure"); // Still try to save execution file if we have output @@ -255,3 +274,28 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { process.exit(exitCode); } } + +/** + * Run Claude via claude-lb proxy + * + * The claude-lb worker handles all retry logic: + * 1. Always tries AWS Bedrock first (via AI Gateway) + * 2. On 429 rate limit: immediate Anthropic failover (HTTP-level, milliseconds) + * 3. All tracking via Cloudflare AI Gateway + * + * No retry logic needed here - single execution with failover handled by proxy. + */ +export async function runClaudeWithRetry( + promptPath: string, + options: ClaudeOptions, + retryOptions?: { + maxAttempts?: number; + }, +) { + console.log('\n🤖 Executing Claude via claude-lb proxy (Bedrock-first with instant failover)'); + + // Single execution - proxy handles Bedrock->Anthropic failover transparently + await runClaude(promptPath, options); + + console.log('✅ Claude execution succeeded'); +} diff --git a/base-action/src/validate-env.ts b/base-action/src/validate-env.ts index 6e48a6843..08448c8ba 100644 --- a/base-action/src/validate-env.ts +++ b/base-action/src/validate-env.ts @@ -23,17 +23,22 @@ export function validateEnvironmentVariables() { ); } } else if (useBedrock) { - const requiredBedrockVars = { - AWS_REGION: process.env.AWS_REGION, - AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, - AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, - }; + // AWS_REGION is always required + if (!process.env.AWS_REGION) { + errors.push("AWS_REGION is required when using AWS Bedrock."); + } - Object.entries(requiredBedrockVars).forEach(([key, value]) => { - if (!value) { - errors.push(`${key} is required when using AWS Bedrock.`); - } - }); + // Support bearer token authentication (simpler) or full AWS credentials + const hasBearerToken = !!process.env.AWS_BEARER_TOKEN_BEDROCK; + const hasAwsCredentials = + !!process.env.AWS_ACCESS_KEY_ID && + !!process.env.AWS_SECRET_ACCESS_KEY; + + if (!hasBearerToken && !hasAwsCredentials) { + errors.push( + "AWS Bedrock requires either AWS_BEARER_TOKEN_BEDROCK (for Bedrock API keys) or both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (for IAM credentials).", + ); + } } else if (useVertex) { const requiredVertexVars = { ANTHROPIC_VERTEX_PROJECT_ID: process.env.ANTHROPIC_VERTEX_PROJECT_ID, diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index 3a14e66bd..b8d28f206 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -15,6 +15,52 @@ import { GITHUB_SERVER_URL } from "../github/api/config"; import { checkAndCommitOrDeleteBranch } from "../github/operations/branch-cleanup"; import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; +/** + * Check if the bot submitted a review on the PR within a time window + */ +async function didBotSubmitRecentReview( + octokit: ReturnType, + params: { + owner: string; + repo: string; + pull_number: number; + botUsername: string; + windowMinutes?: number; + }, +): Promise<{ submitted: boolean; reviewId?: number; submittedAt?: string }> { + const { owner, repo, pull_number, botUsername, windowMinutes = 5 } = params; + + try { + const reviews = await octokit.rest.pulls.listReviews({ + owner, + repo, + pull_number, + per_page: 10, // Check last 10 reviews + }); + + const cutoffTime = new Date(Date.now() - windowMinutes * 60 * 1000); + const recentBotReview = reviews.data.find( + (review) => + review.user?.login === botUsername && + review.submitted_at && // Type guard - ensure timestamp exists + new Date(review.submitted_at) > cutoffTime, + ); + + if (recentBotReview) { + return { + submitted: true, + reviewId: recentBotReview.id, + submittedAt: recentBotReview.submitted_at || undefined, + }; + } + + return { submitted: false }; + } catch (error) { + console.log("Could not check for review submission:", error); + return { submitted: false }; + } +} + async function run() { try { const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!); @@ -214,25 +260,91 @@ async function run() { errorDetails, }; - const updatedBody = updateCommentBody(commentInput); + // Check if this is a PR and if a review was submitted + let shouldDeleteComment = false; + if (context.isPR && !actionFailed) { + const botUsername = comment.user?.login; + if (botUsername) { + const reviewCheck = await didBotSubmitRecentReview(octokit, { + owner, + repo, + pull_number: context.entityNumber, + botUsername, + windowMinutes: 5, // Check last 5 minutes + }); - try { - await updateClaudeComment(octokit.rest, { - owner, - repo, - commentId, - body: updatedBody, - isPullRequestReviewComment: isPRReviewComment, - }); - console.log( - `✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`, - ); - } catch (updateError) { - console.error( - `Failed to update ${isPRReviewComment ? "PR review" : "issue"} comment:`, - updateError, - ); - throw updateError; + if (reviewCheck.submitted) { + shouldDeleteComment = true; + console.log( + `✓ Bot submitted review #${reviewCheck.reviewId} at ${reviewCheck.submittedAt} - will delete progress comment instead of updating`, + ); + } + } + } + + if (shouldDeleteComment) { + // Delete the comment since a review was submitted + try { + if (isPRReviewComment) { + await octokit.rest.pulls.deleteReviewComment({ + owner, + repo, + comment_id: commentId, + }); + } else { + await octokit.rest.issues.deleteComment({ + owner, + repo, + comment_id: commentId, + }); + } + console.log( + `✅ Deleted ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} (review was submitted)`, + ); + } catch (deleteError) { + console.error( + `Failed to delete ${isPRReviewComment ? "PR review" : "issue"} comment:`, + deleteError, + ); + // Fall back to updating the comment if deletion fails + try { + const updatedBody = updateCommentBody(commentInput); + await updateClaudeComment(octokit.rest, { + owner, + repo, + commentId, + body: updatedBody, + isPullRequestReviewComment: isPRReviewComment, + }); + console.log( + `✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} (deletion failed, fell back to update)`, + ); + } catch (updateError) { + console.error("Failed to update comment after deletion failed:", updateError); + throw updateError; + } + } + } else { + // Normal update flow - no review was submitted + const updatedBody = updateCommentBody(commentInput); + try { + await updateClaudeComment(octokit.rest, { + owner, + repo, + commentId, + body: updatedBody, + isPullRequestReviewComment: isPRReviewComment, + }); + console.log( + `✅ Updated ${isPRReviewComment ? "PR review" : "issue"} comment ${commentId} with job link`, + ); + } catch (updateError) { + console.error( + `Failed to update ${isPRReviewComment ? "PR review" : "issue"} comment:`, + updateError, + ); + throw updateError; + } } process.exit(0); diff --git a/src/github/data/fetcher.ts b/src/github/data/fetcher.ts index e6cec2c4c..d1709b5b0 100644 --- a/src/github/data/fetcher.ts +++ b/src/github/data/fetcher.ts @@ -163,7 +163,7 @@ export async function fetchGitHubData({ if (prResult.repository.pullRequest) { const pullRequest = prResult.repository.pullRequest; contextData = pullRequest; - changedFiles = pullRequest.files.nodes || []; + changedFiles = pullRequest.files?.nodes || []; comments = filterCommentsToTriggerTime( pullRequest.comments?.nodes || [], triggerTime, @@ -171,6 +171,11 @@ export async function fetchGitHubData({ reviewData = pullRequest.reviews || []; console.log(`Successfully fetched PR #${prNumber} data`); + if (!pullRequest.files || pullRequest.files.nodes === null) { + console.warn( + `Warning: PR #${prNumber} files data is null (likely >100 files or API limit). Changed files list will be empty.`, + ); + } } else { throw new Error(`PR #${prNumber} not found`); } diff --git a/src/modes/detector.ts b/src/modes/detector.ts index 8e30aff4f..45ffa5405 100644 --- a/src/modes/detector.ts +++ b/src/modes/detector.ts @@ -67,6 +67,7 @@ export function detectMode(context: GitHubContext): AutoDetectedMode { "synchronize", "ready_for_review", "reopened", + "assigned", ]; if (context.eventAction && supportedActions.includes(context.eventAction)) { // If prompt is provided, use agent mode (default for automation) @@ -114,6 +115,7 @@ function validateTrackProgressEvent(context: GitHubContext): void { "synchronize", "ready_for_review", "reopened", + "assigned", ]; if (!validActions.includes(context.eventAction)) { throw new Error( diff --git a/test/modes/detector.test.ts b/test/modes/detector.test.ts index ed6a3a5da..80349af58 100644 --- a/test/modes/detector.test.ts +++ b/test/modes/detector.test.ts @@ -71,6 +71,34 @@ describe("detectMode with enhanced routing", () => { expect(detectMode(context)).toBe("agent"); }); + it("should use tag mode when track_progress is true for pull_request.assigned", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "assigned", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, trackProgress: true }, + }; + + expect(detectMode(context)).toBe("tag"); + }); + + it("should use agent mode when prompt is provided for pull_request.assigned", () => { + const context: GitHubContext = { + ...baseContext, + eventName: "pull_request", + eventAction: "assigned", + payload: { pull_request: { number: 1 } } as any, + entityNumber: 1, + isPR: true, + inputs: { ...baseContext.inputs, prompt: "Fix the issues", trackProgress: false }, + }; + + expect(detectMode(context)).toBe("agent"); + }); + it("should throw error when track_progress is used with unsupported PR action", () => { const context: GitHubContext = { ...baseContext,