overhaul git setup

This commit is contained in:
Colin McDonnell
2025-12-16 18:00:22 -08:00
parent f6ac916e22
commit 1bff21f7fb
19 changed files with 514 additions and 195 deletions
+105
View File
@@ -0,0 +1,105 @@
import { type } from "arktype";
import type { Context } from "../main.ts";
import { log } from "../utils/cli.ts";
import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts";
export const CheckoutPr = type({
pull_number: type.number.describe("the pull request number to checkout"),
});
export type CheckoutPrResult = {
success: true;
number: number;
title: string;
base: string;
head: string;
isFork: boolean;
maintainerCanModify: boolean;
url: string;
headRepo: string;
};
export function CheckoutPrTool(ctx: Context) {
return tool({
name: "checkout_pr",
description:
"Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Use this when you need to work on an existing PR.",
parameters: CheckoutPr,
execute: execute(ctx, async ({ pull_number }) => {
log.info(`🔀 checking out PR #${pull_number}...`);
// fetch PR metadata
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner,
repo: ctx.name,
pull_number,
});
const headRepo = pr.data.head.repo;
if (!headRepo) {
throw new Error(`PR #${pull_number} source repository was deleted`);
}
const isFork = headRepo.full_name !== pr.data.base.repo.full_name;
const baseBranch = pr.data.base.ref;
const headBranch = pr.data.head.ref;
// fetch base branch so origin/<base> exists for diff operations
log.info(`📥 fetching base branch (${baseBranch})...`);
$("git", ["fetch", "--no-tags", "origin", baseBranch]);
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
log.info(`🌿 fetching PR #${pull_number} (${headBranch})...`);
$("git", ["fetch", "--no-tags", "origin", `pull/${pull_number}/head:${headBranch}`]);
// checkout the branch
$("git", ["checkout", headBranch]);
log.info(`✓ checked out PR #${pull_number}`);
// configure push remote for this branch
if (isFork) {
const remoteName = `pr-${pull_number}`;
const forkUrl = `https://x-access-token:${ctx.githubInstallationToken}@github.com/${headRepo.full_name}.git`;
// add fork as a named remote (ignore error if already exists)
try {
$("git", ["remote", "add", remoteName, forkUrl]);
log.info(`📌 added remote '${remoteName}' for fork ${headRepo.full_name}`);
} catch {
// remote already exists, update its URL
$("git", ["remote", "set-url", remoteName, forkUrl]);
log.info(`📌 updated remote '${remoteName}' for fork ${headRepo.full_name}`);
}
// set branch push config so `git push` knows where to push
$("git", ["config", `branch.${headBranch}.pushRemote`, remoteName]);
log.info(`📌 configured branch '${headBranch}' to push to '${remoteName}'`);
// warn if maintainer can't modify (push will likely fail)
if (!pr.data.maintainer_can_modify) {
log.warning(
`⚠️ fork PR has maintainer_can_modify=false - push operations will fail. ` +
`ask the PR author to enable "Allow edits from maintainers" or the fork may be owned by an organization.`
);
}
} else {
// for same-repo PRs, push to origin
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
}
return {
success: true,
number: pr.data.number,
title: pr.data.title,
base: baseBranch,
head: headBranch,
isFork,
maintainerCanModify: pr.data.maintainer_can_modify,
url: pr.data.html_url,
headRepo: headRepo.full_name,
} satisfies CheckoutPrResult;
}),
});
}
+18 -14
View File
@@ -141,35 +141,39 @@ export const PushBranch = type({
force: type.boolean.describe("Force push (use with caution)").default(false),
});
export function PushBranchTool(ctx: Context) {
const remote = ctx.pushRemote;
export function PushBranchTool(_ctx: Context) {
return tool({
name: "push_branch",
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. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested.",
parameters: PushBranch,
execute: execute(ctx, async ({ branchName, force }) => {
execute: execute(_ctx, async ({ branchName, force }) => {
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
// skip -u flag when pushing to URL (can't set upstream without a remote name)
const isUrl = remote.startsWith("https://");
const args = force
? ["push", "--force", ...(isUrl ? [] : ["-u"]), remote, branch]
: ["push", ...(isUrl ? [] : ["-u"]), remote, branch];
// check if branch has a configured pushRemote
let remote = "origin";
try {
remote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
} catch {
// no configured pushRemote, default to origin
}
log.info(`Pushing branch ${branch} to ${isUrl ? "(fork URL)" : remote}`);
const args = force
? ["push", "--force", "-u", remote, branch]
: ["push", "-u", remote, branch];
log.info(`pushing branch ${branch} to ${remote}`);
if (force) {
log.warning(`Force pushing - this will overwrite remote history`);
log.warning(`force pushing - this will overwrite remote history`);
}
$("git", args);
return {
success: true,
branch,
remote: isUrl ? "(fork URL)" : remote,
remote,
force,
message: `Successfully pushed branch ${branch}`,
message: `successfully pushed branch ${branch}`,
};
}),
});
+26 -2
View File
@@ -1,6 +1,9 @@
import { type } from "arktype";
import { agentsManifest } from "../external.ts";
import type { Context } from "../main.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { parseRepoContext } from "../utils/github.ts";
import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts";
import { execute, tool } from "./shared.ts";
@@ -11,6 +14,23 @@ export const PullRequest = type({
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
});
function buildPrBodyWithFooter(ctx: Context, body: string): string {
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID;
const agentName = ctx.payload.agent;
const agentInfo = agentName ? agentsManifest[agentName] : null;
const footer = buildPullfrogFooter({
triggeredBy: true,
agent: agentInfo ? { displayName: agentInfo.displayName, url: agentInfo.url } : undefined,
workflowRun: runId ? { owner: repoContext.owner, repo: repoContext.name, runId } : undefined,
});
const bodyWithoutFooter = stripExistingFooter(body);
return `${bodyWithoutFooter}${footer}`;
}
export function PullRequestTool(ctx: Context) {
return tool({
name: "create_pull_request",
@@ -29,7 +49,9 @@ export function PullRequestTool(ctx: Context) {
}
// validate all changes that would be in the PR (from base to HEAD)
const diff = $("git", ["diff", `origin/${base}...HEAD`], { log: false });
// FORK PR NOTE: origin/<base> is fetched by setupGit, so this works for both fork and same-repo PRs
// use two-dot (..) not three-dot (...) for reliable diffs with shallow clones
const diff = $("git", ["diff", `origin/${base}..HEAD`], { log: false });
if (containsSecrets(diff)) {
throw new Error(
"PR creation blocked: secrets detected in changes. " +
@@ -37,11 +59,13 @@ export function PullRequestTool(ctx: Context) {
);
}
const bodyWithFooter = buildPrBodyWithFooter(ctx, body);
const result = await ctx.octokit.rest.pulls.create({
owner: ctx.owner,
repo: ctx.name,
title: title,
body: body,
body: bodyWithFooter,
head: currentBranch,
base: base,
});
+1 -1
View File
@@ -10,7 +10,7 @@ export function PullRequestInfoTool(ctx: Context) {
return tool({
name: "get_pull_request",
description:
"Retrieve PR information (metadata only). PR branch is already checked out during setup.",
"Retrieve PR metadata (number, title, state, base/head branches, fork status). To checkout a PR branch locally, use checkout_pr instead.",
parameters: PullRequestInfo,
execute: execute(ctx, async ({ pull_number }) => {
const pr = await ctx.octokit.rest.pulls.get({
+4 -1
View File
@@ -35,7 +35,9 @@ export const Review = type({
})
.array()
.describe(
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/<base>...origin/<head>' to find correct line numbers (RIGHT side for new code, LEFT for old)."
// FORK PR NOTE: use HEAD not origin/<head> - for fork PRs, origin/<head> doesn't exist
// because the head branch is in a different repo (the fork). HEAD is the locally checked out PR branch.
"PRIMARY location for ALL feedback. 95%+ of review content should be here. Use 'git diff origin/<base>..HEAD' to find correct line numbers (RIGHT side for new code, LEFT for old). Works for both fork and same-repo PRs."
)
.optional(),
});
@@ -93,6 +95,7 @@ export function ReviewTool(ctx: Context) {
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
+2
View File
@@ -5,6 +5,7 @@ import { FastMCP, type Tool } from "fastmcp";
import { ghPullfrogMcpName } from "../external.ts";
import type { Context } from "../main.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
import { CheckoutPrTool } from "./checkout.ts";
import {
CreateCommentTool,
EditCommentTool,
@@ -78,6 +79,7 @@ export async function startMcpHttpServer(
PullRequestTool(ctx),
ReviewTool(ctx),
PullRequestInfoTool(ctx),
CheckoutPrTool(ctx),
GetReviewCommentsTool(ctx),
ListPullRequestReviewsTool(ctx),
GetCheckSuiteLogsTool(ctx),