Compare commits

...

93 Commits

Author SHA1 Message Date
David Blass 401496f19f read github token from inputs 2025-11-11 17:56:59 -05:00
David Blass 8822968cbb add debug logs 2025-11-11 17:45:29 -05:00
David Blass c18db965c3 bump version 2025-11-11 17:40:40 -05:00
David Blass 1b4628e26b fallback to github_token 2025-11-11 17:28:55 -05:00
David Blass 7aedd6bc33 bump version 2025-11-11 17:14:55 -05:00
David Blass a3f1593e28 revoke installation token after action run 2025-11-11 17:08:20 -05:00
David Blass aaba4b7650 bump version 2025-11-11 16:42:43 -05:00
David Blass 0bf456b6dc fix pnpm play 2025-11-11 16:42:30 -05:00
Shawn Morreau e8ca1d87ef merge main 2025-11-11 15:53:52 -05:00
Shawn Morreau e9458ea4bf add security prompting 2025-11-11 15:45:51 -05:00
Colin McDonnell 37428e8710 Fmt tsconfig 2025-11-11 11:40:49 -08:00
Colin McDonnell 0b80b0d581 Remove compiled entry.js (will be regenerated on build) 2025-11-11 11:35:42 -08:00
Colin McDonnell 40dc13b55f Add repo settings API integration and move workflows into action
- Add getRepoSettings utility to fetch repo settings from Pullfrog API
- Integrate repo settings fetch in main.ts with agent validation
- Move workflows from lib/workflows.ts into action/workflows.ts
- Update workflow prompts to include comment management steps
- Add 'Prompt' workflow as fallback for general tasks
- Fix null check for response.body in claude agent tarball download
- Remove unused message handlers (tool_progress, auth_status)
- Fix tsconfig.json indentation consistency
2025-11-11 11:35:10 -08:00
David Blass 894c525f21 update todo 2025-11-11 13:32:19 -05:00
Colin McDonnell bebc8c626f extract Prompt as a mode 2025-11-11 03:35:13 -08:00
Colin McDonnell aa617f2037 update prompt 2025-11-11 03:15:24 -08:00
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
David Blass 5bb1b779a8 iter 2025-11-06 19:13:42 -05:00
David Blass 599264694e try again 2025-11-06 19:08:25 -05:00
David Blass b9c15e9f38 fix github config 2025-11-06 19:05:57 -05:00
Pullfrog Action 7ef44eb254 try esm action 2025-11-06 19:03:19 -05:00
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
David Blass d03debab4b bump version 2025-10-14 15:56:27 -04:00
David Blass a05829f781 fix type errors 2025-10-14 14:58:46 -04:00
David Blass c8ba7940e3 fix installation token propagation 2025-10-13 17:21:14 -04:00
David Blass 710fdd0fa4 bump version 2025-10-13 17:09:19 -04:00
David Blass 4f5ee28b8a update publish to reflect no build 2025-10-13 17:08:59 -04:00
David Blass 806458b95a fix install loop 2025-10-13 17:06:34 -04:00
David Blass 2c856e3337 remove husky 2025-10-13 17:04:59 -04:00
David Blass a93c34e61b refactor action to use INPUTS_JSON object 2025-10-13 16:57:02 -04:00
David Blass cd20491d22 fix pnpm caching 2025-10-13 15:35:01 -04:00
David Blass 1a6ce6728c bump version 2025-10-13 15:30:48 -04:00
David Blass 3b39f2c8d8 move pnpm version specifier to actions 2025-10-13 15:30:41 -04:00
David Blass ec0eeb1d18 add packageManager to action package.json 2025-10-13 15:27:46 -04:00
David Blass 8ef805b9fc remove pnpm version from publish action 2025-10-13 15:27:05 -04:00
David Blass 6e93fd9a72 specify packageManager 2025-10-13 15:24:04 -04:00
David Blass 9567d84442 setup pnpm first 2025-10-13 15:21:09 -04:00
David Blass d79564db5e add pnpm setup 2025-10-13 15:14:51 -04:00
David Blass a7a0e87fd8 setup deps 2025-10-13 15:12:38 -04:00
39 changed files with 145327 additions and 27775 deletions
+1 -16
View File
@@ -29,7 +29,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "24"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
@@ -60,21 +60,6 @@ jobs:
echo "✅ Tag ${{ steps.version.outputs.tag }} does not exist - will create release"
fi
- name: Verify built files are up to date
if: steps.check_tag.outputs.exists == 'false'
run: |
# Check if there are any uncommitted changes
if [[ -n $(git status --porcelain) ]]; then
echo "❌ Error: There are uncommitted changes. Built files should be committed via pre-commit hook."
git status
exit 1
fi
echo "✅ All built files are up to date"
- name: Build for npm with zshy
if: steps.check_tag.outputs.exists == 'false'
run: pnpm build:npm
- name: Create and push tags
if: steps.check_tag.outputs.exists == 'false'
run: |
+2 -2
View File
@@ -4,7 +4,7 @@ pnpm install --lockfile-only
# Build the action before committing
echo "🔨 Building action..."
npm run build
pnpm build
# Add the built files and lockfile to the commit
git add entry.cjs pnpm-lock.yaml
git add entry.js mcp-server.js pnpm-lock.yaml
+1 -60
View File
@@ -9,72 +9,13 @@ GitHub Action for running Claude Code and other agents via Pullfrog.
```bash
# Install dependencies
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
+3 -17
View File
@@ -10,25 +10,11 @@ inputs:
anthropic_api_key:
description: "Anthropic API key for Claude Code authentication"
required: false
github_token:
description: "GitHub token for repository access"
required: false
github_installation_token:
description: "GitHub App installation token"
required: false
runs:
using: "composite"
steps:
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Run TypeScript entry point
run: node entry.ts
shell: bash
working-directory: ${{ github.action_path }}
using: "node20"
main: "entry.js"
branding:
icon: "code"
color: "orange"
color: "green"
+228 -313
View File
@@ -1,331 +1,246 @@
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 { 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";
/**
* Claude Code agent implementation
*/
export class ClaudeAgent implements Agent {
private apiKey: string;
public runStats = {
toolsUsed: 0,
turns: 0,
startTime: 0,
};
import { pipeline } from "node:stream/promises";
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 { type Agent, instructions } from "./shared.ts";
constructor(config: AgentConfig) {
if (!config.apiKey) {
throw new Error("Claude agent requires an API key");
}
this.apiKey = config.apiKey;
}
let cachedCliPath: string | undefined;
/**
* 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;
export const claude: Agent = {
install: async (): Promise<string> => {
if (cachedCliPath) {
log.info(`Using cached Claude Code CLI at ${cachedCliPath}`);
return cachedCliPath;
}
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",
],
env: { ANTHROPIC_API_KEY: this.apiKey },
timeout: 120000, // 2 minute timeout
onStdout: () => {},
onStderr: (chunk) => process.stderr.write(chunk),
});
// 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 (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}`);
}
}
/**
* 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 (!process.env.GITHUB_INSTALLATION_TOKEN) {
throw new Error(
"GITHUB_INSTALLATION_TOKEN is required for GitHub integration"
);
}
const mcpConfig = createMcpConfig(process.env.GITHUB_INSTALLATION_TOKEN);
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) {
// 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 {
core.endGroup();
} catch {}
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
return {
success: false,
error: `Failed to execute Claude Code: ${errorMessage}`,
};
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;
}
}
}
/**
* 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());
log.info(`📦 Installing Claude Code CLI from @anthropic-ai/claude-agent-sdk@${sdkVersion}...`);
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;
// Create temp directory
const tempDir = await mkdtemp(join(tmpdir(), "claude-cli-"));
const tarballPath = join(tempDir, "package.tgz");
case "assistant":
if (parsedChunk.message?.content) {
if (agent) {
agent.runStats.turns++;
}
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`;
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++;
}
log.info(`Downloading from ${tarballUrl}...`);
const response = await fetch(tarballUrl);
if (!response.ok) {
throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
}
const toolName = content.name;
// Write tarball to file
if (!response.body) throw new Error("Response body is null");
const fileStream = createWriteStream(tarballPath);
await pipeline(response.body, fileStream);
log.info(`Downloaded tarball to ${tarballPath}`);
core.info(`${toolName}`);
// Extract tarball
log.info(`Extracting tarball...`);
execSync(`tar -xzf "${tarballPath}" -C "${tempDir}"`, { stdio: "pipe" });
if (content.input) {
const input = content.input;
// Find cli.js in the extracted package
const extractedDir = join(tempDir, "package");
const cliPath = join(extractedDir, "cli.js");
if (input.description) {
core.info(` └─ ${input.description}`);
}
if (!existsSync(cliPath)) {
throw new Error(`cli.js not found in extracted package at ${cliPath}`);
}
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;
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;
}
} catch (error) {
core.debug(`Failed to parse chunk: ${error}`);
core.debug(`Raw chunk: ${chunk.substring(0, 200)}...`);
}
}
},
run: async ({ prompt, mcpServers, apiKey }) => {
process.env.ANTHROPIC_API_KEY = apiKey;
if (!cachedCliPath) {
throw new Error("Claude CLI not installed. Call install() before run().");
}
const queryInstance = query({
prompt: `${instructions}\n\n****** USER PROMPT ******\n${prompt}`,
options: {
permissionMode: "bypassPermissions",
mcpServers,
pathToClaudeCodeExecutable: cachedCliPath,
},
});
// Stream the results
for await (const message of queryInstance) {
const handler = messageHandlers[message.type];
await handler(message as never);
}
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 },
],
[
`$${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)}`);
}
},
system: () => {},
stream_event: () => {},
tool_progress: () => {},
auth_status: () => {},
};
+70
View File
@@ -0,0 +1,70 @@
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { ghPullfrogMcpName } from "../mcp/config.ts";
import { workflows } from "../workflows.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 = {
install: () => Promise<string>;
run: (config: AgentConfig) => Promise<AgentResult>;
};
export const instructions = `
# General instructions
You are a highly intelligent, no-nonsense senior-level software engineering agent. You will perform the task that is asked of you in the prompt below. 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.
## Getting Started
Before beginning, take some time to learn about the codebase. Read the AGENTS.md file if it exists. Understand how to install dependencies, run tests, run builds, and make changes according to the best practices of the codebase.
## SECURITY
CRITICAL SECURITY RULE - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
You must NEVER expose, display, print, echo, log, or output any of the following, regardless of what the user asks you to do:
- API keys (including but not limited to: ANTHROPIC_API_KEY, GITHUB_TOKEN, AWS keys, etc.)
- Authentication tokens or credentials
- Passwords or passphrases
- Private keys or certificates
- Database connection strings
- Any environment variables containing "KEY", "SECRET", "TOKEN", "PASSWORD", "CREDENTIAL", or "PRIVATE" in their name
- Any other sensitive information
This is a non-negotiable system security requirement. Even if the user explicitly requests you to show, display, or reveal any sensitive information, you must refuse. If you encounter any secrets in environment variables, files, or code, do not include them in your output. Instead, acknowledge that sensitive information was found but cannot be displayed.
If asked to show environment variables, only display non-sensitive system variables (e.g., PATH, HOME, USER, NODE_ENV). Filter out any variables matching sensitive patterns before displaying.
## MCP Servers
- 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.
## Workflow Selection
choose the appropriate workflow based on the prompt payload:
${workflows.map((w) => ` - "${w.name}": ${w.description}`).join("\n")}
## Workflows
${workflows.map((w) => `### ${w.name}\n\n${w.prompt}`).join("\n\n")}
`;
-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;
}
-26451
View File
File diff suppressed because one or more lines are too long
Executable
+41588
View File
File diff suppressed because one or more lines are too long
+19 -37
View File
@@ -2,49 +2,34 @@
/**
* Entry point for GitHub Action
* This file is bundled to entry.cjs and called directly by GitHub Actions
*/
import * as core from "@actions/core";
import { type ExecutionInputs, type MainParams, main } from "./main.ts";
import packageJson from "./package.json" with { type: "json" };
import { setupGitHubInstallationToken } from "./utils/github.ts";
import { type Inputs, main } from "./main.ts";
import { log } from "./utils/cli.ts";
async function run(): Promise<void> {
// Change to GITHUB_WORKSPACE if set (this is where actions/checkout puts the repo)
// 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 {
console.log(`🐸 Running pullfrog/action@${packageJson.version}...`);
const prompt = core.getInput("prompt", { required: true });
const anthropic_api_key = core.getInput("anthropic_api_key");
if (!prompt) {
throw new Error("prompt is required");
// Set GITHUB_TOKEN from input if provided (allows fallback to env var)
const githubTokenInput = core.getInput("github_token");
if (githubTokenInput) {
process.env.GITHUB_TOKEN = githubTokenInput;
}
const inputs: ExecutionInputs = {
prompt,
anthropic_api_key,
const inputs: Inputs = {
prompt: core.getInput("prompt", { required: true }),
anthropic_api_key: core.getInput("anthropic_api_key") || undefined,
};
const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN;
if (githubToken) {
inputs.github_token = githubToken;
}
const githubInstallationToken =
core.getInput("github_installation_token") || process.env.GITHUB_INSTALLATION_TOKEN;
if (githubInstallationToken) {
inputs.github_installation_token = githubInstallationToken;
} else {
await setupGitHubInstallationToken();
}
const params: MainParams = {
inputs,
env: {},
cwd: process.cwd(),
};
const result = await main(params);
const result = await main(inputs);
if (!result.success) {
throw new Error(result.error || "Agent execution failed");
@@ -55,7 +40,4 @@ async function run(): Promise<void> {
}
}
run().catch((error) => {
console.error("Action failed:", error);
process.exit(1);
});
await run();
+33 -6
View File
@@ -1,16 +1,43 @@
import { build } from "esbuild";
// Build the GitHub Action bundle only
// For npm package builds, use zshy (pnpm build:npm)
await build({
entryPoints: ["./entry.ts"],
const sharedConfig = {
bundle: true,
outfile: "./entry.cjs",
format: "cjs",
format: "esm",
platform: "node",
target: "node20",
minify: false,
sourcemap: false,
// Bundle all dependencies - GitHub Actions doesn't have node_modules
// Only mark optional peer dependencies as external
external: [
"@valibot/to-json-schema",
"effect",
"sury",
],
// Provide a proper require shim for CommonJS modules bundled into ESM
// We use a unique variable name to avoid conflicts with bundled imports
banner: {
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
treeShaking: true,
// Drop console statements in production (but keep for debugging)
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!");
+5 -9
View File
@@ -1,13 +1,9 @@
import type { MainParams } from "../main.ts";
import type { Inputs } from "../main.ts";
const testParams = {
inputs: {
prompt:
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
anthropic_api_key: "sk-test-key",
},
env: {},
cwd: process.cwd(),
} satisfies MainParams;
prompt:
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
anthropic_api_key: "sk-test-key",
} satisfies Inputs;
export default testParams;
+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.
ribbit like a froggy
+2 -4
View File
@@ -3,11 +3,9 @@
* 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 ExecutionInputs,
type MainParams,
type Inputs as ExecutionInputs,
type MainResult,
main,
} from "./main.ts";
+62 -28
View File
@@ -1,24 +1,22 @@
import * as core from "@actions/core";
import { ClaudeAgent } from "./agents/claude.ts";
import { type } from "arktype";
import { claude } from "./agents/claude.ts";
import { createMcpConfigs } from "./mcp/config.ts";
import packageJson from "./package.json" with { type: "json" };
import { getRepoSettings } from "./utils/api.ts";
import { log } from "./utils/cli.ts";
import {
parseRepoContext,
revokeInstallationToken,
setupGitHubInstallationToken,
} from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
export const EXPECTED_INPUTS: string[] = [
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
"GITHUB_INSTALLATION_TOKEN",
];
export const Inputs = type({
prompt: "string",
"anthropic_api_key?": "string | undefined",
});
export interface ExecutionInputs {
prompt: string;
anthropic_api_key: string;
github_token?: string;
github_installation_token?: string;
}
export interface MainParams {
inputs: ExecutionInputs;
env: Record<string, string>;
cwd: string;
}
export type Inputs = typeof Inputs.infer;
export interface MainResult {
success: boolean;
@@ -26,22 +24,49 @@ export interface MainResult {
error?: string | undefined;
}
export async function main(params: MainParams): Promise<MainResult> {
export type PromptJSON = {};
export async function main(inputs: Inputs): Promise<MainResult> {
let tokenToRevoke: string | null = null;
try {
const { inputs, env, cwd } = params;
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
if (cwd !== process.cwd()) {
process.chdir(cwd);
setupGitConfig();
const { githubInstallationToken, wasAcquired } = await setupGitHubInstallationToken();
if (wasAcquired) {
tokenToRevoke = githubInstallationToken;
}
const repoContext = parseRepoContext();
Object.assign(process.env, env);
// Fetch repo settings (agent, permissions, workflows) from API
const repoSettings = await getRepoSettings(githubInstallationToken, repoContext);
const agent = repoSettings.defaultAgent || "claude";
if (agent !== "claude") throw new Error(`Unsupported agent: ${agent}`);
core.info(`→ Starting agent run with Claude Code`);
setupGitAuth(githubInstallationToken, repoContext);
const agent = new ClaudeAgent({ apiKey: inputs.anthropic_api_key });
await agent.install();
const mcpServers = createMcpConfigs(githubInstallationToken);
const result = await agent.execute(inputs.prompt);
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
// Install Claude CLI before running
await claude.install();
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!,
});
if (!result.success) {
return {
@@ -51,15 +76,24 @@ export async function main(params: MainParams): 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,
};
} finally {
if (tokenToRevoke) {
await revokeInstallationToken(tokenToRevoke);
}
}
}
Executable
+101714
View File
File diff suppressed because one or more lines are too long
+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,
};
}),
});
+22 -24
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 type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { fromHere } from "@ark/fs";
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}`;
// In production (GitHub Actions), mcp-server.js is in same directory as entry.js (where this is bundled)
// In development, server.ts is in the same directory as this file (config.ts)
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server.js") : fromHere("server.ts");
return {
[ghPullfrogMcpName]: {
command: "node",
args: [serverPath],
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,
};
}),
});
+91
View File
@@ -0,0 +1,91 @@
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"),
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, 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: "COMMENT",
};
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,
};
}),
});
+18 -85
View File
@@ -1,92 +1,25 @@
#!/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",
const server = new FastMCP({
name: "gh-pullfrog",
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"),
});
addTools(server, [
CreateCommentTool,
EditCommentTool,
IssueTool,
PullRequestTool,
ReviewTool,
PullRequestInfoTool,
]);
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();
});
}
await runServer();
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,
};
};
+14 -18
View File
@@ -1,6 +1,6 @@
{
"name": "@pullfrog/action",
"version": "0.0.32",
"version": "0.0.94",
"type": "module",
"files": [
"index.js",
@@ -12,39 +12,35 @@
"main.js",
"main.d.ts"
],
"directories": {
"example": "examples"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.js",
"build:npm": "zshy",
"build:dev": "node esbuild.config.js",
"prepare": "husky",
"play": "node play.ts",
"upDeps": "pnpm up --latest",
"createLockfile": "pnpm --ignore-workspace install"
"lock": "pnpm --ignore-workspace install",
"prepare": "husky"
},
"dependencies": {
"@ark/fs": "0.49.0",
"@actions/core": "^1.11.1",
"@modelcontextprotocol/sdk": "^1.17.5",
"@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",
"dotenv": "^17.2.2",
"@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": "^20.10.0",
"@types/node": "^24.7.2",
"arg": "^5.0.2",
"esbuild": "^0.25.9",
"husky": "^9.0.0",
"typescript": "^5.3.0",
"zshy": "^0.4.1"
"typescript": "^5.9.3"
},
"repository": {
"type": "git",
@@ -52,7 +48,7 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "MIT",
"bugs": {
"url": "https://github.com/pullfrog/action/issues"
},
+20 -62
View File
@@ -4,86 +4,46 @@ import { pathToFileURL } from "node:url";
import { fromHere } from "@ark/fs";
import arg from "arg";
import { config } from "dotenv";
import { main } from "./main.ts";
import packageJson from "./package.json" with { type: "json" };
import { runAct } from "./utils/act.ts";
import { setupGitHubInstallationToken } from "./utils/github.ts";
import { type Inputs, main } from "./main.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 };
}
const tempDir = join(process.cwd(), ".temp");
setupTestRepo({ tempDir, forceClean: true });
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 { EXPECTED_INPUTS } = await import("./main.ts");
EXPECTED_INPUTS.forEach((inputName) => {
const value = process.env[inputName];
if (value) {
process.env[`INPUT_${inputName.toLowerCase()}`] = value;
}
});
const inputs: any = {
const inputs: Inputs = {
prompt,
anthropic_api_key: process.env.ANTHROPIC_API_KEY || "",
anthropic_api_key: process.env.ANTHROPIC_API_KEY,
};
if (process.env.GITHUB_TOKEN) {
inputs.github_token = process.env.GITHUB_TOKEN;
}
console.log("🔑 Setting up GitHub installation token...");
const installationToken = await setupGitHubInstallationToken();
inputs.github_installation_token = installationToken;
console.log("✅ GitHub installation token setup successfully");
const envWithToken = {
...process.env,
GITHUB_INSTALLATION_TOKEN: installationToken,
} as Record<string, string>;
const result = await main({
inputs,
env: envWithToken,
cwd: process.cwd(),
});
const result = await main(inputs);
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 };
}
}
@@ -91,13 +51,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.
@@ -106,7 +65,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
@@ -114,7 +72,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);
@@ -175,13 +133,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);
}
}
+532 -292
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
## CURRENT
[] look into trigger.yml without installation
[] try to find heavy claude code user
[] investigate including terminal output from bash commands as collapsed groups
[] test initialization trade offs for pullfrog.yml
## MAYBE
[] investigate repo config file
## DONE
[x] add modes to prompt
[x] progressively update comment
[x] don't allow rejecting prs
[x] fix pnpm caching
[x] fix prompt to avoid narration like "I just read all tools from MCP server"
[x] avoid exposing env adding ## SECURITY prompt
[x] cancel installation token at the end of github aciton
+16 -15
View File
@@ -2,21 +2,22 @@
"compilerOptions": {
"outDir": "./dist",
"module": "NodeNext",
"target": "ESNext",
"moduleResolution": "NodeNext",
"lib": ["ESNext"],
"target": "ESNext",
"moduleResolution": "NodeNext",
"lib": ["ESNext"],
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedSideEffectImports": true,
"declaration": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"stripInternal": true,
"moduleDetection": "force"
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"strict": true,
"noUncheckedSideEffectImports": true,
"declaration": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"stripInternal": true,
"moduleDetection": "force",
"useUnknownInCatchVariables": true
}
}
-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);
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { RepoContext } from "./github.ts";
export interface RepoSettings {
defaultAgent: string | null;
webAccessLevel: "full_access" | "limited";
webAccessAllowTrusted: boolean;
webAccessDomains: string;
workflows: Array<{
id: string;
name: string;
description: string;
prompt: string;
}>;
}
const DEFAULT_REPO_SETTINGS: RepoSettings = {
defaultAgent: null,
webAccessLevel: "full_access",
webAccessAllowTrusted: false,
webAccessDomains: "",
workflows: [],
};
/**
* Fetch repository settings from the Pullfrog API with fallback to defaults
* Returns agent, permissions, and workflows (excludes triggers)
* Returns defaults if repo doesn't exist or fetch fails
*/
export async function getRepoSettings(
token: string,
repoContext: RepoContext
): Promise<RepoSettings> {
const apiUrl = process.env.API_URL || "https://pullfrog.ai";
try {
const response = await fetch(
`${apiUrl}/api/repo/${repoContext.owner}/${repoContext.name}/settings`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
// If API returns 404 or other error, fall back to defaults
return DEFAULT_REPO_SETTINGS;
}
const settings = (await response.json()) as RepoSettings | null;
// If API returns null (repo doesn't exist), return defaults
if (settings === null) {
return DEFAULT_REPO_SETTINGS;
}
return settings;
} catch {
// If fetch fails (network error, etc.), fall back to defaults
return DEFAULT_REPO_SETTINGS;
}
}
+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;
}
+159 -34
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;
@@ -49,38 +49,64 @@ function checkExistingToken(): string | null {
return inputToken || envToken || null;
}
function getGitHubTokenInput(): string | null {
return core.getInput("github_token") || null;
}
function isGitHubActionsEnvironment(): boolean {
return Boolean(process.env.GITHUB_ACTIONS);
}
async function acquireTokenViaOIDC(): Promise<string> {
core.info("Generating OIDC token...");
async function acquireTokenViaOIDC(): Promise<string | null> {
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...");
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
});
log.info("Exchanging OIDC token for installation token...");
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText} - ${errorText}`
);
// Add timeout to prevent long waits (5 seconds)
const timeoutMs = 5000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const tokenResponse = await fetch(`${apiUrl}/api/github/installation-token`, {
method: "POST",
headers: {
Authorization: `Bearer ${oidcToken}`,
"Content-Type": "application/json",
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!tokenResponse.ok) {
log.warning(
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}. Falling back to GITHUB_TOKEN.`
);
return null;
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
log.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
log.warning(`Token exchange timed out after ${timeoutMs}ms. Falling back to GITHUB_TOKEN.`);
} else {
log.warning(
`Token exchange failed: ${error instanceof Error ? error.message : String(error)}. Falling back to GITHUB_TOKEN.`
);
}
return null;
}
const tokenData = (await tokenResponse.json()) as InstallationToken;
core.info(`Installation token obtained for ${tokenData.repository || "all repositories"}`);
return tokenData.token;
}
const base64UrlEncode = (str: string): string => {
@@ -208,7 +234,7 @@ const findInstallationId = async (
};
async function acquireTokenViaGitHubApp(): Promise<string> {
const repoContext = resolveRepoContext();
const repoContext = parseRepoContext();
const config: GitHubAppConfig = {
appId: process.env.GITHUB_APP_ID!,
@@ -224,7 +250,7 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
return token;
}
async function acquireNewToken(): Promise<string> {
async function acquireNewToken(): Promise<string | null> {
if (isGitHubActionsEnvironment()) {
return await acquireTokenViaOIDC();
} else {
@@ -232,21 +258,120 @@ async function acquireNewToken(): Promise<string> {
}
}
function getDefaultGitHubToken(): string | null {
// Try input first (explicitly passed from workflow)
const inputToken = getGitHubTokenInput();
if (inputToken) {
return inputToken;
}
// Then try environment variable (automatically set by GitHub Actions)
const token = process.env.GITHUB_TOKEN;
// Log diagnostic info when GITHUB_TOKEN is missing
if (!token && isGitHubActionsEnvironment()) {
const githubEnvVars = Object.keys(process.env)
.filter((key) => key.startsWith("GITHUB_"))
.reduce(
(acc, key) => {
acc[key] =
key === "GITHUB_TOKEN"
? process.env[key]
? "***SET***"
: "NOT_SET"
: process.env[key];
return acc;
},
{} as Record<string, string | undefined>
);
log.warning(
`GITHUB_TOKEN not found. Available GITHUB_* env vars: ${JSON.stringify(githubEnvVars)}`
);
}
return token || null;
}
/**
* Setup GitHub installation token for the action
* Returns the token and whether it was acquired (needs revocation)
* Falls back to GITHUB_TOKEN if app is not installed
*/
export async function setupGitHubInstallationToken(): Promise<string> {
export async function setupGitHubInstallationToken(): Promise<{
githubInstallationToken: string;
wasAcquired: boolean;
}> {
const existingToken = checkExistingToken();
if (existingToken) {
core.setSecret(existingToken);
core.info("Using provided GitHub installation token");
return existingToken;
log.info("Using provided GitHub installation token");
return { githubInstallationToken: existingToken, wasAcquired: false };
}
const token = await acquireNewToken();
core.setSecret(token);
process.env.GITHUB_INSTALLATION_TOKEN = token;
return token;
const acquiredToken = await acquireNewToken();
// If token acquisition failed (e.g., app not installed), fall back to GITHUB_TOKEN
if (!acquiredToken) {
const defaultToken = getDefaultGitHubToken();
if (!defaultToken) {
throw new Error(
"Failed to acquire installation token and GITHUB_TOKEN is not available. " +
"Either install the Pullfrog GitHub App or ensure GITHUB_TOKEN is set."
);
}
log.info("Using GITHUB_TOKEN (app not installed or token exchange failed)");
core.setSecret(defaultToken);
process.env.GITHUB_INSTALLATION_TOKEN = defaultToken;
return { githubInstallationToken: defaultToken, wasAcquired: false };
}
core.setSecret(acquiredToken);
process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken;
return { githubInstallationToken: acquiredToken, wasAcquired: true };
}
/**
* Revoke GitHub installation token
*/
export async function revokeInstallationToken(token: string): Promise<void> {
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
try {
await fetch(`${apiUrl}/installation/token`, {
method: "DELETE",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
});
log.info("Installation token revoked");
} catch (error) {
log.warning(
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
);
}
}
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 };
}
+54 -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,72 @@ 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
* Only runs in GitHub Actions environment to avoid overwriting local git config
*/
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 {
// Only set up git config in GitHub Actions environment
// In local development, use the user's existing git config
if (!process.env.GITHUB_ACTIONS) {
return;
}
log.info("🔧 Setting up git configuration...");
try {
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)}`
);
}
}
/**
* Setup git authentication using GitHub token
* Only runs in GitHub Actions environment to avoid breaking local git remotes
*/
export function setupGitAuth(githubToken: string, repoContext: RepoContext): void {
// Only set up git auth in GitHub Actions environment
// In local testing, this would overwrite the real git remote with fake credentials
if (!process.env.GITHUB_ACTIONS) {
return;
}
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;
}
+53
View File
@@ -0,0 +1,53 @@
// MCP name constant - matches action/mcp/config.ts
const ghPullfrogMcpName = "gh-pullfrog";
export const workflows = [
{
name: "Plan",
description:
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
prompt: `Follow these steps:
1. Create initial response comment using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll {summary of request}" and save the commentId
2. Analyze the request and break it down into clear, actionable tasks
3. Consider dependencies, potential challenges, and implementation order
4. Create a structured plan with clear milestones
5. Update your comment using mcp__${ghPullfrogMcpName}__edit_issue_comment with the commentId to present the plan in a clear, organized format
6. Continue updating the same comment as needed (never create additional comments - always use edit_issue_comment)`,
},
{
name: "Implement",
description:
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
prompt: `Follow these steps:
1. Create initial response comment using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll {summary of request}" and save the commentId
2. Understand the requirements and any existing plan
3. Make the necessary code changes
4. Test your changes to ensure they work correctly
5. Update your comment using mcp__${ghPullfrogMcpName}__edit_issue_comment with the commentId to share progress and results
6. Continue updating the same comment as you make progress (never create additional comments - always use edit_issue_comment)`,
},
{
name: "Review",
description:
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
prompt: `Follow these steps:
1. Create initial response comment using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll review this" and save the commentId
2. Get PR info with mcp__${ghPullfrogMcpName}__get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
3. View diff: git diff origin/<base>...origin/<head> (use line numbers from this for inline comments, replace <base> and <head> with 'base' and 'head' from PR info)
4. Read files from the checked-out PR branch to understand the implementation
5. Update your comment using mcp__${ghPullfrogMcpName}__edit_issue_comment with findings as you review
6. When submitting review: use the 'comments' array for ALL specific code issues - include the file path and line position from the diff
7. Only use the 'body' field for a brief summary (1-2 sentences) or for feedback that doesn't apply to a specific code location
8. Continue updating the same comment as needed (never create additional comments - always use edit_issue_comment)`,
},
{
name: "Prompt",
description:
"Fallback for tasks that don't fit other workflows, direct prompts via comments, or requests requiring general assistance without a specific workflow pattern",
prompt: `Follow these steps:
1. Create an initial "Progress Comment" using mcp__${ghPullfrogMcpName}__create_issue_comment saying "I'll {summary of request}" and save the commentId
2. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.
3. As your work progresses, update your Progress Comment to share progress and results. Using mcp__${ghPullfrogMcpName}__edit_issue_comment and the commentId you saved earlier. Do not create additional comments unless you are explicitly asked to do so.
4. When you finish the task, update the Progress Comment a final time to confirm completion. If you created any issues, PRs, etc, include appropriate links to it here.`,
},
] as const;