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
62 changes: 61 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
extractBrowserSessionToken,
extractCookieValue,
isAuthorizedGitHubSessionLogin,
isMcpReadRepoAllowed,
isMcpReadUnscoped,
revokeSession,
timingSafeEqual,
Expand Down Expand Up @@ -176,9 +177,11 @@ import {
} from "../services/miner-dashboard-recommendations";
import {
buildStaticControlPanelRoleSummary,
canLoginAccessRepo,
loadControlPanelAccessScope,
loadControlPanelRoleSummary,
} from "../services/control-panel-roles";
import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities";
import {
buildMcpCompatibilityMetadata,
LATEST_RECOMMENDED_MCP_VERSION,
Expand Down Expand Up @@ -2781,6 +2784,31 @@ export function createApp() {
return c.json({ ...buildIssueSlopAssessment(parsed.data), rubric: ISSUE_SLOP_RUBRIC_MARKDOWN });
});

app.post(OPPORTUNITIES_FIND_PATH, async (c) => {
const identity = await authenticateRequestIdentity(c);
/* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific guards. */
if (!identity) return c.json({ error: "unauthorized" }, 401);
const body = await c.req.json().catch(() => null);
const parsed = validateFindOpportunitiesInput((body ?? {}) as FindOpportunitiesInput);
if (!parsed.ok) {
return c.json({ status: "invalid_request", ranked: [], totalCandidates: 0, reason: parsed.reason }, 400);
}
if (parsed.value.searchQuery) {
const forbidden = await requireDiscoveryAccessForApi(c, identity);
if (forbidden) return forbidden;
} else {
for (const target of parsed.value.targets ?? []) {
const fullName = `${target.owner}/${target.repo}`;
const forbidden = await requireApiRepoReadAccess(c, identity, fullName);
if (forbidden) return forbidden;
}
}
const result = await runFindOpportunities(c.env, parsed.value, {
canAccessRepo: (repoFullName) => canApiAccessRepo(c.env, identity, repoFullName),
});
return c.json(result);
});

