FN-8369: centralize GitHub issue import deduplication

Use provenance-first deduplication consistently across dashboard, CLI, and extension GitHub imports.

- Prioritize persisted sourceIssue and legacy metadata over editable descriptions.
- Reuse the dashboard deduplication helper for CLI and extension imports.
- Prevent duplicate issue creation within a dashboard batch import.

Files changed:
 .changeset/fn-8369-github-import-dedup.md          |  7 ++++++
 packages/cli/src/__tests__/extension.test.ts       | 27 ++++++++++++++++++++++
 packages/cli/src/commands/__tests__/task.test.ts   | 20 ++++++++++------
 packages/cli/src/extension.ts                      | 25 +++++++++++---------
 packages/dashboard/src/__tests__/github.test.ts    | 26 ++++++++++++++++-----
 packages/dashboard/src/__tests__/routes-github.test.ts | 22 ++++++++++++++++++
 packages/dashboard/src/github.ts                   | 27 +++++++++++++---------
 7 files changed, 119 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-8369

Fusion-Task-Lineage: 8eb6d19d-bd0e-487a-9f11-8945d744b7df

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 16:07:34 -07:00
parent ccb7d4e8ff
commit 3e376b98ba
7 changed files with 119 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix GitHub issue imports so edited descriptions cannot hide or falsely match prior imports.
category: fix
dev: Centralizes provenance-first deduplication for dashboard, CLI, and extension issue imports.

View File

