mirror of
https://github.com/louislam/uptime-kuma.git
synced 2026-03-13 09:52:49 +08:00
296 lines
8.2 KiB
JavaScript
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]);
|
|
}
|