List PRs in changelog generator

This commit is contained in:
goaaats 2025-12-07 15:52:13 +01:00
parent c50237cf66
commit 094483e5a0
2 changed files with 166 additions and 67 deletions

View file

@ -8,7 +8,8 @@ import re
import sys import sys
import json import json
import argparse import argparse
from typing import List, Tuple, Optional import os
from typing import List, Tuple, Optional, Dict, Any
def run_git_command(args: List[str]) -> str: def run_git_command(args: List[str]) -> str:
@ -30,14 +31,14 @@ def get_last_two_tags() -> Tuple[str, str]:
"""Get the latest two git tags.""" """Get the latest two git tags."""
tags = run_git_command(["tag", "--sort=-version:refname"]) tags = run_git_command(["tag", "--sort=-version:refname"])
tag_list = [t for t in tags.split("\n") if t] tag_list = [t for t in tags.split("\n") if t]
# Filter out old tags that start with 'v' (old versioning scheme) # Filter out old tags that start with 'v' (old versioning scheme)
tag_list = [t for t in tag_list if not t.startswith('v')] tag_list = [t for t in tag_list if not t.startswith('v')]
if len(tag_list) < 2: if len(tag_list) < 2:
print("Error: Need at least 2 tags in the repository", file=sys.stderr) print("Error: Need at least 2 tags in the repository", file=sys.stderr)
sys.exit(1) sys.exit(1)
return tag_list[0], tag_list[1] return tag_list[0], tag_list[1]
@ -55,58 +56,144 @@ def get_submodule_commit(submodule_path: str, tag: str) -> Optional[str]:
return None return None
def get_commits_between_tags(tag1: str, tag2: str) -> List[Tuple[str, str]]: def get_repo_info() -> Tuple[str, str]:
"""Get commits between two tags. Returns list of (message, author) tuples.""" """Get repository owner and name from git remote."""
try:
remote_url = run_git_command(["config", "--get", "remote.origin.url"])
# Handle both HTTPS and SSH URLs
# SSH: git@github.com:owner/repo.git
# HTTPS: https://github.com/owner/repo.git
match = re.search(r'github\.com[:/](.+?)/(.+?)(?:\.git)?$', remote_url)
if match:
owner = match.group(1)
repo = match.group(2)
return owner, repo
else:
print("Error: Could not parse GitHub repository from remote URL", file=sys.stderr)
sys.exit(1)
except:
print("Error: Could not get git remote URL", file=sys.stderr)
sys.exit(1)
def get_commits_between_tags(tag1: str, tag2: str) -> List[str]:
"""Get commit SHAs between two tags."""
log_output = run_git_command([ log_output = run_git_command([
"log", "log",
f"{tag2}..{tag1}", f"{tag2}..{tag1}",
"--format=%s|%an|%h" "--format=%H"
]) ])
commits = [] commits = [sha.strip() for sha in log_output.split("\n") if sha.strip()]
for line in log_output.split("\n"):
if "|" in line:
message, author, sha = line.split("|", 2)
commits.append((message.strip(), author.strip(), sha.strip()))
return commits return commits
def filter_commits(commits: List[Tuple[str, str]], ignore_patterns: List[str]) -> List[Tuple[str, str]]: def get_pr_for_commit(commit_sha: str, owner: str, repo: str, token: str) -> Optional[Dict[str, Any]]:
"""Filter out commits matching any of the ignore patterns.""" """Get PR information for a commit using GitHub API."""
try:
import requests
except ImportError:
print("Error: requests library is required. Install it with: pip install requests", file=sys.stderr)
sys.exit(1)
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28"
}
if token:
headers["Authorization"] = f"Bearer {token}"
url = f"https://api.github.com/repos/{owner}/{repo}/commits/{commit_sha}/pulls"
try:
response = requests.get(url, headers=headers)
response.raise_for_status()
prs = response.json()
if prs and len(prs) > 0:
# Return the first PR (most relevant one)
pr = prs[0]
return {
"number": pr["number"],
"title": pr["title"],
"author": pr["user"]["login"],
"url": pr["html_url"]
}
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
# Commit might not be associated with a PR
return None
elif e.response.status_code == 403:
print("Warning: GitHub API rate limit exceeded. Consider providing a token.", file=sys.stderr)
return None
else:
print(f"Warning: Failed to fetch PR for commit {commit_sha[:7]}: {e}", file=sys.stderr)
return None
except Exception as e:
print(f"Warning: Error fetching PR for commit {commit_sha[:7]}: {e}", file=sys.stderr)
return None
return None
def get_prs_between_tags(tag1: str, tag2: str, owner: str, repo: str, token: str) -> List[Dict[str, Any]]:
"""Get PRs between two tags using GitHub API."""
commits = get_commits_between_tags(tag1, tag2)
print(f"Found {len(commits)} commits, fetching PR information...")
prs = []
seen_pr_numbers = set()
for i, commit_sha in enumerate(commits, 1):
if i % 10 == 0:
print(f"Progress: {i}/{len(commits)} commits processed...")
pr_info = get_pr_for_commit(commit_sha, owner, repo, token)
if pr_info and pr_info["number"] not in seen_pr_numbers:
seen_pr_numbers.add(pr_info["number"])
prs.append(pr_info)
return prs
def filter_prs(prs: List[Dict[str, Any]], ignore_patterns: List[str]) -> List[Dict[str, Any]]:
"""Filter out PRs matching any of the ignore patterns."""
compiled_patterns = [re.compile(pattern) for pattern in ignore_patterns] compiled_patterns = [re.compile(pattern) for pattern in ignore_patterns]
filtered = [] filtered = []
for message, author, sha in commits: for pr in prs:
if not any(pattern.search(message) for pattern in compiled_patterns): if not any(pattern.search(pr["title"]) for pattern in compiled_patterns):
filtered.append((message, author, sha)) filtered.append(pr)
return filtered return filtered
def generate_changelog(version: str, prev_version: str, commits: List[Tuple[str, str]], def generate_changelog(version: str, prev_version: str, prs: List[Dict[str, Any]],
cs_commit_new: Optional[str], cs_commit_old: Optional[str]) -> str: cs_commit_new: Optional[str], cs_commit_old: Optional[str],
owner: str, repo: str) -> str:
"""Generate markdown changelog.""" """Generate markdown changelog."""
# Calculate statistics # Calculate statistics
commit_count = len(commits) pr_count = len(prs)
unique_authors = len(set(author for _, author, _ in commits)) unique_authors = len(set(pr["author"] for pr in prs))
changelog = f"# Dalamud Release v{version}\n\n" changelog = f"# Dalamud Release v{version}\n\n"
changelog += f"We just released Dalamud v{version}, which should be available to users within a few minutes. " changelog += f"We just released Dalamud v{version}, which should be available to users within a few minutes. "
changelog += f"This release includes **{commit_count} commit{'s' if commit_count != 1 else ''} from {unique_authors} contributor{'s' if unique_authors != 1 else ''}**.\n" changelog += f"This release includes **{pr_count} PR{'s' if pr_count != 1 else ''} from {unique_authors} contributor{'s' if unique_authors != 1 else ''}**.\n"
changelog += f"[Click here](<https://github.com/goatcorp/Dalamud/compare/{prev_version}...{version}>) to see all Dalamud changes.\n\n" changelog += f"[Click here](<https://github.com/{owner}/{repo}/compare/{prev_version}...{version}>) to see all Dalamud changes.\n\n"
if cs_commit_new and cs_commit_old and cs_commit_new != cs_commit_old: if cs_commit_new and cs_commit_old and cs_commit_new != cs_commit_old:
changelog += f"It ships with an updated **FFXIVClientStructs [`{cs_commit_new[:7]}`](<https://github.com/aers/FFXIVClientStructs/commit/{cs_commit_new}>)**.\n" changelog += f"It ships with an updated **FFXIVClientStructs [`{cs_commit_new[:7]}`](<https://github.com/aers/FFXIVClientStructs/commit/{cs_commit_new}>)**.\n"
changelog += f"[Click here](<https://github.com/aers/FFXIVClientStructs/compare/{cs_commit_old}...{cs_commit_new}>) to see all CS changes.\n" changelog += f"[Click here](<https://github.com/aers/FFXIVClientStructs/compare/{cs_commit_old}...{cs_commit_new}>) to see all CS changes.\n"
elif cs_commit_new: elif cs_commit_new:
changelog += f"It ships with **FFXIVClientStructs [`{cs_commit_new[:7]}`](<https://github.com/aers/FFXIVClientStructs/commit/{cs_commit_new}>)**.\n" changelog += f"It ships with **FFXIVClientStructs [`{cs_commit_new[:7]}`](<https://github.com/aers/FFXIVClientStructs/commit/{cs_commit_new}>)**.\n"
changelog += "## Dalamud Changes\n\n" changelog += "## Dalamud Changes\n\n"
for message, author, sha in commits: for pr in prs:
changelog += f"* {message} (by **{author}** as [`{sha}`](<https://github.com/goatcorp/Dalamud/commit/{sha}>))\n" changelog += f"* {pr['title']} ([#**{pr['number']}**](<{pr['url']}>) by **{pr['author']}**)\n"
return changelog return changelog
@ -117,9 +204,9 @@ def post_to_discord(webhook_url: str, content: str, version: str) -> None:
except ImportError: except ImportError:
print("Error: requests library is required. Install it with: pip install requests", file=sys.stderr) print("Error: requests library is required. Install it with: pip install requests", file=sys.stderr)
sys.exit(1) sys.exit(1)
filename = f"changelog-v{version}.md" filename = f"changelog-v{version}.md"
# Prepare the payload # Prepare the payload
data = { data = {
"content": f"Dalamud v{version} has been released!", "content": f"Dalamud v{version} has been released!",
@ -130,13 +217,13 @@ def post_to_discord(webhook_url: str, content: str, version: str) -> None:
} }
] ]
} }
# Prepare the files # Prepare the files
files = { files = {
"payload_json": (None, json.dumps(data)), "payload_json": (None, json.dumps(data)),
"files[0]": (filename, content.encode('utf-8'), 'text/markdown') "files[0]": (filename, content.encode('utf-8'), 'text/markdown')
} }
try: try:
result = requests.post(webhook_url, files=files) result = requests.post(webhook_url, files=files)
result.raise_for_status() result.raise_for_status()
@ -158,54 +245,64 @@ def main():
required=True, required=True,
help="Discord webhook URL" help="Discord webhook URL"
) )
parser.add_argument(
"--github-token",
default=os.environ.get("GITHUB_TOKEN"),
help="GitHub API token (or set GITHUB_TOKEN env var). Increases rate limit."
)
parser.add_argument( parser.add_argument(
"--ignore", "--ignore",
action="append", action="append",
default=[], default=[],
help="Regex patterns to ignore commits (can be specified multiple times)" help="Regex patterns to ignore PRs (can be specified multiple times)"
) )
parser.add_argument( parser.add_argument(
"--submodule-path", "--submodule-path",
default="lib/FFXIVClientStructs", default="lib/FFXIVClientStructs",
help="Path to the FFXIVClientStructs submodule (default: lib/FFXIVClientStructs)" help="Path to the FFXIVClientStructs submodule (default: lib/FFXIVClientStructs)"
) )
args = parser.parse_args() args = parser.parse_args()
# Get repository info
owner, repo = get_repo_info()
print(f"Repository: {owner}/{repo}")
# Get the last two tags # Get the last two tags
latest_tag, previous_tag = get_last_two_tags() latest_tag, previous_tag = get_last_two_tags()
print(f"Generating changelog between {previous_tag} and {latest_tag}") print(f"Generating changelog between {previous_tag} and {latest_tag}")
# Get submodule commits at both tags # Get submodule commits at both tags
cs_commit_new = get_submodule_commit(args.submodule_path, latest_tag) cs_commit_new = get_submodule_commit(args.submodule_path, latest_tag)
cs_commit_old = get_submodule_commit(args.submodule_path, previous_tag) cs_commit_old = get_submodule_commit(args.submodule_path, previous_tag)
if cs_commit_new: if cs_commit_new:
print(f"FFXIVClientStructs commit (new): {cs_commit_new[:7]}") print(f"FFXIVClientStructs commit (new): {cs_commit_new[:7]}")
if cs_commit_old: if cs_commit_old:
print(f"FFXIVClientStructs commit (old): {cs_commit_old[:7]}") print(f"FFXIVClientStructs commit (old): {cs_commit_old[:7]}")
# Get commits between tags # Get PRs between tags
commits = get_commits_between_tags(latest_tag, previous_tag) prs = get_prs_between_tags(latest_tag, previous_tag, owner, repo, args.github_token)
print(f"Found {len(commits)} commits") prs.reverse()
print(f"Found {len(prs)} PRs")
# Filter commits
filtered_commits = filter_commits(commits, args.ignore) # Filter PRs
print(f"After filtering: {len(filtered_commits)} commits") filtered_prs = filter_prs(prs, args.ignore)
print(f"After filtering: {len(filtered_prs)} PRs")
# Generate changelog # Generate changelog
changelog = generate_changelog(latest_tag, previous_tag, filtered_commits, changelog = generate_changelog(latest_tag, previous_tag, filtered_prs,
cs_commit_new, cs_commit_old) cs_commit_new, cs_commit_old, owner, repo)
print("\n" + "="*50) print("\n" + "="*50)
print("Generated Changelog:") print("Generated Changelog:")
print("="*50) print("="*50)
print(changelog) print(changelog)
print("="*50 + "\n") print("="*50 + "\n")
# Post to Discord # Post to Discord
post_to_discord(args.webhook_url, changelog, latest_tag) post_to_discord(args.webhook_url, changelog, latest_tag)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -6,41 +6,43 @@ on:
tags: tags:
- '*' - '*'
permissions: read-all
jobs: jobs:
generate-changelog: generate-changelog:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 # Fetch all history and tags fetch-depth: 0 # Fetch all history and tags
submodules: true # Fetch submodules submodules: true # Fetch submodules
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.14' python-version: '3.14'
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install requests pip install requests
- name: Generate and post changelog - name: Generate and post changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_TERMINAL_PROMPT: 0
run: | run: |
python .github/generate_changelog.py \ python .github/generate_changelog.py \
--webhook-url "${{ secrets.DISCORD_CHANGELOG_WEBHOOK_URL }}" \ --webhook-url "${{ secrets.DISCORD_CHANGELOG_WEBHOOK_URL }}" \
--ignore "^Merge" \ --ignore "Update ClientStructs" \
--ignore "^build:" \ --ignore "^build:"
--ignore "^docs:"
env:
GIT_TERMINAL_PROMPT: 0
- name: Upload changelog as artifact - name: Upload changelog as artifact
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: changelog name: changelog
path: changelog-*.md path: changelog-*.md
if-no-files-found: ignore if-no-files-found: ignore