Files
uptime-kuma/extra/generate-changelog.mjs
2026-03-01 05:41:39 +08:00

296 lines
8.2 KiB
JavaScript

// Script to generate changelog
// Usage: node generate-changelog.mjs <previous-version-tag>
// 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:
\`\`\`json
{{ 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<void>}
*/
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<string>} 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<string>} 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;
}
/**
* @param {string} previousVersion Previous Version Tag
* @param {boolean} removeAuthor Whether to strip the author field from the returned PR list
* @returns {Promise<object>} List of Pull Requests merged since previousVersion
*/
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<string>>} 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<string>} authorSet Set of Authors
* @returns {Set<string>} New Set with mainAuthor at the front
*/
async function mainAuthorToFront(mainAuthor, authorSet) {
if (ignoreList.includes(mainAuthor)) {
return authorSet;
}
return new Set([mainAuthor, ...authorSet]);
}