diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index b2ed924..f3d81b7 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -1157,12 +1157,14 @@ describe("createServer()", () => { // and each tool() call is recorded by the mocked McpServer above. If a // future PR adds a tool file but forgets to call its registerXxxTools // from createServer(), this count drops and the test fails. We assert - // the concrete current tool count (88) rather than a lower bound so a + // the concrete current tool count (89) rather than a lower bound so a // silently-dropped handler is also caught. test("registers all tools (count is stable across registerXxxTools wiring)", () => { const server = createServer() as unknown as { registeredToolNames: string[] }; const names = server.registeredToolNames; - expect(names.length).toBe(88); + expect(names.length).toBe(89); + // create_issue (Gitea bug-filing) must be wired into the default surface. + expect(names).toContain("create_issue"); // Names must be unique — a duplicate registration would indicate a // copy-paste mistake in one of the registerXxxTools() calls. expect(new Set(names).size).toBe(names.length); diff --git a/src/__tests__/issues.test.ts b/src/__tests__/issues.test.ts new file mode 100644 index 0000000..e7f8803 --- /dev/null +++ b/src/__tests__/issues.test.ts @@ -0,0 +1,179 @@ +/** + * Unit tests for create_issue (src/tools/issues.ts). + * + * Pure rendering (buildIssueBody / deriveLabelNames) is tested directly; the + * handler is tested with a mocked global.fetch — no real Gitea calls. Mirrors + * the fetch-mock convention in index.test.ts. + */ + +import { + buildIssueBody, + deriveLabelNames, + handleCreateIssue, +} from "../tools/issues.js"; + +function mockFetchSequence( + responses: Array<{ ok?: boolean; status?: number; body: unknown }>, +) { + const fn = jest.fn(); + for (const r of responses) { + fn.mockResolvedValueOnce({ + ok: r.ok ?? true, + status: r.status ?? 200, + text: jest + .fn() + .mockResolvedValue(typeof r.body === "string" ? r.body : JSON.stringify(r.body)), + }); + } + return fn; +} + +function textOf(result: { content: { type: string; text: string }[] }) { + return JSON.parse(result.content[0].text); +} + +const ORIGINAL_ENV = process.env; +beforeEach(() => { + process.env = { + ...ORIGINAL_ENV, + GITEA_ISSUE_TOKEN: "tok", + GITEA_ISSUE_REPO: "molecule-ai/triage", + GITEA_API_URL: "https://git.example/api/v1", + }; +}); +afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); +}); + +describe("buildIssueBody", () => { + it("renders a context table, the free-text sections, redaction note + provenance", () => { + const body = buildIssueBody({ + title: "t", + description: "boom", + severity: "high", + external: true, + org_id: "org_1", + workspace_id: "ws_1", + component: "runtime", + environment: "prod", + reproduction: "do x", + related_ids: ["#12", "run_9"], + logs_excerpt: "panic: nil", + }); + expect(body).toContain("| Severity | high |"); + expect(body).toContain("| Tenancy | external (customer-facing) |"); + expect(body).toContain("| Component | runtime |"); + expect(body).toContain("## Description"); + expect(body).toContain("## Reproduction"); + expect(body).toContain("- #12"); + expect(body).toContain("Redact secrets"); + expect(body).toContain("Filed via"); + }); + + it("omits the table and optional sections when no structured fields are given", () => { + const body = buildIssueBody({ title: "t", description: "only desc" }); + expect(body).not.toContain("| Field | Value |"); + expect(body).not.toContain("## Reproduction"); + expect(body).not.toContain("## Related"); + expect(body).toContain("## Description"); + }); + + it("labels tenancy internal when external=false", () => { + expect(buildIssueBody({ title: "t", description: "d", external: false })).toContain( + "| Tenancy | internal |", + ); + }); +}); + +describe("deriveLabelNames", () => { + it("derives the taxonomy labels and dedups caller extras", () => { + const ls = deriveLabelNames({ + title: "t", + description: "d", + severity: "critical", + external: true, + component: "cp", + environment: "prod", + labels: ["foo", "source/mcp-filed"], + }); + expect(ls).toEqual( + expect.arrayContaining([ + "source/mcp-filed", + "severity/critical", + "tenancy/external", + "component/cp", + "env/prod", + "foo", + ]), + ); + expect(ls.filter((l) => l === "source/mcp-filed").length).toBe(1); + }); +}); + +describe("handleCreateIssue", () => { + it("returns AUTH_ERROR when no Gitea token is set (no fetch)", async () => { + delete process.env.GITEA_ISSUE_TOKEN; + delete process.env.GITEA_TOKEN; + const r = textOf(await handleCreateIssue({ title: "t", description: "d" })); + expect(r.error).toBe("AUTH_ERROR"); + }); + + it("rejects a malformed repo", async () => { + const r = textOf( + await handleCreateIssue({ title: "t", description: "d", repo: "bad repo" }), + ); + expect(r.error).toBe("VALIDATION_ERROR"); + }); + + it("requires a target repo when GITEA_ISSUE_REPO is unset", async () => { + delete process.env.GITEA_ISSUE_REPO; + const r = textOf(await handleCreateIssue({ title: "t", description: "d" })); + expect(r.error).toBe("CONFIG_ERROR"); + }); + + it("resolves label ids and POSTs the issue to the right repo", async () => { + global.fetch = mockFetchSequence([ + { body: [{ id: 5, name: "severity/high" }, { id: 7, name: "source/mcp-filed" }] }, + { + body: { + number: 42, + html_url: "https://git.example/molecule-ai/triage/issues/42", + title: "t", + }, + }, + ]) as unknown as typeof fetch; + + const r = textOf(await handleCreateIssue({ title: "t", description: "d", severity: "high" })); + expect(r.ok).toBe(true); + expect(r.number).toBe(42); + expect(r.labels_applied).toEqual( + expect.arrayContaining(["severity/high", "source/mcp-filed"]), + ); + + const postCall = (global.fetch as jest.Mock).mock.calls[1]; + expect(postCall[0]).toContain("/repos/molecule-ai/triage/issues"); + const sentBody = JSON.parse(postCall[1].body); + expect(sentBody.labels).toEqual(expect.arrayContaining([5, 7])); + expect(sentBody.title).toBe("t"); + expect(sentBody.body).toContain("## Description"); + }); + + it("reports unmatched labels rather than silently dropping them", async () => { + global.fetch = mockFetchSequence([ + { body: [{ id: 7, name: "source/mcp-filed" }] }, + { body: { number: 1, html_url: "u", title: "t" } }, + ]) as unknown as typeof fetch; + const r = textOf(await handleCreateIssue({ title: "t", description: "d", severity: "low" })); + expect(r.labels_unmatched).toContain("severity/low"); + }); + + it("surfaces a Gitea POST error verbatim", async () => { + global.fetch = mockFetchSequence([ + { body: [] }, + { ok: false, status: 403, body: "forbidden" }, + ]) as unknown as typeof fetch; + const r = textOf(await handleCreateIssue({ title: "t", description: "d" })); + expect(r.error).toBe("AUTH_ERROR"); + }); +}); diff --git a/src/index.ts b/src/index.ts index 2dfe610..83cc645 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { registerScheduleTools } from "./tools/schedules.js"; import { registerApprovalTools } from "./tools/approvals.js"; import { registerDiscoveryTools } from "./tools/discovery.js"; import { registerRemoteAgentTools } from "./tools/remote_agents.js"; +import { registerIssueTools } from "./tools/issues.js"; import { registerManagementTools } from "./tools/management/index.js"; // Re-exports so existing importers (tests, SDK consumers) keep working. @@ -222,6 +223,14 @@ export { handleGetOrgPluginAllowlist, handleSetOrgPluginAllowlist, } from "./tools/management/index.js"; +export { + registerIssueTools, + handleCreateIssue, + buildIssueBody, + deriveLabelNames, + giteaApiUrl, + defaultIssueRepo, +} from "./tools/issues.js"; export { mgmtCall, mgmtGet, managementUrl } from "./tools/management/client.js"; export { registerCpAdminTools, handleListOrgs, handleGetOrg, cpUrl, cpConfigured } from "./tools/management/cp_admin.js"; @@ -251,6 +260,10 @@ export function createServer() { // (list_orgs/get_org) are registered by registerManagementTools via the // separate cp_admin module and gated on CP_ADMIN_API_TOKEN. registerManagementTools(srv); + // Issue filing is useful from BOTH surfaces (an operator on the management + // host and an agent on the workspace surface both observe bugs worth + // tracking). The tool name is unique, so it is safe in both registries. + registerIssueTools(srv); return srv; } @@ -266,6 +279,7 @@ export function createServer() { registerApprovalTools(srv); registerDiscoveryTools(srv); registerRemoteAgentTools(srv); + registerIssueTools(srv); return srv; } @@ -333,7 +347,7 @@ async function main() { mode: "management", }); } else { - logInfo("Molecule AI MCP server running on stdio (88 tools available)", { transport: "stdio", toolCount: 88 }); + logInfo("Molecule AI MCP server running on stdio (89 tools available)", { transport: "stdio", toolCount: 89 }); } } diff --git a/src/tools/issues.ts b/src/tools/issues.ts new file mode 100644 index 0000000..70fae56 --- /dev/null +++ b/src/tools/issues.ts @@ -0,0 +1,329 @@ +/** + * Issue-filing tool — `create_issue`. + * + * Lets a platform operator or agent file a STRUCTURED bug report into Gitea so + * the maintenance / dev team has an actionable, uniformly-shaped ticket instead + * of a free-text Slack/chat message that gets lost. The whole point is that the + * caller supplies the context it already holds — which org, which workspace / + * agent, whether the tenant is EXTERNAL (customer-facing) or internal, severity, + * component, environment, related ids — and this tool renders it into a + * consistent issue body + Gitea labels the triage team can filter on. + * + * Gitea, NOT the control plane: bugs are tracked in Gitea (the canonical SCM, + * `git.moleculesai.app`), so this is the one tool family that talks to a + * different host with a different credential. The client below is modelled + * exactly on tools/management/client.ts::mgmtCall — never throws, returns the + * decoded body on success or a structured ApiError on failure — so the response + * envelope stays SSOT with every other tool. + * + * Auth: a dedicated issue-bot token in GITEA_ISSUE_TOKEN, scoped to + * `issue:write` on the triage repo. We deliberately do NOT reuse a + * tenant/admin credential here — filing issues is a narrow capability and + * should hold a narrow token. + */ + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { toMcpResult, isApiError, type ApiError } from "../api.js"; +import { error as logError } from "../utils/logger.js"; + +// --------------------------------------------------------------------------- +// Config (resolved at CALL time, not module-load, so it can be configured / +// overridden after import — same convention as management/client.ts). +// --------------------------------------------------------------------------- + +/** Gitea REST base, e.g. https://git.moleculesai.app/api/v1 (no trailing slash). */ +export function giteaApiUrl(): string { + const raw = + process.env.GITEA_API_URL || + process.env.GITEA_URL || + "https://git.moleculesai.app/api/v1"; + return raw.replace(/\/+$/, ""); +} + +/** + * The default `owner/name` repo new issues land in when the caller doesn't + * pass `repo`. A single triage repo keeps reports in one queue the + * maintenance team owns; callers can still target a specific product repo. + */ +export function defaultIssueRepo(): string | undefined { + return process.env.GITEA_ISSUE_REPO; +} + +export const SEVERITIES = ["critical", "high", "medium", "low"] as const; +export type Severity = (typeof SEVERITIES)[number]; +export const ENVIRONMENTS = ["prod", "staging", "dev"] as const; +export type Environment = (typeof ENVIRONMENTS)[number]; + +export interface CreateIssueParams { + title: string; + description: string; + repo?: string; + severity?: Severity; + external?: boolean; + org_id?: string; + org_slug?: string; + workspace_id?: string; + agent_role?: string; + component?: string; + environment?: Environment; + related_ids?: string[]; + reproduction?: string; + logs_excerpt?: string; + labels?: string[]; +} + +// --------------------------------------------------------------------------- +// Pure rendering — kept side-effect-free so it is unit-testable without a +// network. buildIssueBody + deriveLabelNames are exported for the tests. +// --------------------------------------------------------------------------- + +function row(k: string, v: string | undefined): string | undefined { + if (v === undefined || v === "") return undefined; + return `| ${k} | ${v} |`; +} + +/** + * Render the structured fields into a Markdown issue body: a context table the + * triage team can scan at a glance, then the free-text sections. Stable shape + * so issues are uniform regardless of which agent filed them. + */ +export function buildIssueBody(p: CreateIssueParams): string { + const tenancy = + p.external === undefined ? undefined : p.external ? "external (customer-facing)" : "internal"; + const tableRows = [ + row("Severity", p.severity), + row("Tenancy", tenancy), + row("Component", p.component), + row("Environment", p.environment), + row("Org", p.org_slug ? `${p.org_slug}${p.org_id ? ` (${p.org_id})` : ""}` : p.org_id), + row("Workspace", p.workspace_id), + row("Agent role", p.agent_role), + ].filter((r): r is string => r !== undefined); + + const parts: string[] = []; + if (tableRows.length > 0) { + parts.push(["| Field | Value |", "| --- | --- |", ...tableRows].join("\n")); + } + parts.push(`## Description\n\n${p.description.trim()}`); + if (p.reproduction && p.reproduction.trim()) { + parts.push(`## Reproduction\n\n${p.reproduction.trim()}`); + } + if (p.related_ids && p.related_ids.length > 0) { + parts.push(`## Related\n\n${p.related_ids.map((id) => `- ${id}`).join("\n")}`); + } + if (p.logs_excerpt && p.logs_excerpt.trim()) { + parts.push( + `## Logs (excerpt)\n\n> Redact secrets before filing — this body is stored in Gitea.\n\n\`\`\`\n${p.logs_excerpt.trim()}\n\`\`\``, + ); + } + const actor = process.env.MOLECULE_AUDIT_ACTOR || "molecule-mcp"; + parts.push(`---\n_Filed via \`create_issue\` (molecule-mcp-server) by ${actor}._`); + return parts.join("\n\n"); +} + +/** + * Derive Gitea label NAMES from the structured fields, plus any caller-supplied + * labels. These are best-effort resolved to existing label ids at file time + * (missing labels are reported, not auto-created — label taxonomy is the dev + * team's to own). + */ +export function deriveLabelNames(p: CreateIssueParams): string[] { + const out = new Set(["source/mcp-filed"]); + if (p.severity) out.add(`severity/${p.severity}`); + if (p.external !== undefined) out.add(p.external ? "tenancy/external" : "tenancy/internal"); + if (p.component) out.add(`component/${p.component}`); + if (p.environment) out.add(`env/${p.environment}`); + for (const l of p.labels ?? []) { + const t = l.trim(); + if (t) out.add(t); + } + return [...out]; +} + +// --------------------------------------------------------------------------- +// Gitea client — never throws, returns ApiError on failure (mgmtCall shape). +// --------------------------------------------------------------------------- + +function giteaHeaders(): Record | ApiError { + const tok = process.env.GITEA_ISSUE_TOKEN || process.env.GITEA_TOKEN; + if (!tok) { + return { + error: "AUTH_ERROR", + detail: + "GITEA_ISSUE_TOKEN is not set. create_issue needs a Gitea token scoped " + + "to issue:write on the triage repo to file bug reports.", + }; + } + return { "Content-Type": "application/json", Authorization: `token ${tok}` }; +} + +async function giteaCall( + method: string, + path: string, + body?: unknown, +): Promise { + const headers = giteaHeaders(); + if (isApiError(headers)) return headers; + const base = giteaApiUrl(); + try { + const res = await fetch(`${base}${path}`, { + method, + headers: headers as Record, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text(); + if (res.status === 401 || res.status === 403) { + return { error: "AUTH_ERROR", detail: text, status: res.status }; + } + if (res.status === 404) { + return { error: "NOT_FOUND", detail: text, status: res.status }; + } + if (res.status === 429) { + return { error: "RATE_LIMITED", detail: text, status: res.status }; + } + return { error: `HTTP ${res.status}`, detail: text, status: res.status }; + } + const text = await res.text(); + if (text.length === 0) return { raw: "", status: res.status } as ApiError; + try { + return JSON.parse(text) as T; + } catch { + return { raw: text, status: res.status } as ApiError; + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError(err, `Gitea API error (${method} ${path})`, { url: base }); + return { error: `Gitea unreachable at ${base}`, detail: msg }; + } +} + +interface GiteaLabel { + id: number; + name: string; +} + +/** + * Resolve label names to ids in `repo`, best-effort. Returns the ids that + * exist and the names that didn't (so the caller can see what was dropped — + * "no silent caps"). A lookup failure degrades to "attach nothing" rather than + * failing the whole file — the body table still carries the taxonomy. + */ +async function resolveLabelIds( + repo: string, + names: string[], +): Promise<{ ids: number[]; matched: string[]; unmatched: string[] }> { + if (names.length === 0) return { ids: [], matched: [], unmatched: [] }; + const res = await giteaCall("GET", `/repos/${repo}/labels?limit=100`); + if (isApiError(res) || !Array.isArray(res)) { + return { ids: [], matched: [], unmatched: names }; + } + const byName = new Map(res.map((l) => [l.name.toLowerCase(), l.id])); + const ids: number[] = []; + const matched: string[] = []; + const unmatched: string[] = []; + for (const n of names) { + const id = byName.get(n.toLowerCase()); + if (id !== undefined) { + ids.push(id); + matched.push(n); + } else { + unmatched.push(n); + } + } + return { ids, matched, unmatched }; +} + +export async function handleCreateIssue(params: CreateIssueParams) { + const repo = (params.repo || defaultIssueRepo() || "").trim(); + if (!repo) { + return toMcpResult({ + error: "CONFIG_ERROR", + detail: + "No target repo. Pass `repo` ('owner/name') or set GITEA_ISSUE_REPO " + + "to the default triage repo.", + }); + } + if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) { + return toMcpResult({ + error: "VALIDATION_ERROR", + detail: `repo must be 'owner/name', got '${repo}'.`, + }); + } + + const labelNames = deriveLabelNames(params); + const { ids, unmatched } = await resolveLabelIds(repo, labelNames); + + const body = buildIssueBody(params); + const created = await giteaCall<{ number: number; html_url: string; title: string }>( + "POST", + `/repos/${repo}/issues`, + { title: params.title, body, labels: ids }, + ); + if (isApiError(created)) { + // Surface the structured Gitea error verbatim so the caller can act on it. + return toMcpResult(created); + } + return toMcpResult({ + ok: true, + repo, + number: created.number, + url: created.html_url, + title: created.title, + labels_applied: labelNames.filter((n) => !unmatched.includes(n)), + labels_unmatched: unmatched, + }); +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +export function registerIssueTools(srv: McpServer) { + srv.tool( + "create_issue", + "File a structured bug report as a Gitea issue for the maintenance/dev team. " + + "Supply the context you already have (org, workspace, agent, whether the " + + "tenant is external/customer-facing, severity, component, environment, " + + "related ids) — it is rendered into a uniform issue body + triage labels. " + + "Targets GITEA_ISSUE_REPO unless `repo` ('owner/name') is given. " + + "Do NOT include secrets/credentials in any field.", + { + title: z.string().describe("Short one-line summary of the bug."), + description: z.string().describe("Detailed description: what happened, expected vs actual, impact."), + repo: z + .string() + .optional() + .describe("Target repo 'owner/name'. Defaults to GITEA_ISSUE_REPO (the triage queue)."), + severity: z.enum(SEVERITIES).optional().describe("critical | high | medium | low"), + external: z + .boolean() + .optional() + .describe("true if this concerns an EXTERNAL (customer-facing) tenant; false for internal."), + org_id: z.string().optional().describe("Molecule org id the bug pertains to."), + org_slug: z.string().optional().describe("Molecule org slug (human-readable)."), + workspace_id: z.string().optional().describe("Affected workspace / agent id."), + agent_role: z.string().optional().describe("Agent role, e.g. 'kimi-coder', 'reviewer'."), + component: z + .string() + .optional() + .describe("Affected component, e.g. controlplane, runtime, mcp-server, provisioner."), + environment: z.enum(ENVIRONMENTS).optional().describe("prod | staging | dev"), + related_ids: z + .array(z.string()) + .optional() + .describe("Related ids: PR numbers, run ids, request ids, EC2 instance ids, etc."), + reproduction: z.string().optional().describe("Steps to reproduce, if known."), + logs_excerpt: z + .string() + .optional() + .describe("Short log/error excerpt. REDACT secrets — this is stored in Gitea."), + labels: z + .array(z.string()) + .optional() + .describe("Extra Gitea label names to attach (best-effort; existing labels only)."), + }, + handleCreateIssue, + ); +}