Files
Sylvestre Ledru 184cca774e Track commits and contributors for GNU and uutils coreutils
Add activity_count.py, which walks both git histories and records, per
month since 2021: non-merge commits, active contributors and cumulative
distinct contributors. Authors are deduplicated via mailmap and bots are
excluded. The cumulative count covers each project's full history, not
just the reported range.

activity-graph.py renders the three metrics as stacked panels sharing an
x-axis, in the same style as the other graphs.

The CI job checks out both histories (blobless, full depth), regenerates
the data and commits the refreshed graph, like the other trackers.

Also fix the lint errors the latest ruff reports on the existing files,
which were failing the lint job.
2026-08-02 23:54:00 +02:00

150 lines
5.0 KiB
Python

# This file is part of the uutils coreutils package.
#
# For the full copyright and license information, please view the LICENSE
# file that was distributed with this source code.
"""Count monthly commits and contributors for GNU and uutils coreutils.
For each project, walk the git history since `--since` and bucket every
non-merge commit by its author month (UTC). We record, per month:
commits — number of non-merge commits
authors — distinct authors active that month
cumulative_authors — distinct authors seen since the project's first
commit (not just since `--since`)
Authors are deduplicated by mailmapped email (lowercased); bots
(dependabot, renovate, github-actions, …) are excluded from both counts.
The result is a JSON object keyed by month (`YYYY-MM`), each holding one
entry per project:
{"2021-01": {"gnu": {"commits": "42", ...}, "uutils": {...}}, ...}
"""
import argparse
import json
import re
import subprocess
import sys
from datetime import date, datetime, timedelta, timezone
BOT_PATTERNS = [
re.compile(r"\[bot\]"),
re.compile(r"\bdependabot\b"),
re.compile(r"\brenovate\b"),
re.compile(r"github-actions"),
re.compile(r"^actions@github\.com$"),
]
def is_bot(email: str, name: str) -> bool:
haystack = f"{email} {name}".lower()
return any(p.search(haystack) for p in BOT_PATTERNS)
def git(repo: str, *args: str) -> str:
return subprocess.check_output(["git", "-C", repo, *args], text=True)
def month_range(since: str, until: str) -> list[str]:
"""All `YYYY-MM` keys from `since` up to `until` (exclusive)."""
start = date.fromisoformat(f"{since[:7]}-01")
# `until` is exclusive, so the last reported month is the one holding the
# day before it.
end = date.fromisoformat(until[:10]) - timedelta(days=1)
months = []
y, m = start.year, start.month
while (y, m) <= (end.year, end.month):
months.append(f"{y:04d}-{m:02d}")
m += 1
if m == 13:
y, m = y + 1, 1
return months
def collect(repo: str, since: str, until: str, rev: str) -> dict[str, dict[str, str]]:
"""Per-month commit/author counts for `repo` in [since, until).
Only the reported months are limited by `since`: the whole history up to
`until` is walked, so `cumulative_authors` counts every contributor the
project ever had, not just those since `since`.
"""
log = git(
repo,
"log",
rev,
"--no-merges",
"--use-mailmap",
f"--until={until}",
"--date=format-local:%Y-%m",
"--pretty=format:%ad\x1f%aE\x1f%aN",
)
per_month: dict[str, dict] = {}
for line in log.splitlines():
if not line.strip():
continue
month, email, name = line.split("\x1f")
if is_bot(email, name):
continue
bucket = per_month.setdefault(month, {"commits": 0, "authors": set()})
bucket["commits"] += 1
bucket["authors"].add(email.lower())
result = {}
seen: set[str] = set()
reported = set(month_range(since, until))
# Walk every month the repo has, oldest first, so the cumulative author
# set carries the pre-`since` history into the first reported month.
for month in sorted(per_month.keys() | reported):
bucket = per_month.get(month, {"commits": 0, "authors": set()})
seen |= bucket["authors"]
if month not in reported:
continue
result[month] = {
"commits": str(bucket["commits"]),
"authors": str(len(bucket["authors"])),
"cumulative_authors": str(len(seen)),
}
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("gnu", help="path to a coreutils/coreutils git checkout")
parser.add_argument("uutils", help="path to a uutils/coreutils git checkout")
parser.add_argument(
"--since",
default="2021-01-01",
help="first month to report (default: 2021-01-01)",
)
parser.add_argument(
"--until",
default=None,
help="exclusive end date (default: the 1st of the current month)",
)
parser.add_argument("--gnu-rev", default="HEAD", help="GNU revision to walk")
parser.add_argument("--uutils-rev", default="HEAD", help="uutils revision to walk")
args = parser.parse_args()
# Default to the start of the current month: the running month is always
# partial, and a half-counted month reads as a collapse in activity.
if args.until is None:
today = datetime.now(timezone.utc).date()
args.until = date(today.year, today.month, 1).isoformat()
gnu = collect(args.gnu, args.since, args.until, args.gnu_rev)
uutils = collect(args.uutils, args.since, args.until, args.uutils_rev)
merged = {
month: {"gnu": gnu[month], "uutils": uutils[month]} for month in sorted(gnu)
}
json.dump(merged, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())