diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 5df4915701..2697717d17 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7394,6 +7394,43 @@ "items": { "$ref": "#/components/schemas/Finding" } + }, + "source": { + "type": "object", + "properties": { + "sourceUrl": { + "type": "string", + "nullable": true + }, + "discoveredAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "observedAt": { + "type": "string", + "nullable": true + }, + "ageDays": { + "type": "number", + "nullable": true + }, + "freshness": { + "type": "string", + "enum": [ + "fresh", + "stale", + "unknown" + ] + } + }, + "required": [ + "ageDays", + "freshness" + ] } }, "required": [ @@ -7405,6 +7442,7 @@ "isActiveOpportunity", "fundingStatus", "consensusRisk", + "source", "linkedPrs", "findings" ] @@ -10991,6 +11029,119 @@ "items": { "type": "string" } + }, + "bounty": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "lifecycle": { + "type": "string", + "enum": [ + "active", + "historical", + "completed", + "cancelled", + "stale", + "ambiguous", + "unknown" + ] + }, + "isActiveOpportunity": { + "type": "boolean" + }, + "fundingStatus": { + "type": "string", + "enum": [ + "funded", + "target_only", + "unknown" + ] + }, + "consensusRisk": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "source": { + "type": "object", + "properties": { + "sourceUrl": { + "type": "string", + "nullable": true + }, + "discoveredAt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "nullable": true + }, + "observedAt": { + "type": "string", + "nullable": true + }, + "ageDays": { + "type": "number", + "nullable": true + }, + "freshness": { + "type": "string", + "enum": [ + "fresh", + "stale", + "unknown" + ] + } + }, + "required": [ + "ageDays", + "freshness" + ] + }, + "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" + ] + } + } + }, + "required": [ + "id", + "lifecycle", + "isActiveOpportunity", + "fundingStatus", + "consensusRisk", + "source", + "linkedPrs" + ] } }, "required": [ diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index ac1bbd2544..f402b7ab11 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -475,6 +475,31 @@ export const BountySchema = z }) .openapi("Bounty"); +const BountySourceContextSchema = z.object({ + sourceUrl: z.string().nullable().optional(), + discoveredAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + observedAt: z.string().nullable().optional(), + ageDays: z.number().nullable(), + freshness: z.enum(["fresh", "stale", "unknown"]), +}); + +const BountyLinkedPrSchema = z.object({ + number: z.number(), + state: z.enum(["open", "closed", "merged", "unknown"]), + isActive: z.boolean(), +}); + +const BountyOpportunityContextSchema = z.object({ + id: z.string(), + 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"]), + source: BountySourceContextSchema, + linkedPrs: z.array(BountyLinkedPrSchema), +}); + export const BountyAdvisorySchema = z .object({ id: z.string(), @@ -485,13 +510,8 @@ export const BountyAdvisorySchema = z 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(), - }), - ), + source: BountySourceContextSchema, + linkedPrs: z.array(BountyLinkedPrSchema), findings: z.array(FindingSchema), }) .openapi("BountyAdvisory"); @@ -1286,6 +1306,7 @@ export const IssueQualityReportSchema = z warnings: z.array(z.string()), }) .optional(), + bounty: BountyOpportunityContextSchema.optional(), status: z.enum(["ready", "needs_proof", "hold", "do_not_use"]), score: z.number(), reasons: z.array(z.string()), diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 014e0702b0..b05c65b3d3 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -497,6 +497,25 @@ export type BountyLinkedPr = { isActive: boolean; }; +export type BountySourceContext = { + sourceUrl?: string | null | undefined; + discoveredAt?: string | null | undefined; + updatedAt?: string | null | undefined; + observedAt?: string | null | undefined; + ageDays: number | null; + freshness: "fresh" | "stale" | "unknown"; +}; + +export type BountyOpportunityContext = { + id: string; + lifecycle: BountyLifecycle; + isActiveOpportunity: boolean; + fundingStatus: "funded" | "target_only" | "unknown"; + consensusRisk: "low" | "medium" | "high"; + source: BountySourceContext; + linkedPrs: BountyLinkedPr[]; +}; + export type BountyAdvisory = { id: string; repoFullName: string; @@ -506,6 +525,7 @@ export type BountyAdvisory = { isActiveOpportunity: boolean; fundingStatus: "funded" | "target_only" | "unknown"; consensusRisk: "low" | "medium" | "high"; + source: BountySourceContext; linkedPrs: BountyLinkedPr[]; findings: SignalFinding[]; }; @@ -541,6 +561,7 @@ export type IssueQualityReport = { title: string; lifecycle?: IssueDiscoveryLifecycleState | undefined; linkage?: IssueLinkageRecord | undefined; + bounty?: BountyOpportunityContext | undefined; status: "ready" | "needs_proof" | "hold" | "do_not_use"; score: number; reasons: string[]; @@ -2666,6 +2687,7 @@ export function buildIssueQualityReport( const bodyLength = issue.body?.trim().length ?? 0; const bounty = bountyByIssue.get(bountyIssueKey(fullName, issue.number)) ?? null; const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null; + const bountyContext = bounty ? buildBountyOpportunityContext(bounty, issue, linkedPrs, linkedMergedPrs) : undefined; const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; const linkage = buildIssueLinkageRecord(issue, lifecycleEntry, linkedPrs, linkedMergedPrs); const reasons = [ @@ -2700,7 +2722,7 @@ export function buildIssueQualityReport( : score < 45 ? "hold" : "ready"; - return { number: issue.number, title: issue.title, lifecycle, linkage, status, score, reasons, warnings }; + return { number: issue.number, title: issue.title, lifecycle, linkage, bounty: bountyContext, status, score, reasons, warnings }; }) .sort((left, right) => right.score - left.score || left.number - right.number); return { @@ -3173,20 +3195,67 @@ export function isHistoricalBountyLifecycle(lifecycle: BountyLifecycle): boolean return lifecycle === "historical" || lifecycle === "completed" || lifecycle === "cancelled"; } -function buildBountyLinkedPrs(issue: IssueRecord | null, pullRequests: PullRequestRecord[]): BountyLinkedPr[] { +function buildBountySourceContext(bounty: BountyRecord): BountySourceContext { + const observedAt = bounty.updatedAt ?? bounty.discoveredAt ?? null; + const ageDays = observedAt ? daysSince(observedAt) : null; + return { + sourceUrl: bounty.sourceUrl ?? null, + discoveredAt: bounty.discoveredAt ?? null, + updatedAt: bounty.updatedAt ?? null, + observedAt, + ageDays, + freshness: ageDays === null ? "unknown" : ageDays > BOUNTY_STALE_DAYS ? "stale" : "fresh", + }; +} + +function buildBountyLinkedPrs( + issue: IssueRecord | null, + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], +): 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); } + for (const pr of recentMergedPullRequests) { + if (pr.linkedIssues.includes(issue.number)) linkedNumbers.add(pr.number); + } const byNumber = new Map(pullRequests.map((pr) => [pr.number, pr])); + const recentMergedByNumber = new Set(recentMergedPullRequests.map((pr) => pr.number)); 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"; + const state: BountyLinkedPr["state"] = recentMergedByNumber.has(number) + ? "merged" + : !pr + ? "unknown" + : pr.mergedAt + ? "merged" + : pr.state === "open" + ? "open" + : "closed"; return { number, state, isActive: state === "open" }; }); } +function buildBountyOpportunityContext( + bounty: BountyRecord, + issue: IssueRecord | null, + pullRequests: PullRequestRecord[] = [], + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], +): BountyOpportunityContext { + const advisory = buildBountyAdvisory(bounty, null, issue, pullRequests, recentMergedPullRequests); + return { + id: advisory.id, + lifecycle: advisory.lifecycle, + isActiveOpportunity: advisory.isActiveOpportunity, + fundingStatus: advisory.fundingStatus, + consensusRisk: advisory.consensusRisk, + source: advisory.source, + linkedPrs: advisory.linkedPrs, + }; +} + /** * 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: @@ -3214,13 +3283,15 @@ export function buildBountyAdvisory( repo: RepositoryRecord | null, issue: IssueRecord | null, pullRequests: PullRequestRecord[] = [], + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], ): 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 source = buildBountySourceContext(bounty); + const linkedPrs = buildBountyLinkedPrs(issue, pullRequests, recentMergedPullRequests); const findings: SignalFinding[] = []; if (lifecycle === "completed") { findings.push({ @@ -3324,6 +3395,7 @@ export function buildBountyAdvisory( isActiveOpportunity: lifecycle === "active", fundingStatus, consensusRisk: computeBountyConsensusRisk(lifecycle, issue, openLinkedPrs.length, mergedLinkedPrs.length, closedLinkedPrs.length, unknownLinkedPrs.length), + source, linkedPrs, findings, }; diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts index 785eae05d1..384b88b7db 100644 --- a/test/unit/issue-quality.test.ts +++ b/test/unit/issue-quality.test.ts @@ -7,7 +7,7 @@ import { type ContributorProfile, type IssueQualityReport, } from "../../src/signals/engine"; -import type { IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../src/types"; +import type { BountyRecord, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../src/types"; describe("issue quality reports", () => { it("downgrades issue filing in direct-PR-only repos to needs_proof", () => { @@ -58,6 +58,85 @@ describe("issue quality reports", () => { }); }); + it("threads reconciled bounty source and linked-PR state into issue quality", () => { + const repo = issueDiscoveryRepo("owner/bounty-context"); + const staleBounty: BountyRecord = { + id: "stale-source", + repoFullName: repo.fullName, + issueNumber: 1, + status: "Active", + payload: { bounty_alpha: "2.0000" }, + sourceUrl: "https://example.test/bounties/1", + discoveredAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }; + const staleReport = buildIssueQualityReport( + repo, + [issue(repo.fullName, 1, "Stale bounty with linked work", { body: "x".repeat(220), linkedPrs: [5] })], + [pr(repo.fullName, 5, "Work in progress", { linkedIssues: [1] })], + repo.fullName, + [staleBounty], + undefined, + [recentMergedPr(repo.fullName, 6, "Recently merged fix", { linkedIssues: [1] })], + ); + const staleEntry = staleReport.issues[0]!; + expect(staleEntry).toMatchObject({ + status: "do_not_use", + bounty: { + id: "stale-source", + lifecycle: "stale", + isActiveOpportunity: false, + fundingStatus: "funded", + source: { + sourceUrl: "https://example.test/bounties/1", + freshness: "stale", + observedAt: "2025-01-01T00:00:00.000Z", + ageDays: expect.any(Number), + }, + linkedPrs: [ + { number: 5, state: "open", isActive: true }, + { number: 6, state: "merged", isActive: false }, + ], + }, + }); + expect(staleEntry.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/Bounty context .* stale/i)])); + expect(JSON.stringify(staleEntry.bounty)).not.toMatch(/wallet|hotkey|raw trust|payout|reward estimate|farming|public score/i); + + const activeReport = buildIssueQualityReport( + repo, + [issue(repo.fullName, 2, "Active bounty", { body: "x".repeat(220) })], + [], + repo.fullName, + [ + { + id: "active-source", + repoFullName: repo.fullName, + issueNumber: 2, + status: "Open", + payload: { bounty_alpha: "1.0000" }, + updatedAt: now(), + }, + ], + ); + expect(activeReport.issues[0]).toMatchObject({ + status: "ready", + bounty: { id: "active-source", lifecycle: "active", isActiveOpportunity: true, source: { freshness: "fresh" } }, + reasons: expect.arrayContaining([expect.stringMatching(/Active bounty context/i)]), + }); + + const completedReport = buildIssueQualityReport( + repo, + [issue(repo.fullName, 3, "Completed bounty", { body: "x".repeat(220) })], + [], + repo.fullName, + [{ id: "completed-source", repoFullName: repo.fullName, issueNumber: 3, status: "Completed", payload: {} }], + ); + expect(completedReport.issues[0]).toMatchObject({ + status: "do_not_use", + bounty: { id: "completed-source", lifecycle: "completed", isActiveOpportunity: false }, + }); + }); + it("marks an already-solved issue as do_not_use when a linked PR exists", () => { const repo = directPrRepo("owner/solved"); const linkedPr = pr(repo.fullName, 100, "Fix for #5", { linkedIssues: [5] });