overhaul git setup
This commit is contained in:
+105
@@ -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
@@ -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}`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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})`],
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user