Browse Source

Improved changelog.js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Mr.doob 3 weeks ago
parent
commit
d718472ad9
1 changed files with 97 additions and 44 deletions
  1. 97 44
      utils/changelog.js

+ 97 - 44
utils/changelog.js

@@ -9,8 +9,10 @@ const categoryPaths = [
 	[ 'src/renderers/common', 'Renderer' ],
 
 	// Main sections
+	[ 'utils/docs', 'Docs' ],
 	[ 'docs', 'Docs' ],
 	[ 'manual', 'Manual' ],
+	[ 'devtools', 'Devtools' ],
 	[ 'editor', 'Editor' ],
 	[ 'test', 'Tests' ],
 	[ 'playground', 'Playground' ],
@@ -34,11 +36,15 @@ const skipPatterns = [
 	/^Update copyright year/i,
 	/^Update \w+\.js\.?$/i, // Generic "Update File.js" commits
 	/^Updated? docs\.?$/i,
-	/^Update REVISION/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', 'Editor', 'Tests', 'Utils', 'Build' ];
+const sectionCategories = [ 'Docs', 'Manual', 'Examples', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ];
 
 function exec( command ) {
 
@@ -54,16 +60,10 @@ function exec( command ) {
 
 }
 
-function getLastTag() {
-
-	return exec( 'git describe --tags --abbrev=0' );
-
-}
-
-function getCommitsSinceTag( tag ) {
+function getCommitsBetweenTags( fromTag, toTag ) {
 
-	// Get commits since tag, oldest first, excluding merge commits
-	const log = exec( `git log ${tag}..HEAD --no-merges --reverse --format="%H|%s|%an"` );
+	// 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 [];
 
@@ -83,11 +83,18 @@ function getChangedFiles( hash ) {
 
 }
 
-function getCoAuthors( hash ) {
+function getCoAuthorsFromPR( prNumber ) {
+
+	const result = exec( `gh pr view ${prNumber} --json commits --jq '[.commits[].authors[].login] | unique | .[]' 2>/dev/null` );
+	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 => m[ 1 ].trim() );
+	return [ ...body.matchAll( regex ) ].map( m => normalizeAuthor( m[ 1 ].trim() ) );
 
 }
 
@@ -124,6 +131,9 @@ function categorizeFile( file ) {
 
 		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 };
 
@@ -152,7 +162,10 @@ function categorizeFile( file ) {
 
 function categorizeCommit( files ) {
 
+	files = files.filter( f => ! f.startsWith( 'examples/screenshots/' ) );
+
 	const categoryCounts = {};
+	const srcCategoryCounts = {};
 	const sectionCounts = {};
 	let hasAddon = false;
 	let addonCategory = null;
@@ -167,7 +180,12 @@ function categorizeCommit( files ) {
 		categoryCounts[ cat ] = ( categoryCounts[ cat ] || 0 ) + 1;
 
 		// Track src files vs addon files
-		if ( file.startsWith( 'src/' ) ) srcCount ++;
+		if ( file.startsWith( 'src/' ) ) {
+
+			srcCount ++;
+			srcCategoryCounts[ cat ] = ( srcCategoryCounts[ cat ] || 0 ) + 1;
+
+		}
 
 		if ( result.isAddon ) {
 
@@ -213,16 +231,20 @@ function categorizeCommit( files ) {
 
 	}
 
-	// Find the most common section (excluding Tests unless it's dominant)
+	// 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;
-	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;
@@ -330,7 +352,7 @@ function addToGroup( groups, key, value ) {
 
 }
 
-function validateEnvironment() {
+function validateEnvironment( tag ) {
 
 	if ( ! exec( 'gh --version 2>/dev/null' ) ) {
 
@@ -340,16 +362,38 @@ function validateEnvironment() {
 
 	}
 
-	const lastTag = getLastTag();
+	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}`;
 
-	if ( ! lastTag ) {
+	const previousResolved = exec( `git rev-parse --verify ${previousTag}` );
 
-		console.error( 'No tags found in repository' );
+	if ( ! previousResolved ) {
+
+		console.error( `Previous tag not found: ${previousTag}` );
 		process.exit( 1 );
 
 	}
 
-	return lastTag;
+	return { tag, previousTag, version };
 
 }
 
@@ -393,6 +437,9 @@ function processCommit( commit, revertedTitles ) {
 
 		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;
@@ -405,19 +452,26 @@ function processCommit( commit, revertedTitles ) {
 	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;
 
-	// Override category if title has a clear prefix
-	const titleCategory = extractCategoryFromTitle( subject );
+	// Use title prefix as category only if file-based didn't assign a section
+	if ( ! section ) {
+
+		const titleCategory = extractCategoryFromTitle( subject );
 
-	if ( titleCategory ) {
+		if ( titleCategory ) {
 
-		category = titleCategory;
-		if ( category === 'Puppeteer' ) category = 'Tests';
-		if ( category === 'Scripts' ) category = 'Utils';
-		section = sectionCategories.includes( category ) ? category : null;
+			category = titleCategory;
+			if ( category === 'Scripts' ) category = 'Utils';
+			if ( category === 'Puppeteer' || category === 'E2E' ) category = 'Tests';
+			section = sectionCategories.includes( category ) ? category : null;
+
+		}
 
 	}
 
@@ -428,7 +482,7 @@ function processCommit( commit, revertedTitles ) {
 
 	}
 
-	const coAuthors = getCoAuthors( commit.hash );
+	const coAuthors = ( prNumber ? getCoAuthorsFromPR( prNumber ) : getCoAuthorsFromCommit( commit.hash ) ).filter( login => login !== author );
 
 	return {
 		entry: {
@@ -445,15 +499,13 @@ function processCommit( commit, revertedTitles ) {
 
 }
 
-function formatOutput( lastTag, coreChanges, addonChanges, sections ) {
+function formatOutput( version, 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';
+	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 ) => {
@@ -477,7 +529,7 @@ function formatOutput( lastTag, coreChanges, addonChanges, sections ) {
 	}
 
 	// Output sections in order
-	const sectionOrder = [ 'Docs', 'Manual', 'Examples', 'Addons', 'Editor', 'Tests', 'Utils', 'Build' ];
+	const sectionOrder = [ 'Docs', 'Manual', 'Examples', 'Addons', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ];
 
 	for ( const sectionName of sectionOrder ) {
 
@@ -530,15 +582,15 @@ function formatOutput( lastTag, coreChanges, addonChanges, sections ) {
 
 function generateChangelog() {
 
-	const lastTag = validateEnvironment();
+	const { tag, previousTag, version } = validateEnvironment( process.argv[ 2 ] );
 
-	console.error( `Generating changelog since ${lastTag}...\n` );
+	console.error( `Generating changelog ${previousTag}..${tag}\n` );
 
-	const commits = getCommitsSinceTag( lastTag );
+	const commits = getCommitsBetweenTags( previousTag, tag );
 
 	if ( commits.length === 0 ) {
 
-		console.error( 'No commits found since last tag' );
+		console.error( `No commits found between ${previousTag} and ${tag}` );
 		process.exit( 1 );
 
 	}
@@ -554,6 +606,7 @@ function generateChangelog() {
 		Docs: [],
 		Manual: [],
 		Examples: [],
+		Devtools: [],
 		Editor: [],
 		Tests: [],
 		Utils: [],
@@ -608,7 +661,7 @@ function generateChangelog() {
 
 	}
 
-	console.log( formatOutput( lastTag, coreChanges, addonChanges, sections ) );
+	console.log( formatOutput( version, coreChanges, addonChanges, sections ) );
 
 }
 

粤ICP备19079148号