Files
profile-stats/scripts/language_stats.py
T

229 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Gitea Language Stats
Calculates the percentage of every programming language used
in all repositories of a given account and prints a markdown
table to stdout, delimited by the
markers <!--LANG_STATS_START--> … <!--LANG_STATS_END-->.
When run as a systemd job, it checks for new commits since the last run
and only regenerates if there are changes. Use --force to always regenerate.
Required environment variables
--------------------------------
GITEA_BASE_URL e.g. https://gitea.example.com
GITEA_TOKEN personal access-token with `repo` scope
GITEA_USERNAME the account to analyse
Optional environment variables
--------------------------------
STATE_FILE path to store last-run state (default: ~/.local/state/language_stats.json)
"""
import argparse
import json
import os
import sys
from collections import defaultdict
from pathlib import Path
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.getenv("GITEA_BASE_URL", "").rstrip("/")
TOKEN = os.getenv("GITEA_TOKEN")
USERNAME = os.getenv("GITEA_USERNAME")
DEFAULT_STATE_DIR = Path.home() / ".local" / "state"
STATE_FILE = Path(os.getenv("STATE_FILE", DEFAULT_STATE_DIR / "language_stats.json"))
# List of files whose byte-counts should be excluded from the totals.
# Each tuple: (repository_name, file_path, language_name)
EXCLUDE_FILES: list[tuple[str, str, str]] = [
("ar-scavenger-hunt", "static/aframe.js", "JavaScript"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.4.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.4-css.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.5.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.5-css.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.6.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.6-css.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.7.html", "HTML"),
("Kingsborough-LibGuide", "groups/home/bootstrap3/Vue3-v.7-css.html", "HTML"),
]
def get_session() -> requests.Session:
"""Create an authenticated requests session."""
session = requests.Session()
session.headers["Authorization"] = f"token {TOKEN}"
return session
def fetch_repos(session: requests.Session) -> list[dict]:
"""Fetch all repositories for the configured user."""
page = 1
repos = []
while True:
resp = session.get(
f"{BASE_URL}/api/v1/users/{USERNAME}/repos",
params={"page": page, "limit": 50},
timeout=30,
)
resp.raise_for_status()
batch = resp.json()
if not batch:
break
repos.extend(batch)
page += 1
return repos
def get_repo_push_timestamps(repos: list[dict]) -> dict[str, str]:
"""Extract repository names and their pushed_at timestamps."""
return {repo["name"]: repo.get("pushed_at", "") for repo in repos}
def load_state() -> dict:
"""Load the previous state from the state file."""
if STATE_FILE.exists():
try:
return json.loads(STATE_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
return {}
def save_state(state: dict) -> None:
"""Save the current state to the state file."""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, indent=2))
def has_new_commits(current_timestamps: dict[str, str], saved_state: dict) -> bool:
"""Check if any repository has new commits since last run."""
saved_timestamps = saved_state.get("repo_timestamps", {})
# Check for new or updated repos
for repo_name, pushed_at in current_timestamps.items():
if repo_name not in saved_timestamps:
return True
if pushed_at != saved_timestamps[repo_name]:
return True
# Check for deleted repos (also counts as a change)
for repo_name in saved_timestamps:
if repo_name not in current_timestamps:
return True
return False
def aggregate_language_bytes(session: requests.Session, repos: list[dict]) -> dict[str, int]:
"""Aggregate language byte counts across all repositories."""
totals: dict[str, int] = defaultdict(int)
for repo in repos:
langs = session.get(
f"{BASE_URL}/api/v1/repos/{USERNAME}/{repo['name']}/languages",
timeout=30,
).json()
for lang, bytes_ in langs.items():
totals[lang] += bytes_
return dict(totals)
def subtract_excluded_files(session: requests.Session, totals: dict[str, int]) -> dict[str, int]:
"""Subtract byte counts for explicitly excluded files."""
for repo_name, file_path, language in EXCLUDE_FILES:
try:
meta = session.get(
f"{BASE_URL}/api/v1/repos/{USERNAME}/{repo_name}/contents/{file_path}",
timeout=30,
)
if meta.ok:
size = meta.json().get("size", 0)
totals[language] = max(0, totals.get(language, 0) - size)
except requests.RequestException:
# Ignore failures continue with best-effort stats
pass
return totals
def generate_markdown(totals: dict[str, int]) -> list[str]:
"""Generate markdown output for language statistics."""
total_bytes = sum(totals.values())
if total_bytes == 0:
return []
pairs = sorted(totals.items(), key=lambda x: x[1], reverse=True)
lines = ["## 📊 Languages\n"]
for lang, nbytes in pairs:
pct = nbytes * 100 / total_bytes
bar = "█" * int(pct / 2) # up to 50 chars
lines.append(f"{bar} {lang}{pct:5.1f}%\\")
return lines
def main() -> int:
"""Main entry point for the script."""
parser = argparse.ArgumentParser(
description="Generate language statistics from Gitea repositories"
)
parser.add_argument(
"--force",
action="store_true",
help="Force regeneration even if no new commits detected",
)
parser.add_argument(
"--check-only",
action="store_true",
help="Only check for new commits, don't generate output",
)
args = parser.parse_args()
# Validate environment
if not BASE_URL or not TOKEN or not USERNAME:
sys.exit("GITEA_BASE_URL, GITEA_TOKEN and GITEA_USERNAME must be set")
session = get_session()
# Fetch current repository state
repos = fetch_repos(session)
current_timestamps = get_repo_push_timestamps(repos)
# Load previous state and check for changes
saved_state = load_state()
if not args.force and not has_new_commits(current_timestamps, saved_state):
print("No new commits detected. Skipping regeneration.", file=sys.stderr)
return 0
if args.check_only:
print("New commits detected.", file=sys.stderr)
return 0
# Generate the stats
totals = aggregate_language_bytes(session, repos)
totals = subtract_excluded_files(session, totals)
if sum(totals.values()) == 0:
sys.exit("No language data returned from API.")
lines = generate_markdown(totals)
for line in lines:
print(line)
# Save current state for next run
new_state = {"repo_timestamps": current_timestamps}
save_state(new_state)
return 0
if __name__ == "__main__":
sys.exit(main())