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
3 changes: 3 additions & 0 deletions .bumpy/clean-rich-deer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
---
'@varlock/bumpy': patch
---
93 changes: 50 additions & 43 deletions packages/bumpy/src/core/changelog-github.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { tryRunArgs } from '../utils/shell.ts';
import type { ChangelogContext, ChangelogFormatter } from './changelog.ts';
import { getBumpTypeForPackage, sortBumpFilesByType } from './changelog.ts';

/** Authors filtered from "Thanks" attribution by default (e.g. bots) */
/** Authors filtered from "Thanks" attribution by default (e.g. AI/automation bots) */
Expand Down Expand Up @@ -51,48 +52,53 @@ export function createGithubFormatter(options: GithubChangelogOptions = {}): Cha

const lines: string[] = [];
lines.push(`## ${release.newVersion}`);
lines.push('');
lines.push(`_${date}_`);
lines.push(`<sub>${date}</sub>`);
lines.push('');

const relevantBumpFiles = bumpFiles.filter((bf) => release.bumpFiles.includes(bf.id));

if (relevantBumpFiles.length > 0) {
for (const bf of relevantBumpFiles) {
if (!bf.summary) continue;

// Extract metadata overrides from summary (pr, commit, author lines)
const { cleanSummary, overrides } = extractSummaryMeta(bf.summary);

// Look up git/PR info, with overrides taking precedence
const gitInfo = resolveBumpFileInfo(bf.id, repoSlug, serverUrl, overrides);

const summaryLines = cleanSummary.split('\n');
const firstLine = linkifyIssueRefs(summaryLines[0]!, serverUrl, repoSlug);

// Build the prefix: PR link, commit link, thanks
const prefix = formatPrefix(
gitInfo,
serverUrl,
repoSlug,
includeCommitLink,
thankContributors,
internalAuthorsSet,
);

lines.push(`-${prefix ? ` ${prefix} -` : ''} ${firstLine}`);

// Include continuation lines
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${linkifyIssueRefs(summaryLines[i]!, serverUrl, repoSlug)}`);
}
const sorted = sortBumpFilesByType(relevantBumpFiles, release.name);

for (const bf of sorted) {
if (!bf.summary) continue;

const type = getBumpTypeForPackage(bf, release.name);
const tag = type !== release.type ? ` *(${type})*` : '';

// Extract metadata overrides from summary (pr, commit, author lines)
const { cleanSummary, overrides } = extractSummaryMeta(bf.summary);

// Look up git/PR info, with overrides taking precedence
const gitInfo = resolveBumpFileInfo(bf.id, repoSlug, serverUrl, overrides);

const summaryLines = cleanSummary.split('\n');
const firstLine = linkifyIssueRefs(summaryLines[0]!, serverUrl, repoSlug);

// Build the prefix: PR link, commit link, thanks
const { links, thanks } = formatPrefix(
gitInfo,
serverUrl,
repoSlug,
includeCommitLink,
thankContributors,
internalAuthorsSet,
);

// Assemble: links, tag, thanks, then summary
const parts = [links, tag, thanks].filter(Boolean);
const hasMeta = parts.length > 0;
lines.push(`- ${parts.join(' ')}${hasMeta ? ' - ' : ''}${firstLine}`);

// Include continuation lines
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${linkifyIssueRefs(summaryLines[i]!, serverUrl, repoSlug)}`);
}
}
}

