merge main

This commit is contained in:
Shawn Morreau
2025-12-03 14:35:12 -05:00
17 changed files with 1948 additions and 1451 deletions
+76 -52
View File
@@ -15,16 +15,16 @@ function buildCommentFooter(payload: Payload): string {
const agentDisplayName = agentInfo?.displayName || "Unknown Agent";
const agentUrl = agentInfo?.url || "https://pullfrog.ai";
// build workflow run URL: https://github.com/{owner}/{repo}/actions/runs/{runId}
const workflowRunUrl = runId
? `https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId}`
: `https://github.com/${repoContext.owner}/${repoContext.name}`;
// build workflow run link or show unavailable message
const workflowRunPart = runId
? `[View workflow run](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "(workflow link unavailable)";
return `
${PULLFROG_DIVIDER}
---
<sup>🐸 Triggered by [Pullfrog](https://pullfrog.ai) | 🤖 [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [𝕏](https://x.com/pullfrogai)</sup>`;
<sup>🐸 Triggered by [Pullfrog](https://pullfrog.ai) | 🤖 [${agentDisplayName}](${agentUrl}) | ${workflowRunPart} | [𝕏](https://x.com/pullfrogai)</sup>`;
}
function stripExistingFooter(body: string): string {
@@ -98,27 +98,80 @@ export const EditCommentTool = tool({
}),
});
let workingCommentId: number | null = null;
/**
* Get progress comment ID from environment variable.
* This allows the webhook handler to pre-create a "leaping into action" comment
* and pass the ID to the action for updates.
*/
function getProgressCommentIdFromEnv(): number | null {
const envCommentId = process.env.PULLFROG_PROGRESS_COMMENT_ID;
if (envCommentId) {
const parsed = parseInt(envCommentId, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return null;
}
export const WorkingComment = type({
issueNumber: type.number.describe("the issue number to comment on"),
intent: type("/^I'll .+$/").describe(
"the body of the initial comment expressing your intent to handle the request. must have the form 'I'll {summary of request}'"
),
// module-level variable to track the progress comment ID
// initialized lazily on first use to allow env var to be set after module load
let progressCommentId: number | null = null;
let progressCommentIdInitialized = false;
function getProgressCommentId(): number | null {
if (!progressCommentIdInitialized) {
progressCommentId = getProgressCommentIdFromEnv();
progressCommentIdInitialized = true;
}
return progressCommentId;
}
function setProgressCommentId(id: number): void {
progressCommentId = id;
progressCommentIdInitialized = true;
}
export const ReportProgress = type({
body: type.string.describe("the progress update content to share"),
});
export const CreateWorkingCommentTool = tool({
name: "create_working_comment",
export const ReportProgressTool = tool({
name: "report_progress",
description:
"Create an initial comment on a GitHub issue that will be updated as work progresses",
parameters: WorkingComment,
execute: contextualize(async ({ issueNumber, intent }, ctx) => {
if (workingCommentId) {
throw new Error("create_working_comment may not be called multiple times");
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress,
execute: contextualize(async ({ body }, ctx) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const existingCommentId = getProgressCommentId();
// if we already have a progress comment, update it
if (existingCommentId) {
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: existingCommentId,
body: bodyWithFooter,
});
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
action: "updated",
};
}
const body = `${intent} <img src="https://pullfrog.ai/party-parrot.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`;
const bodyWithFooter = addFooter(body, ctx.payload);
// no existing comment - create one
const issueNumber = ctx.payload.event.issue_number;
if (issueNumber === undefined) {
// fail silently
return { suggess: true };
// throw new Error(
// "cannot create progress comment: no issue_number found in the payload event"
// );
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
@@ -127,44 +180,15 @@ export const CreateWorkingCommentTool = tool({
body: bodyWithFooter,
});
workingCommentId = result.data.id;
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}),
});
export const WorkingCommentUpdate = type({
body: type.string.describe("the new comment body content"),
});
export const UpdateWorkingCommentTool = tool({
name: "update_working_comment",
description: "Update a working comment on a GitHub issue",
parameters: WorkingCommentUpdate,
execute: contextualize(async ({ body }, ctx) => {
if (!workingCommentId) {
throw new Error("create_working_comment must be called before update_working_comment");
}
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: workingCommentId,
body: bodyWithFooter,
});
// store the comment ID for future updates
setProgressCommentId(result.data.id);
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
action: "created",
};
}),
});
+18 -2
View File
@@ -1,5 +1,6 @@
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts";
@@ -14,11 +15,26 @@ export const PullRequestTool = tool({
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 = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.info(`Current branch: ${currentBranch}`);
// validate PR title and body for secrets
if (containsSecrets(title) || containsSecrets(body)) {
throw new Error(
"PR creation blocked: secrets detected in PR title or body. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
);
}
// validate all changes that would be in the PR (from base to HEAD)
const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false });
if (containsSecrets(diff)) {
throw new Error(
"PR creation blocked: secrets detected in changes. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
);
}
const result = await ctx.octokit.rest.pulls.create({
owner: ctx.owner,
repo: ctx.name,
+4 -27
View File
@@ -1,20 +1,6 @@
import { type } from "arktype";
import type { Mode } from "../modes.ts";
import { contextualize, tool } from "./shared.ts";
// Get modes from environment variable (set by createMcpConfigs)
function getModes(): Mode[] {
const modesJson = process.env.PULLFROG_MODES;
if (modesJson) {
try {
return JSON.parse(modesJson);
} catch {
return [];
}
}
return [];
}
export const SelectMode = type({
modeName: type.string.describe(
"the name of the mode to select (e.g., 'Plan', 'Build', 'Review', 'Prompt')"
@@ -26,23 +12,14 @@ export const SelectModeTool = tool({
description:
"Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
parameters: SelectMode,
execute: contextualize(async ({ modeName }) => {
const allModes = getModes();
if (allModes.length === 0) {
return {
error:
"No modes available. Modes must be provided via PULLFROG_MODES environment variable.",
};
}
const selectedMode = allModes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
execute: contextualize(async ({ modeName }, ctx) => {
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
if (!selectedMode) {
const availableModes = allModes.map((m) => m.name).join(", ");
const availableModes = ctx.modes.map((m) => m.name).join(", ");
return {
error: `Mode "${modeName}" not found. Available modes: ${availableModes}`,
availableModes: allModes.map((m) => ({ name: m.name, description: m.description })),
availableModes: ctx.modes.map((m) => ({ name: m.name, description: m.description })),
};
}
+8 -6
View File
@@ -6,10 +6,9 @@ import { ghPullfrogMcpName } from "../external.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import {
CreateCommentTool,
CreateWorkingCommentTool,
EditCommentTool,
ReplyToReviewCommentTool,
UpdateWorkingCommentTool,
ReportProgressTool,
} from "./comment.ts";
import { DebugShellCommandTool } from "./debug.ts";
import { IssueTool } from "./issue.ts";
@@ -21,7 +20,7 @@ import { PullRequestInfoTool } from "./prInfo.ts";
import { ReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
import { addTools, initMcpContext, type McpInitContext } from "./shared.ts";
/**
* Find an available port starting from the given port
@@ -55,7 +54,11 @@ async function findAvailablePort(startPort: number): Promise<number> {
/**
* Start the MCP HTTP server and return the URL and close function
*/
export async function startMcpHttpServer(): Promise<{ url: string; close: () => Promise<void> }> {
export async function startMcpHttpServer(
state: McpInitContext
): Promise<{ url: string; close: () => Promise<void> }> {
initMcpContext(state);
const server = new FastMCP({
name: ghPullfrogMcpName,
version: "0.0.1",
@@ -63,10 +66,9 @@ export async function startMcpHttpServer(): Promise<{ url: string; close: () =>
addTools(server, [
SelectModeTool,
ReportProgressTool,
CreateCommentTool,
EditCommentTool,
CreateWorkingCommentTool,
UpdateWorkingCommentTool,
ReplyToReviewCommentTool,
IssueTool,
IssueInfoTool,
+26 -25
View File
@@ -2,46 +2,39 @@ import { Octokit } from "@octokit/rest";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { FastMCP, Tool } from "fastmcp";
import type { Payload } from "../external.ts";
import type { Mode } from "../modes.ts";
import { getGitHubInstallationToken, parseRepoContext, type RepoContext } from "../utils/github.ts";
export interface ToolResult {
content: {
type: "text";
text: string;
}[];
isError?: boolean;
export interface McpInitContext {
payload: Payload;
modes: Mode[];
agentName?: string;
}
export function getPayload(): Payload {
const payloadEnv = process.env.PULLFROG_PAYLOAD;
if (!payloadEnv) {
throw new Error("PULLFROG_PAYLOAD environment variable is required");
}
let mcpInitContext: McpInitContext | undefined;
try {
return JSON.parse(payloadEnv) as Payload;
} catch (error) {
throw new Error(
`Failed to parse PULLFROG_PAYLOAD: ${error instanceof Error ? error.message : String(error)}`
);
}
// this must be called on mcp server initialization
export function initMcpContext(state: McpInitContext): void {
mcpInitContext = state;
}
export interface McpContext extends McpInitContext, RepoContext {
octokit: Octokit;
}
export function getMcpContext(): McpContext {
if (!mcpInitContext) {
throw new Error("MCP context not initialized. Call initializeMcpContext first.");
}
return {
...mcpInitContext,
...parseRepoContext(),
octokit: new Octokit({
auth: getGitHubInstallationToken(),
}),
payload: getPayload(),
};
}
export interface McpContext extends RepoContext {
octokit: Octokit;
payload: Payload;
}
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
/**
@@ -124,7 +117,7 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
// only sanitize schemas for gemini agent (it has issues with draft-2020-12 schemas)
const shouldSanitize = process.env.PULLFROG_AGENT === "gemini";
const shouldSanitize = mcpInitContext?.agentName === "gemini";
for (const tool of tools) {
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
@@ -147,6 +140,14 @@ export const contextualize = <T>(
};
};
export interface ToolResult {
content: {
type: "text";
text: string;
}[];
isError?: boolean;
}
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
return {
content: [