525 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|