| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668 |
- import { execFileSync } 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
- [ 'utils/docs', 'Docs' ],
- [ 'docs', 'Docs' ],
- [ 'manual', 'Manual' ],
- [ 'devtools', 'Devtools' ],
- [ '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,
- /^r\d+(\s*\(bis\))*$/i
- ];
- // Authors to skip (bots)
- const skipAuthors = new Set( [ 'dependabot', 'app/renovate', 'renovate[bot]' ] );
- // Categories that map to sections
- const sectionCategories = [ 'Docs', 'Manual', 'Examples', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ];
- function exec( file, args ) {
- try {
- return execFileSync( file, args, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, stdio: [ 'ignore', 'pipe', 'ignore' ] } ).trim();
- } catch ( error ) {
- return '';
- }
- }
- function getCommitsBetweenTags( fromTag, toTag ) {
- // Get commits between tags (exclusive fromTag, inclusive toTag), oldest first, excluding merge commits
- const log = exec( 'git', [ 'log', `${fromTag}..${toTag}`, '--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 getCoAuthorsFromPR( prNumber ) {
- const result = exec( 'gh', [ 'pr', 'view', prNumber, '--json', 'commits', '--jq', '[.commits[].authors[].login] | unique | .[]' ] );
- return result ? result.split( '\n' ).filter( Boolean ) : [];
- }
- function getCoAuthorsFromCommit( hash ) {
- const body = exec( 'git', [ 'log', '-1', '--format=%b', hash ] );
- const regex = /Co-authored-by:\s*([^<]+)\s*<[^>]+>/gi;
- return [ ...body.matchAll( regex ) ].map( m => normalizeAuthor( 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]}' ] );
- 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 ) {
- // Skip barrel/index files
- if ( /\/Three(\.\w+)?\.js$/.test( file ) ) return { category: 'Global', isAddon: false };
- 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 ) {
- files = files.filter( f => ! f.startsWith( 'examples/screenshots/' ) );
- const categoryCounts = {};
- const srcCategoryCounts = {};
- 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 ++;
- srcCategoryCounts[ cat ] = ( srcCategoryCounts[ cat ] || 0 ) + 1;
- }
- 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 };
- }
- // If commit touches src/, treat as core change — category from src/ files only
- if ( srcCount > 0 ) {
- const srcCategory = Object.entries( srcCategoryCounts ).sort( ( a, b ) => b[ 1 ] - a[ 1 ] )[ 0 ][ 0 ];
- return { category: srcCategory, isAddon: false, section: null };
- }
- // Find the most common section
- let maxSection = null;
- let maxSectionCount = 0;
- for ( const [ sec, count ] of Object.entries( sectionCounts ) ) {
- 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( tag ) {
- if ( ! exec( 'gh', [ '--version' ] ) ) {
- console.error( 'GitHub CLI (gh) is required but not installed.' );
- console.error( 'Install from: https://cli.github.com/' );
- process.exit( 1 );
- }
- if ( ! tag ) {
- console.error( 'Usage: node utils/changelog.js <tag>' );
- console.error( 'Example: node utils/changelog.js r185' );
- process.exit( 1 );
- }
- // Verify the tag exists
- const resolved = exec( 'git', [ 'rev-parse', '--verify', tag ] );
- if ( ! resolved ) {
- console.error( `Invalid tag: ${tag}` );
- process.exit( 1 );
- }
- // Get the previous tag
- const version = parseInt( tag.replace( 'r', '' ) );
- const previousTag = `r${version - 1}`;
- const previousResolved = exec( 'git', [ 'rev-parse', '--verify', previousTag ] );
- if ( ! previousResolved ) {
- console.error( `Previous tag not found: ${previousTag}` );
- process.exit( 1 );
- }
- return { tag, previousTag, version };
- }
- 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 ) {
- // Skip commits from bots
- if ( skipAuthors.has( prInfo.author ) ) return null;
- 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;
- // Skip commits from bots (check normalized name for git author fallback)
- if ( skipAuthors.has( normalizeAuthor( author ) ) ) return null;
- const result = categorizeCommit( files );
- let { category, section } = result;
- const { isAddon } = result;
- // Use title prefix as category only if file-based didn't assign a section
- if ( ! section ) {
- const titleCategory = extractCategoryFromTitle( subject );
- if ( titleCategory ) {
- category = titleCategory;
- if ( category === 'Scripts' ) category = 'Utils';
- if ( category === 'Puppeteer' || category === 'E2E' ) category = 'Tests';
- 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 = ( prNumber ? getCoAuthorsFromPR( prNumber ) : getCoAuthorsFromCommit( commit.hash ) ).filter( login => login !== author );
- return {
- entry: {
- subject,
- prNumber,
- author,
- category,
- formatted: formatEntry( subject, prNumber, commit.hash, author, coAuthors, category )
- },
- category,
- section,
- isAddon
- };
- }
- function formatOutput( version, coreChanges, addonChanges, sections ) {
- let output = '';
- const previousVersion = version - 1;
- output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${previousVersion}--${version}\n`;
- output += `https://github.com/mrdoob/three.js/milestone/${version - 87}?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', 'Devtools', '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 { tag, previousTag, version } = validateEnvironment( process.argv[ 2 ] );
- console.error( `Generating changelog ${previousTag}..${tag}\n` );
- const commits = getCommitsBetweenTags( previousTag, tag );
- if ( commits.length === 0 ) {
- console.error( `No commits found between ${previousTag} and ${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: [],
- Devtools: [],
- 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( version, coreChanges, addonChanges, sections ) );
- }
- generateChangelog();
|