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

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