feat(KB-066): add batch GitHub import with throttled request handling

- Add fetchThrottled utility to GitHubClient with 429 retry handling and exponential backoff
- Implement POST /api/github/batch-import endpoint for bulk GitHub resource imports
- Add frontend API client function for batch import operations
- Add comprehensive tests for throttled requests and batch import endpoint
- Add retryAfter field to batch import result types for rate limit handling
- Clean up unfinished UsageIndicator component and worktree retry cleanup code
- Add changeset documenting the new batch GitHub import feature
This commit is contained in:
gsxdsm
2026-03-30 10:56:33 -07:00
parent 702361eafc
commit d928a31bd4
6 changed files with 910 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
---
"@dustinbyrne/kb": minor
---
Add batch GitHub issue import with intelligent throttling
- New `POST /api/github/issues/batch-import` endpoint for importing multiple issues sequentially
- Throttled request utility with exponential backoff and Retry-After header support
- Rate limit protection: 1 batch request per 10 seconds per IP
- Frontend API client function `apiBatchImportGitHubIssues()` with full type support
- Comprehensive test coverage for retry logic, validation, and error handling

View File

@@ -276,6 +276,29 @@ export function apiImportGitHubIssue(owner: string, repo: string, issueNumber: n
});
}
/** Result of a batch import operation for a single issue */
export interface BatchImportResult {
issueNumber: number;
success: boolean;
taskId?: string;
error?: string;
skipped?: boolean;
retryAfter?: number;
}
/** Batch import multiple GitHub issues as kb tasks with throttling */
export function apiBatchImportGitHubIssues(
owner: string,
repo: string,
issueNumbers: number[],
delayMs?: number
): Promise<{ results: BatchImportResult[] }> {
return api<{ results: BatchImportResult[] }>("/github/issues/batch-import", {
method: "POST",
body: JSON.stringify({ owner, repo, issueNumbers, delayMs }),
});
}
// --- Git Remote Detection API ---
/** Git remote info returned by the remotes endpoint */

View File

