import crypto from "crypto";
/**
* Verify GitHub webhook signature using HMAC SHA-256
*
* @param payload - Raw request body (string)
* @param signature - X-Hub-Signature-256 header value
* @param secret - GITHUB_WEBHOOK_SECRET
* @returns true if signature is valid
*/
export function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
if (!signature || !signature.startsWith("sha256=")) {
return false;
}
const hmac = crypto.createHmac("sha256", secret);
const digest = "sha256=" + hmac.update(payload).digest("hex");
// Timing-safe comparison to prevent timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
} catch (e) {
// timingSafeEqual throws if lengths don't match
return false;
}
}
/**
* GitHub webhook event types we care about
*/
export type GitHubEventType =
| "release"
| "issues"
| "issue_comment"
| "pull_request"
| "pull_request_review"
| "pull_request_review_comment"
| "push"
| "star"
| "fork"
| "watch";
/**
* Parse and validate webhook event
*/
export interface WebhookEvent {
type: string;
action?: string;
repository: {
full_name: string;
html_url: string;
};
sender: {
login: string;
};
payload: any;
}
/**
* Extract structured event data from webhook payload
*/
export function parseWebhookEvent(
eventType: string,
payload: any
): WebhookEvent | null {
try {
// Validate required fields
if (!payload.repository || !payload.sender) {
console.error("Missing required fields in webhook payload");
return null;
}
return {
type: eventType,
action: payload.action,
repository: {
full_name: payload.repository.full_name,
html_url: payload.repository.html_url,
},
sender: {
login: payload.sender.login,
},
payload,
};
} catch (error) {
console.error("Error parsing webhook event:", error);
return null;
}
}
/**
* Format webhook event as human-readable message for Discord
*/
export function formatEventMessage(event: WebhookEvent): string {
const { type, action, repository, sender, payload } = event;
const repo = repository.full_name;
switch (type) {
case "release":
return `π **New release in ${repo}**\n` +
`**${payload.release.name || payload.release.tag_name}**\n` +
`By @${sender.login}\n` +
`${payload.release.html_url}`;
case "issues":
if (action === "opened") {
return `π **Issue opened in ${repo}**\n` +
`**#${payload.issue.number}: ${payload.issue.title}**\n` +
`By @${sender.login}\n` +
`${payload.issue.html_url}`;
} else if (action === "labeled") {
const label = payload.label?.name;
if (label === "good first issue") {
return `π **Good first issue in ${repo}**\n` +
`**#${payload.issue.number}: ${payload.issue.title}**\n` +
`${payload.issue.html_url}`;
}
}
return `π Issue ${action} in ${repo}: #${payload.issue.number}`;
case "pull_request":
if (action === "opened") {
return `π **PR opened in ${repo}**\n` +
`**#${payload.pull_request.number}: ${payload.pull_request.title}**\n` +
`By @${sender.login}\n` +
`${payload.pull_request.html_url}`;
} else if (action === "closed" && payload.pull_request.merged) {
return `β
**PR merged in ${repo}**\n` +
`**#${payload.pull_request.number}: ${payload.pull_request.title}**\n` +
`${payload.pull_request.html_url}`;
}
return `π PR ${action} in ${repo}: #${payload.pull_request.number}`;
case "push":
const ref = payload.ref.replace("refs/heads/", "");
const commits = payload.commits?.length || 0;
return `π¦ **${commits} commit(s) pushed to ${repo}:${ref}**\n` +
`By @${sender.login}\n` +
`${payload.compare}`;
case "star":
return `β @${sender.login} starred ${repo}`;
default:
return `π ${type} event in ${repo} (${action || "no action"})`;
}
}
/**
* Check if event should be processed based on configuration
*
* @param event - Parsed webhook event
* @param subscriptions - Repository subscriptions (future: from config file)
* @returns true if event should trigger notification
*/
export function shouldProcessEvent(
event: WebhookEvent,
subscriptions: any = null
): boolean {
// For now, accept all events from any repo
// Phase 3.4 will add subscription filtering
// Filter out some noisy events
const ignoredEvents = ["watch", "fork"];
if (ignoredEvents.includes(event.type)) {
return false;
}
// Filter out some noisy actions
if (event.type === "issues" &&
event.action &&
!["opened", "labeled"].includes(event.action)) {
return false;
}
if (event.type === "pull_request" &&
event.action &&
!["opened", "closed"].includes(event.action)) {
return false;
}
return true;
}