|
| 1 | +export const meta = { |
| 2 | + name: 'release-changelog', |
| 3 | + description: 'Investigate changes since the last release and write a new CHANGELOG.md release section', |
| 4 | + whenToUse: 'Cutting a release. Pass args {version, date?, lastTag?} — investigates every commit since the last tag and edits CHANGELOG.md in place.', |
| 5 | + phases: [ |
| 6 | + { title: 'Survey', detail: 'collect commits since the last release tag' }, |
| 7 | + { title: 'Investigate', detail: 'one agent per meaningful change → structured entry' }, |
| 8 | + { title: 'Write', detail: 'synthesize entries and edit CHANGELOG.md' }, |
| 9 | + ], |
| 10 | +} |
| 11 | + |
| 12 | +// ---- release knobs ---------------------------------------------------- |
| 13 | +// The `args` global is not delivered to scripts in this runtime, so the |
| 14 | +// release is parameterized by these consts. For a normal release leave them |
| 15 | +// as-is: the next version is auto-derived by bumping the requested segment of |
| 16 | +// the previous tag. For an arbitrary release, set VERSION_OVERRIDE. |
| 17 | +const VERSION_OVERRIDE = null // e.g. '0.1.0' to force an exact version; null = auto-bump |
| 18 | +const BUMP = 'patch' // 'patch' | 'minor' | 'major' — segment to increment when auto-bumping |
| 19 | +const LAST_TAG_OVERRIDE = null // e.g. 'v0.0.6'; null = `git describe --tags --abbrev=0` |
| 20 | +const lastTagHint = LAST_TAG_OVERRIDE |
| 21 | + |
| 22 | +function bump(tag, which) { |
| 23 | + const m = String(tag).match(/(\d+)\.(\d+)\.(\d+)/) |
| 24 | + if (!m) throw new Error(`cannot parse a semver from last tag "${tag}"`) |
| 25 | + let [maj, min, pat] = [Number(m[1]), Number(m[2]), Number(m[3])] |
| 26 | + if (which === 'major') { maj += 1; min = 0; pat = 0 } |
| 27 | + else if (which === 'minor') { min += 1; pat = 0 } |
| 28 | + else { pat += 1 } |
| 29 | + return `${maj}.${min}.${pat}` |
| 30 | +} |
| 31 | + |
| 32 | +// ---- schemas ---------------------------------------------------------- |
| 33 | +const SURVEY_SCHEMA = { |
| 34 | + type: 'object', |
| 35 | + additionalProperties: false, |
| 36 | + required: ['lastTag', 'repoUrl', 'today', 'commits'], |
| 37 | + properties: { |
| 38 | + lastTag: { type: 'string', description: 'The previous release tag, e.g. v0.0.6' }, |
| 39 | + repoUrl: { type: 'string', description: 'Canonical GitHub web URL, e.g. https://github.com/harmont-dev/harmont-cli' }, |
| 40 | + today: { type: 'string', description: "Today's date as YYYY-MM-DD" }, |
| 41 | + commits: { |
| 42 | + type: 'array', |
| 43 | + items: { |
| 44 | + type: 'object', |
| 45 | + additionalProperties: false, |
| 46 | + required: ['hash', 'author', 'subject', 'prNumber', 'meaningful'], |
| 47 | + properties: { |
| 48 | + hash: { type: 'string' }, |
| 49 | + author: { type: 'string' }, |
| 50 | + subject: { type: 'string' }, |
| 51 | + prNumber: { type: ['integer', 'null'] }, |
| 52 | + meaningful: { type: 'boolean', description: 'false for chore/CI/version-bump/merge noise that does not belong in a changelog' }, |
| 53 | + }, |
| 54 | + }, |
| 55 | + }, |
| 56 | + }, |
| 57 | +} |
| 58 | + |
| 59 | +const ENTRY_SCHEMA = { |
| 60 | + type: 'object', |
| 61 | + additionalProperties: false, |
| 62 | + required: ['skip', 'entries'], |
| 63 | + properties: { |
| 64 | + skip: { type: 'boolean', description: 'true if this commit is noise and should not appear in the changelog' }, |
| 65 | + entries: { |
| 66 | + type: 'array', |
| 67 | + description: 'One or more changelog bullets derived from this change (a PR may touch multiple categories)', |
| 68 | + items: { |
| 69 | + type: 'object', |
| 70 | + additionalProperties: false, |
| 71 | + required: ['category', 'area', 'breaking', 'text', 'refLabel', 'refUrl', 'externalAuthor'], |
| 72 | + properties: { |
| 73 | + category: { type: 'string', enum: ['Changed', 'Added', 'Removed', 'Fixed'] }, |
| 74 | + area: { type: ['string', 'null'], enum: ['CLI', 'DSL', 'SDK', null], description: 'Bold prefix; null if none fits' }, |
| 75 | + breaking: { type: 'boolean' }, |
| 76 | + text: { type: 'string', description: 'Bullet body only — no leading dash, no bold prefix, no ref link, no author' }, |
| 77 | + refLabel: { type: 'string', description: 'e.g. "#140" for a PR, or a 7-char commit hash' }, |
| 78 | + refUrl: { type: 'string', description: 'Full URL the ref points to (PR or commit)' }, |
| 79 | + externalAuthor: { type: ['string', 'null'], description: 'Display name in trailing parens, ONLY for non-maintainer contributors; null otherwise' }, |
| 80 | + }, |
| 81 | + }, |
| 82 | + }, |
| 83 | + }, |
| 84 | +} |
| 85 | + |
| 86 | +const WRITE_SCHEMA = { |
| 87 | + type: 'object', |
| 88 | + additionalProperties: false, |
| 89 | + required: ['sectionMarkdown', 'refsAdded'], |
| 90 | + properties: { |
| 91 | + sectionMarkdown: { type: 'string', description: 'The full new release section as written into the file' }, |
| 92 | + refsAdded: { type: 'array', items: { type: 'string' } }, |
| 93 | + }, |
| 94 | +} |
| 95 | + |
| 96 | +// ---- Survey ----------------------------------------------------------- |
| 97 | +phase('Survey') |
| 98 | +const survey = await agent( |
| 99 | + `You are surveying git history to prepare a CHANGELOG release.\n\n` + |
| 100 | + `Run these in the repo root:\n` + |
| 101 | + `1. Determine the previous release tag. ${lastTagHint ? `Use "${lastTagHint}".` : 'Run: git describe --tags --abbrev=0'}\n` + |
| 102 | + `2. git log <lastTag>..HEAD --format='%h | %an | %s'\n` + |
| 103 | + `3. git remote get-url origin (normalize ssh/scp form to an https web URL, strip trailing .git)\n` + |
| 104 | + `4. date +%Y-%m-%d → return as "today"\n\n` + |
| 105 | + `For each commit return hash, author, subject, prNumber (parse a trailing "(#NNN)" from the subject; null if none), ` + |
| 106 | + `and meaningful=false for changelog noise: "run ci", "auto-versioned ...", "bump version", merge commits, pure formatting/whitespace. ` + |
| 107 | + `Everything user- or developer-facing is meaningful=true.`, |
| 108 | + { schema: SURVEY_SCHEMA, label: 'survey', phase: 'Survey' } |
| 109 | +) |
| 110 | + |
| 111 | +const repoUrl = survey.repoUrl.replace(/\.git$/, '') |
| 112 | +const version = VERSION_OVERRIDE || bump(survey.lastTag, BUMP) |
| 113 | +const date = survey.today |
| 114 | +const meaningful = survey.commits.filter((c) => c.meaningful) |
| 115 | +log(`${survey.lastTag} → ${version} (${date}): ${survey.commits.length} commits, ${meaningful.length} meaningful`) |
| 116 | + |
| 117 | +// ---- Investigate ------------------------------------------------------ |
| 118 | +phase('Investigate') |
| 119 | +const investigated = await parallel( |
| 120 | + meaningful.map((c) => () => |
| 121 | + agent( |
| 122 | + `Investigate one change and produce changelog bullet(s) in Keep-a-Changelog style.\n\n` + |
| 123 | + `Commit: ${c.hash}\nSubject: ${c.subject}\nAuthor: ${c.author}\nPR: ${c.prNumber ? '#' + c.prNumber : 'none'}\n\n` + |
| 124 | + `Read the actual change:\n` + |
| 125 | + `- git show --stat ${c.hash}\n` + |
| 126 | + (c.prNumber ? `- gh pr view ${c.prNumber} --json title,author,body (use the PR body for the WHY)\n` : '') + |
| 127 | + `\nClassify into category (Changed/Added/Removed/Fixed), area (CLI/DSL/SDK or null), and breaking (bool). ` + |
| 128 | + `Write each bullet as a tight, user-facing sentence describing the OUTCOME, not the implementation. Past where it helps, name the new command/flag/API. ` + |
| 129 | + `Split into multiple entries only when a change genuinely spans categories (e.g. a feature that also removes dead behavior).\n\n` + |
| 130 | + `refLabel/refUrl: prefer the PR (${c.prNumber ? `#${c.prNumber} → ${repoUrl}/pull/${c.prNumber}` : `none — use commit ${c.hash} → ${repoUrl}/commit/${c.hash}`}).\n` + |
| 131 | + `externalAuthor: the contributor's display name ONLY if they are NOT the repo maintainer (maintainer = the dominant committer); else null.\n` + |
| 132 | + `If on reflection this change is pure noise, set skip=true with an empty entries array.`, |
| 133 | + { schema: ENTRY_SCHEMA, label: c.prNumber ? `#${c.prNumber}` : c.hash, phase: 'Investigate' } |
| 134 | + ) |
| 135 | + ) |
| 136 | +) |
| 137 | + |
| 138 | +const allEntries = investigated |
| 139 | + .filter(Boolean) |
| 140 | + .filter((r) => !r.skip) |
| 141 | + .flatMap((r) => r.entries) |
| 142 | + |
| 143 | +if (allEntries.length === 0) { |
| 144 | + log('No meaningful changes found — nothing to release.') |
| 145 | + return { version, lastTag: survey.lastTag, entries: [], note: 'empty' } |
| 146 | +} |
| 147 | + |
| 148 | +// ---- Write ------------------------------------------------------------ |
| 149 | +phase('Write') |
| 150 | +const ORDER = ['Changed', 'Added', 'Removed', 'Fixed'] |
| 151 | +const grouped = ORDER.map((cat) => ({ cat, items: allEntries.filter((e) => e.category === cat) })).filter((g) => g.items.length) |
| 152 | +const entriesJson = JSON.stringify(grouped, null, 2) |
| 153 | + |
| 154 | +const write = await agent( |
| 155 | + `Edit CHANGELOG.md to cut release ${version}${date ? ` dated ${date}` : ''}. Match the file's EXISTING formatting exactly — read it first.\n\n` + |
| 156 | + `Entries to write, already grouped and ordered (Changed, Added, Removed, Fixed):\n${entriesJson}\n\n` + |
| 157 | + `Repo web URL: ${repoUrl}\n\n` + |
| 158 | + `Rules, derived from the existing entries in the file:\n` + |
| 159 | + `- Each bullet: "- " then, if breaking, "**Breaking:** ", then if area set "**<AREA>:** ", then the text, then " (${'[refLabel][ref<n>]'} link)" as "([${'<refLabel>'}][${'<linkid>'}])", then if externalAuthor " (<name>)".\n` + |
| 160 | + ` Concretely a PR ref "#140" renders inline as "([#140][pr140])" and needs a link def "[pr140]: ${repoUrl}/pull/140". A commit ref like "1bf727e" renders "([\`1bf727e\`][c1bf727e])" with def "[c1bf727e]: ${repoUrl}/commit/1bf727e".\n` + |
| 161 | + `- Insert a new "## [${version}] - ${date || '<DATE>'}" section immediately BELOW the "## [Unreleased]" heading, leaving "## [Unreleased]" present and empty.\n` + |
| 162 | + `- Under it emit each non-empty category as "### <Category>" with its bullets, in the given order.\n` + |
| 163 | + `- Append the new link-reference definitions to the link-def block at the BOTTOM of the file, next to the existing ones. Do not duplicate a def that already exists.\n` + |
| 164 | + `- Do NOT touch existing released sections.\n\n` + |
| 165 | + `Apply the edit with the Edit tool, then return the new section markdown and the list of link defs you added.`, |
| 166 | + { schema: WRITE_SCHEMA, label: 'write-changelog', phase: 'Write' } |
| 167 | +) |
| 168 | + |
| 169 | +return { |
| 170 | + version, |
| 171 | + date, |
| 172 | + lastTag: survey.lastTag, |
| 173 | + entryCount: allEntries.length, |
| 174 | + section: write.sectionMarkdown, |
| 175 | + refsAdded: write.refsAdded, |
| 176 | +} |
0 commit comments