diff --git a/danger/dangerfile-utils.js b/danger/dangerfile-utils.js index daaed77..6f755a6 100644 --- a/danger/dangerfile-utils.js +++ b/danger/dangerfile-utils.js @@ -86,8 +86,125 @@ function extractPRFlavor(prTitle, prBranchRef) { return ""; } +/// Find insertion point and determine what content needs to be inserted +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; + } + } + + // Case 1: No Unreleased section exists + if (unreleasedIndex === -1) { + // 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' + }; + } + + // 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; + } + + // Check for our target subsection + if (lines[i].trim().match(new RegExp(`^###\\s+${sectionName}`, 'i'))) { + sectionIndex = i; + break; + } + } + + // Case 3: Subsection doesn't exist, need to create it within Unreleased + if (sectionIndex === -1) { + // Find insertion point after "## Unreleased" but before next main section + let insertAfter = unreleasedIndex; + + // Skip empty lines after "## Unreleased" + while (insertAfter + 1 < nextSectionIndex && lines[insertAfter + 1].trim() === '') { + insertAfter++; + } + + return { + lineNumber: insertAfter + 1, // 1-indexed for GitHub API + insertContent: 'section-and-entry' + }; + } + + // Case 4: Both Unreleased and subsection exist, just add entry + let insertionPoint = sectionIndex + 1; + + // Skip empty lines after section header + while (insertionPoint < nextSectionIndex && lines[insertionPoint].trim() === '') { + insertionPoint++; + } + + return { + lineNumber: insertionPoint + 1, // 1-indexed for GitHub API + insertContent: 'entry-only' + }; +} + +/// 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 + .split(": ") + .slice(-1)[0] + .trim() + .replace(/\.+$/, ""); + + const bulletPoint = `- ${cleanTitle} ([#${prNumber}](${prUrl}))`; + + 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; + } +} + module.exports = { FLAVOR_CONFIG, getFlavorConfig, - extractPRFlavor + extractPRFlavor, + findChangelogInsertionPoint, + generateChangelogSuggestion }; diff --git a/danger/dangerfile-utils.test.js b/danger/dangerfile-utils.test.js index cfd1fe1..d085231 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,376 @@ 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" + insertContent: 'entry-only' + }); + }); + + 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 + insertContent: 'entry-only' + }); + }); + + 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" + insertContent: 'section-and-entry' + }); + }); + + 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" + insertContent: 'section-and-entry' + }); + }); + + it('should create Unreleased section when none exists', () => { + const changelog = `# Changelog + +## 1.0.0 + +Released content`; + + const result = findChangelogInsertionPoint(changelog, 'Features'); + assert.deepStrictEqual(result, { + lineNumber: 3, // Before "## 1.0.0" + insertContent: 'unreleased-and-section' + }); + }); + + 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, + insertContent: 'entry-only' + }); + }); + + 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" + insertContent: 'section-and-entry' + }); + }); + + 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" + insertContent: 'entry-only' + }); + }); + }); + + describe('generateChangelogSuggestion', () => { + it('should generate bullet point for existing section', () => { + const insertionInfo = { lineNumber: 7, insertContent: 'entry-only' }; + 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, insertContent: 'section-and-entry' }; + 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 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, insertContent: 'entry-only' }; + + 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, insertContent: 'entry-only' }; + + 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, insertContent: 'entry-only' }; + + 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, insertContent: 'section-and-entry' }; + + 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))'); + }); + }); + + 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 diff --git a/danger/dangerfile.js b/danger/dangerfile.js index 997a9c0..2f90099 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,78 @@ 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"; + + // 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); + + // 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