@@ -2781,6 +2781,33 @@ legacyDescribe("fn pi extension (legacy exhaustive suite)", () => {
expect(result.content[0].text).toContain(existing.id); expect(result.content[0].text).toContain(existing.id);
}); });
it("fn_task_browse_github_issues marks sourceIssue-only imports as imported", async () => {
await h.store().createTask({
title: "Imported issue",
description: "Edited description without source URL",
sourceIssue: {
provider: "github",
repository: "Acme/Demo",
externalIssueId: "10",
issueNumber: 10,
url: "https://github.com/other/repo/issues/99",
},
});
const tool = api.tools.get("fn_task_browse_github_issues")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([{
number: 10,
title: "Investigate latency",
body: null,
html_url: "https://github.com/acme/demo/issues/10",
labels: [],
}] as never);
const result = await tool.execute("gh-3a", { owner: "acme", repo: "demo" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("✓ Imported");
expect(result.details.issues[0]).toMatchObject({ number: 10, imported: true });
});
it("fn_task_browse_github_issues lists issues via gh api", async () => { it("fn_task_browse_github_issues lists issues via gh api", async () => {
const tool = api.tools.get("fn_task_browse_github_issues")!; const tool = api.tools.get("fn_task_browse_github_issues")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ vi.mocked(runGhJsonAsync).mockResolvedValueOnce([

View File

@@ -123,11 +123,7 @@ vi.mock("@fusion/dashboard", () => ({
sourceIssue: { provider: "github", repository: `${owner}/${repo}`, externalIssueId: String(issue.number), issueNumber: issue.number, url: issue.html_url }, sourceIssue: { provider: "github", repository: `${owner}/${repo}`, externalIssueId: String(issue.number), issueNumber: issue.number, url: issue.html_url },
sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number }, sourceMetadata: { issueUrl: issue.html_url, issueNumber: issue.number },
})), })),
isGitHubIssueAlreadyImported: vi.fn((task: any, input: any) => isGitHubIssueAlreadyImported: vi.fn(),
task.description?.toLowerCase().includes(input.sourceUrl.toLowerCase())
|| (task.sourceIssue?.provider === "github"
&& task.sourceIssue.repository?.toLowerCase() === `${input.owner}/${input.repo}`.toLowerCase()
&& (task.sourceIssue.issueNumber === input.issueNumber || task.sourceIssue.externalIssueId === String(input.issueNumber)))),
isGitLabAlreadyImported: vi.fn(), isGitLabAlreadyImported: vi.fn(),
buildGitLabTaskDescription: vi.fn(), buildGitLabTaskDescription: vi.fn(),
})); }));
@@ -208,7 +204,7 @@ import {
isGhAvailable, isGhAvailable,
runGhJsonAsync, runGhJsonAsync,
} from "@fusion/core/gh-cli"; } from "@fusion/core/gh-cli";
import { GitHubClient, generatePrMetadata } from "@fusion/dashboard"; import { GitHubClient, generatePrMetadata, isGitHubIssueAlreadyImported } from "@fusion/dashboard";
import { createSession, submitResponse } from "@fusion/dashboard/planning"; import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject, createLocalStore } from "../../project-context.js"; import { resolveProject, createLocalStore } from "../../project-context.js";
import { aiMergeTask, runAiMerge, landWorkspaceTask } from "@fusion/engine"; import { aiMergeTask, runAiMerge, landWorkspaceTask } from "@fusion/engine";
@@ -1769,6 +1765,7 @@ describe("runTaskImportGitHubInteractive", () => {
}, },
}, },
]); ]);
vi.mocked(isGitHubIssueAlreadyImported).mockImplementation((_task, input) => input.issueNumber === 1);
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
mockIssue(1, "First Issue", "Description 1"), mockIssue(1, "First Issue", "Description 1"),
@@ -2083,7 +2080,7 @@ describe("runTaskImportFromGitHub", () => {
})); }));
}); });
it("skips already imported issues", async () => { it("delegates provenance-first deduplication to the dashboard helper", async () => {
// Setup existing task with source URL // Setup existing task with source URL
mockListTasks.mockResolvedValueOnce([ mockListTasks.mockResolvedValueOnce([
{ {
@@ -2100,6 +2097,7 @@ describe("runTaskImportFromGitHub", () => {
}, },
}, },
]); ]);
vi.mocked(isGitHubIssueAlreadyImported).mockImplementation((_task, input) => input.issueNumber === 1);
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([ vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
mockIssue(1, "First Issue", "Description 1"), mockIssue(1, "First Issue", "Description 1"),
@@ -2109,6 +2107,14 @@ describe("runTaskImportFromGitHub", () => {
await runTaskImportFromGitHub("owner/repo"); await runTaskImportFromGitHub("owner/repo");
expect(mockCreateTask).toHaveBeenCalledTimes(1); expect(mockCreateTask).toHaveBeenCalledTimes(1);
expect(isGitHubIssueAlreadyImported).toHaveBeenCalledWith(expect.objectContaining({
sourceIssue: expect.objectContaining({ provider: "github", issueNumber: 1 }),
}), expect.objectContaining({
owner: "owner",
repo: "repo",
issueNumber: 1,
sourceUrl: "https://github.com/owner/repo/issues/1",
}));
const skipLine = logSpy.mock.calls.find( const skipLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Skipping #1"), (call) => typeof call[0] === "string" && call[0].includes("Skipping #1"),
); );

View File

@@ -2411,21 +2411,24 @@ export default function kbExtension(pi: ExtensionAPI) {
// Check which issues are already imported // Check which issues are already imported
const store = await getStore(ctx.cwd); const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true }); // FNXC:GithubImport 2026-07-17-00:00: Browse must load full provenance and use the shared helper so its imported marker agrees with all issue-import surfaces even after descriptions are edited.
const importedUrls = new Set<string>(); const existingTasks = await store.listTasks({ slim: false });
const importedIssueNumbers = new Set(
for (const task of existingTasks) { issues
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/); .filter((issue) => existingTasks.some((task) => dashboard.isGitHubIssueAlreadyImported(task, {
if (match) { owner,
importedUrls.add(match[1]); repo,
} issueNumber: issue.number,
} sourceUrl: issue.html_url,
})))
.map((issue) => issue.number),
);
const lines: string[] = []; const lines: string[] = [];
lines.push(`Found ${issues.length} open issues in ${owner}/${repo}:\n`); lines.push(`Found ${issues.length} open issues in ${owner}/${repo}:\n`);
for (const issue of issues) { for (const issue of issues) {
const isImported = importedUrls.has(issue.html_url); const isImported = importedIssueNumbers.has(issue.number);
const issueLabels = issue.labels ?? []; const issueLabels = issue.labels ?? [];
const labelStr = issueLabels.length > 0 ? ` [${issueLabels.map((label) => label.name).join(", ")}]` : ""; const labelStr = issueLabels.length > 0 ? ` [${issueLabels.map((label) => label.name).join(", ")}]` : "";
const importedStr = isImported ? " ✓ Imported" : ""; const importedStr = isImported ? " ✓ Imported" : "";
@@ -2444,7 +2447,7 @@ export default function kbExtension(pi: ExtensionAPI) {
title: issue.title, title: issue.title,
url: issue.html_url, url: issue.html_url,
labels: (issue.labels ?? []).map((label) => label.name), labels: (issue.labels ?? []).map((label) => label.name),
imported: importedUrls.has(issue.html_url), imported: importedIssueNumbers.has(issue.number),
})), })),
}, },
}; };

