diff --git a/app/api/cicd/alerts/route.ts b/app/api/cicd/alerts/route.ts new file mode 100644 index 000000000..0aad1c950 --- /dev/null +++ b/app/api/cicd/alerts/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { timingSafeEqual } from 'crypto'; +import { setAlertConfig } from '@/services/github/webhook-handler'; + +export const runtime = 'nodejs'; + +interface AlertConfigRequest { + repository: string; + enabled: boolean; + onFailure: boolean; + onSuccess: boolean; + webhookUrl?: string; + email?: string; +} + +function verifyAuthToken(request: NextRequest): boolean { + const authHeader = request.headers.get('authorization'); + if (!authHeader) return false; + + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : authHeader; + const expectedToken = process.env.CICD_ALERTS_SECRET || ''; + + if (!expectedToken) { + console.warn('CICD_ALERTS_SECRET not configured'); + return false; + } + + try { + return timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken)); + } catch { + return false; + } +} + +export async function POST(request: NextRequest) { + try { + if (!verifyAuthToken(request)) { + return NextResponse.json( + { error: 'Unauthorized: Invalid or missing authentication token' }, + { status: 401 } + ); + } + + const body: AlertConfigRequest = await request.json(); + + if (!body.repository) { + return NextResponse.json({ error: 'Repository is required' }, { status: 400 }); + } + + setAlertConfig(body.repository, { + enabled: body.enabled ?? true, + onFailure: body.onFailure ?? true, + onSuccess: body.onSuccess ?? false, + webhookUrl: body.webhookUrl, + email: body.email, + }); + + return NextResponse.json({ + success: true, + message: `Alert configuration saved for ${body.repository}`, + config: { + repository: body.repository, + enabled: body.enabled ?? true, + onFailure: body.onFailure ?? true, + onSuccess: body.onSuccess ?? false, + }, + }); + } catch (error) { + console.error('Alert configuration error:', error); + return NextResponse.json({ error: 'Failed to update alert configuration' }, { status: 500 }); + } +} + +export async function GET(request: NextRequest) { + return NextResponse.json({ + message: 'Alert configuration endpoint', + usage: { + method: 'POST', + body: { + repository: 'owner/repo', + enabled: true, + onFailure: true, + onSuccess: false, + webhookUrl: 'optional-webhook-url', + email: 'optional-email@example.com', + }, + }, + }); +} diff --git a/app/api/cicd/reports/route.ts b/app/api/cicd/reports/route.ts new file mode 100644 index 000000000..2e47ae86b --- /dev/null +++ b/app/api/cicd/reports/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generateCIReport } from '@/services/github/webhook-handler'; + +export const runtime = 'nodejs'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const repository = searchParams.get('repository'); + const period = (searchParams.get('period') || 'daily') as 'daily' | 'weekly' | 'monthly'; + + if (!repository) { + return NextResponse.json({ error: 'Repository parameter is required' }, { status: 400 }); + } + + if (!['daily', 'weekly', 'monthly'].includes(period)) { + return NextResponse.json( + { error: 'Invalid period. Use: daily, weekly, or monthly' }, + { status: 400 } + ); + } + + const report = generateCIReport([], period); + + return NextResponse.json({ + success: true, + report, + repository, + period, + }); + } catch (error) { + console.error('Report generation error:', error); + return NextResponse.json({ error: 'Failed to generate report' }, { status: 500 }); + } +} diff --git a/app/api/webhooks/cicd/route.ts b/app/api/webhooks/cicd/route.ts new file mode 100644 index 000000000..a10290023 --- /dev/null +++ b/app/api/webhooks/cicd/route.ts @@ -0,0 +1,71 @@ +import { createHmac, timingSafeEqual } from 'crypto'; +import { NextRequest, NextResponse } from 'next/server'; +import { + parseWebhookEvent, + cacheEvent, + evaluateAlerts, + generateCIReport, + setAlertConfig, +} from '@/services/github/webhook-handler'; + +export const runtime = 'nodejs'; + +function verifyGitHubSignature(request: NextRequest, payload: string): boolean { + const signature = request.headers.get('x-hub-signature-256'); + if (!signature) return false; + + const secret = process.env.WEBHOOK_SECRET || ''; + if (!secret) return false; + + const hmac = createHmac('sha256', secret); + hmac.update(payload); + const expected = `sha256=${hmac.digest('hex')}`; + + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +} + +export async function POST(request: NextRequest) { + try { + const payload = await request.text(); + + if (!verifyGitHubSignature(request, payload)) { + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + const event = JSON.parse(payload); + const ciEvent = parseWebhookEvent(event); + + if (!ciEvent) { + return NextResponse.json( + { message: 'Event not processed (not a CI/CD event)' }, + { status: 200 } + ); + } + + cacheEvent(ciEvent); + await evaluateAlerts(ciEvent); + + return NextResponse.json( + { + success: true, + event: { + type: ciEvent.type, + repository: ciEvent.repository, + status: ciEvent.status, + timestamp: ciEvent.timestamp, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error('Webhook processing error:', error); + return NextResponse.json({ error: 'Failed to process webhook' }, { status: 500 }); + } +} + +export async function GET(request: NextRequest) { + return NextResponse.json( + { message: 'CI/CD Webhook endpoint. POST GitHub events here.' }, + { status: 200 } + ); +} diff --git a/services/github/webhook-handler.test.ts b/services/github/webhook-handler.test.ts new file mode 100644 index 000000000..72acdc16f --- /dev/null +++ b/services/github/webhook-handler.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + parseWebhookEvent, + cacheEvent, + evaluateAlerts, + setAlertConfig, + generateCIReport, +} from './webhook-handler'; + +vi.mock('@/lib/cache', () => { + const store = new Map(); + const DistributedCache = vi.fn().mockImplementation(() => ({ + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: unknown) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => store.delete(key)), + })); + return { DistributedCache }; +}); + +const sampleWorkflowPayload = { + workflow_run: { + id: 42, + name: 'CI', + status: 'completed', + conclusion: 'success', + created_at: '2026-06-16T10:00:00Z', + updated_at: '2026-06-16T10:05:00Z', + run_number: 7, + repository: { + name: 'commitpulse', + full_name: 'JhaSourav07/commitpulse', + owner: { login: 'JhaSourav07', type: 'User' }, + }, + head_branch: 'main', + head_commit: { + id: 'abc1234567890', + message: 'fix: update tests', + timestamp: '2026-06-16T09:58:00Z', + author: { name: 'Anshul Jain', email: 'anshul23102@iiitd.ac.in' }, + }, + }, +}; + +describe('parseWebhookEvent', () => { + it('parses a workflow_run success event', () => { + const event = parseWebhookEvent(sampleWorkflowPayload); + expect(event).not.toBeNull(); + expect(event?.type).toBe('workflow_run'); + expect(event?.status).toBe('success'); + expect(event?.repository).toBe('JhaSourav07/commitpulse'); + }); + + it('parses a workflow_run failure event', () => { + const payload = { + workflow_run: { + ...sampleWorkflowPayload.workflow_run, + conclusion: 'failure', + }, + }; + const event = parseWebhookEvent(payload); + expect(event?.status).toBe('failure'); + }); + + it('parses in_progress status as pending', () => { + const payload = { + workflow_run: { + ...sampleWorkflowPayload.workflow_run, + status: 'in_progress', + conclusion: null, + }, + }; + const event = parseWebhookEvent(payload); + expect(event?.status).toBe('pending'); + }); + + it('returns null when no workflow_run or check_run in payload', () => { + const event = parseWebhookEvent({}); + expect(event).toBeNull(); + }); +}); + +describe('cacheEvent', () => { + it('stores event without throwing', async () => { + const event = parseWebhookEvent(sampleWorkflowPayload)!; + await expect(cacheEvent(event)).resolves.toBeUndefined(); + }); +}); + +describe('setAlertConfig and evaluateAlerts', () => { + beforeEach(async () => { + await setAlertConfig('JhaSourav07/commitpulse', { + enabled: true, + onFailure: true, + onSuccess: false, + }); + }); + + it('does not trigger alert for success when onSuccess is false', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const event = parseWebhookEvent(sampleWorkflowPayload)!; + await evaluateAlerts(event); + consoleSpy.mockRestore(); + }); + + it('triggers email alert for failure when onFailure is true', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const failPayload = { + workflow_run: { + ...sampleWorkflowPayload.workflow_run, + conclusion: 'failure', + }, + }; + const event = parseWebhookEvent(failPayload)!; + await setAlertConfig('JhaSourav07/commitpulse', { + enabled: true, + onFailure: true, + onSuccess: false, + email: 'test@example.com', + }); + await evaluateAlerts(event); + consoleSpy.mockRestore(); + }); +}); + +describe('generateCIReport', () => { + it('returns a report with correct structure for daily period', () => { + const events = [parseWebhookEvent(sampleWorkflowPayload)!]; + const report = generateCIReport(events, 'daily'); + expect(report.period).toBe('daily'); + expect(typeof report.totalEvents).toBe('number'); + expect(report.repositories).toBeDefined(); + }); + + it('counts success and failure correctly', () => { + const successEvent = parseWebhookEvent(sampleWorkflowPayload)!; + const failureEvent = parseWebhookEvent({ + workflow_run: { + ...sampleWorkflowPayload.workflow_run, + conclusion: 'failure', + }, + })!; + + const report = generateCIReport([successEvent, failureEvent], 'weekly') as { + repositories: Record; + }; + + const repo = report.repositories['JhaSourav07/commitpulse']; + expect(repo).toBeDefined(); + expect(repo.total).toBe(2); + expect(repo.success).toBe(1); + expect(repo.failure).toBe(1); + expect(repo.successRate).toBe('50.0%'); + }); + + it('returns zero counts for events outside the period window', () => { + const oldEvent: Parameters[0][0] = { + type: 'workflow_run', + repository: 'JhaSourav07/commitpulse', + timestamp: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(), + status: 'success', + details: { id: 1 }, + }; + const report = generateCIReport([oldEvent], 'daily') as { totalEvents: number }; + expect(report.totalEvents).toBe(0); + }); +}); diff --git a/services/github/webhook-handler.ts b/services/github/webhook-handler.ts new file mode 100644 index 000000000..bc6ba9051 --- /dev/null +++ b/services/github/webhook-handler.ts @@ -0,0 +1,267 @@ +import { DistributedCache } from '@/lib/cache'; +import type { CIWorkflowRun, CIInsights } from '@/types/ci-analytics'; + +interface WebhookPayload { + action?: string; + workflow_run?: { + id: number; + name: string; + status: string; + conclusion: string | null; + created_at: string; + updated_at: string; + run_number: number; + repository: { + name: string; + full_name: string; + owner: { + login: string; + type: string; + }; + }; + head_branch: string; + head_commit: { + id: string; + message: string; + timestamp: string; + author: { + name: string; + email: string; + }; + }; + }; + pull_request?: { + number: number; + title: string; + head: { + ref: string; + }; + base: { + ref: string; + }; + }; + check_run?: { + id: number; + name: string; + status: string; + conclusion: string | null; + started_at: string; + completed_at: string | null; + }; +} + +interface CIEvent { + type: 'workflow_run' | 'check_run' | 'push' | 'pull_request'; + repository: string; + timestamp: string; + status: 'success' | 'failure' | 'pending' | 'skipped'; + details: Record; +} + +interface AlertConfig { + enabled: boolean; + onFailure: boolean; + onSuccess: boolean; + webhookUrl?: string; + email?: string; +} + +const eventCache = new DistributedCache(1000); +const alertCache = new DistributedCache(100); + +function normalizeWorkflowStatus(status: string, conclusion: string | null): CIEvent['status'] { + if (status === 'in_progress' || status === 'queued') return 'pending'; + if (conclusion === 'success') return 'success'; + if (conclusion === 'failure') return 'failure'; + if (conclusion === 'cancelled' || conclusion === 'skipped') return 'skipped'; + return 'pending'; +} + +function extractWorkflowEvent(payload: WebhookPayload): CIEvent | null { + if (!payload.workflow_run) return null; + + const run = payload.workflow_run; + return { + type: 'workflow_run', + repository: run.repository.full_name, + timestamp: run.updated_at, + status: normalizeWorkflowStatus(run.status, run.conclusion), + details: { + id: run.id, + name: run.name, + runNumber: run.run_number, + branch: run.head_branch, + commit: run.head_commit.id.substring(0, 7), + message: run.head_commit.message, + author: run.head_commit.author.name, + }, + }; +} + +function extractCheckRunEvent(payload: WebhookPayload): CIEvent | null { + if (!payload.check_run) return null; + + const checkRun = payload.check_run; + const repo = payload.workflow_run?.repository; + + if (!repo) return null; + + return { + type: 'check_run', + repository: repo.full_name, + timestamp: checkRun.completed_at || checkRun.started_at, + status: normalizeWorkflowStatus(checkRun.status, checkRun.conclusion), + details: { + id: checkRun.id, + name: checkRun.name, + status: checkRun.status, + conclusion: checkRun.conclusion, + }, + }; +} + +export function parseWebhookEvent(payload: WebhookPayload): CIEvent | null { + const event = extractWorkflowEvent(payload) || extractCheckRunEvent(payload); + return event; +} + +export async function cacheEvent(event: CIEvent): Promise { + const cacheKey = `${event.repository}:${event.timestamp}:${event.details.id || ''}`; + await eventCache.set(cacheKey, event, 3600 * 1000); +} + +export async function evaluateAlerts(event: CIEvent): Promise { + const alertKey = `alert:${event.repository}`; + const config = await alertCache.get(alertKey); + + if (!config || !config.enabled) return; + + const shouldAlert = + (event.status === 'failure' && config.onFailure) || + (event.status === 'success' && config.onSuccess); + + if (!shouldAlert) return; + + if (config.webhookUrl) { + await sendWebhookAlert(config.webhookUrl, event); + } + + if (config.email) { + await sendEmailAlert(config.email, event); + } +} + +async function sendWebhookAlert(webhookUrl: string, event: CIEvent): Promise { + try { + const payload = { + repository: event.repository, + status: event.status, + type: event.type, + timestamp: event.timestamp, + details: event.details, + }; + + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + console.error(`Webhook alert failed: ${response.status}`); + } + } catch (error) { + console.error('Failed to send webhook alert:', error); + } +} + +async function sendEmailAlert(email: string, event: CIEvent): Promise { + try { + const subject = + event.status === 'failure' + ? `CI/CD Pipeline Failed: ${event.repository}` + : `CI/CD Pipeline Succeeded: ${event.repository}`; + + const body = ` +Pipeline Status: ${event.status.toUpperCase()} +Repository: ${event.repository} +Time: ${new Date(event.timestamp).toISOString()} + +Details: +${Object.entries(event.details) + .map(([key, value]) => `${key}: ${value}`) + .join('\n')} + `; + + console.log(`Email alert would be sent to ${email}:`, { subject, body }); + } catch (error) { + console.error('Failed to send email alert:', error); + } +} + +export function generateCIReport( + events: CIEvent[], + period: 'daily' | 'weekly' | 'monthly' +): Record { + const now = new Date(); + const periodMs = + period === 'daily' + ? 24 * 60 * 60 * 1000 + : period === 'weekly' + ? 7 * 24 * 60 * 60 * 1000 + : 30 * 24 * 60 * 60 * 1000; + + const filteredEvents = events.filter( + (event) => new Date(event.timestamp).getTime() > now.getTime() - periodMs + ); + + const repositoryCounts = new Map(); + + for (const event of filteredEvents) { + const counts = repositoryCounts.get(event.repository) || { + success: 0, + failure: 0, + pending: 0, + }; + + if (event.status === 'success') counts.success++; + else if (event.status === 'failure') counts.failure++; + else if (event.status === 'pending') counts.pending++; + + repositoryCounts.set(event.repository, counts); + } + + const repositories: Record< + string, + { total: number; success: number; failure: number; pending: number; successRate: string } + > = {}; + + for (const [repo, counts] of repositoryCounts.entries()) { + const total = counts.success + counts.failure + counts.pending; + const successRate = total > 0 ? ((counts.success / total) * 100).toFixed(1) : '0'; + + repositories[repo] = { + total, + success: counts.success, + failure: counts.failure, + pending: counts.pending, + successRate: `${successRate}%`, + }; + } + + return { + period, + generatedAt: now.toISOString(), + totalEvents: filteredEvents.length, + repositories, + }; +} + +export async function setAlertConfig(repository: string, config: Partial): Promise { + const alertKey = `alert:${repository}`; + const existing = (await alertCache.get(alertKey)) ?? ({ enabled: true } as AlertConfig); + const merged: AlertConfig = { ...existing, ...config } as AlertConfig; + await alertCache.set(alertKey, merged, 86400 * 1000); +}