release-note-cli 0.1.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/README.md +76 -0
- package/dist/commands/gen.js +69 -0
- package/dist/commands/latest.js +18 -0
- package/dist/index.js +22 -0
- package/dist/lib/formatter.js +46 -0
- package/dist/lib/git.js +48 -0
- package/dist/lib/parser.js +68 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# release-note-cli
|
|
2
|
+
|
|
3
|
+
Git 커밋 로그에서 릴리스 노트를 자동 생성하는 CLI.
|
|
4
|
+
[Conventional Commits](https://www.conventionalcommits.org/) 규칙을 따르는 커밋을 파싱해서 카테고리별로 정리해줍니다.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g release-note-cli
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# 두 태그 사이 릴리스 노트 생성
|
|
16
|
+
notes gen v1.0.0 v1.1.0
|
|
17
|
+
|
|
18
|
+
# 특정 ref부터 HEAD까지
|
|
19
|
+
notes gen v1.0.0
|
|
20
|
+
|
|
21
|
+
# 마지막 태그부터 HEAD까지
|
|
22
|
+
notes latest
|
|
23
|
+
|
|
24
|
+
# 텍스트 포맷으로 출력
|
|
25
|
+
notes gen v1.0.0 v1.1.0 -o text
|
|
26
|
+
|
|
27
|
+
# CHANGELOG.md에 추가
|
|
28
|
+
notes latest -a
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Output Example
|
|
32
|
+
|
|
33
|
+
```markdown
|
|
34
|
+
## v1.1.0 (2026-02-26)
|
|
35
|
+
|
|
36
|
+
### Features
|
|
37
|
+
- **auth:** add Google OAuth login (a1b2c3d)
|
|
38
|
+
- implement dark mode toggle (d4e5f6g)
|
|
39
|
+
|
|
40
|
+
### Bug Fixes
|
|
41
|
+
- **api:** fix token refresh race condition (h7i8j9k)
|
|
42
|
+
|
|
43
|
+
### Breaking Changes
|
|
44
|
+
- **config:** rename apiUrl to apiBase (l0m1n2o)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Supported Commit Types
|
|
48
|
+
|
|
49
|
+
| Type | Category |
|
|
50
|
+
|------|----------|
|
|
51
|
+
| `feat` | Features |
|
|
52
|
+
| `fix` | Bug Fixes |
|
|
53
|
+
| `docs` | Documentation |
|
|
54
|
+
| `refactor` | Refactoring |
|
|
55
|
+
| `perf` | Performance |
|
|
56
|
+
| `style`, `test`, `chore`, `ci`, `build` | Other Changes |
|
|
57
|
+
|
|
58
|
+
`BREAKING CHANGE:` in body 또는 `feat!:` 형태의 `!` 접미사는 Breaking Changes로 분류됩니다.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|---------|-------------|
|
|
64
|
+
| `notes gen <from> [to]` | 두 git ref 사이의 릴리스 노트 생성 |
|
|
65
|
+
| `notes latest` | 마지막 태그 → HEAD 릴리스 노트 생성 |
|
|
66
|
+
|
|
67
|
+
### Options
|
|
68
|
+
|
|
69
|
+
| Flag | Description | Default |
|
|
70
|
+
|------|-------------|---------|
|
|
71
|
+
| `-o, --output <format>` | 출력 포맷 (`markdown` / `text`) | `markdown` |
|
|
72
|
+
| `-a, --append` | CHANGELOG.md에 추가 | `false` |
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import pc from 'picocolors';
|
|
4
|
+
import { isGitRepo, isValidRef, getCommitsBetween } from '../lib/git.js';
|
|
5
|
+
import { parseCommits, groupByType } from '../lib/parser.js';
|
|
6
|
+
import { formatMarkdown, formatText } from '../lib/formatter.js';
|
|
7
|
+
export function cmdGen(from, to, options) {
|
|
8
|
+
const cwd = process.cwd();
|
|
9
|
+
if (!isGitRepo(cwd)) {
|
|
10
|
+
console.error(pc.red('Error: Not a git repository.'));
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
if (!isValidRef(cwd, from)) {
|
|
14
|
+
console.error(pc.red(`Error: Invalid ref "${from}". Check the tag or commit exists.`));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const toRef = to || 'HEAD';
|
|
18
|
+
if (to && !isValidRef(cwd, to)) {
|
|
19
|
+
console.error(pc.red(`Error: Invalid ref "${to}". Check the tag or commit exists.`));
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
const rawCommits = getCommitsBetween(cwd, from, toRef);
|
|
23
|
+
if (rawCommits.length === 0) {
|
|
24
|
+
console.log(pc.yellow('No commits found in the specified range.'));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const parsed = parseCommits(rawCommits);
|
|
28
|
+
const grouped = groupByType(parsed);
|
|
29
|
+
// Determine version label
|
|
30
|
+
const versionLabel = to || 'Unreleased';
|
|
31
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
32
|
+
const format = options.output || 'markdown';
|
|
33
|
+
const output = format === 'text'
|
|
34
|
+
? formatText(versionLabel, dateStr, grouped)
|
|
35
|
+
: formatMarkdown(versionLabel, dateStr, grouped);
|
|
36
|
+
if (options.append) {
|
|
37
|
+
appendToChangelog(cwd, output);
|
|
38
|
+
console.log(pc.green(`✓ Appended to CHANGELOG.md (${parsed.length} commits)`));
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
console.log(output);
|
|
42
|
+
}
|
|
43
|
+
// Summary
|
|
44
|
+
const summary = [
|
|
45
|
+
grouped.feat.length && `${grouped.feat.length} features`,
|
|
46
|
+
grouped.fix.length && `${grouped.fix.length} fixes`,
|
|
47
|
+
grouped.breaking.length && `${grouped.breaking.length} breaking`,
|
|
48
|
+
].filter(Boolean).join(', ');
|
|
49
|
+
console.error(pc.dim(`${parsed.length} commits processed${summary ? ': ' + summary : ''}`));
|
|
50
|
+
}
|
|
51
|
+
function appendToChangelog(cwd, content) {
|
|
52
|
+
const changelogPath = join(cwd, 'CHANGELOG.md');
|
|
53
|
+
if (!existsSync(changelogPath)) {
|
|
54
|
+
writeFileSync(changelogPath, `# Changelog\n\n${content}`, 'utf-8');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const existing = readFileSync(changelogPath, 'utf-8');
|
|
58
|
+
const insertIndex = existing.indexOf('\n## ');
|
|
59
|
+
if (insertIndex === -1) {
|
|
60
|
+
// No existing version headers, append at end
|
|
61
|
+
writeFileSync(changelogPath, existing.trimEnd() + '\n\n' + content, 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// Insert before the first ## header (after the newline)
|
|
65
|
+
const before = existing.slice(0, insertIndex + 1);
|
|
66
|
+
const after = existing.slice(insertIndex + 1);
|
|
67
|
+
writeFileSync(changelogPath, before + content + after, 'utf-8');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { isGitRepo, getLatestTag } from '../lib/git.js';
|
|
3
|
+
import { cmdGen } from './gen.js';
|
|
4
|
+
export function cmdLatest(options) {
|
|
5
|
+
const cwd = process.cwd();
|
|
6
|
+
if (!isGitRepo(cwd)) {
|
|
7
|
+
console.error(pc.red('Error: Not a git repository.'));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
const tag = getLatestTag(cwd);
|
|
11
|
+
if (!tag) {
|
|
12
|
+
console.error(pc.red('Error: No tags found.'));
|
|
13
|
+
console.error(pc.dim('Hint: Use "notes gen <commit-hash> HEAD" instead.'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
console.error(pc.dim(`Latest tag: ${tag}`));
|
|
17
|
+
cmdGen(tag, undefined, options);
|
|
18
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { cmdGen } from './commands/gen.js';
|
|
4
|
+
import { cmdLatest } from './commands/latest.js';
|
|
5
|
+
const program = new Command();
|
|
6
|
+
program
|
|
7
|
+
.name('notes')
|
|
8
|
+
.description('Generate release notes from git commit logs')
|
|
9
|
+
.version('0.1.0', '-v, --version');
|
|
10
|
+
program
|
|
11
|
+
.command('gen <from> [to]')
|
|
12
|
+
.description('Generate release notes between two git refs (tags, commits, branches)')
|
|
13
|
+
.option('-o, --output <format>', 'Output format: markdown or text', 'markdown')
|
|
14
|
+
.option('-a, --append', 'Append to CHANGELOG.md')
|
|
15
|
+
.action(cmdGen);
|
|
16
|
+
program
|
|
17
|
+
.command('latest')
|
|
18
|
+
.description('Generate notes from latest tag to HEAD')
|
|
19
|
+
.option('-o, --output <format>', 'Output format: markdown or text', 'markdown')
|
|
20
|
+
.option('-a, --append', 'Append to CHANGELOG.md')
|
|
21
|
+
.action(cmdLatest);
|
|
22
|
+
program.parse();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const SECTIONS = [
|
|
2
|
+
{ key: 'breaking', mdTitle: 'Breaking Changes', textTitle: 'Breaking Changes' },
|
|
3
|
+
{ key: 'feat', mdTitle: 'Features', textTitle: 'Features' },
|
|
4
|
+
{ key: 'fix', mdTitle: 'Bug Fixes', textTitle: 'Bug Fixes' },
|
|
5
|
+
{ key: 'docs', mdTitle: 'Documentation', textTitle: 'Documentation' },
|
|
6
|
+
{ key: 'refactor', mdTitle: 'Refactoring', textTitle: 'Refactoring' },
|
|
7
|
+
{ key: 'perf', mdTitle: 'Performance', textTitle: 'Performance' },
|
|
8
|
+
{ key: 'other', mdTitle: 'Other Changes', textTitle: 'Other Changes' },
|
|
9
|
+
];
|
|
10
|
+
function formatCommitLine(c, withScope) {
|
|
11
|
+
const scope = withScope && c.scope ? `**${c.scope}:** ` : '';
|
|
12
|
+
return `${scope}${c.description} (${c.shortHash})`;
|
|
13
|
+
}
|
|
14
|
+
export function formatMarkdown(version, date, groups) {
|
|
15
|
+
const lines = [];
|
|
16
|
+
lines.push(`## ${version} (${date})`);
|
|
17
|
+
lines.push('');
|
|
18
|
+
for (const section of SECTIONS) {
|
|
19
|
+
const commits = groups[section.key];
|
|
20
|
+
if (commits.length === 0)
|
|
21
|
+
continue;
|
|
22
|
+
lines.push(`### ${section.mdTitle}`);
|
|
23
|
+
lines.push('');
|
|
24
|
+
for (const c of commits) {
|
|
25
|
+
lines.push(`- ${formatCommitLine(c, true)}`);
|
|
26
|
+
}
|
|
27
|
+
lines.push('');
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
export function formatText(version, date, groups) {
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push(`${version} (${date})`);
|
|
34
|
+
lines.push('');
|
|
35
|
+
for (const section of SECTIONS) {
|
|
36
|
+
const commits = groups[section.key];
|
|
37
|
+
if (commits.length === 0)
|
|
38
|
+
continue;
|
|
39
|
+
lines.push(`${section.textTitle}:`);
|
|
40
|
+
for (const c of commits) {
|
|
41
|
+
lines.push(` - ${formatCommitLine(c, true)}`);
|
|
42
|
+
}
|
|
43
|
+
lines.push('');
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
const COMMIT_DELIM = '---COMMIT_DELIM---';
|
|
5
|
+
const FIELD_DELIM = '---FIELD_DELIM---';
|
|
6
|
+
function git(path, args) {
|
|
7
|
+
try {
|
|
8
|
+
return execSync(`git ${args}`, {
|
|
9
|
+
cwd: path,
|
|
10
|
+
encoding: 'utf-8',
|
|
11
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
12
|
+
}).trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isGitRepo(path) {
|
|
19
|
+
return existsSync(join(path, '.git'));
|
|
20
|
+
}
|
|
21
|
+
export function getLatestTag(path) {
|
|
22
|
+
return git(path, 'describe --abbrev=0 --tags');
|
|
23
|
+
}
|
|
24
|
+
export function getCommitsBetween(path, from, to) {
|
|
25
|
+
const format = [
|
|
26
|
+
'%H', '%h', '%s', '%b', '%an', '%cI',
|
|
27
|
+
].join(FIELD_DELIM);
|
|
28
|
+
const raw = git(path, `log ${from}..${to} --format=${COMMIT_DELIM}${format}`);
|
|
29
|
+
if (!raw)
|
|
30
|
+
return [];
|
|
31
|
+
return raw
|
|
32
|
+
.split(COMMIT_DELIM)
|
|
33
|
+
.filter(chunk => chunk.trim().length > 0)
|
|
34
|
+
.map(chunk => {
|
|
35
|
+
const fields = chunk.trim().split(FIELD_DELIM);
|
|
36
|
+
return {
|
|
37
|
+
hash: fields[0] || '',
|
|
38
|
+
shortHash: fields[1] || '',
|
|
39
|
+
subject: fields[2] || '',
|
|
40
|
+
body: fields[3] || '',
|
|
41
|
+
author: fields[4] || '',
|
|
42
|
+
date: fields[5] || '',
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
export function isValidRef(path, ref) {
|
|
47
|
+
return git(path, `rev-parse --verify ${ref}`) !== null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const KNOWN_TYPES = new Set([
|
|
2
|
+
'feat', 'fix', 'docs', 'refactor', 'perf',
|
|
3
|
+
'style', 'test', 'chore', 'ci', 'build',
|
|
4
|
+
]);
|
|
5
|
+
const CONVENTIONAL_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)/;
|
|
6
|
+
export function parseCommit(raw) {
|
|
7
|
+
const match = raw.subject.match(CONVENTIONAL_RE);
|
|
8
|
+
let type = 'other';
|
|
9
|
+
let scope;
|
|
10
|
+
let description = raw.subject;
|
|
11
|
+
let breaking = false;
|
|
12
|
+
if (match) {
|
|
13
|
+
const [, rawType, rawScope, bangMark, desc] = match;
|
|
14
|
+
if (KNOWN_TYPES.has(rawType)) {
|
|
15
|
+
type = rawType;
|
|
16
|
+
}
|
|
17
|
+
scope = rawScope || undefined;
|
|
18
|
+
description = desc;
|
|
19
|
+
breaking = bangMark === '!';
|
|
20
|
+
}
|
|
21
|
+
if (!breaking && raw.body.includes('BREAKING CHANGE:')) {
|
|
22
|
+
breaking = true;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
hash: raw.hash,
|
|
26
|
+
shortHash: raw.shortHash,
|
|
27
|
+
subject: raw.subject,
|
|
28
|
+
body: raw.body,
|
|
29
|
+
author: raw.author,
|
|
30
|
+
date: raw.date,
|
|
31
|
+
type,
|
|
32
|
+
scope,
|
|
33
|
+
description,
|
|
34
|
+
breaking,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function parseCommits(raws) {
|
|
38
|
+
return raws.map(parseCommit);
|
|
39
|
+
}
|
|
40
|
+
export function groupByType(commits) {
|
|
41
|
+
const groups = {
|
|
42
|
+
feat: [],
|
|
43
|
+
fix: [],
|
|
44
|
+
docs: [],
|
|
45
|
+
refactor: [],
|
|
46
|
+
perf: [],
|
|
47
|
+
other: [],
|
|
48
|
+
breaking: [],
|
|
49
|
+
};
|
|
50
|
+
for (const c of commits) {
|
|
51
|
+
if (c.breaking) {
|
|
52
|
+
groups.breaking.push(c);
|
|
53
|
+
}
|
|
54
|
+
if (c.type === 'feat')
|
|
55
|
+
groups.feat.push(c);
|
|
56
|
+
else if (c.type === 'fix')
|
|
57
|
+
groups.fix.push(c);
|
|
58
|
+
else if (c.type === 'docs')
|
|
59
|
+
groups.docs.push(c);
|
|
60
|
+
else if (c.type === 'refactor')
|
|
61
|
+
groups.refactor.push(c);
|
|
62
|
+
else if (c.type === 'perf')
|
|
63
|
+
groups.perf.push(c);
|
|
64
|
+
else
|
|
65
|
+
groups.other.push(c);
|
|
66
|
+
}
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "release-note-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate release notes from git commit logs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"notes": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"release-notes",
|
|
14
|
+
"changelog",
|
|
15
|
+
"git",
|
|
16
|
+
"conventional-commits",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"dev": "tsc --watch",
|
|
26
|
+
"start": "node dist/index.js",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"commander": "^12.0.0",
|
|
31
|
+
"picocolors": "^1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^20.0.0",
|
|
35
|
+
"typescript": "^5.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|