FN-6007: prevent duplicate GitHub issue imports and tracking issues

Prevent duplicate GitHub issue/task linkage from creating redundant imports and tracking issues.

- add shared duplicate-detection helpers that match imported GitHub issues by source metadata as well as source URLs
- switch CLI and dashboard GitHub import flows to inspect full task records and reuse the helper during single and batch imports
- remove redundant duplicate/refine tracking-issue creation calls from task workflow routes and add a patch changeset with regression coverage

Files changed:
 .changeset/fn-6007-fix-duplicate-github-issues.md  |  5 ++
 packages/cli/src/__tests__/extension.test.ts       | 68 ++++++++++++++++
 packages/cli/src/extension.ts                      | 24 ++++--
 .../src/__tests__/github-tracking-hook.test.ts     | 68 ++++++++++++++++
 .../dashboard/src/__tests__/routes-github.test.ts  | 73 ++++++++++++++++++
 .../src/__tests__/routes-tasks-ops.test.ts         | 90 +---------------------
 .../dashboard/src/routes/register-git-github.ts    | 24 ++++--
 .../src/routes/register-task-workflow-routes.ts    | 16 ----
 8 files changed, 254 insertions(+), 114 deletions(-)

Fusion-Task-Id: FN-6007

Fusion-Task-Lineage: a32ebb1d-efe4-4a52-be9a-fa3d3b5954bf
This commit is contained in:
gsxdsm
2026-06-07 18:54:24 -07:00
parent 3615b0b19d
commit e84410e520
8 changed files with 254 additions and 114 deletions

View File

@@ -2234,6 +2234,74 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
});
});
it("fn_task_import_github skips issues already imported via sourceIssue even when description was edited", async () => {
const store = new TaskStore(tmpDir);
await store.init();
await store.createTask({
title: "Existing imported issue",
description: "Edited description without source URL",
sourceIssue: {
provider: "github",
repository: "acme/demo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/acme/demo/issues/1",
},
});
store.close();
const tool = api.tools.get("fn_task_import_github")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([
{
number: 1,
title: "Issue one",
body: "First issue body",
html_url: "https://github.com/acme/demo/issues/1",
},
] as never);
const result = await tool.execute("gh-2b", { ownerRepo: "acme/demo" }, undefined, undefined, makeCtx(tmpDir));
expect(result.content[0].text).toContain("Imported 0 tasks from acme/demo");
expect(result.details.createdTasks).toHaveLength(0);
});
it("fn_task_import_github_issue skips issues already imported via sourceIssue even when description was edited", async () => {
const store = new TaskStore(tmpDir);
await store.init();
const existing = await store.createTask({
title: "Existing imported issue",
description: "Edited description without source URL",
sourceIssue: {
provider: "github",
repository: "acme/demo",
externalIssueId: "1",
issueNumber: 1,
url: "https://github.com/acme/demo/issues/1",
},
});
store.close();
const tool = api.tools.get("fn_task_import_github_issue")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce({
number: 1,
title: "Issue one",
body: "First issue body",
html_url: "https://github.com/acme/demo/issues/1",
} as never);
const result = await tool.execute(
"gh-2c",
{ owner: "acme", repo: "demo", issueNumber: 1 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details).toMatchObject({ skipped: true, existingTaskId: existing.id });
expect(result.content[0].text).toContain(existing.id);
});
it("fn_task_browse_github_issues lists issues via gh api", async () => {
const tool = api.tools.get("fn_task_browse_github_issues")!;
vi.mocked(runGhJsonAsync).mockResolvedValueOnce([

View File

@@ -442,6 +442,20 @@ function buildGitHubIssueSource(owner: string, repo: string, issue: { number: nu
};
}
function isIssueAlreadyImported(
task: Pick<Task, "description" | "sourceIssue">,
owner: string,
repo: string,
issueNumber: number,
sourceUrl: string,
): boolean {
const sourceIssue = task.sourceIssue;
return task.description.includes(sourceUrl)
|| (sourceIssue?.provider === "github"
&& sourceIssue.repository === `${owner}/${repo}`
&& sourceIssue.issueNumber === issueNumber);
}
async function fetchGitHubIssueViaGh(
owner: string,
repo: string,
@@ -1274,12 +1288,12 @@ export default function kbExtension(pi: ExtensionAPI) {
}
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true });
const existingTasks = await store.listTasks({ slim: false });
const createdTasks: Array<{ id: string; title: string }> = [];
for (const issue of issues) {
const sourceUrl = issue.html_url;
const alreadyImported = existingTasks.some((task) => task.description.includes(sourceUrl));
const alreadyImported = existingTasks.some((task) => isIssueAlreadyImported(task, owner, repo, issue.number, sourceUrl));
if (alreadyImported) {
continue;
}
@@ -1304,7 +1318,7 @@ export default function kbExtension(pi: ExtensionAPI) {
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
createdTasks.push({ id: task.id, title: task.title || issue.title });
existingTasks.push({ ...task, description });
existingTasks.push(task);
}
const summary = `✓ Imported ${createdTasks.length} tasks from ${owner}/${repo}`;
@@ -1358,11 +1372,11 @@ export default function kbExtension(pi: ExtensionAPI) {
// Check if already imported
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks({ slim: true });
const existingTasks = await store.listTasks({ slim: false });
const sourceUrl = issue.html_url;
for (const task of existingTasks) {
if (task.description.includes(sourceUrl)) {
if (isIssueAlreadyImported(task, owner, repo, issueNumber, sourceUrl)) {
return {
content: [
{