merge main
This commit is contained in:
+76
-52
@@ -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",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user