From 07bd58979d06cca02610501d5dcb634fe29046ee Mon Sep 17 00:00:00 2001 From: Red Davies Date: Tue, 12 May 2026 14:14:05 -0400 Subject: [PATCH] Generate text/index.md via CI scheduled/push/manual The script here generates a table which has the columns: RFC #, Start Date, Feature Name, RFC PR, Pony Issue, as well as the status of said PRs and Issues. From this a user can, at a glance identify which PRs are complete or still in flight. --- .ci-scripts/generate-index.py | 184 +++++++++++++++++++++++++ .github/workflows/update-rfc-index.yml | 51 +++++++ 2 files changed, 235 insertions(+) create mode 100644 .ci-scripts/generate-index.py create mode 100644 .github/workflows/update-rfc-index.yml diff --git a/.ci-scripts/generate-index.py b/.ci-scripts/generate-index.py new file mode 100644 index 00000000..37b66964 --- /dev/null +++ b/.ci-scripts/generate-index.py @@ -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()) diff --git a/.github/workflows/update-rfc-index.yml b/.github/workflows/update-rfc-index.yml new file mode 100644 index 00000000..cb8e69eb --- /dev/null +++ b/.github/workflows/update-rfc-index.yml @@ -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