diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 79053f4ef7ee..282c8a6543bd 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -43,11 +43,12 @@ You can run a pre-configured command in cursor by just typing `/publish_release` ## Updating the Changelog -1. Run `yarn changelog` and copy everything. +1. Run `yarn changelog` (or `yarn generate-changelog` for best-effort formatting) and copy everything. 2. Create a new section in the changelog with the previously determined version number. 3. Paste in the logs you copied earlier. 4. If there are any important features or fixes, highlight them under the `Important Changes` subheading. If there are no important changes, don't include this section. If the `Important Changes` subheading is used, put all other user-facing changes under the `Other Changes` subheading. 5. Any changes that are purely internal (e.g. internal refactors (`ref`) without user-facing changes, tests, chores, etc) should be put under a `
` block, where the `` heading is "Internal Changes" (see example). + - Sometimes, there might be user-facing changes that are marked as `ref`, `chore` or similar - these should go in the main changelog body, not in the internal changes section. 6. Make sure the changelog entries are ordered alphabetically. 7. If any of the PRs are from external contributors, include underneath the commits `Work in this release contributed by . Thank you for your contributions!`. diff --git a/package.json b/package.json index c92a18b0dfe1..631f2aff55e4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:tarball": "run-s clean:tarballs build:tarballs", "build:tarballs": "lerna run build:tarball", "changelog": "ts-node ./scripts/get-commit-list.ts", + "generate-changelog": "ts-node ./scripts/generate-changelog.ts", "circularDepCheck": "lerna run circularDepCheck", "clean": "run-s clean:build clean:caches", "clean:build": "lerna run clean", diff --git a/scripts/generate-changelog.ts b/scripts/generate-changelog.ts new file mode 100644 index 000000000000..cc4c11b84951 --- /dev/null +++ b/scripts/generate-changelog.ts @@ -0,0 +1,303 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { getNewGitCommits } from './get-commit-list'; + +type EntryType = 'important' | 'other' | 'internal'; + +interface ChangelogEntry { + type: EntryType; + content: string; + sortKey: string; + prNumber: string | null; +} + +// ============================================================================ +// Changelog Parsing +// ============================================================================ + +interface ParsedChangelog { + importantChanges: ChangelogEntry[]; + otherChanges: ChangelogEntry[]; + internalChanges: ChangelogEntry[]; + changelogPRs: Set; + contributorsLine: string; +} + +function getUnreleasedSection(content: string): string[] { + const lines = content.split('\n'); + + const unreleasedIndex = lines.findIndex(line => line.trim() === '## Unreleased'); + if (unreleasedIndex === -1) { + // eslint-disable-next-line no-console + console.error('Could not find "## Unreleased" section in CHANGELOG.md'); + process.exit(1); + } + + const nextVersionIndex = lines.findIndex((line, index) => index > unreleasedIndex && /^## \d+\.\d+\.\d+/.test(line)); + if (nextVersionIndex === -1) { + // eslint-disable-next-line no-console + console.error('Could not find next version section after "## Unreleased"'); + process.exit(1); + } + + return lines.slice(unreleasedIndex + 1, nextVersionIndex); +} + +function createEntry(content: string, type: EntryType): ChangelogEntry { + const firstLine = content.split('\n')[0] ?? content; + const prNumber = extractPRNumber(firstLine); + return { + type, + content, + sortKey: extractSortKey(firstLine), + prNumber, + }; +} + +function parseChangelog(unreleasedLines: string[]): ParsedChangelog { + const importantChanges: ChangelogEntry[] = []; + const otherChanges: ChangelogEntry[] = []; + const internalChanges: ChangelogEntry[] = []; + const changelogPRs = new Set(); + let contributorsLine = ''; + + let currentEntry: string[] = []; + let currentType: EntryType | null = null; + let inDetailsBlock = false; + let detailsContent: string[] = []; + + const addEntry = (entry: ChangelogEntry): void => { + if (entry.prNumber) { + changelogPRs.add(entry.prNumber); + } + + if (entry.type === 'important') { + importantChanges.push(entry); + } else if (entry.type === 'internal') { + internalChanges.push(entry); + } else { + otherChanges.push(entry); + } + }; + + const flushCurrentEntry = (): void => { + if (currentEntry.length === 0 || !currentType) return; + + // Remove trailing empty lines from the entry + while (currentEntry.length > 0 && !currentEntry[currentEntry.length - 1]?.trim()) { + currentEntry.pop(); + } + + if (currentEntry.length === 0) return; + + const entry = createEntry(currentEntry.join('\n'), currentType); + addEntry(entry); + + currentEntry = []; + currentType = null; + }; + + const processDetailsContent = (): void => { + for (const line of detailsContent) { + const trimmed = line.trim(); + if (trimmed.startsWith('-') && trimmed.includes('(#')) { + const entry = createEntry(trimmed, 'internal'); + addEntry(entry); + } + } + detailsContent = []; + }; + + for (const line of unreleasedLines) { + // Skip undefined/null lines + if (line == null) continue; + + // Skip empty lines at the start of an entry + if (!line.trim() && currentEntry.length === 0) continue; + + // Skip quote lines + if (isQuoteLine(line)) continue; + + // Capture contributors line + if (isContributorsLine(line)) { + contributorsLine = line; + continue; + } + + // Skip section headings + if (isSectionHeading(line)) { + flushCurrentEntry(); + continue; + } + + // Handle details block + if (line.includes('
')) { + inDetailsBlock = true; + detailsContent = []; + continue; + } + + if (line.includes('
')) { + inDetailsBlock = false; + processDetailsContent(); + continue; + } + + if (inDetailsBlock) { + if (!line.includes('')) { + detailsContent.push(line); + } + continue; + } + + // Handle regular entries + if (line.trim().startsWith('- ')) { + flushCurrentEntry(); + currentEntry = [line]; + currentType = determineEntryType(line); + } else if (currentEntry.length > 0) { + currentEntry.push(line); + } + } + + flushCurrentEntry(); + + return { importantChanges, otherChanges, internalChanges, changelogPRs, contributorsLine }; +} + +// ============================================================================ +// Output Generation +// ============================================================================ + +export function sortEntries(entries: ChangelogEntry[]): void { + entries.sort((a, b) => a.sortKey.localeCompare(b.sortKey)); +} + +function generateOutput( + importantChanges: ChangelogEntry[], + otherChanges: ChangelogEntry[], + internalChanges: ChangelogEntry[], + contributorsLine: string, +): string { + const output: string[] = []; + + if (importantChanges.length > 0) { + output.push('### Important Changes', ''); + for (const entry of importantChanges) { + output.push(entry.content, ''); + } + } + + if (otherChanges.length > 0) { + output.push('### Other Changes', ''); + for (const entry of otherChanges) { + output.push(entry.content); + } + output.push(''); + } + + if (internalChanges.length > 0) { + output.push('
', ' Internal Changes', ''); + for (const entry of internalChanges) { + output.push(entry.content); + } + output.push('', '
', ''); + } + + if (contributorsLine) { + output.push(contributorsLine); + } + + return output.join('\n'); +} + +// ============================================================================ +// Main +// ============================================================================ + +function run(): void { + const changelogPath = join(__dirname, '..', 'CHANGELOG.md'); + const changelogContent = readFileSync(changelogPath, 'utf-8'); + const unreleasedLines = getUnreleasedSection(changelogContent); + + // Parse existing changelog entries + const { importantChanges, otherChanges, internalChanges, changelogPRs, contributorsLine } = + parseChangelog(unreleasedLines); + + // Add new git commits that aren't already in the changelog + for (const commit of getNewGitCommits()) { + const prNumber = extractPRNumber(commit); + + // Skip duplicates + if (prNumber && changelogPRs.has(prNumber)) { + continue; + } + + const entry = createEntry(commit, isInternalCommit(commit) ? 'internal' : 'other'); + + if (entry.type === 'internal') { + internalChanges.push(entry); + } else { + otherChanges.push(entry); + } + } + + // Sort all categories + sortEntries(importantChanges); + sortEntries(otherChanges); + sortEntries(internalChanges); + + // eslint-disable-next-line no-console + console.log(generateOutput(importantChanges, otherChanges, internalChanges, contributorsLine)); +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function extractPRNumber(line: string): string | null { + const match = line.match(/#(\d+)/); + return match?.[1] ?? null; +} + +function extractSortKey(line: string): string { + return line + .trim() + .replace(/^- /, '') + .replace(/\*\*/g, '') + .replace(/\s*\(\[#\d+\].*?\)\s*$/, '') + .toLowerCase(); +} + +function isQuoteLine(line: string): boolean { + return line.includes('—') && (line.includes('Wayne Gretzky') || line.includes('Michael Scott')); +} + +function isContributorsLine(line: string): boolean { + return line.includes('Work in this release was contributed by'); +} + +function isSectionHeading(line: string): boolean { + const trimmed = line.trim(); + return trimmed === '### Important Changes' || trimmed === '### Other Changes'; +} + +function isInternalCommit(line: string): boolean { + return /^- (chore|ref|test|meta)/.test(line.trim()); +} + +function isImportantEntry(line: string): boolean { + return line.includes('**feat') || line.includes('**fix'); +} + +function determineEntryType(line: string): EntryType { + if (isImportantEntry(line)) { + return 'important'; + } + if (isInternalCommit(line)) { + return 'internal'; + } + return 'other'; +} + +run(); diff --git a/scripts/get-commit-list.ts b/scripts/get-commit-list.ts index 04c5932c12cd..b7fbb1c54471 100644 --- a/scripts/get-commit-list.ts +++ b/scripts/get-commit-list.ts @@ -1,6 +1,8 @@ import { execSync } from 'child_process'; -function run(): void { +const ISSUE_URL = 'https://github.com/getsentry/sentry-javascript/pull/'; + +export function getNewGitCommits(): string[] { const commits = execSync('git log --format="- %s"').toString().split('\n'); const lastReleasePos = commits.findIndex(commit => /- meta\(changelog\)/i.test(commit)); @@ -24,11 +26,15 @@ function run(): void { newCommits.sort((a, b) => a.localeCompare(b)); - const issueUrl = 'https://github.com/getsentry/sentry-javascript/pull/'; - const newCommitsWithLink = newCommits.map(commit => commit.replace(/#(\d+)/, `[#$1](${issueUrl}$1)`)); + return newCommits.map(commit => commit.replace(/#(\d+)/, `[#$1](${ISSUE_URL}$1)`)); +} +function run(): void { // eslint-disable-next-line no-console - console.log(newCommitsWithLink.join('\n')); + console.log(getNewGitCommits().join('\n')); } -run(); +// Only run when executed directly, not when imported +if (require.main === module) { + run(); +}