feat(KB-137): add GitHub App webhooks for real-time badge updates

- Add verified POST /api/github/webhooks endpoint with signature verification
- Implement GitHub App installation token authentication for API calls
- Add webhook handlers for pull_request, issues, and issue_comment events
- Support multi-task resource matching by parsed badge URL
- Retire live GitHub polling service in favor of push-based updates
- Add comprehensive webhook integration and unit tests
- Keep 5-minute REST refresh endpoints as fallback
This commit is contained in:
gsxdsm
2026-03-30 13:37:33 -07:00
parent 9499954636
commit c48c6aba66
12 changed files with 2100 additions and 579 deletions

View File

@@ -297,13 +297,43 @@ The dashboard server exposes a REST API at `/api`:
- `GET /api/git/remotes` - List GitHub remotes
- `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`)
- `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`)
- `POST /api/github/webhooks` - GitHub App webhook endpoint for badge updates (see GitHub App Setup below)
- `POST /api/tasks/:id/pr/create` - Create PR
- `GET /api/tasks/:id/pr/status` - Get PR status
- `POST /api/tasks/:id/pr/refresh` - Refresh PR status
- `GET /api/tasks/:id/issue/status` - Get cached issue status
- `POST /api/tasks/:id/issue/refresh` - Refresh issue status
- `GET /api/tasks/:id/pr/status` - Get PR status (5-min staleness, auto background refresh)
- `POST /api/tasks/:id/pr/refresh` - Force refresh PR status
- `GET /api/tasks/:id/issue/status` - Get cached issue status (5-min staleness, auto background refresh)
- `POST /api/tasks/:id/issue/refresh` - Force refresh issue status
- `WS /api/ws` - Real-time PR/issue badge updates for subscribed task cards
### GitHub App Setup for Badge Webhooks
For real-time PR/issue badge updates, configure a GitHub App instead of relying on polling:
**Environment Variables:**
- `KB_GITHUB_APP_ID` - Your GitHub App ID
- `KB_GITHUB_APP_PRIVATE_KEY` - PEM private key content (or use `KB_GITHUB_APP_PRIVATE_KEY_PATH`)
- `KB_GITHUB_APP_PRIVATE_KEY_PATH` - Path to PEM private key file (alternative to direct key)
- `KB_GITHUB_WEBHOOK_SECRET` - Webhook secret for signature verification
**GitHub App Configuration:**
- **Permissions Required:**
- Metadata: Read
- Pull requests: Read
- Issues: Read
- **Webhook Events:** Subscribe to `pull_request`, `issues`, and `issue_comment` events
- **Webhook URL:** `https://your-dashboard-url/api/github/webhooks`
**How it Works:**
1. GitHub sends signed webhook events when PR/issue state changes
2. Server verifies `X-Hub-Signature-256` using `KB_GITHUB_WEBHOOK_SECRET`
3. Server fetches canonical badge data using GitHub App installation token
4. Matching tasks (by parsed badge URL) are updated via `store.updatePrInfo()` / `store.updateIssueInfo()`
5. `task:updated` event triggers `/api/ws` broadcast to subscribed clients
6. No duplicate broadcasts when only `lastCheckedAt` timestamp changes
**Fallback Behavior:**
When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state).
### Multi-Instance Deployments
When running the dashboard on multiple instances behind a load balancer, badge updates can be shared across instances using Redis pub/sub. This ensures that a PR/issue badge change detected on instance A is delivered to subscribed WebSocket clients on instance B.
@@ -315,7 +345,7 @@ When running the dashboard on multiple instances behind a load balancer, badge u
When `KB_BADGE_PUBSUB_REDIS_URL` is not set, the dashboard uses an in-memory adapter for single-instance deployments.
**Design Notes:**
- Per-instance GitHub polling remains isolated; pub/sub only shares badge snapshot updates
- Webhook deliveries to any instance are broadcast to all instances via pub/sub
- WebSocket message format unchanged: `{ type: "badge:updated", taskId, prInfo?, issueInfo?, timestamp }`
- Echo prevention: origin instances ignore their own pub/sub messages via source ID deduplication
- Late subscribers receive the current cached snapshot from their connected instance

View File

@@ -1,304 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PrInfo, TaskStore } from "@kb/core";
import { GitHubPollingService, GitHubRateLimiter } from "../github-poll.js";
import { GitHubRateLimiter } from "../github-poll.js";
const getBadgeStatusesBatch = vi.fn();
vi.mock("../github.js", () => ({
GitHubClient: vi.fn(() => ({
getBadgeStatusesBatch,
})),
}));
function createStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
updatePrInfo: vi.fn(),
updateIssueInfo: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
function createPrInfo(overrides: Partial<PrInfo> = {}): PrInfo {
return {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Test PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
lastCheckedAt: "2026-03-30T00:00:00.000Z",
...overrides,
};
}
describe("GitHubPollingService", () => {
describe("GitHubRateLimiter", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("adds and removes task watches", () => {
const poller = new GitHubPollingService();
it("allows requests within the rate limit", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 3, windowMs: 60000 });
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
poller.watchTask("KB-063", "issue", "owner", "repo", 2);
expect(poller.getWatch("KB-063")?.pr?.number).toBe(1);
expect(poller.getWatch("KB-063")?.issue?.number).toBe(2);
poller.unwatchTaskType("KB-063", "pr");
expect(poller.getWatch("KB-063")?.pr).toBeUndefined();
expect(poller.getWatch("KB-063")?.issue?.number).toBe(2);
poller.unwatchTaskType("KB-063", "issue");
expect(poller.getWatch("KB-063")).toBeUndefined();
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
});
it("does not write to the store when fetched badge data is unchanged", async () => {
const task = {
id: "KB-063",
prInfo: createPrInfo(),
issueInfo: undefined,
};
const updatePrInfo = vi.fn();
const store = createStore({
getTask: vi.fn().mockResolvedValue(task),
updatePrInfo,
});
it("denies requests when rate limit is exceeded", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
getBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: createPrInfo({ lastCheckedAt: undefined }),
},
});
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(updatePrInfo).not.toHaveBeenCalled();
expect(poller.getLastCheckedAt("KB-063", "pr")).toMatch(/^2026|^20/);
limiter.canMakeRequest("owner/repo");
limiter.canMakeRequest("owner/repo");
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
});
it("updates PR info when comment metadata changes", async () => {
const task = {
id: "KB-063",
prInfo: createPrInfo({ commentCount: 0 }),
issueInfo: undefined,
};
const updatePrInfo = vi.fn().mockResolvedValue(undefined);
const store = createStore({
getTask: vi.fn().mockResolvedValue(task),
updatePrInfo,
});
it("resets rate limit after window expires", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
getBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: createPrInfo({ commentCount: 2, lastCommentAt: "2026-03-30T12:00:00.000Z", lastCheckedAt: undefined }),
},
});
limiter.canMakeRequest("owner/repo");
limiter.canMakeRequest("owner/repo");
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
// Advance time past the window
vi.advanceTimersByTime(61000);
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(updatePrInfo).toHaveBeenCalledTimes(1);
expect(updatePrInfo.mock.calls[0][1]).toMatchObject({
commentCount: 2,
lastCommentAt: "2026-03-30T12:00:00.000Z",
});
expect(updatePrInfo.mock.calls[0][1]?.lastCheckedAt).toBeTruthy();
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
});
it("deduplicates repo batch requests for shared resources", async () => {
const store = createStore({
getTask: vi.fn().mockImplementation(async (taskId: string) => ({
id: taskId,
prInfo: createPrInfo(),
issueInfo: undefined,
})),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
});
it("tracks different repositories independently", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
getBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: createPrInfo({ lastCheckedAt: undefined }),
},
});
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
poller.watchTask("KB-064", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(getBadgeStatusesBatch).toHaveBeenCalledTimes(1);
expect(getBadgeStatusesBatch.mock.calls[0][2]).toEqual([
{ alias: "pr_1", type: "pr", number: 1 },
]);
limiter.canMakeRequest("owner/repo1");
limiter.canMakeRequest("owner/repo1");
// repo1 is at limit
expect(limiter.canMakeRequest("owner/repo1")).toBe(false);
// repo2 is not affected
expect(limiter.canMakeRequest("owner/repo2")).toBe(true);
expect(limiter.canMakeRequest("owner/repo2")).toBe(true);
expect(limiter.canMakeRequest("owner/repo2")).toBe(false);
});
it("skips polling when the shared rate limiter denies the request", async () => {
const store = createStore({
getTask: vi.fn().mockResolvedValue({
id: "KB-063",
prInfo: createPrInfo(),
issueInfo: undefined,
}),
updatePrInfo: vi.fn(),
});
it("returns null reset time when no requests have been made", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 0 }),
});
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(getBadgeStatusesBatch).not.toHaveBeenCalled();
expect(limiter.getResetTime("owner/repo")).toBeNull();
});
it("updates issue info when badge-relevant issue fields change", async () => {
const updateIssueInfo = vi.fn().mockResolvedValue(undefined);
const store = createStore({
getTask: vi.fn().mockResolvedValue({
id: "KB-063",
prInfo: undefined,
issueInfo: {
url: "https://github.com/owner/repo/issues/2",
number: 2,
state: "closed",
title: "Tracked issue",
stateReason: "reopened",
lastCheckedAt: "2026-03-30T00:00:00.000Z",
},
}),
updateIssueInfo,
});
it("returns correct reset time after requests", () => {
const limiter = new GitHubRateLimiter({ maxRequests: 2, windowMs: 60000 });
getBadgeStatusesBatch.mockResolvedValue({
issue_2: {
type: "issue",
issueInfo: {
url: "https://github.com/owner/repo/issues/2",
number: 2,
state: "closed",
title: "Tracked issue",
stateReason: "completed",
},
},
});
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
poller.watchTask("KB-063", "issue", "owner", "repo", 2);
await poller.pollOnce();
expect(updateIssueInfo).toHaveBeenCalledTimes(1);
expect(updateIssueInfo.mock.calls[0][1]).toMatchObject({
stateReason: "completed",
});
expect(updateIssueInfo.mock.calls[0][1]?.lastCheckedAt).toBeTruthy();
const before = Date.now();
limiter.canMakeRequest("owner/repo");
const resetTime = limiter.getResetTime("owner/repo");
expect(resetTime).not.toBeNull();
expect(resetTime!.getTime()).toBeGreaterThan(before);
expect(resetTime!.getTime()).toBeLessThanOrEqual(before + 60000);
});
it("keeps watches on transient task load failures", async () => {
const store = createStore({
getTask: vi.fn().mockRejectedValue(new Error("temporary parse error")),
updatePrInfo: vi.fn(),
});
getBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: createPrInfo({ lastCheckedAt: undefined }),
},
});
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(poller.getWatch("KB-063")).toBeDefined();
expect(poller.getLastCheckedAt("KB-063", "pr")).toBeUndefined();
});
it("does not clear badge links on ambiguous null batch responses", async () => {
const updatePrInfo = vi.fn();
const store = createStore({
getTask: vi.fn().mockResolvedValue({
id: "KB-063",
prInfo: createPrInfo(),
issueInfo: undefined,
}),
updatePrInfo,
});
getBadgeStatusesBatch.mockResolvedValue({
pr_1: null,
});
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(updatePrInfo).not.toHaveBeenCalled();
expect(poller.getWatch("KB-063")).toBeDefined();
expect(poller.getLastCheckedAt("KB-063", "pr")).toBeUndefined();
});
it("unwatches tasks that can no longer be loaded", async () => {
const store = createStore({
getTask: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" })),
updatePrInfo: vi.fn(),
});
getBadgeStatusesBatch.mockResolvedValue({
pr_1: {
type: "pr",
prInfo: createPrInfo({ lastCheckedAt: undefined }),
},
});
const poller = new GitHubPollingService({
store,
rateLimiter: new GitHubRateLimiter({ maxRequests: 10 }),
});
poller.watchTask("KB-063", "pr", "owner", "repo", 1);
await poller.pollOnce();
expect(poller.getWatch("KB-063")).toBeUndefined();
it("uses default values when not specified", () => {
const limiter = new GitHubRateLimiter();
// Default is 90 requests per hour
for (let i = 0; i < 90; i++) {
expect(limiter.canMakeRequest("owner/repo")).toBe(true);
}
expect(limiter.canMakeRequest("owner/repo")).toBe(false);
});
});

