Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 318 additions & 0 deletions .github/scripts/link_azip_to_discussion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
#!/usr/bin/env python3
"""Link an AZIP PR to its GitHub Discussion.

Reads the AZIP markdown files changed in a PR, parses the preamble table to
extract the AZIP number and `discussions-to` URL, then updates the target
discussion to:

* carry a `has-azip` label
* have the AZIP PR linked at the top of its body
* be titled `AZIP-N: <original title>`

All mutations are idempotent: re-running makes no change when the discussion
is already linked.

Expected environment:
GH_TOKEN GitHub token with `discussions: write` and `pull-requests: read`.
REPO owner/name of the repo (e.g. "AztecProtocol/governance").
PR_NUMBER Pull request number.
"""

from __future__ import annotations

import base64
import json
import os
import re
import subprocess
import sys
from typing import Any

REPO = os.environ["REPO"]
PR_NUMBER = int(os.environ["PR_NUMBER"])
OWNER, REPO_NAME = REPO.split("/", 1)

# Strict: AZIP discussions must live in this exact repo, and in this category.
DISCUSSION_URL_RE = re.compile(
rf"^https://github\.com/{re.escape(OWNER)}/{re.escape(REPO_NAME)}/discussions/(\d+)/?$"
)
AZIP_NUM_RE = re.compile(r"^\d+$")
LABEL_NAME = "has-azip"
REQUIRED_CATEGORY_SLUG = "azip-proposals"


def gh(args: list[str], *, input_data: str | None = None) -> str:
result = subprocess.run(
["gh"] + args,
check=True,
capture_output=True,
text=True,
input=input_data,
)
return result.stdout


def gh_json(args: list[str]) -> Any:
out = gh(args)
return json.loads(out) if out.strip() else None


def graphql(query: str, variables: dict[str, Any]) -> dict[str, Any]:
payload = json.dumps({"query": query, "variables": variables})
out = gh(["api", "graphql", "--input", "-"], input_data=payload)
data = json.loads(out)
if "errors" in data and data["errors"]:
raise RuntimeError(f"GraphQL errors: {data['errors']}")
return data["data"]


def get_pr() -> dict[str, Any]:
return gh_json(["api", f"repos/{REPO}/pulls/{PR_NUMBER}"])


def list_changed_azips(head_sha: str) -> list[str]:
files = gh_json(["api", "--paginate", f"repos/{REPO}/pulls/{PR_NUMBER}/files"])
changed: list[str] = []
for f in files:
name = f["filename"]
if not name.startswith("AZIPs/") or not name.endswith(".md"):
continue
if name == "AZIPs/template.md":
continue
if f.get("status") == "removed":
continue
changed.append(name)
return changed


def fetch_file_at_ref(path: str, ref: str) -> str:
import urllib.parse

encoded = urllib.parse.quote(path)
data = gh_json(["api", f"repos/{REPO}/contents/{encoded}?ref={ref}"])
if data.get("encoding") != "base64":
raise RuntimeError(f"Unexpected encoding for {path}: {data.get('encoding')!r}")
return base64.b64decode(data["content"]).decode("utf-8")


def split_row(row: str) -> list[str]:
row = row.strip()
if row.startswith("|"):
row = row[1:]
if row.endswith("|"):
row = row[:-1]
return [cell.strip() for cell in row.split("|")]


def parse_preamble(md: str) -> dict[str, str]:
"""Parse the AZIP preamble table.

The preamble is a two-row markdown table whose header cells are wrapped
in backticks (e.g. `` `azip` ``). Returns a dict keyed by the unwrapped
header name, mapped to the corresponding cell in the single data row.
"""
lines = md.split("\n")
for i, line in enumerate(lines):
if "`azip`" in line and "`discussions-to`" in line:
if i + 2 >= len(lines):
raise ValueError("preamble table truncated")
headers = [h.strip().strip("`") for h in split_row(lines[i])]
data = split_row(lines[i + 2])
if len(data) != len(headers):
raise ValueError(
f"preamble header/data mismatch: {len(headers)} vs {len(data)}"
)
return dict(zip(headers, data))
raise ValueError("preamble header row not found")


