feat(tools): create_issue — file structured Gitea bug reports #53

Merged
agent-reviewer merged 1 commits from feat/create-issue-tool into main 2026-06-10 09:43:09 +00:00
4 changed files with 527 additions and 3 deletions
+4 -2
View File
@@ -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);
+179
View File
@@ -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");
});
});
+15 -1
View File
@@ -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 });
}
}
+329
View File
@@ -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<string>(["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<string, string> | 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<T = unknown>(
method: string,
path: string,
body?: unknown,
): Promise<T | ApiError> {
const headers = giteaHeaders();
if (isApiError(headers)) return headers;
const base = giteaApiUrl();
try {
const res = await fetch(`${base}${path}`, {
method,
headers: headers as Record<string, string>,
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<GiteaLabel[]>("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,
);
}