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

@@ -81,6 +81,8 @@ describe("kb pi extension", () => {
"kb_task_pause",
"kb_task_unpause",
"kb_task_import_github",
"kb_task_import_github_issue",
"kb_task_browse_github_issues",
];
for (const name of expected) {

View File

@@ -64,6 +64,7 @@ Options:
--depends <id> Declare dependency on task create (repeatable)
--limit, -l <n> Max issues to import (default: 30, max: 100)
--labels, -L <labels> Comma-separated label filter for import
--interactive, -i Interactive mode for issue selection
--help, -h Show this help
Columns: triage, todo, in-progress, in-review, done
@@ -187,6 +188,7 @@ async function main() {
console.error("Usage: kb task import <owner/repo> [options]");
console.error("Options: --limit <n>, -l <n> (default: 30, max: 100)");
console.error(" --labels <labels>, -L <labels> (comma-separated)");
console.error(" --interactive, -i (interactive mode)");
process.exit(1);
}
@@ -210,7 +212,15 @@ async function main() {
labels = args[labi + 1].split(",").map(l => l.trim()).filter(Boolean);
}
await runTaskImportFromGitHub(ownerRepo, { limit, labels });
// Check for interactive mode
const interactive = args.includes("--interactive") || args.includes("-i");
if (interactive) {
const { runTaskImportGitHubInteractive } = await import("./commands/task.js");
await runTaskImportGitHubInteractive(ownerRepo, { limit, labels });
} else {
await runTaskImportFromGitHub(ownerRepo, { limit, labels });
}
break;
}
default:

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);
}

View File

