feat(KB-064): add batch GitHub badge status fetching

- Add batch status API types (BatchStatusRequest, BatchStatusResponse, etc.)

- Add batch GitHub client wrappers for efficient PR/issue status fetching

- Create useBatchBadgeFetch hook for coordinated dashboard fetching

- Update Board component to batch fetch badge statuses on mount

- Update TaskCard to merge batch, WebSocket, and task data with freshness comparison

- Add batch status REST endpoint at POST /api/tasks/batch/status
This commit is contained in:
gsxdsm
2026-03-30 16:41:21 -07:00
parent 8fb9ae5300
commit 7899345cfa
14 changed files with 1631 additions and 17 deletions

View File

@@ -1,6 +1,19 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchTaskDetail, updateTask, archiveTask, unarchiveTask, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, addSteeringComment, fetchGitRemotes, refineTask } from "./api";
import type { Task, TaskDetail } from "@kb/core";
import {
fetchTaskDetail,
updateTask,
archiveTask,
unarchiveTask,
fetchAuthStatus,
loginProvider,
logoutProvider,
fetchModels,
addSteeringComment,
fetchGitRemotes,
refineTask,
fetchBatchStatus,
} from "./api";
import type { Task, TaskDetail, BatchStatusResponse } from "@kb/core";
const FAKE_DETAIL: TaskDetail = {
id: "KB-001",
@@ -131,6 +144,48 @@ describe("fetchModels", () => {
});
});
describe("fetchBatchStatus", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("posts task ids and unwraps the results envelope", async () => {
const response: BatchStatusResponse = {
results: {
"KB-001": {
issueInfo: {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "closed",
title: "Issue 101",
stateReason: "completed",
lastCheckedAt: "2026-03-30T12:00:00.000Z",
},
stale: false,
},
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await fetchBatchStatus(["KB-001"]);
expect(result).toEqual(response.results);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/github/batch/status", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ taskIds: ["KB-001"] }),
});
});
it("propagates API errors", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "rate limit exceeded" }, 429));
await expect(fetchBatchStatus(["KB-001"])).rejects.toThrow("rate limit exceeded");
});
});
describe("fetchAuthStatus", () => {
const originalFetch = globalThis.fetch;

View File

@@ -1,4 +1,16 @@
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@kb/core";
import type {
Task,
TaskDetail,
TaskAttachment,
TaskCreateInput,
AgentLogEntry,
Column,
MergeResult,
Settings,
BatchStatusResult,
BatchStatusResponse,
BatchStatusEntry,
} from "@kb/core";
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@kb/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult } from "@kb/core";
@@ -388,8 +400,8 @@ export function refreshPrStatus(id: string): Promise<PrRefreshResponse> {
// --- Issue Management API ---
/** Re-export IssueInfo type for convenience */
export type { IssueInfo } from "@kb/core";
/** Re-export GitHub badge-related types for convenience */
export type { IssueInfo, BatchStatusResult, BatchStatusEntry } from "@kb/core";
/** Fetch cached issue status for a task */
export function fetchIssueStatus(id: string): Promise<{ issueInfo: import("@kb/core").IssueInfo; stale: boolean }> {
@@ -403,6 +415,16 @@ export function refreshIssueStatus(id: string): Promise<import("@kb/core").Issue
});
}
/** Batch-refresh cached GitHub badge status for multiple tasks. */
export async function fetchBatchStatus(taskIds: string[]): Promise<BatchStatusResult> {
const response = await api<BatchStatusResponse>("/github/batch/status", {
method: "POST",
body: JSON.stringify({ taskIds }),
});
return response.results;
}
// --- Terminal API ---
/** Terminal exec response - returns sessionId for streaming output via SSE */

View File