View File

@@ -0,0 +1,539 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getGitHubAppConfig,
isGitHubAppConfigured,
verifyWebhookSignature,
classifyWebhookEvent,
parseBadgeUrl,
isSameResource,
hasPrBadgeFieldsChanged,
hasIssueBadgeFieldsChanged,
fetchInstallationToken,
fetchCanonicalPrInfo,
fetchCanonicalIssueInfo,
} from "../github-webhooks.js";
describe("GitHub Webhooks Module", () => {
// Save original env vars
const originalEnv = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...originalEnv };
delete process.env.KB_GITHUB_APP_ID;
delete process.env.KB_GITHUB_APP_PRIVATE_KEY;
delete process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH;
delete process.env.KB_GITHUB_WEBHOOK_SECRET;
});
afterEach(() => {
process.env = originalEnv;
vi.restoreAllMocks();
});
describe("getGitHubAppConfig", () => {
it("returns null when no env vars are set", () => {
expect(getGitHubAppConfig()).toBeNull();
});
it("returns null when only appId is set", () => {
process.env.KB_GITHUB_APP_ID = "12345";
expect(getGitHubAppConfig()).toBeNull();
});
it("returns config when all required vars are set via direct key", () => {
process.env.KB_GITHUB_APP_ID = "12345";
process.env.KB_GITHUB_APP_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----";
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
const config = getGitHubAppConfig();
expect(config).not.toBeNull();
expect(config?.appId).toBe("12345");
expect(config?.privateKey).toContain("RSA PRIVATE KEY");
expect(config?.webhookSecret).toBe("webhook-secret");
});
it("returns null when key file cannot be read", () => {
process.env.KB_GITHUB_APP_ID = "12345";
process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH = "/nonexistent/path/key.pem";
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
expect(getGitHubAppConfig()).toBeNull();
});
it("prefers direct key over file path when both are set", () => {
process.env.KB_GITHUB_APP_ID = "12345";
process.env.KB_GITHUB_APP_PRIVATE_KEY = "direct-key-content";
process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH = "/nonexistent/path/key.pem";
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
const config = getGitHubAppConfig();
expect(config?.privateKey).toBe("direct-key-content");
});
});
describe("isGitHubAppConfigured", () => {
it("returns false when not configured", () => {
expect(isGitHubAppConfigured()).toBe(false);
});
it("returns true when fully configured", () => {
process.env.KB_GITHUB_APP_ID = "12345";
process.env.KB_GITHUB_APP_PRIVATE_KEY = "private-key";
process.env.KB_GITHUB_WEBHOOK_SECRET = "webhook-secret";
expect(isGitHubAppConfigured()).toBe(true);
});
});
describe("verifyWebhookSignature", () => {
it("returns invalid when signature header is missing", () => {
const result = verifyWebhookSignature(
Buffer.from('{"test": "payload"}'),
undefined,
"secret",
);
expect(result.valid).toBe(false);
expect(result.error).toBe("Missing signature header");
});
it("returns invalid when signature does not match", () => {
const result = verifyWebhookSignature(
Buffer.from('{"test": "payload"}'),
"sha256=invalidsignature",
"secret",
);
expect(result.valid).toBe(false);
expect(result.error).toBe("Signature mismatch");
});
it("returns valid when signature matches", () => {
// Compute correct signature for test
const { createHmac } = require("node:crypto");
const payload = '{"test": "payload"}';
const correctSignature = "sha256=" + createHmac("sha256", "secret").update(payload).digest("hex");
const result = verifyWebhookSignature(
Buffer.from(payload),
correctSignature,
"secret",
);
expect(result.valid).toBe(true);
});
it("uses constant-time comparison to prevent timing attacks", () => {
const { createHmac } = require("node:crypto");
const payload = '{"test": "payload"}';
const correctSignature = "sha256=" + createHmac("sha256", "secret").update(payload).digest("hex");
// Should not throw and should return valid
const result = verifyWebhookSignature(
Buffer.from(payload),
correctSignature,
"secret",
);
expect(result.valid).toBe(true);
});
});
describe("classifyWebhookEvent", () => {
it("classifies ping as supported but not relevant", () => {
const result = classifyWebhookEvent("ping", {
repository: { owner: { login: "test-owner" }, name: "test-repo" },
installation: { id: 12345 },
});
expect(result.supported).toBe(true);
expect(result.relevant).toBe(false);
expect(result.owner).toBe("test-owner");
expect(result.repo).toBe("test-repo");
expect(result.installationId).toBe(12345);
});
it("classifies pull_request as supported and relevant", () => {
const result = classifyWebhookEvent("pull_request", {
number: 42,
repository: { owner: { login: "test-owner" }, name: "test-repo" },
installation: { id: 12345 },
});
expect(result.supported).toBe(true);
expect(result.relevant).toBe(true);
expect(result.resourceType).toBe("pr");
expect(result.number).toBe(42);
expect(result.owner).toBe("test-owner");
expect(result.repo).toBe("test-repo");
});
it("classifies issues as supported and relevant", () => {
const result = classifyWebhookEvent("issues", {
issue: { number: 123 },
repository: { owner: { login: "test-owner" }, name: "test-repo" },
installation: { id: 12345 },
});
expect(result.supported).toBe(true);
expect(result.relevant).toBe(true);
expect(result.resourceType).toBe("issue");
expect(result.number).toBe(123);
});
it("classifies issue_comment on PR as supported and relevant", () => {
const result = classifyWebhookEvent("issue_comment", {
issue: { number: 42, pull_request: {} },
repository: { owner: { login: "test-owner" }, name: "test-repo" },
installation: { id: 12345 },
});
expect(result.supported).toBe(true);
expect(result.relevant).toBe(true);
expect(result.resourceType).toBe("pr");
expect(result.number).toBe(42);
});
it("classifies issue_comment on regular issue as supported but not relevant", () => {
const result = classifyWebhookEvent("issue_comment", {
issue: { number: 123 }, // No pull_request field
repository: { owner: { login: "test-owner" }, name: "test-repo" },
installation: { id: 12345 },
});
expect(result.supported).toBe(true);
expect(result.relevant).toBe(false);
});
it("classifies unknown events as unsupported", () => {
const result = classifyWebhookEvent("push", {});
expect(result.supported).toBe(false);
expect(result.relevant).toBe(false);
});
it("handles missing event type", () => {
const result = classifyWebhookEvent(undefined, {});
expect(result.supported).toBe(false);
});
});
describe("parseBadgeUrl", () => {
it("parses PR URL correctly", () => {
const result = parseBadgeUrl("https://github.com/owner/repo/pull/42");
expect(result).toEqual({
owner: "owner",
repo: "repo",
number: 42,
resourceType: "pr",
});
});
it("parses issue URL correctly", () => {
const result = parseBadgeUrl("https://github.com/owner/repo/issues/123");
expect(result).toEqual({
owner: "owner",
repo: "repo",
number: 123,
resourceType: "issue",
});
});
it("returns null for non-GitHub URLs", () => {
expect(parseBadgeUrl("https://gitlab.com/owner/repo/pull/42")).toBeNull();
});
it("returns null for invalid paths", () => {
expect(parseBadgeUrl("https://github.com/owner/repo")).toBeNull();
});
it("returns null for invalid number", () => {
expect(parseBadgeUrl("https://github.com/owner/repo/pull/abc")).toBeNull();
});
it("handles URLs with trailing slash", () => {
const result = parseBadgeUrl("https://github.com/owner/repo/pull/42/");
expect(result?.number).toBe(42);
});
});
describe("isSameResource", () => {
it("returns true for identical resources", () => {
const a = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
const b = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
expect(isSameResource(a, b)).toBe(true);
});
it("returns false for different owners", () => {
const a = { owner: "owner-a", repo: "repo", number: 42, resourceType: "pr" as const };
const b = { owner: "owner-b", repo: "repo", number: 42, resourceType: "pr" as const };
expect(isSameResource(a, b)).toBe(false);
});
it("returns false for different repos", () => {
const a = { owner: "owner", repo: "repo-a", number: 42, resourceType: "pr" as const };
const b = { owner: "owner", repo: "repo-b", number: 42, resourceType: "pr" as const };
expect(isSameResource(a, b)).toBe(false);
});
it("returns false for different numbers", () => {
const a = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
const b = { owner: "owner", repo: "repo", number: 43, resourceType: "pr" as const };
expect(isSameResource(a, b)).toBe(false);
});
it("returns false for different types", () => {
const a = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
const b = { owner: "owner", repo: "repo", number: 42, resourceType: "issue" as const };
expect(isSameResource(a, b)).toBe(false);
});
it("is case insensitive for owner and repo", () => {
const a = { owner: "Owner", repo: "Repo", number: 42, resourceType: "pr" as const };
const b = { owner: "owner", repo: "repo", number: 42, resourceType: "pr" as const };
expect(isSameResource(a, b)).toBe(true);
});
});
describe("hasPrBadgeFieldsChanged", () => {
const basePrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Test PR",
headBranch: "feature",
baseBranch: "main",
commentCount: 0,
lastCommentAt: undefined,
};
it("returns true when current is undefined", () => {
expect(hasPrBadgeFieldsChanged(undefined, basePrInfo)).toBe(true);
});
it("returns false when fields are identical", () => {
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
expect(hasPrBadgeFieldsChanged(current, basePrInfo)).toBe(false);
});
it("returns true when status changes", () => {
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...basePrInfo, status: "closed" as const };
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
});
it("returns true when title changes", () => {
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...basePrInfo, title: "Updated Title" };
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
});
it("returns true when commentCount changes", () => {
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...basePrInfo, commentCount: 5 };
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
});
it("returns true when lastCommentAt changes", () => {
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...basePrInfo, lastCommentAt: "2026-01-02T00:00:00.000Z" };
expect(hasPrBadgeFieldsChanged(current, next)).toBe(true);
});
it("ignores lastCheckedAt differences", () => {
const current = { ...basePrInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...basePrInfo, lastCheckedAt: "2026-01-02T00:00:00.000Z" };
expect(hasPrBadgeFieldsChanged(current, next)).toBe(false);
});
});
describe("hasIssueBadgeFieldsChanged", () => {
const baseIssueInfo = {
url: "https://github.com/owner/repo/issues/123",
number: 123,
state: "open" as const,
title: "Test Issue",
stateReason: undefined,
};
it("returns true when current is undefined", () => {
expect(hasIssueBadgeFieldsChanged(undefined, baseIssueInfo)).toBe(true);
});
it("returns false when fields are identical", () => {
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
expect(hasIssueBadgeFieldsChanged(current, baseIssueInfo)).toBe(false);
});
it("returns true when state changes", () => {
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...baseIssueInfo, state: "closed" as const };
expect(hasIssueBadgeFieldsChanged(current, next)).toBe(true);
});
it("returns true when stateReason changes", () => {
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...baseIssueInfo, stateReason: "completed" as const };
expect(hasIssueBadgeFieldsChanged(current, next)).toBe(true);
});
it("ignores lastCheckedAt differences", () => {
const current = { ...baseIssueInfo, lastCheckedAt: "2026-01-01T00:00:00.000Z" };
const next = { ...baseIssueInfo, lastCheckedAt: "2026-01-02T00:00:00.000Z" };
expect(hasIssueBadgeFieldsChanged(current, next)).toBe(false);
});
});
describe("fetchInstallationToken (integration)", () => {
it("returns null when API request fails", async () => {
// Mock a failed fetch response
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
});
global.fetch = mockFetch;
const token = await fetchInstallationToken(12345, "app-id", "fake-private-key");
expect(token).toBeNull();
});
it("returns token on successful API request", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ token: "ghs_installation_token" }),
});
global.fetch = mockFetch;
// Note: This will fail JWT generation with a fake key, but we can verify the attempt
const token = await fetchInstallationToken(12345, "app-id", "fake-key");
// Expect null because JWT signing will fail with invalid key
expect(token).toBeNull();
});
});
describe("fetchCanonicalPrInfo (integration)", () => {
it("fetches PR data from GitHub API", async () => {
const mockPrData = {
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "Test PR",
state: "open",
merged: false,
head: { ref: "feature-branch" },
base: { ref: "main" },
comments: 5,
updated_at: "2026-01-01T00:00:00Z",
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockPrData),
});
global.fetch = mockFetch;
const result = await fetchCanonicalPrInfo("owner", "repo", 42, "fake-token");
expect(result).not.toBeNull();
expect(result?.number).toBe(42);
expect(result?.status).toBe("open");
expect(result?.title).toBe("Test PR");
expect(result?.headBranch).toBe("feature-branch");
expect(result?.commentCount).toBe(5);
expect(mockFetch).toHaveBeenCalledWith(
"https://api.github.com/repos/owner/repo/pulls/42",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer fake-token",
}),
}),
);
});
it("returns merged status for merged PRs", async () => {
const mockPrData = {
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "Test PR",
state: "closed",
merged: true,
head: { ref: "feature-branch" },
base: { ref: "main" },
comments: 10,
updated_at: "2026-01-02T00:00:00Z",
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockPrData),
});
global.fetch = mockFetch;
const result = await fetchCanonicalPrInfo("owner", "repo", 42, "fake-token");
expect(result?.status).toBe("merged");
});
it("returns null on API error", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
});
global.fetch = mockFetch;
const result = await fetchCanonicalPrInfo("owner", "repo", 42, "fake-token");
expect(result).toBeNull();
});
});
describe("fetchCanonicalIssueInfo (integration)", () => {
it("fetches issue data from GitHub API", async () => {
const mockIssueData = {
number: 123,
html_url: "https://github.com/owner/repo/issues/123",
title: "Test Issue",
state: "open",
state_reason: null,
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockIssueData),
});
global.fetch = mockFetch;
const result = await fetchCanonicalIssueInfo("owner", "repo", 123, "fake-token");
expect(result).not.toBeNull();
expect(result?.number).toBe(123);
expect(result?.state).toBe("open");
expect(result?.title).toBe("Test Issue");
});
it("returns null for PRs (which come through issues endpoint)", async () => {
const mockPrData = {
number: 42,
html_url: "https://github.com/owner/repo/pull/42",
title: "Test PR",
state: "open",
pull_request: {}, // This marks it as a PR
};
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockPrData),
});
global.fetch = mockFetch;
const result = await fetchCanonicalIssueInfo("owner", "repo", 42, "fake-token");
expect(result).toBeNull();
});
it("returns null on API error", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
});
global.fetch = mockFetch;
const result = await fetchCanonicalIssueInfo("owner", "repo", 123, "fake-token");
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,382 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter, once } from "node:events";
import http from "node:http";
import type { Task, TaskStore, PrInfo, IssueInfo } from "@kb/core";
import { createServer } from "../server.js";
import { getGitHubAppConfig } from "../github-webhooks.js";
// Mock the github-webhooks module
vi.mock("../github-webhooks.js", async () => {
const actual = await vi.importActual<typeof import("../github-webhooks.js")>("../github-webhooks.js");
return {
...actual,
getGitHubAppConfig: vi.fn(),
};
});
const mockGetGitHubAppConfig = vi.mocked(getGitHubAppConfig);
class MockStore extends EventEmitter {
private tasks = new Map<string, Task>();
private rootDir: string;
constructor(rootDir: string = process.cwd()) {
super();
this.rootDir = rootDir;
}
getRootDir(): string {
return this.rootDir;
}
async listTasks(): Promise<Task[]> {
return Array.from(this.tasks.values());
}
async getTask(id: string): Promise<Task> {
const task = this.tasks.get(id);
if (!task) {
const error = Object.assign(new Error("Task not found"), { code: "ENOENT" });
throw error;
}
return task;
}
async updatePrInfo(taskId: string, prInfo: PrInfo): Promise<Task> {
const task = await this.getTask(taskId);
const updated = { ...task, prInfo };
this.tasks.set(taskId, updated);
this.emit("task:updated", updated);
return updated;
}
async updateIssueInfo(taskId: string, issueInfo: IssueInfo): Promise<Task> {
const task = await this.getTask(taskId);
const updated = { ...task, issueInfo };
this.tasks.set(taskId, updated);
this.emit("task:updated", updated);
return updated;
}
addTask(task: Task): void {
this.tasks.set(task.id, task);
this.emit("task:created", task);
}
}
function createHmacSignature(payload: string, secret: string): string {
const { createHmac } = require("node:crypto");
return "sha256=" + createHmac("sha256", secret).update(payload).digest("hex");
}
function createPrTask(id: string, url: string, number: number, status: PrInfo["status"] = "open"): Task {
return {
id,
title: "Test task",
description: "Test description",
column: "in-review",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-03-30T00:00:00.000Z",
updatedAt: "2026-03-30T00:00:00.000Z",
columnMovedAt: "2026-03-30T00:00:00.000Z",
prInfo: {
url,
number,
status,
title: "Test PR",
headBranch: "feature",
baseBranch: "main",
commentCount: 0,
lastCheckedAt: "2026-03-30T00:00:00.000Z",
},
};
}
function createIssueTask(id: string, url: string, number: number, state: IssueInfo["state"] = "open"): Task {
return {
id,
title: "Test task",
description: "Test description",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-03-30T00:00:00.000Z",
updatedAt: "2026-03-30T00:00:00.000Z",
columnMovedAt: "2026-03-30T00:00:00.000Z",
issueInfo: {
url,
number,
state,
title: "Test Issue",
},
};
}
describe("POST /api/github/webhooks", () => {
const mockConfig = {
appId: "12345",
privateKey: "mock-private-key",
webhookSecret: "webhook-secret",
};
beforeEach(() => {
vi.clearAllMocks();
mockGetGitHubAppConfig.mockReturnValue(mockConfig);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns 503 when GitHub App is not configured", async () => {
mockGetGitHubAppConfig.mockReturnValue(null);
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{ hostname: "127.0.0.1", port, path: "/api/github/webhooks", method: "POST", headers: { "Content-Type": "application/json" } },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(JSON.stringify({ action: "opened" }));
req.end();
});
expect(response.status).toBe(503);
expect(response.body.error).toContain("not configured");
server.close();
await once(server, "close");
});
it("returns 403 for invalid signature", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const payload = JSON.stringify({ action: "opened", number: 42 });
const invalidSignature = "sha256=invalid";
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": invalidSignature,
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
expect(response.status).toBe(403);
expect(response.body.error).toMatch(/Signature mismatch/i);
server.close();
await once(server, "close");
});
it("returns 200 for valid ping event", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const payload = JSON.stringify({ zen: "Keep it logically awesome" });
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "ping",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
expect(response.status).toBe(200);
expect(response.body.message).toBe("Pong");
server.close();
await once(server, "close");
});
it("returns 202 for unsupported event types", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
const payload = JSON.stringify({ action: "pushed" });
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "push",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
expect(response.status).toBe(202);
expect(response.body.message).toContain("not supported");
server.close();
await once(server, "close");
});
it("returns 202 for issue_comment on regular issues (not PRs)", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
// Issue comment without pull_request field
const payload = JSON.stringify({
action: "created",
issue: { number: 123 }, // No pull_request field → regular issue
repository: { owner: { login: "owner" }, name: "repo" },
installation: { id: 12345 },
comment: { id: 456, body: "Issue comment" },
});
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "issue_comment",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
expect(response.status).toBe(202);
expect(response.body.message).toContain("not relevant");
server.close();
await once(server, "close");
});
it("returns 500 when installation token cannot be fetched", async () => {
const store = new MockStore();
const app = createServer(store as any);
const server = app.listen(0);
await once(server, "listening");
const port = (server.address() as { port: number }).port;
// Valid PR event with missing installation data
const payload = JSON.stringify({
action: "opened",
number: 42,
repository: { owner: { login: "owner" }, name: "repo" },
// No installation field - will cause token fetch to fail
});
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
const req = http.request(
{
hostname: "127.0.0.1",
port,
path: "/api/github/webhooks",
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": signature,
"X-GitHub-Event": "pull_request",
}
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
}
);
req.on("error", reject);
req.write(payload);
req.end();
});
// Should return 400 for missing installation data
expect([400, 500]).toContain(response.status);
server.close();
await once(server, "close");
});
});

