mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-11-01 10:41:52 +08:00
367 lines
12 KiB
Python
Executable File
367 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
#
|
|
# Copyright 2016-present the IoT DC3 original author or authors.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
|
|
HEADER = "# \u2728 What's Changed"
|
|
DETAILS_OPEN = "<details>"
|
|
DETAILS_SUMMARY = "<summary>\U0001f4dd Historical Version Description, Click to Expand</summary>"
|
|
DEFAULT_TAG_PATTERN = "v*"
|
|
GENERATED_CHANGELOG_RE = re.compile(r"^(docs|chore)\(release\): update generated changelog$")
|
|
|
|
CATEGORY_ORDER = [
|
|
"Breaking Changes",
|
|
"Security",
|
|
"Features",
|
|
"Bug Fixes",
|
|
"Performance",
|
|
"Refactoring",
|
|
"Documentation",
|
|
"Build",
|
|
"CI",
|
|
"Tests",
|
|
"Chores",
|
|
"Other",
|
|
]
|
|
|
|
CATEGORY_TITLES = {
|
|
"Breaking Changes": "Breaking Changes",
|
|
"Security": "Security",
|
|
"Features": "Features",
|
|
"Bug Fixes": "Bug Fixes",
|
|
"Performance": "Performance",
|
|
"Refactoring": "Refactoring",
|
|
"Documentation": "Documentation",
|
|
"Build": "Build",
|
|
"CI": "CI",
|
|
"Tests": "Tests",
|
|
"Chores": "Chores",
|
|
"Other": "Other Changes",
|
|
}
|
|
|
|
TYPE_TO_CATEGORY = {
|
|
"feat": "Features",
|
|
"feature": "Features",
|
|
"fix": "Bug Fixes",
|
|
"perf": "Performance",
|
|
"refactor": "Refactoring",
|
|
"docs": "Documentation",
|
|
"doc": "Documentation",
|
|
"build": "Build",
|
|
"ci": "CI",
|
|
"security": "Security",
|
|
"test": "Tests",
|
|
"tests": "Tests",
|
|
"chore": "Chores",
|
|
"style": "Chores",
|
|
"revert": "Chores",
|
|
}
|
|
|
|
CONVENTIONAL_RE = re.compile(r"^(?P<type>[a-zA-Z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<desc>.+)$")
|
|
VERSION_RE = re.compile(r"^### .*?(?P<version>\d+\.\d+\.\d+).*$", re.MULTILINE)
|
|
GENERATED_DATE_RE = re.compile(
|
|
r"^### .*?(?P<version>\d+\.\d+\.\d+)\s*\n\s*_Generated on (?P<date>\d{4}-\d{2}-\d{2})\._",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Commit:
|
|
|
|
full_hash: str
|
|
short_hash: str
|
|
subject: str
|
|
author: str
|
|
type: str
|
|
scope: str
|
|
description: str
|
|
breaking: bool
|
|
category: str
|
|
|
|
|
|
def run_git(*args: str) -> str:
|
|
try:
|
|
return subprocess.check_output(["git", *args], text=True).strip()
|
|
except subprocess.CalledProcessError as exc:
|
|
print(exc.output, file=sys.stderr)
|
|
raise
|
|
|
|
|
|
def resolve_repo_root() -> Path:
|
|
return Path(run_git("rev-parse", "--show-toplevel"))
|
|
|
|
|
|
def read_project_version(repo_root: Path) -> str:
|
|
pom = repo_root / "pom.xml"
|
|
root = ET.parse(pom).getroot()
|
|
namespace = {"m": "http://maven.apache.org/POM/4.0.0"}
|
|
dc3_version = root.findtext("m:properties/m:dc3.version", namespaces=namespace)
|
|
if dc3_version:
|
|
return dc3_version
|
|
project_version = root.findtext("m:version", namespaces=namespace)
|
|
if project_version:
|
|
return project_version
|
|
raise RuntimeError("Unable to resolve project version from pom.xml")
|
|
|
|
|
|
def rev_parse(ref: str) -> str:
|
|
return run_git("rev-parse", ref)
|
|
|
|
|
|
def previous_release_tag(to_ref: str, tag_pattern: str) -> str:
|
|
tags_text = run_git(
|
|
"for-each-ref",
|
|
"--merged",
|
|
to_ref,
|
|
"--sort=-creatordate",
|
|
"--format=%(refname:short)",
|
|
f"refs/tags/{tag_pattern}",
|
|
)
|
|
tags = [tag for tag in tags_text.splitlines() if tag]
|
|
if not tags:
|
|
return ""
|
|
to_hash = rev_parse(to_ref)
|
|
for tag in tags:
|
|
if rev_parse(tag) != to_hash:
|
|
return tag
|
|
return tags[0]
|
|
|
|
|
|
def parse_commit(record: str) -> Commit:
|
|
record = record.strip()
|
|
full_hash, short_hash, subject, author = record.split("\x1f")
|
|
match = CONVENTIONAL_RE.match(subject)
|
|
if match:
|
|
commit_type = match.group("type").lower()
|
|
scope = match.group("scope") or ""
|
|
description = match.group("desc")
|
|
breaking = bool(match.group("breaking"))
|
|
else:
|
|
commit_type = "other"
|
|
scope = ""
|
|
description = subject
|
|
breaking = False
|
|
|
|
category = TYPE_TO_CATEGORY.get(commit_type, "Other")
|
|
security_text = f"{commit_type} {scope} {description}".lower()
|
|
if any(token in security_text for token in ("security", "vulnerability", "cve", "auth bypass")):
|
|
category = "Security"
|
|
if breaking:
|
|
category = "Breaking Changes"
|
|
|
|
return Commit(full_hash, short_hash, subject, author, commit_type, scope, description, breaking, category)
|
|
|
|
|
|
def read_commits(from_ref: str, to_ref: str, include_merges: bool) -> list[Commit]:
|
|
revision = f"{from_ref}..{to_ref}" if from_ref else to_ref
|
|
args = ["log", "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1e", revision]
|
|
if not include_merges:
|
|
args.insert(1, "--no-merges")
|
|
output = run_git(*args)
|
|
if not output:
|
|
return []
|
|
return [parse_commit(record) for record in output.strip("\x1e").split("\x1e") if record.strip()]
|
|
|
|
|
|
def changed_files(commit_hash: str) -> set[str]:
|
|
output = run_git("diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash)
|
|
return {line for line in output.splitlines() if line}
|
|
|
|
|
|
def is_generated_changelog_commit(commit: Commit, change_file: Path) -> bool:
|
|
if not GENERATED_CHANGELOG_RE.match(commit.subject):
|
|
return False
|
|
normalized_change_file = change_file.as_posix()
|
|
return changed_files(commit.full_hash) == {normalized_change_file}
|
|
|
|
|
|
def filter_commits(commits: list[Commit], change_file: Path, include_changelog_commits: bool) -> list[Commit]:
|
|
if include_changelog_commits:
|
|
return commits
|
|
return [commit for commit in commits if not is_generated_changelog_commit(commit, change_file)]
|
|
|
|
|
|
def format_commit(commit: Commit) -> str:
|
|
if commit.scope:
|
|
return f"- **{commit.scope}**: {commit.description} (`{commit.short_hash}`)"
|
|
return f"- {commit.description} (`{commit.short_hash}`)"
|
|
|
|
|
|
def build_summary(commits: list[Commit], from_ref: str, to_ref: str) -> list[str]:
|
|
if not commits:
|
|
return ["- No commits found in the selected range."]
|
|
|
|
categories = Counter(commit.category for commit in commits)
|
|
scopes = Counter(commit.scope for commit in commits if commit.scope)
|
|
category_summary = ", ".join(
|
|
f"{category} {categories[category]}" for category in CATEGORY_ORDER if categories.get(category)
|
|
)
|
|
|
|
lines = [
|
|
f"- Generated from `{from_ref or 'repository start'}` to `{to_ref}`.",
|
|
f"- Included {len(commits)} commits across {len(categories)} categories: {category_summary}.",
|
|
]
|
|
if scopes:
|
|
top_scopes = ", ".join(f"{scope}({count})" for scope, count in scopes.most_common(6))
|
|
lines.append(f"- Most active scopes: {top_scopes}.")
|
|
|
|
highlights = [
|
|
commit
|
|
for category in ("Breaking Changes", "Security", "Features", "Bug Fixes", "Refactoring")
|
|
for commit in commits
|
|
if commit.category == category
|
|
][:5]
|
|
if highlights:
|
|
lines.append(
|
|
"- Highlights: "
|
|
+ "; ".join(
|
|
f"{commit.scope + ': ' if commit.scope else ''}{commit.description}" for commit in highlights
|
|
)
|
|
+ "."
|
|
)
|
|
return lines
|
|
|
|
|
|
def build_release_block(version: str, commits: list[Commit], from_ref: str, to_ref: str, generated_date: str) -> str:
|
|
grouped: dict[str, list[Commit]] = defaultdict(list)
|
|
for commit in commits:
|
|
grouped[commit.category].append(commit)
|
|
|
|
lines = [f"### \U0001f4cc {version}", "", f"_Generated on {generated_date}._", "", "#### Summary"]
|
|
lines.extend(build_summary(commits, from_ref, to_ref))
|
|
|
|
for category in CATEGORY_ORDER:
|
|
category_commits = grouped.get(category)
|
|
if not category_commits:
|
|
continue
|
|
lines.extend(["", f"#### {CATEGORY_TITLES[category]}"])
|
|
lines.extend(format_commit(commit) for commit in category_commits)
|
|
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def split_changelog(existing: str) -> tuple[str, str]:
|
|
if DETAILS_OPEN in existing:
|
|
index = existing.index(DETAILS_OPEN)
|
|
return existing[:index].strip(), existing[index:].strip()
|
|
return existing.strip(), ""
|
|
|
|
|
|
def strip_header(top: str) -> str:
|
|
top = top.strip()
|
|
if top.startswith(HEADER):
|
|
return top[len(HEADER):].strip()
|
|
return top
|
|
|
|
|
|
def known_versions(markdown: str) -> set[str]:
|
|
return set(VERSION_RE.findall(markdown))
|
|
|
|
|
|
def existing_generated_date(markdown: str, version: str) -> str:
|
|
for match in GENERATED_DATE_RE.finditer(markdown):
|
|
if match.group("version") == version:
|
|
return match.group("date")
|
|
return ""
|
|
|
|
|
|
def merge_historical(top: str, details: str, version: str) -> str:
|
|
old_top = strip_header(top)
|
|
if not old_top or version in known_versions(old_top):
|
|
return details or f"{DETAILS_OPEN}\n{DETAILS_SUMMARY}\n\n</details>"
|
|
|
|
old_versions = known_versions(old_top)
|
|
detail_versions = known_versions(details)
|
|
if old_versions and old_versions.issubset(detail_versions):
|
|
return details
|
|
|
|
if not details:
|
|
return f"{DETAILS_OPEN}\n{DETAILS_SUMMARY}\n\n{old_top}\n\n</details>"
|
|
|
|
lines = details.splitlines()
|
|
insert_at = 1
|
|
for index, line in enumerate(lines[:5]):
|
|
if line.startswith("<summary>"):
|
|
insert_at = index + 1
|
|
break
|
|
lines[insert_at:insert_at] = ["", old_top]
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def update_changelog(path: Path, release_block: str, version: str) -> None:
|
|
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
top, details = split_changelog(existing)
|
|
historical = merge_historical(top, details, version)
|
|
path.write_text(f"{HEADER}\n\n{release_block}\n\n{historical}\n", encoding="utf-8")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Generate categorized CHANGE.md content from git commits.")
|
|
parser.add_argument("--from", dest="from_ref", default=os.getenv("FROM", ""))
|
|
parser.add_argument("--to", dest="to_ref", default=os.getenv("TO", "HEAD"))
|
|
parser.add_argument("--version", default=os.getenv("VERSION", ""))
|
|
parser.add_argument("--file", dest="change_file", default=os.getenv("CHANGE_FILE", "dc3/doc/CHANGE.md"))
|
|
parser.add_argument("--tag-pattern", default=os.getenv("TAG_PATTERN", DEFAULT_TAG_PATTERN))
|
|
parser.add_argument("--include-merges", action="store_true", default=os.getenv("INCLUDE_MERGES", "") == "true")
|
|
parser.add_argument(
|
|
"--include-changelog-commits",
|
|
action="store_true",
|
|
default=os.getenv("INCLUDE_CHANGELOG_COMMITS", "") == "true",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
repo_root = resolve_repo_root()
|
|
os.chdir(repo_root)
|
|
|
|
version = args.version or read_project_version(repo_root)
|
|
from_ref = args.from_ref or previous_release_tag(args.to_ref, args.tag_pattern)
|
|
change_file = Path(args.change_file)
|
|
change_path = repo_root / change_file
|
|
existing = change_path.read_text(encoding="utf-8") if change_path.exists() else ""
|
|
generated_date = existing_generated_date(existing, version) or dt.date.today().isoformat()
|
|
commits = read_commits(from_ref, args.to_ref, args.include_merges)
|
|
commits = filter_commits(commits, change_file, args.include_changelog_commits)
|
|
release_block = build_release_block(version, commits, from_ref, args.to_ref, generated_date)
|
|
update_changelog(change_path, release_block, version)
|
|
|
|
print(f"Updated {args.change_file}")
|
|
print(f"Version: {version}")
|
|
print(f"Range: {from_ref or 'repository start'}..{args.to_ref}")
|
|
print(f"Commits: {len(commits)}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|