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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
539
packages/dashboard/src/__tests__/github-webhooks.test.ts
Normal file
539
packages/dashboard/src/__tests__/github-webhooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
382
packages/dashboard/src/__tests__/server-webhook.test.ts
Normal file
382
packages/dashboard/src/__tests__/server-webhook.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
530
packages/dashboard/src/github-webhooks.ts
Normal file
530
packages/dashboard/src/github-webhooks.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user