feat(FN-3875): add GitHub tracking auth resolver and forced client auth mod
Implements a tracking auth resolver with forced GitHub client authentication mode, wiring it across the GitHub tracking lifecycle and settings UI. The feature spans six steps: adding the resolver, forced auth mode, routing tracking issue creation through the resolver, and wiring into lifecycle and s Fusion-Task-Id: FN-3875
This commit is contained in:
108
packages/dashboard/src/__tests__/github-auth.test.ts
Normal file
108
packages/dashboard/src/__tests__/github-auth.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@fusion/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
||||
return {
|
||||
...actual,
|
||||
isGhAvailable: vi.fn(),
|
||||
isGhAuthenticated: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { isGhAuthenticated, isGhAvailable } from "@fusion/core";
|
||||
import { resolveGithubTrackingAuth } from "../github-auth.js";
|
||||
|
||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||
|
||||
describe("resolveGithubTrackingAuth", () => {
|
||||
beforeEach(() => {
|
||||
mockIsGhAvailable.mockReset();
|
||||
mockIsGhAuthenticated.mockReset();
|
||||
mockIsGhAvailable.mockReturnValue(true);
|
||||
mockIsGhAuthenticated.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("uses project token in token mode", () => {
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "token", githubAuthToken: "proj-token" },
|
||||
globalSettings: {},
|
||||
env: { GITHUB_TOKEN: "env-token" },
|
||||
});
|
||||
expect(result).toEqual({ ok: true, auth: { mode: "token", token: "proj-token" } });
|
||||
});
|
||||
|
||||
it("falls back to env token in token mode", () => {
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "token", githubAuthToken: " " },
|
||||
globalSettings: {},
|
||||
env: { GITHUB_TOKEN: "env-token" },
|
||||
});
|
||||
expect(result).toEqual({ ok: true, auth: { mode: "token", token: "env-token" } });
|
||||
});
|
||||
|
||||
it("returns token_missing when token mode has no token", () => {
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "token", githubAuthToken: "" },
|
||||
globalSettings: {},
|
||||
env: {},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, requestedMode: "token", reason: "token_missing" });
|
||||
expect(mockIsGhAvailable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves gh-cli mode when gh is available and authenticated", () => {
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "gh-cli" },
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toEqual({ ok: true, auth: { mode: "gh-cli" } });
|
||||
});
|
||||
|
||||
it("returns gh_not_installed when gh-cli mode has no gh", () => {
|
||||
mockIsGhAvailable.mockReturnValue(false);
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "gh-cli" },
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, requestedMode: "gh-cli", reason: "gh_not_installed" });
|
||||
});
|
||||
|
||||
it("returns gh_not_authenticated when gh-cli mode is unauthenticated", () => {
|
||||
mockIsGhAuthenticated.mockReturnValue(false);
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "gh-cli" },
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, requestedMode: "gh-cli", reason: "gh_not_authenticated" });
|
||||
});
|
||||
|
||||
it("defaults to gh-cli mode when githubAuthMode is undefined", () => {
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toEqual({ ok: true, auth: { mode: "gh-cli" } });
|
||||
});
|
||||
|
||||
it("returns invalid_mode for unsupported mode values", () => {
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "weird" as "gh-cli" },
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, requestedMode: "gh-cli", reason: "invalid_mode" });
|
||||
});
|
||||
|
||||
it("does not cross-fallback from token mode to gh-cli", () => {
|
||||
mockIsGhAvailable.mockReturnValue(true);
|
||||
mockIsGhAuthenticated.mockReturnValue(true);
|
||||
const result = resolveGithubTrackingAuth({
|
||||
projectSettings: { githubAuthMode: "token" },
|
||||
globalSettings: {},
|
||||
env: {},
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false, requestedMode: "token", reason: "token_missing" });
|
||||
expect(mockIsGhAvailable).not.toHaveBeenCalled();
|
||||
expect(mockIsGhAuthenticated).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
115
packages/dashboard/src/__tests__/github-forced-mode.test.ts
Normal file
115
packages/dashboard/src/__tests__/github-forced-mode.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
isGhAvailable: vi.fn(),
|
||||
isGhAuthenticated: vi.fn(),
|
||||
runGh: vi.fn(),
|
||||
runGhJsonAsync: vi.fn(),
|
||||
getGhErrorMessage: vi.fn((error) => error instanceof Error ? error.message : String(error)),
|
||||
};
|
||||
});
|
||||
|
||||
import { getGhErrorMessage, isGhAuthenticated, isGhAvailable, runGh, runGhJsonAsync } from "@fusion/core";
|
||||
import { GitHubClient } from "../github.js";
|
||||
|
||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||
const mockRunGh = vi.mocked(runGh);
|
||||
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
|
||||
const mockGetGhErrorMessage = vi.mocked(getGhErrorMessage);
|
||||
|
||||
describe("GitHubClient forced mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsGhAvailable.mockReturnValue(true);
|
||||
mockIsGhAuthenticated.mockReturnValue(true);
|
||||
mockGetGhErrorMessage.mockImplementation((error: unknown) => error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
|
||||
it("forced token mode uses only REST path", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ number: 1, html_url: "https://github.com/o/r/issues/1", created_at: "2026-01-01T00:00:00.000Z" }),
|
||||
} as never);
|
||||
mockRunGhJsonAsync.mockRejectedValue(new Error("gh should not run"));
|
||||
|
||||
const client = new GitHubClient({ token: "token-123", forceMode: "token" });
|
||||
await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forced token mode without token throws before network", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
|
||||
throw new Error("fetch should not run");
|
||||
});
|
||||
const client = new GitHubClient({ forceMode: "token" });
|
||||
|
||||
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("forced to token mode");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forced gh-cli mode uses only gh path", async () => {
|
||||
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/2", number: 2, createdAt: "2026-01-02T00:00:00.000Z" } as never);
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
|
||||
throw new Error("fetch should not run");
|
||||
});
|
||||
|
||||
const client = new GitHubClient({ forceMode: "gh-cli" });
|
||||
await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
|
||||
|
||||
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forced gh-cli mode without gh throws before network", async () => {
|
||||
mockIsGhAvailable.mockReturnValue(false);
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
|
||||
throw new Error("fetch should not run");
|
||||
});
|
||||
const client = new GitHubClient({ forceMode: "gh-cli" });
|
||||
|
||||
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("gh CLI is not available");
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("legacy constructor keeps opportunistic fallback semantics", async () => {
|
||||
mockRunGh.mockImplementation(() => {
|
||||
throw new Error("gh failed");
|
||||
});
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ number: 3, html_url: "https://github.com/o/r/pull/3", title: "t", state: "open", head: { ref: "head" }, base: { ref: "main" }, comments: 0 }),
|
||||
} as never);
|
||||
|
||||
const client = new GitHubClient("token-legacy");
|
||||
await client.createPr({ owner: "o", repo: "r", title: "t", head: "head", base: "main" });
|
||||
|
||||
expect(mockRunGh).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requireToken throws when token missing", () => {
|
||||
const client = new GitHubClient({ forceMode: "token" });
|
||||
expect(() => (client as any).requireToken()).toThrow("forced to token mode");
|
||||
});
|
||||
|
||||
it("requireGh throws when gh unavailable or unauthenticated", () => {
|
||||
const client = new GitHubClient({ forceMode: "gh-cli" });
|
||||
|
||||
mockIsGhAvailable.mockReturnValue(false);
|
||||
expect(() => (client as any).requireGh()).toThrow("gh CLI is not available");
|
||||
|
||||
mockIsGhAvailable.mockReturnValue(true);
|
||||
mockIsGhAuthenticated.mockReturnValue(false);
|
||||
expect(() => (client as any).requireGh()).toThrow("gh CLI is not authenticated");
|
||||
});
|
||||
});
|
||||
113
packages/dashboard/src/__tests__/github-tracking-auth.test.ts
Normal file
113
packages/dashboard/src/__tests__/github-tracking-auth.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { maybeCreateTrackingIssue } from "../github-tracking.js";
|
||||
|
||||
vi.mock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return {
|
||||
...actual,
|
||||
isGhAvailable: vi.fn(),
|
||||
isGhAuthenticated: vi.fn(),
|
||||
runGhJsonAsync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { isGhAuthenticated, isGhAvailable, runGhJsonAsync } from "@fusion/core";
|
||||
|
||||
const mockIsGhAvailable = vi.mocked(isGhAvailable);
|
||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
|
||||
|
||||
function task(): Task {
|
||||
return {
|
||||
id: "FN-7",
|
||||
title: "Track me",
|
||||
column: "todo",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
githubTracking: { enabled: true },
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("tracking auth mode integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIsGhAvailable.mockReturnValue(true);
|
||||
mockIsGhAuthenticated.mockReturnValue(true);
|
||||
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/5", number: 5, createdAt: "2026-01-01T00:00:00.000Z" } as any);
|
||||
});
|
||||
|
||||
it("token mode uses REST and not gh", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({ number: 5, html_url: "https://github.com/o/r/issues/5", created_at: "2026-01-01T00:00:00.000Z" }),
|
||||
} as never);
|
||||
|
||||
await maybeCreateTrackingIssue(task(), {
|
||||
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||
projectSettings: { githubAuthMode: "token", githubAuthToken: "token" } as any,
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("token mode missing token returns auth_token_missing", async () => {
|
||||
const recordActivity = vi.fn();
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
|
||||
throw new Error("fetch should not run");
|
||||
});
|
||||
|
||||
const result = await maybeCreateTrackingIssue(task(), {
|
||||
taskStore: { recordActivity } as any,
|
||||
projectSettings: { githubAuthMode: "token", githubAuthToken: "" } as any,
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: false, reason: "auth_token_missing" });
|
||||
expect(recordActivity).toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gh-cli mode uses gh and not REST", async () => {
|
||||
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
|
||||
throw new Error("fetch should not run");
|
||||
});
|
||||
|
||||
await maybeCreateTrackingIssue(task(), {
|
||||
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||
projectSettings: { githubAuthMode: "gh-cli" } as any,
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
});
|
||||
|
||||
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gh unavailable returns auth_gh_not_installed", async () => {
|
||||
mockIsGhAvailable.mockReturnValue(false);
|
||||
const result = await maybeCreateTrackingIssue(task(), {
|
||||
taskStore: { recordActivity: vi.fn() } as any,
|
||||
projectSettings: { githubAuthMode: "gh-cli" } as any,
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: false, reason: "auth_gh_not_installed" });
|
||||
});
|
||||
|
||||
it("default mode uses gh-cli", async () => {
|
||||
await maybeCreateTrackingIssue(task(), {
|
||||
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||
projectSettings: {} as any,
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
});
|
||||
|
||||
expect(mockRunGhJsonAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,18 +10,30 @@ const { mockCommentOnIssue } = vi.hoisted(() => ({
|
||||
mockCommentOnIssue: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
|
||||
mockResolveGithubTrackingAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../github.js", () => ({
|
||||
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||
commentOnIssue: (...args: unknown[]) => mockCommentOnIssue(...args),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../github-auth.js", () => ({
|
||||
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
|
||||
}));
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
logEntry: Mock;
|
||||
getSettings: Mock;
|
||||
getGlobalSettingsStore: Mock;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.logEntry = vi.fn().mockResolvedValue(undefined);
|
||||
this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,18 +98,11 @@ describe("formatTrackingComment", () => {
|
||||
describe("GitHubTrackingCommentService", () => {
|
||||
let store: MockStore;
|
||||
let service: GitHubTrackingCommentService;
|
||||
let tokenValue: string;
|
||||
let tokenCalls: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = new MockStore();
|
||||
tokenValue = "ghp_test";
|
||||
tokenCalls = 0;
|
||||
service = new GitHubTrackingCommentService(store as unknown as TaskStore, () => {
|
||||
tokenCalls += 1;
|
||||
return tokenValue;
|
||||
});
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
service = new GitHubTrackingCommentService(store as unknown as TaskStore);
|
||||
});
|
||||
|
||||
it("start/stop are idempotent", async () => {
|
||||
@@ -252,17 +257,14 @@ describe("GitHubTrackingCommentService", () => {
|
||||
expect(mockCommentOnIssue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokes token thunk for each call", async () => {
|
||||
it("resolves auth for each call", async () => {
|
||||
service.start();
|
||||
|
||||
tokenValue = "ghp_1";
|
||||
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
|
||||
|
||||
tokenValue = "ghp_2";
|
||||
store.emit("task:moved", { task: createTask(), from: "in-progress", to: "done" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockCommentOnIssue).toHaveBeenCalledTimes(2);
|
||||
expect(tokenCalls).toBe(2);
|
||||
expect(mockResolveGithubTrackingAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,18 +7,30 @@ const { mockSetIssueState } = vi.hoisted(() => ({
|
||||
mockSetIssueState: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockResolveGithubTrackingAuth } = vi.hoisted(() => ({
|
||||
mockResolveGithubTrackingAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../github.js", () => ({
|
||||
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||
setIssueState: (...args: unknown[]) => mockSetIssueState(...args),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../github-auth.js", () => ({
|
||||
resolveGithubTrackingAuth: (...args: unknown[]) => mockResolveGithubTrackingAuth(...args),
|
||||
}));
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
logEntry: Mock;
|
||||
getSettings: Mock;
|
||||
getGlobalSettingsStore: Mock;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.logEntry = vi.fn().mockResolvedValue(undefined);
|
||||
this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" });
|
||||
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,18 +86,11 @@ describe("decideIssueAction", () => {
|
||||
describe("GitHubTrackingStateService", () => {
|
||||
let store: MockStore;
|
||||
let service: GitHubTrackingStateService;
|
||||
let tokenValue: string;
|
||||
let tokenCalls: number;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store = new MockStore();
|
||||
tokenValue = "ghp_test";
|
||||
tokenCalls = 0;
|
||||
service = new GitHubTrackingStateService(store as unknown as TaskStore, () => {
|
||||
tokenCalls += 1;
|
||||
return tokenValue;
|
||||
});
|
||||
mockResolveGithubTrackingAuth.mockReturnValue({ ok: true, auth: { mode: "token", token: "ghp_test" } });
|
||||
service = new GitHubTrackingStateService(store as unknown as TaskStore);
|
||||
});
|
||||
|
||||
it("start/stop are idempotent", async () => {
|
||||
@@ -233,18 +238,15 @@ describe("GitHubTrackingStateService", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-1", "Failed to reopen GitHub tracking issue", "reopen failed");
|
||||
});
|
||||
|
||||
it("invokes token thunk per call", async () => {
|
||||
it("resolves auth per call", async () => {
|
||||
service.start();
|
||||
|
||||
tokenValue = "ghp_1";
|
||||
store.emit("task:moved", { task: createTask(), from: "todo", to: "done" });
|
||||
|
||||
tokenValue = "ghp_2";
|
||||
store.emit("task:moved", { task: createTask(), from: "done", to: "todo" });
|
||||
await flushAsync();
|
||||
|
||||
expect(mockSetIssueState).toHaveBeenCalledTimes(2);
|
||||
expect(tokenCalls).toBe(2);
|
||||
expect(mockResolveGithubTrackingAuth).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("emits close then reopen in order", async () => {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const createIssueMock = vi.fn();
|
||||
const resolveAuthMock = vi.fn();
|
||||
|
||||
vi.mock("../github.js", () => ({
|
||||
GitHubClient: vi.fn().mockImplementation(() => ({
|
||||
createIssue: createIssueMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../github-auth.js", () => ({
|
||||
resolveGithubTrackingAuth: (...args: unknown[]) => resolveAuthMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
formatTrackingIssueBody,
|
||||
formatTrackingIssueTitle,
|
||||
@@ -26,20 +40,11 @@ describe("formatTrackingIssueTitle", () => {
|
||||
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello" })).toBe("[FN-1] Hello");
|
||||
});
|
||||
|
||||
it("falls back for blank title", () => {
|
||||
expect(formatTrackingIssueTitle({ id: "FN-1", title: " \n\t " })).toBe("[FN-1] Untitled task");
|
||||
});
|
||||
|
||||
it("collapses multiline whitespace", () => {
|
||||
expect(formatTrackingIssueTitle({ id: "FN-1", title: "Hello\n\tWorld" })).toBe("[FN-1] Hello World");
|
||||
});
|
||||
|
||||
it("truncates very long titles while preserving id prefix", () => {
|
||||
const longTitle = "x".repeat(400);
|
||||
const formatted = formatTrackingIssueTitle({ id: "FN-123", title: longTitle });
|
||||
expect(formatted.startsWith("[FN-123] ")).toBe(true);
|
||||
expect(formatted.length).toBeLessThanOrEqual(240);
|
||||
expect(formatted.endsWith("…")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,172 +57,83 @@ describe("formatTrackingIssueBody", () => {
|
||||
summary: "Summary paragraph",
|
||||
})).toBe("Fusion task: FN-X\n\nPrimary paragraph");
|
||||
});
|
||||
|
||||
it("uses prompt when description is empty", () => {
|
||||
expect(formatTrackingIssueBody({ id: "FN-X", description: "", prompt: "Prompt paragraph", summary: "Summary" }))
|
||||
.toBe("Fusion task: FN-X\n\nPrompt paragraph");
|
||||
});
|
||||
|
||||
it("uses summary when description and prompt are unavailable", () => {
|
||||
expect(formatTrackingIssueBody({ id: "FN-X", summary: "Summary paragraph" }))
|
||||
.toBe("Fusion task: FN-X\n\nSummary paragraph");
|
||||
});
|
||||
|
||||
it("falls back when prompt is undefined and sources are empty", () => {
|
||||
expect(formatTrackingIssueBody({ id: "FN-X", description: " ", summary: " " }))
|
||||
.toBe("Fusion task: FN-X\n\nNo summary available.");
|
||||
});
|
||||
|
||||
it("strips markdown noise including headings, bullets, and code fences", () => {
|
||||
const body = formatTrackingIssueBody({
|
||||
id: "FN-X",
|
||||
description: "# Heading\n- bullet\n1. numbered\n```ts\nconst x = 1;\n```\nfinal",
|
||||
});
|
||||
expect(body).toBe("Fusion task: FN-X\n\nHeading bullet numbered const x = 1; final");
|
||||
});
|
||||
|
||||
it("truncates summary to 500 characters with ellipsis", () => {
|
||||
const body = formatTrackingIssueBody({ id: "FN-X", description: "a".repeat(600) });
|
||||
const summary = body.replace("Fusion task: FN-X\n\n", "");
|
||||
expect(summary.length).toBe(500);
|
||||
expect(summary.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
it("removes fusion-style localhost task urls", () => {
|
||||
const body = formatTrackingIssueBody({
|
||||
id: "FN-1",
|
||||
description: "See http://localhost:4040/tasks/FN-1 and continue",
|
||||
});
|
||||
expect(body).not.toContain("localhost");
|
||||
expect(body).not.toMatch(/https?:\/\/[^\s]*\/tasks\/FN-/);
|
||||
});
|
||||
|
||||
it("always starts with fusion task reference", () => {
|
||||
expect(formatTrackingIssueBody({ id: "FN-99", description: "hello" }).startsWith("Fusion task: FN-99\n\n")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeCreateTrackingIssue", () => {
|
||||
it("returns tracking_disabled when not enabled", async () => {
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
|
||||
taskStore: {} as any,
|
||||
githubClient: {} as any,
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
|
||||
});
|
||||
|
||||
it("returns issue_already_linked when issue already exists", async () => {
|
||||
const result = await maybeCreateTrackingIssue(buildTask({
|
||||
githubTracking: {
|
||||
enabled: true,
|
||||
issue: { owner: "o", repo: "r", number: 1, url: "https://github.com/o/r/issues/1", createdAt: "2026-01-01T00:00:00.000Z" },
|
||||
},
|
||||
}), {
|
||||
taskStore: {} as any,
|
||||
githubClient: {} as any,
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toEqual({ created: false, reason: "issue_already_linked" });
|
||||
});
|
||||
|
||||
it("returns github_import_source for imported tasks", async () => {
|
||||
const result = await maybeCreateTrackingIssue(buildTask({
|
||||
githubTracking: { enabled: true },
|
||||
sourceType: "github_import",
|
||||
}), {
|
||||
taskStore: {} as any,
|
||||
githubClient: {} as any,
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toEqual({ created: false, reason: "github_import_source" });
|
||||
});
|
||||
|
||||
it("prefers task repo override over project/global defaults", async () => {
|
||||
const createIssue = vi.fn().mockResolvedValue({
|
||||
owner: "task-owner",
|
||||
repo: "task-repo",
|
||||
number: 11,
|
||||
htmlUrl: "https://github.com/task-owner/task-repo/issues/11",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await maybeCreateTrackingIssue(buildTask({
|
||||
title: "Test",
|
||||
githubTracking: { enabled: true, repoOverride: "task-owner/task-repo" },
|
||||
}), {
|
||||
taskStore: { linkGithubIssue: vi.fn(), recordActivity: vi.fn() } as any,
|
||||
githubClient: { createIssue } as any,
|
||||
projectSettings: { githubTrackingDefaultRepo: "project-owner/project-repo" } as any,
|
||||
globalSettings: { githubTrackingDefaultRepo: "global-owner/global-repo" } as any,
|
||||
});
|
||||
|
||||
expect(createIssue).toHaveBeenCalledWith(expect.objectContaining({ owner: "task-owner", repo: "task-repo" }));
|
||||
});
|
||||
|
||||
it("creates issue, links metadata, and records activity", async () => {
|
||||
const createIssue = vi.fn().mockResolvedValue({
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveAuthMock.mockReturnValue({ ok: true, auth: { mode: "token", token: "tok" } });
|
||||
createIssueMock.mockResolvedValue({
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
number: 12,
|
||||
htmlUrl: "https://github.com/o/r/issues/12",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns tracking_disabled when not enabled", async () => {
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: false } }), {
|
||||
taskStore: {} as any,
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
});
|
||||
expect(result).toEqual({ created: false, reason: "tracking_disabled" });
|
||||
});
|
||||
|
||||
it("returns no_repo_configured and records activity", async () => {
|
||||
const recordActivity = vi.fn();
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
|
||||
taskStore: { recordActivity } as any,
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
logger: { warn: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: false, reason: "no_repo_configured" });
|
||||
expect(recordActivity).toHaveBeenCalledTimes(1);
|
||||
expect(createIssueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates issue, links metadata, and records activity", async () => {
|
||||
const linkGithubIssue = vi.fn();
|
||||
const recordActivity = vi.fn();
|
||||
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ title: "Test", description: "Short body", githubTracking: { enabled: true } }), {
|
||||
taskStore: { linkGithubIssue, recordActivity } as any,
|
||||
githubClient: { createIssue } as any,
|
||||
projectSettings: {},
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
logger: console,
|
||||
});
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(createIssue).toHaveBeenCalledTimes(1);
|
||||
expect(createIssue).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "[FN-1] Test",
|
||||
body: expect.stringMatching(/^Fusion task: FN-1\n\n/),
|
||||
}));
|
||||
const calledBody = createIssue.mock.calls[0][0]?.body as string;
|
||||
expect(calledBody.length).toBeLessThanOrEqual("Fusion task: FN-1\n\n".length + 500);
|
||||
expect(createIssueMock).toHaveBeenCalledTimes(1);
|
||||
expect(linkGithubIssue).toHaveBeenCalledWith("FN-1", expect.objectContaining({ owner: "o", repo: "r", number: 12 }));
|
||||
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ type: "github-issue-created", repo: "o/r", number: 12 }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns no_repo_configured and records activity", async () => {
|
||||
it("returns auth reason when resolver fails", async () => {
|
||||
resolveAuthMock.mockReturnValue({
|
||||
ok: false,
|
||||
requestedMode: "token",
|
||||
reason: "token_missing",
|
||||
message: "missing token",
|
||||
});
|
||||
const recordActivity = vi.fn();
|
||||
const warn = vi.fn();
|
||||
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
|
||||
taskStore: { recordActivity } as any,
|
||||
githubClient: {} as any,
|
||||
projectSettings: {},
|
||||
globalSettings: {},
|
||||
logger: { warn, info: vi.fn() },
|
||||
globalSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
logger: { warn: vi.fn(), info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: false, reason: "no_repo_configured" });
|
||||
expect(recordActivity).toHaveBeenCalledTimes(1);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows github errors", async () => {
|
||||
const warn = vi.fn();
|
||||
const result = await maybeCreateTrackingIssue(buildTask({ githubTracking: { enabled: true } }), {
|
||||
taskStore: { recordActivity: vi.fn() } as any,
|
||||
githubClient: { createIssue: vi.fn().mockRejectedValue(new Error("boom")) } as any,
|
||||
projectSettings: { githubTrackingDefaultRepo: "o/r" } as any,
|
||||
globalSettings: {},
|
||||
logger: { warn, info: vi.fn() },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ created: false, reason: "github_error" });
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(result).toEqual({ created: false, reason: "auth_token_missing" });
|
||||
expect(recordActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
metadata: expect.objectContaining({ type: "github-issue-skipped", reason: "token_missing" }),
|
||||
}));
|
||||
expect(createIssueMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -337,6 +337,8 @@ describe("GET /settings", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prAuthAvailable).toBe(true);
|
||||
expect(res.body.trackingAuthAvailable).toBe(true);
|
||||
expect(res.body.trackingAuthReason).toBeNull();
|
||||
expect(res.body.githubTokenConfigured).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -361,6 +363,8 @@ describe("GET /settings", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.prAuthAvailable).toBe(false);
|
||||
expect(res.body.trackingAuthAvailable).toBe(false);
|
||||
expect(res.body.trackingAuthReason).toBe("gh_not_installed");
|
||||
expect(res.body.githubTokenConfigured).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -551,7 +555,13 @@ describe("PUT /settings", () => {
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({ maxConcurrent: 4, githubTokenConfigured: true, prAuthAvailable: true }),
|
||||
JSON.stringify({
|
||||
maxConcurrent: 4,
|
||||
githubTokenConfigured: true,
|
||||
prAuthAvailable: true,
|
||||
trackingAuthAvailable: true,
|
||||
trackingAuthReason: null,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
@@ -568,7 +578,13 @@ describe("PUT /settings", () => {
|
||||
buildApp(),
|
||||
"PUT",
|
||||
"/api/settings",
|
||||
JSON.stringify({ maxWorktrees: 10, githubTokenConfigured: true, prAuthAvailable: true }),
|
||||
JSON.stringify({
|
||||
maxWorktrees: 10,
|
||||
githubTokenConfigured: true,
|
||||
prAuthAvailable: true,
|
||||
trackingAuthAvailable: false,
|
||||
trackingAuthReason: "token_missing",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
|
||||
@@ -1656,6 +1656,42 @@ describe("PATCH /tasks/:id", () => {
|
||||
expect(res.body.error).toContain("sourceIssue.externalIssueId");
|
||||
});
|
||||
|
||||
it("forwards githubTracking updates including null issue unlink", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL });
|
||||
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
githubTracking: {
|
||||
enabled: true,
|
||||
repoOverride: "runfusion/fusion",
|
||||
issue: null,
|
||||
},
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
githubTracking: {
|
||||
enabled: true,
|
||||
repoOverride: "runfusion/fusion",
|
||||
issue: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 400 for invalid githubTracking repo override format", async () => {
|
||||
const res = await REQUEST(buildApp(), "PATCH", "/api/tasks/KB-001", JSON.stringify({
|
||||
githubTracking: {
|
||||
repoOverride: "invalid repo",
|
||||
},
|
||||
}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("owner/repo");
|
||||
});
|
||||
|
||||
it("does not clear model or assignee fields when they are omitted", async () => {
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_TASK_DETAIL, title: "New" });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user