mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-07-13 05:28:48 +00:00
chore(github-actions): improve changelog generation and git history fetch
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Append new branch commits to CHANGELOG.md, newest-first, grouped by month.
|
||||
|
||||
Usage:
|
||||
gen_changelog.py [path/to/CHANGELOG.md] (default: repo-root/CHANGELOG.md)
|
||||
|
||||
Design notes
|
||||
------------
|
||||
The hand-curated history at the top of CHANGELOG.md is never rewritten. This
|
||||
script is **append-only and idempotent**: it reads the set of commit hashes
|
||||
already accounted for (from the ``gen_changelog:hashes`` manifest comment at the
|
||||
bottom of the file, plus any hash cited inline), then inserts only commits not
|
||||
yet present.
|
||||
|
||||
"Branch work" is defined as non-merge commits authored by ``agessaman`` (the
|
||||
deterministic filter that matches how the changelog was framed). Merges of the
|
||||
upstream MeshCore ``dev`` branch are surfaced as "Upstream sync" markers; all
|
||||
other merges and pure rebase artifacts are recorded in the manifest but not
|
||||
shown.
|
||||
|
||||
New entries clean up best when commits follow ``type(scope): subject``
|
||||
(feat/fix/refactor/...); free-form subjects fall back to a trimmed, as-written
|
||||
summary. If nothing new is found the file is left byte-for-byte untouched.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
AUTHOR = "agessaman"
|
||||
DEFAULT = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
||||
|
||||
MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December"]
|
||||
|
||||
TYPE_LABEL = {"feat": "New", "fix": "Fix", "enhance": "Improvement",
|
||||
"refactor": "Internal", "perf": "Performance", "docs": "Docs",
|
||||
"chore": "Change", "build": "Build", "ci": "CI", "style": "Internal"}
|
||||
|
||||
VERB_LABEL = [
|
||||
(r"^(add|added|implement|introduce|enable|restore|create|support)\b", "New"),
|
||||
(r"^(fix|fixed|correct|resolve)\b", "Fix"),
|
||||
(r"^(enhance|improve|improved|optimize|increase|simplify|streamline)\b", "Improvement"),
|
||||
(r"^(refactor)\b", "Internal"),
|
||||
(r"^(document|docs)\b", "Docs"),
|
||||
]
|
||||
|
||||
CC = re.compile(r"^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.*)$")
|
||||
UPSTREAM_MERGE = re.compile(r"[Mm]erge.*(?:upstream|origin)/dev")
|
||||
# rebase / merge artifacts that should never appear in the visible log
|
||||
NOISE = re.compile(r"conflict marker|duplicate (?:mqtt )?function|unnecessary comments"
|
||||
r"|from rebase|^revert |whoops|^remove final|^remove remaining", re.I)
|
||||
HASH_TOKEN = re.compile(r"·\s*`([0-9a-f]{7,40})`")
|
||||
MANIFEST_HASH = re.compile(r"\b[0-9a-f]{7,40}\b")
|
||||
|
||||
|
||||
def clean(subj: str) -> str:
|
||||
s = subj.strip().lstrip("* ").strip()
|
||||
s = re.split(r"(?<=[.!?])\s+", s)[0].rstrip(". ")
|
||||
if len(s) > 140:
|
||||
s = s[:137].rstrip() + "…"
|
||||
return s[:1].upper() + s[1:] if s else s
|
||||
|
||||
|
||||
def label_for(typ, summary):
|
||||
if typ and typ.lower() in TYPE_LABEL:
|
||||
return TYPE_LABEL[typ.lower()]
|
||||
for pat, lab in VERB_LABEL:
|
||||
if re.match(pat, summary, re.I):
|
||||
return lab
|
||||
return "Change"
|
||||
|
||||
|
||||
def git_commits():
|
||||
out = subprocess.run(
|
||||
["git", "log", f"--author={AUTHOR}", "--pretty=format:%h%x09%ad%x09%p%x09%s",
|
||||
"--date=short"],
|
||||
capture_output=True, text=True, check=True).stdout.splitlines()
|
||||
rows = []
|
||||
for ln in out:
|
||||
h, date, parents, subj = ln.split("\t", 3)
|
||||
rows.append((h, date, len(parents.split()) > 1, subj))
|
||||
return rows
|
||||
|
||||
|
||||
def render(h, date, is_merge, subj):
|
||||
"""Return a markdown bullet, or None if the commit should be hidden."""
|
||||
if is_merge:
|
||||
if UPSTREAM_MERGE.search(subj):
|
||||
ver = re.search(r"v\d+\.\d+\.\d+", subj)
|
||||
tail = f" ({ver.group()})" if ver else ""
|
||||
return f"- ⬆ **Upstream sync** — Synced with upstream MeshCore dev{tail} <sub>{date} · `{h}`</sub>"
|
||||
return None
|
||||
if NOISE.search(subj):
|
||||
return None
|
||||
m = CC.match(subj)
|
||||
if m:
|
||||
typ, scope, _, rest = m.groups()
|
||||
summary = clean(rest)
|
||||
else:
|
||||
typ = scope = None
|
||||
summary = clean(subj)
|
||||
sc = f" · `{scope}`" if scope else ""
|
||||
return f"- **{label_for(typ, summary)}**{sc} — {summary} <sub>{date} · `{h}`</sub>"
|
||||
|
||||
|
||||
def insert_entry(lines, date, bullet):
|
||||
"""Insert a rendered bullet at the top of its month section (creating it)."""
|
||||
y, mo, _ = date.split("-")
|
||||
heading = f"### {MONTHS[int(mo)]} {y}"
|
||||
# existing section: insert as the first bullet (after heading + blank lines)
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.rstrip() == heading:
|
||||
j = i + 1
|
||||
while j < len(lines) and not lines[j].startswith("- "):
|
||||
j += 1
|
||||
lines.insert(j, bullet)
|
||||
return
|
||||
# new (newer) month: place its section above the first existing section
|
||||
for i, ln in enumerate(lines):
|
||||
if ln.startswith("### "):
|
||||
lines[i:i] = [heading, "", bullet, ""]
|
||||
return
|
||||
raise SystemExit("no '### Month Year' section found; is this a CHANGELOG.md?")
|
||||
|
||||
|
||||
def main():
|
||||
path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT
|
||||
text = path.read_text()
|
||||
lines = text.split("\n")
|
||||
|
||||
m = re.search(r"<!--\s*gen_changelog:hashes.*?-->", text, re.S)
|
||||
if not m:
|
||||
sys.exit("manifest block '<!-- gen_changelog:hashes ... -->' not found")
|
||||
known = set(MANIFEST_HASH.findall(m.group(0)))
|
||||
known |= set(HASH_TOKEN.findall(text))
|
||||
|
||||
# oldest-first so that inserting each at the top of its section keeps newest on top
|
||||
new = [(h, date, mg, subj) for (h, date, mg, subj) in git_commits()
|
||||
if h not in known]
|
||||
new.reverse()
|
||||
if not new:
|
||||
print("CHANGELOG.md already up to date.")
|
||||
return
|
||||
|
||||
manifest_idx = next(i for i, ln in enumerate(lines) if ln.rstrip() == "-->"
|
||||
and "gen_changelog:hashes" in "\n".join(lines[max(0, i - 40):i]))
|
||||
body = lines[:manifest_idx]
|
||||
tail = lines[manifest_idx:]
|
||||
|
||||
added = []
|
||||
for h, date, is_merge, subj in new:
|
||||
bullet = render(h, date, is_merge, subj)
|
||||
if bullet:
|
||||
insert_entry(body, date, bullet)
|
||||
added.append(h) # record every processed hash, shown or not
|
||||
|
||||
# append newly processed hashes to the manifest (just before the closing -->)
|
||||
tail.insert(len(tail) - 1, " ".join(added))
|
||||
|
||||
out = "\n".join(body + tail)
|
||||
if "### " not in out:
|
||||
sys.exit("refusing to write: output has no month sections")
|
||||
path.write_text(out)
|
||||
shown = sum(1 for h, d, mg, s in new if render(h, d, mg, s))
|
||||
print(f"added {len(added)} commit(s) to {path.name} "
|
||||
f"({shown} shown, {len(added) - shown} recorded silently)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user