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:
@@ -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([
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user