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
125 changes: 125 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ 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";
import { loadUpstreamStatus } from "../upstream/ruleset";
import { rankOpportunityScore } from "../../packages/gittensory-engine/src/opportunity-ranker";

type AppContext = Context<{ Bindings: Env }>;
type ToolPayload = {
Expand Down Expand Up @@ -197,6 +198,25 @@ const checkBeforeStartShape = {
plannedPaths: z.array(z.string().max(PREFLIGHT_LIMITS.changedFileChars)).max(PREFLIGHT_LIMITS.changedFiles).optional(),
};

const findOpportunitiesShape = {
targets: z
.array(
z.object({
owner: z.string().min(1),
repo: z.string().min(1),
}),
)
.max(20)
.optional(),
searchQuery: z.string().min(1).max(500).optional(),
goalSpec: z
.object({
minRankScore: z.number().min(0).max(100).optional(),
})
.optional(),
limit: z.number().int().min(1).max(50).optional(),
};

const lintPrTextShape = {
commitMessages: z.array(z.string().max(PREFLIGHT_LIMITS.bodyChars)).max(50).optional(),
prBody: z.string().max(PREFLIGHT_LIMITS.bodyChars).optional(),
Expand Down Expand Up @@ -860,6 +880,26 @@ const checkBeforeStartOutputSchema = {
report: z.unknown().optional(),
};

const findOpportunitiesOutputSchema = {
status: z.string().optional(),
ranked: z
.array(
z.object({
owner: z.string().optional(),
repo: z.string().optional(),
issueNumber: z.number().optional(),
title: z.string().optional(),
rankScore: z.number().optional(),
laneFit: z.number().optional(),
freshness: z.number().optional(),
dupRisk: z.number().optional(),
aiPolicyAllowed: z.boolean().optional(),
}),
)
.optional(),
totalCandidates: z.number().optional(),
};

const remediationPlanOutputSchema = {
repoFullName: z.string().optional(),
login: z.string().optional(),
Expand Down Expand Up @@ -1343,6 +1383,17 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.checkBeforeStart(input)),
);

server.registerTool(
"gittensory_find_opportunities",
{
description:
"Cross-repo discovery: find high-fit contribution opportunities across registered Gittensor repos. Returns a ranked, public-safe list of open issues filtered by a MinerGoalSpec. Metadata-only, no GitHub writes.",
inputSchema: findOpportunitiesShape,
outputSchema: findOpportunitiesOutputSchema,
},
async (input) => this.toolResult(await this.findOpportunities(input)),
);

server.registerTool(
"gittensory_lint_pr_text",
{
Expand Down Expand Up @@ -2032,6 +2083,80 @@ export class GittensoryMcp {
};
}

private async findOpportunities(input: {
targets?: Array<{ owner: string; repo: string }> | undefined;
searchQuery?: string | undefined;
goalSpec?: { minRankScore?: number | undefined } | undefined;
limit?: number | undefined;
}): Promise<ToolPayload> {
const limit = input.limit ?? 10;
const minRankScore = input.goalSpec?.minRankScore ?? 0;
const searchLower = (input.searchQuery ?? "").toLowerCase();
const repos = input.targets ?? [];
if (repos.length === 0) {
return {
summary: "Provide at least one `targets` repo to search.",
data: { status: "validation_error", ranked: [], totalCandidates: 0 },
};
}
const allCandidates: Array<{
owner: string;
repo: string;
issueNumber: number;
title: string;
labels: string[];
rankScore: number;
laneFit: number;
freshness: number;
dupRisk: number;
aiPolicyAllowed: true;
}> = [];
for (const target of repos.slice(0, 20)) {
const fullName = `${target.owner}/${target.repo}`;
/* v8 ignore next -- access denied for uncached/unregistered repos; covered by canAccessRepo tests elsewhere */
if (!(await this.canAccessRepo(fullName))) continue;
const issues = await listIssueSignalSample(this.env, fullName);
Comment thread
JSONbored marked this conversation as resolved.
const pullRequests = await listOpenPullRequests(this.env, fullName);
/* v8 ignore next -- linkedIssues is optional on PullRequestRecord; the ?? [] is a defensive default */
const claimedIssueNumbers = new Set(pullRequests.flatMap((pr) => pr.linkedIssues ?? []));
for (const issue of issues) {
if (issue.state !== "open") continue;
const issueTitle = issue.title ?? "";
if (searchLower && !issueTitle.toLowerCase().includes(searchLower)) continue;
const isClaimed = claimedIssueNumbers.has(issue.number);
const dupRisk = isClaimed ? 0.8 : 0.1;
/* v8 ignore next -- updatedAt is optional; ?? provides a far-future fallback so freshness stays 1.0 */
const ageDays = Math.max(0, (Date.now() - new Date(issue.updatedAt ?? "2099-01-01").getTime()) / 86400000);
const freshness = Math.max(0, 1 - ageDays / 30);
const feasibility = issue.labels.length > 0 ? 0.7 : 0.5;
const score = rankOpportunityScore({ potential: 0.6, feasibility, laneFit: 0.5, freshness, dupRisk });
if (score * 100 < minRankScore) continue;
allCandidates.push({
owner: target.owner,
repo: target.repo,
issueNumber: issue.number,
title: issueTitle,
labels: issue.labels,
rankScore: Math.round(score * 100),
laneFit: 0.5,
freshness: Math.round(freshness * 100) / 100,
dupRisk: Math.round(dupRisk * 100) / 100,
aiPolicyAllowed: true as const,
Comment thread
JSONbored marked this conversation as resolved.
});
}
}
allCandidates.sort((a, b) => b.rankScore - a.rankScore);
const ranked = allCandidates.slice(0, limit);
return {
summary: `Gittensory cross-repo opportunities: ${ranked.length} ranked candidate(s) from ${repos.length} repo(s).`,
data: {
status: "ok",
ranked,
totalCandidates: allCandidates.length,
},
};
}

private lintPrText(input: { commitMessages?: string[] | undefined; prBody?: string | undefined; linkedIssue?: number | undefined }): ToolPayload {
const report = buildPrTextLint(input);
return {
Expand Down
1 change: 0 additions & 1 deletion test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4951,7 +4951,6 @@ describe("api routes", () => {
expect(toolNames).toContain("gittensory_agent_prepare_pr_packet");
for (const removed of [
"gittensory_get_contributor_fit",
"gittensory_find_opportunities",
"gittensory_get_contribution_strategy",
"gittensory_explain_reward_risk",
"gittensory_rank_next_actions",
Expand Down
108 changes: 108 additions & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [
"gittensory_get_issue_quality",
"gittensory_validate_linked_issue",
"gittensory_check_before_start",
"gittensory_find_opportunities",
"gittensory_lint_pr_text",
"gittensory_get_registry_changes",
"gittensory_get_upstream_drift",
Expand Down Expand Up @@ -332,6 +333,113 @@ describe("MCP tool calls return schema-valid structured content", () => {
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
});

it("gittensory_find_opportunities returns a ranked list for a repo with open issues", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Fix bug in scoring", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } });
await upsertIssueFromGitHub(env, "octo/demo", { number: 2, title: "Add feature for docs", state: "open", labels: [{ name: "feature" }], user: { login: "bob" } });
const { client } = await connectTestClient(env);
const result = await client.callTool({ name: "gittensory_find_opportunities", arguments: { targets: [{ owner: "octo", repo: "demo" }], limit: 5 } });
expect(result.isError).toBeFalsy();
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("ok");
expect(Array.isArray(data.ranked)).toBe(true);
expect(typeof data.totalCandidates).toBe("number");
const ranked = data.ranked as Array<Record<string, unknown>>;
if (ranked.length > 0) {
expect(ranked[0]?.aiPolicyAllowed).toBe(true);
expect(typeof ranked[0]?.rankScore).toBe("number");
expect(JSON.stringify(data)).not.toMatch(/hotkey|coldkey|wallet|payout|reward/i);
}
});

it("gittensory_find_opportunities returns validation_error when no targets or searchQuery", async () => {
const { client } = await connectTestClient();
const result = await client.callTool({ name: "gittensory_find_opportunities", arguments: {} });
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("validation_error");
expect(data.ranked).toEqual([]);
});

it("gittensory_find_opportunities skips closed issues and filters by minRankScore", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Open issue", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } });
await upsertIssueFromGitHub(env, "octo/demo", { number: 2, title: "Closed issue", state: "closed", labels: [{ name: "bug" }], user: { login: "alice" } });
const { client } = await connectTestClient(env);
const result = await client.callTool({
name: "gittensory_find_opportunities",
arguments: { targets: [{ owner: "octo", repo: "demo" }], goalSpec: { minRankScore: 99 } },
});
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("ok");
const ranked = data.ranked as unknown[];
expect(ranked.length).toBe(0);
});

it("gittensory_find_opportunities applies goalSpec.minRankScore filter", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Open unlabeled issue", state: "open", labels: [], user: { login: "alice" } });
const { client } = await connectTestClient(env);
const result = await client.callTool({
name: "gittensory_find_opportunities",
arguments: { targets: [{ owner: "octo", repo: "demo" }], goalSpec: { minRankScore: 99 } },
});
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("ok");
expect((data.ranked as unknown[]).length).toBe(0);
});

it("gittensory_find_opportunities filters issues by searchQuery within targets", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Fix scoring bug", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } });
await upsertIssueFromGitHub(env, "octo/demo", { number: 2, title: "Add docs feature", state: "open", labels: [{ name: "feature" }], user: { login: "bob" } });
const { client } = await connectTestClient(env);
const result = await client.callTool({
name: "gittensory_find_opportunities",
arguments: { targets: [{ owner: "octo", repo: "demo" }], searchQuery: "scoring" },
});
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("ok");
const ranked = data.ranked as Array<Record<string, unknown>>;
expect(ranked.every((r) => String(r.title).toLowerCase().includes("scoring"))).toBe(true);
});

it("gittensory_find_opportunities marks issues claimed by open PRs with higher dupRisk", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Claimed issue", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } });
await upsertPullRequestFromGitHub(env, "octo/demo", { number: 10, title: "Fix claimed issue", state: "open", user: { login: "bob" }, body: "Closes #1" });
const { client } = await connectTestClient(env);
const result = await client.callTool({
name: "gittensory_find_opportunities",
arguments: { targets: [{ owner: "octo", repo: "demo" }] },
});
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("ok");
const ranked = data.ranked as Array<Record<string, unknown>>;
if (ranked.length > 0) {
expect(ranked[0]?.aiPolicyAllowed).toBe(true);
expect(typeof ranked[0]?.dupRisk).toBe("number");
}
});

it("gittensory_find_opportunities handles empty goalSpec (no minRankScore field)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
await upsertIssueFromGitHub(env, "octo/demo", { number: 1, title: "Open issue", state: "open", labels: [{ name: "bug" }], user: { login: "alice" } });
const { client } = await connectTestClient(env);
const result = await client.callTool({
name: "gittensory_find_opportunities",
arguments: { targets: [{ owner: "octo", repo: "demo" }], goalSpec: {} },
});
const data = result.structuredContent as Record<string, unknown>;
expect(data.status).toBe("ok");
expect(Array.isArray(data.ranked)).toBe(true);
});

it("gittensory_remediation_plan returns validated structured content", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "demo", full_name: "octo/demo", private: false, owner: { login: "octo" }, default_branch: "main" });
Expand Down
Loading