feat(KB-002): add interactive GitHub issue import

- Add --interactive (-i) flag to 'kb task import' CLI command for selective import
- Add GitHubImportModal component to dashboard with issue browser and selection
- Add /api/github/issues/fetch and /api/github/issues/import API endpoints
- Add kb_task_import_github_issue and kb_task_browse_github_issues tools to pi extension
- Add comprehensive tests for CLI, dashboard API, and UI components
This commit is contained in:
gsxdsm
2026-03-29 17:40:28 -07:00
parent fe460a58d1
commit 5214cc0657
18 changed files with 1921 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { createApiRoutes } from "./routes.js";
@@ -656,3 +656,298 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body.error).toBe("not found");
});
});
// --- GitHub Import route tests ---
describe("POST /github/issues/fetch", () => {
let store: TaskStore;
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
beforeEach(() => {
store = createMockStore();
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as any;
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
const mockGitHubIssue = {
number: 1,
title: "Test Issue",
body: "Test body",
html_url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "bug" }],
};
it("fetches issues successfully", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockGitHubIssue]),
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].number).toBe(1);
expect(res.body[0].title).toBe("Test Issue");
});
it("returns 400 when owner is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ repo: "repo" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("owner is required");
});
it("returns 400 when repo is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("repo is required");
});
it("returns 404 when repository not found", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("Repository not found");
});
it("returns 401/403 when authentication fails", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 403,
statusText: "Forbidden",
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(401);
expect(res.body.error).toContain("Authentication failed");
});
it("filters out pull requests", async () => {
const pr = { ...mockGitHubIssue, pull_request: {} };
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([mockGitHubIssue, pr]),
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].number).toBe(1);
});
it("respects limit parameter", async () => {
const manyIssues = Array.from({ length: 50 }, (_, i) => ({ ...mockGitHubIssue, number: i + 1 }));
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(manyIssues),
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/fetch", JSON.stringify({ owner: "owner", repo: "repo", limit: 10 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toHaveLength(10);
});
});
describe("POST /github/issues/import", () => {
let store: TaskStore;
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as any;
store = createMockStore({
createTask: vi.fn().mockResolvedValue({
id: "KB-001",
title: "Test Issue",
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
}),
logEntry: vi.fn().mockResolvedValue(undefined),
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
const mockGitHubIssue = {
number: 1,
title: "Test Issue",
body: "Test body",
html_url: "https://github.com/owner/repo/issues/1",
labels: [{ name: "bug" }],
};
it("imports a single issue successfully", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue),
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(201);
expect(res.body.id).toBe("KB-001");
expect(store.createTask).toHaveBeenCalledWith({
title: "Test Issue",
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
});
});
it("logs the import action", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue),
} as Response);
await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
"Content-Type": "application/json",
});
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Imported from GitHub", "https://github.com/owner/repo/issues/1");
});
it("returns 400 when issueNumber is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("issueNumber is required");
});
it("returns 404 when issue not found", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 999 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("not found");
});
it("returns 400 when importing a pull request", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockGitHubIssue, pull_request: {} }),
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("pull request");
});
it("returns 409 when issue already imported", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
id: "KB-002",
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
},
]);
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue),
} as Response);
const res = await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(409);
expect(res.body.error).toContain("already imported");
expect(res.body.existingTaskId).toBe("KB-002");
expect(store.createTask).not.toHaveBeenCalled();
});
it("truncates long titles to 200 chars", async () => {
const longTitleIssue = {
...mockGitHubIssue,
title: "A".repeat(250),
};
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(longTitleIssue),
} as Response);
await REQUEST(buildApp(), "POST", "/api/github/issues/import", JSON.stringify({ owner: "owner", repo: "repo", issueNumber: 1 }), {
"Content-Type": "application/json",
});
expect(store.createTask).toHaveBeenCalledWith({
title: "A".repeat(200),
description: expect.stringContaining("Source:"),
column: "triage",
dependencies: [],
});
});
});

