Skip to content
Merged
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
68 changes: 68 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -1167,6 +1202,39 @@ export class GittensoryMcp {
};
}

private async predictGate(input: z.infer<z.ZodObject<typeof predictGateShape>>): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async listNotifications(login: string): Promise<ToolPayload> {
this.requireContributorAccess(login);
const deliveries = await listNotificationDeliveriesForRecipient(this.env, login, { channel: "badge", limit: 50 });
Expand Down
16 changes: 13 additions & 3 deletions src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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;
Expand Down Expand Up @@ -119,24 +123,30 @@ 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,
qualityGateMode: gate.readinessMode ?? undefined,
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,
Expand Down
61 changes: 61 additions & 0 deletions test/unit/mcp-predict-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
48 changes: 48 additions & 0 deletions test/unit/predicted-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});