Files
fusion/packages/dashboard/src/github-webhooks.ts
2026-04-16 11:08:06 -07:00

548 lines
15 KiB
TypeScript

import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import type { IssueInfo, PrInfo } from "@fusion/core";
// Module-level cache for the GitHub App private key
// undefined = not yet read, null = read failed, string = cached key
let cachedPrivateKey: string | null | undefined = undefined;
/**
* Clear the private key cache (for testing).
*/
export function _clearPrivateKeyCache(): void {
cachedPrivateKey = undefined;
}
/**
* 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 FUSION_GITHUB_APP_PRIVATE_KEY or FUSION_GITHUB_APP_PRIVATE_KEY_PATH.
*/
export function getGitHubAppConfig(): GitHubAppConfig | null {
const appId = process.env.FUSION_GITHUB_APP_ID;
const webhookSecret = process.env.FUSION_GITHUB_WEBHOOK_SECRET;
let privateKey: string | undefined;
if (process.env.FUSION_GITHUB_APP_PRIVATE_KEY) {
privateKey = process.env.FUSION_GITHUB_APP_PRIVATE_KEY;
} else if (process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH) {
// Check cache before reading from disk.
// Intentionally synchronous: this is a one-time cold-load at server startup or
// on first webhook request. The result is cached in cachedPrivateKey, so the
// readFileSync only executes once per process lifetime.
if (cachedPrivateKey === undefined) {
try {
cachedPrivateKey = readFileSync(process.env.FUSION_GITHUB_APP_PRIVATE_KEY_PATH, "utf-8");
} catch {
// Failed to read key file
cachedPrivateKey = null;
}
}
privateKey = cachedPrivateKey ?? undefined;
}
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": "fn/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": "fn/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": "fn/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;
}