diff --git a/action.yml b/action.yml index 6c459174a..d80acb70f 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,13 @@ inputs: description: "Custom environment variables to pass to Claude Code execution (YAML format)" required: false default: "" + output_mode: + description: "Where to post the review. Comma-separated list. Options: pr_comment, commit_comment, stdout" + required: false + default: "pr_comment" + commit_sha: + description: "Specific commit SHA to comment on for commit_comment mode. Defaults to PR HEAD or github.sha" + required: false # Auth configuration anthropic_api_key: @@ -106,6 +113,8 @@ runs: MCP_CONFIG: ${{ inputs.mcp_config }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} GITHUB_RUN_ID: ${{ github.run_id }} + OUTPUT_MODE: ${{ inputs.output_mode }} + COMMIT_SHA: ${{ inputs.commit_sha }} - name: Run Claude Code id: claude-code @@ -158,6 +167,7 @@ runs: REPOSITORY: ${{ github.repository }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }} CLAUDE_COMMENT_ID: ${{ steps.prepare.outputs.claude_comment_id }} + OUTPUT_IDENTIFIERS: ${{ steps.prepare.outputs.output_identifiers }} GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_TOKEN: ${{ steps.prepare.outputs.GITHUB_TOKEN }} GITHUB_EVENT_NAME: ${{ github.event_name }} @@ -170,6 +180,8 @@ runs: TRIGGER_USERNAME: ${{ github.event.comment.user.login || github.event.issue.user.login || github.event.pull_request.user.login || github.event.sender.login || github.triggering_actor || github.actor || '' }} PREPARE_SUCCESS: ${{ steps.prepare.outcome == 'success' }} PREPARE_ERROR: ${{ steps.prepare.outputs.prepare_error || '' }} + OUTPUT_MODE: ${{ inputs.output_mode }} + COMMIT_SHA: ${{ inputs.commit_sha }} - name: Display Claude Code Report if: steps.prepare.outputs.contains_trigger == 'true' && steps.claude-code.outputs.execution_file != '' diff --git a/src/entrypoints/prepare.ts b/src/entrypoints/prepare.ts index f8b5dc2af..53ec94429 100644 --- a/src/entrypoints/prepare.ts +++ b/src/entrypoints/prepare.ts @@ -10,9 +10,9 @@ import { setupGitHubToken } from "../github/token"; import { checkTriggerAction } from "../github/validation/trigger"; 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 { updateTrackingComment } from "../github/operations/comments/update-with-branch"; +import { OutputManager } from "../output-manager"; import { prepareMcpConfig } from "../mcp/install-mcp-server"; import { createPrompt } from "../create-prompt"; import { createOctokit } from "../github/api/client"; @@ -50,8 +50,31 @@ async function run() { // Step 5: Check if actor is human await checkHumanActor(octokit.rest, context); - // Step 6: Create initial tracking comment - const commentId = await createInitialComment(octokit.rest, context); + // Step 6: Setup output manager and create initial tracking + const outputModes = OutputManager.parseOutputModes( + process.env.OUTPUT_MODE || "pr_comment", + ); + const commitSha = process.env.COMMIT_SHA; + const outputManager = new OutputManager( + outputModes, + octokit.rest, + context, + commitSha, + ); + const outputIdentifiers = await outputManager.createInitial(context); + + // Output the identifiers for downstream steps + core.setOutput( + "output_identifiers", + outputManager.serializeIdentifiers(outputIdentifiers), + ); + + // Legacy support: output the primary identifier as claude_comment_id + const primaryIdentifier = + outputManager.getPrimaryIdentifier(outputIdentifiers); + if (primaryIdentifier) { + core.setOutput("claude_comment_id", primaryIdentifier); + } // Step 7: Fetch GitHub data (once for both branch setup and prompt creation) const githubData = await fetchGitHubData({ @@ -66,18 +89,19 @@ async function run() { const branchInfo = await setupBranch(octokit, githubData, context); // Step 9: Update initial comment with branch link (only for issues that created a new branch) - if (branchInfo.claudeBranch) { + // Note: This only applies to pr_comment strategy, others don't support updates + if (branchInfo.claudeBranch && outputIdentifiers.pr_comment) { await updateTrackingComment( octokit, context, - commentId, + parseInt(outputIdentifiers.pr_comment), branchInfo.claudeBranch, ); } // Step 10: Create prompt file await createPrompt( - commentId, + primaryIdentifier ? parseInt(primaryIdentifier) : 0, branchInfo.baseBranch, branchInfo.claudeBranch, githubData, @@ -92,7 +116,7 @@ async function run() { repo: context.repository.repo, branch: branchInfo.currentBranch, additionalMcpConfig, - claudeCommentId: commentId.toString(), + claudeCommentId: primaryIdentifier || "0", allowedTools: context.inputs.allowedTools, }); core.setOutput("mcp_config", mcpConfig); diff --git a/src/entrypoints/update-comment-link.ts b/src/entrypoints/update-comment-link.ts index 9090373e2..e33cdd383 100644 --- a/src/entrypoints/update-comment-link.ts +++ b/src/entrypoints/update-comment-link.ts @@ -2,91 +2,87 @@ import { createOctokit } from "../github/api/client"; import * as fs from "fs/promises"; -import { - updateCommentBody, - type CommentUpdateInput, -} from "../github/operations/comment-logic"; -import { - parseGitHubContext, - isPullRequestReviewCommentEvent, -} from "../github/context"; +import { type ExecutionDetails } from "../github/operations/comment-logic"; +import { parseGitHubContext } from "../github/context"; import { GITHUB_SERVER_URL } from "../github/api/config"; import { checkAndDeleteEmptyBranch } from "../github/operations/branch-cleanup"; -import { updateClaudeComment } from "../github/operations/comments/update-claude-comment"; +import { OutputManager, type OutputIdentifiers } from "../output-manager"; +import type { ReviewContent } from "../output-strategies/base"; async function run() { try { - const commentId = parseInt(process.env.CLAUDE_COMMENT_ID!); + // Legacy fallback for claude_comment_id + const legacyCommentId = process.env.CLAUDE_COMMENT_ID; + const outputIdentifiersJson = process.env.OUTPUT_IDENTIFIERS; const githubToken = process.env.GITHUB_TOKEN!; const claudeBranch = process.env.CLAUDE_BRANCH; const baseBranch = process.env.BASE_BRANCH || "main"; const triggerUsername = process.env.TRIGGER_USERNAME; + const outputModes = OutputManager.parseOutputModes( + process.env.OUTPUT_MODE || "pr_comment", + ); + const commitSha = process.env.COMMIT_SHA; const context = parseGitHubContext(); const { owner, repo } = context.repository; const octokit = createOctokit(githubToken); + // Parse output identifiers from prepare step or fall back to legacy + let outputIdentifiers: OutputIdentifiers; + if (outputIdentifiersJson) { + outputIdentifiers = OutputManager.deserializeIdentifiers( + outputIdentifiersJson, + ); + } else if (legacyCommentId) { + // Legacy fallback - assume pr_comment mode + outputIdentifiers = { pr_comment: legacyCommentId }; + } else { + outputIdentifiers = {}; + } + + // Create output manager for final update + const outputManager = new OutputManager( + outputModes, + octokit.rest, + context, + commitSha, + ); + const serverUrl = GITHUB_SERVER_URL; const jobUrl = `${serverUrl}/${owner}/${repo}/actions/runs/${process.env.GITHUB_RUN_ID}`; - let comment; - let isPRReviewComment = false; - - try { - // GitHub has separate ID namespaces for review comments and issue comments - // We need to use the correct API based on the event type - if (isPullRequestReviewCommentEvent(context)) { - // For PR review comments, use the pulls API - console.log(`Fetching PR review comment ${commentId}`); - const { data: prComment } = await octokit.rest.pulls.getReviewComment({ - owner, - repo, - comment_id: commentId, - }); - comment = prComment; - isPRReviewComment = true; - console.log("Successfully fetched as PR review comment"); - } - - // For all other event types, use the issues API - if (!comment) { - console.log(`Fetching issue comment ${commentId}`); - const { data: issueComment } = await octokit.rest.issues.getComment({ - owner, - repo, - comment_id: commentId, - }); - comment = issueComment; - isPRReviewComment = false; - console.log("Successfully fetched as issue comment"); - } - } catch (finalError) { - // If all attempts fail, try to determine more information about the comment - console.error("Failed to fetch comment. Debug info:"); - console.error(`Comment ID: ${commentId}`); - console.error(`Event name: ${context.eventName}`); - console.error(`Entity number: ${context.entityNumber}`); - console.error(`Repository: ${context.repository.full_name}`); - - // Try to get the PR info to understand the comment structure + // For legacy support, we still need to fetch the current body if we have a pr_comment identifier + let currentBody = ""; + if (outputIdentifiers.pr_comment) { try { - const { data: pr } = await octokit.rest.pulls.get({ - owner, - repo, - pull_number: context.entityNumber, - }); - console.log(`PR state: ${pr.state}`); - console.log(`PR comments count: ${pr.comments}`); - console.log(`PR review comments count: ${pr.review_comments}`); - } catch { - console.error("Could not fetch PR info for debugging"); + const commentId = parseInt(outputIdentifiers.pr_comment); + // Try to fetch the current comment body for the update + try { + const { data: issueComment } = await octokit.rest.issues.getComment({ + owner, + repo, + comment_id: commentId, + }); + currentBody = issueComment.body ?? ""; + } catch { + // If issue comment fails, try PR review comment + const { data: prComment } = await octokit.rest.pulls.getReviewComment( + { + owner, + repo, + comment_id: commentId, + }, + ); + currentBody = prComment.body ?? ""; + } + } catch (error) { + console.warn( + "Could not fetch current comment body, proceeding with empty body:", + error, + ); } - - throw finalError; } - const currentBody = comment.body ?? ""; - // Check if we need to add branch link for new branches const { shouldDeleteBranch, branchLink } = await checkAndDeleteEmptyBranch( octokit, @@ -140,11 +136,7 @@ async function run() { } // Check if action failed and read output file for execution details - let executionDetails: { - cost_usd?: number; - duration_ms?: number; - duration_api_ms?: number; - } | null = null; + let executionDetails: ExecutionDetails | null = null; let actionFailed = false; let errorDetails: string | undefined; @@ -190,9 +182,10 @@ async function run() { } } - // Prepare input for updateCommentBody function - const commentInput: CommentUpdateInput = { - currentBody, + // Prepare content for all output strategies + const reviewContent: ReviewContent = { + summary: actionFailed ? "Action failed" : "Action completed", + body: currentBody, actionFailed, executionDetails, jobUrl, @@ -203,26 +196,9 @@ async function run() { errorDetails, }; - 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; - } + // Use OutputManager to update all configured output strategies + await outputManager.updateFinal(outputIdentifiers, context, reviewContent); + console.log("✅ Updated all configured output strategies"); process.exit(0); } catch (error) { diff --git a/src/output-manager.ts b/src/output-manager.ts new file mode 100644 index 000000000..cc4376190 --- /dev/null +++ b/src/output-manager.ts @@ -0,0 +1,209 @@ +#!/usr/bin/env bun + +import type { Octokit } from "@octokit/rest"; +import type { ParsedGitHubContext } from "./github/context"; +import type { OutputStrategy, ReviewContent } from "./output-strategies/base"; +import { PrCommentStrategy } from "./output-strategies/pr-comment"; +import { CommitCommentStrategy } from "./output-strategies/commit-comment"; +import { StdoutStrategy } from "./output-strategies/stdout"; + +export interface OutputIdentifiers { + [strategyName: string]: string | null; +} + +export class OutputManager { + private strategies: OutputStrategy[] = []; + + constructor( + outputModes: string[], + octokit: Octokit | null, + context: ParsedGitHubContext, + commitSha?: string, + ) { + // Create and validate strategies based on output modes + for (const mode of outputModes) { + const trimmedMode = mode.trim(); + let strategy: OutputStrategy; + + switch (trimmedMode) { + case "pr_comment": + if (!octokit) { + throw new Error( + "'pr_comment' output mode requires GitHub authentication (octokit instance)", + ); + } + strategy = new PrCommentStrategy(octokit); + break; + case "commit_comment": + if (!octokit) { + throw new Error( + "'commit_comment' output mode requires GitHub authentication (octokit instance)", + ); + } + strategy = new CommitCommentStrategy(octokit, commitSha); + break; + case "stdout": + strategy = new StdoutStrategy(); + break; + default: + throw new Error( + `Unknown output mode: ${trimmedMode}. Valid options: pr_comment, commit_comment, stdout`, + ); + } + + // Validate the strategy can work in this context + try { + strategy.validate(context); + this.strategies.push(strategy); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + throw new Error( + `Output mode '${trimmedMode}' validation failed: ${errorMessage}`, + ); + } + } + + if (this.strategies.length === 0) { + throw new Error("No valid output strategies configured"); + } + + console.log( + `📤 Configured output strategies: ${this.strategies.map((s) => s.name).join(", ")}`, + ); + } + + static parseOutputModes(outputModeInput: string): string[] { + if (!outputModeInput || outputModeInput.trim() === "") { + return ["pr_comment"]; // Default + } + + const modes = outputModeInput + .split(",") + .map((mode) => mode.trim()) + .filter((mode) => mode.length > 0); + + if (modes.length === 0) { + return ["pr_comment"]; // Default + } + + // Remove duplicates while preserving order + return [...new Set(modes)]; + } + + async createInitial( + context: ParsedGitHubContext, + ): Promise { + const identifiers: OutputIdentifiers = {}; + const errors: Error[] = []; + + for (const strategy of this.strategies) { + try { + const identifier = await strategy.createInitial(context); + identifiers[strategy.name] = identifier; + } catch (error) { + console.error( + `❌ Output strategy ${strategy.name} failed during createInitial:`, + error, + ); + const errorObj = + error instanceof Error ? error : new Error(String(error)); + errors.push(errorObj); + identifiers[strategy.name] = null; + } + } + + // If all strategies failed during initial creation, that's a problem + if (errors.length === this.strategies.length) { + const lastError = errors[errors.length - 1]; + throw new Error( + `All output strategies failed during initial creation. Last error: ${lastError?.message || "Unknown error"}`, + ); + } + + return identifiers; + } + + async updateFinal( + identifiers: OutputIdentifiers, + context: ParsedGitHubContext, + content: ReviewContent, + ): Promise { + const errors: Error[] = []; + + for (const strategy of this.strategies) { + try { + const identifier = identifiers[strategy.name] || null; + await strategy.updateFinal(identifier, context, content); + } catch (error) { + console.error( + `❌ Output strategy ${strategy.name} failed during updateFinal:`, + error, + ); + const errorObj = + error instanceof Error ? error : new Error(String(error)); + errors.push(errorObj); + } + } + + // If all strategies failed, throw an error to mark the action as failed + if (errors.length === this.strategies.length) { + throw new Error( + `All ${this.strategies.length} output strategies failed. See logs for details.`, + ); + } + + // If some strategies failed but others succeeded, log a warning + if (errors.length > 0) { + console.warn( + `⚠️ ${errors.length} of ${this.strategies.length} output strategies failed, but action completed partially.`, + ); + } + } + + /** + * Gets the primary identifier for legacy compatibility. + * Returns the first non-null identifier, preferring pr_comment. + */ + getPrimaryIdentifier(identifiers: OutputIdentifiers): string | null { + // Prefer pr_comment for backward compatibility + if (identifiers.pr_comment) { + return identifiers.pr_comment; + } + + // Otherwise return the first non-null identifier + for (const [, identifier] of Object.entries(identifiers)) { + if (identifier) { + return identifier; + } + } + + return null; + } + + /** + * Serializes identifiers to a JSON string for GITHUB_OUTPUT + */ + serializeIdentifiers(identifiers: OutputIdentifiers): string { + return JSON.stringify(identifiers); + } + + /** + * Deserializes identifiers from a JSON string from GITHUB_OUTPUT + */ + static deserializeIdentifiers(serialized: string): OutputIdentifiers { + if (!serialized || serialized.trim() === "") { + return {}; + } + + try { + return JSON.parse(serialized); + } catch (error) { + console.warn( + "Failed to parse identifiers JSON, treating as empty:", + error, + ); + return {}; + } + } +} diff --git a/src/output-strategies/base.ts b/src/output-strategies/base.ts new file mode 100644 index 000000000..0f26050d2 --- /dev/null +++ b/src/output-strategies/base.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env bun + +import type { ParsedGitHubContext } from "../github/context"; +import type { ExecutionDetails } from "../github/operations/comment-logic"; + +export interface ReviewContent { + summary: string; + body: string; + actionFailed: boolean; + executionDetails: ExecutionDetails | null; + jobUrl: string; + branchLink?: string; + prLink?: string; + branchName?: string; + triggerUsername?: string; + errorDetails?: string; +} + +export interface OutputStrategy { + readonly name: string; + + /** + * Creates an initial placeholder/tracking entity if needed. + * Returns an identifier (like a comment ID) for future updates. + */ + createInitial(context: ParsedGitHubContext): Promise; + + /** + * Updates the entity with the final content. + */ + updateFinal( + identifier: string | null, + context: ParsedGitHubContext, + content: ReviewContent, + ): Promise; + + /** + * Validates if this strategy can be used in the given context. + */ + validate(context: ParsedGitHubContext): void; +} diff --git a/src/output-strategies/commit-comment.ts b/src/output-strategies/commit-comment.ts new file mode 100644 index 000000000..2f3c3bbee --- /dev/null +++ b/src/output-strategies/commit-comment.ts @@ -0,0 +1,155 @@ +#!/usr/bin/env bun + +import type { Octokit } from "@octokit/rest"; +import type { ParsedGitHubContext } from "../github/context"; +import type { OutputStrategy, ReviewContent } from "./base"; + +export class CommitCommentStrategy implements OutputStrategy { + readonly name = "commit_comment"; + + constructor( + private octokit: Octokit, + private commitSha?: string, + ) {} + + validate(context: ParsedGitHubContext): void { + const sha = this.getCommitSha(context); + if (!sha) { + throw new Error( + "'commit_comment' output mode requires a determinable commit SHA (provide commit_sha input, or use in pull_request/push context)", + ); + } + } + + private getCommitSha(context: ParsedGitHubContext): string | null { + // 1. Use explicit commit_sha if provided + if (this.commitSha) { + return this.commitSha; + } + + // 2. Use PR HEAD commit if in PR context + if ( + "pull_request" in context.payload && + context.payload.pull_request?.head?.sha + ) { + return context.payload.pull_request.head.sha; + } + + // 3. Use workflow commit SHA + if ("after" in context.payload && context.payload.after) { + return context.payload.after; + } + + // 4. Use github.sha as fallback + return process.env.GITHUB_SHA || null; + } + + async createInitial(_context: ParsedGitHubContext): Promise { + // Commit comments cannot be updated, so we don't create initial placeholder + console.log("📝 Preparing to create commit comment..."); + return null; + } + + async updateFinal( + _identifier: string | null, + context: ParsedGitHubContext, + content: ReviewContent, + ): Promise { + const sha = this.getCommitSha(context); + if (!sha) { + throw new Error("Cannot create commit comment without commit SHA"); + } + + const { owner, repo } = context.repository; + + // Format content for commit comment (plain text, no markdown links) + const commentBody = this.formatForCommitComment(content); + + try { + const response = await this.octokit.rest.repos.createCommitComment({ + owner, + repo, + commit_sha: sha, + body: commentBody, + }); + + console.log( + `✅ Created commit comment on ${sha.substring(0, 7)}: ${response.data.html_url}`, + ); + } catch (error) { + console.error(`Error creating commit comment on ${sha}:`, error); + throw error; + } + } + + private formatForCommitComment(content: ReviewContent): string { + const lines: string[] = []; + + // Header + if (content.actionFailed) { + lines.push("🚨 Claude Code encountered an error"); + } else { + const username = content.triggerUsername || "user"; + lines.push(`✅ Claude Code completed @${username}'s task`); + } + + // Duration info + if (content.executionDetails?.duration_ms) { + const totalSeconds = Math.round( + content.executionDetails.duration_ms / 1000, + ); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const durationStr = + minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + lines.push(`⏱️ Duration: ${durationStr}`); + } + + // Links + lines.push(`🔗 Job: ${content.jobUrl}`); + if (content.branchName) { + lines.push(`🌿 Branch: ${content.branchName}`); + } + if (content.prLink) { + // Extract URL from markdown link + const urlMatch = content.prLink.match(/\(([^)]+)\)/); + if (urlMatch) { + lines.push(`📥 PR: ${urlMatch[1]}`); + } + } + + lines.push(""); // Empty line before content + + // Error details + if (content.actionFailed && content.errorDetails) { + lines.push("Error details:"); + lines.push(content.errorDetails); + lines.push(""); // Empty line after error + } + + // Main content (strip existing header/footer formatting) + let mainContent = content.body; + + // Remove "Claude Code is working..." pattern + mainContent = mainContent + .replace(/Claude Code is working[…\.]{1,3}(?:\s*]*>)?/i, "") + .trim(); + + // Remove existing job/branch links + mainContent = mainContent.replace(/\[View job\]\([^\)]+\)/g, ""); + mainContent = mainContent.replace(/\[View branch\]\([^\)]+\)/g, ""); + mainContent = mainContent.replace(/\[Create .* PR\]\([^\)]+\)/g, ""); + + // Remove separator lines + mainContent = mainContent.replace(/\n*---\n*/g, ""); + + // Clean up extra whitespace + mainContent = mainContent.trim(); + + if (mainContent) { + lines.push(mainContent); + } + + return lines.join("\n"); + } +} diff --git a/src/output-strategies/pr-comment.ts b/src/output-strategies/pr-comment.ts new file mode 100644 index 000000000..d7eb3d7fb --- /dev/null +++ b/src/output-strategies/pr-comment.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env bun + +import type { Octokit } from "@octokit/rest"; +import { appendFileSync } from "fs"; +import { + createJobRunLink, + createCommentBody, +} from "../github/operations/comments/common"; +import { updateCommentBody } from "../github/operations/comment-logic"; +import { + isPullRequestReviewCommentEvent, + type ParsedGitHubContext, +} from "../github/context"; +import type { OutputStrategy, ReviewContent } from "./base"; + +export class PrCommentStrategy implements OutputStrategy { + readonly name = "pr_comment"; + + constructor(private octokit: Octokit) {} + + validate(context: ParsedGitHubContext): void { + // PR comment strategy works for both issues and PRs + if (!context.entityNumber) { + throw new Error( + "'pr_comment' output mode requires an issue or PR number", + ); + } + } + + async createInitial(context: ParsedGitHubContext): Promise { + const { owner, repo } = context.repository; + const jobRunLink = createJobRunLink(owner, repo, context.runId); + const initialBody = createCommentBody(jobRunLink); + + try { + let response; + + // Only use createReplyForReviewComment if it's a PR review comment AND we have a comment_id + if (isPullRequestReviewCommentEvent(context)) { + response = await this.octokit.rest.pulls.createReplyForReviewComment({ + owner, + repo, + pull_number: context.entityNumber, + comment_id: context.payload.comment.id, + body: initialBody, + }); + } else { + // For all other cases (issues, issue comments, or missing comment_id) + response = await this.octokit.rest.issues.createComment({ + owner, + repo, + issue_number: context.entityNumber, + body: initialBody, + }); + } + + // Output the comment ID for downstream steps using GITHUB_OUTPUT + const githubOutput = process.env.GITHUB_OUTPUT!; + appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`); + console.log(`✅ Created initial comment with ID: ${response.data.id}`); + return response.data.id.toString(); + } catch (error) { + console.error("Error in initial comment:", error); + + // Always fall back to regular issue comment if anything fails + try { + const response = await this.octokit.rest.issues.createComment({ + owner, + repo, + issue_number: context.entityNumber, + body: initialBody, + }); + + const githubOutput = process.env.GITHUB_OUTPUT!; + appendFileSync(githubOutput, `claude_comment_id=${response.data.id}\n`); + console.log(`✅ Created fallback comment with ID: ${response.data.id}`); + return response.data.id.toString(); + } catch (fallbackError) { + console.error("Error creating fallback comment:", fallbackError); + throw fallbackError; + } + } + } + + async updateFinal( + identifier: string | null, + context: ParsedGitHubContext, + content: ReviewContent, + ): Promise { + if (!identifier) { + throw new Error("Cannot update comment without identifier"); + } + + const commentId = parseInt(identifier); + const { owner, repo } = context.repository; + + // Use the existing comment body formatting logic + const updatedBody = updateCommentBody({ + currentBody: content.body, + actionFailed: content.actionFailed, + executionDetails: content.executionDetails, + jobUrl: content.jobUrl, + branchLink: content.branchLink, + prLink: content.prLink, + branchName: content.branchName, + triggerUsername: content.triggerUsername, + errorDetails: content.errorDetails, + }); + + try { + // Try PR review comment API first if it's a PR review comment + if (isPullRequestReviewCommentEvent(context)) { + await this.octokit.rest.pulls.updateReviewComment({ + owner, + repo, + comment_id: commentId, + body: updatedBody, + }); + } else { + // Use issue comment API (works for both issues and PR general comments) + await this.octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body: updatedBody, + }); + } + + console.log(`✅ Updated comment with ID: ${commentId}`); + } catch (error: any) { + // If PR review comment update fails with 404, fall back to issue comment API + if (isPullRequestReviewCommentEvent(context) && error.status === 404) { + await this.octokit.rest.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body: updatedBody, + }); + console.log(`✅ Updated comment with ID: ${commentId} (fallback)`); + } else { + throw error; + } + } + } +} diff --git a/src/output-strategies/stdout.ts b/src/output-strategies/stdout.ts new file mode 100644 index 000000000..c1fbc6a25 --- /dev/null +++ b/src/output-strategies/stdout.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env bun + +import type { ParsedGitHubContext } from "../github/context"; +import type { OutputStrategy, ReviewContent } from "./base"; + +export class StdoutStrategy implements OutputStrategy { + readonly name = "stdout"; + + validate(_context: ParsedGitHubContext): void { + // stdout strategy works in any context + } + + async createInitial(_context: ParsedGitHubContext): Promise { + console.log("🤖 Starting Claude Code review..."); + return null; + } + + async updateFinal( + _identifier: string | null, + _context: ParsedGitHubContext, + content: ReviewContent, + ): Promise { + const output = this.formatForStdout(content); + + console.log(""); // Empty line before output + console.log("=".repeat(60)); + console.log("Claude Code Review Summary"); + console.log("=".repeat(60)); + console.log(output); + console.log("=".repeat(60)); + } + + private formatForStdout(content: ReviewContent): string { + const lines: string[] = []; + + // Status + if (content.actionFailed) { + lines.push("Status: FAILED"); + } else { + lines.push("Status: COMPLETED"); + } + + // Duration + if (content.executionDetails?.duration_ms) { + const totalSeconds = Math.round( + content.executionDetails.duration_ms / 1000, + ); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const durationStr = + minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`; + lines.push(`Duration: ${durationStr}`); + } + + // Cost + if (content.executionDetails?.cost_usd) { + lines.push(`Cost: $${content.executionDetails.cost_usd.toFixed(4)}`); + } + + // User + if (content.triggerUsername) { + lines.push(`Triggered by: @${content.triggerUsername}`); + } + + // Links + lines.push(`Job URL: ${content.jobUrl}`); + if (content.branchName) { + lines.push(`Branch: ${content.branchName}`); + } + if (content.prLink) { + // Extract URL from markdown link + const urlMatch = content.prLink.match(/\(([^)]+)\)/); + if (urlMatch) { + lines.push(`PR URL: ${urlMatch[1]}`); + } + } + + lines.push(""); // Empty line before content + + // Error details + if (content.actionFailed && content.errorDetails) { + lines.push("ERROR DETAILS:"); + lines.push("-".repeat(40)); + lines.push(content.errorDetails); + lines.push("-".repeat(40)); + lines.push(""); // Empty line after error + } + + // Main content (convert markdown to plain text) + let mainContent = content.body; + + // Remove "Claude Code is working..." pattern + mainContent = mainContent + .replace(/Claude Code is working[…\.]{1,3}(?:\s*]*>)?/i, "") + .trim(); + + // Convert markdown to plain text + mainContent = this.markdownToPlainText(mainContent); + + // Remove existing job/branch links + mainContent = mainContent.replace(/\[View job\]\([^\)]+\)/g, ""); + mainContent = mainContent.replace(/\[View branch\]\([^\)]+\)/g, ""); + mainContent = mainContent.replace(/\[Create .* PR\]\([^\)]+\)/g, ""); + + // Remove separator lines + mainContent = mainContent.replace(/\n*---\n*/g, ""); + + // Clean up extra whitespace + mainContent = mainContent.trim(); + + if (mainContent) { + lines.push("CONTENT:"); + lines.push("-".repeat(40)); + lines.push(mainContent); + } + + return lines.join("\n"); + } + + private markdownToPlainText(markdown: string): string { + return ( + markdown + // Remove markdown links but keep URL + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, "$1 ($2)") + // Remove bold/italic + .replace(/\*\*([^*]*)\*\*/g, "$1") + .replace(/\*([^*]*)\*/g, "$1") + // Remove code blocks + .replace(/```[\s\S]*?```/g, "[CODE BLOCK]") + // Remove inline code + .replace(/`([^`]*)`/g, "$1") + // Remove headers + .replace(/^#{1,6}\s+(.*)$/gm, "$1") + // Clean up multiple newlines + .replace(/\n{3,}/g, "\n\n") + ); + } +}