Compare commits

..

42 Commits

Author SHA1 Message Date
David Blass 5a21d40d27 start mcp server in memory 2025-11-06 17:56:06 -05:00
David Blass 175f92542e bump version 2025-11-06 17:40:19 -05:00
David Blass b448787f24 update lock 2025-11-06 17:38:49 -05:00
David Blass 65e3da81e9 revert to js action 2025-11-06 17:35:32 -05:00
Colin McDonnell f31e3a026e Update 2025-11-05 22:35:56 -08:00
Colin McDonnell 220652f27b Tweak prompt 2025-11-05 20:59:10 -08:00
Colin McDonnell 349af82bfc remove unrecognized handlers 2025-11-05 19:02:06 -08:00
David Blass 15732d126d start working on passthrough logging for bash 2025-11-05 19:27:37 -05:00
David Blass 36b006108b tweak mcp prompt 2025-11-05 16:03:47 -05:00
David Blass 029ae0d280 bump version 2025-11-05 15:54:37 -05:00
David Blass 92b435eb80 switch to pnpm CLAUDE-ACTION.md README.md action.yml agents coverage entry.ts fixtures index.ts main.ts mcp node_modules package.json play.ts pnpm-lock.yaml todo.md tsconfig.json utils 2025-11-05 15:52:57 -05:00
David Blass cacf9674c4 remove pnpm latest 2025-11-05 13:53:55 -05:00
David Blass f73260e3e6 remove pnpm cache 2025-11-05 13:50:47 -05:00
David Blass 3ddd6db7ca add mode, comment edit prompting 2025-11-05 11:08:44 -05:00
David Blass 68499340e4 add todo 2025-11-02 14:30:42 -05:00
David Blass acb06634be rely primarily on inline pr feedback 2025-10-31 04:04:06 -04:00
David Blass 681e08557c improve agent api 2025-10-31 03:15:51 -04:00
David Blass 15a7154aea improve logging 2025-10-31 01:58:43 -04:00
David Blass 434458a068 update lockfile 2025-10-31 01:07:36 -04:00
David Blass 193954fdd7 bump action 2025-10-31 01:03:17 -04:00
David Blass ab2d762658 update action, iterate on logging 2025-10-31 00:46:40 -04:00
David Blass 876663cd1a improve logging, remove act 2025-10-31 00:25:02 -04:00
David Blass b2badf6d16 improve pr approach 2025-10-30 14:16:44 -04:00
David Blass 05fb2065b2 initial version of pr review tools 2025-10-30 10:52:01 -04:00
ssalbdivad 2042a5bf98 add handler map for sdk parsing 2025-10-24 21:05:08 -04:00
David Blass 12da2b770c remove inaccurate parts of README 2025-10-24 17:36:55 -04:00
David Blass a26ada9839 switch to anthropic typescript-sdk 2025-10-24 17:31:34 -04:00
David Blass 1328894afd update action 2025-10-23 17:10:28 -04:00
Pullfrog Action 85731f8360 fix action cwd 2025-10-23 16:18:55 -04:00
David Blass 1922352d86 fix git push auth 2025-10-23 16:12:15 -04:00
David Blass c0f31415a3 try setting cwd 2025-10-23 15:43:50 -04:00
David Blass 706ce04895 bump version 2025-10-23 15:37:04 -04:00
David Blass 09be8e3068 try adding github token to env 2025-10-23 15:35:36 -04:00
David Blass c6c1210fa0 refactor tool implementation 2025-10-23 15:21:08 -04:00
David Blass 0368512b9e add pr and issue creation support 2025-10-23 10:24:32 -04:00
David Blass 9fb6135fd2 bump 2025-10-17 22:27:59 -04:00
David Blass bb78e5f94b update lockfile 2025-10-17 22:27:24 -04:00
David Blass c668578c6f refactor mcp and add instructions prefix 2025-10-17 22:26:24 -04:00
ssalbdivad 7f1566d9c2 update lockfile 2025-10-15 17:25:54 -04:00
ssalbdivad dd482566c2 bump version 2025-10-15 17:24:58 -04:00
ssalbdivad 57029c32a3 remove zod3 2025-10-15 17:24:50 -04:00
ssalbdivad 757d336475 switch to fastmcp 2025-10-15 17:24:29 -04:00
32 changed files with 2154 additions and 908 deletions
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Ensure lockfile is up to date
echo "🔒 Updating lockfile..."
pnpm install --lockfile-only
# Build the action before committing
echo "🔨 Building action..."
pnpm build
# Add the built files and lockfile to the commit
git add entry.cjs pnpm-lock.yaml
+1 -57
View File
@@ -12,69 +12,13 @@ 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
The `play.ts` script provides two ways to test the action:
### Local Mode (Default)
```bash
npm run play # Uses fixtures/play.txt
npm run play fixtures/complex.txt # Custom prompt file
pnpm play # Uses fixtures/play.txt
```
- Clones the scratch repository to `.temp`
- Runs Claude Code directly on your machine
- Fast iteration for development
### Docker Mode (--act flag)
```bash
npm run play -- --act # Uses fixtures/play.txt
npm run play fixtures/simple.txt -- --act # Custom prompt file
```
- Builds fresh bundles with esbuild
- Creates minimal distribution without node_modules
- Runs in Docker container via `act`
- Simulates GitHub Actions environment
### Prompt Files
Supports `.txt`, `.json`, and `.ts` files:
```bash
npm run play prompt.txt # Plain text prompt
npm run play config.json # JSON configuration
npm run play dynamic.ts # TypeScript with default export
```
## Building
```bash
pnpm build # Production build (bundles & removes node_modules)
pnpm build:dev # Development build (keeps node_modules)
pnpm dev # Watch mode
```
The action is bundled into `entry.cjs` with all dependencies included, eliminating runtime dependency on node_modules.
## Environment Variables
Create `.env` in `/action`:
```bash
ANTHROPIC_API_KEY=sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Claude API key
```
## Architecture
- **entry.cjs**: Bundled action entry point (self-contained)
- **agents/**: Agent implementations (Claude, etc.)
- **utils/**: Utilities for subprocess, act, and formatting
- **fixtures/**: Test prompt files
## Why No node_modules?
pnpm uses symlinks that cause "invalid symlink" errors when `act` copies the action to Docker. Our solution:
1. Bundle everything into `entry.cjs`
2. Remove node_modules after building
3. Create minimal `.act-dist` for Docker testing
+2 -25
View File
@@ -12,31 +12,8 @@ inputs:
required: false
runs:
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
shell: bash
working-directory: ${{ github.action_path }}
- name: Run agent
run: node entry.ts
shell: bash
working-directory: ${{ github.action_path }}
env:
INPUTS_JSON: ${{ toJSON(inputs) }}
using: "node20"
main: "entry.cjs"
branding:
icon: "code"
+138 -326
View File
@@ -1,333 +1,145 @@
import { access, constants } from "node:fs/promises";
import * as core from "@actions/core";
import { createMcpConfig } from "../mcp/config.ts";
import { spawn } from "../utils/subprocess.ts";
import { boxString, tableString } from "../utils/table.ts";
import type { Agent, AgentConfig, AgentResult } from "./types.ts";
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { log } from "../utils/cli.ts";
import { type Agent, instructions } from "./shared.ts";
/**
* Claude Code agent implementation
*/
export class ClaudeAgent implements Agent {
private apiKey: string;
private githubInstallationToken?: string;
public runStats = {
toolsUsed: 0,
turns: 0,
startTime: 0,
};
export const claude: Agent = {
run: async ({ prompt, mcpServers, apiKey }) => {
process.env.ANTHROPIC_API_KEY = apiKey;
constructor(config: AgentConfig) {
if (!config.apiKey) {
throw new Error("Claude agent requires an API key");
}
this.apiKey = config.apiKey;
this.githubInstallationToken = config.githubInstallationToken;
}
const queryInstance = query({
prompt: `${instructions}\n\n${prompt}`,
options: {
permissionMode: "bypassPermissions",
mcpServers,
},
});
/**
* Check if Claude Code CLI is already installed
*/
private async isClaudeInstalled(): Promise<boolean> {
try {
const claudePath = `${process.env.HOME}/.local/bin/claude`;
await access(claudePath, constants.F_OK | constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Install Claude Code CLI
*/
async install(): Promise<void> {
if (await this.isClaudeInstalled()) {
core.info("Claude Code is already installed, skipping installation");
return;
// Stream the results
for await (const message of queryInstance) {
const handler = messageHandlers[message.type];
await handler(message as never);
}
core.info("Installing Claude Code...");
try {
const result = await spawn({
cmd: "bash",
args: [
"-c",
"curl -fsSL https://claude.ai/install.sh | bash -s 1.0.93",
return {
success: true,
output: "",
};
},
};
type SDKMessageType = SDKMessage["type"];
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
data: Extract<SDKMessage, { type: type }>
) => void | Promise<void>;
type SDKMessageHandlers = {
[type in SDKMessageType]: SDKMessageHandler<type>;
};
// Track bash tool IDs to identify when bash tool results come back
const bashToolIds = new Set<string>();
const messageHandlers: SDKMessageHandlers = {
assistant: (data) => {
if (data.message?.content) {
for (const content of data.message.content) {
if (content.type === "text" && content.text?.trim()) {
log.box(content.text.trim(), { title: "Claude" });
} else if (content.type === "tool_use") {
log.info(`${content.name}`);
// Track bash tool IDs
if (content.name === "bash" && content.id) {
bashToolIds.add(content.id);
}
if (content.input) {
const input = content.input as any;
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;
log.info(` └─ content: ${preview}`);
}
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)) {
log.info(` └─ edits: ${input.edits.length} changes`);
input.edits.forEach((edit: any, index: number) => {
if (edit.file_path) log.info(` ${index + 1}. ${edit.file_path}`);
});
}
if (input.task) log.info(` └─ task: ${input.task}`);
if (input.bash_command) log.info(` └─ bash_command: ${input.bash_command}`);
}
}
}
}
},
user: (data) => {
if (data.message?.content) {
for (const content of data.message.content) {
if (content.type === "tool_result") {
const toolUseId = (content as any).tool_use_id;
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
if (isBashTool) {
// Log bash output in a collapsed group
const outputContent =
typeof content.content === "string"
? content.content
: Array.isArray(content.content)
? content.content
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c)))
.join("\n")
: String(content.content);
log.startGroup(`bash output`);
if (content.is_error) {
log.warning(outputContent);
} else {
log.info(outputContent);
}
log.endGroup();
// Clean up the tracked ID
bashToolIds.delete(toolUseId);
} else if (content.is_error) {
const errorContent =
typeof content.content === "string" ? content.content : String(content.content);
log.warning(`Tool error: ${errorContent}`);
}
}
}
}
},
result: async (data) => {
if (data.subtype === "success") {
await log.summaryTable([
[
{ data: "Cost", header: true },
{ data: "Input Tokens", header: true },
{ data: "Output Tokens", header: true },
],
env: { ANTHROPIC_API_KEY: this.apiKey },
timeout: 120000, // 2 minute timeout
onStdout: () => {},
onStderr: (chunk) => process.stderr.write(chunk),
});
if (result.exitCode !== 0) {
throw new Error(
`Installation failed with exit code ${result.exitCode}: ${result.stderr}`
);
}
core.info("Claude Code installed successfully");
} catch (error) {
throw new Error(`Failed to install Claude Code: ${error}`);
[
`$${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") {
log.error(`Max turns reached: ${JSON.stringify(data)}`);
} else if (data.subtype === "error_during_execution") {
log.error(`Execution error: ${JSON.stringify(data)}`);
} else {
log.error(`Failed: ${JSON.stringify(data)}`);
}
}
/**
* Execute Claude Code with the given prompt
*/
async execute(prompt: string): Promise<AgentResult> {
core.info("Running Claude Code...");
try {
const claudePath = `${process.env.HOME}/.local/bin/claude`;
console.log(boxString(prompt, { title: "Prompt" }));
const args = [
"--print",
"--output-format",
"stream-json",
"--verbose",
"--debug",
"--permission-mode",
"bypassPermissions",
];
if (!this.githubInstallationToken) {
throw new Error(
"GITHUB_INSTALLATION_TOKEN is required for GitHub integration"
);
}
const mcpConfig = createMcpConfig(this.githubInstallationToken);
console.log("📋 MCP Config:", mcpConfig);
args.push("--mcp-config", mcpConfig);
const env = {
ANTHROPIC_API_KEY: this.apiKey,
};
core.startGroup("🔄 Run details");
this.runStats = {
toolsUsed: 0,
turns: 0,
startTime: Date.now(),
};
const finalResult = "";
const totalCost = 0;
const result = await spawn({
cmd: claudePath,
args,
env,
input: prompt,
timeout: 10 * 60 * 1000, // 10 minutes
onStdout: (_chunk) => {
processJSONChunk(_chunk, this);
},
onStderr: (_chunk) => {
if (_chunk.trim()) {
processJSONChunk(_chunk, this);
}
},
});
if (result.exitCode !== 0) {
throw new Error(
`Command failed with exit code ${result.exitCode}\n\nStdout: ${result.stdout}\n\nStderr: ${result.stderr}`
);
}
const duration = Date.now() - this.runStats.startTime;
core.info(
`📊 Run Summary: ${this.runStats.toolsUsed} tools used, ${this.runStats.turns} turns, ${duration}ms duration`
);
core.info("✅ Task complete.");
core.endGroup(); // End the collapsible log group
return {
success: true,
output: finalResult,
metadata: {
promptLength: prompt.length,
exitCode: result.exitCode,
durationMs: result.durationMs,
totalCost,
},
};
} catch (error: any) {
try {
core.endGroup();
} catch {}
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return {
success: false,
error: `Failed to execute Claude Code: ${errorMessage}`,
};
}
}
}
/**
* Pretty print a JSON chunk based on its type
*/
function processJSONChunk(chunk: string, agent?: ClaudeAgent): void {
try {
console.log(chunk);
const parsedChunk = JSON.parse(chunk.trim());
switch (parsedChunk.type) {
case "system":
if (parsedChunk.subtype === "init") {
core.info(`🚀 Starting Claude Code session...`);
core.info(
tableString([
["model", parsedChunk.model],
["cwd", parsedChunk.cwd],
["permission_mode", parsedChunk.permissionMode],
[
"tools",
parsedChunk.tools?.length
? `${parsedChunk.tools.length} tools`
: "none",
],
[
"mcp_servers",
parsedChunk.mcp_servers?.length
? `${parsedChunk.mcp_servers.length} servers`
: "none",
],
[
"slash_commands",
parsedChunk.slash_commands?.length
? `${parsedChunk.slash_commands.length} commands`
: "none",
],
])
);
}
break;
case "assistant":
if (parsedChunk.message?.content) {
if (agent) {
agent.runStats.turns++;
}
for (const content of parsedChunk.message.content) {
if (content.type === "text") {
if (content.text.trim()) {
core.info(
boxString(content.text.trim(), { title: "Claude Code" })
);
}
} else if (content.type === "tool_use") {
if (agent) {
agent.runStats.toolsUsed++;
}
const toolName = content.name;
core.info(`${toolName}`);
if (content.input) {
const input = content.input;
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.content) {
const contentPreview =
input.content.length > 100
? `${input.content.substring(0, 100)}...`
: input.content;
core.info(` └─ content: ${contentPreview}`);
}
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.edits && Array.isArray(input.edits)) {
core.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 (input.task) {
core.info(` └─ task: ${input.task}`);
}
if (input.bash_command) {
core.info(` └─ bash_command: ${input.bash_command}`);
}
}
}
}
}
break;
case "user":
if (parsedChunk.message?.content) {
for (const content of parsedChunk.message.content) {
if (content.type === "tool_result") {
if (content.is_error) {
core.warning(`❌ Tool error: ${content.content}`);
} else {
}
}
}
}
break;
case "result":
if (parsedChunk.subtype === "success") {
core.info(
tableString([
[
"Cost",
`$${parsedChunk.total_cost_usd?.toFixed(4) || "0.0000"}`,
],
["Input Tokens", parsedChunk.usage?.input_tokens || 0],
["Output Tokens", parsedChunk.usage?.output_tokens || 0],
["Duration", `${parsedChunk.duration_ms}ms`],
["Turns", parsedChunk.num_turns || 1],
])
);
} else {
core.error(`❌ Failed: ${parsedChunk.error || "Unknown error"}`);
}
break;
default:
core.debug(`📦 Unknown chunk type: ${parsedChunk.type}`);
break;
}
} catch (error) {
core.debug(`Failed to parse chunk: ${error}`);
core.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
}
}
},
system: () => {},
stream_event: () => {},
};
+63
View File
@@ -0,0 +1,63 @@
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { ghPullfrogMcpName } from "../mcp/config.ts";
/**
* Result returned by agent execution
*/
export interface AgentResult {
success: boolean;
output?: string;
error?: string;
metadata?: Record<string, unknown>;
}
/**
* Configuration for agent creation
*/
export interface AgentConfig {
apiKey: string;
githubInstallationToken: string;
prompt: string;
mcpServers: Record<string, McpServerConfig>;
}
export type Agent = {
run: (config: AgentConfig) => Promise<AgentResult>;
};
export const instructions = `
You are a highly intelligent, no-nonsense senior-level software engineering agent. You are careful, to-the-point, and kind. You only say things you know to be true. Your code is focused, minimal, and production-ready. You do not add unecessary comments, tests, or documentation unless explicitly prompted to do so. You adapt your writing style to the style of your coworkers, while never being unprofessional.
- eagerly inspect your MCP servers to determine what tools are available to you, especially ${ghPullfrogMcpName}
- do not under any circumstances use the github cli (\`gh\`). find the corresponding tool from ${ghPullfrogMcpName} instead.
- mode selection: choose the appropriate mode based on the prompt payload:
- choose "plan mode" if the prompt asks to:
- create a plan, break down tasks, outline steps, or analyze requirements
- understand the scope of work before implementation
- provide a todo list or task breakdown
- choose "implement" if the prompt asks to:
- implement, build, create, or develop code changes
- make specific changes to files or features
- execute a plan that was previously created
- the prompt includes specific implementation details or requirements
- choose "review" if the prompt asks to:
- review code, PR, or implementation
- provide feedback, suggestions, or identify issues
- check code quality, style, or correctness
- once you've chosen a mode, follow its associated prompts carefully
- when prompted directly (e.g., via issue comment or PR comment):
(1) start by creating a single response comment using mcp__${ghPullfrogMcpName}__create_issue_comment
- the initial comment should say something like "I'll do {summary of request}" where you summarize what was requested
- save the commentId returned from this initial comment creation
(2) use mcp__${ghPullfrogMcpName}__edit_issue_comment to progressively update that same comment as you make progress
- update the comment with current status, completed tasks, and any relevant information
- continue updating the same comment throughout the planning/implementation process
(3) create_issue_comment should only be used once initially - all subsequent updates must use edit_issue_comment with the saved commentId
- if prompted to review a PR:
(1) get PR info with mcp__${ghPullfrogMcpName}__get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
(2) view diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments)
(3) read files from the checked-out PR branch to understand the implementation
(4) when submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
(5) only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
replace <base> and <head> with 'base' and 'head' from the PR info
`;
-34
View File
@@ -1,34 +0,0 @@
/**
* Standard interface for all Pullfrog agents
*/
export interface Agent {
/**
* Install the agent and any required dependencies
*/
install(): Promise<void>;
/**
* Execute the agent with the given prompt
* @param prompt The prompt to send to the agent
* @param options Additional options specific to the agent
*/
execute(prompt: string, options?: Record<string, any>): Promise<AgentResult>;
}
/**
* Result returned by agent execution
*/
export interface AgentResult {
success: boolean;
output?: string;
error?: string;
metadata?: Record<string, any>;
}
/**
* Configuration for agent creation
*/
export interface AgentConfig {
apiKey?: string;
[key: string]: any;
}
Executable
+389
View File
File diff suppressed because one or more lines are too long
+14 -2
View File
@@ -7,11 +7,16 @@
import * as core from "@actions/core";
import { type } from "arktype";
import { Inputs, main } from "./main.ts";
import { createMcpServer } from "./mcp/server.ts";
import packageJson from "./package.json" with { type: "json" };
import { log } from "./utils/cli.ts";
// Export createMcpServer so it can be called from the spawned MCP process
export { createMcpServer };
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) {
@@ -32,4 +37,11 @@ async function run(): Promise<void> {
}
}
await run();
// Only run main action if INPUTS_JSON is set (running as GitHub Action)
// When SDK spawns MCP server, it calls createMcpServer() directly
if (process.env.INPUTS_JSON) {
// Wrap in IIFE to avoid top-level await in CJS
(async () => {
await run();
})();
}
+27
View File
@@ -0,0 +1,27 @@
import { build } from "esbuild";
// Build the GitHub Action bundle only
// For npm package builds, use zshy (pnpm build:npm)
await build({
entryPoints: ["./entry.ts"],
bundle: true,
outfile: "./entry.cjs",
format: "cjs",
platform: "node",
target: "node20",
minify: true,
sourcemap: false,
// Mark optional peer dependencies as external to avoid bundling errors
external: [
"@valibot/to-json-schema",
"effect",
"sury",
],
// Enable tree-shaking to remove unused code
treeShaking: true,
// Drop console statements in production (but keep for debugging)
drop: [],
});
console.log("✅ Build completed successfully!");
+1 -3
View File
@@ -1,3 +1 @@
Use the MCP GitHub comment tool to add a comment containing your best frog joke to GitHub issue https://github.com/pullfrogai/scratch/issues/2.
Do not use the gh cli. If the mcp tool does not work, bail.
run npx cowsay "don't eat me"
+1 -2
View File
@@ -3,8 +3,7 @@
* This exports the main function for programmatic usage
*/
export { ClaudeAgent } from "./agents/claude.ts";
export type { Agent, AgentConfig, AgentResult } from "./agents/types.ts";
export type { Agent, AgentConfig, AgentResult } from "./agents/shared.ts";
export {
type Inputs as ExecutionInputs,
type MainResult,
+33 -10
View File
@@ -1,7 +1,9 @@
import * as core from "@actions/core";
import { type } from "arktype";
import { ClaudeAgent } from "./agents/claude.ts";
import { setupGitHubInstallationToken } from "./utils/github.ts";
import { claude } from "./agents/claude.ts";
import { createMcpConfigs } from "./mcp/config.ts";
import { log } from "./utils/cli.ts";
import { parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
export const Inputs = type({
prompt: "string",
@@ -16,20 +18,36 @@ export interface MainResult {
error?: string | undefined;
}
export type PromptJSON = {};
export async function main(inputs: Inputs): Promise<MainResult> {
try {
core.info(`Starting agent run with Claude Code`);
log.info("Starting agent run...");
setupGitConfig();
// Setup GitHub installation token
const githubInstallationToken = await setupGitHubInstallationToken();
const repoContext = parseRepoContext();
const agent = new ClaudeAgent({
apiKey: inputs.anthropic_api_key!,
setupGitAuth(githubInstallationToken, repoContext);
const mcpServers = createMcpConfigs(githubInstallationToken);
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
log.info("Running Claude Agent SDK...");
log.box(inputs.prompt, { title: "Prompt" });
// TODO: check if `inputs.prompts` is JSON
// if yes, check if it's a webhook payload or toJSON(github.event)
// for webhook payloads, check the specified `agent` field
const result = await claude.run({
prompt: inputs.prompt,
mcpServers,
githubInstallationToken,
apiKey: inputs.anthropic_api_key!,
});
await agent.install();
const result = await agent.execute(inputs.prompt);
if (!result.success) {
return {
@@ -39,12 +57,17 @@ export async function main(inputs: Inputs): Promise<MainResult> {
};
}
log.success("Task complete.");
await log.writeSummary();
return {
success: true,
output: result.output || "",
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
log.error(errorMessage);
await log.writeSummary();
return {
success: false,
error: errorMessage,
+55
View File
@@ -0,0 +1,55 @@
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
});
export const CreateCommentTool = tool({
name: "create_issue_comment",
description: "Create a comment on a GitHub issue",
parameters: Comment,
execute: contextualize(async ({ issueNumber, body }, ctx) => {
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: body,
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}),
});
export const EditComment = type({
commentId: type.number.describe("the ID of the comment to edit"),
body: type.string.describe("the new comment body content"),
});
export const EditCommentTool = tool({
name: "edit_issue_comment",
description: "Edit a GitHub issue comment by its ID",
parameters: EditComment,
execute: contextualize(async ({ commentId, body }, ctx) => {
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: commentId,
body: body,
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
updatedAt: result.data.updated_at,
};
}),
});
+23 -25
View File
@@ -1,35 +1,33 @@
/**
* Simple MCP configuration helper for adding our minimal GitHub comment server
*/
// const actionPath = process.env.GITHUB_ACTION_PATH || process.cwd();
import { fromHere } from "@ark/fs";
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { parseRepoContext } from "../utils/github.ts";
const actionPath = fromHere("..");
export const ghPullfrogMcpName = "gh-pullfrog";
export function createMcpConfig(githubInstallationToken: string) {
const githubRepository = process.env.GITHUB_REPOSITORY;
if (!githubRepository) {
throw new Error(
"GITHUB_REPOSITORY environment variable is required for MCP GitHub integration"
);
}
export type McpName = typeof ghPullfrogMcpName;
return JSON.stringify(
{
mcpServers: {
minimal_github_comment: {
command: "node",
args: [`${actionPath}/mcp/server.ts`],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
LOG_LEVEL: "debug",
},
},
export type McpConfigs = Record<McpName, McpServerConfig>;
export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
const repoContext = parseRepoContext();
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
// Get absolute path to entry.cjs - use GITHUB_ACTION_PATH if available, otherwise current directory
const entryPath = process.env.GITHUB_ACTION_PATH
? `${process.env.GITHUB_ACTION_PATH}/entry.cjs`
: `${process.cwd()}/entry.cjs`;
return {
[ghPullfrogMcpName]: {
command: "node",
args: ["-e", `require('${entryPath.replace(/'/g, "\\'")}').createMcpServer()`],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
},
},
null,
2
);
};
}
+42
View File
@@ -0,0 +1,42 @@
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const Issue = type({
title: type.string.describe("the title of the issue"),
body: type.string.describe("the body content of the issue"),
labels: type.string
.array()
.describe("optional array of label names to apply to the issue")
.optional(),
assignees: type.string
.array()
.describe("optional array of usernames to assign to the issue")
.optional(),
});
export const IssueTool = tool({
name: "create_issue",
description: "Create a new GitHub issue",
parameters: Issue,
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
const result = await ctx.octokit.rest.issues.create({
owner: ctx.owner,
repo: ctx.name,
title: title,
body: body,
labels: labels ?? [],
assignees: assignees ?? [],
});
return {
success: true,
issueId: result.data.id,
number: result.data.number,
url: result.data.html_url,
title: result.data.title,
state: result.data.state,
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
assignees: result.data.assignees?.map((assignee) => assignee.login),
};
}),
});
+43
View File
@@ -0,0 +1,43 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { contextualize, tool } from "./shared.ts";
export const PullRequest = type({
title: type.string.describe("the title of the pull request"),
body: type.string.describe("the body content of the pull request"),
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
});
export const PullRequestTool = tool({
name: "create_pull_request",
description: "Create a pull request from the current branch",
parameters: PullRequest,
execute: contextualize(async ({ title, body, base }, ctx) => {
// Get the current branch name
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
encoding: "utf8",
}).trim();
log.info(`Current branch: ${currentBranch}`);
const result = await ctx.octokit.rest.pulls.create({
owner: ctx.owner,
repo: ctx.name,
title: title,
body: body,
head: currentBranch,
base: base,
});
return {
success: true,
pullRequestId: result.data.id,
number: result.data.number,
url: result.data.html_url,
title: result.data.title,
head: result.data.head.ref,
base: result.data.base.ref,
};
}),
});
+52
View File
@@ -0,0 +1,52 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { contextualize, tool } from "./shared.ts";
export const PullRequestInfo = type({
pull_number: type.number.describe("The pull request number to fetch"),
});
export const PullRequestInfoTool = tool({
name: "get_pull_request",
description:
"Retrieve PR information and automatically prepare the repository for review by fetching and checking out the PR branch.",
parameters: PullRequestInfo,
execute: contextualize(async ({ pull_number }, ctx) => {
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
const data = pr.data;
const baseBranch = data.base.ref;
const headBranch = data.head.ref;
if (!baseBranch) {
throw new Error(`Base branch not found for PR #${pull_number}`);
}
// Automatically fetch and checkout branches for review
log.info(`Fetching base branch: origin/${baseBranch}`);
execSync(`git fetch origin ${baseBranch} --depth=20`, { stdio: "inherit" });
log.info(`Fetching PR branch: origin/${headBranch}`);
execSync(`git fetch origin ${headBranch}`, { stdio: "inherit" });
log.info(`Checking out PR branch: origin/${headBranch}`);
execSync(`git checkout origin/${headBranch}`, { stdio: "inherit" });
return {
number: data.number,
url: data.html_url,
title: data.title,
state: data.state,
draft: data.draft,
merged: data.merged,
base: baseBranch,
head: headBranch,
};
}),
});
+94
View File
@@ -0,0 +1,94 @@
import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const Review = type({
pull_number: type.number.describe("The pull request number to review"),
event: type
.enumerated("APPROVE", "REQUEST_CHANGES", "COMMENT")
.describe("'APPROVE', 'REQUEST_CHANGES', or 'COMMENT' (the review action)"),
body: type.string
.describe(
"Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
)
.optional(),
commit_id: type.string
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
.optional(),
comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"The line number in the file (use line numbers from the diff - usually the RIGHT side/new code)"
),
side: type
.enumerated("LEFT", "RIGHT")
.describe(
"Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT if not provided."
)
.optional(),
body: type.string.describe("The comment text for this specific line"),
start_line: type.number
.describe("Start line for multi-line comments (optional, for commenting on ranges)")
.optional(),
})
.array()
.describe(
"REQUIRED: Array of inline comments for specific code issues. Use this for all location-specific feedback. Use 'git diff origin/<base>...origin/<head>' to find the correct line numbers (typically use the line numbers shown on the RIGHT side for new code, LEFT side for old code)."
)
.optional(),
});
export const ReviewTool = tool({
name: "submit_pull_request_review",
description:
"Submit a review (approve, request changes, or comment) for an existing pull request. " +
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " +
"Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
parameters: Review,
execute: contextualize(async ({ pull_number, event, body, commit_id, comments = [] }, ctx) => {
// Get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
// Compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.owner,
repo: ctx.name,
pull_number,
event,
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// Convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
};
reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = comment.side || "RIGHT";
}
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
return {
success: true,
reviewId: result.data.id,
html_url: result.data.html_url,
state: result.data.state,
user: result.data.user?.login,
submitted_at: result.data.submitted_at,
};
}),
});
+22 -87
View File
@@ -1,92 +1,27 @@
#!/usr/bin/env node
// Minimal GitHub Issue Comment MCP Server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Octokit } from "@octokit/rest";
import { type } from "arktype";
import { z } from "zod";
import { resolveRepoContext } from "../utils/repo-context.ts";
import { FastMCP } from "fastmcp";
import { CreateCommentTool, EditCommentTool } from "./comment.ts";
import { IssueTool } from "./issue.ts";
import { PullRequestTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts";
import { ReviewTool } from "./review.ts";
import { addTools } from "./shared.ts";
const server = new McpServer({
name: "Minimal GitHub Issue Comment Server",
version: "0.0.1",
});
// Define the schema for creating issue comments
const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
});
server.tool(
"create_issue_comment",
"Create a comment on a GitHub issue",
{
issueNumber: z.number().describe("the issue number to comment on"),
body: z.string().describe("the comment body content"),
},
async ({ issueNumber, body }) => {
try {
Comment.assert({ issueNumber, body });
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
if (!githubInstallationToken) {
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
}
// Resolve repository context from environment
const repoContext = resolveRepoContext();
const octokit = new Octokit({
auth: githubInstallationToken,
});
const result = await octokit.rest.issues.createComment({
owner: repoContext.owner,
repo: repoContext.name,
issue_number: issueNumber,
body: body,
});
return {
content: [
{
type: "text",
text: JSON.stringify(
{
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
},
null,
2
),
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error creating comment: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
}
);
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("exit", () => {
server.close();
export function createMcpServer(): void {
const server = new FastMCP({
name: "gh-pullfrog",
version: "0.0.1",
});
}
await runServer();
addTools(server, [
CreateCommentTool,
EditCommentTool,
IssueTool,
PullRequestTool,
ReviewTool,
PullRequestInfoTool,
]);
server.start();
}
+76
View File
@@ -0,0 +1,76 @@
import { cached } from "@ark/util";
import { Octokit } from "@octokit/rest";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { FastMCP, Tool } from "fastmcp";
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
export interface ToolResult {
content: {
type: "text";
text: string;
}[];
isError?: boolean;
}
export const getMcpContext = cached((): McpContext => {
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
if (!githubInstallationToken) {
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
}
return {
...parseRepoContext(),
octokit: new Octokit({
auth: githubInstallationToken,
}),
};
});
export interface McpContext extends RepoContext {
octokit: Octokit;
}
export const tool = <const params>(tool: Tool<any, StandardSchemaV1<params>>) => tool;
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
for (const tool of tools) {
server.addTool(tool);
}
return server;
};
export const contextualize =
<T>(executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>) =>
async (params: T): Promise<ToolResult> => {
try {
const ctx = getMcpContext();
const result = await executor(params, ctx);
return handleToolSuccess(result);
} catch (error) {
return handleToolError(error);
}
};
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
return {
content: [
{
type: "text",
text: JSON.stringify(data, null, 2),
},
],
};
};
const handleToolError = (error: unknown): ToolResult => {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
isError: true,
};
};
+13 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.47",
"version": "0.0.75",
"type": "module",
"files": [
"index.js",
@@ -15,25 +15,31 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.js",
"play": "node play.ts",
"upDeps": "pnpm up --latest",
"lock": "pnpm --ignore-workspace install"
"lock": "pnpm --ignore-workspace install",
"prepare": "husky"
},
"dependencies": {
"@actions/core": "^1.11.1",
"@ark/fs": "0.49.0",
"@modelcontextprotocol/sdk": "^1.20.0",
"@anthropic-ai/claude-agent-sdk": "^0.1.26",
"@ark/fs": "0.53.0",
"@ark/util": "0.53.0",
"@octokit/rest": "^22.0.0",
"@octokit/webhooks-types": "^7.6.1",
"arktype": "^2.1.22",
"@standard-schema/spec": "1.0.0",
"arktype": "2.1.25",
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"table": "^6.9.0",
"zod": "^3.24.4"
"fastmcp": "^3.20.0",
"table": "^6.9.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"arg": "^5.0.2",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
"typescript": "^5.9.3"
},
"repository": {
+17 -29
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,18 +38,15 @@ export async function run(
process.chdir(originalCwd);
if (result.success) {
console.log("Action completed successfully");
if (result.output) {
console.log("Output:", result.output);
}
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 };
}
}
@@ -64,13 +54,12 @@ export async function run(
if (import.meta.url === `file://${process.argv[1]}`) {
const args = arg({
"--help": Boolean,
"--act": Boolean,
"--raw": String,
"-h": "--help",
});
if (args["--help"]) {
console.log(`
log.info(`
Usage: tsx play.ts [file] [options]
Test the Pullfrog action with various prompts.
@@ -79,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
@@ -87,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);
@@ -148,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);
}
}
+697 -26
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
[x] add modes to prompt
[x] progressively update comment
[] don't allow rejecting prs
[] fix pnpm caching
[] try to avoid claude narrating the initial comment
[] fix prompt to avoid narration like "I just read all tools from MCP server"
[] investigate including terminal output from bash commands as collapsed groups
[] avoid exposing env
-76
View File
@@ -1,76 +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 { buildAction, 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 });
buildAction(actionPath);
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);
}
}
+264
View File
@@ -0,0 +1,264 @@
/**
* CLI output utilities that work well in both local and GitHub Actions environments
*/
import * as core from "@actions/core";
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
/**
* Start a collapsed group (GitHub Actions) or regular group (local)
*/
function startGroup(name: string): void {
if (isGitHubActions) {
core.startGroup(name);
} else {
console.group(name);
}
}
/**
* End a collapsed group
*/
function endGroup(): void {
if (isGitHubActions) {
core.endGroup();
} else {
console.groupEnd();
}
}
/**
* 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
* Works well in both local and GitHub Actions environments
*/
function box(
text: string,
options?: {
title?: string;
maxWidth?: number;
}
): void {
const boxContent = boxString(text, options);
core.info(boxContent);
if (isGitHubActions) {
// Add as markdown code block for summary (no headers)
core.summary.addRaw(`\`\`\`\n${text}\n\`\`\`\n`);
}
}
/**
* Add a table to GitHub Actions job summary (rich formatting)
* Also logs to console. Only use this once at the end of execution.
*/
async function summaryTable(
rows: Array<Array<{ data: string; header?: boolean } | string>>,
options?: {
title?: string;
}
): Promise<void> {
const { title } = options || {};
// 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;
})
);
if (isGitHubActions) {
const summary = core.summary;
if (title) {
summary.addRaw(`**${title}**\n\n`);
}
summary.addTable(formattedRows);
// Note: Don't write immediately, let it accumulate with other summary content
}
// Also log to console for visibility
if (title) {
core.info(`\n${title}`);
}
const tableText = formattedRows.map((row) => row.map((cell) => cell.data).join(" | ")).join("\n");
core.info(`\n${tableText}\n`);
}
/**
* Print a separator line
*/
function separator(length: number = 50): void {
const separatorText = "─".repeat(length);
core.info(separatorText);
if (isGitHubActions) {
core.summary.addRaw(`---\n`);
}
}
/**
* Main logging utility object - import this once and access all utilities
*/
export const log = {
/**
* Print info message
*/
info: (message: string): void => {
core.info(message);
if (isGitHubActions) {
core.summary.addRaw(`${message}\n`);
}
},
/**
* Print warning message
*/
warning: (message: string): void => {
core.warning(message);
if (isGitHubActions) {
core.summary.addRaw(`⚠️ ${message}\n`);
}
},
/**
* Print error message
*/
error: (message: string): void => {
core.error(message);
if (isGitHubActions) {
core.summary.addRaw(`${message}\n`);
}
},
/**
* Print success message
*/
success: (message: string): void => {
const successMessage = `${message}`;
core.info(successMessage);
if (isGitHubActions) {
core.summary.addRaw(`${successMessage}\n`);
}
},
/**
* Print debug message (only if LOG_LEVEL=debug)
*/
debug: (message: string): void => {
if (isDebugEnabled) {
if (isGitHubActions) {
core.debug(message);
} else {
core.info(`[DEBUG] ${message}`);
}
}
},
/**
* Print a formatted box with text
*/
box,
/**
* Add a table to GitHub Actions job summary (rich formatting)
* Only use this once at the end of execution
*/
summaryTable,
/**
* Print a separator line
*/
separator,
/**
* Write all accumulated summary content to the job summary
* Call this at the end of execution to finalize the summary
*/
writeSummary: async (): Promise<void> => {
if (isGitHubActions) {
await core.summary.write();
}
},
/**
* Start a collapsed group (GitHub Actions) or regular group (local)
*/
startGroup,
/**
* End a collapsed group
*/
endGroup,
};
-13
View File
@@ -1,13 +0,0 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* Create a temporary file with the given content
*/
export function createTempFile(content: string, filename = "temp.txt"): string {
const tempDir = mkdtempSync(join(tmpdir(), "pullfrog-"));
const filePath = join(tempDir, filename);
writeFileSync(filePath, content);
return filePath;
}
+32 -10
View File
@@ -1,6 +1,6 @@
import { createSign } from "node:crypto";
import * as core from "@actions/core";
import { resolveRepoContext } from "./repo-context.ts";
import { log } from "./cli.ts";
export interface InstallationToken {
token: string;
@@ -54,14 +54,14 @@ function isGitHubActionsEnvironment(): boolean {
}
async function acquireTokenViaOIDC(): Promise<string> {
core.info("Generating OIDC token...");
log.info("Generating OIDC token...");
const oidcToken = await core.getIDToken("pullfrog-api");
core.info("OIDC token generated successfully");
log.info("OIDC token generated successfully");
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
core.info("Exchanging OIDC token for installation token...");
log.info("Exchanging OIDC token for installation token...");
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
@@ -78,7 +78,7 @@ async function acquireTokenViaOIDC(): Promise<string> {
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
core.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
}
@@ -208,7 +208,7 @@ const findInstallationId = async (
};
async function acquireTokenViaGitHubApp(): Promise<string> {
const repoContext = resolveRepoContext();
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
@@ -239,14 +239,36 @@ export async function setupGitHubInstallationToken(): Promise<string> {
const existingToken = checkExistingToken();
if (existingToken) {
core.setSecret(existingToken);
core.info("Using provided GitHub installation token");
log.info("Using provided GitHub installation token");
return existingToken;
}
const token = await acquireNewToken();
core.setSecret(token);
process.env.GITHUB_INSTALLATION_TOKEN = token;
return token;
}
export interface RepoContext {
owner: string;
name: string;
}
/**
* Parse repository context from GITHUB_REPOSITORY environment variable.
*/
export function parseRepoContext(): RepoContext {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
-22
View File
@@ -1,22 +0,0 @@
export interface RepoContext {
owner: string;
name: string;
}
/**
* Resolve repository context from GITHUB_REPOSITORY environment variable.
* Throws if not available.
*/
export function resolveRepoContext(): RepoContext {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error('GITHUB_REPOSITORY environment variable is required');
}
const [owner, name] = githubRepo.split('/');
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
+31 -11
View File
@@ -1,5 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { log } from "./cli.ts";
import type { RepoContext } from "./github.ts";
export interface SetupOptions {
tempDir: string;
@@ -19,31 +21,49 @@ export function setupTestRepo(options: SetupOptions): void {
if (existsSync(tempDir)) {
if (forceClean) {
console.log("🗑️ Removing existing .temp directory...");
log.info("🗑️ Removing existing .temp directory...");
rmSync(tempDir, { recursive: true, force: true });
console.log("📦 Cloning pullfrogai/scratch into .temp...");
log.info("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
} else {
console.log("📦 Resetting existing .temp repository...");
log.info("📦 Resetting existing .temp repository...");
execSync("git reset --hard HEAD && git clean -fd", {
cwd: tempDir,
stdio: "inherit",
});
}
} else {
console.log("📦 Cloning pullfrogai/scratch into .temp...");
log.info("📦 Cloning pullfrogai/scratch into .temp...");
execSync(`git clone ${repoUrl} ${tempDir}`, { stdio: "inherit" });
}
}
/**
* Build the action bundles
* Setup git configuration to avoid identity errors
*/
export function buildAction(actionPath: string): void {
console.log("🔨 Building fresh bundles with esbuild...");
execSync("node esbuild.config.js", {
cwd: actionPath,
stdio: "inherit",
});
export function setupGitConfig(): void {
log.info("🔧 Setting up git configuration...");
execSync('git config user.email "action@pullfrog.ai"', { stdio: "inherit" });
execSync('git config user.name "Pullfrog Action"', { stdio: "inherit" });
}
/**
* Setup git authentication using GitHub token
*/
export function setupGitAuth(githubToken: string, repoContext: RepoContext): void {
log.info("🔐 Setting up git authentication...");
// Remove existing git auth headers that actions/checkout might have set
try {
execSync("git config --unset-all http.https://github.com/.extraheader", { stdio: "inherit" });
log.info("✓ Removed existing authentication headers");
} catch {
log.info("No existing authentication headers to remove");
}
// Update remote URL to embed the token
const remoteUrl = `https://x-access-token:${githubToken}@github.com/${repoContext.owner}/${repoContext.name}.git`;
execSync(`git remote set-url origin "${remoteUrl}"`, { stdio: "inherit" });
log.info("✓ Updated remote URL with authentication token");
}
+3 -1
View File
@@ -6,6 +6,7 @@ export interface SpawnOptions {
env?: Record<string, string>;
input?: string;
timeout?: number;
cwd?: string;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
}
@@ -21,7 +22,7 @@ export interface SpawnResult {
* Spawn a subprocess with streaming callbacks and buffered results
*/
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const { cmd, args, env, input, timeout, onStdout, onStderr } = options;
const { cmd, args, env, input, timeout, cwd, onStdout, onStderr } = options;
const startTime = Date.now();
let stdoutBuffer = "";
@@ -31,6 +32,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const child = nodeSpawn(cmd, args, {
env: env ? { ...process.env, ...env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
cwd: cwd || process.cwd(),
});
let timeoutId: NodeJS.Timeout | undefined;
-142
View File
@@ -1,142 +0,0 @@
import { table } from "table";
/**
* Print a formatted table with consistent styling
* @param rows - Array of string arrays representing table rows
* @param options - Optional table configuration
*/
export function tableString(
rows: string[][],
options?: {
title?: string;
indent?: string;
drawHorizontalLine?: (lineIndex: number, rowCount: number) => boolean;
}
): string {
const {
title,
indent = "",
drawHorizontalLine = (lineIndex: number, rowCount: number) => {
return lineIndex === 0 || (options?.title && lineIndex === 1) || lineIndex === rowCount;
},
} = options || {};
if (title) {
rows.unshift([title]);
}
const tableOutput = table(rows, {
drawHorizontalLine,
border: {
topBody: ``,
topJoin: ``,
topLeft: ``,
topRight: ``,
bottomBody: ``,
bottomJoin: ``,
bottomLeft: ``,
bottomRight: ``,
bodyLeft: ``,
bodyRight: ``,
bodyJoin: ``,
joinBody: ``,
joinLeft: ``,
joinRight: ``,
joinJoin: ``,
},
});
const indentedOutput = tableOutput.split("\n").join(`\n${indent}`).trim();
return `${indent}${indentedOutput}`;
}
/**
* Print a multi-line string in a formatted box with text wrapping
* @param text - The text to display
* @param options - Optional configuration for the box
*/
export function boxString(
text: string,
options?: {
title?: string;
maxWidth?: number;
indent?: string;
padding?: number;
}
): string {
const { title, maxWidth = 80, indent = "", padding = 1 } = options || {};
// Clean up the text and split into lines
const lines = text.trim().split("\n");
// Word wrap each line to fit within maxWidth
const wrappedLines: string[] = [];
for (const line of lines) {
if (line.length <= maxWidth - padding * 2) {
wrappedLines.push(line);
} else {
// Word wrap the line
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 {
// Word is too long, break it
wrappedLines.push(word.substring(0, maxWidth - padding * 2));
currentLine = word.substring(maxWidth - padding * 2);
}
}
}
if (currentLine) {
wrappedLines.push(currentLine);
}
}
}
// Find the maximum line length for box sizing
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
const boxWidth = maxLineLength + padding * 2;
// Create the box
const topBorder = "┌" + "─".repeat(boxWidth) + "┐";
const bottomBorder = "└" + "─".repeat(boxWidth) + "┘";
const sideBorder = "│";
let result = "";
// Add title if provided
if (title) {
const titleLine = ` ${title} `;
const titlePadding = Math.max(0, boxWidth - titleLine.length);
result += `${indent}${titleLine}${"─".repeat(titlePadding)}\n`;
}
// Add top border (or title border)
if (!title) {
result += `${indent}${topBorder}\n`;
}
// Add content lines
for (const line of wrappedLines) {
const paddedLine = line.padEnd(maxLineLength);
result += `${indent}${sideBorder}${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}${sideBorder}\n`;
}
// Add bottom border
result += `${indent}${bottomBorder}`;
return result;
}