This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch feat/oink-community-content
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git

commit 47100479bdc63492b52ee0539872c4f36257f0ae
Author: dark <[email protected]>
AuthorDate: Fri Sep 4 19:03:17 2026 +0800

    feat(community): add deterministic ASF roster
    
    - derive PMC and Committers from authoritative ASF public data\n- preserve 
the last-good bundle across refresh failures\n- validate roles, mappings, 
staleness, and local avatars\n- cover ordering, drift, mapping, and recovery 
contracts
---
 data/community/github-map.json   |   4 +
 data/community/roster.json       | 209 ++++++++++++++++++++++++
 scripts/community_roster.py      | 338 +++++++++++++++++++++++++++++++++++++++
 scripts/test_community_roster.py |  53 ++++++
 4 files changed, 604 insertions(+)

diff --git a/data/community/github-map.json b/data/community/github-map.json
new file mode 100644
index 000000000..7529e5a32
--- /dev/null
+++ b/data/community/github-map.json
@@ -0,0 +1,4 @@
+{
+  "schema_version": 1,
+  "mappings": {}
+}
diff --git a/data/community/roster.json b/data/community/roster.json
new file mode 100644
index 000000000..ee6197891
--- /dev/null
+++ b/data/community/roster.json
@@ -0,0 +1,209 @@
+{
+  "schema_version": 1,
+  "project": "hugegraph",
+  "retrieved_at": "2026-09-04T11:03:16Z",
+  "source": {
+    "committee": "https://whimsy.apache.org/public/committee-info.json";,
+    "projects": "https://whimsy.apache.org/public/public_ldap_projects.json";,
+    "people": "https://whimsy.apache.org/public/public_ldap_people.json";,
+    "chair": "jermy",
+    "owners": [
+      "hxd",
+      "jermy",
+      "jin",
+      "lidongdai",
+      "linary",
+      "liyu",
+      "ming",
+      "ningjiang",
+      "panjuan",
+      "vaughn",
+      "vgalaxies",
+      "zhaocong"
+    ],
+    "members": [
+      "guoshoujing",
+      "hxd",
+      "jermy",
+      "jin",
+      "jsong010123",
+      "leizou",
+      "lidongdai",
+      "linary",
+      "liuxiaocs",
+      "liyu",
+      "ming",
+      "ningjiang",
+      "panjuan",
+      "pengjunzhi",
+      "spica",
+      "vaughn",
+      "vgalaxies",
+      "vichayturen",
+      "wangjing",
+      "yangjiaqi",
+      "zhangyi89817",
+      "zhaocong"
+    ]
+  },
+  "roles": {
+    "pmc": [
+      {
+        "asf_id": "jermy",
+        "name": "Jermy Li",
+        "initials": "JL",
+        "chair": true,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=jermy";
+      },
+      {
+        "asf_id": "zhaocong",
+        "name": "Cong Zhao",
+        "initials": "CZ",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=zhaocong";
+      },
+      {
+        "asf_id": "jin",
+        "name": "Imba Jin",
+        "initials": "IJ",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=jin";
+      },
+      {
+        "asf_id": "panjuan",
+        "name": "Juan Pan",
+        "initials": "JP",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=panjuan";
+      },
+      {
+        "asf_id": "lidongdai",
+        "name": "Lidong Dai",
+        "initials": "LD",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=lidongdai";
+      },
+      {
+        "asf_id": "linary",
+        "name": "NingRui Li",
+        "initials": "NL",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=linary";
+      },
+      {
+        "asf_id": "ming",
+        "name": "Simon",
+        "initials": "S",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=ming";
+      },
+      {
+        "asf_id": "ningjiang",
+        "name": "Willem Ning Jiang",
+        "initials": "WN",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=ningjiang";
+      },
+      {
+        "asf_id": "hxd",
+        "name": "Xiangdong Huang",
+        "initials": "XH",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=hxd";
+      },
+      {
+        "asf_id": "vaughn",
+        "name": "Yan Zhang",
+        "initials": "YZ",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=vaughn";
+      },
+      {
+        "asf_id": "liyu",
+        "name": "Yu Li",
+        "initials": "YL",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=liyu";
+      },
+      {
+        "asf_id": "vgalaxies",
+        "name": "Yuchen Ding",
+        "initials": "YD",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=vgalaxies";
+      }
+    ],
+    "committers": [
+      {
+        "asf_id": "yangjiaqi",
+        "name": "Jacky Yang",
+        "initials": "JY",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=yangjiaqi";
+      },
+      {
+        "asf_id": "jsong010123",
+        "name": "Jason",
+        "initials": "J",
+        "chair": false,
+        "profile_url": 
"https://people.apache.org/phonebook.html?uid=jsong010123";
+      },
+      {
+        "asf_id": "wangjing",
+        "name": "Jing Wang",
+        "initials": "JW",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=wangjing";
+      },
+      {
+        "asf_id": "pengjunzhi",
+        "name": "Junzhi Peng",
+        "initials": "JP",
+        "chair": false,
+        "profile_url": 
"https://people.apache.org/phonebook.html?uid=pengjunzhi";
+      },
+      {
+        "asf_id": "vichayturen",
+        "name": "Kaiyichen Wei",
+        "initials": "KW",
+        "chair": false,
+        "profile_url": 
"https://people.apache.org/phonebook.html?uid=vichayturen";
+      },
+      {
+        "asf_id": "leizou",
+        "name": "Lei Zou",
+        "initials": "LZ",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=leizou";
+      },
+      {
+        "asf_id": "guoshoujing",
+        "name": "Shoujing Guo",
+        "initials": "SG",
+        "chair": false,
+        "profile_url": 
"https://people.apache.org/phonebook.html?uid=guoshoujing";
+      },
+      {
+        "asf_id": "liuxiaocs",
+        "name": "Xiao Liu",
+        "initials": "XL",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=liuxiaocs";
+      },
+      {
+        "asf_id": "zhangyi89817",
+        "name": "Yi Zhang",
+        "initials": "YZ",
+        "chair": false,
+        "profile_url": 
"https://people.apache.org/phonebook.html?uid=zhangyi89817";
+      },
+      {
+        "asf_id": "spica",
+        "name": "Zhe Wang",
+        "initials": "ZW",
+        "chair": false,
+        "profile_url": "https://people.apache.org/phonebook.html?uid=spica";
+      }
+    ]
+  }
+}
diff --git a/scripts/community_roster.py b/scripts/community_roster.py
new file mode 100644
index 000000000..ff9d2bb36
--- /dev/null
+++ b/scripts/community_roster.py
@@ -0,0 +1,338 @@
+#!/usr/bin/env python3
+"""Refresh and validate the offline Apache HugeGraph community roster."""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import hashlib
+import json
+import os
+import pathlib
+import shutil
+import struct
+import subprocess
+import sys
+import tempfile
+import urllib.error
+import urllib.request
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+DATA_DIR = ROOT / "data" / "community"
+ROSTER_PATH = DATA_DIR / "roster.json"
+MAP_PATH = DATA_DIR / "github-map.json"
+AVATAR_DIR = ROOT / "static" / "img" / "community" / "avatars"
+PROJECT = "hugegraph"
+SCHEMA_VERSION = 1
+SOURCES = {
+    "committee": "https://whimsy.apache.org/public/committee-info.json";,
+    "projects": "https://whimsy.apache.org/public/public_ldap_projects.json";,
+    "people": "https://whimsy.apache.org/public/public_ldap_people.json";,
+}
+
+
+class RosterError(ValueError):
+    pass
+
+
+def _read_json(path: pathlib.Path) -> dict:
+    try:
+        return json.loads(path.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError) as exc:
+        raise RosterError(f"{path.relative_to(ROOT)}: invalid JSON: {exc}") 
from exc
+
+
+def _fetch_json(url: str) -> dict:
+    request = urllib.request.Request(url, headers={"User-Agent": 
"apache-hugegraph-doc-community-roster/1"})
+    with urllib.request.urlopen(request, timeout=30) as response:
+        if response.status != 200:
+            raise RosterError(f"{url}: HTTP {response.status}")
+        return json.load(response)
+
+
+def _person_name(people: dict, asf_id: str) -> str:
+    record = people.get("people", {}).get(asf_id)
+    name = record.get("name") if isinstance(record, dict) else None
+    if isinstance(name, list):
+        name = name[0] if name else ""
+    if not isinstance(name, str) or not name.strip():
+        raise RosterError(f"people source has no public name for ASF ID 
{asf_id!r}")
+    return name.strip()
+
+
+def _initials(name: str) -> str:
+    parts = [part for part in name.replace("-", " ").split() if part]
+    return "".join(part[0].upper() for part in parts[:2]) or "?"
+
+
+def _validate_mapping(data: dict, roster_ids: set[str] | None = None) -> dict:
+    if data.get("schema_version") != SCHEMA_VERSION:
+        raise RosterError("github-map.json: schema_version must be 1")
+    mappings = data.get("mappings")
+    if not isinstance(mappings, dict):
+        raise RosterError("github-map.json: mappings must be an object")
+    logins: set[str] = set()
+    user_ids: set[int] = set()
+    for asf_id, mapping in mappings.items():
+        if roster_ids is not None and asf_id not in roster_ids:
+            raise RosterError(f"github-map.json: unknown ASF ID {asf_id!r}")
+        if not isinstance(mapping, dict):
+            raise RosterError(f"github-map.json: mapping for {asf_id!r} must 
be an object")
+        login, user_id = mapping.get("login"), mapping.get("user_id")
+        if not isinstance(login, str) or not login.strip() or login != 
login.strip():
+            raise RosterError(f"github-map.json: {asf_id!r} needs a reviewed 
login")
+        if not isinstance(user_id, int) or isinstance(user_id, bool) or 
user_id <= 0:
+            raise RosterError(f"github-map.json: {asf_id!r} needs a positive 
numeric user_id")
+        if login.casefold() in logins:
+            raise RosterError(f"github-map.json: duplicate GitHub login 
{login!r}")
+        if user_id in user_ids:
+            raise RosterError(f"github-map.json: duplicate GitHub user_id 
{user_id}")
+        logins.add(login.casefold())
+        user_ids.add(user_id)
+    return mappings
+
+
+def _webp_dimensions(raw: bytes) -> tuple[int, int]:
+    if len(raw) < 30 or raw[:4] != b"RIFF" or raw[8:12] != b"WEBP":
+        raise RosterError("avatar is not a WebP image")
+    chunk = raw[12:16]
+    if chunk == b"VP8X":
+        return 1 + int.from_bytes(raw[24:27], "little"), 1 + 
int.from_bytes(raw[27:30], "little")
+    if chunk == b"VP8L":
+        bits = int.from_bytes(raw[21:25], "little")
+        return 1 + (bits & 0x3FFF), 1 + ((bits >> 14) & 0x3FFF)
+    if chunk == b"VP8 ":
+        marker = raw.find(b"\x9d\x01\x2a", 20)
+        if marker < 0 or marker + 7 > len(raw):
+            raise RosterError("avatar has an invalid VP8 frame")
+        width, height = struct.unpack_from("<HH", raw, marker + 3)
+        return width & 0x3FFF, height & 0x3FFF
+    raise RosterError(f"avatar uses unsupported WebP chunk {chunk!r}")
+
+
+def _avatar_bytes(user_id: int) -> bytes:
+    request = urllib.request.Request(
+        f"https://avatars.githubusercontent.com/u/{user_id}?s=128&v=4";,
+        headers={"Accept": "image/webp", "User-Agent": 
"apache-hugegraph-doc-community-roster/1"},
+    )
+    with urllib.request.urlopen(request, timeout=30) as response:
+        raw = response.read()
+    if _webp_dimensions(raw) != (128, 128):
+        raise RosterError(f"GitHub avatar for numeric user ID {user_id} is not 
128x128 WebP")
+    return raw
+
+
+def _member(asf_id: str, name: str, chair: bool, mapping: dict | None) -> dict:
+    member = {
+        "asf_id": asf_id,
+        "name": name,
+        "initials": _initials(name),
+        "chair": chair,
+        "profile_url": 
f"https://people.apache.org/phonebook.html?uid={asf_id}";,
+    }
+    if mapping:
+        member["github"] = {"login": mapping["login"], "user_id": 
mapping["user_id"]}
+    return member
+
+
+def build_roster(committee_data: dict, projects_data: dict, people_data: dict, 
mapping_data: dict) -> dict:
+    project = projects_data.get("projects", {}).get(PROJECT)
+    committee = committee_data.get("committees", {}).get(PROJECT)
+    if not isinstance(project, dict) or not isinstance(committee, dict):
+        raise RosterError("ASF sources do not contain the HugeGraph project")
+    owners, members = project.get("owners"), project.get("members")
+    chair_map, committee_roster = committee.get("chair"), 
committee.get("roster")
+    if not isinstance(owners, list) or not isinstance(members, list):
+        raise RosterError("LDAP project owners/members must be arrays")
+    if not isinstance(chair_map, dict) or len(chair_map) != 1:
+        raise RosterError("committee source must name exactly one Chair")
+    if not isinstance(committee_roster, dict):
+        raise RosterError("committee roster must be an object")
+    owner_ids, member_ids = set(owners), set(members)
+    chair = next(iter(chair_map))
+    if not owner_ids <= member_ids:
+        raise RosterError("LDAP owners must be a subset of members")
+    if chair not in owner_ids:
+        raise RosterError("Chair must be an LDAP owner")
+    if owner_ids != set(committee_roster):
+        raise RosterError("committee roster and LDAP owners disagree")
+    mappings = _validate_mapping(mapping_data, member_ids)
+    names = {asf_id: _person_name(people_data, asf_id) for asf_id in 
member_ids}
+    pmc_ids = [chair] + sorted(owner_ids - {chair}, key=lambda item: 
names[item].casefold())
+    committer_ids = sorted(member_ids - owner_ids, key=lambda item: 
names[item].casefold())
+    return {
+        "schema_version": SCHEMA_VERSION,
+        "project": PROJECT,
+        "retrieved_at": 
dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00",
 "Z"),
+        "source": {
+            **SOURCES,
+            "chair": chair,
+            "owners": sorted(owner_ids),
+            "members": sorted(member_ids),
+        },
+        "roles": {
+            "pmc": [_member(i, names[i], i == chair, mappings.get(i)) for i in 
pmc_ids],
+            "committers": [_member(i, names[i], False, mappings.get(i)) for i 
in committer_ids],
+        },
+    }
+
+
+def _install_avatars(candidate: dict, target: pathlib.Path) -> None:
+    target.mkdir(parents=True, exist_ok=True)
+    for role in ("pmc", "committers"):
+        for member in candidate["roles"][role]:
+            github = member.get("github")
+            if not github:
+                continue
+            raw = _avatar_bytes(github["user_id"])
+            digest = hashlib.sha256(raw).hexdigest()
+            path = target / f"{digest}.webp"
+            if not path.exists():
+                path.write_bytes(raw)
+            member["avatar"] = f"/img/community/avatars/{path.name}"
+            member["profile_url"] = f"https://github.com/{github['login']}"
+
+
+def validate_bundle(warn_after_days: int) -> list[str]:
+    roster, mapping = _read_json(ROSTER_PATH), _read_json(MAP_PATH)
+    if roster.get("schema_version") != SCHEMA_VERSION or roster.get("project") 
!= PROJECT:
+        raise RosterError("roster.json: unsupported schema_version or project")
+    roles, source = roster.get("roles"), roster.get("source")
+    if not isinstance(roles, dict) or set(roles) != {"pmc", "committers"}:
+        raise RosterError("roster.json: roles must contain only pmc and 
committers")
+    if not isinstance(source, dict):
+        raise RosterError("roster.json: source must be an object")
+    for key, url in SOURCES.items():
+        if source.get(key) != url:
+            raise RosterError(f"roster.json: source.{key} is not 
authoritative")
+    owners, members, chair = source.get("owners"), source.get("members"), 
source.get("chair")
+    if not isinstance(owners, list) or not isinstance(members, list):
+        raise RosterError("roster.json: source owners/members must be arrays")
+    if owners != sorted(set(owners)) or members != sorted(set(members)):
+        raise RosterError("roster.json: source owners/members must be sorted 
and unique")
+    if not set(owners) <= set(members) or chair not in owners:
+        raise RosterError("roster.json: invalid owners/members/Chair 
relationship")
+    pmc, committers = roles["pmc"], roles["committers"]
+    if not isinstance(pmc, list) or not isinstance(committers, list) or not 
pmc:
+        raise RosterError("roster.json: invalid role arrays")
+    people = pmc + committers
+    ids = [person.get("asf_id") for person in people if isinstance(person, 
dict)]
+    if len(ids) != len(people) or len(ids) != len(set(ids)) or set(ids) != 
set(members):
+        raise RosterError("roster.json: members must match unique source ASF 
IDs")
+    if {p["asf_id"] for p in pmc} != set(owners):
+        raise RosterError("roster.json: PMC must equal owners")
+    if {p["asf_id"] for p in committers} != set(members) - set(owners):
+        raise RosterError("roster.json: Committers must equal members minus 
owners")
+    chairs = [person for person in people if person.get("chair") is True]
+    if len(chairs) != 1 or chairs[0].get("asf_id") != chair or pmc[0] != 
chairs[0]:
+        raise RosterError("roster.json: unique Chair must be first in PMC")
+    for role, entries in roles.items():
+        tail = entries[1:] if role == "pmc" else entries
+        if [p.get("name", "").casefold() for p in tail] != 
sorted(p.get("name", "").casefold() for p in tail):
+            raise RosterError(f"roster.json: {role} must be sorted by public 
name casefold")
+        for person in entries:
+            if any(key not in person for key in ("asf_id", "name", "initials", 
"chair", "profile_url")):
+                raise RosterError(f"roster.json: incomplete member {person!r}")
+    mappings = _validate_mapping(mapping, set(ids))
+    for person in people:
+        expected, avatar = mappings.get(person["asf_id"]), person.get("avatar")
+        if expected != person.get("github"):
+            raise RosterError(f"roster.json: GitHub mapping drift for 
{person['asf_id']!r}")
+        if expected:
+            if not isinstance(avatar, str) or not 
avatar.startswith("/img/community/avatars/"):
+                raise RosterError(f"roster.json: mapped member 
{person['asf_id']!r} needs a local avatar")
+            filename = pathlib.PurePosixPath(avatar).name
+            if len(filename) != 69 or not filename.endswith(".webp"):
+                raise RosterError(f"roster.json: avatar filename must be a 
SHA-256 digest")
+            raw = (ROOT / "static" / avatar.lstrip("/")).read_bytes()
+            if hashlib.sha256(raw).hexdigest() != filename[:-5] or 
_webp_dimensions(raw) != (128, 128):
+                raise RosterError(f"roster.json: invalid avatar {avatar}")
+            if person["profile_url"] != 
f"https://github.com/{expected['login']}":
+                raise RosterError(f"roster.json: mapped profile URL mismatch")
+        elif avatar:
+            raise RosterError(f"roster.json: unmapped member has an avatar")
+    try:
+        retrieved = 
dt.datetime.fromisoformat(roster["retrieved_at"].replace("Z", "+00:00"))
+    except (KeyError, TypeError, ValueError) as exc:
+        raise RosterError("roster.json: retrieved_at must be ISO-8601 UTC") 
from exc
+    now = dt.datetime.now(dt.timezone.utc)
+    if retrieved.tzinfo is None or retrieved > now + dt.timedelta(minutes=5):
+        raise RosterError("roster.json: retrieved_at is in the future or lacks 
a timezone")
+    age = now - retrieved
+    return [f"community roster is {age.days} days old (threshold: 
{warn_after_days})"] if age > dt.timedelta(days=warn_after_days) else []
+
+
+def validate_rendered_outputs() -> None:
+    with tempfile.TemporaryDirectory(prefix="hugegraph-community-site-") as 
destination:
+        result = subprocess.run(["hugo", "--quiet", "--destination", 
destination], cwd=ROOT, text=True, capture_output=True)
+        if result.returncode:
+            raise RosterError(f"Hugo render failed:\n{result.stderr.strip()}")
+        expected = {
+            "community/index.html": ('data-community-role="pmc"', 
'data-community-role="committers"'),
+            "_print/community/index.html": ('data-community-role="pmc"', 
'data-community-role="committers"'),
+            "community/index.md": ("## Project members", "### PMC", "### 
Committers"),
+            "cn/community/index.html": ('data-community-role="pmc"', 
'data-community-role="committers"'),
+            "cn/_print/community/index.html": ('data-community-role="pmc"', 
'data-community-role="committers"'),
+            "cn/community/index.md": ("## 项目成员", "### PMC", "### Committers"),
+        }
+        urls = [p["profile_url"] for role in ("pmc", "committers") for p in 
_read_json(ROSTER_PATH)["roles"][role]]
+        for relative, markers in expected.items():
+            path = pathlib.Path(destination) / relative
+            if not path.is_file():
+                raise RosterError(f"rendered output is missing {relative}")
+            rendered = path.read_text(encoding="utf-8")
+            if any(marker not in rendered for marker in markers):
+                raise RosterError(f"rendered output {relative} is missing 
Community markers")
+            positions = [rendered.find(url.replace("&", "&amp;") if 
relative.endswith(".html") else url) for url in urls]
+            if any(position < 0 for position in positions) or positions != 
sorted(positions):
+                raise RosterError(f"rendered output {relative} has role/link 
parity drift")
+
+
+def refresh() -> None:
+    source_data = {key: _fetch_json(url) for key, url in SOURCES.items()}
+    candidate = build_roster(source_data["committee"], 
source_data["projects"], source_data["people"], _read_json(MAP_PATH))
+    with tempfile.TemporaryDirectory(prefix=".community-refresh-", 
dir=DATA_DIR) as work:
+        work_path = pathlib.Path(work)
+        candidate_avatars = work_path / "avatars"
+        _install_avatars(candidate, candidate_avatars)
+        candidate_path = work_path / "roster.json"
+        candidate_path.write_text(json.dumps(candidate, indent=2, 
ensure_ascii=False) + "\n", encoding="utf-8")
+        AVATAR_DIR.mkdir(parents=True, exist_ok=True)
+        for avatar in candidate_avatars.glob("*.webp"):
+            destination = AVATAR_DIR / avatar.name
+            if not destination.exists():
+                shutil.copyfile(avatar, destination)
+        os.replace(candidate_path, ROSTER_PATH)
+        referenced = {pathlib.PurePosixPath(p["avatar"]).name for role in 
candidate["roles"].values() for p in role if p.get("avatar")}
+        for avatar in AVATAR_DIR.glob("*.webp"):
+            if avatar.name not in referenced:
+                avatar.unlink()
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    commands = parser.add_subparsers(dest="command", required=True)
+    commands.add_parser("refresh")
+    validate = commands.add_parser("validate")
+    validate.add_argument("--warn-after-days", type=int, default=90)
+    validate.add_argument("--skip-render", action="store_true")
+    args = parser.parse_args()
+    try:
+        if args.command == "refresh":
+            DATA_DIR.mkdir(parents=True, exist_ok=True)
+            refresh()
+        else:
+            if args.warn_after_days < 0:
+                raise RosterError("--warn-after-days must be non-negative")
+            for warning in validate_bundle(args.warn_after_days):
+                print(f"::warning file=data/community/roster.json::{warning}")
+            if not args.skip_render:
+                validate_rendered_outputs()
+    except (OSError, RosterError, urllib.error.URLError) as exc:
+        print(f"community roster: {exc}", file=sys.stderr)
+        return 1
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/scripts/test_community_roster.py b/scripts/test_community_roster.py
new file mode 100644
index 000000000..e01c441b8
--- /dev/null
+++ b/scripts/test_community_roster.py
@@ -0,0 +1,53 @@
+import importlib.util
+import pathlib
+import unittest
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+SPEC = importlib.util.spec_from_file_location("community_roster", ROOT / 
"scripts" / "community_roster.py")
+roster = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader
+SPEC.loader.exec_module(roster)
+
+
+class CommunityRosterTests(unittest.TestCase):
+    def fixture(self):
+        return (
+            {"committees": {"hugegraph": {"chair": {"chair": {"name": "Chair 
Person"}}, "roster": {"chair": {}, "zeta": {}}}}},
+            {"projects": {"hugegraph": {"owners": ["zeta", "chair"], 
"members": ["other", "zeta", "chair"]}}},
+            {"people": {"chair": {"name": "Chair Person"}, "zeta": {"name": 
"Alpha Owner"}, "other": {"name": "Beta Committer"}}},
+            {"schema_version": 1, "mappings": {}},
+        )
+
+    def test_build_roster_derives_roles_and_order(self):
+        candidate = roster.build_roster(*self.fixture())
+        self.assertEqual(["chair", "zeta"], [p["asf_id"] for p in 
candidate["roles"]["pmc"]])
+        self.assertEqual(["other"], [p["asf_id"] for p in 
candidate["roles"]["committers"]])
+        self.assertTrue(candidate["roles"]["pmc"][0]["chair"])
+
+    def test_build_roster_rejects_committee_ldap_drift(self):
+        committee, projects, people, mapping = self.fixture()
+        committee["committees"]["hugegraph"]["roster"].pop("zeta")
+        with self.assertRaisesRegex(roster.RosterError, "disagree"):
+            roster.build_roster(committee, projects, people, mapping)
+
+    def test_mapping_requires_unique_numeric_ids(self):
+        mapping = {"schema_version": 1, "mappings": {"one": {"login": "same", 
"user_id": 1}, "two": {"login": "other", "user_id": 1}}}
+        with self.assertRaisesRegex(roster.RosterError, "duplicate GitHub 
user_id"):
+            roster._validate_mapping(mapping, {"one", "two"})
+
+    def test_checked_in_bundle_validates(self):
+        self.assertEqual([], roster.validate_bundle(90))
+
+    def test_fetch_failure_preserves_last_good(self):
+        original, old_fetch = roster.ROSTER_PATH.read_bytes(), 
roster._fetch_json
+        try:
+            roster._fetch_json = lambda _url: (_ for _ in 
()).throw(OSError("network down"))
+            with self.assertRaises(OSError):
+                roster.refresh()
+        finally:
+            roster._fetch_json = old_fetch
+        self.assertEqual(original, roster.ROSTER_PATH.read_bytes())
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to