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 diff --git a/.github/workflows/wall-of-honour.yml b/.github/workflows/wall-of-honour.yml new file mode 100644 index 0000000..43668e0 --- /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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21d0b89 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/profile/README.md b/profile/README.md index c3918b5..b33bffe 100644 --- a/profile/README.md +++ b/profile/README.md @@ -1,22 +1,531 @@ -## 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). - +## 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). + + + +--- + +
+ +Contributors who have supported and advanced the BHoM. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
AaronWagner
+
+
Adam Sobieski
+
+
Agnes Lindblom
+
+
Ahmad Sater
+
+
Ahmed Elnagar
+
+
Al Fisher
+
+
Alaa Alfakara
+
+
Albin Bergh
+
+
Alessio Lombar…
+
+
Alex Radne
+
+
Alexis Santella
+
+
Andreas Bak
+
+
Andrew Engh
+
+
Anna Stigenberg
+
+
Anthony Moreho…
+
+
Antigoni Karai…
+
+
Arnaud Declercq
+
+
Arne Martensen
+
+
Arvid Sigurdss…
+
+
Aurora Jensen
+
+
Bing Wang
+
+
Carmen Fan
+
+
Charlie Brooker
+
+
Chris Self
+
+
Christopher Sh…
+
+
Clemens Preisi…
+
+
Conor Hubert
+
+
Courtney Boult…
+
+
Daniel Imms
+
+
Daniel Jonsson
+
+
DarienC
+
+
Deesha Tech
+
+
Desai Wang
+
+
DiellzaElshani
+
+
Dimitrie Stefa…
+
+
Djamila Mamedo…
+
+
Dmitry Zelenkin
+
+
Eddy Dalton
+
+
Eduardo Pignat…
+
+
Einar Persson
+
+
Elina Usmanova
+
+
Ellie Adebowale
+
+
Emil Svedjer
+
+
Emma Sander
+
+
EnricoAntolini
+
+
Erida Bendo
+
+
Erik Forsberg
+
+
Erik Narhi
+
+
Evangelos Pant…
+
+
felipebandeira…
+
+
Felix Mallinder
+
+
Fraser Greenro…
+
+
FrĆ©dĆ©ric Bourg…
+
+
Geir Soderin
+
+
Gideon Tamir
+
+
Giorgio Carlo…
+
+
Habiba Moubarak
+
+
Haiping
+
+
Hedvig Kjellan…
+
+
Isak Larborn
+
+
Isak NƤslund
+
+
izzy lys
+
+
Jack Trainor
+
+
Jaco Bekker
+
+
James Ramsden
+
+
Johanna Isakss…
+
+
Johanna Olin
+
+
John Sloane
+
+
Jonathan Nilli…
+
+
Jose Castro Ag…
+
+
JS
+
+
jukkasus
+
+
Junjie
+
+
Kai Chelliah
+
+
Kalle Edstroem
+
+
Kalle Thorsager
+
+
Kalle Thorsager
+
+
Karolina Prusi…
+
+
Kartheek Mylav…
+
+
Kasia Wodzisz
+
+
Kayleigh Houde
+
+
Kenton Phillips
+
+
Kenton Phillips
+
+
Konrad Stolars…
+
+
Lina Eriksson
+
+
linhnam-nguyen
+
+
Liv Andersson
+
+
livand
+
+
Ludvig Borgens…
+
+
Luis E. Fragua…
+
+
Madelene Sandm…
+
+
Maja Lindroth
+
+
Manuel Chafart
+
+
Maria Glimaker
+
+
Maria Roman
+
+
Maria Tjader
+
+
Mark Cunliffe
+
+
Martin B. Henr…
+
+
Marvin Suen
+
+
Maryanne Wacht…
+
+
Maryanne Wacht…
+
+
Matilda Svenss…
+
+
Matteo Cominet…
+
+
Matthew Swaidan
+
+
Maya Arora-Jon…
+
+
Meinrad Recheis
+
+
Michael Hoehn
+
+
Michal Dengusi…
+
+
Michal Pekacki
+
+
MILeach
+
+
Milo Gillot
+
+
Mish Nuh
+
+
Mozhgan Kabiri…
+
+
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 H…
+
+
Rasmus Canfjor…
+
+
Rene Schulte
+
+
Richard Whitfi…
+
+
Rob May
+
+
Robert Chisholm
+
+
Robin Flyman
+
+
Roly Hudson
+
+
Ross Boulton
+
+
Sally Pickard
+
+
Sam Vazquez
+
+
Samuel Basimal…
+
+
Sofia Malmsten
+
+
StephennipBH
+
+
Thomas Edward…
+
+
Tiago Del Gros…
+
+
Tom Svilans
+
+
Toste Skanberg…
+
+
Tristan Gerrish
+
+
Valerie Green
+
+
Viet Le
+
+
Will Pearson
+
+
wojciech buda
+
+
Łukasz Markows…
+
+ +151 contributors across 109 repositories. Last updated 2026-06-08. + +
+ 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/generate_wall_of_honour.py b/scripts/generate_wall_of_honour.py new file mode 100644 index 0000000..9dc9529 --- /dev/null +++ b/scripts/generate_wall_of_honour.py @@ -0,0 +1,252 @@ +"""Generate BHoM Wall of Honour and splice into profile/README.md.""" +from __future__ import annotations + +import html +import os +import re +import sys +import unicodedata +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"] + + +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"}) + + +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]") + ] + + +# 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]]: + """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 + + +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 or unavailable. + + Handles 404s gracefully (e.g. deleted GitHub accounts) by using the login as the name. + """ + for login, info in contributors.items(): + 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 = 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, + repo_count: int | None = None, +) -> str: + """Render the markdown block (markers included) for the wall.""" + if not contributors: + return ( + "\n\n" + "---\n\n" + "
\n\n" + f"{INTRO_TEXT}\n\n" + f"_(No contributors yet. Last updated {last_updated}.)_\n\n" + "
\n" + "" + ) + + sorted_logins = sorted( + contributors.keys(), + key=lambda login: unicodedata.normalize("NFD", contributors[login]["name"].casefold()), + ) + + 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\n" + "---\n\n" + "
\n\n" + f"{INTRO_TEXT}\n\n" + "\n" + + "\n".join(rows) + + "\n
\n\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: + full_name = info["name"] + display = html.escape(_truncate_name(full_name)) + return ( + f" \n" + f" " + f"
" + f"{display}
\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 found; 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 + + +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_denylist(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, repo_count=len(repos)) + + 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/github_api.py b/scripts/github_api.py new file mode 100644 index 0000000..0da8a86 --- /dev/null +++ b/scripts/github_api.py @@ -0,0 +1,71 @@ +# 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. + + 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) + 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/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/tests/test_generate.py b/scripts/tests/test_generate.py new file mode 100644 index 0000000..65de5e4 --- /dev/null +++ b/scripts/tests/test_generate.py @@ -0,0 +1,404 @@ +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 + + +@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"] + + +@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 == [] + + +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 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}, + ]] + 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([[]]) == {} + + +@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" + + +@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 "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(): + 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 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(11)} + md = render_wall(contributors, "2026-06-08") + assert md.count("") == 2 + # 11 populated cells (links present) + 9 empty padding cells in the second row + 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(): + contributors = { + "zoe": {"avatar_url": "z", "contributions": 1, "name": "Zoe"}, + "Anna": {"avatar_url": "a", "contributions": 1, "name": "Anna"}, + "alex": {"avatar_url": "al", "contributions": 1, "name": "alex"}, + "Ć„ngstrƶm": {"avatar_url": "ang", "contributions": 1, "name": "ƅngstrƶm"}, + } + md = render_wall(contributors, "2026-06-08") + names_in_order = [] + 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 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 contributor across 1 repository" in content # Only alice; bot filtered + assert "dependabot" not in content 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")