mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:48:39 +00:00
credits: categorize direct changes, exclude bots, fix MDX (#13322)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -43,6 +43,17 @@ EXCLUDED_CONTRIBUTORS = {
|
|||||||
"Ubuntu",
|
"Ubuntu",
|
||||||
"user",
|
"user",
|
||||||
"Developer",
|
"Developer",
|
||||||
|
# Bot names that appear in git history
|
||||||
|
"CLAWDINATOR Bot",
|
||||||
|
"Clawd",
|
||||||
|
"Clawdbot",
|
||||||
|
"Clawdbot Maintainers",
|
||||||
|
"Claude Code",
|
||||||
|
"L36 Server",
|
||||||
|
"seans-openclawbot",
|
||||||
|
"therealZpoint-bot",
|
||||||
|
"Vultr-Clawd Admin",
|
||||||
|
"hyf0-agent",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Minimum merged PRs to be considered a maintainer
|
# Minimum merged PRs to be considered a maintainer
|
||||||
@@ -60,6 +71,11 @@ def extract_github_username(email: str) -> str | None:
|
|||||||
return match.group(1).lower() if match else None
|
return match.group(1).lower() if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_name(name: str) -> str:
|
||||||
|
"""Sanitize name for MDX by removing curly braces (which MDX interprets as JS)."""
|
||||||
|
return name.replace("{", "").replace("}", "").strip()
|
||||||
|
|
||||||
|
|
||||||
def run_git(*args: str) -> str:
|
def run_git(*args: str) -> str:
|
||||||
"""Run git command and return stdout."""
|
"""Run git command and return stdout."""
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -88,11 +104,46 @@ def run_gh(*args: str) -> str:
|
|||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
def get_maintainers() -> list[tuple[str, int, int]]:
|
def categorize_commit_files(files: list[str]) -> str:
|
||||||
"""Get maintainers with (login, merge_count, direct_push_count).
|
"""Categorize a commit based on its changed files.
|
||||||
|
|
||||||
|
Returns: 'ci', 'docs only', 'docs', or 'other'
|
||||||
|
- 'ci': any commit with CI files (.github/, scripts/ci*)
|
||||||
|
- 'docs only': only documentation files (docs/ or any .md)
|
||||||
|
- 'docs': docs + other files mixed
|
||||||
|
- 'other': code without CI or docs
|
||||||
|
"""
|
||||||
|
has_ci = False
|
||||||
|
has_docs = False
|
||||||
|
has_other = False
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
f_lower = f.lower()
|
||||||
|
if f_lower.startswith(".github/") or f_lower.startswith("scripts/ci"):
|
||||||
|
has_ci = True
|
||||||
|
elif f_lower.startswith("docs/") or f_lower.endswith(".md"):
|
||||||
|
has_docs = True
|
||||||
|
else:
|
||||||
|
has_other = True
|
||||||
|
|
||||||
|
# CI takes priority if present
|
||||||
|
if has_ci:
|
||||||
|
return "ci"
|
||||||
|
if has_other:
|
||||||
|
if has_docs:
|
||||||
|
return "docs" # Mixed: docs + other
|
||||||
|
return "other" # Pure code
|
||||||
|
if has_docs:
|
||||||
|
return "docs only" # Pure docs
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def get_maintainers() -> list[tuple[str, int, dict[str, int]]]:
|
||||||
|
"""Get maintainers with (login, merge_count, push_counts_by_category).
|
||||||
|
|
||||||
- Merges: from GitHub API (who clicked "merge")
|
- Merges: from GitHub API (who clicked "merge")
|
||||||
- Direct pushes: non-merge commits to main (by committer name matching login)
|
- Direct pushes: non-merge commits to main (by committer name matching login)
|
||||||
|
categorized into 'ci', 'docs', 'other'
|
||||||
"""
|
"""
|
||||||
# 1. Fetch ALL merged PRs using gh pr list (handles pagination automatically)
|
# 1. Fetch ALL merged PRs using gh pr list (handles pagination automatically)
|
||||||
print(" Fetching merged PRs from GitHub API...")
|
print(" Fetching merged PRs from GitHub API...")
|
||||||
@@ -122,40 +173,78 @@ def get_maintainers() -> list[tuple[str, int, int]]:
|
|||||||
f" Found {sum(merge_counts.values())} merged PRs by {len(merge_counts)} users"
|
f" Found {sum(merge_counts.values())} merged PRs by {len(merge_counts)} users"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Count direct pushes (non-merge commits by committer)
|
# 2. Count direct pushes (non-merge commits by committer) with categories
|
||||||
# Use GitHub username from noreply emails, or committer name as fallback
|
# Use GitHub username from noreply emails, or committer name as fallback
|
||||||
print(" Counting direct pushes from git history...")
|
print(" Counting direct pushes from git history...")
|
||||||
push_counts: dict[str, int] = {}
|
# push_counts[key] = {"ci": N, "docs only": N, "docs": N, "other": N}
|
||||||
output = run_git("log", "main", "--no-merges", "--format=%cN|%cE")
|
push_counts: dict[str, dict[str, int]] = {}
|
||||||
|
|
||||||
|
# Get commits with files using a delimiter to parse
|
||||||
|
output = run_git(
|
||||||
|
"log", "main", "--no-merges", "--format=COMMIT|%cN|%cE", "--name-only"
|
||||||
|
)
|
||||||
|
|
||||||
|
current_key: str | None = None
|
||||||
|
current_files: list[str] = []
|
||||||
|
|
||||||
|
def flush_commit() -> None:
|
||||||
|
nonlocal current_key, current_files
|
||||||
|
if current_key and current_files:
|
||||||
|
category = categorize_commit_files(current_files)
|
||||||
|
if current_key not in push_counts:
|
||||||
|
push_counts[current_key] = {
|
||||||
|
"ci": 0,
|
||||||
|
"docs only": 0,
|
||||||
|
"docs": 0,
|
||||||
|
"other": 0,
|
||||||
|
}
|
||||||
|
push_counts[current_key][category] += 1
|
||||||
|
current_key = None
|
||||||
|
current_files = []
|
||||||
|
|
||||||
for line in output.splitlines():
|
for line in output.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or "|" not in line:
|
if not line:
|
||||||
continue
|
|
||||||
name, email = line.rsplit("|", 1)
|
|
||||||
name = name.strip()
|
|
||||||
email = email.strip().lower()
|
|
||||||
if not name or name in EXCLUDED_CONTRIBUTORS:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Use GitHub username from noreply email if available, else committer name
|
if line.startswith("COMMIT|"):
|
||||||
gh_user = extract_github_username(email)
|
# Flush previous commit
|
||||||
if gh_user:
|
flush_commit()
|
||||||
key = gh_user
|
# Parse new commit
|
||||||
|
parts = line.split("|", 2)
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
_, name, email = parts
|
||||||
|
name = name.strip()
|
||||||
|
email = email.strip().lower()
|
||||||
|
if not name or name in EXCLUDED_CONTRIBUTORS:
|
||||||
|
current_key = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use GitHub username from noreply email if available
|
||||||
|
gh_user = extract_github_username(email)
|
||||||
|
current_key = gh_user if gh_user else name.lower()
|
||||||
else:
|
else:
|
||||||
key = name.lower()
|
# This is a file path
|
||||||
push_counts[key] = push_counts.get(key, 0) + 1
|
if current_key:
|
||||||
|
current_files.append(line)
|
||||||
|
|
||||||
|
# Flush last commit
|
||||||
|
flush_commit()
|
||||||
|
|
||||||
# 3. Build maintainer list: anyone with merges >= MIN_MERGES
|
# 3. Build maintainer list: anyone with merges >= MIN_MERGES
|
||||||
maintainers: list[tuple[str, int, int]] = []
|
maintainers: list[tuple[str, int, dict[str, int]]] = []
|
||||||
|
|
||||||
for login, merges in merge_counts.items():
|
for login, merges in merge_counts.items():
|
||||||
if merges >= MIN_MERGES:
|
if merges >= MIN_MERGES:
|
||||||
# Try to find matching push count (case-insensitive)
|
# Try to find matching push count (case-insensitive)
|
||||||
pushes = push_counts.get(login.lower(), 0)
|
pushes = push_counts.get(
|
||||||
|
login.lower(), {"ci": 0, "docs only": 0, "docs": 0, "other": 0}
|
||||||
|
)
|
||||||
maintainers.append((login, merges, pushes))
|
maintainers.append((login, merges, pushes))
|
||||||
|
|
||||||
# Sort by total activity (merges + pushes) descending
|
# Sort by total activity (merges + sum of pushes) descending
|
||||||
maintainers.sort(key=lambda x: (-(x[1] + x[2]), x[0].lower()))
|
maintainers.sort(key=lambda x: (-(x[1] + sum(x[2].values())), x[0].lower()))
|
||||||
return maintainers
|
return maintainers
|
||||||
|
|
||||||
|
|
||||||
@@ -201,29 +290,34 @@ def get_contributors() -> list[tuple[str, int]]:
|
|||||||
if not name or not email or name in EXCLUDED_CONTRIBUTORS:
|
if not name or not email or name in EXCLUDED_CONTRIBUTORS:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Sanitize name for MDX safety and consistent deduplication
|
||||||
|
sanitized = sanitize_name(name)
|
||||||
|
if not sanitized:
|
||||||
|
continue
|
||||||
|
|
||||||
# Determine the merge key:
|
# Determine the merge key:
|
||||||
# 1. If email is a noreply email, use the extracted GitHub username
|
# 1. If email is a noreply email, use the extracted GitHub username
|
||||||
# 2. If the author name matches a known GitHub username, use that
|
# 2. If the author name matches a known GitHub username, use that
|
||||||
# 3. Otherwise use the display name (case-insensitive)
|
# 3. Otherwise use the sanitized display name (case-insensitive)
|
||||||
gh_user = extract_github_username(email)
|
gh_user = extract_github_username(email)
|
||||||
if gh_user:
|
if gh_user:
|
||||||
key = f"gh:{gh_user}"
|
key = f"gh:{gh_user}"
|
||||||
elif name.lower() in known_github_users:
|
elif sanitized.lower() in known_github_users:
|
||||||
key = f"gh:{name.lower()}"
|
key = f"gh:{sanitized.lower()}"
|
||||||
else:
|
else:
|
||||||
key = f"name:{name.lower()}"
|
key = f"name:{sanitized.lower()}"
|
||||||
|
|
||||||
counts[key] = counts.get(key, 0) + 1
|
counts[key] = counts.get(key, 0) + 1
|
||||||
|
|
||||||
# Prefer capitalized version, or longer name (more specific)
|
# Prefer capitalized version, or longer name (more specific)
|
||||||
if key not in canonical or (
|
if key not in canonical or (
|
||||||
(name[0].isupper() and not canonical[key][0].isupper())
|
(sanitized[0].isupper() and not canonical[key][0].isupper())
|
||||||
or (
|
or (
|
||||||
name[0].isupper() == canonical[key][0].isupper()
|
sanitized[0].isupper() == canonical[key][0].isupper()
|
||||||
and len(name) > len(canonical[key])
|
and len(sanitized) > len(canonical[key])
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
canonical[key] = name
|
canonical[key] = sanitized
|
||||||
|
|
||||||
# Build list with counts, sorted by count descending then name
|
# Build list with counts, sorted by count descending then name
|
||||||
contributors = [(canonical[key], count) for key, count in counts.items()]
|
contributors = [(canonical[key], count) for key, count in counts.items()]
|
||||||
@@ -232,16 +326,29 @@ def get_contributors() -> list[tuple[str, int]]:
|
|||||||
|
|
||||||
|
|
||||||
def update_credits(
|
def update_credits(
|
||||||
maintainers: list[tuple[str, int, int]], contributors: list[tuple[str, int]]
|
maintainers: list[tuple[str, int, dict[str, int]]],
|
||||||
|
contributors: list[tuple[str, int]],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the credits.md file with maintainers and contributors."""
|
"""Update the credits.md file with maintainers and contributors."""
|
||||||
content = CREDITS_FILE.read_text(encoding="utf-8")
|
content = CREDITS_FILE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
# Build maintainers section (GitHub usernames with profile links)
|
# Build maintainers section (GitHub usernames with profile links)
|
||||||
maintainer_lines = []
|
maintainer_lines = []
|
||||||
for login, merges, pushes in maintainers:
|
for login, merges, push_cats in maintainers:
|
||||||
if pushes > 0:
|
total_pushes = sum(push_cats.values())
|
||||||
line = f"- [@{login}](https://github.com/{login}) ({merges} merges, {pushes} direct pushes)"
|
if total_pushes > 0:
|
||||||
|
# Build categorized push breakdown
|
||||||
|
push_parts = []
|
||||||
|
if push_cats.get("ci", 0) > 0:
|
||||||
|
push_parts.append(f"{push_cats['ci']} ci")
|
||||||
|
if push_cats.get("docs only", 0) > 0:
|
||||||
|
push_parts.append(f"{push_cats['docs only']} docs only")
|
||||||
|
if push_cats.get("docs", 0) > 0:
|
||||||
|
push_parts.append(f"{push_cats['docs']} docs")
|
||||||
|
if push_cats.get("other", 0) > 0:
|
||||||
|
push_parts.append(f"{push_cats['other']} other")
|
||||||
|
push_str = ", ".join(push_parts)
|
||||||
|
line = f"- [@{login}](https://github.com/{login}) ({merges} merges, {total_pushes} direct changes: {push_str})"
|
||||||
else:
|
else:
|
||||||
line = f"- [@{login}](https://github.com/{login}) ({merges} merges)"
|
line = f"- [@{login}](https://github.com/{login}) ({merges} merges)"
|
||||||
maintainer_lines.append(line)
|
maintainer_lines.append(line)
|
||||||
@@ -253,7 +360,10 @@ def update_credits(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Build contributors section with commit counts
|
# Build contributors section with commit counts
|
||||||
contributor_lines = [f"{name} ({count})" for name, count in contributors]
|
# Sanitize names to avoid MDX interpreting special characters (like {}) as JS
|
||||||
|
contributor_lines = [
|
||||||
|
f"{sanitize_name(name)} ({count})" for name, count in contributors
|
||||||
|
]
|
||||||
contributor_section = (
|
contributor_section = (
|
||||||
", ".join(contributor_lines)
|
", ".join(contributor_lines)
|
||||||
if contributor_lines
|
if contributor_lines
|
||||||
|
|||||||
Reference in New Issue
Block a user