From 5e5f5d8d1cad2bed30d77ac7e8f21d3d2a103f66 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 19 Sep 2025 15:00:01 +0200 Subject: [PATCH 1/3] feat: Danger - Add inline changelog suggestions for GitHub PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements GitHub Issue #45 by replacing generic markdown instructions with inline GitHub suggestions that users can apply with one click. Changes: - Add findChangelogInsertionPoint() to parse CHANGELOG.md structure - Add generateChangelogSuggestion() to create formatted suggestions - Update reportMissingChangelog() to use GitHub PR review comments API - Maintain backward compatibility with fallback to markdown instructions - Add comprehensive test suite (34 tests) covering edge cases Users will now see precise inline suggestions instead of copy-paste instructions, improving developer experience for changelog management. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- danger/dangerfile-utils.js | 89 +++++++++++- danger/dangerfile-utils.test.js | 238 +++++++++++++++++++++++++++++++- danger/dangerfile.js | 57 +++++++- 3 files changed, 375 insertions(+), 9 deletions(-) diff --git a/danger/dangerfile-utils.js b/danger/dangerfile-utils.js index daaed771..58752a47 100644 --- a/danger/dangerfile-utils.js +++ b/danger/dangerfile-utils.js @@ -86,8 +86,95 @@ function extractPRFlavor(prTitle, prBranchRef) { return ""; } +/// Find insertion point for changelog entry in a specific section +function findChangelogInsertionPoint(changelogContent, sectionName) { + const lines = changelogContent.split('\n'); + + // Find "## Unreleased" section + let unreleasedIndex = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().match(/^##\s+Unreleased/i)) { + unreleasedIndex = i; + break; + } + } + + if (unreleasedIndex === -1) { + return null; // No Unreleased section found + } + + // Find the target subsection (e.g., "### Features") + let sectionIndex = -1; + for (let i = unreleasedIndex + 1; i < lines.length; i++) { + // Stop if we hit another main section (##) + if (lines[i].trim().match(/^##\s+/)) { + break; + } + + // Check for our target subsection + if (lines[i].trim().match(new RegExp(`^###\\s+${sectionName}`, 'i'))) { + sectionIndex = i; + break; + } + } + + if (sectionIndex === -1) { + // Section doesn't exist, we need to create it + // Find insertion point after "## Unreleased" + let insertAfter = unreleasedIndex; + + // Skip empty lines after "## Unreleased" + while (insertAfter + 1 < lines.length && lines[insertAfter + 1].trim() === '') { + insertAfter++; + } + + return { + lineNumber: insertAfter + 1, // 1-indexed for GitHub API + createSection: true, + sectionName: sectionName + }; + } + + // Section exists, find first bullet point or insertion point + let insertionPoint = sectionIndex + 1; + + // Skip empty lines after section header + while (insertionPoint < lines.length && lines[insertionPoint].trim() === '') { + insertionPoint++; + } + + // If next line is a bullet point, insert before it + // If it's another section or end of file, insert here + return { + lineNumber: insertionPoint + 1, // 1-indexed for GitHub API + createSection: false + }; +} + +/// Generate suggestion text for changelog entry +function generateChangelogSuggestion(prTitle, prNumber, prUrl, sectionName, insertionInfo) { + // Clean up PR title (remove conventional commit prefix if present) + const cleanTitle = prTitle + .split(": ") + .slice(-1)[0] + .trim() + .replace(/\.+$/, ""); + + const bulletPoint = `- ${cleanTitle} ([#${prNumber}](${prUrl}))`; + + if (insertionInfo.createSection) { + // Need to create the section + return `\n### ${sectionName}\n\n${bulletPoint}`; + } else { + // Just add the bullet point + return bulletPoint; + } +} + module.exports = { FLAVOR_CONFIG, getFlavorConfig, - extractPRFlavor + extractPRFlavor, + findChangelogInsertionPoint, + generateChangelogSuggestion }; diff --git a/danger/dangerfile-utils.test.js b/danger/dangerfile-utils.test.js index cfd1fe1a..954d0474 100644 --- a/danger/dangerfile-utils.test.js +++ b/danger/dangerfile-utils.test.js @@ -1,6 +1,6 @@ const { describe, it } = require('node:test'); const assert = require('node:assert'); -const { getFlavorConfig, extractPRFlavor, FLAVOR_CONFIG } = require('./dangerfile-utils.js'); +const { getFlavorConfig, extractPRFlavor, FLAVOR_CONFIG, findChangelogInsertionPoint, generateChangelogSuggestion } = require('./dangerfile-utils.js'); describe('dangerfile-utils', () => { describe('getFlavorConfig', () => { @@ -275,4 +275,240 @@ describe('dangerfile-utils', () => { }); }); }); + + describe('findChangelogInsertionPoint', () => { + it('should find insertion point for existing Features section', () => { + const changelog = `# Changelog + +## Unreleased + +### Features + +- Existing feature ([#100](url)) + +### Fixes + +- Existing fix ([#99](url)) + +## 1.0.0 + +Released content`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 7, // Before "- Existing feature" + createSection: false + }); + }); + + it('should find insertion point when Features section exists but is empty', () => { + const changelog = `# Changelog + +## Unreleased + +### Features + +### Fixes + +- Existing fix ([#99](url))`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 7, // Right after "### Features" and empty line + createSection: false + }); + }); + + it('should create section when Features section does not exist', () => { + const changelog = `# Changelog + +## Unreleased + +### Fixes + +- Existing fix ([#99](url))`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 4, // Right after "## Unreleased" + createSection: true, + sectionName: 'Features' + }); + }); + + it('should handle changelog with only Unreleased section', () => { + const changelog = `# Changelog + +## Unreleased + +## 1.0.0 + +Released content`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 4, // Right after "## Unreleased" + createSection: true, + sectionName: 'Features' + }); + }); + + it('should return null when no Unreleased section found', () => { + const changelog = `# Changelog + +## 1.0.0 + +Released content`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.strictEqual(result, null); + }); + + it('should handle case-insensitive Unreleased section', () => { + const changelog = `# Changelog + +## unreleased + +### Features + +- Existing feature ([#100](url))`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 7, + createSection: false + }); + }); + + it('should handle different section names', () => { + const changelog = `# Changelog + +## Unreleased + +### Security + +- Security fix ([#101](url))`; + + const result = findChangelogInsertionPoint(changelog, 'Fixes'); + assert.deepStrictEqual(result, { + lineNumber: 4, // After "## Unreleased" + createSection: true, + sectionName: 'Fixes' + }); + }); + + it('should handle extra whitespace around sections', () => { + const changelog = `# Changelog + +## Unreleased + + ### Features + + - Existing feature ([#100](url))`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 7, // Before " - Existing feature" + createSection: false + }); + }); + }); + + describe('generateChangelogSuggestion', () => { + it('should generate bullet point for existing section', () => { + const insertionInfo = { lineNumber: 7, createSection: false }; + const result = generateChangelogSuggestion( + 'feat: add new feature', + 123, + 'https://github.com/repo/pull/123', + 'Features', + insertionInfo + ); + + assert.strictEqual(result, '- add new feature ([#123](https://github.com/repo/pull/123))'); + }); + + it('should generate section with bullet point for new section', () => { + const insertionInfo = { lineNumber: 4, createSection: true, sectionName: 'Features' }; + const result = generateChangelogSuggestion( + 'feat: add new feature', + 123, + 'https://github.com/repo/pull/123', + 'Features', + insertionInfo + ); + + assert.strictEqual(result, '\n### Features\n\n- add new feature ([#123](https://github.com/repo/pull/123))'); + }); + + it('should clean up PR title by removing conventional commit prefix', () => { + const insertionInfo = { lineNumber: 7, createSection: false }; + + const result1 = generateChangelogSuggestion( + 'feat(auth): add OAuth support', + 123, + 'url', + 'Features', + insertionInfo + ); + assert.strictEqual(result1, '- add OAuth support ([#123](url))'); + + const result2 = generateChangelogSuggestion( + 'fix: resolve memory leak', + 124, + 'url', + 'Fixes', + insertionInfo + ); + assert.strictEqual(result2, '- resolve memory leak ([#124](url))'); + }); + + it('should handle non-conventional PR titles', () => { + const insertionInfo = { lineNumber: 7, createSection: false }; + + const result = generateChangelogSuggestion( + 'Fix memory leak in authentication', + 125, + 'url', + 'Fixes', + insertionInfo + ); + assert.strictEqual(result, '- Fix memory leak in authentication ([#125](url))'); + }); + + it('should remove trailing periods from title', () => { + const insertionInfo = { lineNumber: 7, createSection: false }; + + const result = generateChangelogSuggestion( + 'feat: add new feature...', + 126, + 'url', + 'Features', + insertionInfo + ); + assert.strictEqual(result, '- add new feature ([#126](url))'); + }); + + it('should handle various section names', () => { + const insertionInfo = { lineNumber: 4, createSection: true }; + + const securityResult = generateChangelogSuggestion( + 'sec: fix vulnerability', + 127, + 'url', + 'Security', + insertionInfo + ); + assert.strictEqual(securityResult, '\n### Security\n\n- fix vulnerability ([#127](url))'); + + const perfResult = generateChangelogSuggestion( + 'perf: optimize queries', + 128, + 'url', + 'Performance', + insertionInfo + ); + assert.strictEqual(perfResult, '\n### Performance\n\n- optimize queries ([#128](url))'); + }); + }); }); \ No newline at end of file diff --git a/danger/dangerfile.js b/danger/dangerfile.js index 997a9c07..00b0be9e 100644 --- a/danger/dangerfile.js +++ b/danger/dangerfile.js @@ -1,4 +1,4 @@ -const { getFlavorConfig, extractPRFlavor } = require('./dangerfile-utils.js'); +const { getFlavorConfig, extractPRFlavor, findChangelogInsertionPoint, generateChangelogSuggestion } = require('./dangerfile-utils.js'); const headRepoName = danger.github.pr.head.repo.git_url; const baseRepoName = danger.github.pr.base.repo.git_url; @@ -95,20 +95,63 @@ async function checkChangelog() { } -/// Report missing changelog entry -function reportMissingChangelog(changelogFile) { +/// Report missing changelog entry with inline suggestion +async function reportMissingChangelog(changelogFile) { fail("Please consider adding a changelog entry for the next release.", changelogFile); + // Determine the appropriate section based on PR flavor + const flavorConfig = getFlavorConfig(prFlavor); + const sectionName = flavorConfig.changelog || "Features"; + + try { + // Get changelog content + const changelogContent = await danger.github.utils.fileContents(changelogFile); + + // Find insertion point + const insertionInfo = findChangelogInsertionPoint(changelogContent, sectionName); + + if (insertionInfo) { + // Generate suggestion text + const suggestionText = generateChangelogSuggestion( + danger.github.pr.title, + danger.github.pr.number, + danger.github.pr.html_url, + sectionName, + insertionInfo + ); + + // Create GitHub suggestion comment + await danger.github.api.rest.pulls.createReviewComment({ + owner: danger.github.pr.base.repo.owner.login, + repo: danger.github.pr.base.repo.name, + pull_number: danger.github.pr.number, + body: `\`\`\`suggestion\n${suggestionText}\n\`\`\``, + commit_id: danger.github.pr.head.sha, + path: changelogFile, + line: insertionInfo.lineNumber, + side: "RIGHT" + }); + + message(`💡 I've suggested a changelog entry above. Click "Apply suggestion" to add it!`); + } else { + // Fallback to markdown instructions if parsing fails + showMarkdownInstructions(changelogFile, sectionName); + } + } catch (error) { + console.log(`::warning::Failed to create inline suggestion: ${error.message}`); + // Fallback to markdown instructions + showMarkdownInstructions(changelogFile, sectionName); + } +} + +/// Fallback function to show markdown instructions +function showMarkdownInstructions(changelogFile, sectionName) { const prTitleFormatted = danger.github.pr.title .split(": ") .slice(-1)[0] .trim() .replace(/\.+$/, ""); - // Determine the appropriate section based on PR flavor - const flavorConfig = getFlavorConfig(prFlavor); - const sectionName = flavorConfig.changelog || "Features"; - markdown( ` ### Instructions and example for changelog From c4e607f8221d445f33bdfeb8d09439dfae58b759 Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 19 Sep 2025 15:07:55 +0200 Subject: [PATCH 2/3] refactor: Handle missing UNRELEASED sections and improve flexibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhanced changelog insertion logic to handle all possible scenarios: - **No UNRELEASED section**: Creates full "## Unreleased\n### Section\n- Entry" - **UNRELEASED but no subsection**: Creates "### Section\n- Entry" - **Both exist**: Just adds "- Entry" Changes: - Refactored findChangelogInsertionPoint() to return insertContent type - Updated generateChangelogSuggestion() with switch statement for content types - Added 8 new edge case tests (43 total tests, all passing) - Handles empty changelogs, missing sections, mixed case headers This makes the feature work with any changelog state, not just existing structured changelogs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- danger/dangerfile-utils.js | 70 +++++++++---- danger/dangerfile-utils.test.js | 172 ++++++++++++++++++++++++++++---- 2 files changed, 204 insertions(+), 38 deletions(-) diff --git a/danger/dangerfile-utils.js b/danger/dangerfile-utils.js index 58752a47..6f755a66 100644 --- a/danger/dangerfile-utils.js +++ b/danger/dangerfile-utils.js @@ -86,7 +86,7 @@ function extractPRFlavor(prTitle, prBranchRef) { return ""; } -/// Find insertion point for changelog entry in a specific section +/// Find insertion point and determine what content needs to be inserted function findChangelogInsertionPoint(changelogContent, sectionName) { const lines = changelogContent.split('\n'); @@ -99,15 +99,38 @@ function findChangelogInsertionPoint(changelogContent, sectionName) { } } + // Case 1: No Unreleased section exists if (unreleasedIndex === -1) { - return null; // No Unreleased section found + // Find first ## section or top of changelog to insert before it + let insertionPoint = 0; + + // Skip title and initial content, look for first version section + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().match(/^##\s+/)) { + insertionPoint = i; + break; + } + } + + // If no version sections exist, insert at end + if (insertionPoint === 0) { + insertionPoint = lines.length; + } + + return { + lineNumber: insertionPoint + 1, // 1-indexed for GitHub API + insertContent: 'unreleased-and-section' + }; } - // Find the target subsection (e.g., "### Features") + // Case 2: Unreleased section exists, find the target subsection let sectionIndex = -1; + let nextSectionIndex = lines.length; // End of file by default + for (let i = unreleasedIndex + 1; i < lines.length; i++) { // Stop if we hit another main section (##) if (lines[i].trim().match(/^##\s+/)) { + nextSectionIndex = i; break; } @@ -118,40 +141,37 @@ function findChangelogInsertionPoint(changelogContent, sectionName) { } } + // Case 3: Subsection doesn't exist, need to create it within Unreleased if (sectionIndex === -1) { - // Section doesn't exist, we need to create it - // Find insertion point after "## Unreleased" + // Find insertion point after "## Unreleased" but before next main section let insertAfter = unreleasedIndex; // Skip empty lines after "## Unreleased" - while (insertAfter + 1 < lines.length && lines[insertAfter + 1].trim() === '') { + while (insertAfter + 1 < nextSectionIndex && lines[insertAfter + 1].trim() === '') { insertAfter++; } return { lineNumber: insertAfter + 1, // 1-indexed for GitHub API - createSection: true, - sectionName: sectionName + insertContent: 'section-and-entry' }; } - // Section exists, find first bullet point or insertion point + // Case 4: Both Unreleased and subsection exist, just add entry let insertionPoint = sectionIndex + 1; // Skip empty lines after section header - while (insertionPoint < lines.length && lines[insertionPoint].trim() === '') { + while (insertionPoint < nextSectionIndex && lines[insertionPoint].trim() === '') { insertionPoint++; } - // If next line is a bullet point, insert before it - // If it's another section or end of file, insert here return { lineNumber: insertionPoint + 1, // 1-indexed for GitHub API - createSection: false + insertContent: 'entry-only' }; } -/// Generate suggestion text for changelog entry +/// Generate suggestion text for changelog entry based on what needs to be inserted function generateChangelogSuggestion(prTitle, prNumber, prUrl, sectionName, insertionInfo) { // Clean up PR title (remove conventional commit prefix if present) const cleanTitle = prTitle @@ -162,12 +182,22 @@ function generateChangelogSuggestion(prTitle, prNumber, prUrl, sectionName, inse const bulletPoint = `- ${cleanTitle} ([#${prNumber}](${prUrl}))`; - if (insertionInfo.createSection) { - // Need to create the section - return `\n### ${sectionName}\n\n${bulletPoint}`; - } else { - // Just add the bullet point - return bulletPoint; + switch (insertionInfo.insertContent) { + case 'unreleased-and-section': + // Need to create both Unreleased section and subsection + return `## Unreleased\n\n### ${sectionName}\n\n${bulletPoint}\n`; + + case 'section-and-entry': + // Need to create subsection within existing Unreleased + return `\n### ${sectionName}\n\n${bulletPoint}`; + + case 'entry-only': + // Just add the bullet point to existing section + return bulletPoint; + + default: + // Fallback to entry-only + return bulletPoint; } } diff --git a/danger/dangerfile-utils.test.js b/danger/dangerfile-utils.test.js index 954d0474..d085231d 100644 --- a/danger/dangerfile-utils.test.js +++ b/danger/dangerfile-utils.test.js @@ -297,7 +297,7 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); assert.deepStrictEqual(result, { lineNumber: 7, // Before "- Existing feature" - createSection: false + insertContent: 'entry-only' }); }); @@ -315,7 +315,7 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); assert.deepStrictEqual(result, { lineNumber: 7, // Right after "### Features" and empty line - createSection: false + insertContent: 'entry-only' }); }); @@ -331,8 +331,7 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); assert.deepStrictEqual(result, { lineNumber: 4, // Right after "## Unreleased" - createSection: true, - sectionName: 'Features' + insertContent: 'section-and-entry' }); }); @@ -348,12 +347,11 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); assert.deepStrictEqual(result, { lineNumber: 4, // Right after "## Unreleased" - createSection: true, - sectionName: 'Features' + insertContent: 'section-and-entry' }); }); - it('should return null when no Unreleased section found', () => { + it('should create Unreleased section when none exists', () => { const changelog = `# Changelog ## 1.0.0 @@ -361,7 +359,10 @@ Released content`; Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); - assert.strictEqual(result, null); + assert.deepStrictEqual(result, { + lineNumber: 3, // Before "## 1.0.0" + insertContent: 'unreleased-and-section' + }); }); it('should handle case-insensitive Unreleased section', () => { @@ -376,7 +377,7 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); assert.deepStrictEqual(result, { lineNumber: 7, - createSection: false + insertContent: 'entry-only' }); }); @@ -392,8 +393,7 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Fixes'); assert.deepStrictEqual(result, { lineNumber: 4, // After "## Unreleased" - createSection: true, - sectionName: 'Fixes' + insertContent: 'section-and-entry' }); }); @@ -409,14 +409,14 @@ Released content`; const result = findChangelogInsertionPoint(changelog, 'Features'); assert.deepStrictEqual(result, { lineNumber: 7, // Before " - Existing feature" - createSection: false + insertContent: 'entry-only' }); }); }); describe('generateChangelogSuggestion', () => { it('should generate bullet point for existing section', () => { - const insertionInfo = { lineNumber: 7, createSection: false }; + const insertionInfo = { lineNumber: 7, insertContent: 'entry-only' }; const result = generateChangelogSuggestion( 'feat: add new feature', 123, @@ -429,7 +429,7 @@ Released content`; }); it('should generate section with bullet point for new section', () => { - const insertionInfo = { lineNumber: 4, createSection: true, sectionName: 'Features' }; + const insertionInfo = { lineNumber: 4, insertContent: 'section-and-entry' }; const result = generateChangelogSuggestion( 'feat: add new feature', 123, @@ -441,8 +441,21 @@ Released content`; assert.strictEqual(result, '\n### Features\n\n- add new feature ([#123](https://github.com/repo/pull/123))'); }); + it('should generate full Unreleased section when none exists', () => { + const insertionInfo = { lineNumber: 3, insertContent: 'unreleased-and-section' }; + const result = generateChangelogSuggestion( + 'feat: add new feature', + 123, + 'https://github.com/repo/pull/123', + 'Features', + insertionInfo + ); + + assert.strictEqual(result, '## Unreleased\n\n### Features\n\n- add new feature ([#123](https://github.com/repo/pull/123))\n'); + }); + it('should clean up PR title by removing conventional commit prefix', () => { - const insertionInfo = { lineNumber: 7, createSection: false }; + const insertionInfo = { lineNumber: 7, insertContent: 'entry-only' }; const result1 = generateChangelogSuggestion( 'feat(auth): add OAuth support', @@ -464,7 +477,7 @@ Released content`; }); it('should handle non-conventional PR titles', () => { - const insertionInfo = { lineNumber: 7, createSection: false }; + const insertionInfo = { lineNumber: 7, insertContent: 'entry-only' }; const result = generateChangelogSuggestion( 'Fix memory leak in authentication', @@ -477,7 +490,7 @@ Released content`; }); it('should remove trailing periods from title', () => { - const insertionInfo = { lineNumber: 7, createSection: false }; + const insertionInfo = { lineNumber: 7, insertContent: 'entry-only' }; const result = generateChangelogSuggestion( 'feat: add new feature...', @@ -490,7 +503,7 @@ Released content`; }); it('should handle various section names', () => { - const insertionInfo = { lineNumber: 4, createSection: true }; + const insertionInfo = { lineNumber: 4, insertContent: 'section-and-entry' }; const securityResult = generateChangelogSuggestion( 'sec: fix vulnerability', @@ -511,4 +524,127 @@ Released content`; assert.strictEqual(perfResult, '\n### Performance\n\n- optimize queries ([#128](url))'); }); }); + + describe('Edge Cases and Missing Sections', () => { + it('should handle completely empty changelog', () => { + const changelog = ''; + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 2, // Insert at end of empty file (lines.length + 1) + insertContent: 'unreleased-and-section' + }); + }); + + it('should handle changelog with only title', () => { + const changelog = '# Changelog'; + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 2, // Insert after title + insertContent: 'unreleased-and-section' + }); + }); + + it('should handle changelog with title and description but no versions', () => { + const changelog = `# Changelog + +This is a description of the changelog. + +Some additional notes.`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 6, // Insert at end since no version sections + insertContent: 'unreleased-and-section' + }); + }); + + it('should insert before first version when no Unreleased exists', () => { + const changelog = `# Changelog + +## 2.0.0 + +### Features + +- Feature in 2.0.0 + +## 1.0.0 + +### Features + +- Feature in 1.0.0`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 3, // Before "## 2.0.0" + insertContent: 'unreleased-and-section' + }); + }); + + it('should handle Unreleased section with only other subsections', () => { + const changelog = `# Changelog + +## Unreleased + +### Dependencies + +- Update lodash to v4.17.21 + +### Documentation + +- Update README`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 4, // Right after "## Unreleased" + insertContent: 'section-and-entry' + }); + }); + + it('should handle mixed case and spacing in section headers', () => { + const changelog = `# Changelog + +## unreleased + +### features + +- Existing feature`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 7, // Before existing feature + insertContent: 'entry-only' + }); + }); + + it('should handle Unreleased section at end of file', () => { + const changelog = `# Changelog + +## 1.0.0 + +### Features + +- Old feature + +## Unreleased`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 9, // After "## Unreleased" + insertContent: 'section-and-entry' + }); + }); + + it('should create full structure for completely new changelog', () => { + const insertionInfo = { lineNumber: 1, insertContent: 'unreleased-and-section' }; + const result = generateChangelogSuggestion( + 'feat: initial release', + 1, + 'https://github.com/repo/pull/1', + 'Features', + insertionInfo + ); + + assert.strictEqual(result, '## Unreleased\n\n### Features\n\n- initial release ([#1](https://github.com/repo/pull/1))\n'); + }); + }); }); \ No newline at end of file From 26aac240f98c97af2e891a245a3ba187c8c5dc0a Mon Sep 17 00:00:00 2001 From: Ivan Dlugos Date: Fri, 19 Sep 2025 15:30:53 +0200 Subject: [PATCH 3/3] fix: Handle CHANGELOG.md not in PR diff for inline suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub PR review comments API can only target files that are part of the PR diff. When CHANGELOG.md is not modified in the PR, we cannot create inline suggestions and must fall back to markdown instructions. Changes: - Check if changelogFile is in danger.git modified/created/deleted files - Fall back to markdown instructions when file not in diff - Add warning log explaining why inline suggestions cannot be used This fixes the CI error: "could not be resolved" for pull_request_review_thread.path 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- danger/dangerfile.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/danger/dangerfile.js b/danger/dangerfile.js index 00b0be9e..2f90099d 100644 --- a/danger/dangerfile.js +++ b/danger/dangerfile.js @@ -103,6 +103,21 @@ async function reportMissingChangelog(changelogFile) { const flavorConfig = getFlavorConfig(prFlavor); const sectionName = flavorConfig.changelog || "Features"; + // Check if changelog file is part of the PR diff + // GitHub API can only create review comments on files that are modified in the PR + const allChangedFiles = danger.git.created_files + .concat(danger.git.modified_files) + .concat(danger.git.deleted_files); + + const isChangelogInDiff = allChangedFiles.includes(changelogFile); + + if (!isChangelogInDiff) { + // Cannot create inline suggestions on files not in the diff + console.log(`::warning::Cannot create inline suggestion: ${changelogFile} is not modified in this PR`); + showMarkdownInstructions(changelogFile, sectionName); + return; + } + try { // Get changelog content const changelogContent = await danger.github.utils.fileContents(changelogFile);