changelog.js 12 KB

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