mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
npm run wrapping leaves stdin in non-blocking mode while still reporting isTTY=true, so fs.readSync(0, ...) threw EAGAIN and crashed `npm run version` at the "Generate release notes via AI?" prompt. Read from /dev/tty (always blocking) when available, and fall back to stdin with an Atomics.wait retry on EAGAIN for platforms without /dev/tty.
635 lines
17 KiB
JavaScript
635 lines
17 KiB
JavaScript
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const ROOT_DIR = path.join(__dirname, '..');
|
|
const PLAY_STORE_MAX_CHARS = 500;
|
|
const DEFAULT_CHANGELOG = 'Bug fixes and improvements';
|
|
const DOWNLOADS_NOTE =
|
|
'For all current downloads, package links, and platform-specific notes: [check the wiki](https://github.com/super-productivity/super-productivity/wiki/2.01-Downloads-and-Install).';
|
|
|
|
const GENERATED_RELEASE_NOTES_DIR = path.join(
|
|
ROOT_DIR,
|
|
'build',
|
|
'generated-release-notes',
|
|
);
|
|
const PLAY_STORE_WHATS_NEW_DIR = path.join(GENERATED_RELEASE_NOTES_DIR, 'play-store');
|
|
const PLAY_STORE_WHATS_NEW_FILE = path.join(PLAY_STORE_WHATS_NEW_DIR, 'whatsnew-en-US');
|
|
const GITHUB_RELEASE_NOTES_FILE = path.join(ROOT_DIR, 'build', 'release-notes.md');
|
|
|
|
const USER_FACING_TYPES = new Set(['feat', 'fix', 'perf']);
|
|
const LOW_SIGNAL_TYPES = new Set([
|
|
'build',
|
|
'chore',
|
|
'ci',
|
|
'docs',
|
|
'refactor',
|
|
'style',
|
|
'test',
|
|
]);
|
|
const SUPPORTED_AI_PROVIDERS = new Set(['codex', 'claude']);
|
|
const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'none', 'off']);
|
|
const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
const GITHUB_GROUPS = [
|
|
{
|
|
heading: 'Features',
|
|
isMatch: (commit) => commit.type === 'feat',
|
|
},
|
|
{
|
|
heading: 'Fixes',
|
|
isMatch: (commit) => commit.type === 'fix',
|
|
},
|
|
{
|
|
heading: 'Performance',
|
|
isMatch: (commit) => commit.type === 'perf',
|
|
},
|
|
{
|
|
heading: 'Other Changes',
|
|
isMatch: (commit) => !commit.type || !USER_FACING_TYPES.has(commit.type),
|
|
},
|
|
];
|
|
|
|
const RELEASE_NOTES_SCHEMA = {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['githubMarkdown', 'playStore'],
|
|
properties: {
|
|
githubMarkdown: {
|
|
type: 'string',
|
|
description: 'Markdown release notes for GitHub. Use concise grouped bullets.',
|
|
},
|
|
playStore: {
|
|
type: 'string',
|
|
description: 'Plain-text Google Play release notes, max 500 characters.',
|
|
},
|
|
},
|
|
};
|
|
|
|
const parseCommitSubject = (subject) => {
|
|
const match = subject.match(/^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/);
|
|
if (!match) {
|
|
return {
|
|
type: null,
|
|
scope: null,
|
|
description: subject.trim(),
|
|
raw: subject.trim(),
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: match[1].toLowerCase(),
|
|
scope: match[2] || null,
|
|
description: match[4].trim(),
|
|
raw: subject.trim(),
|
|
};
|
|
};
|
|
|
|
const getAndroidVersionInfo = (version) => {
|
|
const versionParts = version.split('-');
|
|
const baseVersion = versionParts[0];
|
|
const preRelease = versionParts[1];
|
|
const isPreRelease = !!preRelease;
|
|
const baseVersionCode =
|
|
Number(
|
|
baseVersion
|
|
.split('.')
|
|
.map((num) => num.padStart(2, '0'))
|
|
.join(''),
|
|
) * 10000;
|
|
const preReleaseNum = isPreRelease
|
|
? parseInt(preRelease.split('.')[1] || '1', 10)
|
|
: null;
|
|
const versionCode = isPreRelease
|
|
? baseVersionCode + preReleaseNum
|
|
: baseVersionCode + 9000;
|
|
const versionCodeWithUnderscores = versionCode
|
|
.toString()
|
|
.padStart(10, '0')
|
|
.replace(/^(\d{2})(\d{2})(\d{2})(\d{4})$/, '$1_$2_$3_$4');
|
|
|
|
return {
|
|
baseVersion,
|
|
isPreRelease,
|
|
versionCode,
|
|
versionCodeWithUnderscores,
|
|
};
|
|
};
|
|
|
|
const getPreviousTag = ({ version, stableOnly = false }) =>
|
|
execFileSync('git', ['tag', '--merged', 'HEAD', '--sort=-v:refname'], {
|
|
encoding: 'utf8',
|
|
})
|
|
.split('\n')
|
|
.find((tag) => {
|
|
const releaseTag = tag.trim();
|
|
if (releaseTag === `v${version}`) {
|
|
return false;
|
|
}
|
|
return stableOnly ? /^v\d+\.\d+\.\d+$/.test(releaseTag) : /^v/.test(releaseTag);
|
|
})
|
|
?.trim();
|
|
|
|
const getCommitSubjectsSincePreviousTag = ({ version }) => {
|
|
try {
|
|
const lastTag = getPreviousTag({ version });
|
|
if (!lastTag) {
|
|
throw new Error('No previous tag found');
|
|
}
|
|
|
|
const gitLog = execFileSync(
|
|
'git',
|
|
['log', `${lastTag}...HEAD`, '--no-merges', '--pretty=format:%s'],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
return gitLog.split('\n').filter(Boolean);
|
|
} catch (err) {
|
|
console.warn(`Could not generate changelog from git tags: ${err.message}`);
|
|
console.warn('Falling back to last 20 commits');
|
|
const gitLog = execFileSync(
|
|
'git',
|
|
['log', '-20', '--no-merges', '--pretty=format:%s'],
|
|
{
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
return gitLog.split('\n').filter(Boolean);
|
|
}
|
|
};
|
|
|
|
const getCommitSubjectsSinceReleaseBase = ({ isPreRelease, version }) => {
|
|
if (isPreRelease) {
|
|
return getCommitSubjectsSincePreviousTag({ version });
|
|
}
|
|
|
|
try {
|
|
const lastStableTag = getPreviousTag({ version, stableOnly: true });
|
|
if (!lastStableTag) {
|
|
throw new Error('No previous stable tag found');
|
|
}
|
|
|
|
const gitLog = execFileSync(
|
|
'git',
|
|
['log', `${lastStableTag}...HEAD`, '--no-merges', '--pretty=format:%s'],
|
|
{ encoding: 'utf8' },
|
|
);
|
|
return gitLog.split('\n').filter(Boolean);
|
|
} catch (err) {
|
|
console.warn(`Could not generate changelog from stable tags: ${err.message}`);
|
|
return getCommitSubjectsSincePreviousTag({ version });
|
|
}
|
|
};
|
|
|
|
const uniqueByDescription = (commits) => {
|
|
const seen = new Set();
|
|
const result = [];
|
|
|
|
for (const commit of commits) {
|
|
const key = commit.description.toLowerCase();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
result.push(commit);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const toBulletLines = (commits) =>
|
|
commits.map((commit) => {
|
|
const scope = commit.scope ? `**${commit.scope}:** ` : '';
|
|
return `- ${scope}${commit.description}`;
|
|
});
|
|
|
|
const toGroupedGithubMarkdown = (commits) => {
|
|
const sections = [];
|
|
|
|
for (const group of GITHUB_GROUPS) {
|
|
const groupCommits = commits.filter(group.isMatch);
|
|
if (groupCommits.length === 0) {
|
|
continue;
|
|
}
|
|
sections.push(`### ${group.heading}\n\n${toBulletLines(groupCommits).join('\n')}`);
|
|
}
|
|
|
|
return sections.join('\n\n') || `### Highlights\n\n- ${DEFAULT_CHANGELOG}`;
|
|
};
|
|
|
|
const getUserFacingCommits = (commits) => {
|
|
const userFacing = commits.filter((commit) => {
|
|
if (commit.type && USER_FACING_TYPES.has(commit.type)) {
|
|
return true;
|
|
}
|
|
return !commit.type || !LOW_SIGNAL_TYPES.has(commit.type);
|
|
});
|
|
|
|
return userFacing.length > 0 ? userFacing : commits;
|
|
};
|
|
|
|
const truncateAtLineBoundary = (text, maxChars) => {
|
|
const lines = text.split('\n');
|
|
let truncated = '';
|
|
|
|
for (const line of lines) {
|
|
const next = truncated ? `${truncated}\n${line}` : line;
|
|
if (next.length > maxChars) {
|
|
break;
|
|
}
|
|
truncated = next;
|
|
}
|
|
|
|
if (truncated) {
|
|
return truncated;
|
|
}
|
|
|
|
return text.slice(0, maxChars).trim();
|
|
};
|
|
|
|
const toPlainTextBullets = (commits, maxChars = PLAY_STORE_MAX_CHARS) => {
|
|
const text = commits
|
|
.map((commit) => `- ${commit.description}`)
|
|
.join('\n')
|
|
.trim();
|
|
|
|
return truncateAtLineBoundary(text || DEFAULT_CHANGELOG, maxChars) || DEFAULT_CHANGELOG;
|
|
};
|
|
|
|
const normalizePlayStoreText = (text) =>
|
|
truncateAtLineBoundary(
|
|
text
|
|
.replace(/\r\n/g, '\n')
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
.trim() || DEFAULT_CHANGELOG,
|
|
PLAY_STORE_MAX_CHARS,
|
|
) || DEFAULT_CHANGELOG;
|
|
|
|
const writeFileEnsuringDir = (filePath, content) => {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
};
|
|
|
|
const readLineSync = () => {
|
|
// Open /dev/tty directly when available: stdin (fd 0) may be in non-blocking
|
|
// mode when this script is invoked through `npm run`, which makes
|
|
// fs.readSync(0, ...) throw EAGAIN immediately. /dev/tty is always blocking.
|
|
let fd = 0;
|
|
let opened = false;
|
|
try {
|
|
fd = fs.openSync('/dev/tty', 'r');
|
|
opened = true;
|
|
} catch {
|
|
fd = 0;
|
|
}
|
|
|
|
const buffer = Buffer.alloc(1);
|
|
const chars = [];
|
|
const sharedBuf = new SharedArrayBuffer(4);
|
|
const waitView = new Int32Array(sharedBuf);
|
|
|
|
try {
|
|
while (true) {
|
|
let bytesRead;
|
|
try {
|
|
bytesRead = fs.readSync(fd, buffer, 0, 1, null);
|
|
} catch (err) {
|
|
if (err.code === 'EAGAIN') {
|
|
Atomics.wait(waitView, 0, 0, 20);
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
if (bytesRead === 0) {
|
|
break;
|
|
}
|
|
|
|
const char = buffer.toString('utf8', 0, bytesRead);
|
|
if (char === '\n') {
|
|
break;
|
|
}
|
|
if (char !== '\r') {
|
|
chars.push(char);
|
|
}
|
|
}
|
|
} finally {
|
|
if (opened) {
|
|
try {
|
|
fs.closeSync(fd);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
return chars.join('');
|
|
};
|
|
|
|
const promptGenerateReleaseNotesViaAi = () => {
|
|
fs.writeSync(1, 'Generate release notes via AI? [Y/n] ');
|
|
return readLineSync();
|
|
};
|
|
|
|
const getDefaultAiProvider = (env = process.env) =>
|
|
env.SP_RELEASE_NOTES_AI_PROVIDER || 'codex';
|
|
|
|
const resolveAiProvider = ({
|
|
env = process.env,
|
|
isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
lifecycleEvent = env.npm_lifecycle_event,
|
|
prompt = promptGenerateReleaseNotesViaAi,
|
|
} = {}) => {
|
|
const explicitProvider = env.SP_RELEASE_NOTES_AI?.trim().toLowerCase();
|
|
|
|
if (explicitProvider) {
|
|
if (FALSE_ENV_VALUES.has(explicitProvider)) {
|
|
return null;
|
|
}
|
|
if (TRUE_ENV_VALUES.has(explicitProvider)) {
|
|
return getDefaultAiProvider(env);
|
|
}
|
|
return explicitProvider;
|
|
}
|
|
|
|
if (lifecycleEvent !== 'version' || !isInteractive) {
|
|
return null;
|
|
}
|
|
|
|
const answer = prompt().trim().toLowerCase();
|
|
if (answer === '' || answer === 'y' || answer === 'yes') {
|
|
return getDefaultAiProvider(env);
|
|
}
|
|
if (answer === 'n' || answer === 'no') {
|
|
return null;
|
|
}
|
|
|
|
console.warn(`Unknown answer "${answer}" - using deterministic release notes`);
|
|
return null;
|
|
};
|
|
|
|
const runCodexReleaseNotes = ({ prompt }) => {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-release-notes-'));
|
|
const schemaFile = path.join(tmpDir, 'schema.json');
|
|
const outputFile = path.join(tmpDir, 'release-notes.json');
|
|
|
|
fs.writeFileSync(schemaFile, JSON.stringify(RELEASE_NOTES_SCHEMA), 'utf8');
|
|
|
|
execFileSync(
|
|
'codex',
|
|
[
|
|
'--ask-for-approval',
|
|
'never',
|
|
'exec',
|
|
'--sandbox',
|
|
'read-only',
|
|
'--ephemeral',
|
|
'--output-schema',
|
|
schemaFile,
|
|
'--output-last-message',
|
|
outputFile,
|
|
'-',
|
|
],
|
|
{
|
|
cwd: ROOT_DIR,
|
|
encoding: 'utf8',
|
|
input: prompt,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: 10 * 60 * 1000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
},
|
|
);
|
|
|
|
return fs.readFileSync(outputFile, 'utf8');
|
|
};
|
|
|
|
const runClaudeReleaseNotes = ({ prompt }) =>
|
|
execFileSync(
|
|
'claude',
|
|
[
|
|
'-p',
|
|
'--tools',
|
|
'',
|
|
'--output-format',
|
|
'text',
|
|
'--json-schema',
|
|
JSON.stringify(RELEASE_NOTES_SCHEMA),
|
|
prompt,
|
|
],
|
|
{
|
|
cwd: ROOT_DIR,
|
|
encoding: 'utf8',
|
|
timeout: 10 * 60 * 1000,
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
},
|
|
);
|
|
|
|
const parseAiResponse = (response) => {
|
|
const jsonText = response.trim().replace(/^```(?:json)?\n?|\n?```$/g, '');
|
|
const parsed = JSON.parse(jsonText);
|
|
|
|
if (
|
|
typeof parsed.githubMarkdown !== 'string' ||
|
|
typeof parsed.playStore !== 'string' ||
|
|
parsed.githubMarkdown.trim().length === 0 ||
|
|
parsed.playStore.trim().length === 0
|
|
) {
|
|
throw new Error('AI release notes response did not match the expected shape');
|
|
}
|
|
|
|
return {
|
|
githubMarkdown: parsed.githubMarkdown.trim(),
|
|
playStore: normalizePlayStoreText(parsed.playStore),
|
|
};
|
|
};
|
|
|
|
const buildAiPrompt = ({
|
|
version,
|
|
commits,
|
|
deterministicGithubMarkdown,
|
|
playStoreText,
|
|
}) => `You are writing release notes for Super Productivity, an open-source todo and time-tracking app.
|
|
|
|
Rewrite the raw commit subjects into user-facing release notes.
|
|
|
|
Rules:
|
|
- Do not invent features, fixes, issue numbers, platforms, or claims.
|
|
- Prefer user-facing changes over internal build/test/refactor details.
|
|
- Keep GitHub notes concise and grouped with markdown headings and bullets.
|
|
- Keep Play Store notes plain text, no markdown links, max ${PLAY_STORE_MAX_CHARS} characters.
|
|
- Return JSON only, matching the provided schema.
|
|
|
|
Version: ${version}
|
|
|
|
Raw commits:
|
|
${JSON.stringify(commits, null, 2)}
|
|
|
|
Deterministic fallback GitHub notes:
|
|
${deterministicGithubMarkdown}
|
|
|
|
Deterministic fallback Play Store notes:
|
|
${playStoreText}
|
|
`;
|
|
|
|
const getAiReleaseNotes = ({
|
|
version,
|
|
commits,
|
|
deterministicGithubMarkdown,
|
|
playStoreText,
|
|
}) => {
|
|
const provider = resolveAiProvider();
|
|
if (!provider) {
|
|
return null;
|
|
}
|
|
|
|
if (!SUPPORTED_AI_PROVIDERS.has(provider)) {
|
|
const message = `Unsupported release notes AI provider "${provider}". Expected codex or claude.`;
|
|
if (process.env.SP_RELEASE_NOTES_AI_REQUIRED === '1') {
|
|
throw new Error(message);
|
|
}
|
|
console.warn(message);
|
|
console.warn('Falling back to deterministic release notes');
|
|
return null;
|
|
}
|
|
|
|
const prompt = buildAiPrompt({
|
|
version,
|
|
commits,
|
|
deterministicGithubMarkdown,
|
|
playStoreText,
|
|
});
|
|
|
|
try {
|
|
const response =
|
|
provider === 'claude'
|
|
? runClaudeReleaseNotes({ prompt })
|
|
: runCodexReleaseNotes({ prompt });
|
|
const aiNotes = parseAiResponse(response);
|
|
console.log(`Using ${provider} generated release notes`);
|
|
return aiNotes;
|
|
} catch (err) {
|
|
const message = `Could not generate AI release notes with ${provider}: ${err.message}`;
|
|
if (process.env.SP_RELEASE_NOTES_AI_REQUIRED === '1') {
|
|
throw new Error(message);
|
|
}
|
|
console.warn(message);
|
|
console.warn('Falling back to deterministic release notes');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getAndroidFastlaneChangelogFile = (versionCode) =>
|
|
path.join(
|
|
ROOT_DIR,
|
|
'android',
|
|
'fastlane',
|
|
'metadata',
|
|
'android',
|
|
'en-US',
|
|
'changelogs',
|
|
`${versionCode}.txt`,
|
|
);
|
|
|
|
const getLegacyFastlaneChangelogFile = (versionCode) =>
|
|
path.join(
|
|
ROOT_DIR,
|
|
'fastlane',
|
|
'metadata',
|
|
'android',
|
|
'en-US',
|
|
'changelogs',
|
|
`${versionCode}.txt`,
|
|
);
|
|
|
|
const generateReleaseNotes = ({ version, versionCode, isPreRelease }) => {
|
|
const commits = uniqueByDescription(
|
|
getCommitSubjectsSinceReleaseBase({ isPreRelease, version }).map(parseCommitSubject),
|
|
);
|
|
const releaseCommits =
|
|
commits.length > 0 ? commits : [parseCommitSubject(DEFAULT_CHANGELOG)];
|
|
const userFacingCommits = getUserFacingCommits(releaseCommits);
|
|
const deterministicGithubMarkdown = toGroupedGithubMarkdown(userFacingCommits);
|
|
const deterministicPlayStoreText = toPlainTextBullets(userFacingCommits);
|
|
const aiReleaseNotes = getAiReleaseNotes({
|
|
version,
|
|
commits: releaseCommits,
|
|
deterministicGithubMarkdown,
|
|
playStoreText: deterministicPlayStoreText,
|
|
});
|
|
const githubReleaseNotes = `${DOWNLOADS_NOTE}
|
|
|
|
${aiReleaseNotes?.githubMarkdown || deterministicGithubMarkdown}
|
|
`;
|
|
|
|
writeFileEnsuringDir(GITHUB_RELEASE_NOTES_FILE, githubReleaseNotes);
|
|
console.log(`Wrote GitHub release notes to ${GITHUB_RELEASE_NOTES_FILE}`);
|
|
|
|
if (isPreRelease) {
|
|
console.log('Pre-release version - skipping Play Store changelog generation');
|
|
return;
|
|
}
|
|
|
|
const playStoreChangelog = aiReleaseNotes?.playStore || deterministicPlayStoreText;
|
|
const androidFastlaneChangelogFile = getAndroidFastlaneChangelogFile(versionCode);
|
|
writeFileEnsuringDir(androidFastlaneChangelogFile, playStoreChangelog);
|
|
|
|
console.log(
|
|
`Wrote Android changelog for ${version} to ${androidFastlaneChangelogFile}`,
|
|
);
|
|
};
|
|
|
|
const preparePlayStoreReleaseNotes = ({ versionCode }) => {
|
|
const candidates = [
|
|
getAndroidFastlaneChangelogFile(versionCode),
|
|
getLegacyFastlaneChangelogFile(versionCode),
|
|
];
|
|
const source = candidates.find((candidate) => fs.existsSync(candidate));
|
|
if (!source) {
|
|
throw new Error(`No versioned Play Store changelog found for ${versionCode}`);
|
|
}
|
|
|
|
const sourceText = fs.readFileSync(source, 'utf8');
|
|
const playStoreChangelog =
|
|
truncateAtLineBoundary(
|
|
sourceText.trim() || DEFAULT_CHANGELOG,
|
|
PLAY_STORE_MAX_CHARS,
|
|
) || DEFAULT_CHANGELOG;
|
|
|
|
writeFileEnsuringDir(PLAY_STORE_WHATS_NEW_FILE, playStoreChangelog);
|
|
|
|
console.log(`Prepared Google Play release notes from ${source}`);
|
|
console.log(`Wrote Google Play release notes to ${PLAY_STORE_WHATS_NEW_FILE}`);
|
|
};
|
|
|
|
const getCurrentPackageVersion = () =>
|
|
JSON.parse(fs.readFileSync(path.join(ROOT_DIR, 'package.json'), 'utf8')).version;
|
|
|
|
if (require.main === module) {
|
|
const command = process.argv[2] || 'generate';
|
|
const version = getCurrentPackageVersion();
|
|
const versionInfo = getAndroidVersionInfo(version);
|
|
|
|
if (command === 'generate') {
|
|
generateReleaseNotes({ version, ...versionInfo });
|
|
} else if (command === 'prepare-play-store') {
|
|
preparePlayStoreReleaseNotes(versionInfo);
|
|
} else {
|
|
console.error(`Unknown release notes command: ${command}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
getAndroidVersionInfo,
|
|
generateReleaseNotes,
|
|
preparePlayStoreReleaseNotes,
|
|
__test: {
|
|
getUserFacingCommits,
|
|
normalizePlayStoreText,
|
|
parseAiResponse,
|
|
parseCommitSubject,
|
|
resolveAiProvider,
|
|
toGroupedGithubMarkdown,
|
|
toPlainTextBullets,
|
|
truncateAtLineBoundary,
|
|
uniqueByDescription,
|
|
},
|
|
};
|