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
5 changes: 5 additions & 0 deletions .bumpy/changelog-block-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': patch
---

Changelog entries now use a block layout when a summary is multi-line, long (>120 chars), or contains markdown block syntax (headings, lists, blockquotes, code fences, tables). In those cases the entry metadata (`*(type)*`, PR link, "Thanks @user!") goes on its own line and the summary is rendered indented below it, instead of being jammed onto the same line. Short single-line summaries are unchanged and stay inline. Internal blank lines in a summary are now preserved so markdown paragraphs and lists render correctly. Applies to both the default and `github` formatters.
33 changes: 23 additions & 10 deletions packages/bumpy/src/core/changelog-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { tryRunArgs } from '../utils/shell.ts';
import type { BumpType } from '../types.ts';
import { maxBump } from '../types.ts';
import type { ChangelogContext, ChangelogFormatter } from './changelog.ts';
import { getBumpTypeForPackage, sortBumpFilesByType } from './changelog.ts';
import { getBumpTypeForPackage, sortBumpFilesByType, summaryNeedsBlockLayout, trimBlankEdges } 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 @@ -72,8 +72,8 @@ export function createGithubFormatter(options: GithubChangelogOptions = {}): Cha
// 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);
const summaryLines = trimBlankEdges(cleanSummary.split('\n'));
const linkify = (s: string) => linkifyIssueRefs(s, serverUrl, repoSlug);

// Build the prefix: PR link, commit link, thanks
const { links, thanks } = formatPrefix(
Expand All @@ -85,15 +85,28 @@ export function createGithubFormatter(options: GithubChangelogOptions = {}): Cha
internalAuthorsSet,
);

// Assemble: links, tag, thanks, then summary
// Assemble metadata: links, tag, thanks
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)}`);
const meta = parts.join(' ');

if (summaryLines.length === 0) {
// Metadata only (e.g. summary was just `pr:`/`author:` lines)
if (hasMeta) lines.push(`- ${meta}`);
} else if (hasMeta && summaryNeedsBlockLayout(cleanSummary)) {
// Long/multiline/markdown summary reads poorly inline — put it on its own
// line(s) below the metadata.
lines.push(`- ${meta}`);
for (const sl of summaryLines) {
lines.push(sl.trim() ? ` ${linkify(sl)}` : '');
}
} else {
// Short single-line summary — keep it inline after the metadata
lines.push(`- ${meta}${hasMeta ? ' - ' : ''}${linkify(summaryLines[0]!)}`);
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${linkify(summaryLines[i]!)}`);
}
}
}
}
Expand Down
51 changes: 46 additions & 5 deletions packages/bumpy/src/core/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,42 @@ export function sortBumpFilesByType(bumpFiles: BumpFile[], packageName: string):
});
}

// ---- Summary layout ----

/** Max length of a single-line summary before it gets its own line below the entry metadata */
const INLINE_SUMMARY_MAX_LENGTH = 120;

