Merge branch 'main' of https://github.com/pullfrog/action
This commit is contained in:
@@ -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 }}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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,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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user