changelog.js 13 KB

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