@@ -429,6 +429,268 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── kb_task_import_github_issue ───────────────────────────────────
// Import a single GitHub issue by its issue number
pi.registerTool({
name: "kb_task_import_github_issue",
label: "KB: Import GitHub Issue",
description:
"Import a specific GitHub issue as a kb task. Fetches the issue by number " +
"and creates a single task in the triage column with the issue title and body.",
promptSnippet: "Import a specific GitHub issue as a kb task",
promptGuidelines: [
"Use for importing a single known issue by its number",
"Requires GITHUB_TOKEN env var for private repositories",
"Skips import if the issue is already imported (checks for existing Source URL)",
],
parameters: Type.Object({
owner: Type.String({
description: "Repository owner (e.g., 'dustinbyrne')",
}),
repo: Type.String({
description: "Repository name (e.g., 'kb')",
}),
issueNumber: Type.Number({
description: "GitHub issue number to import",
minimum: 1,
}),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { owner, repo, issueNumber } = params;
const token = process.env.GITHUB_TOKEN;
// Build URL for single 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-cli/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 };
try {
const response = await fetch(url, {
headers,
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Issue #${issueNumber} not found in ${owner}/${repo}`);
}
if (response.status === 401 || response.status === 403) {
throw new Error(
`Authentication failed. ${token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."}`
);
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
issue = await response.json() as typeof issue;
// Check if it's a pull request
if ("pull_request" in issue && issue.pull_request) {
throw new Error(`#${issueNumber} is a pull request, not an issue`);
}
} finally {
clearTimeout(timeoutId);
}
// Check if already imported
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks();
const sourceUrl = issue.html_url;
for (const task of existingTasks) {
if (task.description.includes(sourceUrl)) {
return {
content: [
{
type: "text",
text: `Issue #${issueNumber} already imported as ${task.id}\nSource: ${sourceUrl}`,
},
],
details: { skipped: true, existingTaskId: task.id, sourceUrl },
};
}
}
// 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: [],
});
return {
content: [
{
type: "text",
text: `Imported ${task.id} from GitHub\n${sourceUrl}`,
},
],
details: { taskId: task.id, sourceUrl },
};
},
});
// ── kb_task_browse_github_issues ──────────────────────────────────
// Browse available GitHub issues before importing
pi.registerTool({
name: "kb_task_browse_github_issues",
label: "KB: Browse GitHub Issues",
description:
"List open GitHub issues from a repository to browse before importing. " +
"Returns issue numbers, titles, and URLs for selection. Use with kb_task_import_github_issue " +
"to import specific issues by number.",
promptSnippet: "Browse open GitHub issues in a repository",
promptGuidelines: [
"Use to preview available issues before importing",
"Returns a list you can reference when importing specific issues",
"Use --limit to control how many issues to show (default: 30)",
"Use --labels to filter by specific labels",
"Requires GITHUB_TOKEN env var for private repositories",
],
parameters: Type.Object({
owner: Type.String({
description: "Repository owner (e.g., 'dustinbyrne')",
}),
repo: Type.String({
description: "Repository name (e.g., 'kb')",
}),
limit: Type.Optional(
Type.Number({
description: "Max issues to show (default: 30, max: 100)",
minimum: 1,
maximum: 100,
})
),
labels: Type.Optional(
Type.Array(Type.String(), {
description: "Label names to filter by",
})
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const { owner, repo, limit = 30, labels } = params;
const token = process.env.GITHUB_TOKEN;
// Build query parameters
const queryParams = new URLSearchParams();
queryParams.append("state", "open");
queryParams.append("per_page", String(Math.min(limit, 100)));
if (labels && labels.length > 0) {
queryParams.append("labels", labels.join(","));
}
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${queryParams}`;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "kb-cli/1.0",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
let issues: Array<{ number: number; title: string; html_url: string; labels: Array<{ name: string }> }>;
try {
const response = await fetch(url, {
headers,
signal: controller.signal,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Repository not found: ${owner}/${repo}`);
}
if (response.status === 401 || response.status === 403) {
throw new Error(
`Authentication failed. ${token ? "Check your GITHUB_TOKEN." : "Set GITHUB_TOKEN env var."}`
);
}
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const allIssues = await response.json() as typeof issues;
// Filter out pull requests
issues = allIssues.filter((i) => !("pull_request" in i && i.pull_request)).slice(0, limit);
} finally {
clearTimeout(timeoutId);
}
if (issues.length === 0) {
return {
content: [{ type: "text", text: `No open issues found in ${owner}/${repo}.` }],
details: { count: 0, issues: [] },
};
}
// Check which issues are already imported
const store = await getStore(ctx.cwd);
const existingTasks = await store.listTasks();
const importedUrls = new Set<string>();
for (const task of existingTasks) {
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (match) {
importedUrls.add(match[1]);
}
}
const lines: string[] = [];
lines.push(`Found ${issues.length} open issues in ${owner}/${repo}:\n`);
for (const issue of issues) {
const isImported = importedUrls.has(issue.html_url);
const labelStr = issue.labels.length > 0 ? ` [${issue.labels.map((l) => l.name).join(", ")}]` : "";
const importedStr = isImported ? " ✓ Imported" : "";
lines.push(` #${issue.number}: ${issue.title.slice(0, 80)}${issue.title.length > 80 ? "…" : ""}${labelStr}${importedStr}`);
lines.push(` ${issue.html_url}`);
}
lines.push("\nUse kb_task_import_github_issue to import a specific issue by number.");
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
count: issues.length,
issues: issues.map((i) => ({
number: i.number,
title: i.title,
url: i.html_url,
labels: i.labels.map((l) => l.name),
imported: importedUrls.has(i.html_url),
})),
},
};
},
});
// ── /kb command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;

View File

