Compare commits

...

12 Commits

Author SHA1 Message Date
Colin McDonnell c2cedce1bc 0.0.142 2025-12-15 23:38:46 -08:00
Colin McDonnell e383dd33dd Clean up destructuring 2025-12-15 23:32:02 -08:00
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
Colin McDonnell b038fc574f Get reviews with comments 2025-12-15 21:37:46 -08:00
Colin McDonnell 316b6cb83c 0.0.138 2025-12-15 21:21:57 -08:00
Colin McDonnell a19ae49224 Determinstically set up PR branch 2025-12-15 21:12:55 -08:00
Colin McDonnell 1d69f0f3e4 0.0.137 2025-12-15 20:22:12 -08:00
Colin McDonnell 2f16d2ef0e Improve repo setup with gh cli 2025-12-15 20:21:56 -08:00
28 changed files with 15077 additions and 16251 deletions
+72 -72
View File
@@ -8,84 +8,84 @@ import { getModes } from "../modes.ts";
* Removes verbose GitHub API metadata (user objects, repository metadata, etc.) * Removes verbose GitHub API metadata (user objects, repository metadata, etc.)
* and keeps only the fields agents actually need. * and keeps only the fields agents actually need.
*/ */
function extractEssentialEventData(event: Payload["event"]): Record<string, unknown> { // function extractEssentialEventData(event: Payload["event"]): Record<string, unknown> {
const trigger = event.trigger; // const trigger = event.trigger;
const essential: Record<string, unknown> = { trigger }; // const essential: Record<string, unknown> = { trigger };
// common fields // // common fields
if ("issue_number" in event) { // if ("issue_number" in event) {
essential.issue_number = event.issue_number; // essential.issue_number = event.issue_number;
} // }
if ("branch" in event && event.branch) { // if ("branch" in event && event.branch) {
essential.branch = event.branch; // essential.branch = event.branch;
} // }
// trigger-specific fields // // trigger-specific fields
switch (trigger) { // switch (trigger) {
case "issue_comment_created": // case "issue_comment_created":
if ("comment_id" in event) essential.comment_id = event.comment_id; // if ("comment_id" in event) essential.comment_id = event.comment_id;
if ("comment_body" in event) essential.comment_body = event.comment_body; // if ("comment_body" in event) essential.comment_body = event.comment_body;
// include issue title/body if available in context (but not the entire context object) // // include issue title/body if available in context (but not the entire context object)
if ("context" in event && event.context && typeof event.context === "object") { // if ("context" in event && event.context && typeof event.context === "object") {
const ctx = event.context as Record<string, unknown>; // const ctx = event.context as Record<string, unknown>;
if (ctx.issue && typeof ctx.issue === "object") { // if (ctx.issue && typeof ctx.issue === "object") {
const issue = ctx.issue as Record<string, unknown>; // const issue = ctx.issue as Record<string, unknown>;
if (issue.title) essential.issue_title = issue.title; // if (issue.title) essential.issue_title = issue.title;
if (issue.body) essential.issue_body = issue.body; // if (issue.body) essential.issue_body = issue.body;
} // }
} // }
break; // break;
case "issues_opened": // case "issues_opened":
case "issues_assigned": // case "issues_assigned":
case "issues_labeled": // case "issues_labeled":
if ("issue_title" in event) essential.issue_title = event.issue_title; // if ("issue_title" in event) essential.issue_title = event.issue_title;
if ("issue_body" in event) essential.issue_body = event.issue_body; // if ("issue_body" in event) essential.issue_body = event.issue_body;
break; // break;
case "pull_request_opened": // case "pull_request_opened":
case "pull_request_review_requested": // case "pull_request_review_requested":
if ("pr_title" in event) essential.pr_title = event.pr_title; // if ("pr_title" in event) essential.pr_title = event.pr_title;
if ("pr_body" in event) essential.pr_body = event.pr_body; // if ("pr_body" in event) essential.pr_body = event.pr_body;
if ("branch" in event) essential.branch = event.branch; // if ("branch" in event) essential.branch = event.branch;
break; // break;
case "pull_request_review_submitted": // case "pull_request_review_submitted":
if ("review_id" in event) essential.review_id = event.review_id; // if ("review_id" in event) essential.review_id = event.review_id;
if ("review_body" in event) essential.review_body = event.review_body; // if ("review_body" in event) essential.review_body = event.review_body;
if ("review_state" in event) essential.review_state = event.review_state; // if ("review_state" in event) essential.review_state = event.review_state;
if ("branch" in event) essential.branch = event.branch; // if ("branch" in event) essential.branch = event.branch;
break; // break;
case "pull_request_review_comment_created": // case "pull_request_review_comment_created":
if ("comment_id" in event) essential.comment_id = event.comment_id; // if ("comment_id" in event) essential.comment_id = event.comment_id;
if ("comment_body" in event) essential.comment_body = event.comment_body; // if ("comment_body" in event) essential.comment_body = event.comment_body;
if ("pr_title" in event) essential.pr_title = event.pr_title; // if ("pr_title" in event) essential.pr_title = event.pr_title;
if ("branch" in event) essential.branch = event.branch; // if ("branch" in event) essential.branch = event.branch;
break; // break;
case "check_suite_completed": // case "check_suite_completed":
if ("pr_title" in event) essential.pr_title = event.pr_title; // if ("pr_title" in event) essential.pr_title = event.pr_title;
if ("pr_body" in event) essential.pr_body = event.pr_body; // if ("pr_body" in event) essential.pr_body = event.pr_body;
if ("branch" in event) essential.branch = event.branch; // if ("branch" in event) essential.branch = event.branch;
if ("check_suite" in event) { // if ("check_suite" in event) {
essential.check_suite = { // essential.check_suite = {
id: event.check_suite.id, // id: event.check_suite.id,
head_sha: event.check_suite.head_sha, // head_sha: event.check_suite.head_sha,
head_branch: event.check_suite.head_branch, // head_branch: event.check_suite.head_branch,
status: event.check_suite.status, // status: event.check_suite.status,
conclusion: event.check_suite.conclusion, // conclusion: event.check_suite.conclusion,
}; // };
} // }
break; // break;
case "workflow_dispatch": // case "workflow_dispatch":
if ("inputs" in event) essential.inputs = event.inputs; // if ("inputs" in event) essential.inputs = event.inputs;
break; // break;
} // }
return essential; // return essential;
} // }
export const addInstructions = (payload: Payload) => { export const addInstructions = (payload: Payload) => {
let encodedEvent = ""; let encodedEvent = "";
@@ -95,8 +95,8 @@ export const addInstructions = (payload: Payload) => {
// no meaningful event data to encode // no meaningful event data to encode
} else { } else {
// extract only essential fields to reduce token usage // extract only essential fields to reduce token usage
const essentialEvent = extractEssentialEventData(payload.event); // const essentialEvent = payload.event;
encodedEvent = toonEncode(essentialEvent); encodedEvent = toonEncode(payload.event);
} }
return ` return `
@@ -168,7 +168,7 @@ MCP servers provide tools you can call. Inspect your available MCP servers at st
Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${ghPullfrogMcpName}/create_issue_comment\` Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${ghPullfrogMcpName}/create_issue_comment\`
**GitHub CLI prohibition**: Do not use the \`gh\` CLI under any circumstances. Use the corresponding tool from ${ghPullfrogMcpName} instead. **GitHub CLI**: Prefer using MCP tools from ${ghPullfrogMcpName} for GitHub operations. The \`gh\` CLI is available as a fallback if needed, but MCP tools handle authentication and provide better integration.
**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author. **Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author.
+13665 -15002
View File
File diff suppressed because it is too large Load Diff
+161 -108
View File
@@ -51,117 +51,170 @@ export const AgentName = type.enumerated(...Object.keys(agentsManifest));
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number]; export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
// base interface for common payload event fields
interface BasePayloadEvent {
issue_number?: number;
is_pr?: boolean;
branch?: string;
pr_title?: string;
pr_body?: string | null;
issue_title?: string;
issue_body?: string | null;
comment_id?: number;
comment_body?: string;
review_id?: number;
review_body?: string | null;
review_state?: string;
review_comments?: any[];
context?: any;
thread?: any;
pull_request?: any;
check_suite?: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
comment_ids?: number[] | "all";
[key: string]: any;
}
interface PullRequestOpenedEvent extends BasePayloadEvent {
trigger: "pull_request_opened";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
branch: string;
}
interface PullRequestReadyForReviewEvent extends BasePayloadEvent {
trigger: "pull_request_ready_for_review";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
branch: string;
}
interface PullRequestReviewRequestedEvent extends BasePayloadEvent {
trigger: "pull_request_review_requested";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
branch: string;
}
interface PullRequestReviewSubmittedEvent extends BasePayloadEvent {
trigger: "pull_request_review_submitted";
issue_number: number;
is_pr: true;
review_id: number;
review_body: string | null;
review_state: string;
review_comments: any[];
context: any;
branch: string;
}
interface PullRequestReviewCommentCreatedEvent extends BasePayloadEvent {
trigger: "pull_request_review_comment_created";
issue_number: number;
is_pr: true;
pr_title: string;
comment_id: number;
comment_body: string;
thread?: any;
branch: string;
}
interface IssuesOpenedEvent extends BasePayloadEvent {
trigger: "issues_opened";
issue_number: number;
issue_title: string;
issue_body: string | null;
}
interface IssuesAssignedEvent extends BasePayloadEvent {
trigger: "issues_assigned";
issue_number: number;
issue_title: string;
issue_body: string | null;
}
interface IssuesLabeledEvent extends BasePayloadEvent {
trigger: "issues_labeled";
issue_number: number;
issue_title: string;
issue_body: string | null;
}
interface IssueCommentCreatedEvent extends BasePayloadEvent {
trigger: "issue_comment_created";
comment_id: number;
comment_body: string;
issue_number: number;
// PR-specific fields (only present when is_pr is true)
is_pr?: true;
branch?: string;
pr_title?: string;
pr_body?: string | null;
}
interface CheckSuiteCompletedEvent extends BasePayloadEvent {
trigger: "check_suite_completed";
issue_number: number;
is_pr: true;
pr_title: string;
pr_body: string | null;
pull_request: any;
branch: string;
check_suite: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
}
interface WorkflowDispatchEvent extends BasePayloadEvent {
trigger: "workflow_dispatch";
}
interface FixReviewEvent extends BasePayloadEvent {
trigger: "fix_review";
issue_number: number;
is_pr: true;
review_id: number;
/** "all" to fix all comments, or specific comment IDs to fix */
comment_ids: number[] | "all";
}
interface UnknownEvent extends BasePayloadEvent {
trigger: "unknown";
}
// discriminated union for payload event based on trigger // discriminated union for payload event based on trigger
// note: all events use issue_number for consistency (PRs are issues in GitHub's API) // note: all events use issue_number for consistency (PRs are issues in GitHub's API)
export type PayloadEvent = export type PayloadEvent =
| { | PullRequestOpenedEvent
trigger: "pull_request_opened"; | PullRequestReadyForReviewEvent
issue_number: number; | PullRequestReviewRequestedEvent
pr_title: string; | PullRequestReviewSubmittedEvent
pr_body: string | null; | PullRequestReviewCommentCreatedEvent
branch: string; | IssuesOpenedEvent
[key: string]: any; | IssuesAssignedEvent
} | IssuesLabeledEvent
| { | IssueCommentCreatedEvent
trigger: "pull_request_ready_for_review"; | CheckSuiteCompletedEvent
issue_number: number; | WorkflowDispatchEvent
pr_title: string; | FixReviewEvent
pr_body: string | null; | UnknownEvent;
branch: string;
[key: string]: any;
}
| {
trigger: "pull_request_review_requested";
issue_number: number;
pr_title: string;
pr_body: string | null;
branch: string;
[key: string]: any;
}
| {
trigger: "pull_request_review_submitted";
issue_number: number;
review_id: number;
review_body: string | null;
review_state: string;
review_comments: any[];
context: any;
branch: string;
[key: string]: any;
}
| {
trigger: "pull_request_review_comment_created";
issue_number: number;
pr_title: string;
comment_id: number;
comment_body: string;
thread?: any;
branch: string;
[key: string]: any;
}
| {
trigger: "issues_opened";
issue_number: number;
issue_title: string;
issue_body: string | null;
[key: string]: any;
}
| {
trigger: "issues_assigned";
issue_number: number;
issue_title: string;
issue_body: string | null;
[key: string]: any;
}
| {
trigger: "issues_labeled";
issue_number: number;
issue_title: string;
issue_body: string | null;
[key: string]: any;
}
| {
trigger: "issue_comment_created";
comment_id: number;
comment_body: string;
issue_number: number;
branch?: string;
[key: string]: any;
}
| {
trigger: "check_suite_completed";
issue_number: number;
pr_title: string;
pr_body: string | null;
pull_request: any;
branch: string;
check_suite: {
id: number;
head_sha: string;
head_branch: string | null;
status: string | null;
conclusion: string | null;
url: string;
};
[key: string]: any;
}
| {
trigger: "workflow_dispatch";
[key: string]: any;
}
| {
trigger: "fix_review";
issue_number: number;
review_id: number;
/** "all" to fix all comments, or specific comment IDs to fix */
comment_ids: number[] | "all";
branch: string;
[key: string]: any;
}
| {
trigger: "unknown";
[key: string]: any;
};
export interface DispatchOptions { export interface DispatchOptions {
/** /**
+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;
+4 -4
View File
@@ -30,7 +30,7 @@ await mcp.call("gh_pullfrog/get_check_suite_logs", {
### review tools ### review tools
#### `get_review_comments` #### `get_review_comments`
get all line-by-line comments for a specific pull request review. get all line-by-line comments and their replies for a specific pull request review.
**parameters:** **parameters:**
- `pull_number` (number): the pull request number - `pull_number` (number): the pull request number
@@ -39,11 +39,11 @@ get all line-by-line comments for a specific pull request review.
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments` **replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments`
**returns:** **returns:**
array of review comments including: array of review comments including threaded replies:
- file path, line number, comment body - file path, line number, comment body
- side (LEFT/RIGHT) and position in diff - side (LEFT/RIGHT) and position in diff
- user, timestamps, html_url - user, timestamps, html_url
- in_reply_to_id for threaded comments - in_reply_to_id for threaded comments (replies have this set to the parent comment id)
**example:** **example:**
```typescript ```typescript
@@ -111,7 +111,7 @@ see individual files for documentation on other tools:
## usage in agents ## usage in agents
agents should never use the `gh` cli. instead, they should use the mcp tools provided by this server. agents should prefer using the mcp tools provided by this server. the `gh` cli is available as a fallback if needed, but mcp tools handle authentication and provide better integration.
the agent instructions automatically include guidance on using these tools. the agent instructions automatically include guidance on using these tools.
+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,
};
}),
});
}
+106 -97
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();
@@ -177,7 +183,8 @@ export async function reportProgress({ body }: { body: string }): Promise<
// no existing comment - create one // no existing comment - create one
const issueNumber = ctx.payload.event.issue_number; const issueNumber = ctx.payload.event.issue_number;
if (issueNumber === undefined) { if (issueNumber === undefined) {
throw new Error("cannot create progress comment: no issue_number found in the payload event"); // cannot create comment without issue_number (e.g., workflow_dispatch events)
return undefined;
} }
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
@@ -199,20 +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 });
return { if (!result) {
success: true, // gracefully handle case where no comment can be created
...result, // 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.",
};
}
return {
success: true,
...result,
};
}),
});
}
/** /**
* Check if the progress comment was updated during execution * Check if the progress comment was updated during execution
@@ -225,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,
@@ -310,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})`
@@ -330,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,
@@ -348,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 -46
View File
@@ -1,55 +1,40 @@
import { type } from "arktype"; import { type } from "arktype";
import { log } from "../utils/cli.ts"; import type { Context } from "../main.ts";
import { $ } from "../utils/shell.ts"; import { execute, tool } from "./shared.ts";
import { contextualize, 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 and automatically prepare the repository for review by fetching and checking out the PR branch.", 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;
const baseBranch = data.base.ref; // detect fork PRs - head repo differs from base repo
const headBranch = data.head.ref; const isFork = data.head.repo.full_name !== data.base.repo.full_name;
if (!baseBranch) { return {
throw new Error(`Base branch not found for PR #${pull_number}`); number: data.number,
} url: data.html_url,
title: data.title,
// Automatically fetch and checkout branches for review state: data.state,
log.info(`Fetching base branch: origin/${baseBranch}`); draft: data.draft,
$("git", ["fetch", "origin", baseBranch, "--depth=20"]); merged: data.merged,
base: data.base.ref,
// use GitHub's PR ref which works for both fork and non-fork PRs head: data.head.ref,
// refs/pull/{number}/head always points to the PR head commit isFork,
log.info(`Fetching PR #${pull_number} using refs/pull/${pull_number}/head`); };
$("git", ["fetch", "origin", `refs/pull/${pull_number}/head`]); }),
});
log.info(`Checking out PR branch: ${headBranch}`); }
// check out a local branch from FETCH_HEAD (the PR ref we just fetched)
$("git", ["checkout", "-B", headBranch, "FETCH_HEAD"]);
return {
number: data.number,
url: data.html_url,
title: data.title,
state: data.state,
draft: data.draft,
merged: data.merged,
base: baseBranch,
head: headBranch,
};
}),
});
+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,
}); };
}),
});
}
+206 -62
View File
@@ -1,76 +1,220 @@
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
// note: diffSide and startDiffSide are on the thread, not the comment
const REVIEW_THREADS_QUERY = `
query ($owner: String!, $repo: String!, $pullNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pullNumber) {
reviewThreads(first: 100) {
nodes {
diffSide
startDiffSide
comments(first: 100) {
nodes {
id
databaseId
body
path
line
startLine
url
author {
login
}
createdAt
updatedAt
pullRequestReview {
databaseId
}
replyTo {
databaseId
}
}
}
}
}
}
}
}
`;
// graphql response types (nodes arrays can contain nulls per GitHub GraphQL spec)
type GraphQLReviewComment = {
id: string;
databaseId: number;
body: string;
path: string;
line: number | null;
startLine: number | null;
url: string;
author: {
login: string;
} | null;
createdAt: string;
updatedAt: string;
pullRequestReview: {
databaseId: number;
} | null;
replyTo: {
databaseId: number;
} | null;
};
type GraphQLReviewThread = {
diffSide: "LEFT" | "RIGHT";
startDiffSide: "LEFT" | "RIGHT" | null;
comments: {
nodes: (GraphQLReviewComment | null)[] | null;
} | null;
} | null;
type GraphQLResponse = {
repository: {
pullRequest: {
reviewThreads: {
nodes: (GraphQLReviewThread | null)[] | null;
} | null;
} | null;
} | null;
};
export const GetReviewComments = type({ export const GetReviewComments = type({
pull_number: type.number.describe("The pull request number"), pull_number: type.number.describe("The pull request number"),
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 for a specific pull request review. Returns line-by-line comments that were left on specific code locations.", 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,
const comments = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listCommentsForReview, { execute: execute(ctx, async ({ pull_number, review_id }) => {
owner: ctx.owner, // fetch all review threads using graphql
repo: ctx.name, const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
pull_number, owner: ctx.owner,
review_id, repo: ctx.name,
}); pullNumber: pull_number,
});
return { const pullRequest = response.repository?.pullRequest;
review_id, if (!pullRequest) {
pull_number, return {
comments: comments.map((comment) => ({ review_id,
id: comment.id, pull_number,
body: comment.body, comments: [],
path: comment.path, count: 0,
line: comment.line, };
side: comment.side, }
start_line: comment.start_line,
start_side: comment.start_side, const threadNodes = pullRequest.reviewThreads?.nodes;
user: typeof comment.user === "string" ? comment.user : comment.user?.login, if (!threadNodes) {
created_at: comment.created_at, return {
updated_at: comment.updated_at, review_id,
html_url: comment.html_url, pull_number,
in_reply_to_id: comment.in_reply_to_id, comments: [],
diff_hunk: comment.diff_hunk, count: 0,
reactions: comment.reactions, };
})), }
count: comments.length,
}; 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
@@ -85,7 +85,7 @@ ${
prompt: `Follow these steps: prompt: `Follow these steps:
1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch) 1. Get PR info with ${ghPullfrogMcpName}/get_pull_request (this automatically prepares the repository by fetching and checking out the PR branch)
2. View diff: \`git diff origin/<base>...origin/<head>\` (replace <base> and <head> with 'base' and 'head' from PR info) 2. **IMPORTANT**: After calling ${ghPullfrogMcpName}/get_pull_request, the PR branch is already checked out locally. View diff using: \`git diff origin/<base>...HEAD\` (replace <base> with 'base' from PR info). Do NOT use \`origin/<head>\` - the branch is checked out locally, not as a remote tracking branch.
3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths. 3. Read files from the checked-out PR branch to understand the implementation. Always use **relative paths** from repo root (e.g., \`src/index.ts\`), never absolute paths.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.136", "version": "0.0.142",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+3 -2
View File
@@ -40,8 +40,9 @@ export function buildPullfrogFooter(params: BuildPullfrogFooterParams): string {
} }
if (params.workflowRun) { if (params.workflowRun) {
const { owner, repo, runId } = params.workflowRun; parts.push(
parts.push(`[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})`); `[View workflow run](https://github.com/${params.workflowRun.owner}/${params.workflowRun.repo}/actions/runs/${params.workflowRun.runId})`
);
} }
if (params.customParts) { if (params.customParts) {
+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
+53 -80
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 type { RepoContext } from "./github.ts";
import { $ } from "./shell.ts"; import { $ } from "./shell.ts";
export interface SetupOptions { export interface SetupOptions {
@@ -63,20 +62,22 @@ 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;
repoContext: RepoContext;
}): void {
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("🔐 Setting up git authentication..."); log.info("🔧 Setting up git authentication...");
// 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,79 +88,51 @@ 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 // embed token directly in origin URL - simple and doesn't expose token in env
// This is scoped to the repo's .git/config, not the user's global config const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`;
const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`; $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
$("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir }); log.info("✓ Updated origin URL with authentication token");
log.info("✓ Updated remote URL with authentication token (scoped to repo)");
}
/** // non-PR events: stay on default branch, push to origin
* Setup git branch based on payload event context if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) {
* Automatically checks out the appropriate branch before agent execution log.debug("Not a PR event, staying on default branch");
*/ return { pushRemote: "origin" };
export function setupGitBranch(payload: Payload): void {
const branch = payload.event.branch;
const repoDir = process.cwd();
if (!branch) {
log.debug("No branch specified in payload, using default branch");
return;
} }
log.info(`🌿 Setting up git branch: ${branch}`); // checkout PR branch - gh pr checkout handles fork remotes and tracking automatically
const prNumber = ctx.payload.event.issue_number;
log.info(`🌿 Checking out PR #${prNumber}...`);
$("gh", ["pr", "checkout", prNumber.toString()], {
cwd: repoDir,
env: { GH_TOKEN: ctx.githubInstallationToken },
});
log.info(`✓ Successfully checked out PR #${prNumber}`);
// if event has issue_number and branch, it's likely a PR - try PR ref first (works for forks) // detect the push remote from branch tracking (set by gh pr checkout)
const issueNumber = "issue_number" in payload.event ? payload.event.issue_number : undefined; const pushRemote = detectPushRemote();
const isLikelyPR = issueNumber !== undefined && branch !== undefined; if (pushRemote !== "origin") {
log.info(`🍴 Fork PR detected, will push to remote: ${pushRemote}`);
if (isLikelyPR) { // embed token in fork remote URL too
try { const forkUrl = $("git", ["remote", "get-url", pushRemote], { cwd: repoDir, log: false });
// use GitHub's PR ref which works for both fork and non-fork PRs const authedForkUrl = forkUrl.replace(
log.debug(`Fetching PR #${issueNumber} using refs/pull/${issueNumber}/head`); "https://github.com/",
execSync(`git fetch origin refs/pull/${issueNumber}/head`, { `https://x-access-token:${ctx.githubInstallationToken}@github.com/`
cwd: repoDir,
stdio: "pipe",
});
// checkout from FETCH_HEAD (the PR ref we just fetched)
log.debug(`Checking out branch: ${branch}`);
execSync(`git checkout -B ${branch} FETCH_HEAD`, {
cwd: repoDir,
stdio: "pipe",
});
log.info(`✓ Successfully checked out PR branch: ${branch}`);
return;
} catch (error) {
// if PR ref fetch fails, fall back to branch name fetch
log.debug(
`PR ref fetch failed, falling back to branch name fetch: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// fallback: fetch by branch name (for non-PR contexts or if PR ref fetch failed)
try {
log.debug(`Fetching branch from origin: ${branch}`);
execSync(`git fetch origin ${branch}`, {
cwd: repoDir,
stdio: "pipe",
});
// checkout the branch, creating local tracking branch
log.debug(`Checking out branch: ${branch}`);
execSync(`git checkout -B ${branch} origin/${branch}`, {
cwd: repoDir,
stdio: "pipe",
});
log.info(`✓ Successfully checked out branch: ${branch}`);
} catch (error) {
// if git operations fail, log warning but don't fail the action
// the agent might still be able to work with the default branch
log.warning(
`Failed to checkout branch ${branch}: ${error instanceof Error ? error.message : String(error)}`
); );
$("git", ["remote", "set-url", pushRemote, authedForkUrl], { cwd: repoDir });
log.info(`✓ Updated ${pushRemote} URL with authentication token`);
}
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";
} }
} }
+2
View File
@@ -14,6 +14,7 @@ interface ShellOptions {
| "ucs2" | "ucs2"
| "utf16le"; | "utf16le";
log?: boolean; log?: boolean;
env?: Record<string, string>;
onError?: (result: { status: number; stdout: string; stderr: string }) => void; onError?: (result: { status: number; stdout: string; stderr: string }) => void;
} }
@@ -36,6 +37,7 @@ export function $(cmd: string, args: string[], options?: ShellOptions): string {
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
encoding, encoding,
cwd: options?.cwd, cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
}); });
const stdout = result.stdout ?? ""; const stdout = result.stdout ?? "";