Compare commits

...

5 Commits

Author SHA1 Message Date
Colin McDonnell b833cdd4af 0.0.141 2025-12-15 23:22:30 -08:00
Colin McDonnell 333ad29965 0.0.140 2025-12-15 23:04:52 -08:00
Colin McDonnell 26336d0ac2 Tool factories 2025-12-15 23:04:20 -08:00
Colin McDonnell 0fced1dfa6 Clean up init 2025-12-15 22:21:47 -08:00
Colin McDonnell 6f96458e2d Fix graphql query 2025-12-15 21:42:43 -08:00
22 changed files with 14699 additions and 15986 deletions
+13615 -14921
View File
File diff suppressed because it is too large Load Diff
+101 -80
View File
@@ -2,6 +2,7 @@ import { mkdtemp } 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";
import { Octokit } from "@octokit/rest";
import { encode as toonEncode } from "@toon-format/toon"; import { encode as toonEncode } from "@toon-format/toon";
import { type } from "arktype"; import { type } from "arktype";
import { agents } from "./agents/index.ts"; import { agents } from "./agents/index.ts";
@@ -11,18 +12,17 @@ import { agentsManifest } from "./external.ts";
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts"; import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
import { createMcpConfigs } from "./mcp/config.ts"; import { createMcpConfigs } from "./mcp/config.ts";
import { startMcpHttpServer } from "./mcp/server.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 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 { log } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts"; import { reportErrorToComment } from "./utils/errorReport.ts";
import { import {
parseRepoContext, parseRepoContext,
type RepoContext,
revokeGitHubInstallationToken, revokeGitHubInstallationToken,
setupGitHubInstallationToken, setupGitHubInstallationToken,
} from "./utils/github.ts"; } from "./utils/github.ts";
import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts"; import { setupGit, setupGitConfig } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts"; import { Timer } from "./utils/timer.ts";
// runtime validation using agents (needed for ArkType) // runtime validation using agents (needed for ArkType)
@@ -61,19 +61,16 @@ export async function main(inputs: Inputs): Promise<MainResult> {
payload = parsePayload(inputs); payload = parsePayload(inputs);
const partialCtx = await initializeContext(inputs, payload); const partialCtx = await initializeContext(inputs, payload);
const ctx = partialCtx as MainContext; const ctx = partialCtx as Context;
timer.checkpoint("initializeContext"); timer.checkpoint("initializeContext");
setupGitAuth({ const { pushRemote } = setupGit(ctx);
githubInstallationToken: ctx.githubInstallationToken, ctx.pushRemote = pushRemote;
repoContext: ctx.repoContext, timer.checkpoint("setupGit");
});
await setupTempDirectory(ctx); await setupTempDirectory(ctx);
timer.checkpoint("setupTempDirectory"); timer.checkpoint("setupTempDirectory");
setupGitBranch(ctx.payload);
await startMcpServer(ctx); await startMcpServer(ctx);
mcpServerClose = ctx.mcpServerClose; mcpServerClose = ctx.mcpServerClose;
timer.checkpoint("startMcpServer"); timer.checkpoint("startMcpServer");
@@ -84,7 +81,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
Array.isArray(ctx.payload.event.comment_ids) && Array.isArray(ctx.payload.event.comment_ids) &&
ctx.payload.event.comment_ids.length === 0 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.`, 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 }; return { success: true };
@@ -157,36 +154,26 @@ function getAllPossibleKeyNames(): string[] {
/** /**
* Throw an error for missing API key with helpful message linking to repo settings * Throw an error for missing API key with helpful message linking to repo settings
*/ */
async function throwMissingApiKeyError({ async function throwMissingApiKeyError(ctx: Context): Promise<never> {
agent,
repoContext,
}: {
agent: (typeof agents)[AgentName] | null;
repoContext: RepoContext;
}): Promise<never> {
const apiUrl = process.env.API_URL || "https://pullfrog.com"; 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`; const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
// for OpenCode, use a generic message since it accepts any API key // 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; let secretNameList: string;
if (isOpenCode) { if (isOpenCode) {
secretNameList = secretNameList =
"any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)"; "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
} else { } else {
const inputKeys = agent?.apiKeyNames || getAllPossibleKeyNames(); const inputKeys = ctx.agent?.apiKeyNames || getAllPossibleKeyNames();
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``);
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
} }
let message = `${ const message = `Pullfrog is configured to use ${ctx.agent.displayName}, but the associated API key was not provided.
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.`
}
To fix this, add the required secret to your GitHub repository: To fix this, add the required secret to your GitHub repository:
@@ -194,28 +181,45 @@ To fix this, add the required secret to your GitHub repository:
2. Click "New repository secret" 2. Click "New repository secret"
3. Set the name to ${secretNameList} 3. Set the name to ${secretNameList}
4. Set the value to your API key 4. Set the value to your API key
5. Click "Add secret"`; 5. Click "Add secret"
if (agent === null) { Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
message += `\n\nAlternatively, configure Pullfrog to use an agent at ${settingsUrl}`;
}
// report to comment if MCP context is available (server has started) // report to comment if MCP context is available (server has started)
await reportErrorToComment({ error: message }); await reportErrorToComment({ error: message });
throw new Error(message); throw new Error(message);
} }
interface MainContext { export interface Context {
// flattened from RepoContext
owner: string;
name: string;
// core fields
inputs: Inputs; inputs: Inputs;
githubInstallationToken: string; payload: Payload;
repoContext: RepoContext; repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
agentName: AgentName; agentName: AgentName;
agent: (typeof agents)[AgentName]; agent: (typeof agents)[AgentName];
githubInstallationToken: string;
octokit: Octokit;
// repo settings from Pullfrog API
repoSettings: RepoSettings;
// modes for MCP tools
modes: Mode[];
// setup fields
pushRemote: string;
sharedTempDir: string; sharedTempDir: string;
payload: Payload;
// mcp fields
mcpServerUrl: string; mcpServerUrl: string;
mcpServerClose: () => Promise<void>; mcpServerClose: () => Promise<void>;
mcpServers: ReturnType<typeof createMcpConfigs>; mcpServers: ReturnType<typeof createMcpConfigs>;
// agent fields
cliPath: string; cliPath: string;
apiKey: string; apiKey: string;
apiKeys: Record<string, string>; apiKeys: Record<string, string>;
@@ -226,8 +230,14 @@ async function initializeContext(
payload: Payload payload: Payload
): Promise< ): Promise<
Omit< Omit<
MainContext, Context,
"mcpServerUrl" | "mcpServerClose" | "mcpServers" | "cliPath" | "apiKey" | "apiKeys" | "mcpServerUrl"
| "mcpServerClose"
| "mcpServers"
| "cliPath"
| "apiKey"
| "apiKeys"
| "pushRemote"
> >
> { > {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
@@ -235,39 +245,65 @@ async function initializeContext(
setupGitConfig(); setupGitConfig();
const githubInstallationToken = await setupGitHubInstallationToken(); const githubInstallationToken = await setupGitHubInstallationToken();
const repoContext = parseRepoContext(); const { owner, name } = parseRepoContext();
// create octokit instance
const octokit = new Octokit({
auth: githubInstallationToken,
});
// fetch repo data
const response = await octokit.repos.get({
owner,
repo: name,
});
const repo = response.data;
// fetch repo settings
const repoSettings = await fetchRepoSettings({
token: githubInstallationToken,
repoContext: { owner, name },
});
// resolve agent and update payload with resolved agent name // resolve agent and update payload with resolved agent name
const { agentName, agent } = await resolveAgent( const { agentName, agent } = resolveAgent({
inputs, inputs,
payload, payload,
githubInstallationToken, repoSettings,
repoContext });
);
const resolvedPayload = { ...payload, agent: agentName }; const resolvedPayload = { ...payload, agent: agentName };
// compute modes from defaults + payload overrides
const computedModes = [
...getModes({ disableProgressComment: resolvedPayload.disableProgressComment }),
...(resolvedPayload.modes || []),
];
return { return {
owner,
name,
inputs, inputs,
githubInstallationToken, githubInstallationToken,
repoContext, octokit,
repo,
agentName, agentName,
agent, agent,
payload: resolvedPayload, payload: resolvedPayload,
repoSettings,
modes: computedModes,
sharedTempDir: "", sharedTempDir: "",
}; };
} }
async function resolveAgent( function resolveAgent({
inputs: Inputs, inputs,
payload: Payload, payload,
githubInstallationToken: string, repoSettings,
repoContext: RepoContext }: {
): Promise<{ agentName: AgentName; agent: (typeof agents)[AgentName] }> { inputs: Inputs;
const repoSettings = await fetchRepoSettings({ payload: Payload;
token: githubInstallationToken, repoSettings: RepoSettings;
repoContext, }): { agentName: AgentName; agent: (typeof agents)[AgentName] } {
});
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined; const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null; const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
@@ -312,10 +348,8 @@ async function resolveAgent(
log.debug(`Available agents: ${availableAgentNames || "none"}`); log.debug(`Available agents: ${availableAgentNames || "none"}`);
if (availableAgents.length === 0) { if (availableAgents.length === 0) {
await throwMissingApiKeyError({ // this will be caught and reported later in validateApiKey
agent: null, throw new Error("no agents available - missing API keys");
repoContext,
});
} }
const agentName = availableAgents[0].name; const agentName = availableAgents[0].name;
@@ -324,9 +358,7 @@ async function resolveAgent(
return { agentName, agent }; return { agentName, agent };
} }
async function setupTempDirectory( async function setupTempDirectory(ctx: Context): Promise<void> {
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
): 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;
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
@@ -352,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 // 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 // this must be set BEFORE starting the MCP server so comment.ts can read it
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID;
@@ -364,26 +396,18 @@ async function startMcpServer(ctx: MainContext): Promise<void> {
} }
} }
const allModes = [ const { url, close } = await startMcpHttpServer(ctx);
...getModes({ disableProgressComment: ctx.payload.disableProgressComment }),
...(ctx.payload.modes || []),
];
const { url, close } = await startMcpHttpServer({
payload: ctx.payload,
modes: allModes,
agentName: ctx.agentName,
});
ctx.mcpServerUrl = url; ctx.mcpServerUrl = url;
ctx.mcpServerClose = close; ctx.mcpServerClose = close;
log.info(`🚀 MCP server started at ${url}`); log.info(`🚀 MCP server started at ${url}`);
} }
function setupMcpServers(ctx: MainContext): void { function setupMcpServers(ctx: Context): void {
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl); ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
log.debug(`📋 MCP Config: ${JSON.stringify(ctx.mcpServers, null, 2)}`); 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 // gemini is the only agent that needs githubInstallationToken for install
if (ctx.agentName === "gemini") { if (ctx.agentName === "gemini") {
ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken); ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken);
@@ -392,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 // collect all matching API keys for this agent
const apiKeys: Record<string, string> = {}; const apiKeys: Record<string, string> = {};
for (const inputKey of ctx.agent.apiKeyNames) { for (const inputKey of ctx.agent.apiKeyNames) {
@@ -414,10 +438,7 @@ async function validateApiKey(ctx: MainContext): Promise<void> {
} }
if (Object.keys(apiKeys).length === 0) { if (Object.keys(apiKeys).length === 0) {
await throwMissingApiKeyError({ await throwMissingApiKeyError(ctx);
agent: ctx.agent,
repoContext: ctx.repoContext,
});
// unreachable - throwMissingApiKeyError always throws // unreachable - throwMissingApiKeyError always throws
return; return;
} }
@@ -427,7 +448,7 @@ async function validateApiKey(ctx: MainContext): Promise<void> {
ctx.apiKeys = apiKeys; ctx.apiKeys = apiKeys;
} }
async function runAgent(ctx: MainContext): Promise<AgentResult> { async function runAgent(ctx: Context): Promise<AgentResult> {
log.info(`Running ${ctx.agentName}...`); log.info(`Running ${ctx.agentName}...`);
// strip context from event // strip context from event
const { context: _context, ...eventWithoutContext } = ctx.payload.event; const { context: _context, ...eventWithoutContext } = ctx.payload.event;
+84 -81
View File
@@ -1,94 +1,97 @@
import { type } from "arktype"; 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({ export const GetCheckSuiteLogs = type({
check_suite_id: type.number.describe("the id from check_suite.id"), check_suite_id: type.number.describe("the id from check_suite.id"),
}); });
export const GetCheckSuiteLogsTool = tool({ export function GetCheckSuiteLogsTool(ctx: Context) {
name: "get_check_suite_logs", return tool({
description: name: "get_check_suite_logs",
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", description:
parameters: GetCheckSuiteLogs, "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
execute: contextualize(async ({ check_suite_id }, ctx) => { parameters: GetCheckSuiteLogs,
// get workflow runs for this specific check suite execute: execute(ctx, async ({ check_suite_id }) => {
const workflowRuns = await ctx.octokit.paginate( // get workflow runs for this specific check suite
ctx.octokit.rest.actions.listWorkflowRunsForRepo, const workflowRuns = await ctx.octokit.paginate(
{ ctx.octokit.rest.actions.listWorkflowRunsForRepo,
owner: ctx.owner, {
repo: ctx.name,
check_suite_id,
per_page: 100,
}
);
const failedRuns = workflowRuns.filter((run) => run.conclusion === "failure");
if (failedRuns.length === 0) {
return {
check_suite_id,
message: "no failed workflow runs found for this check suite",
workflow_runs: [],
};
}
// get logs for each failed run
const logsForRuns = await Promise.all(
failedRuns.map(async (run) => {
const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
run_id: run.id, check_suite_id,
}); per_page: 100,
}
);
const jobLogs = await Promise.all( const failedRuns = workflowRuns.filter((run) => run.conclusion === "failure");
jobs.map(async (job) => {
try {
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
owner: ctx.owner,
repo: ctx.name,
job_id: job.id,
});
const logsUrl = logsResponse.url;
const logsText = await fetch(logsUrl).then((r) => r.text());
return {
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
started_at: job.started_at,
completed_at: job.completed_at,
logs: logsText,
};
} catch (error) {
return {
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
started_at: job.started_at,
completed_at: job.completed_at,
error: `failed to fetch logs: ${error}`,
};
}
})
);
if (failedRuns.length === 0) {
return { return {
workflow_run_id: run.id, check_suite_id,
workflow_name: run.name, message: "no failed workflow runs found for this check suite",
html_url: run.html_url, workflow_runs: [],
conclusion: run.conclusion,
jobs: jobLogs,
}; };
}) }
);
return { // get logs for each failed run
check_suite_id, const logsForRuns = await Promise.all(
workflow_runs: logsForRuns, failedRuns.map(async (run) => {
}; const jobs = await ctx.octokit.paginate(ctx.octokit.rest.actions.listJobsForWorkflowRun, {
}), owner: ctx.owner,
}); repo: ctx.name,
run_id: run.id,
});
const jobLogs = await Promise.all(
jobs.map(async (job) => {
try {
const logsResponse = await ctx.octokit.rest.actions.downloadJobLogsForWorkflowRun({
owner: ctx.owner,
repo: ctx.name,
job_id: job.id,
});
const logsUrl = logsResponse.url;
const logsText = await fetch(logsUrl).then((r) => r.text());
return {
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
started_at: job.started_at,
completed_at: job.completed_at,
logs: logsText,
};
} catch (error) {
return {
job_id: job.id,
job_name: job.name,
status: job.status,
conclusion: job.conclusion,
started_at: job.started_at,
completed_at: job.completed_at,
error: `failed to fetch logs: ${error}`,
};
}
})
);
return {
workflow_run_id: run.id,
workflow_name: run.name,
html_url: run.html_url,
conclusion: run.conclusion,
jobs: jobLogs,
};
})
);
return {
check_suite_id,
workflow_runs: logsForRuns,
};
}),
});
}
+102 -104
View File
@@ -2,10 +2,11 @@ import { Octokit } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import type { Payload } from "../external.ts"; import type { Payload } from "../external.ts";
import { agentsManifest } from "../external.ts"; import { agentsManifest } from "../external.ts";
import type { Context } from "../main.ts";
import { fetchWorkflowRunInfo } from "../utils/api.ts"; import { fetchWorkflowRunInfo } from "../utils/api.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.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. * The prefix text for the initial "leaping into action" comment.
@@ -42,58 +43,62 @@ export const Comment = type({
body: type.string.describe("the comment body content"), body: type.string.describe("the comment body content"),
}); });
export const CreateCommentTool = tool({ export function CreateCommentTool(ctx: Context) {
name: "create_issue_comment", return tool({
description: name: "create_issue_comment",
"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.", description:
parameters: Comment, "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.",
execute: contextualize(async ({ issueNumber, body }, ctx) => { parameters: Comment,
const bodyWithFooter = addFooter(body, ctx.payload); execute: execute(ctx, async ({ issueNumber, body }) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
issue_number: issueNumber, issue_number: issueNumber,
body: bodyWithFooter, body: bodyWithFooter,
}); });
return { return {
success: true, success: true,
commentId: result.data.id, commentId: result.data.id,
url: result.data.html_url, url: result.data.html_url,
body: result.data.body, body: result.data.body,
}; };
}), }),
}); });
}
export const EditComment = type({ export const EditComment = type({
commentId: type.number.describe("the ID of the comment to edit"), commentId: type.number.describe("the ID of the comment to edit"),
body: type.string.describe("the new comment body content"), body: type.string.describe("the new comment body content"),
}); });
export const EditCommentTool = tool({ export function EditCommentTool(ctx: Context) {
name: "edit_issue_comment", return tool({
description: "Edit a GitHub issue comment by its ID", name: "edit_issue_comment",
parameters: EditComment, description: "Edit a GitHub issue comment by its ID",
execute: contextualize(async ({ commentId, body }, ctx) => { parameters: EditComment,
const bodyWithFooter = addFooter(body, ctx.payload); execute: execute(ctx, async ({ commentId, body }) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
comment_id: commentId, comment_id: commentId,
body: bodyWithFooter, body: bodyWithFooter,
}); });
return { return {
success: true, success: true,
commentId: result.data.id, commentId: result.data.id,
url: result.data.html_url, url: result.data.html_url,
body: result.data.body, body: result.data.body,
updatedAt: result.data.updated_at, updatedAt: result.data.updated_at,
}; };
}), }),
}); });
}
/** /**
* Get progress comment ID from environment variable. * 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. * Can be called directly without going through the MCP tool interface.
* Returns result data if successful, undefined if comment cannot be created. * 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; commentId: number;
url: string; url: string;
@@ -150,8 +158,6 @@ export async function reportProgress({ body }: { body: string }): Promise<
} }
| undefined | undefined
> { > {
const ctx = getMcpContext();
const bodyWithFooter = addFooter(body, ctx.payload); const bodyWithFooter = addFooter(body, ctx.payload);
const existingCommentId = getProgressCommentId(); const existingCommentId = getProgressCommentId();
@@ -200,30 +206,32 @@ export async function reportProgress({ body }: { body: string }): Promise<
}; };
} }
export const ReportProgressTool = tool({ export function ReportProgressTool(ctx: Context) {
name: "report_progress", return tool({
description: name: "report_progress",
"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.", description:
parameters: ReportProgress, "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.",
execute: contextualize(async ({ body }) => { parameters: ReportProgress,
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
// this happens for workflow_dispatch events or when there's no associated issue/PR
return {
success: false,
message:
"cannot create progress comment: no issue_number found in the payload event. this may occur for workflow_dispatch events or when there is no associated issue/PR. if you need to comment on a specific issue or PR, use create_issue_comment with an explicit issueNumber.",
};
}
if (!result) {
// gracefully handle case where no comment can be created
// this happens for workflow_dispatch events or when there's no associated issue/PR
return { return {
success: false, success: true,
message: ...result,
"cannot create progress comment: no issue_number found in the payload event. this may occur for workflow_dispatch events or when there is no associated issue/PR. if you need to comment on a specific issue or PR, use create_issue_comment with an explicit issueNumber.",
}; };
} }),
});
return { }
success: true,
...result,
};
}),
});
/** /**
* Check if the progress comment was updated during execution * Check if the progress comment was updated during execution
@@ -236,14 +244,12 @@ export function wasProgressCommentUpdated(): boolean {
* Delete the progress comment if it exists. * Delete the progress comment if it exists.
* Used after submitting a PR review since the review body contains all necessary info. * 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(); const existingCommentId = getProgressCommentId();
if (!existingCommentId) { if (!existingCommentId) {
return false; return false;
} }
const ctx = getMcpContext();
await ctx.octokit.rest.issues.deleteComment({ await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -321,16 +327,6 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
return; return;
} }
// try to get payload from MCP context if available, otherwise use provided payload
let resolvedPayload: Payload | undefined;
try {
const ctx = getMcpContext();
resolvedPayload = ctx.payload;
} catch {
// MCP context not initialized, use provided payload
resolvedPayload = payload;
}
const runId = process.env.GITHUB_RUN_ID; const runId = process.env.GITHUB_RUN_ID;
const workflowRunLink = runId const workflowRunLink = runId
? `[workflow](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${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.`; 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 // 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({ await octokit.rest.issues.updateComment({
owner: repoContext.owner, owner: repoContext.owner,
@@ -359,28 +355,30 @@ export const ReplyToReviewComment = type({
), ),
}); });
export const ReplyToReviewCommentTool = tool({ export function ReplyToReviewCommentTool(ctx: Context) {
name: "reply_to_review_comment", return tool({
description: name: "reply_to_review_comment",
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).", description:
parameters: ReplyToReviewComment, "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
execute: contextualize(async ({ pull_number, comment_id, body }, ctx) => { parameters: ReplyToReviewComment,
const bodyWithFooter = addFooter(body, ctx.payload); execute: execute(ctx, async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = addFooter(body, ctx.payload);
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
pull_number, pull_number,
comment_id, comment_id,
body: bodyWithFooter, body: bodyWithFooter,
}); });
return { return {
success: true, success: true,
commentId: result.data.id, commentId: result.data.id,
url: result.data.html_url, url: result.data.html_url,
body: result.data.body, body: result.data.body,
in_reply_to_id: result.data.in_reply_to_id, in_reply_to_id: result.data.in_reply_to_id,
}; };
}), }),
}); });
}
+7 -28
View File
@@ -1,7 +1,6 @@
import { type } from "arktype"; import { type } from "arktype";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import type { ToolResult } from "./shared.ts"; import { handleToolError, handleToolSuccess, tool, type ToolResult } from "./shared.ts";
import { tool } from "./shared.ts";
export const DebugShellCommand = type({}); export const DebugShellCommand = type({});
@@ -13,33 +12,13 @@ export const DebugShellCommandTool = tool({
execute: async (): Promise<ToolResult> => { execute: async (): Promise<ToolResult> => {
try { try {
const result = $("git", ["status"]); const result = $("git", ["status"]);
return handleToolSuccess({
return { success: true,
content: [ command: "git status",
{ output: result.trim(),
type: "text", });
text: JSON.stringify(
{
success: true,
command: "git status",
output: result.trim(),
},
null,
2
),
},
],
};
} catch (error) { } catch (error) {
return { return handleToolError(error);
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
} }
}, },
}); });
+8 -11
View File
@@ -1,6 +1,6 @@
import { type } from "arktype"; import { type } from "arktype";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts"; import { handleToolError, handleToolSuccess, tool, type ToolResult } from "./shared.ts";
export const ListFiles = type({ export const ListFiles = type({
path: type.string path: type.string
@@ -8,12 +8,13 @@ export const ListFiles = type({
.default("."), .default("."),
}); });
// static tool - doesn't need ctx, just runs git/find commands
export const ListFilesTool = tool({ export const ListFilesTool = tool({
name: "list_files", name: "list_files",
description: description:
"List files in the repository using git ls-files. Useful for discovering the file structure and locating files.", "List files in the repository using git ls-files. Useful for discovering the file structure and locating files.",
parameters: ListFiles, parameters: ListFiles,
execute: contextualize(async ({ path }) => { execute: async ({ path }: { path?: string }): Promise<ToolResult> => {
try { try {
// Use git ls-files to list tracked files // Use git ls-files to list tracked files
// This respects .gitignore and gives a clean list of source 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"], [pathStr, "-maxdepth", "3", "-not", "-path", "*/.*", "-type", "f"],
{ log: false } { log: false }
); );
return { return handleToolSuccess({
files: findOutput.split("\n").filter((f) => f.trim() !== ""), files: findOutput.split("\n").filter((f) => f.trim() !== ""),
method: "find", method: "find",
}; });
} }
return { files, method: "git" }; return handleToolSuccess({ files, method: "git" });
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error); return handleToolError(error);
return {
error: `Failed to list files: ${errorMessage}`,
hint: "Try using a specific path if the repository root is not the current directory.",
};
} }
}), },
}); });
+130 -113
View File
@@ -1,55 +1,65 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts"; import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const CreateBranch = type({ export function CreateBranchTool(ctx: Context) {
branchName: type.string.describe( const defaultBranch = ctx.repo.default_branch || "main";
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
),
baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main"),
});
export const CreateBranchTool = tool({ const CreateBranch = type({
name: "create_branch", branchName: type.string.describe(
description: "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
"Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", ),
parameters: CreateBranch, baseBranch: type.string
execute: contextualize(async ({ branchName, baseBranch }) => { .describe(`The base branch to create from (defaults to '${defaultBranch}')`)
// validate branch name for secrets .default(defaultBranch),
if (containsSecrets(branchName)) { });
throw new Error(
"Branch creation blocked: secrets detected in branch name. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
);
}
log.info(`Creating branch ${branchName} from ${baseBranch}`); return tool({
name: "create_branch",
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: 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";
// fetch base branch to ensure we're up to date // validate branch name for secrets
$("git", ["fetch", "origin", baseBranch, "--depth=1"]); if (containsSecrets(branchName)) {
throw new Error(
"Branch creation blocked: secrets detected in branch name. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
);
}
// checkout base branch, ensuring it matches the remote version log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`);
// -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
// create and checkout new branch // fetch base branch to ensure we're up to date
$("git", ["checkout", "-b", branchName]); $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
// push branch to remote (set upstream) // checkout base branch, ensuring it matches the remote version
$("git", ["push", "-u", "origin", branchName]); // -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]);
log.info(`Successfully created and pushed branch ${branchName}`); // create and checkout new branch
$("git", ["checkout", "-b", branchName]);
return { // push branch to remote (set upstream)
success: true, $("git", ["push", "-u", "origin", branchName]);
branchName,
baseBranch, log.info(`Successfully created and pushed branch ${branchName}`);
message: `Branch ${branchName} created from ${baseBranch} and pushed to remote`,
}; return {
}), success: true,
}); branchName,
baseBranch: resolvedBaseBranch,
message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote`,
};
}),
});
}
export const CommitFiles = type({ export const CommitFiles = type({
message: type.string.describe("The commit message"), message: type.string.describe("The commit message"),
@@ -60,67 +70,69 @@ export const CommitFiles = type({
), ),
}); });
export const CommitFilesTool = tool({ export function CommitFilesTool(ctx: Context) {
name: "commit_files", return tool({
description: name: "commit_files",
"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.", description:
parameters: CommitFiles, "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.",
execute: contextualize(async ({ message, files }) => { parameters: CommitFiles,
// validate commit message for secrets execute: execute(ctx, async ({ message, files }) => {
if (containsSecrets(message)) { // validate commit message for secrets
throw new Error( if (containsSecrets(message)) {
"Commit blocked: secrets detected in commit message. " + throw new Error(
"Please remove any sensitive information (API keys, tokens, passwords) before committing." "Commit blocked: secrets detected in commit message. " +
); "Please remove any sensitive information (API keys, tokens, passwords) before committing."
} );
}
// validate files for secrets if provided // validate files for secrets if provided
if (files.length > 0) { if (files.length > 0) {
for (const file of files) { for (const file of files) {
try { try {
// try to read file content - if it exists, check for secrets // try to read file content - if it exists, check for secrets
const content = $("cat", [file], { log: false }); const content = $("cat", [file], { log: false });
if (containsSecrets(content)) { if (containsSecrets(content)) {
throw new Error( throw new Error(
`Commit blocked: secrets detected in file ${file}. ` + `Commit blocked: secrets detected in file ${file}. ` +
"Please remove any sensitive information (API keys, tokens, passwords) before committing." "Please remove any sensitive information (API keys, tokens, passwords) before committing."
); );
}
} catch (error) {
// if error is about secrets, re-throw it
if (error instanceof Error && error.message.includes("Commit blocked")) {
throw error;
}
// if file doesn't exist (cat fails), that's ok - it will be created by git add
// other errors are also ok - git add will handle them
} }
} catch (error) {
// if error is about secrets, re-throw it
if (error instanceof Error && error.message.includes("Commit blocked")) {
throw error;
}
// if file doesn't exist (cat fails), that's ok - it will be created by git add
// other errors are also ok - git add will handle them
} }
} }
}
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.info(`Committing files on branch ${currentBranch}`); log.info(`Committing files on branch ${currentBranch}`);
// stage files if provided, otherwise stage all changes // stage files if provided, otherwise stage all changes
if (files.length > 0) { if (files.length > 0) {
$("git", ["add", ...files]); $("git", ["add", ...files]);
} else { } else {
$("git", ["add", "."]); $("git", ["add", "."]);
} }
// commit with message // commit with message
$("git", ["commit", "-m", message]); $("git", ["commit", "-m", message]);
const commitSha = $("git", ["rev-parse", "HEAD"], { log: false }); const commitSha = $("git", ["rev-parse", "HEAD"], { log: false });
log.info(`Successfully committed: ${commitSha.substring(0, 7)}`); log.info(`Successfully committed: ${commitSha.substring(0, 7)}`);
return { return {
success: true, success: true,
commitSha, commitSha,
branch: currentBranch, branch: currentBranch,
message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`, message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`,
}; };
}), }),
}); });
}
export const PushBranch = type({ export const PushBranch = type({
branchName: type.string branchName: type.string
@@ -129,27 +141,32 @@ export const PushBranch = type({
force: type.boolean.describe("Force push (use with caution)").default(false), force: type.boolean.describe("Force push (use with caution)").default(false),
}); });
export const PushBranchTool = tool({ export function PushBranchTool(ctx: Context) {
name: "push_branch", const remote = ctx.pushRemote;
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 }) => {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
if (force) { return tool({
log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); name: "push_branch",
$("git", ["push", "--force", "origin", branch]); description:
} else { "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
log.info(`Pushing branch ${branch} to remote`); parameters: PushBranch,
$("git", ["push", "origin", branch]); execute: execute(ctx, async ({ branchName, force }) => {
} const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
return { log.info(`Pushing branch ${branch} to ${remote}`);
success: true, if (force) {
branch, log.warning(`Force pushing - this will overwrite remote history`);
force, $("git", ["push", "--force", "-u", remote, branch]);
message: `Successfully pushed branch ${branch} to remote`, } else {
}; $("git", ["push", "-u", remote, branch]);
}), }
});
return {
success: true,
branch,
remote,
force,
message: `Successfully pushed branch ${branch} to ${remote}`,
};
}),
});
}
+29 -26
View File
@@ -1,5 +1,6 @@
import { type } from "arktype"; 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({ export const Issue = type({
title: type.string.describe("the title of the issue"), title: type.string.describe("the title of the issue"),
@@ -14,29 +15,31 @@ export const Issue = type({
.optional(), .optional(),
}); });
export const IssueTool = tool({ export function IssueTool(ctx: Context) {
name: "create_issue", return tool({
description: "Create a new GitHub issue", name: "create_issue",
parameters: Issue, description: "Create a new GitHub issue",
execute: contextualize(async ({ title, body, labels, assignees }, ctx) => { parameters: Issue,
const result = await ctx.octokit.rest.issues.create({ execute: execute(ctx, async ({ title, body, labels, assignees }) => {
owner: ctx.owner, const result = await ctx.octokit.rest.issues.create({
repo: ctx.name, owner: ctx.owner,
title: title, repo: ctx.name,
body: body, title: title,
labels: labels ?? [], body: body,
assignees: assignees ?? [], labels: labels ?? [],
}); assignees: assignees ?? [],
});
return { return {
success: true, success: true,
issueId: result.data.id, issueId: result.data.id,
number: result.data.number, number: result.data.number,
url: result.data.html_url, url: result.data.html_url,
title: result.data.title, title: result.data.title,
state: result.data.state, state: result.data.state,
labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)), labels: result.data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
assignees: result.data.assignees?.map((assignee) => assignee.login), assignees: result.data.assignees?.map((assignee) => assignee.login),
}; };
}), }),
}); });
}
+31 -28
View File
@@ -1,35 +1,38 @@
import { type } from "arktype"; 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({ export const GetIssueComments = type({
issue_number: type.number.describe("The issue number to get comments for"), issue_number: type.number.describe("The issue number to get comments for"),
}); });
export const GetIssueCommentsTool = tool({ export function GetIssueCommentsTool(ctx: Context) {
name: "get_issue_comments", return tool({
description: name: "get_issue_comments",
"Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", description:
parameters: GetIssueComments, "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
execute: contextualize(async ({ issue_number }, ctx) => { parameters: GetIssueComments,
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { execute: execute(ctx, async ({ issue_number }) => {
owner: ctx.owner, const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
repo: ctx.name, owner: ctx.owner,
issue_number, repo: ctx.name,
}); issue_number,
});
return { return {
issue_number, issue_number,
comments: comments.map((comment) => ({ comments: comments.map((comment) => ({
id: comment.id, id: comment.id,
body: comment.body, body: comment.body,
user: comment.user?.login, user: comment.user?.login,
created_at: comment.created_at, created_at: comment.created_at,
updated_at: comment.updated_at, updated_at: comment.updated_at,
html_url: comment.html_url, html_url: comment.html_url,
author_association: comment.author_association, author_association: comment.author_association,
reactions: comment.reactions, reactions: comment.reactions,
})), })),
count: comments.length, count: comments.length,
}; };
}), }),
}); });
}
+86 -83
View File
@@ -1,93 +1,96 @@
import { type } from "arktype"; 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({ export const GetIssueEvents = type({
issue_number: type.number.describe("The issue number to get events for"), issue_number: type.number.describe("The issue number to get events for"),
}); });
export const GetIssueEventsTool = tool({ export function GetIssueEventsTool(ctx: Context) {
name: "get_issue_events", return tool({
description: name: "get_issue_events",
"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.", description:
parameters: GetIssueEvents, "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.",
execute: contextualize(async ({ issue_number }, ctx) => { parameters: GetIssueEvents,
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, { execute: execute(ctx, async ({ issue_number }) => {
owner: ctx.owner, const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
repo: ctx.name, owner: ctx.owner,
issue_number, repo: ctx.name,
}); issue_number,
});
// Only include events not reflected in current issue state (get_issue already has labels, assignees, state, etc.) // 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 // Keep only relationship/reference events that show connections to other issues/PRs/commits
const relevantEventTypes = new Set(["cross_referenced", "referenced"]); const relevantEventTypes = new Set(["cross_referenced", "referenced"]);
const parsedEvents = events.flatMap((event) => { const parsedEvents = events.flatMap((event) => {
// Filter to only events with an 'event' property and relevant types // Filter to only events with an 'event' property and relevant types
if (!("event" in event) || !relevantEventTypes.has(event.event)) { if (!("event" in event) || !relevantEventTypes.has(event.event)) {
return []; return [];
} }
const baseEvent: Record<string, any> = { const baseEvent: Record<string, any> = {
event: event.event, 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,
}; };
}),
// 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,
};
}),
});
+49 -46
View File
@@ -1,55 +1,58 @@
import { type } from "arktype"; 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({ export const IssueInfo = type({
issue_number: type.number.describe("The issue number to fetch"), issue_number: type.number.describe("The issue number to fetch"),
}); });
export const IssueInfoTool = tool({ export function IssueInfoTool(ctx: Context) {
name: "get_issue", return tool({
description: "Retrieve GitHub issue information by issue number", name: "get_issue",
parameters: IssueInfo, description: "Retrieve GitHub issue information by issue number",
execute: contextualize(async ({ issue_number }, ctx) => { parameters: IssueInfo,
const issue = await ctx.octokit.rest.issues.get({ execute: execute(ctx, async ({ issue_number }) => {
owner: ctx.owner, const issue = await ctx.octokit.rest.issues.get({
repo: ctx.name, owner: ctx.owner,
issue_number, repo: ctx.name,
}); issue_number,
});
const data = issue.data; const data = issue.data;
const hints: string[] = []; const hints: string[] = [];
if (data.comments > 0) { if (data.comments > 0) {
hints.push("use get_issue_comments to retrieve all comments for this issue"); hints.push("use get_issue_comments to retrieve all comments for this issue");
} }
hints.push( hints.push(
"use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)" "use get_issue_events to retrieve cross-references and commit references (relationships not reflected in current state)"
); );
return { return {
number: data.number, number: data.number,
url: data.html_url, url: data.html_url,
title: data.title, title: data.title,
body: data.body, body: data.body,
state: data.state, state: data.state,
locked: data.locked, locked: data.locked,
labels: data.labels?.map((label) => (typeof label === "string" ? label : label.name)), labels: data.labels?.map((label) => (typeof label === "string" ? label : label.name)),
assignees: data.assignees?.map((assignee) => assignee.login), assignees: data.assignees?.map((assignee) => assignee.login),
user: data.user?.login, user: data.user?.login,
created_at: data.created_at, created_at: data.created_at,
updated_at: data.updated_at, updated_at: data.updated_at,
closed_at: data.closed_at, closed_at: data.closed_at,
comments: data.comments, comments: data.comments,
milestone: data.milestone?.title, milestone: data.milestone?.title,
pull_request: data.pull_request pull_request: data.pull_request
? { ? {
url: data.pull_request.url, url: data.pull_request.url,
html_url: data.pull_request.html_url, html_url: data.pull_request.html_url,
diff_url: data.pull_request.diff_url, diff_url: data.pull_request.diff_url,
patch_url: data.pull_request.patch_url, patch_url: data.pull_request.patch_url,
} }
: null, : null,
hints, hints,
}; };
}), }),
}); });
}
+22 -19
View File
@@ -1,27 +1,30 @@
import { type } from "arktype"; 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({ export const AddLabelsParams = type({
issue_number: type.number.describe("the issue or PR number to add labels to"), 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"), labels: type.string.array().atLeastLength(1).describe("array of label names to add"),
}); });
export const AddLabelsTool = tool({ export function AddLabelsTool(ctx: Context) {
name: "add_labels", return tool({
description: name: "add_labels",
"Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.", description:
parameters: AddLabelsParams, "Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.",
execute: contextualize(async ({ issue_number, labels }, ctx) => { parameters: AddLabelsParams,
const result = await ctx.octokit.rest.issues.addLabels({ execute: execute(ctx, async ({ issue_number, labels }) => {
owner: ctx.owner, const result = await ctx.octokit.rest.issues.addLabels({
repo: ctx.name, owner: ctx.owner,
issue_number, repo: ctx.name,
labels, issue_number,
}); labels,
});
return { return {
success: true, success: true,
labels: result.data.map((label) => label.name), labels: result.data.map((label) => label.name),
}; };
}), }),
}); });
}
+45 -42
View File
@@ -1,8 +1,9 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts"; import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const PullRequest = type({ export const PullRequest = type({
title: type.string.describe("the title of the pull request"), title: type.string.describe("the title of the pull request"),
@@ -10,48 +11,50 @@ export const PullRequest = type({
base: type.string.describe("the base branch to merge into (e.g., 'main')"), base: type.string.describe("the base branch to merge into (e.g., 'main')"),
}); });
export const PullRequestTool = tool({ export function PullRequestTool(ctx: Context) {
name: "create_pull_request", return tool({
description: "Create a pull request from the current branch", name: "create_pull_request",
parameters: PullRequest, description: "Create a pull request from the current branch",
execute: contextualize(async ({ title, body, base }, ctx) => { parameters: PullRequest,
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); execute: execute(ctx, async ({ title, body, base }) => {
log.info(`Current branch: ${currentBranch}`); const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.info(`Current branch: ${currentBranch}`);
// validate PR title and body for secrets // validate PR title and body for secrets
if (containsSecrets(title) || containsSecrets(body)) { if (containsSecrets(title) || containsSecrets(body)) {
throw new Error( throw new Error(
"PR creation blocked: secrets detected in PR title or body. " + "PR creation blocked: secrets detected in PR title or body. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR." "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) // validate all changes that would be in the PR (from base to HEAD)
const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false }); const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false });
if (containsSecrets(diff)) { if (containsSecrets(diff)) {
throw new Error( throw new Error(
"PR creation blocked: secrets detected in changes. " + "PR creation blocked: secrets detected in changes. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a PR." "Please remove any sensitive information (API keys, tokens, passwords) before creating a PR."
); );
} }
const result = await ctx.octokit.rest.pulls.create({ const result = await ctx.octokit.rest.pulls.create({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
title: title, title: title,
body: body, body: body,
head: currentBranch, head: currentBranch,
base: base, base: base,
}); });
return { return {
success: true, success: true,
pullRequestId: result.data.id, pullRequestId: result.data.id,
number: result.data.number, number: result.data.number,
url: result.data.html_url, url: result.data.html_url,
title: result.data.title, title: result.data.title,
head: result.data.head.ref, head: result.data.head.ref,
base: result.data.base.ref, base: result.data.base.ref,
}; };
}), }),
}); });
}
+31 -28
View File
@@ -1,37 +1,40 @@
import { type } from "arktype"; 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({ export const PullRequestInfo = type({
pull_number: type.number.describe("The pull request number to fetch"), pull_number: type.number.describe("The pull request number to fetch"),
}); });
export const PullRequestInfoTool = tool({ export function PullRequestInfoTool(ctx: Context) {
name: "get_pull_request", return tool({
description: name: "get_pull_request",
"Retrieve PR information (metadata only). PR branch is already checked out during setup.", description:
parameters: PullRequestInfo, "Retrieve PR information (metadata only). PR branch is already checked out during setup.",
execute: contextualize(async ({ pull_number }, ctx) => { parameters: PullRequestInfo,
const pr = await ctx.octokit.rest.pulls.get({ execute: execute(ctx, async ({ pull_number }) => {
owner: ctx.owner, const pr = await ctx.octokit.rest.pulls.get({
repo: ctx.name, owner: ctx.owner,
pull_number, repo: ctx.name,
}); pull_number,
});
const data = pr.data; const data = pr.data;
// detect fork PRs - head repo differs from base repo // detect fork PRs - head repo differs from base repo
const isFork = data.head.repo.full_name !== data.base.repo.full_name; const isFork = data.head.repo.full_name !== data.base.repo.full_name;
return { return {
number: data.number, number: data.number,
url: data.html_url, url: data.html_url,
title: data.title, title: data.title,
state: data.state, state: data.state,
draft: data.draft, draft: data.draft,
merged: data.merged, merged: data.merged,
base: data.base.ref, base: data.base.ref,
head: data.head.ref, head: data.head.ref,
isFork, isFork,
}; };
}), }),
}); });
}
+75 -72
View File
@@ -1,8 +1,9 @@
import type { RestEndpointMethodTypes } from "@octokit/rest"; import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { deleteProgressComment } from "./comment.ts"; import { deleteProgressComment } from "./comment.ts";
import { contextualize, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const Review = type({ export const Review = type({
pull_number: type.number.describe("The pull request number to review"), pull_number: type.number.describe("The pull request number to review"),
@@ -39,81 +40,83 @@ export const Review = type({
.optional(), .optional(),
}); });
export const ReviewTool = tool({ export function ReviewTool(ctx: Context) {
name: "submit_pull_request_review", return tool({
description: name: "submit_pull_request_review",
"Submit a review for an existing pull request. " + description:
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " + "Submit a review for an existing pull request. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.", "IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
parameters: Review, "Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
execute: contextualize(async ({ pull_number, body, commit_id, comments = [] }, ctx) => { parameters: Review,
// get the PR to determine the head commit if commit_id not provided execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => {
const pr = await ctx.octokit.rest.pulls.get({ // get the PR to determine the head commit if commit_id not provided
owner: ctx.owner, const pr = await ctx.octokit.rest.pulls.get({
repo: ctx.name, owner: ctx.owner,
pull_number, repo: ctx.name,
}); pull_number,
// compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.owner,
repo: ctx.name,
pull_number,
event: "COMMENT",
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
};
reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = comment.side || "RIGHT";
}
return reviewComment;
}); });
}
const result = await ctx.octokit.rest.pulls.createReview(params);
const reviewId = result.data.id;
// build quick links footer and update the review body // compose the request
const apiUrl = process.env.API_URL || "https://pullfrog.com"; const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`; owner: ctx.owner,
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`; repo: ctx.name,
pull_number,
event: "COMMENT",
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
const reviewComment: ReviewComment = {
...comment,
};
reviewComment.side = comment.side || "RIGHT";
if (comment.start_line) {
reviewComment.start_line = comment.start_line;
reviewComment.start_side = comment.side || "RIGHT";
}
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
const reviewId = result.data.id;
const footer = buildPullfrogFooter({ // build quick links footer and update the review body
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`], const apiUrl = process.env.API_URL || "https://pullfrog.com";
}); const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
const updatedBody = (body || "") + footer; const footer = buildPullfrogFooter({
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
// update the review with the footer const updatedBody = (body || "") + footer;
await ctx.octokit.rest.pulls.updateReview({
owner: ctx.owner,
repo: ctx.name,
pull_number,
review_id: reviewId,
body: updatedBody,
});
await deleteProgressComment(); // update the review with the footer
await ctx.octokit.rest.pulls.updateReview({
owner: ctx.owner,
repo: ctx.name,
pull_number,
review_id: reviewId,
body: updatedBody,
});
return { await deleteProgressComment(ctx);
success: true,
reviewId, return {
html_url: result.data.html_url, success: true,
state: result.data.state, reviewId,
user: result.data.user?.login, html_url: result.data.html_url,
submitted_at: result.data.submitted_at, state: result.data.state,
}; user: result.data.user?.login,
}), submitted_at: result.data.submitted_at,
}); };
}),
});
}
+131 -124
View File
@@ -1,13 +1,17 @@
import { type } from "arktype"; 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 // graphql query to fetch all review threads with comments and replies
// note: diffSide and startDiffSide are on the thread, not the comment
const REVIEW_THREADS_QUERY = ` const REVIEW_THREADS_QUERY = `
query ($owner: String!, $repo: String!, $pullNumber: Int!) { query ($owner: String!, $repo: String!, $pullNumber: Int!) {
repository(owner: $owner, name: $repo) { repository(owner: $owner, name: $repo) {
pullRequest(number: $pullNumber) { pullRequest(number: $pullNumber) {
reviewThreads(first: 100) { reviewThreads(first: 100) {
nodes { nodes {
diffSide
startDiffSide
comments(first: 100) { comments(first: 100) {
nodes { nodes {
id id
@@ -16,8 +20,6 @@ query ($owner: String!, $repo: String!, $pullNumber: Int!) {
path path
line line
startLine startLine
diffSide
startSide
url url
author { author {
login login
@@ -47,8 +49,6 @@ type GraphQLReviewComment = {
path: string; path: string;
line: number | null; line: number | null;
startLine: number | null; startLine: number | null;
diffSide: "LEFT" | "RIGHT";
startSide: "LEFT" | "RIGHT" | null;
url: string; url: string;
author: { author: {
login: string; login: string;
@@ -64,10 +64,12 @@ type GraphQLReviewComment = {
}; };
type GraphQLReviewThread = { type GraphQLReviewThread = {
diffSide: "LEFT" | "RIGHT";
startDiffSide: "LEFT" | "RIGHT" | null;
comments: { comments: {
nodes: (GraphQLReviewComment | null)[] | null; nodes: (GraphQLReviewComment | null)[] | null;
} | null; } | null;
}; } | null;
type GraphQLResponse = { type GraphQLResponse = {
repository: { repository: {
@@ -84,130 +86,135 @@ export const GetReviewComments = type({
review_id: type.number.describe("The review ID to get comments for"), review_id: type.number.describe("The review ID to get comments for"),
}); });
export const GetReviewCommentsTool = tool({ export function GetReviewCommentsTool(ctx: Context) {
name: "get_review_comments", return tool({
description: name: "get_review_comments",
"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.", description:
parameters: GetReviewComments, "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.",
execute: contextualize(async ({ pull_number, review_id }, ctx) => { parameters: GetReviewComments,
// fetch all review threads using graphql execute: execute(ctx, async ({ pull_number, review_id }) => {
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, { // fetch all review threads using graphql
owner: ctx.owner, const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
repo: ctx.name, owner: ctx.owner,
pullNumber: pull_number, repo: ctx.name,
}); pullNumber: pull_number,
});
const pullRequest = response.repository?.pullRequest; const pullRequest = response.repository?.pullRequest;
if (!pullRequest) { if (!pullRequest) {
return { return {
review_id, review_id,
pull_number, pull_number,
comments: [], comments: [],
count: 0, count: 0,
}; };
}
const threadNodes = pullRequest.reviewThreads?.nodes;
if (!threadNodes) {
return {
review_id,
pull_number,
comments: [],
count: 0,
};
}
const allComments: {
id: number;
body: string;
path: string;
line: number | null;
side: "LEFT" | "RIGHT";
start_line: number | null;
start_side: "LEFT" | "RIGHT" | null;
user: string | null;
created_at: string;
updated_at: string;
html_url: string;
in_reply_to_id: number | null;
pull_request_review_id: number | null;
}[] = [];
// iterate through all threads (filter out nulls)
for (const thread of threadNodes) {
if (!thread?.comments?.nodes) continue;
// filter out null comments
const threadComments = thread.comments.nodes.filter(
(c): c is GraphQLReviewComment => c !== null
);
if (threadComments.length === 0) continue;
// find the root comment (the one with replyTo == null) to determine thread ownership
const rootComment = threadComments.find((c) => c.replyTo === null);
if (!rootComment) continue;
// check if this thread belongs to the target review using the root comment
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
if (!threadBelongsToReview) continue;
// include all comments from this thread (original + replies)
for (const comment of threadComments) {
allComments.push({
id: comment.databaseId,
body: comment.body,
path: comment.path,
line: comment.line,
start_line: comment.startLine,
side: comment.diffSide,
start_side: comment.startSide,
user: comment.author?.login ?? null,
created_at: comment.createdAt,
updated_at: comment.updatedAt,
html_url: comment.url,
in_reply_to_id: comment.replyTo?.databaseId ?? null,
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
});
} }
}
return { const threadNodes = pullRequest.reviewThreads?.nodes;
review_id, if (!threadNodes) {
pull_number, return {
comments: allComments, review_id,
count: allComments.length, pull_number,
}; comments: [],
}), count: 0,
}); };
}
const allComments: {
id: number;
body: string;
path: string;
line: number | null;
side: "LEFT" | "RIGHT";
start_line: number | null;
start_side: "LEFT" | "RIGHT" | null;
user: string | null;
created_at: string;
updated_at: string;
html_url: string;
in_reply_to_id: number | null;
pull_request_review_id: number | null;
}[] = [];
// iterate through all threads (filter out nulls)
for (const thread of threadNodes) {
if (!thread?.comments?.nodes) continue;
// filter out null comments
const threadComments = thread.comments.nodes.filter(
(c): c is GraphQLReviewComment => c !== null
);
if (threadComments.length === 0) continue;
// find the root comment (the one with replyTo == null) to determine thread ownership
const rootComment = threadComments.find((c) => c.replyTo === null);
if (!rootComment) continue;
// check if this thread belongs to the target review using the root comment
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
if (!threadBelongsToReview) continue;
// include all comments from this thread (original + replies)
// side info comes from thread level, not comment level
for (const comment of threadComments) {
allComments.push({
id: comment.databaseId,
body: comment.body,
path: comment.path,
line: comment.line,
start_line: comment.startLine,
side: thread.diffSide,
start_side: thread.startDiffSide,
user: comment.author?.login ?? null,
created_at: comment.createdAt,
updated_at: comment.updatedAt,
html_url: comment.url,
in_reply_to_id: comment.replyTo?.databaseId ?? null,
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
});
}
}
return {
review_id,
pull_number,
comments: allComments,
count: allComments.length,
};
}),
});
}
export const ListPullRequestReviews = type({ export const ListPullRequestReviews = type({
pull_number: type.number.describe("The pull request number to list reviews for"), pull_number: type.number.describe("The pull request number to list reviews for"),
}); });
export const ListPullRequestReviewsTool = tool({ export function ListPullRequestReviewsTool(ctx: Context) {
name: "list_pull_request_reviews", return tool({
description: name: "list_pull_request_reviews",
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.", description:
parameters: ListPullRequestReviews, "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
execute: contextualize(async ({ pull_number }, ctx) => { parameters: ListPullRequestReviews,
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, { execute: execute(ctx, async ({ pull_number }) => {
owner: ctx.owner, const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
repo: ctx.name, owner: ctx.owner,
pull_number, repo: ctx.name,
}); pull_number,
});
return { return {
pull_number, pull_number,
reviews: reviews.map((review) => ({ reviews: reviews.map((review) => ({
id: review.id, id: review.id,
body: review.body, body: review.body,
state: review.state, state: review.state,
user: review.user?.login, user: review.user?.login,
commit_id: review.commit_id, commit_id: review.commit_id,
submitted_at: review.submitted_at, submitted_at: review.submitted_at,
html_url: review.html_url, html_url: review.html_url,
})), })),
count: reviews.length, count: reviews.length,
}; };
}), }),
}); });
}
+24 -21
View File
@@ -1,5 +1,6 @@
import { type } from "arktype"; 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({ export const SelectMode = type({
modeName: type.string.describe( modeName: type.string.describe(
@@ -7,26 +8,28 @@ export const SelectMode = type({
), ),
}); });
export const SelectModeTool = tool({ export function SelectModeTool(ctx: Context) {
name: "select_mode", return tool({
description: name: "select_mode",
"Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.", description:
parameters: SelectMode, "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
execute: contextualize(async ({ modeName }, ctx) => { parameters: SelectMode,
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()); execute: execute(ctx, async ({ modeName }) => {
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
if (!selectedMode) {
const availableModes = ctx.modes.map((m) => m.name).join(", ");
return {
error: `Mode "${modeName}" not found. Available modes: ${availableModes}`,
availableModes: ctx.modes.map((m) => ({ name: m.name, description: m.description })),
};
}
if (!selectedMode) {
const availableModes = ctx.modes.map((m) => m.name).join(", ");
return { return {
error: `Mode "${modeName}" not found. Available modes: ${availableModes}`, modeName: selectedMode.name,
availableModes: ctx.modes.map((m) => ({ name: m.name, description: m.description })), description: selectedMode.description,
prompt: selectedMode.prompt,
}; };
} }),
});
return { }
modeName: selectedMode.name,
description: selectedMode.description,
prompt: selectedMode.prompt,
};
}),
});
+25 -30
View File
@@ -3,6 +3,7 @@ import "./arkConfig.ts";
import { createServer } from "node:net"; import { createServer } from "node:net";
import { FastMCP, type Tool } from "fastmcp"; import { FastMCP, type Tool } from "fastmcp";
import { ghPullfrogMcpName } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts";
import type { Context } from "../main.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import { import {
CreateCommentTool, CreateCommentTool,
@@ -22,12 +23,7 @@ import { PullRequestInfoTool } from "./prInfo.ts";
import { ReviewTool } from "./review.ts"; import { ReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts"; import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts"; import { SelectModeTool } from "./selectMode.ts";
import { import { addTools, isProgressCommentDisabled } from "./shared.ts";
addTools,
initMcpContext,
isProgressCommentDisabled,
type McpInitContext,
} from "./shared.ts";
/** /**
* Find an available port starting from the given port * Find an available port starting from the given port
@@ -62,43 +58,42 @@ async function findAvailablePort(startPort: number): Promise<number> {
* Start the MCP HTTP server and return the URL and close function * Start the MCP HTTP server and return the URL and close function
*/ */
export async function startMcpHttpServer( export async function startMcpHttpServer(
state: McpInitContext ctx: Context
): Promise<{ url: string; close: () => Promise<void> }> { ): Promise<{ url: string; close: () => Promise<void> }> {
initMcpContext(state);
const server = new FastMCP({ const server = new FastMCP({
name: ghPullfrogMcpName, name: ghPullfrogMcpName,
version: "0.0.1", version: "0.0.1",
}); });
// create all tools as factories, passing ctx
const tools: Tool<any, any>[] = [ const tools: Tool<any, any>[] = [
SelectModeTool, SelectModeTool(ctx),
CreateCommentTool, CreateCommentTool(ctx),
EditCommentTool, EditCommentTool(ctx),
ReplyToReviewCommentTool, ReplyToReviewCommentTool(ctx),
IssueTool, IssueTool(ctx),
IssueInfoTool, IssueInfoTool(ctx),
GetIssueCommentsTool, GetIssueCommentsTool(ctx),
GetIssueEventsTool, GetIssueEventsTool(ctx),
PullRequestTool, PullRequestTool(ctx),
ReviewTool, ReviewTool(ctx),
PullRequestInfoTool, PullRequestInfoTool(ctx),
GetReviewCommentsTool, GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool, ListPullRequestReviewsTool(ctx),
GetCheckSuiteLogsTool, GetCheckSuiteLogsTool(ctx),
DebugShellCommandTool, DebugShellCommandTool,
AddLabelsTool, AddLabelsTool(ctx),
CreateBranchTool, CreateBranchTool(ctx),
CommitFilesTool, CommitFilesTool(ctx),
PushBranchTool, PushBranchTool(ctx),
]; ];
// only include ReportProgressTool if progress comment is not disabled // only include ReportProgressTool if progress comment is not disabled
if (!isProgressCommentDisabled()) { if (!isProgressCommentDisabled(ctx)) {
tools.push(ReportProgressTool); tools.push(ReportProgressTool(ctx));
} }
addTools(server, tools); addTools(ctx, server, tools);
const port = await findAvailablePort(3764); const port = await findAvailablePort(3764);
const host = "127.0.0.1"; const host = "127.0.0.1";
+54 -87
View File
@@ -1,46 +1,60 @@
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 { Context } from "../main.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;
}
let mcpInitContext: McpInitContext | undefined;
// 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(),
}),
};
}
export function isProgressCommentDisabled(): boolean {
return mcpInitContext?.payload.disableProgressComment === true;
}
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef; 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 * 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) * - Removes $schema field (causes "no schema with key or ref" errors)
@@ -162,11 +176,10 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
} as 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) // sanitize schemas for gemini agent and opencode (when using Google API)
// both have issues with draft-2020-12 schemas and any_of enum constructs // both have issues with draft-2020-12 schemas and any_of enum constructs
const shouldSanitize = const shouldSanitize = ctx.agentName === "gemini" || ctx.agentName === "opencode";
mcpInitContext?.agentName === "gemini" || mcpInitContext?.agentName === "opencode";
for (const tool of tools) { for (const tool of tools) {
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool; const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
@@ -174,49 +187,3 @@ export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
} }
return server; return server;
}; };
export const contextualize = <T>(
executor: (params: T, ctx: McpContext) => Promise<Record<string, any>>
) => {
return async (params: T): Promise<ToolResult> => {
try {
const ctx = 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,
};
};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.139", "version": "0.0.141",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+2 -9
View File
@@ -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) { if (!commentId) {
try { return;
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 // update comment directly using GitHub API
+47 -32
View File
@@ -1,8 +1,7 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs"; import { existsSync, rmSync } from "node:fs";
import type { Payload } from "../external.ts"; import type { Context } from "../main.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { getGitHubInstallationToken, type RepoContext } from "./github.ts";
import { $ } from "./shell.ts"; import { $ } from "./shell.ts";
export interface SetupOptions { export interface SetupOptions {
@@ -63,20 +62,23 @@ export function setupGitConfig(): void {
} }
} }
export type SetupGitResult = {
pushRemote: string;
};
/** /**
* Setup git authentication using GitHub installation token * Unified git setup: configures authentication and checks out PR branch if applicable.
* Always uses the installation token, scoped to the current repo only * Uses gh as credential helper so git push works with any remote (including forks).
* 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 setupGitAuth(ctx: { export function setupGit(ctx: Context): SetupGitResult {
githubInstallationToken: string; const { githubInstallationToken, payload } = ctx;
repoContext: RepoContext;
}): void {
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("🔐 Setting up git authentication..."); log.info("🔧 Setting up git configuration...");
// Remove existing git auth headers that actions/checkout might have set // remove existing git auth headers that actions/checkout might have set
// Use --local to scope to this repo only
try { try {
execSync("git config --local --unset-all http.https://github.com/.extraheader", { execSync("git config --local --unset-all http.https://github.com/.extraheader", {
cwd: repoDir, cwd: repoDir,
@@ -87,36 +89,49 @@ export function setupGitAuth(ctx: {
log.debug("No existing authentication headers to remove"); log.debug("No existing authentication headers to remove");
} }
// Update remote URL to embed the token // set GH_TOKEN in process.env so gh auth git-credential uses the installation token
// This is scoped to the repo's .git/config, not the user's global config // (not the default GITHUB_TOKEN which has different permissions)
const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`; process.env.GH_TOKEN = githubInstallationToken;
$("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir });
log.info("✓ Updated remote URL with authentication token (scoped to repo)");
}
/** // set up gh as credential helper - this makes git use GH_TOKEN for any remote
* Setup git branch based on payload event context. $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
* For PR events, uses `gh pr checkout` which handles fork PRs automatically. $("git", ["config", "--local", "--add", "credential.helper", "!gh auth git-credential"], {
* For non-PR events, stays on the default branch. cwd: repoDir,
*/ });
export function setupGitBranch(payload: Payload): void { log.info("✓ Configured gh as credential helper");
// only checkout for PR events - use issue_number directly (no dependency on branch field)
// non-PR events: stay on default branch, push to origin
if (payload.event.is_pr !== true || !payload.event.issue_number) { if (payload.event.is_pr !== true || !payload.event.issue_number) {
log.debug("Not a PR event, staying on default branch"); log.debug("Not a PR event, staying on default branch");
return; return { pushRemote: "origin" };
} }
// checkout PR branch - gh pr checkout handles fork remotes and tracking automatically
const prNumber = payload.event.issue_number; const prNumber = payload.event.issue_number;
const repoDir = process.cwd();
log.info(`🌿 Checking out PR #${prNumber}...`); log.info(`🌿 Checking out PR #${prNumber}...`);
// gh pr checkout handles fork PRs by setting up remotes automatically
const token = getGitHubInstallationToken();
$("gh", ["pr", "checkout", prNumber.toString()], { $("gh", ["pr", "checkout", prNumber.toString()], {
cwd: repoDir, cwd: repoDir,
env: { GH_TOKEN: token }, env: { GH_TOKEN: githubInstallationToken },
}); });
log.info(`✓ Successfully checked out PR #${prNumber}`); log.info(`✓ Successfully checked out PR #${prNumber}`);
// detect the push remote from branch tracking (set by gh pr checkout)
const pushRemote = detectPushRemote();
if (pushRemote !== "origin") {
log.info(`🍴 Fork PR detected, will push to remote: ${pushRemote}`);
}
return { pushRemote };
}
function detectPushRemote(): string {
try {
const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
const upstream = $("git", ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`], {
log: false,
});
// upstream is like "remote/branch", extract remote name
return upstream.split("/")[0];
} catch {
return "origin";
}
} }