@oorabona/release-it-preset 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +833 -0
- package/bin/cli.js +180 -0
- package/bin/run-script.js +72 -0
- package/config/changelog-only.js +41 -0
- package/config/default.js +54 -0
- package/config/helpers.js +52 -0
- package/config/hotfix.js +51 -0
- package/config/manual-changelog.js +64 -0
- package/config/no-changelog.js +40 -0
- package/config/republish.js +60 -0
- package/config/retry-publish.js +40 -0
- package/dist/scripts/check-config.js +285 -0
- package/dist/scripts/check-pr-status.js +164 -0
- package/dist/scripts/extract-changelog.js +66 -0
- package/dist/scripts/init-project.js +191 -0
- package/dist/scripts/lib/commit-parser.js +67 -0
- package/dist/scripts/lib/git-utils.js +33 -0
- package/dist/scripts/lib/semver-utils.js +26 -0
- package/dist/scripts/lib/string-utils.js +12 -0
- package/dist/scripts/populate-unreleased-changelog.js +236 -0
- package/dist/scripts/republish-changelog.js +187 -0
- package/dist/scripts/retry-publish.js +98 -0
- package/dist/scripts/validate-release.js +288 -0
- package/dist/types/check-config.d.ts +65 -0
- package/dist/types/check-pr-status.d.ts +51 -0
- package/dist/types/extract-changelog.d.ts +23 -0
- package/dist/types/init-project.d.ts +37 -0
- package/dist/types/lib/commit-parser.d.ts +44 -0
- package/dist/types/lib/git-utils.d.ts +20 -0
- package/dist/types/lib/semver-utils.d.ts +18 -0
- package/dist/types/lib/string-utils.d.ts +10 -0
- package/dist/types/populate-unreleased-changelog.d.ts +56 -0
- package/dist/types/republish-changelog.d.ts +39 -0
- package/dist/types/retry-publish.d.ts +33 -0
- package/dist/types/validate-release.d.ts +43 -0
- package/package.json +93 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Initialize a project with release-it-preset
|
|
4
|
+
*
|
|
5
|
+
* This script:
|
|
6
|
+
* - Creates CHANGELOG.md with Keep a Changelog template
|
|
7
|
+
* - Creates .release-it.json with extends configuration
|
|
8
|
+
* - Optionally adds scripts to package.json
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* tsx init-project.ts [--yes]
|
|
12
|
+
*
|
|
13
|
+
* Options:
|
|
14
|
+
* --yes Skip prompts and use defaults
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
const CHANGELOG_TEMPLATE = `# Changelog
|
|
19
|
+
|
|
20
|
+
All notable changes to this project will be documented in this file.
|
|
21
|
+
|
|
22
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
23
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
24
|
+
|
|
25
|
+
## [Unreleased]
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
- Initial release
|
|
29
|
+
`;
|
|
30
|
+
const RELEASE_IT_CONFIG = `{
|
|
31
|
+
"extends": "@oorabona/release-it-preset/config/default"
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
const SUGGESTED_SCRIPTS = {
|
|
35
|
+
'release': 'release-it-preset default',
|
|
36
|
+
'release:hotfix': 'release-it-preset hotfix',
|
|
37
|
+
'release:dry': 'release-it-preset default --dry-run',
|
|
38
|
+
'changelog:update': 'release-it-preset update',
|
|
39
|
+
};
|
|
40
|
+
export function parseArgs(args) {
|
|
41
|
+
/* c8 ignore next */
|
|
42
|
+
const argv = args || process.argv.slice(2);
|
|
43
|
+
return {
|
|
44
|
+
yes: argv.includes('--yes') || argv.includes('-y'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function createChangelog(options, deps) {
|
|
48
|
+
const path = 'CHANGELOG.md';
|
|
49
|
+
if (deps.existsSync(path)) {
|
|
50
|
+
deps.warn(`⚠️ ${path} already exists`);
|
|
51
|
+
if (!options.yes) {
|
|
52
|
+
const overwrite = await deps.prompt('Overwrite it?');
|
|
53
|
+
if (!overwrite) {
|
|
54
|
+
deps.log(`ℹ️ Skipping ${path}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
deps.log(`ℹ️ Skipping ${path} (--yes mode, not overwriting existing files)`);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
deps.writeFileSync(path, CHANGELOG_TEMPLATE);
|
|
64
|
+
deps.log(`✅ Created ${path}`);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
export async function createReleaseItConfig(options, deps) {
|
|
68
|
+
const path = '.release-it.json';
|
|
69
|
+
if (deps.existsSync(path)) {
|
|
70
|
+
deps.warn(`⚠️ ${path} already exists`);
|
|
71
|
+
if (!options.yes) {
|
|
72
|
+
const overwrite = await deps.prompt('Overwrite it?');
|
|
73
|
+
if (!overwrite) {
|
|
74
|
+
deps.log(`ℹ️ Skipping ${path}`);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
deps.log(`ℹ️ Skipping ${path} (--yes mode, not overwriting existing files)`);
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
deps.writeFileSync(path, RELEASE_IT_CONFIG);
|
|
84
|
+
deps.log(`✅ Created ${path}`);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
export async function updatePackageJson(options, deps) {
|
|
88
|
+
const path = 'package.json';
|
|
89
|
+
if (!deps.existsSync(path)) {
|
|
90
|
+
deps.warn(`⚠️ ${path} not found, skipping script addition`);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const packageJson = JSON.parse(deps.readFileSync(path, 'utf8'));
|
|
95
|
+
if (!packageJson.scripts) {
|
|
96
|
+
packageJson.scripts = {};
|
|
97
|
+
}
|
|
98
|
+
const scriptsToAdd = {};
|
|
99
|
+
let hasConflicts = false;
|
|
100
|
+
for (const [name, command] of Object.entries(SUGGESTED_SCRIPTS)) {
|
|
101
|
+
if (packageJson.scripts[name]) {
|
|
102
|
+
deps.warn(`⚠️ Script "${name}" already exists in package.json`);
|
|
103
|
+
hasConflicts = true;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
scriptsToAdd[name] = command;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(scriptsToAdd).length === 0) {
|
|
110
|
+
deps.log(`ℹ️ All suggested scripts already exist in package.json`);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (!options.yes) {
|
|
114
|
+
deps.log(`\n📝 Suggested scripts to add to package.json:`);
|
|
115
|
+
for (const [name, command] of Object.entries(scriptsToAdd)) {
|
|
116
|
+
deps.log(` "${name}": "${command}"`);
|
|
117
|
+
}
|
|
118
|
+
const addScripts = await deps.prompt('\nAdd these scripts to package.json?');
|
|
119
|
+
if (!addScripts) {
|
|
120
|
+
deps.log(`ℹ️ Skipping package.json scripts`);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
Object.assign(packageJson.scripts, scriptsToAdd);
|
|
125
|
+
deps.writeFileSync(path, JSON.stringify(packageJson, null, 2) + '\n');
|
|
126
|
+
deps.log(`✅ Updated ${path} with ${Object.keys(scriptsToAdd).length} script(s)`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
deps.warn(`❌ Failed to update ${path}: ${error}`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export async function initProject(options, deps) {
|
|
135
|
+
deps.log('🚀 Initializing project with release-it-preset\n');
|
|
136
|
+
if (options.yes) {
|
|
137
|
+
deps.log('ℹ️ Running in --yes mode (non-interactive)\n');
|
|
138
|
+
}
|
|
139
|
+
const results = {
|
|
140
|
+
changelog: await createChangelog(options, deps),
|
|
141
|
+
releaseIt: await createReleaseItConfig(options, deps),
|
|
142
|
+
packageJson: await updatePackageJson(options, deps),
|
|
143
|
+
};
|
|
144
|
+
deps.log('\n📊 Summary:');
|
|
145
|
+
deps.log(` CHANGELOG.md: ${results.changelog ? '✅ Created' : '⏭️ Skipped'}`);
|
|
146
|
+
deps.log(` .release-it.json: ${results.releaseIt ? '✅ Created' : '⏭️ Skipped'}`);
|
|
147
|
+
deps.log(` package.json: ${results.packageJson ? '✅ Updated' : '⏭️ Skipped'}`);
|
|
148
|
+
const anyCreated = Object.values(results).some((v) => v);
|
|
149
|
+
if (anyCreated) {
|
|
150
|
+
deps.log('\n🎉 Initialization complete!');
|
|
151
|
+
deps.log('\nNext steps:');
|
|
152
|
+
deps.log(' 1. Review the generated files');
|
|
153
|
+
deps.log(' 2. Update CHANGELOG.md [Unreleased] section');
|
|
154
|
+
deps.log(' 3. Run: pnpm release-it-preset default --dry-run');
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
deps.log('\n✨ All files already exist, nothing to do!');
|
|
158
|
+
}
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* CLI entry point - only runs when script is executed directly
|
|
163
|
+
*/
|
|
164
|
+
/* c8 ignore start */
|
|
165
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
166
|
+
async function realPrompt(question) {
|
|
167
|
+
const rl = createInterface({
|
|
168
|
+
input: process.stdin,
|
|
169
|
+
output: process.stdout,
|
|
170
|
+
});
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
rl.question(`${question} (y/N): `, (answer) => {
|
|
173
|
+
rl.close();
|
|
174
|
+
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const options = parseArgs();
|
|
179
|
+
initProject(options, {
|
|
180
|
+
existsSync,
|
|
181
|
+
readFileSync,
|
|
182
|
+
writeFileSync,
|
|
183
|
+
prompt: realPrompt,
|
|
184
|
+
log: console.log,
|
|
185
|
+
warn: console.warn,
|
|
186
|
+
}).catch((error) => {
|
|
187
|
+
console.error('❌ Initialization failed:', error);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/* c8 ignore end */
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conventional commit parsing utilities
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Regex pattern for matching conventional commits
|
|
6
|
+
* Matches: type(scope)?: description
|
|
7
|
+
* Example: feat(api): add new endpoint
|
|
8
|
+
*/
|
|
9
|
+
export const CONVENTIONAL_COMMIT_REGEX = /^(\w+)(?:\(([^)]+)\))?(!)?\s*:\s*(.+)/m;
|
|
10
|
+
/**
|
|
11
|
+
* Strict conventional commit types as defined by Conventional Commits specification
|
|
12
|
+
* Used for strict validation in PR checks
|
|
13
|
+
*/
|
|
14
|
+
export const STRICT_CONVENTIONAL_TYPES = [
|
|
15
|
+
'feat',
|
|
16
|
+
'fix',
|
|
17
|
+
'docs',
|
|
18
|
+
'style',
|
|
19
|
+
'refactor',
|
|
20
|
+
'perf',
|
|
21
|
+
'test',
|
|
22
|
+
'chore',
|
|
23
|
+
'build',
|
|
24
|
+
'ci',
|
|
25
|
+
'revert',
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Regex for strict conventional commit validation (only approved types)
|
|
29
|
+
*/
|
|
30
|
+
export const STRICT_CONVENTIONAL_COMMIT_REGEX = new RegExp(`^(${STRICT_CONVENTIONAL_TYPES.join('|')})(\\(.+\\))?:\\s.+`, 'i');
|
|
31
|
+
/**
|
|
32
|
+
* Check if a commit message matches conventional commit format (lenient)
|
|
33
|
+
*
|
|
34
|
+
* @param message Commit message to check
|
|
35
|
+
* @returns true if message matches conventional format
|
|
36
|
+
*/
|
|
37
|
+
export function isConventionalCommit(message) {
|
|
38
|
+
return CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if a commit message matches strict conventional commit format
|
|
42
|
+
*
|
|
43
|
+
* @param message Commit message to check
|
|
44
|
+
* @returns true if message matches strict format
|
|
45
|
+
*/
|
|
46
|
+
export function isStrictConventionalCommit(message) {
|
|
47
|
+
return STRICT_CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Extract conventional commit parts from a message
|
|
51
|
+
*
|
|
52
|
+
* @param message Commit message
|
|
53
|
+
* @returns Parsed parts or null if not conventional
|
|
54
|
+
*/
|
|
55
|
+
export function parseConventionalCommit(message) {
|
|
56
|
+
const match = message.match(CONVENTIONAL_COMMIT_REGEX);
|
|
57
|
+
if (!match) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const [, type, scope, breaking, description] = match;
|
|
61
|
+
return {
|
|
62
|
+
type: type.trim(),
|
|
63
|
+
scope: scope?.trim(),
|
|
64
|
+
breaking: Boolean(breaking),
|
|
65
|
+
description: description.trim(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git utility functions shared across scripts
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Get GitHub repository URL for commit/tag links
|
|
6
|
+
*
|
|
7
|
+
* Priority:
|
|
8
|
+
* 1. GITHUB_REPOSITORY environment variable (format: owner/repo)
|
|
9
|
+
* 2. Git remote URL (extracted from git config)
|
|
10
|
+
*
|
|
11
|
+
* @param deps Dependencies for git operations
|
|
12
|
+
* @returns Repository URL or empty string if not determinable
|
|
13
|
+
*/
|
|
14
|
+
export function getGitHubRepoUrl(deps) {
|
|
15
|
+
const githubRepo = deps.getEnv('GITHUB_REPOSITORY');
|
|
16
|
+
if (githubRepo) {
|
|
17
|
+
return `https://github.com/${githubRepo}`;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const remote = deps.getEnv('GIT_REMOTE') || 'origin';
|
|
21
|
+
const remoteUrl = deps.execSync(`git config --get remote.${remote}.url`, { encoding: 'utf8' }).trim();
|
|
22
|
+
return remoteUrl
|
|
23
|
+
.replace(/^git@github\.com:/, 'https://github.com/')
|
|
24
|
+
.replace(/\.git$/, '');
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
if (deps.warn) {
|
|
28
|
+
deps.warn('⚠️ Could not determine repository URL. Links will not be generated.');
|
|
29
|
+
deps.warn(' Set GITHUB_REPOSITORY environment variable (e.g., owner/repo)');
|
|
30
|
+
}
|
|
31
|
+
return '';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic versioning utility functions
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Validate if a string is a valid semantic version
|
|
6
|
+
*
|
|
7
|
+
* @param version Version string to validate (can have optional 'v' prefix)
|
|
8
|
+
* @returns true if valid semver, false otherwise
|
|
9
|
+
*/
|
|
10
|
+
export function isValidSemver(version) {
|
|
11
|
+
const semverRegex = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
12
|
+
return semverRegex.test(version);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validate and normalize a semantic version string
|
|
16
|
+
*
|
|
17
|
+
* @param version Version string to validate
|
|
18
|
+
* @throws Error if version is not valid semver
|
|
19
|
+
* @returns Normalized version without 'v' prefix
|
|
20
|
+
*/
|
|
21
|
+
export function validateAndNormalizeSemver(version) {
|
|
22
|
+
if (!isValidSemver(version)) {
|
|
23
|
+
throw new Error(`Invalid semantic version: "${version}". Expected format: [v]MAJOR.MINOR.PATCH[-prerelease][+buildmetadata]`);
|
|
24
|
+
}
|
|
25
|
+
return version.replace(/^v/, '');
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String utility functions shared across scripts
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Escape special regex characters in a string
|
|
6
|
+
*
|
|
7
|
+
* @param input String to escape
|
|
8
|
+
* @returns Escaped string safe for use in RegExp
|
|
9
|
+
*/
|
|
10
|
+
export function escapeRegExp(input) {
|
|
11
|
+
return input.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
12
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Populate [Unreleased] section with commits since the last tag
|
|
4
|
+
*
|
|
5
|
+
* This script:
|
|
6
|
+
* - Extracts commits since the last git tag
|
|
7
|
+
* - Parses conventional commit messages
|
|
8
|
+
* - Groups commits by type (Added, Fixed, Changed, etc.)
|
|
9
|
+
* - Updates the [Unreleased] section in CHANGELOG.md
|
|
10
|
+
* - Generates commit links using the repository URL
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* tsx populate-unreleased-changelog.ts
|
|
14
|
+
*
|
|
15
|
+
* Environment variables:
|
|
16
|
+
* CHANGELOG_FILE - Path to changelog file (default: CHANGELOG.md)
|
|
17
|
+
* GITHUB_REPOSITORY - GitHub repo (owner/repo) for commit links
|
|
18
|
+
* GIT_REMOTE - Git remote name (default: origin)
|
|
19
|
+
*/
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { getGitHubRepoUrl } from './lib/git-utils.js';
|
|
23
|
+
import { CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
|
|
24
|
+
/**
|
|
25
|
+
* Extract all conventional commit patterns from a commit body
|
|
26
|
+
*/
|
|
27
|
+
export function extractConventionalCommitParts(commitBody, sha) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
const conventionalCommitRegex = new RegExp(CONVENTIONAL_COMMIT_REGEX.source, 'gm');
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = conventionalCommitRegex.exec(commitBody)) !== null) {
|
|
32
|
+
const [, type, scope, breaking, description] = match;
|
|
33
|
+
if (type && description && description.trim()) {
|
|
34
|
+
const cleanDescription = description.trim().replace(/\s+/g, ' ');
|
|
35
|
+
parts.push({
|
|
36
|
+
type: type.trim(),
|
|
37
|
+
scope: scope?.trim(),
|
|
38
|
+
description: cleanDescription,
|
|
39
|
+
sha,
|
|
40
|
+
breaking: Boolean(breaking),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return parts;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Normalize commit types to standard changelog categories
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeCommitType(type) {
|
|
50
|
+
const typeMap = {
|
|
51
|
+
feat: '### Added',
|
|
52
|
+
feature: '### Added',
|
|
53
|
+
add: '### Added',
|
|
54
|
+
fix: '### Fixed',
|
|
55
|
+
bugfix: '### Fixed',
|
|
56
|
+
security: '### Security',
|
|
57
|
+
perf: '### Changed',
|
|
58
|
+
refactor: '### Changed',
|
|
59
|
+
style: '### Changed',
|
|
60
|
+
docs: '### Changed',
|
|
61
|
+
test: '### Changed',
|
|
62
|
+
chore: '### Changed',
|
|
63
|
+
build: '### Changed',
|
|
64
|
+
deps: '### Changed',
|
|
65
|
+
dependency: '### Changed',
|
|
66
|
+
dependencies: '### Changed',
|
|
67
|
+
revert: '### Changed',
|
|
68
|
+
remove: '### Removed',
|
|
69
|
+
removed: '### Removed',
|
|
70
|
+
delete: '### Removed',
|
|
71
|
+
deleted: '### Removed',
|
|
72
|
+
ci: false,
|
|
73
|
+
release: false,
|
|
74
|
+
hotfix: false,
|
|
75
|
+
misc: '### Changed',
|
|
76
|
+
};
|
|
77
|
+
const result = typeMap[type.toLowerCase()];
|
|
78
|
+
return result !== undefined ? result : '### Changed';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse git log output and extract all conventional commit parts
|
|
82
|
+
*/
|
|
83
|
+
export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
|
|
84
|
+
if (!gitOutput)
|
|
85
|
+
return '';
|
|
86
|
+
const commitEntries = gitOutput.split('|||END|||').filter((entry) => entry.trim());
|
|
87
|
+
const allParts = [];
|
|
88
|
+
for (const entry of commitEntries) {
|
|
89
|
+
const [sha, ...bodyParts] = entry.split('|');
|
|
90
|
+
const body = bodyParts.join('|').trim();
|
|
91
|
+
if (/\[skip-changelog\]/i.test(body)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (sha && body) {
|
|
95
|
+
const shortSha = sha.trim().substring(0, 7);
|
|
96
|
+
const parts = extractConventionalCommitParts(body, shortSha);
|
|
97
|
+
if (parts.length === 0) {
|
|
98
|
+
const firstLine = body.split('\n')[0].trim();
|
|
99
|
+
if (firstLine) {
|
|
100
|
+
allParts.push({
|
|
101
|
+
type: 'misc',
|
|
102
|
+
description: firstLine,
|
|
103
|
+
sha: shortSha,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
allParts.push(...parts);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const groupedParts = {};
|
|
113
|
+
const breakingChanges = [];
|
|
114
|
+
for (const part of allParts) {
|
|
115
|
+
// Collect breaking changes separately
|
|
116
|
+
if (part.breaking) {
|
|
117
|
+
breakingChanges.push(part);
|
|
118
|
+
}
|
|
119
|
+
const sectionName = normalizeCommitType(part.type);
|
|
120
|
+
if (sectionName === false) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!groupedParts[sectionName]) {
|
|
124
|
+
groupedParts[sectionName] = [];
|
|
125
|
+
}
|
|
126
|
+
groupedParts[sectionName].push(part);
|
|
127
|
+
}
|
|
128
|
+
const sections = [];
|
|
129
|
+
const sectionOrder = ['### Added', '### Fixed', '### Changed', '### Removed', '### Security'];
|
|
130
|
+
// Add BREAKING CHANGES section first if there are any
|
|
131
|
+
if (breakingChanges.length > 0) {
|
|
132
|
+
sections.push('### ⚠️ BREAKING CHANGES');
|
|
133
|
+
sections.push(...breakingChanges.map((part) => {
|
|
134
|
+
const scopePart = part.scope ? ` (${part.scope})` : '';
|
|
135
|
+
const linkPart = repoUrl ? ` ([${part.sha}](${repoUrl}/commit/${part.sha}))` : ` (${part.sha})`;
|
|
136
|
+
return `- ${part.description}${scopePart}${linkPart}`;
|
|
137
|
+
}));
|
|
138
|
+
sections.push('');
|
|
139
|
+
}
|
|
140
|
+
for (const sectionTitle of sectionOrder) {
|
|
141
|
+
if (groupedParts[sectionTitle] && groupedParts[sectionTitle].length > 0) {
|
|
142
|
+
sections.push(sectionTitle);
|
|
143
|
+
sections.push(...groupedParts[sectionTitle].map((part) => {
|
|
144
|
+
const scopePart = part.scope ? ` (${part.scope})` : '';
|
|
145
|
+
const breakingIndicator = part.breaking ? ' ⚠️ BREAKING' : '';
|
|
146
|
+
const linkPart = repoUrl ? ` ([${part.sha}](${repoUrl}/commit/${part.sha}))` : ` (${part.sha})`;
|
|
147
|
+
return `- ${part.description}${scopePart}${breakingIndicator}${linkPart}`;
|
|
148
|
+
}));
|
|
149
|
+
sections.push('');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return sections.length > 0 ? sections.join('\n').trim() : 'No changes yet.';
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Main function to populate changelog with dependency injection
|
|
156
|
+
*/
|
|
157
|
+
export function populateChangelog(deps) {
|
|
158
|
+
const changelogPath = deps.getEnv('CHANGELOG_FILE') || 'CHANGELOG.md';
|
|
159
|
+
deps.log('📝 Populating [Unreleased] section...');
|
|
160
|
+
let latestTag;
|
|
161
|
+
try {
|
|
162
|
+
latestTag = deps.execSync('git describe --tags --abbrev=0 2>/dev/null', { encoding: 'utf8' }).trim();
|
|
163
|
+
deps.log(`ℹ️ Latest tag: ${latestTag}`);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
deps.log('ℹ️ No tags found, using all commits');
|
|
167
|
+
latestTag = '';
|
|
168
|
+
}
|
|
169
|
+
const gitLogCommand = latestTag
|
|
170
|
+
? `git log --pretty=format:"%H|%B|||END|||" ${latestTag}..HEAD`
|
|
171
|
+
: `git log --pretty=format:"%H|%B|||END|||"`;
|
|
172
|
+
let gitOutput;
|
|
173
|
+
try {
|
|
174
|
+
gitOutput = deps.execSync(gitLogCommand, { encoding: 'utf8' }).trim();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
deps.log('ℹ️ No new commits found');
|
|
178
|
+
gitOutput = '';
|
|
179
|
+
}
|
|
180
|
+
const repoUrl = getGitHubRepoUrl({
|
|
181
|
+
execSync: deps.execSync,
|
|
182
|
+
getEnv: deps.getEnv,
|
|
183
|
+
warn: deps.warn,
|
|
184
|
+
});
|
|
185
|
+
const commits = parseCommitsWithMultiplePrefixes(gitOutput, repoUrl);
|
|
186
|
+
const changelog = deps.readFileSync(changelogPath, 'utf8');
|
|
187
|
+
const unreleasedContent = commits && commits.trim() ? commits : 'No changes yet.';
|
|
188
|
+
const unreleasedRegex = /## \[Unreleased\][\s\S]*?(?=## \[|$)/;
|
|
189
|
+
const newUnreleasedSection = `## [Unreleased]\n\n${unreleasedContent}\n\n`;
|
|
190
|
+
let updatedChangelog;
|
|
191
|
+
if (changelog.match(unreleasedRegex)) {
|
|
192
|
+
updatedChangelog = changelog.replace(unreleasedRegex, newUnreleasedSection);
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const doubleNewlineIndex = changelog.indexOf('\n\n');
|
|
196
|
+
if (doubleNewlineIndex === -1) {
|
|
197
|
+
const trimmed = changelog.trimEnd();
|
|
198
|
+
const separator = trimmed.length === 0 ? '' : '\n\n';
|
|
199
|
+
updatedChangelog = `${trimmed}${separator}${newUnreleasedSection}`;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const insertionPoint = doubleNewlineIndex + 2;
|
|
203
|
+
updatedChangelog = changelog.slice(0, insertionPoint) + newUnreleasedSection + changelog.slice(insertionPoint);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
deps.writeFileSync(changelogPath, updatedChangelog);
|
|
207
|
+
const commitCount = unreleasedContent === 'No changes yet.'
|
|
208
|
+
? 0
|
|
209
|
+
: unreleasedContent
|
|
210
|
+
.split('\n')
|
|
211
|
+
.filter((line) => line.trim().startsWith('- '))
|
|
212
|
+
.length;
|
|
213
|
+
deps.log(`✅ Updated [Unreleased] section with ${commitCount} commit(s)`);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* CLI entry point - only runs when script is executed directly
|
|
217
|
+
*/
|
|
218
|
+
/* c8 ignore start */
|
|
219
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
220
|
+
try {
|
|
221
|
+
populateChangelog({
|
|
222
|
+
execSync,
|
|
223
|
+
readFileSync,
|
|
224
|
+
writeFileSync,
|
|
225
|
+
getEnv: (key) => process.env[key],
|
|
226
|
+
log: console.log,
|
|
227
|
+
warn: console.warn,
|
|
228
|
+
error: console.error,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
console.error('❌ Failed to populate [Unreleased] section:', error);
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/* c8 ignore end */
|