Skip to content

Commit 5e855cb

Browse files
authored
Merge branch 'main' into release-please--branches--main--components--mcp
2 parents 8b5b541 + 46a19f8 commit 5e855cb

8 files changed

Lines changed: 341 additions & 1 deletion

File tree

packages/gittensory-mcp/bin/gittensory-mcp.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,11 @@ const STDIO_TOOL_DESCRIPTORS = [
413413
description:
414414
"Return the repo's label-policy audit (configured-vs-live labels, missing configured labels, suspicious status/source-style labels, and trusted-label-pipeline readiness) from the private Gittensory API.",
415415
},
416+
{
417+
name: "gittensory_get_burden_forecast",
418+
description:
419+
"Return the repo's cached maintainer burden forecast (projected review load, queue-growth risk, and stale-PR signals) with a freshness marker, from the private Gittensory API.",
420+
},
416421
{
417422
name: "gittensory_preview_local_pr_score",
418423
description: "Inspect local diff metadata and request a private Gittensory scoring preview. No source contents are uploaded.",
@@ -689,6 +694,24 @@ server.registerTool(
689694
},
690695
);
691696

697+
server.registerTool(
698+
"gittensory_get_burden_forecast",
699+
{
700+
description: stdioToolDescription("gittensory_get_burden_forecast"),
701+
inputSchema: ownerRepoShape,
702+
},
703+
async ({ owner, repo }) => {
704+
const prefix = `/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
705+
const intelligence = await apiGet(`${prefix}/intelligence`);
706+
return toolResult("Gittensory burden forecast.", {
707+
repoFullName: intelligence?.repoFullName ?? `${owner}/${repo}`,
708+
generatedAt: intelligence?.generatedAt,
709+
burdenForecast: intelligence?.burdenForecast ?? null,
710+
burdenForecastFreshness: intelligence?.burdenForecastFreshness ?? null,
711+
});
712+
},
713+
);
714+
692715
server.registerTool(
693716
"gittensory_preview_local_pr_score",
694717
{

src/mcp/server.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import { loadMaintainerNoiseReport, maintainerNoiseSummary } from "../services/m
9595
import { loadLabelAudit, labelAuditSummary } from "../services/label-audit";
9696
import { loadMaintainerLaneReport, maintainerLaneSummary } from "../services/maintainer-lane";
9797
import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack";
98+
import { loadGatePrecisionReport } from "../services/gate-precision";
9899
import { buildUnavailableQueueTrendReport } from "../services/queue-trends";
99100
import {
100101
applyMcpPlanningChoices,
@@ -778,6 +779,18 @@ const maintainerMeasurementReportOutputSchema = {
778779
status: z.string().optional(),
779780
};
780781

782+
// #2220 - gate-precision measurement surfaced over MCP. Mirrors the
783+
// maintainerMeasurementReportOutputSchema pattern: report fields optional, structured sub-reports as
784+
// z.unknown() (buildGatePrecisionReport is the single source of truth for their shape).
785+
const gatePrecisionOutputSchema = {
786+
repoFullName: z.string().optional(),
787+
generatedAt: z.string().optional(),
788+
windowDays: z.number().nullable().optional(),
789+
perGateType: z.array(z.unknown()).optional(),
790+
overall: z.unknown().optional(),
791+
signals: z.array(z.string()).optional(),
792+
};
793+
781794
const contributorProfileOutputSchema = {
782795
login: z.string().optional(),
783796
github: z.unknown().optional(),
@@ -1404,6 +1417,17 @@ export class GittensoryMcp {
14041417
async (input) => this.toolResult(await this.getOutcomeCalibration(input)),
14051418
);
14061419

1420+
server.registerTool(
1421+
"gittensory_get_gate_precision",
1422+
{
1423+
description:
1424+
"Return per-gate-type false-positive precision for a repo's recorded gate blocks — blocked / blocked-then-merged / overridden counts and false-positive rates with low-sample guards. Maintainer-authenticated; measurement only.",
1425+
inputSchema: ownerRepoWindowShape,
1426+
outputSchema: gatePrecisionOutputSchema,
1427+
},
1428+
async (input) => this.toolResult(await this.getGatePrecision(input)),
1429+
);
1430+
14071431
server.registerTool(
14081432
"gittensory_get_fleet_analytics",
14091433
{
@@ -2587,6 +2611,20 @@ export class GittensoryMcp {
25872611
};
25882612
}
25892613

2614+
// #2220 - surface the existing gate-precision measurement over MCP. Same per-repo read gate as
2615+
// getOutcomeCalibration (requireRepoAccess); loadGatePrecisionReport is measurement-only and already
2616+
// scoped to the single repo, so nothing cross-repo is revealed. The options object is spread-omitted
2617+
// when windowDays is absent to satisfy exactOptionalPropertyTypes.
2618+
private async getGatePrecision(input: { owner: string; repo: string; windowDays?: number | undefined }): Promise<ToolPayload> {
2619+
const fullName = `${input.owner}/${input.repo}`;
2620+
await this.requireRepoAccess(fullName);
2621+
const report = await loadGatePrecisionReport(this.env, fullName, input.windowDays === undefined ? {} : { windowDays: input.windowDays });
2622+
return {
2623+
summary: `Gittensory gate precision for ${fullName}: ${report.overall.blocked} gate blocks, overall false-positive rate ${report.overall.falsePositiveRate ?? "n/a (below sample threshold)"}.`,
2624+
data: report as unknown as Record<string, unknown>,
2625+
};
2626+
}
2627+
25902628
// #2224 - surface the deterministic open-PR pressure simulator over MCP. Pure and read-only: the caller
25912629
// supplies all queue/role context, so nothing beyond a computation on that input is revealed and no repo
25922630
// access is required (mirrors gittensory_run_local_scorer). Output is already public-safe - every scenario

src/services/notify-discord.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { recordAuditEvent } from "../db/repositories";
22
import { errorMessage } from "../utils/json";
3+
import type { RecapReport } from "../types";
34

45
// Per-repo Discord notifications (reviewbot parity). Each repo notifies its OWN channel on a terminal action —
56
// merged / closed / changes-requested(manual) — so the operator sees what the bot did, like the old Reviewbott
@@ -149,6 +150,56 @@ export async function notifyActionToDiscord(
149150
}
150151
}
151152

153+
/**
154+
* Deliver a maintainer recap digest (#2245, the Discord channel of #1963) as an embed. Unlike the per-repo
155+
* `ReviewRecap` sender {@link sendReviewRecapToDiscord} (review-recap.ts) — which resolves a per-repo channel via
156+
* {@link resolveDiscordWebhook}, exactly like {@link notifyActionToDiscord} — a maintainer `RecapReport` is ONE
157+
* operator-level digest spanning many repos (`report.repos`), so there is no single repo to route by: it posts to
158+
* the flat global `DISCORD_WEBHOOK_URL`. Best-effort and observable, mirroring `sendReviewRecapToDiscord`: an
159+
* unset/invalid webhook or a send failure is recorded to the audit ledger (`maintainer_recap_notification.discord`)
160+
* and returned as `{ sent: false, reason }` but never thrown, so a Discord outage never breaks the recap job. The
161+
* `RecapReport` is already public-safe (buildMaintainerRecap sanitizes every free-text field), so no re-scrub here.
162+
*/
163+
export async function deliverRecapToDiscord(env: Env, report: RecapReport): Promise<{ sent: boolean; reason?: string }> {
164+
const targetKey = `maintainer-recap:${report.windowDays}d`;
165+
const auditMeta = { windowDays: report.windowDays, repoCount: report.repos.length };
166+
const url = envString(env, "DISCORD_WEBHOOK_URL");
167+
if (!url || !isValidDiscordWebhook(url)) {
168+
const reason = url ? "invalid_global_webhook" : "missing_global_webhook";
169+
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "denied", detail: reason, metadata: auditMeta });
170+
return { sent: false, reason };
171+
}
172+
const body = {
173+
username: "Gittensory",
174+
embeds: [
175+
{
176+
title: `Maintainer recap · ${report.repos.length} repo(s) · ${report.windowDays}d`,
177+
description: report.summary.join("\n").slice(0, 1800),
178+
color: 0x0969da,
179+
fields: [
180+
{ name: "Reviewed", value: `${report.totals.reviewed}`, inline: true },
181+
{ name: "Merged", value: `${report.totals.merged}`, inline: true },
182+
{ name: "Closed", value: `${report.totals.closed}`, inline: true },
183+
{ name: "Gate false positives", value: `${report.totals.gateFalsePositives}/${report.totals.blocked}`, inline: true },
184+
{ name: "Overrides", value: `${report.totals.gateOverrides}`, inline: true },
185+
{ name: "Reversals", value: `${report.totals.reversals}`, inline: true },
186+
],
187+
footer: { text: `Gittensory · generated ${report.generatedAt}` },
188+
},
189+
],
190+
};
191+
try {
192+
await postWebhook(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }, "discord");
193+
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "completed", detail: "sent", metadata: auditMeta });
194+
return { sent: true };
195+
} catch (error) {
196+
const detail = errorMessage(error).slice(0, 160);
197+
console.warn(JSON.stringify({ event: "maintainer_recap_discord_failed", message: detail }));
198+
await recordAuditEvent(env, { eventType: "maintainer_recap_notification.discord", actor: "gittensory", targetKey, outcome: "error", detail, metadata: auditMeta });
199+
return { sent: false, reason: detail };
200+
}
201+
}
202+
152203
/** Slack incoming-webhook URL validation — only `https://hooks.slack.com/services/…`. Exported so other
153204
* Slack senders (e.g. the recap digest's {@link deliverRecapToSlack}, review-recap.ts) reuse the SAME
154205
* validation instead of re-typing the host/path allowlist. */
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
7+
import { closeFixtureServer, startFixtureServer } from "./support/mcp-cli-harness";
8+
9+
const bin = join(process.cwd(), "packages/gittensory-mcp/bin/gittensory-mcp.js");
10+
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;
11+
12+
let client: Client;
13+
let transport: StdioClientTransport;
14+
let configDir: string;
15+
let apiUrl: string;
16+
let capturedRequests: Array<{ url: string; method: string }>;
17+
18+
async function connect() {
19+
configDir = mkdtempSync(join(tmpdir(), "gittensory-burden-forecast-"));
20+
capturedRequests = [];
21+
apiUrl = await startFixtureServer({
22+
onApiRequest: (request) => {
23+
if (request.url && request.url.includes("/intelligence")) {
24+
capturedRequests.push({ url: request.url ?? "", method: request.method ?? "GET" });
25+
}
26+
},
27+
});
28+
transport = new StdioClientTransport({
29+
command: "node",
30+
args: [bin, "--stdio"],
31+
env: {
32+
...process.env,
33+
GITTENSORY_CONFIG_DIR: configDir,
34+
GITTENSORY_API_URL: apiUrl,
35+
GITTENSORY_TOKEN: "session-token",
36+
GITTENSORY_API_TIMEOUT_MS: "5000",
37+
},
38+
});
39+
client = new Client({ name: "burden-forecast-test", version: "0.0.1" });
40+
await client.connect(transport);
41+
}
42+
43+
async function disconnect() {
44+
await client.close().catch(() => undefined);
45+
await closeFixtureServer();
46+
if (configDir) rmSync(configDir, { recursive: true, force: true });
47+
}
48+
49+
describe("gittensory_get_burden_forecast stdio proxy", () => {
50+
beforeEach(connect);
51+
afterEach(disconnect);
52+
53+
it("registers the tool in the stdio server tool list", async () => {
54+
const { tools } = await client.listTools();
55+
expect(tools.map((t) => t.name)).toContain("gittensory_get_burden_forecast");
56+
});
57+
58+
it("proxies owner/repo to /v1/repos/:owner/:repo/intelligence via apiGet and returns the burden forecast", async () => {
59+
const result = await client.callTool({ name: "gittensory_get_burden_forecast", arguments: { owner: "owner", repo: "repo" } });
60+
expect(capturedRequests.length).toBe(1);
61+
const captured = capturedRequests[0]!;
62+
expect(captured.url).toContain("/v1/repos/owner/repo/intelligence");
63+
expect(captured.method).toBe("GET");
64+
expect(result.isError).toBeFalsy();
65+
const text = JSON.stringify(result);
66+
expect(text).not.toMatch(FORBIDDEN_PUBLIC_TERMS);
67+
expect(text).toContain("burdenForecast");
68+
expect(text).toContain("queueGrowthRisk");
69+
expect(text).toContain("owner/repo");
70+
});
71+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3+
import { describe, expect, it } from "vitest";
4+
import { GittensoryMcp } from "../../src/mcp/server";
5+
import { recordGateBlockOutcome, upsertPullRequestFromGitHub } from "../../src/db/repositories";
6+
import { createTestEnv } from "../helpers/d1";
7+
8+
const REPO = "owner/widgets";
9+
10+
async function connect(env: Env) {
11+
const server = new GittensoryMcp(env).createServer();
12+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
13+
await server.connect(serverTransport);
14+
const client = new Client({ name: "gittensory-gate-precision-test", version: "0.1.0" }, { capabilities: {} });
15+
await client.connect(clientTransport);
16+
return client;
17+
}
18+
19+
// 6 blocks citing one code, 2 on PRs that later merged (false positives) → rate 2/6 = 0.333,
20+
// comfortably above the service's MIN_SAMPLE guard so the per-type rate is a number, not null.
21+
async function seedGateLedger(env: Env) {
22+
for (let n = 1; n <= 6; n += 1) {
23+
await recordGateBlockOutcome(env, { repoFullName: REPO, pullNumber: n, headSha: `sha${n}`, blockerCodes: ["missing_linked_issue"] });
24+
await upsertPullRequestFromGitHub(env, REPO, {
25+
number: n,
26+
title: `PR ${n}`,
27+
state: "closed",
28+
user: { login: "alice" },
29+
...(n <= 2 ? { merged_at: "2026-06-01T00:00:00.000Z" } : {}),
30+
});
31+
}
32+
}
33+
34+
describe("MCP gittensory_get_gate_precision (#2220)", () => {
35+
it("returns the per-gate-type precision report for an authorized caller and passes windowDays through", async () => {
36+
const env = createTestEnv();
37+
await seedGateLedger(env);
38+
const client = await connect(env);
39+
const result = await client.callTool({ name: "gittensory_get_gate_precision", arguments: { owner: "owner", repo: "widgets", windowDays: 30 } });
40+
expect(result.isError).toBeFalsy();
41+
const data = result.structuredContent as {
42+
repoFullName: string;
43+
windowDays: number | null;
44+
perGateType: Array<{ gateType: string; blocked: number; blockedThenMerged: number; falsePositiveRate: number | null }>;
45+
overall: { blocked: number; blockedThenMerged: number; falsePositiveRate: number | null };
46+
signals: string[];
47+
};
48+
expect(data.repoFullName).toBe(REPO);
49+
expect(data.windowDays).toBe(30);
50+
expect(data.overall).toMatchObject({ blocked: 6, blockedThenMerged: 2, falsePositiveRate: 0.333 });
51+
expect(data.perGateType[0]).toMatchObject({ gateType: "missing_linked_issue", blocked: 6, blockedThenMerged: 2, falsePositiveRate: 0.333 });
52+
expect(Array.isArray(data.signals)).toBe(true);
53+
// Numeric branch of the summary's ?? fallback.
54+
expect(JSON.stringify(result.content)).toContain("overall false-positive rate 0.333");
55+
});
56+
57+
it("returns an empty report with a null rate when no gate blocks are recorded (no windowDays)", async () => {
58+
const env = createTestEnv();
59+
const client = await connect(env);
60+
const result = await client.callTool({ name: "gittensory_get_gate_precision", arguments: { owner: "owner", repo: "widgets" } });
61+
expect(result.isError).toBeFalsy();
62+
const data = result.structuredContent as { windowDays: number | null; perGateType: unknown[]; overall: { blocked: number; falsePositiveRate: number | null } };
63+
expect(data.windowDays).toBeNull();
64+
expect(data.perGateType).toEqual([]);
65+
expect(data.overall.blocked).toBe(0);
66+
expect(data.overall.falsePositiveRate).toBeNull();
67+
// Null branch of the summary's ?? fallback.
68+
expect(JSON.stringify(result.content)).toContain("n/a (below sample threshold)");
69+
});
70+
71+
it("forbids the static mcp identity when the repo is outside MCP_READ_REPO_ALLOWLIST", async () => {
72+
const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" });
73+
await seedGateLedger(env);
74+
const client = await connect(env);
75+
const result = await client.callTool({ name: "gittensory_get_gate_precision", arguments: { owner: "owner", repo: "widgets" } });
76+
expect(result.isError).toBeTruthy();
77+
expect(JSON.stringify(result.content)).toMatch(/cannot access this repository/i);
78+
});
79+
});

test/unit/mcp-output-schemas.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
3636
"gittensory_explain_score_breakdown",
3737
"gittensory_get_eligibility_plan",
3838
"gittensory_simulate_open_pr_pressure",
39+
"gittensory_get_gate_precision",
3940
];
4041

4142
async function connectTestClient(env: Env = createTestEnv(), identity?: AuthIdentity) {

0 commit comments

Comments
 (0)