mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Rework the CLI tool so contributors no longer need to know the PR number upfront. The new flow: 1. Asks for username, category, and summary first 2. Checks for an existing PR on the current branch 3. If no PR exists and `gh` CLI is available, offers to create a draft PR automatically (pushes the branch and runs `gh pr create --draft`) 4. Falls back to manual PR number entry This eliminates the clunky workflow of having to create a draft PR separately just to get the PR number for the release notes filename. https://claude.ai/code/session_014ncE7UnSdms47J3Xwg6DYG
283 lines
7.7 KiB
TypeScript
283 lines
7.7 KiB
TypeScript
import { exec } from 'node:child_process';
|
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
import { exit } from 'node:process';
|
|
|
|
import prompts from 'prompts';
|
|
|
|
async function run() {
|
|
const hasGhCli = await checkGhCli();
|
|
const username = hasGhCli ? await execAsync("gh api user --jq '.login'") : '';
|
|
|
|
if (!username) {
|
|
console.log(
|
|
'Tip: Install the GitHub CLI (https://github.com/cli/cli) and run `gh auth login` to enable auto-detection of your username and auto-creation of draft PRs.',
|
|
);
|
|
}
|
|
|
|
const activePr = await getActivePr(username);
|
|
if (activePr) {
|
|
console.log(`Found existing PR #${activePr.number}: ${activePr.title}`);
|
|
}
|
|
|
|
// Ask for category and summary first (before PR number)
|
|
const initial = await prompts([
|
|
{
|
|
name: 'githubUsername',
|
|
message: 'Comma-separated GitHub username(s)',
|
|
type: 'text',
|
|
initial: username,
|
|
},
|
|
{
|
|
name: 'releaseNoteType',
|
|
message: 'Release Note Type',
|
|
type: 'select',
|
|
choices: [
|
|
{ title: '✨ Features', value: 'Features' },
|
|
{ title: '👍 Enhancements', value: 'Enhancements' },
|
|
{ title: '🐛 Bugfixes', value: 'Bugfixes' },
|
|
{ title: '⚙️ Maintenance', value: 'Maintenance' },
|
|
],
|
|
},
|
|
{
|
|
name: 'oneLineSummary',
|
|
message: 'Brief Summary',
|
|
type: 'text',
|
|
initial: activePr?.title,
|
|
},
|
|
]);
|
|
|
|
if (
|
|
!initial.githubUsername ||
|
|
!initial.oneLineSummary ||
|
|
initial.releaseNoteType === undefined
|
|
) {
|
|
console.log('All questions must be answered. Exiting');
|
|
exit(1);
|
|
}
|
|
|
|
// Determine PR number: use existing PR, offer to create draft, or ask manually
|
|
let prNumber: number;
|
|
|
|
if (activePr) {
|
|
prNumber = activePr.number;
|
|
} else if (hasGhCli) {
|
|
const { action } = await prompts({
|
|
name: 'action',
|
|
message: 'No existing PR found. How would you like to get the PR number?',
|
|
type: 'select',
|
|
choices: [
|
|
{
|
|
title: '🚀 Create a draft PR automatically',
|
|
value: 'create-draft',
|
|
description:
|
|
'Creates a draft PR using the GitHub CLI and uses its number',
|
|
},
|
|
{
|
|
title: '✏️ Enter PR number manually',
|
|
value: 'manual',
|
|
description: 'Enter a PR number you already know',
|
|
},
|
|
],
|
|
});
|
|
|
|
if (!action) {
|
|
console.log('Exiting');
|
|
exit(1);
|
|
}
|
|
|
|
if (action === 'create-draft') {
|
|
prNumber = await createDraftPr(initial.oneLineSummary);
|
|
} else {
|
|
prNumber = await askForPrNumber();
|
|
}
|
|
} else {
|
|
prNumber = await askForPrNumber();
|
|
}
|
|
|
|
const fileContents = getFileContents(
|
|
initial.releaseNoteType,
|
|
initial.githubUsername,
|
|
initial.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);
|
|
}
|
|
}
|
|
|
|
writeFileSync(filepath, fileContents);
|
|
console.log(`Release note generated successfully: ${filepath}`);
|
|
}
|
|
|
|
async function checkGhCli(): Promise<boolean> {
|
|
const result = await execAsync('gh --version');
|
|
return result !== '';
|
|
}
|
|
|
|
async function createDraftPr(title: string): Promise<number> {
|
|
// Ensure current branch is pushed to remote
|
|
const branchName = await execAsync('git rev-parse --abbrev-ref HEAD');
|
|
if (!branchName || branchName === 'master' || branchName === 'main') {
|
|
console.error(
|
|
'Cannot create a draft PR from the main/master branch. Please switch to a feature branch first.',
|
|
);
|
|
exit(1);
|
|
}
|
|
|
|
console.log(`Pushing branch "${branchName}" to remote...`);
|
|
const pushResult = await execAsync(
|
|
`git push -u origin ${branchName} 2>&1`,
|
|
'Failed to push branch to remote. Please push manually first.',
|
|
);
|
|
if (pushResult === '') {
|
|
// execAsync returns '' on error
|
|
exit(1);
|
|
}
|
|
|
|
console.log('Creating draft PR...');
|
|
const prUrl = await execAsync(
|
|
`gh pr create --draft --title "${title.replace(/"/g, '\\"')}" --body "" 2>&1`,
|
|
'Failed to create draft PR. Please create one manually via GitHub.',
|
|
);
|
|
|
|
if (!prUrl) {
|
|
exit(1);
|
|
}
|
|
|
|
// Extract PR number from URL (e.g., https://github.com/owner/repo/pull/1234)
|
|
const prNumberMatch = prUrl.match(/\/pull\/(\d+)/);
|
|
if (!prNumberMatch) {
|
|
// gh pr create might return just a number or other format
|
|
const numberMatch = prUrl.match(/(\d+)/);
|
|
if (!numberMatch) {
|
|
console.error('Could not parse PR number from output:', prUrl);
|
|
exit(1);
|
|
}
|
|
const prNumber = parseInt(numberMatch[1], 10);
|
|
console.log(`Draft PR #${prNumber} created: ${prUrl.trim()}`);
|
|
return prNumber;
|
|
}
|
|
|
|
const prNumber = parseInt(prNumberMatch[1], 10);
|
|
console.log(`Draft PR #${prNumber} created: ${prUrl.trim()}`);
|
|
return prNumber;
|
|
}
|
|
|
|
async function askForPrNumber(): Promise<number> {
|
|
const nextPrNumber = await getNextPrNumber();
|
|
const { pullRequestNumber } = await prompts({
|
|
name: 'pullRequestNumber',
|
|
message: 'PR Number',
|
|
type: 'number',
|
|
initial: nextPrNumber,
|
|
});
|
|
|
|
if (!pullRequestNumber) {
|
|
console.log('PR number is required. Exiting');
|
|
exit(1);
|
|
}
|
|
|
|
return pullRequestNumber;
|
|
}
|
|
|
|
// 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) {
|
|
if (errorLog) {
|
|
console.log(errorLog);
|
|
}
|
|
res('');
|
|
} else {
|
|
res(stdout.trim());
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
void run();
|