feat: auto-detect contributors from git log in release notes. Never forget to give credit where credit is due <3
This commit is contained in:
@@ -9,6 +9,8 @@ OBTAINIUM_SCHEME = "obtainium://app/"
|
|||||||
|
|
||||||
VARIANTS = ("standard", "dual-screen")
|
VARIANTS = ("standard", "dual-screen")
|
||||||
|
|
||||||
|
GITHUB_NOREPLY_SUFFIX = "@users.noreply.github.com"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Obtainium source types and settings schema
|
# Obtainium source types and settings schema
|
||||||
# Derived from Obtainium source code: lib/app_sources/*.dart
|
# Derived from Obtainium source code: lib/app_sources/*.dart
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ from collections import defaultdict
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
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+)$")
|
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:
|
def run(cmd: list[str], capture: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
return subprocess.run(
|
return subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -231,17 +239,59 @@ def generate_app_table(apps: list[dict[str, Any]], group_by_category: bool = Fal
|
|||||||
return "\n".join(sections)
|
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:
|
if since_tag:
|
||||||
cmd = ["git", "log", f"{since_tag}..HEAD", "--pretty=format:%s"]
|
cmd.append(f"{since_tag}..HEAD")
|
||||||
else:
|
cmd += ["--pretty=format:" + pretty_format]
|
||||||
cmd = ["git", "log", "--pretty=format:%s"]
|
|
||||||
|
|
||||||
result = run(cmd, capture=True, check=False)
|
result = run(cmd, capture=True, check=False)
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
return []
|
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(
|
def generate_release_notes(
|
||||||
@@ -252,12 +302,10 @@ def generate_release_notes(
|
|||||||
) -> str:
|
) -> str:
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
||||||
# Summary section with commit messages as starting points
|
|
||||||
lines.append("## Summary\n")
|
lines.append("## Summary\n")
|
||||||
commits = get_commit_summaries(latest_tag)
|
commits = get_commit_summaries(latest_tag)
|
||||||
if commits:
|
if commits:
|
||||||
for msg in commits:
|
for msg in commits:
|
||||||
# Skip merge commits and release commits
|
|
||||||
if msg.startswith("Merge ") or msg.startswith("release:"):
|
if msg.startswith("Merge ") or msg.startswith("release:"):
|
||||||
continue
|
continue
|
||||||
lines.append(f"- {msg}")
|
lines.append(f"- {msg}")
|
||||||
@@ -265,19 +313,24 @@ def generate_release_notes(
|
|||||||
lines.append("- ")
|
lines.append("- ")
|
||||||
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:
|
if added:
|
||||||
lines.append("## New Apps\n")
|
lines.append("## New Apps\n")
|
||||||
lines.append(generate_app_table(added, group_by_category=True))
|
lines.append(generate_app_table(added, group_by_category=True))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Updated apps section
|
|
||||||
if changed:
|
if changed:
|
||||||
lines.append("## App Updates\n")
|
lines.append("## App Updates\n")
|
||||||
lines.append(generate_app_table(changed, group_by_category=False))
|
lines.append(generate_app_table(changed, group_by_category=False))
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Removed apps section
|
|
||||||
if removed:
|
if removed:
|
||||||
lines.append("## Removed Apps\n")
|
lines.append("## Removed Apps\n")
|
||||||
for app in sorted(removed, key=lambda a: get_display_name(a).lower()):
|
for app in sorted(removed, key=lambda a: get_display_name(a).lower()):
|
||||||
@@ -386,6 +439,7 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
check_prerequisites()
|
check_prerequisites()
|
||||||
|
|
||||||
print("Fetching tags from remote...")
|
print("Fetching tags from remote...")
|
||||||
|
|||||||
Reference in New Issue
Block a user