1
0

actions_changelogs_since_last_run.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. #!/usr/bin/env python3
  2. """
  3. Sends updates to a Discord webhook for new changelog entries since the last GitHub Actions publish run.
  4. Automatically figures out the last run and changelog contents with the GitHub API.
  5. """
  6. import itertools
  7. import os
  8. from pathlib import Path
  9. from typing import Any, Iterable
  10. import requests
  11. import yaml
  12. DEBUG = False
  13. DEBUG_CHANGELOG_FILE_OLD = Path("Resources/Changelog/Old.yml")
  14. GITHUB_API_URL = os.environ.get("GITHUB_API_URL", "https://api.github.com")
  15. # https://discord.com/developers/docs/resources/webhook
  16. DISCORD_SPLIT_LIMIT = 2000
  17. DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL")
  18. CHANGELOG_FILE = "Resources/Changelog/Changelog.yml"
  19. TYPES_TO_EMOJI = {"Fix": "🐛", "Add": "🆕", "Remove": "❌", "Tweak": "⚒️"}
  20. ChangelogEntry = dict[str, Any]
  21. def main():
  22. if not DISCORD_WEBHOOK_URL:
  23. print("No discord webhook URL found, skipping discord send")
  24. return
  25. if DEBUG:
  26. # to debug this script locally, you can use
  27. # a separate local file as the old changelog
  28. last_changelog_stream = DEBUG_CHANGELOG_FILE_OLD.read_text()
  29. else:
  30. # when running this normally in a GitHub actions workflow,
  31. # it will get the old changelog from the GitHub API
  32. last_changelog_stream = get_last_changelog()
  33. last_changelog = yaml.safe_load(last_changelog_stream)
  34. with open(CHANGELOG_FILE, "r") as f:
  35. cur_changelog = yaml.safe_load(f)
  36. diff = diff_changelog(last_changelog, cur_changelog)
  37. message_lines = changelog_entries_to_message_lines(diff)
  38. send_message_lines(message_lines)
  39. def get_most_recent_workflow(
  40. sess: requests.Session, github_repository: str, github_run: str
  41. ) -> Any:
  42. workflow_run = get_current_run(sess, github_repository, github_run)
  43. past_runs = get_past_runs(sess, workflow_run)
  44. for run in past_runs["workflow_runs"]:
  45. # First past successful run that isn't our current run.
  46. if run["id"] == workflow_run["id"]:
  47. continue
  48. return run
  49. def get_current_run(
  50. sess: requests.Session, github_repository: str, github_run: str
  51. ) -> Any:
  52. resp = sess.get(
  53. f"{GITHUB_API_URL}/repos/{github_repository}/actions/runs/{github_run}"
  54. )
  55. resp.raise_for_status()
  56. return resp.json()
  57. def get_past_runs(sess: requests.Session, current_run: Any) -> Any:
  58. """
  59. Get all successful workflow runs before our current one.
  60. """
  61. params = {"status": "success", "created": f"<={current_run['created_at']}"}
  62. resp = sess.get(f"{current_run['workflow_url']}/runs", params=params)
  63. resp.raise_for_status()
  64. return resp.json()
  65. def get_last_changelog() -> str:
  66. github_repository = os.environ["GITHUB_REPOSITORY"]
  67. github_run = os.environ["GITHUB_RUN_ID"]
  68. github_token = os.environ["GITHUB_TOKEN"]
  69. session = requests.Session()
  70. session.headers["Authorization"] = f"Bearer {github_token}"
  71. session.headers["Accept"] = "Accept: application/vnd.github+json"
  72. session.headers["X-GitHub-Api-Version"] = "2022-11-28"
  73. most_recent = get_most_recent_workflow(session, github_repository, github_run)
  74. last_sha = most_recent["head_commit"]["id"]
  75. print(f"Last successful publish job was {most_recent['id']}: {last_sha}")
  76. last_changelog_stream = get_last_changelog_by_sha(
  77. session, last_sha, github_repository
  78. )
  79. return last_changelog_stream
  80. def get_last_changelog_by_sha(
  81. sess: requests.Session, sha: str, github_repository: str
  82. ) -> str:
  83. """
  84. Use GitHub API to get the previous version of the changelog YAML (Actions builds are fetched with a shallow clone)
  85. """
  86. params = {
  87. "ref": sha,
  88. }
  89. headers = {"Accept": "application/vnd.github.raw"}
  90. resp = sess.get(
  91. f"{GITHUB_API_URL}/repos/{github_repository}/contents/{CHANGELOG_FILE}",
  92. headers=headers,
  93. params=params,
  94. )
  95. resp.raise_for_status()
  96. return resp.text
  97. def diff_changelog(
  98. old: dict[str, Any], cur: dict[str, Any]
  99. ) -> Iterable[ChangelogEntry]:
  100. """
  101. Find all new entries not present in the previous publish.
  102. """
  103. old_entry_ids = {e["id"] for e in old["Entries"]}
  104. return (e for e in cur["Entries"] if e["id"] not in old_entry_ids)
  105. def get_discord_body(content: str):
  106. return {
  107. "content": content,
  108. # Do not allow any mentions.
  109. "allowed_mentions": {"parse": []},
  110. # SUPPRESS_EMBEDS
  111. "flags": 1 << 2,
  112. }
  113. def send_discord_webhook(lines: list[str]):
  114. content = "".join(lines)
  115. body = get_discord_body(content)
  116. response = requests.post(DISCORD_WEBHOOK_URL, json=body)
  117. response.raise_for_status()
  118. def changelog_entries_to_message_lines(entries: Iterable[ChangelogEntry]) -> list[str]:
  119. """Process structured changelog entries into a list of lines making up a formatted message."""
  120. message_lines = []
  121. for contributor_name, group in itertools.groupby(entries, lambda x: x["author"]):
  122. message_lines.append(f"**{contributor_name}** updated:\n")
  123. for entry in group:
  124. url = entry.get("url")
  125. if url and not url.strip():
  126. url = None
  127. for change in entry["changes"]:
  128. emoji = TYPES_TO_EMOJI.get(change["type"], "❓")
  129. message = change["message"]
  130. # if a single line is longer than the limit, it needs to be truncated
  131. if len(message) > DISCORD_SPLIT_LIMIT:
  132. message = message[: DISCORD_SPLIT_LIMIT - 100].rstrip() + " [...]"
  133. if url is not None:
  134. line = f"{emoji} - {message} [PR]({url}) \n"
  135. else:
  136. line = f"{emoji} - {message}\n"
  137. message_lines.append(line)
  138. return message_lines
  139. def send_message_lines(message_lines: list[str]):
  140. """Join a list of message lines into chunks that are each below Discord's message length limit, and send them."""
  141. chunk_lines = []
  142. chunk_length = 0
  143. for line in message_lines:
  144. line_length = len(line)
  145. new_chunk_length = chunk_length + line_length
  146. if new_chunk_length > DISCORD_SPLIT_LIMIT:
  147. print("Split changelog and sending to discord")
  148. send_discord_webhook(chunk_lines)
  149. new_chunk_length = line_length
  150. chunk_lines.clear()
  151. chunk_lines.append(line)
  152. chunk_length = new_chunk_length
  153. if chunk_lines:
  154. print("Sending final changelog to discord")
  155. send_discord_webhook(chunk_lines)
  156. if __name__ == "__main__":
  157. main()