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:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -124,7 +124,7 @@ export async function maybeCreateTrackingIssue(
|
||||
|
||||
const resolution = resolveGithubTrackingAuth({
|
||||
projectSettings: deps.projectSettings,
|
||||
globalSettings: {},
|
||||
globalSettings: deps.globalSettings,
|
||||
});
|
||||
|
||||
if (!resolution.ok) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user