@@ -809,4 +809,222 @@ describe("GitHubClient", () => {
})).rejects.toThrow("GitHub CLI (gh) is not available or not authenticated");
});
});
describe("fetchThrottled", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as any;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns success with data on successful request", async () => {
const mockData = { id: 1, title: "Test Issue" };
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockData),
} as Response);
const result = await client.fetchThrottled("https://api.github.com/repos/owner/repo/issues/1");
expect(result.success).toBe(true);
expect(result.data).toEqual(mockData);
expect(result.error).toBeUndefined();
});
it("returns error on non-429 HTTP error without retry", async () => {
fetchSpy.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
json: () => Promise.resolve({ message: "Not Found" }),
} as Response);
const result = await client.fetchThrottled("https://api.github.com/repos/owner/repo/issues/1");
expect(result.success).toBe(false);
expect(result.error).toContain("404");
expect(fetchSpy).toHaveBeenCalledTimes(1); // No retries for non-429 errors
});
it("retries on 429 with exponential backoff", async () => {
fetchSpy
.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: new Headers(),
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1 }),
} as Response);
const result = await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 10, maxRetries: 3 }
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ id: 1 });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("respects Retry-After header on 429", async () => {
const headers = new Headers();
headers.set("Retry-After", "1"); // Use 1 second for test speed
fetchSpy
.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: "Too Many Requests",
headers,
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1 }),
} as Response);
const startTime = Date.now();
const result = await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 100, maxRetries: 3 }
);
const elapsed = Date.now() - startTime;
expect(result.success).toBe(true);
// Should wait at least 1 second (Retry-After value), not just the exponential backoff
expect(elapsed).toBeGreaterThanOrEqual(900); // Allow some tolerance
}, 10000); // Increase timeout for this test
it("returns error with retryAfter after max retries exceeded", async () => {
const headers = new Headers();
headers.set("Retry-After", "1"); // Use 1 second for test speed
// All attempts return 429
fetchSpy.mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
headers,
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response);
const result = await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 1, maxRetries: 2 }
);
expect(result.success).toBe(false);
expect(result.error).toContain("rate limit exceeded");
expect(result.retryAfter).toBe(1);
expect(fetchSpy).toHaveBeenCalledTimes(3); // initial + 2 retries
}, 10000); // Increase timeout for this test
it("retries on network errors with exponential backoff", async () => {
fetchSpy
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1 }),
} as Response);
const result = await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 10, maxRetries: 3 }
);
expect(result.success).toBe(true);
expect(result.data).toEqual({ id: 1 });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("returns error after max retries on persistent network errors", async () => {
fetchSpy.mockRejectedValue(new Error("Network error"));
const result = await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 1, maxRetries: 2 }
);
expect(result.success).toBe(false);
expect(result.error).toBe("Network error");
expect(fetchSpy).toHaveBeenCalledTimes(3); // initial + 2 retries
});
it("enforces delay between sequential requests", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1 }),
} as Response);
// First request
await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 100 }
);
const startTime = Date.now();
// Second request should be delayed
await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/2",
{},
{ delayMs: 100 }
);
const elapsed = Date.now() - startTime;
// Should have waited at least 100ms between requests
expect(elapsed).toBeGreaterThanOrEqual(90); // Allow some tolerance
});
it("uses custom delayMs option", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1 }),
} as Response);
const startTime = Date.now();
await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 200 }
);
const elapsed = Date.now() - startTime;
// Should be relatively quick since no previous request
expect(elapsed).toBeLessThan(100);
});
it("uses custom maxRetries option", async () => {
fetchSpy.mockRejectedValue(new Error("Network error"));
await client.fetchThrottled(
"https://api.github.com/repos/owner/repo/issues/1",
{},
{ delayMs: 1, maxRetries: 1 }
);
expect(fetchSpy).toHaveBeenCalledTimes(2); // initial + 1 retry
});
});
});

View File

