#!/usr/bin/env python3 """Regenerate FAME.md and FAME.svg, a contributor summary produced by git-fame. FAME.md is the full table, FAME.svg a bar chart of the busiest contributors that FAME.md embeds. The chart is drawn here rather than taken from git-fame's own `--format=svg`, which renders the whole table as monospaced text - fine for a handful of contributors, unreadable for a few hundred. It leaves out bots, which do appear in the table. Usage: python scripts/fame.py [--top N] [--svg-top N] Requires `git-fame` (pip install git-fame) and a full (non-shallow) clone, because the line counts come from `git blame` over the whole history. The statistics always describe the master branch, no matter which branch the script is run from, so that FAME.md does not change depending on the checkout. Contributors are sorted by commit count. git-fame also reports "surviving" lines of code, i.e. lines of the current checkout that `git blame` attributes to an author; that is useful context, but it silently discounts work that was later refactored away, so it is not what we rank by. Author identities are merged via .mailmap, so fix duplicates there rather than here. Both files are left alone if nothing but the generation date would change, so that running this regularly (see .github/workflows/fame.yml) does not produce a diff for a week in which the statistics did not move. """ import argparse import json import re import subprocess from datetime import date from pathlib import Path from xml.sax.saxutils import escape BRANCH = "master" DATE_LINE = re.compile(r"^Generated on \d{4}-\d{2}-\d{2} ", re.MULTILINE) # Bots keep their row in the table - they did make those commits - but they # would take a slot away from a person in the chart, which only has room for a # few. GitHub appends "[bot]" to the account name of a bot, e.g. # "dependabot[bot]"; matching that is safer than guessing from the name. BOT = re.compile(r"\[bot\]$") # Generated and binary-ish files would drown out hand-written code. Excalidraw # scenes are the worst offenders: they are pretty-printed JSON, so a single # diagram of a few boxes and arrows is over 10000 lines. Image formats are # listed for good measure - git-fame skips binary files by itself, but .svg is # text and would count. EXCLUDE = r".*\.(excalidraw|png|jpg|jpeg|gif|svg|ico)$" HEADERS = ("Contributor", "Commits", "Lines", "Files") PREAMBLE = """\ # Contributors {count} people have contributed to Borg, with {commits} commits in total. Thanks to everyone who helped! ![Contributors by commit count](FAME.svg) Generated on {date} by `scripts/fame.py`, which computes the statistics with [git-fame](https://github.com/casperdcl/git-fame), from the `{branch}` branch only - commits that exist solely on other branches or in unmerged pull requests are not counted. This list is only a rough summary: it counts commits and lines, which say nothing about reviews, bug reports, translations, documentation review, support work or funding. See `AUTHORS` for a manually maintained list of contributors. "Lines" are surviving lines of code: lines of the current master that `git blame` attributes to that author. Code that was later rewritten does not show up there, so the number understates early contributions. Generated files (such as the Excalidraw sources of the documentation figures) are excluded. """ # Chart geometry, in user units. The viewport is given in user units plus a # viewBox rather than in em, because em on the root resolves against the # font size that element inherits, not against the font size of the text inside. SVG_TOP = 20 # a chart of all 300+ contributors would be unreadable SVG_WIDTH = 760 SVG_ROW_H = 22 SVG_PAD = 14 SVG_LABEL_W = 240 # x where the bars start, i.e. width of the name column SVG_COUNT_W = 70 # right hand column holding the commit count SVG_HEAD_H = 58 SVG_FOOT_H = 26 SVG_NAME_MAX = 32 # characters that fit into the name column # Fills are set per class so that the chart can follow the reader's colour # scheme; GitHub renders the file in an , where the media query applies. # The bars use the green of the logo (docs/_static/logo.svg). That green is # made for a dark background - on white it only reaches a contrast of 1.9:1, # below the 3:1 that WCAG asks for graphics - so the light scheme darkens it # while keeping the hue. SVG_STYLE = """\ """ def collect(): """Run git-fame and return (rows, total), all contributors, most commits first.""" out = subprocess.check_output( ["git-fame", "--silent-progress", "--format=json", "--sort=commits", f"--excl={EXCLUDE}", f"--branch={BRANCH}"], text=True, ) fame = json.loads(out) rows = [(name, commits, loc, files) for name, loc, commits, files, *_percentages in fame["data"]] # git-fame leaves people with the same commit count in its own order, which # is stable for a given checkout but not across checkouts, so equal ranks # would shuffle from one run to the next. Break the tie ourselves. rows.sort(key=lambda row: (-row[1], -row[2], row[0])) return rows, fame["total"] def render(rows, total, contributors): """Return the full FAME.md contents, listing `rows` out of `contributors` people.""" preamble = PREAMBLE.format( count=f"{contributors:,}", commits=f"{total['commits']:,}", date=date.today().isoformat(), branch=BRANCH ) if len(rows) < contributors: preamble += f"Only the top {len(rows):,} contributors by commit count are listed here.\n\n" table = ["| " + " | ".join(HEADERS) + " |", "|:---" + "|---:" * (len(HEADERS) - 1) + "|"] for name, commits, loc, files in rows: # escape the markdown table delimiter, names are commit-controlled input escaped = name.replace("|", "\\|") table.append(f"| {escaped} | {commits:,} | {loc:,} | {files:,} |") return preamble + "\n".join(table) + "\n" def shorten(name): """Truncate `name` to what fits into the name column of the chart.""" return name if len(name) <= SVG_NAME_MAX else name[: SVG_NAME_MAX - 1] + "…" def render_svg(rows, contributors): """Return an SVG bar chart of `rows`, the busiest out of `contributors` people.""" height = SVG_HEAD_H + len(rows) * SVG_ROW_H + SVG_FOOT_H bars = SVG_WIDTH - SVG_LABEL_W - SVG_COUNT_W - SVG_PAD most_commits = rows[0][1] out = [ f'', SVG_STYLE, f'', f'Borg contributors', f'' f"top {len(rows)} of {contributors:,} by commits on the {BRANCH} branch", ] for i, (name, commits, _loc, _files) in enumerate(rows): y = SVG_HEAD_H + i * SVG_ROW_H baseline = y + int(SVG_ROW_H * 0.7) # the scale is linear, but a bar should stay visible however short it is width = max(2, round(bars * commits / most_commits)) # names are commit-controlled input, so & and < would break the XML out.append(f'{escape(shorten(name))}') out.append( f'' ) out.append(f'{commits:,}') out.append( f'generated by scripts/fame.py using git-fame' ) out.append("") return "\n".join(out) + "\n" def same_statistics(old, new): """Return True if the two FAME.md contents differ only in the generation date.""" return DATE_LINE.sub("Generated on ", old) == DATE_LINE.sub("Generated on ", new) def update(path, new, unchanged=str.__eq__): """Write `new` to `path` unless `unchanged` says it is already there.""" if path.exists() and unchanged(path.read_text(encoding="utf-8"), new): print(f"{path} is up to date") return path.write_text(new, encoding="utf-8") print(f"wrote {path}") def main(): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--top", type=int, default=0, metavar="N", help="only list the top N contributors") parser.add_argument( "--svg-top", type=int, default=SVG_TOP, metavar="N", help="chart the top N contributors [default: %(default)s]" ) args = parser.parse_args() repo = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) rows, total = collect() contributors = len(rows) people = [row for row in rows if not BOT.search(row[0])] if args.top: rows = rows[: args.top] print(f"{len(rows)} of {contributors} contributors, {contributors - len(people)} of them bots") update(repo / "FAME.md", render(rows, total, contributors), unchanged=same_statistics) update(repo / "FAME.svg", render_svg(people[: args.svg_top], len(people))) if __name__ == "__main__": main()