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:
@@ -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