Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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",
{
Expand Down
86 changes: 86 additions & 0 deletions test/unit/mcp-cli-burden-forecast.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
23 changes: 23 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down