View File

@@ -285,6 +285,202 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── GitHub Import Routes ──────────────────────────────────────────
/**
* POST /api/github/issues/fetch
* Fetch open issues from a GitHub repository.
* Body: { owner: string, repo: string, limit?: number, labels?: string[] }
* Returns: Array of GitHubIssue objects (filtered, no PRs)
*/
router.post("/github/issues/fetch", async (req, res) => {
try {
const { owner, repo, limit = 30, labels } = req.body;
if (!owner || typeof owner !== "string") {
res.status(400).json({ error: "owner is required" });
return;
}
if (!repo || typeof repo !== "string") {
res.status(400).json({ error: "repo is required" });
return;
}
const token = process.env.GITHUB_TOKEN;
// Build query parameters - only open issues, no PRs
const params = new URLSearchParams();
params.append("state", "open");
params.append("per_page", String(Math.min(limit, 100)));
if (labels && labels.length > 0) {
params.append("labels", labels.join(","));
}
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params}`;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-dashboard/1.0",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch(url, {
headers,
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 404) {
res.status(404).json({ error: `Repository not found: ${owner}/${repo}` });
return;
}
if (response.status === 401 || response.status === 403) {
res.status(token ? 403 : 401).json({
error: `Authentication failed. ${token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."}`,
});
return;
}
res.status(502).json({ error: `GitHub API error: ${response.status} ${response.statusText}` });
return;
}
// Filter out pull requests (they have a pull_request property)
const issues = (await response.json()) as Array<{
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
pull_request?: unknown;
}>;
const filteredIssues = issues.filter((issue) => !issue.pull_request).slice(0, limit);
res.json(filteredIssues);
} finally {
clearTimeout(timeoutId);
}
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* POST /api/github/issues/import
* Import a specific GitHub issue as a kb task.
* Body: { owner: string, repo: string, issueNumber: number }
* Returns: Created Task object
*/
router.post("/github/issues/import", async (req, res) => {
try {
const { owner, repo, issueNumber } = req.body;
if (!owner || typeof owner !== "string") {
res.status(400).json({ error: "owner is required" });
return;
}
if (!repo || typeof repo !== "string") {
res.status(400).json({ error: "repo is required" });
return;
}
if (!issueNumber || typeof issueNumber !== "number" || issueNumber < 1) {
res.status(400).json({ error: "issueNumber is required and must be a positive number" });
return;
}
const token = process.env.GITHUB_TOKEN;
// Fetch the specific issue
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-dashboard/1.0",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
let issue: { number: number; title: string; body: string | null; html_url: string; pull_request?: unknown };
try {
const response = await fetch(url, {
headers,
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 404) {
res.status(404).json({ error: `Issue #${issueNumber} not found in ${owner}/${repo}` });
return;
}
if (response.status === 401 || response.status === 403) {
res.status(token ? 403 : 401).json({
error: `Authentication failed. ${token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."}`,
});
return;
}
res.status(502).json({ error: `GitHub API error: ${response.status} ${response.statusText}` });
return;
}
issue = await response.json() as typeof issue;
// Check if it's a pull request
if (issue.pull_request) {
res.status(400).json({ error: `#${issueNumber} is a pull request, not an issue` });
return;
}
} finally {
clearTimeout(timeoutId);
}
// Check if already imported
const existingTasks = await store.listTasks();
const sourceUrl = issue.html_url;
for (const existingTask of existingTasks) {
if (existingTask.description.includes(sourceUrl)) {
res.status(409).json({
error: `Issue #${issueNumber} already imported as ${existingTask.id}`,
existingTaskId: existingTask.id,
});
return;
}
}
// Create the task
const title = issue.title.slice(0, 200);
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
});
// Log the import action
await store.logEntry(task.id, "Imported from GitHub", sourceUrl);
res.status(201).json(task);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ---------- Auth routes ----------
registerAuthRoutes(router, options?.authStorage);