View File

@@ -2435,19 +2435,33 @@ describe("GitHubClient", () => {
describe("isGitHubIssueAlreadyImported", () => { describe("isGitHubIssueAlreadyImported", () => {
const input = { owner: "owner", repo: "repo", issueNumber: 1, sourceUrl: "https://github.com/owner/repo/issues/1" }; const input = { owner: "owner", repo: "repo", issueNumber: 1, sourceUrl: "https://github.com/owner/repo/issues/1" };
it("matches edited sourceIssue metadata, legacy source metadata, and description URLs", () => { it("prefers persisted sourceIssue provenance over an edited description", () => {
expect(isGitHubIssueAlreadyImported({ description: "Source: https://github.com/OWNER/REPO/issues/1" }, input)).toBe(true);
expect(isGitHubIssueAlreadyImported({ expect(isGitHubIssueAlreadyImported({
description: "Edited description", description: "Edited description without source URL",
sourceIssue: { provider: "github", repository: "Owner/Repo", issueNumber: 1, externalIssueId: "1", url: "https://github.com/other/repo/issues/2" }, sourceIssue: { provider: "github", repository: "Owner/Repo", externalIssueId: "1", url: "https://github.com/other/repo/issues/99" },
}, input)).toBe(true); }, input)).toBe(true);
});
it("matches normalized legacy metadata after sourceIssue", () => {
expect(isGitHubIssueAlreadyImported({ expect(isGitHubIssueAlreadyImported({
description: "Edited description", description: "Edited description without source URL",
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/Owner/Repo/issues/2", issueNumber: 1 } }, source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/Owner/Repo/issues/2", issueNumber: 1 } },
}, input)).toBe(true); }, input)).toBe(true);
}); });
it("does not match a fresh issue", () => { it("does not let a description URL override nonmatching structured provenance", () => {
expect(isGitHubIssueAlreadyImported({
description: `Quoted target URL: ${input.sourceUrl}`,
sourceIssue: { provider: "github", repository: "other/repo", issueNumber: 2, url: "https://github.com/other/repo/issues/2" },
}, input)).toBe(false);
expect(isGitHubIssueAlreadyImported({
description: `Quoted target URL: ${input.sourceUrl}`,
source: { sourceType: "github_import", sourceMetadata: { issueUrl: "https://github.com/other/repo/issues/2", issueNumber: 2 } },
}, input)).toBe(false);
});
it("uses description URLs only as the final legacy fallback", () => {
expect(isGitHubIssueAlreadyImported({ description: "Source: https://github.com/OWNER/REPO/issues/1" }, input)).toBe(true);
expect(isGitHubIssueAlreadyImported({ description: "Unrelated" }, input)).toBe(false); expect(isGitHubIssueAlreadyImported({ description: "Unrelated" }, input)).toBe(false);
}); });
}); });

View File

