| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615 |
- import { execSync } from 'child_process';
- // Path-based categories (used as fallback for non-JS files)
- // Ordered from most specific to least specific
- const categoryPaths = [
- // Specific renderer paths
- [ 'src/renderers/webgl', 'WebGLRenderer' ],
- [ 'src/renderers/webgpu', 'WebGPURenderer' ],
- [ 'src/renderers/common', 'Renderer' ],
- // Main sections
- [ 'docs', 'Docs' ],
- [ 'manual', 'Manual' ],
- [ 'editor', 'Editor' ],
- [ 'test', 'Tests' ],
- [ 'playground', 'Playground' ],
- [ 'utils', 'Utils' ],
- [ 'build', 'Build' ],
- [ 'examples/jsm', 'Addons' ],
- [ 'examples', 'Examples' ],
- [ 'src', 'Global' ]
- ];
- // Skip patterns - commits matching these will be excluded
- const skipPatterns = [
- /^Updated? builds?\.?$/i,
- /^Merge /i,
- /^Update dependency .* to /i,
- /^Update devDependencies/i,
- /^Update github\/codeql-action/i,
- /^Update actions\//i,
- /^Bump .* and /i,
- /^Updated package-lock\.json/i,
- /^Update copyright year/i,
- /^Update \w+\.js\.?$/i, // Generic "Update File.js" commits
- /^Updated? docs\.?$/i,
- /^Update REVISION/i
- ];
- // Categories that map to sections
- const sectionCategories = [ 'Docs', 'Manual', 'Examples', 'Editor', 'Tests', 'Utils', 'Build' ];
- function exec( command ) {
- try {
- return execSync( command, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } ).trim();
- } catch ( error ) {
- return '';
- }
- }
- function getLastTag() {
- return exec( 'git describe --tags --abbrev=0' );
- }
- function getCommitsSinceTag( tag ) {
- // Get commits since tag, oldest first, excluding merge commits
- const log = exec( `git log ${tag}..HEAD --no-merges --reverse --format="%H|%s|%an"` );
- if ( ! log ) return [];
- return log.split( '\n' ).filter( Boolean ).map( line => {
- const [ hash, subject, author ] = line.split( '|' );
- return { hash, subject, author };
- } );
- }
- function getChangedFiles( hash ) {
- const files = exec( `git diff-tree --no-commit-id --name-only -r ${hash}` );
- return files ? files.split( '\n' ).filter( Boolean ) : [];
- }
- function getCoAuthors( hash ) {
- const body = exec( `git log -1 --format="%b" ${hash}` );
- const regex = /Co-authored-by:\s*([^<]+)\s*<[^>]+>/gi;
- return [ ...body.matchAll( regex ) ].map( m => m[ 1 ].trim() );
- }
- function extractPRNumber( subject ) {
- // Match patterns like "(#12345)" or "#12345" at end
- const match = subject.match( /\(#(\d+)\)|\s#(\d+)$/ );
- return match ? ( match[ 1 ] || match[ 2 ] ) : null;
- }
- function getPRInfo( prNumber ) {
- const result = exec( `gh pr view ${prNumber} --json author,title,files --jq '{author: .author.login, title: .title, files: [.files[].path]}' 2>/dev/null` );
- try {
- return result ? JSON.parse( result ) : null;
- } catch ( e ) {
- return null;
- }
- }
- function categorizeFile( file ) {
- // Extract category from JS filename in src/ or examples/jsm/
- if ( file.endsWith( '.js' ) ) {
- const isAddon = file.startsWith( 'examples/jsm/' );
- if ( file.startsWith( 'src/' ) || isAddon ) {
- const match = file.match( /\/([^/]+)\.js$/ );
- if ( match ) return { category: match[ 1 ], isAddon };
- }
- }
- // Check path-based categories for non-JS files or other paths
- for ( const [ pathPrefix, category ] of categoryPaths ) {
- if ( file.startsWith( pathPrefix ) ) {
- return {
- category,
- isAddon: file.startsWith( 'examples/jsm/' ),
- section: sectionCategories.includes( category ) ? category : null
- };
- }
- }
- return { category: 'Global', isAddon: false };
- }
- function categorizeCommit( files ) {
- const categoryCounts = {};
- const sectionCounts = {};
- let hasAddon = false;
- let addonCategory = null;
- let addonCount = 0;
- let srcCount = 0;
- for ( const file of files ) {
- const result = categorizeFile( file );
- const cat = result.category;
- categoryCounts[ cat ] = ( categoryCounts[ cat ] || 0 ) + 1;
- // Track src files vs addon files
- if ( file.startsWith( 'src/' ) ) srcCount ++;
- if ( result.isAddon ) {
- hasAddon = true;
- addonCount ++;
- // Track addon category separately (ignore generic ones)
- if ( cat !== 'Examples' && cat !== 'Loaders' && cat !== 'Exporters' ) {
- if ( ! addonCategory || categoryCounts[ cat ] > categoryCounts[ addonCategory ] ) {
- addonCategory = cat;
- }
- } else if ( ! addonCategory ) {
- addonCategory = cat;
- }
- }
- if ( result.section ) {
- sectionCounts[ result.section ] = ( sectionCounts[ result.section ] || 0 ) + 1;
- }
- }
- // If commit primarily touches src/ files, don't treat as addon even if it has some addon files
- if ( srcCount > addonCount ) {
- hasAddon = false;
- }
- // If this commit has addon files and a specific addon category, use it
- if ( hasAddon && addonCategory && addonCategory !== 'Examples' ) {
- return { category: addonCategory, isAddon: true, section: null };
- }
- // Find the most common section (excluding Tests unless it's dominant)
- let maxSection = null;
- let maxSectionCount = 0;
- const totalFiles = files.length;
- for ( const [ sec, count ] of Object.entries( sectionCounts ) ) {
- // Only use Tests/Build section if it's the majority of files
- if ( ( sec === 'Tests' || sec === 'Build' ) && count < totalFiles * 0.5 ) continue;
- if ( count > maxSectionCount ) {
- maxSectionCount = count;
- maxSection = sec;
- }
- }
- // Return the category with the most files changed
- let maxCategory = 'Global';
- let maxCount = 0;
- for ( const [ cat, count ] of Object.entries( categoryCounts ) ) {
- if ( count > maxCount ) {
- maxCount = count;
- maxCategory = cat;
- }
- }
- return { category: maxCategory, isAddon: false, section: maxSection };
- }
- function shouldSkipCommit( subject ) {
- return skipPatterns.some( pattern => pattern.test( subject ) );
- }
- function extractCategoryFromTitle( title ) {
- // Extract category from title prefix like "Object3D: Added pivot"
- const match = title.match( /^([A-Za-z0-9_/]+):\s/ );
- return match ? match[ 1 ] : null;
- }
- function cleanSubject( subject, category ) {
- // Remove PR number from subject
- let cleaned = subject.replace( /\s*\(#\d+\)\s*$/, '' ).replace( /\s*#\d+\s*$/, '' ).trim();
- // Remove category prefix if it matches (e.g., "Editor: " when category is "Editor")
- const prefixPattern = new RegExp( `^${category}:\\s*`, 'i' );
- cleaned = cleaned.replace( prefixPattern, '' );
- // Also remove common prefixes
- cleaned = cleaned.replace( /^(Examples|Docs|Manual|Editor|Tests|Build|Global|TSL|WebGLRenderer|WebGPURenderer|Renderer|Scripts|Utils):\s*/i, '' );
- // Remove trailing period if present, we'll add it back
- cleaned = cleaned.replace( /\.\s*$/, '' );
- return cleaned;
- }
- function normalizeAuthor( author ) {
- const lower = author.toLowerCase();
- if ( lower === 'mr.doob' ) return 'mrdoob';
- if ( lower === 'michael herzog' ) return 'Mugen87';
- if ( lower === 'garrett johnson' ) return 'gkjohnson';
- if ( lower.startsWith( 'claude' ) ) return 'claude';
- if ( lower.startsWith( 'copilot' ) ) return 'microsoftcopilot';
- if ( lower.includes( 'dependabot' ) ) return 'dependabot';
- return author;
- }
- function formatEntry( subject, prNumber, hash, author, coAuthors, category ) {
- let entry = `${cleanSubject( subject, category )}.`;
- if ( prNumber ) {
- entry += ` #${prNumber}`;
- } else if ( hash ) {
- entry += ` ${hash}`;
- }
- if ( author ) {
- const authors = [ ...new Set( [ author, ...( coAuthors || [] ) ].map( normalizeAuthor ) ) ];
- entry += ` (@${authors.join( ', @' )})`;
- }
- return entry;
- }
- function addToGroup( groups, key, value ) {
- if ( ! groups[ key ] ) groups[ key ] = [];
- groups[ key ].push( value );
- }
- function validateEnvironment() {
- if ( ! exec( 'gh --version 2>/dev/null' ) ) {
- console.error( 'GitHub CLI (gh) is required but not installed.' );
- console.error( 'Install from: https://cli.github.com/' );
- process.exit( 1 );
- }
- const lastTag = getLastTag();
- if ( ! lastTag ) {
- console.error( 'No tags found in repository' );
- process.exit( 1 );
- }
- return lastTag;
- }
- function collectRevertedTitles( commits ) {
- const reverted = new Set();
- for ( const { subject } of commits ) {
- const match = subject.match( /^Revert "(.+)"/ );
- if ( match ) reverted.add( match[ 1 ] );
- }
- return reverted;
- }
- function processCommit( commit, revertedTitles ) {
- // Skip reverts
- if ( /^Revert "/.test( commit.subject ) ) return null;
- // Check if this commit was reverted
- const subjectWithoutPR = commit.subject.replace( /\s*\(#\d+\)\s*$/, '' );
- if ( revertedTitles.has( subjectWithoutPR ) ) return null;
- // Skip certain commits
- if ( shouldSkipCommit( commit.subject ) ) return null;
- const prNumber = extractPRNumber( commit.subject );
- // Try to get PR info for better title and author
- let author = null;
- let subject = commit.subject;
- let files = null;
- if ( prNumber ) {
- const prInfo = getPRInfo( prNumber );
- if ( prInfo ) {
- author = prInfo.author;
- if ( prInfo.title ) subject = prInfo.title;
- if ( prInfo.files && prInfo.files.length > 0 ) files = prInfo.files;
- }
- }
- // Fall back to git data
- if ( ! files ) files = getChangedFiles( commit.hash );
- if ( ! author ) author = commit.author;
- const result = categorizeCommit( files );
- let { category, section } = result;
- const { isAddon } = result;
- // Override category if title has a clear prefix
- const titleCategory = extractCategoryFromTitle( subject );
- if ( titleCategory ) {
- category = titleCategory;
- if ( category === 'Puppeteer' ) category = 'Tests';
- if ( category === 'Scripts' ) category = 'Utils';
- section = sectionCategories.includes( category ) ? category : null;
- }
- // Route jsdoc/typo/docs-related commits to Docs section
- if ( /\b(jsdoc|typo|spelling|documentation)\b/i.test( subject ) ) {
- section = 'Docs';
- }
- const coAuthors = getCoAuthors( commit.hash );
- return {
- entry: {
- subject,
- prNumber,
- author,
- category,
- formatted: formatEntry( subject, prNumber, commit.hash, author, coAuthors, category )
- },
- category,
- section,
- isAddon
- };
- }
- function formatOutput( lastTag, coreChanges, addonChanges, sections ) {
- let output = '';
- // Migration guide and milestone links
- const version = lastTag.replace( 'r', '' );
- const nextVersion = parseInt( version ) + 1;
- output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${version}--${nextVersion}\n`;
- output += 'https://github.com/mrdoob/three.js/milestone/XX?closed=1\n\n';
- // Core changes (Global first, then alphabetically)
- const sortedCore = Object.keys( coreChanges ).sort( ( a, b ) => {
- if ( a === 'Global' ) return - 1;
- if ( b === 'Global' ) return 1;
- return a.localeCompare( b );
- } );
- for ( const category of sortedCore ) {
- output += `- ${category}\n`;
- for ( const entry of coreChanges[ category ] ) {
- output += ` - ${entry.formatted}\n`;
- }
- }
- // Output sections in order
- const sectionOrder = [ 'Docs', 'Manual', 'Examples', 'Addons', 'Editor', 'Tests', 'Utils', 'Build' ];
- for ( const sectionName of sectionOrder ) {
- // Addons section has nested categories
- if ( sectionName === 'Addons' ) {
- const sortedAddons = Object.keys( addonChanges ).sort();
- if ( sortedAddons.length > 0 ) {
- output += '\n**Addons**\n\n';
- for ( const category of sortedAddons ) {
- output += `- ${category}\n`;
- for ( const entry of addonChanges[ category ] ) {
- output += ` - ${entry.formatted}\n`;
- }
- output += '\n';
- }
- }
- continue;
- }
- if ( sections[ sectionName ].length > 0 ) {
- output += `\n**${sectionName}**\n\n`;
- for ( const entry of sections[ sectionName ] ) {
- output += `- ${entry.formatted}\n`;
- }
- }
- }
- return output;
- }
- function generateChangelog() {
- const lastTag = validateEnvironment();
- console.error( `Generating changelog since ${lastTag}...\n` );
- const commits = getCommitsSinceTag( lastTag );
- if ( commits.length === 0 ) {
- console.error( 'No commits found since last tag' );
- process.exit( 1 );
- }
- console.error( `Found ${commits.length} commits\n` );
- const revertedTitles = collectRevertedTitles( commits );
- // Group commits by category
- const coreChanges = {};
- const addonChanges = {};
- const sections = {
- Docs: [],
- Manual: [],
- Examples: [],
- Editor: [],
- Tests: [],
- Utils: [],
- Build: []
- };
- let skipped = 0;
- const total = commits.length;
- const barWidth = 40;
- for ( let i = 0; i < total; i ++ ) {
- const commit = commits[ i ];
- const done = i + 1;
- const filled = Math.round( barWidth * done / total );
- const bar = '█'.repeat( filled ) + '░'.repeat( barWidth - filled );
- const pct = Math.round( 100 * done / total );
- process.stderr.write( `\r ${bar} ${pct}% (${done}/${total})` );
- const result = processCommit( commit, revertedTitles );
- if ( ! result ) {
- skipped ++;
- continue;
- }
- const { entry, category, section, isAddon } = result;
- if ( section && sections[ section ] ) {
- sections[ section ].push( entry );
- } else if ( isAddon ) {
- addToGroup( addonChanges, category, entry );
- } else {
- addToGroup( coreChanges, category, entry );
- }
- }
- process.stderr.write( '\n\n' );
- if ( skipped > 0 ) {
- console.error( `Skipped ${skipped} commits (builds, dependency updates, etc.)\n` );
- }
- console.log( formatOutput( lastTag, coreChanges, addonChanges, sections ) );
- }
- generateChangelog();
|