changelog.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. import { execFileSync } from 'child_process';
  2. // Path-based categories (used as fallback for non-JS files)
  3. // Ordered from most specific to least specific
  4. const categoryPaths = [
  5. // Specific renderer paths
  6. [ 'src/renderers/webgl', 'WebGLRenderer' ],
  7. [ 'src/renderers/webgpu', 'WebGPURenderer' ],
  8. [ 'src/renderers/common', 'Renderer' ],
  9. // Main sections
  10. [ 'utils/docs', 'Docs' ],
  11. [ 'docs', 'Docs' ],
  12. [ 'manual', 'Manual' ],
  13. [ 'devtools', 'Devtools' ],
  14. [ 'editor', 'Editor' ],
  15. [ 'test', 'Tests' ],
  16. [ 'playground', 'Playground' ],
  17. [ 'utils', 'Utils' ],
  18. [ 'build', 'Build' ],
  19. [ 'examples/jsm', 'Addons' ],
  20. [ 'examples', 'Examples' ],
  21. [ 'src', 'Global' ]
  22. ];
  23. // Skip patterns - commits matching these will be excluded
  24. const skipPatterns = [
  25. /^Updated? builds?\.?$/i,
  26. /^Merge /i,
  27. /^Update dependency .* to /i,
  28. /^Update devDependencies/i,
  29. /^Update github\/codeql-action/i,
  30. /^Update actions\//i,
  31. /^Bump .* and /i,
  32. /^Updated package-lock\.json/i,
  33. /^Update copyright year/i,
  34. /^Update \w+\.js\.?$/i, // Generic "Update File.js" commits
  35. /^Updated? docs\.?$/i,
  36. /^Update REVISION/i,
  37. /^r\d+(\s*\(bis\))*$/i
  38. ];
  39. // Authors to skip (bots)
  40. const skipAuthors = new Set( [ 'dependabot', 'app/renovate', 'renovate[bot]' ] );
  41. // Categories that map to sections
  42. const sectionCategories = [ 'Docs', 'Manual', 'Examples', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ];
  43. function exec( file, args ) {
  44. try {
  45. return execFileSync( file, args, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, stdio: [ 'ignore', 'pipe', 'ignore' ] } ).trim();
  46. } catch ( error ) {
  47. return '';
  48. }
  49. }
  50. function getCommitsBetweenTags( fromTag, toTag ) {
  51. // Get commits between tags (exclusive fromTag, inclusive toTag), oldest first, excluding merge commits
  52. const log = exec( 'git', [ 'log', `${fromTag}..${toTag}`, '--no-merges', '--reverse', '--format=%H|%s|%an' ] );
  53. if ( ! log ) return [];
  54. return log.split( '\n' ).filter( Boolean ).map( line => {
  55. const [ hash, subject, author ] = line.split( '|' );
  56. return { hash, subject, author };
  57. } );
  58. }
  59. function getChangedFiles( hash ) {
  60. const files = exec( 'git', [ 'diff-tree', '--no-commit-id', '--name-only', '-r', hash ] );
  61. return files ? files.split( '\n' ).filter( Boolean ) : [];
  62. }
  63. function getCoAuthorsFromPR( prNumber ) {
  64. const result = exec( 'gh', [ 'pr', 'view', prNumber, '--json', 'commits', '--jq', '[.commits[].authors[].login] | unique | .[]' ] );
  65. return result ? result.split( '\n' ).filter( Boolean ) : [];
  66. }
  67. function getCoAuthorsFromCommit( hash ) {
  68. const body = exec( 'git', [ 'log', '-1', '--format=%b', hash ] );
  69. const regex = /Co-authored-by:\s*([^<]+)\s*<[^>]+>/gi;
  70. return [ ...body.matchAll( regex ) ].map( m => normalizeAuthor( m[ 1 ].trim() ) );
  71. }
  72. function extractPRNumber( subject ) {
  73. // Match patterns like "(#12345)" or "#12345" at end
  74. const match = subject.match( /\(#(\d+)\)|\s#(\d+)$/ );
  75. return match ? ( match[ 1 ] || match[ 2 ] ) : null;
  76. }
  77. function getPRInfo( prNumber ) {
  78. const result = exec( 'gh', [ 'pr', 'view', prNumber, '--json', 'author,title,files', '--jq', '{author: .author.login, title: .title, files: [.files[].path]}' ] );
  79. try {
  80. return result ? JSON.parse( result ) : null;
  81. } catch ( e ) {
  82. return null;
  83. }
  84. }
  85. function categorizeFile( file ) {
  86. // Extract category from JS filename in src/ or examples/jsm/
  87. if ( file.endsWith( '.js' ) ) {
  88. const isAddon = file.startsWith( 'examples/jsm/' );
  89. if ( file.startsWith( 'src/' ) || isAddon ) {
  90. // Skip barrel/index files
  91. if ( /\/Three(\.\w+)?\.js$/.test( file ) ) return { category: 'Global', isAddon: false };
  92. const match = file.match( /\/([^/]+)\.js$/ );
  93. if ( match ) return { category: match[ 1 ], isAddon };
  94. }
  95. }
  96. // Check path-based categories for non-JS files or other paths
  97. for ( const [ pathPrefix, category ] of categoryPaths ) {
  98. if ( file.startsWith( pathPrefix ) ) {
  99. return {
  100. category,
  101. isAddon: file.startsWith( 'examples/jsm/' ),
  102. section: sectionCategories.includes( category ) ? category : null
  103. };
  104. }
  105. }
  106. return { category: 'Global', isAddon: false };
  107. }
  108. function categorizeCommit( files ) {
  109. files = files.filter( f => ! f.startsWith( 'examples/screenshots/' ) );
  110. const categoryCounts = {};
  111. const srcCategoryCounts = {};
  112. const sectionCounts = {};
  113. let hasAddon = false;
  114. let addonCategory = null;
  115. let addonCount = 0;
  116. let srcCount = 0;
  117. for ( const file of files ) {
  118. const result = categorizeFile( file );
  119. const cat = result.category;
  120. categoryCounts[ cat ] = ( categoryCounts[ cat ] || 0 ) + 1;
  121. // Track src files vs addon files
  122. if ( file.startsWith( 'src/' ) ) {
  123. srcCount ++;
  124. srcCategoryCounts[ cat ] = ( srcCategoryCounts[ cat ] || 0 ) + 1;
  125. }
  126. if ( result.isAddon ) {
  127. hasAddon = true;
  128. addonCount ++;
  129. // Track addon category separately (ignore generic ones)
  130. if ( cat !== 'Examples' && cat !== 'Loaders' && cat !== 'Exporters' ) {
  131. if ( ! addonCategory || categoryCounts[ cat ] > categoryCounts[ addonCategory ] ) {
  132. addonCategory = cat;
  133. }
  134. } else if ( ! addonCategory ) {
  135. addonCategory = cat;
  136. }
  137. }
  138. if ( result.section ) {
  139. sectionCounts[ result.section ] = ( sectionCounts[ result.section ] || 0 ) + 1;
  140. }
  141. }
  142. // If commit primarily touches src/ files, don't treat as addon even if it has some addon files
  143. if ( srcCount > addonCount ) {
  144. hasAddon = false;
  145. }
  146. // If this commit has addon files and a specific addon category, use it
  147. if ( hasAddon && addonCategory && addonCategory !== 'Examples' ) {
  148. return { category: addonCategory, isAddon: true, section: null };
  149. }
  150. // If commit touches src/, treat as core change — category from src/ files only
  151. if ( srcCount > 0 ) {
  152. const srcCategory = Object.entries( srcCategoryCounts ).sort( ( a, b ) => b[ 1 ] - a[ 1 ] )[ 0 ][ 0 ];
  153. return { category: srcCategory, isAddon: false, section: null };
  154. }
  155. // Find the most common section
  156. let maxSection = null;
  157. let maxSectionCount = 0;
  158. for ( const [ sec, count ] of Object.entries( sectionCounts ) ) {
  159. if ( count > maxSectionCount ) {
  160. maxSectionCount = count;
  161. maxSection = sec;
  162. }
  163. }
  164. // Return the category with the most files changed
  165. let maxCategory = 'Global';
  166. let maxCount = 0;
  167. for ( const [ cat, count ] of Object.entries( categoryCounts ) ) {
  168. if ( count > maxCount ) {
  169. maxCount = count;
  170. maxCategory = cat;
  171. }
  172. }
  173. return { category: maxCategory, isAddon: false, section: maxSection };
  174. }
  175. function shouldSkipCommit( subject ) {
  176. return skipPatterns.some( pattern => pattern.test( subject ) );
  177. }
  178. function extractCategoryFromTitle( title ) {
  179. // Extract category from title prefix like "Object3D: Added pivot"
  180. const match = title.match( /^([A-Za-z0-9_/]+):\s/ );
  181. return match ? match[ 1 ] : null;
  182. }
  183. function cleanSubject( subject, category ) {
  184. // Remove PR number from subject
  185. let cleaned = subject.replace( /\s*\(#\d+\)\s*$/, '' ).replace( /\s*#\d+\s*$/, '' ).trim();
  186. // Remove category prefix if it matches (e.g., "Editor: " when category is "Editor")
  187. const prefixPattern = new RegExp( `^${category}:\\s*`, 'i' );
  188. cleaned = cleaned.replace( prefixPattern, '' );
  189. // Also remove common prefixes
  190. cleaned = cleaned.replace( /^(Examples|Docs|Manual|Editor|Tests|Build|Global|TSL|WebGLRenderer|WebGPURenderer|Renderer|Scripts|Utils):\s*/i, '' );
  191. // Remove trailing period if present, we'll add it back
  192. cleaned = cleaned.replace( /\.\s*$/, '' );
  193. return cleaned;
  194. }
  195. function normalizeAuthor( author ) {
  196. const lower = author.toLowerCase();
  197. if ( lower === 'mr.doob' ) return 'mrdoob';
  198. if ( lower === 'michael herzog' ) return 'Mugen87';
  199. if ( lower === 'garrett johnson' ) return 'gkjohnson';
  200. if ( lower.startsWith( 'claude' ) ) return 'claude';
  201. if ( lower.startsWith( 'copilot' ) ) return 'microsoftcopilot';
  202. if ( lower.includes( 'dependabot' ) ) return 'dependabot';
  203. return author;
  204. }
  205. function formatEntry( subject, prNumber, hash, author, coAuthors, category ) {
  206. let entry = `${cleanSubject( subject, category )}.`;
  207. if ( prNumber ) {
  208. entry += ` #${prNumber}`;
  209. } else if ( hash ) {
  210. entry += ` ${hash}`;
  211. }
  212. if ( author ) {
  213. const authors = [ ...new Set( [ author, ...( coAuthors || [] ) ].map( normalizeAuthor ) ) ];
  214. entry += ` (@${authors.join( ', @' )})`;
  215. }
  216. return entry;
  217. }
  218. function addToGroup( groups, key, value ) {
  219. if ( ! groups[ key ] ) groups[ key ] = [];
  220. groups[ key ].push( value );
  221. }
  222. function validateEnvironment( tag ) {
  223. if ( ! exec( 'gh', [ '--version' ] ) ) {
  224. console.error( 'GitHub CLI (gh) is required but not installed.' );
  225. console.error( 'Install from: https://cli.github.com/' );
  226. process.exit( 1 );
  227. }
  228. if ( ! tag ) {
  229. console.error( 'Usage: node utils/changelog.js <tag>' );
  230. console.error( 'Example: node utils/changelog.js r185' );
  231. process.exit( 1 );
  232. }
  233. // Verify the tag exists
  234. const resolved = exec( 'git', [ 'rev-parse', '--verify', tag ] );
  235. if ( ! resolved ) {
  236. console.error( `Invalid tag: ${tag}` );
  237. process.exit( 1 );
  238. }
  239. // Get the previous tag
  240. const version = parseInt( tag.replace( 'r', '' ) );
  241. const previousTag = `r${version - 1}`;
  242. const previousResolved = exec( 'git', [ 'rev-parse', '--verify', previousTag ] );
  243. if ( ! previousResolved ) {
  244. console.error( `Previous tag not found: ${previousTag}` );
  245. process.exit( 1 );
  246. }
  247. return { tag, previousTag, version };
  248. }
  249. function collectRevertedTitles( commits ) {
  250. const reverted = new Set();
  251. for ( const { subject } of commits ) {
  252. const match = subject.match( /^Revert "(.+)"/ );
  253. if ( match ) reverted.add( match[ 1 ] );
  254. }
  255. return reverted;
  256. }
  257. function processCommit( commit, revertedTitles ) {
  258. // Skip reverts
  259. if ( /^Revert "/.test( commit.subject ) ) return null;
  260. // Check if this commit was reverted
  261. const subjectWithoutPR = commit.subject.replace( /\s*\(#\d+\)\s*$/, '' );
  262. if ( revertedTitles.has( subjectWithoutPR ) ) return null;
  263. // Skip certain commits
  264. if ( shouldSkipCommit( commit.subject ) ) return null;
  265. const prNumber = extractPRNumber( commit.subject );
  266. // Try to get PR info for better title and author
  267. let author = null;
  268. let subject = commit.subject;
  269. let files = null;
  270. if ( prNumber ) {
  271. const prInfo = getPRInfo( prNumber );
  272. if ( prInfo ) {
  273. // Skip commits from bots
  274. if ( skipAuthors.has( prInfo.author ) ) return null;
  275. author = prInfo.author;
  276. if ( prInfo.title ) subject = prInfo.title;
  277. if ( prInfo.files && prInfo.files.length > 0 ) files = prInfo.files;
  278. }
  279. }
  280. // Fall back to git data
  281. if ( ! files ) files = getChangedFiles( commit.hash );
  282. if ( ! author ) author = commit.author;
  283. // Skip commits from bots (check normalized name for git author fallback)
  284. if ( skipAuthors.has( normalizeAuthor( author ) ) ) return null;
  285. const result = categorizeCommit( files );
  286. let { category, section } = result;
  287. const { isAddon } = result;
  288. // Use title prefix as category only if file-based didn't assign a section
  289. if ( ! section ) {
  290. const titleCategory = extractCategoryFromTitle( subject );
  291. if ( titleCategory ) {
  292. category = titleCategory;
  293. if ( category === 'Scripts' ) category = 'Utils';
  294. if ( category === 'Puppeteer' || category === 'E2E' ) category = 'Tests';
  295. section = sectionCategories.includes( category ) ? category : null;
  296. }
  297. }
  298. // Route jsdoc/typo/docs-related commits to Docs section
  299. if ( /\b(jsdoc|typo|spelling|documentation)\b/i.test( subject ) ) {
  300. section = 'Docs';
  301. }
  302. const coAuthors = ( prNumber ? getCoAuthorsFromPR( prNumber ) : getCoAuthorsFromCommit( commit.hash ) ).filter( login => login !== author );
  303. return {
  304. entry: {
  305. subject,
  306. prNumber,
  307. author,
  308. category,
  309. formatted: formatEntry( subject, prNumber, commit.hash, author, coAuthors, category )
  310. },
  311. category,
  312. section,
  313. isAddon
  314. };
  315. }
  316. function formatOutput( version, coreChanges, addonChanges, sections ) {
  317. let output = '';
  318. const previousVersion = version - 1;
  319. output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${previousVersion}--${version}\n`;
  320. output += `https://github.com/mrdoob/three.js/milestone/${version - 87}?closed=1\n\n`;
  321. // Core changes (Global first, then alphabetically)
  322. const sortedCore = Object.keys( coreChanges ).sort( ( a, b ) => {
  323. if ( a === 'Global' ) return - 1;
  324. if ( b === 'Global' ) return 1;
  325. return a.localeCompare( b );
  326. } );
  327. for ( const category of sortedCore ) {
  328. output += `- ${category}\n`;
  329. for ( const entry of coreChanges[ category ] ) {
  330. output += ` - ${entry.formatted}\n`;
  331. }
  332. }
  333. // Output sections in order
  334. const sectionOrder = [ 'Docs', 'Manual', 'Examples', 'Addons', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ];
  335. for ( const sectionName of sectionOrder ) {
  336. // Addons section has nested categories
  337. if ( sectionName === 'Addons' ) {
  338. const sortedAddons = Object.keys( addonChanges ).sort();
  339. if ( sortedAddons.length > 0 ) {
  340. output += '\n**Addons**\n\n';
  341. for ( const category of sortedAddons ) {
  342. output += `- ${category}\n`;
  343. for ( const entry of addonChanges[ category ] ) {
  344. output += ` - ${entry.formatted}\n`;
  345. }
  346. output += '\n';
  347. }
  348. }
  349. continue;
  350. }
  351. if ( sections[ sectionName ].length > 0 ) {
  352. output += `\n**${sectionName}**\n\n`;
  353. for ( const entry of sections[ sectionName ] ) {
  354. output += `- ${entry.formatted}\n`;
  355. }
  356. }
  357. }
  358. return output;
  359. }
  360. function generateChangelog() {
  361. const { tag, previousTag, version } = validateEnvironment( process.argv[ 2 ] );
  362. console.error( `Generating changelog ${previousTag}..${tag}\n` );
  363. const commits = getCommitsBetweenTags( previousTag, tag );
  364. if ( commits.length === 0 ) {
  365. console.error( `No commits found between ${previousTag} and ${tag}` );
  366. process.exit( 1 );
  367. }
  368. console.error( `Found ${commits.length} commits\n` );
  369. const revertedTitles = collectRevertedTitles( commits );
  370. // Group commits by category
  371. const coreChanges = {};
  372. const addonChanges = {};
  373. const sections = {
  374. Docs: [],
  375. Manual: [],
  376. Examples: [],
  377. Devtools: [],
  378. Editor: [],
  379. Tests: [],
  380. Utils: [],
  381. Build: []
  382. };
  383. let skipped = 0;
  384. const total = commits.length;
  385. const barWidth = 40;
  386. for ( let i = 0; i < total; i ++ ) {
  387. const commit = commits[ i ];
  388. const done = i + 1;
  389. const filled = Math.round( barWidth * done / total );
  390. const bar = '█'.repeat( filled ) + '░'.repeat( barWidth - filled );
  391. const pct = Math.round( 100 * done / total );
  392. process.stderr.write( `\r ${bar} ${pct}% (${done}/${total})` );
  393. const result = processCommit( commit, revertedTitles );
  394. if ( ! result ) {
  395. skipped ++;
  396. continue;
  397. }
  398. const { entry, category, section, isAddon } = result;
  399. if ( section && sections[ section ] ) {
  400. sections[ section ].push( entry );
  401. } else if ( isAddon ) {
  402. addToGroup( addonChanges, category, entry );
  403. } else {
  404. addToGroup( coreChanges, category, entry );
  405. }
  406. }
  407. process.stderr.write( '\n\n' );
  408. if ( skipped > 0 ) {
  409. console.error( `Skipped ${skipped} commits (builds, dependency updates, etc.)\n` );
  410. }
  411. console.log( formatOutput( version, coreChanges, addonChanges, sections ) );
  412. }
  413. generateChangelog();
粤ICP备19079148号