Files

330 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Gitea Heatmap Backfill
======================
Retroactively populate the Gitea activity heatmap from existing Git history.
Reads commit history from bare repositories on disk and inserts matching
action records (op_type=5, PUSH_EVENT) into the Gitea SQLite database.
Author attribution is resolved by matching commit author emails against
the email_address table, so all registered emails (primary + alternatives)
are covered.
Usage
-----
Run from the Docker host (not inside the container):
python3 gitea_backfill.py
Or with custom paths:
DB_PATH=/path/to/gitea.db GITEA_ROOT=/path/to/repositories python3 gitea_backfill.py
Requirements
------------
- Python 3.7+
- git available on the host
- Read/write access to the Gitea SQLite database
- git safe.directory configured if repos are owned by a different user:
git config --global --add safe.directory '*'
"""
import os
import sys
import sqlite3
import subprocess
from pathlib import Path
from datetime import datetime, timezone
import hashlib
# ---------------------------------------------------------------------------
# Configuration — override via environment variables or edit directly
# ---------------------------------------------------------------------------
DB_PATH = os.getenv("DB_PATH", "/etc/komodo/stacks/gitea/data/gitea/gitea.db")
GITEA_ROOT = os.getenv("GITEA_ROOT", "/etc/komodo/stacks/gitea/data/git/repositories")
BATCH_SIZE = int(os.getenv("BATCH_SIZE", "500"))
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def create_backup(db_path: str) -> str:
"""Create an atomic backup of the SQLite database using VACUUM INTO."""
backup_path = db_path.replace(".db", ".backup-before-backfill.db")
print(f"[1] Creating backup → {backup_path}")
if os.path.exists(backup_path):
print(" [!] Backup already exists, skipping.")
return backup_path
try:
conn = sqlite3.connect(db_path)
conn.execute(f"VACUUM INTO '{backup_path}'")
conn.close()
print(" [✓] Backup created.")
return backup_path
except Exception as exc:
print(f" [✗] Backup failed: {exc}")
sys.exit(1)
def load_user_map(db_path: str) -> dict[str, int]:
"""
Return a mapping of lowercase email → user_id.
Reads from the email_address table so that both the primary address
and all alternative addresses registered in Gitea are covered.
"""
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute(
"SELECT uid, lower_email FROM email_address WHERE is_activated = 1"
)
rows = cur.fetchall()
conn.close()
return {lower_email.strip(): uid for uid, lower_email in rows if lower_email}
def load_repo_map(db_path: str) -> dict[str, int]:
"""Return a mapping of 'owner/repo' (lowercase) → repo_id."""
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("SELECT id, owner_name, name FROM repository")
rows = cur.fetchall()
conn.close()
return {
f"{owner}/{name}".lower(): rid
for rid, owner, name in rows
if owner and name
}
def load_existing_hashes(cur: sqlite3.Cursor, repo_id: int) -> set[str]:
"""
Load all backfill content hashes already present for a given repo.
A single SELECT per repo replaces one SELECT per commit, making
duplicate detection an O(1) in-memory set lookup.
"""
cur.execute(
"SELECT content FROM action"
" WHERE op_type = 5 AND repo_id = ? AND content IS NOT NULL",
(repo_id,),
)
return {row[0] for row in cur.fetchall()}
# ---------------------------------------------------------------------------
# Repository discovery
# ---------------------------------------------------------------------------
def find_repos(gitea_root: str) -> list[tuple[str, str]]:
"""
Walk the Gitea repository root and return all bare Git repositories,
excluding wiki repos (*.wiki.git).
Returns a list of (absolute_path, 'owner/repo') tuples.
"""
repos = []
root = Path(gitea_root)
for owner_dir in root.iterdir():
if not owner_dir.is_dir():
continue
for repo_dir in owner_dir.iterdir():
if not repo_dir.is_dir():
continue
if repo_dir.name.endswith(".wiki.git"):
continue
# Bare repo detection: HEAD file + objects/ directory at root
if (repo_dir / "HEAD").exists() and (repo_dir / "objects").is_dir():
repo_name = repo_dir.name.removesuffix(".git")
key = f"{owner_dir.name}/{repo_name}".lower()
repos.append((str(repo_dir), key))
return repos
# ---------------------------------------------------------------------------
# Git log extraction
# ---------------------------------------------------------------------------
def extract_commits(repo_path: str) -> list[dict]:
"""
Run `git log --all` on a bare repository and return a list of commits.
Each commit is a dict with keys: sha, email, iso.
"""
cmd = ["git", "-C", repo_path, "log", "--all", "--format=%H|%ae|%aI"]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, check=True, timeout=120
)
except subprocess.CalledProcessError as exc:
print(f" [✗] git log failed: {exc.stderr.strip()}")
return []
except subprocess.TimeoutExpired:
print(f" [✗] git log timed out")
return []
commits = []
for line in result.stdout.splitlines():
parts = line.split("|")
if len(parts) < 3:
continue
sha, email, iso = parts[0], parts[1].lower().strip(), parts[2].strip()
if sha and email and iso:
commits.append({"sha": sha, "email": email, "iso": iso})
return commits
# ---------------------------------------------------------------------------
# Date parsing
# ---------------------------------------------------------------------------
def iso_to_unix(iso_str: str) -> int | None:
"""
Parse an ISO 8601 timestamp with any UTC offset and return a Unix
timestamp in UTC. Returns None if parsing fails.
"""
try:
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
return int(dt.astimezone(timezone.utc).timestamp())
except ValueError as exc:
print(f" [⚠] Cannot parse date '{iso_str}': {exc}")
return None
# ---------------------------------------------------------------------------
# Deduplication key
# ---------------------------------------------------------------------------
def make_content_hash(sha: str, repo_id: int, user_id: int) -> str:
"""
Produce a stable, unique identifier for a (commit, repo, user) triple.
Stored in the action.content column and used to prevent duplicate rows
on repeated runs.
"""
return hashlib.sha256(f"{sha}:{repo_id}:{user_id}".encode()).hexdigest()
# ---------------------------------------------------------------------------
# Core backfill logic
# ---------------------------------------------------------------------------
def backfill(db_path: str, user_map: dict, repo_map: dict, repos: list) -> None:
"""Insert PUSH_EVENT actions for every matched commit in every repo."""
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA cache_size=-32000") # 32 MB page cache
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM action WHERE op_type = 5")
print(f"[2] Existing PUSH_EVENT rows: {cur.fetchone()[0]}\n")
inserted = 0
skipped = 0
no_user = 0
no_repo = 0
for repo_path, repo_key in repos:
repo_id = repo_map.get(repo_key)
if not repo_id:
print(f"[⚠] No database entry for repo '{repo_key}' — skipping")
no_repo += 1
continue
print(f"[→] {repo_key} (id={repo_id})")
commits = extract_commits(repo_path)
if not commits:
print(" No commits found.")
continue
print(f" {len(commits)} commits found.")
existing = load_existing_hashes(cur, repo_id)
for commit in commits:
user_id = user_map.get(commit["email"])
if not user_id:
no_user += 1
continue
content_hash = make_content_hash(commit["sha"], repo_id, user_id)
if content_hash in existing:
skipped += 1
continue
created_unix = iso_to_unix(commit["iso"])
if created_unix is None:
skipped += 1
continue
cur.execute(
"""
INSERT INTO action
(user_id, op_type, act_user_id, repo_id,
comment_id, is_deleted, ref_name, is_private,
content, created_unix)
VALUES (?, 5, ?, ?, NULL, 0, NULL, 0, ?, ?)
""",
(user_id, user_id, repo_id, content_hash, created_unix),
)
existing.add(content_hash)
inserted += 1
if inserted % BATCH_SIZE == 0:
conn.commit()
print(f" [{inserted} inserted so far]")
conn.commit()
conn.close()
width = 44
print(f"\n{'' * width}")
print(f" Backfill complete")
print(f"{'' * width}")
print(f" Inserted : {inserted}")
print(f" Skipped : {skipped:<6} (already present or bad date)")
print(f" No user : {no_user:<6} (email not registered in Gitea)")
print(f" No repo : {no_repo:<6} (repo not found in database)")
print(f"{'' * width}")
print(f"\nRestart Gitea to refresh the heatmap:")
print(f" docker restart gitea\n")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
print("Gitea Heatmap Backfill")
print("=" * 44)
if not os.path.exists(DB_PATH):
print(f"[✗] Database not found: {DB_PATH}")
sys.exit(1)
create_backup(DB_PATH)
print()
user_map = load_user_map(DB_PATH)
print(f"[✓] {len(user_map)} email(s) loaded from email_address table")
repo_map = load_repo_map(DB_PATH)
print(f"[✓] {len(repo_map)} repo(s) loaded from repository table")
repos = find_repos(GITEA_ROOT)
print(f"[✓] {len(repos)} bare repo(s) found on disk")
if not repos:
print("\n[✗] No repositories found. Check GITEA_ROOT.")
sys.exit(1)
print()
backfill(DB_PATH, user_map, repo_map, repos)
if __name__ == "__main__":
main()