From d2f5d45f087da96cd2cf6797312a60b376286d21 Mon Sep 17 00:00:00 2001 From: max <40643627+quotentiroler@users.noreply.github.com> Date: Tue, 10 Feb 2026 02:04:29 -0800 Subject: [PATCH] fix(credits): deduplicate contributors by GitHub username and display name * Scripts: add sync-credits.py to populate maintainers/contributors from git/GitHub * fix(credits): deduplicate contributors by GitHub username and display name --- docs/reference/credits.md | 43 +++--- scripts/sync-credits.py | 318 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 21 deletions(-) create mode 100644 scripts/sync-credits.py diff --git a/docs/reference/credits.md b/docs/reference/credits.md index 6cc5b16d06..49b14fccb3 100644 --- a/docs/reference/credits.md +++ b/docs/reference/credits.md @@ -17,31 +17,32 @@ OpenClaw = CLAW + TARDIS, because every space lobster needs a time and space mac ## Maintainers -- **Peter Steinberger** - Benevolent Dictator - - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete) +- [@steipete](https://github.com/steipete) (546 merges) +- [@gumadeiras](https://github.com/gumadeiras) (51 merges, 43 direct pushes) +- [@cpojer](https://github.com/cpojer) (8 merges, 83 direct pushes) +- [@thewilloftheshadow](https://github.com/thewilloftheshadow) (69 merges) +- [@joshp123](https://github.com/joshp123) (14 merges, 48 direct pushes) +- [@quotentiroler](https://github.com/quotentiroler) (36 merges, 24 direct pushes) +- [@vignesh07](https://github.com/vignesh07) (33 merges, 27 direct pushes) +- [@sebslight](https://github.com/sebslight) (46 merges, 9 direct pushes) +- [@shakkernerd](https://github.com/shakkernerd) (22 merges, 33 direct pushes) +- [@Takhoffman](https://github.com/Takhoffman) (25 merges, 24 direct pushes) +- [@obviyus](https://github.com/obviyus) (45 merges) +- [@tyler6204](https://github.com/tyler6204) (29 merges, 6 direct pushes) +- [@grp06](https://github.com/grp06) (15 merges) +- [@christianklotz](https://github.com/christianklotz) (9 merges) +- [@dvrshil](https://github.com/dvrshil) (2 merges, 3 direct pushes) +- [@badlogic](https://github.com/badlogic) (3 merges) +- [@mbelinky](https://github.com/mbelinky) (2 merges) +- [@sergiopesch](https://github.com/sergiopesch) (2 merges) -- **Shadow** - Discord + Slack subsystem - - GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed) +## Contributors -- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster - - GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh) +480 contributors: Peter Steinberger (7067), Shadow (180), cpojer (79), Tyler Yust (68), Josh Palmer (67), Ayaan Zaidi (66), Vignesh Natarajan (66), Gustavo Madeira Santana (58), sheeek (45), Vignesh (45), quotentiroler (43), Shakker (39), Onur (37), Mariano Belinky (34), Tak Hoffman (32), Jake (24), Seb Slight (24), Benjamin Jesuiter (23), Gustavo Madeira Santana (23), Luke K (pr-0f3t) (23), Glucksberg (22), Muhammed Mukhthar CM (22), Tyler Yust (19), George Pickett (18), Mario Zechner (18), Yurii Chukhlib (18), Conroy Whitney (16), Roshan Singh (15), Azade (14), Dave Lauer (14), ideoutrea (14), Sebastian (14), Seb Slight (12), Marcus Neves (11), Robby (11), Shakker (11), CJ Winslow (10), Muhammed Mukhthar CM (10), zerone0x (10), Christian Klotz (9), Joao Lisboa (9), Nimrod Gutman (9), Shakker Nerd (9), CLAWDINATOR Bot (7), Ian Hildebrand (7), Anton Sotkov (6), Austin Mudd (6), Clawd (6), Clawdbot (6), Dominic Damoah (6), hsrvc (6), James Groat (6), Jefferson Warrior (6), Josh Lehman (6), Mahmoud Ibrahim (6), Marc Terns (6), Nima Karimi (6), Petter Blomberg (6), Ryan Lisse (6), Tobias Bischoff (6), Vignesh (6), Yifeng Wang (6), Alex Fallah (5), Bohdan Podvirnyi (5), Doug von Kohorn (5), Erik (5), gupsammy (5), ide-rea (5), jonisjongithub (5), Jonáš Jančařík (5), Julian Engel (5), Keith the Silly Goose (5), L36 Server (5), Marc (5), Marcus Castro (5), meaningfool (5), Michael Behr (5), Sash Zats (5), Sebastian Schubotz (5), SocialNerd42069 (5), Stefan Förster (5), Tu Nombre Real (5), vrknetha (5), Xaden Ryan (5), Abhi (4), Armin Ronacher (4), Artus KG (4), Bradley Priest (4), Cash Williams (4), Christian A. Rodriguez (4), Claude (4), danielz1z (4), DB Hurley (4), Denis Rybnikov (4), Elie Habib (4), Emanuel Stadler (4), Friederike Seiler (4), Gabriel Trigo (4), Joshua Mitchell (4), Kasper Neist (4), Kelvin Calcano (4), Kit (4), Lucas Czekaj (4), Manuel Maly (4), Peter Siska (4), rahthakor (4), Rodrigo Uroz (4), Ruby (4), Sash Catanzarite (4), Sergii Kozak (4), tsu (4), Wes (4), Yuri Chukhlib (4), Zach Knickerbocker (4), 大猫子 (4), Adam Holt (3), adeboyedn (3), Ameno Osman (3), Basit Mustafa (3), benithors (3), Chris Taylor (3), Connor Shea (3), damaozi (3), Dan (3), Dan Guido (3), ddyo (3), George Zhang (3), henrymascot (3), Jamieson O'Reilly (3), Jefferson Nunn (3), juanpablodlc (3), Justin (3), kiranjd (3), Lauren Rosenberg (3), Magi Metal (3), Manuel Jiménez Torres (3), Matthew (3), Max Sumrall (3), Nacho Iacovino (3), Nicholas Spisak (3), Palash Oswal (3), Pooya Parsa (3), Roopak Nijhara (3), Ross Morsali (3), Sebastian Slight (3), Trevin Chow (3), xiaose (3), Aaron (2), Aaron Konyer (2), Abdel Sy Fane (2), adam91holt (2), Aldo (2), Andrew Lauppe (2), Andrii (2), André Abadesso (2), Ayush Ojha (2), Boran Cui (2), Christof (2), ClawdFx (2), Coy Geek (2), Dante Lex (2), David Guttman (2), David Hurley (2), DBH (2), Echo (2), Elarwei (2), Eng. Juan Combetto (2), Erik von Essen Fisher (2), Ermenegildo Fiorito (2), François Catuhe (2), hougangdev (2), Ivan Pereira (2), Jamieson O'Reilly (2), Jared Verdi (2), Jay Winder (2), Jhin (2), Jonathan D. Rhyne (2), Jonathan Rhyne (2), Jonathan Wilkins (2), JustYannicc (2), Leszek Szpunar (2), Liu Yuan (2), LK (2), Long (2), lploc94 (2), Lucas Czekaj (2), Marchel Fahrezi (2), Marco Marandiz (2), Mariano (2), Matthew Russell (2), Matthieu Bizien (2), Michelle Tilley (2), Mickaël Ahouansou (2), Nicolas Zullo (2), Philipp Spiess (2), Radek Paclt (2), Randy Torres (2), rhuanssauro (2), Richard Poelderl (2), Rishi Vhavle (2), Robby (AI-assisted) (2), SirCrumpet (2), Sk Akram (2), sleontenko (2), slonce70 (2), Sreekaran Srinath (2), Steve Caldwell (2), Tak hoffman (2), ThanhNguyxn (2), Travis (2), tsavo (2), VAC (2), VACInc (2), Val Alexander (2), william arzt (2), Yida-Dev (2), Yudong Han (2), /noctivoro-x (1), 0oAstro (1), 0xJonHoldsCrypto (1), A. Duk (1), Abdullah (1), Abhay (1), abhijeet117 (1), adityashaw2 (1), Advait Paliwal (1), AG (1), Aisling Cahill (1), AJ (1), AJ (@techfren) (1), Akshay (1), Al (1), alejandro maza (1), Alex Alaniz (1), Alex Atallah (1), Alex Zaytsev (1), alexstyl (1), Amit Biswal (1), Andranik Sahakyan (1), Andre Foeken (1), Arnav Gupta (1), Asleep (1), Aviral (1), Ayaan Zaidi (1), baccula (1), Ben Stein (1), bonald (1), bqcfjwhz85-arch (1), Bradley Priest (1), bravostation (1), Bruno Guidolim (1), buddyh (1), Caelum (1), calvin-hpnet (1), Chase Dorsey (1), chenglun.hu (1), Chloe (1), Chris Eidhof (1), Claude Code (1), Clawdbot Maintainers (1), cristip73 (1), Daijiro Miyazawa (1), Dan Ballance (1), Daniel Griesser (1), danielcadenhead (1), Darshil (1), Dave Onkels (1), David Gelberg (1), David Iach (1), David Marsh (1), Denys Vitali (1), DEOKLYONG MOON (1), Dimitrios Ploutarchos (1), Django Navarro (1), Dominic (1), Drake Thomsen (1), Dylan Neve (1), elliotsecops (1), Eric Su (1), Ethan Palm (1), Evan (1), Evan Otero (1), Evan Reid (1), ezhikkk (1), f-trycua (1), Felix Krause (1), Frank Harris (1), Frank Yang (1), fujiwara-tofu-shop (1), Ganghyun Kim (1), ganghyun kim (1), George Tsifrikas (1), gerardward2007 (1), gitpds (1), Hasan FLeyah (1), hcl (1), Hiren Patel (1), HirokiKobayashi-R (1), Hisleren (1), hlbbbbbbb (1), Hudson Rivera (1), Hugo Baraúna (1), humanwritten (1), Hunter Miller (1), hyaxia (1), hyf0-agent (1), Iamadig (1), Igor Markelov (1), Iranb (1), ironbyte-rgb (1), Ivan Casco (1), Jabez Borja (1), Jamie Openshaw (1), Jane (1), jarvis89757 (1), jasonsschin (1), Jay Hickey (1), jaydenfyi (1), Jefferson Nunn (1), Jeremiah Lowin (1), Ji (1), jigar (1), joeynyc (1), John Rood (1), Jon Uleis (1), Josh Long (1), Josh Phillips (1), joshrad-dev (1), Justin Ling (1), Kasper Neist Christjansen (1), Kenny Lee (1), Kentaro Kuribayashi (1), Kevin Lin (1), Kimitaka Watanabe (1), Kira (1), Kiran Jd (1), kitze (1), Kristijan Jovanovski (1), Lalit Singh (1), Levi Figueira (1), Liu Weizhan (1), Lloyd (1), Loganaden Velvindron (1), lsh411 (1), Lucas Kim (1), Luka Zhang (1), Lukin (1), Lukáš Loukota (1), Lutro (1), M00N7682 (1), mac mimi (1), magendary (1), Magi Metal (1), Maksym Brashchenko (1), Manik Vahsith (1), Manuel Hettich (1), Marc Beaupre (1), Marcus Neves (1), Mark Liu (1), Markus Buhatem Koch (1), Martin Púčik (1), Martin Schürrer (1), Matias Wainsten (1), Matt Ezell (1), Matt mini (1), Matthew Dicembrino (1), MattQ (1), Mauro Bolis (1), Max (1), Mert Çiçekçi (1), Michael Lee (1), Miles (1), minghinmatthewlam (1), Mourad Boustani (1), mudrii (1), Mustafa Tag Eldeen (1), myfunc (1), Mykyta Bozhenko (1), Nachx639 (1), Nate (1), Neo (1), Netanel Draiman (1), NickSpisak\_ (1), nicolasstanley (1), nonggia.liang (1), Ogulcan Celik (1), Oleg Kossoy (1), Omar Khaleel (1), Onur Solmaz (1), Oren (1), Ozgur Polat (1), pasogott (1), Patrick Shao (1), Paul Pamment (1), Paul van Oorschot (1), Paulo Portella (1), peetzweg/ (1), Petra Donka (1), Petter Blomberg (1), Pham Nam (1), plum-dawg (1), pookNast (1), Pratham Dubey (1), Quentin (1), rafaelreis-r (1), Rajat Joshi (1), Rami Abdelrazzaq (1), Raphael Borg Ellul Vincenti (1), Ratul Sarna (1), Raymond Berger (1), Riccardo Giorato (1), Richard Pinedo (1), Rob Axelsen (1), robhparker (1), Rohan Nagpal (1), Rohan Patil (1), Rolf Fredheim (1), Rony Kelner (1), ryan (1), Ryan Nelson (1), Samrat Jha (1), seans-openclawbot (1), Sebastian Barrios (1), Senol Dogan (1), Sergiy Dybskiy (1), Shadril Hassan Shifat (1), Shailesh (1), shatner (1), Shaun Loo (1), Shiva Prasad (1), Shivam Kumar Raut (1), Shrinija Kummari (1), Siddhant Jain (1), Simon Kelly (1), Soumyadeep Ghosh (1), spiceoogway (1), Stefan Galescu (1), Stephen Brian King (1), Stephen Chen (1), succ985 (1), Suksham (1), Suvin Nimnaka (1), techboss (1), tewatia (1), The Admiral (1), therealZpoint-bot (1), TideFinder (1), Tim Krase (1), Timo Lins (1), Tom McKenzie (1), tosh-hamburg (1), Travis Hinton (1), Travis Irby (1), uos-status (1), Vasanth Rao Naik Sabavat (1), Vibe Kanban (1), Victor Castell (1), Vincent Koc (1), void (1), Vultr-Clawd Admin (1), wangai-studio (1), Wangnov (1), Wilkins (1), William Stock (1), Wimmie (1), wolfred (1), Xin (1), Xin (1), Xu Haoran (1), Yazin (1), Yeom-JinHo (1), Yevhen Bobrov (1), Yi Wang (1), ymat19 (1), ysqander (1), Yuan Chen (1), Yuanhai (1), Yuting Lin (1), zhixian (1), {Suksham-sharma} (1) -- **Jos** - Telegram, API, Nix mode - - GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes) +_Last updated: 2026-02-10 10:03 UTC_ -- **Christoph Nakazawa** - JS Infra - - GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa) - -- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI - - GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras) - -- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity - - GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler) - -## Core contributors - -- **Maxim Vovshin** (@Hyaxia, [36747317+Hyaxia@users.noreply.github.com](mailto:36747317+Hyaxia@users.noreply.github.com)) - Blogwatcher skill -- **Nacho Iacovino** (@nachoiacovino, [nacho.iacovino@gmail.com](mailto:nacho.iacovino@gmail.com)) - Location parsing (Telegram and WhatsApp) +Keep in sync with scripts/sync-credits.py ## License diff --git a/scripts/sync-credits.py b/scripts/sync-credits.py new file mode 100644 index 0000000000..b6df6b6d14 --- /dev/null +++ b/scripts/sync-credits.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +""" +Sync maintainers and contributors in docs/reference/credits.md from git/GitHub. + +- Maintainers: people who have merged PRs (via GitHub API) + direct pushes to main +- Contributors: all unique commit authors on main with commit counts + +Usage: python scripts/sync-credits.py +""" + +import re +import subprocess +from datetime import datetime, timezone +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent +CREDITS_FILE = REPO_ROOT / "docs" / "reference" / "credits.md" +REPO = "openclaw/openclaw" + +# Exclude bot accounts from maintainer list +EXCLUDED_MAINTAINERS = { + "app/clawdinator", + "clawdinator", + "github-actions", + "dependabot", +} + +# Exclude bot/system names from contributor list +EXCLUDED_CONTRIBUTORS = { + "GitHub", + "github-actions[bot]", + "dependabot[bot]", + "clawdinator[bot]", + "blacksmith-sh[bot]", + "google-labs-jules[bot]", + "Maude Bot", + "Pocket Clawd", + "Ghost", + "Gregor's Bot", + "Jarvis", + "Jarvis Deploy", + "CI", + "Ubuntu", + "user", + "Developer", +} + +# Minimum merged PRs to be considered a maintainer +MIN_MERGES = 2 + + +# Regex to extract GitHub username from noreply email +# Matches: ID+username@users.noreply.github.com or username@users.noreply.github.com +GITHUB_NOREPLY_RE = re.compile(r"^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$", re.I) + + +def extract_github_username(email: str) -> str | None: + """Extract GitHub username from noreply email, or return None.""" + match = GITHUB_NOREPLY_RE.match(email) + return match.group(1).lower() if match else None + + +def run_git(*args: str) -> str: + """Run git command and return stdout.""" + result = subprocess.run( + ["git", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=True, + ) + return result.stdout.strip() + + +def run_gh(*args: str) -> str: + """Run gh CLI command and return stdout.""" + result = subprocess.run( + ["gh", *args], + cwd=REPO_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=True, + ) + return result.stdout.strip() + + +def get_maintainers() -> list[tuple[str, int, int]]: + """Get maintainers with (login, merge_count, direct_push_count). + + - Merges: from GitHub API (who clicked "merge") + - Direct pushes: non-merge commits to main (by committer name matching login) + """ + # 1. Fetch ALL merged PRs using gh pr list (handles pagination automatically) + print(" Fetching merged PRs from GitHub API...") + output = run_gh( + "pr", + "list", + "--repo", + REPO, + "--state", + "merged", + "--limit", + "10000", + "--json", + "mergedBy", + "--jq", + ".[].mergedBy.login", + ) + + merge_counts: dict[str, int] = {} + if output: + for login in output.strip().splitlines(): + login = login.strip() + if login and login not in EXCLUDED_MAINTAINERS: + merge_counts[login] = merge_counts.get(login, 0) + 1 + + print( + f" Found {sum(merge_counts.values())} merged PRs by {len(merge_counts)} users" + ) + + # 2. Count direct pushes (non-merge commits by committer) + # Use GitHub username from noreply emails, or committer name as fallback + print(" Counting direct pushes from git history...") + push_counts: dict[str, int] = {} + output = run_git("log", "main", "--no-merges", "--format=%cN|%cE") + for line in output.splitlines(): + line = line.strip() + if not line or "|" not in line: + continue + name, email = line.rsplit("|", 1) + name = name.strip() + email = email.strip().lower() + if not name or name in EXCLUDED_CONTRIBUTORS: + continue + + # Use GitHub username from noreply email if available, else committer name + gh_user = extract_github_username(email) + if gh_user: + key = gh_user + else: + key = name.lower() + push_counts[key] = push_counts.get(key, 0) + 1 + + # 3. Build maintainer list: anyone with merges >= MIN_MERGES + maintainers: list[tuple[str, int, int]] = [] + + for login, merges in merge_counts.items(): + if merges >= MIN_MERGES: + # Try to find matching push count (case-insensitive) + pushes = push_counts.get(login.lower(), 0) + maintainers.append((login, merges, pushes)) + + # Sort by total activity (merges + pushes) descending + maintainers.sort(key=lambda x: (-(x[1] + x[2]), x[0].lower())) + return maintainers + + +def get_contributors() -> list[tuple[str, int]]: + """Get all unique commit authors on main with commit counts. + + Merges authors by: + 1. GitHub username (extracted from noreply emails) + 2. Author name matching a known GitHub username + 3. Display name (case-insensitive) as final fallback + """ + output = run_git("log", "main", "--format=%aN|%aE") + if not output: + return [] + + # First pass: collect all known GitHub usernames from noreply emails + known_github_users: set[str] = set() + + for line in output.splitlines(): + line = line.strip() + if not line or "|" not in line: + continue + _, email = line.rsplit("|", 1) + email = email.strip().lower() + if not email: + continue + gh_user = extract_github_username(email) + if gh_user: + known_github_users.add(gh_user) + + # Second pass: count commits and pick canonical names + # Key priority: gh:username > name:lowercasename + counts: dict[str, int] = {} + canonical: dict[str, str] = {} # key -> preferred display name + + for line in output.splitlines(): + line = line.strip() + if not line or "|" not in line: + continue + name, email = line.rsplit("|", 1) + name = name.strip() + email = email.strip().lower() + if not name or not email or name in EXCLUDED_CONTRIBUTORS: + continue + + # Determine the merge key: + # 1. If email is a noreply email, use the extracted GitHub username + # 2. If the author name matches a known GitHub username, use that + # 3. Otherwise use the display name (case-insensitive) + gh_user = extract_github_username(email) + if gh_user: + key = f"gh:{gh_user}" + elif name.lower() in known_github_users: + key = f"gh:{name.lower()}" + else: + key = f"name:{name.lower()}" + + counts[key] = counts.get(key, 0) + 1 + + # Prefer capitalized version, or longer name (more specific) + if key not in canonical or ( + (name[0].isupper() and not canonical[key][0].isupper()) + or ( + name[0].isupper() == canonical[key][0].isupper() + and len(name) > len(canonical[key]) + ) + ): + canonical[key] = name + + # Build list with counts, sorted by count descending then name + contributors = [(canonical[key], count) for key, count in counts.items()] + contributors.sort(key=lambda x: (-x[1], x[0].lower())) + return contributors + + +def update_credits( + maintainers: list[tuple[str, int, int]], contributors: list[tuple[str, int]] +) -> None: + """Update the credits.md file with maintainers and contributors.""" + content = CREDITS_FILE.read_text(encoding="utf-8") + + # Build maintainers section (GitHub usernames with profile links) + maintainer_lines = [] + for login, merges, pushes in maintainers: + if pushes > 0: + line = f"- [@{login}](https://github.com/{login}) ({merges} merges, {pushes} direct pushes)" + else: + line = f"- [@{login}](https://github.com/{login}) ({merges} merges)" + maintainer_lines.append(line) + + maintainer_section = ( + "\n".join(maintainer_lines) + if maintainer_lines + else "_No maintainers detected._" + ) + + # Build contributors section with commit counts + contributor_lines = [f"{name} ({count})" for name, count in contributors] + contributor_section = ( + ", ".join(contributor_lines) + if contributor_lines + else "_No contributors detected._" + ) + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + contributor_section = f"{len(contributors)} contributors: {contributor_section}\n\n_Last updated: {timestamp}_" + + # Replace sections by finding markers and rebuilding + lines = content.split("\n") + result = [] + skip_until_next_section = False + i = 0 + + while i < len(lines): + line = lines[i] + + if line == "## Maintainers": + result.append(line) + result.append("") + result.append(maintainer_section) + skip_until_next_section = True + i += 1 + continue + + if line == "## Contributors": + result.append("") + result.append(line) + result.append("") + result.append(contributor_section) + skip_until_next_section = True + i += 1 + continue + + # Check if we hit the next section + if skip_until_next_section and ( + line.startswith("## ") or line.startswith("> ") + ): + skip_until_next_section = False + result.append("") # blank line before next section + + if not skip_until_next_section: + result.append(line) + + i += 1 + + content = "\n".join(result) + CREDITS_FILE.write_text(content, encoding="utf-8") + print(f"Updated {CREDITS_FILE}") + print(f" Maintainers: {len(maintainers)}") + print(f" Contributors: {len(contributors)}") + + +def main() -> None: + print("Syncing credits from git/GitHub...") + maintainers = get_maintainers() + contributors = get_contributors() + update_credits(maintainers, contributors) + + +if __name__ == "__main__": + main()