diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index dd00648326..8a6679b774 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -1962,6 +1962,12 @@ "$ref": "#/components/schemas/RoleContext" } }, + "opportunities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorOpportunity" + } + }, "repoDecisions": { "type": "array", "items": { @@ -2053,6 +2059,7 @@ "profile", "outcomeHistory", "roleContexts", + "opportunities", "repoDecisions", "topActions", "cleanupFirst", @@ -5148,9 +5155,16 @@ "enum": [ "active", "historical", + "completed", + "cancelled", + "stale", + "ambiguous", "unknown" ] }, + "isActiveOpportunity": { + "type": "boolean" + }, "fundingStatus": { "type": "string", "enum": [ @@ -5167,6 +5181,34 @@ "high" ] }, + "linkedPrs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "number" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged", + "unknown" + ] + }, + "isActive": { + "type": "boolean" + } + }, + "required": [ + "number", + "state", + "isActive" + ] + } + }, "findings": { "type": "array", "items": { @@ -5180,11 +5222,66 @@ "issueNumber", "status", "lifecycle", + "isActiveOpportunity", "fundingStatus", "consensusRisk", + "linkedPrs", "findings" ] }, + "BountyLifecycleEvents": { + "type": "object", + "properties": { + "bountyId": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "bountyId": { + "type": "string" + }, + "repoFullName": { + "type": "string" + }, + "issueNumber": { + "type": "number" + }, + "status": { + "type": "string" + }, + "payload": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "generatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "bountyId", + "repoFullName", + "issueNumber", + "status", + "payload", + "generatedAt" + ] + } + } + }, + "required": [ + "bountyId", + "events" + ] + }, "RepositorySettings": { "type": "object", "properties": { @@ -9087,6 +9184,33 @@ ] } }, + "/v1/bounties/{id}/lifecycle": { + "get": { + "responses": { + "200": { + "description": "Bounty lifecycle transition history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BountyLifecycleEvents" + } + } + } + }, + "404": { + "description": "Bounty not found" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/github/webhook": { "post": { "responses": { diff --git a/src/api/routes.ts b/src/api/routes.ts index 5b6b98163d..fbb12f7f32 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -40,6 +40,8 @@ import { listAllPullRequestDetailSyncStates, listCheckSummaries, listBounties, + listBountiesByRepo, + listBountyLifecycleEvents, listContributorIssues, listContributorPullRequests, listContributorRepoStats, @@ -64,6 +66,7 @@ import { listRepositories, getLatestUpstreamRulesetSnapshot, listUpstreamDriftReports, + persistBountyLifecycleEvent, persistScorePreview, persistSignalSnapshot, upsertDigestSubscription, @@ -134,7 +137,7 @@ import { buildPullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildRepoSettingsPreview } from "../signals/settings-preview"; import { fileUpstreamDriftIssues, loadUpstreamStatus, refreshUpstreamDrift } from "../upstream/ruleset"; -import type { ContributorEvidenceRecord, DataQuality, InstallationHealthRecord, JobMessage, JsonValue, RegistrySnapshot, RepoSyncSegmentRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; +import type { BountyLifecycleEventRecord, ContributorEvidenceRecord, DataQuality, InstallationHealthRecord, JobMessage, JsonValue, RegistrySnapshot, RepoSyncSegmentRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; import { errorMessage, nowIso } from "../utils/json"; type AppBindings = { Bindings: Env }; @@ -1173,26 +1176,28 @@ export function createApp() { const body = await c.req.json().catch(() => null); const parsed = preflightSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_preflight_request", issues: parsed.error.issues }, 400); - const [repo, issues, pullRequests, issueQuality] = await Promise.all([ + const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ getRepository(c.env, parsed.data.repoFullName), listIssues(c.env, parsed.data.repoFullName), listPullRequests(c.env, parsed.data.repoFullName), + listBountiesByRepo(c.env, parsed.data.repoFullName), loadOrComputeIssueQualityResponse(c.env, parsed.data.repoFullName), ]); - return c.json(buildPreflightResult(parsed.data, repo, issues, pullRequests, issueQuality?.report)); + return c.json(buildPreflightResult(parsed.data, repo, issues, pullRequests, bounties, issueQuality?.report)); }); app.post("/v1/preflight/local-diff", async (c) => { const body = await c.req.json().catch(() => null); const parsed = localDiffPreflightSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_diff_preflight_request", issues: parsed.error.issues }, 400); - const [repo, issues, pullRequests, issueQuality] = await Promise.all([ + const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ getRepository(c.env, parsed.data.repoFullName), listIssues(c.env, parsed.data.repoFullName), listPullRequests(c.env, parsed.data.repoFullName), + listBountiesByRepo(c.env, parsed.data.repoFullName), loadOrComputeIssueQualityResponse(c.env, parsed.data.repoFullName), ]); - return c.json(buildLocalDiffPreflightResult(parsed.data, repo, issues, pullRequests, issueQuality?.report)); + return c.json(buildLocalDiffPreflightResult(parsed.data, repo, issues, pullRequests, bounties, issueQuality?.report)); }); app.post("/v1/local/branch-analysis", async (c) => { @@ -1201,12 +1206,13 @@ export function createApp() { if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); const unauthorized = await requireContributorAccess(c, parsed.data.login); if (unauthorized) return unauthorized; - const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot, issueQuality] = await Promise.all([ + const [context, repo, issues, pullRequests, recentMergedPullRequests, bounties, snapshot, issueQuality] = await Promise.all([ loadContributorFastContext(c.env, parsed.data.login), getRepository(c.env, parsed.data.repoFullName), listIssues(c.env, parsed.data.repoFullName), listPullRequests(c.env, parsed.data.repoFullName), listRecentMergedPullRequests(c.env, parsed.data.repoFullName), + listBountiesByRepo(c.env, parsed.data.repoFullName), getOrCreateScoringModelSnapshot(c.env), loadOrComputeIssueQualityResponse(c.env, parsed.data.repoFullName), ]); @@ -1220,6 +1226,7 @@ export function createApp() { pullRequests, contributorPullRequests: context.contributorPullRequests, recentMergedPullRequests, + bounties, repositories: context.repositories, checkSummaries, profile: context.profile, @@ -1308,11 +1315,19 @@ export function createApp() { app.get("/v1/bounties/:id/advisory", async (c) => { const bounty = await getBounty(c.env, c.req.param("id")); if (!bounty) return c.json({ error: "bounty_not_found" }, 404); - const [repo, issue] = await Promise.all([ + const [repo, issue, pullRequests] = await Promise.all([ getRepository(c.env, bounty.repoFullName), getIssue(c.env, bounty.repoFullName, bounty.issueNumber), + listPullRequests(c.env, bounty.repoFullName), ]); - return c.json(buildBountyAdvisory(bounty, repo, issue)); + return c.json(buildBountyAdvisory(bounty, repo, issue, pullRequests)); + }); + + app.get("/v1/bounties/:id/lifecycle", async (c) => { + const id = c.req.param("id"); + const bounty = await getBounty(c.env, id); + if (!bounty) return c.json({ error: "bounty_not_found" }, 404); + return c.json({ bountyId: id, events: await listBountyLifecycleEvents(c.env, id) }); }); app.post("/v1/github/webhook", handleGitHubWebhook); @@ -1504,8 +1519,24 @@ export function createApp() { app.post("/v1/internal/bounties/import", async (c) => { const body = await c.req.json().catch(() => null); const bounties = normalizeGittBountySnapshot(body); - await Promise.all(bounties.map((bounty) => upsertBounty(c.env, bounty))); - return c.json({ ok: true, imported: bounties.length }); + const events: BountyLifecycleEventRecord[] = []; + for (const bounty of bounties) { + const existing = await getBounty(c.env, bounty.id); + await upsertBounty(c.env, bounty); + if (!existing || existing.status !== bounty.status) { + events.push({ + id: crypto.randomUUID(), + bountyId: bounty.id, + repoFullName: bounty.repoFullName, + issueNumber: bounty.issueNumber, + status: bounty.status, + payload: { previousStatus: existing?.status ?? null, source: "gitt_import" }, + generatedAt: nowIso(), + }); + } + } + await Promise.all(events.map((event) => persistBountyLifecycleEvent(c.env, event))); + return c.json({ ok: true, imported: bounties.length, lifecycleEvents: events.length }); }); app.post("/v1/internal/repos/:owner/:repo/settings", async (c) => { diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 1b10328a04..e6c4afe2dc 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1637,6 +1637,18 @@ export async function listBounties(env: Env): Promise { return rows.map(toBountyRecord); } +export async function listBountiesByRepo(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(bounties).where(eq(bounties.repoFullName, fullName)).orderBy(desc(bounties.updatedAt)).limit(500); + return rows.map(toBountyRecord); +} + +export async function listBountyLifecycleEvents(env: Env, bountyId: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(bountyLifecycleEvents).where(eq(bountyLifecycleEvents.bountyId, bountyId)).orderBy(desc(bountyLifecycleEvents.generatedAt)).limit(100); + return rows.map(toBountyLifecycleEventRecord); +} + export async function getBounty(env: Env, id: string): Promise { const db = getDb(env.DB); const [row] = await db.select().from(bounties).where(eq(bounties.id, id)).limit(1); @@ -2464,6 +2476,18 @@ function toBountyRecord(row: typeof bounties.$inferSelect): BountyRecord { }; } +function toBountyLifecycleEventRecord(row: typeof bountyLifecycleEvents.$inferSelect): BountyLifecycleEventRecord { + return { + id: row.id, + bountyId: row.bountyId, + repoFullName: row.repoFullName, + issueNumber: row.issueNumber, + status: row.status, + payload: parseJson>(row.payloadJson, {}), + generatedAt: row.generatedAt, + }; +} + function toCollisionEdgeRecord(row: typeof collisionEdges.$inferSelect): CollisionEdgeRecord { return { id: row.id, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d3561956cb..7d20c72cae 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,6 +7,7 @@ import { countOpenIssues, countOpenPullRequests, getBounty, + listBountiesByRepo, getContributorEvidence, getLatestRepoGithubTotalsSnapshot, getIssue, @@ -662,28 +663,30 @@ export class GittensoryMcp { } private async preflightPr(input: z.infer>): Promise { - const [repo, issues, pullRequests, issueQuality] = await Promise.all([ + const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), + listBountiesByRepo(this.env, input.repoFullName), loadOrComputeIssueQualityResponse(this.env, input.repoFullName), ]); return { summary: `Gittensory PR preflight for ${input.repoFullName}.`, - data: buildPreflightResult(input, repo, issues, pullRequests, issueQuality?.report) as unknown as Record, + data: buildPreflightResult(input, repo, issues, pullRequests, bounties, issueQuality?.report) as unknown as Record, }; } private async preflightLocalDiff(input: z.infer>): Promise { - const [repo, issues, pullRequests, issueQuality] = await Promise.all([ + const [repo, issues, pullRequests, bounties, issueQuality] = await Promise.all([ getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), + listBountiesByRepo(this.env, input.repoFullName), loadOrComputeIssueQualityResponse(this.env, input.repoFullName), ]); return { summary: `Gittensory local diff preflight for ${input.repoFullName}.`, - data: buildLocalDiffPreflightResult(input, repo, issues, pullRequests, issueQuality?.report) as unknown as Record, + data: buildLocalDiffPreflightResult(input, repo, issues, pullRequests, bounties, issueQuality?.report) as unknown as Record, }; } @@ -703,12 +706,13 @@ export class GittensoryMcp { private async explainReviewRisk(input: z.infer>): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); - const [repo, issues, pullRequests] = await Promise.all([ + const [repo, issues, pullRequests, bounties] = await Promise.all([ getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), + listBountiesByRepo(this.env, input.repoFullName), ]); - const preflight = buildPreflightResult(input, repo, issues, pullRequests); + const preflight = buildPreflightResult(input, repo, issues, pullRequests, bounties); const roleContext = input.contributorLogin ? buildRoleContext({ login: input.contributorLogin, repo, repoFullName: input.repoFullName, pullRequests, issues }) : null; @@ -848,12 +852,13 @@ export class GittensoryMcp { private async analyzeLocalBranch(input: z.infer>) { this.requireContributorAccess(input.login); - const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot, issueQuality] = await Promise.all([ + const [context, repo, issues, pullRequests, recentMergedPullRequests, bounties, snapshot, issueQuality] = await Promise.all([ this.loadContributorFastContext(input.login), getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), listRecentMergedPullRequests(this.env, input.repoFullName), + listBountiesByRepo(this.env, input.repoFullName), getOrCreateScoringModelSnapshot(this.env), loadOrComputeIssueQualityResponse(this.env, input.repoFullName), ]); @@ -868,6 +873,7 @@ export class GittensoryMcp { pullRequests, contributorPullRequests: context.contributorPullRequests, recentMergedPullRequests, + bounties, repositories: context.repositories, checkSummaries, profile: context.profile, @@ -888,10 +894,14 @@ export class GittensoryMcp { private async getBountyAdvisory(id: string): Promise { const bounty = await getBounty(this.env, id); if (!bounty) throw new Error("Bounty not found."); - const [repo, issue] = await Promise.all([getRepository(this.env, bounty.repoFullName), getIssue(this.env, bounty.repoFullName, bounty.issueNumber)]); + const [repo, issue, pullRequests] = await Promise.all([ + getRepository(this.env, bounty.repoFullName), + getIssue(this.env, bounty.repoFullName, bounty.issueNumber), + listPullRequests(this.env, bounty.repoFullName), + ]); return { summary: `Gittensory bounty advisory for ${id}.`, - data: buildBountyAdvisory(bounty, repo, issue) as unknown as Record, + data: buildBountyAdvisory(bounty, repo, issue, pullRequests) as unknown as Record, }; } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 8c7c556d81..0ca1dcc1b0 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -422,13 +422,38 @@ export const BountyAdvisorySchema = z repoFullName: z.string(), issueNumber: z.number(), status: z.string(), - lifecycle: z.enum(["active", "historical", "unknown"]), + lifecycle: z.enum(["active", "historical", "completed", "cancelled", "stale", "ambiguous", "unknown"]), + isActiveOpportunity: z.boolean(), fundingStatus: z.enum(["funded", "target_only", "unknown"]), consensusRisk: z.enum(["low", "medium", "high"]), + linkedPrs: z.array( + z.object({ + number: z.number(), + state: z.enum(["open", "closed", "merged", "unknown"]), + isActive: z.boolean(), + }), + ), findings: z.array(FindingSchema), }) .openapi("BountyAdvisory"); +export const BountyLifecycleEventsSchema = z + .object({ + bountyId: z.string(), + events: z.array( + z.object({ + id: z.string(), + bountyId: z.string(), + repoFullName: z.string(), + issueNumber: z.number(), + status: z.string(), + payload: z.record(z.unknown()), + generatedAt: z.string(), + }), + ), + }) + .openapi("BountyLifecycleEvents"); + export const RepositorySettingsSchema = z .object({ repoFullName: z.string(), @@ -1172,6 +1197,7 @@ export const ContributorDecisionPackSchema = z profile: z.record(z.unknown()), outcomeHistory: ContributorOutcomeHistorySchema, roleContexts: z.array(RoleContextSchema), + opportunities: z.array(ContributorOpportunitySchema), repoDecisions: z.array(z.record(z.unknown())), topActions: z.array(z.record(z.unknown())), cleanupFirst: z.array(z.record(z.unknown())), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index a2e295d322..0c80a19118 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -7,6 +7,7 @@ import { AgentRunBundleSchema, AgentRunSchema, BountyAdvisorySchema, + BountyLifecycleEventsSchema, BountySchema, BurdenForecastSchema, CollisionReportSchema, @@ -105,6 +106,7 @@ export function buildOpenApiSpec() { registry.register("PullRequestReviewIntelligence", PullRequestReviewIntelligenceSchema); registry.register("Bounty", BountySchema); registry.register("BountyAdvisory", BountyAdvisorySchema); + registry.register("BountyLifecycleEvents", BountyLifecycleEventsSchema); registry.register("RepositorySettings", RepositorySettingsSchema); registry.register("RepoSettingsPreview", RepoSettingsPreviewSchema); registry.register("AgentRun", AgentRunSchema); @@ -450,6 +452,14 @@ export function buildOpenApiSpec() { 404: { description: "Bounty not found" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/bounties/{id}/lifecycle", + responses: { + 200: { description: "Bounty lifecycle transition history", content: { "application/json": { schema: BountyLifecycleEventsSchema } } }, + 404: { description: "Bounty not found" }, + }, + }); registry.registerPath({ method: "post", path: "/v1/github/webhook", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 0cb85f3402..4c162fda0d 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -8,6 +8,8 @@ import { getRepositorySettings, listAllIssues, listAllPullRequests, + listBounties, + listBountiesByRepo, listContributorIssues, listContributorPullRequests, listContributorRepoStats, @@ -58,7 +60,7 @@ import { fetchPublicContributorProfile } from "../github/public"; import { refreshRegistry } from "../registry/sync"; import { buildIssueAdvisory, buildPullRequestAdvisory } from "../rules/advisory"; import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; -import { buildAndPersistContributorDecisionPack } from "../services/decision-pack"; +import { buildAndPersistContributorDecisionPack, loadDecisionPackSharedInputs } from "../services/decision-pack"; import { executeAgentRun, explainBlockersWithAgent, planNextWork } from "../services/agent-orchestrator"; import { loadIssueQualityReportMap } from "../services/issue-quality"; import { @@ -212,7 +214,9 @@ export async function processJob(env: Env, message: JobMessage): Promise { async function buildContributorDecisionPacks(env: Env, login?: string): Promise { const logins = login ? [login] : await discoverContributorLogins(env); - for (const contributorLogin of logins) await buildAndPersistContributorDecisionPack(env, contributorLogin); + // Load the login-independent full-table datasets once, then reuse across every login instead of re-scanning per contributor. + const shared = await loadDecisionPackSharedInputs(env); + for (const contributorLogin of logins) await buildAndPersistContributorDecisionPack(env, contributorLogin, shared); } async function fanOutRepoSignalSnapshotJobs(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { @@ -299,11 +303,12 @@ async function discoverContributorLogins(env: Env): Promise { } async function buildContributorEvidence(env: Env, login?: string): Promise { - const [allPullRequests, allIssues, repositories, syncStates, snapshot] = await Promise.all([ + const [allPullRequests, allIssues, repositories, syncStates, allBounties, snapshot] = await Promise.all([ listAllPullRequests(env), listAllIssues(env), listRepositories(env), listRepoSyncStates(env), + listBounties(env), getOrCreateScoringModelSnapshot(env), ]); const logins = login ? [login] : [...new Set([...allPullRequests, ...allIssues].flatMap((record) => (record.authorLogin ? [record.authorLogin] : [])))].slice(0, 500); @@ -318,7 +323,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise ]); const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); const profile = buildContributorProfile(contributorLogin, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); - const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats, issueQualityByRepo); + const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats, allBounties, issueQualityByRepo); const scoringProfile = buildContributorScoringProfile({ login: contributorLogin, fit, scoringSnapshot: snapshot }); const outcomeHistory = buildContributorOutcomeHistory({ login: contributorLogin, profile, repositories, pullRequests: allPullRequests, issues: allIssues, repoStats, cachedRepoStats }); const strategy = buildContributorStrategy({ login: contributorLogin, fit, scoringProfile, scoringSnapshot: snapshot, outcomeHistory }); @@ -381,12 +386,13 @@ async function buildBurdenForecasts(env: Env, repoFullName?: string): Promise { const repositories = (await listRepositories(env)).filter((repo) => repo.isRegistered && (!repoFullName || repo.fullName === repoFullName)); for (const repo of repositories) { - const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([ + const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts, bounties] = await Promise.all([ listIssueSignalSample(env, repo.fullName), listOpenPullRequests(env, repo.fullName), listRecentMergedPullRequests(env, repo.fullName), listRepoLabels(env, repo.fullName), loadOpenQueueCounts(env, repo.fullName), + listBountiesByRepo(env, repo.fullName), ]); const collisions = buildCollisionReport(repo.fullName, issues, pullRequests, recentMergedPullRequests); const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions, queueCounts); @@ -395,7 +401,7 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): const maintainerLane = buildMaintainerLaneReport(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); const maintainerCutReadiness = buildMaintainerCutReadiness(repo, issues, pullRequests, repo.fullName, queueCounts, collisions); const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); - const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName, collisions, recentMergedPullRequests); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName, bounties, collisions, recentMergedPullRequests); await replaceCollisionEdges(env, repo.fullName, buildCollisionEdges(collisions)); const generatedAt = new Date().toISOString(); await persistSignalSnapshot(env, { @@ -608,11 +614,12 @@ async function maybePublishPrPublicSurface( minerStatus: "confirmed", }); - const [contributorPullRequests, contributorIssues, repoIssues, repoPullRequests, github, cachedRepoStats] = await Promise.all([ + const [contributorPullRequests, contributorIssues, repoIssues, repoPullRequests, repoBounties, github, cachedRepoStats] = await Promise.all([ listContributorPullRequests(env, author), listContributorIssues(env, author), listIssues(env, repoFullName), listPullRequests(env, repoFullName), + listBountiesByRepo(env, repoFullName), fetchPublicContributorProfile(author), listContributorRepoStats(env, author), ]); @@ -635,6 +642,7 @@ async function maybePublishPrPublicSurface( repo, repoIssues, repoPullRequests, + repoBounties, ); if (decision.willComment) { const body = buildPublicPrIntelligenceComment({ diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index d85ef886ba..4a136e3b15 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -2,6 +2,7 @@ import { createAgentRun, getAgentRun, getRepository, + listBountiesByRepo, listCheckSummaries, listAgentActions, listAgentContextSnapshots, @@ -142,10 +143,11 @@ export async function explainBlockersWithAgent(env: Env, input: AgentPlanRequest const login = input.login; const repoFullName = input.repoFullName; const isLocalBranch = "changedFiles" in input || "branchName" in input || "headRef" in input; + const surface = "surface" in input ? (input.surface ?? "api") : "api"; const run = buildRunRecord({ objective: `Explain scoreability and review blockers${repoFullName ? ` for ${repoFullName}` : ""}.`, actorLogin: login, - surface: isLocalBranch ? "api" : ((input as AgentPlanRequest).surface ?? "api"), + surface, status: "running", payload: isLocalBranch ? { kind: "explain_branch_blockers", input: input as unknown as Record } @@ -284,7 +286,7 @@ async function executeLocalBranchRun(env: Env, run: AgentRunRecord, kind: string } async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Promise { - const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot, repo, issues, pullRequests, recentMergedPullRequests, scoringSnapshot, issueQuality] = + const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot, repo, issues, pullRequests, recentMergedPullRequests, bounties, scoringSnapshot, issueQuality] = await Promise.all([ fetchPublicContributorProfile(input.login), listContributorPullRequests(env, input.login), @@ -297,6 +299,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr listIssues(env, input.repoFullName), listPullRequests(env, input.repoFullName), listRecentMergedPullRequests(env, input.repoFullName), + listBountiesByRepo(env, input.repoFullName), getOrCreateScoringModelSnapshot(env), loadOrComputeIssueQualityResponse(env, input.repoFullName), ]); @@ -313,6 +316,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr pullRequests, contributorPullRequests, recentMergedPullRequests, + bounties, repositories, checkSummaries, profile, diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index 87967ce3f2..7bd2558207 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -1,5 +1,8 @@ import { hasRecentAuditEvent, + listAllIssues, + listAllPullRequests, + listBounties, listContributorIssues, listContributorPullRequests, listContributorRepoStats, @@ -23,6 +26,7 @@ import { buildContributorScoringProfile, buildLaneAdvice, buildRoleContext, + type ContributorOpportunity, type ContributorOutcomeHistory, type ContributorProfile, type IssueQualityReport, @@ -30,7 +34,19 @@ import { } from "../signals/engine"; import { buildSignalFidelity } from "../signals/data-quality"; import { loadIssueQualityReportMap } from "./issue-quality"; -import type { ContributorRepoStatRecord, JsonValue, RepositoryRecord, RepoGithubTotalsSnapshotRecord, RepoSyncSegmentRecord, RepoSyncStateRecord, SignalSnapshotRecord } from "../types"; +import type { + BountyRecord, + ContributorRepoStatRecord, + IssueRecord, + JsonValue, + PullRequestRecord, + RepositoryRecord, + RepoGithubTotalsSnapshotRecord, + RepoSyncSegmentRecord, + RepoSyncStateRecord, + ScoringModelSnapshotRecord, + SignalSnapshotRecord, +} from "../types"; import { nowIso } from "../utils/json"; export const CONTRIBUTOR_DECISION_PACK_SIGNAL = "contributor-decision-pack"; @@ -62,6 +78,7 @@ export type ContributorDecisionPack = { }; outcomeHistory: ContributorOutcomeHistory; roleContexts: RoleContext[]; + opportunities: ContributorOpportunity[]; repoDecisions: RepoDecision[]; topActions: DecisionAction[]; cleanupFirst: RepoDecision[]; @@ -228,29 +245,45 @@ async function enqueueDecisionPackRebuild(env: Env, login: string): Promise { - const [ - github, - contributorPullRequests, - contributorIssues, - repositories, - syncStates, - syncSegments, - totals, - cachedRepoStats, - gittensorSnapshot, - scoringSnapshot, - ] = await Promise.all([ - fetchPublicContributorProfile(login), - listContributorPullRequests(env, login), - listContributorIssues(env, login), +/** + * Login-independent datasets used to build a decision pack. These are full-table reads, so the + * batch job loads them ONCE via {@link loadDecisionPackSharedInputs} and reuses them across logins + * instead of re-scanning per contributor. + */ +export type DecisionPackSharedInputs = { + repositories: RepositoryRecord[]; + syncStates: RepoSyncStateRecord[]; + syncSegments: RepoSyncSegmentRecord[]; + totals: RepoGithubTotalsSnapshotRecord[]; + allIssues: IssueRecord[]; + allPullRequests: PullRequestRecord[]; + bounties: BountyRecord[]; + scoringSnapshot: ScoringModelSnapshotRecord; +}; + +export async function loadDecisionPackSharedInputs(env: Env): Promise { + const [repositories, syncStates, syncSegments, totals, allIssues, allPullRequests, bounties, scoringSnapshot] = await Promise.all([ listRepositories(env), listRepoSyncStates(env), listRepoSyncSegments(env), listLatestRepoGithubTotalsSnapshots(env), + listAllIssues(env), + listAllPullRequests(env), + listBounties(env), + getOrCreateScoringModelSnapshot(env), + ]); + return { repositories, syncStates, syncSegments, totals, allIssues, allPullRequests, bounties, scoringSnapshot }; +} + +export async function buildAndPersistContributorDecisionPack(env: Env, login: string, shared?: DecisionPackSharedInputs): Promise { + // The heavy full-table reads are login-independent; reuse caller-provided context (batch job) or load once here (single-login run). + const { repositories, syncStates, syncSegments, totals, allIssues, allPullRequests, bounties, scoringSnapshot } = shared ?? (await loadDecisionPackSharedInputs(env)); + const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ + fetchPublicContributorProfile(login), + listContributorPullRequests(env, login), + listContributorIssues(env, login), listContributorRepoStats(env, login), fetchGittensorContributorSnapshot(login), - getOrCreateScoringModelSnapshot(env), ]); const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); const issueQualityByRepo = await loadIssueQualityReportMap(env, repositories); @@ -264,7 +297,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st repoStats, cachedRepoStats, }); - const fit = buildContributorFit(profile, repositories, [], [], syncStates, repoStats); + const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats, bounties, issueQualityByRepo); const scoringProfile = buildContributorScoringProfile({ login, fit, scoringSnapshot }); const pack = buildContributorDecisionPack({ login, @@ -274,6 +307,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st syncStates, syncSegments, totals, + opportunities: fit.opportunities, scoringModelSnapshotId: scoringSnapshot.id, contributorPullRequests, contributorIssues, @@ -323,6 +357,7 @@ function buildContributorDecisionPack(args: { syncStates: RepoSyncStateRecord[]; syncSegments: RepoSyncSegmentRecord[]; totals: RepoGithubTotalsSnapshotRecord[]; + opportunities?: ContributorOpportunity[] | undefined; scoringModelSnapshotId: string; contributorPullRequests: Parameters[0]["pullRequests"]; contributorIssues: Parameters[0]["issues"]; @@ -387,6 +422,7 @@ function buildContributorDecisionPack(args: { }, outcomeHistory: args.outcomeHistory, roleContexts: roleContexts.filter((role) => role.role !== "unknown" || role.maintainerLane), + opportunities: args.opportunities ?? [], repoDecisions, topActions, cleanupFirst: repoDecisions.filter((decision) => decision.recommendation === "cleanup_first").slice(0, 8), @@ -691,6 +727,7 @@ function withSnapshotMetadata(snapshot: SignalSnapshotRecord): ContributorDecisi stale, freshness: stale ? "stale" : "fresh", rebuildEnqueued: false, + opportunities: payload.opportunities ?? [], }; } diff --git a/src/services/issue-quality.ts b/src/services/issue-quality.ts index cd46723244..6bda202adb 100644 --- a/src/services/issue-quality.ts +++ b/src/services/issue-quality.ts @@ -1,4 +1,4 @@ -import { getRepository, listIssueSignalSample, listOpenPullRequests, listRecentMergedPullRequests, listSignalSnapshots } from "../db/repositories"; +import { getRepository, listBountiesByRepo, listIssueSignalSample, listOpenPullRequests, listRecentMergedPullRequests, listSignalSnapshots } from "../db/repositories"; import { buildIssueQualityReport, type IssueQualityReport } from "../signals/engine"; export type IssueQualityResponse = { @@ -24,8 +24,13 @@ export async function loadOrComputeIssueQualityResponse(env: Env, fullName: stri } const repo = await getRepository(env, fullName); if (!repo) return null; - const [issues, pullRequests, recentMergedPullRequests] = await Promise.all([listIssueSignalSample(env, fullName), listOpenPullRequests(env, fullName), listRecentMergedPullRequests(env, fullName)]); - const report = buildIssueQualityReport(repo, issues, pullRequests, fullName, undefined, recentMergedPullRequests); + const [issues, pullRequests, recentMergedPullRequests, bounties] = await Promise.all([ + listIssueSignalSample(env, fullName), + listOpenPullRequests(env, fullName), + listRecentMergedPullRequests(env, fullName), + listBountiesByRepo(env, fullName), + ]); + const report = buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, undefined, recentMergedPullRequests); return { status: "ready", source: "computed", diff --git a/src/signals/engine.ts b/src/signals/engine.ts index b7de2844ea..5c542abe32 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -428,14 +428,24 @@ export type PullRequestMaintainerPacket = { maintainerNotes: string[]; }; +export type BountyLifecycle = "active" | "historical" | "completed" | "cancelled" | "stale" | "ambiguous" | "unknown"; + +export type BountyLinkedPr = { + number: number; + state: "open" | "closed" | "merged" | "unknown"; + isActive: boolean; +}; + export type BountyAdvisory = { id: string; repoFullName: string; issueNumber: number; status: string; - lifecycle: "active" | "historical" | "unknown"; + lifecycle: BountyLifecycle; + isActiveOpportunity: boolean; fundingStatus: "funded" | "target_only" | "unknown"; consensusRisk: "low" | "medium" | "high"; + linkedPrs: BountyLinkedPr[]; findings: SignalFinding[]; }; @@ -1115,11 +1125,13 @@ export function buildContributorOpportunities( repositories: RepositoryRecord[], issues: IssueRecord[], pullRequests: PullRequestRecord[], + bounties: BountyRecord[] = [], issueQualityByRepo?: Map, ): ContributorOpportunity[] { const opportunities: ContributorOpportunity[] = []; const touchedRepos = new Set(profile.registeredRepoActivity.reposTouched); const labelHistory = new Set(profile.registeredRepoActivity.dominantLabels); + const bountyByIssue = indexBountiesByIssue(bounties); const qualityByKey = issueQualityByRepo ? new Map(Array.from(issueQualityByRepo.entries()).map(([key, value]) => [key.toLowerCase(), value])) : null; @@ -1140,6 +1152,11 @@ export function buildContributorOpportunities( : availableIssues; for (const issue of rankable.slice(0, 5)) { const quality = qualityByIssue?.get(issue.number); + const bounty = bountyByIssue.get(bountyIssueKey(repo.fullName, issue.number)) ?? null; + const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null; + // 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 qualityAdjustment = quality?.status === "ready" @@ -1156,29 +1173,34 @@ export function buildContributorOpportunities( (lane.lane === "split" ? 8 : 0) + (lane.lane === "direct_pr" ? 5 : 0) - queuePenalty - + bountyPenalty - (lane.lane === "inactive" || lane.lane === "unknown" ? 35 : 0) + qualityAdjustment, 0, 100, ); - const downgradeToCaution = quality?.status === "needs_proof" && score >= 70; + const baseFit = score >= 70 ? "good" : score >= 40 ? "caution" : "hold"; + const downgradeToCaution = (bountyPenalty > 0 || quality?.status === "needs_proof") && baseFit === "good"; opportunities.push({ repoFullName: repo.fullName, issueNumber: issue.number, title: issue.title, - fit: downgradeToCaution ? "caution" : score >= 70 ? "good" : score >= 40 ? "caution" : "hold", + fit: downgradeToCaution ? "caution" : baseFit, score, lane: lane.lane, reasons: [ lane.summary, ...(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(", ")}.`] : []), + ...(bountyLifecycle === "active" ? ["An active bounty is attached as contribution context (not guaranteed payout)."] : []), ...(quality?.status === "ready" ? ["Issue quality report rates this issue as ready."] : []), ], warnings: [ ...(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."] : []), + ...(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."] : []), ...(quality?.status === "hold" ? ["Issue quality report rates this issue as hold; consider skipping."] : []), ], @@ -1197,9 +1219,10 @@ export function buildContributorFit( pullRequests: PullRequestRecord[], repoSyncStates: RepoSyncStateRecord[], repoStats: ContributorRepoStatRecord[], + bounties: BountyRecord[] = [], issueQualityByRepo?: Map, ): ContributorFit { - const opportunities = buildContributorOpportunities(profile, repositories, issues, pullRequests, issueQualityByRepo); + const opportunities = buildContributorOpportunities(profile, repositories, issues, pullRequests, bounties, issueQualityByRepo); const languageSet = new Set(profile.github.topLanguages.map((language) => language.toLowerCase())); const syncByRepo = new Map(repoSyncStates.map((state) => [state.repoFullName, state])); const languageFit = repositories @@ -1831,6 +1854,7 @@ export function buildPreflightResult( repo: RepositoryRecord | null, issues: IssueRecord[], pullRequests: PullRequestRecord[], + bounties: BountyRecord[] = [], issueQuality?: IssueQualityReport | null | undefined, ): PreflightResult { const lane = buildLaneAdvice(repo, input.repoFullName); @@ -1867,6 +1891,30 @@ export function buildPreflightResult( action: "Check active issues and PRs before submitting.", }); } + const bountyByIssue = indexBountiesByIssue(bounties); + for (const issueNumber of linkedIssues) { + const bounty = bountyByIssue.get(bountyIssueKey(input.repoFullName, issueNumber)); + if (!bounty) continue; + const linkedIssue = issues.find((candidate) => candidate.repoFullName.toLowerCase() === input.repoFullName.toLowerCase() && candidate.number === issueNumber) ?? null; + const lifecycle = classifyBountyLifecycle(bounty, linkedIssue); + if (isHistoricalBountyLifecycle(lifecycle)) { + findings.push({ + code: "linked_issue_bounty_historical", + severity: "info", + title: "Linked issue bounty is historical", + detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm the work is still wanted before investing in it.`, + action: "Verify the bounty and issue are still open upstream.", + }); + } else if (lifecycle === "stale" || lifecycle === "ambiguous") { + findings.push({ + code: "linked_issue_bounty_unverified", + severity: "warning", + title: "Linked issue bounty needs verification", + detail: `Issue #${issueNumber} has a ${lifecycle} bounty; confirm it is still active before relying on it as contribution context.`, + action: "Re-check the upstream bounty source before submitting.", + }); + } + } findings.push(...issueQualityFindings(linkedIssues, issueQuality)); const changedFiles = input.changedFiles ?? []; const tests = input.tests ?? []; @@ -1898,6 +1946,7 @@ export function buildLocalDiffPreflightResult( repo: RepositoryRecord | null, issues: IssueRecord[], pullRequests: PullRequestRecord[], + bounties: BountyRecord[] = [], issueQuality?: IssueQualityReport | null | undefined, ): LocalDiffPreflightResult { /* v8 ignore next -- Undefined metadata arrays are normalized at API/MCP boundaries; local analysis tests cover empty metadata behavior. */ @@ -1913,6 +1962,7 @@ export function buildLocalDiffPreflightResult( repo, issues, pullRequests, + bounties, issueQuality, ); const codeFileCount = changedFiles.filter(isCodeFile).length; @@ -2157,11 +2207,13 @@ export function buildIssueQualityReport( issues: IssueRecord[], pullRequests: PullRequestRecord[], fullName: string, + bounties: BountyRecord[] = [], prebuiltCollisions?: CollisionReport, recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], ): IssueQualityReport { const lane = buildLaneAdvice(repo, fullName); const collisions = prebuiltCollisions ?? buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + const bountyByIssue = indexBountiesByIssue(bounties); const lifecycleByIssue = new Map(buildIssueDiscoveryLifecycleReport(repo, issues, pullRequests, fullName, recentMergedPullRequests).states.map((entry) => [entry.number, entry])); const reports = issues .filter((issue) => issue.state === "open") @@ -2175,11 +2227,14 @@ export function buildIssueQualityReport( /* v8 ignore next -- Lifecycle map is built from the same issue set; fallback protects malformed external issue-quality payloads. */ const lifecycle = lifecycleByIssue.get(issue.number)?.state ?? "open"; const bodyLength = issue.body?.trim().length ?? 0; + const bounty = bountyByIssue.get(bountyIssueKey(fullName, issue.number)) ?? null; + const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null; const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; const reasons = [ ...(bodyLength >= 200 ? ["Issue has enough body detail to evaluate."] : []), ...(issue.labels.length > 0 ? [`Labels: ${issue.labels.join(", ")}.`] : []), ...(linkedWorkCount === 0 ? ["No active PR is linked in cached metadata."] : []), + ...(bountyLifecycle === "active" ? ["Active bounty context is attached (contribution context, not guaranteed payout)."] : []), ]; const warnings = [ ...(bodyLength < 80 ? ["Issue body is thin; contributor may need more proof before acting."] : []), @@ -2190,12 +2245,19 @@ export function buildIssueQualityReport( ...(age > 90 ? ["Issue is stale in cached metadata."] : []), ...(lifecycle !== "open" ? [`Issue lifecycle is ${lifecycle.replace(/_/g, " ")}.`] : []), ...(lane.lane === "direct_pr" ? ["Repo is direct-PR first; issue filing is not the primary Gittensor lane."] : []), + ...(bountyLifecycle === "completed" ? ["A completed bounty is attached; the work is likely already solved, not an open opportunity."] : []), + ...(bountyLifecycle === "cancelled" ? ["A cancelled bounty is attached; this is not an active opportunity."] : []), + ...(bountyLifecycle === "historical" ? ["Historical bounty context is attached; this is not an active opportunity without upstream confirmation."] : []), + ...(bountyLifecycle === "stale" ? ["Bounty context for this issue looks stale; confirm it is still active before acting."] : []), + ...(bountyLifecycle === "ambiguous" ? ["Bounty state for this issue is ambiguous; verify it before acting."] : []), ]; const score = clamp(100 - warnings.length * 18 + reasons.length * 5 - (age > 180 ? 15 : 0), 0, 100); + const bountyBlocks = bountyLifecycle === "completed" || bountyLifecycle === "cancelled" || bountyLifecycle === "historical"; + const bountyCaution = bountyLifecycle === "stale" || bountyLifecycle === "ambiguous"; const status: IssueQualityReport["issues"][number]["status"] = - linkedWorkCount > 0 || issueCollisions.some((cluster) => cluster.risk === "high") || ["duplicate", "invalid", "solved", "valid_solved"].includes(lifecycle) + linkedWorkCount > 0 || issueCollisions.some((cluster) => cluster.risk === "high") || bountyBlocks || ["duplicate", "invalid", "solved", "valid_solved"].includes(lifecycle) ? "do_not_use" - : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) || lifecycle === "stale" + : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) || bountyCaution || lifecycle === "stale" ? "needs_proof" : score < 45 ? "hold" @@ -2566,21 +2628,129 @@ export function buildRegistryChangeReport(snapshots: RegistrySnapshot[]): Regist }; } -export function buildBountyAdvisory(bounty: BountyRecord, repo: RepositoryRecord | null, issue: IssueRecord | null): BountyAdvisory { - const status = bounty.status.toLowerCase(); - /* v8 ignore next -- Empty bounty status is a legacy-cache fallback; active and historical lifecycles are covered. */ - const lifecycle = status.includes("complete") || status.includes("cancel") || status.includes("closed") ? "historical" : status ? "active" : "unknown"; +export const BOUNTY_STALE_DAYS = 45; + +export function bountyIssueKey(repoFullName: string, issueNumber: number): string { + return `${repoFullName.toLowerCase()}#${issueNumber}`; +} + +export function indexBountiesByIssue(bounties: BountyRecord[]): Map { + const map = new Map(); + for (const bounty of bounties) { + map.set(bountyIssueKey(bounty.repoFullName, bounty.issueNumber), bounty); + } + return map; +} + +export function classifyBountyLifecycle(bounty: BountyRecord, issue: IssueRecord | null): BountyLifecycle { + const status = bounty.status.trim().toLowerCase(); + if (!status) return "unknown"; + if (/cancel|void|expired|withdrawn|rejected|abandon/.test(status)) return "cancelled"; + // Only past-tense payout phrasing (rewarded/awarded) marks completion; a bounty that merely + // advertises a "reward"/"award" is an active offer, not already-completed work. + if (/complete|paid|resolved|rewarded|awarded|fulfil|merged|claimed|done/.test(status)) return "completed"; + if (/historical|archived|closed/.test(status)) return "historical"; + const looksActive = /open|active|live|available|ready|funded|reward|award|in[\s_-]?progress|todo|new/.test(status); + if (!looksActive) return "ambiguous"; + // Active-looking status: reconcile against the linked issue and freshness so dead context is not treated as live. + if (issue && issue.state !== "open") return "ambiguous"; + if (daysSince(bounty.updatedAt ?? bounty.discoveredAt) > BOUNTY_STALE_DAYS) return "stale"; + return "active"; +} + +export function isHistoricalBountyLifecycle(lifecycle: BountyLifecycle): boolean { + return lifecycle === "historical" || lifecycle === "completed" || lifecycle === "cancelled"; +} + +function buildBountyLinkedPrs(issue: IssueRecord | null, pullRequests: PullRequestRecord[]): BountyLinkedPr[] { + if (!issue) return []; + const linkedNumbers = new Set(issue.linkedPrs); + for (const pr of pullRequests) { + if (pr.linkedIssues.includes(issue.number)) linkedNumbers.add(pr.number); + } + const byNumber = new Map(pullRequests.map((pr) => [pr.number, pr])); + return [...linkedNumbers].sort((left, right) => left - right).map((number) => { + const pr = byNumber.get(number); + const state: BountyLinkedPr["state"] = !pr ? "unknown" : pr.mergedAt ? "merged" : pr.state === "open" ? "open" : "closed"; + return { number, state, isActive: state === "open" }; + }); +} + +/** + * Bounty/issue consensus risk derived from linked PR STATE, not raw count, so historical or closed + * attempts are never scored the same as multiple active open PRs: + * - multiple open PRs -> high (concurrent active overlap / strong duplicate-work risk) + * - a single open PR -> medium (active overlap, but not yet crowded) + * - any merged PR -> medium (work may already be solved) + * - several closed or otherwise unresolved PRs -> medium (ambiguous history worth caution) + */ +function computeBountyConsensusRisk( + lifecycle: BountyLifecycle, + issue: IssueRecord | null, + open: number, + merged: number, + closed: number, + unknown: number, +): BountyAdvisory["consensusRisk"] { + if (open > 1) return "high"; + if (lifecycle === "active" && !issue) return "high"; + if (open === 1 || merged > 0 || closed > 1 || unknown > 1) return "medium"; + return "low"; +} + +export function buildBountyAdvisory( + bounty: BountyRecord, + repo: RepositoryRecord | null, + issue: IssueRecord | null, + pullRequests: PullRequestRecord[] = [], +): BountyAdvisory { + const lifecycle = classifyBountyLifecycle(bounty, issue); const target = bounty.payload.target_bounty ?? bounty.payload.target_alpha; const amount = bounty.payload.bounty_amount ?? bounty.payload.bounty_alpha; /* v8 ignore next -- Unknown funding is a sparse-cache fallback; funded and target-only states are covered. */ const fundingStatus = amount && amount !== 0 && amount !== "0.0000" ? "funded" : target ? "target_only" : "unknown"; + const linkedPrs = buildBountyLinkedPrs(issue, pullRequests); const findings: SignalFinding[] = []; + if (lifecycle === "completed") { + findings.push({ + code: "completed_bounty", + severity: "info", + title: "Bounty is completed", + detail: "This bounty is marked completed in the local cache; treat it as historical context, not an open contribution opportunity.", + }); + } if (lifecycle === "historical") { findings.push({ code: "historical_bounty", severity: "info", title: "Bounty is historical", - detail: "This bounty is completed, cancelled, or otherwise not active in the local bounty cache.", + detail: "This bounty is marked historical in the local cache; treat it as contribution context, not an active opportunity.", + }); + } + if (lifecycle === "cancelled") { + findings.push({ + code: "cancelled_bounty", + severity: "info", + title: "Bounty is cancelled", + detail: "This bounty is marked cancelled in the local cache and is not an active contribution opportunity.", + }); + } + if (lifecycle === "stale") { + findings.push({ + code: "stale_bounty", + severity: "warning", + title: "Bounty context may be stale", + detail: `This bounty has not been refreshed in over ${BOUNTY_STALE_DAYS} days; confirm it is still active before acting on it.`, + action: "Re-check the upstream bounty source before treating this as active contribution context.", + }); + } + if (lifecycle === "ambiguous") { + findings.push({ + code: "ambiguous_bounty", + severity: "warning", + title: "Bounty state is ambiguous", + detail: "The bounty status or its linked issue state is inconsistent, so its current state cannot be confirmed from the local cache.", + action: "Confirm the bounty and issue state upstream before treating this as active contribution context.", }); } if (!repo?.isRegistered) { @@ -2599,14 +2769,51 @@ export function buildBountyAdvisory(bounty: BountyRecord, repo: RepositoryRecord detail: "Gittensory has not cached the GitHub issue associated with this bounty.", }); } + // Linked PRs carry different risk by state: open = active overlap, merged = possibly solved, + // closed-unmerged = historical attempts. Surface each class with its own wording so contributors + // know whether they are avoiding duplicate active work, verifying a solved bounty, or reviewing history. + const openLinkedPrs = linkedPrs.filter((pr) => pr.state === "open"); + const mergedLinkedPrs = linkedPrs.filter((pr) => pr.state === "merged"); + const closedLinkedPrs = linkedPrs.filter((pr) => pr.state === "closed"); + const unknownLinkedPrs = linkedPrs.filter((pr) => pr.state === "unknown"); + const prRefs = (prs: BountyLinkedPr[]): string => prs.map((pr) => `#${pr.number}`).join(", "); + if (openLinkedPrs.length > 0) { + findings.push({ + code: "bounty_has_active_pr", + severity: openLinkedPrs.length > 1 ? "warning" : "info", + title: openLinkedPrs.length > 1 ? "Multiple open PRs are actively working this bounty issue" : "An open PR is actively working this bounty issue", + detail: `${openLinkedPrs.length} open PR(s) (${prRefs(openLinkedPrs)}) already reference this bounty's issue; you may be duplicating active in-progress work. Confirm solver state before starting overlapping work.`, + action: "Review the open PR(s) before starting so you do not duplicate active work.", + }); + } + if (mergedLinkedPrs.length > 0) { + findings.push({ + code: "bounty_linked_pr_merged", + severity: "warning", + title: "A merged PR may already resolve this bounty", + detail: `${mergedLinkedPrs.length} merged PR(s) (${prRefs(mergedLinkedPrs)}) reference this bounty's issue; the work may already be solved. Verify the bounty is still open before investing in it.`, + action: "Verify upstream that the bounty is still unsolved before starting.", + }); + } + if (closedLinkedPrs.length > 0) { + findings.push({ + code: "bounty_linked_pr_closed_history", + severity: closedLinkedPrs.length > 1 ? "warning" : "info", + title: closedLinkedPrs.length > 1 ? "Several closed (unmerged) PRs attempted this bounty issue" : "A closed (unmerged) PR attempted this bounty issue", + detail: `${closedLinkedPrs.length} closed, unmerged PR(s) (${prRefs(closedLinkedPrs)}) reference this bounty's issue. These are historical attempts, not active competing work; review why they were closed before re-attempting.`, + action: "Review the closed attempt(s) to understand why they did not land.", + }); + } return { id: bounty.id, repoFullName: bounty.repoFullName, issueNumber: bounty.issueNumber, status: bounty.status, lifecycle, + isActiveOpportunity: lifecycle === "active", fundingStatus, - consensusRisk: issue && issue.linkedPrs.length > 1 ? "medium" : lifecycle === "active" && !issue ? "high" : "low", + consensusRisk: computeBountyConsensusRisk(lifecycle, issue, openLinkedPrs.length, mergedLinkedPrs.length, closedLinkedPrs.length, unknownLinkedPrs.length), + linkedPrs, findings, }; } diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index cce0ed2fdb..94a513ff2f 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -1,6 +1,6 @@ import type { ScorePreviewInput, ScorePreviewResult } from "../scoring/preview"; import { buildScorePreview } from "../scoring/preview"; -import type { CheckSummaryRecord, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; +import type { BountyRecord, CheckSummaryRecord, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; import { nowIso } from "../utils/json"; import { buildLaneAdvice, @@ -169,6 +169,7 @@ export function buildLocalBranchAnalysis(args: { pullRequests: PullRequestRecord[]; contributorPullRequests?: PullRequestRecord[] | undefined; recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; + bounties?: BountyRecord[] | undefined; repositories?: RepositoryRecord[] | undefined; checkSummaries?: CheckSummaryRecord[] | undefined; profile: ContributorProfile; @@ -200,6 +201,7 @@ export function buildLocalBranchAnalysis(args: { args.repo, args.issues, args.pullRequests, + args.bounties ?? [], args.issueQuality, ); const roleContext = buildRoleContext({ diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 6efcd6f510..967e77a86a 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createSessionForGitHubUser } from "../../src/auth/security"; import { upsertBounty, @@ -32,7 +32,15 @@ import { createTestEnv } from "../helpers/d1"; import type { JsonValue } from "../../src/types"; describe("api routes", () => { + // Freshness/readiness fixtures are dated relative to late May 2026; pin the clock so freshness SLO + // windows stay deterministic regardless of when CI runs (fixtures otherwise tip "stale" after 7 days). + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z")); + }); + afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -700,7 +708,8 @@ describe("api routes", () => { env, ); expect(imported.status).toBe(200); - await expect(imported.json()).resolves.toMatchObject({ imported: 1 }); + // A first sighting of bounty id "2" records one lifecycle event (the watcher). + await expect(imported.json()).resolves.toMatchObject({ imported: 1, lifecycleEvents: 1 }); const bounties = await app.request("/v1/bounties", { headers: apiHeaders(env) }, env); expect(bounties.status).toBe(200); @@ -708,7 +717,33 @@ describe("api routes", () => { const bountyAdvisory = await app.request("/v1/bounties/bounty-1/advisory", { headers: apiHeaders(env) }, env); expect(bountyAdvisory.status).toBe(200); - await expect(bountyAdvisory.json()).resolves.toMatchObject({ lifecycle: "historical", fundingStatus: "target_only" }); + await expect(bountyAdvisory.json()).resolves.toMatchObject({ lifecycle: "completed", isActiveOpportunity: false, fundingStatus: "target_only" }); + + // Re-importing the same bounty with a changed status records a second lifecycle transition; an unchanged re-import records none. + const reimported = await app.request( + "/v1/internal/bounties/import", + { + method: "POST", + headers: { authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` }, + body: JSON.stringify({ + success: true, + issue_count: 1, + issues: [{ id: 2, repository_full_name: "entrius/allways-ui", issue_number: 8, status: "Completed", bounty_alpha: "0.0000", target_alpha: "17.0000" }], + }), + }, + env, + ); + await expect(reimported.json()).resolves.toMatchObject({ imported: 1, lifecycleEvents: 1 }); + + const lifecycle = await app.request("/v1/bounties/2/lifecycle", { headers: apiHeaders(env) }, env); + expect(lifecycle.status).toBe(200); + const lifecycleBody = (await lifecycle.json()) as { bountyId: string; events: Array<{ status: string }> }; + expect(lifecycleBody.bountyId).toBe("2"); + expect(lifecycleBody.events).toHaveLength(2); + expect(lifecycleBody.events.map((event) => event.status)).toEqual(expect.arrayContaining(["Cancelled", "Completed"])); + + const missingLifecycle = await app.request("/v1/bounties/missing/lifecycle", { headers: apiHeaders(env) }, env); + expect(missingLifecycle.status).toBe(404); const missingBountyAdvisory = await app.request("/v1/bounties/missing/advisory", { headers: apiHeaders(env) }, env); expect(missingBountyAdvisory.status).toBe(404); @@ -1931,6 +1966,32 @@ describe("api routes", () => { expect(missingRepoDecision.status).toBe(200); await expect(mcpJson(missingRepoDecision)).resolves.toMatchObject({ result: { structuredContent: { status: "not_found", decision: null } } }); + const historicalBountyPreflight = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ + jsonrpc: "2.0", + id: "bounty-preflight", + method: "tools/call", + params: { + name: "gittensory_preflight_pr", + arguments: { + repoFullName: "entrius/allways-ui", + title: "Fix dashboard cache refresh after reconnect", + body: "Fixes #7", + changedFiles: ["src/cache.ts", "test/cache.test.ts"], + }, + }, + }), + }, + env, + ); + expect(historicalBountyPreflight.status).toBe(200); + const historicalBountyPreflightPayload = (await mcpJson(historicalBountyPreflight)) as { result: { structuredContent: { findings: Array<{ code: string }> } } }; + expect(historicalBountyPreflightPayload.result.structuredContent.findings.map((finding) => finding.code)).toContain("linked_issue_bounty_historical"); + await persistSignalSnapshot(env, { id: "mcp-issue-quality", signalType: "issue-quality", diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 1fca5e4502..096cc6e1b4 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createAgentRun, persistScoringModelSnapshot, persistSignalSnapshot, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRecentMergedPullRequest, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { createAgentRun, persistScoringModelSnapshot, persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRecentMergedPullRequest, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { __agentOrchestratorInternals, executeAgentRun, @@ -256,8 +256,10 @@ describe("agent orchestrator", () => { await persistDecisionPack(env, decisionPackFixture()); const bundle = await explainBlockersWithAgent(env, { login: "oktofeesh1", repoFullName: "entrius/gittensor" }); + const mcpBundle = await explainBlockersWithAgent(env, { login: "oktofeesh1", repoFullName: "entrius/gittensor", surface: "mcp" }); expect(bundle.actions).toHaveLength(1); + expect(mcpBundle.run.surface).toBe("mcp"); expect(bundle.actions[0]).toMatchObject({ actionType: "explain_score_blockers", status: "blocked", @@ -521,6 +523,7 @@ describe("agent orchestrator", () => { expect(packet.actions).toHaveLength(1); expect(packet.actions[0]).toMatchObject({ actionType: "prepare_pr_packet", safetyClass: "public_safe", approvalRequired: false }); expect(blockers.actions[0]).toMatchObject({ actionType: "explain_score_blockers", targetRepoFullName: "entrius/allways-ui" }); + expect(JSON.stringify(preflight.actions)).toContain("linked_issue_bounty_historical"); expect(JSON.stringify(preflight.actions)).toContain("Source upload disabled"); }); }); @@ -651,6 +654,7 @@ function decisionPackFixture(overrides: Partial = {}): maintainerLaneRepos: ["JSONbored/awesome-claude"], }, roleContexts: [], + opportunities: [], repoDecisions, topActions: [ action("cleanup_existing_prs", "we-promise/sure", "cleanup_first", 94), @@ -846,6 +850,15 @@ async function seedLocalBranchData(env: Env): Promise { changedFiles: ["src/cache.ts"], payload: {}, }); + await upsertBounty(env, { + id: "local-bounty-7", + repoFullName: "entrius/allways-ui", + issueNumber: 7, + status: "Completed", + amountText: "0.0000", + sourceUrl: "contract://issues/7", + payload: { target_alpha: "5.0000", bounty_alpha: "0.0000" }, + }); } function stubContributorFetch(): void { diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index e3cc5db4f3..93ca722594 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -120,6 +120,7 @@ describe("decision-pack service", () => { profile: { login: "jsonbored", github: {}, source: {}, officialStats: null, registeredRepoActivity: {}, trustSignals: {} }, outcomeHistory: { login: "jsonbored", generatedAt: "2026-05-24T00:00:00.000Z", totals: {}, repoOutcomes: [] }, roleContexts: [], + opportunities: [], repoDecisions: [{ repoFullName: "JSONbored/awesome-claude", recommendation: "maintainer_lane" }], topActions: [], cleanupFirst: [], @@ -557,6 +558,18 @@ describe("decision-pack service", () => { ] as any, syncSegments: [], totals: [{ repoFullName: "owner/pursue", openPullRequestsTotal: 2, openIssuesTotal: 3, mergedPullRequestsTotal: 4, closedUnmergedPullRequestsTotal: 1 }] as any, + opportunities: [ + { + repoFullName: "owner/pursue", + issueNumber: 7, + title: "Fresh funded task", + fit: "good", + score: 82, + lane: "split", + reasons: ["Active bounty context is available."], + warnings: [], + }, + ], scoringModelSnapshotId: "scoring-1", contributorPullRequests: [{ repoFullName: "owner/cleanup", authorLogin: "jsonbored", authorAssociation: "CONTRIBUTOR" }] as any, contributorIssues: [], @@ -569,6 +582,7 @@ describe("decision-pack service", () => { expect(pack.avoidRepos.map((decision) => decision.repoFullName)).toEqual(expect.arrayContaining(["owner/inactive", "owner/unconfigured"])); expect(pack.topActions.map((action) => action.actionKind)).toEqual(expect.arrayContaining(["maintainer_lane_improve_repo", "cleanup_existing_prs", "open_new_direct_pr", "file_issue_discovery"])); expect(pack.roleContexts.map((role) => role.repoFullName)).not.toContain("owner/unconfigured"); + expect(pack.opportunities).toEqual([expect.objectContaining({ repoFullName: "owner/pursue", issueNumber: 7, fit: "good" })]); expect(pack.nextActions.length).toBeGreaterThan(0); }); diff --git a/test/unit/issue-quality-service.test.ts b/test/unit/issue-quality-service.test.ts index 0a21069e37..8aceab15fa 100644 --- a/test/unit/issue-quality-service.test.ts +++ b/test/unit/issue-quality-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { persistSignalSnapshot, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { loadIssueQualityReportMap, loadOrComputeIssueQualityResponse } from "../../src/services/issue-quality"; import { createTestEnv } from "../helpers/d1"; @@ -59,6 +59,43 @@ describe("issue-quality service", () => { await expect(loadOrComputeIssueQualityResponse(env, "owner/missing")).resolves.toBeNull(); }); + it("feeds real bounty state into the computed issue-quality report", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { + name: "bountied", + full_name: "owner/bountied", + private: false, + owner: { login: "owner" }, + default_branch: "main", + }); + await upsertIssueFromGitHub(env, "owner/bountied", { + number: 7, + title: "Dashboard cache refresh fails after reconnect", + state: "open", + html_url: "https://github.com/owner/bountied/issues/7", + user: { login: "reporter" }, + labels: [{ name: "bug" }], + body: "Cache refresh fails after reconnect. ".repeat(12), + }); + await upsertBounty(env, { + id: "bounty-7", + repoFullName: "owner/bountied", + issueNumber: 7, + status: "Completed", + amountText: "0.0000", + sourceUrl: "contract://issues/7", + payload: { target_alpha: "5.0000", bounty_alpha: "0.0000" }, + }); + + const response = await loadOrComputeIssueQualityResponse(env, "owner/bountied"); + expect(response?.source).toBe("computed"); + const issue = response?.report.issues.find((entry) => entry.number === 7); + // A completed bounty must block the issue as a contribution opportunity — proves bounties are + // actually wired into the computed report (regression guard: callers previously passed []). + expect(issue?.status).toBe("do_not_use"); + expect(issue?.warnings).toEqual(expect.arrayContaining([expect.stringContaining("completed bounty")])); + }); + it("falls back to payload or current timestamps for sparse cached snapshots", async () => { const payloadGenerated = "2026-05-29T00:00:00.000Z"; const env = createTestEnv(); diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts index 18a9f4de36..ec637b05cf 100644 --- a/test/unit/issue-quality.test.ts +++ b/test/unit/issue-quality.test.ts @@ -75,6 +75,7 @@ describe("issue quality reports", () => { ], [], repo.fullName, + [], undefined, [recentMergedPr(repo.fullName, 101, "Fixes #6", { linkedIssues: [6] })], ); @@ -133,6 +134,7 @@ describe("buildContributorOpportunities x issue quality", () => { [repo], issues, [], + [], new Map([[repo.fullName, quality]]), ); expect(opportunities.map((o) => o.issueNumber)).toEqual([2]); @@ -154,6 +156,7 @@ describe("buildContributorOpportunities x issue quality", () => { [repo], issues, [], + [], new Map([[repo.fullName, quality]]), ); expect(opportunities[0]).toMatchObject({ fit: "caution" }); @@ -175,6 +178,7 @@ describe("buildContributorOpportunities x issue quality", () => { [repo], issues, [], + [], new Map([[repo.fullName, quality]]), ); expect(opportunities[0]).toMatchObject({ fit: "caution" }); @@ -196,6 +200,7 @@ describe("buildContributorOpportunities x issue quality", () => { [repo], issues, [], + [], new Map([[repo.fullName, quality]]), ); expect(opportunities[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/hold; consider skipping/i)])); @@ -224,6 +229,7 @@ describe("buildContributorOpportunities x issue quality", () => { [], [], [], + [], new Map([[repo.fullName, quality]]), ); expect(fit.opportunities.map((o) => o.issueNumber)).toEqual([2]); @@ -244,6 +250,7 @@ describe("buildContributorOpportunities x issue quality", () => { [repo], issues, [], + [], new Map([["owner/mixedcase", quality]]), ); expect(opportunities).toHaveLength(0); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 32ae3f3e6e..9d111a0549 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { listCollisionEdges, createAgentRun, @@ -25,6 +25,13 @@ import { persistRegistrySnapshot } from "../../src/registry/sync"; import { createTestEnv } from "../helpers/d1"; describe("queue processors", () => { + // Freshness-SLO fixtures are dated relative to late May 2026; pin the clock so staleness windows + // stay deterministic regardless of when CI runs. + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-05-28T00:00:00.000Z")); + }); + afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 52849a94b1..1ceaf6161a 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -258,9 +258,9 @@ describe("signal coverage edge cases", () => { expect(burden.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["queue_growth_risk"])); expect(buildBurdenForecast(directRepo, [], Array.from({ length: 5 }, (_, index) => pr(directRepo.fullName, index + 300, `Medium PR ${index}`, { linkedIssues: [index] })), buildCollisionReport(directRepo.fullName, [], []), 7).level).toBe("medium"); expect(buildBurdenForecast(directRepo, [], Array.from({ length: 12 }, (_, index) => pr(directRepo.fullName, index + 400, `High PR ${index}`, { linkedIssues: [index] })), buildCollisionReport(directRepo.fullName, [], []), 7).level).toBe("high"); - expect(historicalBounty).toMatchObject({ lifecycle: "historical", fundingStatus: "target_only", consensusRisk: "low" }); - expect(activeBounty).toMatchObject({ lifecycle: "active", fundingStatus: "funded", consensusRisk: "low" }); - expect(historicalBounty.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["historical_bounty", "bounty_repo_unregistered", "bounty_issue_not_cached"])); + expect(historicalBounty).toMatchObject({ lifecycle: "cancelled", isActiveOpportunity: false, fundingStatus: "target_only", consensusRisk: "low" }); + expect(activeBounty).toMatchObject({ lifecycle: "active", isActiveOpportunity: true, fundingStatus: "funded", consensusRisk: "low" }); + expect(historicalBounty.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["cancelled_bounty", "bounty_repo_unregistered", "bounty_issue_not_cached"])); const stalePr = pr(directRepo.fullName, 20, "Misc refactor cleanup various things", { linkedIssues: [], diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index c99cc90f05..d3247df0c6 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -648,6 +648,19 @@ describe("v2 signal builders", () => { const fit = buildContributorFit(profile, [awesomeRepo, sureRepo], issues, pullRequests, [], repoStats); const scoringProfile = buildContributorScoringProfile({ login: "jsonbored", fit, scoringSnapshot: scoringSnapshot() }); const strategy = buildContributorStrategy({ login: "jsonbored", fit, scoringProfile, scoringSnapshot: scoringSnapshot(), outcomeHistory: history }); + const avoidStrategy = buildContributorStrategy({ + login: "jsonbored", + fit, + scoringProfile, + scoringSnapshot: scoringSnapshot(), + outcomeHistory: { + ...history, + repoOutcomes: [ + { repoFullName: "owner/closed", maintainerLane: false, closedPullRequestRate: 0.4, credibility: 1, openPullRequests: 0, strengths: [], risks: [] }, + { repoFullName: "owner/low", maintainerLane: false, closedPullRequestRate: 0.1, credibility: 0.5, openPullRequests: 0, strengths: [], risks: [] }, + ], + } as any, + }); const recommendation = buildRepoFitRecommendation({ login: "jsonbored", repo: awesomeRepo, repoFullName: awesomeRepo.fullName, profile, outcomeHistory: history, issues, pullRequests }); const intake = buildContributorIntakeHealth(awesomeRepo, issues, pullRequests, awesomeRepo.fullName); const lane = buildMaintainerLaneReport(awesomeRepo, issues, pullRequests, awesomeRepo.fullName); @@ -680,6 +693,10 @@ describe("v2 signal builders", () => { }); expect(buildContributorPatternReport(history, "failure").patterns.map((pattern) => pattern.title)).toContain("Raw issue activity is not solved discovery evidence"); expect(strategy.maintainerLaneRepos).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "jsonbored/awesome-claude" })])); + expect(avoidStrategy.avoidRepos).toEqual([ + expect.objectContaining({ repoFullName: "owner/closed", reason: "Closed PR rate is 40%." }), + expect.objectContaining({ repoFullName: "owner/low", reason: "Official repo credibility is 0.5." }), + ]); expect(recommendation.recommendation).toBe("maintainer_lane"); expect(intake.level).toEqual(expect.stringMatching(/healthy|watch|strained|blocked/)); expect(lane.summary).toContain("Maintainer lane"); diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index f063d64b38..6d211c294e 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildBountyAdvisory, buildBurdenForecast, + classifyBountyLifecycle, buildCollisionReport, buildConfigQuality, buildContributorOpportunities, @@ -466,8 +467,263 @@ describe("world-class backend signals", () => { }; const linkedIssue: IssueRecord = { ...issues[0]!, linkedPrs: [12, 13] }; - expect(buildBountyAdvisory(active, repo, null)).toMatchObject({ lifecycle: "active", fundingStatus: "funded", consensusRisk: "high" }); - expect(buildBountyAdvisory(historical, null, linkedIssue)).toMatchObject({ lifecycle: "historical", fundingStatus: "target_only", consensusRisk: "medium" }); + expect(buildBountyAdvisory(active, repo, null)).toMatchObject({ lifecycle: "active", isActiveOpportunity: true, fundingStatus: "funded", consensusRisk: "high" }); + expect(buildBountyAdvisory(historical, null, linkedIssue)).toMatchObject({ lifecycle: "completed", isActiveOpportunity: false, fundingStatus: "target_only", consensusRisk: "medium" }); + }); + + it("classifies the full bounty lifecycle: active, historical, completed, cancelled, stale, ambiguous", () => { + const base = { repoFullName: repo.fullName, issueNumber: 7, payload: { bounty_amount: "1.0000" } }; + const openIssue: IssueRecord = { ...issues[0]!, state: "open", linkedPrs: [] }; + const closedIssue: IssueRecord = { ...issues[0]!, state: "closed", linkedPrs: [] }; + + const active: BountyRecord = { ...base, id: "active", status: "Open", updatedAt: new Date().toISOString() }; + const historical: BountyRecord = { ...base, id: "historical", status: "Archived" }; + const completed: BountyRecord = { ...base, id: "completed", status: "Paid out" }; + const cancelled: BountyRecord = { ...base, id: "cancelled", status: "Withdrawn" }; + const stale: BountyRecord = { ...base, id: "stale", status: "Active", updatedAt: "2020-01-01T00:00:00.000Z" }; + const ambiguousStatus: BountyRecord = { ...base, id: "ambiguous-status", status: "Pending triage" }; + + expect(classifyBountyLifecycle(active, openIssue)).toBe("active"); + expect(classifyBountyLifecycle(historical, openIssue)).toBe("historical"); + expect(classifyBountyLifecycle(completed, openIssue)).toBe("completed"); + expect(classifyBountyLifecycle(cancelled, openIssue)).toBe("cancelled"); + expect(classifyBountyLifecycle(stale, openIssue)).toBe("stale"); + expect(classifyBountyLifecycle(ambiguousStatus, openIssue)).toBe("ambiguous"); + // An active-looking bounty on a closed issue is a conflicting signal, not a live opportunity. + expect(classifyBountyLifecycle(active, closedIssue)).toBe("ambiguous"); + + // A bounty that advertises a reward/award is an active offer, not completed work; only past-tense payout phrasing completes it. + expect(classifyBountyLifecycle({ ...base, id: "reward-open", status: "Reward available" }, openIssue)).toBe("active"); + expect(classifyBountyLifecycle({ ...base, id: "award-open", status: "Award open" }, openIssue)).toBe("active"); + expect(classifyBountyLifecycle({ ...base, id: "rewarded", status: "Rewarded" }, openIssue)).toBe("completed"); + expect(classifyBountyLifecycle({ ...base, id: "awarded", status: "Awarded to solver" }, openIssue)).toBe("completed"); + // Empty/whitespace status is a sparse-cache fallback that cannot be classified. + expect(classifyBountyLifecycle({ ...base, id: "blank", status: " " }, openIssue)).toBe("unknown"); + + expect(buildBountyAdvisory(historical, repo, openIssue).findings.map((finding) => finding.code)).toContain("historical_bounty"); + expect(buildBountyAdvisory(completed, repo, openIssue).findings.map((finding) => finding.code)).toContain("completed_bounty"); + expect(buildBountyAdvisory(cancelled, repo, openIssue).findings.map((finding) => finding.code)).toContain("cancelled_bounty"); + expect(buildBountyAdvisory(stale, repo, openIssue).findings.map((finding) => finding.code)).toContain("stale_bounty"); + expect(buildBountyAdvisory(ambiguousStatus, repo, openIssue).findings.map((finding) => finding.code)).toContain("ambiguous_bounty"); + expect(buildBountyAdvisory(stale, repo, openIssue).isActiveOpportunity).toBe(false); + expect(buildBountyAdvisory({ ...base, id: "target-only", status: "Open", payload: { target_bounty: 1, bounty_amount: "0.0000" } }, repo, openIssue).fundingStatus).toBe("target_only"); + expect(buildBountyAdvisory({ ...base, id: "unknown-funding", status: "Open", payload: {} }, repo, openIssue).fundingStatus).toBe("unknown"); + + const stalePreflight = buildPreflightResult({ repoFullName: repo.fullName, title: "Fix cache", body: "Fixes #7" }, repo, [openIssue], [], [stale]); + const ambiguousPreflight = buildPreflightResult({ repoFullName: repo.fullName, title: "Fix cache", body: "Fixes #7" }, repo, [openIssue], [], [ambiguousStatus]); + expect(stalePreflight.findings.map((finding) => finding.code)).toContain("linked_issue_bounty_unverified"); + expect(ambiguousPreflight.findings.map((finding) => finding.code)).toContain("linked_issue_bounty_unverified"); + }); + + it("includes linked PR validity when PR records are available", () => { + const issueWithPrs: IssueRecord = { ...issues[0]!, number: 7, state: "open", linkedPrs: [12, 99] }; + const fundedActive: BountyRecord = { id: "linked", repoFullName: repo.fullName, issueNumber: 7, status: "Open", payload: { bounty_amount: "2.0000" }, updatedAt: new Date().toISOString() }; + + const advisory = buildBountyAdvisory(fundedActive, repo, issueWithPrs, pullRequests); + expect(advisory.linkedPrs).toEqual([ + { number: 12, state: "open", isActive: true }, + { number: 13, state: "open", isActive: true }, + { number: 99, state: "unknown", isActive: false }, + ]); + expect(advisory.findings.map((finding) => finding.code)).toContain("bounty_has_active_pr"); + + // Cover merged/closed linked-PR states and cross-linked discovery (PRs referencing the issue via linkedIssues only). + const mixedPrs: PullRequestRecord[] = [ + { ...pullRequests[0]!, number: 50, state: "merged", mergedAt: "2026-05-01T00:00:00.000Z", linkedIssues: [7] }, + { ...pullRequests[0]!, number: 51, state: "closed", mergedAt: undefined, linkedIssues: [7] }, + ]; + const mixedAdvisory = buildBountyAdvisory(fundedActive, repo, { ...issueWithPrs, linkedPrs: [] }, mixedPrs); + expect(mixedAdvisory.linkedPrs).toEqual([ + { number: 50, state: "merged", isActive: false }, + { number: 51, state: "closed", isActive: false }, + ]); + }); + + it("scores linked-PR risk by state, not raw count", () => { + const openIssue: IssueRecord = { ...issues[0]!, number: 7, state: "open", linkedPrs: [] }; + const fundedActive: BountyRecord = { id: "risk-bounty", repoFullName: repo.fullName, issueNumber: 7, status: "Open", payload: { bounty_amount: "2.0000" }, updatedAt: new Date().toISOString() }; + const linkedOpen = (number: number): PullRequestRecord => ({ ...pullRequests[0]!, number, state: "open", mergedAt: undefined, linkedIssues: [7] }); + const linkedMerged = (number: number): PullRequestRecord => ({ ...pullRequests[0]!, number, state: "merged", mergedAt: "2026-05-01T00:00:00.000Z", linkedIssues: [7] }); + const linkedClosed = (number: number): PullRequestRecord => ({ ...pullRequests[0]!, number, state: "closed", mergedAt: undefined, linkedIssues: [7] }); + + // Multiple OPEN linked PRs => elevated active-overlap risk. + const manyOpen = buildBountyAdvisory(fundedActive, repo, openIssue, [linkedOpen(60), linkedOpen(61)]); + expect(manyOpen.consensusRisk).toBe("high"); + const overlapFinding = manyOpen.findings.find((finding) => finding.code === "bounty_has_active_pr"); + expect(overlapFinding?.severity).toBe("warning"); + expect(overlapFinding?.detail).toContain("duplicating active"); + expect(manyOpen.findings.map((finding) => finding.code)).not.toContain("bounty_linked_pr_merged"); + expect(manyOpen.findings.map((finding) => finding.code)).not.toContain("bounty_linked_pr_closed_history"); + + // A MERGED linked PR => possible solved/resolution warning, not active overlap. + const merged = buildBountyAdvisory(fundedActive, repo, openIssue, [linkedMerged(62)]); + expect(merged.consensusRisk).toBe("medium"); + const mergedFinding = merged.findings.find((finding) => finding.code === "bounty_linked_pr_merged"); + expect(mergedFinding?.severity).toBe("warning"); + expect(mergedFinding?.detail).toMatch(/already be solved/i); + expect(merged.findings.map((finding) => finding.code)).not.toContain("bounty_has_active_pr"); + + // Only CLOSED-unmerged linked PRs => historical caution/ambiguity, distinct from active-overlap wording. + const severalClosed = buildBountyAdvisory(fundedActive, repo, openIssue, [linkedClosed(63), linkedClosed(64)]); + expect(severalClosed.consensusRisk).toBe("medium"); + const closedFinding = severalClosed.findings.find((finding) => finding.code === "bounty_linked_pr_closed_history"); + expect(closedFinding?.severity).toBe("warning"); + expect(closedFinding?.detail).toMatch(/historical attempts, not active competing work/i); + expect(severalClosed.findings.map((finding) => finding.code)).not.toContain("bounty_has_active_pr"); + expect(severalClosed.findings.map((finding) => finding.code)).not.toContain("bounty_linked_pr_merged"); + + // A single closed-unmerged attempt is not elevated like concurrent active overlap. + const oneClosed = buildBountyAdvisory(fundedActive, repo, openIssue, [linkedClosed(65)]); + expect(oneClosed.consensusRisk).toBe("low"); + expect(oneClosed.findings.find((finding) => finding.code === "bounty_linked_pr_closed_history")?.severity).toBe("info"); + }); + + it("feeds bounty state into issue quality scoring", () => { + const completedBounty: BountyRecord = { id: "q1", repoFullName: repo.fullName, issueNumber: 7, status: "Completed", payload: {} }; + const cancelledBounty: BountyRecord = { id: "q2", repoFullName: repo.fullName, issueNumber: 8, status: "Cancelled", payload: {} }; + const activeBounty: BountyRecord = { id: "q3", repoFullName: repo.fullName, issueNumber: 7, status: "Active", payload: {}, updatedAt: new Date().toISOString() }; + const report = buildIssueQualityReport(repo, issues, [], repo.fullName, [completedBounty, cancelledBounty]); + const activeReport = buildIssueQualityReport(repo, issues, [], repo.fullName, [activeBounty]); + const issue7 = report.issues.find((entry) => entry.number === 7)!; + const issue8 = report.issues.find((entry) => entry.number === 8)!; + expect(issue7.status).toBe("do_not_use"); + expect(issue8.status).toBe("do_not_use"); + expect(issue8.warnings.some((warning) => /cancelled bounty/i.test(warning))).toBe(true); + expect(activeReport.issues.find((entry) => entry.number === 7)?.reasons).toContain("Active bounty context is attached (contribution context, not guaranteed payout)."); + + // Historical / stale / ambiguous bounty states surface distinct issue-quality warnings. + const lifecycleIssues: IssueRecord[] = [ + { ...issues[0]!, number: 30, title: "Historical bounty issue", linkedPrs: [] }, + { ...issues[0]!, number: 31, title: "Stale bounty issue", linkedPrs: [] }, + { ...issues[0]!, number: 32, title: "Ambiguous bounty issue", linkedPrs: [] }, + ]; + const lifecycleReport = buildIssueQualityReport(repo, lifecycleIssues, [], repo.fullName, [ + { id: "h", repoFullName: repo.fullName, issueNumber: 30, status: "Archived", payload: {} }, + { id: "s", repoFullName: repo.fullName, issueNumber: 31, status: "Open", payload: {}, updatedAt: "2020-01-01T00:00:00.000Z" }, + { id: "a", repoFullName: repo.fullName, issueNumber: 32, status: "Pending triage", payload: {} }, + ]); + expect(lifecycleReport.issues.find((entry) => entry.number === 30)?.warnings.some((warning) => /historical bounty context/i.test(warning))).toBe(true); + expect(lifecycleReport.issues.find((entry) => entry.number === 31)?.warnings.some((warning) => /bounty context for this issue looks stale/i.test(warning))).toBe(true); + expect(lifecycleReport.issues.find((entry) => entry.number === 32)?.warnings.some((warning) => /bounty state for this issue is ambiguous/i.test(warning))).toBe(true); + }); + + it("surfaces linked-issue quality findings in preflight", () => { + const qualityReport = { + repoFullName: repo.fullName, + generatedAt: new Date().toISOString(), + lane: buildLaneAdvice(repo, repo.fullName), + issues: [ + { number: 7, title: "Ready", status: "ready" as const, score: 90, reasons: [], warnings: [] }, + { number: 8, title: "Needs proof", status: "needs_proof" as const, score: 40, reasons: [], warnings: ["Issue body is thin."] }, + { number: 9, title: "Already covered", status: "do_not_use" as const, score: 0, reasons: [], warnings: [] }, + ], + summary: "", + }; + const preflight = buildPreflightResult({ repoFullName: repo.fullName, title: "Quality preflight", linkedIssues: [7, 8, 9] }, repo, issues, [], [], qualityReport); + const codes = preflight.findings.map((finding) => finding.code); + expect(codes).toContain("issue_quality_needs_proof"); + expect(codes).toContain("issue_quality_do_not_use"); + }); + + it("keeps stale and ambiguous bounties out of strong opportunity ranking", () => { + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, pullRequests, []); + const repoWithoutOpenPrs = { ...repo, fullName: "owner/bounty-fit", registryConfig: { ...repo.registryConfig!, repo: "owner/bounty-fit" } }; + const bountyIssues: IssueRecord[] = [ + { ...issues[0]!, repoFullName: repoWithoutOpenPrs.fullName, number: 1, title: "Fresh funded task", linkedPrs: [] }, + { ...issues[0]!, repoFullName: repoWithoutOpenPrs.fullName, number: 2, title: "Stale funded task", linkedPrs: [] }, + { ...issues[0]!, repoFullName: repoWithoutOpenPrs.fullName, number: 3, title: "Completed funded task", linkedPrs: [] }, + { ...issues[0]!, repoFullName: repoWithoutOpenPrs.fullName, number: 4, title: "Ambiguous funded task", linkedPrs: [] }, + ]; + const bounties: BountyRecord[] = [ + { id: "active-fit", repoFullName: repoWithoutOpenPrs.fullName, issueNumber: 1, status: "Active", payload: { bounty_alpha: "1.0000" }, updatedAt: new Date().toISOString() }, + { id: "stale-fit", repoFullName: repoWithoutOpenPrs.fullName, issueNumber: 2, status: "Active", payload: { bounty_alpha: "1.0000" }, updatedAt: "2020-01-01T00:00:00.000Z" }, + { id: "completed-fit", repoFullName: repoWithoutOpenPrs.fullName, issueNumber: 3, status: "Completed", payload: { bounty_alpha: "1.0000" } }, + { id: "ambiguous-fit", repoFullName: repoWithoutOpenPrs.fullName, issueNumber: 4, status: "Pending triage", payload: { bounty_alpha: "1.0000" } }, + ]; + + const opportunities = buildContributorOpportunities(profile, [repoWithoutOpenPrs], bountyIssues, [], bounties); + + expect(opportunities.map((opportunity) => opportunity.issueNumber)).toEqual([1, 2, 4]); + expect(opportunities.find((opportunity) => opportunity.issueNumber === 1)?.reasons).toContain("An active bounty is attached as contribution context (not guaranteed payout)."); + expect(opportunities.find((opportunity) => opportunity.issueNumber === 2)?.fit).not.toBe("good"); + expect(opportunities.find((opportunity) => opportunity.issueNumber === 2)?.warnings).toContain("Attached bounty context looks stale; confirm it is still active before acting."); + expect(opportunities.find((opportunity) => opportunity.issueNumber === 4)?.fit).not.toBe("good"); + expect(opportunities.find((opportunity) => opportunity.issueNumber === 4)?.warnings).toContain("Attached bounty state is ambiguous; verify it before acting."); + + const strongProfile = buildContributorProfile( + "oktofeesh1", + { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, + [], + [], + [ + { + login: "oktofeesh1", + repoFullName: repoWithoutOpenPrs.fullName, + pullRequests: 4, + mergedPullRequests: 3, + openPullRequests: 0, + issues: 1, + stalePullRequests: 0, + unlinkedPullRequests: 0, + dominantLabels: ["bug", "feature", "enhancement", "refactor", "docs"], + }, + ], + ); + const highSignalStaleIssue: IssueRecord = { ...bountyIssues[1]!, labels: ["bug", "feature", "enhancement", "refactor", "docs"] }; + const highSignalStale = buildContributorOpportunities(strongProfile, [repoWithoutOpenPrs], [highSignalStaleIssue], [], [bounties[1]!]); + expect(highSignalStale[0]).toMatchObject({ fit: "caution", score: 70 }); + }); + + it("drops completed, cancelled, and historical bounty issues from opportunities entirely", () => { + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, pullRequests, []); + const deadRepo = { ...repo, fullName: "owner/dead-bounties", registryConfig: { ...repo.registryConfig!, repo: "owner/dead-bounties" } }; + const deadIssues: IssueRecord[] = [ + { ...issues[0]!, repoFullName: deadRepo.fullName, number: 1, title: "Completed work", linkedPrs: [] }, + { ...issues[0]!, repoFullName: deadRepo.fullName, number: 2, title: "Cancelled work", linkedPrs: [] }, + { ...issues[0]!, repoFullName: deadRepo.fullName, number: 3, title: "Historical work", linkedPrs: [] }, + { ...issues[0]!, repoFullName: deadRepo.fullName, number: 4, title: "Active work", linkedPrs: [] }, + ]; + const deadBounties: BountyRecord[] = [ + { id: "d1", repoFullName: deadRepo.fullName, issueNumber: 1, status: "Completed", payload: { bounty_alpha: "1.0000" } }, + { id: "d2", repoFullName: deadRepo.fullName, issueNumber: 2, status: "Cancelled", payload: { bounty_alpha: "1.0000" } }, + { id: "d3", repoFullName: deadRepo.fullName, issueNumber: 3, status: "Archived", payload: { bounty_alpha: "1.0000" } }, + { id: "d4", repoFullName: deadRepo.fullName, issueNumber: 4, status: "Active", payload: { bounty_alpha: "1.0000" }, updatedAt: new Date().toISOString() }, + ]; + const numbers = buildContributorOpportunities(profile, [deadRepo], deadIssues, [], deadBounties).map((opportunity) => opportunity.issueNumber); + expect(numbers).not.toContain(1); + expect(numbers).not.toContain(2); + expect(numbers).not.toContain(3); + expect(numbers).toContain(4); + }); + + it("keeps bounty-aware opportunity and issue-quality work bounded for large bounty/issue sets", () => { + const profile = buildContributorProfile("oktofeesh1", { login: "oktofeesh1", topLanguages: ["TypeScript"], source: "github" }, pullRequests, []); + // 10 registered repos x 600 issues each = 6000 issues, each with a bounty (even issues completed, odd active). + const bigRepos = Array.from({ length: 10 }, (_, index) => ({ + ...repo, + fullName: `owner/huge-${index}`, + registryConfig: { ...repo.registryConfig!, repo: `owner/huge-${index}` }, + })); + const bigIssues: IssueRecord[] = bigRepos.flatMap((bigRepo) => + Array.from({ length: 600 }, (_, index) => ({ ...issues[0]!, repoFullName: bigRepo.fullName, number: index + 1, title: `Issue ${index + 1}`, linkedPrs: [] })), + ); + const bigBounties: BountyRecord[] = bigIssues.map((issue, index) => ({ + id: `big-${index}`, + repoFullName: issue.repoFullName, + issueNumber: issue.number, + status: issue.number % 2 === 0 ? "Completed" : "Active", + payload: { bounty_alpha: "1.0000" }, + updatedAt: new Date().toISOString(), + })); + + const opportunities = buildContributorOpportunities(profile, bigRepos, bigIssues, [], bigBounties); + // Output is bounded by the 25-opportunity cap even with thousands of candidates... + expect(opportunities.length).toBeLessThanOrEqual(25); + // ...and completed bounties (even issue numbers) are never surfaced as opportunities. + expect(opportunities.every((opportunity) => (opportunity.issueNumber ?? 0) % 2 === 1)).toBe(true); + + const quality = buildIssueQualityReport(bigRepos[0]!, bigIssues.filter((issue) => issue.repoFullName === bigRepos[0]!.fullName), [], bigRepos[0]!.fullName, bigBounties); + expect(quality.issues.length).toBeLessThanOrEqual(100); }); it("covers contributor fit and label audit warning boundaries", () => { @@ -714,12 +970,13 @@ describe("world-class backend signals", () => { const reviews: PullRequestReviewRecord[] = [{ id: "review-1", repoFullName: repo.fullName, pullNumber: 33, reviewerLogin: "maintainer", state: "APPROVED", authorAssociation: "MEMBER", submittedAt: "2026-05-25T00:00:00.000Z", payload: {} }]; const failedChecks: CheckSummaryRecord[] = [{ id: "check-1", repoFullName: repo.fullName, pullNumber: 33, headSha: "sha", name: "test", status: "completed", conclusion: "failure", payload: {} }]; const lifecycle = buildIssueDiscoveryLifecycleReport(issueDiscoveryRepo, lifecycleIssues, [reviewPr], repo.fullName, recentMerged); - const quality = buildIssueQualityReport(issueDiscoveryRepo, lifecycleIssues, [reviewPr], repo.fullName, undefined, recentMerged); + const quality = buildIssueQualityReport(issueDiscoveryRepo, lifecycleIssues, [reviewPr], repo.fullName, [], undefined, recentMerged); const localPreflight = buildLocalDiffPreflightResult( { repoFullName: repo.fullName, title: "Fix solved issue", body: "Fixes #23", changedFiles: ["src/fix.ts"], testFiles: [], changedLineCount: 900, commitMessage: "fix: close #23" }, issueDiscoveryRepo, lifecycleIssues, [reviewPr], + [], quality, ); const maintainerPacket = buildPullRequestMaintainerPacket({ repo: repo, pullRequest: reviewPr, issues: lifecycleIssues, pullRequests: [reviewPr], files, reviews, checks: failedChecks, recentMergedPullRequests: recentMerged, repoFullName: repo.fullName, pullNumber: 33 });