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

@@ -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" });