diff --git a/.changeset/fn-7579-tracking-dedup-stale-issue.md b/.changeset/fn-7579-tracking-dedup-stale-issue.md new file mode 100644 index 0000000000..9a3d2d26c8 --- /dev/null +++ b/.changeset/fn-7579-tracking-dedup-stale-issue.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Stop GitHub tracking-issue creation from linking new tasks to old/closed issues. +category: fix +dev: github-tracking dedup now only reuses OPEN issues and requires a File-Scope path overlap (keyword-only matches no longer link). Prevents mis-linking a fresh task to a stale/resolved tracking issue (FN-7579). Setting `githubTrackingDedupEnabled` unchanged. diff --git a/packages/dashboard/src/__tests__/github-tracking.test.ts b/packages/dashboard/src/__tests__/github-tracking.test.ts index bec42bcd84..140e2095d8 100644 --- a/packages/dashboard/src/__tests__/github-tracking.test.ts +++ b/packages/dashboard/src/__tests__/github-tracking.test.ts @@ -254,13 +254,14 @@ describe("maybeCreateTrackingIssue", () => { const linkGithubIssue = vi.fn(); const recordActivity = vi.fn(); + // FNXC:GithubTracking Only OPEN issues may be reused (a shared File-Scope path is present here). searchIssuesMock.mockResolvedValue([ { number: 400, title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts", body: "rebase-merge path drops output", html_url: "https://github.com/o/r/issues/400", - state: "closed", + state: "open", updatedAt: "2026-05-01T00:00:00.000Z", }, ]); @@ -290,6 +291,74 @@ describe("maybeCreateTrackingIssue", () => { })); }); + // FNXC:GithubTracking 2026-07-05 Regression (FN-7579): dedup mis-linked new tasks to old/stale issues. + // Surfaces: (1) a resolved CLOSED issue that path+keyword-matches must NOT be reused; (2) an OPEN + // issue that matches only on generic keywords (zero File-Scope path overlap) must NOT be reused. + // Invariant: the only reusable candidate is an OPEN issue sharing at least one File-Scope path. + it("does not reuse a CLOSED issue even when file scope and keywords match (FN-7579 stale-issue regression)", async () => { + const linkGithubIssue = vi.fn(); + + searchIssuesMock.mockResolvedValue([ + { + number: 500, + title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts", + body: "rebase-merge truncation resolved long ago", + html_url: "https://github.com/o/r/issues/500", + state: "closed", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]); + + const result = await maybeCreateTrackingIssue(buildTask({ + title: "Fix rebase-merge truncation in registerSessionDiffRoutes", + description: "## File Scope\n- packages/dashboard/src/routes/register-session-diff-routes.ts", + githubTracking: { enabled: true }, + }), { + taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any, + projectSettings: {}, + globalSettings: { githubTrackingDefaultRepo: "o/r" } as any, + rootDir, + logger: { warn: vi.fn(), info: vi.fn() }, + }); + + expect(result).toMatchObject({ created: true }); + expect(createIssueMock).toHaveBeenCalledTimes(1); + expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 12 })); + expect(linkGithubIssue).not.toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 500 })); + }); + + it("does not reuse an OPEN issue matched on keywords only when no file-scope path overlaps (FN-7579)", async () => { + const linkGithubIssue = vi.fn(); + + // Shares generic identifiers (truncation / registerSessionDiffRoutes) but references a DIFFERENT file. + searchIssuesMock.mockResolvedValue([ + { + number: 501, + title: "truncation bug in registerSessionDiffRoutes helper", + body: "affects packages/dashboard/src/routes/register-other-routes.ts truncation registerSessionDiffRoutes", + html_url: "https://github.com/o/r/issues/501", + state: "open", + updatedAt: "2026-06-01T00:00:00.000Z", + }, + ]); + + const result = await maybeCreateTrackingIssue(buildTask({ + title: "Fix rebase-merge truncation in registerSessionDiffRoutes", + description: "## File Scope\n- packages/dashboard/src/routes/register-session-diff-routes.ts", + githubTracking: { enabled: true }, + }), { + taskStore: { linkGithubIssue, recordActivity: vi.fn() } as any, + projectSettings: {}, + globalSettings: { githubTrackingDefaultRepo: "o/r" } as any, + rootDir, + logger: { warn: vi.fn(), info: vi.fn() }, + }); + + expect(result).toMatchObject({ created: true }); + expect(createIssueMock).toHaveBeenCalledTimes(1); + expect(linkGithubIssue).not.toHaveBeenCalledWith("FN-1", expect.objectContaining({ number: 501 })); + }); + it("falls through to create issue when dedup search has no qualifying match", async () => { searchIssuesMock.mockResolvedValue([ { diff --git a/packages/dashboard/src/github-tracking.ts b/packages/dashboard/src/github-tracking.ts index 62c6a83919..76ac4a6f83 100644 --- a/packages/dashboard/src/github-tracking.ts +++ b/packages/dashboard/src/github-tracking.ts @@ -354,11 +354,20 @@ export async function maybeCreateTrackingIssue( const title = formatTrackingIssueTitle(latestTask); const body = formatTrackingIssueBody(latestTask); + /* + FNXC:GithubTracking 2026-07-05-00:00: + Tracking-issue dedup was mis-linking new tasks to OLD/STALE issues (operator report: FN-7579 got an old issue id instead of a fresh one). + Two false-positive vectors, both fixed here: + 1. Search included CLOSED issues (state: "all"), so a resolved tracking issue from an earlier, unrelated task could be reused. Dedup only exists to avoid opening a *second live* issue for the same active work — a closed/resolved issue must never be reused. We now search and accept OPEN issues only. + 2. The accept filter allowed a keyword-only match (matchedKeywords >= 2 with zero file-path overlap). Symptom keywords are generic camelCase identifiers shared across many tasks (e.g. `githubTracking`, `trackingIssue`), so 2-3 shared tokens is a weak signal that routinely mis-matched. We now require at least one File-Scope path overlap before reusing an issue; keyword count only breaks ties / raises confidence. + Net effect: a task with no File-Scope paths (or no OPEN path-overlapping issue) always creates a fresh tracking issue rather than mis-linking. See docs/triage-duplicate-detection-postmortem.md. + */ if (deps.projectSettings.githubTrackingDedupEnabled !== false) { try { const paths = extractFileScopePaths(latestTask as Task & { prompt?: string }); const keywords = extractSymptomKeywords(latestTask, { max: 6 }); - if (paths.length > 0 || keywords.length > 0) { + // FNXC:GithubTracking Path overlap is now mandatory for a dedup link — without File-Scope paths there is no strong-enough signal, so skip the search entirely and create fresh. + if (paths.length > 0) { const queries = buildIssueSearchQueries(paths, keywords); const byNumber = new Map(); for (const query of queries) { - const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "all", limit: 10 }); + const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "open", limit: 10 }); for (const candidate of candidates) { + // FNXC:GithubTracking Defensive: never reuse a closed/resolved issue even if the API returns one. + if (candidate.state !== "open") continue; if (!byNumber.has(candidate.number)) { byNumber.set(candidate.number, candidate); } @@ -380,7 +391,7 @@ export async function maybeCreateTrackingIssue( const scored = [...byNumber.values()] .map((candidate) => ({ candidate, ...scoreCandidateIssue(candidate, paths, keywords) })) .filter((entry) => entry.score >= DEDUP_MATCH_THRESHOLD) - .filter((entry) => entry.matchedPaths.length > 0 || entry.matchedKeywords.length >= 2) + .filter((entry) => entry.matchedPaths.length > 0) .sort((a, b) => b.score - a.score); const bestMatch = scored[0];