@@ -7,6 +7,7 @@ import { TaskDetailModal } from "./components/TaskDetailModal";
import { SettingsModal } from "./components/SettingsModal";
import type { SectionId } from "./components/SettingsModal";
import { ToastContainer } from "./components/ToastContainer";
import { GitHubImportModal } from "./components/GitHubImportModal";
import { useTasks } from "./hooks/useTasks";
import { ToastProvider, useToast } from "./hooks/useToast";
@@ -14,6 +15,7 @@ function AppInner() {
const [isCreating, setIsCreating] = useState(false);
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [githubImportOpen, setGitHubImportOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
const [maxConcurrent, setMaxConcurrent] = useState(2);
const [autoMerge, setAutoMerge] = useState(true);
@@ -91,10 +93,15 @@ function AppInner() {
const handleDetailClose = useCallback(() => setDetailTask(null), []);
const handleGitHubImport = useCallback((task: Task) => {
addToast(`Imported ${task.id} from GitHub`, "success");
}, [addToast]);
return (
<>
<Header
onOpenSettings={() => setSettingsOpen(true)}
onOpenGitHubImport={() => setGitHubImportOpen(true)}
globalPaused={globalPaused}
enginePaused={enginePaused}
onToggleGlobalPause={handleToggleGlobalPause}
@@ -136,6 +143,12 @@ function AppInner() {
initialSection={settingsInitialSection}
/>
)}
<GitHubImportModal
isOpen={githubImportOpen}
onClose={() => setGitHubImportOpen(false)}
onImport={handleGitHubImport}
tasks={tasks}
/>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);

View File

@@ -151,3 +151,35 @@ export function logoutProvider(provider: string): Promise<{ success: boolean }>
body: JSON.stringify({ provider }),
});
}
// --- GitHub Import API ---
/** GitHub issue returned by the fetch endpoint */
export interface GitHubIssue {
number: number;
title: string;
body: string | null;
html_url: string;
labels: Array<{ name: string }>;
}
/** Fetch open GitHub issues from a repository */
export function apiFetchGitHubIssues(
owner: string,
repo: string,
limit?: number,
labels?: string[]
): Promise<GitHubIssue[]> {
return api<GitHubIssue[]>("/github/issues/fetch", {
method: "POST",
body: JSON.stringify({ owner, repo, limit, labels }),
});
}
/** Import a specific GitHub issue as a kb task */
export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: number): Promise<Task> {
return api<Task>("/github/issues/import", {
method: "POST",
body: JSON.stringify({ owner, repo, issueNumber }),
});
}

View File

