test(KB-001): add GitHub import tests and update extension tool count

This commit is contained in:
gsxdsm
2026-03-29 17:24:36 -07:00
parent e8a1ad8f14
commit 21a4913438
2 changed files with 319 additions and 0 deletions

View File

@@ -80,6 +80,7 @@ describe("kb pi extension", () => {
"kb_task_attach",
"kb_task_pause",
"kb_task_unpause",
"kb_task_import_github",
];
for (const name of expected) {

View File

@@ -270,3 +270,321 @@ describe("runTaskCreate with --depends", () => {
});
});
});
// GitHub Import Tests
import { fetchGitHubIssues, runTaskImportFromGitHub, type GitHubIssue } from "./task.js";
describe("fetchGitHubIssues", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
const originalEnv = process.env.GITHUB_TOKEN;
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as any;
delete process.env.GITHUB_TOKEN;
});
afterEach(() => {
globalThis.fetch = originalFetch;
process.env.GITHUB_TOKEN = originalEnv;
vi.restoreAllMocks();
});
const mockIssue: GitHubIssue = {
number: 1,
title: "Test Issue",
body: "Test body",
html_url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "bug" }],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
};
it("fetches issues successfully", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockIssue]),
} as Response);
const issues = await fetchGitHubIssues("owner", "repo");
expect(issues).toHaveLength(1);
expect(issues[0].number).toBe(1);
expect(issues[0].title).toBe("Test Issue");
expect(fetchSpy).toHaveBeenCalledOnce();
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain("https://api.github.com/repos/owner/repo/issues");
expect(url).toContain("state=open");
});
it("includes Authorization header when GITHUB_TOKEN is set", async () => {
process.env.GITHUB_TOKEN = "test-token";
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockIssue]),
} as Response);
await fetchGitHubIssues("owner", "repo");
const headers = fetchSpy.mock.calls[0][1]?.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer test-token");
});
it("respects limit option", async () => {
const manyIssues = Array.from({ length: 50 }, (_, i) => ({
...mockIssue,
number: i + 1,
}));
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(manyIssues),
} as Response);
const issues = await fetchGitHubIssues("owner", "repo", { limit: 10 });
expect(issues).toHaveLength(10);
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain("per_page=10");
});
it("respects labels option", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockIssue]),
} as Response);
await fetchGitHubIssues("owner", "repo", { labels: ["bug", "enhancement"] });
const url = fetchSpy.mock.calls[0][0] as string;
expect(url).toContain("labels=bug%2Cenhancement");
});
it("filters out pull requests", async () => {
const pr = { ...mockIssue, pull_request: {} };
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockIssue, pr]),
} as Response);
const issues = await fetchGitHubIssues("owner", "repo");
expect(issues).toHaveLength(1);
expect(issues[0].number).toBe(1);
});
it("throws error for 404", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
} as Response);
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow("Repository not found");
});
it("throws error for 401/403", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: "Forbidden",
} as Response);
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow("Authentication failed");
});
it("throws generic error for other status codes", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 500,
statusText: "Server Error",
} as Response);
await expect(fetchGitHubIssues("owner", "repo")).rejects.toThrow("GitHub API error: 500");
});
});
describe("runTaskImportFromGitHub", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let mockCreateTask: ReturnType<typeof vi.fn>;
let mockListTasks: ReturnType<typeof vi.fn>;
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as any;
mockCreateTask = vi.fn().mockImplementation((input: { description: string; title?: string }) => ({
id: `KB-${String(mockCreateTask.mock.calls.length).padStart(3, "0")}`,
title: input.title,
description: input.description,
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
mockListTasks = vi.fn().mockResolvedValue([]);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
listTasks: mockListTasks,
}));
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
const mockIssue = (num: number, title: string, body: string | null): GitHubIssue => ({
number: num,
title,
body,
html_url: `https://github.com/owner/repo/issues/${num}`,
labels: [],
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
});
it("imports issues and creates tasks", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([
mockIssue(1, "First Issue", "Description 1"),
mockIssue(2, "Second Issue", "Description 2"),
]),
} as Response);
await runTaskImportFromGitHub("owner/repo");
expect(mockCreateTask).toHaveBeenCalledTimes(2);
expect(mockCreateTask).toHaveBeenCalledWith({
title: "First Issue",
description: "Description 1\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
});
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Imported 2 tasks"),
);
expect(successLine).toBeDefined();
});
it("skips already imported issues", async () => {
// Setup existing task with source URL
mockListTasks.mockResolvedValueOnce([
{
id: "KB-001",
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
},
]);
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([
mockIssue(1, "First Issue", "Description 1"),
mockIssue(2, "Second Issue", "Description 2"),
]),
} as Response);
await runTaskImportFromGitHub("owner/repo");
expect(mockCreateTask).toHaveBeenCalledTimes(1);
const skipLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("Skipping #1"),
);
expect(skipLine).toBeDefined();
});
it("handles empty issues list", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([]),
} as Response);
await runTaskImportFromGitHub("owner/repo");
expect(mockCreateTask).not.toHaveBeenCalled();
const noIssuesLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("No open issues"),
);
expect(noIssuesLine).toBeDefined();
});
it("exits on invalid owner/repo format", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
await expect(runTaskImportFromGitHub("invalid-format")).rejects.toThrow("process.exit");
expect(mockCreateTask).not.toHaveBeenCalled();
});
it("handles API errors gracefully", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
} as Response);
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});
await expect(runTaskImportFromGitHub("owner/repo")).rejects.toThrow("process.exit");
});
it("uses (no description) for empty body", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockIssue(1, "No Body Issue", null)]),
} as Response);
await runTaskImportFromGitHub("owner/repo");
expect(mockCreateTask).toHaveBeenCalledWith({
title: "No Body Issue",
description: "(no description)\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
});
});
it("truncates long titles to 200 chars", async () => {
const longTitle = "A".repeat(250);
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockIssue(1, longTitle, "Body")]),
} as Response);
await runTaskImportFromGitHub("owner/repo");
expect(mockCreateTask).toHaveBeenCalledWith({
title: "A".repeat(200),
description: expect.stringContaining("Body"),
column: "triage",
dependencies: [],
});
});
});