From 8734a78f44ad9b6ea5d1492c9805378574f15d95 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 14 Jun 2026 06:14:10 -0700 Subject: [PATCH 1/3] fix(predict-gate): resolve contributor confirmation for gate prediction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-submission gate prediction (gittensory_predict_gate MCP tool and the /v1/local/branch-analysis self-gate) never passed confirmedContributor, leaving it undefined. Under the default gittensor pack only confirmed Gittensor contributors are ever hard-blocked, so a non-confirmed contributor whose synthetic PR tripped a blocker was told 'failure' when the real maintainer gate returns 'neutral' for them — breaking the documented pre/post-submission parity. Resolve the caller's own confirmed status the same way the pipeline does (official Gittensor API -> confirmed): reuse the already-fetched gittensorSnapshot in the API route, and gate a lookup behind the pack in the MCP tool (oss-anti-slop drops the contributor gate, so it's skipped there). Add MCP regression tests for both non-confirmed -> neutral and confirmed -> failure. --- src/api/routes.ts | 6 ++++ src/mcp/server.ts | 8 +++++ test/unit/mcp-predict-gate.test.ts | 58 +++++++++++++++++++++++++++++- 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index 92aa9dfea3..ddd58d8fe0 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -2142,6 +2142,12 @@ export function createApp() { pullRequests, bounties, issueQuality: issueQuality?.report, + // Parity with the maintainer gate: only CONFIRMED Gittensor contributors are ever hard-blocked, so + // the prediction must be told the caller's own confirmed status — otherwise it over-reports `failure` + // for non-confirmed contributors whose synthetic PR trips a blocker. `gittensorSnapshot` is non-null + // only when the official Gittensor API confirms this login (fetchGittensorContributorSnapshot), which + // matches the pipeline's `official?.status === "confirmed"`. Ignored under the oss-anti-slop pack. + confirmedContributor: context.gittensorSnapshot !== null, }); const response = { ...analysis, predictedGate, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) }; await persistSignal(c.env, "local-branch-analysis", `${parsed.data.login}:${parsed.data.repoFullName}:${parsed.data.branchName ?? parsed.data.headRef ?? "local"}`, parsed.data.repoFullName, response as unknown as Record, analysis.generatedAt); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f36ac58ea6..39abec48cf 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1236,6 +1236,13 @@ export class GittensoryMcp { loadOrComputeIssueQualityResponse(this.env, repoFullName), loadRepoFocusManifest(this.env, repoFullName), ]); + // Parity with the maintainer gate: only CONFIRMED Gittensor contributors are ever hard-blocked, so the + // prediction must know the caller's own confirmed status — otherwise it over-reports `failure` for a + // non-confirmed contributor whose synthetic PR trips a blocker. Resolve it the same way the pipeline + // does (official Gittensor API → confirmed). The oss-anti-slop pack drops the contributor gate entirely, + // so skip the lookup there (keeps the prediction account-free for non-Gittensor adopters). + const pack = manifest.gate.pack ?? "gittensor"; + const confirmedContributor = pack === "oss-anti-slop" ? undefined : (await fetchGittensorContributorSnapshot(input.login)) !== null; const verdict = buildPredictedGateVerdict({ input: { repoFullName, @@ -1251,6 +1258,7 @@ export class GittensoryMcp { pullRequests, bounties, issueQuality: issueQuality?.report, + confirmedContributor, }); return { summary: `Predicted Gittensory gate for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion}.`, diff --git a/test/unit/mcp-predict-gate.test.ts b/test/unit/mcp-predict-gate.test.ts index 99f10265c2..5dae674e89 100644 --- a/test/unit/mcp-predict-gate.test.ts +++ b/test/unit/mcp-predict-gate.test.ts @@ -1,6 +1,6 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; @@ -46,6 +46,62 @@ describe("MCP gittensory_predict_gate", () => { expect((minimal.structuredContent as { pack: string }).pack).toBe("oss-anti-slop"); }); + // Parity regression (#627-class): under the default `gittensor` pack, only CONFIRMED Gittensor + // contributors are ever hard-blocked. The prediction must resolve the caller's confirmed status — if it + // doesn't (the bug), a non-confirmed contributor whose synthetic PR trips a blocker is wrongly told + // `failure` when the real maintainer gate would return `neutral`. + describe("contributor-confirmation parity under the gittensor pack", () => { + afterEach(() => vi.unstubAllGlobals()); + + function stubGittensorMiners(confirmedLogins: Array<{ login: string; id: number }>) { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + // The miners LIST endpoint decides confirmation; follow-up detail/prs/issues calls are best-effort. + if (/\/miners$/.test(url)) { + return Response.json(confirmedLogins.map((m) => ({ githubId: m.id, githubUsername: m.login }))); + } + return Response.json([]); + }); + } + + it("stays NEUTRAL for a non-confirmed contributor even when a blocker fires", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); + // gittensor pack, linked-issue blocks; the contributor supplies no linked issue → blocker fires. + await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "gittensor", linkedIssue: "block" } }); + stubGittensorMiners([]); // miner1 is NOT a confirmed Gittensor contributor + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_predict_gate", + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", linkedIssues: [] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { pack: string; conclusion: string; confirmedContributor: boolean | undefined }; + expect(data.pack).toBe("gittensor"); + expect(data.conclusion).toBe("neutral"); + expect(data.confirmedContributor).toBe(false); + }); + + it("predicts FAILURE for a confirmed contributor when the same blocker fires", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); + await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "gittensor", linkedIssue: "block" } }); + stubGittensorMiners([{ login: "miner1", id: 4242 }]); // miner1 IS confirmed + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_predict_gate", + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", linkedIssues: [] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { conclusion: string; confirmedContributor: boolean | undefined; blockers: Array<{ code: string }> }; + expect(data.confirmedContributor).toBe(true); + expect(data.conclusion).toBe("failure"); + expect(data.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + }); + }); + it("is self-scoped: a session cannot predict for another login", async () => { const env = createTestEnv(); const { session } = await createSessionForGitHubUser(env, { login: "miner1", id: 1 }); From 5a83b36d426f3d6eae6a6d9b28fbbe20166a87f7 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Tue, 16 Jun 2026 03:06:31 -0700 Subject: [PATCH 2/3] chore(deps): clear audit advisories via ws/tar/js-yaml overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI validate job's npm audit step failed on 9 advisories (2 moderate, 7 high) — all transitive in the dev toolchain. The 7 high all trace to ws (via miniflare/wrangler/vitest-pool-workers); tar and js-yaml are the two moderate. Pin patched versions via overrides (ws ^8.21.0, tar ^7.5.16, js-yaml ^4.2.0) — same-major, non-breaking — following the existing esbuild override pattern. npm audit --audit-level=moderate now reports 0. Also syncs the stale packages/gittensory-mcp lockfile version (0.5.0 -> 0.6.0, matching its package.json) left by the upstream merge. --- package-lock.json | 74 +++++++++++++++++++++++++++++------------------ package.json | 3 ++ 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ee2c1584e..1a2294e5e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1079,6 +1079,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1095,6 +1096,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1111,6 +1113,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1127,6 +1130,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1143,6 +1147,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1159,6 +1164,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1175,6 +1181,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,6 +1198,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1207,6 +1215,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1223,6 +1232,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1239,6 +1249,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1255,6 +1266,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1271,6 +1283,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1287,6 +1300,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1303,6 +1317,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1319,6 +1334,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1335,6 +1351,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1351,6 +1368,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1367,6 +1385,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1383,6 +1402,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1399,6 +1419,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1415,6 +1436,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1431,6 +1453,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1447,6 +1470,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1463,6 +1487,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1479,6 +1504,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4438,9 +4464,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4457,9 +4480,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4476,9 +4496,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4495,9 +4512,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4514,9 +4528,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4533,9 +4544,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -9435,9 +9443,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12382,9 +12400,9 @@ } }, "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -13303,9 +13321,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "dev": true, "license": "MIT", "engines": { @@ -13487,7 +13505,7 @@ }, "packages/gittensory-mcp": { "name": "@jsonbored/gittensory-mcp", - "version": "0.5.0", + "version": "0.6.0", "license": "AGPL-3.0-only", "dependencies": { "@modelcontextprotocol/sdk": "1.29.0", diff --git a/package.json b/package.json index cd3a1949ce..3e7cab9c0c 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,9 @@ "@lovable.dev/vite-plugin-dev-server-bridge": "1.0.1", "@lovable.dev/vite-plugin-hmr-gate": "1.0.1", "esbuild": "^0.28.1", + "ws": "^8.21.0", + "tar": "^7.5.16", + "js-yaml": "^4.2.0", "lovable-tagger@1.2.0": { "esbuild": "^0.28.1" }, From c409e657b992501b028b7dc156ac6886220ed634 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Tue, 16 Jun 2026 15:30:15 -0700 Subject: [PATCH 3/3] test(predict-gate): add network-failure/error-path coverage for confirmation lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the review: when the Gittensor confirmation lookup on the prediction path throws/times out, the contributor is treated as non-confirmed → the gate stays neutral (fail-safe), never a false failure. The lookup hits a fixed constant base URL with the login filtered client-side (never interpolated into the URL), so there is no SSRF surface. --- test/unit/mcp-predict-gate.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/unit/mcp-predict-gate.test.ts b/test/unit/mcp-predict-gate.test.ts index ca32a05506..ed17058e34 100644 --- a/test/unit/mcp-predict-gate.test.ts +++ b/test/unit/mcp-predict-gate.test.ts @@ -100,6 +100,30 @@ describe("MCP gittensory_predict_gate", () => { expect(data.conclusion).toBe("failure"); expect(data.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); }); + + it("treats a Gittensor API failure as non-confirmed (fail-safe NEUTRAL, never a false FAILURE)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); + // No explicit pack → defaults to the gittensor pack, which still resolves confirmed status via the API. + await upsertRepoFocusManifest(env, "acme/widgets", { gate: { linkedIssue: "block" } }); + // The confirmation lookup is the only network call on the prediction path (the URL is a fixed constant + // base; the login is never interpolated into it — it is filtered client-side — so there is no SSRF + // surface). When that call fails/times out, fetchGittensorContributorSnapshot resolves to null, so the + // contributor is treated as non-confirmed → the gate stays neutral rather than wrongly blocking them. + vi.stubGlobal("fetch", async () => { + throw new Error("network down"); + }); + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_predict_gate", + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", linkedIssues: [] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { conclusion: string; confirmedContributor: boolean | undefined }; + expect(data.confirmedContributor).toBe(false); + expect(data.conclusion).toBe("neutral"); + }); }); it("is repo-scoped: a session cannot predict against an inaccessible repo", async () => {