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 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
+ }
@@ -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
+ }