diff --git a/README.md b/README.md index c014fa6..a3ecc23 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ pnpm install # Test with default prompt npm run play # Run locally on your machine -npm run play -- --act # Run in Docker (simulates GitHub Actions) ``` ## Testing with play.ts diff --git a/agents/claude.ts b/agents/claude.ts index 86e8952..7fd0fea 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -2,7 +2,7 @@ import * as core from "@actions/core"; import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { createMcpConfig } from "../mcp/config.ts"; import { debugLog, isDebug } from "../utils/logging.ts"; -import { boxString, tableString } from "../utils/table.ts"; +import { log } from "../utils/cli.ts"; import { instructions } from "./shared.ts"; import type { Agent, AgentConfig, AgentResult } from "./types.ts"; @@ -29,9 +29,9 @@ export class ClaudeAgent implements Agent { * Execute Claude Code with the given prompt using the SDK */ async execute(prompt: string): Promise { - core.info("Running Claude Agent SDK..."); + log.info("Running Claude Agent SDK..."); - console.log(boxString(prompt, { title: "Prompt" })); + log.box(prompt, { title: "Prompt" }); const mcpConfig = JSON.parse(createMcpConfig(this.githubInstallationToken)); @@ -59,10 +59,10 @@ export class ClaudeAgent implements Agent { // Stream the results for await (const message of queryInstance) { const handler = messageHandlers[message.type]; - handler(message as never); + await handler(message as never); } - core.info("✅ Task complete."); + log.success("Task complete."); core.endGroup(); return { @@ -76,7 +76,7 @@ type SDKMessageType = SDKMessage["type"]; type SDKMessageHandler = ( data: Extract -) => void; +) => void | Promise; type SDKMessageHandlers = { [type in SDKMessageType]: SDKMessageHandler; @@ -87,33 +87,33 @@ const messageHandlers: SDKMessageHandlers = { if (data.message?.content) { for (const content of data.message.content) { if (content.type === "text" && content.text?.trim()) { - core.info(boxString(content.text.trim(), { title: "Claude" })); + log.box(content.text.trim(), { title: "Claude" }); } else if (content.type === "tool_use") { - core.info(`→ ${content.name}`); + log.info(`→ ${content.name}`); if (content.input) { const input = content.input as any; - if (input.description) core.info(` └─ ${input.description}`); - if (input.command) core.info(` └─ command: ${input.command}`); - if (input.file_path) core.info(` └─ file: ${input.file_path}`); + if (input.description) log.info(` └─ ${input.description}`); + if (input.command) log.info(` └─ command: ${input.command}`); + if (input.file_path) log.info(` └─ file: ${input.file_path}`); if (input.content) { const preview = input.content.length > 100 ? `${input.content.substring(0, 100)}...` : input.content; - core.info(` └─ content: ${preview}`); + log.info(` └─ content: ${preview}`); } - if (input.query) core.info(` └─ query: ${input.query}`); - if (input.pattern) core.info(` └─ pattern: ${input.pattern}`); - if (input.url) core.info(` └─ url: ${input.url}`); + if (input.query) log.info(` └─ query: ${input.query}`); + if (input.pattern) log.info(` └─ pattern: ${input.pattern}`); + if (input.url) log.info(` └─ url: ${input.url}`); if (input.edits && Array.isArray(input.edits)) { - core.info(` └─ edits: ${input.edits.length} changes`); + log.info(` └─ edits: ${input.edits.length} changes`); input.edits.forEach((edit: any, index: number) => { - if (edit.file_path) core.info(` ${index + 1}. ${edit.file_path}`); + if (edit.file_path) log.info(` ${index + 1}. ${edit.file_path}`); }); } - if (input.task) core.info(` └─ task: ${input.task}`); - if (input.bash_command) core.info(` └─ bash_command: ${input.bash_command}`); + if (input.task) log.info(` └─ task: ${input.task}`); + if (input.bash_command) log.info(` └─ bash_command: ${input.bash_command}`); } } } @@ -123,26 +123,27 @@ const messageHandlers: SDKMessageHandlers = { if (data.message?.content) { for (const content of data.message.content) { if (content.type === "tool_result" && content.is_error) { - core.warning(`❌ Tool error: ${content.content}`); + log.warning(`Tool error: ${content.content}`); } } } }, - result: (data) => { + result: async (data) => { if (data.subtype === "success") { - core.info( - tableString([ - ["Cost", `$${data.total_cost_usd?.toFixed(4) || "0.0000"}`], - ["Input Tokens", data.usage?.input_tokens || 0], - ["Output Tokens", data.usage?.output_tokens || 0], - ]) - ); + await log.table([ + [{ data: "Cost", header: true }, { data: "Input Tokens", header: true }, { data: "Output Tokens", header: true }], + [ + `$${data.total_cost_usd?.toFixed(4) || "0.0000"}`, + String(data.usage?.input_tokens || 0), + String(data.usage?.output_tokens || 0), + ], + ]); } else if (data.subtype === "error_max_turns") { - core.error(`❌ Max turns reached: ${JSON.stringify(data)}`); + log.error(`Max turns reached: ${JSON.stringify(data)}`); } else if (data.subtype === "error_during_execution") { - core.error(`❌ Execution error: ${JSON.stringify(data)}`); + log.error(`Execution error: ${JSON.stringify(data)}`); } else { - core.error(`❌ Failed: ${JSON.stringify(data)}`); + log.error(`Failed: ${JSON.stringify(data)}`); } }, system: () => {}, diff --git a/agents/shared.ts b/agents/shared.ts index d813ec5..c0fb2e0 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -5,6 +5,9 @@ export const instructions = `- use the ${mcpServerName} MCP server to interact w - do not under any circumstances use the gh cli - if prompted by a comment to respond to create a new issue, pr or anything else, after succeeding, also respond to the original comment with a very brief message containing a link to it -- if prompted for to review a pr, use the diff_hint returned by mcp__${mcpServerName}__get_pull_request to get the diff - and analyze the diff to determine the review type and body +- if prompted to review a PR: + (1) get PR info with mcp__${mcpServerName}__get_pull_request + (2) fetch both branches: git fetch origin --depth=20 && git fetch origin + (3) view diff: git diff origin/...origin/ + replace and with 'base' and 'head' from the PR info `; diff --git a/entry.ts b/entry.ts index b51bd8c..c4eb4e2 100644 --- a/entry.ts +++ b/entry.ts @@ -8,10 +8,11 @@ import * as core from "@actions/core"; import { type } from "arktype"; import { Inputs, main } from "./main.ts"; import packageJson from "./package.json" with { type: "json" }; +import { log } from "./utils/cli.ts"; async function run(): Promise { try { - console.log(`🐸 Running pullfrog/action@${packageJson.version}...`); + log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); const inputsJson = process.env.INPUTS_JSON; if (!inputsJson) { diff --git a/mcp/prInfo.ts b/mcp/prInfo.ts index 63c3607..ae82d41 100644 --- a/mcp/prInfo.ts +++ b/mcp/prInfo.ts @@ -7,7 +7,7 @@ export const PullRequestInfo = type({ export const PullRequestInfoTool = tool({ name: "get_pull_request", - description: "Retrieve detailed information for a specific pull request by number, with a suggested local diff command.", + description: "Retrieve minimal information for a specific pull request by number.", parameters: PullRequestInfo, execute: contextualize(async ({ pull_number }, ctx) => { const pr = await ctx.octokit.rest.pulls.get({ @@ -17,16 +17,9 @@ export const PullRequestInfoTool = tool({ }); const data = pr.data; - - const baseBranch = data.base?.ref; - const headBranch = data.head?.ref; - const headSha = data.head?.sha; - - // Suggest a local diff command similar to claude-code-action - // Consumers can run this in a repo checkout to view the diff without API diff access - const diff_hint = baseBranch - ? `git fetch origin ${baseBranch} --depth=20 && git diff origin/${baseBranch}...${headSha || "HEAD"}` - : undefined; + + const baseBranch = data.base.ref; + const headBranch = data.head.ref; return { number: data.number, @@ -35,21 +28,8 @@ export const PullRequestInfoTool = tool({ state: data.state, draft: data.draft, merged: data.merged, - mergeable: data.mergeable, - user: data.user?.login, - created_at: data.created_at, - updated_at: data.updated_at, - head: headBranch, - head_sha: headSha, base: baseBranch, - base_sha: data.base?.sha, - additions: data.additions, - deletions: data.deletions, - changed_files: data.changed_files, - labels: (data.labels || []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), - requested_reviewers: (data.requested_reviewers || []).map((u) => u.login), - requested_teams: (data.requested_teams || []).map((t) => t.slug), - diff_hint, + head: headBranch, }; }), }); diff --git a/play.ts b/play.ts index ecde0cf..c43d9c8 100644 --- a/play.ts +++ b/play.ts @@ -6,22 +6,16 @@ import arg from "arg"; import { config } from "dotenv"; import { type Inputs, main } from "./main.ts"; import packageJson from "./package.json" with { type: "json" }; -import { runAct } from "./utils/act.ts"; +import { log } from "./utils/cli.ts"; import { setupTestRepo } from "./utils/setup.ts"; config(); export async function run( - prompt: string, - options: { act?: boolean } = {} + prompt: string ): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> { try { - console.log(`🐸 Running pullfrog/action@${packageJson.version}...`); - if (options.act) { - console.log("🐳 Running with Docker/act..."); - runAct(prompt); - return { success: true }; - } + log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); const tempDir = join(process.cwd(), ".temp"); setupTestRepo({ tempDir, forceClean: true }); @@ -29,11 +23,10 @@ export async function run( const originalCwd = process.cwd(); process.chdir(tempDir); - console.log("🚀 Running action with prompt..."); - console.log("─".repeat(50)); - console.log("Prompt:"); - console.log(prompt); - console.log("─".repeat(50)); + log.info("🚀 Running action with prompt..."); + log.separator(); + log.box(prompt, { title: "Prompt" }); + log.separator(); const inputs: Inputs = { prompt, @@ -45,15 +38,15 @@ export async function run( process.chdir(originalCwd); if (result.success) { - console.log("✅ Action completed successfully"); + log.success("Action completed successfully"); return { success: true, output: result.output || undefined, error: undefined }; } else { - console.error("❌ Action failed:", result.error); + log.error(`Action failed: ${result.error || "Unknown error"}`); return { success: false, error: result.error || undefined, output: undefined }; } - } catch (error) { - const errorMessage = (error as Error).message; - console.error("❌ Error:", errorMessage); + } catch (err) { + const errorMessage = (err as Error).message; + log.error(`Error: ${errorMessage}`); return { success: false, error: errorMessage, output: undefined }; } } @@ -61,7 +54,6 @@ export async function run( if (import.meta.url === `file://${process.argv[1]}`) { const args = arg({ "--help": Boolean, - "--act": Boolean, "--raw": String, "-h": "--help", }); @@ -76,7 +68,6 @@ Arguments: file Prompt file to use (.txt, .json, or .ts) [default: fixtures/basic.txt] Options: - --act Use Docker/act to run the action instead of running directly --raw [prompt] Use raw string as prompt instead of loading from file -h, --help Show this help message @@ -84,7 +75,7 @@ Examples: tsx play.ts # Use default fixture tsx play.ts fixtures/basic.txt # Use specific text file tsx play.ts custom.json # Use JSON file - tsx play.ts --act fixtures/test.ts # Use TypeScript file with Docker/act + tsx play.ts fixtures/test.ts # Use TypeScript file tsx play.ts --raw "Hello world" # Use raw string as prompt `); process.exit(0); @@ -145,13 +136,13 @@ Examples: } try { - const result = await run(prompt, { act: args["--act"] || false }); + const result = await run(prompt); if (!result.success) { process.exit(1); } - } catch (error) { - console.error("❌ Error:", (error as Error).message); + } catch (err) { + log.error((err as Error).message); process.exit(1); } } diff --git a/utils/act.ts b/utils/act.ts deleted file mode 100644 index f8f547f..0000000 --- a/utils/act.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { config } from "dotenv"; -import { setupTestRepo } from "./setup.ts"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const tempDir = join(__dirname, "..", ".temp"); -const actionPath = join(__dirname, ".."); -const envPath = join(__dirname, "..", "..", ".env"); - -const ENV_VARS = ["ANTHROPIC_API_KEY", "GITHUB_INSTALLATION_TOKEN"]; - -export function runAct(prompt: string): void { - setupTestRepo({ tempDir }); - - config({ path: envPath }); - - const workflowPath = join(tempDir, ".github", "workflows", "pullfrog.yml"); - - const distPath = join(actionPath, ".act-dist"); - console.log("📦 Creating minimal distribution for act..."); - execSync(`rm -rf "${distPath}" && mkdir -p "${distPath}"`, { shell: "/bin/bash" }); - - ["action.yml", "entry.cjs", "index.cjs", "package.json"].forEach((file) => { - const src = join(actionPath, file); - if (existsSync(src)) { - execSync(`cp "${src}" "${distPath}"`); - } - }); - - try { - const escapedPrompt = prompt.replace(/'/g, "'\\''"); - - const actCommandParts = [ - "act", - "workflow_dispatch", - "-W", - workflowPath, - "--input", - `prompt='${escapedPrompt}'`, - "--local-repository", - `pullfrog/action@v0=${distPath}`, // Use minimal dist without symlinks - ]; - - ENV_VARS.forEach((key) => { - if (process.env[key]) { - actCommandParts.push("-s", key); - } - }); - - const actCommand = actCommandParts.join(" "); - - console.log("🚀 Running act with prompt:"); - console.log("─".repeat(50)); - console.log(prompt); - console.log("─".repeat(50)); - console.log(""); - - execSync(actCommand, { - stdio: "inherit", - cwd: join(__dirname, "..", ".."), - }); - execSync(`rm -rf "${distPath}"`); - } catch (error) { - execSync(`rm -rf "${distPath}"`); - console.error("❌ Act execution failed:", (error as Error).message); - process.exit(1); - } -} diff --git a/utils/cli.ts b/utils/cli.ts new file mode 100644 index 0000000..e6ba45e --- /dev/null +++ b/utils/cli.ts @@ -0,0 +1,315 @@ +/** + * CLI output utilities for GitHub Actions and local development + * Uses GitHub Actions Job Summaries API when available for rich formatting + */ + +import * as core from "@actions/core"; + +const isGitHubActions = !!process.env.GITHUB_ACTIONS; + +/** + * Print a formatted box with text (for console output) + */ +function boxString( + text: string, + options?: { + title?: string; + maxWidth?: number; + indent?: string; + padding?: number; + } +): string { + const { title, maxWidth = 80, indent = "", padding = 1 } = options || {}; + + const lines = text.trim().split("\n"); + const wrappedLines: string[] = []; + + for (const line of lines) { + if (line.length <= maxWidth - padding * 2) { + wrappedLines.push(line); + } else { + const words = line.split(" "); + let currentLine = ""; + + for (const word of words) { + const testLine = currentLine ? `${currentLine} ${word}` : word; + if (testLine.length <= maxWidth - padding * 2) { + currentLine = testLine; + } else { + if (currentLine) { + wrappedLines.push(currentLine); + currentLine = word; + } else { + wrappedLines.push(word.substring(0, maxWidth - padding * 2)); + currentLine = word.substring(maxWidth - padding * 2); + } + } + } + + if (currentLine) { + wrappedLines.push(currentLine); + } + } + } + + const maxLineLength = Math.max(...wrappedLines.map((line) => line.length)); + const boxWidth = maxLineLength + padding * 2; + + let result = ""; + + if (title) { + const titleLine = ` ${title} `; + const titlePadding = Math.max(0, boxWidth - titleLine.length); + result += `${indent}┌${titleLine}${"─".repeat(titlePadding)}┐\n`; + } + + if (!title) { + result += `${indent}┌${"─".repeat(boxWidth)}┐\n`; + } + + for (const line of wrappedLines) { + const paddedLine = line.padEnd(maxLineLength); + result += `${indent}│${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}│\n`; + } + + result += `${indent}└${"─".repeat(boxWidth)}┘`; + + return result; +} + +/** + * Print a formatted box with text + * In GitHub Actions, uses markdown code blocks; locally uses box formatting + */ +function box( + text: string, + options?: { + title?: string; + maxWidth?: number; + } +): void { + if (isGitHubActions) { + const { title } = options || {}; + let markdown = ""; + if (title) { + markdown += `### ${title}\n\n`; + } + markdown += "```\n"; + markdown += text; + markdown += "\n```\n"; + // Note: summary.write() should be called once at the end, but for immediate visibility we call it here + // In practice, you might want to batch multiple calls + core.summary.addRaw(markdown); + core.info(markdown); + } else { + log.info(boxString(text, options)); + } +} + +/** + * Add a table using GitHub Actions Job Summaries API + * Falls back to formatted console table locally + */ +async function table( + rows: Array>, + options?: { + title?: string; + } +): Promise { + const { title } = options || {}; + + if (isGitHubActions) { + const summary = core.summary; + + if (title) { + summary.addHeading(title); + } + + // Convert rows to format expected by Job Summaries API + const formattedRows = rows.map((row) => + row.map((cell) => { + if (typeof cell === "string") { + return { data: cell }; + } + return cell; + }) + ); + + summary.addTable(formattedRows); + await summary.write(); + + // Also log to console for visibility in logs + const tableText = formattedRows + .map((row) => row.map((cell) => cell.data).join(" | ")) + .join("\n"); + log.info(tableText); + } else { + // Local fallback: simple console table + if (title) { + log.info(`\n${title}`); + } + const tableText = rows + .map((row) => { + const rowData = row.map((cell) => { + const text = typeof cell === "string" ? cell : cell.data; + const isHeader = typeof cell !== "string" && cell.header; + return isHeader ? `**${text}**` : text; + }); + return rowData.join(" | "); + }) + .join("\n"); + log.info(`\n${tableText}\n`); + } +} + +/** + * Add raw markdown to job summary (GitHub Actions) or print as info (local) + */ +async function markdown(content: string): Promise { + if (isGitHubActions) { + core.summary.addRaw(content); + await core.summary.write(); + // Also log to console + core.info(content); + } else { + log.info(content); + } +} + +/** + * Add a code block to output + */ +function codeBlock(code: string, language?: string): void { + if (isGitHubActions) { + core.summary.addCodeBlock(code, language); + core.info(`\`\`\`${language || ""}\n${code}\n\`\`\``); + } else { + log.info(`\`\`\`${language || ""}\n${code}\n\`\`\``); + } +} + +/** + * Add a link to output + */ +function link(text: string, url: string): void { + if (isGitHubActions) { + core.summary.addLink(text, url); + log.info(`[${text}](${url})`); + } else { + log.info(`[${text}](${url})`); + } +} + +/** + * Write all pending summary changes to the job summary + * Call this at the end of your action to ensure all summary content is written + */ +async function writeSummary(): Promise { + if (isGitHubActions) { + await core.summary.write(); + } +} + +/** + * Print a separator line + */ +function separator(length: number = 50): void { + if (isGitHubActions) { + core.info("─".repeat(length)); + } else { + console.log("─".repeat(length)); + } +} + +/** + * Main logging utility object - import this once and access all utilities + */ +export const log = { + /** + * Check if running in GitHub Actions environment + */ + isGitHubActions: (): boolean => isGitHubActions, + + /** + * Print info message (GitHub Actions or console) + */ + info: (message: string): void => { + if (isGitHubActions) { + core.info(message); + } else { + console.log(message); + } + }, + + /** + * Print warning message + */ + warning: (message: string): void => { + if (isGitHubActions) { + core.warning(message); + } else { + console.warn(`⚠️ ${message}`); + } + }, + + /** + * Print error message + */ + error: (message: string): void => { + if (isGitHubActions) { + core.error(message); + } else { + console.error(`❌ ${message}`); + } + }, + + /** + * Print success message + */ + success: (message: string): void => { + const msg = `✅ ${message}`; + if (isGitHubActions) { + core.info(msg); + } else { + console.log(msg); + } + }, + + /** + * Print a formatted box with text + * In GitHub Actions, uses markdown code blocks; locally uses box formatting + */ + box, + + /** + * Add a table using GitHub Actions Job Summaries API + * Falls back to formatted console table locally + */ + table, + + /** + * Add raw markdown to job summary (GitHub Actions) or print as info (local) + */ + markdown, + + /** + * Add a code block to output + */ + codeBlock, + + /** + * Add a link to output + */ + link, + + /** + * Write all pending summary changes to the job summary + * Call this at the end of your action to ensure all summary content is written + */ + writeSummary, + + /** + * Print a separator line + */ + separator, +};