// Script to generate changelog // Usage: node generate-changelog.mjs // GitHub CLI (gh command) is required import * as childProcess from "child_process"; const ignoreList = [ "louislam", "CommanderStorm", "UptimeKumaBot", "weblate", "Copilot", "autofix-ci[bot]", "app/copilot-swe-agent", "app/github-actions", "github-actions[bot]", ]; const mergeList = ["chore: Translations Update from Weblate", "chore: Update dependencies"]; const outputFormat = JSON.stringify({ improvements: [123, 456], newFeatures: [789], bugFixes: [101, 112], securityFixes: [131, 415], translationContributions: [161, 718], others: [192, 21], }); const prompt = `Input Data: {{ input }} LLM Task: - Output a one-line JSON object in the following format: {{ outputFormat }} - Empty arrays included if there are no items for that category. - Exclude reverted pull requests. - "fix: " type pull requests should be categorized as "bugFixes". - "chore: " type pull requests should be categorized as "others" - "feat: " type pull requests should be categorized as "newFeatures" or "improvements" based on the content of the title, you should determine it. - "refactor: " type pull requests should be categorized as "improvements". `.replace("{{ outputFormat }}", outputFormat); const categoryList = { // In case the LLM cannot categorize some items uncategorized: { title: "Uncategorized", items: [], }, newFeatures: { title: "🆕 New Features", items: [], }, improvements: { title: "💇‍♀️ Improvements", items: [], }, bugFixes: { title: "🐞 Bug Fixes", items: [], }, securityFixes: { title: "⬆️ Security Fixes", items: [], }, translationContributions: { title: "🦎 Translation Contributions", items: [], }, others: { title: "Others", items: [], }, }; if (import.meta.main) { await main(); } /** * Main Function * @returns {Promise} */ async function main() { const previousVersion = process.argv[2]; const action = process.argv[3]; const categorizedMap = process.argv[4] ? JSON.parse(process.argv[4]) : null; if (action === "generate") { console.log(`Generating changelog since version ${previousVersion}...`); console.log(await generateChangelog(previousVersion, categorizedMap)); } else { if (!previousVersion) { console.error("Please provide the previous version as the first argument."); process.exit(1); } console.log(await getPrompt(previousVersion)); } } /** * Get Prompt for LLM * @param {string} previousVersion Previous Version Tag * @returns {Promise} Prompt for LLM */ export async function getPrompt(previousVersion) { const input = JSON.stringify(await getPullRequestList(previousVersion, true)); return prompt.replace("{{ input }}", input); } /** * Generate Changelog * @param {string} previousVersion Previous Version Tag * @param {object} categorizedMap It should be generated by the LLM based on the prompt * @returns {Promise} Changelog Content */ export async function generateChangelog(previousVersion, categorizedMap) { const prList = await getPullRequestList(previousVersion); const list = []; let i = 1; for (const pr of prList) { console.log(`Progress: ${i++}/${prList.length}`); let authorSet = await getAuthorList(pr.number); authorSet = await mainAuthorToFront(pr.author.login, authorSet); if (mergeList.includes(pr.title)) { // Check if it is already in the list const existingItem = list.find((item) => item.title === pr.title); if (existingItem) { existingItem.numbers.push(pr.number); for (const author of authorSet) { existingItem.authors.add(author); // Sort the authors existingItem.authors = new Set([...existingItem.authors].sort((a, b) => a.localeCompare(b))); } continue; } } const item = { numbers: [pr.number], title: pr.title, authors: authorSet, }; list.push(item); } for (const item of list) { // Concat pr numbers into a string like #123 #456 const prPart = item.numbers.map((num) => `#${num}`).join(" "); // Concat authors into a string like @user1 @user2 let authorPart = [...item.authors].map((author) => `@${author}`).join(" "); if (authorPart) { authorPart = `(Thanks ${authorPart})`; } const line = `- ${prPart} ${item.title} ${authorPart}`; // Determine the category of the item, based on the title and the categorizedMap let category = "uncategorized"; let prNumber = item.numbers[0]; for (const cat in categorizedMap) { if (categorizedMap[cat].includes(prNumber)) { category = cat; break; } } categoryList[category].items.push(line); } // Generate markdown let content = ""; for (const cat in categoryList) { content += `### ${categoryList[cat].title}\n`; for (const item of categoryList[cat].items) { content += `${item}\n`; } content += `\n`; } return content; } /** * Generate Changelog using AI * @param {string} previousVersion Previous Version Tag * @returns {Promise} Changelog Content */ export async function generateChangelogAI(previousVersion) { // 1. Generate changelog let categorizedMap = null; console.log("Running opencode to categorize PRs..."); const llmPrompt = (await getPrompt(previousVersion)).replaceAll("\n", " "); console.log(llmPrompt); console.log("Running opencode with the above prompt..."); try { const result = childProcess.spawnSync( "opencode", ["run", "-m", "opencode/big-pickle", "--format", "json", llmPrompt], { encoding: "utf-8", timeout: 120000, shell: true, cwd: process.cwd(), env: process.env, } ); if (result.status === 0 && result.stdout) { // Parse NDJSON output: find "type":"text" line for (const line of result.stdout.trim().split("\n")) { try { const obj = JSON.parse(line); if (obj.type === "text" && obj.part?.text) { const jsonMatch = obj.part.text.match(/\{[\s\S]*\}/); if (jsonMatch) { categorizedMap = JSON.parse(jsonMatch[0]); console.log("LLM categorization applied."); break; } } } catch { // skip unparseable lines } } if (!categorizedMap) { console.warn("No JSON found in opencode response."); console.warn(result.stdout); } } else { console.warn("opencode failed or returned no output (status:", result.status, ")"); if (result.stderr) { console.warn("stderr:", result.stderr.slice(0, 500)); } } } catch (e) { console.warn("Failed to run opencode:", e.message); } if (!categorizedMap) { categorizedMap = {}; console.log("OpenCode unavailable, using uncategorized fallback."); } return await generateChangelog(previousVersion, categorizedMap); } /** * @param {string} previousVersion Previous Version Tag * @param {boolean} removeAuthor Whether to strip the author field from the returned PR list * @returns {Promise} List of Pull Requests merged since previousVersion */ export async function getPullRequestList(previousVersion, removeAuthor = false) { // Get the date of previousVersion in iso8601-strict format (2026-02-19T13:34:03+08:00) from git const previousVersionDate = childProcess .execSync(`git log -1 --format=%cd --date=iso8601-strict ${previousVersion}`) .toString() .trim(); if (!previousVersionDate) { throw new Error( `Unable to find the date of version ${previousVersion}. Please make sure the version tag exists.` ); } const ghProcess = childProcess.spawnSync( "gh", [ "pr", "list", "--state", "merged", "--base", "master", "--search", `merged:>=${previousVersionDate}`, "--json", "number,title,author", "--limit", "1000", ], { encoding: "utf-8", } ); if (ghProcess.error) { throw ghProcess.error; } if (ghProcess.status !== 0) { throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`); } const obj = JSON.parse(ghProcess.stdout); if (removeAuthor) { for (const pr of obj) { delete pr.author; } } return obj; } /** * @param {number} prID Pull Request ID * @returns {Promise>} Set of Authors' GitHub Usernames */ async function getAuthorList(prID) { const ghProcess = childProcess.spawnSync("gh", ["pr", "view", prID, "--json", "commits"], { encoding: "utf-8", }); if (ghProcess.error) { throw ghProcess.error; } if (ghProcess.status !== 0) { throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`); } const prInfo = JSON.parse(ghProcess.stdout); const commits = prInfo.commits; const set = new Set(); for (const commit of commits) { for (const author of commit.authors) { if (author.login && !ignoreList.includes(author.login)) { set.add(author.login); } } } // Sort the set return new Set([...set].sort((a, b) => a.localeCompare(b))); } /** * @param {string} mainAuthor Main Author * @param {Set} authorSet Set of Authors * @returns {Set} New Set with mainAuthor at the front */ export async function mainAuthorToFront(mainAuthor, authorSet) { if (ignoreList.includes(mainAuthor)) { return authorSet; } return new Set([mainAuthor, ...authorSet]); }