Tool factories
This commit is contained in:
@@ -12,14 +12,13 @@ import { agentsManifest } from "./external.ts";
|
||||
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
|
||||
import { createMcpConfigs } from "./mcp/config.ts";
|
||||
import { startMcpHttpServer } from "./mcp/server.ts";
|
||||
import { getModes, modes } from "./modes.ts";
|
||||
import { getModes, type Mode, modes } from "./modes.ts";
|
||||
import packageJson from "./package.json" with { type: "json" };
|
||||
import { fetchRepoSettings, fetchWorkflowRunInfo } from "./utils/api.ts";
|
||||
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||
import {
|
||||
parseRepoContext,
|
||||
type RepoContext,
|
||||
revokeGitHubInstallationToken,
|
||||
setupGitHubInstallationToken,
|
||||
} from "./utils/github.ts";
|
||||
@@ -62,7 +61,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
payload = parsePayload(inputs);
|
||||
|
||||
const partialCtx = await initializeContext(inputs, payload);
|
||||
const ctx = partialCtx as MainContext;
|
||||
const ctx = partialCtx as Context;
|
||||
timer.checkpoint("initializeContext");
|
||||
|
||||
const { pushRemote } = setupGit(ctx);
|
||||
@@ -82,7 +81,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
|
||||
Array.isArray(ctx.payload.event.comment_ids) &&
|
||||
ctx.payload.event.comment_ids.length === 0
|
||||
) {
|
||||
await reportProgress({
|
||||
await reportProgress(ctx, {
|
||||
body: `👍 **No approved comments found**\n\nTo use "Fix 👍s", add a 👍 reaction to one or more inline review comments you want fixed.`,
|
||||
});
|
||||
return { success: true };
|
||||
@@ -155,36 +154,26 @@ function getAllPossibleKeyNames(): string[] {
|
||||
/**
|
||||
* Throw an error for missing API key with helpful message linking to repo settings
|
||||
*/
|
||||
async function throwMissingApiKeyError({
|
||||
agent,
|
||||
repoContext,
|
||||
}: {
|
||||
agent: (typeof agents)[AgentName] | null;
|
||||
repoContext: RepoContext;
|
||||
}): Promise<never> {
|
||||
async function throwMissingApiKeyError(ctx: Context): Promise<never> {
|
||||
const apiUrl = process.env.API_URL || "https://pullfrog.com";
|
||||
const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`;
|
||||
const settingsUrl = `${apiUrl}/console/${ctx.owner}/${ctx.name}`;
|
||||
|
||||
const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`;
|
||||
const githubRepoUrl = `https://github.com/${ctx.owner}/${ctx.name}`;
|
||||
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
|
||||
|
||||
// for OpenCode, use a generic message since it accepts any API key
|
||||
const isOpenCode = agent?.name === "opencode";
|
||||
const isOpenCode = ctx.agent?.name === "opencode";
|
||||
let secretNameList: string;
|
||||
if (isOpenCode) {
|
||||
secretNameList =
|
||||
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
|
||||
} else {
|
||||
const inputKeys = agent?.apiKeyNames || getAllPossibleKeyNames();
|
||||
const inputKeys = ctx.agent?.apiKeyNames || getAllPossibleKeyNames();
|
||||
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
|
||||
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
|
||||
}
|
||||
|
||||
let message = `${
|
||||
agent === null
|
||||
? "Pullfrog has no agent configured and no API keys are available in the environment."
|
||||
: `Pullfrog is configured to use ${agent.displayName}, but the associated API key was not provided.`
|
||||
}
|
||||
const message = `Pullfrog is configured to use ${ctx.agent.displayName}, but the associated API key was not provided.
|
||||
|
||||
To fix this, add the required secret to your GitHub repository:
|
||||
|
||||
@@ -192,30 +181,45 @@ To fix this, add the required secret to your GitHub repository:
|
||||
2. Click "New repository secret"
|
||||
3. Set the name to ${secretNameList}
|
||||
4. Set the value to your API key
|
||||
5. Click "Add secret"`;
|
||||
5. Click "Add secret"
|
||||
|
||||
if (agent === null) {
|
||||
message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`;
|
||||
}
|
||||
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
|
||||
|
||||
// report to comment if MCP context is available (server has started)
|
||||
await reportErrorToComment({ error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export interface MainContext {
|
||||
export interface Context {
|
||||
// flattened from RepoContext
|
||||
owner: string;
|
||||
name: string;
|
||||
|
||||
// core fields
|
||||
inputs: Inputs;
|
||||
githubInstallationToken: string;
|
||||
repoContext: RepoContext;
|
||||
payload: Payload;
|
||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||
agentName: AgentName;
|
||||
agent: (typeof agents)[AgentName];
|
||||
sharedTempDir: string;
|
||||
payload: Payload;
|
||||
githubInstallationToken: string;
|
||||
octokit: Octokit;
|
||||
|
||||
// repo settings from Pullfrog API
|
||||
repoSettings: RepoSettings;
|
||||
|
||||
// modes for MCP tools
|
||||
modes: Mode[];
|
||||
|
||||
// setup fields
|
||||
pushRemote: string;
|
||||
sharedTempDir: string;
|
||||
|
||||
// mcp fields
|
||||
mcpServerUrl: string;
|
||||
mcpServerClose: () => Promise<void>;
|
||||
mcpServers: ReturnType<typeof createMcpConfigs>;
|
||||
|
||||
// agent fields
|
||||
cliPath: string;
|
||||
apiKey: string;
|
||||
apiKeys: Record<string, string>;
|
||||
@@ -226,7 +230,7 @@ async function initializeContext(
|
||||
payload: Payload
|
||||
): Promise<
|
||||
Omit<
|
||||
MainContext,
|
||||
Context,
|
||||
| "mcpServerUrl"
|
||||
| "mcpServerClose"
|
||||
| "mcpServers"
|
||||
@@ -241,58 +245,65 @@ async function initializeContext(
|
||||
setupGitConfig();
|
||||
|
||||
const githubInstallationToken = await setupGitHubInstallationToken();
|
||||
const repoContext = parseRepoContext();
|
||||
const { owner, name } = parseRepoContext();
|
||||
|
||||
// fetch repo data
|
||||
// create octokit instance
|
||||
const octokit = new Octokit({
|
||||
auth: githubInstallationToken,
|
||||
});
|
||||
let repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||
try {
|
||||
|
||||
// fetch repo data
|
||||
const response = await octokit.repos.get({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
owner,
|
||||
repo: name,
|
||||
});
|
||||
const repo = response.data;
|
||||
|
||||
// fetch repo settings
|
||||
const repoSettings = await fetchRepoSettings({
|
||||
token: githubInstallationToken,
|
||||
repoContext: { owner, name },
|
||||
});
|
||||
repo = response.data;
|
||||
} catch {
|
||||
// fallback to minimal repo data if API call fails
|
||||
repo = {
|
||||
default_branch: "main",
|
||||
} as Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||
}
|
||||
|
||||
// resolve agent and update payload with resolved agent name
|
||||
const { agentName, agent } = await resolveAgent(
|
||||
const { agentName, agent } = resolveAgent({
|
||||
inputs,
|
||||
payload,
|
||||
githubInstallationToken,
|
||||
repoContext
|
||||
);
|
||||
repoSettings,
|
||||
});
|
||||
const resolvedPayload = { ...payload, agent: agentName };
|
||||
|
||||
// compute modes from defaults + payload overrides
|
||||
const computedModes = [
|
||||
...getModes({ disableProgressComment: resolvedPayload.disableProgressComment }),
|
||||
...(resolvedPayload.modes || []),
|
||||
];
|
||||
|
||||
return {
|
||||
owner,
|
||||
name,
|
||||
inputs,
|
||||
githubInstallationToken,
|
||||
repoContext,
|
||||
octokit,
|
||||
repo,
|
||||
agentName,
|
||||
agent,
|
||||
payload: resolvedPayload,
|
||||
repoSettings,
|
||||
modes: computedModes,
|
||||
sharedTempDir: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAgent(
|
||||
inputs: Inputs,
|
||||
payload: Payload,
|
||||
githubInstallationToken: string,
|
||||
repoContext: RepoContext
|
||||
): Promise<{ agentName: AgentName; agent: (typeof agents)[AgentName] }> {
|
||||
const repoSettings = await fetchRepoSettings({
|
||||
token: githubInstallationToken,
|
||||
repoContext,
|
||||
});
|
||||
|
||||
function resolveAgent({
|
||||
inputs,
|
||||
payload,
|
||||
repoSettings,
|
||||
}: {
|
||||
inputs: Inputs;
|
||||
payload: Payload;
|
||||
repoSettings: RepoSettings;
|
||||
}): { agentName: AgentName; agent: (typeof agents)[AgentName] } {
|
||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||
|
||||
@@ -337,10 +348,8 @@ async function resolveAgent(
|
||||
log.debug(`Available agents: ${availableAgentNames || "none"}`);
|
||||
|
||||
if (availableAgents.length === 0) {
|
||||
await throwMissingApiKeyError({
|
||||
agent: null,
|
||||
repoContext,
|
||||
});
|
||||
// this will be caught and reported later in validateApiKey
|
||||
throw new Error("no agents available - missing API keys");
|
||||
}
|
||||
|
||||
const agentName = availableAgents[0].name;
|
||||
@@ -349,7 +358,7 @@ async function resolveAgent(
|
||||
return { agentName, agent };
|
||||
}
|
||||
|
||||
async function setupTempDirectory(ctx: MainContext): Promise<void> {
|
||||
async function setupTempDirectory(ctx: Context): Promise<void> {
|
||||
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
|
||||
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
|
||||
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
|
||||
@@ -375,7 +384,7 @@ function parsePayload(inputs: Inputs): Payload {
|
||||
}
|
||||
}
|
||||
|
||||
async function startMcpServer(ctx: MainContext): Promise<void> {
|
||||
async function startMcpServer(ctx: Context): Promise<void> {
|
||||
// fetch the pre-created progress comment ID from the database
|
||||
// this must be set BEFORE starting the MCP server so comment.ts can read it
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
@@ -387,28 +396,18 @@ async function startMcpServer(ctx: MainContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const allModes = [
|
||||
...getModes({ disableProgressComment: ctx.payload.disableProgressComment }),
|
||||
...(ctx.payload.modes || []),
|
||||
];
|
||||
const { url, close } = await startMcpHttpServer({
|
||||
payload: ctx.payload,
|
||||
modes: allModes,
|
||||
agentName: ctx.agentName,
|
||||
repo: ctx.repo,
|
||||
pushRemote: ctx.pushRemote,
|
||||
});
|
||||
const { url, close } = await startMcpHttpServer(ctx);
|
||||
ctx.mcpServerUrl = url;
|
||||
ctx.mcpServerClose = close;
|
||||
log.info(`🚀 MCP server started at ${url}`);
|
||||
}
|
||||
|
||||
function setupMcpServers(ctx: MainContext): void {
|
||||
function setupMcpServers(ctx: Context): void {
|
||||
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
|
||||
log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
|
||||
}
|
||||
|
||||
async function installAgentCli(ctx: MainContext): Promise<void> {
|
||||
async function installAgentCli(ctx: Context): Promise<void> {
|
||||
// gemini is the only agent that needs githubInstallationToken for install
|
||||
if (ctx.agentName === "gemini") {
|
||||
ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken);
|
||||
@@ -417,7 +416,7 @@ async function installAgentCli(ctx: MainContext): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateApiKey(ctx: MainContext): Promise<void> {
|
||||
async function validateApiKey(ctx: Context): Promise<void> {
|
||||
// collect all matching API keys for this agent
|
||||
const apiKeys: Record<string, string> = {};
|
||||
for (const inputKey of ctx.agent.apiKeyNames) {
|
||||
@@ -439,10 +438,7 @@ async function validateApiKey(ctx: MainContext): Promise<void> {
|
||||
}
|
||||
|
||||
if (Object.keys(apiKeys).length === 0) {
|
||||
await throwMissingApiKeyError({
|
||||
agent: ctx.agent,
|
||||
repoContext: ctx.repoContext,
|
||||
});
|
||||
await throwMissingApiKeyError(ctx);
|
||||
// unreachable - throwMissingApiKeyError always throws
|
||||
return;
|
||||
}
|
||||
@@ -452,7 +448,7 @@ async function validateApiKey(ctx: MainContext): Promise<void> {
|
||||
ctx.apiKeys = apiKeys;
|
||||
}
|
||||
|
||||
async function runAgent(ctx: MainContext): Promise<AgentResult> {
|
||||
async function runAgent(ctx: Context): Promise<AgentResult> {
|
||||
log.info(`Running ${ctx.agentName}...`);
|
||||
// strip context from event
|
||||
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
|
||||
|
||||
+7
-4
@@ -1,16 +1,18 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const GetCheckSuiteLogs = type({
|
||||
check_suite_id: type.number.describe("the id from check_suite.id"),
|
||||
});
|
||||
|
||||
export const GetCheckSuiteLogsTool = tool({
|
||||
export function GetCheckSuiteLogsTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "get_check_suite_logs",
|
||||
description:
|
||||
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
|
||||
parameters: GetCheckSuiteLogs,
|
||||
execute: contextualize(async ({ check_suite_id }, ctx) => {
|
||||
execute: execute(ctx, async ({ check_suite_id }) => {
|
||||
// get workflow runs for this specific check suite
|
||||
const workflowRuns = await ctx.octokit.paginate(
|
||||
ctx.octokit.rest.actions.listWorkflowRunsForRepo,
|
||||
@@ -91,4 +93,5 @@ export const GetCheckSuiteLogsTool = tool({
|
||||
workflow_runs: logsForRuns,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+29
-31
@@ -2,10 +2,11 @@ import { Octokit } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { fetchWorkflowRunInfo } from "../utils/api.ts";
|
||||
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts";
|
||||
import { contextualize, getMcpContext, tool } from "./shared.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
/**
|
||||
* The prefix text for the initial "leaping into action" comment.
|
||||
@@ -42,12 +43,13 @@ export const Comment = type({
|
||||
body: type.string.describe("the comment body content"),
|
||||
});
|
||||
|
||||
export const CreateCommentTool = tool({
|
||||
export function CreateCommentTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "create_issue_comment",
|
||||
description:
|
||||
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
|
||||
parameters: Comment,
|
||||
execute: contextualize(async ({ issueNumber, body }, ctx) => {
|
||||
execute: execute(ctx, async ({ issueNumber, body }) => {
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.createComment({
|
||||
@@ -64,18 +66,20 @@ export const CreateCommentTool = tool({
|
||||
body: result.data.body,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const EditComment = type({
|
||||
commentId: type.number.describe("the ID of the comment to edit"),
|
||||
body: type.string.describe("the new comment body content"),
|
||||
});
|
||||
|
||||
export const EditCommentTool = tool({
|
||||
export function EditCommentTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "edit_issue_comment",
|
||||
description: "Edit a GitHub issue comment by its ID",
|
||||
parameters: EditComment,
|
||||
execute: contextualize(async ({ commentId, body }, ctx) => {
|
||||
execute: execute(ctx, async ({ commentId, body }) => {
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.issues.updateComment({
|
||||
@@ -93,7 +97,8 @@ export const EditCommentTool = tool({
|
||||
updatedAt: result.data.updated_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get progress comment ID from environment variable.
|
||||
@@ -141,7 +146,10 @@ export const ReportProgress = type({
|
||||
* Can be called directly without going through the MCP tool interface.
|
||||
* Returns result data if successful, undefined if comment cannot be created.
|
||||
*/
|
||||
export async function reportProgress({ body }: { body: string }): Promise<
|
||||
export async function reportProgress(
|
||||
ctx: Context,
|
||||
{ body }: { body: string }
|
||||
): Promise<
|
||||
| {
|
||||
commentId: number;
|
||||
url: string;
|
||||
@@ -150,8 +158,6 @@ export async function reportProgress({ body }: { body: string }): Promise<
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const ctx = await getMcpContext();
|
||||
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
const existingCommentId = getProgressCommentId();
|
||||
|
||||
@@ -200,13 +206,14 @@ export async function reportProgress({ body }: { body: string }): Promise<
|
||||
};
|
||||
}
|
||||
|
||||
export const ReportProgressTool = tool({
|
||||
export function ReportProgressTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "report_progress",
|
||||
description:
|
||||
"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 }) => {
|
||||
const result = await reportProgress({ body });
|
||||
execute: execute(ctx, async ({ body }) => {
|
||||
const result = await reportProgress(ctx, { body });
|
||||
|
||||
if (!result) {
|
||||
// gracefully handle case where no comment can be created
|
||||
@@ -223,7 +230,8 @@ export const ReportProgressTool = tool({
|
||||
...result,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the progress comment was updated during execution
|
||||
@@ -236,14 +244,12 @@ export function wasProgressCommentUpdated(): boolean {
|
||||
* Delete the progress comment if it exists.
|
||||
* Used after submitting a PR review since the review body contains all necessary info.
|
||||
*/
|
||||
export async function deleteProgressComment(): Promise<boolean> {
|
||||
export async function deleteProgressComment(ctx: Context): Promise<boolean> {
|
||||
const existingCommentId = getProgressCommentId();
|
||||
if (!existingCommentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ctx = await getMcpContext();
|
||||
|
||||
await ctx.octokit.rest.issues.deleteComment({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -321,16 +327,6 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// try to get payload from MCP context if available, otherwise use provided payload
|
||||
let resolvedPayload: Payload | undefined;
|
||||
try {
|
||||
const ctx = await getMcpContext();
|
||||
resolvedPayload = ctx.payload;
|
||||
} catch {
|
||||
// MCP context not initialized, use provided payload
|
||||
resolvedPayload = payload;
|
||||
}
|
||||
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
const workflowRunLink = runId
|
||||
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
|
||||
@@ -341,7 +337,7 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
|
||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
|
||||
// add footer if we have payload, otherwise use plain message
|
||||
const body = resolvedPayload ? addFooter(errorMessage, resolvedPayload) : errorMessage;
|
||||
const body = payload ? addFooter(errorMessage, payload) : errorMessage;
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
@@ -359,12 +355,13 @@ export const ReplyToReviewComment = type({
|
||||
),
|
||||
});
|
||||
|
||||
export const ReplyToReviewCommentTool = tool({
|
||||
export function ReplyToReviewCommentTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "reply_to_review_comment",
|
||||
description:
|
||||
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
|
||||
parameters: ReplyToReviewComment,
|
||||
execute: contextualize(async ({ pull_number, comment_id, body }, ctx) => {
|
||||
execute: execute(ctx, async ({ pull_number, comment_id, body }) => {
|
||||
const bodyWithFooter = addFooter(body, ctx.payload);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||
@@ -383,4 +380,5 @@ export const ReplyToReviewCommentTool = tool({
|
||||
in_reply_to_id: result.data.in_reply_to_id,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+4
-25
@@ -1,7 +1,6 @@
|
||||
import { type } from "arktype";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolResult } from "./shared.ts";
|
||||
import { tool } from "./shared.ts";
|
||||
import { handleToolError, handleToolSuccess, tool, type ToolResult } from "./shared.ts";
|
||||
|
||||
export const DebugShellCommand = type({});
|
||||
|
||||
@@ -13,33 +12,13 @@ export const DebugShellCommandTool = tool({
|
||||
execute: async (): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = $("git", ["status"]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
return handleToolSuccess({
|
||||
success: true,
|
||||
command: "git status",
|
||||
output: result.trim(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
return handleToolError(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+8
-11
@@ -1,6 +1,6 @@
|
||||
import { type } from "arktype";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import { handleToolError, handleToolSuccess, tool, type ToolResult } from "./shared.ts";
|
||||
|
||||
export const ListFiles = type({
|
||||
path: type.string
|
||||
@@ -8,12 +8,13 @@ export const ListFiles = type({
|
||||
.default("."),
|
||||
});
|
||||
|
||||
// static tool - doesn't need ctx, just runs git/find commands
|
||||
export const ListFilesTool = tool({
|
||||
name: "list_files",
|
||||
description:
|
||||
"List files in the repository using git ls-files. Useful for discovering the file structure and locating files.",
|
||||
parameters: ListFiles,
|
||||
execute: contextualize(async ({ path }) => {
|
||||
execute: async ({ path }: { path?: string }): Promise<ToolResult> => {
|
||||
try {
|
||||
// Use git ls-files to list tracked files
|
||||
// This respects .gitignore and gives a clean list of source files
|
||||
@@ -30,19 +31,15 @@ export const ListFilesTool = tool({
|
||||
[pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
|
||||
{ log: false }
|
||||
);
|
||||
return {
|
||||
return handleToolSuccess({
|
||||
files: findOutput.split("\n").filter((f) => f.trim() !== ""),
|
||||
method: "find",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return { files, method: "git" };
|
||||
return handleToolSuccess({ files, method: "git" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
error: `Failed to list files: ${errorMessage}`,
|
||||
hint: "Try using a specific path if the repository root is not the current directory.",
|
||||
};
|
||||
return handleToolError(error);
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
+11
-8
@@ -1,10 +1,11 @@
|
||||
import { type } from "arktype";
|
||||
import type { Context } from "../main.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { containsSecrets } from "../utils/secrets.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { contextualize, type McpContext, tool } from "./shared.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export function CreateBranchTool(ctx: McpContext) {
|
||||
export function CreateBranchTool(ctx: Context) {
|
||||
const defaultBranch = ctx.repo.default_branch || "main";
|
||||
const CreateBranch = type({
|
||||
branchName: type.string.describe(
|
||||
@@ -20,7 +21,7 @@ export function CreateBranchTool(ctx: McpContext) {
|
||||
description:
|
||||
"Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.",
|
||||
parameters: CreateBranch,
|
||||
execute: contextualize(async ({ branchName, baseBranch }) => {
|
||||
execute: execute(ctx, async ({ branchName, baseBranch }) => {
|
||||
// baseBranch should always be defined due to default, but TypeScript needs help
|
||||
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
|
||||
|
||||
@@ -68,12 +69,13 @@ export const CommitFiles = type({
|
||||
),
|
||||
});
|
||||
|
||||
export const CommitFilesTool = tool({
|
||||
export function CommitFilesTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "commit_files",
|
||||
description:
|
||||
"Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.",
|
||||
parameters: CommitFiles,
|
||||
execute: contextualize(async ({ message, files }) => {
|
||||
execute: execute(ctx, async ({ message, files }) => {
|
||||
// validate commit message for secrets
|
||||
if (containsSecrets(message)) {
|
||||
throw new Error(
|
||||
@@ -128,7 +130,8 @@ export const CommitFilesTool = tool({
|
||||
message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const PushBranch = type({
|
||||
branchName: type.string
|
||||
@@ -137,7 +140,7 @@ export const PushBranch = type({
|
||||
force: type.boolean.describe("Force push (use with caution)").default(false),
|
||||
});
|
||||
|
||||
export function PushBranchTool(ctx: McpContext) {
|
||||
export function PushBranchTool(ctx: Context) {
|
||||
const remote = ctx.pushRemote;
|
||||
|
||||
return tool({
|
||||
@@ -145,7 +148,7 @@ export function PushBranchTool(ctx: McpContext) {
|
||||
description:
|
||||
"Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
|
||||
parameters: PushBranch,
|
||||
execute: contextualize(async ({ branchName, force }) => {
|
||||
execute: execute(ctx, async ({ branchName, force }) => {
|
||||
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||
|
||||
log.info(`Pushing branch ${branch} to ${remote}`);
|
||||
|
||||
+7
-4
@@ -1,5 +1,6 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const Issue = type({
|
||||
title: type.string.describe("the title of the issue"),
|
||||
@@ -14,11 +15,12 @@ export const Issue = type({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const IssueTool = tool({
|
||||
export function IssueTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "create_issue",
|
||||
description: "Create a new GitHub issue",
|
||||
parameters: Issue,
|
||||
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => {
|
||||
execute: execute(ctx, async ({ title, body, labels, assignees }) => {
|
||||
const result = await ctx.octokit.rest.issues.create({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -39,4 +41,5 @@ export const IssueTool = tool({
|
||||
assignees: result.data.assignees?.map((assignee) => assignee.login),
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const GetIssueComments = type({
|
||||
issue_number: type.number.describe("The issue number to get comments for"),
|
||||
});
|
||||
|
||||
export const GetIssueCommentsTool = tool({
|
||||
export function GetIssueCommentsTool(ctx: Context) {
|
||||
return 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) => {
|
||||
execute: execute(ctx, async ({ issue_number }) => {
|
||||
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -32,4 +34,5 @@ export const GetIssueCommentsTool = tool({
|
||||
count: comments.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,16 +1,18 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const GetIssueEvents = type({
|
||||
issue_number: type.number.describe("The issue number to get events for"),
|
||||
});
|
||||
|
||||
export const GetIssueEventsTool = tool({
|
||||
export function GetIssueEventsTool(ctx: Context) {
|
||||
return 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) => {
|
||||
execute: execute(ctx, async ({ issue_number }) => {
|
||||
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -90,4 +92,5 @@ export const GetIssueEventsTool = tool({
|
||||
count: parsedEvents.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,15 +1,17 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const IssueInfo = type({
|
||||
issue_number: type.number.describe("The issue number to fetch"),
|
||||
});
|
||||
|
||||
export const IssueInfoTool = tool({
|
||||
export function IssueInfoTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "get_issue",
|
||||
description: "Retrieve GitHub issue information by issue number",
|
||||
parameters: IssueInfo,
|
||||
execute: contextualize(async ({ issue_number }, ctx) => {
|
||||
execute: execute(ctx, async ({ issue_number }) => {
|
||||
const issue = await ctx.octokit.rest.issues.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -52,4 +54,5 @@ export const IssueInfoTool = tool({
|
||||
hints,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,17 +1,19 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const AddLabelsParams = type({
|
||||
issue_number: type.number.describe("the issue or PR number to add labels to"),
|
||||
labels: type.string.array().atLeastLength(1).describe("array of label names to add"),
|
||||
});
|
||||
|
||||
export const AddLabelsTool = tool({
|
||||
export function AddLabelsTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "add_labels",
|
||||
description:
|
||||
"Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.",
|
||||
parameters: AddLabelsParams,
|
||||
execute: contextualize(async ({ issue_number, labels }, ctx) => {
|
||||
execute: execute(ctx, async ({ issue_number, labels }) => {
|
||||
const result = await ctx.octokit.rest.issues.addLabels({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -24,4 +26,5 @@ export const AddLabelsTool = tool({
|
||||
labels: result.data.map((label) => label.name),
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { type } from "arktype";
|
||||
import type { Context } from "../main.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { containsSecrets } from "../utils/secrets.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequest = type({
|
||||
title: type.string.describe("the title of the pull request"),
|
||||
@@ -10,11 +11,12 @@ export const PullRequest = type({
|
||||
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
|
||||
});
|
||||
|
||||
export const PullRequestTool = tool({
|
||||
export function PullRequestTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "create_pull_request",
|
||||
description: "Create a pull request from the current branch",
|
||||
parameters: PullRequest,
|
||||
execute: contextualize(async ({ title, body, base }, ctx) => {
|
||||
execute: execute(ctx, async ({ title, body, base }) => {
|
||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||
log.info(`Current branch: ${currentBranch}`);
|
||||
|
||||
@@ -54,4 +56,5 @@ export const PullRequestTool = tool({
|
||||
base: result.data.base.ref,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,16 +1,18 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const PullRequestInfo = type({
|
||||
pull_number: type.number.describe("The pull request number to fetch"),
|
||||
});
|
||||
|
||||
export const PullRequestInfoTool = tool({
|
||||
export function PullRequestInfoTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "get_pull_request",
|
||||
description:
|
||||
"Retrieve PR information (metadata only). PR branch is already checked out during setup.",
|
||||
parameters: PullRequestInfo,
|
||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
||||
execute: execute(ctx, async ({ pull_number }) => {
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -34,4 +36,5 @@ export const PullRequestInfoTool = tool({
|
||||
isFork,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+8
-5
@@ -1,8 +1,9 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import type { Context } from "../main.ts";
|
||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { deleteProgressComment } from "./comment.ts";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const Review = type({
|
||||
pull_number: type.number.describe("The pull request number to review"),
|
||||
@@ -39,14 +40,15 @@ export const Review = type({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ReviewTool = tool({
|
||||
export function ReviewTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "submit_pull_request_review",
|
||||
description:
|
||||
"Submit a review for an existing pull request. " +
|
||||
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
|
||||
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
|
||||
parameters: Review,
|
||||
execute: contextualize(async ({ pull_number, body, commit_id, comments = [] }, ctx) => {
|
||||
execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => {
|
||||
// get the PR to determine the head commit if commit_id not provided
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.owner,
|
||||
@@ -105,7 +107,7 @@ export const ReviewTool = tool({
|
||||
body: updatedBody,
|
||||
});
|
||||
|
||||
await deleteProgressComment();
|
||||
await deleteProgressComment(ctx);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -116,4 +118,5 @@ export const ReviewTool = tool({
|
||||
submitted_at: result.data.submitted_at,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+12
-7
@@ -1,5 +1,6 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
// graphql query to fetch all review threads with comments and replies
|
||||
// note: diffSide and startDiffSide are on the thread, not the comment
|
||||
@@ -85,12 +86,13 @@ export const GetReviewComments = type({
|
||||
review_id: type.number.describe("The review ID to get comments for"),
|
||||
});
|
||||
|
||||
export const GetReviewCommentsTool = tool({
|
||||
export function GetReviewCommentsTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "get_review_comments",
|
||||
description:
|
||||
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
|
||||
parameters: GetReviewComments,
|
||||
execute: contextualize(async ({ pull_number, review_id }, ctx) => {
|
||||
execute: execute(ctx, async ({ pull_number, review_id }) => {
|
||||
// fetch all review threads using graphql
|
||||
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
|
||||
owner: ctx.owner,
|
||||
@@ -180,18 +182,20 @@ export const GetReviewCommentsTool = tool({
|
||||
count: allComments.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const ListPullRequestReviews = type({
|
||||
pull_number: type.number.describe("The pull request number to list reviews for"),
|
||||
});
|
||||
|
||||
export const ListPullRequestReviewsTool = tool({
|
||||
export function ListPullRequestReviewsTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "list_pull_request_reviews",
|
||||
description:
|
||||
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
|
||||
parameters: ListPullRequestReviews,
|
||||
execute: contextualize(async ({ pull_number }, ctx) => {
|
||||
execute: execute(ctx, async ({ pull_number }) => {
|
||||
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
|
||||
owner: ctx.owner,
|
||||
repo: ctx.name,
|
||||
@@ -212,4 +216,5 @@ export const ListPullRequestReviewsTool = tool({
|
||||
count: reviews.length,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+7
-4
@@ -1,5 +1,6 @@
|
||||
import { type } from "arktype";
|
||||
import { contextualize, tool } from "./shared.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const SelectMode = type({
|
||||
modeName: type.string.describe(
|
||||
@@ -7,12 +8,13 @@ export const SelectMode = type({
|
||||
),
|
||||
});
|
||||
|
||||
export const SelectModeTool = tool({
|
||||
export function SelectModeTool(ctx: Context) {
|
||||
return tool({
|
||||
name: "select_mode",
|
||||
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 }, ctx) => {
|
||||
execute: execute(ctx, async ({ modeName }) => {
|
||||
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
|
||||
|
||||
if (!selectedMode) {
|
||||
@@ -29,4 +31,5 @@ export const SelectModeTool = tool({
|
||||
prompt: selectedMode.prompt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+23
-32
@@ -3,6 +3,7 @@ import "./arkConfig.ts";
|
||||
import { createServer } from "node:net";
|
||||
import { FastMCP, type Tool } from "fastmcp";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
CreateCommentTool,
|
||||
@@ -22,13 +23,7 @@ import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { ReviewTool } from "./review.ts";
|
||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import {
|
||||
addTools,
|
||||
getMcpContext,
|
||||
initMcpContext,
|
||||
isProgressCommentDisabled,
|
||||
type McpInitContext,
|
||||
} from "./shared.ts";
|
||||
import { addTools, isProgressCommentDisabled } from "./shared.ts";
|
||||
|
||||
/**
|
||||
* Find an available port starting from the given port
|
||||
@@ -63,46 +58,42 @@ async function findAvailablePort(startPort: number): Promise<number> {
|
||||
* Start the MCP HTTP server and return the URL and close function
|
||||
*/
|
||||
export async function startMcpHttpServer(
|
||||
state: McpInitContext
|
||||
ctx: Context
|
||||
): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
initMcpContext(state);
|
||||
|
||||
const server = new FastMCP({
|
||||
name: ghPullfrogMcpName,
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
// get context for dynamic tool creation
|
||||
const ctx = await getMcpContext();
|
||||
|
||||
// create all tools as factories, passing ctx
|
||||
const tools: Tool<any, any>[] = [
|
||||
SelectModeTool,
|
||||
CreateCommentTool,
|
||||
EditCommentTool,
|
||||
ReplyToReviewCommentTool,
|
||||
IssueTool,
|
||||
IssueInfoTool,
|
||||
GetIssueCommentsTool,
|
||||
GetIssueEventsTool,
|
||||
PullRequestTool,
|
||||
ReviewTool,
|
||||
PullRequestInfoTool,
|
||||
GetReviewCommentsTool,
|
||||
ListPullRequestReviewsTool,
|
||||
GetCheckSuiteLogsTool,
|
||||
SelectModeTool(ctx),
|
||||
CreateCommentTool(ctx),
|
||||
EditCommentTool(ctx),
|
||||
ReplyToReviewCommentTool(ctx),
|
||||
IssueTool(ctx),
|
||||
IssueInfoTool(ctx),
|
||||
GetIssueCommentsTool(ctx),
|
||||
GetIssueEventsTool(ctx),
|
||||
PullRequestTool(ctx),
|
||||
ReviewTool(ctx),
|
||||
PullRequestInfoTool(ctx),
|
||||
GetReviewCommentsTool(ctx),
|
||||
ListPullRequestReviewsTool(ctx),
|
||||
GetCheckSuiteLogsTool(ctx),
|
||||
DebugShellCommandTool,
|
||||
AddLabelsTool,
|
||||
AddLabelsTool(ctx),
|
||||
CreateBranchTool(ctx),
|
||||
CommitFilesTool,
|
||||
CommitFilesTool(ctx),
|
||||
PushBranchTool(ctx),
|
||||
];
|
||||
|
||||
// only include ReportProgressTool if progress comment is not disabled
|
||||
if (!isProgressCommentDisabled()) {
|
||||
tools.push(ReportProgressTool);
|
||||
if (!isProgressCommentDisabled(ctx)) {
|
||||
tools.push(ReportProgressTool(ctx));
|
||||
}
|
||||
|
||||
addTools(server, tools);
|
||||
addTools(ctx, server, tools);
|
||||
|
||||
const port = await findAvailablePort(3764);
|
||||
const host = "127.0.0.1";
|
||||
|
||||
+54
-105
@@ -1,64 +1,60 @@
|
||||
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 McpInitContext {
|
||||
payload: Payload;
|
||||
modes: Mode[];
|
||||
agentName?: string;
|
||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||
pushRemote: string;
|
||||
}
|
||||
|
||||
let mcpInitContext: McpInitContext | undefined;
|
||||
let cachedMcpContext: McpContext | undefined;
|
||||
|
||||
// this must be called on mcp server initialization
|
||||
export function initMcpContext(state: McpInitContext): void {
|
||||
mcpInitContext = state;
|
||||
// clear cached context when reinitializing
|
||||
cachedMcpContext = undefined;
|
||||
}
|
||||
|
||||
export interface McpContext extends McpInitContext, RepoContext {
|
||||
octokit: Octokit;
|
||||
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
|
||||
}
|
||||
|
||||
export async function getMcpContext(): Promise<McpContext> {
|
||||
if (!mcpInitContext) {
|
||||
throw new Error("MCP context not initialized. Call initializeMcpContext first.");
|
||||
}
|
||||
|
||||
// return cached context if available
|
||||
if (cachedMcpContext) {
|
||||
return cachedMcpContext;
|
||||
}
|
||||
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = new Octokit({
|
||||
auth: getGitHubInstallationToken(),
|
||||
});
|
||||
|
||||
cachedMcpContext = {
|
||||
...mcpInitContext,
|
||||
...repoContext,
|
||||
octokit,
|
||||
repo: mcpInitContext.repo,
|
||||
};
|
||||
|
||||
return cachedMcpContext;
|
||||
}
|
||||
|
||||
export function isProgressCommentDisabled(): boolean {
|
||||
return mcpInitContext?.payload.disableProgressComment === true;
|
||||
}
|
||||
import type { Context } from "../main.ts";
|
||||
|
||||
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
|
||||
|
||||
export interface ToolResult {
|
||||
content: {
|
||||
type: "text";
|
||||
text: string;
|
||||
}[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const handleToolError = (error: unknown): ToolResult => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to wrap a tool execute function with error handling.
|
||||
* Captures ctx in closure so tools don't need to handle try/catch.
|
||||
*/
|
||||
export const execute = <T>(ctx: Context, fn: (params: T) => Promise<Record<string, any>>) => {
|
||||
return async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const result = await fn(params);
|
||||
return handleToolSuccess(result);
|
||||
} catch (error) {
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function isProgressCommentDisabled(ctx: Context): boolean {
|
||||
return ctx.payload.disableProgressComment === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle
|
||||
* - Removes $schema field (causes "no schema with key or ref" errors)
|
||||
@@ -180,11 +176,10 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
|
||||
} as T;
|
||||
}
|
||||
|
||||
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
export const addTools = (ctx: Context, server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
// sanitize schemas for gemini agent and opencode (when using Google API)
|
||||
// both have issues with draft-2020-12 schemas and any_of enum constructs
|
||||
const shouldSanitize =
|
||||
mcpInitContext?.agentName === "gemini" || mcpInitContext?.agentName === "opencode";
|
||||
const shouldSanitize = ctx.agentName === "gemini" || ctx.agentName === "opencode";
|
||||
|
||||
for (const tool of tools) {
|
||||
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
|
||||
@@ -192,49 +187,3 @@ export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
}
|
||||
return server;
|
||||
};
|
||||
|
||||
export const contextualize = <T>(
|
||||
executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>
|
||||
) => {
|
||||
return async (params: T): Promise<ToolResult> => {
|
||||
try {
|
||||
const ctx = await getMcpContext();
|
||||
const result = await executor(params, ctx);
|
||||
return handleToolSuccess(result);
|
||||
} catch (error) {
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export interface ToolResult {
|
||||
content: {
|
||||
type: "text";
|
||||
text: string;
|
||||
}[];
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
const handleToolSuccess = (data: Record<string, any>): ToolResult => {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const handleToolError = (error: unknown): ToolResult => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: ${errorMessage}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -48,16 +48,9 @@ export async function reportErrorToComment({
|
||||
}
|
||||
}
|
||||
|
||||
// if no comment ID available, try using reportProgress (requires MCP context)
|
||||
// if no comment ID available, can't update comment
|
||||
if (!commentId) {
|
||||
try {
|
||||
const { reportProgress } = await import("../mcp/comment.ts");
|
||||
await reportProgress({ body: formattedError });
|
||||
return;
|
||||
} catch {
|
||||
// MCP context not available, can't create/update comment
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// update comment directly using GitHub API
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import type { MainContext } from "../main.ts";
|
||||
import type { Context } from "../main.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { $ } from "./shell.ts";
|
||||
|
||||
@@ -72,7 +72,7 @@ export type SetupGitResult = {
|
||||
* For PR events, gh pr checkout sets up proper remote tracking.
|
||||
* Returns the remote to push to (detected from branch tracking after checkout).
|
||||
*/
|
||||
export function setupGit(ctx: MainContext): SetupGitResult {
|
||||
export function setupGit(ctx: Context): SetupGitResult {
|
||||
const { githubInstallationToken, payload } = ctx;
|
||||
const repoDir = process.cwd();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user