From 173c9eb8fbbb8aa5027a07d1def4bd8223503d8c Mon Sep 17 00:00:00 2001 From: sunag Date: Sat, 14 Mar 2026 20:57:19 -0300 Subject: [PATCH 01/21] rename `changelog` -> `changelog.legacy` --- utils/{changelog.js => changelog.legacy.js} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename utils/{changelog.js => changelog.legacy.js} (99%) diff --git a/utils/changelog.js b/utils/changelog.legacy.js similarity index 99% rename from utils/changelog.js rename to utils/changelog.legacy.js index e34d237adc7529..003e57c70cf1a8 100644 --- a/utils/changelog.js +++ b/utils/changelog.legacy.js @@ -44,7 +44,7 @@ function exec( command ) { try { - return execSync( command, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } ).trim(); + return execSync( command, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, stdio: [ 'pipe', 'pipe', 'ignore' ] } ).trim(); } catch ( error ) { @@ -101,7 +101,7 @@ function extractPRNumber( subject ) { function getPRInfo( prNumber ) { - const result = exec( `gh pr view ${prNumber} --json author,title,files --jq '{author: .author.login, title: .title, files: [.files[].path]}' 2>/dev/null` ); + const result = exec( `gh pr view ${prNumber} --json author,title,files --jq '{author: .author.login, title: .title, files: [.files[].path]}'` ); try { @@ -332,7 +332,7 @@ function addToGroup( groups, key, value ) { function validateEnvironment() { - if ( ! exec( 'gh --version 2>/dev/null' ) ) { + if ( ! exec( 'gh --version' ) ) { console.error( 'GitHub CLI (gh) is required but not installed.' ); console.error( 'Install from: https://cli.github.com/' ); From 4b4c0e1d3dc22fd10bb9f6233a16282c1b1e6501 Mon Sep 17 00:00:00 2001 From: sunag Date: Sat, 14 Mar 2026 21:13:09 -0300 Subject: [PATCH 02/21] AI-assisted changelog --- .gitignore | 1 + package.json | 3 +- utils/changelog.js | 484 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 utils/changelog.js diff --git a/.gitignore b/.gitignore index 74a8799be70c97..5c3f3c84583c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ test/treeshake/index.webgpu.nodes.bundle.min.js test/unit/build **/node_modules +**/changelog diff --git a/package.json b/package.json index 401cdb7c359edc..28b295e50b361b 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "test-e2e-cov": "node test/e2e/check-coverage.js", "test-e2e-webgpu": "node test/e2e/puppeteer.js --webgpu", "test-treeshake": "rollup -c test/rollup.treeshake.config.js", - "make-screenshot": "node test/e2e/puppeteer.js --make" + "make-screenshot": "node test/e2e/puppeteer.js --make", + "changelog": "node utils/changelog.js" }, "keywords": [ "three", diff --git a/utils/changelog.js b/utils/changelog.js new file mode 100644 index 00000000000000..1fc639bfdaba8a --- /dev/null +++ b/utils/changelog.js @@ -0,0 +1,484 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import readline from 'readline'; + +const __filename = fileURLToPath( import.meta.url ); +const __dirname = path.dirname( __filename ); + +async function askQuestion( query ) { + + const rl = readline.createInterface( { + input: process.stdin, + output: process.stdout + } ); + + return new Promise( resolve => rl.question( query, ans => { + + rl.close(); + resolve( ans ); + + } ) ); + +} + +async function generateReport() { + + let arg = process.argv[ 2 ]; + + if ( ! arg || arg === '-cache' ) { + + // Fallback to reading REVISION from src/constants.js + try { + + const constantsPath = path.join( __dirname, '../src/constants.js' ); + const constantsContent = fs.readFileSync( constantsPath, 'utf8' ); + const revisionMatch = constantsContent.match( /export\s+const\s+REVISION\s*=\s*['"](.*?)['"]/ ); + + if ( revisionMatch && revisionMatch[ 1 ] ) { + + arg = revisionMatch[ 1 ].replace( /dev$/, '' ); + console.log( `No release specified. Using current REVISION: ${arg}` ); + + // Adjust argv indexing if the first argument was actually the -cache flag + if ( process.argv[ 2 ] === '-cache' ) { + + process.argv.splice( 2, 0, arg ); // Shift everything over by acting as if arg was passed + + } + + } else { + + console.error( 'Usage: npm run changelog [milestone-url-or-number] [-cache ]' ); + console.error( 'Example: npm run changelog https://github.com/mrdoob/three.js/milestone/97 -cache 50' ); + console.error( 'Could not find REVISION in src/constants.js.' ); + process.exit( 1 ); + + } + + } catch ( e ) { + + console.error( 'Usage: npm run changelog [milestone-url-or-number] [-cache ]' ); + console.error( 'Example: npm run changelog https://github.com/mrdoob/three.js/milestone/97 -cache 50' ); + console.error( 'Error reading src/constants.js:', e.message ); + process.exit( 1 ); + + } + + } + + let perPage = 100; + + for ( let i = 2; i < process.argv.length; i ++ ) { // start from 2, just in case -cache is the first arg now + + const param = process.argv[ i ]; + + if ( param === '-cache' && i + 1 < process.argv.length ) { + + perPage = parseInt( process.argv[ i + 1 ], 10 ); + // skip the value + + if ( isNaN( perPage ) || perPage <= 0 || perPage > 100 ) { + + console.error( 'Invalid -cache value. It must be a number between 1 and 100.' ); + process.exit( 1 ); + + } + + } + + } + + const match = arg.match( /(\d+)$/ ); + if ( ! match ) { + + console.error( 'Invalid format. Please provide a release number (e.g. 184 or r184).' ); + process.exit( 1 ); + + } + + const releaseNumber = parseInt( match[ 1 ], 10 ); + const milestoneNumber = releaseNumber - 87; + + const githubToken = await askQuestion( 'Enter GitHub API Token (or press Enter to skip): ' ); + const geminiToken = await askQuestion( 'Enter Gemini API Token (or press Enter to skip AI summary): ' ); + let geminiModel = ''; + + if ( geminiToken ) { + + console.error( '\nFetching available Gemini models...' ); + + try { + + const modelsRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models?key=${geminiToken}` ); + if ( ! modelsRes.ok ) { + + console.error( 'Failed to fetch Gemini models. Please check your API token.' ); + process.exit( 1 ); + + } + + const modelsData = await modelsRes.json(); + const rawModels = modelsData.models + .filter( m => m.name.includes( 'gemini' ) && m.supportedGenerationMethods && m.supportedGenerationMethods.includes( 'generateContent' ) ) + .filter( m => ! m.name.includes( 'vision' ) ) + .map( m => { + + return { + id: m.name.replace( 'models/', '' ), + displayName: m.displayName || m.name.replace( 'models/', '' ) + }; + + } ); + + // Filter out unwanted and noisy models + const filteredModels = rawModels.filter( m => { + + const id = m.id.toLowerCase(); + const name = m.displayName.toLowerCase(); + if ( name.includes( 'nano' ) || name.includes( 'banana' ) || id.includes( 'nano' ) || id.includes( 'banana' ) ) return false; + if ( id.includes( '-latest' ) || id.includes( 'computer-use' ) || id.includes( 'robotics' ) || id.includes( '-tts' ) ) return false; + // Filter out very specific preview builds if they are too noisy, but we'll leave most previews as per the example. + return true; + + } ); + + // Group by version + const groups = {}; + filteredModels.forEach( m => { + + // Try to extract version like "2.5", "3.1", "2.0", "3" + const versionMatch = m.id.match( /gemini-(\d+(?:\.\d+)?)/ ); + if ( versionMatch ) { + + const version = versionMatch[ 1 ]; + if ( ! groups[ version ] ) groups[ version ] = []; + groups[ version ].push( m ); + + } + + } ); + + const sortedVersions = Object.keys( groups ).sort( ( a, b ) => parseFloat( b ) - parseFloat( a ) ); + + if ( sortedVersions.length === 0 ) { + + console.error( 'No suitable Gemini models found after filtering.' ); + process.exit( 1 ); + + } + + console.log( '\nSelect Gemini Version Family:' ); + sortedVersions.forEach( ( v, i ) => { + + console.log( `${i + 1}: Gemini ${v}` ); + + } ); + + const versionChoice = await askQuestion( `Enter choice (1-${sortedVersions.length}) [1]: ` ); + let vIndex = parseInt( versionChoice, 10 ); + if ( isNaN( vIndex ) || vIndex < 1 || vIndex > sortedVersions.length ) { + + vIndex = 1; + + } + + const selectedVersion = sortedVersions[ vIndex - 1 ]; + const familyModels = groups[ selectedVersion ].reverse(); // latest variants first + + console.log( `\nSelect Model for Gemini ${selectedVersion}:` ); + familyModels.forEach( ( m, i ) => { + + console.log( `${i + 1}: ${m.displayName} (${m.id})` ); + + } ); + + const modelChoice = await askQuestion( `Enter choice (1-${familyModels.length}) [1]: ` ); + let mIndex = parseInt( modelChoice, 10 ); + if ( isNaN( mIndex ) || mIndex < 1 || mIndex > familyModels.length ) { + + mIndex = 1; + + } + + geminiModel = familyModels[ mIndex - 1 ].id; + + } catch ( e ) { + + console.error( 'Error fetching models:', e.message ); + process.exit( 1 ); + + } + + } + + const repo = 'mrdoob/three.js'; + + console.error( `Fetching milestone for release r${releaseNumber} (ID: ${milestoneNumber})...` ); + + const headers = { + 'User-Agent': 'NodeJS/ThreeJS-Report', + 'Accept': 'application/vnd.github.v3+json' + }; + + if ( githubToken ) { + + headers[ 'Authorization' ] = `token ${githubToken}`; + + } + + const milestoneRes = await fetch( `https://api.github.com/repos/${repo}/milestones/${milestoneNumber}`, { headers } ); + if ( ! milestoneRes.ok ) { + + console.error( 'Failed to fetch milestone. Check if the milestone exists or rate limit exceeded.' ); + process.exit( 1 ); + + } + + const milestoneData = await milestoneRes.json(); + const milestoneName = milestoneData.title; + + console.error( `Fetching PRs for milestone: ${milestoneName}...` ); + + let page = 1; + let totalPages = '?'; + const cacheFiles = []; + const categories = {}; + let prDescriptionsForAI = ''; + let totalPRs = 0; + + const changelogDir = path.join( __dirname, '../changelog' ); + if ( ! fs.existsSync( changelogDir ) ) { + + fs.mkdirSync( changelogDir ); + + } + + while ( true ) { + + process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); + + const cacheFilename = path.join( changelogDir, `r${releaseNumber}_page_${page}.md` ); + let pageContent = ''; + let isLastPage = false; + + if ( fs.existsSync( cacheFilename ) ) { + + pageContent = fs.readFileSync( cacheFilename, 'utf8' ); + + } else { + + const res = await fetch( `https://api.github.com/repos/${repo}/issues?milestone=${milestoneNumber}&state=closed&per_page=${perPage}&page=${page}`, { headers } ); + if ( ! res.ok ) { + + console.error( '\nFailed to fetch PRs.' ); + process.exit( 1 ); + + } + + if ( totalPages === '?' ) { + + const linkHeader = res.headers.get( 'link' ); + if ( linkHeader ) { + + const lastPageMatch = linkHeader.match( /page=(\d+)>; rel="last"/ ); + if ( lastPageMatch ) { + + totalPages = lastPageMatch[ 1 ]; + process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); + + } + + } + + } + + const data = await res.json(); + if ( data.length === 0 ) break; + + const prsInData = data.filter( issue => issue.pull_request && issue.pull_request.merged_at ); + + for ( const pr of prsInData ) { + + let title = pr.title; + let category = 'Other'; + + const catMatch = title.match( /^([^:]+):\s*(.*)$/ ); + if ( catMatch ) { + + category = catMatch[ 1 ].trim(); + title = catMatch[ 2 ].trim(); + + } + + title = title.replace( /\s*\(#\d+\)\s*$/, '' ).trim(); + title = title.replace( /\.\s*$/, '' ); + + const authors = new Set(); + if ( pr.user && pr.user.login ) authors.add( pr.user.login ); + + if ( pr.body ) { + + const coAuthRegex1 = /Co-authored-by:\s*(?:@)?([a-zA-Z0-9-]+)/gi; + let m; + while ( ( m = coAuthRegex1.exec( pr.body ) ) !== null ) { + + authors.add( m[ 1 ] ); + + } + + } + + const authorsList = Array.from( authors ).map( a => `@${a}` ).join( ', ' ); + + pageContent += `## ${category}: ${title} #${pr.number} (${authorsList})\n\n${pr.body || ''}\n\n---\n\n`; + + } + + fs.writeFileSync( cacheFilename, pageContent ); + + if ( data.length < perPage ) isLastPage = true; + + } + + if ( pageContent.length > 0 ) { + + cacheFiles.push( cacheFilename ); + + if ( geminiToken ) { + + prDescriptionsForAI += pageContent; + + } + + const prRegex = /^## (.*?):\s*(.*?)\s+#(\d+)\s+\((.*?)\)(?:\r?\n)([\s\S]*?)(?=\n---|$)/gm; + let m; + while ( ( m = prRegex.exec( pageContent ) ) !== null ) { + + const category = m[ 1 ].trim(); + const title = m[ 2 ].trim(); + const number = parseInt( m[ 3 ], 10 ); + const authorsList = m[ 4 ].trim(); + + if ( ! categories[ category ] ) categories[ category ] = []; + categories[ category ].push( { title: title + '.', number, authors: authorsList } ); + + totalPRs ++; + + } + + } else if ( ! fs.existsSync( cacheFilename ) ) { + + break; + + } + + if ( isLastPage ) break; + + page ++; + + } + + console.error( `\nFound ${totalPRs} PRs.` ); + + const sortedCategories = Object.keys( categories ).sort(); + + let output = `## ${milestoneName}\n\n`; + + for ( let i = 0; i < sortedCategories.length; i ++ ) { + + const cat = sortedCategories[ i ]; + + if ( i > 0 ) output += '\n'; // Add an empty line between categories + + output += `- ${cat}\n`; + + const sortedPRs = categories[ cat ].sort( ( a, b ) => a.number - b.number ); + + for ( const pr of sortedPRs ) { + + output += ` - ${pr.title} #${pr.number} (${pr.authors})\n`; + + } + + } + + if ( geminiToken && prDescriptionsForAI ) { + + console.error( `Generating AI Summary with Gemini (${geminiModel})...` ); + + const aiPrompt = `You are an assistant analyzing the changes of a new release of the Three.js library (release r${releaseNumber}). +Here are the descriptions of the Pull Requests merged in this release: + +${prDescriptionsForAI} + +Please provide the following information formatted in Markdown (ALL EXPLANATIONS MUST BE WRITTEN IN ENGLISH): +- A detailed and comprehensive summary focusing on the most important changes, including new features, major refactorings, API changes, and breaking changes. IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. +- Migration tips for users upgrading to this new version. Use the exact heading "### Migration Tips" (without any numbering). IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip (since it was never released to the public in the first place). + +Output only the markdown content, without extra code block delimiters. Do not include a code examples section.`; + + try { + + const geminiRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiToken}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { + contents: [ { parts: [ { text: aiPrompt } ] } ] + } ) + } ); + + if ( geminiRes.ok ) { + + const geminiData = await geminiRes.json(); + if ( geminiData.candidates && geminiData.candidates[ 0 ] && geminiData.candidates[ 0 ].content ) { + + const aiText = geminiData.candidates[ 0 ].content.parts[ 0 ].text; + const aiFooter = `\n\n*Generated by ${geminiModel}*`; + output = `## AI Summary (r${releaseNumber})\n\n${aiText}${aiFooter}\n\n---\n\n` + output; + + } + + } else { + + console.error( 'Failed to generate AI summary:', await geminiRes.text() ); + + } + + } catch ( e ) { + + console.error( 'Error generating AI summary:', e.message ); + + } + + } + + const descriptionsFilePath = path.join( changelogDir, `r${releaseNumber}_descriptions.md` ); + const reportFilePath = path.join( changelogDir, `r${releaseNumber}.md` ); + + fs.writeFileSync( descriptionsFilePath, prDescriptionsForAI ); + fs.writeFileSync( reportFilePath, output ); + + for ( const cacheFile of cacheFiles ) { + + if ( fs.existsSync( cacheFile ) ) { + + fs.unlinkSync( cacheFile ); + + } + + } + + console.log( `\nāœ… Report for r${releaseNumber} generated successfully:` ); + console.log( ` šŸ“„ \x1b[36m${reportFilePath}\x1b[0m` ); + + if ( geminiToken && prDescriptionsForAI ) { + + console.log( '\nāœ… Descriptions for AI saved in:' ); + console.log( ` šŸ“„ \x1b[36m${descriptionsFilePath}\x1b[0m\n` ); + + } + +} + +generateReport().catch( console.error ); From ee828ba840e490e16aee5550ff5e6a944e1545cc Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 17 Mar 2026 12:45:18 -0300 Subject: [PATCH 03/21] Update changelog.js --- utils/changelog.js | 288 +++++++++++++++++++++++++++------------------ 1 file changed, 172 insertions(+), 116 deletions(-) diff --git a/utils/changelog.js b/utils/changelog.js index 1fc639bfdaba8a..b5823f62aeade1 100644 --- a/utils/changelog.js +++ b/utils/changelog.js @@ -22,7 +22,7 @@ async function askQuestion( query ) { } -async function generateReport() { +function getReleaseAndCacheArgs() { let arg = process.argv[ 2 ]; @@ -100,132 +100,119 @@ async function generateReport() { const releaseNumber = parseInt( match[ 1 ], 10 ); const milestoneNumber = releaseNumber - 87; - const githubToken = await askQuestion( 'Enter GitHub API Token (or press Enter to skip): ' ); - const geminiToken = await askQuestion( 'Enter Gemini API Token (or press Enter to skip AI summary): ' ); - let geminiModel = ''; + return { releaseNumber, milestoneNumber, perPage }; - if ( geminiToken ) { - - console.error( '\nFetching available Gemini models...' ); - - try { +} - const modelsRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models?key=${geminiToken}` ); - if ( ! modelsRes.ok ) { +async function selectGeminiModel( geminiToken ) { - console.error( 'Failed to fetch Gemini models. Please check your API token.' ); - process.exit( 1 ); + console.error( '\nFetching available Gemini models...' ); - } + try { - const modelsData = await modelsRes.json(); - const rawModels = modelsData.models - .filter( m => m.name.includes( 'gemini' ) && m.supportedGenerationMethods && m.supportedGenerationMethods.includes( 'generateContent' ) ) - .filter( m => ! m.name.includes( 'vision' ) ) - .map( m => { + const modelsRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models?key=${geminiToken}` ); + if ( ! modelsRes.ok ) { - return { - id: m.name.replace( 'models/', '' ), - displayName: m.displayName || m.name.replace( 'models/', '' ) - }; + console.error( 'Failed to fetch Gemini models. Please check your API token.' ); + process.exit( 1 ); - } ); + } - // Filter out unwanted and noisy models - const filteredModels = rawModels.filter( m => { + const modelsData = await modelsRes.json(); + const rawModels = modelsData.models + .filter( m => m.name.includes( 'gemini' ) && m.supportedGenerationMethods && m.supportedGenerationMethods.includes( 'generateContent' ) ) + .filter( m => ! m.name.includes( 'vision' ) ) + .map( m => { - const id = m.id.toLowerCase(); - const name = m.displayName.toLowerCase(); - if ( name.includes( 'nano' ) || name.includes( 'banana' ) || id.includes( 'nano' ) || id.includes( 'banana' ) ) return false; - if ( id.includes( '-latest' ) || id.includes( 'computer-use' ) || id.includes( 'robotics' ) || id.includes( '-tts' ) ) return false; - // Filter out very specific preview builds if they are too noisy, but we'll leave most previews as per the example. - return true; + return { + id: m.name.replace( 'models/', '' ), + displayName: m.displayName || m.name.replace( 'models/', '' ) + }; } ); - // Group by version - const groups = {}; - filteredModels.forEach( m => { + // Filter out unwanted and noisy models + const filteredModels = rawModels.filter( m => { - // Try to extract version like "2.5", "3.1", "2.0", "3" - const versionMatch = m.id.match( /gemini-(\d+(?:\.\d+)?)/ ); - if ( versionMatch ) { + const id = m.id.toLowerCase(); + const name = m.displayName.toLowerCase(); + if ( name.includes( 'nano' ) || name.includes( 'banana' ) || id.includes( 'nano' ) || id.includes( 'banana' ) ) return false; + if ( id.includes( '-latest' ) || id.includes( 'computer-use' ) || id.includes( 'robotics' ) || id.includes( '-tts' ) ) return false; + // Filter out very specific preview builds if they are too noisy, but we'll leave most previews as per the example. + return true; - const version = versionMatch[ 1 ]; - if ( ! groups[ version ] ) groups[ version ] = []; - groups[ version ].push( m ); + } ); - } + // Group by version + const groups = {}; + filteredModels.forEach( m => { - } ); + // Try to extract version like "2.5", "3.1", "2.0", "3" + const versionMatch = m.id.match( /gemini-(\d+(?:\.\d+)?)/ ); + if ( versionMatch ) { - const sortedVersions = Object.keys( groups ).sort( ( a, b ) => parseFloat( b ) - parseFloat( a ) ); - - if ( sortedVersions.length === 0 ) { - - console.error( 'No suitable Gemini models found after filtering.' ); - process.exit( 1 ); + const version = versionMatch[ 1 ]; + if ( ! groups[ version ] ) groups[ version ] = []; + groups[ version ].push( m ); } - console.log( '\nSelect Gemini Version Family:' ); - sortedVersions.forEach( ( v, i ) => { + } ); - console.log( `${i + 1}: Gemini ${v}` ); + const sortedVersions = Object.keys( groups ).sort( ( a, b ) => parseFloat( b ) - parseFloat( a ) ); - } ); + if ( sortedVersions.length === 0 ) { - const versionChoice = await askQuestion( `Enter choice (1-${sortedVersions.length}) [1]: ` ); - let vIndex = parseInt( versionChoice, 10 ); - if ( isNaN( vIndex ) || vIndex < 1 || vIndex > sortedVersions.length ) { + console.error( 'No suitable Gemini models found after filtering.' ); + process.exit( 1 ); - vIndex = 1; + } - } + console.log( '\nSelect Gemini Version Family:' ); + sortedVersions.forEach( ( v, i ) => { - const selectedVersion = sortedVersions[ vIndex - 1 ]; - const familyModels = groups[ selectedVersion ].reverse(); // latest variants first + console.log( `${i + 1}: Gemini ${v}` ); - console.log( `\nSelect Model for Gemini ${selectedVersion}:` ); - familyModels.forEach( ( m, i ) => { + } ); - console.log( `${i + 1}: ${m.displayName} (${m.id})` ); + const versionChoice = await askQuestion( `Enter choice (1-${sortedVersions.length}) [1]: ` ); + let vIndex = parseInt( versionChoice, 10 ); + if ( isNaN( vIndex ) || vIndex < 1 || vIndex > sortedVersions.length ) { - } ); + vIndex = 1; - const modelChoice = await askQuestion( `Enter choice (1-${familyModels.length}) [1]: ` ); - let mIndex = parseInt( modelChoice, 10 ); - if ( isNaN( mIndex ) || mIndex < 1 || mIndex > familyModels.length ) { + } - mIndex = 1; + const selectedVersion = sortedVersions[ vIndex - 1 ]; + const familyModels = groups[ selectedVersion ].reverse(); // latest variants first - } + console.log( `\nSelect Model for Gemini ${selectedVersion}:` ); + familyModels.forEach( ( m, i ) => { - geminiModel = familyModels[ mIndex - 1 ].id; + console.log( `${i + 1}: ${m.displayName} (${m.id})` ); - } catch ( e ) { + } ); - console.error( 'Error fetching models:', e.message ); - process.exit( 1 ); + const modelChoice = await askQuestion( `Enter choice (1-${familyModels.length}) [1]: ` ); + let mIndex = parseInt( modelChoice, 10 ); + if ( isNaN( mIndex ) || mIndex < 1 || mIndex > familyModels.length ) { - } + mIndex = 1; - } + } - const repo = 'mrdoob/three.js'; + return familyModels[ mIndex - 1 ].id; - console.error( `Fetching milestone for release r${releaseNumber} (ID: ${milestoneNumber})...` ); + } catch ( e ) { - const headers = { - 'User-Agent': 'NodeJS/ThreeJS-Report', - 'Accept': 'application/vnd.github.v3+json' - }; + console.error( 'Error fetching models:', e.message ); + process.exit( 1 ); - if ( githubToken ) { + } - headers[ 'Authorization' ] = `token ${githubToken}`; +} - } +async function fetchMilestoneName( repo, milestoneNumber, headers ) { const milestoneRes = await fetch( `https://api.github.com/repos/${repo}/milestones/${milestoneNumber}`, { headers } ); if ( ! milestoneRes.ok ) { @@ -236,9 +223,11 @@ async function generateReport() { } const milestoneData = await milestoneRes.json(); - const milestoneName = milestoneData.title; + return milestoneData.title; - console.error( `Fetching PRs for milestone: ${milestoneName}...` ); +} + +async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken ) { let page = 1; let totalPages = '?'; @@ -381,6 +370,12 @@ async function generateReport() { console.error( `\nFound ${totalPRs} PRs.` ); + return { categories, prDescriptionsForAI, cacheFiles }; + +} + +function formatChangelogData( milestoneName, categories ) { + const sortedCategories = Object.keys( categories ).sort(); let output = `## ${milestoneName}\n\n`; @@ -403,11 +398,15 @@ async function generateReport() { } - if ( geminiToken && prDescriptionsForAI ) { + return output; - console.error( `Generating AI Summary with Gemini (${geminiModel})...` ); +} + +async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ) { + + console.error( `Generating AI Summary with Gemini (${geminiModel})...` ); - const aiPrompt = `You are an assistant analyzing the changes of a new release of the Three.js library (release r${releaseNumber}). + const aiPrompt = `You are an assistant analyzing the changes of a new release of the Three.js library (release r${releaseNumber}). Here are the descriptions of the Pull Requests merged in this release: ${prDescriptionsForAI} @@ -418,46 +417,51 @@ Please provide the following information formatted in Markdown (ALL EXPLANATIONS Output only the markdown content, without extra code block delimiters. Do not include a code examples section.`; - try { - - const geminiRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiToken}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( { - contents: [ { parts: [ { text: aiPrompt } ] } ] - } ) - } ); - - if ( geminiRes.ok ) { - - const geminiData = await geminiRes.json(); - if ( geminiData.candidates && geminiData.candidates[ 0 ] && geminiData.candidates[ 0 ].content ) { + try { - const aiText = geminiData.candidates[ 0 ].content.parts[ 0 ].text; - const aiFooter = `\n\n*Generated by ${geminiModel}*`; - output = `## AI Summary (r${releaseNumber})\n\n${aiText}${aiFooter}\n\n---\n\n` + output; + const geminiRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiToken}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { + contents: [ { parts: [ { text: aiPrompt } ] } ] + } ) + } ); - } + if ( geminiRes.ok ) { - } else { + const geminiData = await geminiRes.json(); + if ( geminiData.candidates && geminiData.candidates[ 0 ] && geminiData.candidates[ 0 ].content ) { - console.error( 'Failed to generate AI summary:', await geminiRes.text() ); + const aiText = geminiData.candidates[ 0 ].content.parts[ 0 ].text; + const aiFooter = `\n\n*Generated by ${geminiModel}*`; + return `## AI Summary (r${releaseNumber})\n\n${aiText}${aiFooter}\n\n---\n\n`; } - } catch ( e ) { + } else { - console.error( 'Error generating AI summary:', e.message ); + console.error( 'Failed to generate AI summary:', await geminiRes.text() ); } + } catch ( e ) { + + console.error( 'Error generating AI summary:', e.message ); + } + return ''; + +} + +function cleanupAndSave( releaseNumber, output, prDescriptionsForAI, cacheFiles, geminiToken ) { + + const changelogDir = path.join( __dirname, '../changelog' ); const descriptionsFilePath = path.join( changelogDir, `r${releaseNumber}_descriptions.md` ); - const reportFilePath = path.join( changelogDir, `r${releaseNumber}.md` ); + const changelogFilePath = path.join( changelogDir, `r${releaseNumber}.md` ); fs.writeFileSync( descriptionsFilePath, prDescriptionsForAI ); - fs.writeFileSync( reportFilePath, output ); + fs.writeFileSync( changelogFilePath, output ); for ( const cacheFile of cacheFiles ) { @@ -469,8 +473,8 @@ Output only the markdown content, without extra code block delimiters. Do not in } - console.log( `\nāœ… Report for r${releaseNumber} generated successfully:` ); - console.log( ` šŸ“„ \x1b[36m${reportFilePath}\x1b[0m` ); + console.log( `\nāœ… Changelog for r${releaseNumber} generated successfully:` ); + console.log( ` šŸ“„ \x1b[36m${changelogFilePath}\x1b[0m` ); if ( geminiToken && prDescriptionsForAI ) { @@ -481,4 +485,56 @@ Output only the markdown content, without extra code block delimiters. Do not in } -generateReport().catch( console.error ); +async function generateChangelog() { + + const { releaseNumber, milestoneNumber, perPage } = getReleaseAndCacheArgs(); + + const githubToken = await askQuestion( 'Enter GitHub API Token (or press Enter to skip): ' ); + const geminiToken = await askQuestion( 'Enter Gemini API Token (or press Enter to skip AI summary): ' ); + + let geminiModel = ''; + if ( geminiToken ) { + + geminiModel = await selectGeminiModel( geminiToken ); + + } + + const repo = 'mrdoob/three.js'; + + console.error( `Fetching milestone for release r${releaseNumber} (ID: ${milestoneNumber})...` ); + + const headers = { + 'User-Agent': 'NodeJS/ThreeJS-Changelog', + 'Accept': 'application/vnd.github.v3+json' + }; + + if ( githubToken ) { + + headers[ 'Authorization' ] = `token ${githubToken}`; + + } + + const milestoneName = await fetchMilestoneName( repo, milestoneNumber, headers ); + + console.error( `Fetching PRs for milestone: ${milestoneName}...` ); + + const { categories, prDescriptionsForAI, cacheFiles } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken ); + + let output = formatChangelogData( milestoneName, categories ); + + if ( geminiToken && prDescriptionsForAI ) { + + const aiSummary = await fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ); + if ( aiSummary ) { + + output = aiSummary + output; + + } + + } + + cleanupAndSave( releaseNumber, output, prDescriptionsForAI, cacheFiles, geminiToken ); + +} + +generateChangelog().catch( console.error ); From 4362712c949c29f8ec4617814f86e7fec3b19da8 Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 17 Mar 2026 13:10:09 -0300 Subject: [PATCH 04/21] Migrate progress bar UI from changelog.legacy.js --- utils/changelog.js | 78 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/utils/changelog.js b/utils/changelog.js index b5823f62aeade1..1cb77e263e2452 100644 --- a/utils/changelog.js +++ b/utils/changelog.js @@ -212,7 +212,7 @@ async function selectGeminiModel( geminiToken ) { } -async function fetchMilestoneName( repo, milestoneNumber, headers ) { +async function fetchMilestoneData( repo, milestoneNumber, headers ) { const milestoneRes = await fetch( `https://api.github.com/repos/${repo}/milestones/${milestoneNumber}`, { headers } ); if ( ! milestoneRes.ok ) { @@ -223,18 +223,20 @@ async function fetchMilestoneName( repo, milestoneNumber, headers ) { } const milestoneData = await milestoneRes.json(); - return milestoneData.title; + return { title: milestoneData.title, closed_issues: milestoneData.closed_issues }; } -async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken ) { +async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, totalExpectedPRs ) { let page = 1; let totalPages = '?'; + let totalPRs = 0; + let processedPRs = 0; + const cacheFiles = []; const categories = {}; let prDescriptionsForAI = ''; - let totalPRs = 0; const changelogDir = path.join( __dirname, '../changelog' ); if ( ! fs.existsSync( changelogDir ) ) { @@ -243,9 +245,36 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, } + const barWidth = 40; + + function updateProgress() { + + if ( totalExpectedPRs > 0 ) { + + const done = processedPRs; + const total = totalExpectedPRs; + const filled = Math.min( barWidth, Math.round( barWidth * done / total ) ); + const bar = 'ā–ˆ'.repeat( filled ) + 'ā–‘'.repeat( Math.max( 0, barWidth - filled ) ); + const pct = Math.min( 100, Math.round( 100 * done / total ) ); + process.stderr.write( `\r ${bar} ${pct}% (${done}/${total})` ); + + } else { + + process.stderr.write( `\rFetching PR page ${page}/${totalPages}...` ); + + } + + } + + // We no longer need the initial 1-page fetch because we have totalExpectedPRs + while ( true ) { - process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); + if ( totalExpectedPRs === 0 ) { + + process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); + + } const cacheFilename = path.join( changelogDir, `r${releaseNumber}_page_${page}.md` ); let pageContent = ''; @@ -255,6 +284,22 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, pageContent = fs.readFileSync( cacheFilename, 'utf8' ); + if ( totalExpectedPRs > 0 ) { + + // we need to guess how many PRs were in this cached file to update the progress bar + const prRegex = /^## (.*?):\s*(.*?)\s+#(\d+)\s+\((.*?)\)(?:\r?\n)([\s\S]*?)(?=\n---|$)/gm; + let cachedCount = 0; + while ( prRegex.exec( pageContent ) !== null ) { + + cachedCount ++; + + } + + processedPRs += cachedCount; + updateProgress(); + + } + } else { const res = await fetch( `https://api.github.com/repos/${repo}/issues?milestone=${milestoneNumber}&state=closed&per_page=${perPage}&page=${page}`, { headers } ); @@ -265,7 +310,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, } - if ( totalPages === '?' ) { + if ( totalPages === '?' || totalExpectedPRs === 0 ) { const linkHeader = res.headers.get( 'link' ); if ( linkHeader ) { @@ -274,7 +319,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, if ( lastPageMatch ) { totalPages = lastPageMatch[ 1 ]; - process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); + if ( totalExpectedPRs === 0 ) process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); } @@ -290,7 +335,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, for ( const pr of prsInData ) { let title = pr.title; - let category = 'Other'; + let category = 'Others'; const catMatch = title.match( /^([^:]+):\s*(.*)$/ ); if ( catMatch ) { @@ -322,6 +367,10 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, pageContent += `## ${category}: ${title} #${pr.number} (${authorsList})\n\n${pr.body || ''}\n\n---\n\n`; + processedPRs ++; + + if ( totalExpectedPRs > 0 ) updateProgress(); + } fs.writeFileSync( cacheFilename, pageContent ); @@ -368,7 +417,13 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, } - console.error( `\nFound ${totalPRs} PRs.` ); + if ( totalExpectedPRs > 0 ) { + + process.stderr.write( '\n\n' ); + + } + + console.error( `Found ${totalPRs} PRs.` ); return { categories, prDescriptionsForAI, cacheFiles }; @@ -514,11 +569,12 @@ async function generateChangelog() { } - const milestoneName = await fetchMilestoneName( repo, milestoneNumber, headers ); + const milestoneData = await fetchMilestoneData( repo, milestoneNumber, headers ); + const milestoneName = milestoneData.title; console.error( `Fetching PRs for milestone: ${milestoneName}...` ); - const { categories, prDescriptionsForAI, cacheFiles } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken ); + const { categories, prDescriptionsForAI, cacheFiles } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, milestoneData.closed_issues ); let output = formatChangelogData( milestoneName, categories ); From 37366a8ff2395289279f7ba06e5ca04160542ddb Mon Sep 17 00:00:00 2001 From: sunag Date: Tue, 17 Mar 2026 13:45:32 -0300 Subject: [PATCH 05/21] add `changelog.ai.md` --- utils/changelog.ai.md | 10 ++++++++++ utils/changelog.js | 14 +++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 utils/changelog.ai.md diff --git a/utils/changelog.ai.md b/utils/changelog.ai.md new file mode 100644 index 00000000000000..31e44fa1385a58 --- /dev/null +++ b/utils/changelog.ai.md @@ -0,0 +1,10 @@ +You are an assistant analyzing the changes of a new release of the Three.js library (release r{RELEASE}). +Here are the descriptions of the Pull Requests merged in this release: + +{DESCRIPTION} + +Please provide the following information formatted in Markdown (ALL EXPLANATIONS MUST BE WRITTEN IN ENGLISH): +- A detailed and comprehensive summary focusing on the most important changes, including new features, major refactorings, API changes, and breaking changes. IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. +- Migration tips for users upgrading to this new version. Use the exact heading "### Migration Tips" (without any numbering). IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip (since it was never released to the public in the first place). + +Output only the markdown content, without extra code block delimiters. Do not include a code examples section. diff --git a/utils/changelog.js b/utils/changelog.js index 1cb77e263e2452..cdf28c123658d5 100644 --- a/utils/changelog.js +++ b/utils/changelog.js @@ -461,16 +461,12 @@ async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, console.error( `Generating AI Summary with Gemini (${geminiModel})...` ); - const aiPrompt = `You are an assistant analyzing the changes of a new release of the Three.js library (release r${releaseNumber}). -Here are the descriptions of the Pull Requests merged in this release: + const promptTemplatePath = path.join( __dirname, 'changelog.ai.md' ); + const promptTemplate = fs.readFileSync( promptTemplatePath, 'utf8' ); -${prDescriptionsForAI} - -Please provide the following information formatted in Markdown (ALL EXPLANATIONS MUST BE WRITTEN IN ENGLISH): -- A detailed and comprehensive summary focusing on the most important changes, including new features, major refactorings, API changes, and breaking changes. IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. -- Migration tips for users upgrading to this new version. Use the exact heading "### Migration Tips" (without any numbering). IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip (since it was never released to the public in the first place). - -Output only the markdown content, without extra code block delimiters. Do not include a code examples section.`; + const aiPrompt = promptTemplate + .replace( '{RELEASE}', releaseNumber ) + .replace( '{DESCRIPTION}', prDescriptionsForAI ); try { From 15199354d90edddc28f0702d1040dc91d6bb1ee2 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 01:39:17 -0300 Subject: [PATCH 06/21] rename `changelog.js` -> `changelog.ai.js` --- utils/{changelog.js => changelog.ai.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename utils/{changelog.js => changelog.ai.js} (100%) diff --git a/utils/changelog.js b/utils/changelog.ai.js similarity index 100% rename from utils/changelog.js rename to utils/changelog.ai.js From f877b2ccf5cb2da51619d5195c714397608609d5 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 01:40:04 -0300 Subject: [PATCH 07/21] rename `changelog.legacy.js` -> `changelog.js` --- utils/{changelog.legacy.js => changelog.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename utils/{changelog.legacy.js => changelog.js} (100%) diff --git a/utils/changelog.legacy.js b/utils/changelog.js similarity index 100% rename from utils/changelog.legacy.js rename to utils/changelog.js From f1815213e7fabc843e0c1170195caf35a4f83993 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:05:13 -0300 Subject: [PATCH 08/21] just AI summary --- utils/changelog.ai.js | 52 ++++++------------------------------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index cdf28c123658d5..7d1cf6c9676c23 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -235,7 +235,6 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, let processedPRs = 0; const cacheFiles = []; - const categories = {}; let prDescriptionsForAI = ''; const changelogDir = path.join( __dirname, '../changelog' ); @@ -365,7 +364,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, const authorsList = Array.from( authors ).map( a => `@${a}` ).join( ', ' ); - pageContent += `## ${category}: ${title} #${pr.number} (${authorsList})\n\n${pr.body || ''}\n\n---\n\n`; + pageContent += `## ${category}: ${title} #${pr.number} (${authorsList})\n\n${pr.body || ''}`; processedPRs ++; @@ -390,16 +389,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, } const prRegex = /^## (.*?):\s*(.*?)\s+#(\d+)\s+\((.*?)\)(?:\r?\n)([\s\S]*?)(?=\n---|$)/gm; - let m; - while ( ( m = prRegex.exec( pageContent ) ) !== null ) { - - const category = m[ 1 ].trim(); - const title = m[ 2 ].trim(); - const number = parseInt( m[ 3 ], 10 ); - const authorsList = m[ 4 ].trim(); - - if ( ! categories[ category ] ) categories[ category ] = []; - categories[ category ].push( { title: title + '.', number, authors: authorsList } ); + while ( prRegex.exec( pageContent ) !== null ) { totalPRs ++; @@ -425,35 +415,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, console.error( `Found ${totalPRs} PRs.` ); - return { categories, prDescriptionsForAI, cacheFiles }; - -} - -function formatChangelogData( milestoneName, categories ) { - - const sortedCategories = Object.keys( categories ).sort(); - - let output = `## ${milestoneName}\n\n`; - - for ( let i = 0; i < sortedCategories.length; i ++ ) { - - const cat = sortedCategories[ i ]; - - if ( i > 0 ) output += '\n'; // Add an empty line between categories - - output += `- ${cat}\n`; - - const sortedPRs = categories[ cat ].sort( ( a, b ) => a.number - b.number ); - - for ( const pr of sortedPRs ) { - - output += ` - ${pr.title} #${pr.number} (${pr.authors})\n`; - - } - - } - - return output; + return { prDescriptionsForAI, cacheFiles }; } @@ -485,7 +447,7 @@ async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, const aiText = geminiData.candidates[ 0 ].content.parts[ 0 ].text; const aiFooter = `\n\n*Generated by ${geminiModel}*`; - return `## AI Summary (r${releaseNumber})\n\n${aiText}${aiFooter}\n\n---\n\n`; + return `## AI Summary (r${releaseNumber})\n\n${aiText}${aiFooter}`; } @@ -570,16 +532,16 @@ async function generateChangelog() { console.error( `Fetching PRs for milestone: ${milestoneName}...` ); - const { categories, prDescriptionsForAI, cacheFiles } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, milestoneData.closed_issues ); + const { prDescriptionsForAI, cacheFiles } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, milestoneData.closed_issues ); - let output = formatChangelogData( milestoneName, categories ); + let output = ''; if ( geminiToken && prDescriptionsForAI ) { const aiSummary = await fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ); if ( aiSummary ) { - output = aiSummary + output; + output = aiSummary; } From 909d85d680eeafd2575d94c46584dd497fca0832 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:05:17 -0300 Subject: [PATCH 09/21] Update changelog.ai.md --- utils/changelog.ai.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/utils/changelog.ai.md b/utils/changelog.ai.md index 31e44fa1385a58..16b551ffcd6276 100644 --- a/utils/changelog.ai.md +++ b/utils/changelog.ai.md @@ -1,10 +1,13 @@ -You are an assistant analyzing the changes of a new release of the Three.js library (release r{RELEASE}). +You are an expert technical writer and developer advocate analyzing the changes of a new release of the Three.js library (release r{RELEASE}). Here are the descriptions of the Pull Requests merged in this release: {DESCRIPTION} Please provide the following information formatted in Markdown (ALL EXPLANATIONS MUST BE WRITTEN IN ENGLISH): -- A detailed and comprehensive summary focusing on the most important changes, including new features, major refactorings, API changes, and breaking changes. IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. -- Migration tips for users upgrading to this new version. Use the exact heading "### Migration Tips" (without any numbering). IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip (since it was never released to the public in the first place). +- A detailed and comprehensive overview of the most important changes, including new features, major refactorings, API additions/deprecations, and optimizations. +- Group the changes logically by components (e.g., Core, WebGPU, Renderer, Materials, Geometries, Loaders, Nodes, Editor, Examples). +- For each significant change, provide technical context explaining *what* changed and *why* it matters to developers. Give detailed and complete descriptions, avoiding vague or generic statements. +- IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. +- Migration tips for users upgrading to this new version. Use the exact heading "### Migration Tips" (without any numbering). Detail any breaking changes or required code updates. IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip (since it was never released to the public in the first place). Output only the markdown content, without extra code block delimiters. Do not include a code examples section. From 7fc19034d3bec1809c291a75715a980d9271681c Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:20:08 -0300 Subject: [PATCH 10/21] add examples --- utils/changelog.ai.js | 83 ++++++++++++++++++++++++++++++++++++++++++- utils/changelog.ai.md | 8 +++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index 7d1cf6c9676c23..49386e52de51ee 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import readline from 'readline'; +import { execSync } from 'child_process'; const __filename = fileURLToPath( import.meta.url ); const __dirname = path.dirname( __filename ); @@ -22,6 +23,83 @@ async function askQuestion( query ) { } +function getExamplesChanges( releaseNumber ) { + + const added = []; + const modified = []; + const removed = []; + + try { + + const diff = execSync( `git diff --name-status r${releaseNumber - 1}..HEAD -- examples/`, { cwd: path.join( __dirname, '..' ), encoding: 'utf8' } ); + const lines = diff.split( '\n' ); + + for ( const line of lines ) { + + if ( ! line ) continue; + + if ( line.startsWith( 'R' ) ) { + + const parts = line.split( /\s+/ ); + if ( parts.length >= 3 ) { + + const remName = parts[ 1 ].replace( 'examples/', '' ).replace( '.html', '' ); + const addName = parts[ 2 ].replace( 'examples/', '' ).replace( '.html', '' ); + + removed.push( `[${remName}](https://threejs.org/examples/#${remName})` ); + added.push( `[${addName}](https://threejs.org/examples/#${addName})` ); + + } + + continue; + + } + + const parts = line.split( /\s+/ ); + if ( parts.length < 2 ) continue; + + const status = parts[ 0 ]; + const file = parts[ 1 ]; + + if ( file.endsWith( '.html' ) ) { + + const name = file.replace( 'examples/', '' ).replace( '.html', '' ); + const link = `[${name}](https://threejs.org/examples/#${name})`; + + if ( status.startsWith( 'A' ) ) { + + added.push( link ); + + } else if ( status.startsWith( 'D' ) ) { + + removed.push( link ); + + } else if ( status.startsWith( 'M' ) ) { + + modified.push( link ); + + } + + } + + } + + } catch ( e ) { + + console.error( 'Failed to get examples diff:', e.message ); + + } + + let output = ''; + + if ( added.length > 0 ) output += `**New Examples:**\n${added.join( ', ' )}\n\n`; + if ( modified.length > 0 ) output += `**Modified Examples:**\n${modified.join( ', ' )}\n\n`; + if ( removed.length > 0 ) output += `**Removed Examples:**\n${removed.join( ', ' )}\n\n`; + + return output; + +} + function getReleaseAndCacheArgs() { let arg = process.argv[ 2 ]; @@ -426,9 +504,12 @@ async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, const promptTemplatePath = path.join( __dirname, 'changelog.ai.md' ); const promptTemplate = fs.readFileSync( promptTemplatePath, 'utf8' ); + const examplesChanges = getExamplesChanges( releaseNumber ); + const aiPrompt = promptTemplate .replace( '{RELEASE}', releaseNumber ) - .replace( '{DESCRIPTION}', prDescriptionsForAI ); + .replace( '{DESCRIPTION}', prDescriptionsForAI ) + .replace( '{EXAMPLES_CHANGES}', examplesChanges ); try { diff --git a/utils/changelog.ai.md b/utils/changelog.ai.md index 16b551ffcd6276..356ca5e92ed21a 100644 --- a/utils/changelog.ai.md +++ b/utils/changelog.ai.md @@ -3,11 +3,15 @@ Here are the descriptions of the Pull Requests merged in this release: {DESCRIPTION} +And here is the list of examples added, modified, and removed in this release: +{EXAMPLES_CHANGES} + Please provide the following information formatted in Markdown (ALL EXPLANATIONS MUST BE WRITTEN IN ENGLISH): - A detailed and comprehensive overview of the most important changes, including new features, major refactorings, API additions/deprecations, and optimizations. -- Group the changes logically by components (e.g., Core, WebGPU, Renderer, Materials, Geometries, Loaders, Nodes, Editor, Examples). +- Group the changes logically by components (e.g., Core, WebGPU, Renderer, Materials, Geometries, Loaders, Nodes, Editor). - For each significant change, provide technical context explaining *what* changed and *why* it matters to developers. Give detailed and complete descriptions, avoiding vague or generic statements. - IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. -- Migration tips for users upgrading to this new version. Use the exact heading "### Migration Tips" (without any numbering). Detail any breaking changes or required code updates. IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip (since it was never released to the public in the first place). +- A section for Examples. Detail "New Examples", followed by "Modified Examples", and then "Removed Examples" (if applicable). For the modified ones, use the provided PR descriptions to briefly explain what was changed. If new examples were added, briefly describe their purpose. IMPORTANT: Group related examples compactly into single bullet points (e.g., `- **[example1](https://...), [example2](https://...)**: General description`). Whenever you list an example, you MUST format it as a markdown link pointing to its URL on the Three.js website, exactly like this: `[example_name](https://threejs.org/examples/#example_name)`. +- Migration tips for users upgrading to this new version. Use the exact heading "## Migration Tips" (without any numbering). Detail any breaking changes or required code updates. IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip. Output only the markdown content, without extra code block delimiters. Do not include a code examples section. From c73173249959a99e6200ecdd5046159a1911c4b5 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:35:08 -0300 Subject: [PATCH 11/21] improve examples context --- utils/changelog.ai.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/utils/changelog.ai.md b/utils/changelog.ai.md index 356ca5e92ed21a..205731724217f8 100644 --- a/utils/changelog.ai.md +++ b/utils/changelog.ai.md @@ -11,7 +11,10 @@ Please provide the following information formatted in Markdown (ALL EXPLANATIONS - Group the changes logically by components (e.g., Core, WebGPU, Renderer, Materials, Geometries, Loaders, Nodes, Editor). - For each significant change, provide technical context explaining *what* changed and *why* it matters to developers. Give detailed and complete descriptions, avoiding vague or generic statements. - IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. -- A section for Examples. Detail "New Examples", followed by "Modified Examples", and then "Removed Examples" (if applicable). For the modified ones, use the provided PR descriptions to briefly explain what was changed. If new examples were added, briefly describe their purpose. IMPORTANT: Group related examples compactly into single bullet points (e.g., `- **[example1](https://...), [example2](https://...)**: General description`). Whenever you list an example, you MUST format it as a markdown link pointing to its URL on the Three.js website, exactly like this: `[example_name](https://threejs.org/examples/#example_name)`. +- A section for Examples. You MUST divide it into three sub-categories: `- **New Examples:**`, `- **Modified Examples:**`, and `- **Removed Examples:**` (if applicable). Under each sub-category, group related examples compactly into single bullet points. You MUST place the description on a new line purely using Markdown formatting (a regular line break and indentation), without HTML tags. For the modified ones, briefly explain what was changed. For new examples, briefly describe their purpose. Format each bullet EXACTLY like this: + - **[example1](https://...), [example2](https://...)**: + General description + Whenever you list an example, you MUST format it as a markdown link pointing to its URL on the Three.js website, exactly like this: `[example_name](https://threejs.org/examples/#example_name)`. - Migration tips for users upgrading to this new version. Use the exact heading "## Migration Tips" (without any numbering). Detail any breaking changes or required code updates. IMPORTANT: If a property or feature was created and then renamed or removed within this same release milestone, DO NOT include it as a migration tip. Output only the markdown content, without extra code block delimiters. Do not include a code examples section. From 246c38102bdcbb31d9cebc851f58bbb6493fbbfa Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:43:15 -0300 Subject: [PATCH 12/21] simplification --- .gitignore | 1 - package.json | 3 +- utils/changelog.ai.js | 185 ++++++++++++------------------------------ 3 files changed, 55 insertions(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index 5c3f3c84583c1d..74a8799be70c97 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,3 @@ test/treeshake/index.webgpu.nodes.bundle.min.js test/unit/build **/node_modules -**/changelog diff --git a/package.json b/package.json index 28b295e50b361b..401cdb7c359edc 100644 --- a/package.json +++ b/package.json @@ -66,8 +66,7 @@ "test-e2e-cov": "node test/e2e/check-coverage.js", "test-e2e-webgpu": "node test/e2e/puppeteer.js --webgpu", "test-treeshake": "rollup -c test/rollup.treeshake.config.js", - "make-screenshot": "node test/e2e/puppeteer.js --make", - "changelog": "node utils/changelog.js" + "make-screenshot": "node test/e2e/puppeteer.js --make" }, "keywords": [ "three", diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index 49386e52de51ee..898956637216eb 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -312,16 +312,8 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, let totalPRs = 0; let processedPRs = 0; - const cacheFiles = []; let prDescriptionsForAI = ''; - const changelogDir = path.join( __dirname, '../changelog' ); - if ( ! fs.existsSync( changelogDir ) ) { - - fs.mkdirSync( changelogDir ); - - } - const barWidth = 40; function updateProgress() { @@ -344,7 +336,6 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, } // We no longer need the initial 1-page fetch because we have totalExpectedPRs - while ( true ) { if ( totalExpectedPRs === 0 ) { @@ -353,129 +344,88 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, } - const cacheFilename = path.join( changelogDir, `r${releaseNumber}_page_${page}.md` ); let pageContent = ''; let isLastPage = false; - if ( fs.existsSync( cacheFilename ) ) { - - pageContent = fs.readFileSync( cacheFilename, 'utf8' ); - - if ( totalExpectedPRs > 0 ) { - - // we need to guess how many PRs were in this cached file to update the progress bar - const prRegex = /^## (.*?):\s*(.*?)\s+#(\d+)\s+\((.*?)\)(?:\r?\n)([\s\S]*?)(?=\n---|$)/gm; - let cachedCount = 0; - while ( prRegex.exec( pageContent ) !== null ) { - - cachedCount ++; - - } - - processedPRs += cachedCount; - updateProgress(); - - } - - } else { - - const res = await fetch( `https://api.github.com/repos/${repo}/issues?milestone=${milestoneNumber}&state=closed&per_page=${perPage}&page=${page}`, { headers } ); - if ( ! res.ok ) { - - console.error( '\nFailed to fetch PRs.' ); - process.exit( 1 ); + const res = await fetch( `https://api.github.com/repos/${repo}/issues?milestone=${milestoneNumber}&state=closed&per_page=${perPage}&page=${page}`, { headers } ); + if ( ! res.ok ) { - } + console.error( '\nFailed to fetch PRs.' ); + process.exit( 1 ); - if ( totalPages === '?' || totalExpectedPRs === 0 ) { + } - const linkHeader = res.headers.get( 'link' ); - if ( linkHeader ) { + if ( totalPages === '?' || totalExpectedPRs === 0 ) { - const lastPageMatch = linkHeader.match( /page=(\d+)>; rel="last"/ ); - if ( lastPageMatch ) { + const linkHeader = res.headers.get( 'link' ); + if ( linkHeader ) { - totalPages = lastPageMatch[ 1 ]; - if ( totalExpectedPRs === 0 ) process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); + const lastPageMatch = linkHeader.match( /page=(\d+)>; rel="last"/ ); + if ( lastPageMatch ) { - } + totalPages = lastPageMatch[ 1 ]; + if ( totalExpectedPRs === 0 ) process.stdout.write( `\rFetching PR page ${page}/${totalPages}...` ); } } - const data = await res.json(); - if ( data.length === 0 ) break; + } - const prsInData = data.filter( issue => issue.pull_request && issue.pull_request.merged_at ); + const data = await res.json(); + if ( data.length === 0 ) break; - for ( const pr of prsInData ) { + const prsInData = data.filter( issue => issue.pull_request && issue.pull_request.merged_at ); - let title = pr.title; - let category = 'Others'; + for ( const pr of prsInData ) { - const catMatch = title.match( /^([^:]+):\s*(.*)$/ ); - if ( catMatch ) { + let title = pr.title; + let category = 'Others'; - category = catMatch[ 1 ].trim(); - title = catMatch[ 2 ].trim(); + const catMatch = title.match( /^([^:]+):\s*(.*)$/ ); + if ( catMatch ) { - } + category = catMatch[ 1 ].trim(); + title = catMatch[ 2 ].trim(); - title = title.replace( /\s*\(#\d+\)\s*$/, '' ).trim(); - title = title.replace( /\.\s*$/, '' ); + } - const authors = new Set(); - if ( pr.user && pr.user.login ) authors.add( pr.user.login ); + title = title.replace( /\s*\(#\d+\)\s*$/, '' ).trim(); + title = title.replace( /\.\s*$/, '' ); - if ( pr.body ) { + const authors = new Set(); + if ( pr.user && pr.user.login ) authors.add( pr.user.login ); - const coAuthRegex1 = /Co-authored-by:\s*(?:@)?([a-zA-Z0-9-]+)/gi; - let m; - while ( ( m = coAuthRegex1.exec( pr.body ) ) !== null ) { + if ( pr.body ) { - authors.add( m[ 1 ] ); + const coAuthRegex1 = /Co-authored-by:\s*(?:@)?([a-zA-Z0-9-]+)/gi; + let m; + while ( ( m = coAuthRegex1.exec( pr.body ) ) !== null ) { - } + authors.add( m[ 1 ] ); } - const authorsList = Array.from( authors ).map( a => `@${a}` ).join( ', ' ); - - pageContent += `## ${category}: ${title} #${pr.number} (${authorsList})\n\n${pr.body || ''}`; - - processedPRs ++; - - if ( totalExpectedPRs > 0 ) updateProgress(); - } - fs.writeFileSync( cacheFilename, pageContent ); - - if ( data.length < perPage ) isLastPage = true; + const authorsList = Array.from( authors ).map( a => `@${a}` ).join( ', ' ); - } - - if ( pageContent.length > 0 ) { - - cacheFiles.push( cacheFilename ); - - if ( geminiToken ) { + pageContent += `## ${category}: ${title} #${pr.number} (${authorsList})\n\n${pr.body || ''}\n\n`; - prDescriptionsForAI += pageContent; + processedPRs ++; - } + if ( totalExpectedPRs > 0 ) updateProgress(); - const prRegex = /^## (.*?):\s*(.*?)\s+#(\d+)\s+\((.*?)\)(?:\r?\n)([\s\S]*?)(?=\n---|$)/gm; - while ( prRegex.exec( pageContent ) !== null ) { + } - totalPRs ++; + if ( data.length < perPage ) isLastPage = true; - } + prDescriptionsForAI += pageContent; - } else if ( ! fs.existsSync( cacheFilename ) ) { + const prRegex = /^## (.*?):\s*(.*?)\s+#(\d+)\s+\((.*?)\)(?:\r?\n)([\s\S]*?)(?=\n## |$)/gm; + while ( prRegex.exec( pageContent ) !== null ) { - break; + totalPRs ++; } @@ -493,7 +443,7 @@ async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, console.error( `Found ${totalPRs} PRs.` ); - return { prDescriptionsForAI, cacheFiles }; + return { prDescriptionsForAI }; } @@ -548,37 +498,6 @@ async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, } -function cleanupAndSave( releaseNumber, output, prDescriptionsForAI, cacheFiles, geminiToken ) { - - const changelogDir = path.join( __dirname, '../changelog' ); - const descriptionsFilePath = path.join( changelogDir, `r${releaseNumber}_descriptions.md` ); - const changelogFilePath = path.join( changelogDir, `r${releaseNumber}.md` ); - - fs.writeFileSync( descriptionsFilePath, prDescriptionsForAI ); - fs.writeFileSync( changelogFilePath, output ); - - for ( const cacheFile of cacheFiles ) { - - if ( fs.existsSync( cacheFile ) ) { - - fs.unlinkSync( cacheFile ); - - } - - } - - console.log( `\nāœ… Changelog for r${releaseNumber} generated successfully:` ); - console.log( ` šŸ“„ \x1b[36m${changelogFilePath}\x1b[0m` ); - - if ( geminiToken && prDescriptionsForAI ) { - - console.log( '\nāœ… Descriptions for AI saved in:' ); - console.log( ` šŸ“„ \x1b[36m${descriptionsFilePath}\x1b[0m\n` ); - - } - -} - async function generateChangelog() { const { releaseNumber, milestoneNumber, perPage } = getReleaseAndCacheArgs(); @@ -613,22 +532,26 @@ async function generateChangelog() { console.error( `Fetching PRs for milestone: ${milestoneName}...` ); - const { prDescriptionsForAI, cacheFiles } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, milestoneData.closed_issues ); - - let output = ''; + const { prDescriptionsForAI } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, milestoneData.closed_issues ); if ( geminiToken && prDescriptionsForAI ) { const aiSummary = await fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ); if ( aiSummary ) { - output = aiSummary; + console.log( '\n\n========================================================================\n\n' ); + console.log( aiSummary ); + console.log( '\n\n========================================================================\n' ); } - } + } else if ( prDescriptionsForAI ) { + + console.log( '\n\n========================================================================\n\n' ); + console.log( prDescriptionsForAI ); + console.log( '\n\n========================================================================\n' ); - cleanupAndSave( releaseNumber, output, prDescriptionsForAI, cacheFiles, geminiToken ); + } } From 90e81cf6a28c32940048fab0ee3c31057eaba170 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:49:43 -0300 Subject: [PATCH 13/21] Update changelog.ai.md --- utils/changelog.ai.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/changelog.ai.md b/utils/changelog.ai.md index 205731724217f8..afb07ceb39e901 100644 --- a/utils/changelog.ai.md +++ b/utils/changelog.ai.md @@ -8,7 +8,7 @@ And here is the list of examples added, modified, and removed in this release: Please provide the following information formatted in Markdown (ALL EXPLANATIONS MUST BE WRITTEN IN ENGLISH): - A detailed and comprehensive overview of the most important changes, including new features, major refactorings, API additions/deprecations, and optimizations. -- Group the changes logically by components (e.g., Core, WebGPU, Renderer, Materials, Geometries, Loaders, Nodes, Editor). +- Group the changes logically by components (e.g., Core, WebGPU, Renderer, Materials, Geometries, Loaders, Nodes, Editor). You MUST use the exact heading level `##` for each component category (e.g., `## Core`, etc.). - For each significant change, provide technical context explaining *what* changed and *why* it matters to developers. Give detailed and complete descriptions, avoiding vague or generic statements. - IMPORTANT: Add an extra blank line between each bullet point in your lists for better readability. - A section for Examples. You MUST divide it into three sub-categories: `- **New Examples:**`, `- **Modified Examples:**`, and `- **Removed Examples:**` (if applicable). Under each sub-category, group related examples compactly into single bullet points. You MUST place the description on a new line purely using Markdown formatting (a regular line break and indentation), without HTML tags. For the modified ones, briefly explain what was changed. For new examples, briefly describe their purpose. Format each bullet EXACTLY like this: From 4bbb8e068f98d8de5b3d96394d38651123777a04 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:51:15 -0300 Subject: [PATCH 14/21] Update changelog.ai.js --- utils/changelog.ai.js | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index 898956637216eb..f59601899d7254 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -463,6 +463,7 @@ async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, try { + // codeql[js/file-access-to-http] - False Positive: The prompt template file is intentionally sent to the LLM API. const geminiRes = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${geminiToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, From 62168a12b202e3e37028e71bbca320c728dbf9da Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:52:53 -0300 Subject: [PATCH 15/21] revert changelog windows compatibility --- utils/changelog.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/utils/changelog.js b/utils/changelog.js index 003e57c70cf1a8..e34d237adc7529 100644 --- a/utils/changelog.js +++ b/utils/changelog.js @@ -44,7 +44,7 @@ function exec( command ) { try { - return execSync( command, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, stdio: [ 'pipe', 'pipe', 'ignore' ] } ).trim(); + return execSync( command, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 } ).trim(); } catch ( error ) { @@ -101,7 +101,7 @@ function extractPRNumber( subject ) { function getPRInfo( prNumber ) { - const result = exec( `gh pr view ${prNumber} --json author,title,files --jq '{author: .author.login, title: .title, files: [.files[].path]}'` ); + const result = exec( `gh pr view ${prNumber} --json author,title,files --jq '{author: .author.login, title: .title, files: [.files[].path]}' 2>/dev/null` ); try { @@ -332,7 +332,7 @@ function addToGroup( groups, key, value ) { function validateEnvironment() { - if ( ! exec( 'gh --version' ) ) { + if ( ! exec( 'gh --version 2>/dev/null' ) ) { console.error( 'GitHub CLI (gh) is required but not installed.' ); console.error( 'Install from: https://cli.github.com/' ); From d7343918565ded1babde110a227e9dc49c385ed5 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 02:53:29 -0300 Subject: [PATCH 16/21] Update changelog.ai.js --- utils/changelog.ai.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index f59601899d7254..1c12460f1ab91f 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -305,7 +305,7 @@ async function fetchMilestoneData( repo, milestoneNumber, headers ) { } -async function fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, totalExpectedPRs ) { +async function fetchAndParsePRs( repo, milestoneNumber, perPage, headers, totalExpectedPRs ) { let page = 1; let totalPages = '?'; @@ -533,7 +533,7 @@ async function generateChangelog() { console.error( `Fetching PRs for milestone: ${milestoneName}...` ); - const { prDescriptionsForAI } = await fetchAndParsePRs( repo, milestoneNumber, releaseNumber, perPage, headers, geminiToken, milestoneData.closed_issues ); + const { prDescriptionsForAI } = await fetchAndParsePRs( repo, milestoneNumber, perPage, headers, milestoneData.closed_issues ); if ( geminiToken && prDescriptionsForAI ) { From ac86b4c8f832869162e60cffe36e37400f4c18e1 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 03:10:28 -0300 Subject: [PATCH 17/21] Update changelog.ai.js --- utils/changelog.ai.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index 1c12460f1ab91f..28237fcd85fe1f 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -540,17 +540,15 @@ async function generateChangelog() { const aiSummary = await fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ); if ( aiSummary ) { - console.log( '\n\n========================================================================\n\n' ); - console.log( aiSummary ); - console.log( '\n\n========================================================================\n' ); + console.clear(); + console.log( aiSummary + '\n' ); } } else if ( prDescriptionsForAI ) { - console.log( '\n\n========================================================================\n\n' ); - console.log( prDescriptionsForAI ); - console.log( '\n\n========================================================================\n' ); + console.clear(); + console.log( prDescriptionsForAI + '\n' ); } From 3c2792e368d2e6792b8fb6ba4c5d0c9f011ca0df Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 03:14:45 -0300 Subject: [PATCH 18/21] Update changelog.ai.js --- utils/changelog.ai.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index 28237fcd85fe1f..56a08f88c18e4b 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -437,12 +437,12 @@ async function fetchAndParsePRs( repo, milestoneNumber, perPage, headers, totalE if ( totalExpectedPRs > 0 ) { + const bar = 'ā–ˆ'.repeat( barWidth ); + process.stderr.write( `\r ${bar} 100% (${totalPRs}/${totalPRs})` ); process.stderr.write( '\n\n' ); } - console.error( `Found ${totalPRs} PRs.` ); - return { prDescriptionsForAI }; } From 5f5ba9fa08c2a2881ed128db342093b6dc3d6d22 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 03:24:44 -0300 Subject: [PATCH 19/21] Update changelog.ai.js --- utils/changelog.ai.js | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/utils/changelog.ai.js b/utils/changelog.ai.js index 56a08f88c18e4b..9e1cdf76abb66b 100644 --- a/utils/changelog.ai.js +++ b/utils/changelog.ai.js @@ -535,23 +535,22 @@ async function generateChangelog() { const { prDescriptionsForAI } = await fetchAndParsePRs( repo, milestoneNumber, perPage, headers, milestoneData.closed_issues ); - if ( geminiToken && prDescriptionsForAI ) { + let summary = ''; - const aiSummary = await fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ); - if ( aiSummary ) { + if ( geminiToken && prDescriptionsForAI ) { - console.clear(); - console.log( aiSummary + '\n' ); - - } + summary = await fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, geminiToken ); } else if ( prDescriptionsForAI ) { - console.clear(); - console.log( prDescriptionsForAI + '\n' ); + summary = prDescriptionsForAI; } + console.log( '\n' + '-'.repeat( 100 ) + '\n' ); + console.log( summary ); + console.log( '\n' ); + } generateChangelog().catch( console.error ); From af2f6e0852f8ef5c77282863052949206098734f Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 03:28:17 -0300 Subject: [PATCH 20/21] rename `changelog.ai.js` -> `changelog.summary.js` --- utils/{changelog.ai.js => changelog.summary.js} | 0 utils/{changelog.ai.md => changelog.summary.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename utils/{changelog.ai.js => changelog.summary.js} (100%) rename utils/{changelog.ai.md => changelog.summary.md} (100%) diff --git a/utils/changelog.ai.js b/utils/changelog.summary.js similarity index 100% rename from utils/changelog.ai.js rename to utils/changelog.summary.js diff --git a/utils/changelog.ai.md b/utils/changelog.summary.md similarity index 100% rename from utils/changelog.ai.md rename to utils/changelog.summary.md From bbfa00ce1feacba0b6b20950ccdc7b7da20afb73 Mon Sep 17 00:00:00 2001 From: sunag Date: Fri, 20 Mar 2026 03:29:14 -0300 Subject: [PATCH 21/21] fix rename --- utils/changelog.summary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/changelog.summary.js b/utils/changelog.summary.js index 9e1cdf76abb66b..92a521a29bddce 100644 --- a/utils/changelog.summary.js +++ b/utils/changelog.summary.js @@ -451,7 +451,7 @@ async function fetchAISummary( releaseNumber, prDescriptionsForAI, geminiModel, console.error( `Generating AI Summary with Gemini (${geminiModel})...` ); - const promptTemplatePath = path.join( __dirname, 'changelog.ai.md' ); + const promptTemplatePath = path.join( __dirname, 'changelog.summary.md' ); const promptTemplate = fs.readFileSync( promptTemplatePath, 'utf8' ); const examplesChanges = getExamplesChanges( releaseNumber );