@@ -9,6 +9,33 @@ import {
runGh,
} from "@kb/core";
/**
* Sleep for a specified number of milliseconds.
*/
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Result of a throttled fetch operation.
*/
export interface ThrottledFetchResult<T> {
success: boolean;
data?: T;
error?: string;
retryAfter?: number;
}
/**
* Options for throttled fetch operations.
*/
export interface ThrottledFetchOptions {
/** Delay between requests in milliseconds (default: 1000ms) */
delayMs?: number;
/** Maximum number of retries on 429 responses (default: 3) */
maxRetries?: number;
}
export interface CreatePrParams {
owner?: string;
repo?: string;
@@ -244,6 +271,7 @@ export function isPrMergeReady(input: {
export class GitHubClient {
private token: string | undefined;
private baseUrl = "https://api.github.com";
private lastRequestTime = 0;
/**
* Create a GitHub client.
@@ -1047,6 +1075,105 @@ export class GitHubClient {
return normalizeBadgeBatchPayload(payload.data?.repository, requests);
}
/**
* Fetch a URL with throttling and automatic retry on rate limit (429) responses.
* Implements exponential backoff and respects Retry-After header when present.
* Ensures minimum delay between sequential requests.
*/
async fetchThrottled<T>(
url: string,
options: RequestInit = {},
throttleOptions: ThrottledFetchOptions = {},
): Promise<ThrottledFetchResult<T>> {
const { delayMs = 1000, maxRetries = 3 } = throttleOptions;
// Enforce delay between sequential requests
const now = Date.now();
const timeSinceLastRequest = now - this.lastRequestTime;
if (this.lastRequestTime > 0 && timeSinceLastRequest < delayMs) {
await delay(delayMs - timeSinceLastRequest);
}
let didBackoffDelay = false;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// On retry attempts (after first failure), apply delay
// Skip if we already applied backoff delay in previous iteration
if (attempt > 0 && !didBackoffDelay) {
await delay(delayMs);
}
didBackoffDelay = false; // Reset for this iteration
this.lastRequestTime = Date.now();
const response = await fetch(url, {
...options,
headers: {
...this.buildHeaders(),
...(options.headers || {}),
},
});
// Handle rate limit (429) with retry logic
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const retryAfterSeconds = retryAfter ? parseInt(retryAfter, 10) : undefined;
// If this is the last retry, return the error
if (attempt >= maxRetries) {
return {
success: false,
error: `GitHub API rate limit exceeded. Retry after ${retryAfterSeconds ?? "unknown"} seconds.`,
retryAfter: retryAfterSeconds,
};
}
// Calculate exponential backoff delay
// Use Retry-After header if present, otherwise use exponential backoff
const backoffDelay = retryAfterSeconds
? retryAfterSeconds * 1000
: delayMs * Math.pow(2, attempt);
await delay(backoffDelay);
didBackoffDelay = true;
// Continue to next iteration - the backoff delay was already applied
// so we skip the standard inter-request delay logic
continue;
}
// Handle other non-OK responses (don't retry)
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
return {
success: false,
error: `GitHub API error: ${response.status} ${error.message || response.statusText}`,
};
}
// Success - parse and return data
const data = await response.json() as T;
return { success: true, data };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
// On last attempt, return the error
if (attempt >= maxRetries) {
return { success: false, error: errorMessage };
}
// For network errors, wait and retry with exponential backoff
// Skip standard inter-request delay since we're applying backoff
const backoffDelay = delayMs * Math.pow(2, attempt);
await delay(backoffDelay);
didBackoffDelay = true;
}
}
// Should never reach here, but TypeScript needs it
return { success: false, error: "Max retries exceeded" };
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",

View File

@@ -2100,6 +2100,360 @@ describe("POST /github/issues/import", () => {
});
});
describe("POST /github/issues/batch-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({
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn().mockImplementation((input) =>
Promise.resolve({
id: `KB-${String(Math.floor(Math.random() * 999)).padStart(3, "0")}`,
title: input.title,
description: input.description,
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: number, title = `Issue ${number}`) => ({
number,
title,
body: `Body for issue ${number}`,
html_url: `https://github.com/owner/repo/issues/${number}`,
labels: [{ name: "bug" }],
});
it("imports multiple issues successfully", async () => {
fetchSpy
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1, "First Issue")),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(2, "Second Issue")),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(3, "Third Issue")),
} as Response);
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(3);
expect(res.body.results.every((r: { success: boolean }) => r.success)).toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(3);
expect(store.createTask).toHaveBeenCalledTimes(3);
});
it("skips already-imported issues", async () => {
// Mock issue 1 fetch
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1, "Already Imported Issue")),
} as Response);
// First import - should create a new task
const res1 = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(res1.status).toBe(200);
expect(res1.body.results).toHaveLength(1);
expect(res1.body.results[0].success).toBe(true);
expect(res1.body.results[0].skipped).toBeUndefined();
const createdTaskId = res1.body.results[0].taskId;
expect(createdTaskId).toBeDefined();
// Now verify that if we import again with the task in the list, it gets skipped
// Update the listTasks mock to return the created task
const createdTaskDescription = `Already Imported Issue\n\nSource: https://github.com/owner/repo/issues/1`;
store.listTasks = vi.fn().mockResolvedValue([
{
id: createdTaskId,
description: createdTaskDescription,
column: "triage",
},
]);
// Second import - should skip
const res2 = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(res2.status).toBe(200);
expect(res2.body.results).toHaveLength(1);
expect(res2.body.results[0].success).toBe(true);
expect(res2.body.results[0].skipped).toBe(true);
expect(res2.body.results[0].taskId).toBe(createdTaskId);
});
it("returns 400 for empty issueNumbers array", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [] }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("at least 1");
});
it("returns 400 for more than 50 issue numbers", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: Array.from({ length: 51 }, (_, i) => i + 1) }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("more than 50");
});
it("returns 400 for invalid issueNumbers (non-integers)", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, "two", 3] }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("positive integers");
});
it("handles partial failures (some succeed, some fail)", async () => {
fetchSpy
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1)),
} as Response)
.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: "Not Found",
json: () => Promise.resolve({ message: "Not Found" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(3)),
} as Response);
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(3);
expect(res.body.results[0].success).toBe(true);
expect(res.body.results[1].success).toBe(false);
expect(res.body.results[1].error).toContain("404");
expect(res.body.results[2].success).toBe(true);
});
it("rejects pull requests with appropriate error", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockGitHubIssue(1), pull_request: {} }),
} as Response);
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.results[0].success).toBe(false);
expect(res.body.results[0].error).toContain("pull request");
});
it("handles rate limit (429) with retry and eventual success", async () => {
fetchSpy
.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: new Headers({ "Retry-After": "1" }),
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1, "Issue After Rate Limit")),
} as Response);
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0].success).toBe(true);
expect(res.body.results[0].taskId).toBeDefined();
expect(fetchSpy).toHaveBeenCalledTimes(2); // Initial 429 + 1 retry
}, 10000); // Increase timeout for retry delay
it("returns error after max retries exceeded on 429", async () => {
// Always return 429
fetchSpy.mockResolvedValue({
ok: false,
status: 429,
statusText: "Too Many Requests",
headers: new Headers({ "Retry-After": "1" }), // 1 second for test speed
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response);
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 1 }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0].success).toBe(false);
expect(res.body.results[0].error).toContain("rate limit");
expect(res.body.results[0].retryAfter).toBe(1);
// Initial attempt + 3 retries = 4 calls
expect(fetchSpy).toHaveBeenCalledTimes(4);
}, 15000); // Increase timeout for multiple retries
it("processes issues sequentially (not parallel)", async () => {
const callTimes: number[] = [];
fetchSpy.mockImplementation(() => {
callTimes.push(Date.now());
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(callTimes.length)),
} as Response);
});
await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1, 2, 3], delayMs: 50 }),
{ "Content-Type": "application/json" }
);
// Verify sequential processing by checking call timing
expect(callTimes).toHaveLength(3);
// Each call should be at least 40ms after the previous (allowing for small timing variations)
for (let i = 1; i < callTimes.length; i++) {
expect(callTimes[i] - callTimes[i - 1]).toBeGreaterThanOrEqual(40);
}
});
it("requires owner parameter", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ repo: "repo", issueNumbers: [1] }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("owner");
});
it("requires repo parameter", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", issueNumbers: [1] }),
{ "Content-Type": "application/json" }
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("repo");
});
it("logs import actions for created tasks", async () => {
fetchSpy.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1)),
} as Response);
await REQUEST(
buildApp(),
"POST",
"/api/github/issues/batch-import",
JSON.stringify({ owner: "owner", repo: "repo", issueNumbers: [1], delayMs: 10 }),
{ "Content-Type": "application/json" }
);
expect(store.logEntry).toHaveBeenCalledWith(
expect.any(String),
"Imported from GitHub",
"https://github.com/owner/repo/issues/1"
);
});
});
// --- Spec Revision route tests ---
describe("POST /tasks/:id/spec/revise", () => {

View File

@@ -1,4 +1,4 @@
import { Router } from "express";
import { Router, type Request, type Response, type NextFunction } from "express";
import multer from "multer";
import { createReadStream } from "node:fs";
import { execSync } from "node:child_process";
@@ -1491,6 +1491,182 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/github/issues/batch-import
* Import multiple GitHub issues as kb tasks with throttling.
* Body: { owner: string, repo: string, issueNumbers: number[], delayMs?: number }
* Returns: { results: BatchImportResult[] }
*/
// Batch import rate limiter: max 1 request per 10 seconds per IP
const batchImportRateLimiter = (() => {
const clients = new Map<string, number>();
const windowMs = 10_000; // 10 seconds
setInterval(() => {
const now = Date.now();
for (const [ip, resetTime] of clients) {
if (now >= resetTime) {
clients.delete(ip);
}
}
}, windowMs);
return (req: Request, res: Response, next: NextFunction): void => {
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
const now = Date.now();
const resetTime = clients.get(ip);
if (resetTime && now < resetTime) {
const retryAfter = Math.ceil((resetTime - now) / 1000);
res.setHeader("Retry-After", String(retryAfter));
res.status(429).json({ error: "Batch import rate limit exceeded. Try again in a few seconds." });
return;
}
clients.set(ip, now + windowMs);
next();
};
})();
router.post("/github/issues/batch-import", batchImportRateLimiter, async (req, res) => {
try {
const { owner, repo, issueNumbers, delayMs } = req.body;
// Validate owner
if (!owner || typeof owner !== "string") {
res.status(400).json({ error: "owner is required" });
return;
}
// Validate repo
if (!repo || typeof repo !== "string") {
res.status(400).json({ error: "repo is required" });
return;
}
// Validate issueNumbers
if (!Array.isArray(issueNumbers)) {
res.status(400).json({ error: "issueNumbers is required and must be an array" });
return;
}
if (issueNumbers.length === 0) {
res.status(400).json({ error: "issueNumbers must contain at least 1 issue number" });
return;
}
if (issueNumbers.length > 50) {
res.status(400).json({ error: "issueNumbers cannot contain more than 50 issue numbers" });
return;
}
if (!issueNumbers.every((n) => typeof n === "number" && n > 0 && Number.isInteger(n))) {
res.status(400).json({ error: "issueNumbers must contain only positive integers" });
return;
}
const token = process.env.GITHUB_TOKEN;
const githubClient = new GitHubClient(token);
// Get existing tasks to check for duplicates
const existingTasks = await store.listTasks();
// Process issues sequentially with throttling
const results: Array<{
issueNumber: number;
success: boolean;
taskId?: string;
error?: string;
skipped?: boolean;
retryAfter?: number;
}> = [];
for (const issueNumber of issueNumbers) {
const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`;
// Use throttled fetch to avoid rate limits
const fetchResult = await githubClient.fetchThrottled<{
number: number;
title: string;
body: string | null;
html_url: string;
pull_request?: unknown;
}>(url, {}, { delayMs: delayMs ?? 1000, maxRetries: 3 });
if (!fetchResult.success) {
results.push({
issueNumber,
success: false,
error: fetchResult.error ?? "Failed to fetch issue",
retryAfter: fetchResult.retryAfter,
});
continue;
}
const issue = fetchResult.data!;
// Check if it's a pull request
if (issue.pull_request) {
results.push({
issueNumber,
success: false,
error: "This is a pull request, not an issue",
});
continue;
}
// Check if already imported
const sourceUrl = issue.html_url;
const existingTask = existingTasks.find((t) => t.description.includes(sourceUrl));
if (existingTask) {
results.push({
issueNumber,
success: true,
skipped: true,
taskId: existingTask.id,
});
continue;
}
// Create the task
const title = issue.title.slice(0, 200);
const body = issue.body?.trim() || "(no description)";
const description = `${body}\n\nSource: ${sourceUrl}`;
try {
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);
results.push({
issueNumber,
success: true,
taskId: task.id,
});
// Add to existingTasks to avoid duplicate imports within the same batch
existingTasks.push({ ...task, description });
} catch (err: any) {
results.push({
issueNumber,
success: false,
error: err.message ?? "Failed to create task",
});
}
}
res.json({ results });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ---------- Auth routes ----------
registerAuthRoutes(router, options?.authStorage);