feat(FN-4044): restore github tracking patch persistence
Restored GitHub tracking patch persistence in the core store with matching test coverage in the task detail and routes tasks ops suites, plus documentation for the repaired flow. Fusion-Task-Id: FN-4044
This commit is contained in:
@@ -396,7 +396,7 @@ Manual/non-auto-merge behavior:
|
|||||||
|
|
||||||
GitHub tracking issues are optional issues Fusion can create from Fusion tasks. They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`): imported issues represent an existing GitHub issue that created the task, while tracking issues are new GitHub issues opened to track a Fusion task.
|
GitHub tracking issues are optional issues Fusion can create from Fusion tasks. They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`): imported issues represent an existing GitHub issue that created the task, while tracking issues are new GitHub issues opened to track a Fusion task.
|
||||||
|
|
||||||
When task creation runs with tracking enabled, Fusion attempts issue creation during task creation flows (including quick create, planning output, automation `create-task` workflow steps, and subtask creation paths that create tasks). Fusion also retries issue creation on existing-task PATCH updates whenever the resulting task is **enabled and still unlinked** (including non-`githubTracking` edits), so previously skipped/unlinked tasks can recover later without a dedicated migration. Creation is best-effort and non-blocking: task updates and task creation still succeed even if repo resolution fails or GitHub calls fail.
|
When task creation runs with tracking enabled, Fusion attempts issue creation during task creation flows (including quick create, planning output, automation `create-task` workflow steps, and subtask creation paths that create tasks). For existing tasks, PATCH first persists any `githubTracking` mutation (enable/disable, repo override, or unlink), then evaluates whether the updated task is **enabled and still unlinked** and should trigger best-effort issue creation (including non-`githubTracking` edits). This keeps retry/create behavior consistent from Task Detail instead of relying on stale pre-patch state. Creation is best-effort and non-blocking: task updates and task creation still succeed even if repo resolution fails or GitHub calls fail.
|
||||||
|
|
||||||
Tracking behavior is controlled per task:
|
Tracking behavior is controlled per task:
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,23 @@ describe("TaskStore github tracking", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists githubTracking through generic updateTask patch flow", async () => {
|
||||||
|
const task = await store.createTask({ description: "Patch issue" });
|
||||||
|
|
||||||
|
await store.updateTask(task.id, {
|
||||||
|
githubTracking: {
|
||||||
|
enabled: true,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await store.getTask(task.id);
|
||||||
|
expect(updated?.githubTracking).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
repoOverride: "octocat/hello-world",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("links and unlinks tracked issue while preserving other tracking fields", async () => {
|
it("links and unlinks tracked issue while preserving other tracking fields", async () => {
|
||||||
const task = await store.createTask({ description: "Link issue" });
|
const task = await store.createTask({ description: "Link issue" });
|
||||||
|
|
||||||
|
|||||||
@@ -3386,7 +3386,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
|
|
||||||
async updateTask(
|
async updateTask(
|
||||||
id: string,
|
id: string,
|
||||||
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
|
||||||
runContext?: RunMutationContext,
|
runContext?: RunMutationContext,
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
return this.withTaskLock(id, async () => {
|
return this.withTaskLock(id, async () => {
|
||||||
@@ -3706,6 +3706,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
} else if (updates.sourceIssue !== undefined) {
|
} else if (updates.sourceIssue !== undefined) {
|
||||||
task.sourceIssue = updates.sourceIssue;
|
task.sourceIssue = updates.sourceIssue;
|
||||||
}
|
}
|
||||||
|
if (updates.githubTracking === null) {
|
||||||
|
task.githubTracking = undefined;
|
||||||
|
} else if (updates.githubTracking !== undefined) {
|
||||||
|
task.githubTracking = updates.githubTracking;
|
||||||
|
}
|
||||||
if (updates.tokenUsage === null) {
|
if (updates.tokenUsage === null) {
|
||||||
task.tokenUsage = undefined;
|
task.tokenUsage = undefined;
|
||||||
} else if (updates.tokenUsage !== undefined) {
|
} else if (updates.tokenUsage !== undefined) {
|
||||||
|
|||||||
@@ -2225,7 +2225,23 @@ describe("TaskDetailModal", () => {
|
|||||||
it("shows create tracking issue action for enabled but unlinked tasks outside editable columns", async () => {
|
it("shows create tracking issue action for enabled but unlinked tasks outside editable columns", async () => {
|
||||||
const { updateTask } = await import("../../api");
|
const { updateTask } = await import("../../api");
|
||||||
const mockUpdate = vi.mocked(updateTask);
|
const mockUpdate = vi.mocked(updateTask);
|
||||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
const onTaskUpdated = vi.fn();
|
||||||
|
const addToast = vi.fn();
|
||||||
|
const updatedTask = makeTask({
|
||||||
|
id: "FN-001",
|
||||||
|
column: "done",
|
||||||
|
githubTracking: {
|
||||||
|
enabled: true,
|
||||||
|
issue: {
|
||||||
|
owner: "runfusion",
|
||||||
|
repo: "fusion",
|
||||||
|
number: 77,
|
||||||
|
url: "https://github.com/runfusion/fusion/issues/77",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockUpdate.mockResolvedValueOnce(updatedTask as Task);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<TaskDetailModal
|
<TaskDetailModal
|
||||||
@@ -2235,7 +2251,8 @@ describe("TaskDetailModal", () => {
|
|||||||
onMoveTask={noopMove}
|
onMoveTask={noopMove}
|
||||||
onDeleteTask={noopDelete}
|
onDeleteTask={noopDelete}
|
||||||
onMergeTask={noopMerge}
|
onMergeTask={noopMerge}
|
||||||
addToast={noop}
|
onTaskUpdated={onTaskUpdated}
|
||||||
|
addToast={addToast}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2245,6 +2262,8 @@ describe("TaskDetailModal", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
|
expect(mockUpdate).toHaveBeenCalledWith("FN-001", { githubTracking: { enabled: true } }, undefined);
|
||||||
});
|
});
|
||||||
|
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Requested GitHub tracking issue creation", "info");
|
||||||
expect(screen.queryByLabelText("Enable GitHub tracking")).toBeNull();
|
expect(screen.queryByLabelText("Enable GitHub tracking")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ describe("github tracking documentation contract", () => {
|
|||||||
|
|
||||||
expect(taskManagement).toContain("## GitHub Tracking Issues");
|
expect(taskManagement).toContain("## GitHub Tracking Issues");
|
||||||
expect(taskManagement).toContain("They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`)");
|
expect(taskManagement).toContain("They are **not** the same as imported source issues (`issueInfo` / `sourceIssue`)");
|
||||||
expect(taskManagement).toContain("task creation flows (including quick create, planning output, and subtask creation paths that create tasks)");
|
expect(taskManagement).toContain("task creation flows (including quick create, planning output, automation `create-task` workflow steps, and subtask creation paths that create tasks)");
|
||||||
expect(taskManagement).toContain("Fusion also attempts issue creation on existing-task edits that update `githubTracking`");
|
expect(taskManagement).toContain("For existing tasks, PATCH first persists any `githubTracking` mutation");
|
||||||
expect(taskManagement).toContain("task.githubTracking.enabled");
|
expect(taskManagement).toContain("task.githubTracking.enabled");
|
||||||
expect(taskManagement).toContain("task.githubTracking.repoOverride");
|
expect(taskManagement).toContain("task.githubTracking.repoOverride");
|
||||||
expect(taskManagement).toContain("Repository resolution order");
|
expect(taskManagement).toContain("Repository resolution order");
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ vi.mock("@fusion/engine", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
import { AgentStore, Database, RoutineStore, TaskStore as CoreTaskStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||||
import { createFnAgent } from "@fusion/engine";
|
import { createFnAgent } from "@fusion/engine";
|
||||||
|
|
||||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||||
@@ -1819,6 +1819,58 @@ describe("PATCH /tasks/:id", () => {
|
|||||||
createIssueSpy.mockRestore();
|
createIssueSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PATCH persists githubTracking for existing tasks and links created issue with a real store", async () => {
|
||||||
|
const rootDir = mkdtempSync(join(tmpdir(), "kb-routes-patch-github-tracking-"));
|
||||||
|
const globalDir = mkdtempSync(join(tmpdir(), "kb-routes-patch-github-tracking-global-"));
|
||||||
|
const realStore = new CoreTaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||||
|
await realStore.init();
|
||||||
|
|
||||||
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
|
||||||
|
owner: "runfusion",
|
||||||
|
repo: "fusion",
|
||||||
|
number: 74,
|
||||||
|
htmlUrl: "https://github.com/runfusion/fusion/issues/74",
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await realStore.updateSettings({
|
||||||
|
githubAuthMode: "token",
|
||||||
|
githubAuthToken: "tok",
|
||||||
|
githubTrackingDefaultRepo: "runfusion/fusion",
|
||||||
|
});
|
||||||
|
const created = await realStore.createTask({ description: "route patch flow", column: "todo" });
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api", createApiRoutes(realStore));
|
||||||
|
|
||||||
|
const res = await REQUEST(app, "PATCH", `/api/tasks/${created.id}`, JSON.stringify({
|
||||||
|
githubTracking: { enabled: true },
|
||||||
|
}), {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "runfusion", repo: "fusion" }));
|
||||||
|
expect(res.body.githubTracking?.enabled).toBe(true);
|
||||||
|
expect(res.body.githubTracking?.issue).toMatchObject({
|
||||||
|
owner: "runfusion",
|
||||||
|
repo: "fusion",
|
||||||
|
number: 74,
|
||||||
|
});
|
||||||
|
|
||||||
|
const persisted = await realStore.getTask(created.id);
|
||||||
|
expect(persisted.githubTracking?.enabled).toBe(true);
|
||||||
|
expect(persisted.githubTracking?.issue?.number).toBe(74);
|
||||||
|
} finally {
|
||||||
|
createIssueSpy.mockRestore();
|
||||||
|
realStore.close();
|
||||||
|
rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
rmSync(globalDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("does not recreate tracking issue during explicit manual unlink patch", async () => {
|
it("does not recreate tracking issue during explicit manual unlink patch", async () => {
|
||||||
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
|
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
|
||||||
owner: "runfusion",
|
owner: "runfusion",
|
||||||
|
|||||||
Reference in New Issue
Block a user