diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4509f8fd549..66b94253145 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,7 +10,7 @@ ## Media - + ## Requirements diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e7359c3be8f..e7744ec127b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -54,13 +54,13 @@ jobs: - name: Publish changelog (Discord) continue-on-error: true - run: Tools/actions_changelogs_since_last_run.py + run: Tools/changelogs/actions_changelogs_since_last_run.py env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISCORD_WEBHOOK_URL: ${{ secrets.CHANGELOG_DISCORD_WEBHOOK }} - name: Publish changelog (RSS) continue-on-error: true - run: Tools/actions_changelog_rss.py + run: Tools/changelogs/actions_changelog_rss.py env: CHANGELOG_RSS_KEY: ${{ secrets.CHANGELOG_RSS_KEY }} diff --git a/Tools/actions_changelogs_since_last_run.py b/Tools/actions_changelogs_since_last_run.py deleted file mode 100755 index a50dc73a4bc..00000000000 --- a/Tools/actions_changelogs_since_last_run.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 - -""" -Sends updates to a Discord webhook for new changelog entries since the last GitHub Actions publish run. - -Automatically figures out the last run and changelog contents with the GitHub API. -""" - -import itertools -import os -from pathlib import Path -from typing import Any, Iterable - -import requests -import yaml -import time - -DEBUG = False -DEBUG_CHANGELOG_FILE_OLD = Path("Resources/Changelog/Old.yml") -GITHUB_API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com") - -# https://discord.com/developers/docs/resources/webhook -DISCORD_SPLIT_LIMIT = 2000 -DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL") - -CHANGELOG_FILE = "Resources/Changelog/Den.yml" - -TYPES_TO_EMOJI = {"Fix": "🐛", "Add": "🆕", "Remove": "❌", "Tweak": "⚒️"} - -ChangelogEntry = dict[str, Any] - - -def main(): - if not DISCORD_WEBHOOK_URL: - print("No discord webhook URL found, skipping discord send") - return - - if DEBUG: - # to debug this script locally, you can use - # a separate local file as the old changelog - last_changelog_stream = DEBUG_CHANGELOG_FILE_OLD.read_text() - else: - # when running this normally in a GitHub actions workflow, - # it will get the old changelog from the GitHub API - last_changelog_stream = get_last_changelog() - - last_changelog = yaml.safe_load(last_changelog_stream) - with open(CHANGELOG_FILE, "r") as f: - cur_changelog = yaml.safe_load(f) - - diff = diff_changelog(last_changelog, cur_changelog) - message_lines = changelog_entries_to_message_lines(diff) - send_message_lines(message_lines) - - -def get_most_recent_workflow( - sess: requests.Session, github_repository: str, github_run: str -) -> Any: - workflow_run = get_current_run(sess, github_repository, github_run) - past_runs = get_past_runs(sess, workflow_run) - for run in past_runs["workflow_runs"]: - # First past successful run that isn't our current run. - if run["id"] == workflow_run["id"]: - continue - - return run - - -def get_current_run( - sess: requests.Session, github_repository: str, github_run: str -) -> Any: - resp = sess.get( - f"{GITHUB_API_URL}/repos/{github_repository}/actions/runs/{github_run}" - ) - resp.raise_for_status() - return resp.json() - - -def get_past_runs(sess: requests.Session, current_run: Any) -> Any: - """ - Get all successful workflow runs before our current one. - """ - params = {"status": "success", "created": f"<={current_run['created_at']}"} - resp = sess.get(f"{current_run['workflow_url']}/runs", params=params) - resp.raise_for_status() - return resp.json() - - -def get_last_changelog() -> str: - github_repository = os.environ["GITHUB_REPOSITORY"] - github_run = os.environ["GITHUB_RUN_ID"] - github_token = os.environ["GITHUB_TOKEN"] - - session = requests.Session() - session.headers["Authorization"] = f"Bearer {github_token}" - session.headers["Accept"] = "Accept: application/vnd.github+json" - session.headers["X-GitHub-Api-Version"] = "2022-11-28" - - most_recent = get_most_recent_workflow(session, github_repository, github_run) - last_sha = most_recent["head_commit"]["id"] - print(f"Last successful publish job was {most_recent['id']}: {last_sha}") - last_changelog_stream = get_last_changelog_by_sha( - session, last_sha, github_repository - ) - - return last_changelog_stream - - -def get_last_changelog_by_sha( - sess: requests.Session, sha: str, github_repository: str -) -> str: - """ - Use GitHub API to get the previous version of the changelog YAML (Actions builds are fetched with a shallow clone) - """ - params = { - "ref": sha, - } - headers = {"Accept": "application/vnd.github.raw"} - - resp = sess.get( - f"{GITHUB_API_URL}/repos/{github_repository}/contents/{CHANGELOG_FILE}", - headers=headers, - params=params, - ) - resp.raise_for_status() - return resp.text - - -def diff_changelog( - old: dict[str, Any], cur: dict[str, Any] -) -> Iterable[ChangelogEntry]: - """ - Find all new entries not present in the previous publish. - """ - old_entry_ids = {e["id"] for e in old["Entries"]} - return (e for e in cur["Entries"] if e["id"] not in old_entry_ids) - - -def get_discord_body(content: str): - return { - "content": content, - # Do not allow any mentions. - "allowed_mentions": {"parse": []}, - # SUPPRESS_EMBEDS - "flags": 1 << 2, - } - - -def send_discord_webhook(lines: list[str]): - content = "".join(lines) - body = get_discord_body(content) - retry_attempt = 0 - - try: - response = requests.post(DISCORD_WEBHOOK_URL, json=body, timeout=10) - while response.status_code == 429: - retry_attempt += 1 - if retry_attempt > 20: - print("Too many retries on a single request despite following retry_after header... giving up") - exit(1) - retry_after = response.json().get("retry_after", 5) - print(f"Rate limited, retrying after {retry_after} seconds") - time.sleep(retry_after) - response = requests.post(DISCORD_WEBHOOK_URL, json=body, timeout=10) - response.raise_for_status() - except requests.exceptions.RequestException as e: - print(f"Failed to send message: {e}") - exit(1) - - -def changelog_entries_to_message_lines(entries: Iterable[ChangelogEntry]) -> list[str]: - """Process structured changelog entries into a list of lines making up a formatted message.""" - message_lines = [] - - for contributor_name, group in itertools.groupby(entries, lambda x: x["author"]): - message_lines.append("\n") - message_lines.append(f"**{contributor_name}** updated:\n") - - for entry in group: - url = entry.get("url") - if url and not url.strip(): - url = None - - for change in entry["changes"]: - emoji = TYPES_TO_EMOJI.get(change["type"], "❓") - message = change["message"] - - # if a single line is longer than the limit, it needs to be truncated - if len(message) > DISCORD_SPLIT_LIMIT: - message = message[: DISCORD_SPLIT_LIMIT - 100].rstrip() + " [...]" - - if url is not None: - pr_number = url.split("/")[-1] - line = f"{emoji} - {message} ([#{pr_number}]({url}))\n" - else: - line = f"{emoji} - {message}\n" - - message_lines.append(line) - - return message_lines - - -def send_message_lines(message_lines: list[str]): - """Join a list of message lines into chunks that are each below Discord's message length limit, and send them.""" - chunk_lines = [] - chunk_length = 0 - - for line in message_lines: - line_length = len(line) - new_chunk_length = chunk_length + line_length - - if new_chunk_length > DISCORD_SPLIT_LIMIT: - print("Split changelog and sending to discord") - send_discord_webhook(chunk_lines) - - new_chunk_length = line_length - chunk_lines.clear() - - chunk_lines.append(line) - chunk_length = new_chunk_length - - if chunk_lines: - print("Sending final changelog to discord") - send_discord_webhook(chunk_lines) - - -if __name__ == "__main__": - main() diff --git a/Tools/actions_changelog_rss.py b/Tools/changelogs/actions_changelog_rss.py old mode 100755 new mode 100644 similarity index 100% rename from Tools/actions_changelog_rss.py rename to Tools/changelogs/actions_changelog_rss.py diff --git a/Tools/changelogs/actions_changelogs_since_last_run.py b/Tools/changelogs/actions_changelogs_since_last_run.py new file mode 100644 index 00000000000..9f7be1f2e7d --- /dev/null +++ b/Tools/changelogs/actions_changelogs_since_last_run.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +""" +Sends updates to a Discord webhook for new changelog entries since the last GitHub Actions publish run. + +Automatically figures out the last run and changelog contents with the GitHub API. +""" + +import requests # REMOVE + +from changelog_helperfunctions import log, get_pr_json, grab_image_urls_from_pr_body, validate_environment, create_session, get_changes, send_discord_webhook, TYPES_TO_EMOJI + + +def main(): + if not validate_environment(): + exit(1) + + sess: requests.Session = create_session() + changes = get_changes(sess) + + data = {"embeds": []} + embed_count: int = 0 + + for author, changes, id, time, url in (entry.values() for entry in changes): + if embed_count >= 10: # discord allows up to 10 embeds per message + if not send_discord_webhook(data): + exit(1) + embed_count = 0 + data = {"embeds": []} + + pr = get_pr_json(sess, url) + + if not pr: + log.error(f"Could not find the pull request from: {url}") + + embed = { + "title": f"{author}", + "thumbnail": {"url": pr["user"]["avatar_url"]}, + "description": f"### {pr["title"]} [[PR]]({url})", + "color": int("#81BABA"[1:], base=16), # embeds want an integer color representation for some reason, this allows you to put in a hex value in code and have a preview of the color in your ide + # change this to whatever color fits your repository + "fields": [] + } + + images = grab_image_urls_from_pr_body(pr["body"]) + if images: + embed.update({"image": { "url": images[0] }}) # maybe discord adds nice multi-image embed support later down the line, for now, only the first image. + else: + log.info(f"Could not find any images in pull request at: {url}") + + for message, type in ((change["message"], change["type"]) for change in changes): + emoji = TYPES_TO_EMOJI.get(type, "❓") + embed["fields"].append({"name":f"", "value":f"{emoji} - {message}"}) + + data["embeds"].append(embed) + embed_count += 1 + + return + + +if __name__ == "__main__": + main() diff --git a/Tools/changelogs/changelog_helperfunctions.py b/Tools/changelogs/changelog_helperfunctions.py new file mode 100644 index 00000000000..dceca0f9e81 --- /dev/null +++ b/Tools/changelogs/changelog_helperfunctions.py @@ -0,0 +1,208 @@ +from pathlib import Path +from typing import Any, Iterable + +import re +import os +import yaml +import time +import logging +import requests + +FORMAT = "%(message)s" +logging.basicConfig( + level="NOTSET", format=FORMAT, datefmt="[%X]" +) +log = logging.getLogger("changelog") + +ChangelogEntry = dict[str, Any] + +DEBUG = False +DEBUG_CHANGELOG_FILE_OLD = Path("Resources/Changelog/Old.yml") +CHANGELOG_FILE = "Resources/Changelog/Den.yml" + +TYPES_TO_EMOJI = {"Fix": "🐛", "Add": "🆕", "Remove": "❌", "Tweak": "⚒️"} + +# https://discord.com/developers/docs/resources/webhook +DISCORD_SPLIT_LIMIT = 2000 + +DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL") +GITHUB_API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com") +GITHUB_REPOSITORY = os.environ.get("GITHUB_REPOSITORY") +GITHUB_RUN = os.environ.get("GITHUB_RUN_ID") +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN") + +if DEBUG: + GITHUB_REPOSITORY = "" + GITHUB_RUN = 1 + GITHUB_TOKEN = "" # replace with your personal access token or the user token from your repository's secrets for debugging DO NOT COMMIT IT WITH THE TOKEN + DISCORD_WEBHOOK_URL = "" # similar deal with this + + +def validate_environment() -> bool: + """Validates whether the current environment has all the required environment variables. Returns true/false based on success/failure.""" + + if not DISCORD_WEBHOOK_URL: + log.error("No Discord webhook URL found.") + return False + + if not os.environ.get("GITHUB_API_URL"): + log.warning("No Github API URL found - if the fallback API URL is deprecated, this script will stop working.") + + if not GITHUB_REPOSITORY: + log.error("No Github repository found.") + return False + + if not GITHUB_RUN: + log.error("No Github run identifier found.") + return False + + if not GITHUB_TOKEN: + log.error("No Github user token found.") + return False + + return True + +def create_session() -> requests.Session: + """Creates a session using the requests module to be used for interacting with REST APIs.""" + sess = requests.Session() + sess.headers["Authorization"] = f"Bearer {GITHUB_TOKEN}" + sess.headers["Accept"] = "Accept: application/vnd.github+json" + sess.headers["X-GitHub-Api-Version"] = "2026-03-10" # upgrade + return sess + +def get_changes( + sess: requests.Session +) -> str: + if DEBUG: + log.info("Debug mode active.") + # to debug this script locally, you can use + # a separate local file as the old changelog + # with a couple of entries removed + with open(DEBUG_CHANGELOG_FILE_OLD, "r", encoding="utf-8-sig") as file: + last_changelog_stream = file.read() + else: + # when running this normally in a GitHub actions workflow, + # it will get the old changelog from the GitHub API + last_changelog_stream = get_last_changelog(sess) + + last_changelog = yaml.safe_load(last_changelog_stream) + with open(CHANGELOG_FILE, "r", encoding="utf-8-sig") as file: + cur_changelog = yaml.safe_load(file) + + return diff_changelog(last_changelog, cur_changelog) # diff_changelog expects a clean string with no byte order mark otherwise it crashes. ask me how I found out. + +def get_most_recent_workflow( + sess: requests.Session, github_repository: str, github_run: str +) -> Any: + workflow_run = get_current_run(sess, github_repository, github_run) + past_runs = get_past_runs(sess, workflow_run) + for run in past_runs["workflow_runs"]: + # First past successful run that isn't our current run. + if run["id"] == workflow_run["id"]: + continue + + return run + + +def get_current_run( + sess: requests.Session, github_repository: str, github_run: str +) -> Any: + resp = sess.get( + f"{GITHUB_API_URL}/repos/{github_repository}/actions/runs/{github_run}" + ) + resp.raise_for_status() + return resp.json() + + +def get_past_runs(sess: requests.Session, current_run: Any) -> Any: + """ + Get all successful workflow runs before our current one. + """ + params = {"status": "success", "created": f"<={current_run['created_at']}"} + resp = sess.get(f"{current_run['workflow_url']}/runs", params=params) + resp.raise_for_status() + return resp.json() + + +def get_last_changelog( + sess: requests.Session +) -> str: + most_recent = get_most_recent_workflow(sess, GITHUB_REPOSITORY, GITHUB_RUN) + last_sha = most_recent["head_commit"]["id"] + print(f"Last successful publish job was {most_recent['id']}: {last_sha}") + last_changelog_stream = get_last_changelog_by_sha( + sess, last_sha, GITHUB_REPOSITORY + ) + + return last_changelog_stream + + +def get_last_changelog_by_sha( + sess: requests.Session, sha: str, github_repository: str +) -> str: + """ + Use GitHub API to get the previous version of the changelog YAML (Actions builds are fetched with a shallow clone) + """ + params = { + "ref": sha, + } + headers = {"Accept": "application/vnd.github.raw"} + + resp = sess.get( + f"{GITHUB_API_URL}/repos/{github_repository}/contents/{CHANGELOG_FILE}", + params=params, + ) + resp.raise_for_status() + return resp.text + + +def diff_changelog( + old: dict[str, Any], cur: dict[str, Any] +) -> Iterable[ChangelogEntry]: + """ + Find all new entries not present in the previous publish. + """ + old_entry_ids = {e["id"] for e in old["Entries"]} + return (e for e in cur["Entries"] if e["id"] not in old_entry_ids) + +def get_pr_json( + sess: requests.Session, pr_url: str +) -> Any: + """Gets the JSON body of the PR using Github's API. The function expects urls in the standard format, not ones already pointing to the API.""" + match = re.match(r"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)", pr_url) + owner, repo, number = match.groups() + + resp = sess.get( + f"{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{number}" + ) + resp.raise_for_status() + return resp.json() + +def grab_image_urls_from_pr_body( + body: str +) -> list[str]: + """Returns a list of all images contained in HTML tags that appear in the body of the PR. This function expects the body as a string.""" + return re.findall(r']+src="([^"]+)"', body) + +def send_discord_webhook( + json: list[str] +) -> bool: + """Handles actually sending the webhook, and deals with rate limiting/exceptions. Returns true/false based on success/failure.""" + retry_attempt = 0 + + try: + response = requests.post(DISCORD_WEBHOOK_URL, json=json, timeout=10) + while response.status_code == 429: + retry_attempt += 1 + if retry_attempt > 20: + log.error("Too many retries on a single request despite following retry_after header... giving up.") + return False + retry_after = response.json().get("retry_after", 5) + log.info(f"Rate limited, retrying after {retry_after} seconds.") + time.sleep(retry_after) + response = requests.post(DISCORD_WEBHOOK_URL, json=json, timeout=10) + response.raise_for_status() + except requests.exceptions.RequestException as e: + log.exception(f"Failed to send message: {e}") + return False + return True \ No newline at end of file