diff --git a/agents/index.ts b/agents/index.ts index ce7f798..98c4c39 100644 --- a/agents/index.ts +++ b/agents/index.ts @@ -6,6 +6,8 @@ import { gemini } from "./gemini.ts"; import { opencode } from "./opencode.ts"; import type { Agent } from "./shared.ts"; +export type { Agent } from "./shared.ts"; + export const agents = { claude, codex, diff --git a/agents/shared.ts b/agents/shared.ts index a56da2d..7c9b931 100644 --- a/agents/shared.ts +++ b/agents/shared.ts @@ -506,7 +506,7 @@ export const agent = (input: input): defineAgent export interface AgentInput { name: AgentName; - install: () => Promise; + install: (token?: string) => Promise; run: (config: AgentConfig) => Promise; } diff --git a/entry b/entry index c37978c..98b2277 100755 --- a/entry +++ b/entry @@ -80145,7 +80145,7 @@ function query({ // package.json var package_default = { name: "@pullfrog/action", - version: "0.0.144", + version: "0.0.146", type: "module", files: [ "index.js", @@ -95158,7 +95158,7 @@ var handleToolError = (error42) => { isError: true }; }; -var execute = (ctx, fn2) => { +var execute = (fn2) => { return async (params) => { try { const result = await fn2(params); @@ -95168,9 +95168,6 @@ var execute = (ctx, fn2) => { } }; }; -function isProgressCommentDisabled(ctx) { - return ctx.payload.disableProgressComment === true; -} function sanitizeSchema(schema2) { if (!schema2 || typeof schema2 !== "object") { return schema2; @@ -95251,7 +95248,7 @@ function sanitizeTool(tool2) { }; } 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) { const processedTool = shouldSanitize ? sanitizeTool(tool2) : tool2; server.addTool(processedTool); @@ -95306,7 +95303,7 @@ function CreateCommentTool(ctx) { name: "create_issue_comment", description: "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", parameters: Comment, - execute: execute(ctx, async ({ issueNumber, body }) => { + execute: execute(async ({ issueNumber, body }) => { const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const result = await ctx.octokit.rest.issues.createComment({ owner: ctx.owner, @@ -95332,7 +95329,7 @@ function EditCommentTool(ctx) { name: "edit_issue_comment", description: "Edit a GitHub issue comment by its ID", parameters: EditComment, - execute: execute(ctx, async ({ commentId, body }) => { + execute: execute(async ({ commentId, body }) => { const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); const result = await ctx.octokit.rest.issues.updateComment({ owner: ctx.owner, @@ -95419,7 +95416,7 @@ function ReportProgressTool(ctx) { name: "report_progress", description: "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", parameters: ReportProgress, - execute: execute(ctx, async ({ body }) => { + execute: execute(async ({ body }) => { const result = await reportProgress(ctx, { body }); if (!result) { return { @@ -95513,7 +95510,7 @@ function ReplyToReviewCommentTool(ctx) { name: "reply_to_review_comment", description: "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).", parameters: ReplyToReviewComment, - execute: 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 result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ owner: ctx.owner, @@ -123902,16 +123899,17 @@ function $(cmd, args3, options) { var CheckoutPr = type({ pull_number: type.number.describe("the pull request number to checkout") }); -async function checkoutPrBranch(ctx, pull_number) { - log.debug(`\u{1F500} checking out PR #${pull_number}...`); - const pr = await ctx.octokit.rest.pulls.get({ - owner: ctx.owner, - repo: ctx.name, - pull_number +async function checkoutPrBranch(params) { + const { octokit, owner, name, token, pullNumber } = params; + log.info(`\u{1F500} checking out PR #${pullNumber}...`); + const pr = await octokit.rest.pulls.get({ + owner, + repo: name, + pull_number: pullNumber }); const headRepo = pr.data.head.repo; 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 baseBranch = pr.data.base.ref; @@ -123924,18 +123922,18 @@ async function checkoutPrBranch(ctx, pull_number) { log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`); $("git", ["fetch", "--no-tags", "origin", baseBranch]); $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); - log.debug(`\u{1F33F} fetching PR #${pull_number} (${headBranch})...`); - $("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]); + log.debug(`\u{1F33F} fetching PR #${pullNumber} (${headBranch})...`); + $("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]); $("git", ["checkout", headBranch]); - log.debug(`\u2713 checked out PR #${pull_number}`); + log.debug(`\u2713 checked out PR #${pullNumber}`); } if (alreadyOnBranch) { log.debug(`\u{1F4E5} fetching base branch (${baseBranch})...`); $("git", ["fetch", "--no-tags", "origin", baseBranch]); } if (isFork) { - const remoteName = `pr-${pull_number}`; - const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`; + const remoteName = `pr-${pullNumber}`; + const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`; try { $("git", ["remote", "add", remoteName, forkUrl]); log.debug(`\u{1F4CC} added remote '${remoteName}' for fork ${headRepo.full_name}`); @@ -123953,15 +123951,22 @@ async function checkoutPrBranch(ctx, pull_number) { } else { $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); } - ctx.toolState.prNumber = pull_number; + return { prNumber: pullNumber }; } function CheckoutPrTool(ctx) { return tool({ 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.", parameters: CheckoutPr, - execute: execute(ctx, async ({ pull_number }) => { - await checkoutPrBranch(ctx, pull_number); + execute: execute(async ({ 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({ owner: ctx.owner, repo: ctx.name, @@ -123995,7 +124000,7 @@ function GetCheckSuiteLogsTool(ctx) { name: "get_check_suite_logs", description: "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", parameters: GetCheckSuiteLogs, - execute: execute(ctx, async ({ check_suite_id }) => { + execute: execute(async ({ check_suite_id }) => { const workflowRuns = await ctx.octokit.paginate( ctx.octokit.rest.actions.listWorkflowRunsForRepo, { @@ -124076,7 +124081,7 @@ function DebugShellCommandTool(_ctx) { 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.", parameters: DebugShellCommand, - execute: execute(_ctx, async () => { + execute: execute(async () => { const result = $("git", ["status"]); return { success: true, @@ -124097,7 +124102,7 @@ function ListFilesTool(_ctx) { 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.", parameters: ListFiles, - execute: execute(_ctx, async ({ path: path4 }) => { + execute: execute(async ({ path: path4 }) => { const pathStr = path4 ?? "."; const cwd2 = process.cwd(); let gitFiles = []; @@ -124194,7 +124199,7 @@ function CreateBranchTool(ctx) { name: "create_branch", description: "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", parameters: CreateBranch, - execute: execute(ctx, async ({ branchName, baseBranch }) => { + execute: execute(async ({ branchName, baseBranch }) => { const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main"; if (containsSecrets(branchName)) { 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." ) }); -function CommitFilesTool(ctx) { +function CommitFilesTool(_ctx) { return tool({ name: "commit_files", description: "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.", parameters: CommitFiles, - execute: execute(ctx, async ({ message, files }) => { + execute: execute(async ({ message, files }) => { if (containsSecrets(message)) { throw new Error( "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", 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, - execute: execute(_ctx, async ({ branchName, force }) => { + execute: execute(async ({ branchName, force }) => { const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); let remote = "origin"; try { @@ -124313,7 +124318,7 @@ function IssueTool(ctx) { name: "create_issue", description: "Create a new GitHub 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({ owner: ctx.owner, repo: ctx.name, @@ -124329,7 +124334,9 @@ function IssueTool(ctx) { url: result.data.html_url, title: result.data.title, 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) }; }) @@ -124345,7 +124352,7 @@ function GetIssueCommentsTool(ctx) { name: "get_issue_comments", description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", parameters: GetIssueComments, - execute: execute(ctx, async ({ issue_number }) => { + execute: execute(async ({ issue_number }) => { ctx.toolState.issueNumber = issue_number; const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { owner: ctx.owner, @@ -124379,7 +124386,7 @@ function GetIssueEventsTool(ctx) { name: "get_issue_events", description: "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", parameters: GetIssueEvents, - execute: execute(ctx, async ({ issue_number }) => { + execute: execute(async ({ issue_number }) => { ctx.toolState.issueNumber = issue_number; const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, { owner: ctx.owner, @@ -124451,7 +124458,7 @@ function IssueInfoTool(ctx) { name: "get_issue", description: "Retrieve GitHub issue information by issue number", parameters: IssueInfo, - execute: execute(ctx, async ({ issue_number }) => { + execute: execute(async ({ issue_number }) => { const issue3 = await ctx.octokit.rest.issues.get({ owner: ctx.owner, repo: ctx.name, @@ -124503,7 +124510,7 @@ function AddLabelsTool(ctx) { name: "add_labels", description: "Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.", parameters: AddLabelsParams, - execute: execute(ctx, async ({ issue_number, labels }) => { + execute: execute(async ({ issue_number, labels }) => { const result = await ctx.octokit.rest.issues.addLabels({ owner: ctx.owner, repo: ctx.name, @@ -124540,7 +124547,7 @@ function PullRequestTool(ctx) { name: "create_pull_request", description: "Create a pull request from the current branch", 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 }); log.debug(`Current branch: ${currentBranch}`); if (containsSecrets(title) || containsSecrets(body)) { @@ -124585,7 +124592,7 @@ function PullRequestInfoTool(ctx) { 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.", parameters: PullRequestInfo, - execute: execute(ctx, async ({ pull_number }) => { + execute: execute(async ({ pull_number }) => { const pr = await ctx.octokit.rest.pulls.get({ owner: ctx.owner, repo: ctx.name, @@ -124648,7 +124655,7 @@ function StartReviewTool(ctx) { 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.", parameters: StartReview, - execute: execute(ctx, async ({ pull_number }) => { + execute: execute(async ({ pull_number }) => { if (ctx.toolState.review) { throw new Error( `Review session already in progress. Call submit_review first to finish it.` @@ -124723,7 +124730,7 @@ function AddReviewCommentTool(ctx) { 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.", parameters: AddReviewComment, - execute: execute(ctx, async ({ path: path4, line, body, side }) => { + execute: execute(async ({ path: path4, line, body, side }) => { if (!ctx.toolState.review) { throw new Error("No review session started. Call start_review first."); } @@ -124754,7 +124761,7 @@ function SubmitReviewTool(ctx) { name: "submit_review", description: "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.", parameters: SubmitReview, - execute: execute(ctx, async ({ body }) => { + execute: execute(async ({ body }) => { if (!ctx.toolState.review) { throw new Error("No review session started. Call start_review first."); } @@ -124860,7 +124867,7 @@ function GetReviewCommentsTool(ctx) { name: "get_review_comments", description: "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", parameters: GetReviewComments, - execute: execute(ctx, async ({ pull_number, review_id }) => { + execute: execute(async ({ pull_number, review_id }) => { const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, { owner: ctx.owner, repo: ctx.name, @@ -124930,7 +124937,7 @@ function ListPullRequestReviewsTool(ctx) { name: "list_pull_request_reviews", description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.", parameters: ListPullRequestReviews, - execute: execute(ctx, async ({ pull_number }) => { + execute: execute(async ({ pull_number }) => { const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, { owner: ctx.owner, repo: ctx.name, @@ -124964,7 +124971,7 @@ function SelectModeTool(ctx) { name: "select_mode", description: "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.", parameters: SelectMode, - execute: execute(ctx, async ({ modeName }) => { + execute: execute(async ({ modeName }) => { const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()); if (!selectedMode) { const availableModes = ctx.modes.map((m) => m.name).join(", "); @@ -125038,7 +125045,7 @@ async function startMcpHttpServer(ctx) { PushBranchTool(ctx), ListFilesTool(ctx) ]; - if (!isProgressCommentDisabled(ctx)) { + if (!ctx.payload.disableProgressComment) { tools.push(ReportProgressTool(ctx)); } addTools(ctx, server, tools); @@ -125688,7 +125695,7 @@ function setupGitConfig() { ); } } -async function setupGit(ctx) { +async function setupGitAuth(params) { const repoDir = process.cwd(); log.info("\xBB setting up git authentication..."); try { @@ -125700,16 +125707,23 @@ async function setupGit(ctx) { } catch { log.debug("\xBB no existing authentication headers to remove"); } - if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) { - const originUrl2 = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; + if (params.payload.event.is_pr !== true || !params.payload.event.issue_number) { + const originUrl2 = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`; $("git", ["remote", "set-url", "origin", originUrl2], { cwd: repoDir }); log.info("\xBB updated origin URL with authentication token"); return; } - const prNumber = ctx.payload.event.issue_number; - const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; + const prNumber = params.payload.event.issue_number; + const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`; $("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 @@ -125745,28 +125759,105 @@ async function main(inputs) { try { const timer = new Timer(); payload = parsePayload(inputs); - const partialCtx = await initializeContext(inputs, payload); - const ctx = partialCtx; - ctx.toolState = {}; - timer.checkpoint("initializeContext"); - await setupGit(ctx); - timer.checkpoint("setupGit"); - await setupTempDirectory(ctx); - timer.checkpoint("setupTempDirectory"); - const [, prepResults] = await Promise.all([installAgentCli(ctx), runPrepPhase()]); - ctx.prepResults = prepResults; + Inputs.assert(inputs); + setupGitConfig(); + const [githubSetup, sharedTempDir] = await Promise.all([ + initializeGitHub(), + createTempDirectory() + ]); + timer.checkpoint("githubSetup"); + const agent2 = resolveAgent({ + inputs, + 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; - ctx.modes = [ + const computedModes = [ ...getModes({ - disableProgressComment: ctx.payload.disableProgressComment, + disableProgressComment: resolvedPayload.disableProgressComment, dependenciesPreinstalled }), - ...ctx.payload.modes || [] + ...resolvedPayload.modes || [] ]; - timer.checkpoint("installAgentCli+prepPhase"); - await startMcpServer(ctx); - mcpServerClose = ctx.mcpServerClose; - timer.checkpoint("startMcpServer"); + const runId = process.env.GITHUB_RUN_ID || ""; + if (runId) { + const workflowRunInfo = await fetchWorkflowRunInfo(runId); + 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) { await reportProgress(ctx, { 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 }; } - setupMcpServers(ctx); - await validateApiKey(ctx); const result = await runAgent(ctx); const mainResult = await handleAgentResult(result); 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(); } } +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) { - return Object.values(agents).filter((agent2) => { - if (agent2.name === "opencode") { - return Object.keys(inputs).some((key) => key.includes("api_key")); - } - return agent2.apiKeyNames.some((inputKey) => inputs[inputKey]); - }); + return Object.values(agents).filter((agent2) => agentHasApiKeys(agent2, inputs)); } function getAllPossibleKeyNames() { 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 settingsUrl = `${apiUrl}/console/${ctx.owner}/${ctx.name}`; - const githubRepoUrl = `https://github.com/${ctx.owner}/${ctx.name}`; + const settingsUrl = `${apiUrl}/console/${params.owner}/${params.name}`; + const githubRepoUrl = `https://github.com/${params.owner}/${params.name}`; const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`; - const isOpenCode = ctx.agent?.name === "opencode"; + const isOpenCode = params.agent.name === "opencode"; let secretNameList; if (isOpenCode) { secretNameList = "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)"; } 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()}\``); 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: @@ -125844,53 +125937,23 @@ To fix this, add the required secret to your GitHub repository: 5. Click "Add secret" 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}...`); - Inputs.assert(inputs); - setupGitConfig(); - const githubInstallationToken2 = await setupGitHubInstallationToken(); + const token = await setupGitHubInstallationToken(); const { owner, name } = parseRepoContext(); - const octokit = new Octokit2({ - auth: githubInstallationToken2 - }); - const response = await octokit.repos.get({ - 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 || [] - ]; + const octokit = new Octokit2({ auth: token }); + const [repoResponse, repoSettings] = await Promise.all([ + octokit.repos.get({ owner, repo: name }), + fetchRepoSettings({ token, repoContext: { owner, name } }) + ]); return { + token, owner, name, - inputs, - githubInstallationToken: githubInstallationToken2, octokit, - repo, - agentName, - agent: agent2, - payload: resolvedPayload, - repoSettings, - modes: computedModes, - sharedTempDir: "" + repo: repoResponse.data, + repoSettings }; } function resolveAgent({ @@ -125901,41 +125964,37 @@ function resolveAgent({ const agentOverride = process.env.AGENT_OVERRIDE; const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null; if (configuredAgentName) { - const agentName2 = configuredAgentName; - const agent3 = agents[agentName2]; + const agent3 = agents[configuredAgentName]; if (!agent3) { - throw new Error(`invalid agent name: ${agentName2}`); + throw new Error(`invalid agent name: ${configuredAgentName}`); } const isExplicitOverride = agentOverride !== void 0 || payload.agent !== null; if (isExplicitOverride) { - log.info(`\xBB selected configured agent: ${agentName2}`); - return { agentName: agentName2, agent: agent3 }; + log.info(`Selected configured agent: ${agent3.name}`); + return agent3; } - const hasMatchingKey = agent3.name === "opencode" ? Object.keys(inputs).some((key) => key.includes("api_key")) : agent3.apiKeyNames.some((inputKey) => inputs[inputKey]); - if (!hasMatchingKey) { - log.warning( - `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 }; + if (agentHasApiKeys(agent3, inputs)) { + log.info(`Selected configured agent: ${agent3.name}`); + return 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 availableAgentNames = availableAgents.map((agent3) => agent3.name).join(", "); - log.debug(`\xBB available agents: ${availableAgentNames || "none"}`); if (availableAgents.length === 0) { throw new Error("no agents available - missing API keys"); } - const agentName = availableAgents[0].name; const agent2 = availableAgents[0]; - log.info(`\xBB no agent configured, defaulting to first available agent: ${agentName}`); - return { agentName, agent: agent2 }; + log.info(`No agent configured, defaulting to first available agent: ${agent2.name}`); + return agent2; } -async function setupTempDirectory(ctx) { - ctx.sharedTempDir = await mkdtemp2(join11(tmpdir2(), "pullfrog-")); - process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; - log.debug(`\xBB created temp dir at ${ctx.sharedTempDir}`); +async function createTempDirectory() { + const sharedTempDir = await mkdtemp2(join11(tmpdir2(), "pullfrog-")); + process.env.PULLFROG_TEMP_DIR = sharedTempDir; + log.info(`\u{1F4C2} PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`); + return sharedTempDir; } function parsePayload(inputs) { try { @@ -125956,71 +126015,55 @@ function parsePayload(inputs) { }; } } -async function startMcpServer(ctx) { - const runId = process.env.GITHUB_RUN_ID || ""; - ctx.runId = runId; - 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}`); - } +async function installAgentCli(params) { + if (params.agent.name === "gemini") { + return params.agent.install(params.token); } - 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(`\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}`); + return params.agent.install(); } -function setupMcpServers(ctx) { - 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) { +function collectApiKeys(agent2, inputs) { const apiKeys = {}; - for (const inputKey of ctx.agent.apiKeyNames) { - const value2 = ctx.inputs[inputKey]; + const inputsRecord = inputs; + for (const inputKey of agent2.apiKeyNames) { + const value2 = inputsRecord[inputKey]; if (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)) { if (value2 && typeof value2 === "string" && key.includes("API_KEY")) { - const inputKey = key.toLowerCase(); - apiKeys[inputKey] = value2; + apiKeys[key.toLowerCase()] = value2; } } } + return apiKeys; +} +function validateApiKey(params) { + const apiKeys = collectApiKeys(params.agent, params.inputs); 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]; - ctx.apiKeys = apiKeys; + return { + success: true, + apiKey: Object.values(apiKeys)[0], + apiKeys + }; } async function runAgent(ctx) { - log.info(`\xBB running ${ctx.agentName}...`); - log.box(ctx.payload.prompt.trim(), { title: "Prompt" }); + log.info(`Running ${ctx.agent.name}...`); + const { context: _context, ...eventWithoutContext } = ctx.payload.event; + const promptContent = `${ctx.payload.prompt} + +${encode(eventWithoutContext)}`; + log.box(promptContent, { title: "Prompt" }); return ctx.agent.run({ payload: ctx.payload, mcpServers: ctx.mcpServers, diff --git a/main.ts b/main.ts index 03fb3ef..0736148 100644 --- a/main.ts +++ b/main.ts @@ -3,8 +3,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { flatMorph } from "@ark/util"; import { Octokit } from "@octokit/rest"; +import { encode as toonEncode } from "@toon-format/toon"; 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 { AgentName, Payload } from "./external.ts"; import { agentsManifest } from "./external.ts"; @@ -22,7 +23,7 @@ import { revokeGitHubInstallationToken, setupGitHubInstallationToken, } from "./utils/github.ts"; -import { setupGit, setupGitConfig } from "./utils/setup.ts"; +import { setupGitAuth, setupGitConfig } from "./utils/setup.ts"; import { Timer } from "./utils/timer.ts"; // runtime validation using agents (needed for ArkType) @@ -50,6 +51,20 @@ export interface MainResult { error?: string | undefined; } +// intermediate result types for deterministic context building +interface GitHubSetup { + token: string; + owner: string; + name: string; + octokit: Octokit; + repo: Awaited>["data"]; + repoSettings: RepoSettings; +} + +type ApiKeySetup = + | { success: true; apiKey: string; apiKeys: Record } + | { success: false; error: string }; + export async function main(inputs: Inputs): Promise { let mcpServerClose: (() => Promise) | undefined; let payload: Payload | undefined; @@ -57,39 +72,126 @@ export async function main(inputs: Inputs): Promise { try { const timer = new Timer(); - // parse payload early to extract agent + // phase 1: parse and validate inputs payload = parsePayload(inputs); + Inputs.assert(inputs); + setupGitConfig(); - const partialCtx = await initializeContext(inputs, payload); - const ctx = partialCtx as Context; - ctx.toolState = {}; - timer.checkpoint("initializeContext"); + // phase 2: fast setup (github + temp dir) + const [githubSetup, sharedTempDir] = await Promise.all([ + initializeGitHub(), + createTempDirectory(), + ]); + timer.checkpoint("githubSetup"); - await setupGit(ctx); - timer.checkpoint("setupGit"); + // phase 3: resolve agent (needs repo settings) + const agent = resolveAgent({ + inputs, + payload, + repoSettings: githubSetup.repoSettings, + }); + const resolvedPayload = { ...payload, agent: agent.name }; - await setupTempDirectory(ctx); - timer.checkpoint("setupTempDirectory"); + // phase 4: validate API key (sync, needs agent) - fail fast before long-running operations + 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 - // run agent CLI installation and prep phase in parallel - const [, prepResults] = await Promise.all([installAgentCli(ctx), runPrepPhase()]); - ctx.prepResults = prepResults; + // phase 5: parallel long-running operations (prep + agent install + git auth) + const toolState: ToolState = {}; + const [prepResults, cliPath] = await Promise.all([ + 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; - ctx.modes = [ + const computedModes: Mode[] = [ ...getModes({ - disableProgressComment: ctx.payload.disableProgressComment, + disableProgressComment: resolvedPayload.disableProgressComment, dependenciesPreinstalled, }), - ...(ctx.payload.modes || []), + ...(resolvedPayload.modes || []), ]; - timer.checkpoint("installAgentCli+prepPhase"); - await startMcpServer(ctx); - mcpServerClose = ctx.mcpServerClose; - timer.checkpoint("startMcpServer"); + // phase 7: compute runId/jobId for MCP tools + const runId = process.env.GITHUB_RUN_ID || ""; + 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 if ( @@ -103,10 +205,6 @@ export async function main(inputs: Inputs): Promise { return { success: true }; } - setupMcpServers(ctx); - - await validateApiKey(ctx); - const result = await runAgent(ctx); const mainResult = await handleAgentResult(result); return mainResult; @@ -142,15 +240,22 @@ export async function main(inputs: Inputs): Promise { /** * Get agents that have matching API keys in the inputs */ -function getAvailableAgents(inputs: Inputs): (typeof agents)[AgentName][] { - return Object.values(agents).filter((agent) => { - // for OpenCode, check if any API_KEY variable exists in inputs - if (agent.name === "opencode") { - return Object.keys(inputs).some((key) => key.includes("api_key")); - } - // for other agents, check apiKeyNames - return agent.apiKeyNames.some((inputKey) => inputs[inputKey]); - }); +/** + * Check if an agent has API keys available (inputs or process.env for opencode) + */ +function agentHasApiKeys(agent: Agent, inputs: Inputs): boolean { + if (agent.name === "opencode") { + // check inputs first, then process.env + 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 as Record; + 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 { +function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: string }): string { 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`; // 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; if (isOpenCode) { secretNameList = "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)"; } 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()}\``); 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: @@ -197,54 +303,34 @@ To fix this, add the required secret to your GitHub repository: 5. Click "Add secret" 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 { - // flattened from RepoContext +// tool context - subset of Context needed by MCP tools +export interface ToolContext { owner: string; name: string; - - // core fields - inputs: Inputs; - payload: Payload; - repo: Awaited>["data"]; - agentName: AgentName; - agent: (typeof agents)[AgentName]; githubInstallationToken: string; octokit: Octokit; - - // repo settings from Pullfrog API + payload: Payload; + repo: Awaited>["data"]; repoSettings: RepoSettings; - - // modes for MCP tools modes: Mode[]; - - // setup fields - sharedTempDir: string; - - // mcp fields - mcpServerUrl: string; - mcpServerClose: () => Promise; - mcpServers: ReturnType; - - // agent fields - cliPath: string; - apiKey: string; - apiKeys: Record; - - // prep phase results prepResults: PrepResult[]; - - // workflow run info + toolState: ToolState; + agent: Agent; + sharedTempDir: string; runId: string; jobId: string | undefined; +} - // tool state - mutable scratchpad for tools - toolState: ToolState; +export interface AgentContext extends Readonly { + readonly inputs: Inputs; + readonly mcpServerUrl: string; + readonly mcpServerClose: () => Promise; + readonly mcpServers: ReturnType; + readonly cliPath: string; + readonly apiKey: string; + readonly apiKeys: Record; } export interface ToolState { @@ -256,80 +342,30 @@ export interface ToolState { }; } -async function initializeContext( - inputs: Inputs, - payload: Payload -): Promise< - Omit< - Context, - | "mcpServerUrl" - | "mcpServerClose" - | "mcpServers" - | "cliPath" - | "apiKey" - | "apiKeys" - | "prepResults" - | "runId" - | "jobId" - | "toolState" - > -> { +/** + * Initialize GitHub connection: token, octokit, repo data, settings + */ +async function initializeGitHub(): Promise { log.info(`๐Ÿธ Running pullfrog/action@${packageJson.version}...`); - Inputs.assert(inputs); - setupGitConfig(); - const githubInstallationToken = await setupGitHubInstallationToken(); + const token = await setupGitHubInstallationToken(); const { owner, name } = parseRepoContext(); - // create octokit instance - const octokit = new Octokit({ - auth: githubInstallationToken, - }); + const octokit = new Octokit({ auth: token }); - // fetch repo data - const response = await octokit.repos.get({ - owner, - repo: name, - }); - const repo = response.data; - - // fetch repo settings - const repoSettings = await fetchRepoSettings({ - token: githubInstallationToken, - repoContext: { owner, name }, - }); - - // resolve agent and update payload with resolved agent name - 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 || []), - ]; + // fetch repo data and settings in parallel + const [repoResponse, repoSettings] = await Promise.all([ + octokit.repos.get({ owner, repo: name }), + fetchRepoSettings({ token, repoContext: { owner, name } }), + ]); return { + token, owner, name, - inputs, - githubInstallationToken, octokit, - repo, - agentName, - agent, - payload: resolvedPayload, + repo: repoResponse.data, repoSettings, - modes: computedModes, - sharedTempDir: "", }; } @@ -341,65 +377,54 @@ function resolveAgent({ inputs: Inputs; payload: Payload; repoSettings: RepoSettings; -}): { agentName: AgentName; agent: (typeof agents)[AgentName] } { +}): Agent { const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined; const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null; if (configuredAgentName) { - const agentName = configuredAgentName; - const agent = agents[agentName]; + const agent = agents[configuredAgentName]; 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 // this allows users to force an agent selection (will fail later with clear error if no keys) const isExplicitOverride = agentOverride !== undefined || payload.agent !== null; - if (isExplicitOverride) { - log.info(`ยป selected configured agent: ${agentName}`); - return { agentName, agent }; + log.info(`Selected configured agent: ${agent.name}`); + return agent; } // for repo-level defaults, check if agent has matching keys before selecting - const hasMatchingKey = - agent.name === "opencode" - ? Object.keys(inputs).some((key) => key.includes("api_key")) - : 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 }; + if (agentHasApiKeys(agent, inputs)) { + log.info(`Selected configured agent: ${agent.name}`); + return 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 availableAgentNames = availableAgents.map((agent) => agent.name).join(", "); - log.debug(`ยป available agents: ${availableAgentNames || "none"}`); - if (availableAgents.length === 0) { - // this will be caught and reported later in validateApiKey throw new Error("no agents available - missing API keys"); } - const agentName = availableAgents[0].name; const agent = availableAgents[0]; - log.info(`ยป no agent configured, defaulting to first available agent: ${agentName}`); - return { agentName, agent }; + log.info(`No agent configured, defaulting to first available agent: ${agent.name}`); + return agent; } -async function setupTempDirectory(ctx: Context): Promise { - ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); - process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; - log.debug(`ยป created temp dir at ${ctx.sharedTempDir}`); +async function createTempDirectory(): Promise { + const sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); + process.env.PULLFROG_TEMP_DIR = sharedTempDir; + log.info(`๐Ÿ“‚ PULLFROG_TEMP_DIR has been created at ${sharedTempDir}`); + return sharedTempDir; } function parsePayload(inputs: Inputs): Payload { @@ -422,90 +447,70 @@ function parsePayload(inputs: Inputs): Payload { } } -async function startMcpServer(ctx: Context): Promise { - 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 { +async function installAgentCli(params: { agent: Agent; token: string }): Promise { // gemini is the only agent that needs githubInstallationToken for install - if (ctx.agentName === "gemini") { - ctx.cliPath = await ctx.agent.install(ctx.githubInstallationToken); - } else { - ctx.cliPath = await ctx.agent.install(); + if (params.agent.name === "gemini") { + return params.agent.install(params.token); } + return params.agent.install(); } -async function validateApiKey(ctx: Context): Promise { - // collect all matching API keys for this agent +function collectApiKeys(agent: Agent, inputs: Inputs): Record { const apiKeys: Record = {}; - for (const inputKey of ctx.agent.apiKeyNames) { - const value = ctx.inputs[inputKey]; + const inputsRecord = inputs as Record; + + for (const inputKey of agent.apiKeyNames) { + const value = inputsRecord[inputKey]; if (value) { apiKeys[inputKey] = value; } } - // for OpenCode: if no keys found in inputs, check process.env for any API_KEY variables - if (ctx.agentName === "opencode" && Object.keys(apiKeys).length === 0) { + // for OpenCode: also check process.env for any API_KEY variables + if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) { for (const [key, value] of Object.entries(process.env)) { if (value && typeof value === "string" && key.includes("API_KEY")) { - // convert env var name back to input key format (lowercase with underscores) - const inputKey = key.toLowerCase(); - apiKeys[inputKey] = value; + apiKeys[key.toLowerCase()] = value; } } } - if (Object.keys(apiKeys).length === 0) { - 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; + return apiKeys; } -async function runAgent(ctx: Context): Promise { - log.info(`ยป running ${ctx.agentName}...`); - log.box(ctx.payload.prompt.trim(), { title: "Prompt" }); +function validateApiKey(params: { + agent: Agent; + 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 { + 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({ payload: ctx.payload, diff --git a/mcp/checkSuite.ts b/mcp/checkSuite.ts index 9506068..4c4f4f9 100644 --- a/mcp/checkSuite.ts +++ b/mcp/checkSuite.ts @@ -1,18 +1,18 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const GetCheckSuiteLogs = type({ check_suite_id: type.number.describe("the id from check_suite.id"), }); -export function GetCheckSuiteLogsTool(ctx: Context) { +export function GetCheckSuiteLogsTool(ctx: ToolContext) { return tool({ name: "get_check_suite_logs", description: "get workflow run logs for a failed check suite. pass check_suite.id from the webhook payload.", parameters: GetCheckSuiteLogs, - execute: execute(ctx, async ({ check_suite_id }) => { + execute: execute(async ({ check_suite_id }) => { // get workflow runs for this specific check suite const workflowRuns = await ctx.octokit.paginate( ctx.octokit.rest.actions.listWorkflowRunsForRepo, diff --git a/mcp/checkout.ts b/mcp/checkout.ts index 6555aca..80d265f 100644 --- a/mcp/checkout.ts +++ b/mcp/checkout.ts @@ -1,5 +1,6 @@ +import type { Octokit } from "@octokit/rest"; import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { log } from "../utils/cli.ts"; import { $ } from "../utils/shell.ts"; import { execute, tool } from "./shared.ts"; @@ -20,23 +21,39 @@ export type CheckoutPrResult = { 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. * 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 { - log.debug(`๐Ÿ”€ checking out PR #${pull_number}...`); +export async function checkoutPrBranch( + params: CheckoutPrBranchParams +): Promise { + const { octokit, owner, name, token, pullNumber } = params; + log.info(`๐Ÿ”€ checking out PR #${pullNumber}...`); // fetch PR metadata - const pr = await ctx.octokit.rest.pulls.get({ - owner: ctx.owner, - repo: ctx.name, - pull_number, + const pr = await octokit.rest.pulls.get({ + owner, + repo: name, + pull_number: pullNumber, }); const headRepo = pr.data.head.repo; 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; @@ -59,12 +76,12 @@ export async function checkoutPrBranch(ctx: Context, pull_number: number): Promi $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); // fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs) - log.debug(`๐ŸŒฟ fetching PR #${pull_number} (${headBranch})...`); - $("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]); + log.debug(`๐ŸŒฟ fetching PR #${pullNumber} (${headBranch})...`); + $("git", ["fetch", "--no-tags", "origin", `pull/${pullNumber}/head:${headBranch}`]); // checkout the branch $("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) @@ -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 // fork remotes. This ensures fork PRs can push even when checkout_pr is called after setupGit. if (isFork) { - const remoteName = `pr-${pull_number}`; - const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`; + const remoteName = `pr-${pullNumber}`; + const forkUrl = `https://x-access-token:${token}@github.com/${headRepo.full_name}.git`; // add fork as a named remote (ignore error if already exists) try { @@ -107,18 +124,26 @@ export async function checkoutPrBranch(ctx: Context, pull_number: number): Promi $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); } - // set PR context - ctx.toolState.prNumber = pull_number; + return { prNumber: pullNumber }; } -export function CheckoutPrTool(ctx: Context) { +export function CheckoutPrTool(ctx: ToolContext) { return tool({ 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.", parameters: CheckoutPr, - execute: execute(ctx, async ({ pull_number }) => { - await checkoutPrBranch(ctx, pull_number); + execute: execute(async ({ 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 const pr = await ctx.octokit.rest.pulls.get({ diff --git a/mcp/comment.ts b/mcp/comment.ts index 3c6d927..f529509 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -2,7 +2,7 @@ import { Octokit } from "@octokit/rest"; import { type } from "arktype"; import type { Payload } from "../external.ts"; import { agentsManifest } from "../external.ts"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { fetchWorkflowRunInfo } from "../utils/api.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { getGitHubInstallationToken, parseRepoContext } from "../utils/github.ts"; @@ -66,13 +66,13 @@ export const Comment = type({ body: type.string.describe("the comment body content"), }); -export function CreateCommentTool(ctx: Context) { +export function CreateCommentTool(ctx: ToolContext) { return tool({ name: "create_issue_comment", description: "Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.", parameters: Comment, - execute: execute(ctx, async ({ issueNumber, body }) => { + execute: execute(async ({ issueNumber, body }) => { const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); 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"), }); -export function EditCommentTool(ctx: Context) { +export function EditCommentTool(ctx: ToolContext) { return tool({ name: "edit_issue_comment", description: "Edit a GitHub issue comment by its ID", parameters: EditComment, - execute: execute(ctx, async ({ commentId, body }) => { + execute: execute(async ({ commentId, body }) => { const bodyWithFooter = await addFooter(body, ctx.payload, ctx.octokit); 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. */ export async function reportProgress( - ctx: Context, + ctx: ToolContext, { body }: { body: string } ): Promise< | { @@ -231,13 +231,13 @@ export async function reportProgress( }; } -export function ReportProgressTool(ctx: Context) { +export function ReportProgressTool(ctx: ToolContext) { return tool({ name: "report_progress", description: "Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.", parameters: ReportProgress, - execute: execute(ctx, async ({ body }) => { + execute: execute(async ({ body }) => { const result = await reportProgress(ctx, { body }); if (!result) { @@ -269,7 +269,7 @@ export function wasProgressCommentUpdated(): boolean { * Delete the progress comment if it exists. * Used after submitting a PR review since the review body contains all necessary info. */ -export async function deleteProgressComment(ctx: Context): Promise { +export async function deleteProgressComment(ctx: ToolContext): Promise { const existingCommentId = getProgressCommentId(); if (!existingCommentId) { return false; @@ -380,13 +380,13 @@ export const ReplyToReviewComment = type({ ), }); -export function ReplyToReviewCommentTool(ctx: Context) { +export function ReplyToReviewCommentTool(ctx: ToolContext) { return tool({ name: "reply_to_review_comment", description: "Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).", parameters: ReplyToReviewComment, - execute: 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 result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ diff --git a/mcp/debug.ts b/mcp/debug.ts index 18bf628..1880aba 100644 --- a/mcp/debug.ts +++ b/mcp/debug.ts @@ -1,17 +1,17 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { $ } from "../utils/shell.ts"; import { execute, tool } from "./shared.ts"; export const DebugShellCommand = type({}); -export function DebugShellCommandTool(_ctx: Context) { +export function DebugShellCommandTool(_ctx: ToolContext) { return tool({ 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.", parameters: DebugShellCommand, - execute: execute(_ctx, async () => { + execute: execute(async () => { const result = $("git", ["status"]); return { success: true, diff --git a/mcp/files.ts b/mcp/files.ts index c0b2e8c..46da352 100644 --- a/mcp/files.ts +++ b/mcp/files.ts @@ -1,7 +1,7 @@ import { relative, resolve } from "node:path"; import { type } from "arktype"; +import type { ToolContext } from "../main.ts"; import { $ } from "../utils/shell.ts"; -import type { Context } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const ListFiles = type({ @@ -10,13 +10,13 @@ export const ListFiles = type({ .default("."), }); -export function ListFilesTool(_ctx: Context) { +export function ListFilesTool(_ctx: ToolContext) { return tool({ 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.", parameters: ListFiles, - execute: execute(_ctx, async ({ path }: { path?: string }) => { + execute: execute(async ({ path }: { path?: string }) => { const pathStr = path ?? "."; const cwd = process.cwd(); diff --git a/mcp/git.ts b/mcp/git.ts index f511991..1cf3826 100644 --- a/mcp/git.ts +++ b/mcp/git.ts @@ -1,11 +1,11 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { log } from "../utils/cli.ts"; import { containsSecrets } from "../utils/secrets.ts"; import { $ } from "../utils/shell.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 CreateBranch = type({ @@ -22,7 +22,7 @@ export function CreateBranchTool(ctx: Context) { description: "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", parameters: CreateBranch, - execute: execute(ctx, async ({ branchName, baseBranch }) => { + execute: execute(async ({ branchName, baseBranch }) => { // baseBranch should always be defined due to default, but TypeScript needs help 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({ name: "commit_files", description: "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.", parameters: CommitFiles, - execute: execute(ctx, async ({ message, files }) => { + execute: execute(async ({ message, files }) => { // validate commit message for secrets if (containsSecrets(message)) { throw new Error( @@ -141,13 +141,13 @@ export const PushBranch = type({ force: type.boolean.describe("Force push (use with caution)").default(false), }); -export function PushBranchTool(_ctx: Context) { +export function PushBranchTool(_ctx: ToolContext) { return tool({ 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.", parameters: PushBranch, - execute: execute(_ctx, async ({ branchName, force }) => { + execute: execute(async ({ branchName, force }) => { const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); // check if branch has a configured pushRemote diff --git a/mcp/issue.ts b/mcp/issue.ts index c4898e0..f129f05 100644 --- a/mcp/issue.ts +++ b/mcp/issue.ts @@ -1,5 +1,5 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const Issue = type({ @@ -15,12 +15,12 @@ export const Issue = type({ .optional(), }); -export function IssueTool(ctx: Context) { +export function IssueTool(ctx: ToolContext) { return tool({ name: "create_issue", description: "Create a new GitHub 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({ owner: ctx.owner, repo: ctx.name, @@ -37,7 +37,9 @@ export function IssueTool(ctx: Context) { url: result.data.html_url, title: result.data.title, 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), }; }), diff --git a/mcp/issueComments.ts b/mcp/issueComments.ts index fa6c4d7..b023c09 100644 --- a/mcp/issueComments.ts +++ b/mcp/issueComments.ts @@ -1,18 +1,18 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const GetIssueComments = type({ issue_number: type.number.describe("The issue number to get comments for"), }); -export function GetIssueCommentsTool(ctx: Context) { +export function GetIssueCommentsTool(ctx: ToolContext) { return tool({ name: "get_issue_comments", description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", parameters: GetIssueComments, - execute: execute(ctx, async ({ issue_number }) => { + execute: execute(async ({ issue_number }) => { // set issue context ctx.toolState.issueNumber = issue_number; diff --git a/mcp/issueEvents.ts b/mcp/issueEvents.ts index 9afc930..67eac72 100644 --- a/mcp/issueEvents.ts +++ b/mcp/issueEvents.ts @@ -1,18 +1,18 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const GetIssueEvents = type({ issue_number: type.number.describe("The issue number to get events for"), }); -export function GetIssueEventsTool(ctx: Context) { +export function GetIssueEventsTool(ctx: ToolContext) { return tool({ name: "get_issue_events", description: "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", parameters: GetIssueEvents, - execute: execute(ctx, async ({ issue_number }) => { + execute: execute(async ({ issue_number }) => { // set issue context ctx.toolState.issueNumber = issue_number; diff --git a/mcp/issueInfo.ts b/mcp/issueInfo.ts index 656f990..a6aa0a5 100644 --- a/mcp/issueInfo.ts +++ b/mcp/issueInfo.ts @@ -1,17 +1,17 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const IssueInfo = type({ issue_number: type.number.describe("The issue number to fetch"), }); -export function IssueInfoTool(ctx: Context) { +export function IssueInfoTool(ctx: ToolContext) { return tool({ name: "get_issue", description: "Retrieve GitHub issue information by issue number", parameters: IssueInfo, - execute: execute(ctx, async ({ issue_number }) => { + execute: execute(async ({ issue_number }) => { const issue = await ctx.octokit.rest.issues.get({ owner: ctx.owner, repo: ctx.name, diff --git a/mcp/labels.ts b/mcp/labels.ts index 335dbd2..b15d1fa 100644 --- a/mcp/labels.ts +++ b/mcp/labels.ts @@ -1,5 +1,5 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; 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"), }); -export function AddLabelsTool(ctx: Context) { +export function AddLabelsTool(ctx: ToolContext) { return tool({ name: "add_labels", description: "Add labels to a GitHub issue or pull request. Only use labels that already exist in the repository.", parameters: AddLabelsParams, - execute: execute(ctx, async ({ issue_number, labels }) => { + execute: execute(async ({ issue_number, labels }) => { const result = await ctx.octokit.rest.issues.addLabels({ owner: ctx.owner, repo: ctx.name, diff --git a/mcp/pr.ts b/mcp/pr.ts index 538d8e2..ddac169 100644 --- a/mcp/pr.ts +++ b/mcp/pr.ts @@ -1,6 +1,6 @@ import { type } from "arktype"; 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 { log } from "../utils/cli.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')"), }); -function buildPrBodyWithFooter(ctx: Context, body: string): string { +function buildPrBodyWithFooter(ctx: ToolContext, body: string): string { const agentName = ctx.payload.agent; const agentInfo = agentName ? agentsManifest[agentName] : null; @@ -29,12 +29,12 @@ function buildPrBodyWithFooter(ctx: Context, body: string): string { return `${bodyWithoutFooter}${footer}`; } -export function PullRequestTool(ctx: Context) { +export function PullRequestTool(ctx: ToolContext) { return tool({ name: "create_pull_request", description: "Create a pull request from the current branch", 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 }); log.debug(`Current branch: ${currentBranch}`); diff --git a/mcp/prInfo.ts b/mcp/prInfo.ts index 432b0b4..f766e37 100644 --- a/mcp/prInfo.ts +++ b/mcp/prInfo.ts @@ -1,18 +1,18 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const PullRequestInfo = type({ pull_number: type.number.describe("The pull request number to fetch"), }); -export function PullRequestInfoTool(ctx: Context) { +export function PullRequestInfoTool(ctx: ToolContext) { return tool({ 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.", parameters: PullRequestInfo, - execute: execute(ctx, async ({ pull_number }) => { + execute: execute(async ({ pull_number }) => { const pr = await ctx.octokit.rest.pulls.get({ owner: ctx.owner, repo: ctx.name, diff --git a/mcp/review.ts b/mcp/review.ts index 6ec7d07..8d23b5d 100644 --- a/mcp/review.ts +++ b/mcp/review.ts @@ -3,7 +3,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import type { RestEndpointMethodTypes } from "@octokit/rest"; import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { log } from "../utils/cli.ts"; import { deleteProgressComment } from "./comment.ts"; @@ -37,7 +37,7 @@ type AddPullRequestReviewThreadResponse = { // helper to find existing pending review for the authenticated user async function findPendingReview( - ctx: Context, + ctx: ToolContext, pull_number: number ): Promise<{ id: number; node_id: string } | null> { 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"), }); -export function StartReviewTool(ctx: Context) { +export function StartReviewTool(ctx: ToolContext) { return tool({ 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.", parameters: StartReview, - execute: execute(ctx, async ({ pull_number }) => { + execute: execute(async ({ pull_number }) => { // check if review already started in this session if (ctx.toolState.review) { throw new Error( @@ -154,13 +154,13 @@ export const AddReviewComment = type({ .optional(), }); -export function AddReviewCommentTool(ctx: Context) { +export function AddReviewCommentTool(ctx: ToolContext) { return tool({ 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.", parameters: AddReviewComment, - execute: execute(ctx, async ({ path, line, body, side }) => { + execute: execute(async ({ path, line, body, side }) => { // check if review started if (!ctx.toolState.review) { throw new Error("No review session started. Call start_review first."); @@ -195,13 +195,13 @@ export const SubmitReview = type({ .optional(), }); -export function SubmitReviewTool(ctx: Context) { +export function SubmitReviewTool(ctx: ToolContext) { return tool({ name: "submit_review", description: "Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.", parameters: SubmitReview, - execute: execute(ctx, async ({ body }) => { + execute: execute(async ({ body }) => { // check if review started if (!ctx.toolState.review) { throw new Error("No review session started. Call start_review first."); @@ -288,7 +288,7 @@ export const Review = type({ .optional(), }); -export function ReviewTool(ctx: Context) { +export function ReviewTool(ctx: ToolContext) { return tool({ name: "submit_pull_request_review", 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. " + "Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.", parameters: Review, - execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => { + execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => { // set PR context ctx.toolState.prNumber = pull_number; diff --git a/mcp/reviewComments.ts b/mcp/reviewComments.ts index 73282b5..2238e1e 100644 --- a/mcp/reviewComments.ts +++ b/mcp/reviewComments.ts @@ -1,5 +1,5 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; // 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"), }); -export function GetReviewCommentsTool(ctx: Context) { +export function GetReviewCommentsTool(ctx: ToolContext) { return tool({ name: "get_review_comments", description: "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", parameters: GetReviewComments, - execute: execute(ctx, async ({ pull_number, review_id }) => { + execute: execute(async ({ pull_number, review_id }) => { // fetch all review threads using graphql const response = await ctx.octokit.graphql(REVIEW_THREADS_QUERY, { owner: ctx.owner, @@ -189,13 +189,13 @@ export const ListPullRequestReviews = type({ 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({ name: "list_pull_request_reviews", description: "List all reviews for a pull request. Returns all reviews including approvals, request changes, and comments.", parameters: ListPullRequestReviews, - execute: execute(ctx, async ({ pull_number }) => { + execute: execute(async ({ pull_number }) => { const reviews = await ctx.octokit.paginate(ctx.octokit.rest.pulls.listReviews, { owner: ctx.owner, repo: ctx.name, diff --git a/mcp/selectMode.ts b/mcp/selectMode.ts index 42c5b6d..369afe5 100644 --- a/mcp/selectMode.ts +++ b/mcp/selectMode.ts @@ -1,5 +1,5 @@ import { type } from "arktype"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { execute, tool } from "./shared.ts"; export const SelectMode = type({ @@ -8,13 +8,13 @@ export const SelectMode = type({ ), }); -export function SelectModeTool(ctx: Context) { +export function SelectModeTool(ctx: ToolContext) { return tool({ name: "select_mode", description: "Select a mode and get its detailed prompt instructions. Call this first to determine which mode to use based on the request.", parameters: SelectMode, - execute: execute(ctx, async ({ modeName }) => { + execute: execute(async ({ modeName }) => { const selectedMode = ctx.modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()); if (!selectedMode) { diff --git a/mcp/server.ts b/mcp/server.ts index b0bb47a..9ea2059 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -1,9 +1,9 @@ import "./arkConfig.ts"; -// this must be imported first import { createServer } from "node:net"; +// this must be imported first import { FastMCP, type Tool } from "fastmcp"; import { ghPullfrogMcpName } from "../external.ts"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; import { CheckoutPrTool } from "./checkout.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { @@ -25,7 +25,7 @@ import { PullRequestInfoTool } from "./prInfo.ts"; import { AddReviewCommentTool, StartReviewTool, SubmitReviewTool } from "./review.ts"; import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.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 @@ -60,7 +60,7 @@ async function findAvailablePort(startPort: number): Promise { * Start the MCP HTTP server and return the URL and close function */ export async function startMcpHttpServer( - ctx: Context + ctx: ToolContext ): Promise<{ url: string; close: () => Promise }> { const server = new FastMCP({ name: ghPullfrogMcpName, @@ -95,8 +95,7 @@ export async function startMcpHttpServer( ListFilesTool(ctx), ]; - // only include ReportProgressTool if progress comment is not disabled - if (!isProgressCommentDisabled(ctx)) { + if (!ctx.payload.disableProgressComment) { tools.push(ReportProgressTool(ctx)); } diff --git a/mcp/shared.ts b/mcp/shared.ts index e4d7907..0d2b791 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -1,6 +1,6 @@ import type { StandardSchemaV1 } from "@standard-schema/spec"; import type { FastMCP, Tool } from "fastmcp"; -import type { Context } from "../main.ts"; +import type { ToolContext } from "../main.ts"; export const tool = (toolDef: Tool>) => toolDef; @@ -40,7 +40,7 @@ export const handleToolError = (error: unknown): ToolResult => { * Helper to wrap a tool execute function with error handling. * Captures ctx in closure so tools don't need to handle try/catch. */ -export const execute = (ctx: Context, fn: (params: T) => Promise>) => { +export const execute = (fn: (params: T) => Promise>) => { return async (params: T): Promise => { try { const result = await fn(params); @@ -51,10 +51,6 @@ export const execute = (ctx: Context, fn: (params: T) => Promise>(tool: T): T { } as T; } -export const addTools = (ctx: Context, server: FastMCP, tools: Tool[]) => { +export const addTools = (ctx: ToolContext, server: FastMCP, tools: Tool[]) => { // sanitize schemas for gemini agent and opencode (when using Google API) // both have issues with draft-2020-12 schemas and any_of enum constructs - const shouldSanitize = ctx.agentName === "gemini" || ctx.agentName === "opencode"; + const shouldSanitize = ctx.agent.name === "gemini" || ctx.agent.name === "opencode"; for (const tool of tools) { const processedTool = shouldSanitize ? sanitizeTool(tool) : tool; diff --git a/package.json b/package.json index 7bb4254..36a0533 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/action", - "version": "0.0.144", + "version": "0.0.146", "type": "module", "files": [ "index.js", diff --git a/utils/setup.ts b/utils/setup.ts index b70ca37..321c4af 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -1,6 +1,8 @@ import { execSync } from "node:child_process"; 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 { log } from "./cli.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. * 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 * - diff operations use: git diff origin/..HEAD */ -export async function setupGit(ctx: Context): Promise { +export async function setupGitAuth(params: SetupGitAuthParams): Promise { const repoDir = process.cwd(); log.info("ยป setting up git authentication..."); @@ -97,20 +108,29 @@ export async function setupGit(ctx: Context): Promise { } // non-PR events: set up origin with token, stay on default branch - if (ctx.payload.event.is_pr !== true || !ctx.payload.event.issue_number) { - const originUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.owner}/${ctx.name}.git`; + if (params.payload.event.is_pr !== true || !params.payload.event.issue_number) { + const originUrl = `https://x-access-token:${params.token}@github.com/${params.owner}/${params.name}.git`; $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); log.info("ยป updated origin URL with authentication token"); return; } // 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 - 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 }); // 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; }