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=" 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 | undefined; const repository = typedPayload?.repository as Record | undefined; const repositoryOwner = repository?.owner as Record | 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 | 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 | 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 | 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, ): 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, ): 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 { 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 { 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 | 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 | 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; }