if (release.isDependencyBump && relevantBumpFiles.length === 0) {
lines.push('- Updated dependencies');
if (release.isDependencyBump) {
const depTag = release.type !== 'patch' ? ` *(patch)* -` : '';
lines.push(`-${depTag} Updated dependencies`);
}

if (release.isCascadeBump && !release.isDependencyBump && relevantBumpFiles.length === 0) {
Expand Down Expand Up @@ -263,8 +269,8 @@ function findBumpFileCommitInfo(bumpFileId: string, repo?: string): BumpFileGitI
// ---- Formatting helpers ----

/**
* Build the prefix portion of a changelog line: PR link, commit link, thanks.
* Matches the format used by @changesets/changelog-github.
* Build the prefix portions of a changelog line, split into links and thanks
* so the bump type tag can be inserted between them.
*/
function formatPrefix(
info: BumpFileGitInfo,
Expand All @@ -273,23 +279,24 @@ function formatPrefix(
includeCommitLink: boolean,
thankContributors: boolean,
internalAuthors: Set<string>,
): string {
const parts: string[] = [];
): { links: string; thanks: string } {
const linkParts: string[] = [];

if (info.prNumber && info.prUrl) {
parts.push(`[#${info.prNumber}](${info.prUrl})`);
linkParts.push(`[#${info.prNumber}](${info.prUrl})`);
}

if (includeCommitLink && info.commitHash && repo) {
const short = info.commitHash.slice(0, 7);
parts.push(`[\`${short}\`](${serverUrl}/${repo}/commit/${info.commitHash})`);
linkParts.push(`[\`${short}\`](${serverUrl}/${repo}/commit/${info.commitHash})`);
}

let thanks = '';
if (thankContributors && info.author && !internalAuthors.has(info.author.toLowerCase())) {
parts.push(`Thanks [@${info.author}](${serverUrl}/${info.author})!`);
thanks = `Thanks [@${info.author}](${serverUrl}/${info.author})!`;
}

return parts.join(' ');
return { links: linkParts.join(' '), thanks };
}

/**
Expand Down
54 changes: 36 additions & 18 deletions packages/bumpy/src/core/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { resolve, relative } from 'node:path';
import { realpathSync } from 'node:fs';
import { log } from '../utils/logger.ts';
import type { BumpFile, PlannedRelease, BumpyConfig } from '../types.ts';
import type { BumpFile, BumpType, PlannedRelease, BumpyConfig } from '../types.ts';
import { BUMP_LEVELS } from '../types.ts';

// ---- Formatter interface ----

Expand All @@ -19,35 +20,52 @@ export interface ChangelogContext {
*/
export type ChangelogFormatter = (ctx: ChangelogContext) => string | Promise<string>;

// ---- Bump type helpers ----

/** Get the bump type a bump file applies to a specific package */
export function getBumpTypeForPackage(bf: BumpFile, packageName: string): BumpType {
const rel = bf.releases.find((r) => r.name === packageName);
return rel?.type === 'none' ? 'patch' : (rel?.type ?? 'patch');
}

/** Sort bump files by bump type for a specific package (major → minor → patch) */
export function sortBumpFilesByType(bumpFiles: BumpFile[], packageName: string): BumpFile[] {
return [...bumpFiles].sort((a, b) => {
const aLevel = BUMP_LEVELS[getBumpTypeForPackage(a, packageName)];
const bLevel = BUMP_LEVELS[getBumpTypeForPackage(b, packageName)];
return bLevel - aLevel;
});
}

// ---- Built-in formatters ----

/** Default formatter — version heading, date, bullet points */
/** Default formatter — version heading with date, bullet points sorted by bump type */
export const defaultFormatter: ChangelogFormatter = (ctx) => {
const { release, bumpFiles, date } = ctx;
const lines: string[] = [];
lines.push(`## ${release.newVersion}`);
lines.push('');
lines.push(`_${date}_`);
lines.push(`<sub>${date}</sub>`);
lines.push('');

const relevantBumpFiles = bumpFiles.filter((bf) => release.bumpFiles.includes(bf.id));

if (relevantBumpFiles.length > 0) {
for (const bf of relevantBumpFiles) {
if (bf.summary) {
const summaryLines = bf.summary.split('\n');
lines.push(`- ${summaryLines[0]}`);
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${summaryLines[i]}`);
}
}
const sorted = sortBumpFilesByType(relevantBumpFiles, release.name);

for (const bf of sorted) {
if (!bf.summary) continue;
const type = getBumpTypeForPackage(bf, release.name);
const tag = type !== release.type ? `*(${type})* ` : '';
const summaryLines = bf.summary.split('\n');
lines.push(`- ${tag}${summaryLines[0]}`);
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${summaryLines[i]}`);
}
}
}

if (release.isDependencyBump && relevantBumpFiles.length === 0) {
lines.push('- Updated dependencies');
if (release.isDependencyBump) {
const tag = release.type !== 'patch' ? `*(patch)* ` : '';
lines.push(`- ${tag}Updated dependencies`);
}

if (release.isCascadeBump && !release.isDependencyBump && relevantBumpFiles.length === 0) {
Expand Down Expand Up @@ -153,7 +171,7 @@ export function prependToChangelog(existingContent: string, newEntry: string): s
// Find the first ## after the # header
const afterTitle = existingContent.indexOf('\n##');
if (afterTitle !== -1) {
return existingContent.slice(0, afterTitle + 1) + '\n' + newEntry + existingContent.slice(afterTitle + 1);
return existingContent.slice(0, afterTitle + 1) + '\n' + newEntry + '\n' + existingContent.slice(afterTitle + 1);
}
// No existing entries, append after the title
return existingContent.trimEnd() + '\n\n' + newEntry;
Expand Down
2 changes: 1 addition & 1 deletion packages/bumpy/test/core/changelog-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('createGithubFormatter', () => {
const result = await formatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('## 1.1.0');
expect(result).toContain('_2026-04-14_');
expect(result).toContain('<sub>2026-04-14</sub>');
expect(result).toContain('Added feature X');
});

Expand Down
8 changes: 5 additions & 3 deletions packages/bumpy/test/core/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ describe('defaultFormatter', () => {
const result = await defaultFormatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('## 1.1.0');
expect(result).toContain('_2026-04-14_');
expect(result).toContain('<sub>2026-04-14</sub>');
expect(result).toContain('- Added new feature');
expect(result).toContain('- Fixed a bug');
expect(result).toContain('- *(patch)* Fixed a bug');
// Minor (matching release type, no tag) should come before patch
expect(result.indexOf('Added new feature')).toBeLessThan(result.indexOf('Fixed a bug'));
});

test('formats dependency bump with no bump files', async () => {
Expand Down Expand Up @@ -115,7 +117,7 @@ describe('generateChangelogEntry', () => {

const result = await generateChangelogEntry(release, [], undefined, '2020-01-01');

expect(result).toContain('_2020-01-01_');
expect(result).toContain('<sub>2020-01-01</sub>');
});
});

Expand Down