improve logging, remove act

This commit is contained in:
David Blass
2025-10-31 00:25:02 -04:00
parent b2badf6d16
commit 876663cd1a
8 changed files with 375 additions and 158 deletions
-1
View File
@@ -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
+32 -31
View File
@@ -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<AgentResult> {
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<type extends SDKMessageType = SDKMessageType> = (
data: Extract<SDKMessage, { type: type }>
) => void;
) => void | Promise<void>;
type SDKMessageHandlers = {
[type in SDKMessageType]: SDKMessageHandler<type>;
@@ -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: () => {},
+5 -2
View File
@@ -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 <base> --depth=20 && git fetch origin <head>
(3) view diff: git diff origin/<base>...origin/<head>
replace <base> and <head> with 'base' and 'head' from the PR info
`;
+2 -1
View File
@@ -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<void> {
try {
console.log(`🐸 Running pullfrog/action@${packageJson.version}...`);
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
const inputsJson = process.env.INPUTS_JSON;
if (!inputsJson) {
+5 -25
View File
@@ -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,
};
}),
});
+16 -25
View File
@@ -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);
}
}
-73
View File
@@ -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);
}
}
+315
View File
@@ -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<Array<{ data: string; header?: boolean } | string>>,
options?: {
title?: string;
}
): Promise<void> {
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<void> {
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<void> {
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,
};