169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
import { type } from "arktype";
|
|
import { log } from "../utils/cli.ts";
|
|
import { containsSecrets } from "../utils/secrets.ts";
|
|
import { $ } from "../utils/shell.ts";
|
|
import { contextualize, type McpContext, tool } from "./shared.ts";
|
|
|
|
export function CreateBranchTool(ctx: McpContext) {
|
|
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 }) => {
|
|
// 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
|
|
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}`);
|
|
|
|
// fetch base branch to ensure we're up to date
|
|
$("git", ["fetch", "origin", resolvedBaseBranch, "--depth=1"]);
|
|
|
|
// 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}`]);
|
|
|
|
// create and checkout new branch
|
|
$("git", ["checkout", "-b", branchName]);
|
|
|
|
// push branch to remote (set upstream)
|
|
$("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`,
|
|
};
|
|
}),
|
|
});
|
|
}
|
|
|
|
export const CommitFiles = type({
|
|
message: type.string.describe("The commit message"),
|
|
files: type.string
|
|
.array()
|
|
.describe(
|
|
"Array of file paths to commit (relative to repo root). If empty, commits all staged changes."
|
|
),
|
|
});
|
|
|
|
export const CommitFilesTool = tool({
|
|
name: "commit_files",
|
|
description:
|
|
"Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.",
|
|
parameters: CommitFiles,
|
|
execute: contextualize(async ({ message, files }) => {
|
|
// validate commit message for secrets
|
|
if (containsSecrets(message)) {
|
|
throw new Error(
|
|
"Commit blocked: secrets detected in commit message. " +
|
|
"Please remove any sensitive information (API keys, tokens, passwords) before committing."
|
|
);
|
|
}
|
|
|
|
// validate files for secrets if provided
|
|
if (files.length > 0) {
|
|
for (const file of files) {
|
|
try {
|
|
// try to read file content - if it exists, check for secrets
|
|
const content = $("cat", [file], { log: false });
|
|
if (containsSecrets(content)) {
|
|
throw new Error(
|
|
`Commit blocked: secrets detected in file ${file}. ` +
|
|
"Please remove any sensitive information (API keys, tokens, passwords) before committing."
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// if error is about secrets, re-throw it
|
|
if (error instanceof Error && error.message.includes("Commit blocked")) {
|
|
throw error;
|
|
}
|
|
// if file doesn't exist (cat fails), that's ok - it will be created by git add
|
|
// other errors are also ok - git add will handle them
|
|
}
|
|
}
|
|
}
|
|
|
|
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
|
log.info(`Committing files on branch ${currentBranch}`);
|
|
|
|
// stage files if provided, otherwise stage all changes
|
|
if (files.length > 0) {
|
|
$("git", ["add", ...files]);
|
|
} else {
|
|
$("git", ["add", "."]);
|
|
}
|
|
|
|
// commit with message
|
|
$("git", ["commit", "-m", message]);
|
|
|
|
const commitSha = $("git", ["rev-parse", "HEAD"], { log: false });
|
|
log.info(`Successfully committed: ${commitSha.substring(0, 7)}`);
|
|
|
|
return {
|
|
success: true,
|
|
commitSha,
|
|
branch: currentBranch,
|
|
message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`,
|
|
};
|
|
}),
|
|
});
|
|
|
|
export const 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),
|
|
});
|
|
|
|
export function PushBranchTool(ctx: McpContext) {
|
|
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}`,
|
|
};
|
|
}),
|
|
});
|
|
}
|