| #!/usr/bin/env python3 | |
| # Copyright (c) Meta Platforms, Inc. and affiliates. | |
| # | |
| # This source code is licensed under the MIT license found in the | |
| # LICENSE file in the root directory of this source tree. | |
| """ | |
| Release Notes Generator | |
| Generates release notes for a GitHub repository by gathering commit, contributor, | |
| and issue data between two refs (tags or branches), then using an LLM to produce | |
| polished markdown from a user-provided template. | |
| The to_ref argument can be a tag or a branch name. When it's a branch, the script | |
| uses the latest commit on that branch, allowing you to prepare release notes before | |
| the release tag is created. | |
| Usage: | |
| python generate_release_notes.py facebook/pyrefly v0.1.0 v0.2.0 | |
| python generate_release_notes.py facebook/pyrefly v0.1.0 main --version v0.2.0 | |
| python generate_release_notes.py facebook/pyrefly v0.1.0 v0.2.0 -o notes.md | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import urllib.error | |
| import urllib.request | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any, Callable, Dict, List, Optional, Tuple | |
| _SCRIPT_DIR: Path = Path(__file__).resolve().parent | |
| _RELEASE_NOTES_DIR: Path = _SCRIPT_DIR.parent / "release_notes" | |
| try: | |
| # Buck build: use fully-qualified module path | |
| from pyrefly.scripts.github_utils import ( | |
| ContributorAnalyzer, | |
| get_tag_date, | |
| GitHubClient, | |
| load_env_file, | |
| ) | |
| from pyrefly.scripts.version_helpers import is_prerelease | |
| except ImportError: | |
| # Standalone / GitHub Actions: fall back to relative import via sys.path | |
| sys.path.insert(0, str(_SCRIPT_DIR)) | |
| from github_utils import ( # type: ignore[import-not-found] # noqa: F811 | |
| ContributorAnalyzer, # pyrefly: ignore | |
| get_tag_date, # pyrefly: ignore | |
| GitHubClient, # pyrefly: ignore | |
| load_env_file, # pyrefly: ignore | |
| ) | |
| from version_helpers import ( # type: ignore[import-not-found] # noqa: F811 # pyrefly: ignore | |
| is_prerelease, | |
| ) | |
| _DEV_DISCLAIMER: str = ( | |
| "> **About dev releases**\n" | |
| "> Dev releases (versions like `X.Y.Z-dev.N`) are non-stable snapshots " | |
| "cut periodically from trunk. They give early adopters a chance to try " | |
| "in-progress features and surface issues before the next stable release, " | |
| "but they don't carry the same stability or compatibility guarantees as " | |
| "a stable release — don't pin production projects to a dev version." | |
| ) | |
| def _dev_disclaimer_for(version: str) -> str: | |
| """Return the dev-release disclaimer markdown for `version`, or empty. | |
| Stripping the leading `v` from a tag-shaped version is intentional: | |
| `is_prerelease` parses canonical semver and rejects the `v` prefix. | |
| Stable releases (no `-dev.N`) get no disclaimer. | |
| """ | |
| canonical = version.lstrip("v") | |
| try: | |
| return _DEV_DISCLAIMER if is_prerelease(canonical) else "" | |
| except ValueError: | |
| return "" | |
| def resolve_ref_date( | |
| client: GitHubClient, owner: str, repo: str, ref: str | |
| ) -> Tuple[datetime, str, Optional[str]]: | |
| """ | |
| Resolve a git ref (tag or branch name) to its commit date. | |
| Tries to resolve as a tag first. If that fails, tries as a branch. | |
| Returns: | |
| Tuple of (datetime, ref_type, commit_sha) where ref_type is "tag" or | |
| "branch" and commit_sha is the HEAD commit SHA (only set for branches). | |
| """ | |
| try: | |
| dt = get_tag_date(client, owner, repo, ref) | |
| return dt, "tag", None | |
| except ValueError: | |
| pass | |
| url = f"{client.base_url}/repos/{owner}/{repo}/branches/{ref}" | |
| try: | |
| branch = client._make_request(url) | |
| commit_sha: str = branch["commit"]["sha"] | |
| commit_date: str = branch["commit"]["commit"]["committer"]["date"] | |
| dt = datetime.fromisoformat(commit_date.replace("Z", "+00:00")) | |
| return dt, "branch", commit_sha | |
| except Exception: | |
| raise ValueError(f"'{ref}' is not a valid tag or branch in {owner}/{repo}") | |
| def get_commits_between_tags( | |
| client: GitHubClient, owner: str, repo: str, from_tag: str, to_tag: str | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| Get all commits between two tags using the GitHub compare API. | |
| Paginates through all results. The compare endpoint caps each response at | |
| 250 commits while reporting the true size of the range in `total_commits`, | |
| so a single unpaginated request silently truncates any longer range. | |
| """ | |
| url = f"{client.base_url}/repos/{owner}/{repo}/compare/{from_tag}...{to_tag}" | |
| commits: List[Dict[str, Any]] = [] | |
| total: Optional[int] = None | |
| page = 1 | |
| while True: | |
| try: | |
| comparison = client._make_request(f"{url}?per_page=100&page={page}") | |
| except Exception as e: | |
| raise ValueError( | |
| f"Could not compare tags {from_tag}...{to_tag}: {e}" | |
| ) from e | |
| # Latch the range size from the first page. GitHub recomputes | |
| # `total_commits` on every response, so commits landing mid-pagination | |
| # would otherwise shift the termination target out from under the loop. | |
| if total is None: | |
| total = comparison["total_commits"] | |
| page_commits = comparison.get("commits", []) | |
| if not page_commits: | |
| break | |
| commits.extend(page_commits) | |
| # pyrefly: ignore [unsupported-operation] | |
| if len(commits) >= total: | |
| break | |
| page += 1 | |
| # Notes generated from a partial range silently omit whole features rather | |
| # than looking wrong, so refuse to summarize a short read. | |
| if len(commits) != total: | |
| raise ValueError( | |
| f"Expected {total} commits between {from_tag} and {to_tag}, but the " | |
| f"compare API returned {len(commits)}." | |
| ) | |
| return commits | |
| def get_closed_issues_between_dates( | |
| client: GitHubClient, owner: str, repo: str, since: str, until: str | |
| ) -> List[Dict[str, Any]]: | |
| """ | |
| Get issues (not PRs) closed between two ISO 8601 date strings. | |
| Paginates through all results. | |
| """ | |
| issues: List[Dict[str, Any]] = [] | |
| page = 1 | |
| while True: | |
| url = ( | |
| f"{client.base_url}/repos/{owner}/{repo}/issues" | |
| f"?state=closed&since={since}&per_page=100&page={page}" | |
| f"&sort=updated&direction=asc" | |
| ) | |
| page_issues = client._make_request(url) | |
| if not page_issues: | |
| break | |
| for issue in page_issues: | |
| if "pull_request" in issue: | |
| continue | |
| closed_at = issue.get("closed_at", "") | |
| if closed_at and since <= closed_at <= until: | |
| issues.append(issue) | |
| if len(page_issues) < 100: | |
| break | |
| page += 1 | |
| return issues | |
| def filter_bug_issues(issues: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| """Return issues identified as bugs by either issue type or label.""" | |
| bug_issues: List[Dict[str, Any]] = [] | |
| for issue in issues: | |
| issue_type = issue.get("type") | |
| if issue_type and issue_type.get("name", "").lower() == "bug": | |
| bug_issues.append(issue) | |
| continue | |
| labels = [label.get("name", "").lower() for label in issue.get("labels", [])] | |
| if "bug" in labels: | |
| bug_issues.append(issue) | |
| return bug_issues | |
| # --------------------------------------------------------------------------- | |
| # LLM provider helpers | |
| # --------------------------------------------------------------------------- | |
| # gpt-4o has a 128k-token context window and `call_openai` reserves 16384 for output. | |
| OPENAI_MAX_PROMPT_TOKENS: int = 128000 - 16384 | |
| def call_openai(user_prompt: str, system_prompt: str, api_key: str) -> str: | |
| """Generate text via the OpenAI Chat Completions API.""" | |
| url = "https://api.openai.com/v1/chat/completions" | |
| headers = { | |
| "Content-Type": "application/json", | |
| "Authorization": f"Bearer {api_key}", | |
| } | |
| payload = json.dumps( | |
| { | |
| "model": "gpt-4o", | |
| "max_tokens": 16384, | |
| "messages": [ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| } | |
| ).encode() | |
| req = urllib.request.Request(url, data=payload, headers=headers, method="POST") | |
| with urllib.request.urlopen(req) as response: | |
| result = json.loads(response.read().decode()) | |
| return result["choices"][0]["message"]["content"] | |
| # --------------------------------------------------------------------------- | |
| # Prompt assembly | |
| # --------------------------------------------------------------------------- | |
| def format_commit_lines(commits: List[Dict[str, Any]]) -> List[str]: | |
| """Render each commit as one markdown bullet: short SHA, author, message.""" | |
| commit_lines: List[str] = [] | |
| for c in commits: | |
| sha = c.get("sha", "")[:7] | |
| msg = c.get("commit", {}).get("message", "").strip() | |
| author = ( | |
| c.get("author", {}).get("login", "unknown") | |
| if c.get("author") | |
| else "unknown" | |
| ) | |
| indented_msg = msg.replace("\n", "\n ") | |
| commit_lines.append(f"- {sha} ({author}): {indented_msg}") | |
| return commit_lines | |
| def build_user_prompt( | |
| template: str, | |
| commit_log: str, | |
| total_commits: int, | |
| contributors: Dict[str, Dict[str, Any]], | |
| bug_issues: List[Dict[str, Any]], | |
| all_closed_issues: List[Dict[str, Any]], | |
| from_tag: str, | |
| to_tag: str, | |
| owner: str, | |
| repo: str, | |
| archived_notes: str = "", | |
| ) -> str: | |
| """ | |
| Assemble the user prompt that is sent to the LLM alongside the system prompt. | |
| Includes the template, commit log, issue lists, and contributor handles. | |
| `commit_log` is either the raw commit bullets or, for a range too large to | |
| fit in one prompt, the LLM-condensed summaries of them. | |
| """ | |
| bug_lines: List[str] = [] | |
| for issue in bug_issues: | |
| body = (issue.get("body") or "")[:500] | |
| bug_lines.append(f"- #{issue['number']}: {issue['title']}\n Body: {body}") | |
| all_bug_numbers = ", ".join(f"#{i['number']}" for i in bug_issues) | |
| closed_lines = [ | |
| f"- #{issue['number']}: {issue['title']}" for issue in all_closed_issues | |
| ] | |
| sorted_contribs = sorted(contributors.values(), key=lambda x: -x["commit_count"]) | |
| contrib_parts: List[str] = [] | |
| for c in sorted_contribs: | |
| if c["type"] == "github_user": | |
| contrib_parts.append(f"@{c['name']}") | |
| else: | |
| name = c["name"].split(" <")[0] | |
| contrib_parts.append(name) | |
| handles = ", ".join(contrib_parts) | |
| today = datetime.now().strftime("%B %d, %Y") | |
| return f"""Generate release notes for the following release. | |
| **Project:** {owner}/{repo} | |
| **Version:** {to_tag} | |
| **Previous version:** {from_tag} | |
| **Release date:** {today} | |
| **Total commits:** {total_commits} | |
| **Total contributors:** {len(contributors)} | |
| **Total issues closed:** {len(all_closed_issues)} | |
| **Bug issues closed:** {len(bug_issues)} | |
| --- | |
| ## Markdown template to follow | |
| ```markdown | |
| {template} | |
| ``` | |
| --- | |
| ## Commits between {from_tag} and {to_tag} ({total_commits} total) | |
| {commit_log or "No commits found."} | |
| --- | |
| ## Bug issues closed ({len(bug_issues)}) | |
| {chr(10).join(bug_lines) if bug_lines else "None"} | |
| All bug issue numbers: {all_bug_numbers} | |
| IMPORTANT: For the bug fixes section, pick the 10 most important/interesting bugs | |
| from the list above and write detailed bullet points for those only. Then add a final | |
| "And more!" bullet point listing ALL remaining issue numbers (the ones you did NOT | |
| describe in detail) as a comma-separated list. | |
| --- | |
| ## All closed issues ({len(all_closed_issues)}) | |
| {chr(10).join(closed_lines) if closed_lines else "None"} | |
| --- | |
| ## Contributors | |
| {handles} | |
| --- | |
| ## Previous release notes (for style and tone reference) | |
| {archived_notes if archived_notes else "No archived release notes provided."} | |
| --- | |
| Fill in every placeholder in the template using the data above. Output only the final | |
| markdown — no commentary, no code fences wrapping the entire document. | |
| """ | |
| _BATCH_SYSTEM_PROMPT: str = """You are helping write release notes for an \ | |
| open-source Python type checker and language server. You will receive one batch \ | |
| of a release's commits. The full commit log is too long for a single request, so \ | |
| each batch is condensed separately and the condensed batches are combined later \ | |
| to write the final release notes. | |
| Condense this batch into a markdown bullet list of candidate release-note items: | |
| - Keep every user-facing change: new features, behavior changes, new or changed \ | |
| configuration options and CLI flags, language server improvements, type checking \ | |
| improvements, and bug fixes. Describe each one in a sentence or two, in terms of \ | |
| what changed for the user. | |
| - Keep any GitHub issue numbers a commit references (e.g. "Fixes #1234"). | |
| - End each bullet with the short SHAs and author handles of the commits it \ | |
| covers, in parentheses. | |
| - Merge commits that contribute to the same change into one bullet. | |
| - Drop purely internal refactors, test-only changes, build-system tweaks, \ | |
| dependency bumps, and website or documentation changes. | |
| Output only the bullet list.""" | |
| def estimate_tokens(text: str) -> int: | |
| """Conservatively estimate the token count of `text`. | |
| Commit logs are dense with SHAs, identifiers, and code, which tokenize at | |
| closer to 3 characters per token than the ~4 typical of English prose. | |
| """ | |
| return len(text) // 3 | |
| def batch_lines(lines: List[str], max_tokens: int) -> List[List[str]]: | |
| """Greedily pack `lines`, in order, into batches of at most `max_tokens` each. | |
| A line larger than `max_tokens` is truncated, with a log line, to fit in a | |
| batch of its own. | |
| """ | |
| batches: List[List[str]] = [] | |
| current: List[str] = [] | |
| current_tokens = 0 | |
| max_chars = (max_tokens - 1) * 3 | |
| for line in lines: | |
| if len(line) > max_chars: | |
| print(f" Truncating oversized commit to fit a batch: {line[:60]}") | |
| line = line[:max_chars] | |
| line_tokens = estimate_tokens(line) + 1 | |
| if current and current_tokens + line_tokens > max_tokens: | |
| batches.append(current) | |
| current, current_tokens = [], 0 | |
| current.append(line) | |
| current_tokens += line_tokens | |
| if current: | |
| batches.append(current) | |
| return batches | |
| def condense_commit_log( | |
| commit_lines: List[str], | |
| max_prompt_tokens: int, | |
| generate_fn: Callable[[str, str, str], str], | |
| api_key: str, | |
| ) -> str: | |
| """Summarize a commit log too large for one prompt, one batch at a time. | |
| Each batch prompt holds the batch system prompt plus at most | |
| `max_prompt_tokens` worth of commits in total. The per-batch summaries are | |
| concatenated in commit order, so the final prompt sees the whole range. | |
| """ | |
| batches = batch_lines( | |
| commit_lines, max_prompt_tokens - estimate_tokens(_BATCH_SYSTEM_PROMPT) - 100 | |
| ) | |
| summaries: List[str] = [] | |
| for i, batch in enumerate(batches, start=1): | |
| print(f" Condensing commit batch {i}/{len(batches)} ({len(batch)} commits)") | |
| batch_prompt = f"## Commit batch {i} of {len(batches)}\n\n" + "\n".join(batch) | |
| summaries.append(generate_fn(batch_prompt, _BATCH_SYSTEM_PROMPT, api_key)) | |
| return ( | |
| f"The raw commit log was too long to include, so it was condensed in " | |
| f"{len(batches)} batches. Each bullet below summarizes one or more " | |
| f"commits.\n\n" + "\n\n".join(summaries) | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Post-generation validation / formatting fixes | |
| # --------------------------------------------------------------------------- | |
| def _fix_bug_fix_count(content: str, max_fixes: int = 10) -> Tuple[str, List[str]]: | |
| """Ensure no more than *max_fixes* detailed bug-fix bullet points. | |
| Any overflow bullets are collapsed into the "And more!" line. | |
| """ | |
| warnings: List[str] = [] | |
| lines = content.split("\n") | |
| bug_section_start: Optional[int] = None | |
| bug_section_end: Optional[int] = None | |
| for i, line in enumerate(lines): | |
| if "\U0001f41b" in line: # Bug emoji | |
| bug_section_start = i | |
| elif bug_section_start is not None and ( | |
| (line.startswith("## ") and "\U0001f41b" not in line) | |
| or (line.strip() == "---" and i > bug_section_start + 2) | |
| ): | |
| bug_section_end = i | |
| break | |
| if bug_section_start is None: | |
| return content, warnings | |
| if bug_section_end is None: | |
| bug_section_end = len(lines) | |
| section_lines = lines[bug_section_start:bug_section_end] | |
| header_lines: List[str] = [] | |
| bug_bullets: List[str] = [] | |
| and_more_line: Optional[str] = None | |
| trailing_lines: List[str] = [] | |
| state = "header" | |
| for line in section_lines: | |
| if state == "header": | |
| if re.match(r"^- #\d+", line): | |
| bug_bullets.append(line) | |
| state = "bullets" | |
| else: | |
| header_lines.append(line) | |
| elif state == "bullets": | |
| if re.match(r"^- #\d+", line): | |
| bug_bullets.append(line) | |
| elif line.startswith("- And more!"): | |
| and_more_line = line | |
| state = "trailing" | |
| else: | |
| trailing_lines.append(line) | |
| state = "trailing" | |
| else: | |
| trailing_lines.append(line) | |
| if len(bug_bullets) <= max_fixes: | |
| return content, warnings | |
| keep = bug_bullets[:max_fixes] | |
| overflow = bug_bullets[max_fixes:] | |
| overflow_numbers: List[str] = [] | |
| for b in overflow: | |
| match = re.match(r"^- #(\d+)", b) | |
| if match: | |
| overflow_numbers.append(f"#{match.group(1)}") | |
| existing_numbers: List[str] = [] | |
| if and_more_line: | |
| existing_numbers = re.findall(r"#\d+", and_more_line) | |
| all_remaining = overflow_numbers + existing_numbers | |
| new_and_more = f"- And more! {', '.join(all_remaining)}" | |
| warnings.append( | |
| f"Reduced bug-fix bullets from {len(bug_bullets)} to {max_fixes} " | |
| f"(moved {len(overflow)} to 'And more!')" | |
| ) | |
| new_section = header_lines + keep + [new_and_more] | |
| return ( | |
| "\n".join( | |
| lines[:bug_section_start] | |
| + new_section | |
| + trailing_lines | |
| + lines[bug_section_end:] | |
| ), | |
| warnings, | |
| ) | |
| def validate_and_fix_release_notes( | |
| content: str, max_bug_fixes: int = 10 | |
| ) -> Tuple[str, List[str]]: | |
| """Run post-generation formatting checks and fixes on the release notes. | |
| Caps detailed bug-fix bullet points at *max_bug_fixes*, moving extras | |
| into the "And more!" line. | |
| Returns: | |
| Tuple of (fixed_content, list_of_warnings). | |
| """ | |
| return _fix_bug_fix_count(content, max_bug_fixes) | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def _parse_args() -> argparse.Namespace: | |
| """Parse command-line arguments.""" | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Generate AI-powered release notes from GitHub data " | |
| "between two refs (tags or branches)." | |
| ), | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| # Between two tags | |
| python generate_release_notes.py facebook/pyrefly v0.1.0 v0.2.0 | |
| # Pre-release: from a tag to a branch (draft notes before tagging) | |
| python generate_release_notes.py facebook/pyrefly v0.1.0 main --version v0.2.0 | |
| # Custom output path | |
| python generate_release_notes.py facebook/pyrefly v0.1.0 v0.2.0 -o notes.md | |
| Environment variables: | |
| GITHUB_TOKEN GitHub personal access token (or set in .env) | |
| OPENAI_API_KEY Required when --provider is openai (default) | |
| """, | |
| ) | |
| parser.add_argument( | |
| "repo", | |
| help="Repository in 'owner/repo' format (e.g. facebook/pyrefly)", | |
| ) | |
| parser.add_argument("from_tag", help="Starting tag (exclusive)") | |
| parser.add_argument( | |
| "to_ref", | |
| help="Ending tag or branch name (e.g. v0.2.0 or main)", | |
| ) | |
| parser.add_argument( | |
| "--version", | |
| help=( | |
| "Version string for the release notes. " | |
| "Defaults to to_ref. Useful when to_ref is a branch name " | |
| "(e.g. --version v0.2.0 when to_ref is 'main')." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--template", | |
| help=( | |
| "Path to a markdown template file. " | |
| "Defaults to release_notes_template.md in the release_notes directory." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--prompt", | |
| help=( | |
| "Path to a custom system prompt file. " | |
| "Defaults to prompt.md in the release_notes directory." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--output", | |
| "-o", | |
| help="Output file path (default: release-notes-<version>.md)", | |
| ) | |
| parser.add_argument( | |
| "--provider", | |
| choices=["openai"], | |
| default="openai", | |
| help="LLM provider (default: openai)", | |
| ) | |
| return parser.parse_args() | |
| def _get_llm_config( | |
| provider: str, | |
| ) -> Tuple[str, Callable[[str, str, str], str]]: | |
| """Resolve the LLM provider to an API key and generate function.""" | |
| if provider == "openai": | |
| api_key = os.environ.get("OPENAI_API_KEY", "") | |
| if not api_key: | |
| print( | |
| "Error: OPENAI_API_KEY not set. " | |
| "Add it to your .env file or export it as an environment variable.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| return api_key, call_openai | |
| print(f"Error: Unknown provider '{provider}'.", file=sys.stderr) | |
| sys.exit(1) | |
| def _load_files( | |
| args: argparse.Namespace, | |
| ) -> Tuple[str, str, Path]: | |
| """Load template, system prompt, and resolve output path.""" | |
| template_path = ( | |
| Path(args.template) | |
| if args.template | |
| else _RELEASE_NOTES_DIR / "release_notes_template.md" | |
| ) | |
| if not template_path.exists(): | |
| print(f"Error: Template file not found: {template_path}", file=sys.stderr) | |
| sys.exit(1) | |
| template = template_path.read_text() | |
| prompt_path = Path(args.prompt) if args.prompt else _RELEASE_NOTES_DIR / "prompt.md" | |
| if not prompt_path.exists(): | |
| print(f"Error: Prompt file not found: {prompt_path}", file=sys.stderr) | |
| sys.exit(1) | |
| system_prompt = prompt_path.read_text().strip() | |
| version = args.version or args.to_ref | |
| safe_tag = version.replace("/", "-") | |
| output_path = ( | |
| Path(args.output) | |
| if args.output | |
| else _RELEASE_NOTES_DIR / f"release-notes-{safe_tag}.md" | |
| ) | |
| return template, system_prompt, output_path | |
| def run_generate( | |
| owner: str, | |
| repo: str, | |
| from_tag: str, | |
| to_ref: str, | |
| version: str, | |
| template: str, | |
| system_prompt: str, | |
| output_path: Path, | |
| generate_fn: Callable[[str, str, str], str], | |
| api_key: str, | |
| max_prompt_tokens: int, | |
| ) -> None: | |
| """Core release-notes generation logic. | |
| Gathers data from GitHub, builds the LLM prompt, calls the provider, | |
| validates the output, and writes the result. This function is used by | |
| both the OSS CLI and the internal Meta wrapper. `max_prompt_tokens` is the | |
| provider's input budget: its context window minus its output token limit. | |
| """ | |
| with GitHubClient() as client: | |
| print( | |
| f"\nGenerating release notes for {owner}/{repo} {from_tag} -> {to_ref}\n" | |
| ) | |
| print(" Resolving refs...") | |
| from_dt = get_tag_date(client, owner, repo, from_tag) | |
| to_dt, to_ref_type, to_sha = resolve_ref_date(client, owner, repo, to_ref) | |
| if to_ref_type == "branch": | |
| assert to_sha is not None | |
| print(f" {from_tag} (tag): {from_dt.date()}") | |
| print(f" {to_ref} (branch HEAD): {to_dt.date()} @ {to_sha[:10]}") | |
| to_date = datetime.now(timezone.utc).isoformat() | |
| else: | |
| print(f" {from_tag}: {from_dt.date()}") | |
| print(f" {to_ref}: {to_dt.date()}") | |
| to_date = to_dt.isoformat() | |
| from_date = from_dt.isoformat() | |
| print(" Fetching commits...") | |
| commits = get_commits_between_tags(client, owner, repo, from_tag, to_ref) | |
| print(f" Found {len(commits)} commits") | |
| contributor_analyzer = ContributorAnalyzer(client, owner, repo) | |
| contributors = contributor_analyzer._extract_contributors(commits) | |
| print(f" Found {len(contributors)} contributors") | |
| print(" Fetching closed issues...") | |
| all_closed_issues = get_closed_issues_between_dates( | |
| client, owner, repo, from_date, to_date | |
| ) | |
| bug_issues = filter_bug_issues(all_closed_issues) | |
| print( | |
| f" Found {len(all_closed_issues)} closed issues ({len(bug_issues)} bugs)" | |
| ) | |
| archived_notes_path = _RELEASE_NOTES_DIR / "release_notes_archived.md" | |
| archived_notes = "" | |
| if archived_notes_path.exists(): | |
| archived_notes = archived_notes_path.read_text() | |
| print(" Loaded archived release notes for style reference") | |
| # Pre-substitute the dev-release disclaimer so the LLM treats the | |
| # filled-in text as fixed boilerplate. Stable releases get an empty | |
| # string here, which collapses the placeholder line cleanly. | |
| template = template.replace("{{DEV_DISCLAIMER}}", _dev_disclaimer_for(version)) | |
| commit_lines = format_commit_lines(commits) | |
| commit_log = "\n".join(commit_lines) | |
| prompt_args = ( | |
| contributors, | |
| bug_issues, | |
| all_closed_issues, | |
| from_tag, | |
| version, | |
| owner, | |
| repo, | |
| archived_notes, | |
| ) | |
| user_prompt = build_user_prompt( | |
| template, commit_log, len(commits), *prompt_args | |
| ) | |
| prompt_tokens = estimate_tokens(system_prompt + user_prompt) | |
| if prompt_tokens > max_prompt_tokens: | |
| print( | |
| f"\n Prompt is ~{prompt_tokens} tokens, over the " | |
| f"{max_prompt_tokens}-token budget; condensing commits in batches..." | |
| ) | |
| commit_log = condense_commit_log( | |
| commit_lines, max_prompt_tokens, generate_fn, api_key | |
| ) | |
| user_prompt = build_user_prompt( | |
| template, commit_log, len(commits), *prompt_args | |
| ) | |
| prompt_tokens = estimate_tokens(system_prompt + user_prompt) | |
| if prompt_tokens > max_prompt_tokens: | |
| raise ValueError( | |
| f"Condensed prompt is still ~{prompt_tokens} tokens, over the " | |
| f"{max_prompt_tokens}-token budget." | |
| ) | |
| print(f" Condensed prompt is ~{prompt_tokens} tokens") | |
| print("\n Generating release notes with LLM...") | |
| result = generate_fn(user_prompt, system_prompt, api_key) | |
| release_notes, fix_warnings = validate_and_fix_release_notes(result) | |
| if fix_warnings: | |
| print("\n Post-generation fixes applied:") | |
| for w in fix_warnings: | |
| print(f" - {w}") | |
| if not release_notes.endswith("\n"): | |
| release_notes += "\n" | |
| output_path.write_text(release_notes) | |
| print(f"\n Release notes written to: {output_path}\n") | |
| def main() -> None: | |
| args = _parse_args() | |
| if "/" not in args.repo: | |
| print("Error: Repository must be in 'owner/repo' format.", file=sys.stderr) | |
| sys.exit(1) | |
| owner, repo = args.repo.split("/", 1) | |
| load_env_file() | |
| api_key, generate_fn = _get_llm_config(args.provider) | |
| template, system_prompt, output_path = _load_files(args) | |
| version = args.version or args.to_ref | |
| try: | |
| run_generate( | |
| owner, | |
| repo, | |
| args.from_tag, | |
| args.to_ref, | |
| version, | |
| template, | |
| system_prompt, | |
| output_path, | |
| generate_fn, | |
| api_key, | |
| OPENAI_MAX_PROMPT_TOKENS, | |
| ) | |
| except ValueError as e: | |
| print(f"\nError: {e}", file=sys.stderr) | |
| sys.exit(1) | |
| except urllib.error.HTTPError as e: | |
| body = e.read().decode() if e.fp else "" | |
| print(f"\nHTTP Error {e.code}: {e.reason}", file=sys.stderr) | |
| if body: | |
| try: | |
| detail = json.loads(body) | |
| print(f" {json.dumps(detail, indent=2)[:500]}", file=sys.stderr) | |
| except json.JSONDecodeError: | |
| print(f" {body[:500]}", file=sys.stderr) | |
| sys.exit(1) | |
| except KeyboardInterrupt: | |
| print("\n\nCancelled.") | |
| sys.exit(0) | |
| except Exception as e: | |
| print(f"\nError: {e}", file=sys.stderr) | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |