Clean up init

This commit is contained in:
Colin McDonnell
2025-12-15 22:21:47 -08:00
parent 6f96458e2d
commit 0fced1dfa6
7 changed files with 320 additions and 199 deletions
+97 -48
View File
@@ -39287,18 +39287,26 @@ var init_buildPullfrogFooter = __esm({
// mcp/shared.ts // mcp/shared.ts
function initMcpContext(state) { function initMcpContext(state) {
mcpInitContext = state; mcpInitContext = state;
cachedMcpContext = void 0;
} }
function getMcpContext() { async function getMcpContext() {
if (!mcpInitContext) { if (!mcpInitContext) {
throw new Error("MCP context not initialized. Call initializeMcpContext first."); throw new Error("MCP context not initialized. Call initializeMcpContext first.");
} }
return { if (cachedMcpContext) {
...mcpInitContext, return cachedMcpContext;
...parseRepoContext(), }
octokit: new Octokit2({ const repoContext = parseRepoContext();
const octokit = new Octokit2({
auth: getGitHubInstallationToken() auth: getGitHubInstallationToken()
}) });
cachedMcpContext = {
...mcpInitContext,
...repoContext,
octokit,
repo: mcpInitContext.repo
}; };
return cachedMcpContext;
} }
function isProgressCommentDisabled() { function isProgressCommentDisabled() {
return mcpInitContext?.payload.disableProgressComment === true; return mcpInitContext?.payload.disableProgressComment === true;
@@ -39382,7 +39390,7 @@ function sanitizeTool(tool2) {
parameters: wrappedSchema parameters: wrappedSchema
}; };
} }
var mcpInitContext, tool, addTools, contextualize, handleToolSuccess, handleToolError; var mcpInitContext, cachedMcpContext, tool, addTools, contextualize, handleToolSuccess, handleToolError;
var init_shared3 = __esm({ var init_shared3 = __esm({
"mcp/shared.ts"() { "mcp/shared.ts"() {
"use strict"; "use strict";
@@ -39400,7 +39408,7 @@ var init_shared3 = __esm({
contextualize = (executor) => { contextualize = (executor) => {
return async (params) => { return async (params) => {
try { try {
const ctx = getMcpContext(); const ctx = await getMcpContext();
const result = await executor(params, ctx); const result = await executor(params, ctx);
return handleToolSuccess(result); return handleToolSuccess(result);
} catch (error41) { } catch (error41) {
@@ -39491,7 +39499,7 @@ function setProgressCommentId(id) {
progressCommentIdInitialized = true; progressCommentIdInitialized = true;
} }
async function reportProgress({ body }) { async function reportProgress({ body }) {
const ctx = getMcpContext(); const ctx = await getMcpContext();
const bodyWithFooter = addFooter(body, ctx.payload); const bodyWithFooter = addFooter(body, ctx.payload);
const existingCommentId = getProgressCommentId(); const existingCommentId = getProgressCommentId();
if (existingCommentId) { if (existingCommentId) {
@@ -39536,7 +39544,7 @@ async function deleteProgressComment() {
if (!existingCommentId) { if (!existingCommentId) {
return false; return false;
} }
const ctx = getMcpContext(); const ctx = await getMcpContext();
await ctx.octokit.rest.issues.deleteComment({ await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
@@ -39588,7 +39596,7 @@ async function ensureProgressCommentUpdated(payload) {
} }
let resolvedPayload; let resolvedPayload;
try { try {
const ctx = getMcpContext(); const ctx = await getMcpContext();
resolvedPayload = ctx.payload; resolvedPayload = ctx.payload;
} catch { } catch {
resolvedPayload = payload; resolvedPayload = payload;
@@ -99850,6 +99858,7 @@ var agents = {
import { mkdtemp as mkdtemp2 } from "node:fs/promises"; import { mkdtemp as mkdtemp2 } from "node:fs/promises";
import { tmpdir as tmpdir2 } from "node:os"; import { tmpdir as tmpdir2 } from "node:os";
import { join as join8 } from "node:path"; import { join as join8 } from "node:path";
init_dist_src5();
init_out4(); init_out4();
init_external(); init_external();
init_comment(); init_comment();
@@ -124356,36 +124365,40 @@ function containsSecrets(content, secrets) {
// mcp/git.ts // mcp/git.ts
init_shared3(); init_shared3();
var CreateBranch = type({ function CreateBranchTool(ctx) {
const defaultBranch = ctx.repo.default_branch || "main";
const CreateBranch = type({
branchName: type.string.describe( branchName: type.string.describe(
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')" "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
), ),
baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main") baseBranch: type.string.describe(`The base branch to create from (defaults to '${defaultBranch}')`).default(defaultBranch)
}); });
var CreateBranchTool = tool({ return tool({
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: contextualize(async ({ branchName, baseBranch }) => { execute: contextualize(async ({ branchName, baseBranch }) => {
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
if (containsSecrets(branchName)) { if (containsSecrets(branchName)) {
throw new Error( throw new Error(
"Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch." "Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
); );
} }
log.info(`Creating branch ${branchName} from ${baseBranch}`); log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`);
$("git", ["fetch", "origin", baseBranch, "--depth=1"]); $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); $("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]);
$("git", ["checkout", "-b", branchName]); $("git", ["checkout", "-b", branchName]);
$("git", ["push", "-u", "origin", branchName]); $("git", ["push", "-u", "origin", branchName]);
log.info(`Successfully created and pushed branch ${branchName}`); log.info(`Successfully created and pushed branch ${branchName}`);
return { return {
success: true, success: true,
branchName, branchName,
baseBranch, baseBranch: resolvedBaseBranch,
message: `Branch ${branchName} created from ${baseBranch} and pushed to remote` message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote`
}; };
}) })
}); });
}
var CommitFiles = type({ var CommitFiles = type({
message: type.string.describe("The commit message"), message: type.string.describe("The commit message"),
files: type.string.array().describe( files: type.string.array().describe(
@@ -124440,27 +124453,31 @@ var PushBranch = type({
branchName: type.string.describe("The branch name to push (defaults to current branch)").optional(), branchName: type.string.describe("The branch name to push (defaults to current branch)").optional(),
force: type.boolean.describe("Force push (use with caution)").default(false) force: type.boolean.describe("Force push (use with caution)").default(false)
}); });
var PushBranchTool = tool({ function PushBranchTool(ctx) {
const remote = ctx.pushRemote;
return tool({
name: "push_branch", name: "push_branch",
description: "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", description: "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
parameters: PushBranch, parameters: PushBranch,
execute: contextualize(async ({ branchName, force }) => { execute: contextualize(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 });
log.info(`Pushing branch ${branch} to ${remote}`);
if (force) { if (force) {
log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); log.warning(`Force pushing - this will overwrite remote history`);
$("git", ["push", "--force", "origin", branch]); $("git", ["push", "--force", "-u", remote, branch]);
} else { } else {
log.info(`Pushing branch ${branch} to remote`); $("git", ["push", "-u", remote, branch]);
$("git", ["push", "origin", branch]);
} }
return { return {
success: true, success: true,
branch, branch,
remote,
force, force,
message: `Successfully pushed branch ${branch} to remote` message: `Successfully pushed branch ${branch} to ${remote}`
}; };
}) })
}); });
}
// mcp/issue.ts // mcp/issue.ts
init_out4(); init_out4();
@@ -125043,6 +125060,7 @@ async function startMcpHttpServer(state) {
name: ghPullfrogMcpName, name: ghPullfrogMcpName,
version: "0.0.1" version: "0.0.1"
}); });
const ctx = await getMcpContext();
const tools = [ const tools = [
SelectModeTool, SelectModeTool,
CreateCommentTool, CreateCommentTool,
@@ -125060,9 +125078,9 @@ async function startMcpHttpServer(state) {
GetCheckSuiteLogsTool, GetCheckSuiteLogsTool,
DebugShellCommandTool, DebugShellCommandTool,
AddLabelsTool, AddLabelsTool,
CreateBranchTool, CreateBranchTool(ctx),
CommitFilesTool, CommitFilesTool,
PushBranchTool PushBranchTool(ctx)
]; ];
if (!isProgressCommentDisabled()) { if (!isProgressCommentDisabled()) {
tools.push(ReportProgressTool); tools.push(ReportProgressTool);
@@ -125156,7 +125174,6 @@ init_github();
// utils/setup.ts // utils/setup.ts
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
init_cli(); init_cli();
init_github();
function setupGitConfig() { function setupGitConfig() {
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("\u{1F527} Setting up git configuration..."); log.info("\u{1F527} Setting up git configuration...");
@@ -125176,9 +125193,10 @@ function setupGitConfig() {
); );
} }
} }
function setupGitAuth(ctx) { function setupGit(ctx) {
const { githubInstallationToken: githubInstallationToken2, payload } = ctx;
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("\u{1F510} Setting up git authentication..."); log.info("\u{1F527} Setting up git configuration...");
try { try {
execSync("git config --local --unset-all http.https://github.com/.extraheader", { execSync("git config --local --unset-all http.https://github.com/.extraheader", {
cwd: repoDir, cwd: repoDir,
@@ -125188,24 +125206,39 @@ function setupGitAuth(ctx) {
} catch { } catch {
log.debug("No existing authentication headers to remove"); log.debug("No existing authentication headers to remove");
} }
const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`; $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
$("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir }); $("git", ["config", "--local", "--add", "credential.helper", "!gh auth git-credential"], {
log.info("\u2713 Updated remote URL with authentication token (scoped to repo)"); cwd: repoDir,
} env: { GH_TOKEN: githubInstallationToken2 }
function setupGitBranch(payload) { });
log.info("\u2713 Configured gh as credential helper");
if (payload.event.is_pr !== true || !payload.event.issue_number) { if (payload.event.is_pr !== true || !payload.event.issue_number) {
log.debug("Not a PR event, staying on default branch"); log.debug("Not a PR event, staying on default branch");
return; return { pushRemote: "origin" };
} }
const prNumber = payload.event.issue_number; const prNumber = payload.event.issue_number;
const repoDir = process.cwd();
log.info(`\u{1F33F} Checking out PR #${prNumber}...`); log.info(`\u{1F33F} Checking out PR #${prNumber}...`);
const token = getGitHubInstallationToken();
$("gh", ["pr", "checkout", prNumber.toString()], { $("gh", ["pr", "checkout", prNumber.toString()], {
cwd: repoDir, cwd: repoDir,
env: { GH_TOKEN: token } env: { GH_TOKEN: githubInstallationToken2 }
}); });
log.info(`\u2713 Successfully checked out PR #${prNumber}`); log.info(`\u2713 Successfully checked out PR #${prNumber}`);
const pushRemote = detectPushRemote();
if (pushRemote !== "origin") {
log.info(`\u{1F374} Fork PR detected, will push to remote: ${pushRemote}`);
}
return { pushRemote };
}
function detectPushRemote() {
try {
const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
const upstream = $("git", ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`], {
log: false
});
return upstream.split("/")[0];
} catch {
return "origin";
}
} }
// utils/timer.ts // utils/timer.ts
@@ -125245,13 +125278,11 @@ async function main(inputs) {
const partialCtx = await initializeContext(inputs, payload); const partialCtx = await initializeContext(inputs, payload);
const ctx = partialCtx; const ctx = partialCtx;
timer.checkpoint("initializeContext"); timer.checkpoint("initializeContext");
setupGitAuth({ const { pushRemote } = setupGit(ctx);
githubInstallationToken: ctx.githubInstallationToken, ctx.pushRemote = pushRemote;
repoContext: ctx.repoContext timer.checkpoint("setupGit");
});
await setupTempDirectory(ctx); await setupTempDirectory(ctx);
timer.checkpoint("setupTempDirectory"); timer.checkpoint("setupTempDirectory");
setupGitBranch(ctx.payload);
await startMcpServer(ctx); await startMcpServer(ctx);
mcpServerClose = ctx.mcpServerClose; mcpServerClose = ctx.mcpServerClose;
timer.checkpoint("startMcpServer"); timer.checkpoint("startMcpServer");
@@ -125349,6 +125380,21 @@ async function initializeContext(inputs, payload) {
setupGitConfig(); setupGitConfig();
const githubInstallationToken2 = await setupGitHubInstallationToken(); const githubInstallationToken2 = await setupGitHubInstallationToken();
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
const octokit = new Octokit2({
auth: githubInstallationToken2
});
let repo;
try {
const response = await octokit.repos.get({
owner: repoContext.owner,
repo: repoContext.name
});
repo = response.data;
} catch {
repo = {
default_branch: "main"
};
}
const { agentName, agent: agent2 } = await resolveAgent( const { agentName, agent: agent2 } = await resolveAgent(
inputs, inputs,
payload, payload,
@@ -125360,6 +125406,7 @@ async function initializeContext(inputs, payload) {
inputs, inputs,
githubInstallationToken: githubInstallationToken2, githubInstallationToken: githubInstallationToken2,
repoContext, repoContext,
repo,
agentName, agentName,
agent: agent2, agent: agent2,
payload: resolvedPayload, payload: resolvedPayload,
@@ -125448,7 +125495,9 @@ async function startMcpServer(ctx) {
const { url: url2, close } = await startMcpHttpServer({ const { url: url2, close } = await startMcpHttpServer({
payload: ctx.payload, payload: ctx.payload,
modes: allModes, modes: allModes,
agentName: ctx.agentName agentName: ctx.agentName,
repo: ctx.repo,
pushRemote: ctx.pushRemote
}); });
ctx.mcpServerUrl = url2; ctx.mcpServerUrl = url2;
ctx.mcpServerClose = close; ctx.mcpServerClose = close;
+37 -12
View File
@@ -2,6 +2,7 @@ import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { flatMorph } from "@ark/util"; import { flatMorph } from "@ark/util";
import { Octokit } from "@octokit/rest";
import { encode as toonEncode } from "@toon-format/toon"; import { encode as toonEncode } from "@toon-format/toon";
import { type } from "arktype"; import { type } from "arktype";
import { agents } from "./agents/index.ts"; import { agents } from "./agents/index.ts";
@@ -22,7 +23,7 @@ import {
revokeGitHubInstallationToken, revokeGitHubInstallationToken,
setupGitHubInstallationToken, setupGitHubInstallationToken,
} from "./utils/github.ts"; } from "./utils/github.ts";
import { setupGitAuth, setupGitBranch, setupGitConfig } from "./utils/setup.ts"; import { setupGit, setupGitConfig } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts"; import { Timer } from "./utils/timer.ts";
// runtime validation using agents (needed for ArkType) // runtime validation using agents (needed for ArkType)
@@ -64,16 +65,13 @@ export async function main(inputs: Inputs): Promise<MainResult> {
const ctx = partialCtx as MainContext; const ctx = partialCtx as MainContext;
timer.checkpoint("initializeContext"); timer.checkpoint("initializeContext");
setupGitAuth({ const { pushRemote } = setupGit(ctx);
githubInstallationToken: ctx.githubInstallationToken, ctx.pushRemote = pushRemote;
repoContext: ctx.repoContext, timer.checkpoint("setupGit");
});
await setupTempDirectory(ctx); await setupTempDirectory(ctx);
timer.checkpoint("setupTempDirectory"); timer.checkpoint("setupTempDirectory");
setupGitBranch(ctx.payload);
await startMcpServer(ctx); await startMcpServer(ctx);
mcpServerClose = ctx.mcpServerClose; mcpServerClose = ctx.mcpServerClose;
timer.checkpoint("startMcpServer"); timer.checkpoint("startMcpServer");
@@ -205,14 +203,16 @@ To fix this, add the required secret to your GitHub repository:
throw new Error(message); throw new Error(message);
} }
interface MainContext { export interface MainContext {
inputs: Inputs; inputs: Inputs;
githubInstallationToken: string; githubInstallationToken: string;
repoContext: RepoContext; repoContext: RepoContext;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
agentName: AgentName; agentName: AgentName;
agent: (typeof agents)[AgentName]; agent: (typeof agents)[AgentName];
sharedTempDir: string; sharedTempDir: string;
payload: Payload; payload: Payload;
pushRemote: string;
mcpServerUrl: string; mcpServerUrl: string;
mcpServerClose: () => Promise<void>; mcpServerClose: () => Promise<void>;
mcpServers: ReturnType<typeof createMcpConfigs>; mcpServers: ReturnType<typeof createMcpConfigs>;
@@ -227,7 +227,13 @@ async function initializeContext(
): Promise< ): Promise<
Omit< Omit<
MainContext, MainContext,
"mcpServerUrl" | "mcpServerClose" | "mcpServers" | "cliPath" | "apiKey" | "apiKeys" | "mcpServerUrl"
| "mcpServerClose"
| "mcpServers"
| "cliPath"
| "apiKey"
| "apiKeys"
| "pushRemote"
> >
> { > {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
@@ -237,6 +243,24 @@ async function initializeContext(
const githubInstallationToken = await setupGitHubInstallationToken(); const githubInstallationToken = await setupGitHubInstallationToken();
const repoContext = parseRepoContext(); const repoContext = parseRepoContext();
// fetch repo data
const octokit = new Octokit({
auth: githubInstallationToken,
});
let repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
try {
const response = await octokit.repos.get({
owner: repoContext.owner,
repo: repoContext.name,
});
repo = response.data;
} catch {
// fallback to minimal repo data if API call fails
repo = {
default_branch: "main",
} as Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
}
// resolve agent and update payload with resolved agent name // resolve agent and update payload with resolved agent name
const { agentName, agent } = await resolveAgent( const { agentName, agent } = await resolveAgent(
inputs, inputs,
@@ -250,6 +274,7 @@ async function initializeContext(
inputs, inputs,
githubInstallationToken, githubInstallationToken,
repoContext, repoContext,
repo,
agentName, agentName,
agent, agent,
payload: resolvedPayload, payload: resolvedPayload,
@@ -324,9 +349,7 @@ async function resolveAgent(
return { agentName, agent }; return { agentName, agent };
} }
async function setupTempDirectory( async function setupTempDirectory(ctx: MainContext): Promise<void> {
ctx: Omit<MainContext, "payload" | "mcpServers" | "cliPath" | "apiKey">
): Promise<void> {
ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-"));
process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir;
log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`);
@@ -372,6 +395,8 @@ async function startMcpServer(ctx: MainContext): Promise<void> {
payload: ctx.payload, payload: ctx.payload,
modes: allModes, modes: allModes,
agentName: ctx.agentName, agentName: ctx.agentName,
repo: ctx.repo,
pushRemote: ctx.pushRemote,
}); });
ctx.mcpServerUrl = url; ctx.mcpServerUrl = url;
ctx.mcpServerClose = close; ctx.mcpServerClose = close;
+3 -3
View File
@@ -150,7 +150,7 @@ export async function reportProgress({ body }: { body: string }): Promise<
} }
| undefined | undefined
> { > {
const ctx = getMcpContext(); const ctx = await getMcpContext();
const bodyWithFooter = addFooter(body, ctx.payload); const bodyWithFooter = addFooter(body, ctx.payload);
const existingCommentId = getProgressCommentId(); const existingCommentId = getProgressCommentId();
@@ -242,7 +242,7 @@ export async function deleteProgressComment(): Promise<boolean> {
return false; return false;
} }
const ctx = getMcpContext(); const ctx = await getMcpContext();
await ctx.octokit.rest.issues.deleteComment({ await ctx.octokit.rest.issues.deleteComment({
owner: ctx.owner, owner: ctx.owner,
@@ -324,7 +324,7 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise<v
// try to get payload from MCP context if available, otherwise use provided payload // try to get payload from MCP context if available, otherwise use provided payload
let resolvedPayload: Payload | undefined; let resolvedPayload: Payload | undefined;
try { try {
const ctx = getMcpContext(); const ctx = await getMcpContext();
resolvedPayload = ctx.payload; resolvedPayload = ctx.payload;
} catch { } catch {
// MCP context not initialized, use provided payload // MCP context not initialized, use provided payload
+31 -18
View File
@@ -2,21 +2,28 @@ import { type } from "arktype";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts"; import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts"; import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts"; import { contextualize, type McpContext, tool } from "./shared.ts";
export const CreateBranch = type({ export function CreateBranchTool(ctx: McpContext) {
const defaultBranch = ctx.repo.default_branch || "main";
const CreateBranch = type({
branchName: type.string.describe( branchName: type.string.describe(
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')" "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
), ),
baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main"), baseBranch: type.string
}); .describe(`The base branch to create from (defaults to '${defaultBranch}')`)
.default(defaultBranch),
});
export const CreateBranchTool = tool({ return tool({
name: "create_branch", name: "create_branch",
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: contextualize(async ({ branchName, baseBranch }) => { execute: contextualize(async ({ branchName, baseBranch }) => {
// baseBranch should always be defined due to default, but TypeScript needs help
const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
// validate branch name for secrets // validate branch name for secrets
if (containsSecrets(branchName)) { if (containsSecrets(branchName)) {
throw new Error( throw new Error(
@@ -25,14 +32,14 @@ export const CreateBranchTool = tool({
); );
} }
log.info(`Creating branch ${branchName} from ${baseBranch}`); log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`);
// fetch base branch to ensure we're up to date // fetch base branch to ensure we're up to date
$("git", ["fetch", "origin", baseBranch, "--depth=1"]); $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
// checkout base branch, ensuring it matches the remote version // checkout base branch, ensuring it matches the remote version
// -B creates or resets the branch to match origin/baseBranch // -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); $("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]);
// create and checkout new branch // create and checkout new branch
$("git", ["checkout", "-b", branchName]); $("git", ["checkout", "-b", branchName]);
@@ -45,11 +52,12 @@ export const CreateBranchTool = tool({
return { return {
success: true, success: true,
branchName, branchName,
baseBranch, baseBranch: resolvedBaseBranch,
message: `Branch ${branchName} created from ${baseBranch} and pushed to remote`, message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote`,
}; };
}), }),
}); });
}
export const CommitFiles = type({ export const CommitFiles = type({
message: type.string.describe("The commit message"), message: type.string.describe("The commit message"),
@@ -129,7 +137,10 @@ export const PushBranch = type({
force: type.boolean.describe("Force push (use with caution)").default(false), force: type.boolean.describe("Force push (use with caution)").default(false),
}); });
export const PushBranchTool = tool({ export function PushBranchTool(ctx: McpContext) {
const remote = ctx.pushRemote;
return tool({
name: "push_branch", name: "push_branch",
description: description:
"Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
@@ -137,19 +148,21 @@ export const PushBranchTool = tool({
execute: contextualize(async ({ branchName, force }) => { execute: contextualize(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 });
log.info(`Pushing branch ${branch} to ${remote}`);
if (force) { if (force) {
log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); log.warning(`Force pushing - this will overwrite remote history`);
$("git", ["push", "--force", "origin", branch]); $("git", ["push", "--force", "-u", remote, branch]);
} else { } else {
log.info(`Pushing branch ${branch} to remote`); $("git", ["push", "-u", remote, branch]);
$("git", ["push", "origin", branch]);
} }
return { return {
success: true, success: true,
branch, branch,
remote,
force, force,
message: `Successfully pushed branch ${branch} to remote`, message: `Successfully pushed branch ${branch} to ${remote}`,
}; };
}), }),
}); });
}
+6 -2
View File
@@ -24,6 +24,7 @@ import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComme
import { SelectModeTool } from "./selectMode.ts"; import { SelectModeTool } from "./selectMode.ts";
import { import {
addTools, addTools,
getMcpContext,
initMcpContext, initMcpContext,
isProgressCommentDisabled, isProgressCommentDisabled,
type McpInitContext, type McpInitContext,
@@ -71,6 +72,9 @@ export async function startMcpHttpServer(
version: "0.0.1", version: "0.0.1",
}); });
// get context for dynamic tool creation
const ctx = await getMcpContext();
const tools: Tool<any, any>[] = [ const tools: Tool<any, any>[] = [
SelectModeTool, SelectModeTool,
CreateCommentTool, CreateCommentTool,
@@ -88,9 +92,9 @@ export async function startMcpHttpServer(
GetCheckSuiteLogsTool, GetCheckSuiteLogsTool,
DebugShellCommandTool, DebugShellCommandTool,
AddLabelsTool, AddLabelsTool,
CreateBranchTool, CreateBranchTool(ctx),
CommitFilesTool, CommitFilesTool,
PushBranchTool, PushBranchTool(ctx),
]; ];
// only include ReportProgressTool if progress comment is not disabled // only include ReportProgressTool if progress comment is not disabled
+25 -7
View File
@@ -9,30 +9,48 @@ export interface McpInitContext {
payload: Payload; payload: Payload;
modes: Mode[]; modes: Mode[];
agentName?: string; agentName?: string;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
pushRemote: string;
} }
let mcpInitContext: McpInitContext | undefined; let mcpInitContext: McpInitContext | undefined;
let cachedMcpContext: McpContext | undefined;
// this must be called on mcp server initialization // this must be called on mcp server initialization
export function initMcpContext(state: McpInitContext): void { export function initMcpContext(state: McpInitContext): void {
mcpInitContext = state; mcpInitContext = state;
// clear cached context when reinitializing
cachedMcpContext = undefined;
} }
export interface McpContext extends McpInitContext, RepoContext { export interface McpContext extends McpInitContext, RepoContext {
octokit: Octokit; octokit: Octokit;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
} }
export function getMcpContext(): McpContext { export async function getMcpContext(): Promise<McpContext> {
if (!mcpInitContext) { if (!mcpInitContext) {
throw new Error("MCP context not initialized. Call initializeMcpContext first."); throw new Error("MCP context not initialized. Call initializeMcpContext first.");
} }
return {
...mcpInitContext, // return cached context if available
...parseRepoContext(), if (cachedMcpContext) {
octokit: new Octokit({ return cachedMcpContext;
}
const repoContext = parseRepoContext();
const octokit = new Octokit({
auth: getGitHubInstallationToken(), auth: getGitHubInstallationToken(),
}), });
cachedMcpContext = {
...mcpInitContext,
...repoContext,
octokit,
repo: mcpInitContext.repo,
}; };
return cachedMcpContext;
} }
export function isProgressCommentDisabled(): boolean { export function isProgressCommentDisabled(): boolean {
@@ -180,7 +198,7 @@ export const contextualize = <T>(
) => { ) => {
return async (params: T): Promise<ToolResult> => { return async (params: T): Promise<ToolResult> => {
try { try {
const ctx = getMcpContext(); const ctx = await getMcpContext();
const result = await executor(params, ctx); const result = await executor(params, ctx);
return handleToolSuccess(result); return handleToolSuccess(result);
} catch (error) { } catch (error) {
+44 -32
View File
@@ -1,8 +1,7 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs"; import { existsSync, rmSync } from "node:fs";
import type { Payload } from "../external.ts"; import type { MainContext } from "../main.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import { getGitHubInstallationToken, type RepoContext } from "./github.ts";
import { $ } from "./shell.ts"; import { $ } from "./shell.ts";
export interface SetupOptions { export interface SetupOptions {
@@ -63,20 +62,23 @@ export function setupGitConfig(): void {
} }
} }
export type SetupGitResult = {
pushRemote: string;
};
/** /**
* Setup git authentication using GitHub installation token * Unified git setup: configures authentication and checks out PR branch if applicable.
* Always uses the installation token, scoped to the current repo only * Uses gh as credential helper so git push works with any remote (including forks).
* For PR events, gh pr checkout sets up proper remote tracking.
* Returns the remote to push to (detected from branch tracking after checkout).
*/ */
export function setupGitAuth(ctx: { export function setupGit(ctx: MainContext): SetupGitResult {
githubInstallationToken: string; const { githubInstallationToken, payload } = ctx;
repoContext: RepoContext;
}): void {
const repoDir = process.cwd(); const repoDir = process.cwd();
log.info("🔐 Setting up git authentication..."); log.info("🔧 Setting up git configuration...");
// Remove existing git auth headers that actions/checkout might have set // remove existing git auth headers that actions/checkout might have set
// Use --local to scope to this repo only
try { try {
execSync("git config --local --unset-all http.https://github.com/.extraheader", { execSync("git config --local --unset-all http.https://github.com/.extraheader", {
cwd: repoDir, cwd: repoDir,
@@ -87,36 +89,46 @@ export function setupGitAuth(ctx: {
log.debug("No existing authentication headers to remove"); log.debug("No existing authentication headers to remove");
} }
// Update remote URL to embed the token // set up gh as credential helper - this makes git use GH_TOKEN for any remote
// This is scoped to the repo's .git/config, not the user's global config $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`; $("git", ["config", "--local", "--add", "credential.helper", "!gh auth git-credential"], {
$("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir }); cwd: repoDir,
log.info("✓ Updated remote URL with authentication token (scoped to repo)"); env: { GH_TOKEN: githubInstallationToken },
} });
log.info("✓ Configured gh as credential helper");
/** // non-PR events: stay on default branch, push to origin
* Setup git branch based on payload event context.
* For PR events, uses `gh pr checkout` which handles fork PRs automatically.
* For non-PR events, stays on the default branch.
*/
export function setupGitBranch(payload: Payload): void {
// only checkout for PR events - use issue_number directly (no dependency on branch field)
if (payload.event.is_pr !== true || !payload.event.issue_number) { if (payload.event.is_pr !== true || !payload.event.issue_number) {
log.debug("Not a PR event, staying on default branch"); log.debug("Not a PR event, staying on default branch");
return; return { pushRemote: "origin" };
} }
// checkout PR branch - gh pr checkout handles fork remotes and tracking automatically
const prNumber = payload.event.issue_number; const prNumber = payload.event.issue_number;
const repoDir = process.cwd();
log.info(`🌿 Checking out PR #${prNumber}...`); log.info(`🌿 Checking out PR #${prNumber}...`);
// gh pr checkout handles fork PRs by setting up remotes automatically
const token = getGitHubInstallationToken();
$("gh", ["pr", "checkout", prNumber.toString()], { $("gh", ["pr", "checkout", prNumber.toString()], {
cwd: repoDir, cwd: repoDir,
env: { GH_TOKEN: token }, env: { GH_TOKEN: githubInstallationToken },
}); });
log.info(`✓ Successfully checked out PR #${prNumber}`); log.info(`✓ Successfully checked out PR #${prNumber}`);
// detect the push remote from branch tracking (set by gh pr checkout)
const pushRemote = detectPushRemote();
if (pushRemote !== "origin") {
log.info(`🍴 Fork PR detected, will push to remote: ${pushRemote}`);
}
return { pushRemote };
}
function detectPushRemote(): string {
try {
const branch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
const upstream = $("git", ["rev-parse", "--abbrev-ref", `${branch}@{upstream}`], {
log: false,
});
// upstream is like "remote/branch", extract remote name
return upstream.split("/")[0];
} catch {
return "origin";
}
} }