refactor main

This commit is contained in:
David Blass
2025-12-17 16:28:17 -05:00
parent 1f1c1602c5
commit 90ed2648be
24 changed files with 638 additions and 546 deletions
+2
View File
@@ -6,6 +6,8 @@ import { gemini } from "./gemini.ts";
import { opencode } from "./opencode.ts"; import { opencode } from "./opencode.ts";
import type { Agent } from "./shared.ts"; import type { Agent } from "./shared.ts";
export type { Agent } from "./shared.ts";
export const agents = { export const agents = {
claude, claude,
codex, codex,
+1 -1
View File
@@ -506,7 +506,7 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
export interface AgentInput { export interface AgentInput {
name: AgentName; name: AgentName;
install: () => Promise<string>; install: (token?: string) => Promise<string>;
run: (config: AgentConfig) => Promise<AgentResult>; run: (config: AgentConfig) => Promise<AgentResult>;
} }
+238 -195
View File
@@ -80145,7 +80145,7 @@ function query({
// package.json // package.json
var package_default = { var package_default = {
name: "@pullfrog/action", name: "@pullfrog/action",
version: "0.0.144", version: "0.0.146",
type: "module", type: "module",
files: [ files: [
"index.js", "index.js",
@@ -95158,7 +95158,7 @@ var handleToolError = (error42) => {
isError: true isError: true
}; };
}; };
var execute = (ctx, fn2) => { var execute = (fn2) => {
return async (params) => { return async (params) => {
try { try {
const result = await fn2(params); const result = await fn2(params);
@@ -95168,9 +95168,6 @@ var execute = (ctx, fn2) => {
} }
}; };
}; };
function isProgressCommentDisabled(ctx) {
return ctx.payload.disableProgressComment === true;
}
function sanitizeSchema(schema2) { function sanitizeSchema(schema2) {
if (!schema2 || typeof schema2 !== "object") { if (!schema2 || typeof schema2 !== "object") {
return schema2; return schema2;
@@ -95251,7 +95248,7 @@ function sanitizeTool(tool2) {
}; };
} }
var addTools = (ctx, server, tools) => { var addTools = (ctx, server, tools) => {
const shouldSanitize = ctx.agentName === "gemini" || ctx.agentName === "opencode"; const shouldSanitize = ctx.agent.name === "gemini" || ctx.agent.name === "opencode";
for (const tool2 of tools) { for (const tool2 of tools) {
const processedTool = shouldSanitize ? sanitizeTool(tool2) : tool2; const processedTool = shouldSanitize ? sanitizeTool(tool2) : tool2;
server.addTool(processedTool); server.addTool(processedTool);
@@ -95306,7 +95303,7 @@ function CreateCommentTool(ctx) {
name: "create_issue_comment", name: "create_issue_comment",
description: "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", description: "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
parameters: Comment, parameters: Comment,
execute: execute(ctx, async ({ issueNumber, body }) => { execute: execute(async ({ issueNumber, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.owner, owner: ctx.owner,
@@ -95332,7 +95329,7 @@ function EditCommentTool(ctx) {
name: "edit_issue_comment", name: "edit_issue_comment",
description: "Edit a GitHub issue comment by its ID", description: "Edit a GitHub issue comment by its ID",
parameters: EditComment, parameters: EditComment,
execute: execute(ctx, async ({ commentId, body }) => { execute: execute(async ({ commentId, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.owner, owner: ctx.owner,
@@ -95419,7 +95416,7 @@ function ReportProgressTool(ctx) {
name: "report_progress", name: "report_progress",
description: "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", description: "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress, parameters: ReportProgress,
execute: execute(ctx, async ({ body }) => { execute: execute(async ({ body }) => {
const result = await reportProgress(ctx, { body }); const result = await reportProgress(ctx, { body });
if (!result) { if (!result) {
return { return {
@@ -95513,7 +95510,7 @@ function ReplyToReviewCommentTool(ctx) {
name: "reply_to_review_comment", name: "reply_to_review_comment",
description: "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).", description: "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
parameters: ReplyToReviewComment, parameters: ReplyToReviewComment,
execute: execute(ctx, async ({ pull_number, comment_id, body }) => { execute: execute(async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
owner: ctx.owner, owner: ctx.owner,
@@ -123902,16 +123899,17 @@ function $(cmd, args3, options) {
var CheckoutPr = type({ var CheckoutPr = type({
pull_number: type.number.describe("the pull request number to checkout") pull_number: type.number.describe("the pull request number to checkout")
}); });
async function checkoutPrBranch(ctx, pull_number) { async function checkoutPrBranch(params) {
log.debug(`\u{1F500} checking out PR #${pull_number}...`); const { octokit, owner, name, token, pullNumber } = params;
const pr = await ctx.octokit.rest.pulls.get({ log.info(`\u{1F500} checking out PR #${pullNumber}...`);
owner: ctx.owner, const pr = await octokit.rest.pulls.get({
repo: ctx.name, owner,
pull_number repo: name,
pull_number: pullNumber
}); });
const headRepo = pr.data.head.repo; const headRepo = pr.data.head.repo;
if (!headRepo) { if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`); throw new Error(`PR #${pullNumber} source repository was deleted`);
} }
const isFork = headRepo.full_name !== pr.data.base.repo.full_name; const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
const baseBranch = pr.data.base.ref; const baseBranch = pr.data.base.ref;
@@ -123924,18 +123922,18 @@ async function checkoutPrBranch(ctx, pull_number) {
log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`); log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`);
$("git", ["fetch", "--no-tags", "origin", baseBranch]); $("git", ["fetch", "--no-tags", "origin", baseBranch]);
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
log.debug(`\u{1F33F} fetching PR #${pull_number} (${headBranch})...`); log.debug(`\u{1F33F} fetching PR #${pullNumber} (${headBranch})...`);
$("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]); $("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]);
$("git", ["checkout", headBranch]); $("git", ["checkout", headBranch]);
log.debug(`\u2713 checked out PR #${pull_number}`); log.debug(`\u2713 checked out PR #${pullNumber}`);
} }
if (alreadyOnBranch) { if (alreadyOnBranch) {
log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`); log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`);
$("git", ["fetch", "--no-tags", "origin", baseBranch]); $("git", ["fetch", "--no-tags", "origin", baseBranch]);
} }
if (isFork) { if (isFork) {
const remoteName = `pr-${pull_number}`; const remoteName = `pr-${pullNumber}`;
const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`; const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
try { try {
$("git", ["remote", "add", remoteName, forkUrl]); $("git", ["remote", "add", remoteName, forkUrl]);
log.debug(`\u{1F4CC} added remote '${remoteName}' for fork ${headRepo.full_name}`); log.debug(`\u{1F4CC} added remote '${remoteName}' for fork ${headRepo.full_name}`);
@@ -123953,15 +123951,22 @@ async function checkoutPrBranch(ctx, pull_number) {
} else { } else {
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
} }
ctx.toolState.prNumber = pull_number; return { prNumber: pullNumber };
} }
function CheckoutPrTool(ctx) { function CheckoutPrTool(ctx) {
return tool({ return tool({
name: "checkout_pr", name: "checkout_pr",
description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.", description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.",
parameters: CheckoutPr, parameters: CheckoutPr,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
await checkoutPrBranch(ctx, pull_number); const result = await checkoutPrBranch({
octokit: ctx.octokit,
owner: ctx.owner,
name: ctx.name,
token: ctx.githubInstallationToken,
pullNumber: pull_number
});
ctx.toolState.prNumber = result.prNumber;
const pr = await ctx.octokit.rest.pulls.get({ const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -123995,7 +124000,7 @@ function GetCheckSuiteLogsTool(ctx) {
name: "get_check_suite_logs", name: "get_check_suite_logs",
description: "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", description: "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
parameters: GetCheckSuiteLogs, parameters: GetCheckSuiteLogs,
execute: execute(ctx, async ({ check_suite_id }) => { execute: execute(async ({ check_suite_id }) => {
const workflowRuns = await ctx.octokit.paginate( const workflowRuns = await ctx.octokit.paginate(
ctx.octokit.rest.actions.listWorkflowRunsForRepo, ctx.octokit.rest.actions.listWorkflowRunsForRepo,
{ {
@@ -124076,7 +124081,7 @@ function DebugShellCommandTool(_ctx) {
name: "debug_shell_command", name: "debug_shell_command",
description: "debug tool: runs 'git status' and returns the output. use this to test shell command execution in the MCP server.", description: "debug tool: runs 'git status' and returns the output. use this to test shell command execution in the MCP server.",
parameters: DebugShellCommand, parameters: DebugShellCommand,
execute: execute(_ctx, async () => { execute: execute(async () => {
const result = $("git", ["status"]); const result = $("git", ["status"]);
return { return {
success: true, success: true,
@@ -124097,7 +124102,7 @@ function ListFilesTool(_ctx) {
name: "list_files", name: "list_files",
description: "List files in the repository, including both git-tracked and untracked files. Useful for discovering the file structure and locating files, including newly created files that haven't been committed yet.", description: "List files in the repository, including both git-tracked and untracked files. Useful for discovering the file structure and locating files, including newly created files that haven't been committed yet.",
parameters: ListFiles, parameters: ListFiles,
execute: execute(_ctx, async ({ path: path4 }) => { execute: execute(async ({ path: path4 }) => {
const pathStr = path4 ?? "."; const pathStr = path4 ?? ".";
const cwd2 = process.cwd(); const cwd2 = process.cwd();
let gitFiles = []; let gitFiles = [];
@@ -124194,7 +124199,7 @@ function CreateBranchTool(ctx) {
name: "create_branch", 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.", 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, parameters: CreateBranch,
execute: execute(ctx, async ({ branchName, baseBranch }) => { execute: execute(async ({ branchName, baseBranch }) => {
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main"; const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
if (containsSecrets(branchName)) { if (containsSecrets(branchName)) {
throw new Error( throw new Error(
@@ -124222,12 +124227,12 @@ var CommitFiles = type({
"Array of file paths to commit (relative to repo root). If empty, commits all staged changes." "Array of file paths to commit (relative to repo root). If empty, commits all staged changes."
) )
}); });
function CommitFilesTool(ctx) { function CommitFilesTool(_ctx) {
return tool({ return tool({
name: "commit_files", name: "commit_files",
description: "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.", description: "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.",
parameters: CommitFiles, parameters: CommitFiles,
execute: execute(ctx, async ({ message, files }) => { execute: execute(async ({ message, files }) => {
if (containsSecrets(message)) { if (containsSecrets(message)) {
throw new Error( throw new Error(
"Commit blocked: secrets detected in commit message. 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."
@@ -124277,7 +124282,7 @@ function PushBranchTool(_ctx) {
name: "push_branch", name: "push_branch",
description: "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.", description: "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.",
parameters: PushBranch, parameters: PushBranch,
execute: execute(_ctx, async ({ branchName, force }) => { execute: execute(async ({ branchName, force }) => {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
let remote = "origin"; let remote = "origin";
try { try {
@@ -124313,7 +124318,7 @@ function IssueTool(ctx) {
name: "create_issue", name: "create_issue",
description: "Create a new GitHub issue", description: "Create a new GitHub issue",
parameters: Issue, parameters: Issue,
execute: execute(ctx, async ({ title, body, labels, assignees }) => { execute: execute(async ({ title, body, labels, assignees }) => {
const result = await ctx.octokit.rest.issues.create({ const result = await ctx.octokit.rest.issues.create({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -124329,7 +124334,9 @@ function IssueTool(ctx) {
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)
}; };
}) })
@@ -124345,7 +124352,7 @@ function GetIssueCommentsTool(ctx) {
name: "get_issue_comments", name: "get_issue_comments",
description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
parameters: GetIssueComments, parameters: GetIssueComments,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
ctx.toolState.issueNumber = issue_number; ctx.toolState.issueNumber = issue_number;
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
owner: ctx.owner, owner: ctx.owner,
@@ -124379,7 +124386,7 @@ function GetIssueEventsTool(ctx) {
name: "get_issue_events", name: "get_issue_events",
description: "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", description: "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.",
parameters: GetIssueEvents, parameters: GetIssueEvents,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
ctx.toolState.issueNumber = issue_number; ctx.toolState.issueNumber = issue_number;
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, { const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
owner: ctx.owner, owner: ctx.owner,
@@ -124451,7 +124458,7 @@ function IssueInfoTool(ctx) {
name: "get_issue", name: "get_issue",
description: "Retrieve GitHub issue information by issue number", description: "Retrieve GitHub issue information by issue number",
parameters: IssueInfo, parameters: IssueInfo,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
const issue3 = await ctx.octokit.rest.issues.get({ const issue3 = await ctx.octokit.rest.issues.get({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -124503,7 +124510,7 @@ function AddLabelsTool(ctx) {
name: "add_labels", name: "add_labels",
description: "Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.", description: "Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.",
parameters: AddLabelsParams, parameters: AddLabelsParams,
execute: execute(ctx, async ({ issue_number, labels }) => { execute: execute(async ({ issue_number, labels }) => {
const result = await ctx.octokit.rest.issues.addLabels({ const result = await ctx.octokit.rest.issues.addLabels({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -124540,7 +124547,7 @@ function PullRequestTool(ctx) {
name: "create_pull_request", name: "create_pull_request",
description: "Create a pull request from the current branch", description: "Create a pull request from the current branch",
parameters: PullRequest, parameters: PullRequest,
execute: execute(ctx, async ({ title, body, base }) => { execute: execute(async ({ title, body, base }) => {
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.debug(`Current branch: ${currentBranch}`); log.debug(`Current branch: ${currentBranch}`);
if (containsSecrets(title) || containsSecrets(body)) { if (containsSecrets(title) || containsSecrets(body)) {
@@ -124585,7 +124592,7 @@ function PullRequestInfoTool(ctx) {
name: "get_pull_request", name: "get_pull_request",
description: "Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.", description: "Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.",
parameters: PullRequestInfo, parameters: PullRequestInfo,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const pr = await ctx.octokit.rest.pulls.get({ const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -124648,7 +124655,7 @@ function StartReviewTool(ctx) {
name: "start_review", name: "start_review",
description: "Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.", description: "Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.",
parameters: StartReview, parameters: StartReview,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
if (ctx.toolState.review) { if (ctx.toolState.review) {
throw new Error( throw new Error(
`Review session already in progress. Call submit_review first to finish it.` `Review session already in progress. Call submit_review first to finish it.`
@@ -124723,7 +124730,7 @@ function AddReviewCommentTool(ctx) {
name: "add_review_comment", name: "add_review_comment",
description: "Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.", description: "Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.",
parameters: AddReviewComment, parameters: AddReviewComment,
execute: execute(ctx, async ({ path: path4, line, body, side }) => { execute: execute(async ({ path: path4, line, body, side }) => {
if (!ctx.toolState.review) { if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first."); throw new Error("No review session started. Call start_review first.");
} }
@@ -124754,7 +124761,7 @@ function SubmitReviewTool(ctx) {
name: "submit_review", name: "submit_review",
description: "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.", description: "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.",
parameters: SubmitReview, parameters: SubmitReview,
execute: execute(ctx, async ({ body }) => { execute: execute(async ({ body }) => {
if (!ctx.toolState.review) { if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first."); throw new Error("No review session started. Call start_review first.");
} }
@@ -124860,7 +124867,7 @@ function GetReviewCommentsTool(ctx) {
name: "get_review_comments", name: "get_review_comments",
description: "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", description: "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
parameters: GetReviewComments, parameters: GetReviewComments,
execute: execute(ctx, async ({ pull_number, review_id }) => { execute: execute(async ({ pull_number, review_id }) => {
const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, { const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -124930,7 +124937,7 @@ function ListPullRequestReviewsTool(ctx) {
name: "list_pull_request_reviews", name: "list_pull_request_reviews",
description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.", description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
parameters: ListPullRequestReviews, parameters: ListPullRequestReviews,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, { const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -124964,7 +124971,7 @@ function SelectModeTool(ctx) {
name: "select_mode", name: "select_mode",
description: "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.", description: "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
parameters: SelectMode, parameters: SelectMode,
execute: execute(ctx, async ({ modeName }) => { execute: execute(async ({ modeName }) => {
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()); const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
if (!selectedMode) { if (!selectedMode) {
const availableModes = ctx.modes.map((m) => m.name).join(", "); const availableModes = ctx.modes.map((m) => m.name).join(", ");
@@ -125038,7 +125045,7 @@ async function startMcpHttpServer(ctx) {
PushBranchTool(ctx), PushBranchTool(ctx),
ListFilesTool(ctx) ListFilesTool(ctx)
]; ];
if (!isProgressCommentDisabled(ctx)) { if (!ctx.payload.disableProgressComment) {
tools.push(ReportProgressTool(ctx)); tools.push(ReportProgressTool(ctx));
} }
addTools(ctx, server, tools); addTools(ctx, server, tools);
@@ -125688,7 +125695,7 @@ function setupGitConfig() {
); );
} }
} }
async function setupGit(ctx) { async function setupGitAuth(params) {
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("\xBB setting up git authentication..."); log.info("\xBB setting up git authentication...");
try { try {
@@ -125700,16 +125707,23 @@ async function setupGit(ctx) {
} catch { } catch {
log.debug("\xBB no existing authentication headers to remove"); log.debug("\xBB no existing authentication headers to remove");
} }
if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) { if (params.payload.event.is_pr !== true || !params.payload.event.issue_number) {
const originUrl2 = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; const originUrl2 = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl2], { cwd: repoDir }); $("git", ["remote", "set-url", "origin", originUrl2], { cwd: repoDir });
log.info("\xBB updated origin URL with authentication token"); log.info("\xBB updated origin URL with authentication token");
return; return;
} }
const prNumber = ctx.payload.event.issue_number; const prNumber = params.payload.event.issue_number;
const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
await checkoutPrBranch(ctx, prNumber); const prContext = await checkoutPrBranch({
octokit: params.octokit,
owner: params.owner,
name: params.name,
token: params.token,
pullNumber: prNumber
});
params.toolState.prNumber = prContext.prNumber;
} }
// utils/timer.ts // utils/timer.ts
@@ -125745,28 +125759,105 @@ async function main(inputs) {
try { try {
const timer = new Timer(); const timer = new Timer();
payload = parsePayload(inputs); payload = parsePayload(inputs);
const partialCtx = await initializeContext(inputs, payload); Inputs.assert(inputs);
const ctx = partialCtx; setupGitConfig();
ctx.toolState = {}; const [githubSetup, sharedTempDir] = await Promise.all([
timer.checkpoint("initializeContext"); initializeGitHub(),
await setupGit(ctx); createTempDirectory()
timer.checkpoint("setupGit"); ]);
await setupTempDirectory(ctx); timer.checkpoint("githubSetup");
timer.checkpoint("setupTempDirectory"); const agent2 = resolveAgent({
const [, prepResults] = await Promise.all([installAgentCli(ctx), runPrepPhase()]); inputs,
ctx.prepResults = prepResults; payload,
repoSettings: githubSetup.repoSettings
});
const resolvedPayload = { ...payload, agent: agent2.name };
const apiKeySetup = validateApiKey({
agent: agent2,
inputs,
owner: githubSetup.owner,
name: githubSetup.name
});
if (!apiKeySetup.success) {
await reportErrorToComment({ error: apiKeySetup.error });
return { success: false, error: apiKeySetup.error };
}
const toolState = {};
const [prepResults, cliPath] = await Promise.all([
runPrepPhase(),
installAgentCli({ agent: agent2, token: githubSetup.token }),
setupGitAuth({
token: githubSetup.token,
owner: githubSetup.owner,
name: githubSetup.name,
payload: resolvedPayload,
octokit: githubSetup.octokit,
toolState
})
]);
timer.checkpoint("prep+agentSetup+gitAuth");
const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || void 0; const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || void 0;
ctx.modes = [ const computedModes = [
...getModes({ ...getModes({
disableProgressComment: ctx.payload.disableProgressComment, disableProgressComment: resolvedPayload.disableProgressComment,
dependenciesPreinstalled dependenciesPreinstalled
}), }),
...ctx.payload.modes || [] ...resolvedPayload.modes || []
]; ];
timer.checkpoint("installAgentCli+prepPhase"); const runId = process.env.GITHUB_RUN_ID || "";
await startMcpServer(ctx); if (runId) {
mcpServerClose = ctx.mcpServerClose; const workflowRunInfo = await fetchWorkflowRunInfo(runId);
timer.checkpoint("startMcpServer"); if (workflowRunInfo.progressCommentId) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
log.info(`\u{1F4DD} Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
}
}
let jobId;
const jobName = process.env.GITHUB_JOB;
if (jobName && runId) {
const jobs = await githubSetup.octokit.rest.actions.listJobsForWorkflowRun({
owner: githubSetup.owner,
repo: githubSetup.name,
run_id: parseInt(runId, 10)
});
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) {
jobId = String(matchingJob.id);
log.info(`\u{1F4CB} Found job ID: ${jobId}`);
}
}
const toolContext = {
owner: githubSetup.owner,
name: githubSetup.name,
githubInstallationToken: githubSetup.token,
octokit: githubSetup.octokit,
payload: resolvedPayload,
repo: githubSetup.repo,
repoSettings: githubSetup.repoSettings,
modes: computedModes,
prepResults,
toolState,
agent: agent2,
sharedTempDir,
runId,
jobId
};
const { url: mcpServerUrl, close: mcpServerCloseFunc } = await startMcpHttpServer(toolContext);
mcpServerClose = mcpServerCloseFunc;
log.info(`\u{1F680} MCP server started at ${mcpServerUrl}`);
const mcpServers = createMcpConfigs(mcpServerUrl);
log.debug(`\u{1F4CB} MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
timer.checkpoint("mcpServer");
const ctx = {
...toolContext,
inputs,
mcpServerUrl,
mcpServerClose: mcpServerCloseFunc,
mcpServers,
cliPath,
apiKey: apiKeySetup.apiKey,
apiKeys: apiKeySetup.apiKeys
};
if (ctx.payload.event.trigger === "fix_review" && Array.isArray(ctx.payload.event.comment_ids) && ctx.payload.event.comment_ids.length === 0) { if (ctx.payload.event.trigger === "fix_review" && Array.isArray(ctx.payload.event.comment_ids) && ctx.payload.event.comment_ids.length === 0) {
await reportProgress(ctx, { await reportProgress(ctx, {
body: `\u{1F44D} **No approved comments found** body: `\u{1F44D} **No approved comments found**
@@ -125775,8 +125866,6 @@ To use "Fix \u{1F44D}s", add a \u{1F44D} reaction to one or more inline review c
}); });
return { success: true }; return { success: true };
} }
setupMcpServers(ctx);
await validateApiKey(ctx);
const result = await runAgent(ctx); const result = await runAgent(ctx);
const mainResult = await handleAgentResult(result); const mainResult = await handleAgentResult(result);
return mainResult; return mainResult;
@@ -125803,13 +125892,17 @@ To use "Fix \u{1F44D}s", add a \u{1F44D} reaction to one or more inline review c
await revokeGitHubInstallationToken(); await revokeGitHubInstallationToken();
} }
} }
function agentHasApiKeys(agent2, inputs) {
if (agent2.name === "opencode") {
const hasInputKey = Object.keys(inputs).some((key) => key.includes("api_key"));
if (hasInputKey) return true;
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
}
const inputsRecord = inputs;
return agent2.apiKeyNames.some((inputKey) => inputsRecord[inputKey]);
}
function getAvailableAgents(inputs) { function getAvailableAgents(inputs) {
return Object.values(agents).filter((agent2) => { return Object.values(agents).filter((agent2) => agentHasApiKeys(agent2, inputs));
if (agent2.name === "opencode") {
return Object.keys(inputs).some((key) => key.includes("api_key"));
}
return agent2.apiKeyNames.some((inputKey) => inputs[inputKey]);
});
} }
function getAllPossibleKeyNames() { function getAllPossibleKeyNames() {
return Object.keys( return Object.keys(
@@ -125819,21 +125912,21 @@ function getAllPossibleKeyNames() {
) )
); );
} }
async function throwMissingApiKeyError(ctx) { function buildMissingApiKeyError(params) {
const apiUrl = process.env.API_URL || "https://pullfrog.com"; const apiUrl = process.env.API_URL || "https://pullfrog.com";
const settingsUrl = `${apiUrl}/console/${ctx.owner}/${ctx.name}`; const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
const githubRepoUrl = `https://github.com/${ctx.owner}/${ctx.name}`; const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`;
const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`; const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`;
const isOpenCode = ctx.agent?.name === "opencode"; const isOpenCode = params.agent.name === "opencode";
let secretNameList; let secretNameList;
if (isOpenCode) { if (isOpenCode) {
secretNameList = "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)"; secretNameList = "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)";
} else { } else {
const inputKeys = ctx.agent?.apiKeyNames || getAllPossibleKeyNames(); const inputKeys = params.agent.apiKeyNames.length > 0 ? params.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 ")}`;
} }
const message = `Pullfrog is configured to use ${ctx.agent.displayName}, but the associated API key was not provided. return `Pullfrog is configured to use ${params.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:
@@ -125844,53 +125937,23 @@ To fix this, add the required secret to your GitHub repository:
5. Click "Add secret" 5. Click "Add secret"
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`; Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
await reportErrorToComment({ error: message });
throw new Error(message);
} }
async function initializeContext(inputs, payload) { async function initializeGitHub() {
log.info(`\u{1F438} Running pullfrog/action@${package_default.version}...`); log.info(`\u{1F438} Running pullfrog/action@${package_default.version}...`);
Inputs.assert(inputs); const token = await setupGitHubInstallationToken();
setupGitConfig();
const githubInstallationToken2 = await setupGitHubInstallationToken();
const { owner, name } = parseRepoContext(); const { owner, name } = parseRepoContext();
const octokit = new Octokit2({ const octokit = new Octokit2({ auth: token });
auth: githubInstallationToken2 const [repoResponse, repoSettings] = await Promise.all([
}); octokit.repos.get({ owner, repo: name }),
const response = await octokit.repos.get({ fetchRepoSettings({ token, repoContext: { owner, name } })
owner, ]);
repo: name
});
const repo = response.data;
const repoSettings = await fetchRepoSettings({
token: githubInstallationToken2,
repoContext: { owner, name }
});
const { agentName, agent: agent2 } = resolveAgent({
inputs,
payload,
repoSettings
});
const resolvedPayload = { ...payload, agent: agentName };
const computedModes = [
...getModes({
disableProgressComment: resolvedPayload.disableProgressComment,
dependenciesPreinstalled: void 0
}),
...resolvedPayload.modes || []
];
return { return {
token,
owner, owner,
name, name,
inputs,
githubInstallationToken: githubInstallationToken2,
octokit, octokit,
repo, repo: repoResponse.data,
agentName, repoSettings
agent: agent2,
payload: resolvedPayload,
repoSettings,
modes: computedModes,
sharedTempDir: ""
}; };
} }
function resolveAgent({ function resolveAgent({
@@ -125901,41 +125964,37 @@ function resolveAgent({
const agentOverride = process.env.AGENT_OVERRIDE; const agentOverride = process.env.AGENT_OVERRIDE;
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null; const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
if (configuredAgentName) { if (configuredAgentName) {
const agentName2 = configuredAgentName; const agent3 = agents[configuredAgentName];
const agent3 = agents[agentName2];
if (!agent3) { if (!agent3) {
throw new Error(`invalid agent name: ${agentName2}`); throw new Error(`invalid agent name: ${configuredAgentName}`);
} }
const isExplicitOverride = agentOverride !== void 0 || payload.agent !== null; const isExplicitOverride = agentOverride !== void 0 || payload.agent !== null;
if (isExplicitOverride) { if (isExplicitOverride) {
log.info(`\xBB selected configured agent: ${agentName2}`); log.info(`Selected configured agent: ${agent3.name}`);
return { agentName: agentName2, agent: agent3 }; return agent3;
} }
const hasMatchingKey = agent3.name === "opencode" ? Object.keys(inputs).some((key) => key.includes("api_key")) : agent3.apiKeyNames.some((inputKey) => inputs[inputKey]); if (agentHasApiKeys(agent3, inputs)) {
if (!hasMatchingKey) { log.info(`Selected configured agent: ${agent3.name}`);
log.warning( return agent3;
`Repo default agent ${agentName2} has no matching API keys. Available agents: ${getAvailableAgents(inputs).map((a) => a.name).join(", ") || "none"}`
);
} else {
log.info(`\xBB selected configured agent: ${agentName2}`);
return { agentName: agentName2, agent: agent3 };
} }
const availableAgents2 = getAvailableAgents(inputs);
log.warning(
`Repo default agent ${agent3.name} has no matching API keys. Available: ${availableAgents2.map((a) => a.name).join(", ") || "none"}`
);
} }
const availableAgents = getAvailableAgents(inputs); const availableAgents = getAvailableAgents(inputs);
const availableAgentNames = availableAgents.map((agent3) => agent3.name).join(", ");
log.debug(`\xBB available agents: ${availableAgentNames || "none"}`);
if (availableAgents.length === 0) { if (availableAgents.length === 0) {
throw new Error("no agents available - missing API keys"); throw new Error("no agents available - missing API keys");
} }
const agentName = availableAgents[0].name;
const agent2 = availableAgents[0]; const agent2 = availableAgents[0];
log.info(`\xBB no agent configured, defaulting to first available agent: ${agentName}`); log.info(`No agent configured, defaulting to first available agent: ${agent2.name}`);
return { agentName, agent: agent2 }; return agent2;
} }
async function setupTempDirectory(ctx) { async function createTempDirectory() {
ctx.sharedTempDir = await mkdtemp2(join11(tmpdir2(), "pullfrog-")); const sharedTempDir = await mkdtemp2(join11(tmpdir2(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.debug(`\xBB created temp dir at ${ctx.sharedTempDir}`); log.info(`\u{1F4C2} PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`);
return sharedTempDir;
} }
function parsePayload(inputs) { function parsePayload(inputs) {
try { try {
@@ -125956,71 +126015,55 @@ function parsePayload(inputs) {
}; };
} }
} }
async function startMcpServer(ctx) { async function installAgentCli(params) {
const runId = process.env.GITHUB_RUN_ID || ""; if (params.agent.name === "gemini") {
ctx.runId = runId; return params.agent.install(params.token);
if (runId) {
const workflowRunInfo = await fetchWorkflowRunInfo(ctx.runId);
if (workflowRunInfo.progressCommentId) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
log.info(`\xBB using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
}
} }
const jobName = process.env.GITHUB_JOB; return params.agent.install();
if (jobName && runId) {
const jobs = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
owner: ctx.owner,
repo: ctx.name,
run_id: parseInt(ctx.runId, 10)
});
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) {
ctx.jobId = String(matchingJob.id);
log.info(`\xBB found job ID: ${ctx.jobId}`);
}
}
const { url: url2, close } = await startMcpHttpServer(ctx);
ctx.mcpServerUrl = url2;
ctx.mcpServerClose = close;
log.info(`\xBB MCP server started at ${url2}`);
} }
function setupMcpServers(ctx) { function collectApiKeys(agent2, inputs) {
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
log.debug(`\xBB MCP config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
}
async function installAgentCli(ctx) {
if (ctx.agentName === "gemini") {
ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken);
} else {
ctx.cliPath = await ctx.agent.install();
}
}
async function validateApiKey(ctx) {
const apiKeys = {}; const apiKeys = {};
for (const inputKey of ctx.agent.apiKeyNames) { const inputsRecord = inputs;
const value2 = ctx.inputs[inputKey]; for (const inputKey of agent2.apiKeyNames) {
const value2 = inputsRecord[inputKey];
if (value2) { if (value2) {
apiKeys[inputKey] = value2; apiKeys[inputKey] = value2;
} }
} }
if (ctx.agentName === "opencode" && Object.keys(apiKeys).length === 0) { if (agent2.name === "opencode" && Object.keys(apiKeys).length === 0) {
for (const [key, value2] of Object.entries(process.env)) { for (const [key, value2] of Object.entries(process.env)) {
if (value2 && typeof value2 === "string" && key.includes("API_KEY")) { if (value2 && typeof value2 === "string" && key.includes("API_KEY")) {
const inputKey = key.toLowerCase(); apiKeys[key.toLowerCase()] = value2;
apiKeys[inputKey] = value2;
} }
} }
} }
return apiKeys;
}
function validateApiKey(params) {
const apiKeys = collectApiKeys(params.agent, params.inputs);
if (Object.keys(apiKeys).length === 0) { if (Object.keys(apiKeys).length === 0) {
await throwMissingApiKeyError(ctx); return {
return; success: false,
error: buildMissingApiKeyError({
agent: params.agent,
owner: params.owner,
name: params.name
})
};
} }
ctx.apiKey = Object.values(apiKeys)[0]; return {
ctx.apiKeys = apiKeys; success: true,
apiKey: Object.values(apiKeys)[0],
apiKeys
};
} }
async function runAgent(ctx) { async function runAgent(ctx) {
log.info(`\xBB running ${ctx.agentName}...`); log.info(`Running ${ctx.agent.name}...`);
log.box(ctx.payload.prompt.trim(), { title: "Prompt" }); const { context: _context, ...eventWithoutContext } = ctx.payload.event;
const promptContent = `${ctx.payload.prompt}
${encode(eventWithoutContext)}`;
log.box(promptContent, { title: "Prompt" });
return ctx.agent.run({ return ctx.agent.run({
payload: ctx.payload, payload: ctx.payload,
mcpServers: ctx.mcpServers, mcpServers: ctx.mcpServers,
+247 -242
View File
@@ -3,8 +3,9 @@ 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 { Octokit } from "@octokit/rest";
import { encode as toonEncode } from "@toon-format/toon";
import { type } from "arktype"; import { type } from "arktype";
import { agents } from "./agents/index.ts"; import { type Agent, agents } from "./agents/index.ts";
import type { AgentResult } from "./agents/shared.ts"; import type { AgentResult } from "./agents/shared.ts";
import type { AgentName, Payload } from "./external.ts"; import type { AgentName, Payload } from "./external.ts";
import { agentsManifest } from "./external.ts"; import { agentsManifest } from "./external.ts";
@@ -22,7 +23,7 @@ import {
revokeGitHubInstallationToken, revokeGitHubInstallationToken,
setupGitHubInstallationToken, setupGitHubInstallationToken,
} from "./utils/github.ts"; } from "./utils/github.ts";
import { setupGit, setupGitConfig } from "./utils/setup.ts"; import { setupGitAuth, 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)
@@ -50,6 +51,20 @@ export interface MainResult {
error?: string | undefined; error?: string | undefined;
} }
// intermediate result types for deterministic context building
interface GitHubSetup {
token: string;
owner: string;
name: string;
octokit: Octokit;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
type ApiKeySetup =
| { success: true; apiKey: string; apiKeys: Record<string, string> }
| { success: false; error: string };
export async function main(inputs: Inputs): Promise<MainResult> { export async function main(inputs: Inputs): Promise<MainResult> {
let mcpServerClose: (() => Promise<void>) | undefined; let mcpServerClose: (() => Promise<void>) | undefined;
let payload: Payload | undefined; let payload: Payload | undefined;
@@ -57,39 +72,126 @@ export async function main(inputs: Inputs): Promise<MainResult> {
try { try {
const timer = new Timer(); const timer = new Timer();
// parse payload early to extract agent // phase 1: parse and validate inputs
payload = parsePayload(inputs); payload = parsePayload(inputs);
Inputs.assert(inputs);
setupGitConfig();
const partialCtx = await initializeContext(inputs, payload); // phase 2: fast setup (github + temp dir)
const ctx = partialCtx as Context; const [githubSetup, sharedTempDir] = await Promise.all([
ctx.toolState = {}; initializeGitHub(),
timer.checkpoint("initializeContext"); createTempDirectory(),
]);
timer.checkpoint("githubSetup");
await setupGit(ctx); // phase 3: resolve agent (needs repo settings)
timer.checkpoint("setupGit"); const agent = resolveAgent({
inputs,
payload,
repoSettings: githubSetup.repoSettings,
});
const resolvedPayload = { ...payload, agent: agent.name };
await setupTempDirectory(ctx); // phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
timer.checkpoint("setupTempDirectory"); const apiKeySetup = validateApiKey({
agent,
inputs,
owner: githubSetup.owner,
name: githubSetup.name,
});
if (!apiKeySetup.success) {
await reportErrorToComment({ error: apiKeySetup.error });
return { success: false, error: apiKeySetup.error };
}
// TODO: david fix this garbage // phase 5: parallel long-running operations (prep + agent install + git auth)
// run agent CLI installation and prep phase in parallel const toolState: ToolState = {};
const [, prepResults] = await Promise.all([installAgentCli(ctx), runPrepPhase()]); const [prepResults, cliPath] = await Promise.all([
ctx.prepResults = prepResults; runPrepPhase(),
installAgentCli({ agent, token: githubSetup.token }),
setupGitAuth({
token: githubSetup.token,
owner: githubSetup.owner,
name: githubSetup.name,
payload: resolvedPayload,
octokit: githubSetup.octokit,
toolState,
}),
]);
timer.checkpoint("prep+agentSetup+gitAuth");
// recompute modes now that we know if dependencies were preinstalled // phase 6: compute modes (needs prep results)
const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined; const dependenciesPreinstalled = prepResults.every((r) => r.dependenciesInstalled) || undefined;
ctx.modes = [ const computedModes: Mode[] = [
...getModes({ ...getModes({
disableProgressComment: ctx.payload.disableProgressComment, disableProgressComment: resolvedPayload.disableProgressComment,
dependenciesPreinstalled, dependenciesPreinstalled,
}), }),
...(ctx.payload.modes || []), ...(resolvedPayload.modes || []),
]; ];
timer.checkpoint("installAgentCli+prepPhase");
await startMcpServer(ctx); // phase 7: compute runId/jobId for MCP tools
mcpServerClose = ctx.mcpServerClose; const runId = process.env.GITHUB_RUN_ID || "";
timer.checkpoint("startMcpServer"); if (runId) {
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
if (workflowRunInfo.progressCommentId) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
log.info(`📝 Using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
}
}
let jobId: string | undefined;
const jobName = process.env.GITHUB_JOB;
if (jobName && runId) {
const jobs = await githubSetup.octokit.rest.actions.listJobsForWorkflowRun({
owner: githubSetup.owner,
repo: githubSetup.name,
run_id: parseInt(runId, 10),
});
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) {
jobId = String(matchingJob.id);
log.info(`📋 Found job ID: ${jobId}`);
}
}
// phase 8: build tool context and start MCP server
const toolContext: ToolContext = {
owner: githubSetup.owner,
name: githubSetup.name,
githubInstallationToken: githubSetup.token,
octokit: githubSetup.octokit,
payload: resolvedPayload,
repo: githubSetup.repo,
repoSettings: githubSetup.repoSettings,
modes: computedModes,
prepResults,
toolState,
agent,
sharedTempDir,
runId,
jobId,
};
const { url: mcpServerUrl, close: mcpServerCloseFunc } = await startMcpHttpServer(toolContext);
mcpServerClose = mcpServerCloseFunc;
log.info(`🚀 MCP server started at ${mcpServerUrl}`);
const mcpServers = createMcpConfigs(mcpServerUrl);
log.debug(`📋 MCP Config: ${JSON.stringify(mcpServers, null, 2)}`);
timer.checkpoint("mcpServer");
// BUILD FINAL IMMUTABLE CONTEXT
const ctx: AgentContext = {
...toolContext,
inputs,
mcpServerUrl,
mcpServerClose: mcpServerCloseFunc,
mcpServers,
cliPath,
apiKey: apiKeySetup.apiKey,
apiKeys: apiKeySetup.apiKeys,
};
// check for empty comment_ids in fix_review trigger - report and exit early // check for empty comment_ids in fix_review trigger - report and exit early
if ( if (
@@ -103,10 +205,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
return { success: true }; return { success: true };
} }
setupMcpServers(ctx);
await validateApiKey(ctx);
const result = await runAgent(ctx); const result = await runAgent(ctx);
const mainResult = await handleAgentResult(result); const mainResult = await handleAgentResult(result);
return mainResult; return mainResult;
@@ -142,15 +240,22 @@ export async function main(inputs: Inputs): Promise<MainResult> {
/** /**
* Get agents that have matching API keys in the inputs * Get agents that have matching API keys in the inputs
*/ */
function getAvailableAgents(inputs: Inputs): (typeof agents)[AgentName][] { /**
return Object.values(agents).filter((agent) => { * Check if an agent has API keys available (inputs or process.env for opencode)
// for OpenCode, check if any API_KEY variable exists in inputs */
if (agent.name === "opencode") { function agentHasApiKeys(agent: Agent, inputs: Inputs): boolean {
return Object.keys(inputs).some((key) => key.includes("api_key")); if (agent.name === "opencode") {
} // check inputs first, then process.env
// for other agents, check apiKeyNames const hasInputKey = Object.keys(inputs).some((key) => key.includes("api_key"));
return agent.apiKeyNames.some((inputKey) => inputs[inputKey]); if (hasInputKey) return true;
}); return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
}
const inputsRecord = inputs as Record<string, string | undefined>;
return agent.apiKeyNames.some((inputKey) => inputsRecord[inputKey]);
}
function getAvailableAgents(inputs: Inputs): Agent[] {
return Object.values(agents).filter((agent) => agentHasApiKeys(agent, inputs));
} }
/** /**
@@ -165,28 +270,29 @@ function getAllPossibleKeyNames(): string[] {
} }
/** /**
* Throw an error for missing API key with helpful message linking to repo settings * Build a helpful error message for missing API key with links to repo settings
*/ */
async function throwMissingApiKeyError(ctx: Context): Promise<never> { function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string {
const apiUrl = process.env.API_URL || "https://pullfrog.com"; const apiUrl = process.env.API_URL || "https://pullfrog.com";
const settingsUrl = `${apiUrl}/console/${ctx.owner}/${ctx.name}`; const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`;
const githubRepoUrl = `https://github.com/${ctx.owner}/${ctx.name}`; const githubRepoUrl = `https://github.com/${params.owner}/${params.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 = ctx.agent?.name === "opencode"; const isOpenCode = params.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 = ctx.agent?.apiKeyNames || getAllPossibleKeyNames(); const inputKeys =
params.agent.apiKeyNames.length > 0 ? params.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 ")}`;
} }
const message = `Pullfrog is configured to use ${ctx.agent.displayName}, but the associated API key was not provided. return `Pullfrog is configured to use ${params.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:
@@ -197,54 +303,34 @@ To fix this, add the required secret to your GitHub repository:
5. Click "Add secret" 5. Click "Add secret"
Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`; Alternatively, configure Pullfrog to use a different agent at ${settingsUrl}`;
// report to comment if MCP context is available (server has started)
await reportErrorToComment({ error: message });
throw new Error(message);
} }
export interface Context { // tool context - subset of Context needed by MCP tools
// flattened from RepoContext export interface ToolContext {
owner: string; owner: string;
name: string; name: string;
// core fields
inputs: Inputs;
payload: Payload;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
agentName: AgentName;
agent: (typeof agents)[AgentName];
githubInstallationToken: string; githubInstallationToken: string;
octokit: Octokit; octokit: Octokit;
payload: Payload;
// repo settings from Pullfrog API repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings; repoSettings: RepoSettings;
// modes for MCP tools
modes: Mode[]; modes: Mode[];
// setup fields
sharedTempDir: string;
// mcp fields
mcpServerUrl: string;
mcpServerClose: () => Promise<void>;
mcpServers: ReturnType<typeof createMcpConfigs>;
// agent fields
cliPath: string;
apiKey: string;
apiKeys: Record<string, string>;
// prep phase results
prepResults: PrepResult[]; prepResults: PrepResult[];
toolState: ToolState;
// workflow run info agent: Agent;
sharedTempDir: string;
runId: string; runId: string;
jobId: string | undefined; jobId: string | undefined;
}
// tool state - mutable scratchpad for tools export interface AgentContext extends Readonly<ToolContext> {
toolState: ToolState; readonly inputs: Inputs;
readonly mcpServerUrl: string;
readonly mcpServerClose: () => Promise<void>;
readonly mcpServers: ReturnType<typeof createMcpConfigs>;
readonly cliPath: string;
readonly apiKey: string;
readonly apiKeys: Record<string, string>;
} }
export interface ToolState { export interface ToolState {
@@ -256,80 +342,30 @@ export interface ToolState {
}; };
} }
async function initializeContext( /**
inputs: Inputs, * Initialize GitHub connection: token, octokit, repo data, settings
payload: Payload */
): Promise< async function initializeGitHub(): Promise<GitHubSetup> {
Omit<
Context,
| "mcpServerUrl"
| "mcpServerClose"
| "mcpServers"
| "cliPath"
| "apiKey"
| "apiKeys"
| "prepResults"
| "runId"
| "jobId"
| "toolState"
>
> {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
Inputs.assert(inputs);
setupGitConfig();
const githubInstallationToken = await setupGitHubInstallationToken(); const token = await setupGitHubInstallationToken();
const { owner, name } = parseRepoContext(); const { owner, name } = parseRepoContext();
// create octokit instance const octokit = new Octokit({ auth: token });
const octokit = new Octokit({
auth: githubInstallationToken,
});
// fetch repo data // fetch repo data and settings in parallel
const response = await octokit.repos.get({ const [repoResponse, repoSettings] = await Promise.all([
owner, octokit.repos.get({ owner, repo: name }),
repo: name, fetchRepoSettings({ token, repoContext: { owner, 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
const { agentName, agent } = resolveAgent({
inputs,
payload,
repoSettings,
});
const resolvedPayload = { ...payload, agent: agentName };
// compute modes from defaults + payload overrides
// note: dependenciesPreinstalled is undefined here since prepPhase runs after this
const computedModes = [
...getModes({
disableProgressComment: resolvedPayload.disableProgressComment,
dependenciesPreinstalled: undefined,
}),
...(resolvedPayload.modes || []),
];
return { return {
token,
owner, owner,
name, name,
inputs,
githubInstallationToken,
octokit, octokit,
repo, repo: repoResponse.data,
agentName,
agent,
payload: resolvedPayload,
repoSettings, repoSettings,
modes: computedModes,
sharedTempDir: "",
}; };
} }
@@ -341,65 +377,54 @@ function resolveAgent({
inputs: Inputs; inputs: Inputs;
payload: Payload; payload: Payload;
repoSettings: RepoSettings; repoSettings: RepoSettings;
}): { agentName: AgentName; agent: (typeof agents)[AgentName] } { }): Agent {
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;
if (configuredAgentName) { if (configuredAgentName) {
const agentName = configuredAgentName; const agent = agents[configuredAgentName];
const agent = agents[agentName];
if (!agent) { if (!agent) {
throw new Error(`invalid agent name: ${agentName}`); throw new Error(`invalid agent name: ${configuredAgentName}`);
} }
// if explicitly configured (via override or payload), respect it even without matching keys // if explicitly configured (via override or payload), respect it even without matching keys
// this allows users to force an agent selection (will fail later with clear error if no keys) // this allows users to force an agent selection (will fail later with clear error if no keys)
const isExplicitOverride = agentOverride !== undefined || payload.agent !== null; const isExplicitOverride = agentOverride !== undefined || payload.agent !== null;
if (isExplicitOverride) { if (isExplicitOverride) {
log.info(`» selected configured agent: ${agentName}`); log.info(`Selected configured agent: ${agent.name}`);
return { agentName, agent }; return agent;
} }
// for repo-level defaults, check if agent has matching keys before selecting // for repo-level defaults, check if agent has matching keys before selecting
const hasMatchingKey = if (agentHasApiKeys(agent, inputs)) {
agent.name === "opencode" log.info(`Selected configured agent: ${agent.name}`);
? Object.keys(inputs).some((key) => key.includes("api_key")) return agent;
: agent.apiKeyNames.some((inputKey) => inputs[inputKey]);
if (!hasMatchingKey) {
log.warning(
`Repo default agent ${agentName} has no matching API keys. Available agents: ${
getAvailableAgents(inputs)
.map((a) => a.name)
.join(", ") || "none"
}`
);
// fall through to auto-selection for repo defaults
} else {
log.info(`» selected configured agent: ${agentName}`);
return { agentName, agent };
} }
// fall through to auto-selection
const availableAgents = getAvailableAgents(inputs);
log.warning(
`Repo default agent ${agent.name} has no matching API keys. Available: ${
availableAgents.map((a) => a.name).join(", ") || "none"
}`
);
} }
const availableAgents = getAvailableAgents(inputs); const availableAgents = getAvailableAgents(inputs);
const availableAgentNames = availableAgents.map((agent) => agent.name).join(", ");
log.debug(`» available agents: ${availableAgentNames || "none"}`);
if (availableAgents.length === 0) { if (availableAgents.length === 0) {
// this will be caught and reported later in validateApiKey
throw new Error("no agents available - missing API keys"); throw new Error("no agents available - missing API keys");
} }
const agentName = availableAgents[0].name;
const agent = availableAgents[0]; const agent = availableAgents[0];
log.info(`» no agent configured, defaulting to first available agent: ${agentName}`); log.info(`No agent configured, defaulting to first available agent: ${agent.name}`);
return { agentName, agent }; return agent;
} }
async function setupTempDirectory(ctx: Context): Promise<void> { async function createTempDirectory(): Promise<string> {
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); const sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; process.env.PULLFROG_TEMP_DIR = sharedTempDir;
log.debug(`» created temp dir at ${ctx.sharedTempDir}`); log.info(`📂 PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`);
return sharedTempDir;
} }
function parsePayload(inputs: Inputs): Payload { function parsePayload(inputs: Inputs): Payload {
@@ -422,90 +447,70 @@ function parsePayload(inputs: Inputs): Payload {
} }
} }
async function startMcpServer(ctx: Context): Promise<void> { async function installAgentCli(params: { agent: Agent; token: string }): Promise<string> {
const runId = process.env.GITHUB_RUN_ID || "";
ctx.runId = runId;
// 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
if (runId) {
const workflowRunInfo = await fetchWorkflowRunInfo(ctx.runId);
if (workflowRunInfo.progressCommentId) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
log.info(`» using pre-created progress comment: ${workflowRunInfo.progressCommentId}`);
}
}
// fetch job ID by matching GITHUB_JOB name
const jobName = process.env.GITHUB_JOB;
if (jobName && runId) {
const jobs = await ctx.octokit.rest.actions.listJobsForWorkflowRun({
owner: ctx.owner,
repo: ctx.name,
run_id: parseInt(ctx.runId, 10),
});
const matchingJob = jobs.data.jobs.find((job) => job.name === jobName);
if (matchingJob) {
ctx.jobId = String(matchingJob.id);
log.info(`» found job ID: ${ctx.jobId}`);
}
}
const { url, close } = await startMcpHttpServer(ctx);
ctx.mcpServerUrl = url;
ctx.mcpServerClose = close;
log.info(`» MCP server started at ${url}`);
}
function setupMcpServers(ctx: Context): void {
ctx.mcpServers = createMcpConfigs(ctx.mcpServerUrl);
log.debug(`» MCP config: ${JSON.stringify(ctx.mcpServers, null, 2)}`);
}
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 (params.agent.name === "gemini") {
ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken); return params.agent.install(params.token);
} else {
ctx.cliPath = await ctx.agent.install();
} }
return params.agent.install();
} }
async function validateApiKey(ctx: Context): Promise<void> { function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> {
// 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) { const inputsRecord = inputs as Record<string, string | undefined>;
const value = ctx.inputs[inputKey];
for (const inputKey of agent.apiKeyNames) {
const value = inputsRecord[inputKey];
if (value) { if (value) {
apiKeys[inputKey] = value; apiKeys[inputKey] = value;
} }
} }
// for OpenCode: if no keys found in inputs, check process.env for any API_KEY variables // for OpenCode: also check process.env for any API_KEY variables
if (ctx.agentName === "opencode" && Object.keys(apiKeys).length === 0) { if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
for (const [key, value] of Object.entries(process.env)) { for (const [key, value] of Object.entries(process.env)) {
if (value && typeof value === "string" && key.includes("API_KEY")) { if (value && typeof value === "string" && key.includes("API_KEY")) {
// convert env var name back to input key format (lowercase with underscores) apiKeys[key.toLowerCase()] = value;
const inputKey = key.toLowerCase();
apiKeys[inputKey] = value;
} }
} }
} }
if (Object.keys(apiKeys).length === 0) { return apiKeys;
await throwMissingApiKeyError(ctx);
// unreachable - throwMissingApiKeyError always throws
return;
}
// keep apiKey for backward compat (first available key)
ctx.apiKey = Object.values(apiKeys)[0];
ctx.apiKeys = apiKeys;
} }
async function runAgent(ctx: Context): Promise<AgentResult> { function validateApiKey(params: {
log.info(`» running ${ctx.agentName}...`); agent: Agent;
log.box(ctx.payload.prompt.trim(), { title: "Prompt" }); inputs: Inputs;
owner: string;
name: string;
}): ApiKeySetup {
const apiKeys = collectApiKeys(params.agent, params.inputs);
if (Object.keys(apiKeys).length === 0) {
return {
success: false,
error: buildMissingApiKeyError({
agent: params.agent,
owner: params.owner,
name: params.name,
}),
};
}
return {
success: true,
apiKey: Object.values(apiKeys)[0],
apiKeys,
};
}
async function runAgent(ctx: AgentContext): Promise<AgentResult> {
log.info(`Running ${ctx.agent.name}...`);
// strip context from event
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
// format: prompt + two newlines + TOON encoded event
const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`;
log.box(promptContent, { title: "Prompt" });
return ctx.agent.run({ return ctx.agent.run({
payload: ctx.payload, payload: ctx.payload,
+3 -3
View File
@@ -1,18 +1,18 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.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 function GetCheckSuiteLogsTool(ctx: Context) { export function GetCheckSuiteLogsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_check_suite_logs", name: "get_check_suite_logs",
description: description:
"get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.",
parameters: GetCheckSuiteLogs, parameters: GetCheckSuiteLogs,
execute: execute(ctx, async ({ check_suite_id }) => { execute: execute(async ({ check_suite_id }) => {
// get workflow runs for this specific check suite // get workflow runs for this specific check suite
const workflowRuns = await ctx.octokit.paginate( const workflowRuns = await ctx.octokit.paginate(
ctx.octokit.rest.actions.listWorkflowRunsForRepo, ctx.octokit.rest.actions.listWorkflowRunsForRepo,
+43 -18
View File
@@ -1,5 +1,6 @@
import type { Octokit } from "@octokit/rest";
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
@@ -20,23 +21,39 @@ export type CheckoutPrResult = {
headRepo: string; headRepo: string;
}; };
interface CheckoutPrBranchParams {
octokit: Octokit;
owner: string;
name: string;
token: string;
pullNumber: number;
}
interface CheckoutPrBranchResult {
prNumber: number;
}
/** /**
* Shared helper to checkout a PR branch and configure fork remotes. * Shared helper to checkout a PR branch and configure fork remotes.
* Assumes origin remote is already configured with authentication. * Assumes origin remote is already configured with authentication.
* Returns the PR number for caller to set on toolState.
*/ */
export async function checkoutPrBranch(ctx: Context, pull_number: number): Promise<void> { export async function checkoutPrBranch(
log.debug(`🔀 checking out PR #${pull_number}...`); params: CheckoutPrBranchParams
): Promise<CheckoutPrBranchResult> {
const { octokit, owner, name, token, pullNumber } = params;
log.info(`🔀 checking out PR #${pullNumber}...`);
// fetch PR metadata // fetch PR metadata
const pr = await ctx.octokit.rest.pulls.get({ const pr = await octokit.rest.pulls.get({
owner: ctx.owner, owner,
repo: ctx.name, repo: name,
pull_number, pull_number: pullNumber,
}); });
const headRepo = pr.data.head.repo; const headRepo = pr.data.head.repo;
if (!headRepo) { if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`); throw new Error(`PR #${pullNumber} source repository was deleted`);
} }
const isFork = headRepo.full_name !== pr.data.base.repo.full_name; const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
@@ -59,12 +76,12 @@ export async function checkoutPrBranch(ctx: Context, pull_number: number): Promi
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs) // fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.debug(`🌿 fetching PR #${pull_number} (${headBranch})...`); log.debug(`🌿 fetching PR #${pullNumber} (${headBranch})...`);
$("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]); $("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]);
// checkout the branch // checkout the branch
$("git", ["checkout", headBranch]); $("git", ["checkout", headBranch]);
log.debug(`✓ checked out PR #${pull_number}`); log.debug(`✓ checked out PR #${pullNumber}`);
} }
// ensure base branch is fetched (needed for diff operations) // ensure base branch is fetched (needed for diff operations)
@@ -78,8 +95,8 @@ export async function checkoutPrBranch(ctx: Context, pull_number: number): Promi
// NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure // NOTE: This always runs regardless of alreadyOnBranch, because setupGit doesn't configure
// fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit. // fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit.
if (isFork) { if (isFork) {
const remoteName = `pr-${pull_number}`; const remoteName = `pr-${pullNumber}`;
const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`; const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`;
// add fork as a named remote (ignore error if already exists) // add fork as a named remote (ignore error if already exists)
try { try {
@@ -107,18 +124,26 @@ export async function checkoutPrBranch(ctx: Context, pull_number: number): Promi
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
} }
// set PR context return { prNumber: pullNumber };
ctx.toolState.prNumber = pull_number;
} }
export function CheckoutPrTool(ctx: Context) { export function CheckoutPrTool(ctx: ToolContext) {
return tool({ return tool({
name: "checkout_pr", name: "checkout_pr",
description: description:
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.", "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.",
parameters: CheckoutPr, parameters: CheckoutPr,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
await checkoutPrBranch(ctx, pull_number); const result = await checkoutPrBranch({
octokit: ctx.octokit,
owner: ctx.owner,
name: ctx.name,
token: ctx.githubInstallationToken,
pullNumber: pull_number,
});
// set prNumber on toolState
ctx.toolState.prNumber = result.prNumber;
// fetch PR metadata to return result // fetch PR metadata to return result
const pr = await ctx.octokit.rest.pulls.get({ const pr = await ctx.octokit.rest.pulls.get({
+11 -11
View File
@@ -2,7 +2,7 @@ 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 type { ToolContext } 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";
@@ -66,13 +66,13 @@ export const Comment = type({
body: type.string.describe("the comment body content"), body: type.string.describe("the comment body content"),
}); });
export function CreateCommentTool(ctx: Context) { export function CreateCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "create_issue_comment", name: "create_issue_comment",
description: description:
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
parameters: Comment, parameters: Comment,
execute: execute(ctx, async ({ issueNumber, body }) => { execute: execute(async ({ issueNumber, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
const result = await ctx.octokit.rest.issues.createComment({ const result = await ctx.octokit.rest.issues.createComment({
@@ -97,12 +97,12 @@ export const EditComment = type({
body: type.string.describe("the new comment body content"), body: type.string.describe("the new comment body content"),
}); });
export function EditCommentTool(ctx: Context) { export function EditCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "edit_issue_comment", name: "edit_issue_comment",
description: "Edit a GitHub issue comment by its ID", description: "Edit a GitHub issue comment by its ID",
parameters: EditComment, parameters: EditComment,
execute: execute(ctx, async ({ commentId, body }) => { execute: execute(async ({ commentId, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
const result = await ctx.octokit.rest.issues.updateComment({ const result = await ctx.octokit.rest.issues.updateComment({
@@ -170,7 +170,7 @@ export const ReportProgress = type({
* 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( export async function reportProgress(
ctx: Context, ctx: ToolContext,
{ body }: { body: string } { body }: { body: string }
): Promise< ): Promise<
| { | {
@@ -231,13 +231,13 @@ export async function reportProgress(
}; };
} }
export function ReportProgressTool(ctx: Context) { export function ReportProgressTool(ctx: ToolContext) {
return tool({ return tool({
name: "report_progress", name: "report_progress",
description: description:
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress, parameters: ReportProgress,
execute: execute(ctx, async ({ body }) => { execute: execute(async ({ body }) => {
const result = await reportProgress(ctx, { body }); const result = await reportProgress(ctx, { body });
if (!result) { if (!result) {
@@ -269,7 +269,7 @@ 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(ctx: Context): Promise<boolean> { export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = getProgressCommentId(); const existingCommentId = getProgressCommentId();
if (!existingCommentId) { if (!existingCommentId) {
return false; return false;
@@ -380,13 +380,13 @@ export const ReplyToReviewComment = type({
), ),
}); });
export function ReplyToReviewCommentTool(ctx: Context) { export function ReplyToReviewCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "reply_to_review_comment", name: "reply_to_review_comment",
description: description:
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).", "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
parameters: ReplyToReviewComment, parameters: ReplyToReviewComment,
execute: execute(ctx, async ({ pull_number, comment_id, body }) => { execute: execute(async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit);
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
+3 -3
View File
@@ -1,17 +1,17 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const DebugShellCommand = type({}); export const DebugShellCommand = type({});
export function DebugShellCommandTool(_ctx: Context) { export function DebugShellCommandTool(_ctx: ToolContext) {
return tool({ return tool({
name: "debug_shell_command", name: "debug_shell_command",
description: description:
"debug tool: runs 'git status' and returns the output. use this to test shell command execution in the MCP server.", "debug tool: runs 'git status' and returns the output. use this to test shell command execution in the MCP server.",
parameters: DebugShellCommand, parameters: DebugShellCommand,
execute: execute(_ctx, async () => { execute: execute(async () => {
const result = $("git", ["status"]); const result = $("git", ["status"]);
return { return {
success: true, success: true,
+3 -3
View File
@@ -1,7 +1,7 @@
import { relative, resolve } from "node:path"; import { relative, resolve } from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "../main.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import type { Context } from "../main.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const ListFiles = type({ export const ListFiles = type({
@@ -10,13 +10,13 @@ export const ListFiles = type({
.default("."), .default("."),
}); });
export function ListFilesTool(_ctx: Context) { export function ListFilesTool(_ctx: ToolContext) {
return tool({ return tool({
name: "list_files", name: "list_files",
description: description:
"List files in the repository, including both git-tracked and untracked files. Useful for discovering the file structure and locating files, including newly created files that haven't been committed yet.", "List files in the repository, including both git-tracked and untracked files. Useful for discovering the file structure and locating files, including newly created files that haven't been committed yet.",
parameters: ListFiles, parameters: ListFiles,
execute: execute(_ctx, async ({ path }: { path?: string }) => { execute: execute(async ({ path }: { path?: string }) => {
const pathStr = path ?? "."; const pathStr = path ?? ".";
const cwd = process.cwd(); const cwd = process.cwd();
+7 -7
View File
@@ -1,11 +1,11 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } 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 { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export function CreateBranchTool(ctx: Context) { export function CreateBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.default_branch || "main"; const defaultBranch = ctx.repo.default_branch || "main";
const CreateBranch = type({ const CreateBranch = type({
@@ -22,7 +22,7 @@ export function CreateBranchTool(ctx: Context) {
description: description:
"Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.",
parameters: CreateBranch, parameters: CreateBranch,
execute: execute(ctx, async ({ branchName, baseBranch }) => { execute: execute(async ({ branchName, baseBranch }) => {
// baseBranch should always be defined due to default, but TypeScript needs help // baseBranch should always be defined due to default, but TypeScript needs help
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main"; const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
@@ -70,13 +70,13 @@ export const CommitFiles = type({
), ),
}); });
export function CommitFilesTool(ctx: Context) { export function CommitFilesTool(_ctx: ToolContext) {
return tool({ return tool({
name: "commit_files", name: "commit_files",
description: description:
"Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.", "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.",
parameters: CommitFiles, parameters: CommitFiles,
execute: execute(ctx, async ({ message, files }) => { execute: execute(async ({ message, files }) => {
// validate commit message for secrets // validate commit message for secrets
if (containsSecrets(message)) { if (containsSecrets(message)) {
throw new Error( throw new Error(
@@ -141,13 +141,13 @@ 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 function PushBranchTool(_ctx: Context) { export function PushBranchTool(_ctx: ToolContext) {
return tool({ return tool({
name: "push_branch", name: "push_branch",
description: description:
"Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.", "Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.",
parameters: PushBranch, parameters: PushBranch,
execute: execute(_ctx, async ({ branchName, force }) => { execute: execute(async ({ branchName, force }) => {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
// check if branch has a configured pushRemote // check if branch has a configured pushRemote
+6 -4
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const Issue = type({ export const Issue = type({
@@ -15,12 +15,12 @@ export const Issue = type({
.optional(), .optional(),
}); });
export function IssueTool(ctx: Context) { export function IssueTool(ctx: ToolContext) {
return tool({ return tool({
name: "create_issue", name: "create_issue",
description: "Create a new GitHub issue", description: "Create a new GitHub issue",
parameters: Issue, parameters: Issue,
execute: execute(ctx, async ({ title, body, labels, assignees }) => { execute: execute(async ({ title, body, labels, assignees }) => {
const result = await ctx.octokit.rest.issues.create({ const result = await ctx.octokit.rest.issues.create({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -37,7 +37,9 @@ export function IssueTool(ctx: Context) {
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),
}; };
}), }),
+3 -3
View File
@@ -1,18 +1,18 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.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 function GetIssueCommentsTool(ctx: Context) { export function GetIssueCommentsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_issue_comments", name: "get_issue_comments",
description: description:
"Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
parameters: GetIssueComments, parameters: GetIssueComments,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
// set issue context // set issue context
ctx.toolState.issueNumber = issue_number; ctx.toolState.issueNumber = issue_number;
+3 -3
View File
@@ -1,18 +1,18 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.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 function GetIssueEventsTool(ctx: Context) { export function GetIssueEventsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_issue_events", name: "get_issue_events",
description: description:
"Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.",
parameters: GetIssueEvents, parameters: GetIssueEvents,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
// set issue context // set issue context
ctx.toolState.issueNumber = issue_number; ctx.toolState.issueNumber = issue_number;
+3 -3
View File
@@ -1,17 +1,17 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.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 function IssueInfoTool(ctx: Context) { export function IssueInfoTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_issue", name: "get_issue",
description: "Retrieve GitHub issue information by issue number", description: "Retrieve GitHub issue information by issue number",
parameters: IssueInfo, parameters: IssueInfo,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
const issue = await ctx.octokit.rest.issues.get({ const issue = await ctx.octokit.rest.issues.get({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
+3 -3
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const AddLabelsParams = type({ export const AddLabelsParams = type({
@@ -7,13 +7,13 @@ export const AddLabelsParams = type({
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 function AddLabelsTool(ctx: Context) { export function AddLabelsTool(ctx: ToolContext) {
return tool({ return tool({
name: "add_labels", name: "add_labels",
description: description:
"Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.", "Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.",
parameters: AddLabelsParams, parameters: AddLabelsParams,
execute: execute(ctx, async ({ issue_number, labels }) => { execute: execute(async ({ issue_number, labels }) => {
const result = await ctx.octokit.rest.issues.addLabels({ const result = await ctx.octokit.rest.issues.addLabels({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
+4 -4
View File
@@ -1,6 +1,6 @@
import { type } from "arktype"; import { type } from "arktype";
import { agentsManifest } from "../external.ts"; import { agentsManifest } from "../external.ts";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.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";
@@ -13,7 +13,7 @@ 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')"),
}); });
function buildPrBodyWithFooter(ctx: Context, body: string): string { function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
const agentName = ctx.payload.agent; const agentName = ctx.payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null; const agentInfo = agentName ? agentsManifest[agentName] : null;
@@ -29,12 +29,12 @@ function buildPrBodyWithFooter(ctx: Context, body: string): string {
return `${bodyWithoutFooter}${footer}`; return `${bodyWithoutFooter}${footer}`;
} }
export function PullRequestTool(ctx: Context) { export function PullRequestTool(ctx: ToolContext) {
return tool({ return tool({
name: "create_pull_request", name: "create_pull_request",
description: "Create a pull request from the current branch", description: "Create a pull request from the current branch",
parameters: PullRequest, parameters: PullRequest,
execute: execute(ctx, async ({ title, body, base }) => { execute: execute(async ({ title, body, base }) => {
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
log.debug(`Current branch: ${currentBranch}`); log.debug(`Current branch: ${currentBranch}`);
+3 -3
View File
@@ -1,18 +1,18 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const PullRequestInfo = type({ export const PullRequestInfo = type({
pull_number: type.number.describe("The pull request number to fetch"), pull_number: type.number.describe("The pull request number to fetch"),
}); });
export function PullRequestInfoTool(ctx: Context) { export function PullRequestInfoTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_pull_request", name: "get_pull_request",
description: description:
"Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.", "Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.",
parameters: PullRequestInfo, parameters: PullRequestInfo,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const pr = await ctx.octokit.rest.pulls.get({ const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
+10 -10
View File
@@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
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 type { ToolContext } from "../main.ts";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts"; import { deleteProgressComment } from "./comment.ts";
@@ -37,7 +37,7 @@ type AddPullRequestReviewThreadResponse = {
// helper to find existing pending review for the authenticated user // helper to find existing pending review for the authenticated user
async function findPendingReview( async function findPendingReview(
ctx: Context, ctx: ToolContext,
pull_number: number pull_number: number
): Promise<{ id: number; node_id: string } | null> { ): Promise<{ id: number; node_id: string } | null> {
const reviews = await ctx.octokit.rest.pulls.listReviews({ const reviews = await ctx.octokit.rest.pulls.listReviews({
@@ -61,13 +61,13 @@ export const StartReview = type({
pull_number: type.number.describe("The pull request number to review"), pull_number: type.number.describe("The pull request number to review"),
}); });
export function StartReviewTool(ctx: Context) { export function StartReviewTool(ctx: ToolContext) {
return tool({ return tool({
name: "start_review", name: "start_review",
description: description:
"Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.", "Start a new review session for a pull request. Creates a scratchpad file for gathering thoughts and a pending review on GitHub. Must be called before add_review_comment.",
parameters: StartReview, parameters: StartReview,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
// check if review already started in this session // check if review already started in this session
if (ctx.toolState.review) { if (ctx.toolState.review) {
throw new Error( throw new Error(
@@ -154,13 +154,13 @@ export const AddReviewComment = type({
.optional(), .optional(),
}); });
export function AddReviewCommentTool(ctx: Context) { export function AddReviewCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "add_review_comment", name: "add_review_comment",
description: description:
"Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.", "Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.",
parameters: AddReviewComment, parameters: AddReviewComment,
execute: execute(ctx, async ({ path, line, body, side }) => { execute: execute(async ({ path, line, body, side }) => {
// check if review started // check if review started
if (!ctx.toolState.review) { if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first."); throw new Error("No review session started. Call start_review first.");
@@ -195,13 +195,13 @@ export const SubmitReview = type({
.optional(), .optional(),
}); });
export function SubmitReviewTool(ctx: Context) { export function SubmitReviewTool(ctx: ToolContext) {
return tool({ return tool({
name: "submit_review", name: "submit_review",
description: description:
"Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.", "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.",
parameters: SubmitReview, parameters: SubmitReview,
execute: execute(ctx, async ({ body }) => { execute: execute(async ({ body }) => {
// check if review started // check if review started
if (!ctx.toolState.review) { if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first."); throw new Error("No review session started. Call start_review first.");
@@ -288,7 +288,7 @@ export const Review = type({
.optional(), .optional(),
}); });
export function ReviewTool(ctx: Context) { export function ReviewTool(ctx: ToolContext) {
return tool({ return tool({
name: "submit_pull_request_review", name: "submit_pull_request_review",
description: description:
@@ -297,7 +297,7 @@ export function ReviewTool(ctx: Context) {
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " + "IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.", "Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
parameters: Review, parameters: Review,
execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => { execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
// set PR context // set PR context
ctx.toolState.prNumber = pull_number; ctx.toolState.prNumber = pull_number;
+5 -5
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
// graphql query to fetch all review threads with comments and replies // graphql query to fetch all review threads with comments and replies
@@ -86,13 +86,13 @@ export const GetReviewComments = type({
review_id: type.number.describe("The review ID to get comments for"), review_id: type.number.describe("The review ID to get comments for"),
}); });
export function GetReviewCommentsTool(ctx: Context) { export function GetReviewCommentsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_review_comments", name: "get_review_comments",
description: description:
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
parameters: GetReviewComments, parameters: GetReviewComments,
execute: execute(ctx, async ({ pull_number, review_id }) => { execute: execute(async ({ pull_number, review_id }) => {
// fetch all review threads using graphql // fetch all review threads using graphql
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, { const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
owner: ctx.owner, owner: ctx.owner,
@@ -189,13 +189,13 @@ 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 function ListPullRequestReviewsTool(ctx: Context) { export function ListPullRequestReviewsTool(ctx: ToolContext) {
return tool({ return tool({
name: "list_pull_request_reviews", name: "list_pull_request_reviews",
description: description:
"List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.", "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.",
parameters: ListPullRequestReviews, parameters: ListPullRequestReviews,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, { const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
+3 -3
View File
@@ -1,5 +1,5 @@
import { type } from "arktype"; import { type } from "arktype";
import type { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const SelectMode = type({ export const SelectMode = type({
@@ -8,13 +8,13 @@ export const SelectMode = type({
), ),
}); });
export function SelectModeTool(ctx: Context) { export function SelectModeTool(ctx: ToolContext) {
return tool({ return tool({
name: "select_mode", name: "select_mode",
description: description:
"Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.", "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.",
parameters: SelectMode, parameters: SelectMode,
execute: execute(ctx, async ({ modeName }) => { execute: execute(async ({ modeName }) => {
const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()); const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase());
if (!selectedMode) { if (!selectedMode) {
+5 -6
View File
@@ -1,9 +1,9 @@
import "./arkConfig.ts"; import "./arkConfig.ts";
// this must be imported first
import { createServer } from "node:net"; import { createServer } from "node:net";
// this must be imported first
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 type { ToolContext } from "../main.ts";
import { CheckoutPrTool } from "./checkout.ts"; import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import { import {
@@ -25,7 +25,7 @@ import { PullRequestInfoTool } from "./prInfo.ts";
import { AddReviewCommentTool, StartReviewTool, SubmitReviewTool } from "./review.ts"; import { AddReviewCommentTool, StartReviewTool, SubmitReviewTool } 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 { addTools, isProgressCommentDisabled } from "./shared.ts"; import { addTools } from "./shared.ts";
/** /**
* Find an available port starting from the given port * Find an available port starting from the given port
@@ -60,7 +60,7 @@ 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(
ctx: Context ctx: ToolContext
): Promise<{ url: string; close: () => Promise<void> }> { ): Promise<{ url: string; close: () => Promise<void> }> {
const server = new FastMCP({ const server = new FastMCP({
name: ghPullfrogMcpName, name: ghPullfrogMcpName,
@@ -95,8 +95,7 @@ export async function startMcpHttpServer(
ListFilesTool(ctx), ListFilesTool(ctx),
]; ];
// only include ReportProgressTool if progress comment is not disabled if (!ctx.payload.disableProgressComment) {
if (!isProgressCommentDisabled(ctx)) {
tools.push(ReportProgressTool(ctx)); tools.push(ReportProgressTool(ctx));
} }
+4 -8
View File
@@ -1,6 +1,6 @@
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 { Context } from "../main.ts"; import type { ToolContext } from "../main.ts";
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef; export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
@@ -40,7 +40,7 @@ export const handleToolError = (error: unknown): ToolResult => {
* Helper to wrap a tool execute function with error handling. * Helper to wrap a tool execute function with error handling.
* Captures ctx in closure so tools don't need to handle try/catch. * 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>>) => { export const execute = <T>(fn: (params: T) => Promise<Record<string, any>>) => {
return async (params: T): Promise<ToolResult> => { return async (params: T): Promise<ToolResult> => {
try { try {
const result = await fn(params); const result = await fn(params);
@@ -51,10 +51,6 @@ export const execute = <T>(ctx: Context, fn: (params: T) => Promise<Record<strin
}; };
}; };
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)
@@ -176,10 +172,10 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
} as T; } as T;
} }
export const addTools = (ctx: Context, server: FastMCP, tools: Tool<any, any>[]) => { export const addTools = (ctx: ToolContext, 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 = ctx.agentName === "gemini" || ctx.agentName === "opencode"; const shouldSanitize = ctx.agent.name === "gemini" || ctx.agent.name === "opencode";
for (const tool of tools) { for (const tool of tools) {
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool; const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@pullfrog/action", "name": "@pullfrog/action",
"version": "0.0.144", "version": "0.0.146",
"type": "module", "type": "module",
"files": [ "files": [
"index.js", "index.js",
+27 -7
View File
@@ -1,6 +1,8 @@
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 { Context } from "../main.ts"; import type { Octokit } from "@octokit/rest";
import type { Payload } from "../external.ts";
import type { ToolState } from "../main.ts";
import { checkoutPrBranch } from "../mcp/checkout.ts"; import { checkoutPrBranch } from "../mcp/checkout.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { $ } from "./shell.ts"; import { $ } from "./shell.ts";
@@ -71,6 +73,15 @@ export function setupGitConfig(): void {
} }
} }
interface SetupGitAuthParams {
token: string;
owner: string;
name: string;
payload: Payload;
octokit: Octokit;
toolState: ToolState;
}
/** /**
* Setup git authentication for the repository. * Setup git authentication for the repository.
* For PR events, uses the shared checkoutPrBranch helper (also used by checkout_pr MCP tool). * For PR events, uses the shared checkoutPrBranch helper (also used by checkout_pr MCP tool).
@@ -80,7 +91,7 @@ export function setupGitConfig(): void {
* - checkoutPrBranch sets per-branch pushRemote config for fork PRs * - checkoutPrBranch sets per-branch pushRemote config for fork PRs
* - diff operations use: git diff origin/<base>..HEAD * - diff operations use: git diff origin/<base>..HEAD
*/ */
export async function setupGit(ctx: Context): Promise<void> { export async function setupGitAuth(params: SetupGitAuthParams): Promise<void> {
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("» setting up git authentication..."); log.info("» setting up git authentication...");
@@ -97,20 +108,29 @@ export async function setupGit(ctx: Context): Promise<void> {
} }
// non-PR events: set up origin with token, stay on default branch // non-PR events: set up origin with token, stay on default branch
if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) { if (params.payload.event.is_pr !== true || !params.payload.event.issue_number) {
const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
log.info("» updated origin URL with authentication token"); log.info("» updated origin URL with authentication token");
return; return;
} }
// PR event: checkout PR branch using shared helper // PR event: checkout PR branch using shared helper
const prNumber = ctx.payload.event.issue_number; const prNumber = params.payload.event.issue_number;
// ensure origin is configured with auth token before checkout // ensure origin is configured with auth token before checkout
const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
// use shared checkout helper (handles fork remotes, push config, etc.) // use shared checkout helper (handles fork remotes, push config, etc.)
await checkoutPrBranch(ctx, prNumber); const prContext = await checkoutPrBranch({
octokit: params.octokit,
owner: params.owner,
name: params.name,
token: params.token,
pullNumber: prNumber,
});
// set prNumber on toolState (the only mutation)
params.toolState.prNumber = prContext.prNumber;
} }