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. - 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. - 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. - 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. - 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: 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; 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 { function areTaskWorkflowResultsEqual(previous?: Task["workflowStepResults"], next?: Task["workflowStepResults"]): boolean {
if (!previous && !next) return true; if (!previous && !next) return true;
if (!previous || !next) return false; if (!previous || !next) return false;
@@ -740,12 +755,30 @@ function TaskCardComponent({
const hasGithubTrackingLink = Boolean(githubTrackedIssue); const hasGithubTrackingLink = Boolean(githubTrackedIssue);
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo); const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
const isGitHubImportedTask = task.sourceType === "github_import"; 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 const showTrackingIndicator = hasGithubTrackingLink
&& !isGitHubImportedTask && !hasMatchingIssueInfoBadge
&& !(task.issueInfo && task.issueInfo.number === githubTrackedIssue?.number); && !hasMatchingSourceIssue;
const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]); const branchMetadata = useMemo(() => getVisibleTaskCardBranches(task), [task.id, task.branch, task.baseBranch]);
const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch); const hasBranchMetadata = Boolean(branchMetadata.branch || branchMetadata.baseBranch);
const sourceIssueUrl = getIssueUrlFromMetadata(task.sourceMetadata);
const isAgentCreated = isAgentCreatedTask(task); const isAgentCreated = isAgentCreatedTask(task);
const sourceAgentName = getSourceAgentName(task); const sourceAgentName = getSourceAgentName(task);
const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent"; const agentCreatedTitle = sourceAgentName ? `Created by agent: ${sourceAgentName}` : "Created by agent";
@@ -1630,6 +1663,7 @@ function TaskCardComponent({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<ProviderIcon provider="github" size="sm" /> <ProviderIcon provider="github" size="sm" />
<span>{`#${githubTrackedIssue.number}`}</span>
</a> </a>
)} )}
{timeIndicator && ( {timeIndicator && (

View File

@@ -1062,7 +1062,33 @@ describe("TaskCard", () => {
expect(screen.queryByRole("link", { name: /Linked GitHub issue/i })).toBeNull(); 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( render(
<TaskCard <TaskCard
task={makeTask({ 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 () => { it("uses the AI summarizer when the title is missing and a summarizer model is configured", async () => {
const linkGithubIssue = vi.fn(); const linkGithubIssue = vi.fn();
const recordActivity = vi.fn(); const recordActivity = vi.fn();

View File

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