add brain
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Changelog Generator
|
||||
|
||||
Automates release notes from Conventional Commits with Keep a Changelog output and strict commit linting. Designed for CI-friendly release workflows.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Generate entry from git range
|
||||
python3 scripts/generate_changelog.py \
|
||||
--from-tag v1.2.0 \
|
||||
--to-tag v1.3.0 \
|
||||
--next-version v1.3.0 \
|
||||
--format markdown
|
||||
|
||||
# Lint commit subjects
|
||||
python3 scripts/commit_linter.py --from-ref origin/main --to-ref HEAD --strict --format text
|
||||
```
|
||||
|
||||
## Included Tools
|
||||
|
||||
- `scripts/generate_changelog.py`: parse commits, infer semver bump, render markdown/JSON, optional file prepend
|
||||
- `scripts/commit_linter.py`: validate commit subjects against Conventional Commits rules
|
||||
|
||||
## References
|
||||
|
||||
- `references/ci-integration.md`
|
||||
- `references/changelog-formatting-guide.md`
|
||||
- `references/monorepo-strategy.md`
|
||||
|
||||
## Installation
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
cp -R engineering/changelog-generator ~/.claude/skills/changelog-generator
|
||||
```
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
```bash
|
||||
cp -R engineering/changelog-generator ~/.codex/skills/changelog-generator
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
|
||||
```bash
|
||||
cp -R engineering/changelog-generator ~/.openclaw/skills/changelog-generator
|
||||
```
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: "changelog-generator"
|
||||
description: "Changelog Generator"
|
||||
---
|
||||
|
||||
# Changelog Generator
|
||||
|
||||
**Tier:** POWERFUL
|
||||
**Category:** Engineering
|
||||
**Domain:** Release Management / Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
Use this skill to produce consistent, auditable release notes from Conventional Commits. It separates commit parsing, semantic bump logic, and changelog rendering so teams can automate releases without losing editorial control.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- Parse commit messages using Conventional Commit rules
|
||||
- Detect semantic bump (`major`, `minor`, `patch`) from commit stream
|
||||
- Render Keep a Changelog sections (`Added`, `Changed`, `Fixed`, etc.)
|
||||
- Generate release entries from git ranges or provided commit input
|
||||
- Enforce commit format with a dedicated linter script
|
||||
- Support CI integration via machine-readable JSON output
|
||||
|
||||
## When to Use
|
||||
|
||||
- Before publishing a release tag
|
||||
- During CI to generate release notes automatically
|
||||
- During PR checks to block invalid commit message formats
|
||||
- In monorepos where package changelogs require scoped filtering
|
||||
- When converting raw git history into user-facing notes
|
||||
|
||||
## Key Workflows
|
||||
|
||||
### 1. Generate Changelog Entry From Git
|
||||
|
||||
```bash
|
||||
python3 scripts/generate_changelog.py \
|
||||
--from-tag v1.3.0 \
|
||||
--to-tag v1.4.0 \
|
||||
--next-version v1.4.0 \
|
||||
--format markdown
|
||||
```
|
||||
|
||||
### 2. Generate Entry From stdin/File Input
|
||||
|
||||
```bash
|
||||
git log v1.3.0..v1.4.0 --pretty=format:'%s' | \
|
||||
python3 scripts/generate_changelog.py --next-version v1.4.0 --format markdown
|
||||
|
||||
python3 scripts/generate_changelog.py --input commits.txt --next-version v1.4.0 --format json
|
||||
```
|
||||
|
||||
### 3. Update `CHANGELOG.md`
|
||||
|
||||
```bash
|
||||
python3 scripts/generate_changelog.py \
|
||||
--from-tag v1.3.0 \
|
||||
--to-tag HEAD \
|
||||
--next-version v1.4.0 \
|
||||
--write CHANGELOG.md
|
||||
```
|
||||
|
||||
### 4. Lint Commits Before Merge
|
||||
|
||||
```bash
|
||||
python3 scripts/commit_linter.py --from-ref origin/main --to-ref HEAD --strict --format text
|
||||
```
|
||||
|
||||
Or file/stdin:
|
||||
|
||||
```bash
|
||||
python3 scripts/commit_linter.py --input commits.txt --strict
|
||||
cat commits.txt | python3 scripts/commit_linter.py --format json
|
||||
```
|
||||
|
||||
## Conventional Commit Rules
|
||||
|
||||
Supported types:
|
||||
|
||||
- `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore`
|
||||
- `security`, `deprecated`, `remove`
|
||||
|
||||
Breaking changes:
|
||||
|
||||
- `type(scope)!: summary`
|
||||
- Footer/body includes `BREAKING CHANGE:`
|
||||
|
||||
SemVer mapping:
|
||||
|
||||
- breaking -> `major`
|
||||
- non-breaking `feat` -> `minor`
|
||||
- all others -> `patch`
|
||||
|
||||
## Script Interfaces
|
||||
|
||||
- `python3 scripts/generate_changelog.py --help`
|
||||
- Reads commits from git or stdin/`--input`
|
||||
- Renders markdown or JSON
|
||||
- Optional in-place changelog prepend
|
||||
- `python3 scripts/commit_linter.py --help`
|
||||
- Validates commit format
|
||||
- Returns non-zero in `--strict` mode on violations
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. Mixing merge commit messages with release commit parsing
|
||||
2. Using vague commit summaries that cannot become release notes
|
||||
3. Failing to include migration guidance for breaking changes
|
||||
4. Treating docs/chore changes as user-facing features
|
||||
5. Overwriting historical changelog sections instead of prepending
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Keep commits small and intent-driven.
|
||||
2. Scope commit messages (`feat(api): ...`) in multi-package repos.
|
||||
3. Enforce linter checks in PR pipelines.
|
||||
4. Review generated markdown before publishing.
|
||||
5. Tag releases only after changelog generation succeeds.
|
||||
6. Keep an `[Unreleased]` section for manual curation when needed.
|
||||
|
||||
## References
|
||||
|
||||
- [references/ci-integration.md](references/ci-integration.md)
|
||||
- [references/changelog-formatting-guide.md](references/changelog-formatting-guide.md)
|
||||
- [references/monorepo-strategy.md](references/monorepo-strategy.md)
|
||||
- [README.md](README.md)
|
||||
|
||||
## Release Governance
|
||||
|
||||
Use this release flow for predictability:
|
||||
|
||||
1. Lint commit history for target release range.
|
||||
2. Generate changelog draft from commits.
|
||||
3. Manually adjust wording for customer clarity.
|
||||
4. Validate semver bump recommendation.
|
||||
5. Tag release only after changelog is approved.
|
||||
|
||||
## Output Quality Checks
|
||||
|
||||
- Each bullet is user-meaningful, not implementation noise.
|
||||
- Breaking changes include migration action.
|
||||
- Security fixes are isolated in `Security` section.
|
||||
- Sections with no entries are omitted.
|
||||
- Duplicate bullets across sections are removed.
|
||||
|
||||
## CI Policy
|
||||
|
||||
- Run `commit_linter.py --strict` on all PRs.
|
||||
- Block merge on invalid conventional commits.
|
||||
- Auto-generate draft release notes on tag push.
|
||||
- Require human approval before writing into `CHANGELOG.md` on main branch.
|
||||
|
||||
## Monorepo Guidance
|
||||
|
||||
- Prefer commit scopes aligned to package names.
|
||||
- Filter commit stream by scope for package-specific releases.
|
||||
- Keep infra-wide changes in root changelog.
|
||||
- Store package changelogs near package roots for ownership clarity.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If no valid conventional commits found: fail early, do not generate misleading empty notes.
|
||||
- If git range invalid: surface explicit range in error output.
|
||||
- If write target missing: create safe changelog header scaffolding.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Changelog Formatting Guide
|
||||
|
||||
Use Keep a Changelog section ordering:
|
||||
|
||||
1. Security
|
||||
2. Added
|
||||
3. Changed
|
||||
4. Deprecated
|
||||
5. Removed
|
||||
6. Fixed
|
||||
|
||||
Rules:
|
||||
|
||||
- One bullet = one user-visible change.
|
||||
- Lead with impact, not implementation detail.
|
||||
- Keep bullets short and actionable.
|
||||
- Include migration note for breaking changes.
|
||||
@@ -0,0 +1,26 @@
|
||||
# CI Integration Examples
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
```yaml
|
||||
name: Changelog Check
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 engineering/changelog-generator/scripts/commit_linter.py \
|
||||
--from-ref origin/main --to-ref HEAD --strict
|
||||
```
|
||||
|
||||
## GitLab CI
|
||||
|
||||
```yaml
|
||||
changelog_lint:
|
||||
image: python:3.12
|
||||
stage: test
|
||||
script:
|
||||
- python3 engineering/changelog-generator/scripts/commit_linter.py --to-ref HEAD --strict
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Monorepo Changelog Strategy
|
||||
|
||||
## Approaches
|
||||
|
||||
| Strategy | When to use | Tradeoff |
|
||||
|----------|-------------|----------|
|
||||
| Single root changelog | Product-wide releases, small teams | Simple but loses package-level detail |
|
||||
| Per-package changelogs | Independent versioning, large teams | Clear ownership but harder to see full picture |
|
||||
| Hybrid model | Root summary + package-specific details | Best of both, more maintenance |
|
||||
|
||||
## Commit Scoping Pattern
|
||||
|
||||
Enforce scoped conventional commits to enable per-package filtering:
|
||||
|
||||
```
|
||||
feat(payments): add Stripe webhook handler
|
||||
fix(auth): handle expired refresh tokens
|
||||
chore(infra): bump base Docker image
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Scope must match a package/directory name exactly
|
||||
- Unscoped commits go to root changelog only
|
||||
- Multi-package changes get separate scoped commits (not one mega-commit)
|
||||
|
||||
## Filtering for Package Releases
|
||||
|
||||
```bash
|
||||
# Generate changelog for 'payments' package only
|
||||
git log v1.3.0..HEAD --pretty=format:'%s' | grep '^[a-z]*\(payments\)' | \
|
||||
python3 scripts/generate_changelog.py --next-version v1.4.0 --format markdown
|
||||
```
|
||||
|
||||
## Ownership Model
|
||||
|
||||
- Package maintainers own their scoped changelog
|
||||
- Platform/infra team owns root changelog
|
||||
- CI enforces scope presence on all commits touching package directories
|
||||
- Root changelog aggregates breaking changes from all packages for visibility
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint commit messages against Conventional Commits.
|
||||
|
||||
Input sources (priority order):
|
||||
1) --input file (one commit subject per line)
|
||||
2) stdin lines
|
||||
3) git range via --from-ref/--to-ref
|
||||
|
||||
Use --strict for non-zero exit on violations.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
CONVENTIONAL_RE = re.compile(
|
||||
r"^(feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)"
|
||||
r"(\([a-z0-9._/-]+\))?(!)?:\s+.{1,120}$"
|
||||
)
|
||||
|
||||
|
||||
class CLIError(Exception):
|
||||
"""Raised for expected CLI errors."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LintReport:
|
||||
total: int
|
||||
valid: int
|
||||
invalid: int
|
||||
violations: List[str]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Validate conventional commit subjects.")
|
||||
parser.add_argument("--input", help="File with commit subjects (one per line).")
|
||||
parser.add_argument("--from-ref", help="Git ref start (exclusive).")
|
||||
parser.add_argument("--to-ref", help="Git ref end (inclusive).")
|
||||
parser.add_argument("--strict", action="store_true", help="Exit non-zero when violations exist.")
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def lines_from_file(path: str) -> List[str]:
|
||||
try:
|
||||
return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
except Exception as exc:
|
||||
raise CLIError(f"Failed reading --input file: {exc}") from exc
|
||||
|
||||
|
||||
def lines_from_stdin() -> List[str]:
|
||||
if sys.stdin.isatty():
|
||||
return []
|
||||
data = sys.stdin.read()
|
||||
return [line.strip() for line in data.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def lines_from_git(args: argparse.Namespace) -> List[str]:
|
||||
if not args.to_ref:
|
||||
return []
|
||||
range_spec = f"{args.from_ref}..{args.to_ref}" if args.from_ref else args.to_ref
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "log", range_spec, "--pretty=format:%s", "--no-merges"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc
|
||||
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def load_lines(args: argparse.Namespace) -> List[str]:
|
||||
if args.input:
|
||||
return lines_from_file(args.input)
|
||||
stdin_lines = lines_from_stdin()
|
||||
if stdin_lines:
|
||||
return stdin_lines
|
||||
git_lines = lines_from_git(args)
|
||||
if git_lines:
|
||||
return git_lines
|
||||
raise CLIError("No commit input found. Use --input, stdin, or --to-ref.")
|
||||
|
||||
|
||||
def lint(lines: List[str]) -> LintReport:
|
||||
violations: List[str] = []
|
||||
valid = 0
|
||||
|
||||
for idx, line in enumerate(lines, start=1):
|
||||
if CONVENTIONAL_RE.match(line):
|
||||
valid += 1
|
||||
continue
|
||||
violations.append(f"line {idx}: {line}")
|
||||
|
||||
return LintReport(total=len(lines), valid=valid, invalid=len(violations), violations=violations)
|
||||
|
||||
|
||||
def format_text(report: LintReport) -> str:
|
||||
lines = [
|
||||
"Conventional commit lint report",
|
||||
f"- total: {report.total}",
|
||||
f"- valid: {report.valid}",
|
||||
f"- invalid: {report.invalid}",
|
||||
]
|
||||
if report.violations:
|
||||
lines.append("Violations:")
|
||||
lines.extend([f"- {v}" for v in report.violations])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
lines = load_lines(args)
|
||||
report = lint(lines)
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(asdict(report), indent=2))
|
||||
else:
|
||||
print(format_text(report))
|
||||
|
||||
if args.strict and report.invalid > 0:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except CLIError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate changelog entries from Conventional Commits.
|
||||
|
||||
Input sources (priority order):
|
||||
1) --input file with one commit subject per line
|
||||
2) stdin commit subjects
|
||||
3) git log from --from-tag/--to-tag or --from-ref/--to-ref
|
||||
|
||||
Outputs markdown or JSON and can prepend into CHANGELOG.md.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
COMMIT_RE = re.compile(
|
||||
r"^(?P<type>feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)"
|
||||
r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<summary>.+)$"
|
||||
)
|
||||
|
||||
SECTION_MAP = {
|
||||
"feat": "Added",
|
||||
"fix": "Fixed",
|
||||
"perf": "Changed",
|
||||
"refactor": "Changed",
|
||||
"security": "Security",
|
||||
"deprecated": "Deprecated",
|
||||
"remove": "Removed",
|
||||
}
|
||||
|
||||
|
||||
class CLIError(Exception):
|
||||
"""Raised for expected CLI failures."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCommit:
|
||||
raw: str
|
||||
ctype: str
|
||||
scope: Optional[str]
|
||||
summary: str
|
||||
breaking: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChangelogEntry:
|
||||
version: str
|
||||
release_date: str
|
||||
sections: Dict[str, List[str]] = field(default_factory=dict)
|
||||
breaking_changes: List[str] = field(default_factory=list)
|
||||
bump: str = "patch"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Generate changelog from conventional commits.")
|
||||
parser.add_argument("--input", help="Text file with one commit subject per line.")
|
||||
parser.add_argument("--from-tag", help="Git tag start (exclusive).")
|
||||
parser.add_argument("--to-tag", help="Git tag end (inclusive).")
|
||||
parser.add_argument("--from-ref", help="Git ref start (exclusive).")
|
||||
parser.add_argument("--to-ref", help="Git ref end (inclusive).")
|
||||
parser.add_argument("--next-version", default="Unreleased", help="Version label for the generated entry.")
|
||||
parser.add_argument("--date", dest="entry_date", default=str(date.today()), help="Release date (YYYY-MM-DD).")
|
||||
parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format.")
|
||||
parser.add_argument("--write", help="Prepend generated markdown entry into this changelog file.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_lines_from_file(path: str) -> List[str]:
|
||||
try:
|
||||
return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
except Exception as exc:
|
||||
raise CLIError(f"Failed reading --input file: {exc}") from exc
|
||||
|
||||
|
||||
def read_lines_from_stdin() -> List[str]:
|
||||
if sys.stdin.isatty():
|
||||
return []
|
||||
payload = sys.stdin.read()
|
||||
return [line.strip() for line in payload.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def read_lines_from_git(args: argparse.Namespace) -> List[str]:
|
||||
if args.from_tag or args.to_tag:
|
||||
if not args.to_tag:
|
||||
raise CLIError("--to-tag is required when using tag range.")
|
||||
start = args.from_tag
|
||||
end = args.to_tag
|
||||
elif args.from_ref or args.to_ref:
|
||||
if not args.to_ref:
|
||||
raise CLIError("--to-ref is required when using ref range.")
|
||||
start = args.from_ref
|
||||
end = args.to_ref
|
||||
else:
|
||||
return []
|
||||
|
||||
range_spec = f"{start}..{end}" if start else end
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["git", "log", range_spec, "--pretty=format:%s", "--no-merges"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc
|
||||
|
||||
return [line.strip() for line in proc.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def load_commits(args: argparse.Namespace) -> List[str]:
|
||||
if args.input:
|
||||
return read_lines_from_file(args.input)
|
||||
|
||||
stdin_lines = read_lines_from_stdin()
|
||||
if stdin_lines:
|
||||
return stdin_lines
|
||||
|
||||
git_lines = read_lines_from_git(args)
|
||||
if git_lines:
|
||||
return git_lines
|
||||
|
||||
raise CLIError("No commit input found. Use --input, stdin, or git range flags.")
|
||||
|
||||
|
||||
def parse_commits(lines: List[str]) -> List[ParsedCommit]:
|
||||
parsed: List[ParsedCommit] = []
|
||||
for line in lines:
|
||||
match = COMMIT_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
ctype = match.group("type")
|
||||
scope = match.group("scope")
|
||||
summary = match.group("summary")
|
||||
breaking = bool(match.group("breaking")) or "BREAKING CHANGE" in line
|
||||
parsed.append(ParsedCommit(raw=line, ctype=ctype, scope=scope, summary=summary, breaking=breaking))
|
||||
return parsed
|
||||
|
||||
|
||||
def determine_bump(commits: List[ParsedCommit]) -> str:
|
||||
if any(c.breaking for c in commits):
|
||||
return "major"
|
||||
if any(c.ctype == "feat" for c in commits):
|
||||
return "minor"
|
||||
return "patch"
|
||||
|
||||
|
||||
def build_entry(commits: List[ParsedCommit], version: str, entry_date: str) -> ChangelogEntry:
|
||||
sections: Dict[str, List[str]] = {
|
||||
"Security": [],
|
||||
"Added": [],
|
||||
"Changed": [],
|
||||
"Deprecated": [],
|
||||
"Removed": [],
|
||||
"Fixed": [],
|
||||
}
|
||||
breaking_changes: List[str] = []
|
||||
|
||||
for commit in commits:
|
||||
if commit.breaking:
|
||||
breaking_changes.append(commit.summary)
|
||||
section = SECTION_MAP.get(commit.ctype)
|
||||
if section:
|
||||
line = commit.summary if not commit.scope else f"{commit.scope}: {commit.summary}"
|
||||
sections[section].append(line)
|
||||
|
||||
sections = {k: v for k, v in sections.items() if v}
|
||||
return ChangelogEntry(
|
||||
version=version,
|
||||
release_date=entry_date,
|
||||
sections=sections,
|
||||
breaking_changes=breaking_changes,
|
||||
bump=determine_bump(commits),
|
||||
)
|
||||
|
||||
|
||||
def render_markdown(entry: ChangelogEntry) -> str:
|
||||
lines = [f"## [{entry.version}] - {entry.release_date}", ""]
|
||||
if entry.breaking_changes:
|
||||
lines.append("### Breaking")
|
||||
lines.extend([f"- {item}" for item in entry.breaking_changes])
|
||||
lines.append("")
|
||||
|
||||
ordered_sections = ["Security", "Added", "Changed", "Deprecated", "Removed", "Fixed"]
|
||||
for section in ordered_sections:
|
||||
items = entry.sections.get(section, [])
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"### {section}")
|
||||
lines.extend([f"- {item}" for item in items])
|
||||
lines.append("")
|
||||
|
||||
lines.append(f"<!-- recommended-semver-bump: {entry.bump} -->")
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def prepend_changelog(path: Path, entry_md: str) -> None:
|
||||
if path.exists():
|
||||
original = path.read_text(encoding="utf-8")
|
||||
else:
|
||||
original = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
|
||||
|
||||
if original.startswith("# Changelog"):
|
||||
first_break = original.find("\n")
|
||||
head = original[: first_break + 1]
|
||||
tail = original[first_break + 1 :].lstrip("\n")
|
||||
combined = f"{head}\n{entry_md}\n{tail}"
|
||||
else:
|
||||
combined = f"# Changelog\n\n{entry_md}\n{original}"
|
||||
path.write_text(combined, encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
lines = load_commits(args)
|
||||
parsed = parse_commits(lines)
|
||||
if not parsed:
|
||||
raise CLIError("No valid conventional commit messages found in input.")
|
||||
|
||||
entry = build_entry(parsed, args.next_version, args.entry_date)
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(asdict(entry), indent=2))
|
||||
else:
|
||||
markdown = render_markdown(entry)
|
||||
print(markdown, end="")
|
||||
if args.write:
|
||||
prepend_changelog(Path(args.write), markdown)
|
||||
|
||||
if args.format == "json" and args.write:
|
||||
prepend_changelog(Path(args.write), render_markdown(entry))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except CLIError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
Reference in New Issue
Block a user