diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 75dd492c29..c8b33db115 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -888,6 +888,7 @@ export const IssueQualityReportSchema = z z.object({ number: z.number(), title: z.string(), + lifecycle: z.enum(["open", "closed_not_solved", "solved", "valid_solved", "stale", "duplicate", "invalid"]).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 90ab87ea2a..431e3916c5 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -468,6 +468,7 @@ export type IssueQualityReport = { issues: Array<{ number: number; title: string; + lifecycle?: IssueDiscoveryLifecycleState | undefined; status: "ready" | "needs_proof" | "hold" | "do_not_use"; score: number; reasons: string[]; @@ -476,6 +477,22 @@ export type IssueQualityReport = { summary: string; }; +export type IssueDiscoveryLifecycleState = "open" | "closed_not_solved" | "solved" | "valid_solved" | "stale" | "duplicate" | "invalid"; + +export type IssueDiscoveryLifecycleReport = { + repoFullName: string; + generatedAt: string; + lane: LaneAdvice; + states: Array<{ + number: number; + title: string; + state: IssueDiscoveryLifecycleState; + solvedByPullRequests: number[]; + reasons: string[]; + }>; + summary: string; +}; + export type BurdenForecast = { repoFullName: string; generatedAt: string; @@ -2128,6 +2145,7 @@ export function buildIssueQualityReport( ): IssueQualityReport { const lane = buildLaneAdvice(repo, fullName); const collisions = prebuiltCollisions ?? buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); + const lifecycleByIssue = new Map(buildIssueDiscoveryLifecycleReport(repo, issues, pullRequests, fullName, recentMergedPullRequests).states.map((entry) => [entry.number, entry])); const reports = issues .filter((issue) => issue.state === "open") .slice(0, 100) @@ -2136,6 +2154,7 @@ export function buildIssueQualityReport( const linkedMergedPrs = recentMergedPullRequests.filter((pr) => pr.linkedIssues.includes(issue.number) || issue.linkedPrs.includes(pr.number)); const issueCollisions = collisions.clusters.filter((cluster) => cluster.items.some((item) => item.type === "issue" && item.number === issue.number)); const age = daysSince(issue.updatedAt ?? issue.createdAt); + const lifecycle = lifecycleByIssue.get(issue.number)?.state ?? "open"; const bodyLength = issue.body?.trim().length ?? 0; const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; const reasons = [ @@ -2150,18 +2169,19 @@ export function buildIssueQualityReport( ...(issue.linkedPrs.length > 0 && linkedPrs.length === 0 && linkedMergedPrs.length === 0 ? [`Cached issue metadata already references PR(s): ${issue.linkedPrs.map((number) => `#${number}`).join(", ")}.`] : []), ...(issueCollisions.length > 0 ? ["Potential duplicate or overlapping issue/PR context exists."] : []), ...(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."] : []), ]; const score = clamp(100 - warnings.length * 18 + reasons.length * 5 - (age > 180 ? 15 : 0), 0, 100); const status: IssueQualityReport["issues"][number]["status"] = - linkedWorkCount > 0 || issueCollisions.some((cluster) => cluster.risk === "high") + linkedWorkCount > 0 || issueCollisions.some((cluster) => cluster.risk === "high") || ["duplicate", "invalid", "solved", "valid_solved"].includes(lifecycle) ? "do_not_use" - : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) + : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) || lifecycle === "stale" ? "needs_proof" : score < 45 ? "hold" : "ready"; - return { number: issue.number, title: issue.title, status, score, reasons, warnings }; + return { number: issue.number, title: issue.title, lifecycle, status, score, reasons, warnings }; }) .sort((left, right) => right.score - left.score || left.number - right.number); return { @@ -2173,6 +2193,70 @@ export function buildIssueQualityReport( }; } +export function buildIssueDiscoveryLifecycleReport( + repo: RepositoryRecord | null, + issues: IssueRecord[], + pullRequests: PullRequestRecord[], + fullName: string, + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], +): IssueDiscoveryLifecycleReport { + const lane = buildLaneAdvice(repo, fullName); + const states = issues + .slice(0, 300) + .map((issue) => classifyIssueDiscoveryLifecycle(issue, pullRequests, recentMergedPullRequests, lane)) + .sort((left, right) => lifecycleRank(left.state) - lifecycleRank(right.state) || left.number - right.number); + return { + repoFullName: fullName, + generatedAt: nowIso(), + lane, + states, + summary: `${states.length} issue lifecycle state(s) classified; ${states.filter((entry) => entry.state === "valid_solved").length} valid solved issue(s), ${states.filter((entry) => entry.state === "closed_not_solved").length} closed without solver evidence.`, + }; +} + +function classifyIssueDiscoveryLifecycle( + issue: IssueRecord, + pullRequests: PullRequestRecord[], + recentMergedPullRequests: RecentMergedPullRequestRecord[], + lane: LaneAdvice, +): IssueDiscoveryLifecycleReport["states"][number] { + const linkedOpenPrs = pullRequests.filter((pr) => pr.linkedIssues.includes(issue.number) || issue.linkedPrs.includes(pr.number)); + const linkedMergedPrs = recentMergedPullRequests.filter((pr) => pr.linkedIssues.includes(issue.number) || issue.linkedPrs.includes(pr.number)); + const solvedByPullRequests = [...new Set([...linkedOpenPrs.filter((pr) => pr.mergedAt || pr.state === "merged").map((pr) => pr.number), ...linkedMergedPrs.map((pr) => pr.number)])].sort( + (left, right) => left - right, + ); + const labels = issue.labels.map((label) => label.toLowerCase()); + const stale = daysSince(issue.updatedAt ?? issue.createdAt) > 90; + const duplicate = labels.some((label) => /duplicate/.test(label)); + const invalid = labels.some((label) => /invalid|wontfix|not planned|won't fix/.test(label)); + const state: IssueDiscoveryLifecycleState = duplicate + ? "duplicate" + : invalid + ? "invalid" + : solvedByPullRequests.length > 0 + ? lane.lane === "issue_discovery" || lane.lane === "split" + ? "valid_solved" + : "solved" + : issue.state !== "open" + ? "closed_not_solved" + : stale + ? "stale" + : "open"; + const reasons = [ + ...(duplicate ? ["Issue carries duplicate labeling."] : []), + ...(invalid ? ["Issue carries invalid or not-planned labeling."] : []), + ...(solvedByPullRequests.length > 0 ? [`Linked solver PR(s): ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}.`] : []), + ...(issue.state !== "open" && solvedByPullRequests.length === 0 ? ["Issue is closed without cached solver PR evidence."] : []), + ...(stale && issue.state === "open" ? ["Issue is stale in cached metadata."] : []), + ...(lane.lane === "direct_pr" ? ["Repo is direct-PR first; lifecycle should not encourage issue filing."] : []), + ]; + return { number: issue.number, title: issue.title, state, solvedByPullRequests, reasons: reasons.length > 0 ? reasons : ["Issue is open with no solver or duplicate signal."] }; +} + +function lifecycleRank(state: IssueDiscoveryLifecycleState): number { + return { valid_solved: 0, solved: 1, open: 2, stale: 3, closed_not_solved: 4, duplicate: 5, invalid: 6 }[state]; +} + function issueQualityFindings(linkedIssues: number[], issueQuality: IssueQualityReport | null | undefined): SignalFinding[] { if (!issueQuality || linkedIssues.length === 0) return []; const byIssue = new Map(issueQuality.issues.map((issue) => [issue.number, issue])); diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 754da60176..56e76b1b15 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -123,6 +123,7 @@ describe("local branch analysis", () => { { number: 7, title: "Cache refresh fails", + lifecycle: "valid_solved", status: "do_not_use", score: 0, reasons: [], diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index cd0480d78e..c99cc90f05 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -11,6 +11,7 @@ import { buildContributorScoringProfile, buildContributorStrategy, buildContributorIntakeHealth, + buildIssueDiscoveryLifecycleReport, buildIssueQualityReport, buildLabelAudit, buildLocalDiffPreflightResult, @@ -458,13 +459,25 @@ describe("v2 signal builders", () => { repoFullName: repo.fullName, number: 44, title: "Fix already solved report", - state: "open", + state: "merged", + mergedAt: "2026-05-01T00:00:00.000Z", linkedIssues: [23], labels: ["bug"], authorLogin: "oktofeesh1", body: "Fixes #23", updatedAt: "2025-01-01T00:00:00.000Z", }, + { + repoFullName: repo.fullName, + number: 45, + title: "Stale contributor branch", + state: "open", + linkedIssues: [], + labels: ["bug"], + authorLogin: "oktofeesh1", + body: "", + updatedAt: "2025-01-01T00:00:00.000Z", + }, ]; const issueQuality = buildIssueQualityReport( { ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.5 } }, @@ -473,6 +486,28 @@ describe("v2 signal builders", () => { repo.fullName, ); expect(issueQuality.issues.map((issue) => issue.status)).toEqual(expect.arrayContaining(["ready", "needs_proof", "do_not_use"])); + expect(issueQuality.issues.find((issue) => issue.number === 22)).toMatchObject({ lifecycle: "stale" }); + expect(issueQuality.issues.find((issue) => issue.number === 23)).toMatchObject({ lifecycle: "valid_solved", status: "do_not_use" }); + + const lifecycle = buildIssueDiscoveryLifecycleReport( + { ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.5 } }, + [ + ...issueSet, + { repoFullName: repo.fullName, number: 24, title: "Duplicate", state: "closed", body: "", labels: ["duplicate"], linkedPrs: [] }, + { repoFullName: repo.fullName, number: 25, title: "Closed without solver", state: "closed", body: "", labels: [], linkedPrs: [] }, + ], + prSet, + repo.fullName, + ); + expect(lifecycle.states.map((state) => [state.number, state.state])).toEqual(expect.arrayContaining([[23, "valid_solved"], [24, "duplicate"], [25, "closed_not_solved"]])); + + const unverifiedMentionLifecycle = buildIssueDiscoveryLifecycleReport( + { ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.5 } }, + [{ repoFullName: repo.fullName, number: 26, title: "Mentioned PR", state: "closed", body: "Maybe PR #123 helps.", labels: [], linkedPrs: [123] }], + [], + repo.fullName, + ); + expect(unverifiedMentionLifecycle.states[0]).toMatchObject({ number: 26, state: "closed_not_solved", solvedByPullRequests: [] }); const collisions = buildCollisionReport(repo.fullName, issueSet, prSet); const forecast = buildBurdenForecast(repo, issueSet, prSet, collisions, 7);