diff --git a/src/utils/__tests__/changelog-extract.test.ts b/src/utils/__tests__/changelog-extract.test.ts index 9eefebe9..ba119ca8 100644 --- a/src/utils/__tests__/changelog-extract.test.ts +++ b/src/utils/__tests__/changelog-extract.test.ts @@ -197,3 +197,108 @@ Intro paragraph: }); }); }); + +describe('extractChangelogEntry code blocks', () => { + it('preserves fenced code block inside a bullet item', () => { + const prBody = `### Changelog Entry + +- Added a new \`foo\` function: + \`\`\`go + func foo() { + fmt.Println("Hello") + } + \`\`\` +- Another entry`; + + const result = extractChangelogEntry(prBody); + expect(result).toHaveLength(2); + expect(result![0].text).toBe('Added a new `foo` function:'); + expect(result![0].nestedContent).toContain('```go'); + expect(result![0].nestedContent).toContain('func foo()'); + expect(result![1].text).toBe('Another entry'); + expect(result![1].nestedContent).toBeUndefined(); + }); + + it('preserves code block without language specifier', () => { + const prBody = `### Changelog Entry + +- Example code: + \`\`\` + some code here + \`\`\``; + + const result = extractChangelogEntry(prBody); + expect(result).toHaveLength(1); + expect(result![0].text).toBe('Example code:'); + expect(result![0].nestedContent).toContain('```'); + expect(result![0].nestedContent).toContain('some code here'); + // Should not have a language tag after the opening fence + expect(result![0].nestedContent).not.toMatch(/```\S/); + }); + + it('preserves standalone code block after paragraph', () => { + const prBody = `### Changelog Entry + +Here is a change with code: + +\`\`\`go +func foo() {} +\`\`\``; + + const result = extractChangelogEntry(prBody); + expect(result).toHaveLength(1); + expect(result![0].text).toBe('Here is a change with code:'); + expect(result![0].nestedContent).toContain('```go'); + expect(result![0].nestedContent).toContain('func foo() {}'); + // Verify 2-space indentation for proper nesting under list items + expect(result![0].nestedContent).toBe(' ```go\n func foo() {}\n ```'); + }); + + it('preserves code block in loose list item', () => { + const prBody = `### Changelog Entry + +- First entry with code: + + \`\`\`go + func foo() {} + \`\`\` + +- Second entry`; + + const result = extractChangelogEntry(prBody); + expect(result).toHaveLength(2); + expect(result![0].text).toBe('First entry with code:'); + expect(result![0].nestedContent).toContain('```go'); + expect(result![0].nestedContent).toContain('func foo() {}'); + expect(result![1].text).toBe('Second entry'); + }); + + it('preserves nested list items alongside code blocks', () => { + const prBody = `### Changelog Entry + +- Main entry + - Sub item + \`\`\`js + const x = 1; + \`\`\``; + + const result = extractChangelogEntry(prBody); + expect(result).toHaveLength(1); + expect(result![0].text).toBe('Main entry'); + expect(result![0].nestedContent).toContain('- Sub item'); + expect(result![0].nestedContent).toContain('```js'); + expect(result![0].nestedContent).toContain('const x = 1;'); + }); + + it('skips orphaned code block with no preceding entry', () => { + const prBody = `### Changelog Entry + +\`\`\`go +func foo() {} +\`\`\``; + + const result = extractChangelogEntry(prBody); + // A bare code block without descriptive text is not a meaningful entry + expect(result).toBeNull(); + }); +}); diff --git a/src/utils/changelog.ts b/src/utils/changelog.ts index e12c553a..cc8245bc 100644 --- a/src/utils/changelog.ts +++ b/src/utils/changelog.ts @@ -312,6 +312,23 @@ export function extractChangelogEntry( return parseTokensToEntries(contentTokens); } +/** + * Renders a fenced code block token as indented markdown lines. + * Each line (opening fence, content lines, closing fence) is prefixed with `indent`. + */ +function renderIndentedCodeBlock( + codeToken: Tokens.Code, + indent = ' ', +): string[] { + const fence = codeToken.lang ? `\`\`\`${codeToken.lang}` : '```'; + const lines = [`${indent}${fence}`]; + for (const line of codeToken.text.split('\n')) { + lines.push(`${indent}${line}`); + } + lines.push(`${indent}\`\`\``); + return lines; +} + /** * Recursively extracts nested content from a list item's tokens. */ @@ -337,6 +354,9 @@ function extractNestedContent(tokens: Token[]): string { nestedLines.push(indentedDeeper); } } + } else if (token.type === 'code') { + // Preserve fenced code blocks (e.g., ```go ... ```) as nested content + nestedLines.push(...renderIndentedCodeBlock(token as Tokens.Code)); } } @@ -390,6 +410,24 @@ function parseTokensToEntries(tokens: Token[]): ChangelogEntryItem[] | null { if (text) { entries.push({ text }); } + } else if (token.type === 'code') { + // Standalone code blocks become nested content on the previous entry, + // or a new entry if there is no previous entry. + // Indent with 2 spaces so the block nests properly under the list item + // in the final markdown (consistent with extractNestedContent). + const codeBlock = renderIndentedCodeBlock(token as Tokens.Code).join( + '\n', + ); + + if (entries.length > 0) { + // Attach to previous entry as nested content + const prev = entries[entries.length - 1]; + prev.nestedContent = prev.nestedContent + ? `${prev.nestedContent}\n${codeBlock}` + : codeBlock; + } + // If no previous entry exists, skip the orphaned code block — a bare + // code block without descriptive text isn't a meaningful changelog entry. } }