@@ -9,8 +9,10 @@ const categoryPaths = [
99 [ 'src/renderers/common' , 'Renderer' ] ,
1010
1111 // Main sections
12+ [ 'utils/docs' , 'Docs' ] ,
1213 [ 'docs' , 'Docs' ] ,
1314 [ 'manual' , 'Manual' ] ,
15+ [ 'devtools' , 'Devtools' ] ,
1416 [ 'editor' , 'Editor' ] ,
1517 [ 'test' , 'Tests' ] ,
1618 [ 'playground' , 'Playground' ] ,
@@ -34,11 +36,15 @@ const skipPatterns = [
3436 / ^ U p d a t e c o p y r i g h t y e a r / i,
3537 / ^ U p d a t e \w + \. j s \. ? $ / i, // Generic "Update File.js" commits
3638 / ^ U p d a t e d ? d o c s \. ? $ / i,
37- / ^ U p d a t e R E V I S I O N / i
39+ / ^ U p d a t e R E V I S I O N / i,
40+ / ^ r \d + ( \s * \( b i s \) ) * $ / i
3841] ;
3942
43+ // Authors to skip (bots)
44+ const skipAuthors = new Set ( [ 'dependabot' , 'app/renovate' , 'renovate[bot]' ] ) ;
45+
4046// Categories that map to sections
41- const sectionCategories = [ 'Docs' , 'Manual' , 'Examples' , 'Editor' , 'Tests' , 'Utils' , 'Build' ] ;
47+ const sectionCategories = [ 'Docs' , 'Manual' , 'Examples' , 'Devtools' , ' Editor', 'Tests' , 'Utils' , 'Build' ] ;
4248
4349function exec ( command ) {
4450
@@ -54,16 +60,10 @@ function exec( command ) {
5460
5561}
5662
57- function getLastTag ( ) {
58-
59- return exec ( 'git describe --tags --abbrev=0' ) ;
60-
61- }
62-
63- function getCommitsSinceTag ( tag ) {
63+ function getCommitsBetweenTags ( fromTag , toTag ) {
6464
65- // Get commits since tag , oldest first, excluding merge commits
66- const log = exec ( `git log ${ tag } ..HEAD --no-merges --reverse --format="%H|%s|%an"` ) ;
65+ // Get commits between tags (exclusive fromTag, inclusive toTag) , oldest first, excluding merge commits
66+ const log = exec ( `git log ${ fromTag } ..${ toTag } --no-merges --reverse --format="%H|%s|%an"` ) ;
6767
6868 if ( ! log ) return [ ] ;
6969
@@ -83,11 +83,18 @@ function getChangedFiles( hash ) {
8383
8484}
8585
86- function getCoAuthors ( hash ) {
86+ function getCoAuthorsFromPR ( prNumber ) {
87+
88+ const result = exec ( `gh pr view ${ prNumber } --json commits --jq '[.commits[].authors[].login] | unique | .[]' 2>/dev/null` ) ;
89+ return result ? result . split ( '\n' ) . filter ( Boolean ) : [ ] ;
90+
91+ }
92+
93+ function getCoAuthorsFromCommit ( hash ) {
8794
8895 const body = exec ( `git log -1 --format="%b" ${ hash } ` ) ;
8996 const regex = / C o - a u t h o r e d - b y : \s * ( [ ^ < ] + ) \s * < [ ^ > ] + > / gi;
90- return [ ...body . matchAll ( regex ) ] . map ( m => m [ 1 ] . trim ( ) ) ;
97+ return [ ...body . matchAll ( regex ) ] . map ( m => normalizeAuthor ( m [ 1 ] . trim ( ) ) ) ;
9198
9299}
93100
@@ -124,6 +131,9 @@ function categorizeFile( file ) {
124131
125132 if ( file . startsWith ( 'src/' ) || isAddon ) {
126133
134+ // Skip barrel/index files
135+ if ( / \/ T h r e e ( \. \w + ) ? \. j s $ / . test ( file ) ) return { category : 'Global' , isAddon : false } ;
136+
127137 const match = file . match ( / \/ ( [ ^ / ] + ) \. j s $ / ) ;
128138 if ( match ) return { category : match [ 1 ] , isAddon } ;
129139
@@ -152,7 +162,10 @@ function categorizeFile( file ) {
152162
153163function categorizeCommit ( files ) {
154164
165+ files = files . filter ( f => ! f . startsWith ( 'examples/screenshots/' ) ) ;
166+
155167 const categoryCounts = { } ;
168+ const srcCategoryCounts = { } ;
156169 const sectionCounts = { } ;
157170 let hasAddon = false ;
158171 let addonCategory = null ;
@@ -167,7 +180,12 @@ function categorizeCommit( files ) {
167180 categoryCounts [ cat ] = ( categoryCounts [ cat ] || 0 ) + 1 ;
168181
169182 // Track src files vs addon files
170- if ( file . startsWith ( 'src/' ) ) srcCount ++ ;
183+ if ( file . startsWith ( 'src/' ) ) {
184+
185+ srcCount ++ ;
186+ srcCategoryCounts [ cat ] = ( srcCategoryCounts [ cat ] || 0 ) + 1 ;
187+
188+ }
171189
172190 if ( result . isAddon ) {
173191
@@ -213,16 +231,20 @@ function categorizeCommit( files ) {
213231
214232 }
215233
216- // Find the most common section (excluding Tests unless it's dominant)
234+ // If commit touches src/, treat as core change — category from src/ files only
235+ if ( srcCount > 0 ) {
236+
237+ const srcCategory = Object . entries ( srcCategoryCounts ) . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) [ 0 ] [ 0 ] ;
238+ return { category : srcCategory , isAddon : false , section : null } ;
239+
240+ }
241+
242+ // Find the most common section
217243 let maxSection = null ;
218244 let maxSectionCount = 0 ;
219- const totalFiles = files . length ;
220245
221246 for ( const [ sec , count ] of Object . entries ( sectionCounts ) ) {
222247
223- // Only use Tests/Build section if it's the majority of files
224- if ( ( sec === 'Tests' || sec === 'Build' ) && count < totalFiles * 0.5 ) continue ;
225-
226248 if ( count > maxSectionCount ) {
227249
228250 maxSectionCount = count ;
@@ -330,7 +352,7 @@ function addToGroup( groups, key, value ) {
330352
331353}
332354
333- function validateEnvironment ( ) {
355+ function validateEnvironment ( tag ) {
334356
335357 if ( ! exec ( 'gh --version 2>/dev/null' ) ) {
336358
@@ -340,16 +362,38 @@ function validateEnvironment() {
340362
341363 }
342364
343- const lastTag = getLastTag ( ) ;
365+ if ( ! tag ) {
366+
367+ console . error ( 'Usage: node utils/changelog.js <tag>' ) ;
368+ console . error ( 'Example: node utils/changelog.js r185' ) ;
369+ process . exit ( 1 ) ;
370+
371+ }
372+
373+ // Verify the tag exists
374+ const resolved = exec ( `git rev-parse --verify ${ tag } ` ) ;
375+
376+ if ( ! resolved ) {
377+
378+ console . error ( `Invalid tag: ${ tag } ` ) ;
379+ process . exit ( 1 ) ;
380+
381+ }
382+
383+ // Get the previous tag
384+ const version = parseInt ( tag . replace ( 'r' , '' ) ) ;
385+ const previousTag = `r${ version - 1 } ` ;
344386
345- if ( ! lastTag ) {
387+ const previousResolved = exec ( `git rev-parse --verify ${ previousTag } ` ) ;
346388
347- console . error ( 'No tags found in repository' ) ;
389+ if ( ! previousResolved ) {
390+
391+ console . error ( `Previous tag not found: ${ previousTag } ` ) ;
348392 process . exit ( 1 ) ;
349393
350394 }
351395
352- return lastTag ;
396+ return { tag , previousTag , version } ;
353397
354398}
355399
@@ -393,6 +437,9 @@ function processCommit( commit, revertedTitles ) {
393437
394438 if ( prInfo ) {
395439
440+ // Skip commits from bots
441+ if ( skipAuthors . has ( prInfo . author ) ) return null ;
442+
396443 author = prInfo . author ;
397444 if ( prInfo . title ) subject = prInfo . title ;
398445 if ( prInfo . files && prInfo . files . length > 0 ) files = prInfo . files ;
@@ -405,19 +452,26 @@ function processCommit( commit, revertedTitles ) {
405452 if ( ! files ) files = getChangedFiles ( commit . hash ) ;
406453 if ( ! author ) author = commit . author ;
407454
455+ // Skip commits from bots (check normalized name for git author fallback)
456+ if ( skipAuthors . has ( normalizeAuthor ( author ) ) ) return null ;
457+
408458 const result = categorizeCommit ( files ) ;
409459 let { category, section } = result ;
410460 const { isAddon } = result ;
411461
412- // Override category if title has a clear prefix
413- const titleCategory = extractCategoryFromTitle ( subject ) ;
462+ // Use title prefix as category only if file-based didn't assign a section
463+ if ( ! section ) {
464+
465+ const titleCategory = extractCategoryFromTitle ( subject ) ;
414466
415- if ( titleCategory ) {
467+ if ( titleCategory ) {
416468
417- category = titleCategory ;
418- if ( category === 'Puppeteer' ) category = 'Tests' ;
419- if ( category === 'Scripts' ) category = 'Utils' ;
420- section = sectionCategories . includes ( category ) ? category : null ;
469+ category = titleCategory ;
470+ if ( category === 'Scripts' ) category = 'Utils' ;
471+ if ( category === 'Puppeteer' || category === 'E2E' ) category = 'Tests' ;
472+ section = sectionCategories . includes ( category ) ? category : null ;
473+
474+ }
421475
422476 }
423477
@@ -428,7 +482,7 @@ function processCommit( commit, revertedTitles ) {
428482
429483 }
430484
431- const coAuthors = getCoAuthors ( commit . hash ) ;
485+ const coAuthors = ( prNumber ? getCoAuthorsFromPR ( prNumber ) : getCoAuthorsFromCommit ( commit . hash ) ) . filter ( login => login !== author ) ;
432486
433487 return {
434488 entry : {
@@ -445,15 +499,13 @@ function processCommit( commit, revertedTitles ) {
445499
446500}
447501
448- function formatOutput ( lastTag , coreChanges , addonChanges , sections ) {
502+ function formatOutput ( version , coreChanges , addonChanges , sections ) {
449503
450504 let output = '' ;
451505
452- // Migration guide and milestone links
453- const version = lastTag . replace ( 'r' , '' ) ;
454- const nextVersion = parseInt ( version ) + 1 ;
455- output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${ version } --${ nextVersion } \n` ;
456- output += 'https://github.com/mrdoob/three.js/milestone/XX?closed=1\n\n' ;
506+ const previousVersion = version - 1 ;
507+ output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${ previousVersion } --${ version } \n` ;
508+ output += `https://github.com/mrdoob/three.js/milestone/${ version - 87 } ?closed=1\n\n` ;
457509
458510 // Core changes (Global first, then alphabetically)
459511 const sortedCore = Object . keys ( coreChanges ) . sort ( ( a , b ) => {
@@ -477,7 +529,7 @@ function formatOutput( lastTag, coreChanges, addonChanges, sections ) {
477529 }
478530
479531 // Output sections in order
480- const sectionOrder = [ 'Docs' , 'Manual' , 'Examples' , 'Addons' , 'Editor' , 'Tests' , 'Utils' , 'Build' ] ;
532+ const sectionOrder = [ 'Docs' , 'Manual' , 'Examples' , 'Addons' , 'Devtools' , ' Editor', 'Tests' , 'Utils' , 'Build' ] ;
481533
482534 for ( const sectionName of sectionOrder ) {
483535
@@ -530,15 +582,15 @@ function formatOutput( lastTag, coreChanges, addonChanges, sections ) {
530582
531583function generateChangelog ( ) {
532584
533- const lastTag = validateEnvironment ( ) ;
585+ const { tag , previousTag , version } = validateEnvironment ( process . argv [ 2 ] ) ;
534586
535- console . error ( `Generating changelog since ${ lastTag } ... \n` ) ;
587+ console . error ( `Generating changelog ${ previousTag } ..${ tag } \n` ) ;
536588
537- const commits = getCommitsSinceTag ( lastTag ) ;
589+ const commits = getCommitsBetweenTags ( previousTag , tag ) ;
538590
539591 if ( commits . length === 0 ) {
540592
541- console . error ( ' No commits found since last tag' ) ;
593+ console . error ( ` No commits found between ${ previousTag } and ${ tag } ` ) ;
542594 process . exit ( 1 ) ;
543595
544596 }
@@ -554,6 +606,7 @@ function generateChangelog() {
554606 Docs : [ ] ,
555607 Manual : [ ] ,
556608 Examples : [ ] ,
609+ Devtools : [ ] ,
557610 Editor : [ ] ,
558611 Tests : [ ] ,
559612 Utils : [ ] ,
@@ -608,7 +661,7 @@ function generateChangelog() {
608661
609662 }
610663
611- console . log ( formatOutput ( lastTag , coreChanges , addonChanges , sections ) ) ;
664+ console . log ( formatOutput ( version , coreChanges , addonChanges , sections ) ) ;
612665
613666}
614667
0 commit comments