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:
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user