Files
gramps/scripts/make_release_notes.py
Doug Blank a32b463b16 Add release notes generator script using litellm
scripts/make_release_notes.py fetches a GitHub release body and commit
history via the gh CLI, resolves author logins, then calls any LLM
supported by litellm (Anthropic Claude, Google Gemini, etc.) to produce
categorised, author-attributed release notes in Markdown.

Fetched data is cached in RELEASE_DATA_<version>.json so subsequent
runs (e.g. trying a different model or revised prompt) skip all GitHub
API calls.  Use --no-cache to force a fresh fetch.  The --model flag
accepts any litellm model string such as gemini/gemini-2.5-flash or
anthropic/claude-opus-4-7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Himanshu Gohel <1551217+hgohel@users.noreply.github.com>
2026-07-04 22:43:17 +01:00

539 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate formatted Gramps release notes from GitHub release data.
Fetches the release body and commit history via the gh CLI, resolves GitHub
usernames for any commits whose author couldn't be matched, then calls an
LLM via litellm to produce categorised, author-attributed release notes in
the project's standard Markdown format.
Fetched GitHub data is saved to RELEASE_DATA_<version>.json and reused on
subsequent runs, so re-runs with a different model or modified prompt skip
all GitHub API calls. Use --no-cache to force a fresh fetch.
Optionally appends a section listing open PRs/issues targeted at a milestone
but not yet merged (--milestone <number>).
Requirements:
gh CLI authenticated (gh auth login)
pip install litellm
ANTHROPIC_API_KEY or GOOGLE_API_KEY etc. set in environment
Usage:
# First run — fetches from GitHub, saves cache, generates notes
python3 scripts/make_release_notes.py --to-tag v6.1.0-beta1 --from-tag v6.0.7
# --to-tag defaults to the latest tag in the same major.minor series
python3 scripts/make_release_notes.py --from-tag v6.0.7
# Re-run — loads cache automatically, no GitHub calls
python3 scripts/make_release_notes.py --to-tag v6.1.0-beta1
# Re-run with a different model
python3 scripts/make_release_notes.py --to-tag v6.1.0-beta1 --model gemini/gemini-2.5-flash
# Force re-fetch and overwrite cache
python3 scripts/make_release_notes.py --to-tag v6.1.0-beta1 --from-tag v6.0.7 --no-cache
# Notes between any two arbitrary tags
python3 scripts/make_release_notes.py --from-tag v6.0.5 --to-tag v6.0.6
# Wildcards work on both --from-tag and --to-tag
python3 scripts/make_release_notes.py --from-tag "6.0.*" --to-tag "6.1.*"
"""
import argparse
import fnmatch
import json
import re
import subprocess
import sys
from pathlib import Path
def gh(*args):
result = subprocess.run(
["gh"] + list(args),
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def get_release_body(tag, repo):
return gh("release", "view", tag, "--repo", repo, "--json", "body", "--jq", ".body")
def get_commits(from_tag, to_tag, repo):
jq = (
".commits[] | "
"{sha: .sha[0:8], "
" author: .author.login, "
' message: (.commit.message | split("\\n")[0])}'
)
output = gh("api", f"repos/{repo}/compare/{from_tag}...{to_tag}", "--jq", jq)
commits = []
for line in output.splitlines():
line = line.strip()
if line:
commits.append(json.loads(line))
return commits
def resolve_author_via_pr(sha, repo):
"""Find the PR that introduced this commit and return its author login."""
try:
output = gh(
"api",
f"repos/{repo}/commits/{sha}/pulls",
"--jq",
".[0].user.login",
)
return output.strip() or None
except subprocess.CalledProcessError:
return None
def resolve_null_authors(commits, repo):
"""Fill in missing author logins by looking up the associated PR."""
for commit in commits:
if commit["author"] is None:
author = resolve_author_via_pr(commit["sha"], repo)
if author:
commit["author"] = author
print(
f" resolved @{author} for: {commit['message'][:60]}",
file=sys.stderr,
)
return commits
SKIP_PATTERNS = re.compile(
r"^(Merge (branch|pull request)|"
r"Translated using Weblate|"
r"Update translation files|"
r"Set to development version|"
r"Release Gramps|"
r"Update (gramps\.pot|ChangeLog|NEWS|copyright)|"
r"Unrelated formatting|"
r"Fix code formatting|"
r"Small one-line fixes|"
r"Code improvements|"
r"Remove out of date comments|"
r"Fix spelling mistake)",
re.IGNORECASE,
)
def filter_commits(commits):
return [c for c in commits if not SKIP_PATTERNS.match(c["message"])]
def save_cache(
path, tag, from_tag, repo, release_date, release_body, commits, milestone_items
):
"""Save fetched GitHub data to a JSON file for later re-use."""
data = {
"tag": tag,
"from_tag": from_tag,
"repo": repo,
"release_date": release_date,
"release_body": release_body,
"commits": commits,
"milestone_items": milestone_items,
}
path.write_text(json.dumps(data, indent=2))
print(f"Cache saved to {path}", file=sys.stderr)
def load_cache(path):
"""Load previously fetched GitHub data from a JSON file."""
if not path.exists():
print(f"ERROR: cache file not found: {path}", file=sys.stderr)
sys.exit(1)
data = json.loads(path.read_text())
print(f"Loaded cache from {path}", file=sys.stderr)
return data
EXAMPLE_OUTPUT = """
## New Features
### UI Enhancements
- **Favorites sidebar**: Add a favorites sidebar and persistent sidebar for all views ([#2091](https://github.com/gramps-project/gramps/pull/2091)) — ***SNoiraud***
## Bug Fixes
### Data & Calculations
- **sort_key**: Replace `hexlify` with `getSortKey()` bytes, drop `binascii` import ([Bug #10077](https://gramps-project.org/bugs/view.php?id=10077)) — ***dsblank***
## Translations
Updated: da, es, fr, hr.
""".strip()
DEFAULT_MODEL = "anthropic/claude-opus-4-7"
def generate_notes(release_body, commits, tag, repo, release_date, model):
"""Call an LLM via litellm to produce Markdown release notes."""
try:
import litellm
except ImportError:
print(
"ERROR: 'litellm' package not installed. Run: pip install litellm",
file=sys.stderr,
)
sys.exit(1)
commit_lines = "\n".join(
f"- [{c['sha']}] @{c['author'] or 'unknown'}: {c['message']}" for c in commits
)
prompt = f"""You are generating release notes for Gramps genealogy software.
Release: {tag}
Repo: https://github.com/{repo}
Date: {release_date}
GitHub release body (contains the official bullet list):
<release_body>
{release_body}
</release_body>
Commits with GitHub author logins (use these to attribute each item):
<commits>
{commit_lines}
</commits>
Write Markdown release notes following this exact structure:
1. `# Gramps {{version}} — Release Notes ({{date}})`
2. One short intro paragraph (2-3 sentences).
3. `> **Always [back up...]** ...` blockquote.
4. Note if this release includes fixes from a prior maintenance release.
5. `---` separator then sections:
- **New Features** with sub-sections by area (e.g. UI Enhancements, Filters, Reports & NarrativeWeb)
- **Bug Fixes** with sub-sections (e.g. Data & Calculations, UI & Editing, Windows)
- **Build System & Type Checking** (if relevant)
- **Testing** (if relevant)
- **Translations** (list language codes updated)
Format each item as:
`- **Label**: Description ([#PR](url) or [Bug #N](url)) — ***@author***`
Rules:
- Use `https://gramps-project.org/bugs/view.php?id=NNNNN` for bug tracker links.
- Use `https://github.com/{repo}/pull/NNN` for PR links.
- Skip merge commits, translation commits, and trivial cleanup.
- Match authors from the commits list. If a commit message mentions a bug number or PR, include the link.
- Group related items; don't list every trivial commit separately.
Example item format:
{EXAMPLE_OUTPUT}
Output only the Markdown. No preamble, no explanation."""
print(f"Calling {model}...", file=sys.stderr)
response = litellm.completion(
model=model,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
def get_milestone_open_items(milestone, repo):
"""Return open PRs and issues assigned to the given milestone number."""
items = []
page = 1
while True:
output = gh(
"api",
f"repos/{repo}/issues?milestone={milestone}&state=open&per_page=100&page={page}",
"--jq",
".[] | {number, title, is_pr: (.pull_request != null),"
" labels: [.labels[].name], user: .user.login}",
)
batch = []
for line in output.splitlines():
line = line.strip()
if line:
batch.append(json.loads(line))
items.extend(batch)
if len(batch) < 100:
break
page += 1
return items
def format_milestone_section(items, repo):
"""Return a Markdown section listing open milestone items."""
if not items:
return ""
prs = [i for i in items if i["is_pr"]]
issues = [i for i in items if not i["is_pr"]]
lines = ["\n---\n", "## Targeted for This Release (Not Yet Merged)\n"]
lines.append(
"_The following pull requests and issues are milestoned for this release "
"but have not been merged yet._\n"
)
if prs:
lines.append("### Open Pull Requests\n")
for item in sorted(prs, key=lambda x: x["number"]):
url = f"https://github.com/{repo}/pull/{item['number']}"
label_str = (
" `" + "` `".join(item["labels"]) + "`" if item["labels"] else ""
)
lines.append(
f"- [#{item['number']}]({url}) {item['title']}"
f"{label_str} — ***@{item['user']}***"
)
if issues:
lines.append("\n### Open Issues\n")
for item in sorted(issues, key=lambda x: x["number"]):
url = f"https://github.com/{repo}/issues/{item['number']}"
label_str = (
" `" + "` `".join(item["labels"]) + "`" if item["labels"] else ""
)
lines.append(
f"- [#{item['number']}]({url}) {item['title']}"
f"{label_str} — ***@{item['user']}***"
)
return "\n".join(lines) + "\n"
def version_key(tag):
"""Sort key for version tags: by numeric components, releases above pre-releases."""
base = tag.split("-")[0] # strip pre-release suffix (e.g. -beta1, -rc1)
nums = tuple(int(n) for n in re.findall(r"\d+", base))
return (nums, "-" not in tag)
def fetch_all_tags(repo):
"""Return all tag names for a GitHub repo."""
tags = []
page = 1
while True:
output = gh(
"api",
f"repos/{repo}/tags?per_page=100&page={page}",
"--jq",
"[.[].name]",
)
batch = json.loads(output)
tags.extend(batch)
if len(batch) < 100:
break
page += 1
return tags
def get_latest_tag_in_series(from_tag, repo):
"""Return the newest tag sharing the same major.minor version as from_tag."""
match = re.match(r"^v?(\d+)\.(\d+)", from_tag)
if not match:
return None
prefix = f"v{match.group(1)}.{match.group(2)}."
series = [t for t in fetch_all_tags(repo) if t.startswith(prefix)]
return sorted(series, key=version_key)[-1] if series else None
def resolve_wildcard_tag(pattern, repo):
"""Return the newest tag matching a glob pattern such as '6.1.*' or 'v6.1.*'."""
if not pattern.startswith("v"):
pattern = "v" + pattern
matches = [t for t in fetch_all_tags(repo) if fnmatch.fnmatch(t, pattern)]
return sorted(matches, key=version_key)[-1] if matches else None
def dump_raw(release_body, commits, tag, milestone_items=None):
print(f"=== Release body for {tag} ===\n")
print(release_body)
print(f"\n=== {len(commits)} commits (after filtering) ===\n")
for c in commits:
print(f" [{c['sha']}] @{c['author'] or '?':20s} {c['message']}")
if milestone_items is not None:
print(f"\n=== {len(milestone_items)} open milestone items ===\n")
for item in milestone_items:
kind = "PR" if item["is_pr"] else "Issue"
print(f" [{kind} #{item['number']}] @{item['user']:20s} {item['title']}")
def main():
parser = argparse.ArgumentParser(
description="Generate Gramps release notes using gh CLI + an LLM via litellm"
)
parser.add_argument(
"--to-tag",
default=None,
help=(
"Release tag, e.g. v6.1.0-beta1. Accepts glob patterns such as '6.1.*' "
"(resolves to the newest matching tag). "
"Defaults to the latest tag in the same major.minor series as --from-tag."
),
)
parser.add_argument(
"--from-tag",
default=None,
help=(
"Previous release tag, e.g. v6.0.7 (required when no cache exists). "
"Accepts glob patterns such as '6.0.*' (resolves to the newest matching tag)."
),
)
parser.add_argument(
"--repo", default="gramps-project/gramps", help="GitHub repo (owner/name)"
)
parser.add_argument(
"--output",
help="Output file (default: RELEASE_NOTES_<version>.md in cwd)",
)
parser.add_argument(
"--dump-only",
action="store_true",
help="Print raw data instead of calling the LLM",
)
parser.add_argument(
"--release-date",
help="Override release date (default: taken from gh release view)",
)
parser.add_argument(
"--milestone",
help="GitHub milestone number; appends a section of open (not-yet-merged) items",
)
parser.add_argument(
"--model",
default=DEFAULT_MODEL,
help=(
f"LLM model string passed to litellm (default: {DEFAULT_MODEL}). "
"Examples: gemini/gemini-2.5-flash, anthropic/claude-sonnet-4-6"
),
)
parser.add_argument(
"--no-cache",
action="store_true",
help="Ignore any existing cache and re-fetch from GitHub (requires --from-tag)",
)
args = parser.parse_args()
from_tag = args.from_tag
if from_tag and ("*" in from_tag or "?" in from_tag):
print(f"Resolving wildcard pattern {from_tag!r}...", file=sys.stderr)
from_tag = resolve_wildcard_tag(from_tag, args.repo)
if not from_tag:
parser.error(f"No tags matched pattern {args.from_tag!r} in {args.repo}")
print(f" Using --from-tag {from_tag}", file=sys.stderr)
to_tag = args.to_tag
if to_tag and ("*" in to_tag or "?" in to_tag):
print(f"Resolving wildcard pattern {to_tag!r}...", file=sys.stderr)
to_tag = resolve_wildcard_tag(to_tag, args.repo)
if not to_tag:
parser.error(f"No tags matched pattern {args.to_tag!r} in {args.repo}")
print(f" Using --to-tag {to_tag}", file=sys.stderr)
elif not to_tag:
if not from_tag:
parser.error("--to-tag or --from-tag is required")
print(f"Resolving latest tag in series from {from_tag}...", file=sys.stderr)
to_tag = get_latest_tag_in_series(from_tag, args.repo)
if not to_tag:
parser.error(
f"No tags found in the same series as {from_tag} in {args.repo}"
)
print(f" Using --to-tag {to_tag}", file=sys.stderr)
version = to_tag.lstrip("v")
output_path = Path(args.output or f"RELEASE_NOTES_{version}.md")
cache_path = Path(f"RELEASE_DATA_{version}.json")
use_cache = cache_path.exists() and not args.no_cache
if use_cache:
data = load_cache(cache_path)
tag = data["tag"]
from_tag = data["from_tag"]
repo = data["repo"]
release_date = data["release_date"]
release_body = data["release_body"]
commits = data["commits"]
milestone_items = data["milestone_items"]
print(
f" {len(commits)} commits, "
f"{'no' if milestone_items is None else len(milestone_items)} milestone items",
file=sys.stderr,
)
else:
if not from_tag:
parser.error(
"--from-tag is required when no cache file exists (or with --no-cache)"
)
tag = to_tag
repo = args.repo
print(f"Fetching release info for {tag}...", file=sys.stderr)
release_body = get_release_body(tag, repo)
release_date = args.release_date
if not release_date:
raw = gh(
"release",
"view",
tag,
"--repo",
repo,
"--json",
"publishedAt",
"--jq",
".publishedAt[:10]",
)
release_date = raw.strip()
print(f"Fetching commits {from_tag}...{tag}...", file=sys.stderr)
commits = get_commits(from_tag, tag, repo)
print(f" {len(commits)} commits found", file=sys.stderr)
print("Resolving missing authors...", file=sys.stderr)
commits = resolve_null_authors(commits, repo)
commits = filter_commits(commits)
print(f" {len(commits)} commits after filtering", file=sys.stderr)
milestone_items = None
if args.milestone:
print(f"Fetching open milestone {args.milestone} items...", file=sys.stderr)
milestone_items = get_milestone_open_items(args.milestone, repo)
print(f" {len(milestone_items)} open items found", file=sys.stderr)
save_cache(
cache_path,
tag,
from_tag,
repo,
release_date,
release_body,
commits,
milestone_items,
)
if args.dump_only:
dump_raw(release_body, commits, tag, milestone_items)
return
notes = generate_notes(release_body, commits, tag, repo, release_date, args.model)
if milestone_items is not None:
notes += format_milestone_section(milestone_items, repo)
output_path.write_text(notes, encoding="utf-8")
print(f"Written to {output_path}", file=sys.stderr)
if __name__ == "__main__":
main()