@@ -0,0 +1,236 @@
import { useState, useEffect, useCallback } from "react";
import type { Task } from "@kb/core";
import { apiFetchGitHubIssues, apiImportGitHubIssue, type GitHubIssue } from "../api";
import { Loader2 } from "lucide-react";
interface GitHubImportModalProps {
isOpen: boolean;
onClose: () => void;
onImport: (task: Task) => void;
tasks: Task[];
}
export function GitHubImportModal({ isOpen, onClose, onImport, tasks }: GitHubImportModalProps) {
const [owner, setOwner] = useState("");
const [repo, setRepo] = useState("");
const [labels, setLabels] = useState("");
const [loading, setLoading] = useState(false);
const [issues, setIssues] = useState<GitHubIssue[]>([]);
const [selectedIssueNumber, setSelectedIssueNumber] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
// Build set of already imported URLs from existing tasks
const importedUrls = new Set<string>();
for (const task of tasks) {
const match = task.description.match(/Source: (https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+)/);
if (match) {
importedUrls.add(match[1]);
}
}
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setOwner("");
setRepo("");
setLabels("");
setIssues([]);
setSelectedIssueNumber(null);
setError(null);
setImporting(false);
}
}, [isOpen]);
// Handle escape key
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen, onClose]);
const handleLoad = useCallback(async () => {
if (!owner.trim() || !repo.trim()) {
setError("Owner and repo are required");
return;
}
setLoading(true);
setError(null);
setIssues([]);
setSelectedIssueNumber(null);
try {
const labelArray = labels
.split(",")
.map((l) => l.trim())
.filter(Boolean);
const fetchedIssues = await apiFetchGitHubIssues(owner.trim(), repo.trim(), 30, labelArray.length > 0 ? labelArray : undefined);
setIssues(fetchedIssues);
if (fetchedIssues.length === 0) {
setError("No open issues found");
}
} catch (err: any) {
setError(err.message || "Failed to fetch issues");
} finally {
setLoading(false);
}
}, [owner, repo, labels]);
const handleImport = useCallback(async () => {
if (selectedIssueNumber === null) return;
setImporting(true);
setError(null);
try {
const task = await apiImportGitHubIssue(owner.trim(), repo.trim(), selectedIssueNumber);
onImport(task);
onClose();
} catch (err: any) {
if (err.message?.includes("already imported")) {
setError(err.message);
} else {
setError(err.message || "Failed to import issue");
}
} finally {
setImporting(false);
}
}, [selectedIssueNumber, owner, repo, onImport, onClose]);
const selectedIssue = issues.find((i) => i.number === selectedIssueNumber);
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal">
<div className="modal-header">
<h3>Import from GitHub</h3>
<button className="modal-close" onClick={onClose}>
&times;
</button>
</div>
<div className="modal-body">
{/* Form Row */}
<div className="form-row">
<div className="form-group">
<label htmlFor="gh-owner">Owner</label>
<input
id="gh-owner"
type="text"
placeholder="e.g. dustinbyrne"
value={owner}
onChange={(e) => setOwner(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
disabled={loading || importing}
/>
</div>
<div className="form-group">
<label htmlFor="gh-repo">Repo</label>
<input
id="gh-repo"
type="text"
placeholder="e.g. kb"
value={repo}
onChange={(e) => setRepo(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
disabled={loading || importing}
/>
</div>
<div className="form-group">
<label htmlFor="gh-labels">Labels (optional)</label>
<input
id="gh-labels"
type="text"
placeholder="bug,enhancement"
value={labels}
onChange={(e) => setLabels(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLoad()}
disabled={loading || importing}
/>
</div>
<div className="form-group form-group--action">
<label>&nbsp;</label>
<button className="btn btn-primary" onClick={handleLoad} disabled={loading || importing || !owner.trim() || !repo.trim()}>
{loading ? <Loader2 size={14} className="spin" /> : "Load"}
</button>
</div>
</div>
{/* Error Display */}
{error && <div className="form-error">{error}</div>}
{/* Issues List */}
{issues.length > 0 && (
<>
<div className="issues-list">
<h4>Found {issues.length} issues:</h4>
{issues.map((issue) => {
const isImported = importedUrls.has(issue.html_url);
return (
<div
key={issue.number}
className={`issue-item ${selectedIssueNumber === issue.number ? "selected" : ""} ${isImported ? "imported" : ""}`}
onClick={() => !isImported && setSelectedIssueNumber(issue.number)}
>
<input
type="radio"
name="issue"
checked={selectedIssueNumber === issue.number}
onChange={() => setSelectedIssueNumber(issue.number)}
disabled={isImported}
/>
<span className="issue-number">#{issue.number}</span>
<span className="issue-title">{issue.title}</span>
{issue.labels.length > 0 && (
<span className="issue-labels">
{issue.labels.map((l) => (
<span key={l.name} className="label-chip">
{l.name}
</span>
))}
</span>
)}
{isImported && <span className="imported-badge">Imported</span>}
</div>
);
})}
</div>
{/* Preview */}
{selectedIssue && (
<div className="issue-preview">
<h4>Preview</h4>
<div className="preview-title">{selectedIssue.title}</div>
<div className="preview-body">
{selectedIssue.body
? selectedIssue.body.slice(0, 200) + (selectedIssue.body.length > 200 ? "…" : "")
: "(no description)"}
</div>
</div>
)}
</>
)}
</div>
<div className="modal-actions">
<button className="btn" onClick={onClose} disabled={importing}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={handleImport}
disabled={selectedIssueNumber === null || importing}
>
{importing ? <Loader2 size={14} className="spin" /> : "Import"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import { Settings, Pause, Play, Square } from "lucide-react";
import { Settings, Pause, Play, Square, Download } from "lucide-react";
interface HeaderProps {
onOpenSettings?: () => void;
onOpenGitHubImport?: () => void;
globalPaused?: boolean;
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
@@ -10,6 +11,7 @@ interface HeaderProps {
export function Header({
onOpenSettings,
onOpenGitHubImport,
globalPaused,
enginePaused,
onToggleGlobalPause,
@@ -23,6 +25,10 @@ export function Header({
<span className="logo-sub">board</span>
</div>
<div className="header-actions">
{/* Import from GitHub */}
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
<Download size={16} />
</button>
{/* Pause button (soft pause): stops new work, lets agents finish */}
<button
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}

View File

@@ -261,3 +261,41 @@ describe("App engine pause (soft pause)", () => {
expect(updateSettings).toHaveBeenCalledWith({ enginePaused: true });
});
});
describe("App GitHub import", () => {
it("opens GitHub import modal when import button is clicked", async () => {
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
});
// Click the import button
fireEvent.click(screen.getByTitle("Import from GitHub"));
// Modal should be visible
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
it("closes GitHub import modal on cancel", async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
});
// Open the modal
fireEvent.click(screen.getByTitle("Import from GitHub"));
expect(screen.getByText("Import from GitHub")).toBeTruthy();
// Close the modal - use getAllByRole since there might be multiple buttons
const cancelButtons = screen.getAllByRole("button", { name: /Cancel/i });
fireEvent.click(cancelButtons[cancelButtons.length - 1]);
// Modal should be closed - the Load button from the modal should be gone
await waitFor(() => {
expect(screen.queryByRole("button", { name: /^Load$/i })).toBeNull();
});
});
});

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { GitHubImportModal } from "../GitHubImportModal";
import { apiFetchGitHubIssues, apiImportGitHubIssue } from "../../api";
import type { Task } from "@kb/core";
// Mock the API module
vi.mock("../../api", () => ({
apiFetchGitHubIssues: vi.fn(),
apiImportGitHubIssue: vi.fn(),
}));
const mockTask: Task = {
id: "KB-001",
title: "Test Issue",
description: "Test body\n\nSource: https://github.com/owner/repo/issues/1",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z",
};
describe("GitHubImportModal", () => {
const onClose = vi.fn();
const onImport = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders when isOpen is true", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
it("does not render when isOpen is false", () => {
render(<GitHubImportModal isOpen={false} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(screen.queryByText("Import from GitHub")).toBeNull();
});
it("has owner and repo inputs", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(screen.getByLabelText("Owner")).toBeTruthy();
expect(screen.getByLabelText("Repo")).toBeTruthy();
});
it("has optional labels input", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
expect(screen.getByLabelText(/Labels/)).toBeTruthy();
});
it("disables Load button when owner or repo is empty", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
expect(loadButton.disabled).toBe(true);
});
it("enables Load button when owner and repo are filled", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
const ownerInput = screen.getByLabelText("Owner");
const repoInput = screen.getByLabelText("Repo");
fireEvent.change(ownerInput, { target: { value: "dustinbyrne" } });
fireEvent.change(repoInput, { target: { value: "kb" } });
const loadButton = screen.getByRole("button", { name: /Load/i }) as HTMLButtonElement;
expect(loadButton.disabled).toBe(false);
});
it("calls apiFetchGitHubIssues when Load is clicked", async () => {
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
const ownerInput = screen.getByLabelText("Owner");
const repoInput = screen.getByLabelText("Repo");
fireEvent.change(ownerInput, { target: { value: "dustinbyrne" } });
fireEvent.change(repoInput, { target: { value: "kb" } });
const loadButton = screen.getByRole("button", { name: /Load/i });
fireEvent.click(loadButton);
await waitFor(() => {
expect(apiFetchGitHubIssues).toHaveBeenCalledWith("dustinbyrne", "kb", 30, undefined);
});
});
it("displays fetched issues after loading", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
{ number: 2, title: "Second Issue", body: "Body 2", html_url: "https://github.com/owner/repo/issues/2", labels: [] },
];
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
const ownerInput = screen.getByLabelText("Owner");
const repoInput = screen.getByLabelText("Repo");
fireEvent.change(ownerInput, { target: { value: "owner" } });
fireEvent.change(repoInput, { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
expect(screen.getByText("Second Issue")).toBeTruthy();
});
});
it("selects an issue when clicked", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
const radio = screen.getByRole("radio") as HTMLInputElement;
fireEvent.click(radio);
expect(radio.checked).toBe(true);
});
it("disables Import button when no issue is selected", async () => {
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
const importButton = screen.getByRole("button", { name: /Import$/i }) as HTMLButtonElement;
expect(importButton.disabled).toBe(true);
});
it("calls apiImportGitHubIssue and onImport when Import is clicked", async () => {
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
]);
vi.mocked(apiImportGitHubIssue).mockResolvedValueOnce(mockTask);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("First Issue")).toBeTruthy();
});
fireEvent.click(screen.getByRole("radio"));
fireEvent.click(screen.getByRole("button", { name: /Import$/i }));
await waitFor(() => {
expect(apiImportGitHubIssue).toHaveBeenCalledWith("owner", "repo", 1);
expect(onImport).toHaveBeenCalledWith(mockTask);
expect(onClose).toHaveBeenCalled();
});
});
it("shows 'Imported' badge for already imported issues", async () => {
const existingTask: Task = {
...mockTask,
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
};
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("Imported")).toBeTruthy();
});
});
it("disables radio buttons for already imported issues", async () => {
const existingTask: Task = {
...mockTask,
description: "Existing\n\nSource: https://github.com/owner/repo/issues/1",
};
const issues = [
{ number: 1, title: "First Issue", body: "Body 1", html_url: "https://github.com/owner/repo/issues/1", labels: [] },
];
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[existingTask]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
const radio = screen.getByRole("radio") as HTMLInputElement;
expect(radio.disabled).toBe(true);
});
});
it("displays error on fetch failure", async () => {
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("Repository not found"));
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("Repository not found")).toBeTruthy();
});
});
it("closes modal on Cancel button click", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(screen.getByRole("button", { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it("closes modal on X button click", () => {
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.click(screen.getByText("×"));
expect(onClose).toHaveBeenCalled();
});
it("displays label chips for issues with labels", async () => {
const issues = [
{ number: 1, title: "Bug Issue", body: "Body", html_url: "https://github.com/owner/repo/issues/1", labels: [{ name: "bug" }, { name: "urgent" }] },
];
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
render(<GitHubImportModal isOpen={true} onClose={onClose} onImport={onImport} tasks={[]} />);
fireEvent.change(screen.getByLabelText("Owner"), { target: { value: "owner" } });
fireEvent.change(screen.getByLabelText("Repo"), { target: { value: "repo" } });
fireEvent.click(screen.getByRole("button", { name: /Load/i }));
await waitFor(() => {
expect(screen.getByText("bug")).toBeTruthy();
expect(screen.getByText("urgent")).toBeTruthy();
});
});
});

View File

@@ -26,6 +26,21 @@ describe("Header", () => {
expect(btn).toBeDefined();
});
it("renders the import button", () => {
const onOpen = vi.fn();
render(<Header onOpenGitHubImport={onOpen} />);
const btn = screen.getByTitle("Import from GitHub");
expect(btn).toBeDefined();
});
it("calls onOpenGitHubImport when import button is clicked", () => {
const onOpen = vi.fn();
render(<Header onOpenGitHubImport={onOpen} />);
const btn = screen.getByTitle("Import from GitHub");
fireEvent.click(btn);
expect(onOpen).toHaveBeenCalledOnce();
});
// ── Pause button (soft pause) ────────────────────────────────────
it("renders pause button with 'Pause scheduling' title when not paused", () => {

View File

@@ -1642,3 +1642,150 @@ body {
gap: 8px;
}
}
/* === GitHub Import Modal === */
.form-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.form-row .form-group {
flex: 1;
min-width: 0;
}
.form-row .form-group--action {
flex: 0 0 auto;
}
.form-row .btn {
height: 32px;
}
.form-error {
margin: 12px 0;
padding: 8px 12px;
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--color-error);
border-radius: var(--radius);
color: var(--color-error);
font-size: 13px;
}
.issues-list {
margin-top: 16px;
max-height: 240px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.issues-list h4 {
padding: 10px 12px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.issue-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background var(--transition-fast);
}
.issue-item:last-child {
border-bottom: none;
}
.issue-item:hover:not(.imported) {
background: var(--card-hover);
}
.issue-item.selected {
background: rgba(88, 166, 255, 0.1);
}
.issue-item.imported {
opacity: 0.6;
cursor: not-allowed;
}
.issue-number {
font-size: 12px;
color: var(--text-muted);
min-width: 40px;
}
.issue-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.issue-labels {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.label-chip {
padding: 2px 6px;
font-size: 11px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-muted);
}
.imported-badge {
padding: 2px 8px;
font-size: 11px;
background: rgba(63, 185, 80, 0.2);
border: 1px solid var(--color-success);
border-radius: 12px;
color: var(--color-success);
}
.issue-preview {
margin-top: 16px;
padding: 12px;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.issue-preview h4 {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
margin-bottom: 8px;
}
.preview-title {
font-weight: 500;
margin-bottom: 8px;
}
.preview-body {
font-size: 13px;
color: var(--text-muted);
line-height: 1.4;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spin {
animation: spin 1s linear infinite;
}

View File

@@ -19,6 +19,7 @@
"build": "vite build && tsc",
"build:client": "vite build",
"dev": "vite build --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {

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);