app.post("/v1/preflight/pr", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = preflightSchema.safeParse(body);
Expand Down Expand Up @@ -5139,6 +5167,7 @@ function contributorEvidenceFromProfile(profile: {

const EXTENSION_PULL_CONTEXT_PATH = "/v1/extension/pull-context";
const EXTENSION_PULL_CONTEXT_SCOPE = "extension:pull_context";
const OPPORTUNITIES_FIND_PATH = "/v1/opportunities/find";
const LINT_PR_TEXT_PATH = "/v1/lint/pr-text";
const LINT_SLOP_RISK_PATH = "/v1/lint/slop-risk";
const LINT_ISSUE_SLOP_PATH = "/v1/lint/issue-slop";
Expand All @@ -5151,7 +5180,7 @@ const EXTENSION_CONTRIBUTOR_CONTEXT_PATH = /^\/v1\/extension\/contributors\/[^/]
type ProtectedRouteContext = {
env: Env;
req: { header: (name: string) => string | undefined | null };
json: (object: { error: string }, status?: number) => Response;
json: (object: { error: string; reason?: string }, status?: number) => Response;
};

function isExtensionScopedSession(identity: AuthIdentity): boolean {
Expand Down Expand Up @@ -5205,6 +5234,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoAgentAuditFeedPath(path)) return true; // route's requireRepoMaintainer enforces per-repo authority (contributors → 403)
if (isRepoAgentPendingActionsPath(path)) return true; // list-only: requireRepoMaintainer; decision POSTs require server tokens
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
if (path === OPPORTUNITIES_FIND_PATH) return true;
if (path === LINT_PR_TEXT_PATH || path === LINT_SLOP_RISK_PATH || path === LINT_ISSUE_SLOP_PATH) return true;
if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true;
// Contributor extension scope reaches only `/v1/extension/contributors/<login>/*`; the handler's
Expand Down Expand Up @@ -5351,6 +5381,36 @@ async function requireExtensionPullContextRepoAccess(
return requireSessionRepoAccess(c, identity, repoFullName, repo);
}

async function requireDiscoveryAccessForApi(c: ProtectedRouteContext, identity: AuthIdentity): Promise<Response | null> {
if (identity.kind === "session") {
if (isAuthorizedGitHubSessionLogin(c.env, identity.actor)) return null;
const scope = await loadControlPanelAccessScope(c.env, identity.actor);
if (scope.operator) return null;
return c.json({ error: "forbidden", reason: "cross_repo_search_requires_discovery_access" }, 403);
}
if (identity.kind === "static" && identity.actor === "mcp" && !isMcpReadUnscoped(c.env.MCP_READ_REPO_ALLOWLIST)) {
return c.json({ error: "forbidden", reason: "cross_repo_search_requires_unscoped_mcp_read" }, 403);
}
return null;
}

async function canApiAccessRepo(env: Env, identity: AuthIdentity, repoFullName: string): Promise<boolean> {
if (identity.kind === "session") return canLoginAccessRepo(env, identity.actor, repoFullName);
if (identity.kind === "static" && identity.actor === "mcp") {
return isMcpReadRepoAllowed(env.MCP_READ_REPO_ALLOWLIST, repoFullName);
}
return true;
}

async function requireApiRepoReadAccess(
c: ProtectedRouteContext,
identity: AuthIdentity,
repoFullName: string,
): Promise<Response | null> {
if (await canApiAccessRepo(c.env, identity, repoFullName)) return null;
return c.json({ error: "forbidden_repo" }, 403);
}

async function requireSessionRepoAccess(
c: ProtectedRouteContext,
identity: Extract<AuthIdentity, { kind: "session" }>,
Expand Down
252 changes: 252 additions & 0 deletions src/mcp/find-opportunities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// Hosted `gittensory_find_opportunities` (#2308): metadata-only cross-repo discovery that composes the
// opportunity fan-out (#2307), deterministic ranker (#2302), and goal-model signals from
// `@jsonbored/gittensory-engine` — never clones source, never uploads metadata, never writes to GitHub.
// Banned repos are hard-skipped upstream in fan-out AI-policy resolution; only `aiPolicyAllowed: true`
// rows are ever returned.

import {
DEFAULT_MINER_GOAL_SPEC,
type MinerGoalSpec,
} from "../../packages/gittensory-engine/src/miner-goal-spec.js";
import {
fetchCandidateIssuesWithSummary,
searchCandidateIssuesWithSummary,
} from "../../packages/gittensory-miner/lib/opportunity-fanout.js";
import { rankCandidateIssuesWithSummary } from "../../packages/gittensory-miner/lib/opportunity-ranker.js";
import { createInstallationToken } from "../github/app";
import { getRepository } from "../db/repositories";

export type FindOpportunitiesTarget = { owner: string; repo: string };

export type FindOpportunitiesGoalSpec = {
lane?: string | undefined;
minRankScore?: number | undefined;
languages?: string[] | undefined;
};

export type FindOpportunitiesInput = {
targets?: FindOpportunitiesTarget[] | undefined;
searchQuery?: string | undefined;
goalSpec?: FindOpportunitiesGoalSpec | undefined;
limit?: number | undefined;
};

export type FindOpportunitiesRankedEntry = {
owner: string;
repo: string;
issueNumber: number;
title: string;
rankScore: number;
laneFit: number;
freshness: number;
dupRisk: number;
aiPolicyAllowed: true;
};

export type FindOpportunitiesResult = {
status: "ok" | "invalid_request" | "github_token_unavailable";
ranked: FindOpportunitiesRankedEntry[];
totalCandidates: number;
appliedLane?: string | undefined;
appliedMinRankScore?: number | undefined;
reason?: string | undefined;
warnings?: Array<{ repoFullName: string; stage: string; message: string }> | undefined;
};

const DEFAULT_LIMIT = 5;
const MAX_LIMIT = 50;

function clamp01(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}

/** Public-safe 0–100 rank score derived from the ranker's 0–1 product score. */
export function publicRankScore(rankScore: number): number {
return Math.round(clamp01(rankScore) * 100);
}

export function normalizeFindOpportunitiesLimit(limit: number | null | undefined): number {
if (!Number.isFinite(limit)) return DEFAULT_LIMIT;
return Math.min(MAX_LIMIT, Math.max(1, Math.trunc(limit!)));
}

export function validateFindOpportunitiesInput(
input: FindOpportunitiesInput,
): { ok: true; value: FindOpportunitiesInput } | { ok: false; reason: string } {
const targets = Array.isArray(input.targets) ? input.targets : undefined;
const searchQuery = typeof input.searchQuery === "string" ? input.searchQuery.trim() : "";
const hasTargets = Boolean(targets && targets.length > 0);
const hasSearch = searchQuery.length > 0;
if (!hasTargets && !hasSearch) {
return { ok: false, reason: "targets_or_search_query_required" };
}
if (hasTargets) {
for (const target of targets!) {
const owner = typeof target?.owner === "string" ? target.owner.trim() : "";
const repo = typeof target?.repo === "string" ? target.repo.trim() : "";
if (!owner || !repo) return { ok: false, reason: "invalid_target" };
}
}
if (hasSearch && searchQuery.length > 500) return { ok: false, reason: "search_query_too_long" };
const minRankScore = input.goalSpec?.minRankScore;
if (minRankScore !== undefined && (!Number.isFinite(minRankScore) || minRankScore < 0 || minRankScore > 100)) {
return { ok: false, reason: "invalid_min_rank_score" };
}
return {
ok: true,
value: {
...(hasTargets ? { targets } : {}),
...(hasSearch ? { searchQuery } : {}),
...(input.goalSpec ? { goalSpec: input.goalSpec } : {}),
...(input.limit !== undefined ? { limit: input.limit } : {}),
},
};
}

function buildGoalSpecsByRepo(
repoFullNames: readonly string[],
goalSpec: FindOpportunitiesGoalSpec | undefined,
): Record<string, MinerGoalSpec> | undefined {
const lane = typeof goalSpec?.lane === "string" ? goalSpec.lane.trim() : "";
const languages = Array.isArray(goalSpec?.languages)
? goalSpec.languages.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: [];
if (!lane && languages.length === 0) return undefined;
const spec: MinerGoalSpec = {
...DEFAULT_MINER_GOAL_SPEC,
...(lane ? { preferredLabels: [lane] } : {}),
...(languages.length > 0
? { wantedPaths: languages.map((language) => `**/*.${language.trim().toLowerCase()}`) }
: {}),
};
const out: Record<string, MinerGoalSpec> = {};
for (const repoFullName of repoFullNames) out[repoFullName] = spec;
return out;
}

function toRankedEntry(
issue: {
owner: string;
repo: string;
issueNumber: number;
title: string;
rankScore: number;
laneFit: number;
freshness: number;
dupRisk: number;
},
): FindOpportunitiesRankedEntry {
return {
owner: issue.owner,
repo: issue.repo,
issueNumber: issue.issueNumber,
title: issue.title,
rankScore: publicRankScore(issue.rankScore),
laneFit: clamp01(issue.laneFit),
freshness: clamp01(issue.freshness),
dupRisk: clamp01(issue.dupRisk),
aiPolicyAllowed: true,
};
}

async function resolveDiscoveryGithubToken(
env: Env,
targets: readonly FindOpportunitiesTarget[],
): Promise<{ token: string | null; reposByFullName: Map<string, Awaited<ReturnType<typeof getRepository>>> }> {
const reposByFullName = new Map<string, Awaited<ReturnType<typeof getRepository>>>();
for (const target of targets) {
const fullName = `${target.owner}/${target.repo}`;
reposByFullName.set(fullName, await getRepository(env, fullName));
}
if (env.GITHUB_PUBLIC_TOKEN) return { token: env.GITHUB_PUBLIC_TOKEN, reposByFullName };
for (const repo of reposByFullName.values()) {
const installationId = repo?.installationId;
if (!installationId) continue;
try {
return { token: await createInstallationToken(env, installationId), reposByFullName };
} catch {
continue;
}
}
return { token: null, reposByFullName };
}

export async function runFindOpportunities(
env: Env,
input: FindOpportunitiesInput,
options: {
canAccessRepo?: ((repoFullName: string) => Promise<boolean> | boolean) | undefined;
} = {},
): Promise<FindOpportunitiesResult> {
const validated = validateFindOpportunitiesInput(input);
if (!validated.ok) {
return { status: "invalid_request", ranked: [], totalCandidates: 0, reason: validated.reason };
}
const parsed = validated.value;
const limit = normalizeFindOpportunitiesLimit(parsed.limit);
const minRankScore = parsed.goalSpec?.minRankScore ?? 0;
const appliedLane = parsed.goalSpec?.lane?.trim() || undefined;

const targets = parsed.targets ?? [];
const { token, reposByFullName } = await resolveDiscoveryGithubToken(env, targets);
if (!token && targets.length > 0) {
const anyInstalled = [...reposByFullName.values()].some(Boolean);
if (!anyInstalled) {
return {
status: "github_token_unavailable",
ranked: [],
totalCandidates: 0,
reason: "github_token_unavailable",
};
}
}

let issues: Awaited<ReturnType<typeof fetchCandidateIssuesWithSummary>>["issues"] = [];
let warnings: Array<{ repoFullName: string; stage: string; message: string }> = [];
if (parsed.searchQuery) {
const search = await searchCandidateIssuesWithSummary(parsed.searchQuery, token ?? "", {});
issues = search.issues;
warnings = search.warnings;
} else {
const allowedTargets: FindOpportunitiesTarget[] = [];
for (const target of targets) {
const fullName = `${target.owner}/${target.repo}`;
if (options.canAccessRepo && !(await options.canAccessRepo(fullName))) continue;
allowedTargets.push(target);
}
if (allowedTargets.length === 0) {
return { status: "invalid_request", ranked: [], totalCandidates: 0, reason: "no_accessible_targets" };
}
const fetched = await fetchCandidateIssuesWithSummary(allowedTargets, token ?? "", {});
issues = fetched.issues;
warnings = fetched.warnings;
}

if (parsed.searchQuery && options.canAccessRepo) {
const filtered = [];
for (const issue of issues) {
if (await options.canAccessRepo(issue.repoFullName)) filtered.push(issue);
}
issues = filtered;
}

const repoFullNames = [...new Set(issues.map((issue) => issue.repoFullName))];
const goalSpecsByRepo = buildGoalSpecsByRepo(repoFullNames, parsed.goalSpec);
const ranked = rankCandidateIssuesWithSummary(issues, {
...(goalSpecsByRepo ? { goalSpecsByRepo } : {}),
});
const filtered = ranked.issues
.map(toRankedEntry)
.filter((entry) => entry.rankScore >= minRankScore)
.slice(0, limit);

return {
status: "ok",
ranked: filtered,
totalCandidates: ranked.issues.length,
...(appliedLane ? { appliedLane } : {}),
...(minRankScore > 0 ? { appliedMinRankScore: minRankScore } : {}),
...(warnings.length > 0 ? { warnings } : {}),
};
}
Loading
Loading