diff --git a/.bumpy/changelog-block-layout.md b/.bumpy/changelog-block-layout.md new file mode 100644 index 0000000..0dd7cf0 --- /dev/null +++ b/.bumpy/changelog-block-layout.md @@ -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. diff --git a/packages/bumpy/src/core/changelog-github.ts b/packages/bumpy/src/core/changelog-github.ts index f8e37de..c6aebb4 100644 --- a/packages/bumpy/src/core/changelog-github.ts +++ b/packages/bumpy/src/core/changelog-github.ts @@ -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) */ @@ -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( @@ -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]!)}`); + } } } } diff --git a/packages/bumpy/src/core/changelog.ts b/packages/bumpy/src/core/changelog.ts index 01b3c05..fd4564a 100644 --- a/packages/bumpy/src/core/changelog.ts +++ b/packages/bumpy/src/core/changelog.ts @@ -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 */ @@ -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]}`); } } diff --git a/packages/bumpy/test/core/changelog-github.test.ts b/packages/bumpy/test/core/changelog-github.test.ts index daa5718..8a585d0 100644 --- a/packages/bumpy/test/core/changelog-github.test.ts +++ b/packages/bumpy/test/core/changelog-github.test.ts @@ -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', { diff --git a/packages/bumpy/test/core/changelog.test.ts b/packages/bumpy/test/core/changelog.test.ts index 4f45386..97f292b 100644 --- a/packages/bumpy/test/core/changelog.test.ts +++ b/packages/bumpy/test/core/changelog.test.ts @@ -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 () => {