feat(FN-3963): clarify automation tracked-task creation semantics

Adds regression tests for automation tracked-task creation behavior across the dashboard automation and tasks routes, plus a documentation clarification on the semantics of how automation creates tracked tasks.

Fusion-Task-Id: FN-3963
This commit is contained in:
Fusion
2026-05-10 19:36:55 -07:00
committed by gsxdsm
parent 87772e1fb5
commit 0c5c47933a
4 changed files with 265 additions and 1 deletions

View File

@@ -383,7 +383,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.
When task creation runs with tracking enabled, Fusion attempts issue creation during task creation flows (including quick create, planning output, and subtask creation paths that create tasks). Fusion also attempts issue creation on existing-task edits that update `githubTracking` (for example enabling tracking or setting a resolvable repo override) when the resulting task is enabled and unlinked. 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). Fusion also attempts issue creation on existing-task edits that update `githubTracking` (for example enabling tracking or setting a resolvable repo override) when the resulting task is enabled and unlinked. 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:

View File

@@ -94,6 +94,22 @@ describe("maybeCreateTrackingIssue", () => {
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
});
it("returns issue_already_linked and does not create again", async () => {
const result = await maybeCreateTrackingIssue(buildTask({
githubTracking: {
enabled: true,
issue: { owner: "task", repo: "repo", number: 99, url: "https://github.com/task/repo/issues/99" },
},
}), {
taskStore: {} as any,
projectSettings: { githubTrackingDefaultRepo: "task/repo", githubAuthMode: "token", githubAuthToken: "tok" } as any,
globalSettings: {},
});
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
expect(createIssueMock).not.toHaveBeenCalled();
});
it("returns no_repo_configured and records activity", async () => {
const recordActivity = vi.fn();
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {

View File

@@ -869,6 +869,131 @@ describe("Automation routes", () => {
);
});
it("create-task automation step attempts tracking issue creation and links metadata", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "task",
repo: "repo",
number: 17,
htmlUrl: "https://github.com/task/repo/issues/17",
createdAt: "2026-01-01T00:00:00.000Z",
});
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({
...FAKE_SCHEDULE,
command: "",
steps: [
{
id: "step-task",
type: "create-task",
name: "Create tracked task",
taskTitle: "Tracked report",
taskDescription: "Create tracked report",
taskColumn: "todo",
},
],
});
const linkGithubIssue = vi.fn().mockResolvedValue(undefined);
const recordActivity = vi.fn().mockResolvedValue(undefined);
const { app, store } = buildApp(mockStore);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.getGlobalSettingsStore as ReturnType<typeof vi.fn>).mockReturnValue({ getSettings: vi.fn().mockResolvedValue({}) });
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-9002",
title: "Tracked report",
description: "Create tracked report",
githubTracking: { enabled: true },
});
(store as unknown as { linkGithubIssue: typeof linkGithubIssue }).linkGithubIssue = linkGithubIssue;
(store as unknown as { recordActivity: typeof recordActivity }).recordActivity = recordActivity;
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run");
expect(res.status).toBe(200);
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
expect(linkGithubIssue).toHaveBeenCalledWith("FN-9002", expect.objectContaining({ owner: "task", repo: "repo", number: 17 }));
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ type: "github-issue-created" }) }));
createIssueSpy.mockRestore();
});
it("create-task automation step keeps success when tracking issue creation fails", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockRejectedValue(new Error("github down"));
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({
...FAKE_SCHEDULE,
command: "",
steps: [
{
id: "step-task",
type: "create-task",
name: "Create tracked task",
taskDescription: "Create tracked report",
},
],
});
const { app, store } = buildApp(mockStore);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-9003",
description: "Create tracked report",
githubTracking: { enabled: true },
});
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run");
expect(res.status).toBe(200);
expect(res.body.result.stepResults[0]).toEqual(expect.objectContaining({ success: true }));
expect(createIssueSpy).toHaveBeenCalledTimes(1);
createIssueSpy.mockRestore();
});
it("does not create tracking issue in automation create-task when task already linked", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue");
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({
...FAKE_SCHEDULE,
command: "",
steps: [
{
id: "step-task",
type: "create-task",
name: "Create tracked task",
taskDescription: "Create tracked report",
},
],
});
const { app, store } = buildApp(mockStore);
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-9004",
description: "Create tracked report",
githubTracking: {
enabled: true,
issue: { owner: "task", repo: "repo", number: 3, url: "https://github.com/task/repo/issues/3" },
},
});
const res = await REQUEST(app, "POST", "/api/automations/sched-001/run");
expect(res.status).toBe(200);
expect(res.body.result.stepResults[0]).toEqual(expect.objectContaining({ success: true }));
expect(createIssueSpy).not.toHaveBeenCalled();
createIssueSpy.mockRestore();
});
it("respects continueOnFailure for create-task failures", async () => {
const mockStore = createMockAutomationStore();
mockStore.getSchedule.mockResolvedValue({

View File

@@ -1946,6 +1946,129 @@ describe("POST /subtasks/*", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-102", { dependencies: ["FN-101"] });
});
it("subtask batch creation attempts tracking issue creation and links metadata", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockResolvedValue({
owner: "task",
repo: "repo",
number: 55,
htmlUrl: "https://github.com/task/repo/issues/55",
createdAt: "2026-01-01T00:00:00.000Z",
});
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-103", title: "First", column: "triage", githubTracking: { enabled: true } });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createIssueSpy).toHaveBeenCalledWith(expect.objectContaining({ owner: "task", repo: "repo" }));
expect(store.linkGithubIssue).toHaveBeenCalledWith("FN-103", expect.objectContaining({ owner: "task", repo: "repo", number: 55 }));
expect(store.recordActivity).toHaveBeenCalledWith(expect.objectContaining({ metadata: expect.objectContaining({ type: "github-issue-created" }) }));
createIssueSpy.mockRestore();
});
it("subtask batch creation remains successful when tracking issue creation fails", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue").mockRejectedValue(new Error("boom"));
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-104", title: "First", column: "triage", githubTracking: { enabled: true } });
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createRes.body.tasks).toHaveLength(1);
expect(createIssueSpy).toHaveBeenCalledTimes(1);
createIssueSpy.mockRestore();
});
it("subtask batch creation does not recreate tracking issue when task is already linked", async () => {
const createIssueSpy = vi.spyOn(GitHubClient.prototype, "createIssue");
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
githubTrackingDefaultRepo: "task/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "FN-105",
title: "First",
column: "triage",
githubTracking: {
enabled: true,
issue: { owner: "task", repo: "repo", number: 9, url: "https://github.com/task/repo/issues/9" },
},
});
const start = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/start-streaming",
JSON.stringify({ description: "Break this feature into subtasks" }),
{ "Content-Type": "application/json" },
);
const createRes = await REQUEST(
buildApp(),
"POST",
"/api/subtasks/create-tasks",
JSON.stringify({
sessionId: start.body.sessionId,
subtasks: [{ tempId: "subtask-1", title: "First", description: "Do first" }],
}),
{ "Content-Type": "application/json" },
);
expect(createRes.status).toBe(201);
expect(createIssueSpy).not.toHaveBeenCalled();
createIssueSpy.mockRestore();
});
it("applies explicit branch selection to created subtasks", async () => {
(store.createTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_TASK_DETAIL, id: "FN-201", title: "First", column: "triage" });