Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .bumpy/ci-check-comment-frog-images.md
Original file line number Diff line number Diff line change
@@ -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
12 changes: 1 addition & 11 deletions packages/bumpy/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -58,16 +58,6 @@ export async function checkCommand(rootDir: string): Promise<void> {
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[],
Expand Down
188 changes: 150 additions & 38 deletions packages/bumpy/src/commands/ci.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----

Expand Down Expand Up @@ -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) {
Expand All @@ -71,18 +85,19 @@ 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}`);
}

// 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);
}
}
Expand Down Expand Up @@ -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 = [
`<a href="${__BUMPY_WEBSITE_URL__}"><img src="${FROG_IMG_BASE}/frog-talking.png" alt="bumpy-frog" width="60" align="left" style="image-rendering: pixelated;" title="Hi! I'm bumpy!" /></a>`,
'',
'**The changes in this PR will be included in the next version bump.**',
'<br clear="left" />',
].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<string, PlannedRelease[]> = { 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 = [
`<a href="${__BUMPY_WEBSITE_URL__}"><img src="${FROG_IMG_BASE}/frog-neutral.png" alt="bumpy-frog" width="60" align="left" style="image-rendering: pixelated;" title="Hi! I'm bumpy!" /></a>`,
'',
"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.**",
'<br clear="left" />\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
Expand All @@ -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');
Expand All @@ -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-(?<id>[0-9]+)$") | .id`;
const existingComment = tryRunArgs(['gh', 'pr', 'view', validPr, '--json', 'comments', '--jq', jqFilter], {
cwd: rootDir,
});
Expand All @@ -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');
Expand All @@ -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') {
Expand Down
15 changes: 15 additions & 0 deletions packages/bumpy/src/core/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/bumpy/src/utils/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function detectWorkspaces(rootDir: string): Promise<WorkspaceInfo>
return { packageManager: pm, globs, catalogs };
}

async function detectPackageManager(rootDir: string): Promise<PackageManager> {
export async function detectPackageManager(rootDir: string): Promise<PackageManager> {
// Check lockfiles in priority order
if ((await exists(resolve(rootDir, 'bun.lock'))) || (await exists(resolve(rootDir, 'bun.lockb')))) {
return 'bun';
Expand Down