add brain
This commit is contained in:
@@ -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