Skip to content

Commit 5e5f5d8

Browse files
vaindclaude
andcommitted
feat: Danger - Add inline changelog suggestions for GitHub PRs
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 <[email protected]>
1 parent a4ff0c2 commit 5e5f5d8

File tree

3 files changed

+375
-9
lines changed

3 files changed

+375
-9
lines changed

danger/dangerfile-utils.js

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,95 @@ function extractPRFlavor(prTitle, prBranchRef) {
8686
return "";
8787
}
8888

89+
/// Find insertion point for changelog entry in a specific section
90+
function findChangelogInsertionPoint(changelogContent, sectionName) {
91+
const lines = changelogContent.split('\n');
92+
93+
// Find "## Unreleased" section
94+
let unreleasedIndex = -1;
95+
for (let i = 0; i < lines.length; i++) {
96+
if (lines[i].trim().match(/^##\s+Unreleased/i)) {
97+
unreleasedIndex = i;
98+
break;
99+
}
100+
}
101+
102+
if (unreleasedIndex === -1) {
103+
return null; // No Unreleased section found
104+
}
105+
106+
// Find the target subsection (e.g., "### Features")
107+
let sectionIndex = -1;
108+
for (let i = unreleasedIndex + 1; i < lines.length; i++) {
109+
// Stop if we hit another main section (##)
110+
if (lines[i].trim().match(/^##\s+/)) {
111+
break;
112+
}
113+
114+
// Check for our target subsection
115+
if (lines[i].trim().match(new RegExp(`^###\\s+${sectionName}`, 'i'))) {
116+
sectionIndex = i;
117+
break;
118+
}
119+
}
120+
121+
if (sectionIndex === -1) {
122+
// Section doesn't exist, we need to create it
123+
// Find insertion point after "## Unreleased"
124+
let insertAfter = unreleasedIndex;
125+
126+
// Skip empty lines after "## Unreleased"
127+
while (insertAfter + 1 < lines.length && lines[insertAfter + 1].trim() === '') {
128+
insertAfter++;
129+
}
130+
131+
return {
132+
lineNumber: insertAfter + 1, // 1-indexed for GitHub API
133+
createSection: true,
134+
sectionName: sectionName
135+
};
136+
}
137+
138+
// Section exists, find first bullet point or insertion point
139+
let insertionPoint = sectionIndex + 1;
140+
141+
// Skip empty lines after section header
142+
while (insertionPoint < lines.length && lines[insertionPoint].trim() === '') {
143+
insertionPoint++;
144+
}
145+
146+
// If next line is a bullet point, insert before it
147+
// If it's another section or end of file, insert here
148+
return {
149+
lineNumber: insertionPoint + 1, // 1-indexed for GitHub API
150+
createSection: false
151+
};
152+
}
153+
154+
/// Generate suggestion text for changelog entry
155+
function generateChangelogSuggestion(prTitle, prNumber, prUrl, sectionName, insertionInfo) {
156+
// Clean up PR title (remove conventional commit prefix if present)
157+
const cleanTitle = prTitle
158+
.split(": ")
159+
.slice(-1)[0]
160+
.trim()
161+
.replace(/\.+$/, "");
162+
163+
const bulletPoint = `- ${cleanTitle} ([#${prNumber}](${prUrl}))`;
164+
165+
if (insertionInfo.createSection) {
166+
// Need to create the section
167+
return `\n### ${sectionName}\n\n${bulletPoint}`;
168+
} else {
169+
// Just add the bullet point
170+
return bulletPoint;
171+
}
172+
}
173+
89174
module.exports = {
90175
FLAVOR_CONFIG,
91176
getFlavorConfig,
92-
extractPRFlavor
177+
extractPRFlavor,
178+
findChangelogInsertionPoint,
179+
generateChangelogSuggestion
93180
};

danger/dangerfile-utils.test.js

Lines changed: 237 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const { describe, it } = require('node:test');
22
const assert = require('node:assert');
3-
const { getFlavorConfig, extractPRFlavor, FLAVOR_CONFIG } = require('./dangerfile-utils.js');
3+
const { getFlavorConfig, extractPRFlavor, FLAVOR_CONFIG, findChangelogInsertionPoint, generateChangelogSuggestion } = require('./dangerfile-utils.js');
44

55
describe('dangerfile-utils', () => {
66
describe('getFlavorConfig', () => {
@@ -275,4 +275,240 @@ describe('dangerfile-utils', () => {
275275
});
276276
});
277277
});
278+
279+
describe('findChangelogInsertionPoint', () => {
280+
it('should find insertion point for existing Features section', () => {
281+
const changelog = `# Changelog
282+
283+
## Unreleased
284+
285+
### Features
286+
287+
- Existing feature ([#100](url))
288+
289+
### Fixes
290+
291+
- Existing fix ([#99](url))
292+
293+
## 1.0.0
294+
295+
Released content`;
296+
297+
const result = findChangelogInsertionPoint(changelog, 'Features');
298+
assert.deepStrictEqual(result, {
299+
lineNumber: 7, // Before "- Existing feature"
300+
createSection: false
301+
});
302+
});
303+
304+
it('should find insertion point when Features section exists but is empty', () => {
305+
const changelog = `# Changelog
306+
307+
## Unreleased
308+
309+
### Features
310+
311+
### Fixes
312+
313+
- Existing fix ([#99](url))`;
314+
315+
const result = findChangelogInsertionPoint(changelog, 'Features');
316+
assert.deepStrictEqual(result, {
317+
lineNumber: 7, // Right after "### Features" and empty line
318+
createSection: false
319+
});
320+
});
321+
322+
it('should create section when Features section does not exist', () => {
323+
const changelog = `# Changelog
324+
325+
## Unreleased
326+
327+
### Fixes
328+
329+
- Existing fix ([#99](url))`;
330+
331+
const result = findChangelogInsertionPoint(changelog, 'Features');
332+
assert.deepStrictEqual(result, {
333+
lineNumber: 4, // Right after "## Unreleased"
334+
createSection: true,
335+
sectionName: 'Features'
336+
});
337+
});
338+
339+
it('should handle changelog with only Unreleased section', () => {
340+
const changelog = `# Changelog
341+
342+
## Unreleased
343+
344+
## 1.0.0
345+
346+
Released content`;
347+
348+
const result = findChangelogInsertionPoint(changelog, 'Features');
349+
assert.deepStrictEqual(result, {
350+
lineNumber: 4, // Right after "## Unreleased"
351+
createSection: true,
352+
sectionName: 'Features'
353+
});
354+
});
355+
356+
it('should return null when no Unreleased section found', () => {
357+
const changelog = `# Changelog
358+
359+
## 1.0.0
360+
361+
Released content`;
362+
363+
const result = findChangelogInsertionPoint(changelog, 'Features');
364+
assert.strictEqual(result, null);
365+
});
366+
367+
it('should handle case-insensitive Unreleased section', () => {
368+
const changelog = `# Changelog
369+
370+
## unreleased
371+
372+
### Features
373+
374+
- Existing feature ([#100](url))`;
375+
376+
const result = findChangelogInsertionPoint(changelog, 'Features');
377+
assert.deepStrictEqual(result, {
378+
lineNumber: 7,
379+
createSection: false
380+
});
381+
});
382+
383+
it('should handle different section names', () => {
384+
const changelog = `# Changelog
385+
386+
## Unreleased
387+
388+
### Security
389+
390+
- Security fix ([#101](url))`;
391+
392+
const result = findChangelogInsertionPoint(changelog, 'Fixes');
393+
assert.deepStrictEqual(result, {
394+
lineNumber: 4, // After "## Unreleased"
395+
createSection: true,
396+
sectionName: 'Fixes'
397+
});
398+
});
399+
400+
it('should handle extra whitespace around sections', () => {
401+
const changelog = `# Changelog
402+
403+
## Unreleased
404+
405+
### Features
406+
407+
- Existing feature ([#100](url))`;
408+
409+
const result = findChangelogInsertionPoint(changelog, 'Features');
410+
assert.deepStrictEqual(result, {
411+
lineNumber: 7, // Before " - Existing feature"
412+
createSection: false
413+
});
414+
});
415+
});
416+
417+
describe('generateChangelogSuggestion', () => {
418+
it('should generate bullet point for existing section', () => {
419+
const insertionInfo = { lineNumber: 7, createSection: false };
420+
const result = generateChangelogSuggestion(
421+
'feat: add new feature',
422+
123,
423+
'https://github.com/repo/pull/123',
424+
'Features',
425+
insertionInfo
426+
);
427+
428+
assert.strictEqual(result, '- add new feature ([#123](https://github.com/repo/pull/123))');
429+
});
430+
431+
it('should generate section with bullet point for new section', () => {
432+
const insertionInfo = { lineNumber: 4, createSection: true, sectionName: 'Features' };
433+
const result = generateChangelogSuggestion(
434+
'feat: add new feature',
435+
123,
436+
'https://github.com/repo/pull/123',
437+
'Features',
438+
insertionInfo
439+
);
440+
441+
assert.strictEqual(result, '\n### Features\n\n- add new feature ([#123](https://github.com/repo/pull/123))');
442+
});
443+
444+
it('should clean up PR title by removing conventional commit prefix', () => {
445+
const insertionInfo = { lineNumber: 7, createSection: false };
446+
447+
const result1 = generateChangelogSuggestion(
448+
'feat(auth): add OAuth support',
449+
123,
450+
'url',
451+
'Features',
452+
insertionInfo
453+
);
454+
assert.strictEqual(result1, '- add OAuth support ([#123](url))');
455+
456+
const result2 = generateChangelogSuggestion(
457+
'fix: resolve memory leak',
458+
124,
459+
'url',
460+
'Fixes',
461+
insertionInfo
462+
);
463+
assert.strictEqual(result2, '- resolve memory leak ([#124](url))');
464+
});
465+
466+
it('should handle non-conventional PR titles', () => {
467+
const insertionInfo = { lineNumber: 7, createSection: false };
468+
469+
const result = generateChangelogSuggestion(
470+
'Fix memory leak in authentication',
471+
125,
472+
'url',
473+
'Fixes',
474+
insertionInfo
475+
);
476+
assert.strictEqual(result, '- Fix memory leak in authentication ([#125](url))');
477+
});
478+
479+
it('should remove trailing periods from title', () => {
480+
const insertionInfo = { lineNumber: 7, createSection: false };
481+
482+
const result = generateChangelogSuggestion(
483+
'feat: add new feature...',
484+
126,
485+
'url',
486+
'Features',
487+
insertionInfo
488+
);
489+
assert.strictEqual(result, '- add new feature ([#126](url))');
490+
});
491+
492+
it('should handle various section names', () => {
493+
const insertionInfo = { lineNumber: 4, createSection: true };
494+
495+
const securityResult = generateChangelogSuggestion(
496+
'sec: fix vulnerability',
497+
127,
498+
'url',
499+
'Security',
500+
insertionInfo
501+
);
502+
assert.strictEqual(securityResult, '\n### Security\n\n- fix vulnerability ([#127](url))');
503+
504+
const perfResult = generateChangelogSuggestion(
505+
'perf: optimize queries',
506+
128,
507+
'url',
508+
'Performance',
509+
insertionInfo
510+
);
511+
assert.strictEqual(perfResult, '\n### Performance\n\n- optimize queries ([#128](url))');
512+
});
513+
});
278514
});

0 commit comments

Comments
 (0)