| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Gitea Heatmap Backfill |
| 4 | ====================== |
| 5 | Retroactively populate the Gitea activity heatmap from existing Git history. |
| 6 | |
| 7 | Reads commit history from bare repositories on disk and inserts matching |
| 8 | action records (op_type=5, PUSH_EVENT) into the Gitea SQLite database. |
| 9 | |
| 10 | Author attribution is resolved by matching commit author emails against |
| 11 | the email_address table, so all registered emails (primary + alternatives) |
| 12 | are covered. |
| 13 | |
| 14 | Usage |
| 15 | ----- |
| 16 | Run from the Docker host (not inside the container): |
| 17 | |
| 18 | python3 gitea_backfill.py |
| 19 | |
| 20 | Or with custom paths: |
| 21 | |
| 22 | DB_PATH=/path/to/gitea.db GITEA_ROOT=/path/to/repositories python3 gitea_backfill.py |
| 23 | |
| 24 | Requirements |
| 25 | ------------ |
| 26 | - Python 3.7+ |
| 27 | - git available on the host |
| 28 | - Read/write access to the Gitea SQLite database |
| 29 | - git safe.directory configured if repos are owned by a different user: |
| 30 | git config --global --add safe.directory '*' |
| 31 | """ |
| 32 | |
| 33 | import os |
| 34 | import sys |
| 35 | import sqlite3 |
| 36 | import subprocess |
| 37 | from pathlib import Path |
| 38 | from datetime import datetime, timezone |
| 39 | import hashlib |
| 40 | |
| 41 | # --------------------------------------------------------------------------- |
| 42 | # Configuration — override via environment variables or edit directly |
| 43 | # --------------------------------------------------------------------------- |
| 44 | |
| 45 | DB_PATH = os.getenv("DB_PATH", "/etc/komodo/stacks/gitea/data/gitea/gitea.db") |
| 46 | GITEA_ROOT = os.getenv("GITEA_ROOT", "/etc/komodo/stacks/gitea/data/git/repositories") |
| 47 | BATCH_SIZE = int(os.getenv("BATCH_SIZE", "500")) |
| 48 | |
| 49 | |
| 50 | # --------------------------------------------------------------------------- |
| 51 | # Database helpers |
| 52 | # --------------------------------------------------------------------------- |
| 53 | |
| 54 | def create_backup(db_path: str) -> str: |
| 55 | """Create an atomic backup of the SQLite database using VACUUM INTO.""" |
| 56 | backup_path = db_path.replace(".db", ".backup-before-backfill.db") |
| 57 | print(f"[1] Creating backup → {backup_path}") |
| 58 | if os.path.exists(backup_path): |
| 59 | print(" [!] Backup already exists, skipping.") |
| 60 | return backup_path |
| 61 | try: |
| 62 | conn = sqlite3.connect(db_path) |
| 63 | conn.execute(f"VACUUM INTO '{backup_path}'") |
| 64 | conn.close() |
| 65 | print(" [✓] Backup created.") |
| 66 | return backup_path |
| 67 | except Exception as exc: |
| 68 | print(f" [✗] Backup failed: {exc}") |
| 69 | sys.exit(1) |
| 70 | |
| 71 | |
| 72 | def load_user_map(db_path: str) -> dict[str, int]: |
| 73 | """ |
| 74 | Return a mapping of lowercase email → user_id. |
| 75 | |
| 76 | Reads from the email_address table so that both the primary address |
| 77 | and all alternative addresses registered in Gitea are covered. |
| 78 | """ |
| 79 | conn = sqlite3.connect(db_path) |
| 80 | cur = conn.cursor() |
| 81 | cur.execute( |
| 82 | "SELECT uid, lower_email FROM email_address WHERE is_activated = 1" |
| 83 | ) |
| 84 | rows = cur.fetchall() |
| 85 | conn.close() |
| 86 | return {lower_email.strip(): uid for uid, lower_email in rows if lower_email} |
| 87 | |
| 88 | |
| 89 | def load_repo_map(db_path: str) -> dict[str, int]: |
| 90 | """Return a mapping of 'owner/repo' (lowercase) → repo_id.""" |
| 91 | conn = sqlite3.connect(db_path) |
| 92 | cur = conn.cursor() |
| 93 | cur.execute("SELECT id, owner_name, name FROM repository") |
| 94 | rows = cur.fetchall() |
| 95 | conn.close() |
| 96 | return { |
| 97 | f"{owner}/{name}".lower(): rid |
| 98 | for rid, owner, name in rows |
| 99 | if owner and name |
| 100 | } |
| 101 | |
| 102 | |
| 103 | def load_existing_hashes(cur: sqlite3.Cursor, repo_id: int) -> set[str]: |
| 104 | """ |
| 105 | Load all backfill content hashes already present for a given repo. |
| 106 | |
| 107 | A single SELECT per repo replaces one SELECT per commit, making |
| 108 | duplicate detection an O(1) in-memory set lookup. |
| 109 | """ |
| 110 | cur.execute( |
| 111 | "SELECT content FROM action" |
| 112 | " WHERE op_type = 5 AND repo_id = ? AND content IS NOT NULL", |
| 113 | (repo_id,), |
| 114 | ) |
| 115 | return {row[0] for row in cur.fetchall()} |
| 116 | |
| 117 | |
| 118 | # --------------------------------------------------------------------------- |
| 119 | # Repository discovery |
| 120 | # --------------------------------------------------------------------------- |
| 121 | |
| 122 | def find_repos(gitea_root: str) -> list[tuple[str, str]]: |
| 123 | """ |
| 124 | Walk the Gitea repository root and return all bare Git repositories, |
| 125 | excluding wiki repos (*.wiki.git). |
| 126 | |
| 127 | Returns a list of (absolute_path, 'owner/repo') tuples. |
| 128 | """ |
| 129 | repos = [] |
| 130 | root = Path(gitea_root) |
| 131 | for owner_dir in root.iterdir(): |
| 132 | if not owner_dir.is_dir(): |
| 133 | continue |
| 134 | for repo_dir in owner_dir.iterdir(): |
| 135 | if not repo_dir.is_dir(): |
| 136 | continue |
| 137 | if repo_dir.name.endswith(".wiki.git"): |
| 138 | continue |
| 139 | # Bare repo detection: HEAD file + objects/ directory at root |
| 140 | if (repo_dir / "HEAD").exists() and (repo_dir / "objects").is_dir(): |
| 141 | repo_name = repo_dir.name.removesuffix(".git") |
| 142 | key = f"{owner_dir.name}/{repo_name}".lower() |
| 143 | repos.append((str(repo_dir), key)) |
| 144 | return repos |
| 145 | |
| 146 | |
| 147 | # --------------------------------------------------------------------------- |
| 148 | # Git log extraction |
| 149 | # --------------------------------------------------------------------------- |
| 150 | |
| 151 | def extract_commits(repo_path: str) -> list[dict]: |
| 152 | """ |
| 153 | Run `git log --all` on a bare repository and return a list of commits. |
| 154 | |
| 155 | Each commit is a dict with keys: sha, email, iso. |
| 156 | """ |
| 157 | cmd = ["git", "-C", repo_path, "log", "--all", "--format=%H|%ae|%aI"] |
| 158 | try: |
| 159 | result = subprocess.run( |
| 160 | cmd, capture_output=True, text=True, check=True, timeout=120 |
| 161 | ) |
| 162 | except subprocess.CalledProcessError as exc: |
| 163 | print(f" [✗] git log failed: {exc.stderr.strip()}") |
| 164 | return [] |
| 165 | except subprocess.TimeoutExpired: |
| 166 | print(f" [✗] git log timed out") |
| 167 | return [] |
| 168 | |
| 169 | commits = [] |
| 170 | for line in result.stdout.splitlines(): |
| 171 | parts = line.split("|") |
| 172 | if len(parts) < 3: |
| 173 | continue |
| 174 | sha, email, iso = parts[0], parts[1].lower().strip(), parts[2].strip() |
| 175 | if sha and email and iso: |
| 176 | commits.append({"sha": sha, "email": email, "iso": iso}) |
| 177 | return commits |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # Date parsing |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | def iso_to_unix(iso_str: str) -> int | None: |
| 185 | """ |
| 186 | Parse an ISO 8601 timestamp with any UTC offset and return a Unix |
| 187 | timestamp in UTC. Returns None if parsing fails. |
| 188 | """ |
| 189 | try: |
| 190 | dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00")) |
| 191 | return int(dt.astimezone(timezone.utc).timestamp()) |
| 192 | except ValueError as exc: |
| 193 | print(f" [⚠] Cannot parse date '{iso_str}': {exc}") |
| 194 | return None |
| 195 | |
| 196 | |
| 197 | # --------------------------------------------------------------------------- |
| 198 | # Deduplication key |
| 199 | # --------------------------------------------------------------------------- |
| 200 | |
| 201 | def make_content_hash(sha: str, repo_id: int, user_id: int) -> str: |
| 202 | """ |
| 203 | Produce a stable, unique identifier for a (commit, repo, user) triple. |
| 204 | |
| 205 | Stored in the action.content column and used to prevent duplicate rows |
| 206 | on repeated runs. |
| 207 | """ |
| 208 | return hashlib.sha256(f"{sha}:{repo_id}:{user_id}".encode()).hexdigest() |
| 209 | |
| 210 | |
| 211 | # --------------------------------------------------------------------------- |
| 212 | # Core backfill logic |
| 213 | # --------------------------------------------------------------------------- |
| 214 | |
| 215 | def backfill(db_path: str, user_map: dict, repo_map: dict, repos: list) -> None: |
| 216 | """Insert PUSH_EVENT actions for every matched commit in every repo.""" |
| 217 | conn = sqlite3.connect(db_path) |
| 218 | conn.execute("PRAGMA journal_mode=WAL") |
| 219 | conn.execute("PRAGMA synchronous=NORMAL") |
| 220 | conn.execute("PRAGMA cache_size=-32000") # 32 MB page cache |
| 221 | cur = conn.cursor() |
| 222 | |
| 223 | cur.execute("SELECT COUNT(*) FROM action WHERE op_type = 5") |
| 224 | print(f"[2] Existing PUSH_EVENT rows: {cur.fetchone()[0]}\n") |
| 225 | |
| 226 | inserted = 0 |
| 227 | skipped = 0 |
| 228 | no_user = 0 |
| 229 | no_repo = 0 |
| 230 | |
| 231 | for repo_path, repo_key in repos: |
| 232 | repo_id = repo_map.get(repo_key) |
| 233 | if not repo_id: |
| 234 | print(f"[⚠] No database entry for repo '{repo_key}' — skipping") |
| 235 | no_repo += 1 |
| 236 | continue |
| 237 | |
| 238 | print(f"[→] {repo_key} (id={repo_id})") |
| 239 | commits = extract_commits(repo_path) |
| 240 | if not commits: |
| 241 | print(" No commits found.") |
| 242 | continue |
| 243 | print(f" {len(commits)} commits found.") |
| 244 | |
| 245 | existing = load_existing_hashes(cur, repo_id) |
| 246 | |
| 247 | for commit in commits: |
| 248 | user_id = user_map.get(commit["email"]) |
| 249 | if not user_id: |
| 250 | no_user += 1 |
| 251 | continue |
| 252 | |
| 253 | content_hash = make_content_hash(commit["sha"], repo_id, user_id) |
| 254 | if content_hash in existing: |
| 255 | skipped += 1 |
| 256 | continue |
| 257 | |
| 258 | created_unix = iso_to_unix(commit["iso"]) |
| 259 | if created_unix is None: |
| 260 | skipped += 1 |
| 261 | continue |
| 262 | |
| 263 | cur.execute( |
| 264 | """ |
| 265 | INSERT INTO action |
| 266 | (user_id, op_type, act_user_id, repo_id, |
| 267 | comment_id, ref_name, is_private, |
| 268 | content, created_unix) |
| 269 | VALUES (?, 5, ?, ?, NULL, NULL, 0, ?, ?) |
| 270 | """, |
| 271 | (user_id, user_id, repo_id, content_hash, created_unix), |
| 272 | ) |
| 273 | existing.add(content_hash) |
| 274 | inserted += 1 |
| 275 | |
| 276 | if inserted % BATCH_SIZE == 0: |
| 277 | conn.commit() |
| 278 | print(f" [{inserted} inserted so far]") |
| 279 | |
| 280 | conn.commit() |
| 281 | conn.close() |
| 282 | |
| 283 | width = 44 |
| 284 | print(f"\n{'─' * width}") |
| 285 | print(f" Backfill complete") |
| 286 | print(f"{'─' * width}") |
| 287 | print(f" Inserted : {inserted}") |
| 288 | print(f" Skipped : {skipped:<6} (already present or bad date)") |
| 289 | print(f" No user : {no_user:<6} (email not registered in Gitea)") |
| 290 | print(f" No repo : {no_repo:<6} (repo not found in database)") |
| 291 | print(f"{'─' * width}") |
| 292 | print(f"\nRestart Gitea to refresh the heatmap:") |
| 293 | print(f" docker restart gitea\n") |
| 294 | |
| 295 | |
| 296 | # --------------------------------------------------------------------------- |
| 297 | # Entry point |
| 298 | # --------------------------------------------------------------------------- |
| 299 | |
| 300 | def main() -> None: |
| 301 | print("Gitea Heatmap Backfill") |
| 302 | print("=" * 44) |
| 303 | |
| 304 | if not os.path.exists(DB_PATH): |
| 305 | print(f"[✗] Database not found: {DB_PATH}") |
| 306 | sys.exit(1) |
| 307 | |
| 308 | create_backup(DB_PATH) |
| 309 | print() |
| 310 | |
| 311 | user_map = load_user_map(DB_PATH) |
| 312 | print(f"[✓] {len(user_map)} email(s) loaded from email_address table") |
| 313 | |
| 314 | repo_map = load_repo_map(DB_PATH) |
| 315 | print(f"[✓] {len(repo_map)} repo(s) loaded from repository table") |
| 316 | |
| 317 | repos = find_repos(GITEA_ROOT) |
| 318 | print(f"[✓] {len(repos)} bare repo(s) found on disk") |
| 319 | |
| 320 | if not repos: |
| 321 | print("\n[✗] No repositories found. Check GITEA_ROOT.") |
| 322 | sys.exit(1) |
| 323 | |
| 324 | print() |
| 325 | backfill(DB_PATH, user_map, repo_map, repos) |
| 326 | |
| 327 | |
| 328 | if __name__ == "__main__": |
| 329 | main() |
ian / Forgejo Heatmap Backfill
Last active 1 hour ago
Very lightly altered version of the gitea heatmap backfil found below (just adjusted the sqlite schema and nothing else, used to fix heatmap for recently added repos on forgejo 16.0.4) https://raw.githubusercontent.com/Maethik/gitea-heatmap-backfill/main/gitea_backfill.py