194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
import { type } from "arktype";
|
|
import type { ToolContext } from "../main.ts";
|
|
import { log } from "../utils/cli.ts";
|
|
import { containsSecrets } from "../utils/secrets.ts";
|
|
import { $ } from "../utils/shell.ts";
|
|
import { execute, tool } from "./shared.ts";
|
|
|
|
export function CreateBranchTool(ctx: ToolContext) {
|
|
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: execute(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.debug(`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.debug(`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 function CommitFilesTool(_ctx: ToolContext) {
|
|
return 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: execute(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.debug(`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.debug(`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: ToolContext) {
|
|
return tool({
|
|
name: "push_branch",
|
|
description:
|
|
"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(async ({ branchName, force }) => {
|
|
const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
|
|
|
// 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
|
|
}
|
|
|
|
// check if branch has a configured merge ref (remote branch name may differ from local)
|
|
let remoteBranch = branch;
|
|
try {
|
|
const mergeRef = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
|
// merge ref is like "refs/heads/main", extract the branch name
|
|
remoteBranch = mergeRef.replace("refs/heads/", "");
|
|
} catch {
|
|
// no configured merge ref, use local branch name
|
|
}
|
|
|
|
// use refspec when local and remote branch names differ
|
|
const refspec = branch === remoteBranch ? branch : `${branch}:${remoteBranch}`;
|
|
const args = force
|
|
? ["push", "--force", "-u", remote, refspec]
|
|
: ["push", "-u", remote, refspec];
|
|
|
|
log.debug(`pushing ${branch} to ${remote}/${remoteBranch}`);
|
|
if (force) {
|
|
log.warning(`force pushing - this will overwrite remote history`);
|
|
}
|
|
$("git", args);
|
|
|
|
return {
|
|
success: true,
|
|
branch,
|
|
remoteBranch,
|
|
remote,
|
|
force,
|
|
message: `successfully pushed ${branch} to ${remote}/${remoteBranch}`,
|
|
};
|
|
}),
|
|
});
|
|
}
|