FN-5835: reuse imported GitHub source issue tracking

Reuse the existing imported source issue record when tracking GitHub work instead of creating duplicate entries.

- update GitHub tracking logic to detect and reuse previously imported source issues
- add/expand dashboard tracking tests to cover duplicate-import and reuse behavior
- add a changeset documenting the FN-5835 tracking fix for @runfusion/fusion

Files changed:
 .changeset/fn-5835-source-issue-tracking.md        |   5 ++
 .../src/__tests__/github-tracking.test.ts          | 100 ++++++++++++++++++---
 packages/dashboard/src/github-tracking.ts          |  32 +++++++
 3 files changed, 126 insertions(+), 11 deletions(-)

Fusion-Task-Id: FN-5835

Fusion-Task-Lineage: d0827711-8bfb-471c-b2c4-69324da33373
This commit is contained in:
gsxdsm
2026-06-01 08:13:36 -07:00
parent e16893a3c7
commit b4230c06b1
3 changed files with 126 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Reuse imported GitHub source issues as task tracking links when GitHub tracking is enabled, instead of creating a duplicate issue. Tasks imported from GitHub now link their existing `sourceIssue` (when valid) as `githubTracking.issue` with no GitHub auth or issue creation call required.

View File

@@ -375,7 +375,7 @@ describe("maybeCreateTrackingIssue", () => {
expect(createIssueMock).toHaveBeenCalledTimes(1); expect(createIssueMock).toHaveBeenCalledTimes(1);
}); });
it("creates issue for github_import tasks when tracking is explicitly enabled", async () => { it("links GitHub sourceIssue instead of creating a duplicate", async () => {
const linkGithubIssue = vi.fn(); const linkGithubIssue = vi.fn();
const recordActivity = vi.fn(); const recordActivity = vi.fn();
@@ -383,6 +383,13 @@ describe("maybeCreateTrackingIssue", () => {
sourceType: "github_import", sourceType: "github_import",
title: "Imported issue follow-up", title: "Imported issue follow-up",
description: "Short body", description: "Short body",
sourceIssue: {
provider: "github",
repository: "upstream/repo",
externalIssueId: "123",
issueNumber: 123,
url: "https://github.com/upstream/repo/issues/123",
},
githubTracking: { enabled: true }, githubTracking: { enabled: true },
}), { }), {
taskStore: { linkGithubIssue, recordActivity } as any, taskStore: { linkGithubIssue, recordActivity } as any,
@@ -392,18 +399,89 @@ describe("maybeCreateTrackingIssue", () => {
logger: console, logger: console,
}); });
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({ expect(result).toEqual({ created: false, reason: "source_issue_linked" });
owner: "o", expect(createIssueMock).not.toHaveBeenCalled();
repo: "r", expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({
title: "[FN-1] Imported issue follow-up", owner: "upstream",
body: "Fusion task: FN-1\n\nShort body", repo: "repo",
number: 123,
url: "https://github.com/upstream/repo/issues/123",
})); }));
expect(result).toMatchObject({ expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
created: true, metadata: expect.objectContaining({ type: "github-issue-source-linked", repo: "upstream/repo", number: 123 }),
issue: { owner: "o", repo: "r", number: 12, htmlUrl: "https://github.com/o/r/issues/12" }, }));
});
it("constructs sourceIssue URL when sourceIssue.url is missing", async () => {
const linkGithubIssue = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({
sourceIssue: {
provider: "github",
repository: "upstream/repo",
externalIssueId: "987",
issueNumber: 987,
},
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(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 }));
expect(recordActivity).toHaveBeenCalled(); expect(result).toEqual({ created: false, reason: "source_issue_linked" });
expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({
url: "https://github.com/upstream/repo/issues/987",
}));
expect(createIssueMock).not.toHaveBeenCalled();
});
it("falls through to normal create path when sourceIssue provider is non-github", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
title: "Imported issue follow-up",
description: "Short body",
sourceIssue: {
provider: "gitlab",
repository: "group/project",
externalIssueId: "321",
issueNumber: 321,
},
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue: vi.fn(), 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);
});
it("falls through to normal create path when sourceIssue repository slug is invalid", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
title: "Imported issue follow-up",
description: "Short body",
sourceIssue: {
provider: "github",
repository: "invalidslug",
externalIssueId: "222",
issueNumber: 222,
},
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue: vi.fn(), 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);
}); });
it("uses the AI summarizer when the title is missing and a summarizer model is configured", async () => { it("uses the AI summarizer when the title is missing and a summarizer model is configured", async () => {

View File

@@ -1,6 +1,7 @@
import { import {
AiServiceError, AiServiceError,
MIN_DESCRIPTION_LENGTH, MIN_DESCRIPTION_LENGTH,
parseRepoSlug,
resolveTaskGithubTracking, resolveTaskGithubTracking,
summarizeTitle, summarizeTitle,
type GlobalSettings, type GlobalSettings,
@@ -152,6 +153,7 @@ export type MaybeCreateTrackingIssueReason =
| "no_repo_configured" | "no_repo_configured"
| "no_title_available" | "no_title_available"
| "existing_issue_found" | "existing_issue_found"
| "source_issue_linked"
| "github_error" | "github_error"
| "auth_token_missing" | "auth_token_missing"
| "auth_gh_not_installed" | "auth_gh_not_installed"
@@ -227,6 +229,36 @@ export async function maybeCreateTrackingIssue(
}); });
} }
const sourceIssue = latestTask.sourceIssue;
if (sourceIssue?.provider === "github") {
const sourceRepo = parseRepoSlug(sourceIssue.repository);
if (sourceRepo && Number.isFinite(sourceIssue.issueNumber)) {
const url = sourceIssue.url
?? `https://github.com/${sourceRepo.owner}/${sourceRepo.repo}/issues/${sourceIssue.issueNumber}`;
const createdAt = new Date().toISOString();
await deps.taskStore.linkGithubIssue(task.id, {
owner: sourceRepo.owner,
repo: sourceRepo.repo,
number: sourceIssue.issueNumber,
url,
createdAt,
});
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: `Linked source issue ${sourceRepo.owner}/${sourceRepo.repo}#${sourceIssue.issueNumber}`,
metadata: {
type: "github-issue-source-linked",
repo: `${sourceRepo.owner}/${sourceRepo.repo}`,
number: sourceIssue.issueNumber,
htmlUrl: url,
},
});
return { created: false, reason: "source_issue_linked" };
}
}
const repo = resolvedTracking.repo; const repo = resolvedTracking.repo;
if (!repo) { if (!repo) {