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
+131 -82
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) {
return cachedMcpContext;
}
const repoContext = parseRepoContext();
const octokit = new Octokit2({
auth: getGitHubInstallationToken()
});
cachedMcpContext = {
...mcpInitContext, ...mcpInitContext,
...parseRepoContext(), ...repoContext,
octokit: new Octokit2({ octokit,
auth: getGitHubInstallationToken() 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) {
branchName: type.string.describe( const defaultBranch = ctx.repo.default_branch || "main";
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')" const CreateBranch = type({
), branchName: type.string.describe(
baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main") "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
}); ),
var CreateBranchTool = tool({ baseBranch: type.string.describe(`The base branch to create from (defaults to '${defaultBranch}')`).default(defaultBranch)
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.", return tool({
parameters: CreateBranch, name: "create_branch",
execute: contextualize(async ({ branchName, baseBranch }) => { description: "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.",
if (containsSecrets(branchName)) { parameters: CreateBranch,
throw new Error( execute: contextualize(async ({ branchName, baseBranch }) => {
"Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch." const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
); if (containsSecrets(branchName)) {
} throw new Error(
log.info(`Creating branch ${branchName} from ${baseBranch}`); "Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
$("git", ["fetch", "origin", baseBranch, "--depth=1"]); );
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); }
$("git", ["checkout", "-b", branchName]); log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`);
$("git", ["push", "-u", "origin", branchName]); $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
log.info(`Successfully created and pushed branch ${branchName}`); $("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]);
return { $("git", ["checkout", "-b", branchName]);
success: true, $("git", ["push", "-u", "origin", branchName]);
branchName, log.info(`Successfully created and pushed branch ${branchName}`);
baseBranch, return {
message: `Branch ${branchName} created from ${baseBranch} and pushed to remote` success: true,
}; branchName,
}) baseBranch: resolvedBaseBranch,
}); 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) {
name: "push_branch", const remote = ctx.pushRemote;
description: "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", return tool({
parameters: PushBranch, name: "push_branch",
execute: contextualize(async ({ branchName, force }) => { description: "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); parameters: PushBranch,
if (force) { execute: contextualize(async ({ branchName, force }) => {
log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
$("git", ["push", "--force", "origin", branch]); log.info(`Pushing branch ${branch} to ${remote}`);
} else { if (force) {
log.info(`Pushing branch ${branch} to remote`); log.warning(`Force pushing - this will overwrite remote history`);
$("git", ["push", "origin", branch]); $("git", ["push", "--force", "-u", remote, branch]);
} } else {
return { $("git", ["push", "-u", remote, branch]);
success: true, }
branch, return {
force, success: true,
message: `Successfully pushed branch ${branch} to remote` branch,
}; remote,
}) force,
}); 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
+74 -61
View File
@@ -2,54 +2,62 @@ 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) {
branchName: type.string.describe( const defaultBranch = ctx.repo.default_branch || "main";
"The name of the branch to create (e.g., 'pullfrog/123-fix-bug')" const CreateBranch = type({
), branchName: type.string.describe(
baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main"), "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')"
}); ),
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 }) => {
// validate branch name for secrets // baseBranch should always be defined due to default, but TypeScript needs help
if (containsSecrets(branchName)) { const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main";
throw new Error(
"Branch creation blocked: secrets detected in branch name. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
);
}
log.info(`Creating branch ${branchName} from ${baseBranch}`); // validate branch name for secrets
if (containsSecrets(branchName)) {
throw new Error(
"Branch creation blocked: secrets detected in branch name. " +
"Please remove any sensitive information (API keys, tokens, passwords) before creating a branch."
);
}
// fetch base branch to ensure we're up to date log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`);
$("git", ["fetch", "origin", baseBranch, "--depth=1"]);
// checkout base branch, ensuring it matches the remote version // fetch base branch to ensure we're up to date
// -B creates or resets the branch to match origin/baseBranch $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
$("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]);
// create and checkout new branch // checkout base branch, ensuring it matches the remote version
$("git", ["checkout", "-b", branchName]); // -B creates or resets the branch to match origin/baseBranch
$("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]);
// push branch to remote (set upstream) // create and checkout new branch
$("git", ["push", "-u", "origin", branchName]); $("git", ["checkout", "-b", branchName]);
log.info(`Successfully created and pushed branch ${branchName}`); // push branch to remote (set upstream)
$("git", ["push", "-u", "origin", branchName]);
return { log.info(`Successfully created and pushed branch ${branchName}`);
success: true,
branchName, return {
baseBranch, success: true,
message: `Branch ${branchName} created from ${baseBranch} and pushed to remote`, branchName,
}; baseBranch: resolvedBaseBranch,
}), message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote`,
}); };
}),
});
}
export const CommitFiles = type({ export const CommitFiles = type({
message: type.string.describe("The commit message"), message: type.string.describe("The commit message"),
@@ -129,27 +137,32 @@ export const PushBranch = type({
force: type.boolean.describe("Force push (use with caution)").default(false), force: type.boolean.describe("Force push (use with caution)").default(false),
}); });
export const PushBranchTool = tool({ export function PushBranchTool(ctx: McpContext) {
name: "push_branch", const remote = ctx.pushRemote;
description:
"Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
parameters: PushBranch,
execute: contextualize(async ({ branchName, force }) => {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
if (force) { return tool({
log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); name: "push_branch",
$("git", ["push", "--force", "origin", branch]); description:
} else { "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.",
log.info(`Pushing branch ${branch} to remote`); parameters: PushBranch,
$("git", ["push", "origin", branch]); execute: contextualize(async ({ branchName, force }) => {
} const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
return { log.info(`Pushing branch ${branch} to ${remote}`);
success: true, if (force) {
branch, log.warning(`Force pushing - this will overwrite remote history`);
force, $("git", ["push", "--force", "-u", remote, branch]);
message: `Successfully pushed branch ${branch} to remote`, } else {
}; $("git", ["push", "-u", remote, branch]);
}), }
});
return {
success: true,
branch,
remote,
force,
message: `Successfully pushed branch ${branch} to ${remote}`,
};
}),
});
}
+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 {
// return cached context if available
if (cachedMcpContext) {
return cachedMcpContext;
}
const repoContext = parseRepoContext();
const octokit = new Octokit({
auth: getGitHubInstallationToken(),
});
cachedMcpContext = {
...mcpInitContext, ...mcpInitContext,
...parseRepoContext(), ...repoContext,
octokit: new Octokit({ octokit,
auth: getGitHubInstallationToken(), 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";
}
} }