🔧 Added release-notes-generator.ts under generate:release-notes (#4664)

This commit is contained in:
Alec Bakholdin
2025-03-20 10:19:04 -04:00
committed by GitHub
parent b2cca2337c
commit d9716caf5d
6 changed files with 208 additions and 3 deletions

View File

@@ -1 +1 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->

View File

@@ -0,0 +1,182 @@
import { exec } from 'node:child_process';
import { existsSync, writeFile } from 'node:fs';
import { exit } from 'node:process';
import prompts from 'prompts';
async function run() {
const username = await execAsync(
// eslint-disable-next-line rulesdir/typography
"gh api user --jq '.login'",
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
);
const activePr = await getActivePr(username);
if (activePr) {
console.log(
`Found potentially matching PR ${activePr.number}: ${activePr.title}`,
);
}
const prNumber = activePr?.number ?? (await getNextPrNumber());
const result = await prompts([
{
name: 'githubUsername',
message: 'Comma-separated GitHub username(s)',
type: 'text',
initial: username,
},
{
name: 'pullRequestNumber',
message: 'PR Number',
type: 'number',
initial: prNumber,
},
{
name: 'releaseNoteType',
message: 'Release Note Type',
type: 'select',
choices: [
{ title: 'Features', value: 'Features' },
{ title: 'Enhancements', value: 'Enhancements' },
{ title: 'Bugfix', value: 'Bugfix' },
{ title: 'Maintenance', value: 'Maintenance' },
],
},
{
name: 'oneLineSummary',
message: 'Brief Summary',
type: 'text',
initial: activePr?.title,
},
]);
if (
!result.githubUsername ||
!result.oneLineSummary ||
!result.releaseNoteType
) {
console.log('All questions must be answered. Exiting');
exit(1);
}
const fileContents = getFileContents(
result.releaseNoteType,
result.githubUsername,
result.oneLineSummary,
);
const filepath = `./upcoming-release-notes/${prNumber}.md`;
if (existsSync(filepath)) {
const { confirm } = await prompts({
name: 'confirm',
type: 'confirm',
message: `This will overwrite the existing release note ${filepath} Are you sure?`,
});
if (!confirm) {
console.log('Exiting');
exit(1);
}
}
writeFile(filepath, fileContents, err => {
if (err) {
console.error('Failed to write release note file:', err);
exit(1);
} else {
console.log(
`Release note generated successfully: ./upcoming-release-notes/${prNumber}.md`,
);
}
});
}
// makes an attempt to find an existing open PR from <username>:<branch>
async function getActivePr(
username: string,
): Promise<{ number: number; title: string } | undefined> {
if (!username) {
return undefined;
}
const branchName = await execAsync('git rev-parse --abbrev-ref HEAD');
if (!branchName) {
return undefined;
}
const forkHead = `${username}:${branchName}`;
return getPrNumberFromHead(forkHead);
}
async function getPrNumberFromHead(
head: string,
): Promise<{ number: number; title: string } | undefined> {
try {
// head is a weird query parameter in this API call. If nothing matches, it
// will return as if the head query parameter doesn't exist. To get around
// this, we make the page size 2 and only return the number if the length.
const resp = await fetch(
'https://api.github.com/repos/actualbudget/actual/pulls?state=open&per_page=2&head=' +
head,
);
if (!resp.ok) {
console.warn('error fetching from github pulls api:', resp.status);
return undefined;
}
const ghResponse = await resp.json();
if (ghResponse?.length === 1) {
return ghResponse[0];
} else {
return undefined;
}
} catch (e) {
console.warn('error fetching from github pulls api:', e);
}
}
async function getNextPrNumber(): Promise<number> {
try {
const resp = await fetch(
'https://api.github.com/repos/actualbudget/actual/issues?state=all&per_page=1',
);
if (!resp.ok) {
throw new Error(`API responded with status: ${resp.status}`);
}
const ghResponse = await resp.json();
const latestPrNumber = ghResponse?.[0]?.number;
if (!latestPrNumber) {
console.error(
'Could not find latest issue number in GitHub API response',
ghResponse,
);
exit(1);
}
return latestPrNumber + 1;
} catch (error) {
console.error('Failed to fetch next PR number:', error);
exit(1);
}
}
function getFileContents(type: string, username: string, summary: string) {
return `---
category: ${type}
authors: [${username}]
---
${summary}
`;
}
// simple exec that fails silently and returns an empty string on failure
async function execAsync(cmd: string, errorLog?: string): Promise<string> {
return new Promise<string>(res => {
exec(cmd, (error, stdout) => {
if (error) {
console.log(errorLog);
res('');
} else {
res(stdout.trim());
}
});
});
}
run();

View File

@@ -36,6 +36,7 @@
"build:desktop": "./bin/package-electron",
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "yarn workspaces foreach --all --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --parallel --verbose run e2e",
@@ -51,6 +52,7 @@
"prepare": "husky"
},
"devDependencies": {
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.26.1",
"cross-env": "^7.0.3",
"eslint": "^9.22.0",
@@ -68,7 +70,9 @@
"node-jq": "^4.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.8.2",
"typescript-eslint": "^8.26.1",
"typescript-strict-plugin": "^2.4.4"

View File

@@ -36,7 +36,7 @@
}
]
},
"include": ["packages/**/*"],
"include": ["packages/**/*", "bin/*.ts"],
"exclude": [
"node_modules",
"**/node_modules/*",

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [alecbakholdin]
---
Added release note generator

View File

@@ -6185,6 +6185,16 @@ __metadata:
languageName: node
linkType: hard
"@types/prompts@npm:^2.4.9":
version: 2.4.9
resolution: "@types/prompts@npm:2.4.9"
dependencies:
"@types/node": "npm:*"
kleur: "npm:^3.0.3"
checksum: 10/69b8372f4c790b45fea16a46ff8d1bcc71b14579481776b67bd6263637118a7ecb1f12e1311506c29fadc81bf618dc64f1a91f903cfd5be67a0455a227b3e462
languageName: node
linkType: hard
"@types/prop-types@npm:*, @types/prop-types@npm:^15.0.0":
version: 15.7.5
resolution: "@types/prop-types@npm:15.7.5"
@@ -6908,6 +6918,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "actual@workspace:."
dependencies:
"@types/prompts": "npm:^2.4.9"
"@typescript-eslint/parser": "npm:^8.26.1"
cross-env: "npm:^7.0.3"
eslint: "npm:^9.22.0"
@@ -6925,7 +6936,9 @@ __metadata:
node-jq: "npm:^4.0.1"
npm-run-all: "npm:^4.1.5"
prettier: "npm:^3.5.3"
prompts: "npm:^2.4.2"
source-map-support: "npm:^0.5.21"
ts-node: "npm:^10.9.2"
typescript: "npm:^5.8.2"
typescript-eslint: "npm:^8.26.1"
typescript-strict-plugin: "npm:^2.4.4"
@@ -16893,7 +16906,7 @@ __metadata:
languageName: node
linkType: hard
"prompts@npm:^2.0.1":
"prompts@npm:^2.0.1, prompts@npm:^2.4.2":
version: 2.4.2
resolution: "prompts@npm:2.4.2"
dependencies: