358 lines
13 KiB
TypeScript
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;
|
|
}
|
|
},
|
|
});
|