@@ -2,7 +2,8 @@ import type { Task, TaskDetail, Column as ColumnType } from "@kb/core";
import { COLUMNS } from "@kb/core";
import { Column } from "./Column";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useCallback, useRef } from "react";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
interface BoardProps {
tasks: Task[];
@@ -42,6 +43,8 @@ function areTaskArraysEqual(previous: Task[], next: Task[]): boolean {
export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onArchiveTask, onUnarchiveTask, searchQuery = "" }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
const { fetchBatch } = useBatchBadgeFetch();
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
triage: [],
todo: [],
@@ -92,6 +95,41 @@ export function Board({ tasks, maxConcurrent, onMoveTask, onOpenDetail, addToast
return stableGrouped;
}, [filteredTasks]);
// Collect task IDs with GitHub badge info for batch fetching
const taskIdsWithBadges = useMemo(() => {
return filteredTasks
.filter((t) => t.prInfo || t.issueInfo)
.map((t) => t.id);
}, [filteredTasks]);
// Batch fetch badge statuses on mount and when visible tasks change
useEffect(() => {
if (taskIdsWithBadges.length === 0) return;
// Debounce the batch fetch to handle rapid changes
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
// Fetch in chunks of 50 to respect the API limit
const chunks: string[][] = [];
for (let i = 0; i < taskIdsWithBadges.length; i += 50) {
chunks.push(taskIdsWithBadges.slice(i, i + 50));
}
// Fire all chunks concurrently - the hook handles deduplication
chunks.forEach((chunk) => {
void fetchBatch(chunk);
});
}, 500);
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [taskIdsWithBadges, fetchBatch]);
return (
<main className="board" id="board">
{COLUMNS.map((col) => (

View File

@@ -1,8 +1,11 @@
import { memo, useCallback, useState, useRef, useEffect } from "react";
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo } from "@kb/core";
import { fetchTaskDetail, uploadAttachment } from "../api";
import { TaskCardBadge } from "./TaskCardBadge";
import { GitHubBadge } from "./GitHubBadge";
import { pickPreferredBadge } from "./TaskCardBadge";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -134,6 +137,7 @@ function TaskCardComponent({
const touchOpenHandledRef = useRef(false);
const cardRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
const { badgeUpdates, subscribeToBadge, unsubscribeFromBadge } = useBadgeWebSocket();
// Touch gesture detection refs
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
@@ -307,6 +311,66 @@ function TaskCardComponent({
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo);
useEffect(() => {
if (!hasGitHubBadge || !isInViewport) {
unsubscribeFromBadge(task.id);
return;
}
subscribeToBadge(task.id);
return () => {
unsubscribeFromBadge(task.id);
};
}, [hasGitHubBadge, isInViewport, subscribeToBadge, task.id, unsubscribeFromBadge]);
const liveBadgeData = badgeUpdates.get(task.id);
// Get fresh batch data if available
const batchData = useMemo(() => getFreshBatchData(task.id), [task.id]);
// Pick the freshest data among WebSocket, batch, and task data
const livePrInfo = useMemo(() => {
const wsData = liveBadgeData?.prInfo;
const wsTimestamp = liveBadgeData?.timestamp;
const batchInfo = batchData?.result?.prInfo;
const batchTimestamp = batchData?.timestamp ? new Date(batchData.timestamp).toISOString() : undefined;
const taskInfo = task.prInfo;
const taskTimestamp = task.prInfo?.lastCheckedAt ?? task.updatedAt;
// Compare all three sources and pick the freshest
let bestData = pickPreferredBadge<PrInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
let bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
if (batchInfo && batchTimestamp) {
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
bestData = batchInfo;
}
}
return bestData;
}, [liveBadgeData, batchData, task.prInfo, task.updatedAt]);
const liveIssueInfo = useMemo(() => {
const wsData = liveBadgeData?.issueInfo;
const wsTimestamp = liveBadgeData?.timestamp;
const batchInfo = batchData?.result?.issueInfo;
const batchTimestamp = batchData?.timestamp ? new Date(batchData.timestamp).toISOString() : undefined;
const taskInfo = task.issueInfo;
const taskTimestamp = task.issueInfo?.lastCheckedAt ?? task.updatedAt;
// Compare all three sources and pick the freshest
let bestData = pickPreferredBadge<IssueInfo>(wsData, wsTimestamp, taskInfo, taskTimestamp);
let bestTimestamp = wsTimestamp && wsTimestamp >= taskTimestamp ? wsTimestamp : taskTimestamp;
if (batchInfo && batchTimestamp) {
if (!bestTimestamp || batchTimestamp > bestTimestamp) {
bestData = batchInfo;
}
}
return bestData;
}, [liveBadgeData, batchData, task.issueInfo, task.updatedAt]);
const enterEditMode = useCallback((e?: React.MouseEvent) => {
e?.stopPropagation();
if (!canEdit || isSaving) return;
@@ -519,12 +583,9 @@ function TaskCardComponent({
</span>
)}
{hasGitHubBadge && (
<TaskCardBadge
taskId={task.id}
prInfo={task.prInfo}
issueInfo={task.issueInfo}
updatedAt={task.updatedAt}
isInViewport={isInViewport}
<GitHubBadge
prInfo={livePrInfo}
issueInfo={liveIssueInfo}
/>
)}
<div className="card-header-actions">

View File

@@ -3,7 +3,7 @@ import type { IssueInfo, PrInfo } from "@kb/core";
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
import { GitHubBadge } from "./GitHubBadge";
function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
export function pickPreferredBadge<T extends { lastCheckedAt?: string }>(
liveValue: T | null | undefined,
liveTimestamp: string | undefined,
taskValue: T | undefined,

View File

@@ -0,0 +1,265 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useBatchBadgeFetch, __resetBatchBadgeStoreForTests } from "../useBatchBadgeFetch";
import * as api from "../../api";
import type { BatchStatusResult } from "@kb/core";
// Mock the API module
vi.mock("../../api", () => ({
fetchBatchStatus: vi.fn(),
}));
const mockFetchBatchStatus = vi.mocked(api.fetchBatchStatus);
describe("useBatchBadgeFetch", () => {
beforeEach(() => {
__resetBatchBadgeStoreForTests();
mockFetchBatchStatus.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("calls API with correct task IDs", async () => {
const mockResult: BatchStatusResult = {
"KB-001": {
issueInfo: {
url: "https://github.com/owner/repo/issues/1",
number: 1,
state: "open",
title: "Test Issue",
},
stale: false,
},
};
mockFetchBatchStatus.mockResolvedValueOnce(mockResult);
const { result } = renderHook(() => useBatchBadgeFetch());
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
expect(mockFetchBatchStatus).toHaveBeenCalledWith(["KB-001"]);
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
});
it("shares pending promise for concurrent calls with same IDs", async () => {
const mockResult: BatchStatusResult = {
"KB-001": { issueInfo: undefined, prInfo: undefined, stale: true },
};
// Create a delayed promise so we can verify deduplication
let resolvePromise: (value: BatchStatusResult) => void;
const promise = new Promise<BatchStatusResult>((resolve) => {
resolvePromise = resolve;
});
mockFetchBatchStatus.mockReturnValueOnce(promise);
const hook1 = renderHook(() => useBatchBadgeFetch());
const hook2 = renderHook(() => useBatchBadgeFetch());
// Start both fetches concurrently (but don't await yet)
const fetchPromise1 = hook1.result.current.fetchBatch(["KB-001"]);
const fetchPromise2 = hook2.result.current.fetchBatch(["KB-001"]);
// Resolve the shared promise
resolvePromise!(mockResult);
// Now await both
await act(async () => {
await Promise.all([fetchPromise1, fetchPromise2]);
});
// Should only make one API call due to promise deduplication
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
});
it("uses cached data for calls within 5 seconds (no API call)", async () => {
const mockResult: BatchStatusResult = {
"KB-001": {
issueInfo: {
url: "https://github.com/owner/repo/issues/1",
number: 1,
state: "open",
title: "Test Issue",
},
stale: false,
},
};
mockFetchBatchStatus.mockResolvedValueOnce(mockResult);
const { result } = renderHook(() => useBatchBadgeFetch());
// First fetch
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
// Second fetch within 5 seconds - should use cache
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
// Should not make another API call
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
});
it("makes new API call after 5 second cache expires", async () => {
const mockResult: BatchStatusResult = {
"KB-001": { issueInfo: undefined, prInfo: undefined, stale: true },
};
mockFetchBatchStatus.mockResolvedValue(mockResult);
const { result } = renderHook(() => useBatchBadgeFetch());
// First fetch
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
// Wait for cache to expire (5 seconds + 1ms buffer)
await new Promise((resolve) => setTimeout(resolve, 5100));
// Second fetch after cache expired
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
// Should make a new API call
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(2);
}, 10000);
it("retries 429 errors with exponential backoff", async () => {
const rateLimitError = new Error("429 Rate limit exceeded");
const mockResult: BatchStatusResult = {
"KB-001": { issueInfo: undefined, prInfo: undefined, stale: true },
};
// First calls fail with 429, eventually succeeds
mockFetchBatchStatus
.mockRejectedValueOnce(rateLimitError)
.mockRejectedValueOnce(rateLimitError)
.mockResolvedValueOnce(mockResult);
const { result } = renderHook(() => useBatchBadgeFetch());
// Start the fetch
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
// Wait for retries (exponential backoff: 1s + 2s = 3s total)
await new Promise((resolve) => setTimeout(resolve, 4000));
// Should have made multiple calls due to retries
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(3);
}, 10000);
it("does not retry non-429 errors", async () => {
const otherError = new Error("Network error");
mockFetchBatchStatus.mockRejectedValueOnce(otherError);
const { result } = renderHook(() => useBatchBadgeFetch());
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
// Should only make one call (no retries for non-429 errors)
expect(mockFetchBatchStatus).toHaveBeenCalledTimes(1);
});
it("returns undefined for uncached task IDs", () => {
const { result } = renderHook(() => useBatchBadgeFetch());
const data = result.current.getBatchData("KB-UNKNOWN");
expect(data).toBeUndefined();
});
it("skips empty task ID arrays", async () => {
const { result } = renderHook(() => useBatchBadgeFetch());
await act(async () => {
await result.current.fetchBatch([]);
});
expect(mockFetchBatchStatus).not.toHaveBeenCalled();
});
it("stores data and makes it available via getBatchData", async () => {
const mockResult: BatchStatusResult = {
"KB-001": {
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Test PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
stale: false,
},
};
mockFetchBatchStatus.mockResolvedValueOnce(mockResult);
// Use a single hook instance for the entire test
const { result } = renderHook(() => useBatchBadgeFetch());
// Fetch the data
await act(async () => {
await result.current.fetchBatch(["KB-001"]);
});
// Verify data was stored - access result in the same act block
let storedData;
act(() => {
storedData = result.current.getBatchData("KB-001");
});
expect(storedData).toBeDefined();
expect(storedData?.result.prInfo?.title).toBe("Test PR");
expect(storedData?.timestamp).toBeGreaterThan(0);
});
it("module-level store shares data across hooks", async () => {
const mockResult: BatchStatusResult = {
"KB-001": {
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Shared PR",
headBranch: "feature/shared",
baseBranch: "main",
commentCount: 0,
},
stale: false,
},
};
mockFetchBatchStatus.mockResolvedValueOnce(mockResult);
// Create two hooks
const hook1 = renderHook(() => useBatchBadgeFetch());
const hook2 = renderHook(() => useBatchBadgeFetch());
// Fetch from first hook
await act(async () => {
await hook1.result.current.fetchBatch(["KB-001"]);
});
// Second hook should see the data via getBatchData
let sharedData;
act(() => {
sharedData = hook2.result.current.getBatchData("KB-001");
});
expect(sharedData).toBeDefined();
expect(sharedData?.result.prInfo?.title).toBe("Shared PR");
});
});

View File

@@ -0,0 +1,173 @@
import { useCallback, useRef, useState } from "react";
import { fetchBatchStatus } from "../api";
import type { BatchStatusResult } from "@kb/core";
// Module-level store to share batch data across hook instances
const batchBadgeStore = {
data: new Map<string, { result: BatchStatusResult[string]; timestamp: number }>(),
pendingPromise: null as Promise<BatchStatusResult> | null,
lastFetchTime: null as number | null,
};
/** Maximum age of cached batch data in milliseconds (5 seconds) */
const CACHE_MAX_AGE_MS = 5000;
/**
* Check if fresh batch data exists for a task ID.
* @param taskId - The task ID to check
* @returns The cached data if fresh, undefined otherwise
*/
export function getFreshBatchData(taskId: string): { result: BatchStatusResult[string]; timestamp: number } | undefined {
const cached = batchBadgeStore.data.get(taskId);
if (!cached) return undefined;
const now = Date.now();
if (now - cached.timestamp > CACHE_MAX_AGE_MS) {
return undefined;
}
return cached;
}
interface UseBatchBadgeFetchResult {
/** Manually trigger a batch fetch for the given task IDs */
fetchBatch: (taskIds: string[]) => Promise<void>;
/** Whether a batch fetch is currently in progress */
isLoading: boolean;
/** Timestamp of the last successful fetch (shared across all hook instances) */
lastFetchTime: number | null;
/** Get cached batch data for a specific task ID */
getBatchData: (taskId: string) => { result: BatchStatusResult[string]; timestamp: number } | undefined;
}
/**
* Hook for batch fetching GitHub badge statuses.
*
* Features:
* - Request deduplication: concurrent calls with the same IDs wait for the same promise
* - 5-second debounce: rapid calls within 5 seconds reuse cached results
* - Exponential backoff retry: handles 429 rate limit errors with up to 3 retries
* - Shared store: data is available across all hook instances
*/
export function useBatchBadgeFetch(): UseBatchBadgeFetchResult {
const [isLoading, setIsLoading] = useState(false);
const fetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/**
* Internal fetch with retry logic.
*/
const fetchWithRetry = useCallback(async (taskIds: string[]): Promise<BatchStatusResult> => {
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const results = await fetchBatchStatus(taskIds);
return results;
} catch (err: any) {
lastError = err instanceof Error ? err : new Error(String(err));
// If it's a 429 rate limit error, wait before retrying with exponential backoff
if (err?.message?.includes("429") || err?.message?.toLowerCase().includes("rate limit")) {
const delayMs = Math.min(1000 * Math.pow(2, attempt), 30000); // Max 30s delay
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
// For other errors, don't retry - just break and let the partial results be used
break;
}
}
// If we exhausted retries or hit a non-retryable error, throw the last error
if (lastError) {
throw lastError;
}
return {};
}, []);
/**
* Fetch batch badge statuses for the given task IDs.
* Implements deduplication, debouncing, and retry logic.
*/
const fetchBatch = useCallback(async (taskIds: string[]): Promise<void> => {
if (taskIds.length === 0) return;
// Check if we have recent cached data (within 5 seconds) for all requested IDs
const now = Date.now();
const fiveSecondsAgo = now - 5000;
const hasFreshCache = taskIds.every((id) => {
const cached = batchBadgeStore.data.get(id);
return cached && cached.timestamp > fiveSecondsAgo;
});
if (hasFreshCache && batchBadgeStore.lastFetchTime && batchBadgeStore.lastFetchTime > fiveSecondsAgo) {
// All data is fresh, no need to fetch
return;
}
// Clear any pending debounced fetch
if (fetchTimeoutRef.current) {
clearTimeout(fetchTimeoutRef.current);
fetchTimeoutRef.current = null;
}
// Check if there's already a pending fetch we can reuse
if (batchBadgeStore.pendingPromise) {
setIsLoading(true);
try {
await batchBadgeStore.pendingPromise;
} finally {
setIsLoading(false);
}
return;
}
setIsLoading(true);
// Create the promise and store it for deduplication
const promise = fetchWithRetry(taskIds);
batchBadgeStore.pendingPromise = promise;
try {
const results = await promise;
// Update the store with new data
const timestamp = Date.now();
for (const [taskId, result] of Object.entries(results)) {
batchBadgeStore.data.set(taskId, { result, timestamp });
}
batchBadgeStore.lastFetchTime = timestamp;
} catch (err) {
// Even on error, we don't throw - the hook handles errors gracefully
// and partial results are still stored
} finally {
batchBadgeStore.pendingPromise = null;
setIsLoading(false);
}
}, [fetchWithRetry]);
/**
* Get cached batch data for a specific task ID.
*/
const getBatchData = useCallback((taskId: string) => {
return batchBadgeStore.data.get(taskId);
}, []);
return {
fetchBatch,
isLoading,
lastFetchTime: batchBadgeStore.lastFetchTime,
getBatchData,
};
}
/**
* Reset the batch badge store (useful for testing).
*/
export function __resetBatchBadgeStoreForTests(): void {
batchBadgeStore.data.clear();
batchBadgeStore.pendingPromise = null;
batchBadgeStore.lastFetchTime = null;
}

View File

@@ -35,6 +35,10 @@ const mockRunGhJson = vi.mocked(runGhJson);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const mockGetCurrentRepo = vi.mocked(getCurrentRepo);
function createGraphQlBatchPayload(repository: Record<string, unknown>) {
return JSON.stringify({ data: { repository } });
}
describe("GitHubClient", () => {
let client: GitHubClient;
@@ -47,6 +51,7 @@ describe("GitHubClient", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -402,6 +407,230 @@ describe("GitHubClient", () => {
});
});
describe("getBatchIssueStatus", () => {
it("uses the REST issues list endpoint for recent requested issues", async () => {
mockRunGhJsonAsync.mockResolvedValue([
{
number: 250,
html_url: "https://github.com/owner/repo/issues/250",
title: "Issue 250",
state: "open",
state_reason: null,
},
{
number: 120,
html_url: "https://github.com/owner/repo/issues/120",
title: "Issue 120",
state: "closed",
state_reason: "completed",
},
]);
const result = await client.getBatchIssueStatus("owner", "repo", [250, 120]);
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"api",
"repos/owner/repo/issues?state=all&per_page=100",
]);
expect(mockRunGhAsync).not.toHaveBeenCalled();
expect(result.get(250)).toMatchObject({ number: 250, state: "open" });
expect(result.get(120)).toMatchObject({ number: 120, state: "closed", stateReason: "completed" });
});
it("falls back for requested issues missing from the REST list response", async () => {
mockRunGhJsonAsync.mockResolvedValue([
{
number: 250,
html_url: "https://github.com/owner/repo/issues/250",
title: "Issue 250",
state: "open",
state_reason: null,
},
]);
mockRunGhAsync.mockResolvedValue(
createGraphQlBatchPayload({
issue_120: {
number: 120,
url: "https://github.com/owner/repo/issues/120",
title: "Issue 120",
state: "CLOSED",
stateReason: "COMPLETED",
},
issue_100: null,
}),
);
const result = await client.getBatchIssueStatus("owner", "repo", [250, 120, 100]);
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(1);
expect(mockRunGhAsync).toHaveBeenCalledTimes(1);
expect(result.get(250)).toMatchObject({ number: 250, state: "open" });
expect(result.get(120)).toMatchObject({ number: 120, state: "closed", stateReason: "completed" });
expect(result.has(100)).toBe(false);
expect(result.size).toBe(2);
});
it("returns early for empty input", async () => {
const result = await client.getBatchIssueStatus("owner", "repo", []);
expect(result.size).toBe(0);
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
expect(mockRunGhAsync).not.toHaveBeenCalled();
});
it("retries transient REST failures with a 5 second backoff", async () => {
vi.useFakeTimers();
mockRunGhJsonAsync
.mockRejectedValueOnce(new Error("secondary rate limit"))
.mockRejectedValueOnce(new Error("502 Bad Gateway"))
.mockResolvedValueOnce([
{
number: 5,
html_url: "https://github.com/owner/repo/issues/5",
title: "Issue 5",
state: "open",
state_reason: null,
},
]);
const promise = client.getBatchIssueStatus("owner", "repo", [5]);
await vi.advanceTimersByTimeAsync(10_000);
const result = await promise;
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(3);
expect(result.get(5)?.number).toBe(5);
});
it("stops retrying the REST batch call after 3 attempts", async () => {
vi.useFakeTimers();
mockRunGhJsonAsync.mockRejectedValue(new Error("secondary rate limit"));
const exhaustedPromise = client.getBatchIssueStatus("owner", "repo", [6]);
const rejection = expect(exhaustedPromise).rejects.toThrow("secondary rate limit");
await vi.advanceTimersByTimeAsync(10_000);
await rejection;
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(3);
});
});
describe("getBatchPrStatus", () => {
it("uses the REST pulls list endpoint and maps merged PRs correctly", async () => {
mockRunGhJsonAsync.mockResolvedValue([
{
number: 150,
html_url: "https://github.com/owner/repo/pull/150",
title: "PR 150",
state: "closed",
merged_at: "2026-03-30T12:00:00Z",
head: { ref: "feature/150" },
base: { ref: "main" },
comments: 2,
updated_at: "2026-03-30T11:00:00Z",
},
{
number: 147,
html_url: "https://github.com/owner/repo/pull/147",
title: "PR 147",
state: "closed",
merged_at: null,
head: { ref: "feature/147" },
base: { ref: "main" },
comments: 1,
updated_at: "2026-03-30T11:00:00Z",
},
]);
const result = await client.getBatchPrStatus("owner", "repo", [150, 147]);
expect(mockRunGhJsonAsync).toHaveBeenCalledWith([
"api",
"repos/owner/repo/pulls?state=all&per_page=100",
]);
expect(mockRunGhAsync).not.toHaveBeenCalled();
expect(result.get(150)?.status).toBe("merged");
expect(result.get(147)?.status).toBe("closed");
});
it("chunks fallback exact lookups when more than 100 requested PRs are missing from the REST list", async () => {
mockRunGhJsonAsync.mockResolvedValue([]);
mockRunGhAsync
.mockResolvedValueOnce(
createGraphQlBatchPayload(
Object.fromEntries(
Array.from({ length: 100 }, (_, index) => {
const number = 150 - index;
return [`pr_${number}`, {
number,
url: `https://github.com/owner/repo/pull/${number}`,
title: `PR ${number}`,
state: number === 150 ? "MERGED" : number === 147 ? "CLOSED" : "OPEN",
baseRefName: "main",
headRefName: `feature/${number}`,
comments: { totalCount: number % 4, nodes: [{ updatedAt: "2026-03-30T11:00:00Z" }] },
}];
}),
),
),
)
.mockResolvedValueOnce(
createGraphQlBatchPayload({
pr_50: {
number: 50,
url: "https://github.com/owner/repo/pull/50",
title: "PR 50",
state: "OPEN",
baseRefName: "main",
headRefName: "feature/50",
comments: { totalCount: 2, nodes: [{ updatedAt: "2026-03-30T11:00:00Z" }] },
},
}),
);
const requestedNumbers = Array.from({ length: 101 }, (_, index) => 150 - index);
const result = await client.getBatchPrStatus("owner", "repo", requestedNumbers);
expect(mockRunGhJsonAsync).toHaveBeenCalledTimes(1);
expect(mockRunGhAsync).toHaveBeenCalledTimes(2);
expect(result.size).toBe(101);
expect(result.get(150)?.status).toBe("merged");
expect(result.get(149)?.status).toBe("open");
expect(result.get(147)?.status).toBe("closed");
});
it("falls back to REST auth when gh REST batch fetch fails and a token is available", async () => {
mockRunGhJsonAsync.mockRejectedValueOnce(new Error("gh failed"));
const clientWithToken = new GitHubClient("ghp_token");
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: () => Promise.resolve([
{
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "PR 42",
state: "open",
merged_at: null,
head: { ref: "feature/42" },
base: { ref: "main" },
comments: 1,
updated_at: "2026-03-30T11:00:00Z",
},
]),
});
global.fetch = mockFetch as any;
const result = await clientWithToken.getBatchPrStatus("owner", "repo", [42]);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/pulls?state=all&per_page=100",
expect.objectContaining({ headers: expect.any(Object) }),
);
expect(result.get(42)?.number).toBe(42);
});
});
describe("listIssues", () => {
const mockIssues = [
{

View File

@@ -152,6 +152,27 @@ interface GhIssueViewJson {
stateReason?: "completed" | "not_planned" | "reopened";
}
interface RestIssueListItem {
number: number;
html_url: string;
title: string;
state: string;
state_reason?: "completed" | "not_planned" | "reopened";
pull_request?: unknown;
}
interface RestPrListItem {
number: number;
html_url: string;
title: string;
state: string;
merged_at?: string | null;
head: { ref: string };
base: { ref: string };
comments: number;
updated_at?: string;
}
interface GraphQlBatchPullRequest {
number: number;
url: string;
@@ -180,6 +201,10 @@ interface GraphQlBatchPayload {
errors?: Array<{ message: string }>;
}
const MAX_BADGE_BATCH_SIZE = 100;
const BATCH_RETRY_DELAY_MS = 5_000;
const MAX_BATCH_RETRIES = 3;
function normalizeCheckState(state: string | null | undefined): PrCheckState {
switch ((state ?? "").toLowerCase()) {
case "success":
@@ -997,6 +1022,219 @@ export class GitHubClient {
};
}
async getBatchIssueStatus(
owner: string,
repo: string,
issueNumbers: number[],
): Promise<Map<number, IssueInfo>> {
const requestedNumbers = uniqueBatchNumbers(issueNumbers);
if (requestedNumbers.length === 0) {
return new Map();
}
const issues = await retryBatchRequest(() => this.getRecentIssueStatuses(owner, repo, requestedNumbers));
const missingNumbers = requestedNumbers.filter((number) => !issues.has(number));
if (missingNumbers.length === 0) {
return issues;
}
// Fall back to the exact-number badge query only for resources that were not
// present in the recent REST listing, keeping the common path REST-based while
// still bounding request count for older sparse issue numbers.
const fallbackRequests = missingNumbers.map((number) => ({
alias: `issue_${number}`,
type: "issue" as const,
number,
}));
const fallbackResources = await this.getBadgeStatusesBatchWithRetry(owner, repo, fallbackRequests);
for (const request of fallbackRequests) {
const resource = fallbackResources[request.alias];
if (!resource || resource.type !== "issue") continue;
issues.set(request.number, resource.issueInfo);
}
return issues;
}
async getBatchPrStatus(
owner: string,
repo: string,
prNumbers: number[],
): Promise<Map<number, PrInfo>> {
const requestedNumbers = uniqueBatchNumbers(prNumbers);
if (requestedNumbers.length === 0) {
return new Map();
}
const prs = await retryBatchRequest(() => this.getRecentPrStatuses(owner, repo, requestedNumbers));
const missingNumbers = requestedNumbers.filter((number) => !prs.has(number));
if (missingNumbers.length === 0) {
return prs;
}
// Use the exact-number fallback only for PRs omitted from the recent REST page
// so older items do not force paginated list scans or N single-resource calls.
const fallbackRequests = missingNumbers.map((number) => ({
alias: `pr_${number}`,
type: "pr" as const,
number,
}));
const fallbackResources = await this.getBadgeStatusesBatchWithRetry(owner, repo, fallbackRequests);
for (const request of fallbackRequests) {
const resource = fallbackResources[request.alias];
if (!resource || resource.type !== "pr") continue;
prs.set(request.number, resource.prInfo);
}
return prs;
}
private async getRecentIssueStatuses(
owner: string,
repo: string,
requestedNumbers: number[],
): Promise<Map<number, IssueInfo>> {
const requestedSet = new Set(requestedNumbers);
const issues = new Map<number, IssueInfo>();
const items = await this.listRecentIssueStatusPage(owner, repo);
for (const issue of items) {
if (!requestedSet.has(issue.number) || issue.pull_request) continue;
issues.set(issue.number, {
url: issue.html_url,
number: issue.number,
state: this.mapIssueState(issue.state),
title: issue.title,
stateReason: issue.state_reason,
});
}
return issues;
}
private async listRecentIssueStatusPage(
owner: string,
repo: string,
): Promise<RestIssueListItem[]> {
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=all&per_page=${MAX_BADGE_BATCH_SIZE}`;
if (this.hasGhAuth()) {
try {
return await runGhJsonAsync<RestIssueListItem[]>(["api", path]);
} catch (err) {
if (this.token) {
return this.listRecentIssueStatusPageWithApi(owner, repo);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.listRecentIssueStatusPageWithApi(owner, repo);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async listRecentIssueStatusPageWithApi(
owner: string,
repo: string,
): Promise<RestIssueListItem[]> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=all&per_page=${MAX_BADGE_BATCH_SIZE}`;
const response = await fetch(url, { headers: this.buildHeaders() });
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
return response.json() as Promise<RestIssueListItem[]>;
}
private async getRecentPrStatuses(
owner: string,
repo: string,
requestedNumbers: number[],
): Promise<Map<number, PrInfo>> {
const requestedSet = new Set(requestedNumbers);
const prs = new Map<number, PrInfo>();
const items = await this.listRecentPrStatusPage(owner, repo);
for (const pr of items) {
if (!requestedSet.has(pr.number)) continue;
prs.set(pr.number, {
url: pr.html_url,
number: pr.number,
status: pr.merged_at ? "merged" : this.mapPrState(pr.state),
title: pr.title,
headBranch: pr.head.ref,
baseBranch: pr.base.ref,
commentCount: pr.comments,
lastCommentAt: pr.updated_at,
});
}
return prs;
}
private async listRecentPrStatusPage(
owner: string,
repo: string,
): Promise<RestPrListItem[]> {
const path = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=all&per_page=${MAX_BADGE_BATCH_SIZE}`;
if (this.hasGhAuth()) {
try {
return await runGhJsonAsync<RestPrListItem[]>(["api", path]);
} catch (err) {
if (this.token) {
return this.listRecentPrStatusPageWithApi(owner, repo);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.listRecentPrStatusPageWithApi(owner, repo);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async listRecentPrStatusPageWithApi(
owner: string,
repo: string,
): Promise<RestPrListItem[]> {
const url = `${this.baseUrl}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=all&per_page=${MAX_BADGE_BATCH_SIZE}`;
const response = await fetch(url, { headers: this.buildHeaders() });
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`GitHub API error: ${response.status} ${error.message || response.statusText}`);
}
return response.json() as Promise<RestPrListItem[]>;
}
private async getBadgeStatusesBatchWithRetry(
owner: string,
repo: string,
requests: BadgeBatchRequest[],
): Promise<BadgeBatchResponse> {
const response: BadgeBatchResponse = {};
for (const chunk of chunkBadgeRequests(requests, MAX_BADGE_BATCH_SIZE)) {
const chunkResponse = await retryBatchRequest(() => this.getBadgeStatusesBatch(owner, repo, chunk));
Object.assign(response, chunkResponse);
}
return response;
}
async getBadgeStatusesBatch(
owner: string,
repo: string,
@@ -1641,6 +1879,48 @@ export class GitHubClient {
}
}
function uniqueBatchNumbers(numbers: number[]): number[] {
return [...new Set(numbers.filter((number) => Number.isInteger(number) && number > 0))];
}
function chunkBadgeRequests(requests: BadgeBatchRequest[], size: number): BadgeBatchRequest[][] {
if (requests.length === 0) return [];
const chunks: BadgeBatchRequest[][] = [];
for (let index = 0; index < requests.length; index += size) {
chunks.push(requests.slice(index, index + size));
}
return chunks;
}
async function retryBatchRequest<T>(operation: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_BATCH_RETRIES; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt >= MAX_BATCH_RETRIES || !shouldRetryBatchRequestError(error)) {
throw error;
}
await delay(BATCH_RETRY_DELAY_MS);
}
}
throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "Batch request failed"));
}
function shouldRetryBatchRequestError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /rate limit|secondary rate limit|timed out|timeout|fetch failed|econnreset|econnrefused|socket hang up|502|503|504/i.test(message);
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function buildBadgeBatchQuery(requests: BadgeBatchRequest[]): string {
const selections = requests
.map((request) => {

View File

@@ -3,6 +3,7 @@ import express from "express";
import http from "node:http";
import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import type { TaskStore, TaskAttachment } from "@kb/core";
import type { TaskDetail } from "@kb/core";
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
@@ -38,6 +39,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
...overrides,
} as unknown as TaskStore;
}
@@ -1849,6 +1853,226 @@ describe("Pause/Unpause endpoints", () => {
expect(res.body.error).toContain("no associated issue");
});
});
describe("POST /github/batch/status", () => {
let store: TaskStore;
beforeEach(() => {
store = createMockStore({
getTask: vi.fn(),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns status for multiple tasks in one request", async () => {
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "KB-001",
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
issueInfo: {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "open" as const,
title: "Issue 101",
},
})
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "KB-002",
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
prInfo: {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "PR 42",
headBranch: "feature/42",
baseBranch: "main",
commentCount: 0,
},
});
vi.spyOn(GitHubClient.prototype, "getBatchIssueStatus").mockResolvedValue(new Map([
[101, {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "closed",
title: "Issue 101",
stateReason: "completed",
}],
]));
vi.spyOn(GitHubClient.prototype, "getBatchPrStatus").mockResolvedValue(new Map([
[42, {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "merged",
title: "PR 42",
headBranch: "feature/42",
baseBranch: "main",
commentCount: 3,
}],
]));
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/batch/status",
JSON.stringify({ taskIds: ["KB-001", "KB-002"] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.results["KB-001"].issueInfo.state).toBe("closed");
expect(res.body.results["KB-001"].stale).toBe(false);
expect(res.body.results["KB-002"].prInfo.status).toBe("merged");
expect(res.body.results["KB-002"].stale).toBe(false);
expect(store.updateIssueInfo).toHaveBeenCalledWith(
"KB-001",
expect.objectContaining({ number: 101, state: "closed", lastCheckedAt: expect.any(String) }),
);
expect(store.updatePrInfo).toHaveBeenCalledWith(
"KB-002",
expect.objectContaining({ number: 42, status: "merged", lastCheckedAt: expect.any(String) }),
);
});
it("handles partial failures without dropping successful results", async () => {
(store.getTask as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "KB-001",
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
issueInfo: {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "open" as const,
title: "Issue 101",
},
})
.mockResolvedValueOnce({
...FAKE_TASK_DETAIL,
id: "KB-002",
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
issueInfo: {
url: "https://github.com/owner/repo/issues/404",
number: 404,
state: "open" as const,
title: "Issue 404",
},
});
vi.spyOn(GitHubClient.prototype, "getBatchIssueStatus").mockResolvedValue(new Map([
[101, {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "closed",
title: "Issue 101",
stateReason: "completed",
}],
]));
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/batch/status",
JSON.stringify({ taskIds: ["KB-001", "KB-002"] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.results["KB-001"].issueInfo.state).toBe("closed");
expect(res.body.results["KB-002"].error).toContain("Issue #404 not found");
expect(res.body.results["KB-002"].stale).toBe(true);
});
it("returns 429 when rate limit is exceeded", async () => {
const originalRepo = process.env.GITHUB_REPOSITORY;
process.env.GITHUB_REPOSITORY = "owner/repo";
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
issueInfo: {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "open" as const,
title: "Issue 101",
},
});
const canMakeRequestSpy = vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(false);
const getResetTimeSpy = vi.spyOn(githubRateLimiter, "getResetTime").mockReturnValue(new Date("2026-03-30T12:05:00.000Z"));
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/batch/status",
JSON.stringify({ taskIds: ["KB-001"] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(429);
expect(res.body.error).toContain("rate limit exceeded");
expect(res.body.resetAt).toBe("2026-03-30T12:05:00.000Z");
canMakeRequestSpy.mockRestore();
getResetTimeSpy.mockRestore();
if (originalRepo) {
process.env.GITHUB_REPOSITORY = originalRepo;
} else {
delete process.env.GITHUB_REPOSITORY;
}
});
it("calculates stale per task based on refresh success and existing cached data", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
...FAKE_TASK_DETAIL,
id: "KB-001",
updatedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
issueInfo: {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "open" as const,
title: "Issue 101",
lastCheckedAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
},
});
vi.spyOn(GitHubClient.prototype, "getBatchIssueStatus").mockResolvedValue(new Map());
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/batch/status",
JSON.stringify({ taskIds: ["KB-001"] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.results["KB-001"].stale).toBe(true);
expect(res.body.results["KB-001"].error).toContain("Issue #101 not found");
});
it("returns empty results for empty taskIds", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/github/batch/status",
JSON.stringify({ taskIds: [] }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ results: {} });
expect(store.getTask).not.toHaveBeenCalled();
});
});
});
// --- GitHub Import route tests ---

View File

@@ -3,7 +3,7 @@ import multer from "multer";
import { createReadStream } from "node:fs";
import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -104,6 +104,24 @@ function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
return null;
}
function parseGitHubBadgeUrl(url: string | undefined): { owner: string; repo: string } | null {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const parts = parsed.pathname.split("/").filter(Boolean);
if (parts.length < 4) return null;
const [owner, repo, resourceType] = parts;
if ((resourceType !== "issues" && resourceType !== "pull") || !owner || !repo) {
return null;
}
return { owner, repo };
} catch {
return null;
}
}
/**
* Get GitHub remotes from the current git repository.
* Executes `git remote -v` and parses the output.
@@ -2147,6 +2165,203 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/github/batch/status
* Refresh issue/PR badge status for up to 100 tasks in grouped GitHub requests.
* Body: { taskIds: string[] }
*/
router.post("/github/batch/status", async (req, res) => {
try {
const { taskIds } = (req.body ?? {}) as import("@kb/core").BatchStatusRequest;
if (!Array.isArray(taskIds)) {
res.status(400).json({ error: "taskIds must be an array" });
return;
}
if (taskIds.some((taskId) => typeof taskId !== "string" || taskId.trim().length === 0)) {
res.status(400).json({ error: "taskIds must contain non-empty strings" });
return;
}
if (taskIds.length > 100) {
res.status(400).json({ error: "taskIds must contain at most 100 items" });
return;
}
if (taskIds.length === 0) {
res.json({ results: {} } satisfies BatchStatusResponse);
return;
}
const fallbackRepo = getDefaultGitHubRepo(store);
const results: BatchStatusResult = {};
const issueGroups = new Map<string, { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }>();
const prGroups = new Map<string, { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }>();
const tasksById = new Map<string, Awaited<ReturnType<TaskStore["getTask"]>>>();
for (const taskId of taskIds) {
try {
const task = await store.getTask(taskId);
tasksById.set(taskId, task);
const entry = ensureBatchStatusEntry(results, taskId);
if (task.issueInfo) entry.issueInfo = task.issueInfo;
if (task.prInfo) entry.prInfo = task.prInfo;
entry.stale = Boolean(
(task.issueInfo && isBatchStatusStale(task.issueInfo, task.updatedAt))
|| (task.prInfo && isBatchStatusStale(task.prInfo, task.updatedAt)),
);
if (!task.issueInfo && !task.prInfo) {
appendBatchStatusError(results, taskId, "Task has no GitHub badge metadata");
continue;
}
if (task.issueInfo) {
const issueRepo = parseGitHubBadgeUrl(task.issueInfo.url) ?? fallbackRepo;
if (!issueRepo) {
appendBatchStatusError(results, taskId, "Could not determine GitHub repository for issue badge");
} else {
const repoKey = `${issueRepo.owner}/${issueRepo.repo}`;
const group = issueGroups.get(repoKey) ?? {
owner: issueRepo.owner,
repo: issueRepo.repo,
numbers: new Set<number>(),
taskIds: new Set<string>(),
};
group.numbers.add(task.issueInfo.number);
group.taskIds.add(taskId);
issueGroups.set(repoKey, group);
}
}
if (task.prInfo) {
const prRepo = parseGitHubBadgeUrl(task.prInfo.url) ?? fallbackRepo;
if (!prRepo) {
appendBatchStatusError(results, taskId, "Could not determine GitHub repository for PR badge");
} else {
const repoKey = `${prRepo.owner}/${prRepo.repo}`;
const group = prGroups.get(repoKey) ?? {
owner: prRepo.owner,
repo: prRepo.repo,
numbers: new Set<number>(),
taskIds: new Set<string>(),
};
group.numbers.add(task.prInfo.number);
group.taskIds.add(taskId);
prGroups.set(repoKey, group);
}
}
} catch (err: any) {
if (err?.code === "ENOENT") {
appendBatchStatusError(results, taskId, `Task ${taskId} not found`);
} else {
appendBatchStatusError(results, taskId, err.message || `Failed to load task ${taskId}`);
}
}
}
const client = new GitHubClient(githubToken);
const applyIssueGroup = async (group: { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }) => {
const repoKey = `${group.owner}/${group.repo}`;
if (!githubRateLimiter.canMakeRequest(repoKey)) {
const resetTime = githubRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return false;
}
try {
const issueStatuses = await client.getBatchIssueStatus(group.owner, group.repo, [...group.numbers]);
const refreshedAt = new Date().toISOString();
for (const taskId of group.taskIds) {
const task = tasksById.get(taskId);
if (!task?.issueInfo) continue;
const issueInfo = issueStatuses.get(task.issueInfo.number);
if (!issueInfo) {
appendBatchStatusError(results, taskId, `Issue #${task.issueInfo.number} not found in ${group.owner}/${group.repo}`);
continue;
}
const updatedIssueInfo: IssueInfo = {
...issueInfo,
lastCheckedAt: refreshedAt,
};
await store.updateIssueInfo(taskId, updatedIssueInfo);
const entry = ensureBatchStatusEntry(results, taskId);
entry.issueInfo = updatedIssueInfo;
entry.stale = entry.prInfo ? isBatchStatusStale(entry.prInfo, task.updatedAt) : false;
}
} catch (err: any) {
for (const taskId of group.taskIds) {
appendBatchStatusError(results, taskId, err.message || `Failed to refresh issue badges for ${repoKey}`);
}
}
return true;
};
const applyPrGroup = async (group: { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }) => {
const repoKey = `${group.owner}/${group.repo}`;
if (!githubRateLimiter.canMakeRequest(repoKey)) {
const resetTime = githubRateLimiter.getResetTime(repoKey);
res.status(429).json({
error: "GitHub API rate limit exceeded for this repository",
resetAt: resetTime?.toISOString(),
});
return false;
}
try {
const prStatuses = await client.getBatchPrStatus(group.owner, group.repo, [...group.numbers]);
const refreshedAt = new Date().toISOString();
for (const taskId of group.taskIds) {
const task = tasksById.get(taskId);
if (!task?.prInfo) continue;
const prInfo = prStatuses.get(task.prInfo.number);
if (!prInfo) {
appendBatchStatusError(results, taskId, `PR #${task.prInfo.number} not found in ${group.owner}/${group.repo}`);
continue;
}
const updatedPrInfo: PrInfo = {
...prInfo,
lastCheckedAt: refreshedAt,
};
await store.updatePrInfo(taskId, updatedPrInfo);
const entry = ensureBatchStatusEntry(results, taskId);
entry.prInfo = updatedPrInfo;
entry.stale = entry.issueInfo ? isBatchStatusStale(entry.issueInfo, task.updatedAt) : false;
}
} catch (err: any) {
for (const taskId of group.taskIds) {
appendBatchStatusError(results, taskId, err.message || `Failed to refresh PR badges for ${repoKey}`);
}
}
return true;
};
for (const group of issueGroups.values()) {
const shouldContinue = await applyIssueGroup(group);
if (!shouldContinue) return;
}
for (const group of prGroups.values()) {
const shouldContinue = await applyPrGroup(group);
if (!shouldContinue) return;
}
for (const taskId of taskIds) {
ensureBatchStatusEntry(results, taskId);
}
res.json({ results } satisfies BatchStatusResponse);
} catch (err: any) {
res.status(500).json({ error: err.message || "Failed to batch refresh GitHub status" });
}
});
// ── Terminal Routes ─────────────────────────────────────────────────
/**
@@ -2984,6 +3199,36 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return router;
}
function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null {
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [owner, repo] = envRepo.split("/");
if (owner && repo) {
return { owner, repo };
}
}
const rootDir = typeof store.getRootDir === "function" ? store.getRootDir() : process.cwd();
return getCurrentGitHubRepo(rootDir);
}
function isBatchStatusStale(info: { lastCheckedAt?: string } | undefined, updatedAt?: string): boolean {
const lastChecked = info?.lastCheckedAt ?? updatedAt;
if (!lastChecked) return true;
return Date.now() - new Date(lastChecked).getTime() > 5 * 60 * 1000;
}
function ensureBatchStatusEntry(results: BatchStatusResult, taskId: string): BatchStatusEntry {
results[taskId] ??= { stale: true };
return results[taskId];
}
function appendBatchStatusError(results: BatchStatusResult, taskId: string, message: string): void {
const entry = ensureBatchStatusEntry(results, taskId);
entry.error = entry.error ? `${entry.error}; ${message}` : message;
entry.stale = true;
}
/**
* Background PR refresh - updates PR status without blocking the response.
* Silently logs errors without affecting the user experience.