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:
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user