diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ce8484ad90..92748bf228 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -80,6 +80,7 @@ import { import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; +import { buildPredictedGateVerdict } from "../rules/predicted-gate"; import { buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; @@ -360,6 +361,29 @@ const notificationsOutputSchema = { notifications: z.unknown().optional(), }; +const predictGateShape = { + login: z.string().min(1), + owner: z.string().min(1), + repo: z.string().min(1), + title: z.string().min(1), + body: z.string().optional(), + labels: z.array(z.string()).optional(), + linkedIssues: z.array(z.number().int().positive()).optional(), +}; + +const predictGateOutputSchema = { + predicted: z.boolean().optional(), + basis: z.string().optional(), + pack: z.enum(["gittensor", "oss-anti-slop"]).optional(), + conclusion: z.string().optional(), + title: z.string().optional(), + summary: z.string().optional(), + readinessScore: z.number().nullable().optional(), + blockers: z.unknown().optional(), + warnings: z.unknown().optional(), + note: z.string().optional(), +}; + const markNotificationsReadOutputSchema = { login: z.string().optional(), marked: z.number().optional(), @@ -570,6 +594,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.monitorOpenPullRequests(input.login)), ); + server.registerTool( + "gittensory_predict_gate", + { + description: + "Predict whether a planned PR would pass the repo's Gittensory gate, from its PUBLIC .gittensory.yml only — an agent-native pre-submission self-check that works on ANY repo (no Gittensor account). Under the oss-anti-slop pack the verdict applies to any author; self-scoped to the authenticated login.", + inputSchema: predictGateShape, + outputSchema: predictGateOutputSchema, + }, + async (input) => this.toolResult(await this.predictGate(input)), + ); + server.registerTool( "gittensory_list_notifications", { @@ -1167,6 +1202,39 @@ export class GittensoryMcp { }; } + private async predictGate(input: z.infer>): Promise { + this.requireContributorAccess(input.login); + const repoFullName = `${input.owner}/${input.repo}`; + const [repo, issues, pullRequests, bounties, issueQuality, manifest] = await Promise.all([ + getRepository(this.env, repoFullName), + listIssues(this.env, repoFullName), + listPullRequests(this.env, repoFullName), + listBountiesByRepo(this.env, repoFullName), + loadOrComputeIssueQualityResponse(this.env, repoFullName), + loadRepoFocusManifest(this.env, repoFullName), + ]); + const verdict = buildPredictedGateVerdict({ + input: { + repoFullName, + contributorLogin: input.login, + title: input.title, + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.labels === undefined ? {} : { labels: input.labels }), + ...(input.linkedIssues === undefined ? {} : { linkedIssues: input.linkedIssues }), + }, + manifest, + repo, + issues, + pullRequests, + bounties, + issueQuality: issueQuality?.report, + }); + return { + summary: `Predicted Gittensory gate for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion}.`, + data: verdict as unknown as Record, + }; + } + private async listNotifications(login: string): Promise { this.requireContributorAccess(login); const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { channel: "badge", limit: 50 }); diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index d31864b787..f21e7ebe36 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -8,7 +8,7 @@ import { } from "../signals/engine"; import type { FocusManifest } from "../signals/focus-manifest"; import { sanitizePublicComment } from "../github/commands"; -import type { BountyRecord, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types"; +import type { BountyRecord, GatePolicyPack, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types"; import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } from "./advisory"; /** @@ -27,6 +27,10 @@ import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } export type PredictedGateVerdict = { predicted: true; basis: "public_config"; + /** Which policy pack the repo's public config selects (#692/#693). Under `oss-anti-slop` the predicted + * verdict applies to ANY author (no confirmed-contributor gate) — so an agent on a non-Gittensor repo + * gets a meaningful "will this pass?" answer with no Gittensor account. */ + pack: GatePolicyPack; conclusion: GateCheckConclusion; title: string; summary: string; @@ -119,6 +123,11 @@ export function buildPredictedGateVerdict(args: { const requireLinkedIssue = gate.linkedIssue !== null && gate.linkedIssue !== "off"; const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue }); + // Pack-aware (#693): under `oss-anti-slop` the gate blocks ANY author, so drop the confirmed-contributor + // gate entirely (mirrors gateCheckPolicy). `gittensor` keeps it. Pack comes from the PUBLIC .gittensory.yml. + const pack: GatePolicyPack = gate.pack ?? "gittensor"; + const effectiveConfirmedContributor = pack === "oss-anti-slop" ? undefined : args.confirmedContributor; + const evaluation = evaluateGateCheck(advisory, { linkedIssueGateMode: gate.linkedIssue ?? undefined, duplicatePrGateMode: gate.duplicates ?? undefined, @@ -126,17 +135,18 @@ export function buildPredictedGateVerdict(args: { qualityGateMinScore: gate.readinessMinScore ?? null, aiReviewGateMode: gate.aiReviewMode ?? undefined, readinessScore: readiness.total, - confirmedContributor: args.confirmedContributor, + confirmedContributor: effectiveConfirmedContributor, }); return { predicted: true, basis: "public_config", + pack, conclusion: evaluation.conclusion, title: sanitizePublicComment(evaluation.title), summary: sanitizePublicComment(evaluation.summary), readinessScore: readiness.total, - confirmedContributor: args.confirmedContributor, + confirmedContributor: effectiveConfirmedContributor, blockers: evaluation.blockers.map(publicSafeFinding), warnings: evaluation.warnings.map(publicSafeFinding), note: PREDICTED_GATE_NOTE, diff --git a/test/unit/mcp-predict-gate.test.ts b/test/unit/mcp-predict-gate.test.ts new file mode 100644 index 0000000000..99f10265c2 --- /dev/null +++ b/test/unit/mcp-predict-gate.test.ts @@ -0,0 +1,61 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { GittensoryMcp } from "../../src/mcp/server"; +import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env, identity?: AuthIdentity) { + const server = (identity ? new GittensoryMcp(env, identity) : new GittensoryMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "gittensory-predict-gate-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +describe("MCP gittensory_predict_gate", () => { + it("predicts the gate from public config on an unregistered repo under oss-anti-slop", async () => { + const env = createTestEnv(); + // A non-Gittensor repo: app-installed (so gittensory has "seen" it) but NOT Gittensor-registered, with + // public config only (gate.pack oss-anti-slop, linked-issue blocks any author). + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); + await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "oss-anti-slop", linkedIssue: "block" } }); + const client = await connect(env); + + const result = await client.callTool({ + name: "gittensory_predict_gate", + // Pass body + labels + linkedIssues so the optional-field plumbing is exercised. + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", body: "Improves upload reliability.", labels: ["enhancement"], linkedIssues: [] }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { pack: string; conclusion: string; blockers: Array<{ code: string }> }; + expect(data.pack).toBe("oss-anti-slop"); + expect(data.conclusion).toBe("failure"); + expect(data.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward estimate|trust score/i); + + // Also works with only the required fields (optional body/labels/linkedIssues omitted). + const minimal = await client.callTool({ + name: "gittensory_predict_gate", + arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Minimal self-check" }, + }); + expect(minimal.isError).toBeFalsy(); + expect((minimal.structuredContent as { pack: string }).pack).toBe("oss-anti-slop"); + }); + + it("is self-scoped: a session cannot predict for another login", async () => { + const env = createTestEnv(); + const { session } = await createSessionForGitHubUser(env, { login: "miner1", id: 1 }); + const client = await connect(env, { kind: "session", actor: "miner1", session }); + + const result = await client.callTool({ + name: "gittensory_predict_gate", + arguments: { login: "someone-else", owner: "acme", repo: "widgets", title: "x" }, + }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("authenticated GitHub login"); + }); +}); diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index e190e7462b..da4d0149b8 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -79,3 +79,51 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers).toHaveLength(0); }); }); + +describe("pack-aware prediction (#693)", () => { + it("defaults to the gittensor pack and surfaces it", () => { + expect(verdict({ gate: { duplicates: "block" } }).pack).toBe("gittensor"); + }); + + it("under oss-anti-slop, blocks ANY author — even a self-declared non-confirmed contributor", () => { + const result = buildPredictedGateVerdict({ + input: { ...BASE_INPUT, body: "no issue", linkedIssues: [] }, + manifest: parseFocusManifest({ gate: { pack: "oss-anti-slop", linkedIssue: "block" } }), + repo: REPO, + issues: [], + pullRequests: [], + confirmedContributor: false, // ignored under oss-anti-slop + }); + expect(result.pack).toBe("oss-anti-slop"); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + expect(result.confirmedContributor).toBeUndefined(); + }); + + it("under gittensor, the same non-confirmed contributor stays neutral (matches the real gate)", () => { + const result = buildPredictedGateVerdict({ + input: { ...BASE_INPUT, body: "no issue", linkedIssues: [] }, + manifest: parseFocusManifest({ gate: { pack: "gittensor", linkedIssue: "block" } }), + repo: REPO, + issues: [], + pullRequests: [], + confirmedContributor: false, + }); + expect(result.pack).toBe("gittensor"); + expect(result.conclusion).toBe("neutral"); + }); + + it("runs on a non-Gittensor (app-installed, unregistered) repo under oss-anti-slop with no Gittensor account", () => { + const result = buildPredictedGateVerdict({ + input: { ...BASE_INPUT, body: "no issue", linkedIssues: [] }, + manifest: parseFocusManifest({ gate: { pack: "oss-anti-slop", linkedIssue: "block" } }), + // App-installed but NOT Gittensor-registered: a real repo record (not null → gittensory has "seen" it). + repo: { ...REPO, isRegistered: false }, + issues: [], + pullRequests: [], + }); + expect(result.pack).toBe("oss-anti-slop"); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + }); +});