@@ -1419,6 +1419,28 @@ describe("POST /github/issues/batch-import", () => {
expect(throttledSpy).toHaveBeenCalledTimes(2); expect(throttledSpy).toHaveBeenCalledTimes(2);
}); });
it("deduplicates repeated issue numbers using tasks accumulated within the batch", async () => {
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
.mockResolvedValueOnce({ success: true, data: mockGitHubIssue(1, "Duplicate issue") } as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
.mockResolvedValueOnce({ success: true, data: mockGitHubIssue(1, "Duplicate issue") } as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 1], delayMs: 1 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.results).toEqual([
{ issueNumber: 1, success: true, taskId: expect.any(String) },
{ issueNumber: 1, success: true, skipped: true, taskId: expect.any(String) },
]);
expect(store.createTask).toHaveBeenCalledTimes(1);
expect(throttledSpy).toHaveBeenCalledTimes(2);
});
it("returns 400 for empty issueNumbers array", async () => { it("returns 400 for empty issueNumbers array", async () => {
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),

View File

@@ -113,8 +113,11 @@ function parseIssueUrl(stdout: string): { owner: string; repo: string; number: n
} }
/* /*
FNXC:GithubImport 2026-07-15-00:00: FNXC:GithubImport 2026-07-17-00:00:
GitHub import deduplication must survive edited task descriptions and owner/repo casing changes. Both CLI import paths, both extension tools, and dashboard single/batch routes share this sourceIssue-first helper, mirroring GitLab provenance while retaining legacy description URL matching. GitHub issue import deduplication treats persisted provenance as authoritative so edited descriptions and owner/repo casing changes cannot misidentify an import. Every dashboard, CLI, and extension issue-import surface shares this helper, which checks sourceIssue first, legacy github_import metadata second, and legacy description URLs last.
FNXC:GithubImport 2026-07-17-00:00:
The description compatibility fallback is eligible only when neither GitHub sourceIssue nor object-shaped github_import metadata exists. A nonmatching structured record must return false rather than letting quoted or stale URL text override its provenance.
*/ */
export function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }): { export function buildGitHubIssueSource(owner: string, repo: string, issue: { number: number; html_url: string }): {
sourceIssue: TaskSourceIssue; sourceIssue: TaskSourceIssue;
@@ -148,12 +151,9 @@ export function isGitHubIssueAlreadyImported(
): boolean { ): boolean {
const { owner, repo, issueNumber, sourceUrl } = input; const { owner, repo, issueNumber, sourceUrl } = input;
const repository = `${owner}/${repo}`; const repository = `${owner}/${repo}`;
const normalizedSourceUrl = sourceUrl.toLocaleLowerCase();
if (task.description?.toLocaleLowerCase().includes(normalizedSourceUrl)) return true;
const sourceIssue = task.sourceIssue; const sourceIssue = task.sourceIssue;
if (sourceIssue?.provider === "github") { const hasGitHubSourceIssue = sourceIssue?.provider === "github";
if (hasGitHubSourceIssue) {
if (equalsIgnoreCase(sourceIssue.url, sourceUrl)) return true; if (equalsIgnoreCase(sourceIssue.url, sourceUrl)) return true;
if (equalsIgnoreCase(sourceIssue.repository, repository) if (equalsIgnoreCase(sourceIssue.repository, repository)
&& (sourceIssue.issueNumber === issueNumber || sourceIssue.externalIssueId === String(issueNumber))) { && (sourceIssue.issueNumber === issueNumber || sourceIssue.externalIssueId === String(issueNumber))) {
@@ -162,14 +162,19 @@ export function isGitHubIssueAlreadyImported(
} }
const metadata = task.source?.sourceMetadata; const metadata = task.source?.sourceMetadata;
if (task.source?.sourceType === "github_import" && metadata && typeof metadata === "object") { const hasGitHubSourceMetadata = task.source?.sourceType === "github_import" && metadata && typeof metadata === "object";
if (hasGitHubSourceMetadata) {
const sourceMetadata = metadata as Record<string, unknown>; const sourceMetadata = metadata as Record<string, unknown>;
if (equalsIgnoreCase(typeof sourceMetadata.issueUrl === "string" ? sourceMetadata.issueUrl : undefined, sourceUrl)) return true; if (equalsIgnoreCase(typeof sourceMetadata.issueUrl === "string" ? sourceMetadata.issueUrl : undefined, sourceUrl)) return true;
return sourceMetadata.issueNumber === issueNumber if (sourceMetadata.issueNumber === issueNumber
&& equalsIgnoreCase(repositoryFromGitHubIssueUrl(sourceMetadata.issueUrl), repository); && equalsIgnoreCase(repositoryFromGitHubIssueUrl(sourceMetadata.issueUrl), repository)) {
return true;
}
} }
return false; if (hasGitHubSourceIssue || hasGitHubSourceMetadata) return false;
return task.description?.toLocaleLowerCase().includes(sourceUrl.toLocaleLowerCase()) ?? false;
} }
/** /**