From e8444708d3457a1a1895f0350184926ffe3c8c97 Mon Sep 17 00:00:00 2001 From: Richard Macias Date: Sat, 14 Feb 2026 20:51:25 -0600 Subject: [PATCH] feat: auto-detect contributors from git log in release notes. Never forget to give credit where credit is due <3 --- scripts/constants.py | 2 ++ scripts/release.py | 76 +++++++++++++++++++++++++++++++++++++------- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/scripts/constants.py b/scripts/constants.py index 8f0c1bb..49357e9 100644 --- a/scripts/constants.py +++ b/scripts/constants.py @@ -9,6 +9,8 @@ OBTAINIUM_SCHEME = "obtainium://app/" VARIANTS = ("standard", "dual-screen") +GITHUB_NOREPLY_SUFFIX = "@users.noreply.github.com" + # --------------------------------------------------------------------------- # Obtainium source types and settings schema # Derived from Obtainium source code: lib/app_sources/*.dart diff --git a/scripts/release.py b/scripts/release.py index 5a1d620..cef40c5 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -30,7 +30,8 @@ from collections import defaultdict from pathlib import Path from typing import Any -from utils import get_application_url, get_display_name, make_obtainium_link, should_include_app +from constants import GITHUB_NOREPLY_SUFFIX +from utils import get_application_url, get_display_name, load_dotenv, make_obtainium_link, should_include_app REPO_ROOT = Path(__file__).resolve().parent.parent @@ -42,6 +43,13 @@ APPLICATIONS_JSON = REPO_ROOT / "src" / "applications.json" SEMVER_PATTERN = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") +def load_owner_emails() -> set[str]: + raw = os.environ.get("OWNER_EMAILS", "") + if not raw.strip(): + return set() + return {email.strip().lower() for email in raw.split(",") if email.strip()} + + def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess.CompletedProcess: return subprocess.run( cmd, @@ -231,17 +239,59 @@ def generate_app_table(apps: list[dict[str, Any]], group_by_category: bool = Fal return "\n".join(sections) -def get_commit_summaries(since_tag: str | None) -> list[str]: +def _git_log_lines(since_tag: str | None, pretty_format: str) -> list[str]: + cmd = ["git", "log"] if since_tag: - cmd = ["git", "log", f"{since_tag}..HEAD", "--pretty=format:%s"] - else: - cmd = ["git", "log", "--pretty=format:%s"] + cmd.append(f"{since_tag}..HEAD") + cmd += ["--pretty=format:" + pretty_format] result = run(cmd, capture=True, check=False) if result.returncode != 0 or not result.stdout.strip(): return [] + return result.stdout.strip().splitlines() - return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()] + +def extract_github_username(email: str) -> str | None: + if not email.endswith(GITHUB_NOREPLY_SUFFIX): + return None + # Noreply format: "id+username" or just "username" + local_part = email[: -len(GITHUB_NOREPLY_SUFFIX)] + if "+" in local_part: + return local_part.split("+", 1)[1] + return local_part + + +def format_contributor(name: str, email: str) -> str: + username = extract_github_username(email) + if username: + return f"@{username}" + return name + + +def get_contributors(since_tag: str | None) -> list[str]: + owner_emails = load_owner_emails() + seen: set[str] = set() + contributors: list[str] = [] + + for line in _git_log_lines(since_tag, "%an%x00%ae"): + if "\x00" not in line: + continue + name, email = line.split("\x00", 1) + name, email = name.strip(), email.strip() + + if email.lower() in owner_emails: + continue + + formatted = format_contributor(name, email) + if formatted not in seen: + seen.add(formatted) + contributors.append(formatted) + + return sorted(contributors, key=str.lower) + + +def get_commit_summaries(since_tag: str | None) -> list[str]: + return [line.strip() for line in _git_log_lines(since_tag, "%s") if line.strip()] def generate_release_notes( @@ -252,12 +302,10 @@ def generate_release_notes( ) -> str: lines: list[str] = [] - # Summary section with commit messages as starting points lines.append("## Summary\n") commits = get_commit_summaries(latest_tag) if commits: for msg in commits: - # Skip merge commits and release commits if msg.startswith("Merge ") or msg.startswith("release:"): continue lines.append(f"- {msg}") @@ -265,19 +313,24 @@ def generate_release_notes( lines.append("- ") lines.append("") - # New apps section + contributors = get_contributors(latest_tag) + if contributors: + lines.append("## Contributors\n") + lines.append("Thanks to the following people for their contributions to this release:\n") + for contributor in contributors: + lines.append(f"- {contributor}") + lines.append("") + if added: lines.append("## New Apps\n") lines.append(generate_app_table(added, group_by_category=True)) lines.append("") - # Updated apps section if changed: lines.append("## App Updates\n") lines.append(generate_app_table(changed, group_by_category=False)) lines.append("") - # Removed apps section if removed: lines.append("## Removed Apps\n") for app in sorted(removed, key=lambda a: get_display_name(a).lower()): @@ -386,6 +439,7 @@ def main() -> None: ) args = parser.parse_args() + load_dotenv() check_prerequisites() print("Fetching tags from remote...")