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