Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
65 changes: 63 additions & 2 deletions scripts/check_contributors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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")

Expand Down
40 changes: 40 additions & 0 deletions tests/test_check_contributors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading