diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index de6bc1caed..a160fd4f94 100644 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -364,6 +364,10 @@ const STDIO_TOOL_DESCRIPTORS = [ name: "gittensory_get_maintainer_noise", description: "Return the maintainer queue-noise triage report for a repo: a noise score/level, the specific noise sources to clear first, and recommended maintainer actions. Maintainer-authenticated; advisory only.", }, + { + name: "gittensory_get_burden_forecast", + description: "Return the cached maintainer burden forecast for a repo, including projected review load, queue growth risk, stale PR signals, and a freshness marker.", + }, { name: "gittensory_preflight_pr", description: "Preflight planned PR metadata against lane, duplicate, linked issue, test, and queue signals.", @@ -535,6 +539,30 @@ server.registerTool( }, ); +server.registerTool( + "gittensory_get_burden_forecast", + { + description: stdioToolDescription("gittensory_get_burden_forecast"), + inputSchema: ownerRepoShape, + }, + async ({ owner, repo }) => { + // The burden forecast has no dedicated GET route; the API serves it as the + // burdenForecast slice of the repo intelligence endpoint, so proxy that and + // mirror the hosted tool's not_found contract when the slice is absent. + const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`; + const intelligence = await apiGet(`${prefix}/intelligence`); + const repoFullName = intelligence?.repoFullName ?? `${owner}/${repo}`; + if (!intelligence?.burdenForecast) { + return toolResult(`Gittensory has no cached burden forecast for ${repoFullName}.`, { status: "not_found", repoFullName }); + } + return toolResult(`Gittensory burden forecast for ${repoFullName} (cached, ${intelligence.burdenForecastFreshness?.freshness ?? "unknown"}).`, { + repoFullName, + burdenForecast: intelligence.burdenForecast, + burdenForecastFreshness: intelligence.burdenForecastFreshness ?? null, + }); + }, +); + server.registerTool( "gittensory_preflight_pr", { diff --git a/test/unit/mcp-cli-burden-forecast.test.ts b/test/unit/mcp-cli-burden-forecast.test.ts new file mode 100644 index 0000000000..626d669a29 --- /dev/null +++ b/test/unit/mcp-cli-burden-forecast.test.ts @@ -0,0 +1,86 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness"; + +const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js"); +const FORBIDDEN_PUBLIC_TERMS = /wallet\s*[:=]\s*\S+|hotkey\s*[:=]\s*\S+|coldkey\s*[:=]\s*\S+|raw trust score is|your trust score|reward estimate is|estimated reward/i; + +let client: Client; +let transport: StdioClientTransport; +let configDir: string; +let apiUrl: string; +let capturedRequests: Array<{ url: string; method: string }>; + +async function connect() { + configDir = mkdtempSync(join(tmpdir(), "gittensory-burden-forecast-")); + capturedRequests = []; + apiUrl = await startFixtureServer({ + onApiRequest: (request) => { + if (request.url && request.url.includes("/intelligence")) { + capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" }); + } + }, + }); + transport = new StdioClientTransport({ + command: "node", + args: [bin, "--stdio"], + env: { + ...process.env, + GITTENSORY_CONFIG_DIR: configDir, + GITTENSORY_API_URL: apiUrl, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_API_TIMEOUT_MS: "5000", + }, + }); + client = new Client({ name: "burden-forecast-test", version: "0.0.1" }); + await client.connect(transport); +} + +async function disconnect() { + await client.close().catch(() => undefined); + await closeFixtureServer(); + if (configDir) rmSync(configDir, { recursive: true, force: true }); +} + +describe("gittensory_get_burden_forecast stdio proxy", () => { + beforeEach(connect); + afterEach(disconnect); + + it("registers the tool in the stdio server tool list", async () => { + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toContain("gittensory_get_burden_forecast"); + }); + + it("proxies owner/repo to the intelligence endpoint via apiGet and returns the burden-forecast slice with freshness", async () => { + const result = await client.callTool({ name: "gittensory_get_burden_forecast", arguments: { owner: "acme", repo: "widgets" } }); + expect(capturedRequests.length).toBe(1); + const captured = capturedRequests[0]!; + expect(captured.url).toContain("/v1/repos/acme/widgets/intelligence"); + expect(captured.method).toBe("GET"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("burdenForecast"); + expect(text).toContain("queueGrowthRisk"); + expect(text).toContain("burdenForecastFreshness"); + expect(text).toContain("acme/widgets"); + expect(text).toContain("cached, fresh"); + }); + + it("mirrors the hosted not_found contract when the intelligence payload has no cached forecast", async () => { + const result = await client.callTool({ name: "gittensory_get_burden_forecast", arguments: { owner: "acme", repo: "quiet" } }); + expect(capturedRequests.length).toBe(1); + expect(capturedRequests[0]!.url).toContain("/v1/repos/acme/quiet/intelligence"); + expect(result.isError).toBeFalsy(); + const text = JSON.stringify(result); + expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS); + expect(text).toContain("no cached burden forecast"); + expect(text).toContain("not_found"); + expect(text).toContain("acme/quiet"); + expect(text).not.toContain("burdenForecastFreshness"); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 8155f1805d..64d601d765 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -332,6 +332,29 @@ export async function startFixtureServer( response.end(JSON.stringify({ repoFullName: "owner/repo", agentPaused: body.agentPaused === true, ...(body.autonomy ? { autonomy: body.autonomy } : {}) })); return; } + // Distinct fixture repos for the burden-forecast intelligence slice: one with a + // cached forecast and one without, so both branches of the stdio proxy are covered. + if (request.url === "/v1/repos/acme/widgets/intelligence" && request.method === "GET") { + response.end( + JSON.stringify({ + status: "ready", + source: "snapshot", + repoFullName: "acme/widgets", + generatedAt: "2026-05-30T00:00:00.000Z", + burdenForecast: { + projectedReviewLoad: { nextSevenDays: 12, trend: "rising" }, + queueGrowthRisk: "elevated", + stalePrSignals: [{ pullNumber: 41, ageDays: 9, signal: "no-review-activity" }], + }, + burdenForecastFreshness: { source: "snapshot", generatedAt: "2026-05-30T00:00:00.000Z", ageSeconds: 120, freshness: "fresh" }, + }), + ); + return; + } + if (request.url === "/v1/repos/acme/quiet/intelligence" && request.method === "GET") { + response.end(JSON.stringify({ status: "ready", source: "snapshot", repoFullName: "acme/quiet", generatedAt: "2026-05-30T00:00:00.000Z" })); + return; + } // #554 gate precision telemetry (read-only). Echoes ?windowDays so the CLI window pass-through is testable. if (request.url?.startsWith("/v1/repos/owner/repo/gate-precision") && request.method === "GET") { const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays");