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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
173
packages/dashboard/app/hooks/useBatchBadgeFetch.ts
Normal file
173
packages/dashboard/app/hooks/useBatchBadgeFetch.ts
Normal 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;
|
||||
}
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user