This commit is contained in:
Shawn Morreau
2025-12-01 10:45:14 -05:00
20 changed files with 851 additions and 546 deletions
+42
View File
@@ -0,0 +1,42 @@
name: Pullfrog
on:
workflow_dispatch:
inputs:
prompt:
type: string
description: 'Agent prompt'
workflow_call:
inputs:
prompt:
description: 'Agent prompt'
type: string
permissions:
id-token: write
contents: read
jobs:
pullfrog:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
# optionally, setup your repo here
# the agent can figure this out itself, but pre-setup is more efficient
# - uses: actions/setup-node@v6
- name: Run agent
uses: pullfrog/action@v0
with:
prompt: ${{ github.event.inputs.prompt }}
# feel free to comment out any you won't use
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
google_api_key: ${{ secrets.GOOGLE_API_KEY }}
gemini_api_key: ${{ secrets.GEMINI_API_KEY }}
cursor_api_key: ${{ secrets.CURSOR_API_KEY }}
+16 -8
View File
@@ -1,11 +1,14 @@
<p align="center"> <p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.ai/frog-white-200px.png"> <h1 align="center">
<img src="https://pullfrog.ai/frog-200px.png" width="40px" align="center" alt="Pullfrog logo" /> <picture>
</picture> <source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.ai/frog-white-200px.png">
<h1 align="center">Pullfrog</h1> <img src="https://pullfrog.ai/frog-green-200px.png" width="25px" align="center" alt="Pullfrog logo" />
</picture><br />
Pullfrog
</h1>
<p align="center"> <p align="center">
GitHub Action for running AI agents via Pullfrog to automate development workflows Bring your favorite coding agent into GitHub
</p> </p>
</p> </p>
@@ -15,7 +18,13 @@
Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions. Pullfrog is a GitHub bot that brings the full power of your favorite coding agents into GitHub. It's open source and powered by GitHub Actions.
Once configured, you can start triggering agent runs. <a href="https://github.com/apps/pullfrog/installations/new">
<img src="https://pullfrog.ai/add-to-github.png" alt="Add to GitHub" width="150px" />
</a>
<br />
Once added, you can start triggering agent runs.
- **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task. - **Tag `@pullfrog`** — Tag `@pullfrog` in a comment anywhere in your repo. It will pull in any relevant context using the action's internal MCP server and perform the appropriate task.
- **Prompt from the web** — Trigger arbitrary tasks from the Pullfrog dashboard - **Prompt from the web** — Trigger arbitrary tasks from the Pullfrog dashboard
@@ -44,7 +53,6 @@ Install the Pullfrog GitHub App on your personal or organization account. During
[Add to GitHub ➜](https://github.com/apps/pullfrog/installations/new) [Add to GitHub ➜](https://github.com/apps/pullfrog/installations/new)
<details> <details>
<summary><strong>Manual setup instructions</strong></summary> <summary><strong>Manual setup instructions</strong></summary>
+2 -2
View File
@@ -2,7 +2,7 @@ import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" };
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { agent, installFromNpmTarball } from "./shared.ts"; import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
export const claude = agent({ export const claude = agent({
name: "claude", name: "claude",
@@ -15,7 +15,7 @@ export const claude = agent({
}); });
}, },
run: async ({ payload, mcpServers, apiKey, cliPath }) => { run: async ({ payload, mcpServers, apiKey, cliPath }) => {
process.env.ANTHROPIC_API_KEY = apiKey; setupProcessAgentEnv({ ANTHROPIC_API_KEY: apiKey });
const prompt = addInstructions(payload); const prompt = addInstructions(payload);
console.log(prompt); console.log(prompt);
+13 -14
View File
@@ -4,7 +4,12 @@ import { join } from "node:path";
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { agent, type ConfigureMcpServersParams, installFromNpmTarball } from "./shared.ts"; import {
agent,
type ConfigureMcpServersParams,
installFromNpmTarball,
setupProcessAgentEnv,
} from "./shared.ts";
export const codex = agent({ export const codex = agent({
name: "codex", name: "codex",
@@ -15,16 +20,16 @@ export const codex = agent({
executablePath: "bin/codex.js", executablePath: "bin/codex.js",
}); });
}, },
run: async ({ payload, mcpServers, apiKey, cliPath, githubInstallationToken }) => { run: async ({ payload, mcpServers, apiKey, cliPath }) => {
process.env.OPENAI_API_KEY = apiKey;
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
// create config directory for codex before setting HOME // create config directory for codex before setting HOME
const tempHome = process.env.PULLFROG_TEMP_DIR!; const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "codex"); const configDir = join(tempHome, ".config", "codex");
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
process.env.HOME = tempHome; setupProcessAgentEnv({
OPENAI_API_KEY: apiKey,
HOME: tempHome,
});
configureCodexMcpServers({ mcpServers, cliPath }); configureCodexMcpServers({ mcpServers, cliPath });
@@ -35,21 +40,16 @@ export const codex = agent({
}; };
const codex = new Codex(codexOptions); const codex = new Codex(codexOptions);
// Configure thread options to match Claude's permissions (bypassPermissions)
// approvalPolicy: "never" = no approval needed (equivalent to bypassPermissions)
// sandboxMode: "workspace-write" = allow file writes
// networkAccessEnabled: true = allow network access (needed for GitHub API calls)
const thread = codex.startThread({ const thread = codex.startThread({
approvalPolicy: "never", approvalPolicy: "never",
sandboxMode: "workspace-write", // use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
sandboxMode: "danger-full-access",
networkAccessEnabled: true, networkAccessEnabled: true,
}); });
try { try {
// Use runStreamed to get streaming events similar to claude.ts
const streamedTurn = await thread.runStreamed(addInstructions(payload)); const streamedTurn = await thread.runStreamed(addInstructions(payload));
// Stream events and handle them
let finalOutput = ""; let finalOutput = "";
for await (const event of streamedTurn.events) { for await (const event of streamedTurn.events) {
const handler = messageHandlers[event.type]; const handler = messageHandlers[event.type];
@@ -58,7 +58,6 @@ export const codex = agent({
handler(event as never); handler(event as never);
} }
// Capture final response from agent messages
if (event.type === "item.completed" && event.item.type === "agent_message") { if (event.type === "item.completed" && event.item.type === "agent_message") {
finalOutput = event.item.text; finalOutput = event.item.text;
} }
+9 -14
View File
@@ -4,7 +4,12 @@ import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts"; import {
agent,
type ConfigureMcpServersParams,
createAgentEnv,
installFromCurl,
} from "./shared.ts";
// cursor cli event types inferred from stream-json output // cursor cli event types inferred from stream-json output
interface CursorSystemEvent { interface CursorSystemEvent {
@@ -138,10 +143,7 @@ export const cursor = agent({
executableName: "cursor-agent", executableName: "cursor-agent",
}); });
}, },
run: async ({ payload, apiKey, cliPath, githubInstallationToken, mcpServers }) => { run: async ({ payload, apiKey, cliPath, mcpServers }) => {
process.env.CURSOR_API_KEY = apiKey;
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
configureCursorMcpServers({ mcpServers, cliPath }); configureCursorMcpServers({ mcpServers, cliPath });
try { try {
@@ -165,16 +167,9 @@ export const cursor = agent({
], ],
{ {
cwd: process.cwd(), cwd: process.cwd(),
env: { env: createAgentEnv({
CURSOR_API_KEY: apiKey, CURSOR_API_KEY: apiKey,
GITHUB_INSTALLATION_TOKEN: githubInstallationToken, }),
LOG_LEVEL: process.env.LOG_LEVEL,
NODE_ENV: process.env.NODE_ENV,
HOME: process.env.HOME,
PATH: process.env.PATH,
// Don't override HOME - Cursor CLI needs access to macOS keychain
// MCP config is written to tempDir/.cursor/mcp.json which Cursor will find
},
stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr stdio: ["ignore", "pipe", "pipe"], // Ignore stdin, pipe stdout/stderr
} }
); );
+9 -14
View File
@@ -2,7 +2,12 @@ import { spawnSync } from "node:child_process";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts"; import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { agent, type ConfigureMcpServersParams, installFromGithub } from "./shared.ts"; import {
agent,
type ConfigureMcpServersParams,
createAgentEnv,
installFromGithub,
} from "./shared.ts";
// gemini cli event types inferred from stream-json output (NDJSON format) // gemini cli event types inferred from stream-json output (NDJSON format)
interface GeminiInitEvent { interface GeminiInitEvent {
@@ -152,16 +157,12 @@ export const gemini = agent({
...(githubInstallationToken && { githubInstallationToken }), ...(githubInstallationToken && { githubInstallationToken }),
}); });
}, },
run: async ({ payload, apiKey, mcpServers, githubInstallationToken, cliPath }) => { run: async ({ payload, apiKey, mcpServers, cliPath }) => {
configureGeminiMcpServers({ mcpServers, cliPath }); configureGeminiMcpServers({ mcpServers, cliPath });
if (!apiKey) { if (!apiKey) {
throw new Error("google_api_key or gemini_api_key is required for gemini agent"); throw new Error("google_api_key or gemini_api_key is required for gemini agent");
} }
// Set environment variables for Gemini CLI and MCP servers
process.env.GEMINI_API_KEY = apiKey;
process.env.GITHUB_INSTALLATION_TOKEN = githubInstallationToken;
const sessionPrompt = addInstructions(payload); const sessionPrompt = addInstructions(payload);
log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`); log.info(`Starting Gemini CLI with prompt: ${payload.prompt.substring(0, 100)}...`);
@@ -170,15 +171,9 @@ export const gemini = agent({
const result = await spawn({ const result = await spawn({
cmd: "node", cmd: "node",
args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt], args: [cliPath, "--yolo", "--output-format=stream-json", "-p", sessionPrompt],
env: { env: createAgentEnv({
PATH: process.env.PATH || "",
HOME: process.env.HOME || "",
TMPDIR: process.env.TMPDIR || "/tmp",
GEMINI_API_KEY: apiKey, GEMINI_API_KEY: apiKey,
GITHUB_INSTALLATION_TOKEN: githubInstallationToken, }),
LOG_LEVEL: process.env.LOG_LEVEL!,
NODE_ENV: process.env.NODE_ENV!,
},
timeout: 600000, // 10 minutes timeout: 600000, // 10 minutes
onStdout: async (chunk) => { onStdout: async (chunk) => {
const text = chunk.toString(); const text = chunk.toString();
+35 -9
View File
@@ -8,6 +8,7 @@ import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type { show } from "@ark/util"; import type { show } from "@ark/util";
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts"; import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { getGitHubInstallationToken } from "../utils/github.ts";
/** /**
* Result returned by agent execution * Result returned by agent execution
@@ -24,7 +25,6 @@ export interface AgentResult {
*/ */
export interface AgentConfig { export interface AgentConfig {
apiKey: string; apiKey: string;
githubInstallationToken: string;
payload: Payload; payload: Payload;
mcpServers: Record<string, McpHttpServerConfig>; mcpServers: Record<string, McpHttpServerConfig>;
cliPath: string; cliPath: string;
@@ -38,6 +38,35 @@ export interface ConfigureMcpServersParams {
cliPath: string; cliPath: string;
} }
/**
* Add agent-specific vars to a whitelisted environment object for agent subprocesses.
*
* @param agentSpecificVars - Object containing agent-specific environment variables to include
* @returns Whitelisted environment object safe for subprocess spawning
*/
export function createAgentEnv(agentSpecificVars: Record<string, string>): Record<string, string> {
return {
PATH: process.env.PATH,
HOME: process.env.HOME,
LOG_LEVEL: process.env.LOG_LEVEL,
NODE_ENV: process.env.NODE_ENV,
GITHUB_TOKEN: getGitHubInstallationToken(),
...agentSpecificVars,
// values could be undefined but will be ignored
} as never;
}
/**
* Set up whitelisted environment variables in the current process.
* Used for SDKs that run in the same process (e.g., Claude SDK, Codex SDK).
* Includes agent-agnostic vars (PATH, HOME, LOG_LEVEL, NODE_ENV) plus agent-specific vars.
*
* @param agentSpecificVars - Object containing agent-specific environment variables to include
*/
export function setupProcessAgentEnv(agentSpecificVars: Record<string, string>): void {
Object.assign(process.env, createAgentEnv(agentSpecificVars));
}
/** /**
* Parameters for installing from npm tarball * Parameters for installing from npm tarball
*/ */
@@ -320,17 +349,14 @@ export async function installFromCurl({
log.info(`Installing to temp directory at ${tempDir}...`); log.info(`Installing to temp directory at ${tempDir}...`);
// Run the install script with HOME set to temp directory
// The Cursor install script installs to $HOME/.local/bin/{executableName}
// By setting HOME=tempDir, we ensure it installs to tempDir/.local/bin/{executableName}
const installResult = spawnSync("bash", [installScriptPath], { const installResult = spawnSync("bash", [installScriptPath], {
cwd: tempDir, cwd: tempDir,
env: { env: {
HOME: tempDir, // Cursor install script uses HOME for installation path // Run the install script with HOME set to temp directory
PATH: process.env.PATH || "", // ensuring a fresh install for each run
SHELL: process.env.SHELL || "/bin/bash", HOME: tempDir,
USER: process.env.USER || "", SHELL: process.env.SHELL,
TMPDIR: process.env.TMPDIR || "/tmp", USER: process.env.USER,
}, },
stdio: "pipe", stdio: "pipe",
encoding: "utf-8", encoding: "utf-8",
+419 -346
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
use debug_shell_command tool to run 'git status' and explain the result summarize https://github.com/pullfrogai/scratch/issues/56, its status, and pertinent discussion on the issue
+3 -36
View File
@@ -1,5 +1,4 @@
import { existsSync, readFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { flatMorph } from "@ark/util"; import { flatMorph } from "@ark/util";
@@ -18,7 +17,7 @@ import { log } from "./utils/cli.ts";
import { import {
parseRepoContext, parseRepoContext,
type RepoContext, type RepoContext,
revokeInstallationToken, revokeGitHubInstallationToken,
setupGitHubInstallationToken, setupGitHubInstallationToken,
} from "./utils/github.ts"; } from "./utils/github.ts";
import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts"; import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts";
@@ -49,9 +48,7 @@ export interface MainResult {
} }
export async function main(inputs: Inputs): Promise<MainResult> { export async function main(inputs: Inputs): Promise<MainResult> {
let pollInterval: NodeJS.Timeout | null = null;
let mcpServerClose: (() => Promise<void>) | undefined; let mcpServerClose: (() => Promise<void>) | undefined;
let githubInstallationToken: string | undefined;
try { try {
// parse payload early to extract agent // parse payload early to extract agent
@@ -59,15 +56,12 @@ export async function main(inputs: Inputs): Promise<MainResult> {
const partialCtx = await initializeContext(inputs, payload); const partialCtx = await initializeContext(inputs, payload);
const ctx = partialCtx as MainContext; const ctx = partialCtx as MainContext;
githubInstallationToken = ctx.githubInstallationToken;
setupGitAuth({ setupGitAuth({
githubInstallationToken: ctx.githubInstallationToken, githubInstallationToken: ctx.githubInstallationToken,
repoContext: ctx.repoContext, repoContext: ctx.repoContext,
}); });
await setupTempDirectory(ctx); await setupTempDirectory(ctx);
setupMcpLogPolling(ctx);
pollInterval = ctx.pollInterval;
setupGitBranch(ctx.payload); setupGitBranch(ctx.payload);
await startMcpServer(ctx); await startMcpServer(ctx);
@@ -87,15 +81,10 @@ export async function main(inputs: Inputs): Promise<MainResult> {
error: errorMessage, error: errorMessage,
}; };
} finally { } finally {
if (pollInterval) {
clearInterval(pollInterval);
}
if (mcpServerClose) { if (mcpServerClose) {
await mcpServerClose(); await mcpServerClose();
} }
if (githubInstallationToken) { await revokeGitHubInstallationToken();
await revokeInstallationToken(githubInstallationToken);
}
} }
} }
@@ -170,8 +159,6 @@ interface MainContext {
agentName: AgentNameType; agentName: AgentNameType;
agent: (typeof agents)[AgentNameType]; agent: (typeof agents)[AgentNameType];
sharedTempDir: string; sharedTempDir: string;
mcpLogPath: string;
pollInterval: NodeJS.Timeout | null;
payload: Payload; payload: Payload;
mcpServerUrl: string; mcpServerUrl: string;
mcpServerClose: () => Promise<void>; mcpServerClose: () => Promise<void>;
@@ -210,8 +197,6 @@ async function initializeContext(
agent, agent,
payload: resolvedPayload, payload: resolvedPayload,
sharedTempDir: "", sharedTempDir: "",
mcpLogPath: "",
pollInterval: null,
}; };
} }
@@ -262,25 +247,9 @@ async function setupTempDirectory(
): Promise<void> { ): Promise<void> {
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
ctx.mcpLogPath = join(ctx.sharedTempDir, "mcpLog.txt");
await writeFile(ctx.mcpLogPath, "", "utf-8");
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
} }
function setupMcpLogPolling(ctx: MainContext): void {
let lastSize = 0;
ctx.pollInterval = setInterval(() => {
if (existsSync(ctx.mcpLogPath)) {
const content = readFileSync(ctx.mcpLogPath, "utf-8");
if (content.length > lastSize) {
const newContent = content.slice(lastSize);
process.stdout.write(newContent);
lastSize = content.length;
}
}
}, 100);
}
function parsePayload(inputs: Inputs): Payload { function parsePayload(inputs: Inputs): Payload {
try { try {
const parsedPrompt = JSON.parse(inputs.prompt); const parsedPrompt = JSON.parse(inputs.prompt);
@@ -307,7 +276,6 @@ async function startMcpServer(ctx: MainContext): Promise<void> {
const githubRepository = `${repoContext.owner}/${repoContext.name}`; const githubRepository = `${repoContext.owner}/${repoContext.name}`;
const allModes = [...modes, ...(ctx.payload.modes || [])]; const allModes = [...modes, ...(ctx.payload.modes || [])];
process.env.GITHUB_INSTALLATION_TOKEN = ctx.githubInstallationToken;
process.env.GITHUB_REPOSITORY = githubRepository; process.env.GITHUB_REPOSITORY = githubRepository;
process.env.PULLFROG_MODES = JSON.stringify(allModes); process.env.PULLFROG_MODES = JSON.stringify(allModes);
process.env.PULLFROG_PAYLOAD = JSON.stringify(ctx.payload); process.env.PULLFROG_PAYLOAD = JSON.stringify(ctx.payload);
@@ -357,7 +325,6 @@ async function runAgent(ctx: MainContext): Promise<AgentResult> {
return ctx.agent.run({ return ctx.agent.run({
payload: ctx.payload, payload: ctx.payload,
mcpServers: ctx.mcpServers, mcpServers: ctx.mcpServers,
githubInstallationToken: ctx.githubInstallationToken,
apiKey: ctx.apiKey, apiKey: ctx.apiKey,
cliPath: ctx.cliPath, cliPath: ctx.cliPath,
}); });
+25
View File
@@ -74,6 +74,31 @@ await mcp.call("gh_pullfrog/list_pull_request_reviews", {
}); });
``` ```
#### `reply_to_review_comment`
reply to a PR review comment thread explaining how the feedback was addressed.
**parameters:**
- `pull_number` (number): the pull request number
- `comment_id` (number): the ID of the review comment to reply to
- `body` (string): the reply text explaining how the feedback was addressed
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies`
**returns:**
the created reply comment including:
- comment id, body, html_url
- in_reply_to_id showing it's a reply to the specified comment
**example:**
```typescript
// after addressing a review comment
await mcp.call("gh_pullfrog/reply_to_review_comment", {
pull_number: 47,
comment_id: 2567334961,
body: "removed the function as requested"
});
```
### other tools ### other tools
see individual files for documentation on other tools: see individual files for documentation on other tools:
+32
View File
@@ -168,3 +168,35 @@ export const UpdateWorkingCommentTool = tool({
}; };
}), }),
}); });
export const ReplyToReviewComment = type({
pull_number: type.number.describe("the pull request number"),
comment_id: type.number.describe("the ID of the review comment to reply to"),
body: type.string.describe("the reply text explaining how the feedback was addressed"),
});
export const ReplyToReviewCommentTool = tool({
name: "reply_to_review_comment",
description:
"Reply to a PR review comment thread explaining how the feedback was addressed. Use this after addressing each review comment to provide specific context about the changes made.",
parameters: ReplyToReviewComment,
execute: contextualize(async ({ pull_number, comment_id, body }, ctx) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
owner: ctx.owner,
repo: ctx.name,
pull_number,
comment_id,
body: bodyWithFooter,
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
in_reply_to_id: result.data.in_reply_to_id,
};
}),
});
+35
View File
@@ -0,0 +1,35 @@
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const GetIssueComments = type({
issue_number: type.number.describe("The issue number to get comments for"),
});
export const GetIssueCommentsTool = tool({
name: "get_issue_comments",
description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
parameters: GetIssueComments,
execute: contextualize(async ({ issue_number }, ctx) => {
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
owner: ctx.owner,
repo: ctx.name,
issue_number,
});
return {
issue_number,
comments: comments.map((comment) => ({
id: comment.id,
body: comment.body,
user: comment.user?.login,
created_at: comment.created_at,
updated_at: comment.updated_at,
html_url: comment.html_url,
author_association: comment.author_association,
reactions: comment.reactions,
})),
count: comments.length,
};
}),
});
+93
View File
@@ -0,0 +1,93 @@
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const GetIssueEvents = type({
issue_number: type.number.describe("The issue number to get events for"),
});
export const GetIssueEventsTool = tool({
name: "get_issue_events",
description:
"Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.",
parameters: GetIssueEvents,
execute: contextualize(async ({ issue_number }, ctx) => {
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
owner: ctx.owner,
repo: ctx.name,
issue_number,
});
// Only include events not reflected in current issue state (get_issue already has labels, assignees, state, etc.)
// Keep only relationship/reference events that show connections to other issues/PRs/commits
const relevantEventTypes = new Set(["cross_referenced", "referenced"]);
const parsedEvents = events.flatMap((event) => {
// Filter to only events with an 'event' property and relevant types
if (!("event" in event) || !relevantEventTypes.has(event.event)) {
return [];
}
const baseEvent: Record<string, any> = {
event: event.event,
};
// Common fields
if ("id" in event) {
baseEvent.id = event.id;
}
if ("actor" in event && event.actor) {
baseEvent.actor = event.actor.login;
} else if ("user" in event && event.user) {
baseEvent.actor = event.user.login;
}
if ("created_at" in event) {
baseEvent.created_at = event.created_at;
}
// Event-specific data
if (event.event === "cross_referenced") {
if ("source" in event && event.source) {
const source = event.source as {
type?: string;
issue?: { number: number; title: string; html_url: string };
pull_request?: { number: number; title: string; html_url: string };
};
baseEvent.source = {
type: source.type,
issue: source.issue
? {
number: source.issue.number,
title: source.issue.title,
html_url: source.issue.html_url,
}
: null,
pull_request: source.pull_request
? {
number: source.pull_request.number,
title: source.pull_request.title,
html_url: source.pull_request.html_url,
}
: null,
};
}
}
if (event.event === "referenced") {
if ("commit_id" in event) {
baseEvent.commit_id = event.commit_id;
}
if ("commit_url" in event) {
baseEvent.commit_url = event.commit_url;
}
}
return [baseEvent];
});
return {
issue_number,
events: parsedEvents,
count: parsedEvents.length,
};
}),
});
+55
View File
@@ -0,0 +1,55 @@
import { type } from "arktype";
import { contextualize, tool } from "./shared.ts";
export const IssueInfo = type({
issue_number: type.number.describe("The issue number to fetch"),
});
export const IssueInfoTool = tool({
name: "get_issue",
description: "Retrieve GitHub issue information by issue number",
parameters: IssueInfo,
execute: contextualize(async ({ issue_number }, ctx) => {
const issue = await ctx.octokit.rest.issues.get({
owner: ctx.owner,
repo: ctx.name,
issue_number,
});
const data = issue.data;
const hints: string[] = [];
if (data.comments > 0) {
hints.push("use get_issue_comments to retrieve all comments for this issue");
}
hints.push(
"use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)"
);
return {
number: data.number,
url: data.html_url,
title: data.title,
body: data.body,
state: data.state,
locked: data.locked,
labels: data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
assignees: data.assignees?.map((assignee) => assignee.login),
user: data.user?.login,
created_at: data.created_at,
updated_at: data.updated_at,
closed_at: data.closed_at,
comments: data.comments,
milestone: data.milestone?.title,
pull_request: data.pull_request
? {
url: data.pull_request.url,
html_url: data.pull_request.html_url,
diff_url: data.pull_request.diff_url,
patch_url: data.pull_request.patch_url,
}
: null,
hints,
};
}),
});
+8
View File
@@ -8,10 +8,14 @@ import {
CreateCommentTool, CreateCommentTool,
CreateWorkingCommentTool, CreateWorkingCommentTool,
EditCommentTool, EditCommentTool,
ReplyToReviewCommentTool,
UpdateWorkingCommentTool, UpdateWorkingCommentTool,
} from "./comment.ts"; } from "./comment.ts";
import { DebugShellCommandTool } from "./debug.ts"; import { DebugShellCommandTool } from "./debug.ts";
import { IssueTool } from "./issue.ts"; import { IssueTool } from "./issue.ts";
import { GetIssueCommentsTool } from "./issueComments.ts";
import { GetIssueEventsTool } from "./issueEvents.ts";
import { IssueInfoTool } from "./issueInfo.ts";
import { PullRequestTool } from "./pr.ts"; import { PullRequestTool } from "./pr.ts";
import { PullRequestInfoTool } from "./prInfo.ts"; import { PullRequestInfoTool } from "./prInfo.ts";
import { ReviewTool } from "./review.ts"; import { ReviewTool } from "./review.ts";
@@ -63,7 +67,11 @@ export async function startMcpHttpServer(): Promise<{ url: string; close: () =>
EditCommentTool, EditCommentTool,
CreateWorkingCommentTool, CreateWorkingCommentTool,
UpdateWorkingCommentTool, UpdateWorkingCommentTool,
ReplyToReviewCommentTool,
IssueTool, IssueTool,
IssueInfoTool,
GetIssueCommentsTool,
GetIssueEventsTool,
PullRequestTool, PullRequestTool,
ReviewTool, ReviewTool,
PullRequestInfoTool, PullRequestInfoTool,
+7 -10
View File
@@ -2,7 +2,7 @@ import { Octokit } from "@octokit/rest";
import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { FastMCP, Tool } from "fastmcp"; import type { FastMCP, Tool } from "fastmcp";
import type { Payload } from "../external.ts"; import type { Payload } from "../external.ts";
import { parseRepoContext, type RepoContext } from "../utils/github.ts"; import { getGitHubInstallationToken, parseRepoContext, type RepoContext } from "../utils/github.ts";
export interface ToolResult { export interface ToolResult {
content: { content: {
@@ -28,15 +28,10 @@ export function getPayload(): Payload {
} }
export function getMcpContext(): McpContext { export function getMcpContext(): McpContext {
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
if (!githubInstallationToken) {
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
}
return { return {
...parseRepoContext(), ...parseRepoContext(),
octokit: new Octokit({ octokit: new Octokit({
auth: githubInstallationToken, auth: getGitHubInstallationToken(),
}), }),
payload: getPayload(), payload: getPayload(),
}; };
@@ -56,9 +51,10 @@ export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
return server; return server;
}; };
export const contextualize = export const contextualize = <T>(
<T>(executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>) => executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>
async (params: T): Promise<ToolResult> => { ) => {
return async (params: T): Promise<ToolResult> => {
try { try {
const ctx = getMcpContext(); const ctx = getMcpContext();
const result = await executor(params, ctx); const result = await executor(params, ctx);
@@ -67,6 +63,7 @@ export const contextualize =
return handleToolError(error); return handleToolError(error);
} }
}; };
};
const handleToolSuccess = (data: Record<string, any>): ToolResult => { const handleToolSuccess = (data: Record<string, any>): ToolResult => {
return { return {
+26
View File
@@ -33,6 +33,32 @@ export const modes: Mode[] = [
9. When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR. 9. When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR.
10. Update the Working Comment one final time with a summary of the results. Include links to any created issues/PRs, e.g. \`[View PR](https://github.com/org/repo/pull/123)\` 10. Update the Working Comment one final time with a summary of the results. Include links to any created issues/PRs, e.g. \`[View PR](https://github.com/org/repo/pull/123)\`
`,
},
{
name: "Address Reviews",
description:
"Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
prompt: `Follow these steps:
1. ${initialCommentInstruction}
2. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically fetches and checks out the PR branch)
3. Review the feedback provided. Understand each review comment and what changes are being requested.
4. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists.
5. Make the necessary code changes to address the feedback. Work through each review comment systematically.
6. After addressing each review comment, use ${ghPullfrogMcpName}/reply_to_review_comment to reply directly to that comment thread explaining what change was made (keep replies concise, 1-2 sentences).
7. Test your changes to ensure they work correctly.
8. Update your working comment using ${ghPullfrogMcpName}/update_working_comment to share overall progress.
9. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one.
10. Update the Working Comment one final time with a summary of all changes made.
`, `,
}, },
{ {
+1 -88
View File
@@ -3,8 +3,7 @@
*/ */
import { spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { appendFileSync, existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { join } from "node:path";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { table } from "table"; import { table } from "table";
@@ -325,41 +324,8 @@ export const log = {
log.info(output.trimEnd()); log.info(output.trimEnd());
}, },
/**
* Log MCP tool call information to mcpLog.txt in the temp directory
*/
toolCallToFile: ({
toolName,
request,
result,
error,
}: {
toolName: string;
request: unknown;
result?: string;
error?: string;
}): void => {
const logPath = getMcpLogPath();
const params: Parameters<typeof formatToolCall>[0] = { toolName, request };
if (error) {
params.error = error;
} else if (result) {
params.result = result;
}
const logEntry = formatToolCall(params);
appendFileSync(logPath, logEntry, "utf-8");
},
}; };
/**
* Get the path to the MCP log file in the temp directory
*/
function getMcpLogPath(): string {
const tempDir = process.env.PULLFROG_TEMP_DIR!;
return join(tempDir, "mcpLog.txt");
}
/** /**
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones * Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
*/ */
@@ -385,59 +351,6 @@ export function formatIndentedField(label: string, content: string): string {
return formatted; return formatted;
} }
/**
* Format the input field for a tool call
*/
function formatToolInput(request: unknown): string {
const requestFormatted = formatJsonValue(request);
if (requestFormatted === "{}") {
return "";
}
return formatIndentedField("input", requestFormatted);
}
/**
* Format the result field for a tool call, parsing JSON if possible
*/
function formatToolResult(result: string): string {
try {
const parsed = JSON.parse(result);
const formatted = formatJsonValue(parsed);
return formatIndentedField("result", formatted);
} catch {
// Not JSON, display as-is
return formatIndentedField("result", result);
}
}
/**
* Format a complete tool call entry with tool name, input, result, and error
*/
function formatToolCall({
toolName,
request,
result,
error,
}: {
toolName: string;
request: unknown;
result?: string;
error?: string;
}): string {
let logEntry = `${toolName}\n`;
logEntry += formatToolInput(request);
if (error) {
logEntry += formatIndentedField("error", error);
} else if (result) {
logEntry += formatToolResult(result);
}
logEntry += "\n";
return logEntry;
}
/** /**
* Finds a CLI executable path by checking if it's installed globally * Finds a CLI executable path by checking if it's installed globally
* @param name The name of the CLI executable to find * @param name The name of the CLI executable to find
+20 -4
View File
@@ -241,21 +241,37 @@ async function acquireNewToken(): Promise<string> {
} }
} }
// Store token in memory instead of process.env
let githubInstallationToken: string | undefined;
/** /**
* Setup GitHub installation token for the action * Setup GitHub installation token for the action
*/ */
export async function setupGitHubInstallationToken(): Promise<string> { export async function setupGitHubInstallationToken(): Promise<string> {
const acquiredToken = await acquireNewToken(); const acquiredToken = await acquireNewToken();
core.setSecret(acquiredToken); core.setSecret(acquiredToken);
process.env.GITHUB_INSTALLATION_TOKEN = acquiredToken; githubInstallationToken = acquiredToken;
return acquiredToken; return acquiredToken;
} }
/** /**
* Revoke GitHub installation token * Get the GitHub installation token from memory
*/ */
export async function revokeInstallationToken(token: string): Promise<void> { export function getGitHubInstallationToken(): string {
if (!githubInstallationToken) {
throw new Error("GitHub installation token not set. Call setupGitHubInstallationToken first.");
}
return githubInstallationToken;
}
export async function revokeGitHubInstallationToken(): Promise<void> {
if (!githubInstallationToken) {
return;
}
const token = githubInstallationToken;
githubInstallationToken = undefined;
const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com"; const apiUrl = process.env.GITHUB_API_URL || "https://api.github.com";
try { try {