From d56c7a19630070be274897f3a0ec6819986fdd2a Mon Sep 17 00:00:00 2001 From: goaaats Date: Tue, 25 Nov 2025 20:31:46 +0100 Subject: [PATCH] Add auto-generated changelogs through CI --- .github/generate_changelog.py | 211 +++++++++++++++++++++++ .github/workflows/generate-changelog.yml | 46 +++++ 2 files changed, 257 insertions(+) create mode 100644 .github/generate_changelog.py create mode 100644 .github/workflows/generate-changelog.yml diff --git a/.github/generate_changelog.py b/.github/generate_changelog.py new file mode 100644 index 000000000..5e921fd6e --- /dev/null +++ b/.github/generate_changelog.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Generate a changelog from git commits between the last two tags and post to Discord webhook. +""" + +import subprocess +import re +import sys +import json +import argparse +from typing import List, Tuple, Optional + + +def run_git_command(args: List[str]) -> str: + """Run a git command and return its output.""" + try: + result = subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Git command failed: {e}", file=sys.stderr) + sys.exit(1) + + +def get_last_two_tags() -> Tuple[str, str]: + """Get the latest two git tags.""" + tags = run_git_command(["tag", "--sort=-version:refname"]) + tag_list = [t for t in tags.split("\n") if t] + + # Filter out old tags that start with 'v' (old versioning scheme) + tag_list = [t for t in tag_list if not t.startswith('v')] + + if len(tag_list) < 2: + print("Error: Need at least 2 tags in the repository", file=sys.stderr) + sys.exit(1) + + return tag_list[0], tag_list[1] + + +def get_submodule_commit(submodule_path: str, tag: str) -> Optional[str]: + """Get the commit hash of a submodule at a specific tag.""" + try: + # Get the submodule commit at the specified tag + result = run_git_command(["ls-tree", tag, submodule_path]) + # Format is: " commit \t" + parts = result.split() + if len(parts) >= 3 and parts[1] == "commit": + return parts[2] + return None + except: + return None + + +def get_commits_between_tags(tag1: str, tag2: str) -> List[Tuple[str, str]]: + """Get commits between two tags. Returns list of (message, author) tuples.""" + log_output = run_git_command([ + "log", + f"{tag2}..{tag1}", + "--format=%s|%an|%h" + ]) + + commits = [] + 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 + + +def filter_commits(commits: List[Tuple[str, str]], ignore_patterns: List[str]) -> List[Tuple[str, str]]: + """Filter out commits matching any of the ignore patterns.""" + compiled_patterns = [re.compile(pattern) for pattern in ignore_patterns] + + filtered = [] + for message, author, sha in commits: + if not any(pattern.search(message) for pattern in compiled_patterns): + filtered.append((message, author, sha)) + + return filtered + + +def generate_changelog(version: str, prev_version: str, commits: List[Tuple[str, str]], + cs_commit_new: Optional[str], cs_commit_old: Optional[str]) -> str: + """Generate markdown changelog.""" + # Calculate statistics + commit_count = len(commits) + unique_authors = len(set(author for _, author, _ in commits)) + + 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"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"[Click here]() to see all Dalamud changes.\n\n" + + 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]}`]()**.\n" + changelog += f"[Click here]() to see all CS changes.\n" + elif cs_commit_new: + changelog += f"It ships with **FFXIVClientStructs [`{cs_commit_new[:7]}`]()**.\n" + + changelog += "## Dalamud Changes\n\n" + + for message, author, sha in commits: + changelog += f"* {message} (by **{author}** as [`{sha}`]())\n" + + return changelog + + +def post_to_discord(webhook_url: str, content: str, version: str) -> None: + """Post changelog to Discord webhook as a file attachment.""" + try: + import requests + except ImportError: + print("Error: requests library is required. Install it with: pip install requests", file=sys.stderr) + sys.exit(1) + + filename = f"changelog-v{version}.md" + + # Prepare the payload + data = { + "content": f"Dalamud v{version} has been released!", + "attachments": [ + { + "id": "0", + "filename": filename + } + ] + } + + # Prepare the files + files = { + "payload_json": (None, json.dumps(data)), + "files[0]": (filename, content.encode('utf-8'), 'text/markdown') + } + + try: + result = requests.post(webhook_url, files=files) + result.raise_for_status() + print(f"Successfully posted to Discord webhook, code {result.status_code}") + except requests.exceptions.HTTPError as err: + print(f"Failed to post to Discord: {err}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Failed to post to Discord: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate changelog from git commits and post to Discord webhook" + ) + parser.add_argument( + "--webhook-url", + required=True, + help="Discord webhook URL" + ) + parser.add_argument( + "--ignore", + action="append", + default=[], + help="Regex patterns to ignore commits (can be specified multiple times)" + ) + parser.add_argument( + "--submodule-path", + default="lib/FFXIVClientStructs", + help="Path to the FFXIVClientStructs submodule (default: lib/FFXIVClientStructs)" + ) + + args = parser.parse_args() + + # Get the last two tags + latest_tag, previous_tag = get_last_two_tags() + print(f"Generating changelog between {previous_tag} and {latest_tag}") + + # Get submodule commits at both tags + cs_commit_new = get_submodule_commit(args.submodule_path, latest_tag) + cs_commit_old = get_submodule_commit(args.submodule_path, previous_tag) + + if cs_commit_new: + print(f"FFXIVClientStructs commit (new): {cs_commit_new[:7]}") + if cs_commit_old: + print(f"FFXIVClientStructs commit (old): {cs_commit_old[:7]}") + + # Get commits between tags + commits = get_commits_between_tags(latest_tag, previous_tag) + print(f"Found {len(commits)} commits") + + # Filter commits + filtered_commits = filter_commits(commits, args.ignore) + print(f"After filtering: {len(filtered_commits)} commits") + + # Generate changelog + changelog = generate_changelog(latest_tag, previous_tag, filtered_commits, + cs_commit_new, cs_commit_old) + + print("\n" + "="*50) + print("Generated Changelog:") + print("="*50) + print(changelog) + print("="*50 + "\n") + + # Post to Discord + post_to_discord(args.webhook_url, changelog, latest_tag) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml new file mode 100644 index 000000000..5fed3b1eb --- /dev/null +++ b/.github/workflows/generate-changelog.yml @@ -0,0 +1,46 @@ +name: Generate Changelog + +on: + workflow_dispatch: + push: + tags: + - '*' + +jobs: + generate-changelog: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history and tags + submodules: true # Fetch submodules + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install requests + + - name: Generate and post changelog + run: | + python .github/generate_changelog.py \ + --webhook-url "${{ secrets.DISCORD_CHANGELOG_WEBHOOK_URL }}" \ + --ignore "^Merge" \ + --ignore "^build:" \ + --ignore "^docs:" + env: + GIT_TERMINAL_PROMPT: 0 + + - name: Upload changelog as artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: changelog + path: changelog-*.md + if-no-files-found: ignore \ No newline at end of file