def ensure_label_id() -> str:
data = graphql(
"""
query($owner: String!, $name: String!, $label: String!) {
repository(owner: $owner, name: $name) {
label(name: $label) { id }
}
}
""",
{"owner": OWNER, "name": REPO_NAME, "label": LABEL_NAME},
)
label = data["repository"]["label"]
if label:
return label["id"]
raise RuntimeError(
f"Label {LABEL_NAME!r} does not exist on {REPO}. "
"Create it once (Issues → Labels) before running this workflow."
)


def get_discussion(number: int) -> dict[str, Any]:
data = graphql(
"""
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
discussion(number: $number) {
id
title
body
category { slug name }
labels(first: 50) { nodes { name } }
}
}
}
""",
{"owner": OWNER, "name": REPO_NAME, "number": number},
)
disc = data["repository"]["discussion"]
if not disc:
raise RuntimeError(f"Discussion #{number} not found")
slug = (disc.get("category") or {}).get("slug")
if slug != REQUIRED_CATEGORY_SLUG:
raise RuntimeError(
f"Discussion #{number} is in category {slug!r}; refusing to mutate. "
f"Only {REQUIRED_CATEGORY_SLUG!r} discussions may be linked to AZIPs."
)
return disc


def add_label(discussion_id: str, label_id: str) -> None:
graphql(
"""
mutation($id: ID!, $labels: [ID!]!) {
addLabelsToLabelable(input: { labelableId: $id, labelIds: $labels }) {
clientMutationId
}
}
""",
{"id": discussion_id, "labels": [label_id]},
)


def update_discussion(discussion_id: str, *, title: str, body: str) -> None:
graphql(
"""
mutation($id: ID!, $title: String!, $body: String!) {
updateDiscussion(input: { discussionId: $id, title: $title, body: $body }) {
discussion { id }
}
}
""",
{"id": discussion_id, "title": title, "body": body},
)


def desired_title(current_title: str, azip_num: int) -> str:
# Strip any leading `AZIP-\d+` prefix (regardless of the specific number)
# so title normalization stays idempotent even when the AZIP number is
# reassigned mid-review. Also tolerates zero padding, and `:`/`-`/space
# separators after the number.
prefix_re = re.compile(r"^AZIP-\d+\s*[:\-]?\s*", re.IGNORECASE)
stripped = prefix_re.sub("", current_title).strip()
return f"AZIP-{azip_num}: {stripped}"


def desired_body(current_body: str, azip_num: int, pr_url: str) -> str:
link_line = f"**AZIP:** [AZIP-{azip_num}]({pr_url})"
# If an AZIP link block already exists at the top, replace it rather than stacking.
existing = re.match(
r"^\*\*AZIP:\*\*\s*\[AZIP-\d+\]\(https?://\S+\)\s*\n+",
current_body,
)
rest = current_body[existing.end():] if existing else current_body.lstrip("\n")
return f"{link_line}\n\n{rest}"


def process_azip(path: str, pr_url: str, head_sha: str, label_id: str) -> None:
print(f"::group::{path}")
try:
md = fetch_file_at_ref(path, head_sha)
preamble = parse_preamble(md)

azip_raw = preamble.get("azip", "").strip()
disc_raw = preamble.get("discussions-to", "").strip()

if not AZIP_NUM_RE.match(azip_raw):
print(f"::notice file={path}::azip number not yet assigned ({azip_raw!r}); skipping")
return

m = DISCUSSION_URL_RE.match(disc_raw)
if not m:
print(
f"::warning file={path}::discussions-to is not a "
f"{OWNER}/{REPO_NAME} discussion URL ({disc_raw!r}); skipping"
)
return

azip_num = int(azip_raw)
disc_num = int(m.group(1))

