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 linkGithubIssue = vi.fn();
|
||||||
const recordActivity = vi.fn();
|
const recordActivity = vi.fn();
|
||||||
|
|
||||||
|
// FNXC:GithubTracking Only OPEN issues may be reused (a shared File-Scope path is present here).
|
||||||
searchIssuesMock.mockResolvedValue([
|
searchIssuesMock.mockResolvedValue([
|
||||||
{
|
{
|
||||||
number: 400,
|
number: 400,
|
||||||
title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts",
|
title: "Diff route truncation in packages/dashboard/src/routes/register-session-diff-routes.ts",
|
||||||
body: "rebase-merge path drops output",
|
body: "rebase-merge path drops output",
|
||||||
html_url: "https://github.com/o/r/issues/400",
|
html_url: "https://github.com/o/r/issues/400",
|
||||||
state: "closed",
|
state: "open",
|
||||||
updatedAt: "2026-05-01T00:00:00.000Z",
|
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 () => {
|
it("falls through to create issue when dedup search has no qualifying match", async () => {
|
||||||
searchIssuesMock.mockResolvedValue([
|
searchIssuesMock.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -354,11 +354,20 @@ export async function maybeCreateTrackingIssue(
|
|||||||
const title = formatTrackingIssueTitle(latestTask);
|
const title = formatTrackingIssueTitle(latestTask);
|
||||||
const body = formatTrackingIssueBody(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) {
|
if (deps.projectSettings.githubTrackingDedupEnabled !== false) {
|
||||||
try {
|
try {
|
||||||
const paths = extractFileScopePaths(latestTask as Task & { prompt?: string });
|
const paths = extractFileScopePaths(latestTask as Task & { prompt?: string });
|
||||||
const keywords = extractSymptomKeywords(latestTask, { max: 6 });
|
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 queries = buildIssueSearchQueries(paths, keywords);
|
||||||
const byNumber = new Map<number, {
|
const byNumber = new Map<number, {
|
||||||
number: number;
|
number: number;
|
||||||
@@ -370,8 +379,10 @@ export async function maybeCreateTrackingIssue(
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
for (const query of queries) {
|
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) {
|
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)) {
|
if (!byNumber.has(candidate.number)) {
|
||||||
byNumber.set(candidate.number, candidate);
|
byNumber.set(candidate.number, candidate);
|
||||||
}
|
}
|
||||||
@@ -380,7 +391,7 @@ export async function maybeCreateTrackingIssue(
|
|||||||
const scored = [...byNumber.values()]
|
const scored = [...byNumber.values()]
|
||||||
.map((candidate) => ({ candidate, ...scoreCandidateIssue(candidate, paths, keywords) }))
|
.map((candidate) => ({ candidate, ...scoreCandidateIssue(candidate, paths, keywords) }))
|
||||||
.filter((entry) => entry.score >= DEDUP_MATCH_THRESHOLD)
|
.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);
|
.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
const bestMatch = scored[0];
|
const bestMatch = scored[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user