diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..59d63187 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,85 @@ +# Release Drafter configuration +# ───────────────────────────────────────────────────────────────────────────── +# Automatically drafts the GitHub Release body when PRs are merged to develop. +# Uses PR labels to categorize changes. Works alongside the AI Changelog +# Generator (changelog.yml) — release-drafter populates the GitHub Release UI, +# while the changelog script writes the CHANGELOG.md file. +# +# Labels to apply on PRs: +# enhancement / feature → 🚀 New Features +# bug / fix → 🐛 Bug Fixes +# security → 🔒 Security +# documentation / docs → 📖 Documentation +# performance → ⚡ Performance +# dependencies → 📦 Dependency Updates +# breaking-change → 💥 Breaking Changes + +name-template: '$RESOLVED_VERSION' +tag-template: '$RESOLVED_VERSION' + +# Match existing tag format: plain semver e.g. 1.8.1 (no "v" prefix) +version-template: '$MAJOR.$MINOR.$PATCH' + +categories: + - title: '💥 Breaking Changes' + labels: + - 'breaking-change' + - title: '🚀 New Features' + labels: + - 'enhancement' + - 'feature' + - title: '🐛 Bug Fixes' + labels: + - 'bug' + - 'fix' + - title: '🔒 Security' + labels: + - 'security' + - title: '⚡ Performance' + labels: + - 'performance' + - title: '📖 Documentation' + labels: + - 'documentation' + - 'docs' + - title: '📦 Dependency Updates' + labels: + - 'dependencies' + +# How to format each change line +change-template: '- $TITLE (#$NUMBER) @$AUTHOR' +change-title-escapes: '\<*_&' + +# Which labels determine the next semantic version bump +version-resolver: + major: + labels: + - 'breaking-change' + - 'major' + minor: + labels: + - 'enhancement' + - 'feature' + - 'minor' + patch: + labels: + - 'bug' + - 'fix' + - 'patch' + - 'security' + - 'performance' + - 'documentation' + - 'docs' + - 'dependencies' + default: patch + +# Template for the GitHub Release body +template: | + ## What's Changed + + $CHANGES + + --- + 📦 **Artifact:** `com.sap.cds:sdm:$RESOLVED_VERSION` + 📖 **Full Changelog:** https://github.com/$OWNER/$REPOSITORY/blob/develop/CHANGELOG.md + 🔗 **Compare:** https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...$RESOLVED_VERSION diff --git a/.github/scripts/generate-changelog.js b/.github/scripts/generate-changelog.js new file mode 100644 index 00000000..e6e7dd38 --- /dev/null +++ b/.github/scripts/generate-changelog.js @@ -0,0 +1,253 @@ +/** + * AI Changelog Generator + * ───────────────────────────────────────────────────────────────────────────── + * Triggered by the changelog.yml workflow on every GitHub release event. + * + * What it does: + * 1. Finds the previous tag to determine the commit range + * 2. Fetches merged PRs and commit messages since the last tag + * 3. Calls Gemini to write a structured changelog entry matching the project's + * existing CHANGELOG.md format (## Version / ### Added / ### Fixed …) + * 4. Prepends the generated entry to CHANGELOG.md + * 5. Git commit + push back to develop is handled by the workflow (not here) + * + * Required env vars: GITHUB_TOKEN, GEMINI_API_KEY, RELEASE_TAG + */ + +'use strict'; + +const { getOctokit, context } = require('@actions/github'); +const { GoogleGenerativeAI } = require('@google/generative-ai'); +const fs = require('fs'); +const path = require('path'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function truncate(str, max) { + if (!str) return ''; + return str.length <= max ? str : str.slice(0, max) + '...'; +} + +async function fetchWithRetry(fn, retries = 3, delay = 1000) { + for (let i = 0; i < retries; i++) { + try { return await fn(); } + catch (e) { + if (i === retries - 1 || (e.status && e.status < 500)) throw e; + console.warn(`Retry ${i + 1}/${retries} after error: ${e.message}`); + await new Promise(r => setTimeout(r, delay * Math.pow(2, i))); + } + } +} + +// ─── Main ──────────────────────────────────────────────────────────────────── + +async function run() { + const token = process.env.GITHUB_TOKEN; + const apiKey = process.env.GEMINI_API_KEY; + const currentTag = process.env.RELEASE_TAG; + + if (!token) throw new Error('GITHUB_TOKEN is required'); + if (!currentTag) throw new Error('RELEASE_TAG is required'); + + const octokit = getOctokit(token); + const { owner, repo } = context.repo; + + console.log(`\n📋 Generating changelog for: ${owner}/${repo} @ ${currentTag}\n`); + + // ─── 1. Find previous tag ──────────────────────────────────────────────── + const tags = await fetchWithRetry(() => + octokit.paginate(octokit.rest.repos.listTags, { owner, repo, per_page: 100 }) + ); + + const currentIdx = tags.findIndex(t => t.name === currentTag); + const previousTag = currentIdx >= 0 && tags[currentIdx + 1] ? tags[currentIdx + 1].name : null; + + console.log(`🔖 Range: ${previousTag ?? '(beginning of repo)'} → ${currentTag}`); + + // ─── 2. Get commits between tags ───────────────────────────────────────── + let commitMessages = []; + if (previousTag) { + try { + const { data } = await fetchWithRetry(() => + octokit.rest.repos.compareCommits({ owner, repo, base: previousTag, head: currentTag }) + ); + commitMessages = data.commits + .map(c => c.commit.message.split('\n')[0].trim()) + .filter(m => + m.length > 0 && + !m.startsWith('Merge ') && + !m.startsWith('Update version') && + !m.startsWith("docs(changelog):") + ); + console.log(`📝 Found ${commitMessages.length} relevant commits`); + } catch (e) { + console.warn('⚠️ Could not compare commits:', e.message); + } + } + + // ─── 3. Get merged PRs since previous tag ──────────────────────────────── + let mergedPRs = []; + try { + // Resolve the date of the previous tag's commit for filtering PRs + let since = null; + if (previousTag) { + const refData = await fetchWithRetry(() => + octokit.rest.git.getRef({ owner, repo, ref: `tags/${previousTag}` }) + ); + let sha = refData.data.object.sha; + // Annotated tags point to a tag object, not a commit directly + if (refData.data.object.type === 'tag') { + const tagObj = await fetchWithRetry(() => + octokit.rest.git.getTag({ owner, repo, tag_sha: sha }) + ); + sha = tagObj.data.object.sha; + } + const commitData = await fetchWithRetry(() => + octokit.rest.repos.getCommit({ owner, repo, ref: sha }) + ); + since = commitData.data.commit.committer.date; + console.log(`📅 Looking for PRs merged after: ${since}`); + } + + const allPRs = await fetchWithRetry(() => + octokit.paginate(octokit.rest.pulls.list, { + owner, repo, state: 'closed', per_page: 100, sort: 'updated', direction: 'desc' + }) + ); + + mergedPRs = allPRs + .filter(pr => { + if (!pr.merged_at) return false; + if (since && new Date(pr.merged_at) <= new Date(since)) return false; + return true; + }) + .map(pr => ({ + number: pr.number, + title: pr.title, + labels: pr.labels.map(l => l.name), + body: truncate(pr.body || '', 400), + })); + + console.log(`🔀 Found ${mergedPRs.length} merged PRs`); + } catch (e) { + console.warn('⚠️ Could not fetch merged PRs:', e.message); + } + + // ─── 4. Build Gemini prompt ─────────────────────────────────────────────── + const prBlock = mergedPRs.length > 0 + ? mergedPRs.map(pr => + `- PR #${pr.number}: "${pr.title}" [labels: ${pr.labels.join(', ') || 'none'}]\n Body: ${pr.body || '(none)'}`) + .join('\n') + : '(no PR data available — use commit messages)'; + + const commitBlock = commitMessages.length > 0 + ? commitMessages.slice(0, 50).map(m => `- ${m}`).join('\n') + : '(no commit data available)'; + + const prompt = `You are a technical changelog writer for the SAP CAP Java SDK Document Management Service plugin. + +**Project context:** +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). + +**Version:** ${currentTag} + +**Merged PRs since previous release (${previousTag ?? 'beginning'}):** +${prBlock} + +**Relevant commit messages:** +${commitBlock} + +**Instructions — follow EXACTLY:** +1. Output ONLY the changelog entry — no preamble, no explanation, no markdown fences. +2. Match this exact format: + +## Version ${currentTag} + +### Added +- + +### Fixed +- + +3. Only include sections (Added / Fixed / Changed / Security / Deprecated) that have actual content — omit sections with nothing to report. +4. Write each bullet as a clear, concise sentence a library consumer can understand. No internal class names. +5. If a PR title or commit is vague, infer from the body or omit it entirely. +6. Do NOT fabricate changes — only describe what is evidenced by the PR/commit data above.`; + + // ─── 5. Call Gemini (with structured fallback) ──────────────────────────── + let changelogEntry = ''; + + if (apiKey) { + try { + const genAI = new GoogleGenerativeAI(apiKey); + const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' }); + const result = await fetchWithRetry(() => model.generateContent(prompt)); + changelogEntry = result.response.text().trim(); + console.log('\n✅ Gemini generated changelog entry:\n'); + console.log(changelogEntry); + } catch (e) { + console.warn(`⚠️ Gemini failed (${e.message}), falling back to structured list.`); + } + } else { + console.warn('⚠️ GEMINI_API_KEY not set — using structured fallback.'); + } + + // Structured fallback: build entry from PR labels without Gemini + if (!changelogEntry) { + const added = mergedPRs.filter(pr => pr.labels.some(l => ['enhancement', 'feature'].includes(l))); + const fixed = mergedPRs.filter(pr => pr.labels.some(l => ['bug', 'fix'].includes(l))); + const security = mergedPRs.filter(pr => pr.labels.includes('security')); + const other = mergedPRs.filter(pr => + !added.includes(pr) && !fixed.includes(pr) && !security.includes(pr) && pr.title + ); + + changelogEntry = `## Version ${currentTag}`; + if (added.length) changelogEntry += `\n\n### Added\n${added.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`; + if (fixed.length) changelogEntry += `\n\n### Fixed\n${fixed.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`; + if (security.length) changelogEntry += `\n\n### Security\n${security.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`; + if (other.length) changelogEntry += `\n\n### Changed\n${other.map(pr => `- ${pr.title} (#${pr.number})`).join('\n')}`; + + if (mergedPRs.length === 0 && commitMessages.length > 0) { + changelogEntry += `\n\n### Changed\n${commitMessages.slice(0, 15).map(m => `- ${m}`).join('\n')}`; + } + + console.log('\n📋 Structured fallback entry:\n'); + console.log(changelogEntry); + } + + // ─── 6. Prepend entry to CHANGELOG.md ──────────────────────────────────── + const changelogPath = path.join(process.cwd(), 'CHANGELOG.md'); + + if (!fs.existsSync(changelogPath)) { + throw new Error(`CHANGELOG.md not found at ${changelogPath}`); + } + + const existing = fs.readFileSync(changelogPath, 'utf8'); + + // Guard: don't double-write if this version already exists + if (existing.includes(`## Version ${currentTag}`)) { + console.log(`\nℹ️ Version ${currentTag} already present in CHANGELOG.md — skipping write.`); + return; + } + + // Insert after the header lines (everything before the first "## Version" block) + const firstVersionIdx = existing.indexOf('\n## Version'); + let newContent; + if (firstVersionIdx !== -1) { + newContent = + existing.slice(0, firstVersionIdx + 1) + + '\n' + changelogEntry + '\n' + + existing.slice(firstVersionIdx + 1); + } else { + // No existing version entries — append after header + newContent = existing.trimEnd() + '\n\n' + changelogEntry + '\n'; + } + + fs.writeFileSync(changelogPath, newContent, 'utf8'); + console.log('\n✅ CHANGELOG.md updated successfully.'); +} + +run().catch(err => { + console.error('❌ Changelog generation failed:', err.message); + process.exit(1); +}); diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 00000000..58bdfbb3 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,93 @@ +name: AI Changelog Generator + +# Triggers on every GitHub Release event — both pre-release (Artifactory) and +# full release (Maven Central) so CHANGELOG.md is always updated before the +# artifacts land in the registries. +# +# Can also be triggered manually via workflow_dispatch for end-to-end testing +# before a real release. Set dry_run=true (default) to skip the git commit. +on: + release: + types: [prereleased, released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to generate changelog for (e.g. 1.8.0)' + required: true + default: '1.8.0' + dry_run: + description: 'Dry run — generate entry but do NOT commit to develop' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + +permissions: + contents: write # needed to commit + push CHANGELOG.md to develop + pull-requests: read # needed to list merged PRs for the changelog entry + +jobs: + generate-changelog: + name: Generate & Commit CHANGELOG.md + runs-on: ubuntu-latest + + steps: + # Checkout develop (not the tag) so we can commit back to the branch. + # fetch-depth: 0 is required to compare tags via the GitHub API calls + # inside the script. + - name: Checkout develop + uses: actions/checkout@v6 + with: + ref: develop + token: ${{ secrets.GH_TOKEN }} + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Cache npm packages + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-changelog-${{ hashFiles('.github/scripts/generate-changelog.js') }} + restore-keys: | + ${{ runner.os }}-changelog- + + - name: Install dependencies + run: npm install @actions/github @google/generative-ai + + - name: Generate CHANGELOG entry via Gemini + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # Use manual input tag when triggered via workflow_dispatch, otherwise use the release tag + RELEASE_TAG: ${{ github.event.inputs.tag || github.event.release.tag_name }} + run: node .github/scripts/generate-changelog.js + + # Always print the diff so dry-run results are visible in the Actions log + - name: Show generated CHANGELOG diff + run: | + echo "──── CHANGELOG.md diff (what would be committed) ────" + git diff CHANGELOG.md || echo "(no changes — version may already exist)" + echo "─────────────────────────────────────────────────────" + if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then + echo "ℹ️ DRY RUN — commit step is skipped. Review the diff above." + fi + + # Commit only if CHANGELOG.md was actually modified (idempotent). + # Skipped when dry_run=true (manual trigger default) so you can test + # end-to-end without touching develop. + - name: Commit and push CHANGELOG.md + if: ${{ github.event.inputs.dry_run != 'true' }} + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git add CHANGELOG.md + git diff --cached --quiet && echo "No CHANGELOG changes to commit." && exit 0 + git commit -m "docs(changelog): auto-update for ${{ github.event.release.tag_name }}" + git pull --rebase origin develop + git push origin develop diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 00000000..9a5d8d50 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,29 @@ +name: Release Drafter + +# Keeps a draft GitHub Release up-to-date whenever: +# - A PR is opened/updated/merged against develop +# - A direct push lands on develop +# +# The draft is promoted to a real release manually by a maintainer, which then +# triggers the build + deploy workflows (prereleased / released). +on: + push: + branches: + - develop + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: write # create/update draft release + pull-requests: write # label PRs with auto-detected version bump label + +jobs: + update-release-draft: + name: Update Draft Release + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v6 + with: + config-name: release-drafter.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}