chore: improve changelog generator (#7058)

This commit is contained in:
Louis Lam
2026-03-01 05:41:39 +08:00
committed by GitHub
parent bdcbd4c886
commit 5c81277702
2 changed files with 151 additions and 38 deletions

View File

@@ -4,29 +4,76 @@
import * as childProcess from "child_process"; 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 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 mergeList = ["chore: Translations Update from Weblate", "chore: Update dependencies"];
const template = ` const outputFormat = JSON.stringify({
improvements: [123, 456],
newFeatures: [789],
bugFixes: [101, 112],
securityFixes: [131, 415],
translationContributions: [161, 718],
others: [192, 21],
});
LLM Task: Please help to put above PRs into the following sections based on their content. If a PR fits multiple sections, choose the most relevant one. If a PR doesn't fit any section, place it in "Others". If there are grammatical errors in the PR titles, please correct them. Don't change the PR numbers and authors, and keep the format. Output as markdown file format. const prompt = `Input Data:
\`\`\`json
{{ input }}
\`\`\`
Changelog: 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);
### 🆕 New Features const categoryList = {
// In case the LLM cannot categorize some items
### 💇‍♀️ Improvements uncategorized: {
title: "Uncategorized",
### 🐞 Bug Fixes items: [],
},
### ⬆️ Security Fixes newFeatures: {
title: "🆕 New Features",
### 🦎 Translation Contributions items: [],
},
### Others improvements: {
- Other small changes, code refactoring and comment/doc updates in this repo: 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) { if (import.meta.main) {
await main(); await main();
@@ -38,25 +85,40 @@ if (import.meta.main) {
*/ */
async function main() { async function main() {
const previousVersion = process.argv[2]; const previousVersion = process.argv[2];
const action = process.argv[3];
const categorizedMap = process.argv[4] ? JSON.parse(process.argv[4]) : null;
if (!previousVersion) { if (action === "generate") {
console.error("Please provide the previous version as the first argument."); console.log(`Generating changelog since version ${previousVersion}...`);
process.exit(1); 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));
} }
}
console.log(`Generating changelog since version ${previousVersion}...`); /**
console.log(await generateChangelog(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 * Generate Changelog
* @param {string} previousVersion Previous Version Tag * @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 * @returns {Promise<string>} Changelog Content
*/ */
export async function generateChangelog(previousVersion) { export async function generateChangelog(previousVersion, categorizedMap) {
const prList = await getPullRequestList(previousVersion); const prList = await getPullRequestList(previousVersion);
const list = []; const list = [];
let content = "";
let i = 1; let i = 1;
for (const pr of prList) { for (const pr of prList) {
@@ -98,20 +160,45 @@ export async function generateChangelog(previousVersion) {
authorPart = `(Thanks ${authorPart})`; authorPart = `(Thanks ${authorPart})`;
} }
content += `- ${prPart} ${item.title} ${authorPart}\n`; 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);
} }
return content + "\n" + template; // 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 {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 * @returns {Promise<object>} List of Pull Requests merged since previousVersion
*/ */
async function getPullRequestList(previousVersion) { async function getPullRequestList(previousVersion, removeAuthor = false) {
// Get the date of previousVersion in YYYY-MM-DD format from git // Get the date of previousVersion in iso8601-strict format (2026-02-19T13:34:03+08:00) from git
const previousVersionDate = childProcess const previousVersionDate = childProcess
.execSync(`git log -1 --format=%cd --date=short ${previousVersion}`) .execSync(`git log -1 --format=%cd --date=iso8601-strict ${previousVersion}`)
.toString() .toString()
.trim(); .trim();
@@ -150,7 +237,15 @@ async function getPullRequestList(previousVersion) {
throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`); throw new Error(`gh command failed with status ${ghProcess.status}: ${ghProcess.stderr}`);
} }
return JSON.parse(ghProcess.stdout); const obj = JSON.parse(ghProcess.stdout);
if (removeAuthor) {
for (const pr of obj) {
delete pr.author;
}
}
return obj;
} }
/** /**

View File

@@ -1,7 +1,7 @@
import "dotenv/config"; import "dotenv/config";
import * as childProcess from "child_process"; import * as childProcess from "child_process";
import semver from "semver"; import semver from "semver";
import { generateChangelog } from "../generate-changelog.mjs"; import { getPrompt } from "../generate-changelog.mjs";
import fs from "fs"; import fs from "fs";
import tar from "tar"; import tar from "tar";
@@ -308,15 +308,15 @@ export async function createDistTarGz() {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
export async function createReleasePR(version, previousVersion, dryRun, branchName = "release", githubRunId = null) { export async function createReleasePR(version, previousVersion, dryRun, branchName = "release", githubRunId = null) {
const changelog = await generateChangelog(previousVersion); const prompt = await getPrompt(previousVersion);
const title = dryRun ? `chore: update to ${version} (dry run)` : `chore: update to ${version}`; const title = dryRun ? `chore: update to ${version} (dry run)` : `chore: update to ${version}`;
// Build the artifact link - use direct run link if available, otherwise link to workflow file // Build the artifact link - use direct run link if available, otherwise link to workflow file
const artifactLink = githubRunId const artifactLink = githubRunId
? `https://github.com/louislam/uptime-kuma/actions/runs/${githubRunId}/workflow` ? `https://github.com/louislam/uptime-kuma/actions/runs/${githubRunId}/workflow`
: `https://github.com/louislam/uptime-kuma/actions/workflows/beta-release.yml`; : `https://github.com/louislam/uptime-kuma/actions/workflows/beta-release.yml`;
const body = `## Release ${version} const body = `## Release ${version}
This PR prepares the release for version ${version}. This PR prepares the release for version ${version}.
@@ -330,10 +330,16 @@ This PR prepares the release for version ${version}.
- [ ] (Beta only) Set prerelease - [ ] (Beta only) Set prerelease
- [ ] Publish the release note on GitHub. - [ ] Publish the release note on GitHub.
### Changelog ### Ask LLM to categorize the changelog
\`\`\`md \`\`\`md
${changelog} ${prompt}
\`\`\`
Run the following command to generate the changelog with the categorized map from LLM:
\`\`\`bash
npm run generate-changelog ${previousVersion} generate 'JSON_MAPPING_BY_LLM_HERE'
\`\`\` \`\`\`
### Release Artifacts ### Release Artifacts
@@ -341,7 +347,19 @@ The \`dist.tar.gz\` archive will be available as an artifact in the workflow run
`; `;
// Create the PR using gh CLI // Create the PR using gh CLI
const args = ["pr", "create", "--title", title, "--body", body, "--base", "master", "--head", branchName, "--draft"]; const args = [
"pr",
"create",
"--title",
title,
"--body",
body,
"--base",
"master",
"--head",
branchName,
"--draft",
];
console.log(`Creating draft PR: ${title}`); console.log(`Creating draft PR: ${title}`);