diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 0d136a3a7..8cb6dbc27 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -20,9 +20,21 @@ const path = require('node:path'); const { parseFragment, FRAGMENT_ERROR } = require('./parse.cjs'); const { renderChangelog } = require('./render.cjs'); const { serializeChangelog } = require('./serialize.cjs'); +const { renderGithubReleaseNotes } = require('./github-release-notes.cjs'); function parseArgs(argv) { - const opts = { cmd: null, repo: process.cwd(), version: null, date: null, json: false }; + const opts = { + cmd: null, + repo: process.cwd(), + version: null, + date: null, + fromRef: null, + toRef: null, + output: null, + repoSlug: 'gsd-build/get-shit-done', + installCommand: 'npx get-shit-done-cc@latest', + json: false, + }; if (argv.length === 0) return { ok: true, opts }; opts.cmd = argv[0]; @@ -41,12 +53,26 @@ function parseArgs(argv) { for (let i = 1; i < argv.length; i++) { const a = argv[i]; if (a === '--json') { opts.json = true; continue; } - if (a === '--repo' || a === '--version' || a === '--date') { + if ( + a === '--repo' || + a === '--version' || + a === '--date' || + a === '--from' || + a === '--to' || + a === '--output' || + a === '--repo-slug' || + a === '--install-command' + ) { const r = requireValue(a, i); if (!r.ok) return { ok: false, error: r.error }; if (a === '--repo') opts.repo = r.value; else if (a === '--version') opts.version = r.value; else if (a === '--date') opts.date = r.value; + else if (a === '--from') opts.fromRef = r.value; + else if (a === '--to') opts.toRef = r.value; + else if (a === '--output') opts.output = r.value; + else if (a === '--repo-slug') opts.repoSlug = r.value; + else if (a === '--install-command') opts.installCommand = r.value; i++; continue; } @@ -154,26 +180,78 @@ function cmdRender(opts) { }; } +function cmdGithubReleaseNotes(opts) { + const repo = path.resolve(opts.repo); + const report = renderGithubReleaseNotes({ + repo, + fromRef: opts.fromRef, + toRef: opts.toRef, + repoSlug: opts.repoSlug, + installCommand: opts.installCommand, + }); + + if (!report.ok) { + return { + exitCode: 1, + report: { + consumed: 0, + failures: report.failures, + release: { from: opts.fromRef, to: opts.toRef }, + }, + }; + } + + if (opts.output) { + fs.writeFileSync(path.resolve(opts.output), report.body); + } + + return { + exitCode: 0, + report: { + consumed: report.fragments.length, + failures: [], + release: { from: opts.fromRef, to: opts.toRef }, + output: opts.output || null, + body: opts.output ? null : report.body, + }, + }; +} + +function usage() { + return [ + 'usage:', + ' changeset/cli.cjs render --repo --version V --date D [--json]', + ' changeset/cli.cjs github-release-notes --repo --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]', + '', + ].join('\n'); +} + function main() { const parsed = parseArgs(process.argv.slice(2)); if (!parsed.ok) { process.stderr.write(`${parsed.error}\n`); - process.stderr.write('usage: changeset/cli.cjs render --repo --version V --date D [--json]\n'); + process.stderr.write(usage()); process.exit(2); } const { opts } = parsed; - if (opts.cmd !== 'render') { - process.stderr.write('usage: changeset/cli.cjs render --repo --version V --date D [--json]\n'); + if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes') { + process.stderr.write(usage()); process.exit(2); } - if (!opts.version || !opts.date) { + if (opts.cmd === 'render' && (!opts.version || !opts.date)) { process.stderr.write('--version and --date are required for render\n'); process.exit(2); } + if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) { + process.stderr.write('--from and --to are required for github-release-notes\n'); + process.exit(2); + } - const { exitCode, report } = cmdRender(opts); + const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); if (opts.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } else if (opts.cmd === 'github-release-notes' && report.body) { + process.stdout.write(report.body); } else { process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`); if (report.failures.length > 0) { @@ -188,4 +266,4 @@ function main() { if (require.main === module) main(); -module.exports = { cmdRender, parseArgs, splitChangelog, listFragmentFiles }; +module.exports = { cmdRender, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage }; diff --git a/scripts/changeset/github-release-notes.cjs b/scripts/changeset/github-release-notes.cjs new file mode 100644 index 000000000..a7234e768 --- /dev/null +++ b/scripts/changeset/github-release-notes.cjs @@ -0,0 +1,198 @@ +'use strict'; + +const cp = require('node:child_process'); +const path = require('node:path'); + +const { parseFragment } = require('./parse.cjs'); + +const SECTION_ORDER = ['Fixed', 'Added', 'Changed', 'Deprecated', 'Removed', 'Security']; + +const FIXED_GROUPS = [ + { + title: 'Verification, update & review safety', + pattern: /\b(verifier|verification|verify|probe|probes|debt|tbd|fixme|xxx|detect-custom-files|review|summary|blocker|critical)\b/i, + }, + { + title: 'State, planning & execution', + pattern: /\b(state|planning|planner|plan-phase|phase|roadmap|execute|executor|worktree|worktrees|resolve-model|init\.progress|model override|human_needed|ship preflight)\b/i, + }, + { + title: 'Install & runtime conversion', + pattern: /\b(install|installer|runtime|windows|powershell|codex|gemini|antigravity|hook|hooks|gsd-sdk|sdk readiness|cjs|model-catalog|path|shim)\b/i, + }, +]; + +const REMOVED_GROUPS = [ + { + title: 'Intel updater', + pattern: /\b(intel|gsd-intel-updater|layout detection)\b/i, + }, +]; + +function runGit(repo, args) { + return cp.execFileSync('git', args, { + cwd: repo, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function validateGitRef({ repo, ref, label }) { + if (typeof ref !== 'string' || ref.trim() !== ref || ref.length === 0) { + throw new Error(`Invalid git ref for ${label}: expected a non-empty trimmed string`); + } + if ( + ref.startsWith('-') || + ref.includes('..') || + ref.includes('//') || + !/^[A-Za-z0-9._/-]+$/.test(ref) + ) { + throw new Error(`Invalid git ref for ${label}: ${ref}`); + } + runGit(repo, ['rev-parse', '--verify', `${ref}^{commit}`]); + return ref; +} + +function changedFragmentPaths({ repo, fromRef, toRef }) { + const from = validateGitRef({ repo, ref: fromRef, label: 'fromRef' }); + const to = validateGitRef({ repo, ref: toRef, label: 'toRef' }); + const out = runGit(repo, ['diff', '--name-only', `${from}..${to}`, '--', '.changeset']); + return out + .split(/\r?\n/) + .filter(Boolean) + .filter((file) => /^\.changeset\/[^/]+\.md$/.test(file)); +} + +function readFileAtRef({ repo, ref, file }) { + return runGit(repo, ['show', `${ref}:${file}`]); +} + +function loadFragmentsFromRange({ repo, fromRef, toRef }) { + const files = changedFragmentPaths({ repo, fromRef, toRef }); + const fragments = []; + const failures = []; + + for (const file of files) { + try { + const src = readFileAtRef({ repo, ref: toRef, file }); + const parsed = parseFragment(src); + if (parsed.ok) { + fragments.push({ + ...parsed.fragment, + file, + slug: path.basename(file, '.md'), + }); + } else { + failures.push({ file, reason: parsed.reason, detail: parsed.detail || null }); + } + } catch (e) { + failures.push({ file, reason: 'read_failed', detail: e.message }); + } + } + + return { fragments, failures }; +} + +function classifyGroup(fragment) { + const haystack = `${fragment.slug || ''}\n${fragment.body || ''}`; + const groups = fragment.type === 'Removed' ? REMOVED_GROUPS : FIXED_GROUPS; + const match = groups.find((group) => group.pattern.test(haystack)); + if (match) return match.title; + if (fragment.type === 'Removed') return 'Removed'; + if (fragment.type === 'Fixed') return 'Other fixes'; + return fragment.type; +} + +function buildGithubReleaseNotesIr({ fragments }) { + const sections = []; + for (const type of SECTION_ORDER) { + const typed = fragments.filter((fragment) => fragment.type === type); + if (typed.length === 0) continue; + + const groupMap = new Map(); + for (const fragment of typed) { + const groupTitle = classifyGroup(fragment); + if (!groupMap.has(groupTitle)) groupMap.set(groupTitle, []); + groupMap.get(groupTitle).push(fragment); + } + + sections.push({ + type, + groups: Array.from(groupMap, ([title, bullets]) => ({ title, bullets })), + }); + } + return { sections }; +} + +function formatBullet(fragment) { + if (!Number.isInteger(fragment.pr) || fragment.pr <= 0) { + throw new Error(`Fragment ${fragment.slug || fragment.file || ''} missing valid pr field`); + } + const body = `${fragment.body.trim()} (#${fragment.pr})`; + const lines = body.split(/\r?\n/); + return lines.map((line, index) => (index === 0 ? `- ${line}` : ` ${line}`)).join('\n'); +} + +function compareUrl({ repoSlug, fromRef, toRef }) { + const normalizedSlug = String(repoSlug || '').trim(); + if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(normalizedSlug)) { + throw new Error(`Invalid repoSlug format: ${repoSlug} (expected "owner/repo")`); + } + return `https://github.com/${normalizedSlug}/compare/${fromRef}...${toRef}`; +} + +function serializeGithubReleaseNotes({ + ir, + fromRef, + toRef, + repoSlug = 'gsd-build/get-shit-done', + installCommand = 'npx get-shit-done-cc@latest', +}) { + if (installCommand.includes('`')) { + throw new Error('installCommand cannot contain backtick characters'); + } + const lines = []; + for (const section of ir.sections) { + lines.push(`## ${section.type}`); + lines.push(''); + for (const group of section.groups) { + lines.push(`### ${group.title}`); + for (const bullet of group.bullets) { + lines.push(formatBullet(bullet)); + } + lines.push(''); + } + } + lines.push('---'); + lines.push(''); + lines.push(`Install/upgrade: \`${installCommand}\``); + lines.push(''); + lines.push(`**Full Changelog**: ${compareUrl({ repoSlug, fromRef, toRef })}`); + lines.push(''); + return lines.join('\n'); +} + +function renderGithubReleaseNotes(options) { + const { fragments, failures } = loadFragmentsFromRange(options); + if (failures.length > 0) { + return { ok: false, fragments, failures, body: null }; + } + const ir = buildGithubReleaseNotesIr({ fragments }); + return { + ok: true, + fragments, + failures: [], + ir, + body: serializeGithubReleaseNotes({ ir, ...options }), + }; +} + +module.exports = { + changedFragmentPaths, + loadFragmentsFromRange, + buildGithubReleaseNotesIr, + serializeGithubReleaseNotes, + renderGithubReleaseNotes, + classifyGroup, + validateGitRef, +}; diff --git a/tests/changeset-github-release-notes.test.cjs b/tests/changeset-github-release-notes.test.cjs new file mode 100644 index 000000000..ad68eacba --- /dev/null +++ b/tests/changeset-github-release-notes.test.cjs @@ -0,0 +1,162 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'changeset', 'cli.cjs'); +const { + loadFragmentsFromRange, + buildGithubReleaseNotesIr, + serializeGithubReleaseNotes, + renderGithubReleaseNotes, +} = require(path.join(ROOT, 'scripts', 'changeset', 'github-release-notes.cjs')); + +function run(command, args, cwd) { + const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8' }); + assert.equal(result.status, 0, `${command} ${args.join(' ')}\nstdout=${result.stdout}\nstderr=${result.stderr}`); + return result.stdout; +} + +function writeFragment(repo, name, type, pr, body) { + const dir = path.join(repo, '.changeset'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${name}.md`), `---\ntype: ${type}\npr: ${pr}\n---\n${body}\n`); +} + +function createTaggedRepo() { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-release-notes-')); + run('git', ['init', '-q'], repo); + run('git', ['config', 'user.email', 'test@example.com'], repo); + run('git', ['config', 'user.name', 'Test User'], repo); + fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); + run('git', ['add', 'README.md'], repo); + run('git', ['commit', '-q', '-m', 'initial'], repo); + run('git', ['tag', 'v1.0.0'], repo); + + writeFragment(repo, 'fix-install-sdk', 'Fixed', 101, '**`gsd-sdk` now installs reliably** — persistent PATH is checked.'); + writeFragment(repo, 'remove-intel-noise', 'Removed', 102, '**`gsd-intel-updater` no longer emits layout detection noise** — ordinary projects stay quiet.'); + run('git', ['add', '.changeset'], repo); + run('git', ['commit', '-q', '-m', 'add changesets'], repo); + run('git', ['tag', 'v1.0.1'], repo); + return repo; +} + +describe('changeset github release notes: tag-range renderer (#3382)', () => { + test('loads changed changeset slugs from a git tag range', () => { + const repo = createTaggedRepo(); + const result = loadFragmentsFromRange({ repo, fromRef: 'v1.0.0', toRef: 'v1.0.1' }); + + assert.deepEqual(result.failures, []); + assert.deepEqual( + result.fragments.map((fragment) => ({ slug: fragment.slug, type: fragment.type, pr: fragment.pr })), + [ + { slug: 'fix-install-sdk', type: 'Fixed', pr: 101 }, + { slug: 'remove-intel-noise', type: 'Removed', pr: 102 }, + ], + ); + }); + + test('builds grouped GitHub release-note IR from parsed fragments', () => { + const repo = createTaggedRepo(); + const { fragments } = loadFragmentsFromRange({ repo, fromRef: 'v1.0.0', toRef: 'v1.0.1' }); + const ir = buildGithubReleaseNotesIr({ fragments }); + + assert.deepEqual( + ir.sections.map((section) => ({ + type: section.type, + groups: section.groups.map((group) => ({ title: group.title, prs: group.bullets.map((b) => b.pr) })), + })), + [ + { type: 'Fixed', groups: [{ title: 'Install & runtime conversion', prs: [101] }] }, + { type: 'Removed', groups: [{ title: 'Intel updater', prs: [102] }] }, + ], + ); + }); + + test('CLI writes a notes file suitable for gh release edit --notes-file', () => { + const repo = createTaggedRepo(); + const output = path.join(repo, 'release-notes.md'); + const result = cp.spawnSync( + process.execPath, + [ + SCRIPT, + 'github-release-notes', + '--repo', repo, + '--from', 'v1.0.0', + '--to', 'v1.0.1', + '--repo-slug', 'example/project', + '--output', output, + '--json', + ], + { encoding: 'utf8' }, + ); + + assert.equal(result.status, 0, `stdout=${result.stdout}\nstderr=${result.stderr}`); + const report = JSON.parse(result.stdout); + assert.deepEqual( + { consumed: report.consumed, output: report.output, hasBodyInJson: report.body !== null }, + { consumed: 2, output, hasBodyInJson: false }, + ); + + const generated = renderGithubReleaseNotes({ + repo, + fromRef: 'v1.0.0', + toRef: 'v1.0.1', + repoSlug: 'example/project', + installCommand: 'npx get-shit-done-cc@latest', + }); + assert.equal(fs.readFileSync(output, 'utf8'), generated.body); + }); + + test('rejects unsafe git refs before rendering a range', () => { + const repo = createTaggedRepo(); + assert.throws( + () => loadFragmentsFromRange({ repo, fromRef: '--help', toRef: 'v1.0.1' }), + /Invalid git ref/, + ); + }); + + test('validates PR metadata and repo slug before serializing release notes', () => { + assert.throws( + () => serializeGithubReleaseNotes({ + ir: { + sections: [ + { + type: 'Fixed', + groups: [{ title: 'Other fixes', bullets: [{ slug: 'missing-pr', body: 'missing pr' }] }], + }, + ], + }, + fromRef: 'v1.0.0', + toRef: 'v1.0.1', + }), + /missing valid pr field/, + ); + + assert.throws( + () => serializeGithubReleaseNotes({ + ir: { sections: [] }, + fromRef: 'v1.0.0', + toRef: 'v1.0.1', + repoSlug: 'owner/repo/extra', + }), + /Invalid repoSlug format/, + ); + + assert.throws( + () => serializeGithubReleaseNotes({ + ir: { sections: [] }, + fromRef: 'v1.0.0', + toRef: 'v1.0.1', + installCommand: 'echo `bad`', + }), + /installCommand cannot contain backtick/, + ); + }); +});