Files
DroidDeck/tools/release/pr_discord.py
T

230 lines
10 KiB
Python

import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
REPORT = "<!-- droiddeck-pr-release -->"
STATE = re.compile(r"\n<!-- droiddeck-discord: (\{[^\n]*\}) -->")
def github(path, method="GET", body=None):
args = ["gh", "api", path, "--method", method]
if body is not None:
args += ["--input", "-"]
result = subprocess.run(args, input=json.dumps(body) if body is not None else None,
capture_output=True, text=True)
if result.returncode:
raise RuntimeError("GitHub request failed")
return json.loads(result.stdout) if result.stdout.strip() else None
def report_comment(repo, number):
page = 1
while True:
comments = github(f"repos/{repo}/issues/{number}/comments?per_page=100&page={page}")
for comment in comments:
if (comment.get("user", {}).get("login") == "github-actions[bot]"
and (comment.get("body") or "").startswith(REPORT)):
return comment
if len(comments) < 100:
return None
page += 1
def saved_message(body, webhook_id):
match = STATE.search(body)
if not match:
return None
try:
state = json.loads(match.group(1))
except ValueError:
return None
if (state.get("webhook_id") == webhook_id
and re.fullmatch(r"[0-9]{15,22}", str(state.get("message_id", "")))):
return state["message_id"]
return None
class Discord:
def __init__(self, webhook):
match = re.fullmatch(r"https://discord\.com/api(?:/v[0-9]+)?/webhooks/([0-9]{15,22})/([A-Za-z0-9_-]+)", webhook)
if not match:
raise RuntimeError("Invalid Discord webhook configuration")
self.url = webhook
self.id = match.group(1)
def request(self, method, suffix="", body=None):
data = json.dumps(body).encode() if body is not None else None
request = urllib.request.Request(self.url + suffix, data=data,
headers={"Content-Type": "application/json",
"User-Agent": "DroidDeck-PR-builds"}, method=method)
for attempt in range(3):
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
if error.code == 404:
return None
if method != "POST" and error.code in (429, 500, 502, 503, 504) and attempt < 2:
time.sleep(2 ** attempt)
continue
raise RuntimeError(f"Discord request failed (HTTP {error.code})") from None
except (OSError, ValueError):
raise RuntimeError("Discord request failed") from None
def plain_title(title):
title = " ".join(title.split())[:300]
return re.sub(r"([\\`*_~|\[\]<>])", r"\\\1", title)
def description_excerpt(body):
body = re.sub(r"<!--.*?-->", "", body or "", flags=re.DOTALL)
body = re.sub(r"```.*?```", "", body, flags=re.DOTALL)
for paragraph in re.split(r"\n\s*\n", body):
lines = [line.strip() for line in paragraph.splitlines()]
lines = [line for line in lines if line and not re.match(r"^(#{1,6}\s|[-*]\s+\[[ xX]\]|🤖)", line)]
text = " ".join(lines)
if not text or text.strip("*_` :.").lower() in (
"summary", "description", "checklist", "testing", "n/a", "none",
"please describe your changes here", "describe your changes"):
continue
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
if len(text) > 300:
text = text[:297].rsplit(" ", 1)[0] + "..."
return plain_title(text)
return ""
def commit_subject(message):
subject = (message or "").splitlines()[0].strip() if message else ""
return plain_title(subject[:300])
def build_content(repo, number, title, sha, apk, release, description=None, commit_message=None):
excerpt = description_excerpt(description)
subject = commit_subject(commit_message)
return (f"**Signed test build** `pr-{number}` · `{sha[:7]}`\n"
f"[#{number}](https://github.com/{repo}/pull/{number}): {plain_title(title)}\n"
+ (f"Latest commit: {subject}\n" if subject else "")
+ (f"\n{excerpt}\n\n" if excerpt else "") +
"_Not merged — for testing._\n"
f"In the app: **Updates → Test builds → #{number}**\n"
f"Download: [standard APK](<{apk}>) · [all variants](<{release}>)")
def retired_content(content, merged=False, superseded_by=None):
label = f"Superseded by `{superseded_by[:7]}`" if superseded_by else ("Merged" if merged else "Closed")
if content.startswith(("**Merged** —", "**Closed** —", "**Superseded by `")):
content = "\n".join(line[2:-2] if line.startswith("~~") and line.endswith("~~") else line
for line in content.splitlines()[2:])
struck = "\n".join(f"~~{line}~~" if line else "" for line in content.splitlines())
note = "This test build has been replaced." if superseded_by else "This test build is no longer available."
return f"**{label}** — {note}\n\n{struck}"
def sync(mode, repo, number, discord, sha="", apk="", release=""):
pull = github(f"repos/{repo}/pulls/{number}")
if mode == "publish":
if (pull["state"] != "open" or pull.get("draft") or pull["base"]["ref"] != "main"
or pull["head"]["sha"] != sha):
print("PR changed; no Discord announcement published")
return
elif pull["state"] != "closed":
print("PR is open; Discord announcement retained")
return
comment = report_comment(repo, number)
if comment is None:
if mode == "retire":
return
raise RuntimeError("Signed build report comment is missing")
message_id = saved_message(comment["body"], discord.id)
state_match = STATE.search(comment["body"])
try:
old_state = json.loads(state_match.group(1)) if state_match else {}
except ValueError:
old_state = {}
if mode == "retire":
if message_id:
message = discord.request("GET", f"/messages/{message_id}")
if message:
discord.request("PATCH", f"/messages/{message_id}",
{"content": retired_content(message["content"], pull.get("merged", False)),
"allowed_mentions": {"parse": []}, "flags": 4})
return
try:
commit = github(f"repos/{repo}/commits/{sha}") or {}
except RuntimeError:
commit = {}
commit_message = (commit.get("commit") or {}).get("message", "")
content = build_content(repo, number, pull["title"], sha, apk, release, pull.get("body"), commit_message)
if len(content) > 1800:
raise RuntimeError("Discord announcement is too long")
payload = {"content": content, "allowed_mentions": {"parse": []}, "flags": 4}
old_message = None
previous_sha = old_state.get("sha")
if message_id and not previous_sha:
old_message = discord.request("GET", f"/messages/{message_id}")
if old_message:
match = re.search(r"`([0-9a-f]{7})`", old_message.get("content", ""))
previous_sha = match.group(1) if match else None
if message_id and (previous_sha == sha or previous_sha == sha[:7]):
message = discord.request("PATCH", f"/messages/{message_id}", payload)
else:
if message_id and old_state.get("sha"):
previous = discord.request("GET", f"/messages/{message_id}")
if previous:
discord.request("PATCH", f"/messages/{message_id}",
{"content": retired_content(previous["content"], superseded_by=sha),
"allowed_mentions": {"parse": []}, "flags": 4})
elif message_id and old_message:
discord.request("PATCH", f"/messages/{message_id}",
{"content": retired_content(old_message["content"], superseded_by=sha),
"allowed_mentions": {"parse": []}, "flags": 4})
message = None
if message is None:
message = discord.request("POST", "?wait=true", payload)
if message is None:
raise RuntimeError("Discord webhook no longer exists")
state = {"webhook_id": discord.id, "message_id": message["id"], "sha": sha}
latest = github(f"repos/{repo}/issues/comments/{comment['id']}")
body = STATE.sub("", latest["body"]).rstrip() + f"\n<!-- droiddeck-discord: {json.dumps(state)} -->"
github(f"repos/{repo}/issues/comments/{comment['id']}", "PATCH", {"body": body})
print("Discord PR announcement updated")
def main():
webhook = os.environ.get("WEBHOOK", "")
if not webhook:
print("::notice::No DISCORD_PR_BUILDS_WEBHOOK secret; nothing posted")
return
repo = os.environ["GITHUB_REPOSITORY"]
number = os.environ["PR_NUMBER"]
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) or not re.fullmatch(r"[1-9][0-9]*", number):
raise RuntimeError("Invalid PR configuration")
mode = sys.argv[1]
if mode not in ("publish", "retire"):
raise RuntimeError("Invalid announcement mode")
sha, apk, release = (os.environ.get(key, "") for key in ("HEAD_SHA", "APK", "RELEASE"))
if mode == "publish":
prefix = f"https://github.com/Droid-Deck/DroidDeck-CI/releases/"
if (not re.fullmatch(r"[0-9a-f]{40}", sha)
or not apk.startswith(prefix + f"download/pr-{number}/")
or release != prefix + f"tag/pr-{number}"
or re.search(r"[\s<>]", apk)):
raise RuntimeError("Invalid published build configuration")
sync(mode, repo, number, Discord(webhook), sha, apk, release)
if __name__ == "__main__":
try:
main()
except Exception as error:
print(f"::error::{error}" if isinstance(error, RuntimeError) else "::error::Discord announcement failed", file=sys.stderr)
sys.exit(1)