Skip to content

Commit 437cd10

Browse files
authored
Merge pull request #8 from dmno-dev/fix/ci-comment-frog-images
Fix PR comment and version PR description + related logic
2 parents d219d71 + 6561172 commit 437cd10

5 files changed

Lines changed: 179 additions & 50 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Rework CI check PR comment
6+
7+
- Restyle with frog images matching the version PR description
8+
- Filter to only changesets added/modified in the PR, not all pending changesets
9+
- Add links to view diff and edit each changeset file on GitHub
10+
- Add "click to add changeset" link for GitHub's file creation UI
11+
- Detect package manager for correct CLI instructions
12+
- Fix comment update using correct REST API numeric IDs and stdin flag

packages/bumpy/src/commands/check.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { log, colorize } from '../utils/logger.ts';
33
import { loadConfig } from '../core/config.ts';
44
import { discoverWorkspace } from '../core/workspace.ts';
55
import { readChangesets } from '../core/changeset.ts';
6-
import { tryRunArgs } from '../utils/shell.ts';
6+
import { getChangedFiles } from '../core/git.ts';
77
import type { WorkspacePackage } from '../types.ts';
88

99
/**
@@ -58,16 +58,6 @@ export async function checkCommand(rootDir: string): Promise<void> {
5858
process.exit(1);
5959
}
6060

61-
/** Get files changed on this branch compared to the base branch */
62-
function getChangedFiles(rootDir: string, baseBranch: string): string[] {
63-
// Try merge-base first (works on branches)
64-
const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir });
65-
const ref = mergeBase || `origin/${baseBranch}`;
66-
const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir });
67-
if (!diff) return [];
68-
return diff.split('\n').filter(Boolean);
69-
}
70-
7161
/** Map changed files to the packages they belong to */
7262
function findChangedPackages(
7363
changedFiles: string[],

packages/bumpy/src/commands/ci.ts

Lines changed: 150 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { loadConfig } from '../core/config.ts';
33
import { discoverWorkspace } from '../core/workspace.ts';
44
import { DependencyGraph } from '../core/dep-graph.ts';
55
import { readChangesets } from '../core/changeset.ts';
6+
import { getChangedFiles } from '../core/git.ts';
67
import { assembleReleasePlan } from '../core/release-plan.ts';
78
import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts';
8-
import type { BumpyConfig, ReleasePlan, PlannedRelease } from '../types.ts';
9+
import { randomName } from '../utils/names.ts';
10+
import { detectPackageManager } from '../utils/package-manager.ts';
11+
import type { BumpyConfig, Changeset, PackageManager, ReleasePlan, PlannedRelease } from '../types.ts';
912

1013
// ---- Validation helpers ----
1114

@@ -51,18 +54,29 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi
5154
const config = await loadConfig(rootDir);
5255
const { packages } = await discoverWorkspace(rootDir, config);
5356
const depGraph = new DependencyGraph(packages);
54-
const changesets = await readChangesets(rootDir);
57+
const allChangesets = await readChangesets(rootDir);
5558

5659
const inCI = !!process.env.CI;
5760
const shouldComment = opts.comment ?? inCI;
5861
const prNumber = detectPrNumber();
62+
const pm = await detectPackageManager(rootDir);
63+
64+
// Filter to only changesets added/modified in this PR
65+
const changedFiles = getChangedFiles(rootDir, config.baseBranch);
66+
const prChangesetIds = new Set(
67+
changedFiles
68+
.filter((f) => /^\.bumpy\/.*\.md$/.test(f) && !f.endsWith('README.md'))
69+
.map((f) => f.replace(/^\.bumpy\//, '').replace(/\.md$/, '')),
70+
);
71+
const prChangesets = allChangesets.filter((cs) => prChangesetIds.has(cs.id));
5972

60-
if (changesets.length === 0) {
73+
if (prChangesets.length === 0) {
6174
const msg = 'No changesets found in this PR.';
6275
log.info(msg);
6376

6477
if (shouldComment && prNumber) {
65-
await postOrUpdatePrComment(prNumber, formatNoChangesetsComment(), rootDir);
78+
const prBranch = detectPrBranch(rootDir);
79+
await postOrUpdatePrComment(prNumber, formatNoChangesetsComment(prBranch, pm), rootDir);
6680
}
6781

6882
if (opts.failOnMissing) {
@@ -71,18 +85,19 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi
7185
return;
7286
}
7387

74-
const plan = assembleReleasePlan(changesets, packages, depGraph, config);
88+
const plan = assembleReleasePlan(prChangesets, packages, depGraph, config);
7589

7690
// Pretty output for logs
77-
log.bold(`${changesets.length} changeset(s) → ${plan.releases.length} package(s) to release\n`);
91+
log.bold(`${prChangesets.length} changeset(s) → ${plan.releases.length} package(s) to release\n`);
7892
for (const r of plan.releases) {
7993
const tag = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : '';
8094
console.log(` ${r.name}: ${r.oldVersion}${colorize(r.newVersion, 'cyan')}${tag}`);
8195
}
8296

8397
// Comment on PR
8498
if (shouldComment && prNumber) {
85-
const comment = formatReleasePlanComment(plan, changesets.length);
99+
const prBranch = detectPrBranch(rootDir);
100+
const comment = formatReleasePlanComment(plan, prChangesets, prNumber, prBranch, pm);
86101
await postOrUpdatePrComment(prNumber, comment, rootDir);
87102
}
88103
}
@@ -223,47 +238,114 @@ async function createVersionPr(
223238

224239
// ---- PR comment helpers ----
225240

226-
function formatReleasePlanComment(plan: ReleasePlan, changesetCount: number): string {
241+
const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images';
242+
243+
function buildAddChangesetLink(prBranch: string | null): string | null {
244+
if (!prBranch) return null;
245+
const repo = process.env.GITHUB_REPOSITORY;
246+
if (!repo) return null;
247+
248+
const template = ['---', '"package-name": patch', '---', '', 'Description of the change', ''].join('\n');
249+
const filename = `.bumpy/${randomName()}.md`;
250+
return `https://github.com/${repo}/new/${prBranch}?filename=${encodeURIComponent(filename)}&value=${encodeURIComponent(template)}`;
251+
}
252+
253+
function pmRunCommand(pm: PackageManager): string {
254+
if (pm === 'bun') return 'bunx bumpy';
255+
if (pm === 'pnpm') return 'pnpm exec bumpy';
256+
if (pm === 'yarn') return 'yarn bumpy';
257+
return 'npx bumpy';
258+
}
259+
260+
function formatReleasePlanComment(
261+
plan: ReleasePlan,
262+
changesets: Changeset[],
263+
prNumber: string,
264+
prBranch: string | null,
265+
pm: PackageManager,
266+
): string {
267+
const repo = process.env.GITHUB_REPOSITORY;
227268
const lines: string[] = [];
228-
lines.push('## 🐸 Bumpy Release Plan\n');
229-
lines.push(`**${changesetCount}** changeset(s) → **${plan.releases.length}** package(s) to release\n`);
230269

231-
const groups: [string, PlannedRelease[]][] = [
232-
['🔴 Major', plan.releases.filter((r) => r.type === 'major')],
233-
['🟡 Minor', plan.releases.filter((r) => r.type === 'minor')],
234-
['🟢 Patch', plan.releases.filter((r) => r.type === 'patch')],
235-
];
270+
const preamble = [
271+
`<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>`,
272+
'',
273+
'**The changes in this PR will be included in the next version bump.**',
274+
'<br clear="left" />',
275+
].join('\n');
276+
lines.push(preamble);
277+
lines.push('');
236278

237-
for (const [label, group] of groups) {
238-
if (group.length === 0) continue;
239-
lines.push(`### ${label}\n`);
240-
lines.push('| Package | Change |');
241-
lines.push('|---------|--------|');
242-
for (const r of group) {
279+
// Package list grouped by bump type
280+
const groups: Record<string, PlannedRelease[]> = { major: [], minor: [], patch: [] };
281+
for (const r of plan.releases) {
282+
groups[r.type]?.push(r);
283+
}
284+
285+
for (const type of ['major', 'minor', 'patch'] as const) {
286+
const releases = groups[type];
287+
if (!releases || releases.length === 0) continue;
288+
289+
lines.push(bumpSectionHeader(type));
290+
lines.push('');
291+
for (const r of releases) {
243292
const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : '';
244-
lines.push(`| \`${r.name}\` | ${r.oldVersion} → **${r.newVersion}**${suffix} |`);
293+
lines.push(`- \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`);
245294
}
246295
lines.push('');
247296
}
248297

298+
// Changeset file list with links
299+
lines.push(`#### Changesets in this PR`);
300+
lines.push('');
301+
for (const cs of changesets) {
302+
const filename = `${cs.id}.md`;
303+
const parts: string[] = [`\`${filename}\``];
304+
if (repo) {
305+
parts.push(`([view diff](https://github.com/${repo}/pull/${prNumber}/files#diff-.bumpy/${filename}))`);
306+
if (prBranch) {
307+
parts.push(`([edit](https://github.com/${repo}/edit/${prBranch}/.bumpy/${filename}))`);
308+
}
309+
}
310+
lines.push(`- ${parts.join(' ')}`);
311+
}
312+
lines.push('');
313+
314+
const addLink = buildAddChangesetLink(prBranch);
315+
if (addLink) {
316+
lines.push(`[Click here if you want to add another changeset to this PR](${addLink})\n`);
317+
} else {
318+
lines.push(`To add another changeset, run \`${pmRunCommand(pm)} add\`\n`);
319+
}
320+
249321
lines.push('---');
250322
lines.push(`_This comment is maintained by [bumpy](${__BUMPY_WEBSITE_URL__})._`);
251323
return lines.join('\n');
252324
}
253325

254-
function formatNoChangesetsComment(): string {
255-
return [
256-
'## 🐸 Bumpy Release Plan\n',
257-
'No changesets found in this PR. If this PR should trigger a release, run:\n',
326+
function formatNoChangesetsComment(prBranch: string | null, pm: PackageManager): string {
327+
const runCmd = pmRunCommand(pm);
328+
const lines = [
329+
`<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>`,
330+
'',
331+
"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.**",
332+
'<br clear="left" />\n',
333+
'You can add a changeset by running:\n',
258334
'```bash',
259-
'bumpy add',
260-
'```\n',
261-
'---',
262-
`_This comment is maintained by [bumpy](${__BUMPY_WEBSITE_URL__})._`,
263-
].join('\n');
264-
}
335+
`${runCmd} add`,
336+
'```',
337+
];
265338

266-
const FROG_IMG_BASE = 'https://raw.githubusercontent.com/dmno-dev/bumpy/main/images';
339+
const addLink = buildAddChangesetLink(prBranch);
340+
if (addLink) {
341+
lines.push('');
342+
lines.push(`Or [click here to add a changeset](${addLink}) directly on GitHub.`);
343+
}
344+
345+
lines.push('\n---');
346+
lines.push(`_This comment is maintained by [bumpy](${__BUMPY_WEBSITE_URL__})._`);
347+
return lines.join('\n');
348+
}
267349

268350
function bumpSectionHeader(type: string): string {
269351
// I think pixelated css gets stripped but may as well leave it
@@ -288,10 +370,32 @@ function formatVersionPrBody(plan: ReleasePlan, preamble: string): string {
288370
lines.push(bumpSectionHeader(type));
289371
lines.push('');
290372
for (const r of releases) {
291-
const suffix = r.isDependencyBump ? ' (dep)' : r.isCascadeBump ? ' (cascade)' : '';
292-
lines.push(`- \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`);
373+
const suffix = r.isDependencyBump ? ' _(dep)_' : r.isCascadeBump ? ' _(cascade)_' : '';
374+
lines.push(`#### \`${r.name}\` ${r.oldVersion} → **${r.newVersion}**${suffix}`);
375+
lines.push('');
376+
377+
const relevantChangesets = plan.changesets.filter((cs) => r.changesets.includes(cs.id));
378+
379+
if (relevantChangesets.length > 0) {
380+
for (const cs of relevantChangesets) {
381+
if (cs.summary) {
382+
const summaryLines = cs.summary.split('\n');
383+
lines.push(`- ${summaryLines[0]}`);
384+
for (let i = 1; i < summaryLines.length; i++) {
385+
if (summaryLines[i]!.trim()) {
386+
lines.push(` ${summaryLines[i]}`);
387+
}
388+
}
389+
}
390+
}
391+
} else if (r.isDependencyBump) {
392+
lines.push('- Updated dependencies');
393+
} else if (r.isCascadeBump) {
394+
lines.push('- Version bump via cascade rule');
395+
}
396+
397+
lines.push('');
293398
}
294-
lines.push('');
295399
}
296400

297401
return lines.join('\n');
@@ -305,7 +409,7 @@ async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: st
305409

306410
try {
307411
// Find existing bumpy comment using gh with jq
308-
const jqFilter = `.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .id`;
412+
const jqFilter = `.comments[] | select(.body | startswith("${COMMENT_MARKER}")) | .url | capture("issuecomment-(?<id>[0-9]+)$") | .id`;
309413
const existingComment = tryRunArgs(['gh', 'pr', 'view', validPr, '--json', 'comments', '--jq', jqFilter], {
310414
cwd: rootDir,
311415
});
@@ -315,7 +419,7 @@ async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: st
315419

316420
if (commentId) {
317421
await runArgsAsync(
318-
['gh', 'api', `repos/{owner}/{repo}/issues/comments/${commentId}`, '-X', 'PATCH', '-f', 'body=@-'],
422+
['gh', 'api', `repos/{owner}/{repo}/issues/comments/${commentId}`, '-X', 'PATCH', '-F', 'body=@-'],
319423
{ cwd: rootDir, input: markedBody },
320424
);
321425
log.dim(' Updated PR comment');
@@ -328,6 +432,14 @@ async function postOrUpdatePrComment(prNumber: string, body: string, rootDir: st
328432
}
329433
}
330434

435+
function detectPrBranch(rootDir: string): string | null {
436+
// GitHub Actions sets GITHUB_HEAD_REF for pull_request events
437+
if (process.env.GITHUB_HEAD_REF) return process.env.GITHUB_HEAD_REF;
438+
// Fallback: ask gh for the PR head branch
439+
const branch = tryRunArgs(['gh', 'pr', 'view', '--json', 'headRefName', '--jq', '.headRefName'], { cwd: rootDir });
440+
return branch?.trim() || null;
441+
}
442+
331443
function detectPrNumber(): string | null {
332444
// GitHub Actions
333445
if (process.env.GITHUB_EVENT_NAME === 'pull_request') {

packages/bumpy/src/core/git.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,21 @@ export function tagExists(tag: string, opts?: { cwd?: string }): boolean {
3939
return tryRunArgs(['git', 'tag', '-l', tag], opts) === tag;
4040
}
4141

42+
/** Get files changed on this branch compared to a base branch */
43+
export function getChangedFiles(rootDir: string, baseBranch: string): string[] {
44+
// Ensure we have the base branch ref (may need fetching in shallow CI clones)
45+
if (!tryRunArgs(['git', 'rev-parse', '--verify', `origin/${baseBranch}`], { cwd: rootDir })) {
46+
tryRunArgs(['git', 'fetch', 'origin', baseBranch, '--depth=1'], { cwd: rootDir });
47+
}
48+
49+
// Try merge-base for the most accurate comparison
50+
const mergeBase = tryRunArgs(['git', 'merge-base', 'HEAD', `origin/${baseBranch}`], { cwd: rootDir });
51+
const ref = mergeBase || `origin/${baseBranch}`;
52+
const diff = tryRunArgs(['git', 'diff', '--name-only', ref], { cwd: rootDir });
53+
if (!diff) return [];
54+
return diff.split('\n').filter(Boolean);
55+
}
56+
4257
/** Get all tags matching a pattern */
4358
export function listTags(pattern: string, opts?: { cwd?: string }): string[] {
4459
const result = tryRunArgs(['git', 'tag', '-l', pattern], opts);

packages/bumpy/src/utils/package-manager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function detectWorkspaces(rootDir: string): Promise<WorkspaceInfo>
2020
return { packageManager: pm, globs, catalogs };
2121
}
2222

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

0 commit comments

Comments
 (0)