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