feat(KB-063): add realtime GitHub badge updates

- Add a focused GitHub badge poller with shared rate-limit, freshness, and cleanup handling
- Wire a dedicated badge websocket server and routes to stream PR and issue badge snapshots
- Add a shared useBadgeWebSocket hook and update TaskCard to subscribe only when visible while preserving partial badge state
- Cover polling, websocket, and task card flows with tests and document the realtime badge channel
This commit is contained in:
gsxdsm
2026-03-30 03:19:51 -07:00
parent 93e0c12549
commit 4e23716b7d
21 changed files with 3176 additions and 199 deletions

View File

@@ -1,7 +1,8 @@
import type { PrInfo } from "@kb/core";
import type { IssueInfo, PrInfo } from "@kb/core";
import {
isGhAvailable,
isGhAuthenticated,
runGhAsync,
runGhJsonAsync,
getGhErrorMessage,
getCurrentRepo,
@@ -67,6 +68,19 @@ export interface MergePrParams {
method?: "merge" | "squash" | "rebase";
}
export interface BadgeBatchRequest {
alias: string;
type: "pr" | "issue";
number: number;
}
export type BadgeBatchResponse = Record<
string,
| { type: "pr"; prInfo: Omit<PrInfo, "lastCheckedAt"> }
| { type: "issue"; issueInfo: Omit<IssueInfo, "lastCheckedAt"> }
| null
>;
// gh CLI JSON output types
interface GhPrViewJson {
id?: string;
@@ -111,6 +125,34 @@ interface GhIssueViewJson {
stateReason?: "completed" | "not_planned" | "reopened";
}
interface GraphQlBatchPullRequest {
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED" | "MERGED";
baseRefName: string;
headRefName: string;
comments: {
totalCount: number;
nodes: Array<{ updatedAt: string } | null>;
};
}
interface GraphQlBatchIssue {
number: number;
url: string;
title: string;
state: "OPEN" | "CLOSED";
stateReason?: "COMPLETED" | "NOT_PLANNED" | "REOPENED" | null;
}
interface GraphQlBatchPayload {
data?: {
repository?: Record<string, GraphQlBatchPullRequest | GraphQlBatchIssue | null>;
};
errors?: Array<{ message: string }>;
}
function normalizeCheckState(state: string | null | undefined): PrCheckState {
switch ((state ?? "").toLowerCase()) {
case "success":
@@ -927,6 +969,84 @@ export class GitHubClient {
};
}
async getBadgeStatusesBatch(
owner: string,
repo: string,
requests: BadgeBatchRequest[],
): Promise<BadgeBatchResponse> {
if (requests.length === 0) {
return {};
}
if (this.hasGhAuth()) {
try {
return await this.getBadgeStatusesBatchWithGh(owner, repo, requests);
} catch (err) {
if (this.token) {
return this.getBadgeStatusesBatchWithApi(owner, repo, requests);
}
throw new Error(getGhErrorMessage(err));
}
}
if (this.token) {
return this.getBadgeStatusesBatchWithApi(owner, repo, requests);
}
throw new Error("GitHub CLI (gh) is not available or not authenticated, and no GITHUB_TOKEN provided.");
}
private async getBadgeStatusesBatchWithGh(
owner: string,
repo: string,
requests: BadgeBatchRequest[],
): Promise<BadgeBatchResponse> {
const query = buildBadgeBatchQuery(requests);
const output = await runGhAsync([
"api",
"graphql",
"-f",
`query=${query}`,
"-F",
`owner=${owner}`,
"-F",
`repo=${repo}`,
]);
const payload = JSON.parse(output) as GraphQlBatchPayload;
if (payload.errors?.length) {
throw new Error(payload.errors[0].message);
}
return normalizeBadgeBatchPayload(payload.data?.repository, requests);
}
private async getBadgeStatusesBatchWithApi(
owner: string,
repo: string,
requests: BadgeBatchRequest[],
): Promise<BadgeBatchResponse> {
const response = await fetch(`${this.baseUrl}/graphql`, {
method: "POST",
headers: {
...this.buildHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({
query: buildBadgeBatchQuery(requests),
variables: { owner, repo },
}),
});
const payload = (await response.json()) as GraphQlBatchPayload;
if (!response.ok || payload.errors?.length) {
const message = payload.errors?.[0]?.message || response.statusText;
throw new Error(`GitHub API error: ${response.status} ${message}`);
}
return normalizeBadgeBatchPayload(payload.data?.repository, requests);
}
private buildHeaders(): Record<string, string> {
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
@@ -1221,6 +1341,131 @@ export class GitHubClient {
}
}
function buildBadgeBatchQuery(requests: BadgeBatchRequest[]): string {
const selections = requests
.map((request) => {
if (request.type === "pr") {
return `${request.alias}: pullRequest(number: ${request.number}) {
number
url
title
state
baseRefName
headRefName
comments(last: 1) {
totalCount
nodes {
updatedAt
}
}
}`;
}
return `${request.alias}: issue(number: ${request.number}) {
number
url
title
state
stateReason
}`;
})
.join("\n");
return `query RepoBadgeStatuses($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
${selections}
}
}`;
}
function normalizeBadgeBatchPayload(
repository: Record<string, GraphQlBatchPullRequest | GraphQlBatchIssue | null> | undefined,
requests: BadgeBatchRequest[],
): BadgeBatchResponse {
const response: BadgeBatchResponse = {};
for (const request of requests) {
const resource = repository?.[request.alias];
if (!resource) {
response[request.alias] = null;
continue;
}
if (request.type === "pr") {
if (!isGraphQlBatchPullRequest(resource)) {
response[request.alias] = null;
continue;
}
response[request.alias] = {
type: "pr",
prInfo: {
url: resource.url,
number: resource.number,
status: mapGraphQlBatchPrState(resource.state),
title: resource.title,
headBranch: resource.headRefName,
baseBranch: resource.baseRefName,
commentCount: resource.comments.totalCount,
lastCommentAt: resource.comments.nodes.find(Boolean)?.updatedAt,
},
};
continue;
}
if (isGraphQlBatchPullRequest(resource)) {
response[request.alias] = null;
continue;
}
response[request.alias] = {
type: "issue",
issueInfo: {
url: resource.url,
number: resource.number,
state: resource.state === "OPEN" ? "open" : "closed",
title: resource.title,
stateReason: mapGraphQlBatchIssueStateReason(resource.stateReason),
},
};
}
return response;
}
function isGraphQlBatchPullRequest(
resource: GraphQlBatchPullRequest | GraphQlBatchIssue,
): resource is GraphQlBatchPullRequest {
return "headRefName" in resource;
}
function mapGraphQlBatchPrState(state: GraphQlBatchPullRequest["state"]): PrInfo["status"] {
switch (state) {
case "OPEN":
return "open";
case "MERGED":
return "merged";
case "CLOSED":
default:
return "closed";
}
}
function mapGraphQlBatchIssueStateReason(
stateReason: GraphQlBatchIssue["stateReason"],
): IssueInfo["stateReason"] {
switch (stateReason) {
case "COMPLETED":
return "completed";
case "NOT_PLANNED":
return "not_planned";
case "REOPENED":
return "reopened";
default:
return undefined;
}
}
/**
* Extract owner/repo from a GitHub remote URL or return null if not a GitHub remote.
* @deprecated Use parseRepoFromRemote from gh-cli.ts instead