add brain

This commit is contained in:
2026-03-12 15:17:52 +07:00
parent fd9f558fa1
commit e7821a7a9d
355 changed files with 93784 additions and 24 deletions

View File

@@ -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
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
```

View File

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

View File

@@ -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)

View File

@@ -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)