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
105 changes: 105 additions & 0 deletions src/utils/__tests__/changelog-extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
38 changes: 38 additions & 0 deletions src/utils/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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));
}
}

Expand Down Expand Up @@ -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.
}
}

Expand Down
Loading