disc = get_discussion(disc_num)
new_title = desired_title(disc["title"], azip_num)
new_body = desired_body(disc["body"], azip_num, pr_url)

title_changed = new_title != disc["title"]
body_changed = new_body != disc["body"]
has_label = any(l["name"] == LABEL_NAME for l in disc["labels"]["nodes"])

if not has_label:
add_label(disc["id"], label_id)
print(f"Added label {LABEL_NAME!r} to discussion #{disc_num}")
else:
print(f"Label {LABEL_NAME!r} already present on discussion #{disc_num}")

if title_changed or body_changed:
update_discussion(disc["id"], title=new_title, body=new_body)
parts = []
if title_changed:
parts.append("title")
if body_changed:
parts.append("body")
print(f"Updated discussion #{disc_num} ({', '.join(parts)})")
else:
print(f"Discussion #{disc_num} already linked; no body/title change")
finally:
print("::endgroup::")


def main() -> int:
pr = get_pr()
pr_url = pr["html_url"]
head_sha = pr["head"]["sha"]

files = list_changed_azips(head_sha)
if not files:
print("No AZIP files changed in this PR; nothing to do.")
return 0

label_id = ensure_label_id()

# Guard: two changed AZIPs must not target the same discussion. Last
# writer would win otherwise, silently.
seen_discussions: dict[int, str] = {}
errors = 0
for path in files:
try:
md = fetch_file_at_ref(path, head_sha)
preamble = parse_preamble(md)
disc_raw = preamble.get("discussions-to", "").strip()
m = DISCUSSION_URL_RE.match(disc_raw)
if m:
disc_num = int(m.group(1))
if disc_num in seen_discussions:
print(
f"::error file={path}::discussion #{disc_num} is also "
f"referenced by {seen_discussions[disc_num]}; refusing "
f"to mutate the same discussion twice in one run"
)
errors += 1
continue
seen_discussions[disc_num] = path
process_azip(path, pr_url, head_sha, label_id)
except Exception as e:
print(f"::error file={path}::{e}")
errors += 1
return 1 if errors else 0


if __name__ == "__main__":
sys.exit(main())
70 changes: 70 additions & 0 deletions .github/workflows/link-azip-to-discussion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: Link AZIP to Discussion

# When an AZIP PR is opened, edited, or resynchronized, this workflow reads
# the AZIP preamble (`azip` number and `discussions-to` URL), then updates
# the referenced GitHub Discussion:
#
# 1. Applies the `has-azip` label.
# 2. Prepends a link to the AZIP PR at the top of the discussion body.
# 3. Prefixes the discussion title with `AZIP-N: `.
#
# Idempotent: re-running makes no change if the discussion is already linked.
#
# Security notes:
# * Uses `pull_request_target` so the workflow can write to discussions for
# PRs opened from forks. To keep this safe we never check out or execute
# PR code. We only read one file (the AZIP markdown) via the API and
# parse it strictly.
# * `discussions-to` values are validated against a strict regex before
# any API call is made.

on:
pull_request_target:
types: [opened, reopened, synchronize, edited]
paths:
- 'AZIPs/**.md'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to reprocess'
required: true
type: number

permissions:
contents: read
pull-requests: read
discussions: write

concurrency:
group: link-azip-${{ github.event.pull_request.number || inputs.pr_number }}
cancel-in-progress: false

jobs:
link:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
steps:
- name: Checkout workflow scripts only
uses: actions/checkout@v4
with:
# Intentionally check out the base ref, NOT the PR head. We only
# need the script in `.github/scripts/`. PR contents are fetched
# separately via the API and parsed without execution.
# Uses the PR's base ref when triggered by pull_request_target, so
# the script matches the branch the PR will merge into; falls back
# to the default branch for manual workflow_dispatch runs.
ref: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}
sparse-checkout: |
.github/scripts
sparse-checkout-cone-mode: false

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Link AZIP PR to discussion
run: python3 .github/scripts/link_azip_to_discussion.py