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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user