Skip to content
Closed
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
184 changes: 184 additions & 0 deletions .ci-scripts/generate-index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Generate a markdown index of every RFC and the status of its PR/issue.

Reads the RFC header from each `text/NNNN-*.md`, queries the GitHub GraphQL
API for the live state of each referenced RFC PR (ponylang/rfcs) and Pony
issue (ponylang/ponyc), and prints a markdown table to stdout (or `-o FILE`).

Requires the `GITHUB_TOKEN` environment variable. In a GitHub Actions job the
default workflow token is sufficient; this script only reads public data.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path

HEADER_FIELDS = {
"- Feature Name:": "feature",
"- Start Date:": "start",
"- RFC PR:": "pr_url",
"- Pony Issue:": "issue_url",
}
PR_URL_RE = re.compile(r"github\.com/ponylang/rfcs/pull/(\d+)")
ISSUE_URL_RE = re.compile(r"github\.com/ponylang/ponyc/issues/(\d+)")

GRAPHQL_URL = "https://api.github.com/graphql"


def parse_header(path: Path) -> dict[str, str]:
fields = {v: "" for v in HEADER_FIELDS.values()}
with path.open() as f:
for line in f:
line = line.rstrip("\n")
if line.startswith("# "):
break
for prefix, field in HEADER_FIELDS.items():
if line.startswith(prefix):
fields[field] = line[len(prefix):].strip()
break
return fields


def query_github(prs: set[int], issues: set[int], token: str) -> dict:
if not prs and not issues:
return {"rfcs": {}, "ponyc": {}}
pr_aliases = " ".join(
f"pr{n}: pullRequest(number: {n}) {{ state merged }}"
for n in sorted(prs)
)
issue_aliases = " ".join(
f"i{n}: issue(number: {n}) {{ state stateReason }}"
for n in sorted(issues)
)
query = (
"query {"
f' rfcs: repository(owner: "ponylang", name: "rfcs") {{ {pr_aliases} }}'
f' ponyc: repository(owner: "ponylang", name: "ponyc") {{ {issue_aliases} }}'
" }"
)
req = urllib.request.Request(
GRAPHQL_URL,
data=json.dumps({"query": query}).encode(),
headers={
"Authorization": f"bearer {token}",
"User-Agent": "ponylang-rfc-index-generator",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req) as resp:
body = json.load(resp)
if body.get("errors"):
raise RuntimeError(f"GraphQL errors: {body['errors']}")
return body["data"]


def pr_status(node: dict | None) -> str:
if node is None:
return "Unknown"
if node.get("merged"):
return "Merged"
return node.get("state", "Unknown").capitalize()


def issue_status(node: dict | None) -> str:
if node is None:
return "Unknown"
if node.get("state") == "OPEN":
return "Open"
reason = node.get("stateReason")
return {
"COMPLETED": "Closed (completed)",
"NOT_PLANNED": "Closed (not planned)",
"REOPENED": "Open (reopened)",
}.get(reason, "Closed")


def cell(num: int | None, url: str, status: str) -> str:
if num is None:
return ""
return f"[#{num}]({url}) — {status}"


def collect_rfcs(text_dir: Path) -> list[dict]:
rfcs = []
for path in sorted(text_dir.glob("[0-9]*.md")):
header = parse_header(path)
pr_match = PR_URL_RE.search(header["pr_url"])
issue_match = ISSUE_URL_RE.search(header["issue_url"])
rfcs.append({
"num": path.name.split("-", 1)[0],
"start": header["start"],
"feature": header["feature"],
"pr_num": int(pr_match.group(1)) if pr_match else None,
"pr_url": header["pr_url"] if pr_match else "",
"issue_num": int(issue_match.group(1)) if issue_match else None,
"issue_url": header["issue_url"] if issue_match else "",
})
return rfcs


def render(rfcs: list[dict], data: dict) -> str:
lines = [
"# RFC Index",
"",
"| RFC # | Start Date | Feature Name | RFC PR | Pony Issue |",
"|-------|------------|--------------|--------|------------|",
]
for r in rfcs:
pr_node = data["rfcs"].get(f"pr{r['pr_num']}") if r["pr_num"] else None
issue_node = data["ponyc"].get(f"i{r['issue_num']}") if r["issue_num"] else None
lines.append(
f"| {r['num']} | {r['start']} | {r['feature']} | "
f"{cell(r['pr_num'], r['pr_url'], pr_status(pr_node))} | "
f"{cell(r['issue_num'], r['issue_url'], issue_status(issue_node))} |"
)
lines.append("\nThis file is automatically generated, do not edit")
return "\n".join(lines) + "\n"


def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"text_dir",
nargs="?",
default="text",
type=Path,
help="Directory of RFC .md files (default: text)",
)
ap.add_argument(
"-o", "--output",
type=Path,
help="Output file (default: stdout)",
)
args = ap.parse_args()

if not args.text_dir.is_dir():
print(f"error: not a directory: {args.text_dir}", file=sys.stderr)
return 2

token = os.environ.get("GITHUB_TOKEN")
if not token:
print("error: GITHUB_TOKEN not set", file=sys.stderr)
return 2

rfcs = collect_rfcs(args.text_dir)
prs = {r["pr_num"] for r in rfcs if r["pr_num"] is not None}
issues = {r["issue_num"] for r in rfcs if r["issue_num"] is not None}
data = query_github(prs, issues, token)
output = render(rfcs, data)

if args.output:
args.output.write_text(output)
else:
sys.stdout.write(output)
return 0


if __name__ == "__main__":
sys.exit(main())
51 changes: 51 additions & 0 deletions .github/workflows/update-rfc-index.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Update RFC index

on:
schedule:
# Daily at 03:17 UTC. Picks up status changes on tracking issues
# in ponylang/ponyc even when no commits land in this repo.
- cron: '17 3 * * *'
push:
branches: [main]
paths:
- 'text/[0-9]*.md'
- '.ci-scripts/generate-index.py'
- '.github/workflows/update-rfc-index.yml'
workflow_dispatch:

concurrency:
group: update-rfc-index
cancel-in-progress: false

permissions:
contents: write

jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6.0.2

- name: Set up Python
uses: actions/setup-python@v6.0.0
with:
python-version: '3.x'

- name: Generate index
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 .ci-scripts/generate-index.py > text/index.md

- name: Commit and push if changed
run: |
if [[ -z "$(git status --porcelain text/index.md)" ]]; then
echo "No changes to text/index.md."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add text/index.md
git commit -m "Regenerate RFC index"
git push
Loading