Files
cacti/.github/scripts/update-contributors.py
Thomas Vincent b430f5b1ec fix(tests): make hooks, CI helpers and the i18n audit fail closed (#8457)
* fix(hooks): check staged blobs and fail closed in the changelog tools

The pre-commit hooks read working-tree files while claiming to check staged content, check-changelog.py passed when its base diff failed, and fold-changelog.py deleted fragments it had not validated.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(tests): make CI helper scripts fail when they check nothing

check_all_pages.sh passed without logging in or crawling, the sink inventory accepted ripgrep errors, install_packages.sh and copyright_year.sh lost their failures, and the LDAP spec asserted before navigation finished.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(i18n): audit only changed .po entries and keep contributor badges separate

audit_po.py failed every translation PR on pre-existing errors and matched non-printf text as conversions; update-contributors.py leaked badges between aliased contributors.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* docs(changelog): add fragment for #8457

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(e2e): move the pointer off the Deepness multiselect button

Deepness sets a zero body margin, so the unhovered #ms-button sits at the viewport origin and headless Chromium on Linux intermittently applies :hover to it.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: pull pinned integration database from authenticated mirror

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-23 08:17:12 -04:00

337 lines
12 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Refresh .all-contributorsrc and the README contributors table.
This is a best-effort daily heuristic, not a re-run of the manual audit that
seeded .all-contributorsrc. Known limitations:
- Author matching is done by comparing git commit author name/email against
each contributor's stored "login"/"name" fields, plus any variants listed
in FOUNDER_ALIASES below. Contributors whose git identity doesn't resemble
either field and isn't listed there will not get their badges refreshed
automatically; those stay as manually curated.
- The manually curated ordering (founders/emeritus, active developers,
honorable mentions, then by contribution count) is preserved as-is. New
contributors are appended at the end, not re-sorted into a tier.
- "review" badges come from the most recently updated merged PRs only (see
REVIEW_PR_PAGES below), not full repo history, to keep the GraphQL query
cheap enough to run daily.
"""
from __future__ import annotations
import json
import math
import re
import subprocess
import sys
from pathlib import Path
REPO = "Cacti/cacti"
CONTRIBUTORSRC = Path(".all-contributorsrc")
README = Path("README.md")
REVIEW_PR_PAGES = 5
REVIEW_PR_PAGE_SIZE = 50
# login (lowercased) and avatar_url substrings that should never be
# recorded as human contributors.
BOT_LOGINS = {"dependabot[bot]", "weblate", "copilot-pull-request-reviewer", "github-advanced-security"}
BOT_AVATAR_MARKERS = ("githubusercontent.com/in/",)
# Ordered so the rendered badge list always reads the same way as the
# hand-built table already in README.md.
BADGE_ORDER = [
"code", "security", "doc", "infra", "translation", "test",
"design", "maintenance", "review", "bug", "ideas",
"projectManagement", "question",
]
EMOJI = {
"code": ("\U0001F4BB", "Code"),
"security": ("\U0001F6E1️", "Security"),
"doc": ("\U0001F4D6", "Documentation"),
"infra": ("\U0001F687", "Infrastructure"),
"translation": ("\U0001F30D", "Translation"),
"test": ("⚠️", "Tests"),
"design": ("\U0001F3A8", "Design"),
"maintenance": ("\U0001F6A7", "Maintenance"),
"review": ("\U0001F440", "Reviewed Pull Requests"),
"bug": ("\U0001F41B", "Bug reports"),
"ideas": ("\U0001F914", "Ideas, Planning, & Feedback"),
"projectManagement": ("\U0001F4C6", "Project Management"),
"question": ("\U0001F4AC", "Answering Questions"),
}
# path prefix -> badge, checked against every file touched by a commit.
PATH_BADGES = [
(re.compile(r"^locales/"), "translation"),
(re.compile(r"^tests/"), "test"),
(re.compile(r"^images/"), "design"),
(re.compile(r"^(\.github/|docker/|Dockerfile|docker-compose)"), "infra"),
(re.compile(r"^(composer\.json|phpstan|rector\.php|\.php-cs-fixer\.php|\.pre-commit-config\.yaml)"), "maintenance"),
(re.compile(r"^docs/"), "doc"),
(re.compile(r"\.md$"), "doc"),
]
SECURITY_MESSAGE_RE = re.compile(r"CVE-|GHSA|security|XSS|SQLi|vulnerab", re.IGNORECASE)
# login -> known git-author-name variants, for founders/long-time
# contributors who commit under a real-name alias that doesn't match
# their login or their stored "name" field. Compiled from about.php
# credits and verified email evidence during the manual audit that
# seeded .all-contributorsrc.
FOUNDER_ALIASES = {
"Rax": ["Ian Berry"],
"gan-dalf": ["Reinhard Scheck"],
"rony": ["Tony Roman"],
"ablyler": ["Andy Blyler"],
"TheWitness": ["TheWitness", "Larry Adams"],
"netniV": ["netniV", "Mark Brugnoli-Vinten"],
"cigamit": ["cigamit", "Jimmy Conner"],
"xmacan": ["Petr Macek"],
"browniebraun": ["browniebraun", "Browniebraun", "Andreas Braun"],
"ddb4github": ["Jing Chen", "ddb4github"],
"somethingwithproof": ["Thomas Vincent", "somethingwithproof"],
"bmfmancini": ["Sean Mancini"],
"Linegod": ["Linegod", "Jeff Pasnak"],
"BSOD2600": ["Chris Bell"],
"paulgevers": ["Paul Gevers"],
"mortenstevens": ["Morten Stevens"],
"jpobeda": ["Javier", "jpobeda"],
"nerosketch": ["Dmitry", "nerosketch"],
"zersh01": ["Сергей", "zersh01"],
"nakayama1869": ["Nakayama Kito", "nakayama1869"],
}
def run(cmd: list[str]) -> str:
return subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
def gh_api(path: str, paginate: bool = False) -> list | dict:
cmd = ["gh", "api", path]
if paginate:
cmd.append("--paginate")
return json.loads(run(cmd) or "[]")
def is_bot(login: str, avatar_url: str) -> bool:
login_l = login.lower()
if login_l in BOT_LOGINS or "weblate" in login_l:
return True
return any(marker in (avatar_url or "") for marker in BOT_AVATAR_MARKERS)
def load_contributors() -> dict:
return json.loads(CONTRIBUTORSRC.read_text(encoding="utf-8"))
def fetch_current_contributors() -> list[dict]:
return gh_api(f"repos/{REPO}/contributors", paginate=True)
def merge_new_contributors(config: dict) -> bool:
existing_logins = {c["login"].lower() for c in config["contributors"]}
changed = False
for c in fetch_current_contributors():
login = c.get("login", "")
if not login or login.lower() in existing_logins:
continue
if is_bot(login, c.get("avatar_url", "")):
continue
config["contributors"].append({
"login": login,
"name": login,
"avatar_url": c.get("avatar_url", ""),
"profile": f"https://github.com/{login}",
"contributions": ["code"],
})
existing_logins.add(login.lower())
changed = True
return changed
def git_log_by_author() -> dict[str, dict]:
"""Map a lowercase author name/email to touched paths and messages."""
raw = run(["git", "log", "--name-only", "--pretty=format:@@%an|%ae|%s"])
by_author: dict[str, dict] = {}
current = None
for line in raw.splitlines():
if line.startswith("@@"):
name, email, subject = line[2:].split("|", 2)
key_name, key_email = name.strip().lower(), email.strip().lower()
current = by_author.setdefault(key_name, {"emails": set(), "paths": set(), "messages": []})
current["emails"].add(key_email)
current["messages"].append(subject)
# also index by email so either identity resolves to the same bucket
by_author.setdefault(key_email, current)
elif line.strip() and current is not None:
current["paths"].add(line.strip())
return by_author
def badges_for(activity: dict) -> set[str]:
badges: set[str] = set()
for path in activity["paths"]:
for pattern, badge in PATH_BADGES:
if pattern.search(path):
badges.add(badge)
if any(SECURITY_MESSAGE_RE.search(m) for m in activity["messages"]):
badges.add("security")
if activity["paths"] or activity["messages"]:
badges.add("code")
return badges
def ordered_badges(current: list[str], badges: set[str]) -> list[str]:
"""Order badges by BADGE_ORDER, keeping curated types it does not list."""
known = [b for b in BADGE_ORDER if b in badges]
return known + [b for b in current if b in badges and b not in BADGE_ORDER]
def refresh_path_badges(config: dict) -> bool:
by_author = git_log_by_author()
changed = False
for contributor in config["contributors"]:
login = contributor["login"].lower()
name = contributor.get("name", "").lower()
activity = by_author.get(login) or by_author.get(name)
for alias in FOUNDER_ALIASES.get(contributor["login"], []):
alias_activity = by_author.get(alias.lower())
if alias_activity is None or alias_activity is activity:
continue
if activity is None:
activity = alias_activity
else:
# Merge into a copy: by_author buckets are shared across name
# and email keys, so an in-place merge leaks badges to others.
activity = {
"paths": activity["paths"] | alias_activity["paths"],
"messages": activity["messages"] + alias_activity["messages"],
}
if activity is None:
continue
new_badges = badges_for(activity)
before = set(contributor["contributions"])
after = before | new_badges
if after != before:
contributor["contributions"] = ordered_badges(contributor["contributions"], after)
changed = True
return changed
def refresh_review_badges(config: dict) -> bool:
query = """
query($cursor: String) {
repository(owner: "Cacti", name: "cacti") {
pullRequests(states: MERGED, first: %d, after: $cursor, orderBy: {field: UPDATED_AT, direction: DESC}) {
pageInfo { hasNextPage endCursor }
nodes { reviews(first: 20) { nodes { author { login } } } }
}
}
}
""" % REVIEW_PR_PAGE_SIZE
reviewers: set[str] = set()
cursor = None
for _ in range(REVIEW_PR_PAGES):
args = ["gh", "api", "graphql", "-f", f"query={query}"]
if cursor:
args += ["-f", f"cursor={cursor}"]
data = json.loads(run(args))
prs = data["data"]["repository"]["pullRequests"]
for pr in prs["nodes"]:
for review in pr["reviews"]["nodes"]:
author = review.get("author")
if author and author.get("login"):
reviewers.add(author["login"].lower())
if not prs["pageInfo"]["hasNextPage"]:
break
cursor = prs["pageInfo"]["endCursor"]
changed = False
by_login = {c["login"].lower(): c for c in config["contributors"]}
for login in reviewers:
if is_bot(login, ""):
continue
contributor = by_login.get(login)
if contributor is None:
continue
if "review" not in contributor["contributions"]:
contributor["contributions"] = ordered_badges(
contributor["contributions"], set(contributor["contributions"]) | {"review"}
)
changed = True
return changed
def cell_html(contributor: dict, width_pct: str) -> str:
links = "".join(
f'<span title="{EMOJI[badge][1]}">{EMOJI[badge][0]}</span>'
for badge in BADGE_ORDER if badge in contributor["contributions"]
)
return (
f' <td align="center" valign="top" width="{width_pct}">'
f'<a href="{contributor["profile"]}">'
f'<img src="{contributor["avatar_url"]}" width="100px;" alt="{contributor["name"]}"/>'
f'<br /><sub><b>{contributor["name"]}</b></sub></a>'
f'<br />{links}</td>'
)
def render_table(config: dict) -> str:
per_line = config["contributorsPerLine"]
width_pct = f"{math.floor((100 / per_line) * 100) / 100:.2f}%"
contributors = config["contributors"]
rows = []
for i in range(0, len(contributors), per_line):
chunk = contributors[i:i + per_line]
cells = "\n".join(cell_html(c, width_pct) for c in chunk)
rows.append(f" <tr>\n{cells}\n </tr>")
return (
"<!-- markdownlint-disable -->\n"
"<table>\n"
" <tbody>\n"
+ "\n".join(rows) +
"\n </tbody>\n"
"</table>\n"
"<!-- markdownlint-restore -->"
)
def update_readme(config: dict) -> bool:
text = README.read_text(encoding="utf-8")
start_marker = "<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->"
end_marker = "<!-- ALL-CONTRIBUTORS-LIST:END -->"
start = text.index(start_marker) + len(start_marker)
end = text.index(end_marker)
new_text = text[:start] + "\n" + render_table(config) + "\n\n" + text[end:]
if new_text == text:
return False
README.write_text(new_text, encoding="utf-8")
return True
def main() -> int:
config = load_contributors()
changed = False
changed |= merge_new_contributors(config)
changed |= refresh_path_badges(config)
changed |= refresh_review_badges(config)
if not changed:
print("no contributor changes detected")
return 0
CONTRIBUTORSRC.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
update_readme(config)
print("contributors updated")
return 0
if __name__ == "__main__":
sys.exit(main())