diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e91be8d..c0c7cb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ Prerequisites for the full `make check`: Python 3.10 or newer, Node 18 or newer | `check_explainer_structure.py` | An explainer is missing a required section (definition, why it matters, concrete example, detection code, limitations, related concepts, further reading), has no `python` block, has fewer than two Further Reading entries, or lacks the footer line | Add the section (the check accepts the usual heading variants, e.g. "Why This Matters"); pages with a known gap are listed in `KNOWN_EXCEPTIONS` in the script, and an exception is removed when its page is fixed | | `check_traction.py` | Stars, forks, contributors, watching, social reach, countries or audits differ between README, ROADMAP and METRICS, or the README's "Show N completed items" differs from its `- [x]` lines | Edit the figure in the file(s) the message names so they agree; the README's "External Contributors" is one below the total; `--live` (opt-in, needs `gh`, run weekly by `traction-live.yml`) also compares stars, forks, watching and contributors with GitHub | | `check_dataset_sources.py` | A URL in a comment of an audit's `unfair.py` or `fair.py` is not on that audit's `**Dataset:**` line in the README | Change the link in both places so they match (a script with no link is fine) | -| `check_contributors.py` | An entry in CONTRIBUTORS.md has a merged-PR count or first-merged date that differs from `gh pr list --state merged` (counted only up to the PR the snapshot line names), an entry is out of order (PRs, then commits, then first merge), or a merged-PR author has no entry | Edit the entry's `**N merged PRs · M commits · first merged DATE**` line or move it; run it offline with `--prs FILE` (JSON from `gh pr list --state merged --limit 5000 --json number,author,mergedAt`); it is skipped when `gh` is missing | +| `check_contributors.py` | An entry in CONTRIBUTORS.md has a merged-PR count or first-merged date that differs from `gh pr list --state merged` (counted only up to the PR the snapshot line names), an entry is out of order (PRs, then commits, then first merge), or a merged-PR author has no entry | Run `python3 scripts/check_contributors.py --fix` to refresh known counts/dates and restore order; it leaves commit counts and profile prose unchanged. Use `--prs FILE` to supply saved JSON from `gh pr list --state merged --limit 5000 --json number,author,mergedAt`; without GitHub data the fixer refuses to write | | `key-fold-check` | The JavaScript case-folding table `KEY_FOLD` no longer matches Python | `make key-fold`, then commit `assets/profiler-engine.js` | | A test that runs a README command | Every `faircode ...` line in the README, CONTRIBUTING and SPEC code blocks is checked against the CLI's argument parser; one of them uses a flag or value that no longer exists | Fix the example or the CLI | | `check_generated_files_current.py` (CI) | Generated explainer pages, sitemap, OG images or `llms-full.txt` are out of date | `make build-explainers`, then commit the result | diff --git a/scripts/check_contributors.py b/scripts/check_contributors.py index ab97fb2..54332cd 100644 --- a/scripts/check_contributors.py +++ b/scripts/check_contributors.py @@ -18,9 +18,11 @@ python3 scripts/check_contributors.py # asks `gh` for the merged pull requests python3 scripts/check_contributors.py --prs FILE # FILE: gh pr list --state merged --limit 5000 --json number,author,mergedAt + python3 scripts/check_contributors.py --fix # refresh known counts/dates and sort entries in place Without `gh` (or a network) it prints a note and exits 0, so `make lint` works offline; pass `--require` to make -that an error. Exit code: 0 = consistent (or skipped), 1 = a stale count, date, order or missing entry. +that an error. `--fix` requires GitHub data or `--prs FILE` and preserves entry prose and commit counts. Exit code: +0 = consistent (or skipped), 1 = a stale count, date, order or missing entry. """ from __future__ import annotations @@ -118,6 +120,53 @@ def check(entries: list[dict], prs: list[dict], through: int | None) -> tuple[li return problems, skipped +def fix_document(text: str, prs: list[dict], through: int | None) -> str: + """Refresh known PR counts/dates and order entries without editing their prose.""" + entries, _ = parse(text) + authors = merged_by_author(prs, through) + by_login = {login.lower(): info for login, info in authors.items()} + refreshed_entries = [] + for entry in entries: + refreshed = dict(entry) + actual = by_login.get(entry["handle"].lower()) + if actual is not None and not entry["deleted"]: + refreshed["merged"] = actual["count"] + refreshed["first"] = actual["first"] + refreshed_entries.append(refreshed) + + section_heads = [(m.start(), m.group(1)) for m in re.finditer(r"^## (.+)$", text, re.MULTILINE)] + entry_matches = list(ENTRY.finditer(text)) + replacements = [] + stats_pattern = re.compile(r"\*\*\d+ merged PRs? · \d+ commits? · first merged \d{4}-\d{2}-\d{2}\*\*") + for section_index, (section_start, section) in enumerate(section_heads): + if section not in SECTIONS: + continue + section_end = section_heads[section_index + 1][0] if section_index + 1 < len(section_heads) else len(text) + matches = [m for m in entry_matches if section_start < m.start() < section_end] + if not matches: + continue + start = matches[0].start() + section_entries = [entry for entry in refreshed_entries if entry["section"] == section] + blocks = [] + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else section_end + block = text[match.start():end] + entry = section_entries[index] + stats = (f"**{entry['merged']} merged PR{'s' if entry['merged'] != 1 else ''} · " + f"{entry['commits']} commit{'s' if entry['commits'] != 1 else ''} · " + f"first merged {entry['first']}**") + block, count = stats_pattern.subn(stats, block, count=1) + if count != 1: + raise ValueError(f"could not locate statistics line for {entry['handle']}") + blocks.append((entry, block)) + blocks.sort(key=lambda item: (-item[0]["merged"], -item[0]["commits"], item[0]["first"])) + replacements.append((start, section_end, "".join(block for _, block in blocks))) + + for start, end, replacement in reversed(replacements): + text = text[:start] + replacement + text[end:] + return text + + def fetch_prs() -> list[dict] | None: if shutil.which("gh") is None: return None @@ -131,17 +180,29 @@ def main(argv=None) -> int: parser.add_argument("--file", type=Path, default=ROOT / "CONTRIBUTORS.md") parser.add_argument("--prs", metavar="FILE", help="merged pull requests as JSON instead of asking gh") parser.add_argument("--require", action="store_true", help="fail instead of skipping when GitHub cannot be reached") + parser.add_argument("--fix", action="store_true", help="rewrite known PR counts/dates and sort contributor entries") args = parser.parse_args(argv) - entries, through = parse(args.file.read_text(encoding="utf-8")) + text = args.file.read_text(encoding="utf-8") + entries, through = parse(text) if not entries: print(f"error: no contributor entries found in {args.file}") return 1 prs = json.loads(Path(args.prs).read_text(encoding="utf-8")) if args.prs else fetch_prs() if prs is None: + if args.fix: + print("error: --fix needs merged pull request data from GitHub or --prs FILE") + return 2 print("note: could not fetch merged pull requests from GitHub (gh missing or offline); " "skipping the contributors check") return 1 if args.require else 0 + if args.fix: + fixed = fix_document(text, prs, through) + if fixed != text: + args.file.write_text(fixed, encoding="utf-8") + print(f"updated: {args.file}") + text = fixed + entries, through = parse(text) if through is None: print("note: no 'covering everything merged through **PR #N**' snapshot line found; counting every merged PR") diff --git a/tests/test_check_contributors.py b/tests/test_check_contributors.py index 42171e4..da5b800 100644 --- a/tests/test_check_contributors.py +++ b/tests/test_check_contributors.py @@ -91,6 +91,46 @@ def test_a_mis_sorted_section_fails(tmp_path, capsys): assert code == 1 and "Contributors: entry 1 is bob" in out and "puts carol" in out +def test_fix_refreshes_counts_dates_and_order_without_changing_entry_prose(tmp_path, capsys): + bob = ("### Bob Builder - [@bob](https://github.com/bob)\n\n" + "**1 merged PR · 4 commits · first merged 2026-06-08**\n\n" + "bob's original profile prose stays exactly as written.\n\n") + carol = ("### Carol - [@carol](https://github.com/carol)\n\n" + "**3 merged PRs · 3 commits · first merged 2026-06-09**\n\n" + "carol's original profile prose stays exactly as written.\n\n") + doc = DOC[:DOC.index("### Bob Builder")] + doc += carol + bob + DOC[DOC.index("### Old Timer"):] + # Carol is deliberately ahead of Bob and has the wrong count and date. + path = tmp_path / "CONTRIBUTORS.md" + prs_path = tmp_path / "prs.json" + path.write_text(doc, encoding="utf-8") + prs_path.write_text(json.dumps(PRS), encoding="utf-8") + + assert script.main(["--file", str(path), "--prs", str(prs_path), "--fix"]) == 0 + output = capsys.readouterr().out + fixed = path.read_text(encoding="utf-8") + assert "updated:" in output and "OK: 4 entries" in output + assert fixed.index("Bob Builder") < fixed.index("Carol") + assert "**2 merged PRs · 4 commits · first merged 2026-06-01**" in fixed + assert "**2 merged PRs · 3 commits · first merged 2026-06-02**" in fixed + assert "bob's original profile prose stays exactly as written." in fixed + assert "carol's original profile prose stays exactly as written." in fixed + + assert script.main(["--file", str(path), "--prs", str(prs_path), "--fix"]) == 0 + assert "updated:" not in capsys.readouterr().out + assert path.read_text(encoding="utf-8") == fixed + + +def test_fix_requires_pr_data_and_does_not_change_the_file(tmp_path, capsys, monkeypatch): + path = tmp_path / "CONTRIBUTORS.md" + path.write_text(DOC, encoding="utf-8") + monkeypatch.setattr(script, "fetch_prs", lambda: None) + + assert script.main(["--file", str(path), "--fix"]) == 2 + assert "needs merged pull request data" in capsys.readouterr().out + assert path.read_text(encoding="utf-8") == DOC + + def test_an_author_with_no_entry_fails_but_the_maintainer_and_bots_do_not(tmp_path, capsys): code, out = run(tmp_path, capsys, prs=PRS + [pr(12, "dave", "2026-07-02")]) assert code == 1 and "dave: 1 merged PRs on GitHub but no entry" in out