From e726f3a5683b7b6be375f4cb63e2dab29be72fcc Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 22:22:38 -0700 Subject: [PATCH 1/6] Restyle CI check PR comment to use frog images Co-Authored-By: Claude Opus 4.6 (1M context) --- .bumpy/ci-check-comment-frog-images.md | 10 ++++++ packages/bumpy/src/commands/ci.ts | 49 +++++++++++++++----------- 2 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 .bumpy/ci-check-comment-frog-images.md diff --git a/.bumpy/ci-check-comment-frog-images.md b/.bumpy/ci-check-comment-frog-images.md new file mode 100644 index 0000000..aacfaf1 --- /dev/null +++ b/.bumpy/ci-check-comment-frog-images.md @@ -0,0 +1,10 @@ +--- +'@varlock/bumpy': patch +--- + +Restyle CI check PR comment to use frog images matching the version PR description + +- Use `frog-talking.png` preamble in the release plan comment +- Use `bumpSectionHeader()` with frog images instead of emoji labels +- Switch from table format to bullet list for consistency with version PR +- Use `frog-neutral.png` in the no-changesets comment diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 35b66b6..6281a1b 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -223,25 +223,34 @@ async function createVersionPr( // ---- PR comment helpers ---- +const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images'; + function formatReleasePlanComment(plan: ReleasePlan, changesetCount: number): string { const lines: string[] = []; - lines.push('## 🐸 Bumpy Release Plan\n'); - lines.push(`**${changesetCount}** changeset(s) → **${plan.releases.length}** package(s) to release\n`); - - const groups: [string, PlannedRelease[]][] = [ - ['🔴 Major', plan.releases.filter((r) => r.type === 'major')], - ['🟡 Minor', plan.releases.filter((r) => r.type === 'minor')], - ['🟢 Patch', plan.releases.filter((r) => r.type === 'patch')], - ]; - - for (const [label, group] of groups) { - if (group.length === 0) continue; - lines.push(`### ${label}\n`); - lines.push('| Package | Change |'); - lines.push('|---------|--------|'); - for (const r of group) { + + const preamble = [ + `bumpy-frog`, + '', + `**${changesetCount}** changeset(s) → **${plan.releases.length}** package(s) to release`, + '
', + ].join('\n'); + lines.push(preamble); + lines.push(''); + + const groups: Record = { major: [], minor: [], patch: [] }; + for (const r of plan.releases) { + groups[r.type]?.push(r); + } + + for (const type of ['major', 'minor', 'patch'] as const) { + const releases = groups[type]; + if (!releases || releases.length === 0) continue; + + lines.push(bumpSectionHeader(type)); + lines.push(''); + for (const r of releases) { const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : ''; - lines.push(`| \`${r.name}\` | ${r.oldVersion} → **${r.newVersion}**${suffix} |`); + lines.push(`- \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`); } lines.push(''); } @@ -253,8 +262,10 @@ function formatReleasePlanComment(plan: ReleasePlan, changesetCount: number): st function formatNoChangesetsComment(): string { return [ - '## 🐸 Bumpy Release Plan\n', - 'No changesets found in this PR. If this PR should trigger a release, run:\n', + `bumpy-frog`, + '', + 'No changesets found in this PR. If this PR should trigger a release, run:', + '
\n', '```bash', 'bumpy add', '```\n', @@ -263,8 +274,6 @@ function formatNoChangesetsComment(): string { ].join('\n'); } -const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images'; - function bumpSectionHeader(type: string): string { // I think pixelated css gets stripped but may as well leave it const frog = `${type}`; From 3ee2c15236b822d8c6e6bc198655326c419c1ac6 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 23:07:43 -0700 Subject: [PATCH 2/6] Improve CI check PR comment with PR-scoped changesets and rich links - Filter changesets to only those added/modified in the PR - Show package list with frog image headers and changeset file links - Add view-diff and edit links for each changeset file - Add "click to add changeset" link for GitHub UI - Use detected package manager for CLI instructions - Extract getChangedFiles to shared git utility Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/bumpy/src/commands/check.ts | 12 +- packages/bumpy/src/commands/ci.ts | 143 +++++++++++++++++--- packages/bumpy/src/core/git.ts | 9 ++ packages/bumpy/src/utils/package-manager.ts | 2 +- 4 files changed, 134 insertions(+), 32 deletions(-) diff --git a/packages/bumpy/src/commands/check.ts b/packages/bumpy/src/commands/check.ts index eb120ac..e5a0b3a 100644 --- a/packages/bumpy/src/commands/check.ts +++ b/packages/bumpy/src/commands/check.ts @@ -3,7 +3,7 @@ import { log, colorize } from '../utils/logger.ts'; import { loadConfig } from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { readChangesets } from '../core/changeset.ts'; -import { tryRunArgs } from '../utils/shell.ts'; +import { getChangedFiles } from '../core/git.ts'; import type { WorkspacePackage } from '../types.ts'; /** @@ -58,16 +58,6 @@ export async function checkCommand(rootDir: string): Promise { process.exit(1); } -/** Get files changed on this branch compared to the base branch */ -function getChangedFiles(rootDir: string, baseBranch: string): string[] { - // Try merge-base first (works on branches) - const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir }); - const ref = mergeBase || `origin/${baseBranch}`; - const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir }); - if (!diff) return []; - return diff.split('\n').filter(Boolean); -} - /** Map changed files to the packages they belong to */ function findChangedPackages( changedFiles: string[], diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 6281a1b..e49949f 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -3,9 +3,12 @@ import { loadConfig } from '../core/config.ts'; import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; import { readChangesets } from '../core/changeset.ts'; +import { getChangedFiles } from '../core/git.ts'; import { assembleReleasePlan } from '../core/release-plan.ts'; import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts'; -import type { BumpyConfig, ReleasePlan, PlannedRelease } from '../types.ts'; +import { randomName } from '../utils/names.ts'; +import { detectPackageManager } from '../utils/package-manager.ts'; +import type { BumpyConfig, Changeset, PackageManager, ReleasePlan, PlannedRelease } from '../types.ts'; // ---- Validation helpers ---- @@ -51,18 +54,29 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi const config = await loadConfig(rootDir); const { packages } = await discoverWorkspace(rootDir, config); const depGraph = new DependencyGraph(packages); - const changesets = await readChangesets(rootDir); + const allChangesets = await readChangesets(rootDir); const inCI = !!process.env.CI; const shouldComment = opts.comment ?? inCI; const prNumber = detectPrNumber(); + const pm = await detectPackageManager(rootDir); + + // Filter to only changesets added/modified in this PR + const changedFiles = getChangedFiles(rootDir, config.baseBranch); + const prChangesetIds = new Set( + changedFiles + .filter((f) => /^\.bumpy\/.*\.md$/.test(f) && !f.endsWith('README.md')) + .map((f) => f.replace(/^\.bumpy\//, '').replace(/\.md$/, '')), + ); + const prChangesets = allChangesets.filter((cs) => prChangesetIds.has(cs.id)); - if (changesets.length === 0) { + if (prChangesets.length === 0) { const msg = 'No changesets found in this PR.'; log.info(msg); if (shouldComment && prNumber) { - await postOrUpdatePrComment(prNumber, formatNoChangesetsComment(), rootDir); + const prBranch = detectPrBranch(rootDir); + await postOrUpdatePrComment(prNumber, formatNoChangesetsComment(prBranch, pm), rootDir); } if (opts.failOnMissing) { @@ -71,10 +85,10 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi return; } - const plan = assembleReleasePlan(changesets, packages, depGraph, config); + const plan = assembleReleasePlan(prChangesets, packages, depGraph, config); // Pretty output for logs - log.bold(`${changesets.length} changeset(s) → ${plan.releases.length} package(s) to release\n`); + log.bold(`${prChangesets.length} changeset(s) → ${plan.releases.length} package(s) to release\n`); for (const r of plan.releases) { const tag = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : ''; console.log(` ${r.name}: ${r.oldVersion} → ${colorize(r.newVersion, 'cyan')}${tag}`); @@ -82,7 +96,8 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi // Comment on PR if (shouldComment && prNumber) { - const comment = formatReleasePlanComment(plan, changesets.length); + const prBranch = detectPrBranch(rootDir); + const comment = formatReleasePlanComment(plan, prChangesets, prNumber, prBranch, pm); await postOrUpdatePrComment(prNumber, comment, rootDir); } } @@ -225,18 +240,43 @@ async function createVersionPr( const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images'; -function formatReleasePlanComment(plan: ReleasePlan, changesetCount: number): string { +function buildAddChangesetLink(prBranch: string | null): string | null { + if (!prBranch) return null; + const repo = process.env.GITHUB_REPOSITORY; + if (!repo) return null; + + const template = ['---', '"package-name": patch', '---', '', 'Description of the change', ''].join('\n'); + const filename = `.bumpy/${randomName()}.md`; + return `https://github.com/${repo}/new/${prBranch}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(template)}`; +} + +function pmRunCommand(pm: PackageManager): string { + if (pm === 'bun') return 'bunx bumpy'; + if (pm === 'pnpm') return 'pnpm exec bumpy'; + if (pm === 'yarn') return 'yarn bumpy'; + return 'npx bumpy'; +} + +function formatReleasePlanComment( + plan: ReleasePlan, + changesets: Changeset[], + prNumber: string, + prBranch: string | null, + pm: PackageManager, +): string { + const repo = process.env.GITHUB_REPOSITORY; const lines: string[] = []; const preamble = [ `bumpy-frog`, '', - `**${changesetCount}** changeset(s) → **${plan.releases.length}** package(s) to release`, + '**The changes in this PR will be included in the next version bump.**', '
', ].join('\n'); lines.push(preamble); lines.push(''); + // Package list grouped by bump type const groups: Record = { major: [], minor: [], patch: [] }; for (const r of plan.releases) { groups[r.type]?.push(r); @@ -255,23 +295,56 @@ function formatReleasePlanComment(plan: ReleasePlan, changesetCount: number): st lines.push(''); } + // Changeset file list with links + lines.push(`#### Changesets in this PR`); + lines.push(''); + for (const cs of changesets) { + const filename = `${cs.id}.md`; + const parts: string[] = [`\`${filename}\``]; + if (repo) { + parts.push(`([view diff](https://github.com/${repo}/pull/${prNumber}/files#diff-.bumpy/${filename}))`); + if (prBranch) { + parts.push(`([edit](https://github.com/${repo}/edit/${prBranch}/.bumpy/${filename}))`); + } + } + lines.push(`- ${parts.join(' ')}`); + } + lines.push(''); + + const addLink = buildAddChangesetLink(prBranch); + if (addLink) { + lines.push(`[Click here if you want to add another changeset to this PR](${addLink})\n`); + } else { + lines.push(`To add another changeset, run \`${pmRunCommand(pm)} add\`\n`); + } + lines.push('---'); lines.push(`_This comment is maintained by [bumpy](${__BUMPY_WEBSITE_URL__})._`); return lines.join('\n'); } -function formatNoChangesetsComment(): string { - return [ +function formatNoChangesetsComment(prBranch: string | null, pm: PackageManager): string { + const runCmd = pmRunCommand(pm); + const lines = [ `bumpy-frog`, '', - 'No changesets found in this PR. If this PR should trigger a release, run:', + "Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**", '
\n', + 'You can add a changeset by running:\n', '```bash', - 'bumpy add', - '```\n', - '---', - `_This comment is maintained by [bumpy](${__BUMPY_WEBSITE_URL__})._`, - ].join('\n'); + `${runCmd} add`, + '```', + ]; + + const addLink = buildAddChangesetLink(prBranch); + if (addLink) { + lines.push(''); + lines.push(`Or [click here to add a changeset](${addLink}) directly on GitHub.`); + } + + lines.push('\n---'); + lines.push(`_This comment is maintained by [bumpy](${__BUMPY_WEBSITE_URL__})._`); + return lines.join('\n'); } function bumpSectionHeader(type: string): string { @@ -297,10 +370,32 @@ function formatVersionPrBody(plan: ReleasePlan, preamble: string): string { lines.push(bumpSectionHeader(type)); lines.push(''); for (const r of releases) { - const suffix = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : ''; - lines.push(`- \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`); + const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : ''; + lines.push(`#### \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`); + lines.push(''); + + const relevantChangesets = plan.changesets.filter((cs) => r.changesets.includes(cs.id)); + + if (relevantChangesets.length > 0) { + for (const cs of relevantChangesets) { + if (cs.summary) { + const summaryLines = cs.summary.split('\n'); + lines.push(`- ${summaryLines[0]}`); + for (let i = 1; i < summaryLines.length; i++) { + if (summaryLines[i]!.trim()) { + lines.push(` ${summaryLines[i]}`); + } + } + } + } + } else if (r.isDependencyBump) { + lines.push('- Updated dependencies'); + } else if (r.isCascadeBump) { + lines.push('- Version bump via cascade rule'); + } + + lines.push(''); } - lines.push(''); } return lines.join('\n'); @@ -337,6 +432,14 @@ async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: st } } +function detectPrBranch(rootDir: string): string | null { + // GitHub Actions sets GITHUB_HEAD_REF for pull_request events + if (process.env.GITHUB_HEAD_REF) return process.env.GITHUB_HEAD_REF; + // Fallback: ask gh for the PR head branch + const branch = tryRunArgs(['gh', 'pr', 'view', '--json', 'headRefName', '--jq', '.headRefName'], { cwd: rootDir }); + return branch?.trim() || null; +} + function detectPrNumber(): string | null { // GitHub Actions if (process.env.GITHUB_EVENT_NAME === 'pull_request') { diff --git a/packages/bumpy/src/core/git.ts b/packages/bumpy/src/core/git.ts index 05ff898..fea06bd 100644 --- a/packages/bumpy/src/core/git.ts +++ b/packages/bumpy/src/core/git.ts @@ -39,6 +39,15 @@ export function tagExists(tag: string, opts?: { cwd?: string }): boolean { return tryRunArgs(['git', 'tag', '-l', tag], opts) === tag; } +/** Get files changed on this branch compared to a base branch */ +export function getChangedFiles(rootDir: string, baseBranch: string): string[] { + const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir }); + const ref = mergeBase || `origin/${baseBranch}`; + const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir }); + if (!diff) return []; + return diff.split('\n').filter(Boolean); +} + /** Get all tags matching a pattern */ export function listTags(pattern: string, opts?: { cwd?: string }): string[] { const result = tryRunArgs(['git', 'tag', '-l', pattern], opts); diff --git a/packages/bumpy/src/utils/package-manager.ts b/packages/bumpy/src/utils/package-manager.ts index e2fc51e..0b2b4dc 100644 --- a/packages/bumpy/src/utils/package-manager.ts +++ b/packages/bumpy/src/utils/package-manager.ts @@ -20,7 +20,7 @@ export async function detectWorkspaces(rootDir: string): Promise return { packageManager: pm, globs, catalogs }; } -async function detectPackageManager(rootDir: string): Promise { +export async function detectPackageManager(rootDir: string): Promise { // Check lockfiles in priority order if ((await exists(resolve(rootDir, 'bun.lock'))) || (await exists(resolve(rootDir, 'bun.lockb')))) { return 'bun'; From 93a8404fc33176e204c56faa598372ff14e728e2 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 23:14:16 -0700 Subject: [PATCH 3/6] Fix PR comment update: extract numeric ID from comment URL The gh CLI returns GraphQL node IDs in comments JSON, but the REST API PATCH endpoint requires numeric IDs. Extract from the URL instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/bumpy/src/commands/ci.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index e49949f..316843e 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -409,7 +409,7 @@ async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: st try { // Find existing bumpy comment using gh with jq - const jqFilter = `.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .id`; + const jqFilter = `.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .url | capture("issuecomment-(?[0-9]+)$") | .id`; const existingComment = tryRunArgs(['gh', 'pr', 'view', validPr, '--json', 'comments', '--jq', jqFilter], { cwd: rootDir, }); From c21e1e71f0991f4145211df3ec92a1cd3be20e74 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 23:16:41 -0700 Subject: [PATCH 4/6] Fix PR comment update: use -F flag for stdin body in gh api -f treats @- as a literal string, -F reads from stdin. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/bumpy/src/commands/ci.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 316843e..1fff8fc 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -419,7 +419,7 @@ async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: st if (commentId) { await runArgsAsync( - ['gh', 'api', `repos/{owner}/{repo}/issues/comments/${commentId}`, '-X', 'PATCH', '-f', 'body=@-'], + ['gh', 'api', `repos/{owner}/{repo}/issues/comments/${commentId}`, '-X', 'PATCH', '-F', 'body=@-'], { cwd: rootDir, input: markedBody }, ); log.dim(' Updated PR comment'); From f4c60fee8b090c5f468be8a14ac550007465b3c1 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 23:19:43 -0700 Subject: [PATCH 5/6] Fix getChangedFiles for shallow CI clones Fetch origin base branch when not available, which happens in shallow clones created by actions/checkout with default fetch-depth: 1. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/bumpy/src/core/git.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/bumpy/src/core/git.ts b/packages/bumpy/src/core/git.ts index fea06bd..7546a5c 100644 --- a/packages/bumpy/src/core/git.ts +++ b/packages/bumpy/src/core/git.ts @@ -41,6 +41,12 @@ export function tagExists(tag: string, opts?: { cwd?: string }): boolean { /** Get files changed on this branch compared to a base branch */ export function getChangedFiles(rootDir: string, baseBranch: string): string[] { + // Ensure we have the base branch ref (may need fetching in shallow CI clones) + if (!tryRunArgs(['git', 'rev-parse', '--verify', `origin/${baseBranch}`], { cwd: rootDir })) { + tryRunArgs(['git', 'fetch', 'origin', baseBranch, '--depth=1'], { cwd: rootDir }); + } + + // Try merge-base for the most accurate comparison const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir }); const ref = mergeBase || `origin/${baseBranch}`; const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir }); From 6561172b0bdd080eca5fb8062c32ef8bdaed61f8 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 14 Apr 2026 23:22:08 -0700 Subject: [PATCH 6/6] Update changeset description Co-Authored-By: Claude Opus 4.6 (1M context) --- .bumpy/ci-check-comment-frog-images.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.bumpy/ci-check-comment-frog-images.md b/.bumpy/ci-check-comment-frog-images.md index aacfaf1..bfb5364 100644 --- a/.bumpy/ci-check-comment-frog-images.md +++ b/.bumpy/ci-check-comment-frog-images.md @@ -2,9 +2,11 @@ '@varlock/bumpy': patch --- -Restyle CI check PR comment to use frog images matching the version PR description +Rework CI check PR comment -- Use `frog-talking.png` preamble in the release plan comment -- Use `bumpSectionHeader()` with frog images instead of emoji labels -- Switch from table format to bullet list for consistency with version PR -- Use `frog-neutral.png` in the no-changesets comment +- Restyle with frog images matching the version PR description +- Filter to only changesets added/modified in the PR, not all pending changesets +- Add links to view diff and edit each changeset file on GitHub +- Add "click to add changeset" link for GitHub's file creation UI +- Detect package manager for correct CLI instructions +- Fix comment update using correct REST API numeric IDs and stdin flag