fix(FN-7579): stop tracking-issue dedup from linking new tasks to stale issues
GitHub tracking-issue creation reused old/closed issues via dedup: - searched state:"all", so a resolved issue from an unrelated task could be reused - accepted keyword-only matches (generic shared identifiers) with no file overlap Dedup now only reuses OPEN issues and requires a File-Scope path overlap; without an open path-overlapping issue a fresh tracking issue is always created. Adds two regression tests asserting closed and keyword-only matches are not reused. Fusion-Task-Id: FN-7579 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.changeset/fn-7579-tracking-dedup-stale-issue.md
Normal file
7
.changeset/fn-7579-tracking-dedup-stale-issue.md
Normal file
@@ -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.
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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<number, {
|
||||
number: number;
|
||||
@@ -370,8 +379,10 @@ export async function maybeCreateTrackingIssue(
|
||||
}>();
|
||||
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user