merge main

This commit is contained in:
Shawn Morreau
2025-11-21 11:08:03 -05:00
22 changed files with 424 additions and 89 deletions
+53 -4
View File
@@ -1,6 +1,46 @@
import { type } from "arktype";
import type { Payload } from "../external.ts";
import { agentsManifest } from "../external.ts";
import { parseRepoContext } from "../utils/github.ts";
import { contextualize, tool } from "./shared.ts";
const PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
function buildCommentFooter(payload: Payload): string {
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID;
const agentName = payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
const agentDisplayName = agentInfo?.displayName || "Unknown Agent";
const agentUrl = agentInfo?.url || "https://pullfrog.ai";
// build workflow run URL
const workflowRunUrl = runId
? `https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId}`
: `https://github.com/${repoContext.owner}/${repoContext.name}`;
return `
${PULLFROG_DIVIDER}
---
<sup>🐸 Triggered by [Pullfrog](https://pullfrog.ai) | 🤖 [${agentDisplayName}](${agentUrl}) | [View workflow run](${workflowRunUrl}) | [𝕏](https://x.com/pullfrogai)</sup>`;
}
function stripExistingFooter(body: string): string {
const dividerIndex = body.indexOf(PULLFROG_DIVIDER);
if (dividerIndex === -1) {
return body;
}
return body.substring(0, dividerIndex).trimEnd();
}
function addFooter(body: string, payload: Payload): string {
const bodyWithoutFooter = stripExistingFooter(body);
const footer = buildCommentFooter(payload);
return `${bodyWithoutFooter}${footer}`;
}
export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
@@ -11,11 +51,13 @@ export const CreateCommentTool = tool({
description: "Create a comment on a GitHub issue",
parameters: Comment,
execute: contextualize(async ({ issueNumber, body }, ctx) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: body,
body: bodyWithFooter,
});
return {
@@ -37,11 +79,13 @@ export const EditCommentTool = tool({
description: "Edit a GitHub issue comment by its ID",
parameters: EditComment,
execute: contextualize(async ({ commentId, body }, ctx) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner,
repo: ctx.name,
comment_id: commentId,
body: body,
body: bodyWithFooter,
});
return {
@@ -73,11 +117,14 @@ export const CreateWorkingCommentTool = tool({
throw new Error("create_working_comment may not be called multiple times");
}
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);
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner,
repo: ctx.name,
issue_number: issueNumber,
body: `${intent} <img src="https://pullfrog.ai/party-parrot.gif" width="14px" height="14px" style="vertical-align: middle; margin-left: 4px;" />`,
body: bodyWithFooter,
});
workingCommentId = result.data.id;
@@ -104,11 +151,13 @@ export const UpdateWorkingCommentTool = tool({
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,
body: bodyWithFooter,
});
return {
+20 -6
View File
@@ -4,6 +4,7 @@
import type { McpStdioServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { fromHere } from "@ark/fs";
import type { Payload } from "../external.ts";
import { ghPullfrogMcpName } from "../external.ts";
import type { Mode } from "../modes.ts";
import { parseRepoContext } from "../utils/github.ts";
@@ -12,7 +13,11 @@ export type McpName = typeof ghPullfrogMcpName;
export type McpConfigs = Record<McpName, McpStdioServerConfig>;
export function createMcpConfigs(githubInstallationToken: string, modes: Mode[]): McpConfigs {
export function createMcpConfigs(
githubInstallationToken: string,
modes: Mode[],
payload: Payload
): McpConfigs {
const repoContext = parseRepoContext();
const githubRepository = `${repoContext.owner}/${repoContext.name}`;
@@ -20,15 +25,24 @@ export function createMcpConfigs(githubInstallationToken: string, modes: Mode[])
// In development, server.ts is in the same directory as this file (config.ts)
const serverPath = process.env.GITHUB_ACTIONS ? fromHere("mcp-server") : fromHere("server.ts");
const env: Record<string, string> = {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
PULLFROG_MODES: JSON.stringify(modes),
PULLFROG_PAYLOAD: JSON.stringify(payload),
PULLFROG_TEMP_DIR: process.env.PULLFROG_TEMP_DIR!,
};
// pass through GITHUB_RUN_ID if available (automatically set in GitHub Actions)
if (process.env.GITHUB_RUN_ID) {
env.GITHUB_RUN_ID = process.env.GITHUB_RUN_ID;
}
return {
[ghPullfrogMcpName]: {
command: "node",
args: [serverPath],
env: {
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
GITHUB_REPOSITORY: githubRepository,
PULLFROG_MODES: JSON.stringify(modes),
},
env,
},
};
}
+10 -5
View File
@@ -1,5 +1,5 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts";
export const ListFiles = type({
@@ -17,14 +17,19 @@ export const ListFilesTool = tool({
try {
// Use git ls-files to list tracked files
// This respects .gitignore and gives a clean list of source files
const command = path === "." ? "git ls-files" : `git ls-files ${path}`;
const output = execSync(command, { encoding: "utf-8" });
const pathStr = path ?? ".";
const output = $("git", pathStr === "." ? ["ls-files"] : ["ls-files", pathStr], {
log: false,
});
const files = output.split("\n").filter((f) => f.trim() !== "");
if (files.length === 0) {
// Fallback for non-git environments or untracked files
const findCmd = `find ${path} -maxdepth 3 -not -path '*/.*' -type f`;
const findOutput = execSync(findCmd, { encoding: "utf-8" });
const findOutput = $(
"find",
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
{ log: false }
);
return {
files: findOutput.split("\n").filter((f) => f.trim() !== ""),
method: "find",
+2 -4
View File
@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts";
export const PullRequest = type({
@@ -15,9 +15,7 @@ export const PullRequestTool = tool({
parameters: PullRequest,
execute: contextualize(async ({ title, body, base }, ctx) => {
// Get the current branch name
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
encoding: "utf8",
}).trim();
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.info(`Current branch: ${currentBranch}`);
+4 -4
View File
@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts";
export const PullRequestInfo = type({
@@ -30,14 +30,14 @@ export const PullRequestInfoTool = tool({
// Automatically fetch and checkout branches for review
log.info(`Fetching base branch: origin/${baseBranch}`);
execSync(`git fetch origin ${baseBranch} --depth=20`, { stdio: "inherit" });
$("git", ["fetch", "origin", baseBranch, "--depth=20"]);
log.info(`Fetching PR branch: origin/${headBranch}`);
execSync(`git fetch origin ${headBranch}`, { stdio: "inherit" });
$("git", ["fetch", "origin", headBranch]);
log.info(`Checking out PR branch: origin/${headBranch}`);
// check out a local branch tracking the remote branch so we can push changes
execSync(`git checkout -B ${headBranch} origin/${headBranch}`, { stdio: "inherit" });
$("git", ["checkout", "-B", headBranch, `origin/${headBranch}`]);
return {
number: data.number,
+20 -3
View File
@@ -1,7 +1,7 @@
import { cached } from "@ark/util";
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 { log } from "../utils/cli.ts";
import { parseRepoContext, type RepoContext } from "../utils/github.ts";
@@ -13,7 +13,22 @@ export interface ToolResult {
isError?: boolean;
}
export const getMcpContext = cached((): McpContext => {
export function getPayload(): Payload {
const payloadEnv = process.env.PULLFROG_PAYLOAD;
if (!payloadEnv) {
throw new Error("PULLFROG_PAYLOAD environment variable is required");
}
try {
return JSON.parse(payloadEnv) as Payload;
} catch (error) {
throw new Error(
`Failed to parse PULLFROG_PAYLOAD: ${error instanceof Error ? error.message : String(error)}`
);
}
}
export function getMcpContext(): McpContext {
const githubInstallationToken = process.env.GITHUB_INSTALLATION_TOKEN;
if (!githubInstallationToken) {
throw new Error("GITHUB_INSTALLATION_TOKEN environment variable is required");
@@ -24,11 +39,13 @@ export const getMcpContext = cached((): McpContext => {
octokit: new Octokit({
auth: githubInstallationToken,
}),
payload: getPayload(),
};
});
}
export interface McpContext extends RepoContext {
octokit: Octokit;
payload: Payload;
}
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => {