opencode working

This commit is contained in:
Shawn Morreau
2025-12-10 01:02:39 -05:00
parent bbda005ee9
commit ccb28d8cf5
13 changed files with 567 additions and 89 deletions
+154
View File
@@ -0,0 +1,154 @@
import { type } from "arktype";
import { log } from "../utils/cli.ts";
import { containsSecrets } from "../utils/secrets.ts";
import { $ } from "../utils/shell.ts";
import { contextualize, tool } from "./shared.ts";
export 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 (e.g., 'main')").default("main"),
});
export const 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 }) => {
// 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 ${baseBranch}`);
// fetch base branch to ensure we're up to date
$("git", ["fetch", "origin", baseBranch, "--depth=1"]);
// checkout base branch
$("git", ["checkout", baseBranch]);
// 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,
message: `Branch ${branchName} created from ${baseBranch} 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 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 });
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`,
};
}),
});
+2 -2
View File
@@ -7,7 +7,8 @@ export const GetIssueComments = type({
export const GetIssueCommentsTool = tool({
name: "get_issue_comments",
description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
description:
"Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
parameters: GetIssueComments,
execute: contextualize(async ({ issue_number }, ctx) => {
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
@@ -32,4 +33,3 @@ export const GetIssueCommentsTool = tool({
};
}),
});
+4
View File
@@ -11,6 +11,7 @@ import {
ReportProgressTool,
} from "./comment.ts";
import { DebugShellCommandTool } from "./debug.ts";
import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts";
import { IssueTool } from "./issue.ts";
import { GetIssueCommentsTool } from "./issueComments.ts";
import { GetIssueEventsTool } from "./issueEvents.ts";
@@ -87,6 +88,9 @@ export async function startMcpHttpServer(
GetCheckSuiteLogsTool,
DebugShellCommandTool,
AddLabelsTool,
CreateBranchTool,
CommitFilesTool,
PushBranchTool,
];
// only include ReportProgressTool if progress comment is not disabled
+48 -3
View File
@@ -42,10 +42,11 @@ export function isProgressCommentDisabled(): boolean {
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
/**
* Sanitize JSON schema to remove problematic fields that Gemini CLI can't handle
* Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle
* - Removes $schema field (causes "no schema with key or ref" errors)
* - Converts $defs to definitions (draft-07 compatibility)
* - Removes any draft-2020-12 specific features
* - Converts any_of with enum values to direct STRING enum (Google API requirement)
*/
function sanitizeSchema(schema: any): any {
if (!schema || typeof schema !== "object") {
@@ -56,6 +57,43 @@ function sanitizeSchema(schema: any): any {
return schema.map(sanitizeSchema);
}
// handle any_of with enum values - convert to direct STRING enum for Google API
// Google API requires: {type: "string", enum: [...]} not {anyOf: [{enum: [...]}, {enum: [...]}]}
if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
const enumValues: string[] = [];
let allAreEnumObjects = true;
for (const item of schema.anyOf) {
if (item && typeof item === "object" && Array.isArray(item.enum)) {
// collect enum values (only strings)
const stringEnums = item.enum.filter((v: any) => typeof v === "string");
if (stringEnums.length > 0) {
enumValues.push(...stringEnums);
} else {
allAreEnumObjects = false;
break;
}
} else {
allAreEnumObjects = false;
break;
}
}
// if all any_of items are enum objects with string values, convert to direct STRING enum
if (allAreEnumObjects && enumValues.length > 0) {
const uniqueEnums = [...new Set(enumValues)];
// preserve other properties from the original schema (like description)
const result: any = {
type: "string",
enum: uniqueEnums,
};
if (schema.description) {
result.description = schema.description;
}
return result;
}
}
const sanitized: any = {};
for (const [key, value] of Object.entries(schema)) {
@@ -64,6 +102,11 @@ function sanitizeSchema(schema: any): any {
continue;
}
// skip any_of if we already converted it above
if (key === "anyOf" && schema.anyOf) {
continue;
}
// convert $defs to definitions for draft-07 compatibility
if (key === "$defs") {
sanitized.definitions = sanitizeSchema(value);
@@ -120,8 +163,10 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
}
export const addTools = (server: FastMCP, tools: Tool<any, any>[]) => {
// only sanitize schemas for gemini agent (it has issues with draft-2020-12 schemas)
const shouldSanitize = mcpInitContext?.agentName === "gemini";
// sanitize schemas for gemini agent and opencode (when using Google API)
// both have issues with draft-2020-12 schemas and any_of enum constructs
const shouldSanitize =
mcpInitContext?.agentName === "gemini" || mcpInitContext?.agentName === "opencode";
for (const tool of tools) {
const processedTool = shouldSanitize ? sanitizeTool(tool) : tool;