Add maintainer automation for repo-state hygiene so contributor acknowledgements, count-sensitive docs, and GitHub About metadata stay aligned from the same workflow. Cover the new scripts with regression tests and wire them into the local test suite to keep future maintenance changes from drifting silently.
134 lines
4.3 KiB
Python
134 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from _project_paths import find_repo_root
|
|
from update_readme import configure_utf8_output, load_metadata
|
|
|
|
|
|
CONTRIBUTOR_SECTION_START = "We officially thank the following contributors for their help in making this repository awesome!\n\n"
|
|
SPECIAL_LINK_OVERRIDES = {
|
|
"Copilot": "https://github.com/apps/copilot-swe-agent",
|
|
"github-actions[bot]": "https://github.com/apps/github-actions",
|
|
"copilot-swe-agent[bot]": "https://github.com/apps/copilot-swe-agent",
|
|
}
|
|
|
|
|
|
def parse_existing_contributor_links(content: str) -> dict[str, str]:
|
|
links: dict[str, str] = {}
|
|
pattern = re.compile(r"^- \[@(?P<label>.+?)\]\((?P<url>https://github\.com/.+?)\)$")
|
|
for line in content.splitlines():
|
|
match = pattern.match(line.strip())
|
|
if not match:
|
|
continue
|
|
links[match.group("label")] = match.group("url")
|
|
return links
|
|
|
|
|
|
def parse_contributors_response(payload: list[dict]) -> list[str]:
|
|
contributors: list[str] = []
|
|
seen: set[str] = set()
|
|
for entry in payload:
|
|
login = entry.get("login")
|
|
if not isinstance(login, str) or not login or login in seen:
|
|
continue
|
|
seen.add(login)
|
|
contributors.append(login)
|
|
return contributors
|
|
|
|
|
|
def infer_contributor_url(login: str, existing_links: dict[str, str]) -> str:
|
|
if login in existing_links:
|
|
return existing_links[login]
|
|
if login in SPECIAL_LINK_OVERRIDES:
|
|
return SPECIAL_LINK_OVERRIDES[login]
|
|
if login.endswith("[bot]"):
|
|
app_name = login[: -len("[bot]")]
|
|
return f"https://github.com/apps/{app_name}"
|
|
return f"https://github.com/{login}"
|
|
|
|
|
|
def render_contributor_lines(contributors: list[str], existing_links: dict[str, str]) -> str:
|
|
lines = []
|
|
for login in contributors:
|
|
url = infer_contributor_url(login, existing_links)
|
|
lines.append(f"- [@{login}]({url})")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def update_repo_contributors_section(content: str, contributors: list[str]) -> str:
|
|
existing_links = parse_existing_contributor_links(content)
|
|
rendered_list = render_contributor_lines(contributors, existing_links)
|
|
|
|
if CONTRIBUTOR_SECTION_START not in content or "\n## " not in content:
|
|
raise ValueError("README.md does not contain the expected Repo Contributors section structure.")
|
|
|
|
start_index = content.index(CONTRIBUTOR_SECTION_START) + len(CONTRIBUTOR_SECTION_START)
|
|
end_index = content.index("\n## ", start_index)
|
|
return f"{content[:start_index]}{rendered_list}\n{content[end_index:]}"
|
|
|
|
|
|
def fetch_contributors(repo: str) -> list[str]:
|
|
result = subprocess.run(
|
|
[
|
|
"gh",
|
|
"api",
|
|
f"repos/{repo}/contributors?per_page=100",
|
|
"--paginate",
|
|
"--slurp",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
payload = json.loads(result.stdout)
|
|
flat_entries: list[dict] = []
|
|
for page in payload:
|
|
if isinstance(page, list):
|
|
flat_entries.extend(entry for entry in page if isinstance(entry, dict))
|
|
return parse_contributors_response(flat_entries)
|
|
|
|
|
|
def sync_contributors(base_dir: str | Path, dry_run: bool = False) -> bool:
|
|
root = Path(base_dir)
|
|
metadata = load_metadata(str(root))
|
|
contributors = fetch_contributors(metadata["repo"])
|
|
readme_path = root / "README.md"
|
|
original = readme_path.read_text(encoding="utf-8")
|
|
updated = update_repo_contributors_section(original, contributors)
|
|
|
|
if updated == original:
|
|
return False
|
|
|
|
if dry_run:
|
|
print(f"[dry-run] Would update contributors in {readme_path}")
|
|
return True
|
|
|
|
readme_path.write_text(updated, encoding="utf-8", newline="\n")
|
|
print(f"✅ Updated contributors in {readme_path}")
|
|
return True
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Synchronize the README Repo Contributors section.")
|
|
parser.add_argument("--dry-run", action="store_true", help="Preview contributor changes without writing files.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
root = find_repo_root(__file__)
|
|
sync_contributors(root, dry_run=args.dry_run)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
configure_utf8_output()
|
|
sys.exit(main())
|