mirror of
https://github.com/uutils/coreutils-tracking.git
synced 2026-10-16 03:23:31 -07:00
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.
150 lines
5.0 KiB
Python
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())
|