Skip to content

Commit baa2244

Browse files
feat(changelog): add AI-powered changelog generator and release-drafter
Adds fully automated CHANGELOG.md generation on every GitHub Release event, plus a Release Drafter for keeping the GitHub Release body up-to-date as PRs are merged to develop. New files: - .github/scripts/generate-changelog.js Gemini-powered script that finds PRs and commits since the last tag, calls Gemini Flash to write a structured changelog entry matching the project's existing ## Version / ### Added / ### Fixed format, and prepends it to CHANGELOG.md. Includes a structured fallback (from PR labels) in case the Gemini API is unavailable. - .github/workflows/changelog.yml Triggers on both prereleased and released GitHub Release events. Checks out develop, runs the Gemini script, then commits and pushes the updated CHANGELOG.md with git pull --rebase to avoid conflicts with the concurrent newrelease version-bump commit. - .github/release-drafter.yml Configuration for release-drafter: categorizes PRs by label into Breaking Changes, New Features, Bug Fixes, Security, Performance, Documentation, Dependency Updates. Generates next semver automatically and populates the GitHub Release body draft. - .github/workflows/release-drafter.yml Triggers on push to develop and on PR events to keep the draft release body current as work lands.
1 parent 50b4987 commit baa2244

4 files changed

Lines changed: 431 additions & 0 deletions

File tree

