CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
mcp-tools-expanded.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 0feb720 | 1 | /** |
| 2 | * Expanded MCP tool surface — wraps every meaningful Gluecron action so | |
| 3 | * any AI tool-use loop can drive the platform end-to-end. | |
| 4 | * | |
| 5 | * Each tool here is a THIN wrapper around an existing lib helper or a | |
| 6 | * REST handler's underlying primitives — we never duplicate business | |
| 7 | * logic. Auth/gating reuses the shared helpers exported from | |
| 8 | * `mcp-tools.ts`. | |
| 9 | * | |
| 10 | * Categories (per the build spec): | |
| 11 | * | |
| 12 | * REPOS fork / delete / update / search / clone_url | |
| 13 | * ISSUES close / reopen / label / unlabel / assign / search | |
| 14 | * PRS close / request_changes / search / open_draft / | |
| 15 | * generate_description | |
| 16 | * FILES & GIT read_file / write_file / delete_file / list_tree / | |
| 17 | * get_commit / create_branch / atomic_multi_file_commit | |
| 18 | * AI WORKFLOWS ship_spec / voice_to_pr / refactor_across_repos / | |
| 19 | * explain_repo / chat_with_repo / chat_continue / | |
| 20 | * generate_tests / generate_commit_message / | |
| 21 | * generate_release_notes / propose_migration / | |
| 22 | * propose_doc_update | |
| 23 | * CI / DEPLOYS trigger_workflow / get_workflow_run / | |
| 24 | * get_workflow_logs / cancel_workflow_run / | |
| 25 | * get_preview_url / provision_pr_sandbox | |
| 26 | * AGENTS create_agent_session / acquire_lease / | |
| 27 | * release_lease / get_agent_budget | |
| 28 | * SEMANTIC semantic_search / find_symbol | |
| 29 | * INSIGHTS pr_status_summary / repo_health / ai_cost_summary | |
| 30 | * | |
| 31 | * Scope contract: | |
| 32 | * - Read-only tools accept any authenticated caller (anonymous works | |
| 33 | * on public repos via the existing `resolveAccessibleRepo` flow). | |
| 34 | * - Write tools require `repo` scope. | |
| 35 | * - Destructive tools (delete_repo) require `admin` scope. | |
| 36 | */ | |
| 37 | ||
| 38 | import { and, eq, like, or, desc, asc } from "drizzle-orm"; | |
| 39 | import { db } from "../db"; | |
| 40 | import { | |
| 41 | repositories, | |
| 42 | users, | |
| 43 | issues, | |
| 44 | issueComments, | |
| 45 | pullRequests, | |
| 46 | prComments, | |
| 47 | labels, | |
| 48 | issueLabels, | |
| 49 | workflows, | |
| 50 | workflowRuns, | |
| 51 | workflowJobs, | |
| 52 | } from "../db/schema"; | |
| 53 | import { | |
| 54 | repoExists, | |
| 55 | getRepoPath, | |
| 56 | getBlob, | |
| 57 | getTree, | |
| 58 | getTreeRecursive, | |
| 59 | getCommit, | |
| 60 | resolveRef, | |
| 61 | refExists, | |
| 62 | objectExists, | |
| 63 | updateRef, | |
| 64 | writeBlob, | |
| 65 | createOrUpdateFileOnBranch, | |
| 66 | getBlobShaAtPath, | |
| 67 | } from "../git/repository"; | |
| 68 | import { join } from "path"; | |
| 69 | import { mkdir, unlink, rm } from "fs/promises"; | |
| 70 | import { config } from "./config"; | |
| 71 | import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp"; | |
| 72 | import type { McpContext } from "./mcp"; | |
| 73 | import type { McpToolHandler } from "./mcp-tools"; | |
| 74 | import { | |
| 75 | mcpArgString, | |
| 76 | mcpArgNumber, | |
| 77 | mcpGateWriteAccess, | |
| 78 | mcpResolveAccessibleRepo, | |
| 79 | mcpRequireAuthedCtx, | |
| 80 | mcpLoadPrByNumber, | |
| 81 | mcpLoadIssueByNumber, | |
| 82 | mcpPrUrl, | |
| 83 | mcpIssueUrl, | |
| 84 | } from "./mcp-tools"; | |
| 85 | ||
| 86 | // --------------------------------------------------------------------------- | |
| 87 | // Local helpers | |
| 88 | // --------------------------------------------------------------------------- | |
| 89 | ||
| 90 | /** | |
| 91 | * Require a scope on the caller's token. Session-cookie callers receive | |
| 92 | * `["repo","user","admin"]` so they always pass. Anonymous and limited | |
| 93 | * PATs may fail here. When `ctx.scopes` is undefined we treat the context | |
| 94 | * as legacy-permissive (matches the pre-expansion behaviour of the | |
| 95 | * existing 15-tool surface). | |
| 96 | */ | |
| 97 | function requireScope(ctx: McpContext, scope: string, toolName: string): void { | |
| 98 | // Undefined scopes → legacy/permissive. Anonymous (no userId) is caught | |
| 99 | // separately by `requireAuthedCtx` further upstream. | |
| 100 | if (ctx.scopes === undefined) return; | |
| 101 | if (ctx.scopes.includes(scope) || ctx.scopes.includes("admin")) return; | |
| 102 | throw new McpError( | |
| 103 | ERR_INVALID_PARAMS, | |
| 104 | `tool ${toolName} requires '${scope}' scope (token carries: ${ctx.scopes.join(",") || "none"})` | |
| 105 | ); | |
| 106 | } | |
| 107 | ||
| 108 | function argBool(args: Record<string, unknown>, key: string, fallback = false): boolean { | |
| 109 | const v = args[key]; | |
| 110 | if (typeof v === "boolean") return v; | |
| 111 | if (typeof v === "string") return v === "true" || v === "1"; | |
| 112 | return fallback; | |
| 113 | } | |
| 114 | ||
| 115 | function argStringArray(args: Record<string, unknown>, key: string): string[] { | |
| 116 | const v = args[key]; | |
| 117 | if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string"); | |
| 118 | return []; | |
| 119 | } | |
| 120 | ||
| 121 | // --------------------------------------------------------------------------- | |
| 122 | // REPOS | |
| 123 | // --------------------------------------------------------------------------- | |
| 124 | ||
| 125 | const forkRepo: McpToolHandler = { | |
| 126 | tool: { | |
| 127 | name: "gluecron_fork_repo", | |
| 128 | description: | |
| 129 | "Fork a repository to the authenticated caller's namespace. Mirrors POST /:owner/:repo/fork. Returns {owner, repo, url}. Requires 'repo' scope.", | |
| 130 | inputSchema: { | |
| 131 | type: "object", | |
| 132 | properties: { | |
| 133 | owner: { type: "string", description: "Source repo owner" }, | |
| 134 | repo: { type: "string", description: "Source repo name" }, | |
| 135 | }, | |
| 136 | required: ["owner", "repo"], | |
| 137 | }, | |
| 138 | }, | |
| 139 | async run(args, ctx) { | |
| 140 | const owner = mcpArgString(args, "owner"); | |
| 141 | const repo = mcpArgString(args, "repo"); | |
| 142 | const userId = mcpRequireAuthedCtx(ctx, "gluecron_fork_repo"); | |
| 143 | requireScope(ctx, "repo", "gluecron_fork_repo"); | |
| 144 | ||
| 145 | const info = await mcpResolveAccessibleRepo(owner, repo, userId); | |
| 146 | ||
| 147 | const [me] = await db | |
| 148 | .select({ id: users.id, username: users.username }) | |
| 149 | .from(users) | |
| 150 | .where(eq(users.id, userId)) | |
| 151 | .limit(1); | |
| 152 | if (!me) throw new McpError(ERR_INVALID_PARAMS, "caller user not found"); | |
| 153 | ||
| 154 | if (me.username === owner) { | |
| 155 | throw new McpError(ERR_INVALID_PARAMS, "cannot fork your own repository"); | |
| 156 | } | |
| 157 | if (await repoExists(me.username, repo)) { | |
| 158 | throw new McpError( | |
| 159 | ERR_INVALID_PARAMS, | |
| 160 | `${me.username}/${repo} already exists` | |
| 161 | ); | |
| 162 | } | |
| 163 | ||
| 164 | const sourcePath = getRepoPath(owner, repo); | |
| 165 | const destPath = join(config.gitReposPath, me.username, `${repo}.git`); | |
| 166 | ||
| 167 | const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], { | |
| 168 | stdout: "pipe", | |
| 169 | stderr: "pipe", | |
| 170 | }); | |
| 171 | const code = await proc.exited; | |
| 172 | if (code !== 0) { | |
| 173 | throw new McpError(ERR_INVALID_PARAMS, "git clone --bare failed"); | |
| 174 | } | |
| 175 | ||
| 176 | const [sourceRepo] = await db | |
| 177 | .select() | |
| 178 | .from(repositories) | |
| 179 | .where(eq(repositories.id, info.repoId)) | |
| 180 | .limit(1); | |
| 181 | if (!sourceRepo) throw new McpError(ERR_METHOD_NOT_FOUND, "source repo row missing"); | |
| 182 | ||
| 183 | const [newRepo] = await db | |
| 184 | .insert(repositories) | |
| 185 | .values({ | |
| 186 | name: repo, | |
| 187 | ownerId: me.id, | |
| 188 | description: sourceRepo.description | |
| 189 | ? `Fork of ${owner}/${repo} — ${sourceRepo.description}` | |
| 190 | : `Fork of ${owner}/${repo}`, | |
| 191 | isPrivate: false, | |
| 192 | defaultBranch: sourceRepo.defaultBranch, | |
| 193 | diskPath: destPath, | |
| 194 | forkedFromId: sourceRepo.id, | |
| 195 | }) | |
| 196 | .returning(); | |
| 197 | ||
| 198 | if (newRepo) { | |
| 199 | try { | |
| 200 | const { bootstrapRepository } = await import("./repo-bootstrap"); | |
| 201 | await bootstrapRepository({ | |
| 202 | repositoryId: newRepo.id, | |
| 203 | ownerUserId: me.id, | |
| 204 | defaultBranch: sourceRepo.defaultBranch, | |
| 205 | skipWelcomeIssue: true, | |
| 206 | }); | |
| 207 | } catch { | |
| 208 | /* bootstrap is non-fatal */ | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | try { | |
| 213 | await db | |
| 214 | .update(repositories) | |
| 215 | .set({ forkCount: (sourceRepo.forkCount ?? 0) + 1 }) | |
| 216 | .where(eq(repositories.id, sourceRepo.id)); | |
| 217 | } catch { | |
| 218 | /* non-fatal */ | |
| 219 | } | |
| 220 | ||
| 221 | return { | |
| 222 | owner: me.username, | |
| 223 | repo, | |
| 224 | url: `/${me.username}/${repo}`, | |
| 225 | }; | |
| 226 | }, | |
| 227 | }; | |
| 228 | ||
| 229 | const deleteRepo: McpToolHandler = { | |
| 230 | tool: { | |
| 231 | name: "gluecron_delete_repo", | |
| 232 | description: | |
| 233 | "Permanently delete a repository row (git data on disk is left untouched). Owner-only. Requires 'admin' scope. Returns {deleted: true}.", | |
| 234 | inputSchema: { | |
| 235 | type: "object", | |
| 236 | properties: { | |
| 237 | owner: { type: "string", description: "Repo owner username" }, | |
| 238 | repo: { type: "string", description: "Repo name" }, | |
| 239 | }, | |
| 240 | required: ["owner", "repo"], | |
| 241 | }, | |
| 242 | }, | |
| 243 | async run(args, ctx) { | |
| 244 | const owner = mcpArgString(args, "owner"); | |
| 245 | const repo = mcpArgString(args, "repo"); | |
| 246 | requireScope(ctx, "admin", "gluecron_delete_repo"); | |
| 247 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_delete_repo"); | |
| 248 | ||
| 249 | if (gate.ownerId !== gate.userId) { | |
| 250 | throw new McpError( | |
| 251 | ERR_METHOD_NOT_FOUND, | |
| 252 | `no admin access to ${owner}/${repo}` | |
| 253 | ); | |
| 254 | } | |
| 255 | ||
| 256 | await db.delete(repositories).where(eq(repositories.id, gate.repoId)); | |
| 257 | return { deleted: true }; | |
| 258 | }, | |
| 259 | }; | |
| 260 | ||
| 261 | const updateRepo: McpToolHandler = { | |
| 262 | tool: { | |
| 263 | name: "gluecron_update_repo", | |
| 264 | description: | |
| 265 | "Update repository description / visibility / default_branch. Owner-only. Requires 'repo' scope. Returns {ok: true}.", | |
| 266 | inputSchema: { | |
| 267 | type: "object", | |
| 268 | properties: { | |
| 269 | owner: { type: "string", description: "Repo owner username" }, | |
| 270 | repo: { type: "string", description: "Repo name" }, | |
| 271 | description: { type: "string", description: "New description (optional)" }, | |
| 272 | is_private: { type: "boolean", description: "Set visibility (optional)" }, | |
| 273 | default_branch: { type: "string", description: "Set default branch (optional)" }, | |
| 274 | }, | |
| 275 | required: ["owner", "repo"], | |
| 276 | }, | |
| 277 | }, | |
| 278 | async run(args, ctx) { | |
| 279 | const owner = mcpArgString(args, "owner"); | |
| 280 | const repo = mcpArgString(args, "repo"); | |
| 281 | requireScope(ctx, "repo", "gluecron_update_repo"); | |
| 282 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_update_repo"); | |
| 283 | ||
| 284 | if (gate.ownerId !== gate.userId) { | |
| 285 | throw new McpError( | |
| 286 | ERR_METHOD_NOT_FOUND, | |
| 287 | `no admin access to ${owner}/${repo}` | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 291 | const updates: Record<string, unknown> = { updatedAt: new Date() }; | |
| 292 | if (typeof args.description === "string") updates.description = args.description; | |
| 293 | if (typeof args.is_private === "boolean") updates.isPrivate = args.is_private; | |
| 294 | if (typeof args.default_branch === "string") updates.defaultBranch = args.default_branch; | |
| 295 | if (Object.keys(updates).length === 1) { | |
| 296 | return { ok: true, noop: true }; | |
| 297 | } | |
| 298 | await db.update(repositories).set(updates).where(eq(repositories.id, gate.repoId)); | |
| 299 | return { ok: true }; | |
| 300 | }, | |
| 301 | }; | |
| 302 | ||
| 303 | const searchRepos: McpToolHandler = { | |
| 304 | tool: { | |
| 305 | name: "gluecron_search_repos", | |
| 306 | description: | |
| 307 | "Full-text search of public repositories by name/description. Mirrors GET /api/v2/search/repos. Returns ranked rows.", | |
| 308 | inputSchema: { | |
| 309 | type: "object", | |
| 310 | properties: { | |
| 311 | query: { type: "string", description: "Search keyword" }, | |
| 312 | sort: { type: "string", description: "stars | updated | name (default: stars)" }, | |
| 313 | limit: { type: "number", description: "Max results (1-100, default 30)" }, | |
| 314 | }, | |
| 315 | required: ["query"], | |
| 316 | }, | |
| 317 | }, | |
| 318 | async run(args) { | |
| 319 | const q = mcpArgString(args, "query"); | |
| 320 | const sort = mcpArgString(args, "sort", "stars"); | |
| 321 | const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 30))); | |
| 322 | const orderBy = | |
| 323 | sort === "updated" | |
| 324 | ? desc(repositories.updatedAt) | |
| 325 | : sort === "name" | |
| 326 | ? asc(repositories.name) | |
| 327 | : desc(repositories.starCount); | |
| 328 | const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`; | |
| 329 | const rows = await db | |
| 330 | .select({ | |
| 331 | id: repositories.id, | |
| 332 | name: repositories.name, | |
| 333 | description: repositories.description, | |
| 334 | stars: repositories.starCount, | |
| 335 | forks: repositories.forkCount, | |
| 336 | ownerName: users.username, | |
| 337 | }) | |
| 338 | .from(repositories) | |
| 339 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 340 | .where( | |
| 341 | and( | |
| 342 | eq(repositories.isPrivate, false), | |
| 343 | or( | |
| 344 | like(repositories.name, pattern), | |
| 345 | like(repositories.description, pattern) | |
| 346 | ) | |
| 347 | ) | |
| 348 | ) | |
| 349 | .orderBy(orderBy) | |
| 350 | .limit(limit); | |
| 351 | return { | |
| 352 | total: rows.length, | |
| 353 | repos: rows.map((r) => ({ | |
| 354 | fullName: `${r.ownerName}/${r.name}`, | |
| 355 | description: r.description || "", | |
| 356 | stars: r.stars, | |
| 357 | forks: r.forks, | |
| 358 | })), | |
| 359 | }; | |
| 360 | }, | |
| 361 | }; | |
| 362 | ||
| 363 | const cloneUrl: McpToolHandler = { | |
| 364 | tool: { | |
| 365 | name: "gluecron_clone_url", | |
| 366 | description: | |
| 367 | "Return the authenticated HTTPS clone URL for a repo + a credential-helper hint. Use this instead of embedding tokens in URLs. Returns {url, hint}.", | |
| 368 | inputSchema: { | |
| 369 | type: "object", | |
| 370 | properties: { | |
| 371 | owner: { type: "string", description: "Repo owner username" }, | |
| 372 | repo: { type: "string", description: "Repo name" }, | |
| 373 | }, | |
| 374 | required: ["owner", "repo"], | |
| 375 | }, | |
| 376 | }, | |
| 377 | async run(args, ctx) { | |
| 378 | const owner = mcpArgString(args, "owner"); | |
| 379 | const repo = mcpArgString(args, "repo"); | |
| 380 | // Verify access; rejects private-without-access via the privacy | |
| 381 | // contract. | |
| 382 | await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 383 | const base = config.appBaseUrl?.replace(/\/$/, "") || "https://gluecron.com"; | |
| 384 | return { | |
| 385 | url: `${base}/${owner}/${repo}.git`, | |
| 386 | hint: | |
| 387 | "Use a credential helper rather than embedding a PAT in the URL:\n" + | |
| 388 | ` git -c credential.helper='!f() { echo "username=token"; echo "password=$GLUECRON_PAT"; }; f' clone ${base}/${owner}/${repo}.git`, | |
| 389 | }; | |
| 390 | }, | |
| 391 | }; | |
| 392 | ||
| 393 | // --------------------------------------------------------------------------- | |
| 394 | // ISSUES — label/unlabel/assign/search (close/reopen already live in mcp-tools) | |
| 395 | // --------------------------------------------------------------------------- | |
| 396 | ||
| 397 | const labelIssue: McpToolHandler = { | |
| 398 | tool: { | |
| 399 | name: "gluecron_label_issue", | |
| 400 | description: | |
| 401 | "Attach one or more labels to an issue. Labels are created if they don't yet exist on the repo. Requires 'repo' scope. Returns {labels}.", | |
| 402 | inputSchema: { | |
| 403 | type: "object", | |
| 404 | properties: { | |
| 405 | owner: { type: "string" }, | |
| 406 | repo: { type: "string" }, | |
| 407 | number: { type: "number" }, | |
| 408 | labels: { type: "array", description: "Label names (strings)" }, | |
| 409 | }, | |
| 410 | required: ["owner", "repo", "number", "labels"], | |
| 411 | }, | |
| 412 | }, | |
| 413 | async run(args, ctx) { | |
| 414 | const owner = mcpArgString(args, "owner"); | |
| 415 | const repo = mcpArgString(args, "repo"); | |
| 416 | const number = mcpArgNumber(args, "number"); | |
| 417 | const labelNames = argStringArray(args, "labels"); | |
| 418 | if (labelNames.length === 0) { | |
| 419 | throw new McpError(ERR_INVALID_PARAMS, "labels must be a non-empty array of strings"); | |
| 420 | } | |
| 421 | requireScope(ctx, "repo", "gluecron_label_issue"); | |
| 422 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_label_issue"); | |
| 423 | const issue = await mcpLoadIssueByNumber(gate.repoId, number); | |
| 424 | if (!issue) { | |
| 425 | throw new McpError(ERR_METHOD_NOT_FOUND, `issue not found: ${owner}/${repo}#${number}`); | |
| 426 | } | |
| 427 | ||
| 428 | const applied: string[] = []; | |
| 429 | for (const name of labelNames) { | |
| 430 | const trimmed = name.trim(); | |
| 431 | if (!trimmed) continue; | |
| 432 | // Ensure label row exists. | |
| 433 | let [labelRow] = await db | |
| 434 | .select() | |
| 435 | .from(labels) | |
| 436 | .where(and(eq(labels.repositoryId, gate.repoId), eq(labels.name, trimmed))) | |
| 437 | .limit(1); | |
| 438 | if (!labelRow) { | |
| 439 | const inserted = await db | |
| 440 | .insert(labels) | |
| 441 | .values({ repositoryId: gate.repoId, name: trimmed }) | |
| 442 | .returning(); | |
| 443 | labelRow = inserted[0]; | |
| 444 | } | |
| 445 | if (!labelRow) continue; | |
| 446 | try { | |
| 447 | await db | |
| 448 | .insert(issueLabels) | |
| 449 | .values({ issueId: issue.id, labelId: labelRow.id }); | |
| 450 | } catch { | |
| 451 | /* unique violation — already attached */ | |
| 452 | } | |
| 453 | applied.push(trimmed); | |
| 454 | } | |
| 455 | return { labels: applied }; | |
| 456 | }, | |
| 457 | }; | |
| 458 | ||
| 459 | const unlabelIssue: McpToolHandler = { | |
| 460 | tool: { | |
| 461 | name: "gluecron_unlabel_issue", | |
| 462 | description: | |
| 463 | "Detach a label from an issue. Idempotent. Requires 'repo' scope. Returns {removed: boolean}.", | |
| 464 | inputSchema: { | |
| 465 | type: "object", | |
| 466 | properties: { | |
| 467 | owner: { type: "string" }, | |
| 468 | repo: { type: "string" }, | |
| 469 | number: { type: "number" }, | |
| 470 | label: { type: "string" }, | |
| 471 | }, | |
| 472 | required: ["owner", "repo", "number", "label"], | |
| 473 | }, | |
| 474 | }, | |
| 475 | async run(args, ctx) { | |
| 476 | const owner = mcpArgString(args, "owner"); | |
| 477 | const repo = mcpArgString(args, "repo"); | |
| 478 | const number = mcpArgNumber(args, "number"); | |
| 479 | const labelName = mcpArgString(args, "label"); | |
| 480 | requireScope(ctx, "repo", "gluecron_unlabel_issue"); | |
| 481 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_unlabel_issue"); | |
| 482 | const issue = await mcpLoadIssueByNumber(gate.repoId, number); | |
| 483 | if (!issue) { | |
| 484 | throw new McpError(ERR_METHOD_NOT_FOUND, `issue not found: ${owner}/${repo}#${number}`); | |
| 485 | } | |
| 486 | const [labelRow] = await db | |
| 487 | .select() | |
| 488 | .from(labels) | |
| 489 | .where(and(eq(labels.repositoryId, gate.repoId), eq(labels.name, labelName))) | |
| 490 | .limit(1); | |
| 491 | if (!labelRow) return { removed: false }; | |
| 492 | const result = await db | |
| 493 | .delete(issueLabels) | |
| 494 | .where(and(eq(issueLabels.issueId, issue.id), eq(issueLabels.labelId, labelRow.id))) | |
| 495 | .returning({ id: issueLabels.id }); | |
| 496 | return { removed: result.length > 0 }; | |
| 497 | }, | |
| 498 | }; | |
| 499 | ||
| 500 | const assignIssue: McpToolHandler = { | |
| 501 | tool: { | |
| 502 | name: "gluecron_assign_issue", | |
| 503 | description: | |
| 504 | "Assign an issue to a user. Gluecron does not yet have a dedicated assignee table; assignment is modelled as an `assignee:<username>` label so it integrates with existing label tooling. Requires 'repo' scope.", | |
| 505 | inputSchema: { | |
| 506 | type: "object", | |
| 507 | properties: { | |
| 508 | owner: { type: "string" }, | |
| 509 | repo: { type: "string" }, | |
| 510 | number: { type: "number" }, | |
| 511 | assignee: { type: "string", description: "Username to assign" }, | |
| 512 | }, | |
| 513 | required: ["owner", "repo", "number", "assignee"], | |
| 514 | }, | |
| 515 | }, | |
| 516 | async run(args, ctx) { | |
| 517 | const owner = mcpArgString(args, "owner"); | |
| 518 | const repo = mcpArgString(args, "repo"); | |
| 519 | const number = mcpArgNumber(args, "number"); | |
| 520 | const assignee = mcpArgString(args, "assignee"); | |
| 521 | return await labelIssue.run( | |
| 522 | { owner, repo, number, labels: [`assignee:${assignee}`] }, | |
| 523 | ctx | |
| 524 | ); | |
| 525 | }, | |
| 526 | }; | |
| 527 | ||
| 528 | const searchIssues: McpToolHandler = { | |
| 529 | tool: { | |
| 530 | name: "gluecron_search_issues", | |
| 531 | description: | |
| 532 | "Search issues by title/body keyword on a single repo, filtered by state. Returns ranked rows.", | |
| 533 | inputSchema: { | |
| 534 | type: "object", | |
| 535 | properties: { | |
| 536 | owner: { type: "string" }, | |
| 537 | repo: { type: "string" }, | |
| 538 | query: { type: "string", description: "Search keyword" }, | |
| 539 | state: { type: "string", description: "open | closed | all (default open)" }, | |
| 540 | limit: { type: "number" }, | |
| 541 | }, | |
| 542 | required: ["owner", "repo", "query"], | |
| 543 | }, | |
| 544 | }, | |
| 545 | async run(args, ctx) { | |
| 546 | const owner = mcpArgString(args, "owner"); | |
| 547 | const repo = mcpArgString(args, "repo"); | |
| 548 | const q = mcpArgString(args, "query"); | |
| 549 | const state = mcpArgString(args, "state", "open"); | |
| 550 | const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25))); | |
| 551 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 552 | const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`; | |
| 553 | const stateClause = | |
| 554 | state === "all" | |
| 555 | ? eq(issues.repositoryId, info.repoId) | |
| 556 | : and(eq(issues.repositoryId, info.repoId), eq(issues.state, state)); | |
| 557 | const rows = await db | |
| 558 | .select({ | |
| 559 | number: issues.number, | |
| 560 | title: issues.title, | |
| 561 | body: issues.body, | |
| 562 | state: issues.state, | |
| 563 | createdAt: issues.createdAt, | |
| 564 | }) | |
| 565 | .from(issues) | |
| 566 | .where( | |
| 567 | and( | |
| 568 | stateClause, | |
| 569 | or(like(issues.title, pattern), like(issues.body, pattern)) | |
| 570 | ) | |
| 571 | ) | |
| 572 | .orderBy(desc(issues.createdAt)) | |
| 573 | .limit(limit); | |
| 574 | return { | |
| 575 | total: rows.length, | |
| 576 | issues: rows.map((r) => ({ | |
| 577 | number: r.number, | |
| 578 | title: r.title, | |
| 579 | body: r.body || "", | |
| 580 | state: r.state, | |
| 581 | url: mcpIssueUrl(owner, repo, r.number), | |
| 582 | createdAt: r.createdAt, | |
| 583 | })), | |
| 584 | }; | |
| 585 | }, | |
| 586 | }; | |
| 587 | ||
| 588 | // --------------------------------------------------------------------------- | |
| 589 | // PRS — close (already), request_changes, search, draft, generate_description | |
| 590 | // --------------------------------------------------------------------------- | |
| 591 | ||
| 592 | const requestChanges: McpToolHandler = { | |
| 593 | tool: { | |
| 594 | name: "gluecron_request_changes", | |
| 595 | description: | |
| 596 | "Post a 'changes requested' AI-review comment on a PR. The comment is tagged with isAiReview=true so the gate-checker recognises it. Requires 'repo' scope.", | |
| 597 | inputSchema: { | |
| 598 | type: "object", | |
| 599 | properties: { | |
| 600 | owner: { type: "string" }, | |
| 601 | repo: { type: "string" }, | |
| 602 | number: { type: "number" }, | |
| 603 | body: { type: "string", description: "Review body (Markdown)" }, | |
| 604 | }, | |
| 605 | required: ["owner", "repo", "number", "body"], | |
| 606 | }, | |
| 607 | }, | |
| 608 | async run(args, ctx) { | |
| 609 | const owner = mcpArgString(args, "owner"); | |
| 610 | const repo = mcpArgString(args, "repo"); | |
| 611 | const number = mcpArgNumber(args, "number"); | |
| 612 | const body = mcpArgString(args, "body"); | |
| 613 | requireScope(ctx, "repo", "gluecron_request_changes"); | |
| 614 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_request_changes"); | |
| 615 | const pr = await mcpLoadPrByNumber(gate.repoId, number); | |
| 616 | if (!pr) { | |
| 617 | throw new McpError(ERR_METHOD_NOT_FOUND, `pr not found: ${owner}/${repo}#${number}`); | |
| 618 | } | |
| 619 | const formatted = `**Changes requested**\n\n${body}`; | |
| 620 | const [inserted] = await db | |
| 621 | .insert(prComments) | |
| 622 | .values({ | |
| 623 | pullRequestId: pr.id, | |
| 624 | authorId: gate.userId, | |
| 625 | body: formatted, | |
| 626 | isAiReview: true, | |
| 627 | }) | |
| 628 | .returning(); | |
| 629 | return { commentId: inserted.id }; | |
| 630 | }, | |
| 631 | }; | |
| 632 | ||
| 633 | const searchPrs: McpToolHandler = { | |
| 634 | tool: { | |
| 635 | name: "gluecron_search_prs", | |
| 636 | description: "Search pull requests by title/body keyword on a repo.", | |
| 637 | inputSchema: { | |
| 638 | type: "object", | |
| 639 | properties: { | |
| 640 | owner: { type: "string" }, | |
| 641 | repo: { type: "string" }, | |
| 642 | query: { type: "string" }, | |
| 643 | state: { type: "string", description: "open|closed|merged|all (default open)" }, | |
| 644 | limit: { type: "number" }, | |
| 645 | }, | |
| 646 | required: ["owner", "repo", "query"], | |
| 647 | }, | |
| 648 | }, | |
| 649 | async run(args, ctx) { | |
| 650 | const owner = mcpArgString(args, "owner"); | |
| 651 | const repo = mcpArgString(args, "repo"); | |
| 652 | const q = mcpArgString(args, "query"); | |
| 653 | const state = mcpArgString(args, "state", "open"); | |
| 654 | const limit = Math.max(1, Math.min(100, mcpArgNumber(args, "limit", 25))); | |
| 655 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 656 | const pattern = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`; | |
| 657 | const stateClause = | |
| 658 | state === "all" | |
| 659 | ? eq(pullRequests.repositoryId, info.repoId) | |
| 660 | : and(eq(pullRequests.repositoryId, info.repoId), eq(pullRequests.state, state)); | |
| 661 | const rows = await db | |
| 662 | .select({ | |
| 663 | number: pullRequests.number, | |
| 664 | title: pullRequests.title, | |
| 665 | body: pullRequests.body, | |
| 666 | state: pullRequests.state, | |
| 667 | baseBranch: pullRequests.baseBranch, | |
| 668 | headBranch: pullRequests.headBranch, | |
| 669 | isDraft: pullRequests.isDraft, | |
| 670 | createdAt: pullRequests.createdAt, | |
| 671 | }) | |
| 672 | .from(pullRequests) | |
| 673 | .where( | |
| 674 | and( | |
| 675 | stateClause, | |
| 676 | or(like(pullRequests.title, pattern), like(pullRequests.body, pattern)) | |
| 677 | ) | |
| 678 | ) | |
| 679 | .orderBy(desc(pullRequests.createdAt)) | |
| 680 | .limit(limit); | |
| 681 | return { | |
| 682 | total: rows.length, | |
| 683 | prs: rows.map((p) => ({ | |
| 684 | number: p.number, | |
| 685 | title: p.title, | |
| 686 | body: p.body || "", | |
| 687 | state: p.state, | |
| 688 | baseBranch: p.baseBranch, | |
| 689 | headBranch: p.headBranch, | |
| 690 | isDraft: p.isDraft, | |
| 691 | url: mcpPrUrl(owner, repo, p.number), | |
| 692 | createdAt: p.createdAt, | |
| 693 | })), | |
| 694 | }; | |
| 695 | }, | |
| 696 | }; | |
| 697 | ||
| 698 | const openDraftPr: McpToolHandler = { | |
| 699 | tool: { | |
| 700 | name: "gluecron_open_draft_pr", | |
| 701 | description: | |
| 702 | "Open a draft pull request. Same payload as gluecron_create_pr but forces is_draft=true. Useful for AI-in-progress PRs that shouldn't run mergeability checks yet.", | |
| 703 | inputSchema: { | |
| 704 | type: "object", | |
| 705 | properties: { | |
| 706 | owner: { type: "string" }, | |
| 707 | repo: { type: "string" }, | |
| 708 | title: { type: "string" }, | |
| 709 | body: { type: "string" }, | |
| 710 | head_branch: { type: "string" }, | |
| 711 | base_branch: { type: "string" }, | |
| 712 | }, | |
| 713 | required: ["owner", "repo", "title", "head_branch"], | |
| 714 | }, | |
| 715 | }, | |
| 716 | async run(args, ctx) { | |
| 717 | const owner = mcpArgString(args, "owner"); | |
| 718 | const repo = mcpArgString(args, "repo"); | |
| 719 | const title = mcpArgString(args, "title"); | |
| 720 | const body = mcpArgString(args, "body", ""); | |
| 721 | const headBranch = mcpArgString(args, "head_branch"); | |
| 722 | requireScope(ctx, "repo", "gluecron_open_draft_pr"); | |
| 723 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_open_draft_pr"); | |
| 724 | const baseBranch = mcpArgString(args, "base_branch", gate.defaultBranch); | |
| 725 | if (baseBranch === headBranch) { | |
| 726 | throw new McpError(ERR_INVALID_PARAMS, "base and head branches must be different"); | |
| 727 | } | |
| 728 | const [pr] = await db | |
| 729 | .insert(pullRequests) | |
| 730 | .values({ | |
| 731 | repositoryId: gate.repoId, | |
| 732 | authorId: gate.userId, | |
| 733 | title, | |
| 734 | body: body || null, | |
| 735 | baseBranch, | |
| 736 | headBranch, | |
| 737 | isDraft: true, | |
| 738 | }) | |
| 739 | .returning(); | |
| 740 | return { number: pr.number, url: mcpPrUrl(owner, repo, pr.number), isDraft: true }; | |
| 741 | }, | |
| 742 | }; | |
| 743 | ||
| 744 | const generatePrDescription: McpToolHandler = { | |
| 745 | tool: { | |
| 746 | name: "gluecron_generate_pr_description", | |
| 747 | description: | |
| 748 | "Generate an AI commit-message-style description for a diff. Uses src/lib/ai-commit-message.ts under the hood; gracefully degrades to a heuristic when ANTHROPIC_API_KEY is missing. Returns {subject, body}.", | |
| 749 | inputSchema: { | |
| 750 | type: "object", | |
| 751 | properties: { | |
| 752 | diff: { type: "string", description: "Unified-diff body" }, | |
| 753 | style: { | |
| 754 | type: "string", | |
| 755 | description: "'conventional' (default) or 'plain'", | |
| 756 | }, | |
| 757 | }, | |
| 758 | required: ["diff"], | |
| 759 | }, | |
| 760 | }, | |
| 761 | async run(args) { | |
| 762 | const diff = mcpArgString(args, "diff"); | |
| 763 | const style = mcpArgString(args, "style", "conventional"); | |
| 764 | const { generateCommitMessage } = await import("./ai-commit-message"); | |
| 765 | const msg = await generateCommitMessage(diff, { | |
| 766 | style: style === "plain" ? "plain" : "conventional", | |
| 767 | }); | |
| 768 | return msg; | |
| 769 | }, | |
| 770 | }; | |
| 771 | ||
| 772 | // --------------------------------------------------------------------------- | |
| 773 | // FILES & GIT PLUMBING | |
| 774 | // --------------------------------------------------------------------------- | |
| 775 | ||
| 776 | const readFile: McpToolHandler = { | |
| 777 | tool: { | |
| 778 | name: "gluecron_read_file", | |
| 779 | description: | |
| 780 | "Read a file from a repo at a given ref. Mirrors GET /api/v2/repos/:owner/:repo/contents/:path. Returns {path, size, content, encoding}.", | |
| 781 | inputSchema: { | |
| 782 | type: "object", | |
| 783 | properties: { | |
| 784 | owner: { type: "string" }, | |
| 785 | repo: { type: "string" }, | |
| 786 | ref: { type: "string", description: "Branch / tag / sha (default HEAD)" }, | |
| 787 | path: { type: "string" }, | |
| 788 | encoding: { type: "string", description: "'utf8' (default) or 'base64'" }, | |
| 789 | }, | |
| 790 | required: ["owner", "repo", "path"], | |
| 791 | }, | |
| 792 | }, | |
| 793 | async run(args, ctx) { | |
| 794 | const owner = mcpArgString(args, "owner"); | |
| 795 | const repo = mcpArgString(args, "repo"); | |
| 796 | const ref = mcpArgString(args, "ref", "HEAD"); | |
| 797 | const filePath = mcpArgString(args, "path"); | |
| 798 | await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 799 | const blob = await getBlob(owner, repo, ref, filePath); | |
| 800 | if (!blob) { | |
| 801 | throw new McpError(ERR_METHOD_NOT_FOUND, `path not found: ${owner}/${repo}@${ref}:${filePath}`); | |
| 802 | } | |
| 803 | return { | |
| 804 | path: filePath, | |
| 805 | size: blob.size, | |
| 806 | isBinary: blob.isBinary, | |
| 807 | content: blob.isBinary ? null : blob.content, | |
| 808 | encoding: blob.isBinary ? null : "utf8", | |
| 809 | }; | |
| 810 | }, | |
| 811 | }; | |
| 812 | ||
| 813 | const writeFile: McpToolHandler = { | |
| 814 | tool: { | |
| 815 | name: "gluecron_write_file", | |
| 816 | description: | |
| 817 | "Create or update a file on a branch via git plumbing. Wraps `createOrUpdateFileOnBranch`. Pass `content` as a UTF-8 string OR `content_base64` for binary. Requires 'repo' scope.", | |
| 818 | inputSchema: { | |
| 819 | type: "object", | |
| 820 | properties: { | |
| 821 | owner: { type: "string" }, | |
| 822 | repo: { type: "string" }, | |
| 823 | path: { type: "string" }, | |
| 824 | branch: { type: "string" }, | |
| 825 | message: { type: "string", description: "Commit message" }, | |
| 826 | content: { type: "string", description: "UTF-8 file body (optional)" }, | |
| 827 | content_base64: { type: "string", description: "Base64 file body (optional)" }, | |
| 828 | expect_blob_sha: { | |
| 829 | type: "string", | |
| 830 | description: "Optimistic-concurrency check: existing blob sha must match.", | |
| 831 | }, | |
| 832 | }, | |
| 833 | required: ["owner", "repo", "path", "branch", "message"], | |
| 834 | }, | |
| 835 | }, | |
| 836 | async run(args, ctx) { | |
| 837 | const owner = mcpArgString(args, "owner"); | |
| 838 | const repo = mcpArgString(args, "repo"); | |
| 839 | const filePath = mcpArgString(args, "path"); | |
| 840 | const branch = mcpArgString(args, "branch"); | |
| 841 | const message = mcpArgString(args, "message"); | |
| 842 | requireScope(ctx, "repo", "gluecron_write_file"); | |
| 843 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_write_file"); | |
| 844 | ||
| 845 | let bytes: Uint8Array; | |
| 846 | if (typeof args.content === "string") { | |
| 847 | bytes = new TextEncoder().encode(args.content); | |
| 848 | } else if (typeof args.content_base64 === "string") { | |
| 849 | try { | |
| 850 | bytes = new Uint8Array(Buffer.from(args.content_base64, "base64")); | |
| 851 | } catch { | |
| 852 | throw new McpError(ERR_INVALID_PARAMS, "content_base64 is not valid base64"); | |
| 853 | } | |
| 854 | } else { | |
| 855 | throw new McpError(ERR_INVALID_PARAMS, "either content or content_base64 is required"); | |
| 856 | } | |
| 857 | ||
| 858 | const [author] = await db | |
| 859 | .select({ username: users.username, email: users.email }) | |
| 860 | .from(users) | |
| 861 | .where(eq(users.id, gate.userId)) | |
| 862 | .limit(1); | |
| 863 | if (!author) throw new McpError(ERR_INVALID_PARAMS, "author lookup failed"); | |
| 864 | ||
| 865 | const expectBlobSha = | |
| 866 | typeof args.expect_blob_sha === "string" ? args.expect_blob_sha : null; | |
| 867 | ||
| 868 | const res = await createOrUpdateFileOnBranch({ | |
| 869 | owner, | |
| 870 | name: repo, | |
| 871 | branch, | |
| 872 | filePath, | |
| 873 | bytes, | |
| 874 | message, | |
| 875 | authorName: author.username, | |
| 876 | authorEmail: author.email || `${author.username}@users.noreply.gluecron`, | |
| 877 | expectBlobSha, | |
| 878 | }); | |
| 879 | if ("error" in res) { | |
| 880 | if (res.error === "sha-mismatch") { | |
| 881 | throw new McpError(ERR_INVALID_PARAMS, "sha does not match current blob at path"); | |
| 882 | } | |
| 883 | throw new McpError(ERR_INVALID_PARAMS, `write failed: ${res.error}`); | |
| 884 | } | |
| 885 | return { | |
| 886 | commitSha: res.commitSha, | |
| 887 | blobSha: res.blobSha, | |
| 888 | parentSha: res.parentSha, | |
| 889 | branch, | |
| 890 | path: filePath, | |
| 891 | }; | |
| 892 | }, | |
| 893 | }; | |
| 894 | ||
| 895 | const deleteFile: McpToolHandler = { | |
| 896 | tool: { | |
| 897 | name: "gluecron_delete_file", | |
| 898 | description: | |
| 899 | "Delete a file from a branch via git plumbing. Requires the existing blob sha (optimistic concurrency) and 'repo' scope. Mirrors DELETE /api/v2/contents.", | |
| 900 | inputSchema: { | |
| 901 | type: "object", | |
| 902 | properties: { | |
| 903 | owner: { type: "string" }, | |
| 904 | repo: { type: "string" }, | |
| 905 | path: { type: "string" }, | |
| 906 | branch: { type: "string" }, | |
| 907 | message: { type: "string" }, | |
| 908 | sha: { type: "string", description: "Current blob sha (40-hex)" }, | |
| 909 | }, | |
| 910 | required: ["owner", "repo", "path", "branch", "message", "sha"], | |
| 911 | }, | |
| 912 | }, | |
| 913 | async run(args, ctx) { | |
| 914 | const owner = mcpArgString(args, "owner"); | |
| 915 | const repo = mcpArgString(args, "repo"); | |
| 916 | const filePath = mcpArgString(args, "path"); | |
| 917 | const branch = mcpArgString(args, "branch"); | |
| 918 | const message = mcpArgString(args, "message"); | |
| 919 | const expectSha = mcpArgString(args, "sha"); | |
| 920 | if (!/^[0-9a-f]{40}$/.test(expectSha)) { | |
| 921 | throw new McpError(ERR_INVALID_PARAMS, "sha must be a 40-char lowercase hex string"); | |
| 922 | } | |
| 923 | requireScope(ctx, "repo", "gluecron_delete_file"); | |
| 924 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_delete_file"); | |
| 925 | ||
| 926 | const fullRef = `refs/heads/${branch}`; | |
| 927 | const repoDir = getRepoPath(owner, repo); | |
| 928 | const parentSha = await resolveRef(owner, repo, fullRef); | |
| 929 | if (!parentSha) throw new McpError(ERR_METHOD_NOT_FOUND, "branch not found"); | |
| 930 | const existing = await getBlobShaAtPath(owner, repo, branch, filePath); | |
| 931 | if (!existing) throw new McpError(ERR_METHOD_NOT_FOUND, "file not found"); | |
| 932 | if (existing !== expectSha) { | |
| 933 | throw new McpError(ERR_INVALID_PARAMS, "sha does not match current blob at path"); | |
| 934 | } | |
| 935 | ||
| 936 | const [author] = await db | |
| 937 | .select({ username: users.username, email: users.email }) | |
| 938 | .from(users) | |
| 939 | .where(eq(users.id, gate.userId)) | |
| 940 | .limit(1); | |
| 941 | if (!author) throw new McpError(ERR_INVALID_PARAMS, "author lookup failed"); | |
| 942 | ||
| 943 | // Stand up a transient index + work-tree dir so git's safety checks pass. | |
| 944 | const tmpIndex = join( | |
| 945 | repoDir, | |
| 946 | `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}` | |
| 947 | ); | |
| 948 | const tmpWorkTree = join( | |
| 949 | repoDir, | |
| 950 | `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}` | |
| 951 | ); | |
| 952 | await mkdir(tmpWorkTree, { recursive: true }); | |
| 953 | const env = { | |
| 954 | ...process.env, | |
| 955 | GIT_INDEX_FILE: tmpIndex, | |
| 956 | GIT_DIR: repoDir, | |
| 957 | GIT_WORK_TREE: tmpWorkTree, | |
| 958 | GIT_AUTHOR_NAME: author.username, | |
| 959 | GIT_AUTHOR_EMAIL: author.email || `${author.username}@users.noreply.gluecron`, | |
| 960 | GIT_COMMITTER_NAME: author.username, | |
| 961 | GIT_COMMITTER_EMAIL: author.email || `${author.username}@users.noreply.gluecron`, | |
| 962 | }; | |
| 963 | const cleanup = async () => { | |
| 964 | try { | |
| 965 | await unlink(tmpIndex).catch(() => {}); | |
| 966 | await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {}); | |
| 967 | } catch { | |
| 968 | /* ignore */ | |
| 969 | } | |
| 970 | }; | |
| 971 | ||
| 972 | const run = async (cmd: string[]): Promise<{ code: number; out: string }> => { | |
| 973 | const proc = Bun.spawn(cmd, { | |
| 974 | cwd: repoDir, | |
| 975 | env, | |
| 976 | stdout: "pipe", | |
| 977 | stderr: "pipe", | |
| 978 | }); | |
| 979 | const out = await new Response(proc.stdout).text(); | |
| 980 | const code = await proc.exited; | |
| 981 | return { code, out }; | |
| 982 | }; | |
| 983 | ||
| 984 | try { | |
| 985 | const rt = await run(["git", "read-tree", parentSha]); | |
| 986 | if (rt.code !== 0) { | |
| 987 | await cleanup(); | |
| 988 | throw new McpError(ERR_INVALID_PARAMS, "read-tree failed"); | |
| 989 | } | |
| 990 | const ui = await run(["git", "update-index", "--remove", filePath]); | |
| 991 | if (ui.code !== 0) { | |
| 992 | await cleanup(); | |
| 993 | throw new McpError(ERR_INVALID_PARAMS, "update-index --remove failed"); | |
| 994 | } | |
| 995 | const wt = await run(["git", "write-tree"]); | |
| 996 | const newTree = wt.out.trim(); | |
| 997 | if (wt.code !== 0 || !/^[0-9a-f]{40}$/.test(newTree)) { | |
| 998 | await cleanup(); | |
| 999 | throw new McpError(ERR_INVALID_PARAMS, "write-tree failed"); | |
| 1000 | } | |
| 1001 | const ct = await run([ | |
| 1002 | "git", | |
| 1003 | "commit-tree", | |
| 1004 | newTree, | |
| 1005 | "-p", | |
| 1006 | parentSha, | |
| 1007 | "-m", | |
| 1008 | message, | |
| 1009 | ]); | |
| 1010 | const commitSha = ct.out.trim(); | |
| 1011 | if (ct.code !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) { | |
| 1012 | await cleanup(); | |
| 1013 | throw new McpError(ERR_INVALID_PARAMS, "commit-tree failed"); | |
| 1014 | } | |
| 1015 | const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha); | |
| 1016 | await cleanup(); | |
| 1017 | if (!ok) throw new McpError(ERR_INVALID_PARAMS, "update-ref failed"); | |
| 1018 | return { commitSha, branch, deletedPath: filePath }; | |
| 1019 | } catch (err) { | |
| 1020 | await cleanup(); | |
| 1021 | throw err; | |
| 1022 | } | |
| 1023 | }, | |
| 1024 | }; | |
| 1025 | ||
| 1026 | const listTree: McpToolHandler = { | |
| 1027 | tool: { | |
| 1028 | name: "gluecron_list_tree", | |
| 1029 | description: | |
| 1030 | "List directory contents at a ref. Optionally `recursive: true` returns the full file list. Mirrors GET /api/v2/repos/.../tree/:ref.", | |
| 1031 | inputSchema: { | |
| 1032 | type: "object", | |
| 1033 | properties: { | |
| 1034 | owner: { type: "string" }, | |
| 1035 | repo: { type: "string" }, | |
| 1036 | ref: { type: "string", description: "Branch / tag / sha" }, | |
| 1037 | path: { type: "string", description: "Sub-path within the repo" }, | |
| 1038 | recursive: { type: "boolean" }, | |
| 1039 | }, | |
| 1040 | required: ["owner", "repo", "ref"], | |
| 1041 | }, | |
| 1042 | }, | |
| 1043 | async run(args, ctx) { | |
| 1044 | const owner = mcpArgString(args, "owner"); | |
| 1045 | const repo = mcpArgString(args, "repo"); | |
| 1046 | const ref = mcpArgString(args, "ref"); | |
| 1047 | const path = mcpArgString(args, "path", ""); | |
| 1048 | const recursive = argBool(args, "recursive", false); | |
| 1049 | await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 1050 | if (recursive) { | |
| 1051 | const out = await getTreeRecursive(owner, repo, ref, 50_000); | |
| 1052 | if (!out) throw new McpError(ERR_METHOD_NOT_FOUND, "ref not found"); | |
| 1053 | return out; | |
| 1054 | } | |
| 1055 | const tree = await getTree(owner, repo, ref, path); | |
| 1056 | return { path, ref, entries: tree }; | |
| 1057 | }, | |
| 1058 | }; | |
| 1059 | ||
| 1060 | const getCommitTool: McpToolHandler = { | |
| 1061 | tool: { | |
| 1062 | name: "gluecron_get_commit", | |
| 1063 | description: "Fetch a single commit's metadata by SHA. Mirrors GET /api/v2/repos/.../commits/:sha.", | |
| 1064 | inputSchema: { | |
| 1065 | type: "object", | |
| 1066 | properties: { | |
| 1067 | owner: { type: "string" }, | |
| 1068 | repo: { type: "string" }, | |
| 1069 | sha: { type: "string", description: "Commit SHA" }, | |
| 1070 | }, | |
| 1071 | required: ["owner", "repo", "sha"], | |
| 1072 | }, | |
| 1073 | }, | |
| 1074 | async run(args, ctx) { | |
| 1075 | const owner = mcpArgString(args, "owner"); | |
| 1076 | const repo = mcpArgString(args, "repo"); | |
| 1077 | const sha = mcpArgString(args, "sha"); | |
| 1078 | await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 1079 | const commit = await getCommit(owner, repo, sha); | |
| 1080 | if (!commit) throw new McpError(ERR_METHOD_NOT_FOUND, `commit not found: ${sha}`); | |
| 1081 | return commit; | |
| 1082 | }, | |
| 1083 | }; | |
| 1084 | ||
| 1085 | const createBranch: McpToolHandler = { | |
| 1086 | tool: { | |
| 1087 | name: "gluecron_create_branch", | |
| 1088 | description: | |
| 1089 | "Create a new branch ref pointing at an existing sha. Mirrors POST /api/v2/repos/.../git/refs. Requires 'repo' scope. Returns {ref, sha}.", | |
| 1090 | inputSchema: { | |
| 1091 | type: "object", | |
| 1092 | properties: { | |
| 1093 | owner: { type: "string" }, | |
| 1094 | repo: { type: "string" }, | |
| 1095 | branch: { type: "string", description: "New branch name (short form)" }, | |
| 1096 | sha: { type: "string", description: "Target commit sha (40-hex)" }, | |
| 1097 | }, | |
| 1098 | required: ["owner", "repo", "branch", "sha"], | |
| 1099 | }, | |
| 1100 | }, | |
| 1101 | async run(args, ctx) { | |
| 1102 | const owner = mcpArgString(args, "owner"); | |
| 1103 | const repo = mcpArgString(args, "repo"); | |
| 1104 | const branchName = mcpArgString(args, "branch"); | |
| 1105 | const sha = mcpArgString(args, "sha"); | |
| 1106 | if (!/^[0-9a-f]{40}$/.test(sha)) { | |
| 1107 | throw new McpError(ERR_INVALID_PARAMS, "sha must be a 40-char lowercase hex string"); | |
| 1108 | } | |
| 1109 | requireScope(ctx, "repo", "gluecron_create_branch"); | |
| 1110 | await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_create_branch"); | |
| 1111 | ||
| 1112 | if (!(await objectExists(owner, repo, sha))) { | |
| 1113 | throw new McpError(ERR_INVALID_PARAMS, `sha not found in repository: ${sha}`); | |
| 1114 | } | |
| 1115 | const ref = `refs/heads/${branchName}`; | |
| 1116 | if (await refExists(owner, repo, ref)) { | |
| 1117 | const existing = await resolveRef(owner, repo, ref); | |
| 1118 | if (existing === sha) return { ref, sha, alreadyExists: true }; | |
| 1119 | throw new McpError(ERR_INVALID_PARAMS, `ref already exists: ${ref}`); | |
| 1120 | } | |
| 1121 | const ok = await updateRef(owner, repo, ref, sha); | |
| 1122 | if (!ok) throw new McpError(ERR_INVALID_PARAMS, "update-ref failed"); | |
| 1123 | return { ref, sha }; | |
| 1124 | }, | |
| 1125 | }; | |
| 1126 | ||
| 1127 | const atomicMultiFileCommit: McpToolHandler = { | |
| 1128 | tool: { | |
| 1129 | name: "gluecron_atomic_multi_file_commit", | |
| 1130 | description: | |
| 1131 | "Apply a set of file writes + deletes as a single atomic commit on a branch (creates the branch if it doesn't exist). The killer agent tool: blob/tree/commit/ref-update sequence. Each change is `{path, content?, content_base64?, deleted?}`. Requires 'repo' scope.", | |
| 1132 | inputSchema: { | |
| 1133 | type: "object", | |
| 1134 | properties: { | |
| 1135 | owner: { type: "string" }, | |
| 1136 | repo: { type: "string" }, | |
| 1137 | branch: { type: "string" }, | |
| 1138 | base_branch: { | |
| 1139 | type: "string", | |
| 1140 | description: "Branch to fork from when `branch` doesn't yet exist (default: repo default).", | |
| 1141 | }, | |
| 1142 | message: { type: "string" }, | |
| 1143 | changes: { | |
| 1144 | type: "array", | |
| 1145 | description: "Array of {path, content?|content_base64?, deleted?} entries.", | |
| 1146 | }, | |
| 1147 | }, | |
| 1148 | required: ["owner", "repo", "branch", "message", "changes"], | |
| 1149 | }, | |
| 1150 | }, | |
| 1151 | async run(args, ctx) { | |
| 1152 | const owner = mcpArgString(args, "owner"); | |
| 1153 | const repo = mcpArgString(args, "repo"); | |
| 1154 | const branch = mcpArgString(args, "branch"); | |
| 1155 | const message = mcpArgString(args, "message"); | |
| 1156 | requireScope(ctx, "repo", "gluecron_atomic_multi_file_commit"); | |
| 1157 | const gate = await mcpGateWriteAccess( | |
| 1158 | { owner, repo }, | |
| 1159 | ctx, | |
| 1160 | "gluecron_atomic_multi_file_commit" | |
| 1161 | ); | |
| 1162 | const baseBranch = mcpArgString(args, "base_branch", gate.defaultBranch); | |
| 1163 | ||
| 1164 | const rawChanges = Array.isArray(args.changes) ? args.changes : []; | |
| 1165 | if (rawChanges.length === 0) { | |
| 1166 | throw new McpError(ERR_INVALID_PARAMS, "changes must be a non-empty array"); | |
| 1167 | } | |
| 1168 | type Change = { | |
| 1169 | path: string; | |
| 1170 | bytes?: Uint8Array; | |
| 1171 | deleted: boolean; | |
| 1172 | }; | |
| 1173 | const changes: Change[] = []; | |
| 1174 | for (const entry of rawChanges) { | |
| 1175 | if (!entry || typeof entry !== "object") { | |
| 1176 | throw new McpError(ERR_INVALID_PARAMS, "each change must be an object"); | |
| 1177 | } | |
| 1178 | const e = entry as Record<string, unknown>; | |
| 1179 | const path = typeof e.path === "string" ? e.path.trim() : ""; | |
| 1180 | if (!path) { | |
| 1181 | throw new McpError(ERR_INVALID_PARAMS, "change.path is required"); | |
| 1182 | } | |
| 1183 | const deleted = e.deleted === true; | |
| 1184 | let bytes: Uint8Array | undefined; | |
| 1185 | if (!deleted) { | |
| 1186 | if (typeof e.content === "string") { | |
| 1187 | bytes = new TextEncoder().encode(e.content); | |
| 1188 | } else if (typeof e.content_base64 === "string") { | |
| 1189 | try { | |
| 1190 | bytes = new Uint8Array(Buffer.from(e.content_base64, "base64")); | |
| 1191 | } catch { | |
| 1192 | throw new McpError(ERR_INVALID_PARAMS, `bad base64 for ${path}`); | |
| 1193 | } | |
| 1194 | } else { | |
| 1195 | throw new McpError( | |
| 1196 | ERR_INVALID_PARAMS, | |
| 1197 | `change.${path}: provide content, content_base64, or deleted:true` | |
| 1198 | ); | |
| 1199 | } | |
| 1200 | } | |
| 1201 | changes.push({ path, bytes, deleted }); | |
| 1202 | } | |
| 1203 | ||
| 1204 | const repoDir = getRepoPath(owner, repo); | |
| 1205 | const fullRef = `refs/heads/${branch}`; | |
| 1206 | ||
| 1207 | // Resolve parent — branch exists OR fall back to base_branch HEAD. | |
| 1208 | let parentSha: string | null = null; | |
| 1209 | if (await refExists(owner, repo, fullRef)) { | |
| 1210 | parentSha = await resolveRef(owner, repo, fullRef); | |
| 1211 | } else { | |
| 1212 | parentSha = await resolveRef(owner, repo, baseBranch); | |
| 1213 | if (!parentSha) { | |
| 1214 | throw new McpError( | |
| 1215 | ERR_INVALID_PARAMS, | |
| 1216 | `base_branch ${baseBranch} does not exist` | |
| 1217 | ); | |
| 1218 | } | |
| 1219 | } | |
| 1220 | ||
| 1221 | const [author] = await db | |
| 1222 | .select({ username: users.username, email: users.email }) | |
| 1223 | .from(users) | |
| 1224 | .where(eq(users.id, gate.userId)) | |
| 1225 | .limit(1); | |
| 1226 | if (!author) throw new McpError(ERR_INVALID_PARAMS, "author lookup failed"); | |
| 1227 | ||
| 1228 | const tmpIndex = join( | |
| 1229 | repoDir, | |
| 1230 | `index.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}` | |
| 1231 | ); | |
| 1232 | const tmpWorkTree = join( | |
| 1233 | repoDir, | |
| 1234 | `worktree.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}` | |
| 1235 | ); | |
| 1236 | await mkdir(tmpWorkTree, { recursive: true }); | |
| 1237 | const env = { | |
| 1238 | ...process.env, | |
| 1239 | GIT_INDEX_FILE: tmpIndex, | |
| 1240 | GIT_DIR: repoDir, | |
| 1241 | GIT_WORK_TREE: tmpWorkTree, | |
| 1242 | GIT_AUTHOR_NAME: author.username, | |
| 1243 | GIT_AUTHOR_EMAIL: author.email || `${author.username}@users.noreply.gluecron`, | |
| 1244 | GIT_COMMITTER_NAME: author.username, | |
| 1245 | GIT_COMMITTER_EMAIL: author.email || `${author.username}@users.noreply.gluecron`, | |
| 1246 | }; | |
| 1247 | const cleanup = async () => { | |
| 1248 | await unlink(tmpIndex).catch(() => {}); | |
| 1249 | await rm(tmpWorkTree, { recursive: true, force: true }).catch(() => {}); | |
| 1250 | }; | |
| 1251 | const run = async (cmd: string[]): Promise<{ code: number; out: string }> => { | |
| 1252 | const proc = Bun.spawn(cmd, { cwd: repoDir, env, stdout: "pipe", stderr: "pipe" }); | |
| 1253 | const out = await new Response(proc.stdout).text(); | |
| 1254 | const code = await proc.exited; | |
| 1255 | return { code, out }; | |
| 1256 | }; | |
| 1257 | ||
| 1258 | try { | |
| 1259 | // 1. Seed the index from the parent tree. | |
| 1260 | if (parentSha) { | |
| 1261 | const rt = await run(["git", "read-tree", parentSha]); | |
| 1262 | if (rt.code !== 0) { | |
| 1263 | await cleanup(); | |
| 1264 | throw new McpError(ERR_INVALID_PARAMS, "read-tree failed"); | |
| 1265 | } | |
| 1266 | } | |
| 1267 | ||
| 1268 | // 2. Apply each change. | |
| 1269 | for (const change of changes) { | |
| 1270 | if (change.deleted) { | |
| 1271 | const ui = await run(["git", "update-index", "--remove", change.path]); | |
| 1272 | if (ui.code !== 0) { | |
| 1273 | await cleanup(); | |
| 1274 | throw new McpError( | |
| 1275 | ERR_INVALID_PARAMS, | |
| 1276 | `delete failed: ${change.path}` | |
| 1277 | ); | |
| 1278 | } | |
| 1279 | } else { | |
| 1280 | const blobSha = await writeBlob(owner, repo, change.bytes!); | |
| 1281 | if (!blobSha) { | |
| 1282 | await cleanup(); | |
| 1283 | throw new McpError(ERR_INVALID_PARAMS, `write-blob failed: ${change.path}`); | |
| 1284 | } | |
| 1285 | const ui = await run([ | |
| 1286 | "git", | |
| 1287 | "update-index", | |
| 1288 | "--add", | |
| 1289 | "--cacheinfo", | |
| 1290 | `100644,${blobSha},${change.path}`, | |
| 1291 | ]); | |
| 1292 | if (ui.code !== 0) { | |
| 1293 | await cleanup(); | |
| 1294 | throw new McpError(ERR_INVALID_PARAMS, `update-index failed: ${change.path}`); | |
| 1295 | } | |
| 1296 | } | |
| 1297 | } | |
| 1298 | ||
| 1299 | // 3. write-tree → commit-tree → update-ref. | |
| 1300 | const wt = await run(["git", "write-tree"]); | |
| 1301 | const newTree = wt.out.trim(); | |
| 1302 | if (wt.code !== 0 || !/^[0-9a-f]{40}$/.test(newTree)) { | |
| 1303 | await cleanup(); | |
| 1304 | throw new McpError(ERR_INVALID_PARAMS, "write-tree failed"); | |
| 1305 | } | |
| 1306 | const ctArgs = parentSha | |
| 1307 | ? ["git", "commit-tree", newTree, "-p", parentSha, "-m", message] | |
| 1308 | : ["git", "commit-tree", newTree, "-m", message]; | |
| 1309 | const ct = await run(ctArgs); | |
| 1310 | const commitSha = ct.out.trim(); | |
| 1311 | if (ct.code !== 0 || !/^[0-9a-f]{40}$/.test(commitSha)) { | |
| 1312 | await cleanup(); | |
| 1313 | throw new McpError(ERR_INVALID_PARAMS, "commit-tree failed"); | |
| 1314 | } | |
| 1315 | const ok = await updateRef(owner, repo, fullRef, commitSha, parentSha || undefined); | |
| 1316 | await cleanup(); | |
| 1317 | if (!ok) throw new McpError(ERR_INVALID_PARAMS, "update-ref failed"); | |
| 1318 | ||
| 1319 | return { | |
| 1320 | commitSha, | |
| 1321 | branch, | |
| 1322 | parentSha, | |
| 1323 | files: changes.map((c) => ({ path: c.path, deleted: c.deleted })), | |
| 1324 | }; | |
| 1325 | } catch (err) { | |
| 1326 | await cleanup(); | |
| 1327 | throw err; | |
| 1328 | } | |
| 1329 | }, | |
| 1330 | }; | |
| 1331 | ||
| 1332 | // --------------------------------------------------------------------------- | |
| 1333 | // AI WORKFLOWS | |
| 1334 | // --------------------------------------------------------------------------- | |
| 1335 | ||
| 1336 | const shipSpec: McpToolHandler = { | |
| 1337 | tool: { | |
| 1338 | name: "gluecron_ship_spec", | |
| 1339 | description: | |
| 1340 | "Drop a spec file in .gluecron/specs/ with status: ready so the autopilot picks it up. Wraps voice-to-pr.shipAsSpec — handle both voice + manual specs. Requires 'repo' scope.", | |
| 1341 | inputSchema: { | |
| 1342 | type: "object", | |
| 1343 | properties: { | |
| 1344 | owner: { type: "string" }, | |
| 1345 | repo: { type: "string" }, | |
| 1346 | title: { type: "string" }, | |
| 1347 | body: { type: "string", description: "Spec body (Markdown)" }, | |
| 1348 | }, | |
| 1349 | required: ["owner", "repo", "title", "body"], | |
| 1350 | }, | |
| 1351 | }, | |
| 1352 | async run(args, ctx) { | |
| 1353 | const owner = mcpArgString(args, "owner"); | |
| 1354 | const repo = mcpArgString(args, "repo"); | |
| 1355 | const title = mcpArgString(args, "title"); | |
| 1356 | const body = mcpArgString(args, "body"); | |
| 1357 | requireScope(ctx, "repo", "gluecron_ship_spec"); | |
| 1358 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_ship_spec"); | |
| 1359 | const { shipAsSpec } = await import("./voice-to-pr"); | |
| 1360 | // We use voice-to-pr's `shipAsSpec` even for non-voice specs — it | |
| 1361 | // builds the front-matter + commits via createOrUpdateFileOnBranch. | |
| 1362 | const transcript = `${title}\n\n${body}`; | |
| 1363 | const res = await shipAsSpec({ | |
| 1364 | repositoryId: gate.repoId, | |
| 1365 | userId: gate.userId, | |
| 1366 | transcript, | |
| 1367 | interpretation: { kind: "spec", title, body_markdown: body }, | |
| 1368 | }); | |
| 1369 | if (!res.ok) { | |
| 1370 | throw new McpError(ERR_INVALID_PARAMS, res.error); | |
| 1371 | } | |
| 1372 | return res; | |
| 1373 | }, | |
| 1374 | }; | |
| 1375 | ||
| 1376 | const voiceToPr: McpToolHandler = { | |
| 1377 | tool: { | |
| 1378 | name: "gluecron_voice_to_pr", | |
| 1379 | description: | |
| 1380 | "Interpret a free-form voice transcript and either ship it as a spec or create an issue (caller picks via `as`). Wraps src/lib/voice-to-pr.ts. Requires 'repo' scope.", | |
| 1381 | inputSchema: { | |
| 1382 | type: "object", | |
| 1383 | properties: { | |
| 1384 | owner: { type: "string" }, | |
| 1385 | repo: { type: "string" }, | |
| 1386 | transcript: { type: "string" }, | |
| 1387 | as: { | |
| 1388 | type: "string", | |
| 1389 | description: "'spec' or 'issue' (default: auto via interpretVoiceTranscript)", | |
| 1390 | }, | |
| 1391 | }, | |
| 1392 | required: ["owner", "repo", "transcript"], | |
| 1393 | }, | |
| 1394 | }, | |
| 1395 | async run(args, ctx) { | |
| 1396 | const owner = mcpArgString(args, "owner"); | |
| 1397 | const repo = mcpArgString(args, "repo"); | |
| 1398 | const transcript = mcpArgString(args, "transcript"); | |
| 1399 | const as = mcpArgString(args, "as", "auto"); | |
| 1400 | requireScope(ctx, "repo", "gluecron_voice_to_pr"); | |
| 1401 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_voice_to_pr"); | |
| 1402 | ||
| 1403 | const { interpretVoiceTranscript, shipAsSpec, createIssueFromVoice } = | |
| 1404 | await import("./voice-to-pr"); | |
| 1405 | ||
| 1406 | let intent: "spec" | "issue"; | |
| 1407 | let interpretation; | |
| 1408 | if (as === "spec" || as === "issue") { | |
| 1409 | const interp = await interpretVoiceTranscript({ transcript }); | |
| 1410 | interpretation = interp.ok ? interp.suggestion : undefined; | |
| 1411 | intent = as; | |
| 1412 | } else { | |
| 1413 | const interp = await interpretVoiceTranscript({ transcript }); | |
| 1414 | if (!interp.ok) { | |
| 1415 | throw new McpError(ERR_INVALID_PARAMS, interp.error); | |
| 1416 | } | |
| 1417 | interpretation = interp.suggestion; | |
| 1418 | intent = interp.suggestion.kind === "issue" ? "issue" : "spec"; | |
| 1419 | } | |
| 1420 | ||
| 1421 | if (intent === "spec") { | |
| 1422 | const res = await shipAsSpec({ | |
| 1423 | repositoryId: gate.repoId, | |
| 1424 | userId: gate.userId, | |
| 1425 | transcript, | |
| 1426 | interpretation, | |
| 1427 | }); | |
| 1428 | if (!res.ok) throw new McpError(ERR_INVALID_PARAMS, res.error); | |
| 1429 | return { kind: "spec", ...res }; | |
| 1430 | } | |
| 1431 | const res = await createIssueFromVoice({ | |
| 1432 | repositoryId: gate.repoId, | |
| 1433 | userId: gate.userId, | |
| 1434 | transcript, | |
| 1435 | interpretation, | |
| 1436 | }); | |
| 1437 | if (!res.ok) throw new McpError(ERR_INVALID_PARAMS, res.error); | |
| 1438 | return { kind: "issue", ...res }; | |
| 1439 | }, | |
| 1440 | }; | |
| 1441 | ||
| 1442 | const refactorAcrossRepos: McpToolHandler = { | |
| 1443 | tool: { | |
| 1444 | name: "gluecron_refactor_across_repos", | |
| 1445 | description: | |
| 1446 | "Plan + execute a refactor that spans multiple repos owned by the caller. Wraps src/lib/multi-repo-refactor.ts. `dry_run: true` returns the plan only. Requires 'repo' scope.", | |
| 1447 | inputSchema: { | |
| 1448 | type: "object", | |
| 1449 | properties: { | |
| 1450 | description: { type: "string", description: "Natural-language description" }, | |
| 1451 | repository_ids: { | |
| 1452 | type: "array", | |
| 1453 | description: "Optional explicit repo IDs to scope the refactor to.", | |
| 1454 | }, | |
| 1455 | dry_run: { | |
| 1456 | type: "boolean", | |
| 1457 | description: "When true, returns the plan and does NOT execute.", | |
| 1458 | }, | |
| 1459 | }, | |
| 1460 | required: ["description"], | |
| 1461 | }, | |
| 1462 | }, | |
| 1463 | async run(args, ctx) { | |
| 1464 | const description = mcpArgString(args, "description"); | |
| 1465 | const userId = mcpRequireAuthedCtx(ctx, "gluecron_refactor_across_repos"); | |
| 1466 | requireScope(ctx, "repo", "gluecron_refactor_across_repos"); | |
| 1467 | const dryRun = argBool(args, "dry_run", false); | |
| 1468 | const ids = argStringArray(args, "repository_ids"); | |
| 1469 | const { planRefactor, executeRefactor } = await import("./multi-repo-refactor"); | |
| 1470 | const planRes = await planRefactor({ | |
| 1471 | userId, | |
| 1472 | description, | |
| 1473 | repositoryIds: ids.length ? ids : undefined, | |
| 1474 | }); | |
| 1475 | if (!planRes.ok) throw new McpError(ERR_INVALID_PARAMS, planRes.error); | |
| 1476 | if (dryRun) return { plan: planRes.plan, refactor: planRes.refactor, executed: false }; | |
| 1477 | const exec = await executeRefactor({ refactorId: planRes.refactor.id }); | |
| 1478 | if (!exec.ok) throw new McpError(ERR_INVALID_PARAMS, exec.error); | |
| 1479 | return { | |
| 1480 | plan: planRes.plan, | |
| 1481 | refactor: exec.refactor, | |
| 1482 | children: exec.children, | |
| 1483 | executed: true, | |
| 1484 | }; | |
| 1485 | }, | |
| 1486 | }; | |
| 1487 | ||
| 1488 | const explainRepo: McpToolHandler = { | |
| 1489 | tool: { | |
| 1490 | name: "gluecron_explain_repo", | |
| 1491 | description: | |
| 1492 | "Return the cached AI 'explain this codebase' Markdown for a repo. Pure read — never triggers a new generation (use the web UI for that).", | |
| 1493 | inputSchema: { | |
| 1494 | type: "object", | |
| 1495 | properties: { | |
| 1496 | owner: { type: "string" }, | |
| 1497 | repo: { type: "string" }, | |
| 1498 | }, | |
| 1499 | required: ["owner", "repo"], | |
| 1500 | }, | |
| 1501 | }, | |
| 1502 | async run(args, ctx) { | |
| 1503 | const owner = mcpArgString(args, "owner"); | |
| 1504 | const repo = mcpArgString(args, "repo"); | |
| 1505 | await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 1506 | const { codebaseExplanations } = await import("../db/schema"); | |
| 1507 | const [row] = await db | |
| 1508 | .select() | |
| 1509 | .from(codebaseExplanations) | |
| 1510 | .where( | |
| 1511 | eq( | |
| 1512 | codebaseExplanations.repositoryId, | |
| 1513 | (await mcpResolveAccessibleRepo(owner, repo, ctx.userId)).repoId | |
| 1514 | ) | |
| 1515 | ) | |
| 1516 | .orderBy(desc(codebaseExplanations.generatedAt)) | |
| 1517 | .limit(1); | |
| 1518 | if (!row) return { explanation: null }; | |
| 1519 | return { | |
| 1520 | commitSha: row.commitSha, | |
| 1521 | generatedAt: row.generatedAt, | |
| 1522 | markdown: row.markdown, | |
| 1523 | }; | |
| 1524 | }, | |
| 1525 | }; | |
| 1526 | ||
| 1527 | const chatWithRepo: McpToolHandler = { | |
| 1528 | tool: { | |
| 1529 | name: "gluecron_chat_with_repo", | |
| 1530 | description: | |
| 1531 | "Start a new chat with a repo: creates a chat row, sends the first user message, streams + persists the assistant reply. Returns {chat_id, reply}. Requires authentication.", | |
| 1532 | inputSchema: { | |
| 1533 | type: "object", | |
| 1534 | properties: { | |
| 1535 | owner: { type: "string" }, | |
| 1536 | repo: { type: "string" }, | |
| 1537 | message: { type: "string", description: "Initial user message" }, | |
| 1538 | title: { type: "string", description: "Chat title (optional)" }, | |
| 1539 | }, | |
| 1540 | required: ["owner", "repo", "message"], | |
| 1541 | }, | |
| 1542 | }, | |
| 1543 | async run(args, ctx) { | |
| 1544 | const owner = mcpArgString(args, "owner"); | |
| 1545 | const repo = mcpArgString(args, "repo"); | |
| 1546 | const userMessage = mcpArgString(args, "message"); | |
| 1547 | const title = mcpArgString(args, "title", ""); | |
| 1548 | const userId = mcpRequireAuthedCtx(ctx, "gluecron_chat_with_repo"); | |
| 1549 | const info = await mcpResolveAccessibleRepo(owner, repo, userId); | |
| 1550 | const { createChat, appendUserMessage, streamAssistantReply } = await import( | |
| 1551 | "./repo-chat" | |
| 1552 | ); | |
| 1553 | const chat = await createChat({ | |
| 1554 | repositoryId: info.repoId, | |
| 1555 | ownerUserId: userId, | |
| 1556 | title: title || userMessage.slice(0, 80), | |
| 1557 | }); | |
| 1558 | if (!chat) throw new McpError(ERR_INVALID_PARAMS, "chat creation failed"); | |
| 1559 | await appendUserMessage(chat.id, userMessage); | |
| 1560 | const reply = await streamAssistantReply({ | |
| 1561 | chatId: chat.id, | |
| 1562 | repoId: info.repoId, | |
| 1563 | userMessage, | |
| 1564 | }); | |
| 1565 | return { | |
| 1566 | chat_id: chat.id, | |
| 1567 | reply: reply ? { id: reply.id, content: reply.content, citations: reply.citations } : null, | |
| 1568 | }; | |
| 1569 | }, | |
| 1570 | }; | |
| 1571 | ||
| 1572 | const chatContinue: McpToolHandler = { | |
| 1573 | tool: { | |
| 1574 | name: "gluecron_chat_continue", | |
| 1575 | description: | |
| 1576 | "Send another message to an existing repo chat. Returns the assistant's reply.", | |
| 1577 | inputSchema: { | |
| 1578 | type: "object", | |
| 1579 | properties: { | |
| 1580 | chat_id: { type: "string" }, | |
| 1581 | message: { type: "string" }, | |
| 1582 | }, | |
| 1583 | required: ["chat_id", "message"], | |
| 1584 | }, | |
| 1585 | }, | |
| 1586 | async run(args, ctx) { | |
| 1587 | const chatId = mcpArgString(args, "chat_id"); | |
| 1588 | const userMessage = mcpArgString(args, "message"); | |
| 1589 | const userId = mcpRequireAuthedCtx(ctx, "gluecron_chat_continue"); | |
| 1590 | const { getChatForUser, appendUserMessage, streamAssistantReply } = await import( | |
| 1591 | "./repo-chat" | |
| 1592 | ); | |
| 1593 | const chat = await getChatForUser(chatId, userId); | |
| 1594 | if (!chat) throw new McpError(ERR_METHOD_NOT_FOUND, "chat not found"); | |
| 1595 | await appendUserMessage(chatId, userMessage); | |
| 1596 | const reply = await streamAssistantReply({ | |
| 1597 | chatId, | |
| 1598 | repoId: chat.repositoryId, | |
| 1599 | userMessage, | |
| 1600 | }); | |
| 1601 | return { | |
| 1602 | chat_id: chatId, | |
| 1603 | reply: reply ? { id: reply.id, content: reply.content, citations: reply.citations } : null, | |
| 1604 | }; | |
| 1605 | }, | |
| 1606 | }; | |
| 1607 | ||
| 1608 | const generateTests: McpToolHandler = { | |
| 1609 | tool: { | |
| 1610 | name: "gluecron_generate_tests", | |
| 1611 | description: | |
| 1612 | "Generate tests for a PR via Claude. Wraps src/lib/ai-test-generator.generateTestsForPr. Mode 'follow-up-pr' opens a new PR; 'append-commit' commits onto the head branch. Requires 'repo' scope.", | |
| 1613 | inputSchema: { | |
| 1614 | type: "object", | |
| 1615 | properties: { | |
| 1616 | owner: { type: "string" }, | |
| 1617 | repo: { type: "string" }, | |
| 1618 | number: { type: "number", description: "PR number" }, | |
| 1619 | mode: { type: "string", description: "'follow-up-pr' or 'append-commit'" }, | |
| 1620 | }, | |
| 1621 | required: ["owner", "repo", "number"], | |
| 1622 | }, | |
| 1623 | }, | |
| 1624 | async run(args, ctx) { | |
| 1625 | const owner = mcpArgString(args, "owner"); | |
| 1626 | const repo = mcpArgString(args, "repo"); | |
| 1627 | const number = mcpArgNumber(args, "number"); | |
| 1628 | const mode = mcpArgString(args, "mode", "follow-up-pr"); | |
| 1629 | if (mode !== "follow-up-pr" && mode !== "append-commit") { | |
| 1630 | throw new McpError(ERR_INVALID_PARAMS, "mode must be follow-up-pr|append-commit"); | |
| 1631 | } | |
| 1632 | requireScope(ctx, "repo", "gluecron_generate_tests"); | |
| 1633 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_generate_tests"); | |
| 1634 | const pr = await mcpLoadPrByNumber(gate.repoId, number); | |
| 1635 | if (!pr) throw new McpError(ERR_METHOD_NOT_FOUND, "pr not found"); | |
| 1636 | const { generateTestsForPr } = await import("./ai-test-generator"); | |
| 1637 | const res = await generateTestsForPr({ prId: pr.id, mode }); | |
| 1638 | if (!res.ok) throw new McpError(ERR_INVALID_PARAMS, res.error || "test generation failed"); | |
| 1639 | return res; | |
| 1640 | }, | |
| 1641 | }; | |
| 1642 | ||
| 1643 | const generateCommitMessageTool: McpToolHandler = { | |
| 1644 | tool: { | |
| 1645 | name: "gluecron_generate_commit_message", | |
| 1646 | description: | |
| 1647 | "Generate a commit message for a diff. Same engine as gluecron_generate_pr_description but explicit for the commit-message use case.", | |
| 1648 | inputSchema: { | |
| 1649 | type: "object", | |
| 1650 | properties: { | |
| 1651 | diff: { type: "string" }, | |
| 1652 | style: { type: "string", description: "'conventional' (default) or 'plain'" }, | |
| 1653 | }, | |
| 1654 | required: ["diff"], | |
| 1655 | }, | |
| 1656 | }, | |
| 1657 | async run(args) { | |
| 1658 | const diff = mcpArgString(args, "diff"); | |
| 1659 | const style = mcpArgString(args, "style", "conventional"); | |
| 1660 | const { generateCommitMessage } = await import("./ai-commit-message"); | |
| 1661 | return await generateCommitMessage(diff, { | |
| 1662 | style: style === "plain" ? "plain" : "conventional", | |
| 1663 | }); | |
| 1664 | }, | |
| 1665 | }; | |
| 1666 | ||
| 1667 | const generateReleaseNotes: McpToolHandler = { | |
| 1668 | tool: { | |
| 1669 | name: "gluecron_generate_release_notes", | |
| 1670 | description: | |
| 1671 | "Generate release notes between two tags. Wraps src/lib/ai-release-notes.generateReleaseNotes. Returns the rendered Markdown + section data.", | |
| 1672 | inputSchema: { | |
| 1673 | type: "object", | |
| 1674 | properties: { | |
| 1675 | owner: { type: "string" }, | |
| 1676 | repo: { type: "string" }, | |
| 1677 | from_tag: { type: "string", description: "Previous tag (optional)" }, | |
| 1678 | to_tag: { type: "string", description: "New tag" }, | |
| 1679 | }, | |
| 1680 | required: ["owner", "repo", "to_tag"], | |
| 1681 | }, | |
| 1682 | }, | |
| 1683 | async run(args, ctx) { | |
| 1684 | const owner = mcpArgString(args, "owner"); | |
| 1685 | const repo = mcpArgString(args, "repo"); | |
| 1686 | const toTag = mcpArgString(args, "to_tag"); | |
| 1687 | const fromTag = mcpArgString(args, "from_tag", ""); | |
| 1688 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 1689 | const { generateReleaseNotes: gen } = await import("./ai-release-notes"); | |
| 1690 | return await gen({ | |
| 1691 | repositoryId: info.repoId, | |
| 1692 | fromTag: fromTag || null, | |
| 1693 | toTag, | |
| 1694 | }); | |
| 1695 | }, | |
| 1696 | }; | |
| 1697 | ||
| 1698 | const proposeMigration: McpToolHandler = { | |
| 1699 | tool: { | |
| 1700 | name: "gluecron_propose_migration", | |
| 1701 | description: | |
| 1702 | "Propose a dependency-upgrade PR. Wraps src/lib/migration-assistant.proposeMajorMigration. Requires 'repo' scope.", | |
| 1703 | inputSchema: { | |
| 1704 | type: "object", | |
| 1705 | properties: { | |
| 1706 | owner: { type: "string" }, | |
| 1707 | repo: { type: "string" }, | |
| 1708 | dependency: { type: "string" }, | |
| 1709 | from_version: { type: "string" }, | |
| 1710 | to_version: { type: "string" }, | |
| 1711 | base_sha: { type: "string", description: "Commit sha to fork from" }, | |
| 1712 | changelog: { type: "string", description: "Optional changelog text" }, | |
| 1713 | }, | |
| 1714 | required: ["owner", "repo", "dependency", "from_version", "to_version", "base_sha"], | |
| 1715 | }, | |
| 1716 | }, | |
| 1717 | async run(args, ctx) { | |
| 1718 | const owner = mcpArgString(args, "owner"); | |
| 1719 | const repo = mcpArgString(args, "repo"); | |
| 1720 | const dependency = mcpArgString(args, "dependency"); | |
| 1721 | const fromVersion = mcpArgString(args, "from_version"); | |
| 1722 | const toVersion = mcpArgString(args, "to_version"); | |
| 1723 | const baseSha = mcpArgString(args, "base_sha"); | |
| 1724 | const changelog = mcpArgString(args, "changelog", ""); | |
| 1725 | requireScope(ctx, "repo", "gluecron_propose_migration"); | |
| 1726 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_propose_migration"); | |
| 1727 | const { proposeMajorMigration } = await import("./migration-assistant"); | |
| 1728 | const res = await proposeMajorMigration({ | |
| 1729 | repositoryId: gate.repoId, | |
| 1730 | dependency, | |
| 1731 | fromVersion, | |
| 1732 | toVersion, | |
| 1733 | baseSha, | |
| 1734 | changelog: changelog || null, | |
| 1735 | }); | |
| 1736 | if (!res) { | |
| 1737 | throw new McpError( | |
| 1738 | ERR_INVALID_PARAMS, | |
| 1739 | "migration proposal returned null (no manifest, throttle, or AI failure)" | |
| 1740 | ); | |
| 1741 | } | |
| 1742 | return res; | |
| 1743 | }, | |
| 1744 | }; | |
| 1745 | ||
| 1746 | const proposeDocUpdate: McpToolHandler = { | |
| 1747 | tool: { | |
| 1748 | name: "gluecron_propose_doc_update", | |
| 1749 | description: | |
| 1750 | "Manual trigger for the AI doc-update flow: scans tracked sections on the default branch and opens a PR rewriting stale prose. Requires 'repo' scope.", | |
| 1751 | inputSchema: { | |
| 1752 | type: "object", | |
| 1753 | properties: { | |
| 1754 | owner: { type: "string" }, | |
| 1755 | repo: { type: "string" }, | |
| 1756 | }, | |
| 1757 | required: ["owner", "repo"], | |
| 1758 | }, | |
| 1759 | }, | |
| 1760 | async run(args, ctx) { | |
| 1761 | const owner = mcpArgString(args, "owner"); | |
| 1762 | const repo = mcpArgString(args, "repo"); | |
| 1763 | requireScope(ctx, "repo", "gluecron_propose_doc_update"); | |
| 1764 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_propose_doc_update"); | |
| 1765 | const { runDocDriftCheckForRepo } = await import("./ai-doc-updater"); | |
| 1766 | const res = await runDocDriftCheckForRepo(gate.repoId); | |
| 1767 | return res ?? { proposed: 0, note: "no drift detected or AI unavailable" }; | |
| 1768 | }, | |
| 1769 | }; | |
| 1770 | ||
| 1771 | // --------------------------------------------------------------------------- | |
| 1772 | // CI / DEPLOYS | |
| 1773 | // --------------------------------------------------------------------------- | |
| 1774 | ||
| 1775 | const triggerWorkflow: McpToolHandler = { | |
| 1776 | tool: { | |
| 1777 | name: "gluecron_trigger_workflow", | |
| 1778 | description: | |
| 1779 | "Dispatch a workflow_dispatch run. Mirrors POST /api/v2/repos/.../actions/workflows/:filename/dispatches. Requires 'repo' scope.", | |
| 1780 | inputSchema: { | |
| 1781 | type: "object", | |
| 1782 | properties: { | |
| 1783 | owner: { type: "string" }, | |
| 1784 | repo: { type: "string" }, | |
| 1785 | filename: { type: "string", description: "Workflow filename (e.g. ci.yml)" }, | |
| 1786 | ref: { type: "string", description: "Branch / tag / sha (default: repo default branch)" }, | |
| 1787 | inputs: { type: "object", description: "Workflow inputs (object)" }, | |
| 1788 | }, | |
| 1789 | required: ["owner", "repo", "filename"], | |
| 1790 | }, | |
| 1791 | }, | |
| 1792 | async run(args, ctx) { | |
| 1793 | const owner = mcpArgString(args, "owner"); | |
| 1794 | const repo = mcpArgString(args, "repo"); | |
| 1795 | const filename = mcpArgString(args, "filename"); | |
| 1796 | requireScope(ctx, "repo", "gluecron_trigger_workflow"); | |
| 1797 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_trigger_workflow"); | |
| 1798 | ||
| 1799 | // Match the api-v2 helper: stored workflow path is | |
| 1800 | // `.gluecron/workflows/<filename>`; we match by trailing basename. | |
| 1801 | const candidates = await db | |
| 1802 | .select() | |
| 1803 | .from(workflows) | |
| 1804 | .where(eq(workflows.repositoryId, gate.repoId)); | |
| 1805 | const wfRow = candidates.find((row) => { | |
| 1806 | const idx = row.path.lastIndexOf("/"); | |
| 1807 | const base = idx >= 0 ? row.path.slice(idx + 1) : row.path; | |
| 1808 | return base === filename; | |
| 1809 | }); | |
| 1810 | if (!wfRow) throw new McpError(ERR_METHOD_NOT_FOUND, `workflow not found: ${filename}`); | |
| 1811 | ||
| 1812 | let parsedObj: Record<string, unknown> = {}; | |
| 1813 | try { | |
| 1814 | const v = JSON.parse(wfRow.parsed); | |
| 1815 | if (v && typeof v === "object" && !Array.isArray(v)) { | |
| 1816 | parsedObj = v as Record<string, unknown>; | |
| 1817 | } | |
| 1818 | } catch { | |
| 1819 | /* */ | |
| 1820 | } | |
| 1821 | ||
| 1822 | // Inline-copy of the dispatch-spec extractor in src/routes/api-v2.ts | |
| 1823 | // (private to that module). Same shape, same semantics — keeping the | |
| 1824 | // logic local avoids an export-shuffle in the api-v2 module. | |
| 1825 | const rawOn = parsedObj.on as | |
| 1826 | | string | |
| 1827 | | string[] | |
| 1828 | | Record<string, unknown> | |
| 1829 | | null | |
| 1830 | | undefined; | |
| 1831 | type DispatchInputSpec = { required?: boolean; default?: unknown }; | |
| 1832 | let dispatchEnabled = false; | |
| 1833 | let dispatchInputs: Record<string, DispatchInputSpec> | null = null; | |
| 1834 | if (typeof rawOn === "string") { | |
| 1835 | dispatchEnabled = rawOn === "workflow_dispatch"; | |
| 1836 | } else if (Array.isArray(rawOn)) { | |
| 1837 | dispatchEnabled = rawOn.includes("workflow_dispatch"); | |
| 1838 | } else if (rawOn && typeof rawOn === "object") { | |
| 1839 | const slot = (rawOn as Record<string, unknown>)["workflow_dispatch"]; | |
| 1840 | if (slot !== undefined) { | |
| 1841 | dispatchEnabled = true; | |
| 1842 | if (slot && typeof slot === "object") { | |
| 1843 | const inputs = (slot as Record<string, unknown>).inputs; | |
| 1844 | if (inputs && typeof inputs === "object" && !Array.isArray(inputs)) { | |
| 1845 | dispatchInputs = inputs as Record<string, DispatchInputSpec>; | |
| 1846 | } | |
| 1847 | } | |
| 1848 | } | |
| 1849 | } | |
| 1850 | if (!dispatchEnabled) { | |
| 1851 | throw new McpError(ERR_INVALID_PARAMS, "workflow has no workflow_dispatch trigger"); | |
| 1852 | } | |
| 1853 | ||
| 1854 | const providedInputs = | |
| 1855 | args.inputs && typeof args.inputs === "object" && !Array.isArray(args.inputs) | |
| 1856 | ? (args.inputs as Record<string, unknown>) | |
| 1857 | : undefined; | |
| 1858 | if (dispatchInputs) { | |
| 1859 | const missing: string[] = []; | |
| 1860 | for (const [n, spec] of Object.entries(dispatchInputs)) { | |
| 1861 | if (!spec || typeof spec !== "object") continue; | |
| 1862 | const typed = spec as DispatchInputSpec; | |
| 1863 | const has = | |
| 1864 | !!providedInputs && | |
| 1865 | Object.prototype.hasOwnProperty.call(providedInputs, n) && | |
| 1866 | providedInputs[n] !== undefined && | |
| 1867 | providedInputs[n] !== null; | |
| 1868 | if (typed.required && !has && typed.default === undefined) { | |
| 1869 | missing.push(n); | |
| 1870 | } | |
| 1871 | } | |
| 1872 | if (missing.length) { | |
| 1873 | throw new McpError( | |
| 1874 | ERR_INVALID_PARAMS, | |
| 1875 | `missing required workflow inputs: ${missing.join(",")}` | |
| 1876 | ); | |
| 1877 | } | |
| 1878 | } | |
| 1879 | ||
| 1880 | const refIn = mcpArgString(args, "ref", gate.defaultBranch); | |
| 1881 | const commitSha = await resolveRef(owner, repo, refIn); | |
| 1882 | if (!commitSha) { | |
| 1883 | throw new McpError(ERR_INVALID_PARAMS, `ref not found: ${refIn}`); | |
| 1884 | } | |
| 1885 | const { enqueueRun } = await import("./workflow-runner"); | |
| 1886 | const runId = await enqueueRun({ | |
| 1887 | workflowId: wfRow.id, | |
| 1888 | repositoryId: gate.repoId, | |
| 1889 | event: "workflow_dispatch", | |
| 1890 | ref: refIn, | |
| 1891 | commitSha, | |
| 1892 | triggeredBy: gate.userId, | |
| 1893 | }); | |
| 1894 | if (!runId) throw new McpError(ERR_INVALID_PARAMS, "enqueueRun failed"); | |
| 1895 | return { runId, workflowName: wfRow.name, ref: refIn, commitSha }; | |
| 1896 | }, | |
| 1897 | }; | |
| 1898 | ||
| 1899 | const getWorkflowRun: McpToolHandler = { | |
| 1900 | tool: { | |
| 1901 | name: "gluecron_get_workflow_run", | |
| 1902 | description: "Fetch a workflow run's metadata + status. Mirrors GET /api/v2/repos/.../actions/runs/:id.", | |
| 1903 | inputSchema: { | |
| 1904 | type: "object", | |
| 1905 | properties: { | |
| 1906 | owner: { type: "string" }, | |
| 1907 | repo: { type: "string" }, | |
| 1908 | run_id: { type: "string" }, | |
| 1909 | }, | |
| 1910 | required: ["owner", "repo", "run_id"], | |
| 1911 | }, | |
| 1912 | }, | |
| 1913 | async run(args, ctx) { | |
| 1914 | const owner = mcpArgString(args, "owner"); | |
| 1915 | const repo = mcpArgString(args, "repo"); | |
| 1916 | const runId = mcpArgString(args, "run_id"); | |
| 1917 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 1918 | const [run] = await db | |
| 1919 | .select() | |
| 1920 | .from(workflowRuns) | |
| 1921 | .where(eq(workflowRuns.id, runId)) | |
| 1922 | .limit(1); | |
| 1923 | if (!run || run.repositoryId !== info.repoId) { | |
| 1924 | throw new McpError(ERR_METHOD_NOT_FOUND, "run not found"); | |
| 1925 | } | |
| 1926 | return run; | |
| 1927 | }, | |
| 1928 | }; | |
| 1929 | ||
| 1930 | const getWorkflowLogs: McpToolHandler = { | |
| 1931 | tool: { | |
| 1932 | name: "gluecron_get_workflow_logs", | |
| 1933 | description: | |
| 1934 | "Return concatenated per-job logs for a workflow run, plus per-job metadata. JSON-friendly companion to the ZIP-download endpoint.", | |
| 1935 | inputSchema: { | |
| 1936 | type: "object", | |
| 1937 | properties: { | |
| 1938 | owner: { type: "string" }, | |
| 1939 | repo: { type: "string" }, | |
| 1940 | run_id: { type: "string" }, | |
| 1941 | }, | |
| 1942 | required: ["owner", "repo", "run_id"], | |
| 1943 | }, | |
| 1944 | }, | |
| 1945 | async run(args, ctx) { | |
| 1946 | const owner = mcpArgString(args, "owner"); | |
| 1947 | const repo = mcpArgString(args, "repo"); | |
| 1948 | const runId = mcpArgString(args, "run_id"); | |
| 1949 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 1950 | const [run] = await db | |
| 1951 | .select() | |
| 1952 | .from(workflowRuns) | |
| 1953 | .where(eq(workflowRuns.id, runId)) | |
| 1954 | .limit(1); | |
| 1955 | if (!run || run.repositoryId !== info.repoId) { | |
| 1956 | throw new McpError(ERR_METHOD_NOT_FOUND, "run not found"); | |
| 1957 | } | |
| 1958 | const jobs = await db | |
| 1959 | .select() | |
| 1960 | .from(workflowJobs) | |
| 1961 | .where(eq(workflowJobs.runId, run.id)) | |
| 1962 | .orderBy(asc(workflowJobs.jobOrder)); | |
| 1963 | return { | |
| 1964 | runId: run.id, | |
| 1965 | status: run.status, | |
| 1966 | conclusion: run.conclusion, | |
| 1967 | jobs: jobs.map((j) => ({ | |
| 1968 | id: j.id, | |
| 1969 | name: j.name, | |
| 1970 | status: j.status, | |
| 1971 | conclusion: j.conclusion, | |
| 1972 | logs: j.logs || "", | |
| 1973 | })), | |
| 1974 | }; | |
| 1975 | }, | |
| 1976 | }; | |
| 1977 | ||
| 1978 | const cancelWorkflowRun: McpToolHandler = { | |
| 1979 | tool: { | |
| 1980 | name: "gluecron_cancel_workflow_run", | |
| 1981 | description: "Cancel a queued/running workflow run. Requires 'repo' scope.", | |
| 1982 | inputSchema: { | |
| 1983 | type: "object", | |
| 1984 | properties: { | |
| 1985 | owner: { type: "string" }, | |
| 1986 | repo: { type: "string" }, | |
| 1987 | run_id: { type: "string" }, | |
| 1988 | }, | |
| 1989 | required: ["owner", "repo", "run_id"], | |
| 1990 | }, | |
| 1991 | }, | |
| 1992 | async run(args, ctx) { | |
| 1993 | const owner = mcpArgString(args, "owner"); | |
| 1994 | const repo = mcpArgString(args, "repo"); | |
| 1995 | const runId = mcpArgString(args, "run_id"); | |
| 1996 | requireScope(ctx, "repo", "gluecron_cancel_workflow_run"); | |
| 1997 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_cancel_workflow_run"); | |
| 1998 | const [run] = await db | |
| 1999 | .select() | |
| 2000 | .from(workflowRuns) | |
| 2001 | .where(eq(workflowRuns.id, runId)) | |
| 2002 | .limit(1); | |
| 2003 | if (!run || run.repositoryId !== gate.repoId) { | |
| 2004 | throw new McpError(ERR_METHOD_NOT_FOUND, "run not found"); | |
| 2005 | } | |
| 2006 | if (run.status !== "queued" && run.status !== "running") { | |
| 2007 | return { cancelled: false, reason: `run is ${run.status}` }; | |
| 2008 | } | |
| 2009 | const now = new Date(); | |
| 2010 | await db | |
| 2011 | .update(workflowRuns) | |
| 2012 | .set({ status: "cancelled", conclusion: "cancelled", finishedAt: now }) | |
| 2013 | .where(eq(workflowRuns.id, run.id)); | |
| 2014 | await db | |
| 2015 | .update(workflowJobs) | |
| 2016 | .set({ status: "cancelled", conclusion: "cancelled", finishedAt: now }) | |
| 2017 | .where(eq(workflowJobs.runId, run.id)); | |
| 2018 | return { cancelled: true }; | |
| 2019 | }, | |
| 2020 | }; | |
| 2021 | ||
| 2022 | const getPreviewUrl: McpToolHandler = { | |
| 2023 | tool: { | |
| 2024 | name: "gluecron_get_preview_url", | |
| 2025 | description: | |
| 2026 | "Return the branch-preview URL + status for a (repo, branch) pair. Wraps branch-previews.getPreviewForBranch.", | |
| 2027 | inputSchema: { | |
| 2028 | type: "object", | |
| 2029 | properties: { | |
| 2030 | owner: { type: "string" }, | |
| 2031 | repo: { type: "string" }, | |
| 2032 | branch: { type: "string" }, | |
| 2033 | }, | |
| 2034 | required: ["owner", "repo", "branch"], | |
| 2035 | }, | |
| 2036 | }, | |
| 2037 | async run(args, ctx) { | |
| 2038 | const owner = mcpArgString(args, "owner"); | |
| 2039 | const repo = mcpArgString(args, "repo"); | |
| 2040 | const branch = mcpArgString(args, "branch"); | |
| 2041 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 2042 | const { getPreviewForBranch, previewStatusLabel, formatExpiresIn, buildPreviewUrl } = | |
| 2043 | await import("./branch-previews"); | |
| 2044 | const row = await getPreviewForBranch(info.repoId, branch); | |
| 2045 | if (!row) { | |
| 2046 | return { | |
| 2047 | exists: false, | |
| 2048 | url: buildPreviewUrl(owner, repo, branch), | |
| 2049 | status: "missing", | |
| 2050 | }; | |
| 2051 | } | |
| 2052 | return { | |
| 2053 | exists: true, | |
| 2054 | url: row.previewUrl, | |
| 2055 | status: row.status, | |
| 2056 | statusLabel: previewStatusLabel(row.status), | |
| 2057 | expiresAt: row.expiresAt, | |
| 2058 | expiresIn: formatExpiresIn(row.expiresAt), | |
| 2059 | }; | |
| 2060 | }, | |
| 2061 | }; | |
| 2062 | ||
| 2063 | const provisionPrSandbox: McpToolHandler = { | |
| 2064 | tool: { | |
| 2065 | name: "gluecron_provision_pr_sandbox", | |
| 2066 | description: | |
| 2067 | "Provision (or re-provision) a sandbox for a PR. Wraps pr-sandbox.provisionSandbox. Requires 'repo' scope.", | |
| 2068 | inputSchema: { | |
| 2069 | type: "object", | |
| 2070 | properties: { | |
| 2071 | owner: { type: "string" }, | |
| 2072 | repo: { type: "string" }, | |
| 2073 | number: { type: "number" }, | |
| 2074 | }, | |
| 2075 | required: ["owner", "repo", "number"], | |
| 2076 | }, | |
| 2077 | }, | |
| 2078 | async run(args, ctx) { | |
| 2079 | const owner = mcpArgString(args, "owner"); | |
| 2080 | const repo = mcpArgString(args, "repo"); | |
| 2081 | const number = mcpArgNumber(args, "number"); | |
| 2082 | requireScope(ctx, "repo", "gluecron_provision_pr_sandbox"); | |
| 2083 | const gate = await mcpGateWriteAccess({ owner, repo }, ctx, "gluecron_provision_pr_sandbox"); | |
| 2084 | const pr = await mcpLoadPrByNumber(gate.repoId, number); | |
| 2085 | if (!pr) throw new McpError(ERR_METHOD_NOT_FOUND, "pr not found"); | |
| 2086 | const { provisionSandbox } = await import("./pr-sandbox"); | |
| 2087 | const row = await provisionSandbox({ prId: pr.id }); | |
| 2088 | if (!row) throw new McpError(ERR_INVALID_PARAMS, "provision failed"); | |
| 2089 | return row; | |
| 2090 | }, | |
| 2091 | }; | |
| 2092 | ||
| 2093 | // --------------------------------------------------------------------------- | |
| 2094 | // AGENTS | |
| 2095 | // --------------------------------------------------------------------------- | |
| 2096 | ||
| 2097 | const createAgentSession: McpToolHandler = { | |
| 2098 | tool: { | |
| 2099 | name: "gluecron_create_agent_session", | |
| 2100 | description: | |
| 2101 | "Mint a new agent-multiplayer session. Returns the plaintext `token` exactly once (store it). Requires 'admin' scope.", | |
| 2102 | inputSchema: { | |
| 2103 | type: "object", | |
| 2104 | properties: { | |
| 2105 | name: { type: "string", description: "Human-readable session name (unique per owner)" }, | |
| 2106 | repository_id: { type: "string", description: "Optional repo to scope to" }, | |
| 2107 | branch_namespace: { type: "string", description: "Optional branch namespace override" }, | |
| 2108 | budget_cents_per_day: { type: "number", description: "Daily budget cap in cents (default 500)" }, | |
| 2109 | }, | |
| 2110 | required: ["name"], | |
| 2111 | }, | |
| 2112 | }, | |
| 2113 | async run(args, ctx) { | |
| 2114 | const userId = mcpRequireAuthedCtx(ctx, "gluecron_create_agent_session"); | |
| 2115 | requireScope(ctx, "admin", "gluecron_create_agent_session"); | |
| 2116 | const name = mcpArgString(args, "name"); | |
| 2117 | const { createAgentSession } = await import("./agent-multiplayer"); | |
| 2118 | const res = await createAgentSession({ | |
| 2119 | ownerUserId: userId, | |
| 2120 | name, | |
| 2121 | repositoryId: | |
| 2122 | typeof args.repository_id === "string" ? args.repository_id : null, | |
| 2123 | branchNamespace: | |
| 2124 | typeof args.branch_namespace === "string" ? args.branch_namespace : undefined, | |
| 2125 | budgetCentsPerDay: | |
| 2126 | typeof args.budget_cents_per_day === "number" | |
| 2127 | ? args.budget_cents_per_day | |
| 2128 | : undefined, | |
| 2129 | }); | |
| 2130 | if (!res) throw new McpError(ERR_INVALID_PARAMS, "session creation failed"); | |
| 2131 | return { | |
| 2132 | session: { id: res.session.id, name: res.session.name }, | |
| 2133 | token: res.token, | |
| 2134 | warning: "Token is shown exactly once — store it now.", | |
| 2135 | }; | |
| 2136 | }, | |
| 2137 | }; | |
| 2138 | ||
| 2139 | const acquireLease: McpToolHandler = { | |
| 2140 | tool: { | |
| 2141 | name: "gluecron_acquire_lease", | |
| 2142 | description: | |
| 2143 | "Grab an exclusive lease on a target (e.g. a PR or branch) for an agent session. Returns null when another agent holds an active lease.", | |
| 2144 | inputSchema: { | |
| 2145 | type: "object", | |
| 2146 | properties: { | |
| 2147 | agent_session_id: { type: "string" }, | |
| 2148 | target_type: { type: "string", description: "e.g. 'pull_request', 'branch'" }, | |
| 2149 | target_id: { type: "string" }, | |
| 2150 | duration_ms: { type: "number", description: "Lease duration (default 5 minutes)" }, | |
| 2151 | }, | |
| 2152 | required: ["agent_session_id", "target_type", "target_id"], | |
| 2153 | }, | |
| 2154 | }, | |
| 2155 | async run(args, ctx) { | |
| 2156 | mcpRequireAuthedCtx(ctx, "gluecron_acquire_lease"); | |
| 2157 | requireScope(ctx, "repo", "gluecron_acquire_lease"); | |
| 2158 | const sessionId = mcpArgString(args, "agent_session_id"); | |
| 2159 | const targetType = mcpArgString(args, "target_type"); | |
| 2160 | const targetId = mcpArgString(args, "target_id"); | |
| 2161 | const durationMs = | |
| 2162 | typeof args.duration_ms === "number" ? args.duration_ms : undefined; | |
| 2163 | const { acquireLease } = await import("./agent-multiplayer"); | |
| 2164 | const lease = await acquireLease(sessionId, targetType, targetId, durationMs); | |
| 2165 | return { lease }; | |
| 2166 | }, | |
| 2167 | }; | |
| 2168 | ||
| 2169 | const releaseLeaseTool: McpToolHandler = { | |
| 2170 | tool: { | |
| 2171 | name: "gluecron_release_lease", | |
| 2172 | description: "Release a lease by id. Idempotent. Returns {released}.", | |
| 2173 | inputSchema: { | |
| 2174 | type: "object", | |
| 2175 | properties: { lease_id: { type: "string" } }, | |
| 2176 | required: ["lease_id"], | |
| 2177 | }, | |
| 2178 | }, | |
| 2179 | async run(args, ctx) { | |
| 2180 | mcpRequireAuthedCtx(ctx, "gluecron_release_lease"); | |
| 2181 | requireScope(ctx, "repo", "gluecron_release_lease"); | |
| 2182 | const leaseId = mcpArgString(args, "lease_id"); | |
| 2183 | const { releaseLease } = await import("./agent-multiplayer"); | |
| 2184 | return { released: await releaseLease(leaseId) }; | |
| 2185 | }, | |
| 2186 | }; | |
| 2187 | ||
| 2188 | const getAgentBudget: McpToolHandler = { | |
| 2189 | tool: { | |
| 2190 | name: "gluecron_get_agent_budget", | |
| 2191 | description: "Return spent / cap / remaining cents for an agent session.", | |
| 2192 | inputSchema: { | |
| 2193 | type: "object", | |
| 2194 | properties: { agent_session_id: { type: "string" } }, | |
| 2195 | required: ["agent_session_id"], | |
| 2196 | }, | |
| 2197 | }, | |
| 2198 | async run(args, ctx) { | |
| 2199 | mcpRequireAuthedCtx(ctx, "gluecron_get_agent_budget"); | |
| 2200 | const sessionId = mcpArgString(args, "agent_session_id"); | |
| 2201 | const { getAgentUsage } = await import("./agent-multiplayer"); | |
| 2202 | return await getAgentUsage(sessionId); | |
| 2203 | }, | |
| 2204 | }; | |
| 2205 | ||
| 2206 | // --------------------------------------------------------------------------- | |
| 2207 | // SEMANTIC | |
| 2208 | // --------------------------------------------------------------------------- | |
| 2209 | ||
| 2210 | const semanticSearch: McpToolHandler = { | |
| 2211 | tool: { | |
| 2212 | name: "gluecron_semantic_search", | |
| 2213 | description: | |
| 2214 | "Query the per-repo vector index (Voyage embeddings when configured, hash fallback otherwise). Wraps src/lib/semantic-search.searchRepository.", | |
| 2215 | inputSchema: { | |
| 2216 | type: "object", | |
| 2217 | properties: { | |
| 2218 | owner: { type: "string" }, | |
| 2219 | repo: { type: "string" }, | |
| 2220 | query: { type: "string" }, | |
| 2221 | limit: { type: "number" }, | |
| 2222 | }, | |
| 2223 | required: ["owner", "repo", "query"], | |
| 2224 | }, | |
| 2225 | }, | |
| 2226 | async run(args, ctx) { | |
| 2227 | const owner = mcpArgString(args, "owner"); | |
| 2228 | const repo = mcpArgString(args, "repo"); | |
| 2229 | const query = mcpArgString(args, "query"); | |
| 2230 | const limit = mcpArgNumber(args, "limit", 20); | |
| 2231 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 2232 | const { searchRepository } = await import("./semantic-search"); | |
| 2233 | const hits = await searchRepository({ | |
| 2234 | repositoryId: info.repoId, | |
| 2235 | query, | |
| 2236 | limit, | |
| 2237 | }); | |
| 2238 | return { hits }; | |
| 2239 | }, | |
| 2240 | }; | |
| 2241 | ||
| 2242 | const findSymbol: McpToolHandler = { | |
| 2243 | tool: { | |
| 2244 | name: "gluecron_find_symbol", | |
| 2245 | description: | |
| 2246 | "Find definitions of a symbol by name within a repo. Wraps src/lib/symbols.findDefinitions.", | |
| 2247 | inputSchema: { | |
| 2248 | type: "object", | |
| 2249 | properties: { | |
| 2250 | owner: { type: "string" }, | |
| 2251 | repo: { type: "string" }, | |
| 2252 | name: { type: "string", description: "Symbol name" }, | |
| 2253 | }, | |
| 2254 | required: ["owner", "repo", "name"], | |
| 2255 | }, | |
| 2256 | }, | |
| 2257 | async run(args, ctx) { | |
| 2258 | const owner = mcpArgString(args, "owner"); | |
| 2259 | const repo = mcpArgString(args, "repo"); | |
| 2260 | const name = mcpArgString(args, "name"); | |
| 2261 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 2262 | const { findDefinitions } = await import("./symbols"); | |
| 2263 | const defs = await findDefinitions(info.repoId, name); | |
| 2264 | return { definitions: defs }; | |
| 2265 | }, | |
| 2266 | }; | |
| 2267 | ||
| 2268 | // --------------------------------------------------------------------------- | |
| 2269 | // INSIGHTS | |
| 2270 | // --------------------------------------------------------------------------- | |
| 2271 | ||
| 2272 | const prStatusSummary: McpToolHandler = { | |
| 2273 | tool: { | |
| 2274 | name: "gluecron_pr_status_summary", | |
| 2275 | description: | |
| 2276 | "Compute a one-shot status summary for a PR: state, risk score, AI-review verdicts (trio), gate signals. Read-only.", | |
| 2277 | inputSchema: { | |
| 2278 | type: "object", | |
| 2279 | properties: { | |
| 2280 | owner: { type: "string" }, | |
| 2281 | repo: { type: "string" }, | |
| 2282 | number: { type: "number" }, | |
| 2283 | }, | |
| 2284 | required: ["owner", "repo", "number"], | |
| 2285 | }, | |
| 2286 | }, | |
| 2287 | async run(args, ctx) { | |
| 2288 | const owner = mcpArgString(args, "owner"); | |
| 2289 | const repo = mcpArgString(args, "repo"); | |
| 2290 | const number = mcpArgNumber(args, "number"); | |
| 2291 | const info = await mcpResolveAccessibleRepo(owner, repo, ctx.userId); | |
| 2292 | const pr = await mcpLoadPrByNumber(info.repoId, number); | |
| 2293 | if (!pr) throw new McpError(ERR_METHOD_NOT_FOUND, "pr not found"); | |
| 2294 | const { getLatestCachedPrRisk } = await import("./pr-risk"); | |
| 2295 | const risk = await getLatestCachedPrRisk(pr.id); | |
| 2296 | const aiComments = await db | |
| 2297 | .select({ body: prComments.body, isAiReview: prComments.isAiReview }) | |
| 2298 | .from(prComments) | |
| 2299 | .where(and(eq(prComments.pullRequestId, pr.id), eq(prComments.isAiReview, true))); | |
| 2300 | const { TRIO_SUMMARY_MARKER } = await import("./ai-review-trio"); | |
| 2301 | const trioSummary = aiComments.find((c) => c.body.includes(TRIO_SUMMARY_MARKER)); | |
| 2302 | return { | |
| 2303 | number: pr.number, | |
| 2304 | title: pr.title, | |
| 2305 | state: pr.state, | |
| 2306 | isDraft: pr.isDraft, | |
| 2307 | baseBranch: pr.baseBranch, | |
| 2308 | headBranch: pr.headBranch, | |
| 2309 | risk: risk | |
| 2310 | ? { score: risk.score, band: risk.band, summary: risk.aiSummary } | |
| 2311 | : null, | |
| 2312 | aiReviewCount: aiComments.length, | |
| 2313 | trioSummary: trioSummary ? trioSummary.body : null, | |
| 2314 | url: mcpPrUrl(owner, repo, pr.number), | |
| 2315 | }; | |
| 2316 | }, | |
| 2317 | }; | |
| 2318 | ||
| 2319 | const aiCostSummary: McpToolHandler = { | |
| 2320 | tool: { | |
| 2321 | name: "gluecron_ai_cost_summary", | |
| 2322 | description: | |
| 2323 | "Return AI spend rollups. Scope by one of: user_id (self), repo {owner,repo}, or agent_session_id. Defaults to caller's user spend.", | |
| 2324 | inputSchema: { | |
| 2325 | type: "object", | |
| 2326 | properties: { | |
| 2327 | scope: { type: "string", description: "'user' (default) | 'repo' | 'agent'" }, | |
| 2328 | owner: { type: "string" }, | |
| 2329 | repo: { type: "string" }, | |
| 2330 | agent_session_id: { type: "string" }, | |
| 2331 | }, | |
| 2332 | }, | |
| 2333 | }, | |
| 2334 | async run(args, ctx) { | |
| 2335 | const scope = mcpArgString(args, "scope", "user"); | |
| 2336 | const userId = mcpRequireAuthedCtx(ctx, "gluecron_ai_cost_summary"); | |
| 2337 | const { | |
| 2338 | summarizeCostsForUser, | |
| 2339 | summarizeCostsForRepo, | |
| 2340 | summarizeCostsForAgent, | |
| 2341 | } = await import("./ai-cost-tracker"); | |
| 2342 | if (scope === "repo") { | |
| 2343 | const owner = mcpArgString(args, "owner"); | |
| 2344 | const repo = mcpArgString(args, "repo"); | |
| 2345 | const info = await mcpResolveAccessibleRepo(owner, repo, userId); | |
| 2346 | return await summarizeCostsForRepo(info.repoId); | |
| 2347 | } | |
| 2348 | if (scope === "agent") { | |
| 2349 | const sessionId = mcpArgString(args, "agent_session_id"); | |
| 2350 | return await summarizeCostsForAgent(sessionId); | |
| 2351 | } | |
| 2352 | return await summarizeCostsForUser(userId); | |
| 2353 | }, | |
| 2354 | }; | |
| 2355 | ||
| 2356 | // --------------------------------------------------------------------------- | |
| 2357 | // Registry | |
| 2358 | // --------------------------------------------------------------------------- | |
| 2359 | ||
| 2360 | /** | |
| 2361 | * Every expanded tool keyed by name. Merged into `defaultTools()` in | |
| 2362 | * `mcp-tools.ts` so the MCP HTTP route advertises all of them. | |
| 2363 | */ | |
| 2364 | export function expandedTools(): Record<string, McpToolHandler> { | |
| 2365 | return { | |
| 2366 | [forkRepo.tool.name]: forkRepo, | |
| 2367 | [deleteRepo.tool.name]: deleteRepo, | |
| 2368 | [updateRepo.tool.name]: updateRepo, | |
| 2369 | [searchRepos.tool.name]: searchRepos, | |
| 2370 | [cloneUrl.tool.name]: cloneUrl, | |
| 2371 | ||
| 2372 | [labelIssue.tool.name]: labelIssue, | |
| 2373 | [unlabelIssue.tool.name]: unlabelIssue, | |
| 2374 | [assignIssue.tool.name]: assignIssue, | |
| 2375 | [searchIssues.tool.name]: searchIssues, | |
| 2376 | ||
| 2377 | [requestChanges.tool.name]: requestChanges, | |
| 2378 | [searchPrs.tool.name]: searchPrs, | |
| 2379 | [openDraftPr.tool.name]: openDraftPr, | |
| 2380 | [generatePrDescription.tool.name]: generatePrDescription, | |
| 2381 | ||
| 2382 | [readFile.tool.name]: readFile, | |
| 2383 | [writeFile.tool.name]: writeFile, | |
| 2384 | [deleteFile.tool.name]: deleteFile, | |
| 2385 | [listTree.tool.name]: listTree, | |
| 2386 | [getCommitTool.tool.name]: getCommitTool, | |
| 2387 | [createBranch.tool.name]: createBranch, | |
| 2388 | [atomicMultiFileCommit.tool.name]: atomicMultiFileCommit, | |
| 2389 | ||
| 2390 | [shipSpec.tool.name]: shipSpec, | |
| 2391 | [voiceToPr.tool.name]: voiceToPr, | |
| 2392 | [refactorAcrossRepos.tool.name]: refactorAcrossRepos, | |
| 2393 | [explainRepo.tool.name]: explainRepo, | |
| 2394 | [chatWithRepo.tool.name]: chatWithRepo, | |
| 2395 | [chatContinue.tool.name]: chatContinue, | |
| 2396 | [generateTests.tool.name]: generateTests, | |
| 2397 | [generateCommitMessageTool.tool.name]: generateCommitMessageTool, | |
| 2398 | [generateReleaseNotes.tool.name]: generateReleaseNotes, | |
| 2399 | [proposeMigration.tool.name]: proposeMigration, | |
| 2400 | [proposeDocUpdate.tool.name]: proposeDocUpdate, | |
| 2401 | ||
| 2402 | [triggerWorkflow.tool.name]: triggerWorkflow, | |
| 2403 | [getWorkflowRun.tool.name]: getWorkflowRun, | |
| 2404 | [getWorkflowLogs.tool.name]: getWorkflowLogs, | |
| 2405 | [cancelWorkflowRun.tool.name]: cancelWorkflowRun, | |
| 2406 | [getPreviewUrl.tool.name]: getPreviewUrl, | |
| 2407 | [provisionPrSandbox.tool.name]: provisionPrSandbox, | |
| 2408 | ||
| 2409 | [createAgentSession.tool.name]: createAgentSession, | |
| 2410 | [acquireLease.tool.name]: acquireLease, | |
| 2411 | [releaseLeaseTool.tool.name]: releaseLeaseTool, | |
| 2412 | [getAgentBudget.tool.name]: getAgentBudget, | |
| 2413 | ||
| 2414 | [semanticSearch.tool.name]: semanticSearch, | |
| 2415 | [findSymbol.tool.name]: findSymbol, | |
| 2416 | ||
| 2417 | [prStatusSummary.tool.name]: prStatusSummary, | |
| 2418 | [aiCostSummary.tool.name]: aiCostSummary, | |
| 2419 | }; | |
| 2420 | } | |
| 2421 | ||
| 2422 | /** Test-only export of the per-tool handlers. */ | |
| 2423 | export const __expandedTest = { | |
| 2424 | forkRepo, | |
| 2425 | deleteRepo, | |
| 2426 | updateRepo, | |
| 2427 | searchRepos, | |
| 2428 | cloneUrl, | |
| 2429 | labelIssue, | |
| 2430 | unlabelIssue, | |
| 2431 | assignIssue, | |
| 2432 | searchIssues, | |
| 2433 | requestChanges, | |
| 2434 | searchPrs, | |
| 2435 | openDraftPr, | |
| 2436 | generatePrDescription, | |
| 2437 | readFile, | |
| 2438 | writeFile, | |
| 2439 | deleteFile, | |
| 2440 | listTree, | |
| 2441 | getCommitTool, | |
| 2442 | createBranch, | |
| 2443 | atomicMultiFileCommit, | |
| 2444 | shipSpec, | |
| 2445 | voiceToPr, | |
| 2446 | refactorAcrossRepos, | |
| 2447 | explainRepo, | |
| 2448 | chatWithRepo, | |
| 2449 | chatContinue, | |
| 2450 | generateTests, | |
| 2451 | generateCommitMessageTool, | |
| 2452 | generateReleaseNotes, | |
| 2453 | proposeMigration, | |
| 2454 | proposeDocUpdate, | |
| 2455 | triggerWorkflow, | |
| 2456 | getWorkflowRun, | |
| 2457 | getWorkflowLogs, | |
| 2458 | cancelWorkflowRun, | |
| 2459 | getPreviewUrl, | |
| 2460 | provisionPrSandbox, | |
| 2461 | createAgentSession, | |
| 2462 | acquireLease, | |
| 2463 | releaseLeaseTool, | |
| 2464 | getAgentBudget, | |
| 2465 | semanticSearch, | |
| 2466 | findSymbol, | |
| 2467 | prStatusSummary, | |
| 2468 | aiCostSummary, | |
| 2469 | requireScope, | |
| 2470 | }; |