diff --git a/src/signals/engine.ts b/src/signals/engine.ts index c5b6e6b7b3..4bf27301c2 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1480,8 +1480,9 @@ export function buildRoleContext(args: { // Derive solved / valid-solved issue-discovery counts from cached issues using the same // lifecycle classifier as buildIssueDiscoveryLifecycleReport. Used as the cache fallback for // official solvedIssues / validSolvedIssues so a contributor without official Gittensor data -// still gets credit for issues their own merged PRs solved. (Contributor-wide recent-merged -// solver PRs are not loaded here, so detection uses the cached pull_requests set.) +// still gets solved credit from merged PR evidence while self-solved issue loops do not +// inflate valid issue-discovery credit. (Contributor-wide recent-merged solver PRs are not +// loaded here, so detection uses the cached pull_requests set.) function cachedSolvedIssueCounts(issues: IssueRecord[], pullRequests: PullRequestRecord[], lane: LaneAdvice): { solvedIssues: number; validSolvedIssues: number } { let solvedIssues = 0; let validSolvedIssues = 0; @@ -2869,9 +2870,10 @@ function classifyIssueDiscoveryLifecycle( ): IssueDiscoveryLifecycleReport["states"][number] { const linkedOpenPrs = pullRequests.filter((pr) => pr.linkedIssues.includes(issue.number)); const linkedMergedPrs = recentMergedPullRequests.filter((pr) => pr.linkedIssues.includes(issue.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 mergedSolverPrs = [...linkedOpenPrs.filter((pr) => pr.mergedAt || pr.state === "merged"), ...linkedMergedPrs]; + const solvedByPullRequests = [...new Set(mergedSolverPrs.map((pr) => pr.number))].sort((left, right) => left - right); + const issueAuthorLogin = issue.authorLogin; + const selfSolvedLoop = Boolean(issueAuthorLogin && mergedSolverPrs.length > 0 && mergedSolverPrs.every((pr) => sameLogin(pr.authorLogin, issueAuthorLogin))); const labels = issue.labels.map((label) => label.toLowerCase()); const stale = daysSince(issue.updatedAt ?? issue.createdAt) > 90; const duplicate = labels.some((label) => /duplicate/.test(label)); @@ -2881,7 +2883,7 @@ function classifyIssueDiscoveryLifecycle( : invalid ? "invalid" : solvedByPullRequests.length > 0 - ? lane.lane === "issue_discovery" || lane.lane === "split" + ? (lane.lane === "issue_discovery" || lane.lane === "split") && !selfSolvedLoop ? "valid_solved" : "solved" : issue.state !== "open" @@ -2893,6 +2895,7 @@ function classifyIssueDiscoveryLifecycle( ...(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(", ")}.`] : []), + ...(selfSolvedLoop ? ["Linked solver PR author matches the issue reporter; cache treats this as solved but not valid issue-discovery evidence."] : []), ...(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."] : []), diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 6bd0169fbf..0aab5a7273 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -524,6 +524,28 @@ describe("v2 signal builders", () => { ); expect(lifecycle.states.map((state) => [state.number, state.state])).toEqual(expect.arrayContaining([[23, "valid_solved"], [24, "duplicate"], [25, "closed_not_solved"]])); + const selfSolvedLifecycle = buildIssueDiscoveryLifecycleReport( + { ...repo, registryConfig: { ...repo.registryConfig!, issueDiscoveryShare: 0.5 } }, + [{ repoFullName: repo.fullName, number: 26, title: "Self solved", state: "closed", authorLogin: "selfdev", body: "I will fix this.", labels: [], linkedPrs: [46] }], + [ + { + repoFullName: repo.fullName, + number: 46, + title: "Fix self solved report", + state: "merged", + mergedAt: "2026-05-01T00:00:00.000Z", + linkedIssues: [26], + labels: ["bug"], + authorLogin: "selfdev", + body: "Fixes #26", + updatedAt: "2026-05-01T00:00:00.000Z", + }, + ], + repo.fullName, + ); + expect(selfSolvedLifecycle.states[0]).toMatchObject({ number: 26, state: "solved", solvedByPullRequests: [46] }); + expect(selfSolvedLifecycle.states[0]?.reasons.join(" ")).toMatch(/not valid issue-discovery evidence/); + 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] }], @@ -822,7 +844,7 @@ describe("v2 signal builders", () => { body: `Fixes #${issueNumber}`, updatedAt: "2026-05-21T00:00:00.000Z", }); - // acme/widgets is an issue-discovery lane (valid_solved); acme/tools is direct-PR (solved). + // acme/widgets is an issue-discovery lane, but self-solved loops only get solved credit; acme/tools is direct-PR (solved). const repositories = [mkRepo("acme/widgets", 1), mkRepo("acme/tools", 0)]; const poisonedIssue: IssueRecord = { ...mkIssue("acme/widgets", 9, 102), @@ -844,12 +866,12 @@ describe("v2 signal builders", () => { // Without the cache fallback these were hardcoded to 0 even though the contributor's own // merged PRs solved their issues. Open issues with only contributor-controlled issue-body // PR text must not inflate the cached solved evidence. - expect(discovery).toMatchObject({ solvedIssues: 1, validSolvedIssues: 1, openIssues: 1 }); + expect(discovery).toMatchObject({ solvedIssues: 1, validSolvedIssues: 0, openIssues: 1 }); expect(direct).toMatchObject({ solvedIssues: 1, validSolvedIssues: 0 }); expect(history.totals.solvedIssues).toBe(2); - expect(history.totals.validSolvedIssues).toBe(1); - expect(history.reconciliation?.repos.find((entry) => entry.repoFullName === "acme/widgets")?.cached).toMatchObject({ solvedIssues: 1, validSolvedIssues: 1, openIssues: 1 }); - expect(discovery?.strengths.join(" ")).toMatch(/valid solved issue-discovery report/); + expect(history.totals.validSolvedIssues).toBe(0); + expect(history.reconciliation?.repos.find((entry) => entry.repoFullName === "acme/widgets")?.cached).toMatchObject({ solvedIssues: 1, validSolvedIssues: 0, openIssues: 1 }); + expect(discovery?.strengths.join(" ")).not.toMatch(/valid solved issue-discovery report/); }); it("keeps cached reconciliation stats separate from official profile counts", () => {