learning_ai_common_plat/services/mcp-server/src/modules/dev/changelog-tools.ts
2026-03-05 22:30:39 -08:00

358 lines
13 KiB
TypeScript

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<string, any[]>, commit: any) => {
const category = categorizeCommit(commit.message, commit.files);
if (!acc[category]) acc[category] = [];
acc[category].push(commit);
return acc;
},
{} as Record<string, any[]>
);
// 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<string, string> = {
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;
}
},
});