1190 lines
35 KiB
JavaScript
Executable File
1190 lines
35 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { execSync } from 'child_process';
|
|
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// Colors for console output
|
|
const colors = {
|
|
red: '\x1b[0;31m',
|
|
green: '\x1b[0;32m',
|
|
yellow: '\x1b[0;33m',
|
|
blue: '\x1b[0;34m',
|
|
nc: '\x1b[0m', // No Color
|
|
};
|
|
|
|
// Default configuration
|
|
const DEFAULT_CONFIG = {
|
|
outputFormat: 'integrated', // 'integrated' or 'separated'
|
|
primaryRepo: null, // Index or name of primary repository (for separated format)
|
|
repositories: [
|
|
{
|
|
name: 'primary',
|
|
path: null, // Will be set from command line
|
|
label: 'primary',
|
|
includePrLinks: true, // Default to include PR links
|
|
},
|
|
],
|
|
// Template for separated format
|
|
separatedTemplate: {
|
|
header: null, // Optional header text/markdown
|
|
primaryLabel: 'Primary', // Label for primary section
|
|
secondaryLabel: 'Additional Changes', // Label for secondary section
|
|
secondaryIntro:
|
|
'All primary updates are included. Additional repository-specific features and fixes:', // Intro text for secondary
|
|
},
|
|
};
|
|
|
|
class ReleaseNotesGenerator {
|
|
constructor(options = {}) {
|
|
this.fromVersion = options.fromVersion || 'v3.1.0';
|
|
this.toVersion = options.toVersion || 'v3.2.0';
|
|
this.fetchCommits = options.fetchCommits !== false;
|
|
this.pullCommits = options.pullCommits || false;
|
|
this.includePrLinks = options.includePrLinks !== false; // Default to true
|
|
this.config = options.config || DEFAULT_CONFIG;
|
|
this.outputDir =
|
|
options.outputDir || join(__dirname, '..', 'output', 'release-notes');
|
|
}
|
|
|
|
log(message, color = 'nc') {
|
|
console.log(`${colors[color]}${message}${colors.nc}`);
|
|
}
|
|
|
|
// Validate git tag exists in repository
|
|
validateGitTag(version, repoPath) {
|
|
if (version === 'local') {
|
|
return true; // Special case for development
|
|
}
|
|
|
|
if (!existsSync(repoPath)) {
|
|
this.log(`Error: Repository not found: ${repoPath}`, 'red');
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const tags = execSync(`git -C "${repoPath}" tag --list`, {
|
|
encoding: 'utf8',
|
|
});
|
|
if (!tags.split('\n').includes(version)) {
|
|
this.log(
|
|
`Error: Version tag '${version}' does not exist in repository ${repoPath}`,
|
|
'red'
|
|
);
|
|
this.log('Available tags (most recent first):', 'yellow');
|
|
const recentTags = execSync(
|
|
`git -C "${repoPath}" tag --list --sort=-version:refname`,
|
|
{ encoding: 'utf8' }
|
|
)
|
|
.split('\n')
|
|
.slice(0, 10)
|
|
.filter((tag) => tag.trim())
|
|
.map((tag) => ` ${tag}`)
|
|
.join('\n');
|
|
console.log(recentTags);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
this.log(`Error validating tags in ${repoPath}: ${error.message}`, 'red');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Get commits from repository using subject line pattern
|
|
getCommitsFromRepo(repoPath, pattern, format = '%h %s') {
|
|
try {
|
|
const output = execSync(
|
|
`git -C "${repoPath}" log --format="${format}" "${this.fromVersion}..${this.toVersion}"`,
|
|
{ encoding: 'utf8' }
|
|
);
|
|
|
|
return output
|
|
.split('\n')
|
|
.filter((line) => line.match(new RegExp(pattern)))
|
|
.filter((line) => line.trim());
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Get commits including merge commit bodies
|
|
getCommitsWithBody(repoPath, pattern) {
|
|
try {
|
|
const output = execSync(
|
|
`git -C "${repoPath}" log --format="%B" "${this.fromVersion}..${this.toVersion}"`,
|
|
{ encoding: 'utf8' }
|
|
);
|
|
|
|
// Split into lines and find lines that match the pattern
|
|
const lines = output.split('\n');
|
|
const matches = [];
|
|
|
|
for (const line of lines) {
|
|
const trimmedLine = line.trim();
|
|
if (
|
|
trimmedLine.startsWith(pattern) ||
|
|
trimmedLine.startsWith('* ' + pattern) ||
|
|
trimmedLine.startsWith('- ' + pattern)
|
|
) {
|
|
// Remove the bullet point prefix if present
|
|
const cleanLine = trimmedLine.replace(/^[*-]\s*/, '');
|
|
if (cleanLine.length > pattern.length) {
|
|
matches.push(cleanLine);
|
|
}
|
|
}
|
|
}
|
|
|
|
return matches;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Extract PR number from commit message
|
|
extractPrNumber(message) {
|
|
const match = message.match(/#(\d+)/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
// Configuration for enhancing commit messages
|
|
getEnhancementConfig() {
|
|
return {
|
|
// Keywords to detect different areas of the codebase
|
|
detectors: {
|
|
auth: ['auth', 'token', 'permission', 'credential', 'security'],
|
|
database: ['database', 'table', 'schema', 'catalog'],
|
|
query: ['query', 'sql', 'select', 'influxql'],
|
|
storage: ['storage', 'parquet', 'wal', 'object store'],
|
|
license: ['license', 'licensing'],
|
|
compaction: ['compact', 'compaction'],
|
|
cache: ['cache', 'caching', 'lru'],
|
|
metrics: ['metric', 'monitoring', 'telemetry'],
|
|
retention: ['retention', 'ttl', 'expire'],
|
|
api: ['api', 'endpoint', 'http', 'rest'],
|
|
cli: ['cli', 'command', 'cmd', 'flag'],
|
|
},
|
|
|
|
// Feature type mappings
|
|
featureTypes: {
|
|
feat: {
|
|
'auth+database': 'Enhanced database authorization',
|
|
auth: 'Authentication and security',
|
|
'database+retention': 'Database retention management',
|
|
database: 'Database management',
|
|
query: 'Query functionality',
|
|
'storage+compaction': 'Storage compaction',
|
|
storage: 'Storage engine',
|
|
license: 'License management',
|
|
cache: 'Caching system',
|
|
metrics: 'Monitoring and metrics',
|
|
cli: 'Command-line interface',
|
|
api: 'API functionality',
|
|
},
|
|
fix: {
|
|
auth: 'Authentication fix',
|
|
database: 'Database reliability',
|
|
query: 'Query processing',
|
|
storage: 'Storage integrity',
|
|
compaction: 'Compaction stability',
|
|
cache: 'Cache reliability',
|
|
license: 'License validation',
|
|
cli: 'CLI reliability',
|
|
api: 'API stability',
|
|
_default: 'Bug fix',
|
|
},
|
|
perf: {
|
|
query: 'Query performance',
|
|
storage: 'Storage performance',
|
|
compaction: 'Compaction performance',
|
|
cache: 'Cache performance',
|
|
_default: 'Performance improvement',
|
|
},
|
|
},
|
|
|
|
// Feature name extraction patterns
|
|
featurePatterns: {
|
|
'delete|deletion': 'Data deletion',
|
|
retention: 'Retention policies',
|
|
'token|auth': 'Authentication',
|
|
'database|db': 'Database management',
|
|
table: 'Table operations',
|
|
query: 'Query engine',
|
|
cache: 'Caching',
|
|
'metric|monitoring': 'Monitoring',
|
|
license: 'Licensing',
|
|
'compaction|compact': 'Storage compaction',
|
|
wal: 'Write-ahead logging',
|
|
parquet: 'Parquet storage',
|
|
api: 'API',
|
|
'cli|command': 'CLI',
|
|
},
|
|
};
|
|
}
|
|
|
|
// Detect areas based on keywords in the description
|
|
detectAreas(description, files = []) {
|
|
const config = this.getEnhancementConfig();
|
|
const lowerDesc = description.toLowerCase();
|
|
const detectedAreas = new Set();
|
|
|
|
// Check description for keywords
|
|
for (const [area, keywords] of Object.entries(config.detectors)) {
|
|
if (keywords.some((keyword) => lowerDesc.includes(keyword))) {
|
|
detectedAreas.add(area);
|
|
}
|
|
}
|
|
|
|
// Check files for patterns
|
|
const filePatterns = {
|
|
auth: ['auth/', 'security/', 'token/'],
|
|
database: ['database/', 'catalog/', 'schema/'],
|
|
query: ['query/', 'sql/', 'influxql/'],
|
|
storage: ['storage/', 'parquet/', 'wal/'],
|
|
api: ['api/', 'http/', 'rest/'],
|
|
cli: ['cli/', 'cmd/', 'command/'],
|
|
metrics: ['metrics/', 'telemetry/', 'monitoring/'],
|
|
cache: ['cache/', 'lru/'],
|
|
};
|
|
|
|
for (const [area, patterns] of Object.entries(filePatterns)) {
|
|
if (
|
|
files.some((file) => patterns.some((pattern) => file.includes(pattern)))
|
|
) {
|
|
detectedAreas.add(area);
|
|
}
|
|
}
|
|
|
|
return Array.from(detectedAreas);
|
|
}
|
|
|
|
// Get enhancement label based on type and detected areas
|
|
getEnhancementLabel(type, areas) {
|
|
const config = this.getEnhancementConfig();
|
|
const typeConfig = config.featureTypes[type];
|
|
|
|
if (!typeConfig) {
|
|
return this.capitalizeFirst(type);
|
|
}
|
|
|
|
// Check for multi-area combinations first
|
|
if (areas.length > 1) {
|
|
const comboKey = areas.slice(0, 2).sort().join('+');
|
|
if (typeConfig[comboKey]) {
|
|
return typeConfig[comboKey];
|
|
}
|
|
}
|
|
|
|
// Check for single area match
|
|
if (areas.length > 0 && typeConfig[areas[0]]) {
|
|
return typeConfig[areas[0]];
|
|
}
|
|
|
|
// Return default if available
|
|
return typeConfig._default || this.capitalizeFirst(type);
|
|
}
|
|
|
|
// Extract feature name using patterns
|
|
extractFeatureName(description) {
|
|
const config = this.getEnhancementConfig();
|
|
const words = description.toLowerCase();
|
|
|
|
// Check each pattern
|
|
for (const [pattern, featureName] of Object.entries(
|
|
config.featurePatterns
|
|
)) {
|
|
const regex = new RegExp(`\\b(${pattern})\\b`, 'i');
|
|
if (regex.test(words)) {
|
|
return featureName;
|
|
}
|
|
}
|
|
|
|
// Default to extracting the first significant word
|
|
const significantWords = words
|
|
.split(' ')
|
|
.filter(
|
|
(w) =>
|
|
w.length > 3 &&
|
|
![
|
|
'the',
|
|
'and',
|
|
'for',
|
|
'with',
|
|
'from',
|
|
'into',
|
|
'that',
|
|
'this',
|
|
].includes(w)
|
|
);
|
|
|
|
return significantWords.length > 0
|
|
? this.capitalizeFirst(significantWords[0])
|
|
: 'Feature';
|
|
}
|
|
|
|
// Get detailed information about a commit including files changed
|
|
getCommitDetails(repoPath, commitHash) {
|
|
try {
|
|
const output = execSync(
|
|
`git -C "${repoPath}" show --name-only --format="%s%n%b" ${commitHash}`,
|
|
{ encoding: 'utf8' }
|
|
);
|
|
|
|
const lines = output.split('\n');
|
|
const subject = lines[0];
|
|
let bodyLines = [];
|
|
let fileLines = [];
|
|
let inBody = true;
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (line === '') continue;
|
|
|
|
// If we hit a file path, we're done with the body
|
|
if (line.includes('/') || line.includes('.')) {
|
|
inBody = false;
|
|
}
|
|
|
|
if (inBody) {
|
|
bodyLines.push(line);
|
|
} else {
|
|
fileLines.push(line);
|
|
}
|
|
}
|
|
|
|
return {
|
|
subject,
|
|
body: bodyLines.join('\n'),
|
|
files: fileLines,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Enhance commit message with analysis of changes
|
|
enhanceCommitMessage(
|
|
repoPath,
|
|
commitMessage,
|
|
prNumber,
|
|
includePrLinks = null
|
|
) {
|
|
// Extract the basic semantic prefix
|
|
const semanticMatch = commitMessage.match(
|
|
/^(feat|fix|perf|refactor|style|test|docs|chore):\s*(.+)/
|
|
);
|
|
if (!semanticMatch) return commitMessage;
|
|
|
|
const [, type, description] = semanticMatch;
|
|
|
|
// Remove PR number from description if it's already there to avoid duplication
|
|
const cleanDescription = description.replace(/\s*\(#\d+\)$/g, '').trim();
|
|
|
|
// Get commit hash if available
|
|
const hashMatch = commitMessage.match(/^([a-f0-9]+)\s+/);
|
|
const commitHash = hashMatch ? hashMatch[1] : null;
|
|
|
|
// Try to enhance based on the type and description
|
|
const enhanced = this.generateEnhancedDescription(
|
|
type,
|
|
cleanDescription,
|
|
repoPath,
|
|
commitHash
|
|
);
|
|
|
|
// Use repository-specific setting if provided, otherwise use global setting
|
|
const shouldIncludePrLinks =
|
|
includePrLinks !== null ? includePrLinks : this.includePrLinks;
|
|
|
|
// If we have a PR number and should include PR links, include it
|
|
if (prNumber && shouldIncludePrLinks) {
|
|
return `${enhanced} ([#${prNumber}](https://github.com/influxdata/influxdb/pull/${prNumber}))`;
|
|
}
|
|
|
|
return enhanced;
|
|
}
|
|
|
|
// Generate enhanced description based on commit type and analysis
|
|
generateEnhancedDescription(type, description, repoPath, commitHash) {
|
|
// Get additional context if commit hash is available
|
|
let files = [];
|
|
if (commitHash) {
|
|
const details = this.getCommitDetails(repoPath, commitHash);
|
|
if (details) {
|
|
files = details.files;
|
|
}
|
|
}
|
|
|
|
// Detect areas affected by this commit
|
|
const areas = this.detectAreas(description, files);
|
|
|
|
// Get the enhancement label
|
|
const label = this.getEnhancementLabel(type, areas);
|
|
|
|
// For features without detected areas, try to extract a feature name
|
|
if (type === 'feat' && areas.length === 0) {
|
|
const featureName = this.extractFeatureName(description);
|
|
return `**${featureName}**: ${this.capitalizeFirst(description)}`;
|
|
}
|
|
|
|
return `**${label}**: ${this.capitalizeFirst(description)}`;
|
|
}
|
|
|
|
// Capitalize first letter of a string
|
|
capitalizeFirst(str) {
|
|
if (!str) return '';
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
}
|
|
|
|
// Get release date
|
|
getReleaseDate(repoPath) {
|
|
try {
|
|
const output = execSync(
|
|
`git -C "${repoPath}" log -1 --format=%ai "${this.toVersion}"`,
|
|
{ encoding: 'utf8' }
|
|
);
|
|
return output.split(' ')[0].trim();
|
|
} catch {
|
|
return new Date().toISOString().split('T')[0];
|
|
}
|
|
}
|
|
|
|
// Fetch latest commits from repositories
|
|
async fetchFromRepositories() {
|
|
if (!this.fetchCommits) {
|
|
this.log('Skipping fetch (using local commits only)', 'yellow');
|
|
return;
|
|
}
|
|
|
|
const action = this.pullCommits ? 'Pulling' : 'Fetching';
|
|
this.log(`${action} latest commits from all repositories...`, 'yellow');
|
|
|
|
if (this.pullCommits) {
|
|
this.log('Warning: This will modify your working directories!', 'red');
|
|
}
|
|
|
|
for (const repo of this.config.repositories) {
|
|
if (!existsSync(repo.path)) {
|
|
this.log(`✗ Repository not found: ${repo.path}`, 'red');
|
|
continue;
|
|
}
|
|
|
|
const repoName = repo.name || repo.path.split('/').pop();
|
|
|
|
try {
|
|
if (this.pullCommits) {
|
|
this.log(` Pulling changes in ${repoName}...`);
|
|
execSync(`git -C "${repo.path}" pull origin`, { stdio: 'pipe' });
|
|
this.log(` ✓ Successfully pulled changes in ${repoName}`, 'green');
|
|
} else {
|
|
this.log(` Fetching from ${repoName}...`);
|
|
execSync(`git -C "${repo.path}" fetch origin`, { stdio: 'pipe' });
|
|
this.log(` ✓ Successfully fetched from ${repoName}`, 'green');
|
|
}
|
|
} catch {
|
|
this.log(
|
|
` ✗ Failed to ${this.pullCommits ? 'pull' : 'fetch'} from ${repoName}`,
|
|
'red'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect commits by category from all repositories
|
|
collectCommits() {
|
|
this.log('\nAnalyzing commits across all repositories...', 'yellow');
|
|
|
|
const results = {
|
|
features: [],
|
|
fixes: [],
|
|
breaking: [],
|
|
perf: [],
|
|
api: [],
|
|
};
|
|
|
|
for (const repo of this.config.repositories) {
|
|
if (!existsSync(repo.path)) {
|
|
continue;
|
|
}
|
|
|
|
const repoLabel = repo.label || repo.name || repo.path.split('/').pop();
|
|
this.log(` Analyzing ${repoLabel}...`);
|
|
|
|
// Features - check both commit subjects and merge commit bodies
|
|
const featuresSubject = this.getCommitsFromRepo(
|
|
repo.path,
|
|
'^[a-f0-9]+ feat:'
|
|
).map((line) => {
|
|
const prNumber = this.extractPrNumber(line);
|
|
const enhanced = this.enhanceCommitMessage(
|
|
repo.path,
|
|
line.replace(/^[a-f0-9]* /, ''),
|
|
prNumber,
|
|
repo.includePrLinks
|
|
);
|
|
return `- [${repoLabel}] ${enhanced}`;
|
|
});
|
|
|
|
const featuresBody = this.getCommitsWithBody(repo.path, 'feat:').map(
|
|
(line) => {
|
|
const prNumber = this.extractPrNumber(line);
|
|
const enhanced = this.enhanceCommitMessage(
|
|
repo.path,
|
|
line,
|
|
prNumber,
|
|
repo.includePrLinks
|
|
);
|
|
return `- [${repoLabel}] ${enhanced}`;
|
|
}
|
|
);
|
|
|
|
results.features.push(...featuresSubject, ...featuresBody);
|
|
|
|
// Fixes - check both commit subjects and merge commit bodies
|
|
const fixesSubject = this.getCommitsFromRepo(
|
|
repo.path,
|
|
'^[a-f0-9]+ fix:'
|
|
).map((line) => {
|
|
const prNumber = this.extractPrNumber(line);
|
|
const enhanced = this.enhanceCommitMessage(
|
|
repo.path,
|
|
line.replace(/^[a-f0-9]* /, ''),
|
|
prNumber,
|
|
repo.includePrLinks
|
|
);
|
|
return `- [${repoLabel}] ${enhanced}`;
|
|
});
|
|
|
|
const fixesBody = this.getCommitsWithBody(repo.path, 'fix:').map(
|
|
(line) => {
|
|
const prNumber = this.extractPrNumber(line);
|
|
const enhanced = this.enhanceCommitMessage(
|
|
repo.path,
|
|
line,
|
|
prNumber,
|
|
repo.includePrLinks
|
|
);
|
|
return `- [${repoLabel}] ${enhanced}`;
|
|
}
|
|
);
|
|
|
|
results.fixes.push(...fixesSubject, ...fixesBody);
|
|
|
|
// Performance improvements
|
|
const perfSubject = this.getCommitsFromRepo(
|
|
repo.path,
|
|
'^[a-f0-9]+ perf:'
|
|
).map((line) => {
|
|
const prNumber = this.extractPrNumber(line);
|
|
const enhanced = this.enhanceCommitMessage(
|
|
repo.path,
|
|
line.replace(/^[a-f0-9]* /, ''),
|
|
prNumber,
|
|
repo.includePrLinks
|
|
);
|
|
return `- [${repoLabel}] ${enhanced}`;
|
|
});
|
|
|
|
const perfBody = this.getCommitsWithBody(repo.path, 'perf:').map(
|
|
(line) => {
|
|
const prNumber = this.extractPrNumber(line);
|
|
const enhanced = this.enhanceCommitMessage(
|
|
repo.path,
|
|
line,
|
|
prNumber,
|
|
repo.includePrLinks
|
|
);
|
|
return `- [${repoLabel}] ${enhanced}`;
|
|
}
|
|
);
|
|
|
|
results.perf.push(...perfSubject, ...perfBody);
|
|
|
|
// Breaking changes
|
|
const breaking = this.getCommitsFromRepo(
|
|
repo.path,
|
|
'^[a-f0-9]+ .*(BREAKING|breaking change)'
|
|
).map((line) => line.replace(/^[a-f0-9]* /, `- [${repoLabel}] `));
|
|
|
|
results.breaking.push(...breaking);
|
|
|
|
// API changes
|
|
const api = this.getCommitsFromRepo(
|
|
repo.path,
|
|
'(api|endpoint|/write|/query|/ping|/health|/metrics|v1|v2|v3)'
|
|
).map((line) => line.replace(/^[a-f0-9]* /, `- [${repoLabel}] `));
|
|
|
|
results.api.push(...api);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// Generate integrated format release notes
|
|
generateIntegratedFormat(commits, releaseDate) {
|
|
const lines = [];
|
|
|
|
lines.push(`## ${this.toVersion} {date="${releaseDate}"}`);
|
|
lines.push('');
|
|
lines.push('### Features');
|
|
lines.push('');
|
|
|
|
if (commits.features.length > 0) {
|
|
commits.features.forEach((feature) => {
|
|
// Enhanced messages already include PR links
|
|
lines.push(feature);
|
|
});
|
|
} else {
|
|
lines.push('- No new features in this release');
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('### Bug Fixes');
|
|
lines.push('');
|
|
|
|
if (commits.fixes.length > 0) {
|
|
commits.fixes.forEach((fix) => {
|
|
// Enhanced messages already include PR links
|
|
lines.push(fix);
|
|
});
|
|
} else {
|
|
lines.push('- No bug fixes in this release');
|
|
}
|
|
|
|
// Add breaking changes if any
|
|
if (commits.breaking.length > 0) {
|
|
lines.push('');
|
|
lines.push('### Breaking Changes');
|
|
lines.push('');
|
|
commits.breaking.forEach((change) => {
|
|
const pr = this.extractPrNumber(change);
|
|
const cleanLine = change.replace(/ \\(#\\d+\\)$/, '');
|
|
if (pr && this.includePrLinks) {
|
|
lines.push(
|
|
`${cleanLine} ([#${pr}](https://github.com/influxdata/influxdb/pull/${pr}))`
|
|
);
|
|
} else {
|
|
lines.push(cleanLine);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add performance improvements if any
|
|
if (commits.perf.length > 0) {
|
|
lines.push('');
|
|
lines.push('### Performance Improvements');
|
|
lines.push('');
|
|
commits.perf.forEach((perf) => {
|
|
// Enhanced messages already include PR links
|
|
lines.push(perf);
|
|
});
|
|
}
|
|
|
|
// Add HTTP API changes if any
|
|
if (commits.api.length > 0) {
|
|
lines.push('');
|
|
lines.push('### HTTP API Changes');
|
|
lines.push('');
|
|
commits.api.forEach((api) => {
|
|
const pr = this.extractPrNumber(api);
|
|
const cleanLine = api.replace(/ \\(#\\d+\\)$/, '');
|
|
if (pr && this.includePrLinks) {
|
|
lines.push(
|
|
`${cleanLine} ([#${pr}](https://github.com/influxdata/influxdb/pull/${pr}))`
|
|
);
|
|
} else {
|
|
lines.push(cleanLine);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add API analysis summary
|
|
lines.push('');
|
|
lines.push('### API Analysis Summary');
|
|
lines.push('');
|
|
lines.push(
|
|
'The following endpoints may have been affected in this release:'
|
|
);
|
|
lines.push('- v1 API endpoints: `/write`, `/query`, `/ping`');
|
|
lines.push('- v2 API endpoints: `/api/v2/write`, `/api/v2/query`');
|
|
lines.push('- v3 API endpoints: `/api/v3/*`');
|
|
lines.push('- System endpoints: `/health`, `/metrics`');
|
|
lines.push('');
|
|
lines.push(
|
|
'Please review the commit details above and consult the API documentation for specific changes.'
|
|
);
|
|
lines.push('');
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Generate separated format release notes
|
|
generateSeparatedFormat(commits, releaseDate) {
|
|
const lines = [];
|
|
|
|
// Add custom header if provided
|
|
if (this.config.separatedTemplate && this.config.separatedTemplate.header) {
|
|
lines.push(this.config.separatedTemplate.header);
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push(`## ${this.toVersion} {date="${releaseDate}"}`);
|
|
lines.push('');
|
|
|
|
// Determine primary repository
|
|
let primaryRepoLabel = null;
|
|
if (this.config.primaryRepo !== null) {
|
|
// Find primary repo by index or name
|
|
if (typeof this.config.primaryRepo === 'number') {
|
|
const primaryRepo = this.config.repositories[this.config.primaryRepo];
|
|
primaryRepoLabel = primaryRepo ? primaryRepo.label : null;
|
|
} else {
|
|
const primaryRepo = this.config.repositories.find(
|
|
(r) => r.name === this.config.primaryRepo
|
|
);
|
|
primaryRepoLabel = primaryRepo ? primaryRepo.label : null;
|
|
}
|
|
}
|
|
|
|
// If no primary specified, use the first repository
|
|
if (!primaryRepoLabel && this.config.repositories.length > 0) {
|
|
primaryRepoLabel = this.config.repositories[0].label;
|
|
}
|
|
|
|
// Separate commits by primary and secondary repositories
|
|
const primaryCommits = {
|
|
features: [],
|
|
fixes: [],
|
|
perf: [],
|
|
};
|
|
|
|
const secondaryCommits = {
|
|
features: [],
|
|
fixes: [],
|
|
perf: [],
|
|
};
|
|
|
|
// Sort commits into primary and secondary
|
|
for (const type of ['features', 'fixes', 'perf']) {
|
|
commits[type].forEach((commit) => {
|
|
// Extract repository label from commit
|
|
const labelMatch = commit.match(/^- \[([^\]]+)\]/);
|
|
if (labelMatch) {
|
|
const repoLabel = labelMatch[1];
|
|
const cleanCommit = commit.replace(/^- \[[^\]]+\] /, '- ');
|
|
|
|
if (repoLabel === primaryRepoLabel) {
|
|
primaryCommits[type].push(cleanCommit);
|
|
} else {
|
|
// Keep the label for secondary commits
|
|
secondaryCommits[type].push(commit);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Primary section
|
|
const primaryLabel =
|
|
this.config.separatedTemplate?.primaryLabel || 'Primary';
|
|
lines.push(`### ${primaryLabel}`);
|
|
lines.push('');
|
|
lines.push('#### Features');
|
|
lines.push('');
|
|
|
|
if (primaryCommits.features.length > 0) {
|
|
primaryCommits.features.forEach((feature) => {
|
|
lines.push(feature);
|
|
});
|
|
} else {
|
|
lines.push('- No new features in this release');
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('#### Bug Fixes');
|
|
lines.push('');
|
|
|
|
if (primaryCommits.fixes.length > 0) {
|
|
primaryCommits.fixes.forEach((fix) => {
|
|
lines.push(fix);
|
|
});
|
|
} else {
|
|
lines.push('- No bug fixes in this release');
|
|
}
|
|
|
|
// Primary performance improvements if any
|
|
if (primaryCommits.perf.length > 0) {
|
|
lines.push('');
|
|
lines.push('#### Performance Improvements');
|
|
lines.push('');
|
|
primaryCommits.perf.forEach((perf) => {
|
|
lines.push(perf);
|
|
});
|
|
}
|
|
|
|
// Secondary section (only if there are secondary repositories)
|
|
const hasSecondaryChanges =
|
|
secondaryCommits.features.length > 0 ||
|
|
secondaryCommits.fixes.length > 0 ||
|
|
secondaryCommits.perf.length > 0;
|
|
|
|
if (this.config.repositories.length > 1) {
|
|
lines.push('');
|
|
const secondaryLabel =
|
|
this.config.separatedTemplate?.secondaryLabel || 'Additional Changes';
|
|
lines.push(`### ${secondaryLabel}`);
|
|
lines.push('');
|
|
|
|
const secondaryIntro =
|
|
this.config.separatedTemplate?.secondaryIntro ||
|
|
'All primary updates are included. Additional repository-specific features and fixes:';
|
|
lines.push(secondaryIntro);
|
|
lines.push('');
|
|
|
|
// Secondary features
|
|
if (secondaryCommits.features.length > 0) {
|
|
lines.push('#### Features');
|
|
lines.push('');
|
|
secondaryCommits.features.forEach((feature) => {
|
|
lines.push(feature);
|
|
});
|
|
lines.push('');
|
|
}
|
|
|
|
// Secondary fixes
|
|
if (secondaryCommits.fixes.length > 0) {
|
|
lines.push('#### Bug Fixes');
|
|
lines.push('');
|
|
secondaryCommits.fixes.forEach((fix) => {
|
|
lines.push(fix);
|
|
});
|
|
lines.push('');
|
|
}
|
|
|
|
// Secondary performance improvements
|
|
if (secondaryCommits.perf.length > 0) {
|
|
lines.push('#### Performance Improvements');
|
|
lines.push('');
|
|
secondaryCommits.perf.forEach((perf) => {
|
|
lines.push(perf);
|
|
});
|
|
lines.push('');
|
|
}
|
|
|
|
// No secondary changes message
|
|
if (!hasSecondaryChanges) {
|
|
lines.push('#### No additional changes');
|
|
lines.push('');
|
|
lines.push(
|
|
'All changes in this release are included in the primary repository.'
|
|
);
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
// Add common sections (breaking changes, API changes, etc.)
|
|
this.addCommonSections(lines, commits);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Add common sections (breaking changes, API analysis)
|
|
addCommonSections(lines, commits) {
|
|
// Add breaking changes if any
|
|
if (commits.breaking.length > 0) {
|
|
lines.push('### Breaking Changes');
|
|
lines.push('');
|
|
commits.breaking.forEach((change) => {
|
|
const pr = this.extractPrNumber(change);
|
|
const cleanLine = change.replace(/ \\(#\\d+\\)$/, '');
|
|
if (pr && this.includePrLinks) {
|
|
lines.push(
|
|
`${cleanLine} ([#${pr}](https://github.com/influxdata/influxdb/pull/${pr}))`
|
|
);
|
|
} else {
|
|
lines.push(cleanLine);
|
|
}
|
|
});
|
|
lines.push('');
|
|
}
|
|
|
|
// Add HTTP API changes if any
|
|
if (commits.api.length > 0) {
|
|
lines.push('### HTTP API Changes');
|
|
lines.push('');
|
|
commits.api.forEach((api) => {
|
|
const pr = this.extractPrNumber(api);
|
|
const cleanLine = api.replace(/ \\(#\\d+\\)$/, '');
|
|
if (pr && this.includePrLinks) {
|
|
lines.push(
|
|
`${cleanLine} ([#${pr}](https://github.com/influxdata/influxdb/pull/${pr}))`
|
|
);
|
|
} else {
|
|
lines.push(cleanLine);
|
|
}
|
|
});
|
|
lines.push('');
|
|
}
|
|
|
|
// Add API analysis summary
|
|
lines.push('### API Analysis Summary');
|
|
lines.push('');
|
|
lines.push(
|
|
'The following endpoints may have been affected in this release:'
|
|
);
|
|
lines.push('- v1 API endpoints: `/write`, `/query`, `/ping`');
|
|
lines.push('- v2 API endpoints: `/api/v2/write`, `/api/v2/query`');
|
|
lines.push('- v3 API endpoints: `/api/v3/*`');
|
|
lines.push('- System endpoints: `/health`, `/metrics`');
|
|
lines.push('');
|
|
lines.push(
|
|
'Please review the commit details above and consult the API documentation for specific changes.'
|
|
);
|
|
lines.push('');
|
|
}
|
|
|
|
// Generate release notes
|
|
async generate() {
|
|
this.log('Validating version tags...', 'yellow');
|
|
|
|
// Validate tags in primary repository
|
|
const primaryRepo = this.config.repositories[0];
|
|
if (
|
|
!this.validateGitTag(this.fromVersion, primaryRepo.path) ||
|
|
!this.validateGitTag(this.toVersion, primaryRepo.path)
|
|
) {
|
|
process.exit(1);
|
|
}
|
|
|
|
this.log('✓ Version tags validated successfully', 'green');
|
|
this.log('');
|
|
|
|
this.log(`Generating release notes for ${this.toVersion}`, 'blue');
|
|
this.log(`Primary Repository: ${primaryRepo.path}`);
|
|
|
|
if (this.config.repositories.length > 1) {
|
|
this.log('Additional Repositories:');
|
|
this.config.repositories.slice(1).forEach((repo) => {
|
|
this.log(` - ${repo.path}`);
|
|
});
|
|
}
|
|
|
|
this.log(`From: ${this.fromVersion} To: ${this.toVersion}\n`);
|
|
|
|
// Get release date from primary repository
|
|
const releaseDate = this.getReleaseDate(primaryRepo.path);
|
|
this.log(`Release Date: ${releaseDate}\n`, 'green');
|
|
|
|
// Fetch latest commits
|
|
await this.fetchFromRepositories();
|
|
|
|
// Collect commits
|
|
const commits = this.collectCommits();
|
|
|
|
// Generate output based on format
|
|
let content;
|
|
if (this.config.outputFormat === 'separated') {
|
|
content = this.generateSeparatedFormat(commits, releaseDate);
|
|
} else {
|
|
content = this.generateIntegratedFormat(commits, releaseDate);
|
|
}
|
|
|
|
// Ensure output directory exists
|
|
mkdirSync(this.outputDir, { recursive: true });
|
|
|
|
// Write output file
|
|
const outputFile = join(
|
|
this.outputDir,
|
|
`release-notes-${this.toVersion}.md`
|
|
);
|
|
writeFileSync(outputFile, content);
|
|
|
|
this.log(`\nRelease notes generated in: ${outputFile}`, 'green');
|
|
this.log(
|
|
'Please review and edit the generated notes before adding to documentation.',
|
|
'yellow'
|
|
);
|
|
|
|
// If running in GitHub Actions, also output the relative path
|
|
if (process.env.GITHUB_WORKSPACE || process.env.GITHUB_ACTIONS) {
|
|
const relativePath = outputFile.replace(
|
|
`${process.env.GITHUB_WORKSPACE}/`,
|
|
''
|
|
);
|
|
this.log(`\nRelative path for GitHub Actions: ${relativePath}`, 'green');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse command line arguments
|
|
function parseArgs() {
|
|
const args = process.argv.slice(2);
|
|
const options = {
|
|
fetchCommits: true,
|
|
pullCommits: false,
|
|
includePrLinks: true,
|
|
config: { ...DEFAULT_CONFIG },
|
|
};
|
|
|
|
let i = 0;
|
|
while (i < args.length) {
|
|
switch (args[i]) {
|
|
case '--no-fetch':
|
|
options.fetchCommits = false;
|
|
i++;
|
|
break;
|
|
case '--pull':
|
|
options.pullCommits = true;
|
|
options.fetchCommits = true;
|
|
i++;
|
|
break;
|
|
case '--no-pr-links':
|
|
options.includePrLinks = false;
|
|
i++;
|
|
break;
|
|
case '--config':
|
|
if (i + 1 >= args.length) {
|
|
console.error('Error: --config requires a configuration file path');
|
|
process.exit(1);
|
|
}
|
|
// Load configuration from JSON file
|
|
try {
|
|
const configPath = args[i + 1];
|
|
const configData = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
options.config = { ...DEFAULT_CONFIG, ...configData };
|
|
} catch (error) {
|
|
console.error(`Error loading configuration: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
i += 2;
|
|
break;
|
|
case '--format':
|
|
if (i + 1 >= args.length) {
|
|
console.error(
|
|
'Error: --format requires a format type (integrated|separated)'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
options.config.outputFormat = args[i + 1];
|
|
i += 2;
|
|
break;
|
|
case '--help':
|
|
case '-h':
|
|
printUsage();
|
|
process.exit(0);
|
|
break;
|
|
default:
|
|
// Positional arguments: fromVersion toVersion primaryRepo [additionalRepos...]
|
|
if (!options.fromVersion) {
|
|
options.fromVersion = args[i];
|
|
} else if (!options.toVersion) {
|
|
options.toVersion = args[i];
|
|
} else {
|
|
// Repository paths
|
|
if (!options.config.repositories[0].path) {
|
|
options.config.repositories[0].path = args[i];
|
|
options.config.repositories[0].name = args[i].split('/').pop();
|
|
options.config.repositories[0].label =
|
|
options.config.repositories[0].name;
|
|
} else {
|
|
// Additional repositories
|
|
const repoName = args[i].split('/').pop();
|
|
options.config.repositories.push({
|
|
name: repoName,
|
|
path: args[i],
|
|
label: repoName,
|
|
});
|
|
}
|
|
}
|
|
i++;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Set defaults
|
|
options.fromVersion = options.fromVersion || 'v3.1.0';
|
|
options.toVersion = options.toVersion || 'v3.2.0';
|
|
|
|
// Set default labels if not provided
|
|
options.config.repositories.forEach((repo, index) => {
|
|
if (!repo.label) {
|
|
repo.label = repo.name || `repo${index + 1}`;
|
|
}
|
|
// Set default includePrLinks if not specified
|
|
if (repo.includePrLinks === undefined) {
|
|
repo.includePrLinks = options.includePrLinks;
|
|
}
|
|
});
|
|
|
|
return options;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`
|
|
Usage: node generate-release-notes.js [options] <from_version> <to_version> <primary_repo_path> [additional_repo_paths...]
|
|
|
|
Options:
|
|
--no-fetch Skip fetching latest commits from remote
|
|
--pull Pull latest changes (implies fetch) - use with caution
|
|
--no-pr-links Omit PR links from commit messages (default: include links)
|
|
--config <file> Load configuration from JSON file
|
|
--format <type> Output format: 'integrated' or 'separated'
|
|
-h, --help Show this help message
|
|
|
|
Examples:
|
|
node generate-release-notes.js v3.1.0 v3.2.0 /path/to/influxdb
|
|
node generate-release-notes.js --no-fetch v3.1.0 v3.2.0 /path/to/influxdb
|
|
node generate-release-notes.js --pull v3.1.0 v3.2.0 /path/to/influxdb /path/to/influxdb_pro
|
|
node generate-release-notes.js --config config.json v3.1.0 v3.2.0
|
|
node generate-release-notes.js --format separated v3.1.0 v3.2.0 /path/to/influxdb /path/to/influxdb_pro
|
|
|
|
Configuration file format (JSON):
|
|
{
|
|
"outputFormat": "separated",
|
|
"primaryRepo": "influxdb",
|
|
"repositories": [
|
|
{
|
|
"name": "influxdb",
|
|
"path": "/path/to/influxdb",
|
|
"label": "Core",
|
|
"includePrLinks": true
|
|
},
|
|
{
|
|
"name": "influxdb_pro",
|
|
"path": "/path/to/influxdb_pro",
|
|
"label": "Enterprise",
|
|
"includePrLinks": false
|
|
}
|
|
],
|
|
"separatedTemplate": {
|
|
"header": "> [!Note]\\n> #### InfluxDB 3 Core and Enterprise relationship\\n>\\n> InfluxDB 3 Enterprise is a superset of InfluxDB 3 Core.\\n> All updates to Core are automatically included in Enterprise.\\n> The Enterprise sections below only list updates exclusive to Enterprise.",
|
|
"primaryLabel": "Core",
|
|
"secondaryLabel": "Enterprise",
|
|
"secondaryIntro": "All Core updates are included in Enterprise. Additional Enterprise-specific features and fixes:"
|
|
}
|
|
}
|
|
`);
|
|
}
|
|
|
|
// Main execution
|
|
async function main() {
|
|
try {
|
|
const options = parseArgs();
|
|
const generator = new ReleaseNotesGenerator(options);
|
|
await generator.generate();
|
|
} catch (error) {
|
|
console.error(`Error: ${error.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// Run if called directly
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|
|
|
|
export { ReleaseNotesGenerator };
|