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 297013000f
commit f78f01ac5b
12 changed files with 2100 additions and 579 deletions

View File

@@ -0,0 +1,26 @@
---
"@dustinbyrne/kb": patch
---
Replace GitHub badge polling with GitHub App webhook ingestion
- Added `POST /api/github/webhooks` endpoint for verified GitHub App webhook delivery
- Webhook signature verification using `X-Hub-Signature-256` header
- Support for `pull_request`, `issues`, and `issue_comment` (on PRs) events
- GitHub App installation token authentication for canonical badge fetches
- Multi-task resource matching by parsed badge URL (supports cross-repo badges)
- Idempotent freshness updates without duplicate websocket broadcasts
- Retained 5-minute REST refresh endpoints as fallback path
- Removed live `/api/ws` dependency on `GitHubPollingService` lifecycle
**Configuration:**
- `KB_GITHUB_APP_ID` - GitHub App ID
- `KB_GITHUB_APP_PRIVATE_KEY` or `KB_GITHUB_APP_PRIVATE_KEY_PATH` - PEM private key
- `KB_GITHUB_WEBHOOK_SECRET` - Webhook secret for signature verification
**Required GitHub App Permissions:**
- Metadata: Read
- Pull requests: Read
- Issues: Read
**Webhook Events:** `pull_request`, `issues`, `issue_comment`

View File

@@ -74,8 +74,9 @@ Use `useBadgeWebSocket()` when a UI surface needs live badge snapshots for speci
- `/api/ws` is badge-specific; do **not** reuse it for general task updates.
- Badge broadcasts should contain only `prInfo` / `issueInfo` snapshot data, never full task objects.
- WebSocket subscription changes drive the focused GitHub poller so only actively viewed badge-linked tasks are polled.
- Keep the existing 5-minute refresh endpoints as a fallback path when websocket delivery is unavailable.
- Badge updates are now **push-based via GitHub App webhooks** at `POST /api/github/webhooks`.
- The server verifies webhook signatures using `KB_GITHUB_WEBHOOK_SECRET`, fetches canonical badge state with GitHub App installation tokens, and broadcasts updates via the existing `task:updated` → `/api/ws` bridge.
- Keep the existing 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) as a fallback path when webhook delivery is unavailable.
## Git

View File

@@ -42,7 +42,42 @@ export KB_BADGE_PUBSUB_CHANNEL="my-app-badge-updates"
kb dashboard
```
With this configuration, PR/issue badge updates detected on one instance are delivered to subscribed WebSocket clients on other instances. Each instance maintains its own GitHub polling, only the badge snapshots are shared.
With this configuration, PR/issue badge updates received via webhook on one instance are delivered to subscribed WebSocket clients on all instances.
### GitHub App Webhook Setup
For real-time PR/issue badge updates, configure a GitHub App to push updates to the dashboard:
**Required Environment Variables:**
```bash
export KB_GITHUB_APP_ID="123456"
export KB_GITHUB_APP_PRIVATE_KEY_PATH="/path/to/private-key.pem"
# Or: export KB_GITHUB_APP_PRIVATE_KEY="$(cat /path/to/private-key.pem)"
export KB_GITHUB_WEBHOOK_SECRET="your-webhook-secret"
```
**GitHub App Configuration:**
1. Create a GitHub App at Settings → Developer settings → GitHub Apps
2. Set the **Webhook URL** to `https://your-domain/api/github/webhooks`
3. Generate and download a **Private Key**
4. Configure these **Permissions**:
- Metadata: Read
- Pull requests: Read
- Issues: Read
5. Subscribe to these **Webhook Events**:
- Pull request
- Issues
- Issue comment
**Minimum Permissions Summary:**
| Permission | Level | Purpose |
|------------|-------|---------|
| Metadata | Read | Access repository metadata |
| Pull requests | Read | Fetch PR status, title, comments |
| Issues | Read | Fetch issue status, title, state |
**Fallback Behavior:**
When webhooks are not configured or delivery fails, the dashboard falls back to the 5-minute background refresh on the PR/issue status endpoints. The 5-minute staleness window ensures reasonably fresh data even without webhooks.
### Create a task

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;
}
}