diff --git a/migrations/0053_cross_repo_opportunity_alert_filters.sql b/migrations/0053_cross_repo_opportunity_alert_filters.sql new file mode 100644 index 0000000000..5635d23c63 --- /dev/null +++ b/migrations/0053_cross_repo_opportunity_alert_filters.sql @@ -0,0 +1,8 @@ +-- Cross-repo opportunity discovery (#1060): extend issue-watch subscriptions so the existing opt-in +-- notification channel can filter proactive opportunity alerts by repo lane and freshness window. +-- +-- `lanes_json` stores the allowed repo lanes ([] = any lane). `freshness_days` stores the maximum issue +-- age in days to notify on (NULL = any age). Both fields are additive + nullable/defaulted so existing +-- subscriptions keep their current semantics. +ALTER TABLE issue_watch_subscriptions ADD COLUMN lanes_json TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE issue_watch_subscriptions ADD COLUMN freshness_days INTEGER; diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 945f47ffe2..2d43a7e87a 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -139,6 +139,7 @@ gittensory-mcp agent packet --login jsonbored --repo we-promise/sure --base orig The same capabilities are exposed to MCP clients as: +- `gittensory_find_opportunities` - `gittensory_agent_plan_next_work` - `gittensory_agent_start_run` - `gittensory_agent_get_run` diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 6af5023af7..bbceb78460 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1354,28 +1354,44 @@ export async function listNotificationSubscriptionsForLogin(env: Env, login: str // ─── Issue-watch subscriptions (#699 path B) ───────────────────────────────────────────────────────── function toIssueWatchSubscription(row: typeof issueWatchSubscriptions.$inferSelect): IssueWatchSubscription { - return { login: row.login, repoFullName: row.repoFullName, labels: parseJson(row.labelsJson, []), createdAt: row.createdAt, updatedAt: row.updatedAt }; + return { + login: row.login, + repoFullName: row.repoFullName, + labels: parseJson(row.labelsJson, []), + lanes: parseJson(row.lanesJson, []), + freshnessDays: row.freshnessDays, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; } /** Subscribe a miner to a repo's new grabbable issues; idempotent on (login, repo) — re-subscribing just * updates the label filter. `login`, `repoFullName`, and `labels` ([]=any) are all lowercased so matching * is case-insensitive: GitHub repo names are case-insensitive, and the delivery lookup keys off the * webhook's canonical `repository.full_name`, so a watch stored under a different casing must still match. */ -export async function upsertIssueWatchSubscription(env: Env, input: { login: string; repoFullName: string; labels?: string[] | undefined }): Promise { +export async function upsertIssueWatchSubscription( + env: Env, + input: { login: string; repoFullName: string; labels?: string[] | undefined; lanes?: IssueWatchSubscription["lanes"] | undefined; freshnessDays?: number | null | undefined }, +): Promise { const db = getDb(env.DB); const login = input.login.toLowerCase(); const repoFullName = input.repoFullName.toLowerCase(); const labels = [...new Set((input.labels ?? []).map((label) => label.toLowerCase().trim()).filter(Boolean))]; + const lanes = [...new Set((input.lanes ?? []).map((lane) => lane.trim().toLowerCase()).filter(Boolean))] as IssueWatchSubscription["lanes"]; + const freshnessDays = typeof input.freshnessDays === "number" && Number.isFinite(input.freshnessDays) ? Math.max(1, Math.floor(input.freshnessDays)) : null; await db .insert(issueWatchSubscriptions) - .values({ id: crypto.randomUUID(), login, repoFullName, labelsJson: jsonString(labels), updatedAt: nowIso() }) - .onConflictDoUpdate({ target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName], set: { labelsJson: jsonString(labels), updatedAt: nowIso() } }); + .values({ id: crypto.randomUUID(), login, repoFullName, labelsJson: jsonString(labels), lanesJson: jsonString(lanes), freshnessDays, updatedAt: nowIso() }) + .onConflictDoUpdate({ + target: [issueWatchSubscriptions.login, issueWatchSubscriptions.repoFullName], + set: { labelsJson: jsonString(labels), lanesJson: jsonString(lanes), freshnessDays, updatedAt: nowIso() }, + }); const [row] = await db .select() .from(issueWatchSubscriptions) .where(and(eq(issueWatchSubscriptions.login, login), eq(issueWatchSubscriptions.repoFullName, repoFullName))); /* v8 ignore next -- the row always exists immediately after the upsert above; the literal is a type-safety fallback. */ - return row ? toIssueWatchSubscription(row) : { login, repoFullName, labels }; + return row ? toIssueWatchSubscription(row) : { login, repoFullName, labels, lanes, freshnessDays }; } export async function listIssueWatchSubscriptionsForLogin(env: Env, login: string): Promise { diff --git a/src/db/schema.ts b/src/db/schema.ts index 8630cd327f..e4e4501a7c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -933,6 +933,8 @@ export const issueWatchSubscriptions = sqliteTable( login: text("login").notNull(), repoFullName: text("repo_full_name").notNull(), labelsJson: text("labels_json").notNull().default("[]"), + lanesJson: text("lanes_json").notNull().default("[]"), + freshnessDays: integer("freshness_days"), createdAt: text("created_at").notNull().$defaultFn(() => nowIso()), updatedAt: text("updated_at").notNull().$defaultFn(() => nowIso()), }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e4221f88f9..b6055dcd1a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,6 +13,7 @@ import { createPendingAgentActionIfAbsent, getBounty, listBountiesByRepo, + listAllIssues, getContributorEvidence, getLatestRepoGithubTotalsSnapshot, getInstallation, @@ -69,6 +70,7 @@ import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; import { buildUnavailableQueueTrendReport } from "../services/queue-trends"; +import { buildOpportunityDiscoveryResult } from "../services/opportunity-discovery"; import { applyMcpPlanningChoices, buildMcpPlanningElicitationAudit, @@ -714,13 +716,37 @@ const watchIssuesShape = { action: z.enum(["watch", "unwatch", "list"]).default("list"), repoFullName: z.string().min(3).max(200).optional(), labels: z.array(z.string().min(1).max(100)).max(50).optional(), + lanes: z.array(z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"])).max(10).optional(), + freshnessDays: z.number().int().positive().max(365).optional(), }; const watchIssuesOutputSchema = { - watching: z.array(z.object({ repoFullName: z.string(), labels: z.array(z.string()) })).optional(), + watching: z + .array(z.object({ repoFullName: z.string(), labels: z.array(z.string()), lanes: z.array(z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"])), freshnessDays: z.number().int().nullable().optional() })) + .optional(), changed: z.string().optional(), }; +const findOpportunitiesShape = { + login: z.string().min(1), + limit: z.number().int().positive().max(25).optional(), + labels: z.array(z.string().min(1).max(100)).max(50).optional(), + lanes: z.array(z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"])).max(10).optional(), + freshnessDays: z.number().int().positive().max(365).optional(), +}; + +const findOpportunitiesOutputSchema = { + login: z.string().optional(), + generatedAt: z.string().optional(), + freshness: z.string().optional(), + summary: z.string().optional(), + filters: z.unknown().optional(), + opportunities: z.unknown().optional(), + status: z.string().optional(), + reason: z.string().optional(), + rebuildEnqueued: z.boolean().optional(), +}; + const explainRepoDecisionOutputSchema = { status: z.string().optional(), login: z.string().optional(), @@ -1127,13 +1153,23 @@ export class GittensoryMcp { "gittensory_watch_issues", { description: - "Watch repos for NEW grabbable, high-multiplier issues (maintainer-created, not WIP). action=watch subscribes a repo (optional label filter), unwatch removes it, list (default) returns your watches. When a matching issue opens you're notified via gittensory_list_notifications. Self-scoped to the authenticated login.", + "Watch repos for proactive issue alerts. action=watch subscribes a repo with optional lane, label, and freshness filters; unwatch removes it; list (default) returns your watches. Matching new, aging, and newly-prioritized issues notify via gittensory_list_notifications. Self-scoped to the authenticated login.", inputSchema: watchIssuesShape, outputSchema: watchIssuesOutputSchema, }, async (input) => this.toolResult(await this.watchIssues(input)), ); + server.registerTool( + "gittensory_find_opportunities", + { + description: "Return a deterministic cross-repo shortlist of the best issues to build right now, filtered by lane, label, and freshness. Metadata only; no raw reward or score exposure.", + inputSchema: findOpportunitiesShape, + outputSchema: findOpportunitiesOutputSchema, + }, + async (input) => this.toolResult(await this.findOpportunities(input)), + ); + server.registerTool( "gittensory_explain_repo_decision", { @@ -2029,20 +2065,58 @@ export class GittensoryMcp { if (!input.repoFullName) return { summary: `${input.action} requires repoFullName.`, data: {} }; await this.requireWatchableRepo(input.login, input.repoFullName); if (input.action === "watch") { - await upsertIssueWatchSubscription(this.env, { login: input.login, repoFullName: input.repoFullName, labels: input.labels }); - changed = `watching ${input.repoFullName}${input.labels && input.labels.length > 0 ? ` (labels: ${input.labels.join(", ")})` : ""}`; + await upsertIssueWatchSubscription(this.env, { + login: input.login, + repoFullName: input.repoFullName, + labels: input.labels, + lanes: input.lanes, + freshnessDays: input.freshnessDays, + }); + const filters = [ + input.labels && input.labels.length > 0 ? `labels: ${input.labels.join(", ")}` : null, + input.lanes && input.lanes.length > 0 ? `lanes: ${input.lanes.join(", ")}` : null, + input.freshnessDays ? `freshness <= ${input.freshnessDays}d` : null, + ].filter(Boolean); + changed = `watching ${input.repoFullName}${filters.length > 0 ? ` (${filters.join("; ")})` : ""}`; } else { const removed = await deleteIssueWatchSubscription(this.env, input.login, input.repoFullName); changed = removed ? `unwatched ${input.repoFullName}` : `was not watching ${input.repoFullName}`; } } - const watching = (await listIssueWatchSubscriptionsForLogin(this.env, input.login)).map((sub) => ({ repoFullName: sub.repoFullName, labels: sub.labels })); + const watching = (await listIssueWatchSubscriptionsForLogin(this.env, input.login)).map((sub) => ({ + repoFullName: sub.repoFullName, + labels: sub.labels, + lanes: sub.lanes, + ...(sub.freshnessDays !== undefined ? { freshnessDays: sub.freshnessDays } : {}), + })); return { summary: `Watching ${watching.length} repo(s) for new grabbable issues${changed ? ` (${changed})` : ""}.`, data: { watching, ...(changed ? { changed } : {}) } as unknown as Record, }; } + private async findOpportunities(input: z.infer>): Promise { + this.requireContributorAccess(input.login); + const serving = await loadContributorDecisionPackForServing(this.env, input.login); + if (serving.kind === "needs_refresh") { + return { + summary: `Cross-repo opportunity shortlist for ${input.login} needs a decision-pack refresh.`, + data: serving.refresh as unknown as Record, + }; + } + const issues = await listAllIssues(this.env); + const result = buildOpportunityDiscoveryResult(serving.pack, issues, { + limit: input.limit, + labels: input.labels, + lanes: input.lanes, + freshnessDays: input.freshnessDays, + }); + return { + summary: `Cross-repo opportunity shortlist for ${input.login}.`, + data: result as unknown as Record, + }; + } + private async markNotificationsRead(login: string, ids?: string[]): Promise { this.requireContributorAccess(login); const marked = await markNotificationDeliveriesRead(this.env, login, ids); diff --git a/src/notifications/service.ts b/src/notifications/service.ts index 1dafdb638b..dbc11cb908 100644 --- a/src/notifications/service.ts +++ b/src/notifications/service.ts @@ -8,7 +8,7 @@ import { listNotificationSubscriptionsForLogin, markNotificationDeliveryDelivered, } from "../db/repositories"; -import { isGrabbableHighMultiplierIssue } from "../signals/engine"; +import { buildLaneAdvice, isGrabbableHighMultiplierIssue } from "../signals/engine"; import { canLoginAccessRepo } from "../services/control-panel-roles"; import type { DetectedNotificationEvent, IssueRecord, NotificationChannel, NotificationDeliveryRecord, NotificationSubscriptionRecord } from "../types"; import { nowIso } from "../utils/json"; @@ -48,6 +48,18 @@ export function buildMergedOutcomeNotification(event: DetectedNotificationEvent) // `pullNumber` field carries the ISSUE number. Public-safe — "open to grab" framing, never raw reward/score. export function buildIssueWatchNotification(event: DetectedNotificationEvent): { title: string; body: string } { const ref = `${event.repoFullName}#${event.pullNumber}`; + if (event.trigger === "reprioritized") { + return { + title: sanitizePublicComment(`Best issue right now on ${ref}`), + body: sanitizePublicComment(`A watched issue on ${ref} moved into your strongest current cross-repo shortlist. Re-check it now if you want the best current fit from your lane and queue context.`), + }; + } + if (event.trigger === "aging") { + return { + title: sanitizePublicComment(`Aging issue worth revisiting on ${ref}`), + body: sanitizePublicComment(`A watched issue on ${ref} is still a strong current fit and has aged in the queue. Re-check it now if you want a mature target that still lines up with your filters.`), + }; + } return { title: sanitizePublicComment(`New issue to grab on ${ref}`), body: sanitizePublicComment(`A new maintainer-created issue opened on ${ref} that is open for you to grab. Maintainer-created issues are strong early targets on ${event.repoFullName} — claim it to line up your next contribution.`), @@ -78,9 +90,14 @@ export async function detectIssueWatchEvents(env: Env, repoFullName: string, iss const detectedAt = nowIso(); const issueLabels = new Set(issue.labels.map((label) => label.toLowerCase().trim())); const authorLogin = issue.authorLogin?.toLowerCase(); + const issueAgeDays = (() => { + const parsed = Date.parse(issue.createdAt ?? issue.updatedAt ?? ""); + return Number.isFinite(parsed) ? Math.max(0, Math.floor((Date.now() - parsed) / 86_400_000)) : 0; + })(); const matching = watchers // An empty label filter matches any issue; otherwise at least one watched label must be present. .filter((watcher) => watcher.labels.length === 0 || watcher.labels.some((label) => issueLabels.has(label))) + .filter((watcher) => !watcher.freshnessDays || issueAgeDays <= watcher.freshnessDays) // Don't ping the maintainer who opened the issue about their own issue. .filter((watcher) => watcher.login.toLowerCase() !== authorLogin); @@ -89,15 +106,17 @@ export async function detectIssueWatchEvents(env: Env, repoFullName: string, iss // reach a non-collaborator. The repo is the same for all watchers, so resolve it once and only pay the // per-watcher access check on the private path. const repo = await getRepository(env, repoFullName); + const repoLane = buildLaneAdvice(repo, repoFullName).lane; const authorizedWatchers = repo && !repo.isPrivate - ? matching - : (await Promise.all(matching.map(async (watcher) => ((repo && (await canLoginAccessRepo(env, watcher.login, repoFullName))) ? watcher : null)))).filter( - (watcher) => watcher !== null, - ); + ? matching.filter((watcher) => watcher.lanes.length === 0 || watcher.lanes.includes(repoLane)) + : ( + await Promise.all(matching.map(async (watcher) => ((repo && (await canLoginAccessRepo(env, watcher.login, repoFullName))) ? watcher : null))) + ).filter((watcher): watcher is NonNullable => watcher !== null && (watcher.lanes.length === 0 || watcher.lanes.includes(repoLane))); return authorizedWatchers.map((watcher) => ({ eventType: "issue_watch_match" as const, + trigger: "opened" as const, recipientLogin: watcher.login, repoFullName, pullNumber: issue.number, // carries the ISSUE number for this eventType diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6d5bacfad7..469a7167ee 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -95,6 +95,7 @@ import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory, evaluateGateCheck, isTestPath } from "../rules/advisory"; import { detectNotificationEvents } from "../notifications/events"; import { deliverNotification, detectIssueWatchEvents, evaluateNotificationEvent } from "../notifications/service"; +import { detectDecisionPackOpportunityEvents } from "../services/opportunity-discovery"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; import { buildAndPersistContributorDecisionPack, loadDecisionPackSharedInputs } from "../services/decision-pack"; import { @@ -372,7 +373,9 @@ async function buildContributorDecisionPacks(env: Env, login?: string): Promise< const shared = await loadDecisionPackSharedInputs(env); for (const contributorLogin of logins) { try { - await buildAndPersistContributorDecisionPack(env, contributorLogin, shared); + const pack = await buildAndPersistContributorDecisionPack(env, contributorLogin, shared); + const opportunityEvents = await detectDecisionPackOpportunityEvents(env, pack, shared.allIssues); + await Promise.all(opportunityEvents.map((event) => env.JOBS.send({ type: "notify-evaluate", requestedBy: "api", event }))); } catch (error) { // Isolate per-login failures so one bad login can't fail the whole batch (which would re-run // from the first login on retry and poison-pill the queue) (#787). diff --git a/src/services/opportunity-discovery.ts b/src/services/opportunity-discovery.ts new file mode 100644 index 0000000000..f2aef34b6f --- /dev/null +++ b/src/services/opportunity-discovery.ts @@ -0,0 +1,201 @@ +import { listIssueWatchSubscriptionsForLogin } from "../db/repositories"; +import type { ParticipationLane, ContributorOpportunity } from "../signals/engine"; +import type { DetectedNotificationEvent, IssueRecord, IssueWatchSubscription } from "../types"; +import type { ContributorDecisionPack } from "./decision-pack"; +import { nowIso } from "../utils/json"; + +const PRIORITIZED_ALERT_MAX_RANK = 3; +const AGING_ALERT_MIN_DAYS = 14; + +export type OpportunityDiscoveryFilters = { + lanes?: ParticipationLane[] | undefined; + labels?: string[] | undefined; + freshnessDays?: number | undefined; + limit?: number | undefined; +}; + +export type OpportunityDiscoveryItem = { + rank: number; + repoFullName: string; + issueNumber: number; + title: string; + lane: ParticipationLane; + fit: ContributorOpportunity["fit"]; + availability: ContributorOpportunity["availability"]; + multiplierTier: ContributorOpportunity["multiplierTier"]; + priorityBand: "top" | "high" | "watch"; + freshness: { + ageDays: number; + band: "new" | "fresh" | "recent" | "stale"; + }; + labels: string[]; + whyNow: string[]; + cautions: string[]; +}; + +export type OpportunityDiscoveryResult = { + login: string; + generatedAt: string; + freshness: ContributorDecisionPack["freshness"]; + summary: string; + filters: { + lanes: ParticipationLane[]; + labels: string[]; + freshnessDays?: number | undefined; + limit: number; + }; + opportunities: OpportunityDiscoveryItem[]; +}; + +type DecoratedOpportunity = { + opportunity: ContributorOpportunity; + issue: IssueRecord; + rank: number; + ageDays: number; +}; + +function issueKey(repoFullName: string, issueNumber: number): string { + return `${repoFullName.toLowerCase()}#${issueNumber}`; +} + +function normalizeLabels(labels?: string[] | undefined): string[] { + return [...new Set((labels ?? []).map((label) => label.toLowerCase().trim()).filter(Boolean))]; +} + +function normalizeLanes(lanes?: ParticipationLane[] | undefined): ParticipationLane[] { + return [...new Set((lanes ?? []).map((lane) => lane.trim().toLowerCase() as ParticipationLane).filter(Boolean))]; +} + +function issueAgeDays(issue: IssueRecord): number { + const raw = issue.createdAt ?? issue.updatedAt; + if (!raw) return 0; + const parsed = Date.parse(raw); + if (!Number.isFinite(parsed)) return 0; + return Math.max(0, Math.floor((Date.now() - parsed) / 86_400_000)); +} + +function freshnessBand(ageDays: number): OpportunityDiscoveryItem["freshness"]["band"] { + if (ageDays <= 3) return "new"; + if (ageDays <= 14) return "fresh"; + if (ageDays <= 45) return "recent"; + return "stale"; +} + +function priorityBand(rank: number): OpportunityDiscoveryItem["priorityBand"] { + if (rank <= 3) return "top"; + if (rank <= 10) return "high"; + return "watch"; +} + +function decoratePackOpportunities(pack: ContributorDecisionPack, issues: IssueRecord[]): DecoratedOpportunity[] { + const issuesByKey = new Map(issues.map((issue) => [issueKey(issue.repoFullName, issue.number), issue] as const)); + return pack.opportunities + .map((opportunity, index) => { + if (typeof opportunity.issueNumber !== "number") return null; + const issue = issuesByKey.get(issueKey(opportunity.repoFullName, opportunity.issueNumber)); + if (!issue) return null; + return { + opportunity, + issue, + rank: index + 1, + ageDays: issueAgeDays(issue), + }; + }) + .filter((entry): entry is DecoratedOpportunity => entry !== null); +} + +function matchesSubscription(entry: DecoratedOpportunity, subscription: IssueWatchSubscription): boolean { + const watchedLabels = new Set(subscription.labels); + const issueLabels = new Set(entry.issue.labels.map((label) => label.toLowerCase().trim())); + if (subscription.lanes.length > 0 && !subscription.lanes.includes(entry.opportunity.lane)) return false; + if (subscription.freshnessDays && entry.ageDays > subscription.freshnessDays) return false; + if (watchedLabels.size > 0 && !subscription.labels.some((label) => issueLabels.has(label))) return false; + return true; +} + +function matchesFilters(entry: DecoratedOpportunity, filters: OpportunityDiscoveryFilters): boolean { + const lanes = normalizeLanes(filters.lanes); + const labels = normalizeLabels(filters.labels); + const issueLabels = new Set(entry.issue.labels.map((label) => label.toLowerCase().trim())); + if (lanes.length > 0 && !lanes.includes(entry.opportunity.lane)) return false; + if (typeof filters.freshnessDays === "number" && entry.ageDays > filters.freshnessDays) return false; + if (labels.length > 0 && !labels.some((label) => issueLabels.has(label))) return false; + return true; +} + +export function buildOpportunityDiscoveryResult( + pack: ContributorDecisionPack, + issues: IssueRecord[], + filters: OpportunityDiscoveryFilters = {}, +): OpportunityDiscoveryResult { + const limit = Math.min(25, Math.max(1, filters.limit ?? 10)); + const lanes = normalizeLanes(filters.lanes); + const labels = normalizeLabels(filters.labels); + const decorated = decoratePackOpportunities(pack, issues).filter((entry) => matchesFilters(entry, filters)).slice(0, limit); + const opportunities = decorated.map((entry) => ({ + rank: entry.rank, + repoFullName: entry.opportunity.repoFullName, + issueNumber: entry.issue.number, + title: entry.opportunity.title, + lane: entry.opportunity.lane, + fit: entry.opportunity.fit, + availability: entry.opportunity.availability, + multiplierTier: entry.opportunity.multiplierTier, + priorityBand: priorityBand(entry.rank), + freshness: { ageDays: entry.ageDays, band: freshnessBand(entry.ageDays) }, + labels: entry.issue.labels, + whyNow: entry.opportunity.reasons.slice(0, 4), + cautions: entry.opportunity.warnings.slice(0, 4), + })); + return { + login: pack.login, + generatedAt: pack.generatedAt, + freshness: pack.freshness, + summary: opportunities.length > 0 + ? `${pack.login} has ${opportunities.length} ranked cross-repo issue candidate(s) ready to inspect now.` + : `${pack.login} has no ranked cross-repo issue candidates matching the current filters.`, + filters: { + lanes, + labels, + ...(typeof filters.freshnessDays === "number" ? { freshnessDays: filters.freshnessDays } : {}), + limit, + }, + opportunities, + }; +} + +export async function detectDecisionPackOpportunityEvents( + env: Env, + pack: ContributorDecisionPack, + issues: IssueRecord[], +): Promise { + const subscriptions = await listIssueWatchSubscriptionsForLogin(env, pack.login); + if (subscriptions.length === 0) return []; + const decorated = decoratePackOpportunities(pack, issues); + const events = new Map(); + for (const subscription of subscriptions) { + const matches = decorated.filter((entry) => entry.opportunity.fit === "good" && entry.opportunity.availability === "ready" && matchesSubscription(entry, subscription)); + const top = matches[0]; + if (!top) continue; + const trigger = + top.rank <= PRIORITIZED_ALERT_MAX_RANK + ? "reprioritized" + : top.ageDays >= AGING_ALERT_MIN_DAYS + ? "aging" + : null; + if (!trigger) continue; + const dedupKey = `issue_watch_match:${trigger}:${top.opportunity.repoFullName}#${top.issue.number}:${pack.login.toLowerCase()}`; + events.set(dedupKey, { + eventType: "issue_watch_match", + trigger, + recipientLogin: pack.login, + repoFullName: top.opportunity.repoFullName, + pullNumber: top.issue.number, + dedupKey, + deeplink: `https://github.com/${top.opportunity.repoFullName}/issues/${top.issue.number}`, + actorLogin: top.issue.authorLogin ?? "unknown", + detectedAt: nowIso(), + }); + } + return [...events.values()]; +} diff --git a/src/services/subnet-interface.ts b/src/services/subnet-interface.ts index 3d5cdb4ad3..0c926cee38 100644 --- a/src/services/subnet-interface.ts +++ b/src/services/subnet-interface.ts @@ -10,6 +10,7 @@ const SUBNET_INTERFACE_SCHEMA_VERSION = "1.0"; // Names mirror src/mcp/server.ts registrations; the list is intentionally a miner-facing subset (not all 33). const CONTRIBUTION_MCP_TOOLS: ReadonlyArray<{ name: string; summary: string }> = [ { name: "gittensory_get_decision_pack", summary: "Surface contribution candidates across registered repos with duplicate-risk context." }, + { name: "gittensory_find_opportunities", summary: "Rank the best cross-repo issues to build right now with lane, freshness, and queue context." }, { name: "gittensory_check_before_start", summary: "Check whether an issue is already claimed or solved before writing code." }, { name: "gittensory_validate_linked_issue", summary: "Confirm whether a planned PR has a linked issue before opening it." }, { name: "gittensory_preflight_pr", summary: "Preflight a planned PR for lane fit, duplicate risk, and review burden." }, @@ -21,7 +22,7 @@ const CONTRIBUTION_MCP_TOOLS: ReadonlyArray<{ name: string; summary: string }> = const ONBOARDING_STEPS: ReadonlyArray = [ "Maintainers: install the Gittensory GitHub App on a gittensor-registered repository.", "Contributors (miners): connect the Gittensory MCP endpoint in your agent harness (Claude Code, Cursor, etc.).", - "Use gittensory_get_decision_pack to find high-fit, low-duplicate issues, then gittensory_check_before_start before writing code.", + "Use gittensory_find_opportunities or gittensory_get_decision_pack to find high-fit, low-duplicate issues, then gittensory_check_before_start before writing code.", "Preflight with gittensory_preflight_pr and open a focused PR linked to its issue.", ]; diff --git a/src/signals/engine.ts b/src/signals/engine.ts index bb28c28c4b..21026d602c 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1344,10 +1344,12 @@ export function buildContributorOpportunities( const quality = qualityByIssue?.get(issue.number); const bounty = bountyByIssue.get(bountyIssueKey(repo.fullName, issue.number)) ?? null; const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null; + const ageDays = daysSince(issue.createdAt ?? issue.updatedAt); // Never steer contributors toward completed, cancelled, or otherwise historical bounty work. if (bountyLifecycle && isHistoricalBountyLifecycle(bountyLifecycle)) continue; const bountyPenalty = bountyLifecycle === "stale" || bountyLifecycle === "ambiguous" ? 30 : 0; const labelFit = issue.labels.filter((label) => labelHistory.has(label)).length; + const freshnessAdjustment = ageDays <= 3 ? 10 : ageDays <= 14 ? 6 : ageDays <= 30 ? 3 : ageDays > 120 ? -8 : 0; const qualityAdjustment = quality?.status === "ready" ? 10 @@ -1371,7 +1373,8 @@ export function buildContributorOpportunities( (lane.lane === "split" ? 8 : 0) + (lane.lane === "direct_pr" ? 5 : 0) - queuePenalty - - bountyPenalty - + bountyPenalty + + freshnessAdjustment - (lane.lane === "inactive" || lane.lane === "unknown" ? 35 : 0) + qualityAdjustment + multiplierBoost - @@ -1395,6 +1398,7 @@ export function buildContributorOpportunities( ...(maintainerAuthored && !maintainerWip ? ["Maintainer-created issue — typically the highest contribution multiplier on Gittensor."] : []), ...(touchedRepos.has(repo.fullName) ? ["Contributor has prior activity in this registered repo."] : []), ...(labelFit > 0 ? [`Issue labels overlap contributor history: ${issue.labels.filter((label) => labelHistory.has(label)).join(", ")}.`] : []), + ...(ageDays <= 14 ? ["Issue opened recently, so freshness still favors moving now."] : []), ...(bountyLifecycle === "active" ? ["An active bounty is attached as contribution context (not guaranteed payout)."] : []), ...(quality?.status === "ready" ? ["Issue quality report rates this issue as ready."] : []), ], @@ -1404,6 +1408,7 @@ export function buildContributorOpportunities( ...(repoPullRequests.length >= 8 ? ["This repo has a busy open PR queue."] : []), ...(lane.lane === "issue_discovery" ? ["This repo is not a direct-PR-first lane."] : []), ...(lane.lane === "unknown" || lane.lane === "inactive" ? ["Gittensory cannot recommend this as a strong contribution target right now."] : []), + ...(ageDays > 120 ? ["This issue is old enough that freshness no longer helps it."] : []), ...(bountyLifecycle === "stale" ? ["Attached bounty context looks stale; confirm it is still active before acting."] : []), ...(bountyLifecycle === "ambiguous" ? ["Attached bounty state is ambiguous; verify it before acting."] : []), ...(quality?.status === "needs_proof" ? ["Issue quality report flags this issue as needing more proof before acting."] : []), diff --git a/src/types.ts b/src/types.ts index 71b10d4edb..8b107dda05 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1283,6 +1283,8 @@ export type IssueWatchSubscription = { login: string; repoFullName: string; labels: string[]; + lanes: Array<"direct_pr" | "issue_discovery" | "split" | "inactive" | "unknown">; + freshnessDays?: number | null | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; }; @@ -1290,6 +1292,7 @@ export type IssueWatchSubscription = { // A notification-worthy event extracted from a webhook payload (src/notifications/events.ts). export type DetectedNotificationEvent = { eventType: NotificationEventType; + trigger?: "opened" | "aging" | "reprioritized" | undefined; recipientLogin: string; repoFullName: string; pullNumber: number; diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 7f2bf062a6..eb13bddc1d 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -4889,9 +4889,9 @@ describe("api routes", () => { expect(toolNames).toContain("gittensory_agent_get_run"); expect(toolNames).toContain("gittensory_agent_explain_next_action"); expect(toolNames).toContain("gittensory_agent_prepare_pr_packet"); + expect(toolNames).toContain("gittensory_find_opportunities"); for (const removed of [ "gittensory_get_contributor_fit", - "gittensory_find_opportunities", "gittensory_get_contribution_strategy", "gittensory_explain_reward_risk", "gittensory_rank_next_actions", diff --git a/test/unit/issue-watch.test.ts b/test/unit/issue-watch.test.ts index 98bfb78594..fdcfeb8261 100644 --- a/test/unit/issue-watch.test.ts +++ b/test/unit/issue-watch.test.ts @@ -16,7 +16,18 @@ import type { IssueRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; function issue(over: Partial = {}): IssueRecord { - return { repoFullName: "owner/repo", number: 5, title: "Add retry to sync", state: "open", authorAssociation: "OWNER", authorLogin: "maintainer", labels: [], linkedPrs: [], ...over }; + return { + repoFullName: "owner/repo", + number: 5, + title: "Add retry to sync", + state: "open", + authorAssociation: "OWNER", + authorLogin: "maintainer", + labels: [], + linkedPrs: [], + createdAt: "2026-06-14T00:00:00.000Z", + ...over, + }; } describe("isGrabbableHighMultiplierIssue (#699)", () => { @@ -31,16 +42,18 @@ describe("isGrabbableHighMultiplierIssue (#699)", () => { describe("issue-watch subscriptions (CRUD)", () => { it("subscribes idempotently, lists, normalizes labels, and unwatches", async () => { const env = createTestEnv(); - await upsertIssueWatchSubscription(env, { login: "Miner", repoFullName: "owner/repo", labels: ["Bug", " good first issue "] }); + await upsertIssueWatchSubscription(env, { login: "Miner", repoFullName: "owner/repo", labels: ["Bug", " good first issue "], lanes: ["split"], freshnessDays: 21 }); let mine = await listIssueWatchSubscriptionsForLogin(env, "miner"); expect(mine).toHaveLength(1); - expect(mine[0]).toMatchObject({ repoFullName: "owner/repo", labels: ["bug", "good first issue"] }); // lowercased + trimmed + expect(mine[0]).toMatchObject({ repoFullName: "owner/repo", labels: ["bug", "good first issue"], lanes: ["split"], freshnessDays: 21 }); // lowercased + trimmed // Re-subscribe (idempotent on login+repo) updates the label filter, not a duplicate row. - await upsertIssueWatchSubscription(env, { login: "miner", repoFullName: "owner/repo", labels: [] }); + await upsertIssueWatchSubscription(env, { login: "miner", repoFullName: "owner/repo", labels: [], lanes: [], freshnessDays: undefined }); mine = await listIssueWatchSubscriptionsForLogin(env, "miner"); expect(mine).toHaveLength(1); expect(mine[0]!.labels).toEqual([]); + expect(mine[0]!.lanes).toEqual([]); + expect(mine[0]!.freshnessDays).toBeNull(); // Watchers-for-repo lists across logins. await upsertIssueWatchSubscription(env, { login: "other", repoFullName: "owner/repo" }); @@ -131,7 +144,7 @@ describe("detectIssueWatchEvents", () => { await upsertIssueWatchSubscription(env, { login: "alice", repoFullName: "owner/repo" }); const events = await detectIssueWatchEvents(env, "owner/repo", issue({ number: 12, authorLogin: undefined, authorAssociation: "MEMBER" })); expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ recipientLogin: "alice", actorLogin: "unknown", pullNumber: 12 }); + expect(events[0]).toMatchObject({ recipientLogin: "alice", actorLogin: "unknown", pullNumber: 12, trigger: "opened" }); }); }); @@ -155,6 +168,34 @@ describe("buildIssueWatchNotification", () => { const event = { eventType: "issue_watch_match" as const, recipientLogin: "alice", repoFullName: "owner/repo", pullNumber: 9, dedupKey: "k", deeplink: "https://github.com/owner/repo/issues/9", actorLogin: "maintainer", detectedAt: "2026-06-14T00:00:00.000Z" }; expect(buildNotificationContent(event).title).toContain("New issue to grab on owner/repo#9"); }); + + it("renders distinct copy for reprioritized and aging issue alerts", () => { + const reprioritized = buildIssueWatchNotification({ + eventType: "issue_watch_match", + trigger: "reprioritized", + recipientLogin: "alice", + repoFullName: "owner/repo", + pullNumber: 9, + dedupKey: "k1", + deeplink: "https://github.com/owner/repo/issues/9", + actorLogin: "maintainer", + detectedAt: "2026-06-14T00:00:00.000Z", + }); + expect(reprioritized.title).toContain("Best issue right now"); + + const aging = buildIssueWatchNotification({ + eventType: "issue_watch_match", + trigger: "aging", + recipientLogin: "alice", + repoFullName: "owner/repo", + pullNumber: 9, + dedupKey: "k2", + deeplink: "https://github.com/owner/repo/issues/9", + actorLogin: "maintainer", + detectedAt: "2026-06-14T00:00:00.000Z", + }); + expect(aging.title).toContain("Aging issue worth revisiting"); + }); }); async function connect(env: Env, identity?: AuthIdentity) { @@ -173,7 +214,7 @@ describe("MCP gittensory_watch_issues", () => { const watched = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "watch", repoFullName: "owner/repo", labels: ["bug"] } }); expect(watched.isError).toBeFalsy(); - expect((watched.structuredContent as { watching: Array<{ repoFullName: string }> }).watching).toEqual([{ repoFullName: "owner/repo", labels: ["bug"] }]); + expect((watched.structuredContent as { watching: Array<{ repoFullName: string }> }).watching).toEqual([{ repoFullName: "owner/repo", labels: ["bug"], lanes: [], freshnessDays: null }]); const listed = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "list" } }); expect((listed.structuredContent as { watching: unknown[] }).watching).toHaveLength(1); @@ -192,7 +233,7 @@ describe("MCP gittensory_watch_issues", () => { const result = await client.callTool({ name: "gittensory_watch_issues", arguments: { login: "miner", action: "watch", repoFullName: "owner/repo" } }); expect(result.isError).toBeFalsy(); - expect((result.structuredContent as { watching: Array<{ repoFullName: string }> }).watching).toEqual([{ repoFullName: "owner/repo", labels: [] }]); + expect((result.structuredContent as { watching: Array<{ repoFullName: string }> }).watching).toEqual([{ repoFullName: "owner/repo", labels: [], lanes: [], freshnessDays: null }]); }); it("blocks session actors from watching inaccessible (private) repositories", async () => { diff --git a/test/unit/opportunity-discovery.test.ts b/test/unit/opportunity-discovery.test.ts new file mode 100644 index 0000000000..923336d0b1 --- /dev/null +++ b/test/unit/opportunity-discovery.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { upsertIssueWatchSubscription } from "../../src/db/repositories"; +import { buildOpportunityDiscoveryResult, detectDecisionPackOpportunityEvents } from "../../src/services/opportunity-discovery"; +import type { ContributorDecisionPack } from "../../src/services/decision-pack"; +import type { ContributorOpportunity } from "../../src/signals/engine"; +import type { IssueRecord } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +function opportunity(over: Partial = {}): ContributorOpportunity { + return { + repoFullName: "owner/repo", + issueNumber: 7, + title: "Ship cached ranking", + fit: "good", + score: 91, + lane: "split", + multiplierTier: "maintainer_created", + availability: "ready", + reasons: ["Repository is configured for both issue discovery and direct PR review.", "Issue quality report rates this issue as ready."], + warnings: [], + ...over, + }; +} + +function pack(opportunities: ContributorOpportunity[]): ContributorDecisionPack { + return { + status: "ready", + source: "snapshot", + login: "miner", + generatedAt: "2026-06-23T00:00:00.000Z", + stale: false, + freshness: "fresh", + rebuildEnqueued: false, + scoringModelSnapshotId: "snapshot", + profile: { + login: "miner", + github: { login: "miner", name: null, followers: 0, publicRepos: 0, topLanguages: [], source: "github" }, + source: "github_cache", + officialStats: null, + registeredRepoActivity: { reposTouched: [], pullRequests: 0, mergedPullRequests: 0, dominantLabels: [] }, + trustSignals: { evidenceScore: 0, level: "new", unlinkedOpenPullRequests: 0, maintainerAssociatedPullRequests: 0 }, + }, + outcomeHistory: { login: "miner", generatedAt: "2026-06-23T00:00:00.000Z", source: "github_cache", totals: { pullRequests: 0, mergedPullRequests: 0, openPullRequests: 0, issues: 0 }, repoOutcomes: [] }, + roleContexts: [], + opportunities, + repoDecisions: [], + topActions: [], + actionPortfolio: { generatedAt: "2026-06-23T00:00:00.000Z", bucketOrder: [], buckets: [], topActions: [], counts: { cleanup: 0, wait: 0, direct_pr: 0, issue_discovery: 0, avoid: 0, maintainer_lane: 0 }, summary: "" }, + cleanupFirst: [], + pursueRepos: [], + avoidRepos: [], + maintainerLaneRepos: [], + scoreBlockers: [], + recommendationOutcomeFeedback: { totals: { total: 0, positive: 0, negative: 0, merged: 0, rejected: 0, closed: 0, stale: 0, ignored: 0, improved: 0, maintainerLaneTotal: 0 }, repos: [] } as never, + dataQuality: { signalFidelity: { status: "complete", warnings: [], updatedAt: "2026-06-23T00:00:00.000Z" } as never }, + summary: "", + nextActions: [], + } as unknown as ContributorDecisionPack; +} + +describe("buildOpportunityDiscoveryResult", () => { + it("returns a deterministic cross-repo shortlist without exposing raw scores", () => { + const result = buildOpportunityDiscoveryResult( + pack([ + opportunity({ repoFullName: "owner/repo", issueNumber: 7, title: "Ship cached ranking" }), + opportunity({ repoFullName: "other/repo", issueNumber: 11, title: "Tighten docs", lane: "direct_pr", multiplierTier: "community", score: 70 }), + ]), + [ + { repoFullName: "owner/repo", number: 7, title: "Ship cached ranking", state: "open", labels: ["bug"], linkedPrs: [], createdAt: "2026-06-22T00:00:00.000Z" }, + { repoFullName: "other/repo", number: 11, title: "Tighten docs", state: "open", labels: ["docs"], linkedPrs: [], createdAt: "2026-05-01T00:00:00.000Z" }, + ], + { lanes: ["split"], labels: ["bug"], freshnessDays: 14 }, + ); + expect(result.opportunities).toHaveLength(1); + expect(result.opportunities[0]).toMatchObject({ + repoFullName: "owner/repo", + issueNumber: 7, + lane: "split", + labels: ["bug"], + }); + expect(JSON.stringify(result)).not.toMatch(/\"score\":/); + }); +}); + +describe("detectDecisionPackOpportunityEvents", () => { + it("emits a reprioritized alert for a matching top-ranked watched issue", async () => { + const env = createTestEnv(); + await upsertIssueWatchSubscription(env, { login: "miner", repoFullName: "owner/repo", lanes: ["split"], labels: ["bug"], freshnessDays: 30 }); + const events = await detectDecisionPackOpportunityEvents( + env, + pack([opportunity({ repoFullName: "owner/repo", issueNumber: 7, title: "Ship cached ranking" })]), + [{ repoFullName: "owner/repo", number: 7, title: "Ship cached ranking", state: "open", labels: ["bug"], linkedPrs: [], createdAt: "2026-06-20T00:00:00.000Z", authorLogin: "maintainer" } satisfies IssueRecord], + ); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "issue_watch_match", + trigger: "reprioritized", + recipientLogin: "miner", + repoFullName: "owner/repo", + pullNumber: 7, + }); + }); +});