diff --git a/.bumpy/ci-check-comment-frog-images.md b/.bumpy/ci-check-comment-frog-images.md new file mode 100644 index 0000000..bfb5364 --- /dev/null +++ b/.bumpy/ci-check-comment-frog-images.md @@ -0,0 +1,12 @@ +--- +'@varlock/bumpy': patch +--- + +Rework CI check PR 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 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 35b66b6..1fff8fc 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); } } @@ -223,47 +238,114 @@ async function createVersionPr( // ---- PR comment helpers ---- -function formatReleasePlanComment(plan: ReleasePlan, changesetCount: number): string { +const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images'; + +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[] = []; - 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')], - ]; + const preamble = [ + `bumpy-frog`, + '', + '**The changes in this PR will be included in the next version bump.**', + '
', + ].join('\n'); + lines.push(preamble); + lines.push(''); - 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) { + // Package list grouped by bump type + 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(''); } + // 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 [ - '## 🐸 Bumpy Release Plan\n', - 'No changesets found in this PR. If this PR should trigger a release, run:\n', +function formatNoChangesetsComment(prBranch: string | null, pm: PackageManager): string { + const runCmd = pmRunCommand(pm); + const lines = [ + `bumpy-frog`, + '', + "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 FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images'; + 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 { // I think pixelated css gets stripped but may as well leave it @@ -288,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'); @@ -305,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, }); @@ -315,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'); @@ -328,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..7546a5c 100644 --- a/packages/bumpy/src/core/git.ts +++ b/packages/bumpy/src/core/git.ts @@ -39,6 +39,21 @@ 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[] { + // 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 }); + 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';