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 24e964274e
commit 991776b80c
6 changed files with 102 additions and 10 deletions

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) {