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

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

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

- Create useBatchBadgeFetch hook for coordinated dashboard fetching

- Update Board component to batch fetch badge statuses on mount

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

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

View File

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

View File

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

View File

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

View File

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