Files
fusion/packages/dashboard/src/__tests__/github-tracking-hook.test.ts
2026-05-18 11:46:43 -07:00

525 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { TaskStore, setTaskCreatedHook } from "@fusion/core";
const { mockCreateIssue, mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
mockCreateIssue: vi.fn(),
mockResolveGithubTrackingAuth: vi.fn(),
}));
vi.mock("../github.js", () => ({
GitHubClient: vi.fn().mockImplementation(() => ({
createIssue: (...args: unknown[]) => mockCreateIssue(...args),
})),
}));
vi.mock("../github-auth.js", () => ({
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
}));
import { createTrackingIssueForTask, registerGithubTrackingHook } from "../github-tracking-hook.js";
import * as githubTracking from "../github-tracking.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-dashboard-github-tracking-hook-test-"));
}
describe("registerGithubTrackingHook", () => {
let rootDir: string;
let globalDir: string;
let store: TaskStore;
beforeEach(async () => {
setTaskCreatedHook(undefined);
vi.clearAllMocks();
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "tok" } });
mockCreateIssue.mockResolvedValue({
owner: "o",
repo: "r",
number: 42,
htmlUrl: "https://github.com/o/r/issues/42",
createdAt: "2026-01-01T00:00:00.000Z",
});
rootDir = makeTmpDir();
globalDir = makeTmpDir();
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
});
afterEach(async () => {
setTaskCreatedHook(undefined);
store.close();
await rm(rootDir, { recursive: true, force: true });
await rm(globalDir, { recursive: true, force: true });
});
it("creates a tracking issue when githubTracking.enabled is true and repo is configured", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingDefaultRepo: "o/r",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const task = await store.createTask({
description: "test task",
title: "Test task",
githubTracking: { enabled: true },
});
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({ owner: "o", repo: "r", title: expect.stringContaining(task.id) }),
);
});
it("is a no-op when githubTracking is not enabled", async () => {
registerGithubTrackingHook();
await store.createTask({
description: "no tracking",
title: "No tracking",
});
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("is a no-op when task already has a linked issue", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingDefaultRepo: "o/r",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const task = await store.createTask({
description: "already linked",
title: "Already linked",
githubTracking: {
enabled: true,
issue: { owner: "o", repo: "r", number: 1, url: "https://github.com/o/r/issues/1" },
},
});
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("does not propagate hook errors out of createTask", async () => {
mockCreateIssue.mockRejectedValue(new Error("Octokit failure"));
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingDefaultRepo: "o/r",
githubAuthMode: "token",
githubAuthToken: "tok",
});
// Should NOT throw — best-effort contract
const task = await store.createTask({
description: "will fail gracefully",
title: "Graceful failure",
githubTracking: { enabled: true },
});
expect(task.id).toMatch(/^FN-/);
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
it("creates one issue total across hook execution and follow-up stale reference call", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingDefaultRepo: "o/r",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const createdTask = await store.createTask({
description: "stale follow up",
title: "Stale follow up",
githubTracking: { enabled: true },
});
const staleTaskRef = { ...createdTask, githubTracking: { enabled: true } };
const projectSettings = await store.getSettings();
const result = await githubTracking.maybeCreateTrackingIssue(staleTaskRef, {
taskStore: store,
projectSettings,
globalSettings: {},
rootDir,
logger: { warn: vi.fn(), info: vi.fn() },
});
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
it("passes request token when settings token is missing", async () => {
const task = await store.createTask({
description: "token override",
githubTracking: { enabled: true },
});
const spy = vi.spyOn(githubTracking, "maybeCreateTrackingIssue").mockResolvedValue({
created: false,
reason: "tracking_disabled",
});
await createTrackingIssueForTask(store, task, { githubToken: "request-token" });
expect(spy).toHaveBeenCalledWith(
task,
expect.objectContaining({
projectSettings: expect.objectContaining({ githubAuthToken: "request-token" }),
}),
);
spy.mockRestore();
});
it("passes registered hook githubToken fallback when settings token is missing", async () => {
registerGithubTrackingHook({ githubToken: "hook-token" });
await store.updateSettings({
githubTrackingDefaultRepo: "o/r",
githubAuthMode: "token",
});
await store.createTask({
description: "hook token fallback",
title: "Hook token fallback",
githubTracking: { enabled: true },
});
await vi.waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
});
it("prefers settings token over request token", async () => {
await store.updateSettings({ githubAuthToken: "settings-token" });
const task = await store.createTask({
description: "token precedence",
githubTracking: { enabled: true },
});
const spy = vi.spyOn(githubTracking, "maybeCreateTrackingIssue").mockResolvedValue({
created: false,
reason: "tracking_disabled",
});
await createTrackingIssueForTask(store, task, { githubToken: "request-token" });
expect(spy).toHaveBeenCalledWith(
task,
expect.objectContaining({
projectSettings: expect.objectContaining({ githubAuthToken: "settings-token" }),
}),
);
spy.mockRestore();
});
it("swallows maybeCreateTrackingIssue errors", async () => {
const task = await store.createTask({
description: "best effort",
githubTracking: { enabled: true },
});
const spy = vi.spyOn(githubTracking, "maybeCreateTrackingIssue").mockRejectedValue(new Error("boom"));
await expect(createTrackingIssueForTask(store, task)).resolves.toBeUndefined();
spy.mockRestore();
});
it("creates tracking issue with summarized title when createTask summarization is pending", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
await store.createTask(
{
description: "Long task description ".repeat(20),
},
{
onSummarize: async () => "Summarized Issue Title",
settings: { autoSummarizeTitles: true },
},
);
await vi.waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({
owner: "owner",
repo: "repo",
title: expect.stringContaining("Summarized Issue Title"),
}),
);
});
it("creates one fallback issue title when summarizer rejects", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const description = "Fallback issue title words should appear in GitHub issue title. ".repeat(10);
await store.createTask(
{ description },
{
onSummarize: async () => {
throw new Error("summarizer failed");
},
settings: { autoSummarizeTitles: true },
},
);
await vi.waitFor(() => {
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
});
expect(mockCreateIssue).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining("Fallback issue title words should appear"),
}),
);
});
it("persists enabled=true from project default when repo is missing", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubAuthMode: "token",
githubAuthToken: "tok",
});
const created = await store.createTask({
description: "default tracking without repo",
title: "Default tracking without repo",
});
const persisted = await store.getTask(created.id);
expect(persisted?.githubTracking?.enabled).toBe(true);
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("persists enabled=true from project default when no title is available", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
titleSummarizerProvider: undefined,
titleSummarizerModelId: undefined,
titleSummarizerFallbackProvider: undefined,
titleSummarizerFallbackModelId: undefined,
});
const created = await store.createTask({
description: "```ts\nconst value = 1;\n```",
});
const persisted = await store.getTask(created.id);
expect(persisted?.githubTracking?.enabled).toBe(true);
expect(persisted?.githubTracking?.issue).toBeUndefined();
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("creates issue and keeps enabled=true when project default tracking is on", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const created = await store.createTask({
description: "default tracking with repo",
title: "Default tracking with repo",
});
const persisted = await store.getTask(created.id);
expect(mockCreateIssue).toHaveBeenCalledTimes(1);
expect(persisted?.githubTracking?.enabled).toBe(true);
expect(persisted?.githubTracking?.issue).toEqual(
expect.objectContaining({ owner: "owner", repo: "repo", number: 42 }),
);
});
it("invokes maybeCreateTrackingIssue for api-source tasks when default tracking is enabled", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const spy = vi.spyOn(githubTracking, "maybeCreateTrackingIssue");
const created = await store.createTask({
description: "api sourced task",
source: { sourceType: "api" },
});
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ id: created.id, sourceType: "api" }),
expect.objectContaining({
projectSettings: expect.objectContaining({ githubTrackingDefaultRepo: "owner/repo" }),
}),
);
});
it("keeps inline enabled=false without flipping back on", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const created = await store.createTask({
description: "tracking explicitly disabled",
title: "Tracking explicitly disabled",
githubTracking: { enabled: false },
});
const persisted = await store.getTask(created.id);
const enabledLogs = persisted?.log.filter((entry) => entry.action === "GitHub tracking enabled") ?? [];
expect(persisted?.githubTracking?.enabled).toBe(false);
expect(enabledLogs).toHaveLength(0);
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("avoids redundant enabled writes when inline enabled=true already set", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubAuthMode: "token",
githubAuthToken: "tok",
});
const created = await store.createTask({
description: "tracking already enabled",
title: "Tracking already enabled",
githubTracking: { enabled: true },
});
const afterHook = await store.getTask(created.id);
const enabledLogsBefore = afterHook?.log.filter((entry) => entry.action === "GitHub tracking enabled").length ?? 0;
await createTrackingIssueForTask(store, created);
const afterSecondPass = await store.getTask(created.id);
const enabledLogsAfter = afterSecondPass?.log.filter((entry) => entry.action === "GitHub tracking enabled").length ?? 0;
expect(enabledLogsBefore).toBe(0);
expect(enabledLogsAfter).toBe(0);
expect(mockCreateIssue).not.toHaveBeenCalled();
});
it("creates exactly one issue per planning-style createTask invocation", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
await store.createTask({
title: "Planning single task",
description: "planning summary output",
source: { sourceType: "api" },
});
await store.createTask({
title: "Planning subtask A",
description: "planning subtask output",
source: { sourceType: "api", sourceMetadata: { planningSessionId: "sess-1" } },
});
expect(mockCreateIssue).toHaveBeenCalledTimes(2);
});
it("creates issue during createTask await when summarization is disabled", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubTrackingDefaultRepo: "owner/repo",
githubAuthMode: "token",
githubAuthToken: "tok",
});
const beforeAwaitCalls = mockCreateIssue.mock.calls.length;
await store.createTask(
{
description: "No summarization configured but tracking issue should still be created",
},
{
settings: { autoSummarizeTitles: false },
},
);
const afterAwaitCalls = mockCreateIssue.mock.calls.length;
expect(afterAwaitCalls - beforeAwaitCalls).toBe(1);
expect(mockCreateIssue).toHaveBeenLastCalledWith(
expect.objectContaining({
title: expect.stringContaining("No summarization configured"),
}),
);
});
it("records a github-tracking-no-repo activity for agent-created tasks when defaults enable tracking", async () => {
registerGithubTrackingHook();
await store.updateSettings({
githubTrackingEnabledByDefault: true,
githubAuthMode: "token",
githubAuthToken: "tok",
});
const task = await store.createTask({
description: "agent-created task with missing repo",
source: { sourceType: "api" },
});
const activity = await store.getActivityLog({ type: "task:updated" });
const trackingNoRepoEntries = activity.filter((entry) =>
entry.taskId === task.id && (entry.metadata as { type?: string } | undefined)?.type === "github-tracking-no-repo",
);
expect(mockCreateIssue).not.toHaveBeenCalled();
expect(trackingNoRepoEntries).toHaveLength(1);
});
});