Compare commits

...

14 Commits

Author SHA1 Message Date
Shawn Morreau 1c128b293f don't allow rejecting prs 2025-11-10 16:53:31 -05:00
Shawn Morreau c08008668b Merge branch 'main' of https://github.com/pullfrog/action 2025-11-10 16:05:44 -05:00
David Blass 7ac2938570 update todos 2025-11-10 16:02:37 -05:00
Shawn Morreau 363e4ecda2 update readme 2025-11-10 15:27:16 -05:00
David Blass 13cc56944f remove some debug logging 2025-11-06 21:11:28 -05:00
David Blass 2d91473f6e debug mcp 2025-11-06 21:03:13 -05:00
David Blass 3937c3bdba debug mcp server location 2025-11-06 20:58:19 -05:00
David Blass bac3f3e9c6 bundle mcp-server.js 2025-11-06 20:50:20 -05:00
David Blass 5ea1d95b70 debug dir structure 2025-11-06 20:38:20 -05:00
David Blass 6d0c21f0f5 move directory logging 2025-11-06 20:34:35 -05:00
David Blass c31824144b fix bundle import 2025-11-06 20:32:28 -05:00
David Blass 0a63f3da9d try download claude 2025-11-06 20:28:58 -05:00
David Blass 42b023cc86 okok 2025-11-06 19:37:31 -05:00
David Blass 854e3d5e4d add debug 2025-11-06 19:19:26 -05:00
17 changed files with 144218 additions and 392 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ echo "🔨 Building action..."
pnpm build pnpm build
# Add the built files and lockfile to the commit # Add the built files and lockfile to the commit
git add entry.js pnpm-lock.yaml git add entry.js mcp-server.js pnpm-lock.yaml
-3
View File
@@ -9,9 +9,6 @@ GitHub Action for running Claude Code and other agents via Pullfrog.
```bash ```bash
# Install dependencies # Install dependencies
pnpm install pnpm install
# Test with default prompt
npm run play # Run locally on your machine
``` ```
## Testing with play.ts ## Testing with play.ts
+100 -1
View File
@@ -1,16 +1,113 @@
import { execSync } from "node:child_process";
import { createWriteStream, existsSync, rmSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pipeline } from "node:stream/promises";
import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import packageJson from "../package.json" with { type: "json" };
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { type Agent, instructions } from "./shared.ts"; import { type Agent, instructions } from "./shared.ts";
let cachedCliPath: string | undefined;
export const claude: Agent = { export const claude: Agent = {
install: async (): Promise<string> => {
if (cachedCliPath) {
log.info(`Using cached Claude Code CLI at ${cachedCliPath}`);
return cachedCliPath;
}
// Get the SDK version from package.json and resolve to actual version
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
let sdkVersion: string;
// If it's a range (starts with ^ or ~), query npm registry for the latest matching version
if (versionRange.startsWith("^") || versionRange.startsWith("~")) {
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
log.info(`Resolving version for range ${versionRange}...`);
try {
const registryResponse = await fetch(`${npmRegistry}/@anthropic-ai/claude-agent-sdk`);
if (!registryResponse.ok) {
throw new Error(`Failed to query registry: ${registryResponse.status}`);
}
const registryData = (await registryResponse.json()) as {
"dist-tags": { latest: string };
versions: Record<string, unknown>;
};
// Get the latest version that matches the range (simplified: just use latest)
sdkVersion = registryData["dist-tags"].latest;
log.info(`Resolved to version ${sdkVersion}`);
} catch (error) {
log.warning(
`Failed to resolve version from registry, using latest: ${error instanceof Error ? error.message : String(error)}`
);
sdkVersion = "latest";
}
} else {
sdkVersion = versionRange;
}
log.info(`📦 Installing Claude Code CLI from @anthropic-ai/claude-agent-sdk@${sdkVersion}...`);
// Create temp directory
const tempDir = await mkdtemp(join(tmpdir(), "claude-cli-"));
const tarballPath = join(tempDir, "package.tgz");
try {
// Download tarball from npm
const npmRegistry = process.env.NPM_REGISTRY || "https://registry.npmjs.org";
const tarballUrl = `${npmRegistry}/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-${sdkVersion}.tgz`;
log.info(`Downloading from ${tarballUrl}...`);
const response = await fetch(tarballUrl);
if (!response.ok) {
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
}
// Write tarball to file
const fileStream = createWriteStream(tarballPath);
await pipeline(response.body!, fileStream);
log.info(`Downloaded tarball to ${tarballPath}`);
// Extract tarball
log.info(`Extracting tarball...`);
execSync(`tar -xzf "${tarballPath}" -C "${tempDir}"`, { stdio: "pipe" });
// Find cli.js in the extracted package
const extractedDir = join(tempDir, "package");
const cliPath = join(extractedDir, "cli.js");
if (!existsSync(cliPath)) {
throw new Error(`cli.js not found in extracted package at ${cliPath}`);
}
cachedCliPath = cliPath;
log.info(`✓ Claude Code CLI installed at ${cliPath}`);
return cliPath;
} catch (error) {
// Cleanup on error
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw error;
}
},
run: async ({ prompt, mcpServers, apiKey }) => { run: async ({ prompt, mcpServers, apiKey }) => {
process.env.ANTHROPIC_API_KEY = apiKey; process.env.ANTHROPIC_API_KEY = apiKey;
if (!cachedCliPath) {
throw new Error("Claude CLI not installed. Call install() before run().");
}
const queryInstance = query({ const queryInstance = query({
prompt: `${instructions}\n\n${prompt}`, prompt: `${instructions}\n\n****** USER PROMPT ******\n${prompt}`,
options: { options: {
permissionMode: "bypassPermissions", permissionMode: "bypassPermissions",
mcpServers, mcpServers,
pathToClaudeCodeExecutable: cachedCliPath,
}, },
}); });
@@ -142,4 +239,6 @@ const messageHandlers: SDKMessageHandlers = {
}, },
system: () => {}, system: () => {},
stream_event: () => {}, stream_event: () => {},
tool_progress: () => {},
auth_status: () => {},
}; };
+1
View File
@@ -22,6 +22,7 @@ export interface AgentConfig {
} }
export type Agent = { export type Agent = {
install: () => Promise<string>;
run: (config: AgentConfig) => Promise<AgentResult>; run: (config: AgentConfig) => Promise<AgentResult>;
}; };
+41353 -331
View File
File diff suppressed because one or more lines are too long
+8 -7
View File
@@ -6,17 +6,18 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { type Inputs, main } from "./main.ts"; import { type 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"; 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> { async function run(): Promise<void> {
try { // Change to GITHUB_WORKSPACE if set (this is where actions/checkout puts the repo)
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); // JavaScript actions run from the action's directory, not the checked-out repo
if (process.env.GITHUB_WORKSPACE && process.cwd() !== process.env.GITHUB_WORKSPACE) {
log.debug(`Changing to GITHUB_WORKSPACE: ${process.env.GITHUB_WORKSPACE}`);
process.chdir(process.env.GITHUB_WORKSPACE);
log.debug(`New working directory: ${process.cwd()}`);
}
try {
const inputs: Inputs = { const inputs: Inputs = {
prompt: core.getInput("prompt", { required: true }), prompt: core.getInput("prompt", { required: true }),
anthropic_api_key: core.getInput("anthropic_api_key") || undefined, anthropic_api_key: core.getInput("anthropic_api_key") || undefined,
+18 -7
View File
@@ -1,15 +1,11 @@
import { build } from "esbuild"; import { build } from "esbuild";
// Build the GitHub Action bundle only const sharedConfig = {
// For npm package builds, use zshy (pnpm build:npm)
await build({
entryPoints: ["./entry.ts"],
bundle: true, bundle: true,
outfile: "./entry.js",
format: "esm", format: "esm",
platform: "node", platform: "node",
target: "node20", target: "node20",
minify: true, minify: false,
sourcemap: false, sourcemap: false,
// Bundle all dependencies - GitHub Actions doesn't have node_modules // Bundle all dependencies - GitHub Actions doesn't have node_modules
// Only mark optional peer dependencies as external // Only mark optional peer dependencies as external
@@ -19,13 +15,28 @@ await build({
"sury", "sury",
], ],
// Provide a proper require shim for CommonJS modules bundled into ESM // Provide a proper require shim for CommonJS modules bundled into ESM
// We use a unique variable name to avoid conflicts with bundled imports
banner: { banner: {
js: `import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; const require = createRequire(import.meta.url); const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);`, js: `import { createRequire as __createRequire } from 'module'; import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __dirnameFn } from 'path'; const require = __createRequire(import.meta.url); const __filename = __fileURLToPath(import.meta.url); const __dirname = __dirnameFn(__filename);`,
}, },
// Enable tree-shaking to remove unused code // Enable tree-shaking to remove unused code
treeShaking: true, treeShaking: true,
// Drop console statements in production (but keep for debugging) // Drop console statements in production (but keep for debugging)
drop: [], drop: [],
};
// Build the main entry bundle (without MCP)
await build({
...sharedConfig,
entryPoints: ["./entry.ts"],
outfile: "./entry.js",
});
// Build the MCP server bundle
await build({
...sharedConfig,
entryPoints: ["./mcp/server.ts"],
outfile: "./mcp-server.js",
}); });
console.log("✅ Build completed successfully!"); console.log("✅ Build completed successfully!");
+1 -1
View File
@@ -1 +1 @@
run npx cowsay "don't eat me" Review https://github.com/pullfrogai/scratch/pull/17
+5 -1
View File
@@ -1,6 +1,7 @@
import { type } from "arktype"; import { type } from "arktype";
import { claude } from "./agents/claude.ts"; import { claude } from "./agents/claude.ts";
import { createMcpConfigs } from "./mcp/config.ts"; import { createMcpConfigs } from "./mcp/config.ts";
import packageJson from "./package.json" with { type: "json" };
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
import { parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts"; import { parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts"; import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
@@ -22,7 +23,7 @@ export type PromptJSON = {};
export async function main(inputs: Inputs): Promise<MainResult> { export async function main(inputs: Inputs): Promise<MainResult> {
try { try {
log.info("Starting agent run..."); log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
setupGitConfig(); setupGitConfig();
@@ -35,6 +36,9 @@ export async function main(inputs: Inputs): Promise<MainResult> {
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`); log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
// Install Claude CLI before running
await claude.install();
log.info("Running Claude Agent SDK..."); log.info("Running Claude Agent SDK...");
log.box(inputs.prompt, { title: "Prompt" }); log.box(inputs.prompt, { title: "Prompt" });
Executable
+102694
View File
File diff suppressed because one or more lines are too long
+5 -9
View File
@@ -3,6 +3,7 @@
*/ */
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk"; import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { fromHere } from "@ark/fs";
import { parseRepoContext } from "../utils/github.ts"; import { parseRepoContext } from "../utils/github.ts";
export const ghPullfrogMcpName = "gh-pullfrog"; export const ghPullfrogMcpName = "gh-pullfrog";
@@ -15,19 +16,14 @@ export function createMcpConfigs(githubInstallationToken: string): McpConfigs {
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const githubRepository = `${repoContext.owner}/${repoContext.name}`; const githubRepository = `${repoContext.owner}/${repoContext.name}`;
// Get absolute path to entry.js - use GITHUB_ACTION_PATH if available, otherwise current directory // In production (GitHub Actions), mcp-server.js is in same directory as entry.js (where this is bundled)
const entryPath = process.env.GITHUB_ACTION_PATH // In development, server.ts is in the same directory as this file (config.ts)
? `${process.env.GITHUB_ACTION_PATH}/entry.js` const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server.js") : fromHere("server.ts");
: `${process.cwd()}/entry.js`;
return { return {
[ghPullfrogMcpName]: { [ghPullfrogMcpName]: {
command: "node", command: "node",
args: [ args: [serverPath],
"--input-type=module",
"-e",
`import('${entryPath.replace(/'/g, "\\'")}').then(m => m.createMcpServer())`,
],
env: { env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken, GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository, GITHUB_REPOSITORY: githubRepository,
+2 -5
View File
@@ -4,9 +4,6 @@ import { contextualize, tool } from "./shared.ts";
export const Review = type({ export const Review = type({
pull_number: type.number.describe("The pull request number to review"), 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 body: type.string
.describe( .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." "Brief summary or general feedback that doesn't apply to specific code locations. Keep it concise - most feedback should be in the 'comments' array."
@@ -45,7 +42,7 @@ export const ReviewTool = tool({
"IMPORTANT: Use 'comments' array for ALL specific code issues at the line-level. " + "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.", "Only use 'body' for a brief summary or feedback that doesn't apply to a specific location.",
parameters: Review, parameters: Review,
execute: contextualize(async ({ pull_number, event, body, commit_id, comments = [] }, ctx) => { execute: contextualize(async ({ pull_number, body, commit_id, comments = [] }, ctx) => {
// Get the PR to determine the head commit if commit_id not provided // Get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({ const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner, owner: ctx.owner,
@@ -58,7 +55,7 @@ export const ReviewTool = tool({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
pull_number, pull_number,
event, event: "COMMENT",
}; };
if (body) params.body = body; if (body) params.body = body;
if (commit_id) { if (commit_id) {
+13 -15
View File
@@ -8,20 +8,18 @@ import { PullRequestInfoTool } from "./prInfo.ts";
import { ReviewTool } from "./review.ts"; import { ReviewTool } from "./review.ts";
import { addTools } from "./shared.ts"; import { addTools } from "./shared.ts";
export function createMcpServer(): void { const server = new FastMCP({
const server = new FastMCP({ name: "gh-pullfrog",
name: "gh-pullfrog", version: "0.0.1",
version: "0.0.1", });
});
addTools(server, [ addTools(server, [
CreateCommentTool, CreateCommentTool,
EditCommentTool, EditCommentTool,
IssueTool, IssueTool,
PullRequestTool, PullRequestTool,
ReviewTool, ReviewTool,
PullRequestInfoTool, PullRequestInfoTool,
]); ]);
server.start(); server.start();
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.78", "version": "0.0.88",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
-3
View File
@@ -5,7 +5,6 @@ import { fromHere } from "@ark/fs";
import arg from "arg"; import arg from "arg";
import { config } from "dotenv"; import { config } from "dotenv";
import { type Inputs, main } from "./main.ts"; import { type Inputs, main } from "./main.ts";
import packageJson from "./package.json" with { type: "json" };
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
import { setupTestRepo } from "./utils/setup.ts"; import { setupTestRepo } from "./utils/setup.ts";
@@ -15,8 +14,6 @@ export async function run(
prompt: string prompt: string
): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> { ): Promise<{ success: boolean; output?: string | undefined; error?: string | undefined }> {
try { try {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
const tempDir = join(process.cwd(), ".temp"); const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true }); setupTestRepo({ tempDir, forceClean: true });
+5 -3
View File
@@ -1,8 +1,10 @@
[x] add modes to prompt [x] add modes to prompt
[x] progressively update comment [x] progressively update comment
[] don't allow rejecting prs [x] don't allow rejecting prs
[] fix pnpm caching [x] 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" [] fix prompt to avoid narration like "I just read all tools from MCP server"
[] investigate including terminal output from bash commands as collapsed groups [] investigate including terminal output from bash commands as collapsed groups
[] avoid exposing env [] avoid exposing env
[] test initialization trade offs for pullfrog.yml
[] try to find heavy claude code user
[] investigate repo config file?
+11 -4
View File
@@ -47,13 +47,21 @@ export function setupGitConfig(): void {
// Only set up git config in GitHub Actions environment // Only set up git config in GitHub Actions environment
// In local development, use the user's existing git config // In local development, use the user's existing git config
if (!process.env.GITHUB_ACTIONS) { if (!process.env.GITHUB_ACTIONS) {
log.info("⚠️ Skipping git configuration setup (not in GitHub Actions)");
return; return;
} }
log.info("🔧 Setting up git configuration..."); log.info("🔧 Setting up git configuration...");
execSync('git config user.email "action@pullfrog.ai"', { stdio: "inherit" }); try {
execSync('git config user.name "Pullfrog Action"', { stdio: "inherit" }); execSync('git config user.email "action@pullfrog.ai"', { stdio: "pipe" });
execSync('git config user.name "Pullfrog Action"', { stdio: "pipe" });
log.debug("setupGitConfig: ✓ Git configuration set successfully");
} catch (error) {
// If git config fails, log warning but don't fail the action
// This can happen if we're not in a git repo or git isn't available
log.warning(
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
);
}
} }
/** /**
@@ -64,7 +72,6 @@ export function setupGitAuth(githubToken: string, repoContext: RepoContext): voi
// Only set up git auth in GitHub Actions environment // Only set up git auth in GitHub Actions environment
// In local testing, this would overwrite the real git remote with fake credentials // In local testing, this would overwrite the real git remote with fake credentials
if (!process.env.GITHUB_ACTIONS) { if (!process.env.GITHUB_ACTIONS) {
log.info("⚠️ Skipping git authentication setup (not in GitHub Actions)");
return; return;
} }