Skip to content

Commit dc6aeb1

Browse files
critesjoshclaude
andcommitted
Add workflow to link AZIP PRs to their discussions
When an AZIP PR is opened or updated, this workflow reads the `azip` number and `discussions-to` URL from the preamble and: 1. Adds a `has-azip` label to the referenced discussion 2. Prepends `**AZIP:** [AZIP-N](pr-url)` to the top of the body 3. Prefixes the discussion title with `AZIP-N:` Idempotent across re-runs. Hard-fails when the target discussion is not in the `azip-proposals` category, so a fork PR cannot point `discussions-to` at an unrelated discussion and get us to mutate it under the bot identity. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b74230e commit dc6aeb1

2 files changed

Lines changed: 388 additions & 0 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env python3
2+
"""Link an AZIP PR to its GitHub Discussion.
3+
4+
Reads the AZIP markdown files changed in a PR, parses the preamble table to
5+
extract the AZIP number and `discussions-to` URL, then updates the target
6+
discussion to:
7+
8+
* carry a `has-azip` label
9+
* have the AZIP PR linked at the top of its body
10+
* be titled `AZIP-N: <original title>`
11+
12+
All mutations are idempotent: re-running makes no change when the discussion
13+
is already linked.
14+
15+
Expected environment:
16+
GH_TOKEN GitHub token with `discussions: write` and `pull-requests: read`.
17+
REPO owner/name of the repo (e.g. "AztecProtocol/governance").
18+
PR_NUMBER Pull request number.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import base64
24+
import json
25+
import os
26+
import re
27+
import subprocess
28+
import sys
29+
from typing import Any
30+
31+
REPO = os.environ["REPO"]
32+
PR_NUMBER = int(os.environ["PR_NUMBER"])
33+
OWNER, REPO_NAME = REPO.split("/", 1)
34+
35+
# Strict: AZIP discussions must live in this exact repo, and in this category.
36+
DISCUSSION_URL_RE = re.compile(
37+
rf"^https://github\.com/{re.escape(OWNER)}/{re.escape(REPO_NAME)}/discussions/(\d+)/?$"
38+
)
39+
AZIP_NUM_RE = re.compile(r"^\d+$")
40+
LABEL_NAME = "has-azip"
41+
REQUIRED_CATEGORY_SLUG = "azip-proposals"
42+
43+
44+
def gh(args: list[str], *, input_data: str | None = None) -> str:
45+
result = subprocess.run(
46+
["gh"] + args,
47+
check=True,
48+
capture_output=True,
49+
text=True,
50+
input=input_data,
51+
)
52+
return result.stdout
53+
54+
55+
def gh_json(args: list[str]) -> Any:
56+
out = gh(args)
57+
return json.loads(out) if out.strip() else None
58+
59+
60+
def graphql(query: str, variables: dict[str, Any]) -> dict[str, Any]:
61+
payload = json.dumps({"query": query, "variables": variables})
62+
out = gh(["api", "graphql", "--input", "-"], input_data=payload)
63+
data = json.loads(out)
64+
if "errors" in data and data["errors"]:
65+
raise RuntimeError(f"GraphQL errors: {data['errors']}")
66+
return data["data"]
67+
68+
69+
def get_pr() -> dict[str, Any]:
70+
return gh_json(["api", f"repos/{REPO}/pulls/{PR_NUMBER}"])
71+
72+
73+
def list_changed_azips(head_sha: str) -> list[str]:
74+
files = gh_json(["api", "--paginate", f"repos/{REPO}/pulls/{PR_NUMBER}/files"])
75+
changed: list[str] = []
76+
for f in files:
77+
name = f["filename"]
78+
if not name.startswith("AZIPs/") or not name.endswith(".md"):
79+
continue
80+
if name == "AZIPs/template.md":
81+
continue
82+
if f.get("status") == "removed":
83+
continue
84+
changed.append(name)
85+
return changed
86+
87+
88+
def fetch_file_at_ref(path: str, ref: str) -> str:
89+
import urllib.parse
90+
91+
encoded = urllib.parse.quote(path)
92+
data = gh_json(["api", f"repos/{REPO}/contents/{encoded}?ref={ref}"])
93+
if data.get("encoding") != "base64":
94+
raise RuntimeError(f"Unexpected encoding for {path}: {data.get('encoding')!r}")
95+
return base64.b64decode(data["content"]).decode("utf-8")
96+
97+
98+
def split_row(row: str) -> list[str]:
99+
row = row.strip()
100+
if row.startswith("|"):
101+
row = row[1:]
102+
if row.endswith("|"):
103+
row = row[:-1]
104+
return [cell.strip() for cell in row.split("|")]
105+
106+
107+
def parse_preamble(md: str) -> dict[str, str]:
108+
"""Parse the AZIP preamble table.
109+
110+
The preamble is a two-row markdown table whose header cells are wrapped
111+
in backticks (e.g. `` `azip` ``). Returns a dict keyed by the unwrapped
112+
header name, mapped to the corresponding cell in the single data row.
113+
"""
114+
lines = md.split("\n")
115+
for i, line in enumerate(lines):
116+
if "`azip`" in line and "`discussions-to`" in line:
117+
if i + 2 >= len(lines):
118+
raise ValueError("preamble table truncated")
119+
headers = [h.strip().strip("`") for h in split_row(lines[i])]
120+
data = split_row(lines[i + 2])
121+
if len(data) != len(headers):
122+
raise ValueError(
123+
f"preamble header/data mismatch: {len(headers)} vs {len(data)}"
124+
)
125+
return dict(zip(headers, data))
126+
raise ValueError("preamble header row not found")
127+
128+
129+
def ensure_label_id() -> str:
130+
data = graphql(
131+
"""
132+
query($owner: String!, $name: String!, $label: String!) {
133+
repository(owner: $owner, name: $name) {
134+
label(name: $label) { id }
135+
}
136+
}
137+
""",
138+
{"owner": OWNER, "name": REPO_NAME, "label": LABEL_NAME},
139+
)
140+
label = data["repository"]["label"]
141+
if label:
142+
return label["id"]
143+
raise RuntimeError(
144+
f"Label {LABEL_NAME!r} does not exist on {REPO}. "
145+
"Create it once (Issues → Labels) before running this workflow."
146+
)
147+
148+
149+
def get_discussion(number: int) -> dict[str, Any]:
150+
data = graphql(
151+
"""
152+
query($owner: String!, $name: String!, $number: Int!) {
153+
repository(owner: $owner, name: $name) {
154+
discussion(number: $number) {
155+
id
156+
title
157+
body
158+
category { slug name }
159+
labels(first: 50) { nodes { name } }
160+
}
161+
}
162+
}
163+
""",
164+
{"owner": OWNER, "name": REPO_NAME, "number": number},
165+
)
166+
disc = data["repository"]["discussion"]
167+
if not disc:
168+
raise RuntimeError(f"Discussion #{number} not found")
169+
slug = (disc.get("category") or {}).get("slug")
170+
if slug != REQUIRED_CATEGORY_SLUG:
171+
raise RuntimeError(
172+
f"Discussion #{number} is in category {slug!r}; refusing to mutate. "
173+
f"Only {REQUIRED_CATEGORY_SLUG!r} discussions may be linked to AZIPs."
174+
)
175+
return disc
176+
177+
178+
def add_label(discussion_id: str, label_id: str) -> None:
179+
graphql(
180+
"""
181+
mutation($id: ID!, $labels: [ID!]!) {
182+
addLabelsToLabelable(input: { labelableId: $id, labelIds: $labels }) {
183+
clientMutationId
184+
}
185+
}
186+
""",
187+
{"id": discussion_id, "labels": [label_id]},
188+
)
189+
190+
191+
def update_discussion(discussion_id: str, *, title: str, body: str) -> None:
192+
graphql(
193+
"""
194+
mutation($id: ID!, $title: String!, $body: String!) {
195+
updateDiscussion(input: { discussionId: $id, title: $title, body: $body }) {
196+
discussion { id }
197+
}
198+
}
199+
""",
200+
{"id": discussion_id, "title": title, "body": body},
201+
)
202+
203+
204+
def desired_title(current_title: str, azip_num: int) -> str:
205+
# Strip any leading `AZIP-\d+` prefix (regardless of the specific number)
206+
# so title normalization stays idempotent even when the AZIP number is
207+
# reassigned mid-review. Also tolerates zero padding, and `:`/`-`/space
208+
# separators after the number.
209+
prefix_re = re.compile(r"^AZIP-\d+\s*[:\-]?\s*", re.IGNORECASE)
210+
stripped = prefix_re.sub("", current_title).strip()
211+
return f"AZIP-{azip_num}: {stripped}"
212+
213+
214+
def desired_body(current_body: str, azip_num: int, pr_url: str) -> str:
215+
link_line = f"**AZIP:** [AZIP-{azip_num}]({pr_url})"
216+
# If an AZIP link block already exists at the top, replace it rather than stacking.
217+
existing = re.match(
218+
r"^\*\*AZIP:\*\*\s*\[AZIP-\d+\]\(https?://\S+\)\s*\n+",
219+
current_body,
220+
)
221+
rest = current_body[existing.end():] if existing else current_body.lstrip("\n")
222+
return f"{link_line}\n\n{rest}"
223+
224+
225+
def process_azip(path: str, pr_url: str, head_sha: str, label_id: str) -> None:
226+
print(f"::group::{path}")
227+
try:
228+
md = fetch_file_at_ref(path, head_sha)
229+
preamble = parse_preamble(md)
230+
231+
azip_raw = preamble.get("azip", "").strip()
232+
disc_raw = preamble.get("discussions-to", "").strip()
233+
234+
if not AZIP_NUM_RE.match(azip_raw):
235+
print(f"::notice file={path}::azip number not yet assigned ({azip_raw!r}); skipping")
236+
return
237+
238+
m = DISCUSSION_URL_RE.match(disc_raw)
239+
if not m:
240+
print(
241+
f"::warning file={path}::discussions-to is not a "
242+
f"{OWNER}/{REPO_NAME} discussion URL ({disc_raw!r}); skipping"
243+
)
244+
return
245+
246+
azip_num = int(azip_raw)
247+
disc_num = int(m.group(1))
248+
249+
disc = get_discussion(disc_num)
250+
new_title = desired_title(disc["title"], azip_num)
251+
new_body = desired_body(disc["body"], azip_num, pr_url)
252+
253+
title_changed = new_title != disc["title"]
254+
body_changed = new_body != disc["body"]
255+
has_label = any(l["name"] == LABEL_NAME for l in disc["labels"]["nodes"])
256+
257+
if not has_label:
258+
add_label(disc["id"], label_id)
259+
print(f"Added label {LABEL_NAME!r} to discussion #{disc_num}")
260+
else:
261+
print(f"Label {LABEL_NAME!r} already present on discussion #{disc_num}")
262+
263+
if title_changed or body_changed:
264+
update_discussion(disc["id"], title=new_title, body=new_body)
265+
parts = []
266+
if title_changed:
267+
parts.append("title")
268+
if body_changed:
269+
parts.append("body")
270+
print(f"Updated discussion #{disc_num} ({', '.join(parts)})")
271+
else:
272+
print(f"Discussion #{disc_num} already linked; no body/title change")
273+
finally:
274+
print("::endgroup::")
275+
276+
277+
def main() -> int:
278+
pr = get_pr()
279+
pr_url = pr["html_url"]
280+
head_sha = pr["head"]["sha"]
281+
282+
files = list_changed_azips(head_sha)
283+
if not files:
284+
print("No AZIP files changed in this PR; nothing to do.")
285+
return 0
286+
287+
label_id = ensure_label_id()
288+
289+
# Guard: two changed AZIPs must not target the same discussion. Last
290+
# writer would win otherwise, silently.
291+
seen_discussions: dict[int, str] = {}
292+
errors = 0
293+
for path in files:
294+
try:
295+
md = fetch_file_at_ref(path, head_sha)
296+
preamble = parse_preamble(md)
297+
disc_raw = preamble.get("discussions-to", "").strip()
298+
m = DISCUSSION_URL_RE.match(disc_raw)
299+
if m:
300+
disc_num = int(m.group(1))
301+
if disc_num in seen_discussions:
302+
print(
303+
f"::error file={path}::discussion #{disc_num} is also "
304+
f"referenced by {seen_discussions[disc_num]}; refusing "
305+
f"to mutate the same discussion twice in one run"
306+
)
307+
errors += 1
308+
continue
309+
seen_discussions[disc_num] = path
310+
process_azip(path, pr_url, head_sha, label_id)
311+
except Exception as e:
312+
print(f"::error file={path}::{e}")
313+
errors += 1
314+
return 1 if errors else 0
315+
316+
317+
if __name__ == "__main__":
318+
sys.exit(main())
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: Link AZIP to Discussion
2+
3+
# When an AZIP PR is opened, edited, or resynchronized, this workflow reads
4+
# the AZIP preamble (`azip` number and `discussions-to` URL), then updates
5+
# the referenced GitHub Discussion:
6+
#
7+
# 1. Applies the `has-azip` label.
8+
# 2. Prepends a link to the AZIP PR at the top of the discussion body.
9+
# 3. Prefixes the discussion title with `AZIP-N: `.
10+
#
11+
# Idempotent: re-running makes no change if the discussion is already linked.
12+
#
13+
# Security notes:
14+
# * Uses `pull_request_target` so the workflow can write to discussions for
15+
# PRs opened from forks. To keep this safe we never check out or execute
16+
# PR code. We only read one file (the AZIP markdown) via the API and
17+
# parse it strictly.
18+
# * `discussions-to` values are validated against a strict regex before
19+
# any API call is made.
20+
21+
on:
22+
pull_request_target:
23+
types: [opened, reopened, synchronize, edited]
24+
paths:
25+
- 'AZIPs/**.md'
26+
workflow_dispatch:
27+
inputs:
28+
pr_number:
29+
description: 'PR number to reprocess'
30+
required: true
31+
type: number
32+
33+
permissions:
34+
contents: read
35+
pull-requests: read
36+
discussions: write
37+
38+
concurrency:
39+
group: link-azip-${{ github.event.pull_request.number || inputs.pr_number }}
40+
cancel-in-progress: false
41+
42+
jobs:
43+
link:
44+
runs-on: ubuntu-latest
45+
env:
46+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+
REPO: ${{ github.repository }}
48+
PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }}
49+
steps:
50+
- name: Checkout workflow scripts only
51+
uses: actions/checkout@v4
52+
with:
53+
# Intentionally check out the base ref, NOT the PR head. We only
54+
# need the script in `.github/scripts/`. PR contents are fetched
55+
# separately via the API and parsed without execution.
56+
# Uses the PR's base ref when triggered by pull_request_target, so
57+
# the script matches the branch the PR will merge into; falls back
58+
# to the default branch for manual workflow_dispatch runs.
59+
ref: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }}
60+
sparse-checkout: |
61+
.github/scripts
62+
sparse-checkout-cone-mode: false
63+
64+
- name: Set up Python
65+
uses: actions/setup-python@v5
66+
with:
67+
python-version: '3.12'
68+
69+
- name: Link AZIP PR to discussion
70+
run: python3 .github/scripts/link_azip_to_discussion.py

0 commit comments

Comments
 (0)