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,5 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock node:readline/promises before importing the module under test
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(),
}));
// Mock @kb/core before importing the module under test
vi.mock("@kb/core", () => {
const COLUMNS = ["triage", "specified", "in-progress", "review", "done"];
@@ -21,6 +26,7 @@ vi.mock("@kb/core", () => {
// Mock @kb/engine
vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@kb/core";
import { runTaskShow, runTaskCreate } from "./task.js";
@@ -271,6 +277,248 @@ describe("runTaskCreate with --depends", () => {
});
});
import { runTaskImportGitHubInteractive } from "./task.js";
describe("runTaskImportGitHubInteractive", () => {
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 selected issues via interactive mode", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([
mockIssue(1, "First Issue", "Description 1"),
mockIssue(2, "Second Issue", "Description 2"),
mockIssue(3, "Third Issue", "Description 3"),
]),
} as Response);
// Mock readline to select issues 1 and 3
const mockReadline = {
question: vi.fn().mockResolvedValueOnce("1,3"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValueOnce(mockReadline as any);
await runTaskImportGitHubInteractive("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: [],
});
expect(mockCreateTask).toHaveBeenCalledWith({
title: "Third Issue",
description: "Description 3\n\nSource: https://github.com/owner/repo/issues/3",
column: "triage",
dependencies: [],
});
});
it('imports all issues when "all" is selected', async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([
mockIssue(1, "First Issue", "Description 1"),
mockIssue(2, "Second Issue", "Description 2"),
]),
} as Response);
// Mock readline to select "all"
const mockReadline = {
question: vi.fn().mockResolvedValueOnce("all"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValueOnce(mockReadline as any);
await runTaskImportGitHubInteractive("owner/repo");
expect(mockCreateTask).toHaveBeenCalledTimes(2);
});
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);
const mockReadline = {
question: vi.fn().mockResolvedValueOnce("all"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValueOnce(mockReadline as any);
await runTaskImportGitHubInteractive("owner/repo");
expect(mockCreateTask).toHaveBeenCalledTimes(1);
expect(mockCreateTask).toHaveBeenCalledWith({
title: "Second Issue",
description: "Description 2\n\nSource: https://github.com/owner/repo/issues/2",
column: "triage",
dependencies: [],
});
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 runTaskImportGitHubInteractive("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(runTaskImportGitHubInteractive("invalid-format")).rejects.toThrow("process.exit");
expect(mockCreateTask).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
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(runTaskImportGitHubInteractive("owner/repo")).rejects.toThrow("process.exit");
expect(mockCreateTask).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("re-prompts on invalid input", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([
mockIssue(1, "First Issue", "Description 1"),
]),
} as Response);
// First invalid input, then valid
const mockReadline = {
question: vi.fn()
.mockResolvedValueOnce("invalid")
.mockResolvedValueOnce("1"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValueOnce(mockReadline as any);
await runTaskImportGitHubInteractive("owner/repo");
expect(mockReadline.question).toHaveBeenCalledTimes(2);
expect(mockCreateTask).toHaveBeenCalledTimes(1);
});
it("re-prompts on out of range selection", async () => {
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve([
mockIssue(1, "First Issue", "Description 1"),
]),
} as Response);
// First out of range, then valid
const mockReadline = {
question: vi.fn()
.mockResolvedValueOnce("99")
.mockResolvedValueOnce("1"),
close: vi.fn(),
};
vi.mocked(createInterface).mockReturnValueOnce(mockReadline as any);
await runTaskImportGitHubInteractive("owner/repo");
expect(mockReadline.question).toHaveBeenCalledTimes(2);
expect(mockCreateTask).toHaveBeenCalledTimes(1);
});
});
// GitHub Import Tests
import { fetchGitHubIssues, runTaskImportFromGitHub, type GitHubIssue } from "./task.js";
@@ -537,6 +785,7 @@ describe("runTaskImportFromGitHub", () => {
await expect(runTaskImportFromGitHub("invalid-format")).rejects.toThrow("process.exit");
expect(mockCreateTask).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("handles API errors gracefully", async () => {
@@ -551,6 +800,7 @@ describe("runTaskImportFromGitHub", () => {
});
await expect(runTaskImportFromGitHub("owner/repo")).rejects.toThrow("process.exit");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("uses (no description) for empty body", async () => {

View File

@@ -286,6 +286,138 @@ export async function runTaskMove(id: string, column: string) {
console.log();
}
export async function runTaskImportGitHubInteractive(
ownerRepo: string,
options: TaskImportOptions = {}
): Promise<void> {
// Parse owner/repo
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
if (!match) {
console.error(`Invalid owner/repo format: ${ownerRepo}`);
console.error(`Expected format: owner/repo (e.g., dustinbyrne/kb)`);
process.exit(1);
}
const [, owner, repo] = match;
const { limit = 30, labels } = options;
console.log(`\n Fetching issues from ${owner}/${repo}...\n`);
const store = await getStore();
const existingTasks = await store.listTasks();
// Build a set of already-imported issue URLs
const importedUrls = new Map<string, string>();
for (const task of existingTasks) {
// Match Source URL anywhere in description (more robust than end-of-string anchor)
const sourceMatch = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (sourceMatch) {
importedUrls.set(sourceMatch[1], task.id);
}
}
let issues: GitHubIssue[];
try {
issues = await fetchGitHubIssues(owner, repo, { limit, labels });
} catch (err: any) {
console.error(`${err.message}\n`);
process.exit(1);
}
if (issues.length === 0) {
console.log(` No open issues found in ${owner}/${repo}.\n`);
return;
}
// Display issues with numbers
console.log(` Found ${issues.length} issues:\n`);
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
const alreadyImported = importedUrls.has(issue.html_url);
const status = alreadyImported ? ` [Imported as ${importedUrls.get(issue.html_url)}]` : "";
console.log(` ${i + 1}. #${issue.number} ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${status}`);
}
console.log();
// Create readline interface for interactive selection
const rl = createInterface({ input: process.stdin, output: process.stdout });
let selectedIndices: number[] = [];
let validInput = false;
while (!validInput) {
const answer = await rl.question(' Enter numbers to import (comma-separated) or "all": ');
const trimmed = answer.trim().toLowerCase();
if (trimmed === "all") {
selectedIndices = issues.map((_, i) => i);
validInput = true;
} else {
const nums = trimmed
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
if (nums.length === 0) {
console.log(" Please enter at least one number or 'all'");
continue;
}
const outOfRange = nums.filter((n) => n < 1 || n > issues.length);
if (outOfRange.length > 0) {
console.log(` Invalid selection: ${outOfRange.join(", ")} (range: 1-${issues.length})`);
continue;
}
selectedIndices = nums.map((n) => n - 1); // Convert to 0-based
validInput = true;
}
}
rl.close();
console.log();
let created = 0;
let skipped = 0;
for (const idx of selectedIndices) {
const issue = issues[idx];
// Check if already imported
if (importedUrls.has(issue.html_url)) {
const existingId = importedUrls.get(issue.html_url)!;
console.log(` → Skipping #${issue.number}: already imported as ${existingId}`);
skipped++;
continue;
}
// Prepare title (truncate to 200 chars)
const title = issue.title.slice(0, 200);
// Prepare description
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${issue.html_url}`;
// Create the task
const task = await store.createTask({
title: title || undefined,
description,
column: "triage",
dependencies: [],
});
const label = task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "");
console.log(` ✓ Created ${task.id}: ${label}`);
created++;
}
console.log();
console.log(` ✓ Imported ${created} tasks from ${owner}/${repo}${skipped > 0 ? ` (${skipped} skipped)` : ""}`);
console.log();
}
// ── GitHub Issue Import ───────────────────────────────────────────
export interface GitHubIssue {
@@ -394,7 +526,8 @@ export async function runTaskImportFromGitHub(
// Build a set of already-imported issue URLs
const importedUrls = new Map<string, string>();
for (const task of existingTasks) {
const sourceMatch = task.description.match(/Source: (https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+)$/m);
// Match Source URL anywhere in description (more robust than end-of-string anchor)
const sourceMatch = task.description.match(/Source: (https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/\d+)/);
if (sourceMatch) {
importedUrls.set(sourceMatch[1], task.id);
}