feat(FN-4271): create tracking issue for github-imported tasks

Adds a tracking issue mechanism for GitHub-imported tasks, creating an internal Fusion task to track the import lifecycle. The TaskCard component gains visibility into tracking status, backed by new tests in both TaskCard and GitHub tracking modules.

Fusion-Task-Id: FN-4271
This commit is contained in:
Fusion
2026-05-13 00:25:15 -07:00
committed by gsxdsm
parent eb1e4e61c2
commit b3f9a8ac91
6 changed files with 102 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix: enabling GitHub tracking on a task imported from GitHub now creates a tracking issue instead of silently skipping. The board task card shows the tracking-issue link unless it points at the exact same `owner/repo#number` as the imported source issue.

View File

@@ -476,8 +476,9 @@ Tracking behavior is controlled per task:
- After an engine/dashboard restart, Task Detail preserves the fetched full `githubTracking` payload even when the board opened the modal from a slim task row that intentionally omitted tracking metadata.
- When a task is already tracking-enabled but still unlinked, Task Detail exposes a **Create tracking issue** action in the disclosure content (including non-editable columns like `done`) so "Issue not yet created" is not a dead-end state.
- Clearing the Task Detail repo override stores `null`, which reverts repo resolution to project/global defaults.
- Explicit task-level enablement is honored even when project/global GitHub tracking defaults are unset. If `enabled: true` and the repo resolves at task scope (for example via `repoOverride`), Fusion attempts tracking-issue creation on both create-time and eligible edit-time flows.
- Explicit task-level enablement is honored even when project/global GitHub tracking defaults are unset. If `enabled: true` and the repo resolves at task scope (for example via `repoOverride`), Fusion attempts tracking-issue creation on both create-time and eligible edit-time flows, including tasks imported from GitHub (`sourceType: "github_import"`).
- Explicit manual unlink (`githubTracking.issue: null`) does not recreate a tracking issue in that same update request, and disabling tracking does not create new issues.
- On board cards, Fusion shows both the imported-source provenance marker and tracking link when they refer to different issues. The tracking chip is hidden only when the linked tracking issue exactly matches the source issue (`owner/repo#number`) to avoid duplicate badges.
Repository resolution order:

View File

@@ -326,6 +326,21 @@ function getIssueUrlFromMetadata(metadata: Task["sourceMetadata"]): string | und
return typeof issueUrl === "string" && issueUrl.length > 0 ? issueUrl : undefined;
}
function parseGithubIssueUrl(url?: string): { owner: string; repo: string; number: number } | null {
if (!url) return null;
const match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:$|[/?#])/i);
if (!match) return null;
const issueNumber = Number(match[3]);
if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null;
return {
owner: match[1],
repo: match[2],
number: issueNumber,
};
}
function areTaskWorkflowResultsEqual(previous?: Task["workflowStepResults"], next?: Task["workflowStepResults"]): boolean {
if (!previous && !next) return true;
if (!previous || !next) return false;
@@ -740,12 +755,30 @@ function TaskCardComponent({
const hasGithubTrackingLink = Boolean(githubTrackedIssue);
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
const isGitHubImportedTask = task.sourceType === "github_import";
const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
const sourceIssueFromUrl = useMemo(() => parseGithubIssueUrl(sourceIssueUrl), [sourceIssueUrl]);
const issueInfoFromUrl = useMemo(() => parseGithubIssueUrl(task.issueInfo?.url), [task.issueInfo?.url]);
const issueInfoOwner = task.issueInfo?.owner ?? issueInfoFromUrl?.owner;
const issueInfoRepo = task.issueInfo?.repo ?? issueInfoFromUrl?.repo;
const hasMatchingIssueInfoBadge = Boolean(
task.issueInfo
&& githubTrackedIssue
&& task.issueInfo.number === githubTrackedIssue.number
&& issueInfoOwner === githubTrackedIssue.owner
&& issueInfoRepo === githubTrackedIssue.repo,
);
const hasMatchingSourceIssue = Boolean(
sourceIssueFromUrl
&& githubTrackedIssue
&& sourceIssueFromUrl.number === githubTrackedIssue.number
&& sourceIssueFromUrl.owner === githubTrackedIssue.owner
&& sourceIssueFromUrl.repo === githubTrackedIssue.repo,
);
const showTrackingIndicator = hasGithubTrackingLink
&& !isGitHubImportedTask
&& !(task.issueInfo && task.issueInfo.number === githubTrackedIssue?.number);
&& !hasMatchingIssueInfoBadge
&& !hasMatchingSourceIssue;
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
const isAgentCreated = isAgentCreatedTask(task);
const sourceAgentName = getSourceAgentName(task);
const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent";
@@ -1630,6 +1663,7 @@ function TaskCardComponent({
onClick={(e) => e.stopPropagation()}
>
<ProviderIcon provider="github" size="sm" />
<span>{`#${githubTrackedIssue.number}`}</span>
</a>
)}
{timeIndicator && (

View File

@@ -1062,7 +1062,33 @@ describe("TaskCard", () => {
expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull();
});
it("does not render a GitHub tracking link for github_import tasks", () => {
it("renders a GitHub tracking link for github_import tasks when the tracking issue is distinct from source", () => {
render(
<TaskCard
task={makeTask({
column: "todo",
sourceType: "github_import",
sourceMetadata: { issueUrl: "https://github.com/owner/repo/issues/42" },
githubTracking: {
issue: {
owner: "other",
repo: "tracking",
number: 99,
url: "https://github.com/other/tracking/issues/99",
createdAt: "2026-05-12T00:00:00.000Z",
},
},
})}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByRole("link", { name: "Linked GitHub issue #99" })).toBeDefined();
expect(screen.getByLabelText("Imported from GitHub")).toBeDefined();
});
it("deduplicates the tracking link when github_import tracking issue matches source owner/repo/number", () => {
render(
<TaskCard
task={makeTask({

View File

@@ -225,6 +225,37 @@ describe("maybeCreateTrackingIssue", () => {
}));
});
it("creates issue for github_import tasks when tracking is explicitly enabled", async () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({
sourceType: "github_import",
title: "Imported issue follow-up",
description: "Short body",
githubTracking: { enabled: true },
}), {
taskStore: { linkGithubIssue, recordActivity } as any,
projectSettings: {},
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
rootDir,
logger: console,
});
expect(createIssueMock).toHaveBeenCalledWith(expect.objectContaining({
owner: "o",
repo: "r",
title: "[FN-1] Imported issue follow-up",
body: "Fusion task: FN-1\n\nShort body",
}));
expect(result).toMatchObject({
created: true,
issue: { owner: "o", repo: "r", number: 12, htmlUrl: "https://github.com/o/r/issues/12" },
});
expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 }));
expect(recordActivity).toHaveBeenCalled();
});
it("uses the AI summarizer when the title is missing and a summarizer model is configured", async () => {
const linkGithubIssue = vi.fn();
const recordActivity = vi.fn();

View File

@@ -142,7 +142,6 @@ export interface MaybeCreateTrackingIssueDeps {
export type MaybeCreateTrackingIssueReason =
| "tracking_disabled"
| "issue_already_linked"
| "github_import_source"
| "no_repo_configured"
| "no_title_available"
| "github_error"
@@ -193,10 +192,6 @@ export async function maybeCreateTrackingIssue(
return { created: false, reason: "issue_already_linked" };
}
if (task.sourceType === "github_import") {
return { created: false, reason: "github_import_source" };
}
const repo = resolvedTracking.repo;
if (!repo) {