From d8960230858b5a28b88fe280904c8e18fd93f666 Mon Sep 17 00:00:00 2001 From: Harish Garg Date: Mon, 11 Aug 2025 10:16:12 +0530 Subject: [PATCH 1/2] chore(cli): add safe release notes generator (artifact only) --- README.md | 9 +++ scripts/pr_desc.py | 128 +++++++++++++++++++++++++++++++++++++++ scripts/release_notes.py | 121 ++++++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 scripts/pr_desc.py create mode 100644 scripts/release_notes.py diff --git a/README.md b/README.md index 43296168..aec1c00b 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,15 @@ Generating assets is complicated. See [docs/asset-generation.md](docs/asset-gen --- +### Generate release notes locally (optional helper) + +```bash +python3 scripts/release_notes.py --last-tag > RELEASE_NOTES.md +# or pick a range: +python3 scripts/release_notes.py --since vX.Y.Z > RELEASE_NOTES.md + +--- + ## About ### PreTeXt-CLI Team diff --git a/scripts/pr_desc.py b/scripts/pr_desc.py new file mode 100644 index 00000000..735c411c --- /dev/null +++ b/scripts/pr_desc.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +pr_desc.py — Draft a PR description from a git diff. + +Safe by default: +- Local git diff only. No network calls unless you pass --llm and set env vars. + +Usage: + python3 scripts/pr_desc.py --base main --head HEAD > PR_DESCRIPTION.md + python3 scripts/pr_desc.py --staged > PR_DESCRIPTION.md + python3 scripts/pr_desc.py --base v1.2.0 --head HEAD --llm > PR_DESCRIPTION.md +""" +import argparse, os, subprocess, json, urllib.request + +def run(cmd: list[str]) -> str: + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if p.returncode != 0: + raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}") + return p.stdout + +def git_diff(base: str | None, head: str | None, staged: bool) -> str: + if staged: + return run(["git", "diff", "--staged"]) + if base and head: + return run(["git", "diff", f"{base}...{head}"]) + return run(["git", "diff", "HEAD~1..HEAD"]) # default: last commit + +def heuristic_summary(diff_text: str) -> str: + lines = diff_text.splitlines() + added = sum(1 for ln in lines if ln.startswith("+") and not ln.startswith("+++")) + removed = sum(1 for ln in lines if ln.startswith("-") and not ln.startswith("---")) + files = [ln[6:] for ln in lines if ln.startswith("+++ b/")] + subsystems = sorted({(f.split("/")[0] if "/" in f else "(root)") for f in files}) + title = f"Draft: Update {files[0]} (+{added}/-{removed}, {len(files)} files)" if files else "Draft: Update codebase" + + bullets = [] + if added or removed: + bullets.append(f"Code changes: **+{added} / -{removed}** across **{len(files)} file(s)**.") + if subsystems: + bullets.append("Touches: " + ", ".join(subsystems)) + if any("test" in f.lower() for f in files): + bullets.append("Includes test changes.") + if any(f.endswith(('.md', '.txt')) for f in files): + bullets.append("Includes documentation updates.") + + body = "\n".join(f"- {b}" for b in bullets) if bullets else "- Minor internal changes." + return f"""# {title} + +## What changed +{body} + +## Why +Briefly explain the problem/goal addressed in this PR. + +## How to verify +- [ ] Run unit tests +- [ ] Manual verification steps + +## Risks & rollbacks +- Risk level: Low / Medium / High +- Rollback: `git revert ` or feature flag off +""" + +def llm_summary(diff_text: str) -> str | None: + api_key = os.getenv("LLM_API_KEY") + base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") + model = os.getenv("LLM_MODEL", "gpt-4o-mini") + if not api_key: + return None + prompt = f""" +You are helping write a crisp GitHub Pull Request description. +Summarize the unified diff that follows. Output Markdown only with these sections: + +# + +## What changed +- 4–8 bullets with concrete changes + +## Why +- 1–3 bullets giving rationale and user impact + +## How to verify +- checklist of commands or steps + +## Risks & rollbacks +- risk level +- how to revert safely + +Unified diff: +{diff_text[:200000]} +""" + data = { + "model": model, + "messages": [ + {"role": "system", "content": "You are an expert release engineer. Be precise and concise."}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.2, + } + req = urllib.request.Request( + f"{base_url}/chat/completions", + data=json.dumps(data).encode("utf-8"), + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return payload["choices"][0]["message"]["content"] + except Exception: + return None + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base"); ap.add_argument("--head") + ap.add_argument("--staged", action="store_true") + ap.add_argument("--llm", action="store_true") + args = ap.parse_args() + + diff_text = git_diff(args.base, args.head, args.staged) + if args.llm: + md = llm_summary(diff_text) + if md: + print(md); return + print(heuristic_summary(diff_text)) + +if __name__ == "__main__": + main() + diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 00000000..82414d48 --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +release_notes.py — Generate release notes from merged commits/PR titles. + +Safe by default: +- Works locally using git history. No network calls. +- Optional LLM polish via any OpenAI-compatible endpoint *only* if you pass --llm and set env vars. + +Usage: + python3 scripts/release_notes.py --last-tag > RELEASE_NOTES.md + python3 scripts/release_notes.py --since v1.2.0 --until HEAD > RELEASE_NOTES.md + python3 scripts/release_notes.py --since v1.2.0 --llm > RELEASE_NOTES.md + +Optional env (OpenAI-compatible chat API) if you use --llm: + LLM_API_KEY = your key + LLM_BASE_URL = https://api.openai.com/v1 (or your own endpoint) + LLM_MODEL = gpt-4o-mini (or any served model) +""" +import argparse, os, subprocess, re, json, urllib.request + +def run(cmd: list[str]) -> str: + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if p.returncode != 0: + raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}") + return p.stdout + +def last_tag() -> str | None: + try: + return run(["git", "describe", "--tags", "--abbrev=0"]).strip() + except Exception: + return None + +def commits(since: str | None = None, until: str = "HEAD") -> list[str]: + rng = f"{since}..{until}" if since else until + # Prefer merge subjects first (often carry PR titles) + log = run(["git", "log", "--merges", "--pretty=%s", rng]) + if not log.strip(): + log = run(["git", "log", "--pretty=%s", rng]) + return [ln.strip() for ln in log.splitlines() if ln.strip()] + +def categorize(msgs: list[str]) -> dict[str, list[str]]: + cats = {k: [] for k in ["feat","fix","perf","docs","refactor","test","build","ci","chore","other"]} + pat = re.compile(r"^(feat|fix|perf|docs|refactor|test|build|ci|chore)(\(.+?\))?:\s*(.+)$", re.I) + for m in msgs: + mt = pat.match(m) + if mt: + cats[mt.group(1).lower()].append(mt.group(3)) + else: + cats["other"].append(m) + return cats + +def format_notes(cats: dict[str, list[str]], since: str | None, until: str) -> str: + title = f"Release Notes ({since} → {until})" if since else f"Release Notes (up to {until})" + order = ["feat","fix","perf","refactor","docs","test","build","ci","chore","other"] + labels = { + "feat":"Features","fix":"Fixes","perf":"Performance","refactor":"Refactoring", + "docs":"Documentation","test":"Tests","build":"Build","ci":"CI","chore":"Chore","other":"Other" + } + out = [f"# {title}", ""] + for k in order: + items = cats.get(k, []) + if items: + out.append(f"## {labels[k]}") + out.extend(f"- {it}" for it in items) + out.append("") + out.append("— Generated locally by release_notes.py") + return "\n".join(out) + +def llm_polish(markdown: str) -> str | None: + api_key = os.getenv("LLM_API_KEY") + base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") + model = os.getenv("LLM_MODEL", "gpt-4o-mini") + if not api_key: + return None + prompt = f"""Rewrite the following release notes for clarity and concision. +Keep headings and lists in Markdown. Do not invent changes. + +{markdown[:200000]} +""" + data = { + "model": model, + "messages": [ + {"role": "system", "content": "You are a meticulous technical editor."}, + {"role": "user", "content": prompt} + ], + "temperature": 0.2, + } + req = urllib.request.Request( + f"{base_url}/chat/completions", + data=json.dumps(data).encode("utf-8"), + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + payload = json.loads(resp.read().decode("utf-8")) + return payload["choices"][0]["message"]["content"] + except Exception: + return None + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--since", help="tag/ref to start from (e.g., v1.2.0)") + ap.add_argument("--until", default="HEAD", help="end ref (default HEAD)") + ap.add_argument("--last-tag", action="store_true", help="use last tag as --since") + ap.add_argument("--llm", action="store_true", help="polish output via LLM if env vars set") + args = ap.parse_args() + + since = args.since or (last_tag() if args.last_tag else None) + msgs = commits(since, args.until) + cats = categorize(msgs) + md = format_notes(cats, since, args.until) + if args.llm: + polished = llm_polish(md) + if polished: + print(polished) + return + print(md) + +if __name__ == "__main__": + main() + From ba07fabbd1472ed7b93f68cc53a4cb57ad3f9ca7 Mon Sep 17 00:00:00 2001 From: Harish Garg Date: Mon, 11 Aug 2025 10:29:58 +0530 Subject: [PATCH 2/2] style: format with black and fix README code block --- README.md | 2 +- scripts/pr_desc.py | 45 +++++++++++++++++------ scripts/release_notes.py | 78 ++++++++++++++++++++++++++++++++++------ 3 files changed, 103 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index aec1c00b..585546cf 100644 --- a/README.md +++ b/README.md @@ -317,8 +317,8 @@ Generating assets is complicated. See [docs/asset-generation.md](docs/asset-gen python3 scripts/release_notes.py --last-tag > RELEASE_NOTES.md # or pick a range: python3 scripts/release_notes.py --since vX.Y.Z > RELEASE_NOTES.md +``` ---- ## About diff --git a/scripts/pr_desc.py b/scripts/pr_desc.py index 735c411c..f3120d0d 100644 --- a/scripts/pr_desc.py +++ b/scripts/pr_desc.py @@ -10,7 +10,12 @@ python3 scripts/pr_desc.py --staged > PR_DESCRIPTION.md python3 scripts/pr_desc.py --base v1.2.0 --head HEAD --llm > PR_DESCRIPTION.md """ -import argparse, os, subprocess, json, urllib.request +import argparse +import os +import subprocess +import json +import urllib.request + def run(cmd: list[str]) -> str: p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) @@ -18,6 +23,7 @@ def run(cmd: list[str]) -> str: raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}") return p.stdout + def git_diff(base: str | None, head: str | None, staged: bool) -> str: if staged: return run(["git", "diff", "--staged"]) @@ -25,25 +31,34 @@ def git_diff(base: str | None, head: str | None, staged: bool) -> str: return run(["git", "diff", f"{base}...{head}"]) return run(["git", "diff", "HEAD~1..HEAD"]) # default: last commit + def heuristic_summary(diff_text: str) -> str: lines = diff_text.splitlines() added = sum(1 for ln in lines if ln.startswith("+") and not ln.startswith("+++")) removed = sum(1 for ln in lines if ln.startswith("-") and not ln.startswith("---")) files = [ln[6:] for ln in lines if ln.startswith("+++ b/")] subsystems = sorted({(f.split("/")[0] if "/" in f else "(root)") for f in files}) - title = f"Draft: Update {files[0]} (+{added}/-{removed}, {len(files)} files)" if files else "Draft: Update codebase" + title = ( + f"Draft: Update {files[0]} (+{added}/-{removed}, {len(files)} files)" + if files + else "Draft: Update codebase" + ) bullets = [] if added or removed: - bullets.append(f"Code changes: **+{added} / -{removed}** across **{len(files)} file(s)**.") + bullets.append( + f"Code changes: **+{added} / -{removed}** across **{len(files)} file(s)**." + ) if subsystems: bullets.append("Touches: " + ", ".join(subsystems)) if any("test" in f.lower() for f in files): bullets.append("Includes test changes.") - if any(f.endswith(('.md', '.txt')) for f in files): + if any(f.endswith((".md", ".txt")) for f in files): bullets.append("Includes documentation updates.") - body = "\n".join(f"- {b}" for b in bullets) if bullets else "- Minor internal changes." + body = ( + "\n".join(f"- {b}" for b in bullets) if bullets else "- Minor internal changes." + ) return f"""# {title} ## What changed @@ -61,6 +76,7 @@ def heuristic_summary(diff_text: str) -> str: - Rollback: `git revert ` or feature flag off """ + def llm_summary(diff_text: str) -> str | None: api_key = os.getenv("LLM_API_KEY") base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") @@ -92,7 +108,10 @@ def llm_summary(diff_text: str) -> str | None: data = { "model": model, "messages": [ - {"role": "system", "content": "You are an expert release engineer. Be precise and concise."}, + { + "role": "system", + "content": "You are an expert release engineer. Be precise and concise.", + }, {"role": "user", "content": prompt}, ], "temperature": 0.2, @@ -100,7 +119,10 @@ def llm_summary(diff_text: str) -> str | None: req = urllib.request.Request( f"{base_url}/chat/completions", data=json.dumps(data).encode("utf-8"), - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, ) try: with urllib.request.urlopen(req, timeout=60) as resp: @@ -109,9 +131,11 @@ def llm_summary(diff_text: str) -> str | None: except Exception: return None + def main(): ap = argparse.ArgumentParser() - ap.add_argument("--base"); ap.add_argument("--head") + ap.add_argument("--base") + ap.add_argument("--head") ap.add_argument("--staged", action="store_true") ap.add_argument("--llm", action="store_true") args = ap.parse_args() @@ -120,9 +144,10 @@ def main(): if args.llm: md = llm_summary(diff_text) if md: - print(md); return + print(md) + return print(heuristic_summary(diff_text)) + if __name__ == "__main__": main() - diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 82414d48..b4e72ff6 100644 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -16,7 +16,13 @@ LLM_BASE_URL = https://api.openai.com/v1 (or your own endpoint) LLM_MODEL = gpt-4o-mini (or any served model) """ -import argparse, os, subprocess, re, json, urllib.request +import argparse +import os +import subprocess +import re +import json +import urllib.request + def run(cmd: list[str]) -> str: p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) @@ -24,12 +30,14 @@ def run(cmd: list[str]) -> str: raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{p.stderr}") return p.stdout + def last_tag() -> str | None: try: return run(["git", "describe", "--tags", "--abbrev=0"]).strip() except Exception: return None + def commits(since: str | None = None, until: str = "HEAD") -> list[str]: rng = f"{since}..{until}" if since else until # Prefer merge subjects first (often carry PR titles) @@ -38,9 +46,26 @@ def commits(since: str | None = None, until: str = "HEAD") -> list[str]: log = run(["git", "log", "--pretty=%s", rng]) return [ln.strip() for ln in log.splitlines() if ln.strip()] + def categorize(msgs: list[str]) -> dict[str, list[str]]: - cats = {k: [] for k in ["feat","fix","perf","docs","refactor","test","build","ci","chore","other"]} - pat = re.compile(r"^(feat|fix|perf|docs|refactor|test|build|ci|chore)(\(.+?\))?:\s*(.+)$", re.I) + cats = { + k: [] + for k in [ + "feat", + "fix", + "perf", + "docs", + "refactor", + "test", + "build", + "ci", + "chore", + "other", + ] + } + pat = re.compile( + r"^(feat|fix|perf|docs|refactor|test|build|ci|chore)(\(.+?\))?:\s*(.+)$", re.I + ) for m in msgs: mt = pat.match(m) if mt: @@ -49,12 +74,36 @@ def categorize(msgs: list[str]) -> dict[str, list[str]]: cats["other"].append(m) return cats + def format_notes(cats: dict[str, list[str]], since: str | None, until: str) -> str: - title = f"Release Notes ({since} → {until})" if since else f"Release Notes (up to {until})" - order = ["feat","fix","perf","refactor","docs","test","build","ci","chore","other"] + title = ( + f"Release Notes ({since} → {until})" + if since + else f"Release Notes (up to {until})" + ) + order = [ + "feat", + "fix", + "perf", + "refactor", + "docs", + "test", + "build", + "ci", + "chore", + "other", + ] labels = { - "feat":"Features","fix":"Fixes","perf":"Performance","refactor":"Refactoring", - "docs":"Documentation","test":"Tests","build":"Build","ci":"CI","chore":"Chore","other":"Other" + "feat": "Features", + "fix": "Fixes", + "perf": "Performance", + "refactor": "Refactoring", + "docs": "Documentation", + "test": "Tests", + "build": "Build", + "ci": "CI", + "chore": "Chore", + "other": "Other", } out = [f"# {title}", ""] for k in order: @@ -66,6 +115,7 @@ def format_notes(cats: dict[str, list[str]], since: str | None, until: str) -> s out.append("— Generated locally by release_notes.py") return "\n".join(out) + def llm_polish(markdown: str) -> str | None: api_key = os.getenv("LLM_API_KEY") base_url = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") @@ -81,14 +131,17 @@ def llm_polish(markdown: str) -> str | None: "model": model, "messages": [ {"role": "system", "content": "You are a meticulous technical editor."}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ], "temperature": 0.2, } req = urllib.request.Request( f"{base_url}/chat/completions", data=json.dumps(data).encode("utf-8"), - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, ) try: with urllib.request.urlopen(req, timeout=60) as resp: @@ -97,12 +150,15 @@ def llm_polish(markdown: str) -> str | None: except Exception: return None + def main(): ap = argparse.ArgumentParser() ap.add_argument("--since", help="tag/ref to start from (e.g., v1.2.0)") ap.add_argument("--until", default="HEAD", help="end ref (default HEAD)") ap.add_argument("--last-tag", action="store_true", help="use last tag as --since") - ap.add_argument("--llm", action="store_true", help="polish output via LLM if env vars set") + ap.add_argument( + "--llm", action="store_true", help="polish output via LLM if env vars set" + ) args = ap.parse_args() since = args.since or (last_tag() if args.last_tag else None) @@ -116,6 +172,6 @@ def main(): return print(md) + if __name__ == "__main__": main() -