/**
* Markdown block-level constructs that read poorly when jammed onto the same
* line as the entry metadata. Anchored to line starts so stray punctuation in
* prose (e.g. a `|` mid-sentence) doesn't trigger a false positive.
*/
const MARKDOWN_BLOCK_RE = /(?:^|\n)[ \t]*(?:#{1,6}[ \t]|[-*+][ \t]|\d+[.)][ \t]|>[ \t]?|```|~~~|\|)/;

/**
* Decide whether a summary should be rendered as a block — metadata on its own
* line with the summary indented below — rather than inline after the metadata
* (e.g. `- #50 (major) Thanks @user! - summary`).
*
* Inline reads well for a short single line; block reads better when the summary
* is multi-line, long, or contains markdown block syntax.
*/
export function summaryNeedsBlockLayout(summary: string): boolean {
const contentLines = summary.split('\n').filter((l) => l.trim());
if (contentLines.length > 1) return true;
if ((contentLines[0]?.length ?? 0) > INLINE_SUMMARY_MAX_LENGTH) return true;
return MARKDOWN_BLOCK_RE.test(summary);
}

/** Strip leading/trailing blank lines while preserving internal structure (e.g. paragraph breaks) */
export function trimBlankEdges(lines: string[]): string[] {
let start = 0;
let end = lines.length;
while (start < end && !lines[start]!.trim()) start++;
while (end > start && !lines[end - 1]!.trim()) end--;
return lines.slice(start, end);
}

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

/** Default formatter — version heading with date, bullet points sorted by bump type */
Expand All @@ -55,12 +91,17 @@ export const defaultFormatter: ChangelogFormatter = (ctx) => {
for (const bf of sorted) {
if (!bf.summary) continue;
const type = getBumpTypeForPackage(bf, release.name);
const summaryLines = bf.summary.split('\n');
lines.push(`- *(${type})* ${summaryLines[0]}`);
for (let i = 1; i < summaryLines.length; i++) {
if (summaryLines[i]!.trim()) {
lines.push(` ${summaryLines[i]}`);
const summaryLines = trimBlankEdges(bf.summary.split('\n'));
if (summaryLines.length === 0) continue;

if (summaryNeedsBlockLayout(bf.summary)) {
// Metadata on its own line; summary block indented below
lines.push(`- *(${type})*`);
for (const sl of summaryLines) {
lines.push(sl.trim() ? ` ${sl}` : '');
}
} else {
lines.push(`- *(${type})* ${summaryLines[0]}`);
}
}

Expand Down
53 changes: 53 additions & 0 deletions packages/bumpy/test/core/changelog-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,59 @@ describe('createGithubFormatter', () => {
expect(result).not.toContain('issues/123');
});

test('keeps short single-line summaries inline with the metadata', async () => {
addMockRule({ match: /^git log/, response: '' });

const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' });
const release = makeRelease('pkg-a', '1.0.1', { bumpFiles: ['cs1'] });
const bumpFiles = [makeBumpFile('cs1', [{ name: 'pkg-a', type: 'patch' }], 'Fixed the bug')];

const result = await formatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('*(patch)* - Fixed the bug');
// No indented continuation line for inline entries
expect(result).not.toContain('\n Fixed the bug');
});

test('uses block layout for multi-line summaries', async () => {
addMockRule({ match: /^git log/, response: '' });
addMockRule({
match: /gh pr view 42/,
response: JSON.stringify({
url: 'https://github.com/dmno-dev/bumpy/pull/42',
author: { login: 'contributor' },
mergeCommit: { oid: 'abc1234567890' },
}),
});

const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' });
const release = makeRelease('pkg-a', '1.0.1', { bumpFiles: ['cs1'] });
const bumpFiles = [
makeBumpFile('cs1', [{ name: 'pkg-a', type: 'patch' }], 'pr: #42\nFirst line\n\nSecond paragraph'),
];

const result = await formatter({ release, bumpFiles, date: '2026-04-14' });

// Metadata line ends with the PR/tag/thanks, NOT the summary
expect(result).toContain('Thanks [@contributor](https://github.com/contributor)!\n First line');
expect(result).not.toContain('! - First line');
// Internal blank line preserved between paragraphs
expect(result).toContain(' First line\n\n Second paragraph');
});

test('uses block layout for markdown summaries', async () => {
addMockRule({ match: /^git log/, response: '' });

const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' });
const release = makeRelease('pkg-a', '1.1.0', { type: 'minor', bumpFiles: ['cs1'] });
const bumpFiles = [makeBumpFile('cs1', [{ name: 'pkg-a', type: 'minor' }], '## Heading\nsome details')];

const result = await formatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('*(minor)*\n ## Heading');
expect(result).not.toContain('*(minor)* - ## Heading');
});

test('handles dependency bump with source packages', async () => {
const formatter = createGithubFormatter({ repo: 'dmno-dev/bumpy' });
const release = makeRelease('pkg-a', '1.0.1', {
Expand Down
34 changes: 33 additions & 1 deletion packages/bumpy/test/core/changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,40 @@ describe('defaultFormatter', () => {

const result = await defaultFormatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('- *(minor)* First line');
// Multi-line summaries use block layout: metadata on its own line, summary indented below
expect(result).toContain('- *(minor)*\n First line');
expect(result).toContain(' Second paragraph');
// Internal blank line (paragraph break) is preserved
expect(result).toContain(' First line\n\n Second paragraph');
});

test('keeps short single-line summaries inline', async () => {
const release = makeRelease('pkg-a', '1.1.0', { type: 'minor', bumpFiles: ['cs1'] });
const bumpFiles = [makeBumpFile('cs1', [{ name: 'pkg-a', type: 'minor' }], 'Added feature X')];

const result = await defaultFormatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('- *(minor)* Added feature X');
});

test('uses block layout for long single-line summaries', async () => {
const longSummary = `Fixed a subtle issue ${'that affected many users '.repeat(6)}`.trim();
const release = makeRelease('pkg-a', '1.0.1', { type: 'patch', bumpFiles: ['cs1'] });
const bumpFiles = [makeBumpFile('cs1', [{ name: 'pkg-a', type: 'patch' }], longSummary)];

const result = await defaultFormatter({ release, bumpFiles, date: '2026-04-14' });

expect(longSummary.length).toBeGreaterThan(120);
expect(result).toContain(`- *(patch)*\n ${longSummary}`);
});

test('uses block layout for markdown summaries', async () => {
const release = makeRelease('pkg-a', '1.1.0', { type: 'minor', bumpFiles: ['cs1'] });
const bumpFiles = [makeBumpFile('cs1', [{ name: 'pkg-a', type: 'minor' }], '# Big change')];

const result = await defaultFormatter({ release, bumpFiles, date: '2026-04-14' });

expect(result).toContain('- *(minor)*\n # Big change');
});

test('only includes bump files referenced by the release', async () => {
Expand Down