#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Forgejo statistics Copyright (C) 2024 Ari Archer This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .""" import json import typing as t from collections import defaultdict from datetime import datetime, timedelta from warnings import filterwarnings as filter_warnings import matplotlib.dates as mdates import matplotlib.pyplot as plt import numpy as np import requests # Configuration BASE_URL: str = "https://git.ari.lt/api/v1" USERNAME: str = "ari" LANG_COLS: t.Final[int] = 3 # How many colums for the languages AUTHORS: t.Tuple[str, ...] = ( USERNAME, "TruncatedDinoSour", "Ari Archer", "ari.web.xyz@gmail.com", "ari@ari.lt", "B00bleaTeA", "4FAD63E936B305906A6C4894A50D5B4B599AF8A2", ) IGNORE_REPOS: t.Tuple[str, ...] = "dino-kernel", "sysvinit" COLOUR: str = "#ffa647" DPI: t.Final[int] = 200 COMMIT_PAGE_LIMIT: t.Final[int] = 5 # Maximum commit pages (similar to UI) COMMIT_YEARS: t.Final[int] = ( 2 # How many years should the commit plot use in your commit history? ) # Code def is_authoring(author: t.Any) -> bool: """Returns if the user is authoring""" author = str(author) for a in AUTHORS: if a.lower() in author.lower(): return True return False def get_repositories() -> t.List[t.Dict[str, t.Any]]: """Fetch all repositories of a user""" all_repos: t.List[t.Dict[str, t.Any]] = [] page: int = 1 while True: response: requests.Response = requests.get( f"{BASE_URL}/users/{USERNAME}/repos?page={page}&limit=100" ) response.raise_for_status() repos: t.List[t.Any] = response.json() print(f"Got {len(repos)} repositories from page {page}") if not repos: break for repo in repos: if repo["name"] not in IGNORE_REPOS and is_authoring(repo["owner"]): print(f"Author of {repo['name']}") all_repos.append(repo) page += 1 return all_repos def get_repo_languages(repo: t.Dict[str, t.Any]) -> t.Dict[str, int]: """Fetch languages used in a specific repository""" response: requests.Response = requests.get( f"{BASE_URL}/repos/{USERNAME}/{repo['name']}/languages" ) response.raise_for_status() languages: t.Dict[str, int] = response.json() print(f"Languages for {repo['name']}: {languages}") return languages def get_repo_commits(repo: t.Dict[str, t.Any]) -> t.List[t.Dict[str, t.Any]]: """Get the commit objects in a specific repository""" all_commits: t.List[t.Dict[str, t.Any]] = [] page: int = 1 while page <= COMMIT_PAGE_LIMIT: response: requests.Response = requests.get( f"{BASE_URL}/repos/{USERNAME}/{repo['name']}/commits?author={USERNAME}&page={page}&limit=100" ) if response.status_code == 409: break response.raise_for_status() commits: t.List[t.Dict[str, t.Any]] = [ commit for commit in response.json() if is_authoring(commit) ] print( f"Got authored {len(commits)} commits from repository's {repo['name']} page {page}" ) if not commits: break all_commits.extend(commits) page += 1 return all_commits def analyze_data( repos: t.List[t.Dict[str, t.Any]] ) -> t.Tuple[t.Dict[str, int], t.Dict[str, int]]: """Analyze repositories to get language usage and commit activity""" language_stats: t.Dict[str, int] = defaultdict(int) commit_activity: t.Dict[str, int] = defaultdict(int) for idx, repo in enumerate(repos): print(f"[{idx / len(repos):.2%}] Analyzing {repo['name']} ...") languages: t.Dict[str, int] = get_repo_languages(repo) for lang, count in languages.items(): language_stats[lang] += count commits: t.List[t.Dict[str, t.Any]] = get_repo_commits(repo) for commit in commits: date_str = commit["commit"]["author"]["date"][:10] commit_activity[date_str] += 1 return language_stats, commit_activity def plot_data( language_stats: t.Dict[str, int], commit_activity: t.Dict[str, int], language_colours: t.Dict[str, str], ) -> None: """Plot language statistics and commit activity""" plt.style.use("dark_background") # -*- Language statistics -*- print("Plotting languages...") sorted_languages: t.List[t.Tuple[str, int]] = sorted( language_stats.items(), key=lambda x: x[1], reverse=True )[: 7 * LANG_COLS] languages, counts = zip(*sorted_languages) total: int = sum(counts) fig_lang, ax_lang = plt.subplots( figsize=((10 / 3) * LANG_COLS, (2 / 3) * LANG_COLS) ) fig_lang.patch.set_facecolor("#000000") fig_lang.patch.set_alpha(0.3) cumulative_counts: t.Any = np.cumsum([0] + list(counts[:-1])) for lang, count, cum_count in zip(languages, counts, cumulative_counts): color = language_colours.get(lang.lower(), "#CCCCCC") ax_lang.barh(0, count, color=color, left=cum_count, edgecolor="white") ax_lang.set_xlim(0, total) # Ensure the x-axis matches the total width of all bars ax_lang.axis("off") ax_lang.set_title("Top Programming Languages by Usage", fontsize=14, color=COLOUR) ax_lang.legend( handles=[ plt.Rectangle( (0, 0), 1, 1, color=language_colours.get(lang.lower(), "#CCCCCC") ) for lang in languages ], labels=[ f"{lang} - {count/total:.2%}" for lang, count in zip(languages, counts) ], loc="upper center", bbox_to_anchor=(0.5, -0.05), ncol=LANG_COLS, fontsize=8, frameon=False, ) plt.tight_layout() plt.savefig("languages.png", dpi=DPI) print("Wrote languages.png") # -*- Commit statistics -*- print("Plotting commits...") two_years_ago: datetime = datetime.now() - timedelta(days=(365.25 * COMMIT_YEARS)) filtered_dates: t.Dict[str, int] = { date: count for date, count in commit_activity.items() if datetime.strptime(date, "%Y-%m-%d") >= two_years_ago } dates: t.List[str] = sorted(filtered_dates.keys()) counts: t.List[int] = [filtered_dates[date] for date in dates] dates: t.List[np.datetime64] = [np.datetime64(date) for date in dates] fig_commits, ax_commits = plt.subplots(figsize=(16, 9)) fig_commits.patch.set_facecolor("#000000") fig_commits.patch.set_alpha(0.3) ax_commits.patch.set_alpha(0.2) ax_commits.fill_between(dates, counts, color=COLOUR, alpha=0.4) ax_commits.plot(dates, counts, color=COLOUR, alpha=0.6) ax_commits.xaxis.set_major_locator(mdates.MonthLocator()) ax_commits.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) ax_commits.set_xlabel("Date", fontsize=12, color="white") ax_commits.set_ylabel("Number of Commits", fontsize=12, color="white") ax_commits.set_title("Commit Activity", fontsize=14, color=COLOUR) ax_commits.grid(True, which="both", linestyle="--", linewidth=0.5, color="gray") plt.gcf().autofmt_xdate() plt.tight_layout() plt.savefig("commits.png", dpi=DPI) print("Wrote commits.png") def main() -> int: """entry / main function""" with open("lang.json", "r") as fp: language_colours: t.Dict[str, str] = json.load(fp) repos: t.List[t.Dict[str, t.Any]] = get_repositories() print(f"Found {len(repos)} repositories") print("Analyzing repositories...") language_stats, commit_activity = analyze_data(repos) print("Plotting data...") plot_data(language_stats, commit_activity, language_colours) return 0 if __name__ == "__main__": assert main.__annotations__.get("return") is int, "main() should return an integer" filter_warnings("error", category=Warning) raise SystemExit(main())