feat(FN-4032): add tracking retry with actionable create issue error in tas

The merge adds a GitHub integration retry lifecycle to the task tracking system, including an actionable retry mechanism for failed issue creation, fixes to tracking and auth retry flow, and corresponding tests in the dashboard and engine packages. Documentation is updated in `docs/architecture.md`

Fusion-Task-Id: FN-4032
This commit is contained in:
Fusion
2026-05-11 16:27:40 -07:00
committed by gsxdsm
parent 351702d055
commit 2ccf8c699d
10 changed files with 177 additions and 42 deletions

View File

@@ -837,6 +837,24 @@ export function TaskDetailContent({
}
}, [addToast, canEditGithubTracking, githubRepoOverrideTrimmed, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const handleRetryGithubTrackingIssueCreate = useCallback(async () => {
if (!githubTrackingEnabled || githubTrackedIssue || isSavingGithubTracking) return;
setIsSavingGithubTracking(true);
try {
const updatedTask = await updateTask(task.id, {
githubTracking: {
enabled: true,
},
}, projectId);
onTaskUpdated?.(updatedTask);
addToast("Requested GitHub tracking issue creation", "info");
} catch (err) {
addToast(`Failed to update ${task.id}: ${getErrorMessage(err)}`, "error");
} finally {
if (mountedRef.current) setIsSavingGithubTracking(false);
}
}, [addToast, githubTrackedIssue, githubTrackingEnabled, isSavingGithubTracking, onTaskUpdated, projectId, task.id]);
const enterEditMode = useCallback(() => {
if (!canEdit) return;
setIsEditing(true);
@@ -2399,40 +2417,47 @@ export function TaskDetailContent({
</div>
</dl>
)}
{canEditGithubTracking && (
<div className="detail-github-tracking-controls">
<label className="checkbox-label" htmlFor="detail-github-tracking-toggle">
<input
id="detail-github-tracking-toggle"
type="checkbox"
checked={githubTrackingEnabled}
disabled={isSavingGithubTracking}
onChange={() => void handleToggleGithubTracking()}
/>
Enable GitHub tracking
</label>
<div className="detail-github-tracking-repo-row">
<input
className="input"
value={githubRepoOverrideDraft}
onChange={(event) => {
setGithubRepoOverrideDraft(event.target.value);
setGithubRepoOverrideError(null);
}}
placeholder={effectiveGithubRepoDefault || "owner/repo"}
/>
<button className="btn btn-sm" onClick={() => void handleSaveGithubRepoOverride()} disabled={isSavingGithubTracking}>
Save
</button>
</div>
{githubRepoOverrideError && <small className="detail-github-tracking-error">{githubRepoOverrideError}</small>}
{githubTrackedIssue && (
<button className="btn btn-sm touch-target" onClick={() => void handleUnlinkGithubIssue()} disabled={isSavingGithubTracking}>
Unlink GitHub issue
</button>
)}
</div>
)}
<div className="detail-github-tracking-controls">
{!githubTrackedIssue && githubTrackingEnabled && (
<button className="btn btn-sm touch-target" onClick={() => void handleRetryGithubTrackingIssueCreate()} disabled={isSavingGithubTracking}>
Create tracking issue
</button>
)}
{canEditGithubTracking && (
<>
<label className="checkbox-label" htmlFor="detail-github-tracking-toggle">
<input
id="detail-github-tracking-toggle"
type="checkbox"
checked={githubTrackingEnabled}
disabled={isSavingGithubTracking}
onChange={() => void handleToggleGithubTracking()}
/>
Enable GitHub tracking
</label>
<div className="detail-github-tracking-repo-row">
<input
className="input"
value={githubRepoOverrideDraft}
onChange={(event) => {
setGithubRepoOverrideDraft(event.target.value);
setGithubRepoOverrideError(null);
}}
placeholder={effectiveGithubRepoDefault || "owner/repo"}
/>
<button className="btn btn-sm" onClick={() => void handleSaveGithubRepoOverride()} disabled={isSavingGithubTracking}>
Save
</button>
</div>
{githubRepoOverrideError && <small className="detail-github-tracking-error">{githubRepoOverrideError}</small>}
{githubTrackedIssue && (
<button className="btn btn-sm touch-target" onClick={() => void handleUnlinkGithubIssue()} disabled={isSavingGithubTracking}>
Unlink GitHub issue
</button>
)}
</>
)}
</div>
</div>
)}
</div>

View File

@@ -2222,6 +2222,32 @@ describe("TaskDetailModal", () => {
expect(screen.queryByText("GitHub tracking")).toBeNull();
});
it("shows create tracking issue action for enabled but unlinked tasks outside editable columns", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
render(
<TaskDetailModal
task={makeTask({ id: "FN-001", column: "done", githubTracking: { enabled: true } })}
onClose={noop}
onOpenDetail={noopOpenDetail}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expandGithubTracking();
fireEvent.click(screen.getByRole("button", { name: "Create tracking issue" }));
await waitFor(() => {
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
});
expect(screen.queryByLabelText("Enable GitHub tracking")).toBeNull();
});
it("sends githubTracking disabled→enabled toggle payload", async () => {
const { updateTask } = await import("../../api");
const mockUpdate = vi.mocked(updateTask);

View File

@@ -85,6 +85,24 @@ describe("resolveGithubTrackingAuth", () => {
expect(result).toEqual({ ok: true, auth: { mode: "gh-cli" } });
});
it("uses global githubAuthMode/githubAuthToken fallback when present", () => {
const result = resolveGithubTrackingAuth({
projectSettings: {},
globalSettings: { githubAuthMode: "token", githubAuthToken: "global-token" },
env: {},
});
expect(result).toEqual({ ok: true, auth: { mode: "token", token: "global-token" } });
});
it("uses defensive projectGithubAuthMode/projectGithubAuthToken fallback keys", () => {
const result = resolveGithubTrackingAuth({
projectSettings: {},
globalSettings: { projectGithubAuthMode: "token", projectGithubAuthToken: "project-global-token" },
env: {},
});
expect(result).toEqual({ ok: true, auth: { mode: "token", token: "project-global-token" } });
});
it("returns invalid_mode for unsupported mode values", () => {
const result = resolveGithubTrackingAuth({
projectSettings: { githubAuthMode: "weird" as "gh-cli" },

View File

@@ -211,4 +211,16 @@ describe("maybeCreateTrackingIssue", () => {
}));
expect(createIssueMock).not.toHaveBeenCalled();
});
it("passes global settings through to auth resolution", async () => {
const globalSettings = { githubTrackingDefaultRepo: "o/r", githubAuthMode: "token" } as any;
await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
projectSettings: {},
globalSettings,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(resolveAuthMock).toHaveBeenCalledWith(expect.objectContaining({ globalSettings }));
});
});

View File

@@ -1871,6 +1871,42 @@ describe("PATCH /tasks/:id", () => {
createIssueSpy.mockRestore();
});
it("retries tracking issue creation on non-tracking patch when task is enabled but unlinked", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "runfusion",
repo: "fusion",
number: 101,
htmlUrl: "https://github.com/runfusion/fusion/issues/101",
createdAt: "2026-01-01T00:00:00.000Z",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "tok" });
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
title: "Retitled",
githubTracking: { enabled: true, repoOverride: "runfusion/fusion" },
});
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
title: "Retitled",
githubTracking: {
enabled: true,
repoOverride: "runfusion/fusion",
issue: { owner: "runfusion", repo: "fusion", number: 101, url: "https://github.com/runfusion/fusion/issues/101", createdAt: "2026-01-01T00:00:00.000Z" },
},
});
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({ title: "Retitled" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "runfusion", repo: "fusion" }));
expect(store.linkGithubIssue).toHaveBeenCalledWith("KB-001", expect.objectContaining({ number: 101 }));
createIssueSpy.mockRestore();
});
it("returns 400 for invalid githubTracking repo override format", async () => {
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
githubTracking: {

View File

@@ -17,18 +17,31 @@ export type GithubTrackingAuthResolution =
export interface ResolveGithubTrackingAuthDeps {
projectSettings: Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;
globalSettings: Pick<GlobalSettings, never>;
globalSettings?: Partial<GlobalSettings> | Record<string, unknown>;
env?: NodeJS.ProcessEnv;
}
function pickString(source: Record<string, unknown> | undefined, key: string): string | undefined {
const value = source?.[key];
return typeof value === "string" ? value : undefined;
}
export function resolveGithubTrackingAuth(
deps: ResolveGithubTrackingAuthDeps,
): GithubTrackingAuthResolution {
const requestedMode = deps.projectSettings.githubAuthMode ?? "gh-cli";
const global = (deps.globalSettings ?? {}) as Record<string, unknown>;
const requestedMode = deps.projectSettings.githubAuthMode
?? pickString(global, "githubAuthMode")
?? pickString(global, "projectGithubAuthMode")
?? "gh-cli";
const env = deps.env ?? process.env;
if (requestedMode === "token") {
const token = deps.projectSettings.githubAuthToken?.trim() || env.GITHUB_TOKEN?.trim() || "";
const token = deps.projectSettings.githubAuthToken?.trim()
|| pickString(global, "githubAuthToken")?.trim()
|| pickString(global, "projectGithubAuthToken")?.trim()
|| env.GITHUB_TOKEN?.trim()
|| "";
if (!token) {
return {
ok: false,

View File

@@ -124,7 +124,7 @@ export async function maybeCreateTrackingIssue(
const resolution = resolveGithubTrackingAuth({
projectSettings: deps.projectSettings,
globalSettings: {},
globalSettings: deps.globalSettings,
});
if (!resolution.ok) {

View File

@@ -1829,8 +1829,12 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
validatedGithubTracking !== null &&
typeof validatedGithubTracking === "object" &&
validatedGithubTracking.issue === null;
const shouldAttemptTrackingIssueCreate =
!manualUnlinkRequested &&
task.githubTracking?.enabled === true &&
!task.githubTracking?.issue;
if (hasBodyField("githubTracking") && !manualUnlinkRequested) {
if (shouldAttemptTrackingIssueCreate) {
await maybeCreateTaskTrackingIssue(scopedStore, task, options?.githubToken);
const refreshedTask = await scopedStore.getTask(req.params.id, {
activityLogLimit: TASK_DETAIL_ACTIVITY_LOG_LIMIT,