From 0fced1dfa6eb4a654c8cba59e53fc41fe148b78c Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Mon, 15 Dec 2025 22:21:47 -0800 Subject: [PATCH] Clean up init --- entry | 213 ++++++++++++++++++++++++++++++------------------- main.ts | 49 +++++++++--- mcp/comment.ts | 6 +- mcp/git.ts | 135 +++++++++++++++++-------------- mcp/server.ts | 8 +- mcp/shared.ts | 32 ++++++-- utils/setup.ts | 76 ++++++++++-------- 7 files changed, 320 insertions(+), 199 deletions(-) diff --git a/entry b/entry index c75ff6e..ab5e861 100755 --- a/entry +++ b/entry @@ -39287,18 +39287,26 @@ var init_buildPullfrogFooter = __esm({ // mcp/shared.ts function initMcpContext(state) { mcpInitContext = state; + cachedMcpContext = void 0; } -function getMcpContext() { +async function getMcpContext() { if (!mcpInitContext) { 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, - ...parseRepoContext(), - octokit: new Octokit2({ - auth: getGitHubInstallationToken() - }) + ...repoContext, + octokit, + repo: mcpInitContext.repo }; + return cachedMcpContext; } function isProgressCommentDisabled() { return mcpInitContext?.payload.disableProgressComment === true; @@ -39382,7 +39390,7 @@ function sanitizeTool(tool2) { parameters: wrappedSchema }; } -var mcpInitContext, tool, addTools, contextualize, handleToolSuccess, handleToolError; +var mcpInitContext, cachedMcpContext, tool, addTools, contextualize, handleToolSuccess, handleToolError; var init_shared3 = __esm({ "mcp/shared.ts"() { "use strict"; @@ -39400,7 +39408,7 @@ var init_shared3 = __esm({ contextualize = (executor) => { return async (params) => { try { - const ctx = getMcpContext(); + const ctx = await getMcpContext(); const result = await executor(params, ctx); return handleToolSuccess(result); } catch (error41) { @@ -39491,7 +39499,7 @@ function setProgressCommentId(id) { progressCommentIdInitialized = true; } async function reportProgress({ body }) { - const ctx = getMcpContext(); + const ctx = await getMcpContext(); const bodyWithFooter = addFooter(body, ctx.payload); const existingCommentId = getProgressCommentId(); if (existingCommentId) { @@ -39536,7 +39544,7 @@ async function deleteProgressComment() { if (!existingCommentId) { return false; } - const ctx = getMcpContext(); + const ctx = await getMcpContext(); await ctx.octokit.rest.issues.deleteComment({ owner: ctx.owner, repo: ctx.name, @@ -39588,7 +39596,7 @@ async function ensureProgressCommentUpdated(payload) { } let resolvedPayload; try { - const ctx = getMcpContext(); + const ctx = await getMcpContext(); resolvedPayload = ctx.payload; } catch { resolvedPayload = payload; @@ -99850,6 +99858,7 @@ var agents = { import { mkdtemp as mkdtemp2 } from "node:fs/promises"; import { tmpdir as tmpdir2 } from "node:os"; import { join as join8 } from "node:path"; +init_dist_src5(); init_out4(); init_external(); init_comment(); @@ -124356,36 +124365,40 @@ function containsSecrets(content, secrets) { // mcp/git.ts init_shared3(); -var CreateBranch = type({ - branchName: type.string.describe( - "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") -}); -var CreateBranchTool = tool({ - name: "create_branch", - description: "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", - parameters: CreateBranch, - execute: contextualize(async ({ branchName, baseBranch }) => { - if (containsSecrets(branchName)) { - throw new Error( - "Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch." - ); - } - log.info(`Creating branch ${branchName} from ${baseBranch}`); - $("git", ["fetch", "origin", baseBranch, "--depth=1"]); - $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); - $("git", ["checkout", "-b", branchName]); - $("git", ["push", "-u", "origin", branchName]); - log.info(`Successfully created and pushed branch ${branchName}`); - return { - success: true, - branchName, - baseBranch, - message: `Branch ${branchName} created from ${baseBranch} and pushed to remote` - }; - }) -}); +function CreateBranchTool(ctx) { + const defaultBranch = ctx.repo.default_branch || "main"; + const CreateBranch = type({ + branchName: type.string.describe( + "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) + }); + return tool({ + name: "create_branch", + description: "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", + parameters: CreateBranch, + execute: contextualize(async ({ branchName, baseBranch }) => { + const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main"; + if (containsSecrets(branchName)) { + throw new Error( + "Branch creation blocked: secrets detected in branch name. Please remove any sensitive information (API keys, tokens, passwords) before creating a branch." + ); + } + log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`); + $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]); + $("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]); + $("git", ["checkout", "-b", branchName]); + $("git", ["push", "-u", "origin", branchName]); + log.info(`Successfully created and pushed branch ${branchName}`); + return { + success: true, + branchName, + baseBranch: resolvedBaseBranch, + message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote` + }; + }) + }); +} var CommitFiles = type({ message: type.string.describe("The commit message"), 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(), force: type.boolean.describe("Force push (use with caution)").default(false) }); -var PushBranchTool = tool({ - name: "push_branch", - 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) { - log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); - $("git", ["push", "--force", "origin", branch]); - } else { - log.info(`Pushing branch ${branch} to remote`); - $("git", ["push", "origin", branch]); - } - return { - success: true, - branch, - force, - message: `Successfully pushed branch ${branch} to remote` - }; - }) -}); +function PushBranchTool(ctx) { + const remote = ctx.pushRemote; + return tool({ + name: "push_branch", + 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 }); + log.info(`Pushing branch ${branch} to ${remote}`); + if (force) { + log.warning(`Force pushing - this will overwrite remote history`); + $("git", ["push", "--force", "-u", remote, branch]); + } else { + $("git", ["push", "-u", remote, branch]); + } + return { + success: true, + branch, + remote, + force, + message: `Successfully pushed branch ${branch} to ${remote}` + }; + }) + }); +} // mcp/issue.ts init_out4(); @@ -125043,6 +125060,7 @@ async function startMcpHttpServer(state) { name: ghPullfrogMcpName, version: "0.0.1" }); + const ctx = await getMcpContext(); const tools = [ SelectModeTool, CreateCommentTool, @@ -125060,9 +125078,9 @@ async function startMcpHttpServer(state) { GetCheckSuiteLogsTool, DebugShellCommandTool, AddLabelsTool, - CreateBranchTool, + CreateBranchTool(ctx), CommitFilesTool, - PushBranchTool + PushBranchTool(ctx) ]; if (!isProgressCommentDisabled()) { tools.push(ReportProgressTool); @@ -125156,7 +125174,6 @@ init_github(); // utils/setup.ts import { execSync } from "node:child_process"; init_cli(); -init_github(); function setupGitConfig() { const repoDir = process.cwd(); 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(); - log.info("\u{1F510} Setting up git authentication..."); + log.info("\u{1F527} Setting up git configuration..."); try { execSync("git config --local --unset-all http.https://github.com/.extraheader", { cwd: repoDir, @@ -125188,24 +125206,39 @@ function setupGitAuth(ctx) { } catch { 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", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir }); - log.info("\u2713 Updated remote URL with authentication token (scoped to repo)"); -} -function setupGitBranch(payload) { + $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); + $("git", ["config", "--local", "--add", "credential.helper", "!gh auth git-credential"], { + cwd: repoDir, + env: { GH_TOKEN: githubInstallationToken2 } + }); + log.info("\u2713 Configured gh as credential helper"); if (payload.event.is_pr !== true || !payload.event.issue_number) { log.debug("Not a PR event, staying on default branch"); - return; + return { pushRemote: "origin" }; } const prNumber = payload.event.issue_number; - const repoDir = process.cwd(); log.info(`\u{1F33F} Checking out PR #${prNumber}...`); - const token = getGitHubInstallationToken(); $("gh", ["pr", "checkout", prNumber.toString()], { cwd: repoDir, - env: { GH_TOKEN: token } + env: { GH_TOKEN: githubInstallationToken2 } }); 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 @@ -125245,13 +125278,11 @@ async function main(inputs) { const partialCtx = await initializeContext(inputs, payload); const ctx = partialCtx; timer.checkpoint("initializeContext"); - setupGitAuth({ - githubInstallationToken: ctx.githubInstallationToken, - repoContext: ctx.repoContext - }); + const { pushRemote } = setupGit(ctx); + ctx.pushRemote = pushRemote; + timer.checkpoint("setupGit"); await setupTempDirectory(ctx); timer.checkpoint("setupTempDirectory"); - setupGitBranch(ctx.payload); await startMcpServer(ctx); mcpServerClose = ctx.mcpServerClose; timer.checkpoint("startMcpServer"); @@ -125349,6 +125380,21 @@ async function initializeContext(inputs, payload) { setupGitConfig(); const githubInstallationToken2 = await setupGitHubInstallationToken(); 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( inputs, payload, @@ -125360,6 +125406,7 @@ async function initializeContext(inputs, payload) { inputs, githubInstallationToken: githubInstallationToken2, repoContext, + repo, agentName, agent: agent2, payload: resolvedPayload, @@ -125448,7 +125495,9 @@ async function startMcpServer(ctx) { const { url: url2, close } = await startMcpHttpServer({ payload: ctx.payload, modes: allModes, - agentName: ctx.agentName + agentName: ctx.agentName, + repo: ctx.repo, + pushRemote: ctx.pushRemote }); ctx.mcpServerUrl = url2; ctx.mcpServerClose = close; diff --git a/main.ts b/main.ts index 60e64e5..58974a5 100644 --- a/main.ts +++ b/main.ts @@ -2,6 +2,7 @@ import { mkdtemp } from "node:fs/promises"; 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"; @@ -22,7 +23,7 @@ import { revokeGitHubInstallationToken, setupGitHubInstallationToken, } 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"; // runtime validation using agents (needed for ArkType) @@ -64,16 +65,13 @@ export async function main(inputs: Inputs): Promise { const ctx = partialCtx as MainContext; timer.checkpoint("initializeContext"); - setupGitAuth({ - githubInstallationToken: ctx.githubInstallationToken, - repoContext: ctx.repoContext, - }); + const { pushRemote } = setupGit(ctx); + ctx.pushRemote = pushRemote; + timer.checkpoint("setupGit"); await setupTempDirectory(ctx); timer.checkpoint("setupTempDirectory"); - setupGitBranch(ctx.payload); - await startMcpServer(ctx); mcpServerClose = ctx.mcpServerClose; timer.checkpoint("startMcpServer"); @@ -205,14 +203,16 @@ To fix this, add the required secret to your GitHub repository: throw new Error(message); } -interface MainContext { +export interface MainContext { inputs: Inputs; githubInstallationToken: string; repoContext: RepoContext; + repo: Awaited>["data"]; agentName: AgentName; agent: (typeof agents)[AgentName]; sharedTempDir: string; payload: Payload; + pushRemote: string; mcpServerUrl: string; mcpServerClose: () => Promise; mcpServers: ReturnType; @@ -227,7 +227,13 @@ async function initializeContext( ): Promise< Omit< MainContext, - "mcpServerUrl" | "mcpServerClose" | "mcpServers" | "cliPath" | "apiKey" | "apiKeys" + | "mcpServerUrl" + | "mcpServerClose" + | "mcpServers" + | "cliPath" + | "apiKey" + | "apiKeys" + | "pushRemote" > > { log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); @@ -237,6 +243,24 @@ async function initializeContext( const githubInstallationToken = await setupGitHubInstallationToken(); const repoContext = parseRepoContext(); + // fetch repo data + const octokit = new Octokit({ + auth: githubInstallationToken, + }); + let repo: Awaited>["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>["data"]; + } + // resolve agent and update payload with resolved agent name const { agentName, agent } = await resolveAgent( inputs, @@ -250,6 +274,7 @@ async function initializeContext( inputs, githubInstallationToken, repoContext, + repo, agentName, agent, payload: resolvedPayload, @@ -324,9 +349,7 @@ async function resolveAgent( return { agentName, agent }; } -async function setupTempDirectory( - ctx: Omit -): Promise { +async function setupTempDirectory(ctx: MainContext): Promise { ctx.sharedTempDir = await mkdtemp(join(tmpdir(), "pullfrog-")); process.env.PULLFROG_TEMP_DIR = ctx.sharedTempDir; log.info(`📂 PULLFROG_TEMP_DIR has been created at ${ctx.sharedTempDir}`); @@ -372,6 +395,8 @@ async function startMcpServer(ctx: MainContext): Promise { payload: ctx.payload, modes: allModes, agentName: ctx.agentName, + repo: ctx.repo, + pushRemote: ctx.pushRemote, }); ctx.mcpServerUrl = url; ctx.mcpServerClose = close; diff --git a/mcp/comment.ts b/mcp/comment.ts index 611fb89..d5f5cb1 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -150,7 +150,7 @@ export async function reportProgress({ body }: { body: string }): Promise< } | undefined > { - const ctx = getMcpContext(); + const ctx = await getMcpContext(); const bodyWithFooter = addFooter(body, ctx.payload); const existingCommentId = getProgressCommentId(); @@ -242,7 +242,7 @@ export async function deleteProgressComment(): Promise { return false; } - const ctx = getMcpContext(); + const ctx = await getMcpContext(); await ctx.octokit.rest.issues.deleteComment({ owner: ctx.owner, @@ -324,7 +324,7 @@ export async function ensureProgressCommentUpdated(payload?: Payload): Promise { - // 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." - ); - } + return tool({ + name: "create_branch", + description: + "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", + parameters: CreateBranch, + execute: contextualize(async ({ branchName, baseBranch }) => { + // baseBranch should always be defined due to default, but TypeScript needs help + const resolvedBaseBranch = baseBranch || ctx.repo.default_branch || "main"; - 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 - $("git", ["fetch", "origin", baseBranch, "--depth=1"]); + log.info(`Creating branch ${branchName} from ${resolvedBaseBranch}`); - // checkout base branch, ensuring it matches the remote version - // -B creates or resets the branch to match origin/baseBranch - $("git", ["checkout", "-B", baseBranch, `origin/${baseBranch}`]); + // fetch base branch to ensure we're up to date + $("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]); - // create and checkout new branch - $("git", ["checkout", "-b", branchName]); + // checkout base branch, ensuring it matches the remote version + // -B creates or resets the branch to match origin/baseBranch + $("git", ["checkout", "-B", resolvedBaseBranch, `origin/${resolvedBaseBranch}`]); - // push branch to remote (set upstream) - $("git", ["push", "-u", "origin", branchName]); + // create and checkout new branch + $("git", ["checkout", "-b", branchName]); - log.info(`Successfully created and pushed branch ${branchName}`); + // push branch to remote (set upstream) + $("git", ["push", "-u", "origin", branchName]); - return { - success: true, - branchName, - baseBranch, - message: `Branch ${branchName} created from ${baseBranch} and pushed to remote`, - }; - }), -}); + log.info(`Successfully created and pushed branch ${branchName}`); + + return { + success: true, + branchName, + baseBranch: resolvedBaseBranch, + message: `Branch ${branchName} created from ${resolvedBaseBranch} and pushed to remote`, + }; + }), + }); +} export const CommitFiles = type({ 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), }); -export const PushBranchTool = tool({ - name: "push_branch", - 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 }); +export function PushBranchTool(ctx: McpContext) { + const remote = ctx.pushRemote; - if (force) { - log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); - $("git", ["push", "--force", "origin", branch]); - } else { - log.info(`Pushing branch ${branch} to remote`); - $("git", ["push", "origin", branch]); - } + return tool({ + name: "push_branch", + 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 }); - return { - success: true, - branch, - force, - message: `Successfully pushed branch ${branch} to remote`, - }; - }), -}); + log.info(`Pushing branch ${branch} to ${remote}`); + if (force) { + log.warning(`Force pushing - this will overwrite remote history`); + $("git", ["push", "--force", "-u", remote, branch]); + } else { + $("git", ["push", "-u", remote, branch]); + } + + return { + success: true, + branch, + remote, + force, + message: `Successfully pushed branch ${branch} to ${remote}`, + }; + }), + }); +} diff --git a/mcp/server.ts b/mcp/server.ts index 975e3b3..af2540d 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -24,6 +24,7 @@ import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComme import { SelectModeTool } from "./selectMode.ts"; import { addTools, + getMcpContext, initMcpContext, isProgressCommentDisabled, type McpInitContext, @@ -71,6 +72,9 @@ export async function startMcpHttpServer( version: "0.0.1", }); + // get context for dynamic tool creation + const ctx = await getMcpContext(); + const tools: Tool[] = [ SelectModeTool, CreateCommentTool, @@ -88,9 +92,9 @@ export async function startMcpHttpServer( GetCheckSuiteLogsTool, DebugShellCommandTool, AddLabelsTool, - CreateBranchTool, + CreateBranchTool(ctx), CommitFilesTool, - PushBranchTool, + PushBranchTool(ctx), ]; // only include ReportProgressTool if progress comment is not disabled diff --git a/mcp/shared.ts b/mcp/shared.ts index 5fd66e4..75659a5 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -9,30 +9,48 @@ export interface McpInitContext { payload: Payload; modes: Mode[]; agentName?: string; + repo: Awaited>["data"]; + pushRemote: string; } let mcpInitContext: McpInitContext | undefined; +let cachedMcpContext: McpContext | undefined; // this must be called on mcp server initialization export function initMcpContext(state: McpInitContext): void { mcpInitContext = state; + // clear cached context when reinitializing + cachedMcpContext = undefined; } export interface McpContext extends McpInitContext, RepoContext { octokit: Octokit; + repo: Awaited>["data"]; } -export function getMcpContext(): McpContext { +export async function getMcpContext(): Promise { if (!mcpInitContext) { 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, - ...parseRepoContext(), - octokit: new Octokit({ - auth: getGitHubInstallationToken(), - }), + ...repoContext, + octokit, + repo: mcpInitContext.repo, }; + + return cachedMcpContext; } export function isProgressCommentDisabled(): boolean { @@ -180,7 +198,7 @@ export const contextualize = ( ) => { return async (params: T): Promise => { try { - const ctx = getMcpContext(); + const ctx = await getMcpContext(); const result = await executor(params, ctx); return handleToolSuccess(result); } catch (error) { diff --git a/utils/setup.ts b/utils/setup.ts index c0b39d6..b52e3e0 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -1,8 +1,7 @@ import { execSync } from "node:child_process"; import { existsSync, rmSync } from "node:fs"; -import type { Payload } from "../external.ts"; +import type { MainContext } from "../main.ts"; import { log } from "./cli.ts"; -import { getGitHubInstallationToken, type RepoContext } from "./github.ts"; import { $ } from "./shell.ts"; export interface SetupOptions { @@ -63,20 +62,23 @@ export function setupGitConfig(): void { } } +export type SetupGitResult = { + pushRemote: string; +}; + /** - * Setup git authentication using GitHub installation token - * Always uses the installation token, scoped to the current repo only + * Unified git setup: configures authentication and checks out PR branch if applicable. + * 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: { - githubInstallationToken: string; - repoContext: RepoContext; -}): void { +export function setupGit(ctx: MainContext): SetupGitResult { + const { githubInstallationToken, payload } = ctx; 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 - // Use --local to scope to this repo only + // remove existing git auth headers that actions/checkout might have set try { execSync("git config --local --unset-all http.https://github.com/.extraheader", { cwd: repoDir, @@ -87,36 +89,46 @@ export function setupGitAuth(ctx: { log.debug("No existing authentication headers to remove"); } - // Update remote URL to embed the token - // This is scoped to the repo's .git/config, not the user's global config - const remoteUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${ctx.repoContext.owner}/${ctx.repoContext.name}.git`; - $("git", ["remote", "set-url", "origin", remoteUrl], { cwd: repoDir }); - log.info("✓ Updated remote URL with authentication token (scoped to repo)"); -} + // set up gh as credential helper - this makes git use GH_TOKEN for any remote + $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); + $("git", ["config", "--local", "--add", "credential.helper", "!gh auth git-credential"], { + cwd: repoDir, + env: { GH_TOKEN: githubInstallationToken }, + }); + log.info("✓ Configured gh as credential helper"); -/** - * 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) + // non-PR events: stay on default branch, push to origin if (payload.event.is_pr !== true || !payload.event.issue_number) { 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 repoDir = process.cwd(); - 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()], { cwd: repoDir, - env: { GH_TOKEN: token }, + env: { GH_TOKEN: githubInstallationToken }, }); - 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"; + } }