post to .profile
This commit is contained in:
+79
-12
@@ -2,9 +2,9 @@
|
|||||||
"""
|
"""
|
||||||
Gitea Language Stats
|
Gitea Language Stats
|
||||||
Calculates the percentage of every programming language used
|
Calculates the percentage of every programming language used
|
||||||
in all repositories of a given account and prints a markdown
|
in all repositories of a given account and updates the README.md
|
||||||
table to stdout, delimited by the
|
in the .profile repository between the markers
|
||||||
markers <!--LANG_STATS_START--> … <!--LANG_STATS_END-->.
|
<!--LANG_STATS_START--> and <!--LANG_STATS_END-->.
|
||||||
|
|
||||||
When run as a systemd job, it checks for new commits since the last run
|
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.
|
and only regenerates if there are changes. Use --force to always regenerate.
|
||||||
@@ -12,7 +12,7 @@ and only regenerates if there are changes. Use --force to always regenerate.
|
|||||||
Required environment variables
|
Required environment variables
|
||||||
--------------------------------
|
--------------------------------
|
||||||
GITEA_BASE_URL e.g. https://gitea.example.com
|
GITEA_BASE_URL e.g. https://gitea.example.com
|
||||||
GITEA_TOKEN personal access-token with `repo` scope
|
GITEA_TOKEN personal access-token with `repo` scope (needs write access)
|
||||||
GITEA_USERNAME the account to analyse
|
GITEA_USERNAME the account to analyse
|
||||||
|
|
||||||
Optional environment variables
|
Optional environment variables
|
||||||
@@ -20,8 +20,10 @@ Optional environment variables
|
|||||||
STATE_FILE path to store last-run state (default: ~/.local/state/language_stats.json)
|
STATE_FILE path to store last-run state (default: ~/.local/state/language_stats.json)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -38,6 +40,14 @@ USERNAME = os.getenv("GITEA_USERNAME")
|
|||||||
DEFAULT_STATE_DIR = Path.home() / ".local" / "state"
|
DEFAULT_STATE_DIR = Path.home() / ".local" / "state"
|
||||||
STATE_FILE = Path(os.getenv("STATE_FILE", DEFAULT_STATE_DIR / "language_stats.json"))
|
STATE_FILE = Path(os.getenv("STATE_FILE", DEFAULT_STATE_DIR / "language_stats.json"))
|
||||||
|
|
||||||
|
# Profile repository where README will be updated
|
||||||
|
PROFILE_REPO = ".profile"
|
||||||
|
README_PATH = "README.md"
|
||||||
|
|
||||||
|
# Markers for content replacement
|
||||||
|
MARKER_START = "<!--LANG_STATS_START-->"
|
||||||
|
MARKER_END = "<!--LANG_STATS_END-->"
|
||||||
|
|
||||||
# List of files whose byte-counts should be excluded from the totals.
|
# List of files whose byte-counts should be excluded from the totals.
|
||||||
# Each tuple: (repository_name, file_path, language_name)
|
# Each tuple: (repository_name, file_path, language_name)
|
||||||
EXCLUDE_FILES: list[tuple[str, str, str]] = [
|
EXCLUDE_FILES: list[tuple[str, str, str]] = [
|
||||||
@@ -80,8 +90,16 @@ def fetch_repos(session: requests.Session) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def get_repo_timestamps(repos: list[dict]) -> dict[str, str]:
|
def get_repo_timestamps(repos: list[dict]) -> dict[str, str]:
|
||||||
"""Extract repository names and their updated_at timestamps."""
|
"""Extract repository names and their updated_at timestamps.
|
||||||
return {repo["name"]: repo.get("updated_at", "") for repo in repos}
|
|
||||||
|
Excludes the profile repo to avoid infinite loops (updating it
|
||||||
|
would trigger another update).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
repo["name"]: repo.get("updated_at", "")
|
||||||
|
for repo in repos
|
||||||
|
if repo["name"] != PROFILE_REPO
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_state() -> dict:
|
def load_state() -> dict:
|
||||||
@@ -151,11 +169,11 @@ def subtract_excluded_files(session: requests.Session, totals: dict[str, int]) -
|
|||||||
return totals
|
return totals
|
||||||
|
|
||||||
|
|
||||||
def generate_markdown(totals: dict[str, int]) -> list[str]:
|
def generate_markdown(totals: dict[str, int]) -> str:
|
||||||
"""Generate markdown output for language statistics."""
|
"""Generate markdown output for language statistics."""
|
||||||
total_bytes = sum(totals.values())
|
total_bytes = sum(totals.values())
|
||||||
if total_bytes == 0:
|
if total_bytes == 0:
|
||||||
return []
|
return ""
|
||||||
|
|
||||||
pairs = sorted(totals.items(), key=lambda x: x[1], reverse=True)
|
pairs = sorted(totals.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
@@ -165,7 +183,51 @@ def generate_markdown(totals: dict[str, int]) -> list[str]:
|
|||||||
bar = "█" * int(pct / 2) # up to 50 chars
|
bar = "█" * int(pct / 2) # up to 50 chars
|
||||||
lines.append(f"{bar} {lang}{pct:5.1f}%\\")
|
lines.append(f"{bar} {lang}{pct:5.1f}%\\")
|
||||||
|
|
||||||
return lines
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_readme(session: requests.Session) -> tuple[str, str]:
|
||||||
|
"""Fetch the README from the profile repository.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (content, sha) where sha is needed for updating.
|
||||||
|
"""
|
||||||
|
resp = session.get(
|
||||||
|
f"{BASE_URL}/api/v1/repos/{USERNAME}/{PROFILE_REPO}/contents/{README_PATH}",
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = base64.b64decode(data["content"]).decode("utf-8")
|
||||||
|
return content, data["sha"]
|
||||||
|
|
||||||
|
|
||||||
|
def replace_between_markers(content: str, new_content: str) -> str:
|
||||||
|
"""Replace content between LANG_STATS markers."""
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"({re.escape(MARKER_START)})(.*?)({re.escape(MARKER_END)})",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
replacement = rf"\1\n{new_content}\n\3"
|
||||||
|
new_readme, count = pattern.subn(replacement, content)
|
||||||
|
if count == 0:
|
||||||
|
raise ValueError(f"Markers {MARKER_START} and {MARKER_END} not found in README")
|
||||||
|
return new_readme
|
||||||
|
|
||||||
|
|
||||||
|
def update_readme(session: requests.Session, content: str, sha: str) -> None:
|
||||||
|
"""Update the README in the profile repository."""
|
||||||
|
encoded_content = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||||
|
resp = session.put(
|
||||||
|
f"{BASE_URL}/api/v1/repos/{USERNAME}/{PROFILE_REPO}/contents/{README_PATH}",
|
||||||
|
json={
|
||||||
|
"content": encoded_content,
|
||||||
|
"sha": sha,
|
||||||
|
"message": "Update language statistics",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -213,9 +275,14 @@ def main() -> int:
|
|||||||
if sum(totals.values()) == 0:
|
if sum(totals.values()) == 0:
|
||||||
sys.exit("No language data returned from API.")
|
sys.exit("No language data returned from API.")
|
||||||
|
|
||||||
lines = generate_markdown(totals)
|
markdown = generate_markdown(totals)
|
||||||
for line in lines:
|
|
||||||
print(line)
|
# Fetch current README, update it, and push
|
||||||
|
readme_content, readme_sha = fetch_readme(session)
|
||||||
|
updated_readme = replace_between_markers(readme_content, markdown)
|
||||||
|
update_readme(session, updated_readme, readme_sha)
|
||||||
|
|
||||||
|
print("README.md updated successfully.", file=sys.stderr)
|
||||||
|
|
||||||
# Save current state for next run
|
# Save current state for next run
|
||||||
new_state = {"repo_timestamps": current_timestamps}
|
new_state = {"repo_timestamps": current_timestamps}
|
||||||
|
|||||||
Reference in New Issue
Block a user