2024-06-20 23:29:56 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""Forgejo statistics
|
|
|
|
|
|
|
|
Copyright (C) 2024 Ari Archer <ari@ari.lt>
|
|
|
|
|
|
|
|
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 <https://www.gnu.org/licenses/>."""
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2024-06-21 18:45:37 +00:00
|
|
|
DPI: t.Final[int] = 200
|
2024-06-20 23:29:56 +00:00
|
|
|
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(
|
2024-06-21 00:04:01 +00:00
|
|
|
f"{BASE_URL}/users/{USERNAME}/repos?page={page}&limit=100"
|
2024-06-20 23:29:56 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
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 -*-
|
2024-06-21 18:45:37 +00:00
|
|
|
|
2024-06-21 00:03:18 +00:00
|
|
|
print("Plotting languages...")
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 18:45:37 +00:00
|
|
|
sorted_languages: t.List[t.Tuple[str, int]] = sorted(
|
|
|
|
language_stats.items(), key=lambda x: x[1], reverse=True
|
|
|
|
)[: 7 * LANG_COLS]
|
2024-06-20 23:29:56 +00:00
|
|
|
languages, counts = zip(*sorted_languages)
|
2024-06-21 18:45:37 +00:00
|
|
|
total: int = sum(counts)
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 00:03:18 +00:00
|
|
|
fig_lang, ax_lang = plt.subplots(
|
|
|
|
figsize=((10 / 3) * LANG_COLS, (2 / 3) * LANG_COLS)
|
|
|
|
)
|
|
|
|
fig_lang.patch.set_facecolor("#000000")
|
2024-06-21 18:45:37 +00:00
|
|
|
fig_lang.patch.set_alpha(0.3)
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 18:45:37 +00:00
|
|
|
cumulative_counts: t.Any = np.cumsum([0] + list(counts[:-1]))
|
2024-06-20 23:29:56 +00:00
|
|
|
|
|
|
|
for lang, count, cum_count in zip(languages, counts, cumulative_counts):
|
|
|
|
color = language_colours.get(lang.lower(), "#CCCCCC")
|
2024-06-21 00:03:18 +00:00
|
|
|
ax_lang.barh(0, count, color=color, left=cum_count, edgecolor="white")
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 18:45:37 +00:00
|
|
|
ax_lang.set_xlim(0, total) # Ensure the x-axis matches the total width of all bars
|
2024-06-21 00:03:18 +00:00
|
|
|
ax_lang.axis("off")
|
|
|
|
ax_lang.set_title("Top Programming Languages by Usage", fontsize=14, color=COLOUR)
|
|
|
|
ax_lang.legend(
|
2024-06-20 23:29:56 +00:00
|
|
|
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()
|
2024-06-21 00:03:18 +00:00
|
|
|
plt.savefig("languages.png", dpi=DPI)
|
2024-06-21 18:45:37 +00:00
|
|
|
|
2024-06-20 23:29:56 +00:00
|
|
|
print("Wrote languages.png")
|
|
|
|
|
|
|
|
# -*- Commit statistics -*-
|
|
|
|
|
2024-06-21 00:03:18 +00:00
|
|
|
print("Plotting commits...")
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 18:45:37 +00:00
|
|
|
two_years_ago: datetime = datetime.now() - timedelta(days=(365.25 * COMMIT_YEARS))
|
|
|
|
filtered_dates: t.Dict[str, int] = {
|
2024-06-20 23:29:56 +00:00
|
|
|
date: count
|
|
|
|
for date, count in commit_activity.items()
|
|
|
|
if datetime.strptime(date, "%Y-%m-%d") >= two_years_ago
|
|
|
|
}
|
|
|
|
|
2024-06-21 18:45:37 +00:00
|
|
|
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]
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 00:03:18 +00:00
|
|
|
fig_commits, ax_commits = plt.subplots(figsize=(16, 9))
|
|
|
|
fig_commits.patch.set_facecolor("#000000")
|
2024-06-21 18:45:37 +00:00
|
|
|
fig_commits.patch.set_alpha(0.3)
|
2024-06-21 00:10:08 +00:00
|
|
|
ax_commits.patch.set_alpha(0.2)
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 00:03:18 +00:00
|
|
|
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")
|
2024-06-20 23:29:56 +00:00
|
|
|
|
2024-06-21 00:03:18 +00:00
|
|
|
plt.gcf().autofmt_xdate()
|
2024-06-20 23:29:56 +00:00
|
|
|
plt.tight_layout()
|
2024-06-21 00:03:18 +00:00
|
|
|
plt.savefig("commits.png", dpi=DPI)
|
2024-06-21 18:45:37 +00:00
|
|
|
|
2024-06-20 23:29:56 +00:00
|
|
|
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())
|