From ad58c997f7c14d1e1978469fcbdb03d143f25cec Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:06:34 +0100 Subject: [PATCH 01/16] chore: add Python deps and package skeleton for wall-of-honour --- .gitignore | 1 + requirements.txt | 3 +++ scripts/__init__.py | 0 scripts/tests/__init__.py | 0 4 files changed, 4 insertions(+) create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 scripts/__init__.py create mode 100644 scripts/tests/__init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d0b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a23a8c8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests==2.32.3 +pytest==8.3.3 +responses==0.25.3 diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 649238ead8d7c7ed1045c03d55471799af030160 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:11:53 +0100 Subject: [PATCH 02/16] feat: GitHub API session helper with retry and pagination --- scripts/github_api.py | 67 ++++++++++++++++++++++++++++++++ scripts/tests/test_github_api.py | 55 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 scripts/github_api.py create mode 100644 scripts/tests/test_github_api.py diff --git a/scripts/github_api.py b/scripts/github_api.py new file mode 100644 index 0000000..1c20a51 --- /dev/null +++ b/scripts/github_api.py @@ -0,0 +1,67 @@ +# scripts/github_api.py +"""Thin GitHub REST API helpers: session with retry, paginated GET.""" +from __future__ import annotations + +import re +from typing import Any + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +GITHUB_API = "https://api.github.com" +_LINK_NEXT_RE = re.compile(r'<([^>]+)>;\s*rel="next"') + + +def make_session(token: str) -> requests.Session: + """Build a requests Session with retry policy and auth header.""" + session = requests.Session() + session.headers.update({ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }) + retry = Retry( + total=3, + backoff_factor=1.0, + status_forcelist=(500, 502, 503, 504), + allowed_methods=("GET",), + raise_on_status=False, + ) + adapter = HTTPAdapter(max_retries=retry) + session.mount("https://", adapter) + session.mount("http://", adapter) + return session + + +def _parse_next(link_header: str | None) -> str | None: + """Return next-page URL from a Link header, or None.""" + if not link_header: + return None + match = _LINK_NEXT_RE.search(link_header) + return match.group(1) if match else None + + +def paginated_get(session: requests.Session, url: str, params: dict | None = None) -> list[Any]: + """GET a paginated GitHub endpoint, following Link rel=next, returning the flat list.""" + items: list[Any] = [] + next_url: str | None = url + next_params = params + while next_url: + response = session.get(next_url, params=next_params, timeout=30) + response.raise_for_status() + page = response.json() + if isinstance(page, list): + items.extend(page) + else: + items.append(page) + next_url = _parse_next(response.headers.get("Link")) + next_params = None + return items + + +def get_one(session: requests.Session, url: str) -> dict[str, Any]: + """Single-object GET (no pagination). Returns parsed JSON dict.""" + response = session.get(url, timeout=30) + response.raise_for_status() + return response.json() diff --git a/scripts/tests/test_github_api.py b/scripts/tests/test_github_api.py new file mode 100644 index 0000000..46df042 --- /dev/null +++ b/scripts/tests/test_github_api.py @@ -0,0 +1,55 @@ +# scripts/tests/test_github_api.py +import responses +from scripts.github_api import paginated_get, make_session + + +@responses.activate +def test_paginated_get_single_page(): + responses.add( + responses.GET, + "https://api.github.com/orgs/BHoM/repos", + json=[{"name": "repo1"}, {"name": "repo2"}], + status=200, + ) + session = make_session("fake-token") + result = paginated_get(session, "https://api.github.com/orgs/BHoM/repos") + assert result == [{"name": "repo1"}, {"name": "repo2"}] + + +@responses.activate +def test_paginated_get_multiple_pages(): + responses.add( + responses.GET, + "https://api.github.com/orgs/BHoM/repos", + json=[{"name": "repo1"}], + status=200, + headers={"Link": '; rel="next"'}, + ) + responses.add( + responses.GET, + "https://api.github.com/orgs/BHoM/repos?page=2", + json=[{"name": "repo2"}], + status=200, + ) + session = make_session("fake-token") + result = paginated_get(session, "https://api.github.com/orgs/BHoM/repos") + assert result == [{"name": "repo1"}, {"name": "repo2"}] + + +@responses.activate +def test_paginated_get_retries_on_500(): + responses.add(responses.GET, "https://api.github.com/test", status=500) + responses.add(responses.GET, "https://api.github.com/test", json=[{"x": 1}], status=200) + session = make_session("fake-token") + result = paginated_get(session, "https://api.github.com/test") + assert result == [{"x": 1}] + + +@responses.activate +def test_paginated_get_raises_after_3_failures(): + for _ in range(4): + responses.add(responses.GET, "https://api.github.com/test", status=500) + session = make_session("fake-token") + import pytest + with pytest.raises(Exception): + paginated_get(session, "https://api.github.com/test") From 6ee8fef8206f399e89a0c6425a390d2c7ea53658 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:15:23 +0100 Subject: [PATCH 03/16] feat: list_org_repos filters archived and .github --- scripts/generate_wall_of_honour.py | 19 +++++++++++++++++++ scripts/tests/test_generate.py | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 scripts/generate_wall_of_honour.py create mode 100644 scripts/tests/test_generate.py diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py new file mode 100644 index 0000000..254f7ea --- /dev/null +++ b/scripts/generate_wall_of_honour.py @@ -0,0 +1,19 @@ +"""Generate BHoM Wall of Honour and splice into profile/README.md.""" +from __future__ import annotations + +import os +import sys +from datetime import date +from pathlib import Path +from typing import Any + +import requests + +from scripts.github_api import GITHUB_API, get_one, make_session, paginated_get + + +def list_org_repos(session: requests.Session, org: str) -> list[dict[str, Any]]: + """List non-archived repos in `org`, excluding the org's `.github` repo.""" + url = f"{GITHUB_API}/orgs/{org}/repos" + all_repos = paginated_get(session, url, params={"per_page": 100, "type": "all"}) + return [r for r in all_repos if not r["archived"] and r["name"] != ".github"] diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py new file mode 100644 index 0000000..851778d --- /dev/null +++ b/scripts/tests/test_generate.py @@ -0,0 +1,22 @@ +import responses +from scripts.generate_wall_of_honour import list_org_repos +from scripts.github_api import make_session + + +@responses.activate +def test_list_org_repos_filters_archived_and_dot_github(): + responses.add( + responses.GET, + "https://api.github.com/orgs/BHoM/repos", + json=[ + {"name": "BHoM", "archived": False}, + {"name": "old_repo", "archived": True}, + {"name": ".github", "archived": False}, + {"name": "BHoM_Engine", "archived": False}, + ], + status=200, + ) + session = make_session("fake-token") + result = list_org_repos(session, "BHoM") + names = [r["name"] for r in result] + assert names == ["BHoM", "BHoM_Engine"] From 40366b1a1620e6f34c65cf49aedf5bcf303e8738 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:20:15 +0100 Subject: [PATCH 04/16] feat: fetch_repo_contributors with 204-safe pagination --- scripts/generate_wall_of_honour.py | 6 ++++++ scripts/github_api.py | 6 +++++- scripts/tests/test_generate.py | 32 +++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index 254f7ea..a7d28b1 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -17,3 +17,9 @@ def list_org_repos(session: requests.Session, org: str) -> list[dict[str, Any]]: url = f"{GITHUB_API}/orgs/{org}/repos" all_repos = paginated_get(session, url, params={"per_page": 100, "type": "all"}) return [r for r in all_repos if not r["archived"] and r["name"] != ".github"] + + +def fetch_repo_contributors(session: requests.Session, org: str, repo: str) -> list[dict[str, Any]]: + """Fetch contributors for a single repo. Returns [] for an empty repo (HTTP 204).""" + url = f"{GITHUB_API}/repos/{org}/{repo}/contributors" + return paginated_get(session, url, params={"per_page": 100, "anon": "false"}) diff --git a/scripts/github_api.py b/scripts/github_api.py index 1c20a51..0da8a86 100644 --- a/scripts/github_api.py +++ b/scripts/github_api.py @@ -43,13 +43,17 @@ def _parse_next(link_header: str | None) -> str | None: def paginated_get(session: requests.Session, url: str, params: dict | None = None) -> list[Any]: - """GET a paginated GitHub endpoint, following Link rel=next, returning the flat list.""" + """GET a paginated GitHub endpoint, following Link rel=next, returning the flat list. + + Returns [] on 204 No Content (e.g. empty repo contributors).""" items: list[Any] = [] next_url: str | None = url next_params = params while next_url: response = session.get(next_url, params=next_params, timeout=30) response.raise_for_status() + if response.status_code == 204 or not response.content: + return items page = response.json() if isinstance(page, list): items.extend(page) diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index 851778d..40afb5f 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -1,5 +1,5 @@ import responses -from scripts.generate_wall_of_honour import list_org_repos +from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos from scripts.github_api import make_session @@ -20,3 +20,33 @@ def test_list_org_repos_filters_archived_and_dot_github(): result = list_org_repos(session, "BHoM") names = [r["name"] for r in result] assert names == ["BHoM", "BHoM_Engine"] + + +@responses.activate +def test_fetch_repo_contributors_returns_list(): + responses.add( + responses.GET, + "https://api.github.com/repos/BHoM/BHoM_Engine/contributors", + json=[ + {"login": "alice", "type": "User", "avatar_url": "https://x/a", "contributions": 42}, + {"login": "dependabot[bot]", "type": "Bot", "avatar_url": "https://x/d", "contributions": 5}, + ], + status=200, + ) + session = make_session("fake-token") + result = fetch_repo_contributors(session, "BHoM", "BHoM_Engine") + assert len(result) == 2 + assert result[0]["login"] == "alice" + + +@responses.activate +def test_fetch_repo_contributors_handles_204_empty(): + # GitHub returns 204 for an empty repo + responses.add( + responses.GET, + "https://api.github.com/repos/BHoM/empty_repo/contributors", + status=204, + ) + session = make_session("fake-token") + result = fetch_repo_contributors(session, "BHoM", "empty_repo") + assert result == [] From 760701e07b3476a1debdf75ef49f9737d7a3be30 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:23:05 +0100 Subject: [PATCH 05/16] feat: filter_bots excludes Bot-type and [bot]-suffix accounts --- scripts/generate_wall_of_honour.py | 8 ++++++++ scripts/tests/test_generate.py | 27 ++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index a7d28b1..241e9fd 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -23,3 +23,11 @@ def fetch_repo_contributors(session: requests.Session, org: str, repo: str) -> l """Fetch contributors for a single repo. Returns [] for an empty repo (HTTP 204).""" url = f"{GITHUB_API}/repos/{org}/{repo}/contributors" return paginated_get(session, url, params={"per_page": 100, "anon": "false"}) + + +def filter_bots(contributors: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Remove bot accounts (type=Bot or login ending in [bot]).""" + return [ + c for c in contributors + if c.get("type") != "Bot" and not c["login"].endswith("[bot]") + ] diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index 40afb5f..f419934 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -1,5 +1,5 @@ import responses -from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos +from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos, filter_bots from scripts.github_api import make_session @@ -50,3 +50,28 @@ def test_fetch_repo_contributors_handles_204_empty(): session = make_session("fake-token") result = fetch_repo_contributors(session, "BHoM", "empty_repo") assert result == [] + + +def test_filter_bots_excludes_type_bot(): + raw = [ + {"login": "alice", "type": "User"}, + {"login": "dependabot[bot]", "type": "Bot"}, + ] + assert filter_bots(raw) == [{"login": "alice", "type": "User"}] + + +def test_filter_bots_excludes_bot_suffix_login_even_if_type_user(): + # Some old bot accounts have type=User but [bot] suffix + raw = [ + {"login": "alice", "type": "User"}, + {"login": "old-tool[bot]", "type": "User"}, + ] + assert filter_bots(raw) == [{"login": "alice", "type": "User"}] + + +def test_filter_bots_keeps_all_humans(): + raw = [ + {"login": "alice", "type": "User"}, + {"login": "bob", "type": "User"}, + ] + assert filter_bots(raw) == raw From 5b8baacf7ebd6558807cdf5b10de21540726068e Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:24:50 +0100 Subject: [PATCH 06/16] feat: aggregate_contributors dedupes and sums across repos --- scripts/generate_wall_of_honour.py | 19 +++++++++++++++++++ scripts/tests/test_generate.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index 241e9fd..f93cf21 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -31,3 +31,22 @@ def filter_bots(contributors: list[dict[str, Any]]) -> list[dict[str, Any]]: c for c in contributors if c.get("type") != "Bot" and not c["login"].endswith("[bot]") ] + + +def aggregate_contributors( + per_repo_lists: list[list[dict[str, Any]]], +) -> dict[str, dict[str, Any]]: + """De-duplicate contributors by login, summing contributions across repos.""" + aggregated: dict[str, dict[str, Any]] = {} + for contributors in per_repo_lists: + for c in contributors: + login = c["login"] + existing = aggregated.get(login) + if existing is None: + aggregated[login] = { + "avatar_url": c["avatar_url"], + "contributions": c["contributions"], + } + else: + existing["contributions"] += c["contributions"] + return aggregated diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index f419934..ea1be4f 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -1,5 +1,5 @@ import responses -from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos, filter_bots +from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos, filter_bots, aggregate_contributors from scripts.github_api import make_session @@ -75,3 +75,31 @@ def test_filter_bots_keeps_all_humans(): {"login": "bob", "type": "User"}, ] assert filter_bots(raw) == raw + + +def test_aggregate_single_repo(): + per_repo = [[ + {"login": "alice", "avatar_url": "https://x/a", "contributions": 10}, + ]] + result = aggregate_contributors(per_repo) + assert result == { + "alice": {"avatar_url": "https://x/a", "contributions": 10} + } + + +def test_aggregate_dedupes_and_sums_across_repos(): + per_repo = [ + [{"login": "alice", "avatar_url": "https://x/a", "contributions": 10}], + [{"login": "alice", "avatar_url": "https://x/a", "contributions": 5}], + [{"login": "bob", "avatar_url": "https://x/b", "contributions": 3}], + ] + result = aggregate_contributors(per_repo) + assert result == { + "alice": {"avatar_url": "https://x/a", "contributions": 15}, + "bob": {"avatar_url": "https://x/b", "contributions": 3}, + } + + +def test_aggregate_empty_input(): + assert aggregate_contributors([]) == {} + assert aggregate_contributors([[]]) == {} From b05226d906ba6aa4124d8138dbfb8f4bad551a61 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:26:45 +0100 Subject: [PATCH 07/16] feat: enrich_display_names with blank-name login fallback --- scripts/generate_wall_of_honour.py | 12 ++++++++ scripts/tests/test_generate.py | 44 +++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index f93cf21..f83c3cd 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -50,3 +50,15 @@ def aggregate_contributors( else: existing["contributions"] += c["contributions"] return aggregated + + +def enrich_display_names( + session: requests.Session, + contributors: dict[str, dict[str, Any]], +) -> dict[str, dict[str, Any]]: + """Fetch each contributor's display name; fall back to login when blank.""" + for login, info in contributors.items(): + user = get_one(session, f"{GITHUB_API}/users/{login}") + name = (user.get("name") or "").strip() + info["name"] = name if name else login + return contributors diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index ea1be4f..7f439f6 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -1,5 +1,5 @@ import responses -from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos, filter_bots, aggregate_contributors +from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos, filter_bots, aggregate_contributors, enrich_display_names from scripts.github_api import make_session @@ -103,3 +103,45 @@ def test_aggregate_dedupes_and_sums_across_repos(): def test_aggregate_empty_input(): assert aggregate_contributors([]) == {} assert aggregate_contributors([[]]) == {} + + +@responses.activate +def test_enrich_display_names_uses_real_name(): + responses.add( + responses.GET, + "https://api.github.com/users/alice", + json={"login": "alice", "name": "Alice Example"}, + status=200, + ) + session = make_session("fake-token") + contributors = {"alice": {"avatar_url": "https://x/a", "contributions": 10}} + result = enrich_display_names(session, contributors) + assert result["alice"]["name"] == "Alice Example" + + +@responses.activate +def test_enrich_display_names_falls_back_to_login_when_null(): + responses.add( + responses.GET, + "https://api.github.com/users/bob", + json={"login": "bob", "name": None}, + status=200, + ) + session = make_session("fake-token") + contributors = {"bob": {"avatar_url": "https://x/b", "contributions": 5}} + result = enrich_display_names(session, contributors) + assert result["bob"]["name"] == "bob" + + +@responses.activate +def test_enrich_display_names_falls_back_to_login_when_empty_string(): + responses.add( + responses.GET, + "https://api.github.com/users/carol", + json={"login": "carol", "name": " "}, # whitespace only + status=200, + ) + session = make_session("fake-token") + contributors = {"carol": {"avatar_url": "https://x/c", "contributions": 1}} + result = enrich_display_names(session, contributors) + assert result["carol"]["name"] == "carol" From ce412d04ed6b388646ecafceb3015d5fec0f49d9 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:31:37 +0100 Subject: [PATCH 08/16] feat: render_wall produces 7-col grid sorted alphabetically --- scripts/generate_wall_of_honour.py | 51 ++++++++++++++++++++++++++++++ scripts/tests/test_generate.py | 45 ++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index f83c3cd..f6ce15e 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -3,6 +3,7 @@ import os import sys +import unicodedata from datetime import date from pathlib import Path from typing import Any @@ -62,3 +63,53 @@ def enrich_display_names( name = (user.get("name") or "").strip() info["name"] = name if name else login return contributors + + +GRID_COLS = 7 +AVATAR_SIZE = 100 + + +def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> str: + """Render the markdown block (markers included) for the wall.""" + if not contributors: + return ( + "\n" + "## Wall of Honour\n\n" + "Wall coming soon — no contributors yet.\n\n" + f"_Last updated: {last_updated}_\n" + "" + ) + + sorted_logins = sorted( + contributors.keys(), + key=lambda login: unicodedata.normalize("NFD", contributors[login]["name"].casefold()), + ) + + rows: list[str] = [] + for row_start in range(0, len(sorted_logins), GRID_COLS): + row_logins = sorted_logins[row_start:row_start + GRID_COLS] + cells = [_render_cell(login, contributors[login]) for login in row_logins] + rows.append(" \n" + "\n".join(cells) + "\n ") + + return ( + "\n" + "## Wall of Honour\n\n" + f"Thank you to the **{len(contributors)} people** who have contributed to BHoM.\n\n" + "\n" + + "\n".join(rows) + + "\n
\n\n" + f"_Last updated: {last_updated}_\n" + "" + ) + + +def _render_cell(login: str, info: dict[str, Any]) -> str: + name = info["name"] + return ( + " \n" + f" " + f"
" + f"{name}
\n" + " " + ) diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index 7f439f6..a2ed86e 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -145,3 +145,48 @@ def test_enrich_display_names_falls_back_to_login_when_empty_string(): contributors = {"carol": {"avatar_url": "https://x/c", "contributions": 1}} result = enrich_display_names(session, contributors) assert result["carol"]["name"] == "carol" + + +from scripts.generate_wall_of_honour import render_wall + + +def test_render_wall_empty(): + md = render_wall({}, "2026-06-08") + assert "Wall coming soon" in md or "0 people" in md + assert "" in md + assert "" in md + + +def test_render_wall_single_row(): + contributors = { + "alice": {"avatar_url": "https://x/a", "contributions": 10, "name": "Alice Example"}, + "bob": {"avatar_url": "https://x/b", "contributions": 5, "name": "Bob Sample"}, + } + md = render_wall(contributors, "2026-06-08") + assert "2 people" in md + assert md.count("") == 1 # 2 cells fit in 1 row of 7 + assert "Alice Example" in md + assert "Bob Sample" in md + # Alphabetical: Alice before Bob + assert md.index("Alice Example") < md.index("Bob Sample") + + +def test_render_wall_two_rows_with_remainder(): + contributors = {f"user{i}": {"avatar_url": f"https://x/{i}", "contributions": 1, "name": f"User {i}"} for i in range(8)} + md = render_wall(contributors, "2026-06-08") + assert md.count("") == 2 + assert md.count(" Date: Mon, 8 Jun 2026 11:34:20 +0100 Subject: [PATCH 09/16] feat: splice_into_readme with marker bootstrap and idempotency --- scripts/generate_wall_of_honour.py | 39 +++++++++++++++++++++ scripts/tests/test_generate.py | 56 ++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index f6ce15e..b796631 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import re import sys import unicodedata from datetime import date @@ -113,3 +114,41 @@ def _render_cell(login: str, info: dict[str, Any]) -> str: f"{name}\n" " " ) + + +WALL_MARKER_START = "" +WALL_MARKER_END = "" +_WALL_BLOCK_RE = re.compile( + re.escape(WALL_MARKER_START) + r".*?" + re.escape(WALL_MARKER_END), + re.DOTALL, +) + +BOOTSTRAP_TEMPLATE = """# BHoM + +The BHoM (Buildings and Habitats object Model) is an open-source AEC framework. + +{wall} +""" + + +def splice_into_readme(readme_path: str, new_wall: str) -> bool: + """Replace wall block in README with `new_wall`. Returns True if file changed.""" + path = Path(readme_path) + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + new_content = BOOTSTRAP_TEMPLATE.format(wall=new_wall) + path.write_text(new_content, encoding="utf-8") + return True + + current = path.read_text(encoding="utf-8") + if _WALL_BLOCK_RE.search(current): + updated = _WALL_BLOCK_RE.sub(lambda _: new_wall, current) + else: + # No markers — append the wall after existing content + sep = "" if current.endswith("\n") else "\n" + updated = current + sep + "\n" + new_wall + "\n" + + if updated == current: + return False + path.write_text(updated, encoding="utf-8") + return True diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index a2ed86e..b3d050b 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -1,3 +1,6 @@ +import tempfile +from pathlib import Path + import responses from scripts.generate_wall_of_honour import fetch_repo_contributors, list_org_repos, filter_bots, aggregate_contributors, enrich_display_names from scripts.github_api import make_session @@ -190,3 +193,56 @@ def test_render_wall_sort_is_case_insensitive_and_unicode(): for name in ["alex", "Anna", "Ångström", "Zoe"]: names_in_order.append(md.index(name)) assert names_in_order == sorted(names_in_order) + + +from scripts.generate_wall_of_honour import splice_into_readme + + +def test_splice_preserves_surrounding_content(): + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + readme.write_text( + "# BHoM\n\nIntro paragraph.\n\n" + "\nOLD WALL\n\n\n" + "Footer content.\n", + encoding="utf-8", + ) + new_wall = "\nNEW WALL\n" + changed = splice_into_readme(str(readme), new_wall) + assert changed is True + content = readme.read_text(encoding="utf-8") + assert "Intro paragraph." in content + assert "Footer content." in content + assert "NEW WALL" in content + assert "OLD WALL" not in content + + +def test_splice_bootstraps_when_markers_absent(): + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + readme.write_text("# BHoM\n\nIntro only.\n", encoding="utf-8") + new_wall = "\nNEW WALL\n" + changed = splice_into_readme(str(readme), new_wall) + assert changed is True + content = readme.read_text(encoding="utf-8") + assert "Intro only." in content + assert "NEW WALL" in content + + +def test_splice_creates_file_when_missing(): + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + new_wall = "\nFRESH\n" + changed = splice_into_readme(str(readme), new_wall) + assert changed is True + assert readme.exists() + assert "FRESH" in readme.read_text(encoding="utf-8") + + +def test_splice_idempotent_no_change(): + with tempfile.TemporaryDirectory() as tmp: + readme = Path(tmp) / "README.md" + block = "\nSAME\n" + readme.write_text(f"# BHoM\n\n{block}\n", encoding="utf-8") + changed = splice_into_readme(str(readme), block) + assert changed is False From 4765fa85dddabdb1905f514a61efb02ba6aea002 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:36:38 +0100 Subject: [PATCH 10/16] feat: main orchestrator wires the pipeline together --- scripts/generate_wall_of_honour.py | 41 ++++++++++++++++++++++++++++ scripts/tests/test_generate.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index b796631..7272919 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -152,3 +152,44 @@ def splice_into_readme(readme_path: str, new_wall: str) -> bool: return False path.write_text(updated, encoding="utf-8") return True + + +DEFAULT_README_PATH = "profile/README.md" + + +def main() -> int: + """Entrypoint. Returns exit code.""" + token = os.environ["GITHUB_TOKEN"] + org = os.environ.get("GITHUB_ORG", "BHoM") + readme_path = os.environ.get("README_PATH", DEFAULT_README_PATH) + + session = make_session(token) + + print(f"Listing repos in {org}...") + repos = list_org_repos(session, org) + print(f"Found {len(repos)} non-archived repos.") + + per_repo: list[list[dict[str, Any]]] = [] + for repo in repos: + contributors = fetch_repo_contributors(session, org, repo["name"]) + human = filter_bots(contributors) + print(f" {repo['name']}: {len(human)} human contributors") + per_repo.append(human) + + aggregated = aggregate_contributors(per_repo) + print(f"Aggregated to {len(aggregated)} unique contributors. Enriching display names...") + + enriched = enrich_display_names(session, aggregated) + today = date.today().isoformat() + wall_md = render_wall(enriched, today) + + changed = splice_into_readme(readme_path, wall_md) + if changed: + print("README updated.") + else: + print("README unchanged.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index b3d050b..5bd22b6 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -246,3 +246,47 @@ def test_splice_idempotent_no_change(): readme.write_text(f"# BHoM\n\n{block}\n", encoding="utf-8") changed = splice_into_readme(str(readme), block) assert changed is False + + +from scripts.generate_wall_of_honour import main + + +@responses.activate +def test_main_end_to_end(monkeypatch, tmp_path): + # Mock org repos + responses.add( + responses.GET, + "https://api.github.com/orgs/BHoM/repos", + json=[{"name": "BHoM_Engine", "archived": False}], + status=200, + ) + # Mock contributors + responses.add( + responses.GET, + "https://api.github.com/repos/BHoM/BHoM_Engine/contributors", + json=[ + {"login": "alice", "type": "User", "avatar_url": "https://x/a", "contributions": 10}, + {"login": "dependabot[bot]", "type": "Bot", "avatar_url": "https://x/d", "contributions": 3}, + ], + status=200, + ) + # Mock user details + responses.add( + responses.GET, + "https://api.github.com/users/alice", + json={"login": "alice", "name": "Alice Example"}, + status=200, + ) + + readme = tmp_path / "profile" / "README.md" + monkeypatch.setenv("GITHUB_TOKEN", "fake-token") + monkeypatch.setenv("GITHUB_ORG", "BHoM") + monkeypatch.setenv("README_PATH", str(readme)) + + exit_code = main() + assert exit_code == 0 + assert readme.exists() + content = readme.read_text(encoding="utf-8") + assert "Alice Example" in content + assert "1 people" in content # Only alice; bot filtered + assert "dependabot" not in content From 2bd3397efdea6268b09228565bdcb64858632adf Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:37:57 +0100 Subject: [PATCH 11/16] ci: pytest workflow for scripts/ --- .github/workflows/test-scripts.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test-scripts.yml diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml new file mode 100644 index 0000000..0fbe034 --- /dev/null +++ b/.github/workflows/test-scripts.yml @@ -0,0 +1,25 @@ +name: Test scripts + +on: + push: + paths: + - 'scripts/**' + - 'requirements.txt' + - '.github/workflows/test-scripts.yml' + pull_request: + paths: + - 'scripts/**' + - 'requirements.txt' + - '.github/workflows/test-scripts.yml' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + - run: pip install -r requirements.txt + - run: pytest scripts/tests -v From f833f40e4ef4610ca0ed03bf73a26cd081200fd3 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:38:37 +0100 Subject: [PATCH 12/16] ci: monthly wall-of-honour generation workflow --- .github/workflows/wall-of-honour.yml | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/wall-of-honour.yml diff --git a/.github/workflows/wall-of-honour.yml b/.github/workflows/wall-of-honour.yml new file mode 100644 index 0000000..9454deb --- /dev/null +++ b/.github/workflows/wall-of-honour.yml @@ -0,0 +1,40 @@ +name: Generate Wall of Honour + +on: + schedule: + - cron: '0 6 1 * *' # 06:00 UTC on the 1st of every month + workflow_dispatch: {} + +permissions: + contents: write + +concurrency: + group: wall-of-honour + cancel-in-progress: false + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + - run: pip install -r requirements.txt + - name: Generate + run: python -m scripts.generate_wall_of_honour + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_ORG: BHoM + - name: Commit if changed + run: | + if [[ -n "$(git status --porcelain profile/README.md)" ]]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add profile/README.md + git commit -m "chore(wall): refresh wall of honour" + git push + else + echo "No changes — wall is already current." + fi From 71ac7afaddadc867ffd4567a1384a076c16d3613 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:39:37 +0100 Subject: [PATCH 13/16] docs: add wall-of-honour markers to profile README --- profile/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/profile/README.md b/profile/README.md index c3918b5..d81ef9d 100644 --- a/profile/README.md +++ b/profile/README.md @@ -20,3 +20,10 @@ We also have a space for questions and discussions: https://github.com/orgs/BHoM Please let us know how can we improve it! Please raise GitHub Issues in the [Documentation repo](https://github.com/BHoM/documentation/issues) or start a [Discussion](https://github.com/orgs/BHoM/discussions). + +## Wall of Honour + +Wall coming soon — first refresh runs on the 1st of next month. + +_Last updated: never_ + From 04b6521a258984e9e345f0cffb8ff308dbe2e112 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 11:49:34 +0100 Subject: [PATCH 14/16] style: replace em dashes and tone down phrasing --- .github/workflows/wall-of-honour.yml | 2 +- profile/README.md | 4 ++-- scripts/generate_wall_of_honour.py | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/wall-of-honour.yml b/.github/workflows/wall-of-honour.yml index 9454deb..43668e0 100644 --- a/.github/workflows/wall-of-honour.yml +++ b/.github/workflows/wall-of-honour.yml @@ -36,5 +36,5 @@ jobs: git commit -m "chore(wall): refresh wall of honour" git push else - echo "No changes — wall is already current." + echo "No changes; wall is already current." fi diff --git a/profile/README.md b/profile/README.md index d81ef9d..b0d36c8 100644 --- a/profile/README.md +++ b/profile/README.md @@ -23,7 +23,7 @@ Please let us know how can we improve it! Please raise GitHub Issues in the [Doc ## Wall of Honour -Wall coming soon — first refresh runs on the 1st of next month. +Wall coming soon. First refresh runs on the 1st of next month. -_Last updated: never_ +Last updated: never diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index 7272919..c1b24ee 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -76,8 +76,8 @@ def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> s return ( "\n" "## Wall of Honour\n\n" - "Wall coming soon — no contributors yet.\n\n" - f"_Last updated: {last_updated}_\n" + "Wall coming soon. No contributors yet.\n\n" + f"Last updated: {last_updated}\n" "" ) @@ -95,11 +95,11 @@ def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> s return ( "\n" "## Wall of Honour\n\n" - f"Thank you to the **{len(contributors)} people** who have contributed to BHoM.\n\n" + f"{len(contributors)} people have contributed to BHoM.\n\n" "\n" + "\n".join(rows) + "\n
\n\n" - f"_Last updated: {last_updated}_\n" + f"Last updated: {last_updated}\n" "" ) @@ -144,7 +144,7 @@ def splice_into_readme(readme_path: str, new_wall: str) -> bool: if _WALL_BLOCK_RE.search(current): updated = _WALL_BLOCK_RE.sub(lambda _: new_wall, current) else: - # No markers — append the wall after existing content + # No markers found; append the wall after existing content sep = "" if current.endswith("\n") else "\n" updated = current + sep + "\n" + new_wall + "\n" From e6006d6ab2c504c70e9d09856a6601e613daedb4 Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 12:00:33 +0100 Subject: [PATCH 15/16] feat: denylist BHoMBot and BuroHappold1 from wall Adds DENYLISTED_LOGINS frozenset and filter_denylist function applied after filter_bots in main(). Catches non-human accounts that don't have type=Bot or a [bot] login suffix: - BHoMBot: BHoM org automation bot - BuroHappold1: shared "Administrator" account Wall regenerated against real BHoM org: 153 -> 151 unique contributors. --- profile/README.md | 558 +++++++++++++++++++++++++++-- scripts/generate_wall_of_honour.py | 16 +- scripts/tests/test_generate.py | 27 ++ 3 files changed, 571 insertions(+), 30 deletions(-) diff --git a/profile/README.md b/profile/README.md index b0d36c8..9e48593 100644 --- a/profile/README.md +++ b/profile/README.md @@ -1,29 +1,529 @@ -## Welcome to the BHoM! - -The BHoM (Buildings and Habitats object Model) is a collaborative project for the AEC (Architecture, Engineering and Construction) industry that aims to improve how we design the Built Environment and human Habitats. - -BHoM is a collection of schemas, functionality and interoperability infrastructure, developed with the aim of improving the way we work and communicate in AEC. BHoM is crafted as transdisciplinary, software-agnostic and office/region/country-invariant. BHoM is powered by an active, diverse community and a wide range of contributors. -The whole BHoM project uses an open-source model for project architecture, co-creation and planning. Please explore, experiment and contribute to both the source code and the documentation! Open-source means we can feed off and pool our disparate knowledge, experience and expertise towards a common goal – better design and a better Built Environment and Habitats for everyone. - -#### Technical philosophy of the BHoM - -The BHoM integrates concepts from different languages, coding environments and platforms. It's designed to be compatible with both visual flow-based programming (e.g. [Grasshopper](https://simplyrhino.co.uk/3d-modelling-software/grasshopper), Excel) and programming (in particular, C#). This is to integrate well in existing workflows of professionals in the AEC industry, regardless of their level of computational proficiency. BHoM aims to combine the work of professional programmers with the valuable efforts of computational designers/engineers/architects and scripters, all in the same ecosystem. - -### Contributing - -Please see https://bhom.xyz/documentation/Contributing/! 🚀 - -### Documentation and Q&A - -All documentation can be found in https://bhom.xyz/documentation/! -We also have a space for questions and discussions: https://github.com/orgs/BHoM/discussions. - -Please let us know how can we improve it! Please raise GitHub Issues in the [Documentation repo](https://github.com/BHoM/documentation/issues) or start a [Discussion](https://github.com/orgs/BHoM/discussions). - - -## Wall of Honour - -Wall coming soon. First refresh runs on the 1st of next month. - -Last updated: never - +## Welcome to the BHoM! + +The BHoM (Buildings and Habitats object Model) is a collaborative project for the AEC (Architecture, Engineering and Construction) industry that aims to improve how we design the Built Environment and human Habitats. + +BHoM is a collection of schemas, functionality and interoperability infrastructure, developed with the aim of improving the way we work and communicate in AEC. BHoM is crafted as transdisciplinary, software-agnostic and office/region/country-invariant. BHoM is powered by an active, diverse community and a wide range of contributors. +The whole BHoM project uses an open-source model for project architecture, co-creation and planning. Please explore, experiment and contribute to both the source code and the documentation! Open-source means we can feed off and pool our disparate knowledge, experience and expertise towards a common goal – better design and a better Built Environment and Habitats for everyone. + +#### Technical philosophy of the BHoM + +The BHoM integrates concepts from different languages, coding environments and platforms. It's designed to be compatible with both visual flow-based programming (e.g. [Grasshopper](https://simplyrhino.co.uk/3d-modelling-software/grasshopper), Excel) and programming (in particular, C#). This is to integrate well in existing workflows of professionals in the AEC industry, regardless of their level of computational proficiency. BHoM aims to combine the work of professional programmers with the valuable efforts of computational designers/engineers/architects and scripters, all in the same ecosystem. + +### Contributing + +Please see https://bhom.xyz/documentation/Contributing/! 🚀 + +### Documentation and Q&A + +All documentation can be found in https://bhom.xyz/documentation/! +We also have a space for questions and discussions: https://github.com/orgs/BHoM/discussions. + +Please let us know how can we improve it! Please raise GitHub Issues in the [Documentation repo](https://github.com/BHoM/documentation/issues) or start a [Discussion](https://github.com/orgs/BHoM/discussions). + + +## Wall of Honour + +151 people have contributed to BHoM. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
AaronWagner
+
+
Adam Sobieski
+
+
Agnes Lindblom
+
+
Ahmad Sater
+
+
Ahmed Elnagar
+
+
Al Fisher
+
+
Alaa Alfakara
+
+
Albin Bergh
+
+
Alessio Lombardi
+
+
Alex Radne
+
+
Alexis Santella
+
+
Andreas Bak
+
+
Andrew Engh
+
+
Anna Stigenberg
+
+
Anthony Morehouse
+
+
Antigoni Karaiskou
+
+
Arnaud Declercq
+
+
Arne Martensen
+
+
Arvid Sigurdsson
+
+
Aurora Jensen
+
+
Bing Wang
+
+
Carmen Fan
+
+
Charlie Brooker
+
+
Chris Self
+
+
Christopher Short
+
+
Clemens Preisinger
+
+
Conor Hubert
+
+
Courtney Boulter
+
+
Daniel Imms
+
+
Daniel Jonsson
+
+
DarienC
+
+
Deesha Tech
+
+
Desai Wang
+
+
DiellzaElshani
+
+
Dimitrie Stefanescu
+
+
Djamila Mamedova
+
+
Dmitry Zelenkin
+
+
Eddy Dalton
+
+
Eduardo Pignatelli
+
+
Einar Persson
+
+
Elina Usmanova
+
+
Ellie Adebowale
+
+
Emil Svedjer
+
+
Emma Sander
+
+
EnricoAntolini
+
+
Erida Bendo
+
+
Erik Forsberg
+
+
Erik Narhi
+
+
Evangelos Pantazis
+
+
felipebandeiramm
+
+
Felix Mallinder
+
+
Fraser Greenroyd
+
+
Frédéric Bourgeon
+
+
Geir Soderin
+
+
Gideon Tamir
+
+
Giorgio Carlo Roberto Albieri
+
+
Habiba Moubarak
+
+
Haiping
+
+
Hedvig Kjellander
+
+
Isak Larborn
+
+
Isak Näslund
+
+
izzy lys
+
+
Jack Trainor
+
+
Jaco Bekker
+
+
James Ramsden
+
+
Johanna Isaksson
+
+
Johanna Olin
+
+
John Sloane
+
+
Jonathan Nillius
+
+
Jose Castro Aguilar
+
+
JS
+
+
jukkasus
+
+
Junjie
+
+
Kai Chelliah
+
+
Kalle Edstroem
+
+
Kalle Thorsager
+
+
Kalle Thorsager
+
+
Karolina Prusicka
+
+
Kartheek Mylavarapu
+
+
Kasia Wodzisz
+
+
Kayleigh Houde
+
+
Kenton Phillips
+
+
Kenton Phillips
+
+
Konrad Stolarski
+
+
Lina Eriksson
+
+
linhnam-nguyen
+
+
Liv Andersson
+
+
livand
+
+
Ludvig Borgenstam
+
+
Luis E. Fraguada
+
+
Madelene Sandmer
+
+
Maja Lindroth
+
+
Manuel Chafart
+
+
Maria Glimaker
+
+
Maria Roman
+
+
Maria Tjader
+
+
Mark Cunliffe
+
+
Martin B. Henriksen
+
+
Marvin Suen
+
+
Maryanne Wachter
+
+
Maryanne Wachter
+
+
Matilda Svensson
+
+
Matteo Cominetti
+
+
Matthew Swaidan
+
+
Maya Arora-Jonsson
+
+
Meinrad Recheis
+
+
Michael Hoehn
+
+
Michal Dengusiak
+
+
Michal Pekacki
+
+
MILeach
+
+
Milo Gillot
+
+
Mish Nuh
+
+
Mozhgan Kabiri Chimeh
+
+
Nathan Brown
+
+
Nazeefa Bushra
+
+
Nicola Smithies
+
+
non-manifold
+
+
Ofek Bruhis
+
+
Oliver Stainton
+
+
Oscar Borgström
+
+
Paul Poinet
+
+
Paul Richmond
+
+
Pawel Baran
+
+
Peter Harman
+
+
Peter Heywood
+
+
Peter Nugent
+
+
PiotrBuda
+
+
Puria Safari Hesari
+
+
Rasmus Canfjorden
+
+
Rene Schulte
+
+
Richard Whitfield
+
+
Rob May
+
+
Robert Chisholm
+
+
Robin Flyman
+
+
Roly Hudson
+
+
Ross Boulton
+
+
Sally Pickard
+
+
Sam Vazquez
+
+
Samuel Basimalla
+
+
Sofia Malmsten
+
+
StephennipBH
+
+
Thomas Edward Kingstone
+
+
Tiago Del Grossi
+
+
Tom Svilans
+
+
Toste Skanberg Dahlstedt
+
+
Tristan Gerrish
+
+
Valerie Green
+
+
Viet Le
+
+
Will Pearson
+
+
wojciech buda
+
+
Łukasz Markowski
+
+ +Last updated: 2026-06-08 + diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index c1b24ee..da00aa1 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -35,6 +35,20 @@ def filter_bots(contributors: list[dict[str, Any]]) -> list[dict[str, Any]]: ] +# Logins to exclude from the wall: org automation accounts and shared admin +# accounts that don't carry a [bot] suffix or type=Bot in the API. +# Add new entries here if a future refresh surfaces another non-human. +DENYLISTED_LOGINS = frozenset({ + "BHoMBot", # BHoM org automation bot + "BuroHappold1", # shared "Administrator" account +}) + + +def filter_denylist(contributors: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Remove contributors whose login is in DENYLISTED_LOGINS.""" + return [c for c in contributors if c["login"] not in DENYLISTED_LOGINS] + + def aggregate_contributors( per_repo_lists: list[list[dict[str, Any]]], ) -> dict[str, dict[str, Any]]: @@ -172,7 +186,7 @@ def main() -> int: per_repo: list[list[dict[str, Any]]] = [] for repo in repos: contributors = fetch_repo_contributors(session, org, repo["name"]) - human = filter_bots(contributors) + human = filter_denylist(filter_bots(contributors)) print(f" {repo['name']}: {len(human)} human contributors") per_repo.append(human) diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index 5bd22b6..e3430c4 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -80,6 +80,33 @@ def test_filter_bots_keeps_all_humans(): assert filter_bots(raw) == raw +from scripts.generate_wall_of_honour import filter_denylist, DENYLISTED_LOGINS + + +def test_filter_denylist_excludes_known_accounts(): + raw = [ + {"login": "alice", "type": "User"}, + {"login": "BHoMBot", "type": "User"}, + {"login": "BuroHappold1", "type": "User"}, + ] + result = filter_denylist(raw) + assert result == [{"login": "alice", "type": "User"}] + + +def test_filter_denylist_keeps_unrelated_logins(): + raw = [ + {"login": "alice", "type": "User"}, + {"login": "bob", "type": "User"}, + ] + assert filter_denylist(raw) == raw + + +def test_denylist_constant_includes_known_accounts(): + # Lock in the current denylist so accidental removal is caught by CI. + assert "BHoMBot" in DENYLISTED_LOGINS + assert "BuroHappold1" in DENYLISTED_LOGINS + + def test_aggregate_single_repo(): per_repo = [[ {"login": "alice", "avatar_url": "https://x/a", "contributions": 10}, From 7a0003ee7e54e15529475415b74e047f871923db Mon Sep 17 00:00:00 2001 From: Seun Akanni Date: Mon, 8 Jun 2026 12:52:05 +0100 Subject: [PATCH 16/16] feat(wall): polish styling, truncate long names, expose @login on hover Layout: - Drop avatar size 100 -> 70; bump grid 7 -> 10 cols - Fixed cell width (80px) + padded incomplete rows for uniform alignment - valign="top" anchors avatars to top so wrapped names don't push them around - cellpadding="4" cellspacing="0" for compact spacing - Wrap heading/table/stats in
- Add hr separator above wall to space it from preceding README content Content: - Replace "## Wall of Honour" heading with prose intro: "Contributors who have supported and advanced the BHoM." - Replace "X people have contributed to BHoM" with footer stats: "N contributors across M repositories. Last updated YYYY-MM-DD." - Pluralization helper so 1/N both read correctly Names: - Truncate display names >15 chars with ellipsis - Full name still available via title attr swapped to @login (GitHub alias) - HTML-escape display in for special characters Resilience: - enrich_display_names catches HTTPError so deleted accounts (404 on users/{login}) fall back to login instead of crashing the workflow Tests: - Anonymize fixtures: replace Thomas Edward Kingstone with mocklongname - 35/35 passing --- profile/README.md | 680 +++++++++++++++-------------- scripts/generate_wall_of_honour.py | 85 +++- scripts/tests/test_generate.py | 97 +++- 3 files changed, 496 insertions(+), 366 deletions(-) diff --git a/profile/README.md b/profile/README.md index 9e48593..b33bffe 100644 --- a/profile/README.md +++ b/profile/README.md @@ -21,509 +21,511 @@ We also have a space for questions and discussions: https://github.com/orgs/BHoM Please let us know how can we improve it! Please raise GitHub Issues in the [Documentation repo](https://github.com/BHoM/documentation/issues) or start a [Discussion](https://github.com/orgs/BHoM/discussions). -## Wall of Honour -151 people have contributed to BHoM. +--- - +
+ +Contributors who have supported and advanced the BHoM. + +
- - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - + + - - - - - - + - + + - - - - - - + - - - - - - - - - - - - - + + + + + + + + + + + +
-
AaronWagner
+
+
AaronWagner
-
Adam Sobieski
+
+
Adam Sobieski
-
Agnes Lindblom
+
+
Agnes Lindblom
-
Ahmad Sater
+
+
Ahmad Sater
-
Ahmed Elnagar
+
+
Ahmed Elnagar
-
Al Fisher
+
+
Al Fisher
-
Alaa Alfakara
+
+
Alaa Alfakara
-
Albin Bergh
+
+
Albin Bergh
-
Alessio Lombardi
+
+
Alessio Lombar…
-
Alex Radne
+
+
Alex Radne
-
Alexis Santella
-
-
Andreas Bak
+
+
Alexis Santella
-
Andrew Engh
+
+
Andreas Bak
-
Anna Stigenberg
+
+
Andrew Engh
-
Anthony Morehouse
+
+
Anna Stigenberg
-
Antigoni Karaiskou
+
+
Anthony Moreho…
-
Arnaud Declercq
+
+
Antigoni Karai…
-
Arne Martensen
+
+
Arnaud Declercq
-
Arvid Sigurdsson
+
+
Arne Martensen
-
Aurora Jensen
+
+
Arvid Sigurdss…
-
Bing Wang
+
+
Aurora Jensen
-
Carmen Fan
+
+
Bing Wang
-
Charlie Brooker
+
+
Carmen Fan
-
Chris Self
+
+
Charlie Brooker
-
Christopher Short
+
+
Chris Self
-
Clemens Preisinger
+
+
Christopher Sh…
-
Conor Hubert
+
+
Clemens Preisi…
-
Courtney Boulter
+
+
Conor Hubert
-
Daniel Imms
+
+
Courtney Boult…
-
Daniel Jonsson
+
+
Daniel Imms
-
DarienC
+
+
Daniel Jonsson
-
Deesha Tech
-
-
Desai Wang
+
+
DarienC
-
DiellzaElshani
+
+
Deesha Tech
-
Dimitrie Stefanescu
+
+
Desai Wang
-
Djamila Mamedova
+
+
DiellzaElshani
-
Dmitry Zelenkin
+
+
Dimitrie Stefa…
-
Eddy Dalton
+
+
Djamila Mamedo…
-
Eduardo Pignatelli
+
+
Dmitry Zelenkin
-
Einar Persson
+
+
Eddy Dalton
-
Elina Usmanova
+
+
Eduardo Pignat…
-
Ellie Adebowale
+
+
Einar Persson
-
Emil Svedjer
+
+
Elina Usmanova
-
Emma Sander
+
+
Ellie Adebowale
-
EnricoAntolini
+
+
Emil Svedjer
-
Erida Bendo
+
+
Emma Sander
-
Erik Forsberg
+
+
EnricoAntolini
-
Erik Narhi
+
+
Erida Bendo
-
Evangelos Pantazis
+
+
Erik Forsberg
-
felipebandeiramm
+
+
Erik Narhi
-
Felix Mallinder
+
+
Evangelos Pant…
-
Fraser Greenroyd
+
+
felipebandeira…
-
Frédéric Bourgeon
-
-
Geir Soderin
+
+
Felix Mallinder
-
Gideon Tamir
+
+
Fraser Greenro…
-
Giorgio Carlo Roberto Albieri
+
+
Frédéric Bourg…
-
Habiba Moubarak
+
+
Geir Soderin
-
Haiping
+
+
Gideon Tamir
-
Hedvig Kjellander
+
+
Giorgio Carlo…
-
Isak Larborn
+
+
Habiba Moubarak
-
Isak Näslund
+
+
Haiping
-
izzy lys
+
+
Hedvig Kjellan…
-
Jack Trainor
+
+
Isak Larborn
-
Jaco Bekker
+
+
Isak Näslund
-
James Ramsden
+
+
izzy lys
-
Johanna Isaksson
+
+
Jack Trainor
-
Johanna Olin
+
+
Jaco Bekker
-
John Sloane
+
+
James Ramsden
-
Jonathan Nillius
+
+
Johanna Isakss…
-
Jose Castro Aguilar
+
+
Johanna Olin
-
JS
+
+
John Sloane
-
jukkasus
+
+
Jonathan Nilli…
-
Junjie
+
+
Jose Castro Ag…
-
Kai Chelliah
-
-
Kalle Edstroem
+
+
JS
-
Kalle Thorsager
+
+
jukkasus
-
Kalle Thorsager
+
+
Junjie
-
Karolina Prusicka
+
+
Kai Chelliah
-
Kartheek Mylavarapu
+
+
Kalle Edstroem
-
Kasia Wodzisz
+
+
Kalle Thorsager
-
Kayleigh Houde
+
+
Kalle Thorsager
-
Kenton Phillips
+
+
Karolina Prusi…
-
Kenton Phillips
+
+
Kartheek Mylav…
-
Konrad Stolarski
+
+
Kasia Wodzisz
-
Lina Eriksson
+
+
Kayleigh Houde
-
linhnam-nguyen
+
+
Kenton Phillips
-
Liv Andersson
+
+
Kenton Phillips
-
livand
+
+
Konrad Stolars…
-
Ludvig Borgenstam
+
+
Lina Eriksson
-
Luis E. Fraguada
+
+
linhnam-nguyen
-
Madelene Sandmer
+
+
Liv Andersson
-
Maja Lindroth
+
+
livand
-
Manuel Chafart
+
+
Ludvig Borgens…
-
Maria Glimaker
+
+
Luis E. Fragua…
-
Maria Roman
-
-
Maria Tjader
+
+
Madelene Sandm…
-
Mark Cunliffe
+
+
Maja Lindroth
-
Martin B. Henriksen
+
+
Manuel Chafart
-
Marvin Suen
+
+
Maria Glimaker
-
Maryanne Wachter
+
+
Maria Roman
-
Maryanne Wachter
+
+
Maria Tjader
-
Matilda Svensson
+
+
Mark Cunliffe
-
Matteo Cominetti
+
+
Martin B. Henr…
-
Matthew Swaidan
+
+
Marvin Suen
-
Maya Arora-Jonsson
+
+
Maryanne Wacht…
-
Meinrad Recheis
+
+
Maryanne Wacht…
-
Michael Hoehn
+
+
Matilda Svenss…
-
Michal Dengusiak
+
+
Matteo Cominet…
-
Michal Pekacki
+
+
Matthew Swaidan
-
MILeach
+
+
Maya Arora-Jon…
-
Milo Gillot
+
+
Meinrad Recheis
-
Mish Nuh
+
+
Michael Hoehn
-
Mozhgan Kabiri Chimeh
+
+
Michal Dengusi…
-
Nathan Brown
+
+
Michal Pekacki
-
Nazeefa Bushra
+
+
MILeach
-
Nicola Smithies
-
-
non-manifold
+
+
Milo Gillot
-
Ofek Bruhis
+
+
Mish Nuh
-
Oliver Stainton
+
+
Mozhgan Kabiri…
-
Oscar Borgström
+
+
Nathan Brown
-
Paul Poinet
+
+
Nazeefa Bushra
-
Paul Richmond
+
+
Nicola Smithies
-
Pawel Baran
+
+
non-manifold
-
Peter Harman
+
+
Ofek Bruhis
-
Peter Heywood
+
+
Oliver Stainton
-
Peter Nugent
+
+
Oscar Borgström
-
PiotrBuda
+
+
Paul Poinet
+
+
Paul Richmond
+
+
Pawel Baran
-
Puria Safari Hesari
+
+
Peter Harman
-
Rasmus Canfjorden
+
+
Peter Heywood
-
Rene Schulte
+
+
Peter Nugent
-
Richard Whitfield
+
+
PiotrBuda
-
Rob May
+
+
Puria Safari H…
-
Robert Chisholm
+
+
Rasmus Canfjor…
+
+
Rene Schulte
-
Robin Flyman
+
+
Richard Whitfi…
+
+
Rob May
+
+
Robert Chisholm
-
Roly Hudson
+
+
Robin Flyman
-
Ross Boulton
+
+
Roly Hudson
-
Sally Pickard
+
+
Ross Boulton
-
Sam Vazquez
+
+
Sally Pickard
-
Samuel Basimalla
+
+
Sam Vazquez
-
Sofia Malmsten
+
+
Samuel Basimal…
+
+
Sofia Malmsten
-
StephennipBH
-
-
Thomas Edward Kingstone
+
+
StephennipBH
-
Tiago Del Grossi
+
+
Thomas Edward…
-
Tom Svilans
+
+
Tiago Del Gros…
-
Toste Skanberg Dahlstedt
+
+
Tom Svilans
-
Tristan Gerrish
+
+
Toste Skanberg…
-
Valerie Green
+
+
Tristan Gerrish
-
Viet Le
+
+
Valerie Green
-
Will Pearson
+
+
Viet Le
-
wojciech buda
+
+
Will Pearson
-
Łukasz Markowski
+
+
wojciech buda
+
Łukasz Markows…
+
-Last updated: 2026-06-08 +151 contributors across 109 repositories. Last updated 2026-06-08. + +
diff --git a/scripts/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py index da00aa1..9dc9529 100644 --- a/scripts/generate_wall_of_honour.py +++ b/scripts/generate_wall_of_honour.py @@ -1,6 +1,7 @@ """Generate BHoM Wall of Honour and splice into profile/README.md.""" from __future__ import annotations +import html import os import re import sys @@ -72,26 +73,47 @@ def enrich_display_names( session: requests.Session, contributors: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: - """Fetch each contributor's display name; fall back to login when blank.""" + """Fetch each contributor's display name; fall back to login when blank or unavailable. + + Handles 404s gracefully (e.g. deleted GitHub accounts) by using the login as the name. + """ for login, info in contributors.items(): - user = get_one(session, f"{GITHUB_API}/users/{login}") - name = (user.get("name") or "").strip() + try: + user = get_one(session, f"{GITHUB_API}/users/{login}") + name = (user.get("name") or "").strip() + except requests.HTTPError: + name = "" info["name"] = name if name else login return contributors -GRID_COLS = 7 -AVATAR_SIZE = 100 +GRID_COLS = 10 +AVATAR_SIZE = 70 +CELL_WIDTH = 80 +NAME_MAX_LEN = 15 # truncate longer display names with an ellipsis; full name in title attr + + +def _pluralize(n: int, singular: str, plural: str) -> str: + return f"{n} {singular if n == 1 else plural}" + + +INTRO_TEXT = "Contributors who have supported and advanced the BHoM." -def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> str: +def render_wall( + contributors: dict[str, dict[str, Any]], + last_updated: str, + repo_count: int | None = None, +) -> str: """Render the markdown block (markers included) for the wall.""" if not contributors: return ( - "\n" - "## Wall of Honour\n\n" - "Wall coming soon. No contributors yet.\n\n" - f"Last updated: {last_updated}\n" + "\n\n" + "---\n\n" + "
\n\n" + f"{INTRO_TEXT}\n\n" + f"_(No contributors yet. Last updated {last_updated}.)_\n\n" + "
\n" "" ) @@ -101,31 +123,52 @@ def render_wall(contributors: dict[str, dict[str, Any]], last_updated: str) -> s ) rows: list[str] = [] + empty_cell = f" " for row_start in range(0, len(sorted_logins), GRID_COLS): row_logins = sorted_logins[row_start:row_start + GRID_COLS] cells = [_render_cell(login, contributors[login]) for login in row_logins] + while len(cells) < GRID_COLS: + cells.append(empty_cell) rows.append(" \n" + "\n".join(cells) + "\n ") + contributors_part = _pluralize(len(contributors), "contributor", "contributors") + if repo_count is not None: + repos_part = _pluralize(repo_count, "repository", "repositories") + stats = f"{contributors_part} across {repos_part}. Last updated {last_updated}." + else: + stats = f"{contributors_part}. Last updated {last_updated}." + return ( - "\n" - "## Wall of Honour\n\n" - f"{len(contributors)} people have contributed to BHoM.\n\n" - "\n" + "\n\n" + "---\n\n" + "
\n\n" + f"{INTRO_TEXT}\n\n" + "
\n" + "\n".join(rows) + "\n
\n\n" - f"Last updated: {last_updated}\n" + f"{stats}\n\n" + "\n" "" ) +def _truncate_name(name: str, max_len: int = NAME_MAX_LEN) -> str: + """Trim to max_len visible characters, appending an ellipsis if shortened.""" + if len(name) <= max_len: + return name + return name[: max_len - 1].rstrip() + "…" + + def _render_cell(login: str, info: dict[str, Any]) -> str: - name = info["name"] + full_name = info["name"] + display = html.escape(_truncate_name(full_name)) return ( - " \n" - f" " + f" \n" + f" " f"
" - f"{name}
\n" + f"width=\"{AVATAR_SIZE}\" height=\"{AVATAR_SIZE}\" " + f"style=\"border-radius: 50%\" />
" + f"{display}\n" " " ) @@ -195,7 +238,7 @@ def main() -> int: enriched = enrich_display_names(session, aggregated) today = date.today().isoformat() - wall_md = render_wall(enriched, today) + wall_md = render_wall(enriched, today, repo_count=len(repos)) changed = splice_into_readme(readme_path, wall_md) if changed: diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py index e3430c4..65de5e4 100644 --- a/scripts/tests/test_generate.py +++ b/scripts/tests/test_generate.py @@ -177,14 +177,43 @@ def test_enrich_display_names_falls_back_to_login_when_empty_string(): assert result["carol"]["name"] == "carol" +@responses.activate +def test_enrich_display_names_falls_back_on_404(): + # Deleted GitHub account: users/{login} returns 404; we should not crash. + responses.add( + responses.GET, + "https://api.github.com/users/deleted_user", + status=404, + ) + session = make_session("fake-token") + contributors = {"deleted_user": {"avatar_url": "https://x/d", "contributions": 1}} + result = enrich_display_names(session, contributors) + assert result["deleted_user"]["name"] == "deleted_user" + + from scripts.generate_wall_of_honour import render_wall def test_render_wall_empty(): md = render_wall({}, "2026-06-08") - assert "Wall coming soon" in md or "0 people" in md + assert "supported and advanced the BHoM" in md assert "" in md assert "" in md + assert "
" in md + # Horizontal rule separates the wall from preceding README content + assert "\n---\n" in md + + +def test_render_wall_includes_hr_separator(): + contributors = { + "alice": {"avatar_url": "x", "contributions": 1, "name": "Alice"}, + } + md = render_wall(contributors, "2026-06-08") + # The hr appears between the start marker and the centered content + start_idx = md.index("") + div_idx = md.index("
") + hr_idx = md.index("---") + assert start_idx < hr_idx < div_idx def test_render_wall_single_row(): @@ -193,19 +222,75 @@ def test_render_wall_single_row(): "bob": {"avatar_url": "https://x/b", "contributions": 5, "name": "Bob Sample"}, } md = render_wall(contributors, "2026-06-08") - assert "2 people" in md - assert md.count("") == 1 # 2 cells fit in 1 row of 7 + assert "2 contributors" in md + assert md.count("") == 1 # 2 cells fit in 1 row of 10 assert "Alice Example" in md assert "Bob Sample" in md # Alphabetical: Alice before Bob assert md.index("Alice Example") < md.index("Bob Sample") + # Rounded avatars + assert "border-radius: 50%" in md + # Centered wrapper + assert "
" in md + # Empty padding cells fill row to GRID_COLS width + assert md.count("") == 8 + + +def test_render_wall_stats_with_repo_count(): + contributors = { + "alice": {"avatar_url": "https://x/a", "contributions": 10, "name": "Alice Example"}, + "bob": {"avatar_url": "https://x/b", "contributions": 5, "name": "Bob Sample"}, + } + md = render_wall(contributors, "2026-06-08", repo_count=42) + assert "2 contributors across 42 repositories" in md + + +def test_render_wall_pluralization_singular(): + contributors = { + "solo": {"avatar_url": "https://x/s", "contributions": 1, "name": "Solo Dev"}, + } + md = render_wall(contributors, "2026-06-08", repo_count=1) + assert "1 contributor across 1 repository" in md + assert "1 contributors" not in md + assert "1 repositories" not in md def test_render_wall_two_rows_with_remainder(): - contributors = {f"user{i}": {"avatar_url": f"https://x/{i}", "contributions": 1, "name": f"User {i}"} for i in range(8)} + contributors = {f"user{i}": {"avatar_url": f"https://x/{i}", "contributions": 1, "name": f"User {i}"} for i in range(11)} md = render_wall(contributors, "2026-06-08") assert md.count("") == 2 - assert md.count("") == 9 + + +def test_render_wall_truncates_long_names_with_ellipsis(): + contributors = { + "mocklongname": {"avatar_url": "https://x/m", "contributions": 1, "name": "A Very Long Display Name"}, + } + md = render_wall(contributors, "2026-06-08") + # Display in is truncated + assert "A Very Long Display Name" not in md + assert "…" in md + # GitHub login (with @ prefix) is exposed via the title attribute for hover + assert "title=\"@mocklongname\"" in md + assert "title=\"A Very Long Display Name\"" not in md + + +def test_render_wall_keeps_short_names_intact(): + contributors = { + "alice": {"avatar_url": "https://x/a", "contributions": 1, "name": "Alice"}, + } + md = render_wall(contributors, "2026-06-08") + assert "Alice" in md + assert "…" not in md # no ellipsis anywhere + + +def test_render_wall_uses_valign_top(): + # valign="top" keeps avatars aligned even when names wrap to two lines + contributors = {"alice": {"avatar_url": "x", "contributions": 1, "name": "Alice"}} + md = render_wall(contributors, "2026-06-08") + assert "valign=\"top\"" in md def test_render_wall_sort_is_case_insensitive_and_unicode(): @@ -315,5 +400,5 @@ def test_main_end_to_end(monkeypatch, tmp_path): assert readme.exists() content = readme.read_text(encoding="utf-8") assert "Alice Example" in content - assert "1 people" in content # Only alice; bot filtered + assert "1 contributor across 1 repository" in content # Only alice; bot filtered assert "dependabot" not in content