Files
SideStore/scripts/ci/generate_release_notes.py
2026-02-24 05:27:15 +05:30

279 lines
7.3 KiB
Python

#!/usr/bin/env python3
import subprocess
import sys
import os
import re
from pathlib import Path
IGNORED_AUTHORS = []
TAG_MARKER = "###"
HEADER_MARKER = "####"
# ----------------------------------------------------------
# helpers
# ----------------------------------------------------------
def run(cmd: str) -> str:
return subprocess.check_output(cmd, shell=True, text=True).strip()
def commit_exists(rev: str) -> bool:
if not rev:
return False
try:
subprocess.check_output(
f"git rev-parse --verify {rev}^{{commit}}",
shell=True,
stderr=subprocess.DEVNULL,
)
return True
except subprocess.CalledProcessError:
return False
def head_commit():
return run("git rev-parse HEAD")
def first_commit():
return run("git rev-list --max-parents=0 HEAD").splitlines()[0]
def repo_url():
url = run("git config --get remote.origin.url")
if url.startswith("git@"):
url = url.replace("git@", "https://").replace(":", "/")
return url.removesuffix(".git")
def commit_messages(start, end="HEAD"):
out = run(f"git log {start}..{end} --pretty=format:%s")
return out.splitlines() if out else []
def authors(range_expr, fmt="%an"):
try:
out = run(f"git log {range_expr} --pretty=format:{fmt}")
result = {a.strip() for a in out.splitlines() if a.strip()}
return result - set(IGNORED_AUTHORS)
except subprocess.CalledProcessError:
return set()
def branch_base():
try:
default_ref = run("git rev-parse --abbrev-ref origin/HEAD")
default_branch = default_ref.split("/")[-1]
return run(f"git merge-base HEAD origin/{default_branch}")
except Exception:
return first_commit()
def fmt_msg(msg):
msg = msg.lstrip()
if msg.startswith("-"):
msg = msg[1:].strip()
return f"- {msg}"
def fmt_author(author):
return author if author.startswith("@") else f"@{author.split()[0]}"
# ----------------------------------------------------------
# release note generation
# ----------------------------------------------------------
def resolve_start_commit(last_successful: str):
if commit_exists(last_successful):
return last_successful
try:
return run("git rev-parse HEAD~10")
except Exception:
return first_commit()
def generate_release_notes(last_successful, tag, branch):
current = head_commit()
# fallback if missing/invalid
if not last_successful or not commit_exists(last_successful):
try:
last_successful = run("git rev-parse HEAD~10")
except Exception:
last_successful = first_commit()
messages = commit_messages(last_successful, current)
# fallback if empty range
if not messages:
try:
last_successful = run("git rev-parse HEAD~10")
except Exception:
last_successful = first_commit()
messages = commit_messages(last_successful, current)
section = f"{TAG_MARKER} {tag}\n"
section += f"{HEADER_MARKER} What's Changed\n"
if not messages or last_successful == current:
section += "- Nothing...\n"
else:
for m in messages:
section += f"{fmt_msg(m)}\n"
prev_authors = authors(branch)
recent_authors = authors(f"{last_successful}..{current}")
new_authors = recent_authors - prev_authors
if new_authors:
section += f"\n{HEADER_MARKER} New Contributors\n"
for a in sorted(new_authors):
section += f"- {fmt_author(a)} made their first contribution\n"
if messages and last_successful != current:
url = repo_url()
section += (
f"\n{HEADER_MARKER} Full Changelog: "
f"[{last_successful[:8]}...{current[:8]}]"
f"({url}/compare/{last_successful}...{current})\n"
)
return section
# ----------------------------------------------------------
# markdown update
# ----------------------------------------------------------
def update_release_md(existing, new_section, tag):
if not existing:
return new_section
tag_lower = tag.lower()
is_special = tag_lower in {"alpha", "beta", "nightly"}
pattern = fr"(^{TAG_MARKER} .*$)"
parts = re.split(pattern, existing, flags=re.MULTILINE)
processed = []
special_seen = {"alpha": False, "beta": False, "nightly": False}
last_special_idx = -1
i = 0
while i < len(parts):
if i % 2 == 1:
header = parts[i]
name = header[3:].strip().lower()
if name in special_seen:
special_seen[name] = True
last_special_idx = len(processed)
if name == tag_lower:
i += 2
continue
processed.append(parts[i])
i += 1
insert_pos = 0
if is_special:
order = ["alpha", "beta", "nightly"]
for t in order:
if t == tag_lower:
break
if special_seen[t]:
idx = processed.index(f"{TAG_MARKER} {t}")
insert_pos = idx + 2
elif last_special_idx >= 0:
insert_pos = last_special_idx + 2
processed.insert(insert_pos, new_section)
result = ""
for part in processed:
if part.startswith(f"{TAG_MARKER} ") and not result.endswith("\n\n"):
result = result.rstrip("\n") + "\n\n"
result += part
return result.rstrip() + "\n"
# ----------------------------------------------------------
# retrieval
# ----------------------------------------------------------
def retrieve_tag(tag, file_path: Path):
if not file_path.exists():
return ""
content = file_path.read_text()
match = re.search(
fr"^{TAG_MARKER} {re.escape(tag)}$",
content,
re.MULTILINE | re.IGNORECASE,
)
if not match:
return ""
start = match.end()
if start < len(content) and content[start] == "\n":
start += 1
next_tag = re.search(fr"^{TAG_MARKER} ", content[start:], re.MULTILINE)
end = start + next_tag.start() if next_tag else len(content)
return content[start:end].strip()
# ----------------------------------------------------------
# entrypoint
# ----------------------------------------------------------
def main():
args = sys.argv[1:]
if not args:
sys.exit(
"Usage:\n"
" generate_release_notes.py <last_successful> [tag] [branch] [--output-dir DIR]\n"
" generate_release_notes.py --retrieve <tag> [--output-dir DIR]"
)
output_dir = Path.cwd()
if "--output-dir" in args:
idx = args.index("--output-dir")
output_dir = Path(args[idx + 1]).resolve()
del args[idx:idx + 2]
output_dir.mkdir(parents=True, exist_ok=True)
release_file = output_dir / "release-notes.md"
if args[0] == "--retrieve":
print(retrieve_tag(args[1], release_file))
return
last_successful = args[0]
tag = args[1] if len(args) > 1 else head_commit()
branch = args[2] if len(args) > 2 else (
os.environ.get("GITHUB_REF") or branch_base()
)
new_section = generate_release_notes(last_successful, tag, branch)
existing = release_file.read_text() if release_file.exists() else ""
updated = update_release_md(existing, new_section, tag)
release_file.write_text(updated)
print(new_section)
if __name__ == "__main__":
main()