Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand All @@ -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"
Expand All @@ -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."] : []),
Expand Down
32 changes: 27 additions & 5 deletions test/unit/signals-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }],
Expand Down Expand Up @@ -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),
Expand All @@ -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", () => {
Expand Down