View File

@@ -3,10 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import type { Task } from "@kb/core";
import { createServer } from "../server.js";
import { githubPoller } from "../github-poll.js";
import { WebSocketManager } from "../websocket.js";
import { InMemoryBadgePubSub, type BadgePubSub } from "../badge-pubsub.js";
import { GitHubPollingService } from "../github-poll.js";
class MockSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
@@ -190,23 +188,7 @@ describe("WebSocketManager", () => {
});
describe("/api/ws integration", () => {
let startSpy: ReturnType<typeof vi.spyOn>;
let stopSpy: ReturnType<typeof vi.spyOn>;
let replaceSpy: ReturnType<typeof vi.spyOn>;
let unwatchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
startSpy = vi.spyOn(githubPoller, "start").mockImplementation(() => {});
stopSpy = vi.spyOn(githubPoller, "stop").mockImplementation(() => {});
replaceSpy = vi.spyOn(githubPoller, "replaceTaskWatches").mockImplementation(() => {});
unwatchSpy = vi.spyOn(githubPoller, "unwatchTask").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("delivers badge updates to subscribed websocket clients and manages poller lifecycle", async () => {
it("delivers badge updates to subscribed websocket clients via task:updated events", async () => {
const initialTask = createTask();
const store = new MockStore(initialTask);
const app = createServer(store as any, { githubToken: "test-token" });
@@ -222,22 +204,10 @@ describe("/api/ws integration", () => {
});
await once(client, "open");
await waitForExpectation(() => {
expect(startSpy).toHaveBeenCalledTimes(1);
});
client.send(JSON.stringify({ type: "subscribe", taskId: initialTask.id }));
await waitForExpectation(() => {
expect(replaceSpy).toHaveBeenCalledWith(initialTask.id, [
{
taskId: initialTask.id,
type: "pr",
owner: "owner",
repo: "repo",
number: 42,
},
]);
});
// Wait for subscription to be established
await new Promise((resolve) => setTimeout(resolve, 100));
const updatedTask = createTask({
prInfo: {
@@ -262,12 +232,6 @@ describe("/api/ws integration", () => {
client.close();
await once(client, "close");
await waitForExpectation(() => {
expect(unwatchSpy).toHaveBeenCalledWith(initialTask.id);
expect(stopSpy).toHaveBeenCalledTimes(1);
});
server.close();
await once(server, "close");
});
@@ -302,15 +266,10 @@ describe("multi-instance /api/ws integration", () => {
const storeA = new MockStore(taskA);
const storeB = new MockStore(taskA); // Instance B starts with same task data
// Create separate pollers for each instance
const pollerA = new GitHubPollingService();
const pollerB = new GitHubPollingService();
// Create server A with zero local websocket subscribers (no local ws clients)
const appA = createServer(storeA as any, {
githubToken: "test-token",
badgePubSub: sharedPubSub,
githubPoller: pollerA,
});
const serverA = appA.listen(0);
await once(serverA, "listening");
@@ -320,7 +279,6 @@ describe("multi-instance /api/ws integration", () => {
const appB = createServer(storeB as any, {
githubToken: "test-token",
badgePubSub: sharedPubSub,
githubPoller: pollerB,
});
const serverB = appB.listen(0);
await once(serverB, "listening");
@@ -388,12 +346,10 @@ describe("multi-instance /api/ws integration", () => {
const task = createTask({ id: "KB-ECHO-001" });
const store = new MockStore(task);
const poller = new GitHubPollingService();
const app = createServer(store as any, {
githubToken: "test-token",
badgePubSub: sharedPubSub,
githubPoller: poller,
});
const server = app.listen(0);
await once(server, "listening");
@@ -477,14 +433,11 @@ describe("multi-instance /api/ws integration", () => {
const storeA = new MockStore(task);
const storeB = new MockStore(task);
const pollerA = new GitHubPollingService();
const pollerB = new GitHubPollingService();
// Create both instances
const appA = createServer(storeA as any, {
githubToken: "test-token",
badgePubSub: sharedPubSub,
githubPoller: pollerA,
});
const serverA = appA.listen(0);
await once(serverA, "listening");
@@ -493,7 +446,6 @@ describe("multi-instance /api/ws integration", () => {
const appB = createServer(storeB as any, {
githubToken: "test-token",
badgePubSub: sharedPubSub,
githubPoller: pollerB,
});
const serverB = appB.listen(0);
await once(serverB, "listening");
@@ -546,79 +498,4 @@ describe("multi-instance /api/ws integration", () => {
expect(badgeMessage).toBeDefined();
expect(badgeMessage.prInfo.status).toBe("merged");
}, 5000);
it("maintains separate poller watch state per server instance", async () => {
// Create two completely separate server instances
const taskA = createTask({ id: "KB-POLLER-001" });
const taskB = createTask({ id: "KB-POLLER-002" });
const storeA = new MockStore(taskA);
const storeB = new MockStore(taskB);
// Create separate pollers
const pollerA = new GitHubPollingService();
const pollerB = new GitHubPollingService();
// Create servers with separate pollers
const appA = createServer(storeA as any, {
githubToken: "test-token",
githubPoller: pollerA,
});
const serverA = appA.listen(0);
await once(serverA, "listening");
const portA = (serverA.address() as import("node:net").AddressInfo).port;
const appB = createServer(storeB as any, {
githubToken: "test-token",
githubPoller: pollerB,
});
const serverB = appB.listen(0);
await once(serverB, "listening");
const portB = (serverB.address() as import("node:net").AddressInfo).port;
// Connect clients to both servers
const clientA = new WebSocket(`ws://127.0.0.1:${portA}/api/ws`);
await once(clientA, "open");
const clientB = new WebSocket(`ws://127.0.0.1:${portB}/api/ws`);
await once(clientB, "open");
// Wait for connections to establish
await new Promise((resolve) => setTimeout(resolve, 50));
// Subscribe to different tasks on each server
clientA.send(JSON.stringify({ type: "subscribe", taskId: taskA.id }));
clientB.send(JSON.stringify({ type: "subscribe", taskId: taskB.id }));
// Wait for subscriptions to establish
await new Promise((resolve) => setTimeout(resolve, 200));
// Verify pollerA is watching taskA but not taskB
expect(pollerA.getWatchedTaskIds()).toContain(taskA.id);
expect(pollerA.getWatchedTaskIds()).not.toContain(taskB.id);
// Verify pollerB is watching taskB but not taskA
expect(pollerB.getWatchedTaskIds()).toContain(taskB.id);
expect(pollerB.getWatchedTaskIds()).not.toContain(taskA.id);
// Now unsubscribe from B and verify A's poller is unaffected
clientB.send(JSON.stringify({ type: "unsubscribe", taskId: taskB.id }));
await new Promise((resolve) => setTimeout(resolve, 100));
// PollerA should still be watching taskA
expect(pollerA.getWatchedTaskIds()).toContain(taskA.id);
// PollerB should have stopped watching taskB
expect(pollerB.getWatchedTaskIds()).not.toContain(taskB.id);
// Cleanup
clientA.close();
clientB.close();
await Promise.race([once(clientA, "close"), new Promise(r => setTimeout(r, 500))]);
await Promise.race([once(clientB, "close"), new Promise(r => setTimeout(r, 500))]);
serverA.close();
serverB.close();
await Promise.race([once(serverA, "close"), new Promise(r => setTimeout(r, 500))]);
await Promise.race([once(serverB, "close"), new Promise(r => setTimeout(r, 500))]);
}, 5000);
});

View File

@@ -0,0 +1,530 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import type { IssueInfo, PrInfo } from "@kb/core";
import { GitHubClient } from "./github.js";
/**
* GitHub App webhook configuration from environment variables.
*/
export interface GitHubAppConfig {
appId: string;
privateKey: string;
webhookSecret: string;
}
/**
* Supported GitHub webhook events for badge updates.
*/
export type SupportedGitHubEvent = "ping" | "pull_request" | "issues" | "issue_comment";
/**
* Classification result for an incoming webhook event.
*/
export interface WebhookEventClassification {
/** Whether this event type is supported at all */
supported: boolean;
/** Whether this specific event payload warrants a badge refresh */
relevant: boolean;
/** The resource type this event affects, if relevant */
resourceType?: "pr" | "issue";
/** Repository owner from the event */
owner?: string;
/** Repository name from the event */
repo?: string;
/** PR or issue number from the event */
number?: number;
/** Installation ID for App authentication */
installationId?: number;
}
/**
* Parsed badge URL components for matching tasks.
*/
export interface BadgeUrlComponents {
owner: string;
repo: string;
number: number;
resourceType: "pr" | "issue";
}
/**
* Result of webhook signature verification.
*/
export interface VerificationResult {
valid: boolean;
error?: string;
}
/**
* Read GitHub App configuration from environment variables.
* Supports KB_GITHUB_APP_PRIVATE_KEY or KB_GITHUB_APP_PRIVATE_KEY_PATH.
*/
export function getGitHubAppConfig(): GitHubAppConfig | null {
const appId = process.env.KB_GITHUB_APP_ID;
const webhookSecret = process.env.KB_GITHUB_WEBHOOK_SECRET;
let privateKey: string | undefined;
if (process.env.KB_GITHUB_APP_PRIVATE_KEY) {
privateKey = process.env.KB_GITHUB_APP_PRIVATE_KEY;
} else if (process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH) {
try {
privateKey = readFileSync(process.env.KB_GITHUB_APP_PRIVATE_KEY_PATH, "utf-8");
} catch {
// Failed to read key file
return null;
}
}
if (!appId || !privateKey || !webhookSecret) {
return null;
}
return { appId, privateKey, webhookSecret };
}
/**
* Validate that GitHub App configuration is complete.
*/
export function isGitHubAppConfigured(): boolean {
return getGitHubAppConfig() !== null;
}
/**
* Verify the X-Hub-Signature-256 header against the raw request body.
* Uses constant-time comparison to prevent timing attacks.
*/
export function verifyWebhookSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret: string,
): VerificationResult {
if (!signatureHeader) {
return { valid: false, error: "Missing signature header" };
}
// Expected format: "sha256=<hex_signature>"
const expectedSignature = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const expectedHeader = `sha256=${expectedSignature}`;
// Constant-time comparison to prevent timing attacks
if (signatureHeader.length !== expectedHeader.length) {
return { valid: false, error: "Signature mismatch" };
}
try {
const signatureBuffer = Buffer.from(signatureHeader);
const expectedBuffer = Buffer.from(expectedHeader);
if (!timingSafeEqual(signatureBuffer, expectedBuffer)) {
return { valid: false, error: "Signature mismatch" };
}
} catch {
return { valid: false, error: "Signature verification failed" };
}
return { valid: true };
}
/**
* Classify a GitHub webhook event to determine if it requires badge refresh.
*/
export function classifyWebhookEvent(
eventType: string | undefined,
payload: unknown,
): WebhookEventClassification {
if (!eventType) {
return { supported: false, relevant: false };
}
const typedPayload = payload as Record<string, unknown> | undefined;
const repository = typedPayload?.repository as Record<string, unknown> | undefined;
const repositoryOwner = repository?.owner as Record<string, unknown> | undefined;
const owner = typeof repositoryOwner?.login === "string"
? repositoryOwner.login
: undefined;
const repo = typeof repository?.name === "string"
? repository.name
: undefined;
const installationData = typedPayload?.installation as Record<string, unknown> | undefined;
const installationId = typeof installationData?.id === "number"
? installationData.id
: undefined;
// Handle ping events (health check from GitHub)
if (eventType === "ping") {
return {
supported: true,
relevant: false,
owner,
repo,
installationId,
};
}
// Handle pull_request events
if (eventType === "pull_request") {
const prNumber = typeof typedPayload?.number === "number"
? typedPayload.number
: undefined;
// All pull_request events are relevant for PR badges
if (owner && repo && prNumber !== undefined) {
return {
supported: true,
relevant: true,
resourceType: "pr",
owner,
repo,
number: prNumber,
installationId,
};
}
return { supported: true, relevant: false, owner, repo, installationId };
}
// Handle issues events
if (eventType === "issues") {
const issuePayload = typedPayload?.issue as Record<string, unknown> | undefined;
const issueNumber = typeof issuePayload?.number === "number"
? issuePayload.number
: undefined;
// All issues events are relevant for issue badges
if (owner && repo && issueNumber !== undefined) {
return {
supported: true,
relevant: true,
resourceType: "issue",
owner,
repo,
number: issueNumber,
installationId,
};
}
return { supported: true, relevant: false, owner, repo, installationId };
}
// Handle issue_comment events (only relevant when on a PR)
if (eventType === "issue_comment") {
const issueData = typedPayload?.issue as Record<string, unknown> | undefined;
const commentNumber = typeof issueData?.number === "number" ? issueData.number : undefined;
const isPullRequest = issueData?.pull_request !== undefined;
// Only process issue_comment events on PRs (not regular issues)
if (isPullRequest && owner && repo && commentNumber !== undefined) {
return {
supported: true,
relevant: true,
resourceType: "pr",
owner,
repo,
number: commentNumber,
installationId,
};
}
return { supported: true, relevant: false, owner, repo, installationId };
}
// Unsupported event type
return { supported: false, relevant: false, owner, repo, installationId };
}
/**
* Parse a GitHub badge URL (PR or issue) into its components.
* Supports formats like:
* - https://github.com/owner/repo/pull/123
* - https://github.com/owner/repo/issues/123
*/
export function parseBadgeUrl(url: string): BadgeUrlComponents | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") {
return null;
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
if (pathParts.length < 4) {
return null;
}
const [owner, repo, type, numberStr] = pathParts;
const number = parseInt(numberStr, 10);
if (!owner || !repo || !Number.isFinite(number) || number < 1) {
return null;
}
let resourceType: "pr" | "issue";
if (type === "pull") {
resourceType = "pr";
} else if (type === "issues") {
resourceType = "issue";
} else {
return null;
}
return { owner, repo, number, resourceType };
} catch {
return null;
}
}
/**
* Check if two badge URL components refer to the same resource.
*/
export function isSameResource(
a: BadgeUrlComponents,
b: BadgeUrlComponents,
): boolean {
return (
a.owner.toLowerCase() === b.owner.toLowerCase() &&
a.repo.toLowerCase() === b.repo.toLowerCase() &&
a.number === b.number &&
a.resourceType === b.resourceType
);
}
/**
* Check if PR badge-relevant fields have changed (excluding lastCheckedAt).
*/
export function hasPrBadgeFieldsChanged(
current: PrInfo | undefined,
next: Omit<PrInfo, "lastCheckedAt">,
): boolean {
if (!current) return true;
return (
current.url !== next.url ||
current.number !== next.number ||
current.status !== next.status ||
current.title !== next.title ||
current.headBranch !== next.headBranch ||
current.baseBranch !== next.baseBranch ||
current.commentCount !== next.commentCount ||
current.lastCommentAt !== next.lastCommentAt
);
}
/**
* Check if issue badge-relevant fields have changed (excluding lastCheckedAt).
*/
export function hasIssueBadgeFieldsChanged(
current: IssueInfo | undefined,
next: Omit<IssueInfo, "lastCheckedAt">,
): boolean {
if (!current) return true;
return (
current.url !== next.url ||
current.number !== next.number ||
current.state !== next.state ||
current.title !== next.title ||
current.stateReason !== next.stateReason
);
}
/**
* GitHub App installation token response.
*/
interface InstallationTokenResponse {
token: string;
expires_at: string;
}
/**
* Generate a JWT for GitHub App authentication.
* The JWT is used to request an installation access token.
*/
async function generateAppJWT(appId: string, privateKey: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const expiration = now + 600; // 10 minutes (GitHub requires < 10 min)
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iat: now - 60, // Issued 1 minute ago to account for clock skew
exp: expiration,
iss: appId,
};
const encodedHeader = Buffer.from(JSON.stringify(header))
.toString("base64url");
const encodedPayload = Buffer.from(JSON.stringify(payload))
.toString("base64url");
const signingInput = `${encodedHeader}.${encodedPayload}`;
const { createSign } = await import("node:crypto");
const signature = createSign("RSA-SHA256")
.update(signingInput)
.sign(privateKey, "base64url");
return `${signingInput}.${signature}`;
}
/**
* Fetch an installation access token for a GitHub App.
* This token is used to make API calls on behalf of the app installation.
*/
export async function fetchInstallationToken(
installationId: number,
appId: string,
privateKey: string,
): Promise<string | null> {
try {
const jwt = await generateAppJWT(appId, privateKey);
const response = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${jwt}`,
"User-Agent": "kb-dashboard/1.0",
},
},
);
if (!response.ok) {
return null;
}
const data = await response.json() as InstallationTokenResponse;
return data.token;
} catch {
return null;
}
}
/**
* Fetch canonical badge state for a PR using GitHub App installation token.
* This uses the same normalization as the standard GitHubClient but with
* App authentication instead of gh CLI or user token.
*/
export async function fetchCanonicalPrInfo(
owner: string,
repo: string,
number: number,
installationToken: string,
): Promise<Omit<PrInfo, "lastCheckedAt"> | null> {
try {
// Fetch PR data via REST API with installation token
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${installationToken}`,
"User-Agent": "kb-dashboard/1.0",
},
},
);
if (!response.ok) {
return null;
}
const data = await response.json() as {
number: number;
html_url: string;
title: string;
state: string;
merged: boolean;
head: { ref: string };
base: { ref: string };
comments: number;
updated_at: string;
};
// Fetch comment count separately if needed (REST API includes it)
return {
url: data.html_url,
number: data.number,
status: data.merged ? "merged" : data.state === "open" ? "open" : "closed",
title: data.title,
headBranch: data.head.ref,
baseBranch: data.base.ref,
commentCount: data.comments,
lastCommentAt: data.updated_at,
};
} catch {
return null;
}
}
/**
* Fetch canonical badge state for an issue using GitHub App installation token.
*/
export async function fetchCanonicalIssueInfo(
owner: string,
repo: string,
number: number,
installationToken: string,
): Promise<Omit<IssueInfo, "lastCheckedAt"> | null> {
try {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${installationToken}`,
"User-Agent": "kb-dashboard/1.0",
},
},
);
if (!response.ok) {
return null;
}
const data = await response.json() as {
number: number;
html_url: string;
title: string;
state: string;
state_reason?: "completed" | "not_planned" | "reopened" | null;
pull_request?: unknown;
};
// Skip PRs - they come through the issues endpoint too
if (data.pull_request) {
return null;
}
return {
url: data.html_url,
number: data.number,
state: data.state === "open" ? "open" : "closed",
title: data.title,
stateReason: data.state_reason ?? undefined,
};
} catch {
return null;
}
}
/**
* Webhook handler result indicating what action was taken.
*/
export interface WebhookHandlerResult {
/** Whether the webhook was accepted (signature valid) */
accepted: boolean;
/** HTTP status code to return */
statusCode: number;
/** Tasks that were updated (for logging/telemetry) */
updatedTaskIds: string[];
/** Whether any badge-relevant fields actually changed */
badgeFieldsChanged: boolean;
/** Error message if acceptance failed */
error?: string;
}

View File

@@ -993,7 +993,7 @@ export class GitHubClient {
number: data.number,
state: this.mapIssueState(data.state),
title: data.title,
stateReason: data.state_reason,
stateReason: data.state_reason ?? undefined,
};
}
@@ -1463,9 +1463,182 @@ export class GitHubClient {
title: data.title,
body: data.body,
state: this.mapIssueState(data.state),
stateReason: data.state_reason,
stateReason: data.state_reason ?? undefined,
};
}
// ==========================================
// GitHub App Installation Auth Methods
// ==========================================
/**
* Generate a JWT for GitHub App authentication.
* Used to request installation access tokens.
*/
static async generateAppJWT(appId: string, privateKey: string): Promise<string> {
const { createSign } = await import("node:crypto");
const now = Math.floor(Date.now() / 1000);
const expiration = now + 600; // 10 minutes max per GitHub requirements
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iat: now - 60, // 1 minute ago to account for clock skew
exp: expiration,
iss: appId,
};
const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = createSign("RSA-SHA256")
.update(signingInput)
.sign(privateKey, "base64url");
return `${signingInput}.${signature}`;
}
/**
* Fetch an installation access token for a GitHub App.
* This token is used to make API calls on behalf of the app installation.
*/
static async fetchInstallationToken(
installationId: number,
appId: string,
privateKey: string,
): Promise<string | null> {
try {
const jwt = await GitHubClient.generateAppJWT(appId, privateKey);
const response = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${jwt}`,
"User-Agent": "kb-dashboard/1.0",
},
},
);
if (!response.ok) {
return null;
}
const data = await response.json() as { token: string };
return data.token;
} catch {
return null;
}
}
/**
* Fetch canonical PR info using GitHub App installation authentication.
* This bypasses the gh CLI and user tokens for webhook-driven updates.
*/
static async fetchPrWithInstallationToken(
owner: string,
repo: string,
number: number,
installationToken: string,
): Promise<Omit<PrInfo, "lastCheckedAt"> | null> {
try {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls/${number}`,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${installationToken}`,
"User-Agent": "kb-dashboard/1.0",
},
},
);
if (!response.ok) {
return null;
}
const data = await response.json() as {
number: number;
html_url: string;
title: string;
state: string;
merged: boolean;
head: { ref: string };
base: { ref: string };
comments: number;
updated_at: string;
};
return {
url: data.html_url,
number: data.number,
status: data.merged ? "merged" : data.state === "open" ? "open" : "closed",
title: data.title,
headBranch: data.head.ref,
baseBranch: data.base.ref,
commentCount: data.comments,
lastCommentAt: data.updated_at,
};
} catch {
return null;
}
}
/**
* Fetch canonical issue info using GitHub App installation authentication.
*/
static async fetchIssueWithInstallationToken(
owner: string,
repo: string,
number: number,
installationToken: string,
): Promise<Omit<IssueInfo, "lastCheckedAt"> | null> {
try {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${number}`,
{
headers: {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${installationToken}`,
"User-Agent": "kb-dashboard/1.0",
},
},
);
if (!response.ok) {
return null;
}
const data = await response.json() as {
number: number;
html_url: string;
title: string;
state: string;
state_reason?: "completed" | "not_planned" | "reopened" | null;
pull_request?: unknown;
};
// Skip PRs - they come through the issues endpoint too
if (data.pull_request) {
return null;
}
return {
url: data.html_url,
number: data.number,
state: data.state === "open" ? "open" : "closed",
title: data.title,
stateReason: data.state_reason ?? undefined,
};
} catch {
return null;
}
}
}
function buildBadgeBatchQuery(requests: BadgeBatchRequest[]): string {
@@ -1593,6 +1766,58 @@ function mapGraphQlBatchIssueStateReason(
}
}
/**
* Parse a GitHub badge URL (PR or issue) into its components.
* Supports formats like:
* - https://github.com/owner/repo/pull/123
* - https://github.com/owner/repo/issues/123
*
* This is a shared helper used by routes.ts, server.ts, and the webhook handler
* to ensure consistent badge URL parsing across the codebase.
*/
export function parseBadgeUrl(url: string): { owner: string; repo: string; number: number; resourceType: "pr" | "issue" } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") {
return null;
}
const pathParts = parsed.pathname.split("/").filter(Boolean);
if (pathParts.length < 4) {
return null;
}
const [owner, repo, type, numberStr] = pathParts;
const number = parseInt(numberStr, 10);
if (!owner || !repo || !Number.isFinite(number) || number < 1) {
return null;
}
let resourceType: "pr" | "issue";
if (type === "pull") {
resourceType = "pr";
} else if (type === "issues") {
resourceType = "issue";
} else {
return null;
}
return { owner, repo, number, resourceType };
} catch {
return null;
}
}
/**
* @deprecated Use parseBadgeUrl instead
*/
export function parseGitHubBadgeUrl(url: string): { owner: string; repo: string } | null {
const parsed = parseBadgeUrl(url);
if (!parsed) return null;
return { owner: parsed.owner, repo: parsed.repo };
}
/**
* Extract owner/repo from a GitHub remote URL or return null if not a GitHub remote.
* @deprecated Use parseRepoFromRemote from gh-cli.ts instead

View File

@@ -5,12 +5,21 @@ import { execSync } from "node:child_process";
import type { TaskStore, Column, MergeResult } from "@kb/core";
import { COLUMNS, VALID_TRANSITIONS, type PrInfo, isGhAuthenticated } from "@kb/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo } from "./github.js";
import { githubPoller as globalGithubPoller, GitHubPollingService, githubRateLimiter } from "./github-poll.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse } from "./file-service.js";
import { fetchAllProviderUsage } from "./usage.js";
import {
getGitHubAppConfig,
verifyWebhookSignature,
classifyWebhookEvent,
isSameResource,
hasPrBadgeFieldsChanged,
hasIssueBadgeFieldsChanged,
type BadgeUrlComponents,
} from "./github-webhooks.js";
/**
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
@@ -541,9 +550,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Get GitHub token from options or env
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
// Use injected githubPoller if provided, otherwise use global singleton
const githubPoller = options?.githubPoller ?? globalGithubPoller;
// Scheduler config (includes persisted settings)
router.get("/config", async (_req, res) => {
@@ -1742,9 +1748,177 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
/**
* POST /api/github/webhooks
* GitHub App webhook endpoint for badge updates.
* Accepts signed webhook deliveries for pull_request, issues, and issue_comment events.
* Verifies X-Hub-Signature-256, fetches canonical badge state, and updates matching tasks.
*
* Responses:
* - 200: Valid ping event
* - 202: Valid but unsupported/irrelevant event
* - 401: Missing required webhook auth headers
* - 403: Signature mismatch/tampering detected
* - 503: GitHub App configuration missing or incomplete
* - 500: Installation token refresh failed
*/
router.post("/github/webhooks", async (req, res) => {
const config = getGitHubAppConfig();
if (!config) {
res.status(503).json({ error: "GitHub App not configured" });
return;
}
// Get raw body (Buffer from express.raw() middleware)
const rawBody = req.body as Buffer;
if (!Buffer.isBuffer(rawBody)) {
res.status(400).json({ error: "Invalid request body" });
return;
}
// Verify signature
const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined;
const verification = verifyWebhookSignature(rawBody, signatureHeader, config.webhookSecret);
if (!verification.valid) {
res.status(403).json({ error: verification.error ?? "Invalid signature" });
return;
}
// Parse payload after verification
let payload: unknown;
try {
payload = JSON.parse(rawBody.toString("utf-8"));
} catch {
res.status(400).json({ error: "Invalid JSON payload" });
return;
}
// Classify event
const eventType = req.headers["x-github-event"] as string | undefined;
const classification = classifyWebhookEvent(eventType, payload);
// Handle ping
if (eventType === "ping") {
res.status(200).json({ message: "Pong" });
return;
}
// Unsupported event
if (!classification.supported) {
res.status(202).json({ message: "Event type not supported" });
return;
}
// Not relevant for badge updates (e.g., issue_comment on regular issue)
if (!classification.relevant) {
res.status(202).json({ message: "Event not relevant for badges" });
return;
}
// Missing required data
if (!classification.owner || !classification.repo || classification.number === undefined || !classification.installationId) {
res.status(400).json({ error: "Missing repository or installation data" });
return;
}
// Fetch installation token
const installationToken = await GitHubClient.fetchInstallationToken(
classification.installationId,
config.appId,
config.privateKey,
);
if (!installationToken) {
res.status(500).json({ error: "Failed to fetch installation token" });
return;
}
// Fetch canonical badge state
let badgeData: Omit<PrInfo, "lastCheckedAt"> | Omit<import("@kb/core").IssueInfo, "lastCheckedAt"> | null = null;
if (classification.resourceType === "pr") {
badgeData = await GitHubClient.fetchPrWithInstallationToken(
classification.owner,
classification.repo,
classification.number,
installationToken,
);
} else {
badgeData = await GitHubClient.fetchIssueWithInstallationToken(
classification.owner,
classification.repo,
classification.number,
installationToken,
);
}
if (!badgeData) {
res.status(202).json({ message: "Badge resource not found or inaccessible" });
return;
}
// Find all matching tasks by badge URL
const tasks = await store.listTasks();
const matchingTasks: Array<{ id: string; resourceType: "pr" | "issue"; current: unknown }> = [];
for (const task of tasks) {
if (classification.resourceType === "pr" && task.prInfo) {
const parsed = parseBadgeUrl(task.prInfo.url);
if (parsed &&
parsed.owner.toLowerCase() === classification.owner!.toLowerCase() &&
parsed.repo.toLowerCase() === classification.repo!.toLowerCase() &&
parsed.number === classification.number) {
matchingTasks.push({ id: task.id, resourceType: "pr", current: task.prInfo });
}
} else if (classification.resourceType === "issue" && task.issueInfo) {
const parsed = parseBadgeUrl(task.issueInfo.url);
if (parsed &&
parsed.owner.toLowerCase() === classification.owner!.toLowerCase() &&
parsed.repo.toLowerCase() === classification.repo!.toLowerCase() &&
parsed.number === classification.number) {
matchingTasks.push({ id: task.id, resourceType: "issue", current: task.issueInfo });
}
}
}
if (matchingTasks.length === 0) {
res.status(202).json({ message: "No tasks linked to this resource" });
return;
}
// Update matching tasks
const checkedAt = new Date().toISOString();
let badgeFieldsChanged = false;
for (const match of matchingTasks) {
if (match.resourceType === "pr") {
const current = match.current as PrInfo;
const next = { ...(badgeData as Omit<PrInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
const changed = hasPrBadgeFieldsChanged(current, badgeData as Omit<PrInfo, "lastCheckedAt">);
if (changed || current.lastCheckedAt !== checkedAt) {
await store.updatePrInfo(match.id, next);
if (changed) badgeFieldsChanged = true;
}
} else {
const current = match.current as import("@kb/core").IssueInfo;
const next = { ...(badgeData as Omit<import("@kb/core").IssueInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
const changed = hasIssueBadgeFieldsChanged(current, badgeData as Omit<import("@kb/core").IssueInfo, "lastCheckedAt">);
if (changed || current.lastCheckedAt !== checkedAt) {
await store.updateIssueInfo(match.id, next);
if (changed) badgeFieldsChanged = true;
}
}
}
res.status(200).json({
updated: matchingTasks.length,
tasks: matchingTasks.map(m => m.id),
badgeFieldsChanged,
});
});
/**
* GET /api/tasks/:id/pr/status
* Get cached PR status for a task. Triggers background refresh if stale (>5 min).
* Uses only persisted badge timestamps (no in-memory poller state).
*/
router.get("/tasks/:id/pr/status", async (req, res) => {
try {
@@ -1757,9 +1931,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Check if data is stale (>5 minutes since last check)
const fiveMinutesMs = 5 * 60 * 1000;
const lastChecked = githubPoller.getLastCheckedAt(task.id, "pr")
|| task.prInfo.lastCheckedAt
|| task.updatedAt;
const lastChecked = task.prInfo.lastCheckedAt || task.updatedAt;
const lastCheckedTime = new Date(lastChecked).getTime();
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
@@ -1797,23 +1969,29 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
return;
}
// Get owner/repo from git remote or GITHUB_REPOSITORY env
// Get owner/repo from badge URL first, then fall back to env/git
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
const badgeParsed = parseBadgeUrl(task.prInfo.url);
if (badgeParsed) {
owner = badgeParsed.owner;
repo = badgeParsed.repo;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) {
res.status(400).json({ error: "Could not determine GitHub repository" });
return;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) {
res.status(400).json({ error: "Could not determine GitHub repository" });
return;
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
// Check rate limit
@@ -1861,6 +2039,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* GET /api/tasks/:id/issue/status
* Get cached issue status for a task. Triggers background refresh if stale (>5 min).
* Uses only persisted badge timestamps (no in-memory poller state).
*/
router.get("/tasks/:id/issue/status", async (req, res) => {
try {
@@ -1872,9 +2051,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
const fiveMinutesMs = 5 * 60 * 1000;
const lastChecked = githubPoller.getLastCheckedAt(task.id, "issue")
|| task.issueInfo.lastCheckedAt
|| task.updatedAt;
const lastChecked = task.issueInfo.lastCheckedAt || task.updatedAt;
const lastCheckedTime = new Date(lastChecked).getTime();
const isStale = Date.now() - lastCheckedTime > fiveMinutesMs;
@@ -1912,19 +2089,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
// Get owner/repo from badge URL first, then fall back to env/git
const badgeParsed = parseBadgeUrl(task.issueInfo.url);
if (badgeParsed) {
owner = badgeParsed.owner;
repo = badgeParsed.repo;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) {
res.status(400).json({ error: "Could not determine GitHub repository" });
return;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) {
res.status(400).json({ error: "Could not determine GitHub repository" });
return;
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
const repoKey = `${owner}/${repo}`;
@@ -2577,23 +2761,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* Background PR refresh - updates PR status without blocking the response.
* Silently logs errors without affecting the user experience.
* Prefers badge URL for repo resolution to support multi-repo setups.
*/
async function refreshPrInBackground(store: TaskStore, taskId: string, currentPrInfo: PrInfo, token?: string): Promise<void> {
try {
// Get owner/repo from git remote or GITHUB_REPOSITORY env
// Get owner/repo from badge URL first, then fall back to env/git
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
const badgeParsed = parseBadgeUrl(currentPrInfo.url);
if (badgeParsed) {
owner = badgeParsed.owner;
repo = badgeParsed.repo;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) return; // Silent fail - can't determine repo
owner = gitRepo.owner;
repo = gitRepo.repo;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) return; // Silent fail - can't determine repo
owner = gitRepo.owner;
repo = gitRepo.repo;
}
}
const repoKey = `${owner}/${repo}`;
@@ -2621,16 +2812,23 @@ async function refreshIssueInBackground(
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
// Get owner/repo from badge URL first, then fall back to env/git
const badgeParsed = parseBadgeUrl(currentIssueInfo.url);
if (badgeParsed) {
owner = badgeParsed.owner;
repo = badgeParsed.repo;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) return;
owner = gitRepo.owner;
repo = gitRepo.repo;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
owner = o;
repo = r;
} else {
const gitRepo = getCurrentGitHubRepo(store.getRootDir());
if (!gitRepo) return;
owner = gitRepo.owner;
repo = gitRepo.repo;
}
}
const repoKey = `${owner}/${repo}`;

View File

@@ -11,8 +11,7 @@ import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { getTerminalService, type TerminalSession } from "./terminal-service.js";
import { WebSocketServer, type WebSocket } from "ws";
import { terminalSessionManager } from "./terminal.js";
import { getCurrentGitHubRepo } from "./github.js";
import { githubPoller as globalGithubPoller, GitHubPollingService, type TaskWatchInput } from "./github-poll.js";
import { getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { WebSocketManager, type BadgeSnapshot } from "./websocket.js";
import type { BadgePubSub } from "./badge-pubsub.js";
import { createBadgePubSub, type BadgePubSubMessage } from "./badge-pubsub.js";
@@ -32,8 +31,6 @@ export interface ServerOptions {
modelRegistry?: ModelRegistryLike;
/** Optional BadgePubSub adapter for cross-instance badge snapshot fan-out — if not provided, creates from env or falls back to in-memory */
badgePubSub?: BadgePubSub;
/** Optional GitHubPollingService instance for per-server poller isolation — if not provided, uses the global singleton */
githubPoller?: GitHubPollingService;
}
type DashboardExpressApp = ReturnType<typeof express> & {
@@ -45,6 +42,12 @@ type DashboardExpressApp = ReturnType<typeof express> & {
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
const app = express();
// Raw body buffer for webhook signature verification - must be before express.json()
// Only applied to the webhook route
app.use("/api/github/webhooks", express.raw({ type: "application/json" }));
// Standard JSON parsing for all other routes
app.use(express.json());
// Initialize terminal service with project root
@@ -367,14 +370,6 @@ export function setupBadgeWebSocket(
// Use injected badgePubSub or create from environment
const badgePubSub = options?.badgePubSub ?? createBadgePubSub({ sourceId: serverId });
void badgePubSub.start();
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
const githubPoller = options?.githubPoller ?? globalGithubPoller;
githubPoller.configure({
store,
token: githubToken,
});
// Prime cache with existing tasks
void store.listTasks().then((tasks) => {
@@ -405,50 +400,6 @@ export function setupBadgeWebSocket(
dashboardApp.badgeWsServer = wss;
dashboardApp.badgeWsManager = wsManager;
const syncPollerTask = async (taskId: string): Promise<void> => {
if (wsManager.getSubscriptionCount(taskId) === 0) {
githubPoller.unwatchTask(taskId);
return;
}
try {
const task = await store.getTask(taskId);
const watches: TaskWatchInput[] = [];
if (task.prInfo) {
const repo = resolveBadgeRepo(task.prInfo.url, store);
if (repo) {
watches.push({
taskId: task.id,
type: "pr",
owner: repo.owner,
repo: repo.repo,
number: task.prInfo.number,
});
}
}
if (task.issueInfo) {
const repo = resolveBadgeRepo(task.issueInfo.url, store);
if (repo) {
watches.push({
taskId: task.id,
type: "issue",
owner: repo.owner,
repo: repo.repo,
number: task.issueInfo.number,
});
}
}
githubPoller.replaceTaskWatches(task.id, watches);
} catch (err: any) {
if (err?.code === "ENOENT") {
githubPoller.unwatchTask(taskId);
}
}
};
const broadcastBadgeSnapshot = (taskId: string, snapshot: BadgeSnapshot): void => {
wsManager.broadcastBadgeUpdate(taskId, snapshot);
};
@@ -483,7 +434,6 @@ export function setupBadgeWebSocket(
// Broadcast to local websocket subscribers if any
if (wsManager.getSubscriptionCount(task.id) > 0) {
broadcastBadgeSnapshot(task.id, nextSnapshot);
void syncPollerTask(task.id);
}
};
@@ -497,7 +447,6 @@ export function setupBadgeWebSocket(
const onTaskDeleted = (task: Task) => {
badgeSnapshots.delete(task.id);
githubPoller.unwatchTask(task.id);
};
store.on("task:updated", onTaskUpdated);
@@ -521,32 +470,15 @@ export function setupBadgeWebSocket(
}
});
wsManager.on("client:connected", (_clientId, totalClients) => {
if (totalClients === 1) {
githubPoller.start();
}
});
wsManager.on("client:disconnected", (_clientId, totalClients) => {
if (totalClients === 0) {
githubPoller.stop();
}
});
wsManager.on("subscription:changed", (taskId, subscriberCount) => {
if (subscriberCount === 0) {
githubPoller.unwatchTask(taskId);
return;
}
// Send cached snapshot to late subscriber if available
// This ensures a client subscribing after a remote update still sees the latest state
const cachedSnapshot = badgeSnapshots.get(taskId);
if (cachedSnapshot) {
broadcastBadgeSnapshot(taskId, cachedSnapshot);
if (subscriberCount > 0) {
const cachedSnapshot = badgeSnapshots.get(taskId);
if (cachedSnapshot) {
broadcastBadgeSnapshot(taskId, cachedSnapshot);
}
}
void syncPollerTask(taskId);
});
wss.on("connection", (ws: WebSocket) => {
@@ -564,7 +496,6 @@ export function setupBadgeWebSocket(
wsManager.dispose();
void badgePubSub.dispose();
githubPoller.reset();
wss.close();
dashboardApp.terminalWsServer = null;
dashboardApp.badgeWsServer = null;
@@ -592,38 +523,3 @@ function snapshotsEqual(a: BadgeSnapshot | undefined, b: BadgeSnapshot | undefin
return true;
}
function resolveBadgeRepo(url: string, store: TaskStore): { owner: string; repo: string } | null {
const parsedUrl = parseGitHubBadgeUrl(url);
if (parsedUrl) {
return parsedUrl;
}
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [owner, repo] = envRepo.split("/");
if (owner && repo) {
return { owner, repo };
}
}
return getCurrentGitHubRepo(store.getRootDir());
}
function parseGitHubBadgeUrl(url: string): { owner: string; repo: string } | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") {
return null;
}
const [owner, repo] = parsed.pathname.split("/").filter(Boolean);
if (!owner || !repo) {
return null;
}
return { owner, repo };
} catch {
return null;
}
}