Skip to content
89 changes: 89 additions & 0 deletions app/api/cicd/alerts/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
});
}
35 changes: 35 additions & 0 deletions app/api/cicd/reports/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
71 changes: 71 additions & 0 deletions app/api/webhooks/cicd/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
168 changes: 168 additions & 0 deletions services/github/webhook-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>();
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<string, { total: number; success: number; failure: number; successRate: string }>;
};

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<typeof generateCIReport>[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);
});
});
Loading
Loading