Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions scripts/pr_desc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/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
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)
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 <merge-commit>` 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:

# <Concise PR title, 8–12 words>

## 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()
177 changes: 177 additions & 0 deletions scripts/release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#!/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
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)
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()
Loading