Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions scripts/release-changelog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
bumpVersion,
Commit,
formatReleaseNotes,
formatReleasePrBody,
generateChangesetContent,
} from './release-changelog';

const commit = (subject: string, body = ''): Commit => ({
hash: 'abc1234',
subject,
body,
});

const pr = (n: number) =>
`[#${n}](https://github.com/remoteoss/remote-flows/pull/${n})`;

describe('generateChangesetContent', () => {
it('groups commits by type, keeping commit order within each section', () => {
const changeset = generateChangesetContent([
commit('ci(e2e): only surface failures (#5)'),
commit('fix(onboarding): disable submit while checking (#2)'),
commit('feat(onboarding): wire onBlur events (#1)'),
commit('docs: keep public JSDoc short (#4)'),
commit('test(e2e): cover China salary conversion (#6)'),
commit('fix: pre-fill resumed steps (#3)'),
commit('chore(example): bump GBR schema version (#7)'),
commit('refactor: extract helper (#8)'),
]);

expect(changeset).toEqual({
versionBump: 'minor',
content: `#### Features

- wire onBlur events (#1) ${pr(1)}

#### Fixes

- disable submit while checking (#2) ${pr(2)}
- pre-fill resumed steps (#3) ${pr(3)}

#### Docs

- keep public JSDoc short (#4) ${pr(4)}

#### Chores

- only surface failures (#5) ${pr(5)}
- cover China salary conversion (#6) ${pr(6)}
- bump GBR schema version (#7) ${pr(7)}
- extract helper (#8) ${pr(8)}`,
});
});

it('leaves out empty sections', () => {
expect(
generateChangesetContent([
commit('fix: stable pricing plan schema (#9)'),
commit('chore: bump tsx (#10)'),
]),
).toEqual({
versionBump: 'patch',
content: `#### Fixes

- stable pricing plan schema (#9) ${pr(9)}

#### Chores

- bump tsx (#10) ${pr(10)}`,
});
});

it('bumps major when a feat or fix body has a BREAKING CHANGE footer', () => {
expect(
generateChangesetContent([
commit('feat: add flag (#1)'),
commit('fix: drop legacy prop (#2)', 'BREAKING CHANGE: prop removed'),
])?.versionBump,
).toBe('major');
});

it('keeps backticks and quotes in descriptions as-is', () => {
expect(
generateChangesetContent([
commit('fix: stop stripping the required `name` field "now" (#11)'),
])?.content,
).toBe(
`#### Fixes\n\n- stop stripping the required \`name\` field "now" (#11) ${pr(11)}`,
);
});

it('omits the PR link when the commit has no PR number', () => {
expect(generateChangesetContent([commit('chore: tidy up')])?.content).toBe(
'#### Chores\n\n- tidy up',
);
});

it('returns null when no commit is conventional', () => {
expect(generateChangesetContent([commit('Update README')])).toBeNull();
});
});

describe('bumpVersion', () => {
it.each([
['patch', '1.60.1'],
['minor', '1.61.0'],
['major', '2.0.0'],
] as const)('applies a %s bump', (versionBump, expected) => {
expect(bumpVersion('1.60.0', versionBump)).toBe(expected);
});
});

describe('release notes', () => {
const changeset = {
versionBump: 'minor' as const,
content: `#### Features\n\n- wire onBlur events (#1) ${pr(1)}`,
};

it('formats the changelog entry', () => {
expect(formatReleaseNotes('1.61.0', changeset)).toBe(`## 1.61.0

### Minor Changes

#### Features

- wire onBlur events (#1) ${pr(1)}`);
});

it('appends the generated-by footer to the PR body', () => {
expect(formatReleasePrBody('1.61.0', changeset)).toBe(`## 1.61.0

### Minor Changes

#### Features

- wire onBlur events (#1) ${pr(1)}

---

This release was automatically generated from conventional commits.`);
});
});
144 changes: 144 additions & 0 deletions scripts/release-changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
export type VersionBump = 'patch' | 'minor' | 'major';

export interface Commit {
hash: string;
subject: string;
body: string;
}

export interface ParsedCommit {
type: string;
scope?: string;
description: string;
versionBump: VersionBump;
prNumber?: string;
hash: string;
}

export interface Changeset {
versionBump: VersionBump;
content: string;
}

const CHANGELOG_SECTIONS: Record<string, string> = {
feat: 'Features',
fix: 'Fixes',
docs: 'Docs',
};

export function parseConventionalCommit(commit: Commit): ParsedCommit | null {
const { subject, body } = commit;

// Updated regex to handle both colon and dash separators
const match = subject.match(/^(\w+)(?:\(([^)]+)\))?\s*[-:]\s*(.+)$/);

if (!match) {
return null;
}

const [, type, scope, description] = match;
let versionBump: VersionBump = 'patch';

if (type === 'feat') {
versionBump = 'minor';
}
if (type === 'feat' && body.includes('BREAKING CHANGE')) {
versionBump = 'major';
}
if (type === 'fix' && body.includes('BREAKING CHANGE')) {
versionBump = 'major';
}

const prMatch = (description + ' ' + body).match(/#(\d+)/);
const prNumber = prMatch ? prMatch[1] : undefined;

return {
type,
scope,
description,
versionBump,
prNumber,
hash: commit.hash,
};
}

export function generateChangesetContent(commits: Commit[]): Changeset | null {
const parsedCommits = commits
.map(parseConventionalCommit)
.filter((commit): commit is ParsedCommit => commit !== null);

if (parsedCommits.length === 0) {
return null;
}

let versionBump: VersionBump = 'patch';
if (parsedCommits.some((commit) => commit.versionBump === 'major')) {
versionBump = 'major';
} else if (parsedCommits.some((commit) => commit.versionBump === 'minor')) {
versionBump = 'minor';
}

const sections: Record<string, string[]> = {
Features: [],
Fixes: [],
Docs: [],
Chores: [],
};

parsedCommits.forEach((commit) => {
const prText = commit.prNumber
? ` [#${commit.prNumber}](https://github.com/remoteoss/remote-flows/pull/${commit.prNumber})`
: '';
sections[CHANGELOG_SECTIONS[commit.type] ?? 'Chores'].push(
`- ${commit.description}${prText}`,
);
});

return {
versionBump,
content: Object.entries(sections)
.filter(([, items]) => items.length > 0)
.map(([title, items]) => `#### ${title}\n\n${items.join('\n')}`)
.join('\n\n'),
};
}

export function bumpVersion(version: string, versionBump: VersionBump): string {
const [major, minor, patch] = version.split('.').map(Number);
if (versionBump === 'major') {
return `${major + 1}.0.0`;
}
if (versionBump === 'minor') {
return `${major}.${minor + 1}.0`;
}
return `${major}.${minor}.${patch + 1}`;
}

export function formatReleaseNotes(
version: string,
changeset: Changeset,
): string {
const versionType =
changeset.versionBump === 'major'
? 'Major'
: changeset.versionBump === 'minor'
? 'Minor'
: 'Patch';

return `## ${version}

### ${versionType} Changes

${changeset.content}`;
}

export function formatReleasePrBody(
version: string,
changeset: Changeset,
): string {
return `${formatReleaseNotes(version, changeset)}

---

This release was automatically generated from conventional commits.`;
}
Loading
Loading