.github/release-drafter.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Release Drafter configuration
2+
# ─────────────────────────────────────────────────────────────────────────────
3+
# Automatically drafts the GitHub Release body when PRs are merged to develop.
4+
# Uses PR labels to categorize changes. Works alongside the AI Changelog
5+
# Generator (changelog.yml) — release-drafter populates the GitHub Release UI,
6+
# while the changelog script writes the CHANGELOG.md file.
7+
#
8+
# Labels to apply on PRs:
9+
# enhancement / feature → 🚀 New Features
10+
# bug / fix → 🐛 Bug Fixes
11+
# security → 🔒 Security
12+
# documentation / docs → 📖 Documentation
13+
# performance → ⚡ Performance
14+
# dependencies → 📦 Dependency Updates
15+
# breaking-change → 💥 Breaking Changes
16+
17+
name-template: '$RESOLVED_VERSION'
18+
tag-template: '$RESOLVED_VERSION'
19+
20+
# Match existing tag format: plain semver e.g. 1.8.1 (no "v" prefix)
21+
version-template: '$MAJOR.$MINOR.$PATCH'
22+
23+
categories:
24+
- title: '💥 Breaking Changes'
25+
labels:
26+
- 'breaking-change'
27+
- title: '🚀 New Features'
28+
labels:
29+
- 'enhancement'
30+
- 'feature'
31+
- title: '🐛 Bug Fixes'
32+
labels:
33+
- 'bug'
34+
- 'fix'
35+
- title: '🔒 Security'
36+
labels:
37+
- 'security'
38+
- title: '⚡ Performance'
39+
labels:
40+
- 'performance'
41+
- title: '📖 Documentation'
42+
labels:
43+
- 'documentation'
44+
- 'docs'
45+
- title: '📦 Dependency Updates'
46+
labels:
47+
- 'dependencies'
48+
49+
# How to format each change line
50+
change-template: '- $TITLE (#$NUMBER) @$AUTHOR'
51+
change-title-escapes: '\<*_&'
52+
53+
# Which labels determine the next semantic version bump
54+
version-resolver:
55+
major:
56+
labels:
57+
- 'breaking-change'
58+
- 'major'
59+
minor:
60+
labels:
61+
- 'enhancement'
62+
- 'feature'
63+
- 'minor'
64+
patch:
65+
labels:
66+
- 'bug'
67+
- 'fix'
68+
- 'patch'
69+
- 'security'
70+
- 'performance'
71+
- 'documentation'
72+
- 'docs'
73+
- 'dependencies'
74+
default: patch
75+
76+
# Template for the GitHub Release body
77+
template: |
78+
## What's Changed
79+
80+
$CHANGES
81+
82+
---
83+
📦 **Artifact:** `com.sap.cds:sdm:$RESOLVED_VERSION`
84+
📖 **Full Changelog:** https://github.com/$OWNER/$REPOSITORY/blob/develop/CHANGELOG.md
85+
🔗 **Compare:** https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...$RESOLVED_VERSION
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* AI Changelog Generator
3+
* ─────────────────────────────────────────────────────────────────────────────
4+
* Triggered by the changelog.yml workflow on every GitHub release event.
5+
*
6+
* What it does:
7+
* 1. Finds the previous tag to determine the commit range
8+
* 2. Fetches merged PRs and commit messages since the last tag
9+
* 3. Calls Gemini to write a structured changelog entry matching the project's
10+
* existing CHANGELOG.md format (## Version / ### Added / ### Fixed …)
11+
* 4. Prepends the generated entry to CHANGELOG.md
12+
* 5. Git commit + push back to develop is handled by the workflow (not here)
13+
*
14+
* Required env vars: GITHUB_TOKEN, GEMINI_API_KEY, RELEASE_TAG
15+
*/
16+
17+
'use strict';
18+
19+
const { getOctokit, context } = require('@actions/github');
20+
const { GoogleGenerativeAI } = require('@google/generative-ai');
21+
const fs = require('fs');
22+
const path = require('path');
23+
24+
// ─── Helpers ─────────────────────────────────────────────────────────────────
25+
26+
function truncate(str, max) {
27+
if (!str) return '';
28+
return str.length <= max ? str : str.slice(0, max) + '...';
29+
}
30+
31+
async function fetchWithRetry(fn, retries = 3, delay = 1000) {
32+
for (let i = 0; i < retries; i++) {
33+
try { return await fn(); }
34+
catch (e) {
35+
if (i === retries - 1 || (e.status && e.status < 500)) throw e;
36+
console.warn(`Retry ${i + 1}/${retries} after error: ${e.message}`);
37+
await new Promise(r => setTimeout(r, delay * Math.pow(2, i)));
38+
}
39+
}
40+
}
41+
42+
// ─── Main ────────────────────────────────────────────────────────────────────
43+
44+
async function run() {
45+
const token = process.env.GITHUB_TOKEN;
46+
const apiKey = process.env.GEMINI_API_KEY;
47+
const currentTag = process.env.RELEASE_TAG;
48+
49+
if (!token) throw new Error('GITHUB_TOKEN is required');
50+
if (!currentTag) throw new Error('RELEASE_TAG is required');
51+
52+
const octokit = getOctokit(token);
53+
const { owner, repo } = context.repo;
54+
55+
console.log(`\n📋 Generating changelog for: ${owner}/${repo} @ ${currentTag}\n`);
56+
57+
// ─── 1. Find previous tag ────────────────────────────────────────────────
58+
const tags = await fetchWithRetry(() =>
59+
octokit.paginate(octokit.rest.repos.listTags, { owner, repo, per_page: 100 })
60+
);
61+
62+
const currentIdx = tags.findIndex(t => t.name === currentTag);
63+
const previousTag = currentIdx >= 0 && tags[currentIdx + 1] ? tags[currentIdx + 1].name : null;
64+
65+
console.log(`🔖 Range: ${previousTag ?? '(beginning of repo)'}${currentTag}`);
66+
67+
// ─── 2. Get commits between tags ─────────────────────────────────────────
68+
let commitMessages = [];
69+
if (previousTag) {
70+
try {
71+
const { data } = await fetchWithRetry(() =>
72+
octokit.rest.repos.compareCommits({ owner, repo, base: previousTag, head: currentTag })
73+
);
74+
commitMessages = data.commits
75+
.map(c => c.commit.message.split('\n')[0].trim())
76+
.filter(m =>
77+
m.length > 0 &&
78+
!m.startsWith('Merge ') &&
79+
!m.startsWith('Update version') &&
80+
!m.startsWith("docs(changelog):")
81+
);
82+
console.log(`📝 Found ${commitMessages.length} relevant commits`);
83+
} catch (e) {
84+
console.warn('⚠️ Could not compare commits:', e.message);
85+
}
86+
}
87+
88+
// ─── 3. Get merged PRs since previous tag ────────────────────────────────
89+
let mergedPRs = [];
90+
try {
91+
// Resolve the date of the previous tag's commit for filtering PRs
92+
let since = null;
93+
if (previousTag) {
94+
const refData = await fetchWithRetry(() =>
95+
octokit.rest.git.getRef({ owner, repo, ref: `tags/${previousTag}` })
96+
);
97+
let sha = refData.data.object.sha;
98+
// Annotated tags point to a tag object, not a commit directly
99+
if (refData.data.object.type === 'tag') {
100+
const tagObj = await fetchWithRetry(() =>
101+
octokit.rest.git.getTag({ owner, repo, tag_sha: sha })
102+
);
103+
sha = tagObj.data.object.sha;
104+
}
105+
const commitData = await fetchWithRetry(() =>
106+
octokit.rest.repos.getCommit({ owner, repo, ref: sha })
107+
);
108+
since = commitData.data.commit.committer.date;
109+
console.log(`📅 Looking for PRs merged after: ${since}`);
110+
}
111+
112+
const allPRs = await fetchWithRetry(() =>
113+
octokit.paginate(octokit.rest.pulls.list, {
114+
owner, repo, state: 'closed', per_page: 100, sort: 'updated', direction: 'desc'
115+
})
116+
);
117+
118+
mergedPRs = allPRs
119+
.filter(pr => {
120+
if (!pr.merged_at) return false;
121+
if (since && new Date(pr.merged_at) <= new Date(since)) return false;
122+
return true;
123+
})
124+
.map(pr => ({
125+
number: pr.number,
126+
title: pr.title,
127+
labels: pr.labels.map(l => l.name),
128+
body: truncate(pr.body || '', 400),
129+
}));
130+
131+
console.log(`🔀 Found ${mergedPRs.length} merged PRs`);
132+
} catch (e) {
133+
console.warn('⚠️ Could not fetch merged PRs:', e.message);
134+
}
135+
136+
// ─── 4. Build Gemini prompt ───────────────────────────────────────────────
137+
const prBlock = mergedPRs.length > 0
138+
? mergedPRs.map(pr =>
139+
`- PR #${pr.number}: "${pr.title}" [labels: ${pr.labels.join(', ') || 'none'}]\n Body: ${pr.body || '(none)'}`)
140+
.join('\n')
141+
: '(no PR data available — use commit messages)';
142+
143+
const commitBlock = commitMessages.length > 0
144+
? commitMessages.slice(0, 50).map(m => `- ${m}`).join('\n')
145+
: '(no commit data available)';
146+
147+
const prompt = `You are a technical changelog writer for the SAP CAP Java SDK Document Management Service plugin.
148+
149+
**Project context:**
150+
This is a Maven JAR library (Java 17) used by SAP CAP Java applications to route Attachment CRUD events to SAP Document Management Service via CMIS REST API. Key subsystems: CAP event handlers (@Before/@On/@After), OAuth2 token management (TokenHandler), EhCache 3 caching (8 named caches), Apache HttpClient 5 CMIS calls, multi-tenant Cloud Foundry deployments, upload virus scan states (uploading / Success / Failed / VirusDetected / VirusScanInprogress).
151+
152+
**Version:** ${currentTag}
153+
154+
**Merged PRs since previous release (${previousTag ?? 'beginning'}):**
155+
${prBlock}
156+
157+
**Relevant commit messages:**
158+
${commitBlock}
159+
160+
**Instructions — follow EXACTLY:**
161+
1. Output ONLY the changelog entry — no preamble, no explanation, no markdown fences.
162+
2. Match this exact format:
163+
164+
## Version ${currentTag}
165+
166+
### Added
167+
- <user-facing feature description>
168+
169+
### Fixed
170+
- <user-facing bug fix description>
171+
172+
3. Only include sections (Added / Fixed / Changed / Security / Deprecated) that have actual content — omit sections with nothing to report.
173+
4. Write each bullet as a clear, concise sentence a library consumer can understand. No internal class names.
174+
5. If a PR title or commit is vague, infer from the body or omit it entirely.
175+
6. Do NOT fabricate changes — only describe what is evidenced by the PR/commit data above.`;
176+
177+
// ─── 5. Call Gemini (with structured fallback) ────────────────────────────
178+
let changelogEntry = '';
179+
180+
if (apiKey) {
181+
try {
182+
const genAI = new GoogleGenerativeAI(apiKey);
183+
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
184+
const result = await fetchWithRetry(() => model.generateContent(prompt));
185+
changelogEntry = result.response.text().trim();
186+
console.log('\n✅ Gemini generated changelog entry:\n');
187+
console.log(changelogEntry);
188+
} catch (e) {
189+
console.warn(`⚠️ Gemini failed (${e.message}), falling back to structured list.`);
190+
}
191+
} else {
192+
console.warn('⚠️ GEMINI_API_KEY not set — using structured fallback.');
193+
}
194+
195+
// Structured fallback: build entry from PR labels without Gemini
196+
if (!changelogEntry) {
197+
const added = mergedPRs.filter(pr => pr.labels.some(l => ['enhancement', 'feature'].includes(l)));
198+
const fixed = mergedPRs.filter(pr => pr.labels.some(l => ['bug', 'fix'].includes(l)));
199+
const security = mergedPRs.filter(pr => pr.labels.includes('security'));
200+
const other = mergedPRs.filter(pr =>
201+
!added.includes(pr) && !fixed.includes(pr) && !security.includes(pr) && pr.title
202+
);
203+
204+
changelogEntry = `## Version ${currentTag}`;
205+
if (added.length) changelogEntry += `\n\n### Added\n${added.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`;
206+
if (fixed.length) changelogEntry += `\n\n### Fixed\n${fixed.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`;
207+
if (security.length) changelogEntry += `\n\n### Security\n${security.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`;
208+
if (other.length) changelogEntry += `\n\n### Changed\n${other.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`;
209+
210+
if (mergedPRs.length === 0 && commitMessages.length > 0) {
211+
changelogEntry += `\n\n### Changed\n${commitMessages.slice(0, 15).map(m => `- ${m}`).join('\n')}`;
212+
}
213+
214+
console.log('\n📋 Structured fallback entry:\n');
215+
console.log(changelogEntry);
216+
}
217+
218+
// ─── 6. Prepend entry to CHANGELOG.md ────────────────────────────────────
219+
const changelogPath = path.join(process.cwd(), 'CHANGELOG.md');
220+
221+
if (!fs.existsSync(changelogPath)) {
222+
throw new Error(`CHANGELOG.md not found at ${changelogPath}`);
223+
}
224+
225+
const existing = fs.readFileSync(changelogPath, 'utf8');
226+
227+
// Guard: don't double-write if this version already exists
228+
if (existing.includes(`## Version ${currentTag}`)) {
229+
console.log(`\nℹ️ Version ${currentTag} already present in CHANGELOG.md — skipping write.`);
230+
return;
231+
}
232+
233+
// Insert after the header lines (everything before the first "## Version" block)
234+
const firstVersionIdx = existing.indexOf('\n## Version');
235+
let newContent;
236+
if (firstVersionIdx !== -1) {
237+
newContent =
238+
existing.slice(0, firstVersionIdx + 1) +
239+
'\n' + changelogEntry + '\n' +
240+
existing.slice(firstVersionIdx + 1);
241+
} else {
242+
// No existing version entries — append after header
243+
newContent = existing.trimEnd() + '\n\n' + changelogEntry + '\n';
244+
}
245+
246+
fs.writeFileSync(changelogPath, newContent, 'utf8');
247+
console.log('\n✅ CHANGELOG.md updated successfully.');
248+
}
249+
250+
run().catch(err => {
251+
console.error('❌ Changelog generation failed:', err.message);
252+
process.exit(1);
253+
});

0 commit comments

Comments
 (0)