import { z } from 'zod'; import { registerTool } from '../tools/registry.js'; import { platformFetch } from '../../lib/platform-client.js'; registerTool({ name: 'changelog.generate', description: 'Generate changelog from merged PRs and telemetry impact analysis. Requires admin role.', requiredRole: 'admin', inputSchema: z.object({ productId: z.string().min(1).describe('Product ID to generate changelog for'), fromCommit: z.string().min(1).describe('Starting commit SHA or tag'), toCommit: z.string().optional().describe('Ending commit SHA (defaults to HEAD)'), timeWindow: z .object({ days: z .number() .int() .min(1) .max(90) .default(7) .describe('Look back period in days for telemetry impact'), }) .optional() .describe('Time window for telemetry impact analysis'), includeTelemetry: z.boolean().default(true).describe('Include telemetry impact analysis'), format: z.enum(['markdown', 'json']).default('markdown').describe('Output format'), }), async execute(args, req) { const { productId, fromCommit, toCommit, timeWindow, includeTelemetry, format } = args; const runId = `changelog-${Date.now()}`; req.log.info( { runId, productId, fromCommit, toCommit, includeTelemetry }, 'Generating changelog' ); try { // Step 1: Get commit history and PR information const commitsResponse = await platformFetch<{ commits: Array<{ sha: string; message: string; author: string; date: string; prNumber?: number; prTitle?: string; prBody?: string; files: Array<{ path: string; additions: number; deletions: number; }>; }>; total: number; }>( `/api/changelog/commits?productId=${productId}&from=${fromCommit}${toCommit ? `&to=${toCommit}` : ''}`, { method: 'GET' }, { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } ); // Step 2: Get telemetry impact if requested let telemetryImpact = null; if (includeTelemetry) { const lookbackDays = timeWindow?.days || 7; const telemetryResponse = await platformFetch<{ before: { errorRate: number; totalEvents: number; uniqueUsers: number; topErrors: Array<{ message: string; count: number }>; }; after: { errorRate: number; totalEvents: number; uniqueUsers: number; topErrors: Array<{ message: string; count: number }>; }; impact: { errorRateChange: number; eventsChange: number; usersChange: number; newErrors: Array<{ message: string; count: number }>; resolvedErrors: Array<{ message: string; count: number }>; }; }>( `/api/changelog/telemetry-impact?productId=${productId}&days=${lookbackDays}`, { method: 'GET' }, { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } ); telemetryImpact = telemetryResponse; } // Step 3: Analyze and categorize changes const categorizeCommit = (message: string, files: Array<{ path: string }>) => { const msg = message.toLowerCase(); if (msg.includes('feat') || msg.includes('feature')) return 'feature'; if (msg.includes('fix') || msg.includes('bug')) return 'bugfix'; if (msg.includes('refactor') || msg.includes('cleanup')) return 'refactor'; if (msg.includes('docs') || msg.includes('readme')) return 'documentation'; if (msg.includes('test') || msg.includes('spec')) return 'testing'; if (msg.includes('chore') || msg.includes('build') || msg.includes('deps')) return 'maintenance'; // Categorize by file paths const hasBackend = files.some(f => f.path.includes('backend/') || f.path.includes('src/')); const hasFrontend = files.some(f => f.path.includes('web/') || f.path.includes('src/app/')); const hasTests = files.some(f => f.path.includes('test') || f.path.includes('spec')); if (hasTests) return 'testing'; if (hasBackend && hasFrontend) return 'fullstack'; if (hasBackend) return 'backend'; if (hasFrontend) return 'frontend'; return 'other'; }; const categorized = commitsResponse.commits.reduce( (acc: Record, commit: any) => { const category = categorizeCommit(commit.message, commit.files); if (!acc[category]) acc[category] = []; acc[category].push(commit); return acc; }, {} as Record ); // Step 4: Generate changelog content const generateMarkdown = () => { let md = `# Changelog for ${productId}\n\n`; md += `**From:** ${fromCommit}\n`; if (toCommit) md += `**To:** ${toCommit}\n`; md += `**Generated:** ${new Date().toISOString()}\n`; md += `**Total Commits:** ${commitsResponse.total}\n\n`; // Telemetry Impact Section if (telemetryImpact) { md += `## ๐Ÿ“Š Telemetry Impact\n\n`; md += `| Metric | Before | After | Change |\n`; md += `|--------|--------|-------|--------|\n`; md += `| Error Rate | ${(telemetryImpact.before.errorRate * 100).toFixed(2)}% | ${(telemetryImpact.after.errorRate * 100).toFixed(2)}% | ${telemetryImpact.impact.errorRateChange > 0 ? '+' : ''}${(telemetryImpact.impact.errorRateChange * 100).toFixed(2)}% |\n`; md += `| Total Events | ${telemetryImpact.before.totalEvents.toLocaleString()} | ${telemetryImpact.after.totalEvents.toLocaleString()} | ${telemetryImpact.impact.eventsChange > 0 ? '+' : ''}${telemetryImpact.impact.eventsChange.toLocaleString()} |\n`; md += `| Unique Users | ${telemetryImpact.before.uniqueUsers.toLocaleString()} | ${telemetryImpact.after.uniqueUsers.toLocaleString()} | ${telemetryImpact.impact.usersChange > 0 ? '+' : ''}${telemetryImpact.impact.usersChange.toLocaleString()} |\n\n`; if (telemetryImpact.impact.newErrors.length > 0) { md += `### ๐Ÿšจ New Errors\n`; telemetryImpact.impact.newErrors.forEach(error => { md += `- \`${error.message}\` (${error.count} occurrences)\n`; }); md += `\n`; } if (telemetryImpact.impact.resolvedErrors.length > 0) { md += `### โœ… Resolved Errors\n`; telemetryImpact.impact.resolvedErrors.forEach(error => { md += `- \`${error.message}\` (${error.count} occurrences)\n`; }); md += `\n`; } } // Categorized Changes const categoryEmojis: Record = { feature: 'โœจ', bugfix: '๐Ÿ›', refactor: 'โ™ป๏ธ', documentation: '๐Ÿ“š', testing: '๐Ÿงช', maintenance: '๐Ÿ”ง', backend: 'โš™๏ธ', frontend: '๐ŸŽจ', fullstack: '๐Ÿ”„', other: '๐Ÿ“', }; Object.entries(categorized).forEach(([category, commits]) => { if (commits.length === 0) return; md += `## ${categoryEmojis[category]} ${category.charAt(0).toUpperCase() + category.slice(1)} (${commits.length})\n\n`; commits.forEach((commit: any) => { md += `### ${commit.prTitle || commit.message.split('\n')[0]}\n\n`; md += `**Commit:** \`${commit.sha.substring(0, 7)}\`\n`; md += `**Author:** ${commit.author}\n`; md += `**Date:** ${new Date(commit.date).toLocaleDateString()}\n`; if (commit.prNumber) { md += `**PR:** #${commit.prNumber}\n`; } const totalChanges = commit.files.reduce( (sum: number, file: any) => sum + file.additions + file.deletions, 0 ); md += `**Changes:** +${commit.files.reduce((sum: number, f: any) => sum + f.additions, 0)} -${commit.files.reduce((sum: number, f: any) => sum + f.deletions, 0)} (${totalChanges} total)\n`; // List modified files (limit to 10) const modifiedFiles = commit.files .slice(0, 10) .map((f: any) => f.path) .join(', '); md += `**Files:** ${modifiedFiles}${commit.files.length > 10 ? '...' : ''}\n`; // PR description excerpt if (commit.prBody) { const excerpt = commit.prBody.split('\n')[0].substring(0, 200); if (excerpt) { md += `**Description:** ${excerpt}${excerpt.length === 200 ? '...' : ''}\n`; } } md += `\n`; }); }); return md; }; const content = format === 'markdown' ? generateMarkdown() : JSON.stringify( { productId, fromCommit, toCommit, generatedAt: new Date().toISOString(), totalCommits: commitsResponse.total, categorized, telemetryImpact, }, null, 2 ); req.log.info( { runId, productId, totalCommits: commitsResponse.total, categories: Object.keys(categorized), }, 'Changelog generated successfully' ); return { runId, productId, fromCommit, toCommit, format, totalCommits: commitsResponse.total, categories: Object.keys(categorized), hasTelemetryImpact: !!telemetryImpact, content, summary: `Generated changelog with ${commitsResponse.total} commits across ${Object.keys(categorized).length} categories${includeTelemetry ? ' including telemetry impact' : ''}`, }; } catch (error) { req.log.error( { runId, productId, error: error instanceof Error ? error.message : String(error) }, 'Failed to generate changelog' ); throw error; } }, }); registerTool({ name: 'changelog.compareReleases', description: 'Compare two releases/tags and generate a detailed comparison report. Requires admin role.', requiredRole: 'admin', inputSchema: z.object({ productId: z.string().min(1).describe('Product ID'), fromTag: z.string().min(1).describe('Source release tag'), toTag: z.string().min(1).describe('Target release tag'), includeFileChanges: z.boolean().default(true).describe('Include detailed file changes'), includeMetrics: z.boolean().default(true).describe('Include performance metrics comparison'), }), async execute(args, req) { const { productId, fromTag, toTag, includeFileChanges, includeMetrics } = args; const runId = `compare-${Date.now()}`; req.log.info({ runId, productId, fromTag, toTag }, 'Comparing releases'); try { const response = await platformFetch<{ comparison: { commitsAhead: number; commitsBehind: number; fileChanges: { added: number; modified: number; deleted: number; files: Array<{ path: string; change: 'added' | 'modified' | 'deleted'; additions: number; deletions: number; }>; }; contributors: Array<{ name: string; commits: number; additions: number; deletions: number; }>; }; metrics?: { performance: { before: { [key: string]: number }; after: { [key: string]: number }; changes: { [key: string]: number }; }; reliability: { before: { errorRate: number; uptime: number }; after: { errorRate: number; uptime: number }; changes: { errorRateChange: number; uptimeChange: number }; }; }; }>( `/api/changelog/compare?productId=${productId}&from=${fromTag}&to=${toTag}&includeFiles=${includeFileChanges}&includeMetrics=${includeMetrics}`, { method: 'GET' }, { token: req.headers.authorization?.replace('Bearer ', '') || '', requestId: req.id } ); const { comparison, metrics } = response; req.log.info( { runId, productId, commitsAhead: comparison.commitsAhead, fileChanges: comparison.fileChanges, }, 'Release comparison completed' ); return { runId, productId, fromTag, toTag, comparison, metrics, summary: `Compared ${fromTag} โ†’ ${toTag}: ${comparison.commitsAhead} commits ahead, ${comparison.fileChanges.added + comparison.fileChanges.modified + comparison.fileChanges.deleted} files changed`, }; } catch (error) { req.log.error( { runId, productId, error: error instanceof Error ? error.message : String(error) }, 'Failed to compare releases' ); throw error; } }, });