navig-github 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,567 @@
1
+ """
2
+ The GitHub engine Analytics Module - Backup statistics, reporting, and insights.
3
+
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import subprocess
9
+ from collections import defaultdict
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ @dataclass
17
+ class RepositoryStats:
18
+ """Statistics for a single repository."""
19
+
20
+ name: str
21
+ path: Path
22
+ size_bytes: int = 0
23
+ file_count: int = 0
24
+ commit_count: int = 0
25
+ branch_count: int = 0
26
+ tag_count: int = 0
27
+ last_commit_date: str | None = None
28
+ last_backup_date: str | None = None
29
+ is_bare: bool = False
30
+ has_lfs: bool = False
31
+ languages: dict[str, int] = field(default_factory=dict)
32
+
33
+ @property
34
+ def size_mb(self) -> float:
35
+ """Size in megabytes."""
36
+ return self.size_bytes / (1024 * 1024)
37
+
38
+ @property
39
+ def size_gb(self) -> float:
40
+ """Size in gigabytes."""
41
+ return self.size_bytes / (1024 * 1024 * 1024)
42
+
43
+ def to_dict(self) -> dict:
44
+ """Convert to dictionary."""
45
+ return {
46
+ "name": self.name,
47
+ "path": str(self.path),
48
+ "size_bytes": self.size_bytes,
49
+ "size_mb": round(self.size_mb, 2),
50
+ "file_count": self.file_count,
51
+ "commit_count": self.commit_count,
52
+ "branch_count": self.branch_count,
53
+ "tag_count": self.tag_count,
54
+ "last_commit_date": self.last_commit_date,
55
+ "last_backup_date": self.last_backup_date,
56
+ "is_bare": self.is_bare,
57
+ "has_lfs": self.has_lfs,
58
+ "languages": self.languages,
59
+ }
60
+
61
+
62
+ @dataclass
63
+ class BackupStats:
64
+ """Aggregate statistics for a backup directory."""
65
+
66
+ path: Path
67
+ total_repositories: int = 0
68
+ total_size_bytes: int = 0
69
+ total_files: int = 0
70
+ total_commits: int = 0
71
+ repositories: list[RepositoryStats] = field(default_factory=list)
72
+ categories: dict[str, int] = field(default_factory=dict)
73
+ languages: dict[str, int] = field(default_factory=dict)
74
+ analyzed_at: str = field(default_factory=lambda: datetime.now().isoformat())
75
+
76
+ @property
77
+ def total_size_mb(self) -> float:
78
+ """Total size in megabytes."""
79
+ return self.total_size_bytes / (1024 * 1024)
80
+
81
+ @property
82
+ def total_size_gb(self) -> float:
83
+ """Total size in gigabytes."""
84
+ return self.total_size_bytes / (1024 * 1024 * 1024)
85
+
86
+ @property
87
+ def avg_repo_size_mb(self) -> float:
88
+ """Average repository size in MB."""
89
+ if self.total_repositories == 0:
90
+ return 0
91
+ return self.total_size_mb / self.total_repositories
92
+
93
+ def to_dict(self) -> dict:
94
+ """Convert to dictionary."""
95
+ return {
96
+ "path": str(self.path),
97
+ "total_repositories": self.total_repositories,
98
+ "total_size_bytes": self.total_size_bytes,
99
+ "total_size_mb": round(self.total_size_mb, 2),
100
+ "total_size_gb": round(self.total_size_gb, 3),
101
+ "total_files": self.total_files,
102
+ "total_commits": self.total_commits,
103
+ "avg_repo_size_mb": round(self.avg_repo_size_mb, 2),
104
+ "categories": self.categories,
105
+ "languages": self.languages,
106
+ "analyzed_at": self.analyzed_at,
107
+ "repositories": [r.to_dict() for r in self.repositories],
108
+ }
109
+
110
+
111
+ @dataclass
112
+ class BackupHistory:
113
+ """Historical record of backups."""
114
+
115
+ backup_id: str
116
+ started_at: str
117
+ completed_at: str | None = None
118
+ duration_seconds: float = 0
119
+ repos_cloned: int = 0
120
+ repos_updated: int = 0
121
+ repos_failed: int = 0
122
+ total_size_bytes: int = 0
123
+ success: bool = True
124
+ error_message: str | None = None
125
+
126
+ def to_dict(self) -> dict:
127
+ """Convert to dictionary."""
128
+ return {
129
+ "backup_id": self.backup_id,
130
+ "started_at": self.started_at,
131
+ "completed_at": self.completed_at,
132
+ "duration_seconds": self.duration_seconds,
133
+ "repos_cloned": self.repos_cloned,
134
+ "repos_updated": self.repos_updated,
135
+ "repos_failed": self.repos_failed,
136
+ "total_size_bytes": self.total_size_bytes,
137
+ "success": self.success,
138
+ "error_message": self.error_message,
139
+ }
140
+
141
+
142
+ class BackupAnalytics:
143
+ """
144
+ Analyze backup directories and generate statistics.
145
+
146
+ """
147
+
148
+ HISTORY_FILE = ".navig-github-history.json"
149
+
150
+ def __init__(self, backup_dir: Path | None = None):
151
+ """Initialize the analytics engine."""
152
+ self.backup_dir = backup_dir or Path("backups")
153
+ self._history: list[BackupHistory] = []
154
+ self._load_history()
155
+
156
+ def _load_history(self) -> None:
157
+ """Load backup history from disk."""
158
+ history_path = self.backup_dir / self.HISTORY_FILE
159
+ if history_path.exists():
160
+ try:
161
+ data = json.loads(history_path.read_text())
162
+ self._history = [
163
+ BackupHistory(**h) for h in data.get("history", [])
164
+ ]
165
+ except (json.JSONDecodeError, TypeError):
166
+ self._history = []
167
+
168
+ def _save_history(self) -> None:
169
+ """Save backup history to disk."""
170
+ history_path = self.backup_dir / self.HISTORY_FILE
171
+ history_path.parent.mkdir(parents=True, exist_ok=True)
172
+ data = {
173
+ "version": "1.0",
174
+ "updated_at": datetime.now().isoformat(),
175
+ "history": [h.to_dict() for h in self._history],
176
+ }
177
+ history_path.write_text(json.dumps(data, indent=2))
178
+
179
+ def record_backup(
180
+ self,
181
+ repos_cloned: int,
182
+ repos_updated: int,
183
+ repos_failed: int,
184
+ duration_seconds: float,
185
+ total_size_bytes: int = 0,
186
+ error_message: str | None = None,
187
+ ) -> BackupHistory:
188
+ """Record a backup operation in history."""
189
+ import uuid
190
+
191
+ now = datetime.now()
192
+ history = BackupHistory(
193
+ backup_id=str(uuid.uuid4())[:8],
194
+ started_at=now.isoformat(),
195
+ completed_at=now.isoformat(),
196
+ duration_seconds=duration_seconds,
197
+ repos_cloned=repos_cloned,
198
+ repos_updated=repos_updated,
199
+ repos_failed=repos_failed,
200
+ total_size_bytes=total_size_bytes,
201
+ success=repos_failed == 0,
202
+ error_message=error_message,
203
+ )
204
+
205
+ self._history.append(history)
206
+
207
+ # Keep last 100 entries
208
+ if len(self._history) > 100:
209
+ self._history = self._history[-100:]
210
+
211
+ self._save_history()
212
+ return history
213
+
214
+ def get_history(self, limit: int = 20) -> list[BackupHistory]:
215
+ """Get recent backup history."""
216
+ return self._history[-limit:]
217
+
218
+ def analyze_repository(self, repo_path: Path) -> RepositoryStats:
219
+ """Analyze a single repository and return statistics."""
220
+ stats = RepositoryStats(
221
+ name=repo_path.name,
222
+ path=repo_path,
223
+ )
224
+
225
+ # Check if it's a bare repository
226
+ if (repo_path / "HEAD").exists() and not (repo_path / ".git").exists():
227
+ stats.is_bare = True
228
+ git_dir = repo_path
229
+ elif (repo_path / ".git").exists():
230
+ git_dir = repo_path / ".git"
231
+ else:
232
+ # Not a git repository
233
+ return stats
234
+
235
+ # Calculate size
236
+ stats.size_bytes = self._get_directory_size(repo_path)
237
+
238
+ # Count files (for non-bare repos)
239
+ if not stats.is_bare:
240
+ stats.file_count = self._count_files(repo_path)
241
+
242
+ # Get git statistics
243
+ try:
244
+ # Commit count
245
+ result = subprocess.run(
246
+ ["git", "rev-list", "--count", "HEAD"],
247
+ cwd=repo_path,
248
+ capture_output=True,
249
+ text=True,
250
+ timeout=30,
251
+ )
252
+ if result.returncode == 0:
253
+ stats.commit_count = int(result.stdout.strip())
254
+ except (subprocess.TimeoutExpired, ValueError):
255
+ pass
256
+
257
+ try:
258
+ # Branch count
259
+ result = subprocess.run(
260
+ ["git", "branch", "-a"],
261
+ cwd=repo_path,
262
+ capture_output=True,
263
+ text=True,
264
+ timeout=30,
265
+ )
266
+ if result.returncode == 0:
267
+ stats.branch_count = len([
268
+ b for b in result.stdout.strip().split("\n")
269
+ if b.strip() and not b.strip().startswith("->")
270
+ ])
271
+ except subprocess.TimeoutExpired:
272
+ pass
273
+
274
+ try:
275
+ # Tag count
276
+ result = subprocess.run(
277
+ ["git", "tag"],
278
+ cwd=repo_path,
279
+ capture_output=True,
280
+ text=True,
281
+ timeout=30,
282
+ )
283
+ if result.returncode == 0:
284
+ stats.tag_count = len([t for t in result.stdout.strip().split("\n") if t.strip()])
285
+ except subprocess.TimeoutExpired:
286
+ pass
287
+
288
+ try:
289
+ # Last commit date
290
+ result = subprocess.run(
291
+ ["git", "log", "-1", "--format=%ci"],
292
+ cwd=repo_path,
293
+ capture_output=True,
294
+ text=True,
295
+ timeout=30,
296
+ )
297
+ if result.returncode == 0 and result.stdout.strip():
298
+ stats.last_commit_date = result.stdout.strip()
299
+ except subprocess.TimeoutExpired:
300
+ pass
301
+
302
+ # Check for LFS
303
+ lfs_config = repo_path / ".lfsconfig" if not stats.is_bare else git_dir / "lfs"
304
+ if lfs_config.exists() or (repo_path / ".gitattributes").exists():
305
+ gitattributes = repo_path / ".gitattributes"
306
+ if gitattributes.exists():
307
+ content = gitattributes.read_text()
308
+ stats.has_lfs = "filter=lfs" in content
309
+
310
+ # Get language statistics (for non-bare repos)
311
+ if not stats.is_bare:
312
+ stats.languages = self._analyze_languages(repo_path)
313
+
314
+ # Last backup date (file modification time)
315
+ try:
316
+ mtime = repo_path.stat().st_mtime
317
+ stats.last_backup_date = datetime.fromtimestamp(mtime).isoformat()
318
+ except OSError:
319
+ pass
320
+
321
+ return stats
322
+
323
+ def analyze_directory(self, path: Path | None = None) -> BackupStats:
324
+ """Analyze an entire backup directory."""
325
+ path = path or self.backup_dir
326
+
327
+ stats = BackupStats(path=path)
328
+
329
+ # Find all git repositories
330
+ repos = self._find_repositories(path)
331
+
332
+ for repo_path in repos:
333
+ repo_stats = self.analyze_repository(repo_path)
334
+ stats.repositories.append(repo_stats)
335
+ stats.total_size_bytes += repo_stats.size_bytes
336
+ stats.total_files += repo_stats.file_count
337
+ stats.total_commits += repo_stats.commit_count
338
+
339
+ # Aggregate languages
340
+ for lang, count in repo_stats.languages.items():
341
+ stats.languages[lang] = stats.languages.get(lang, 0) + count
342
+
343
+ stats.total_repositories = len(repos)
344
+
345
+ # Categorize repositories
346
+ stats.categories = self._categorize_repositories(repos)
347
+
348
+ return stats
349
+
350
+ def _find_repositories(self, path: Path) -> list[Path]:
351
+ """Find all git repositories in a directory."""
352
+ repos = []
353
+
354
+ for root, dirs, files in os.walk(path):
355
+ root_path = Path(root)
356
+
357
+ # Check for regular git repo
358
+ if ".git" in dirs:
359
+ repos.append(root_path)
360
+ dirs.remove(".git") # Don't recurse into .git
361
+ dirs[:] = [] # Don't recurse into subdirectories of a repo
362
+ continue
363
+
364
+ # Check for bare repo
365
+ if "HEAD" in files and "objects" in dirs and "refs" in dirs:
366
+ repos.append(root_path)
367
+ dirs[:] = [] # Don't recurse into subdirectories
368
+ continue
369
+
370
+ return repos
371
+
372
+ def _get_directory_size(self, path: Path) -> int:
373
+ """Calculate total size of a directory in bytes."""
374
+ total = 0
375
+ try:
376
+ for entry in path.rglob("*"):
377
+ if entry.is_file():
378
+ try:
379
+ total += entry.stat().st_size
380
+ except OSError:
381
+ pass
382
+ except OSError:
383
+ pass
384
+ return total
385
+
386
+ def _count_files(self, path: Path) -> int:
387
+ """Count non-hidden files in a directory."""
388
+ count = 0
389
+ try:
390
+ for entry in path.rglob("*"):
391
+ if entry.is_file() and not any(p.startswith(".") for p in entry.parts):
392
+ count += 1
393
+ except OSError:
394
+ pass
395
+ return count
396
+
397
+ def _analyze_languages(self, path: Path) -> dict[str, int]:
398
+ """Analyze language distribution in a repository."""
399
+ extensions: defaultdict[str, int] = defaultdict(int)
400
+
401
+ language_map = {
402
+ ".py": "Python",
403
+ ".js": "JavaScript",
404
+ ".ts": "TypeScript",
405
+ ".jsx": "React JSX",
406
+ ".tsx": "React TSX",
407
+ ".java": "Java",
408
+ ".go": "Go",
409
+ ".rs": "Rust",
410
+ ".c": "C",
411
+ ".cpp": "C++",
412
+ ".h": "C/C++ Header",
413
+ ".cs": "C#",
414
+ ".rb": "Ruby",
415
+ ".php": "PHP",
416
+ ".swift": "Swift",
417
+ ".kt": "Kotlin",
418
+ ".scala": "Scala",
419
+ ".r": "R",
420
+ ".sql": "SQL",
421
+ ".html": "HTML",
422
+ ".css": "CSS",
423
+ ".scss": "SCSS",
424
+ ".sass": "Sass",
425
+ ".less": "Less",
426
+ ".json": "JSON",
427
+ ".yaml": "YAML",
428
+ ".yml": "YAML",
429
+ ".xml": "XML",
430
+ ".md": "Markdown",
431
+ ".sh": "Shell",
432
+ ".bash": "Bash",
433
+ ".zsh": "Zsh",
434
+ ".ps1": "PowerShell",
435
+ ".dockerfile": "Dockerfile",
436
+ }
437
+
438
+ try:
439
+ for entry in path.rglob("*"):
440
+ if entry.is_file() and not any(p.startswith(".") for p in entry.parts):
441
+ ext = entry.suffix.lower()
442
+ if ext in language_map:
443
+ extensions[language_map[ext]] += 1
444
+ except OSError:
445
+ pass
446
+
447
+ return dict(extensions)
448
+
449
+ def _categorize_repositories(self, repos: list[Path]) -> dict[str, int]:
450
+ """Categorize repositories by directory structure."""
451
+ categories: dict[str, int] = defaultdict(int)
452
+
453
+ for repo_path in repos:
454
+ # Look for category indicators in path
455
+ parts = repo_path.parts
456
+
457
+ if "private" in parts:
458
+ categories["private"] += 1
459
+ elif "public" in parts:
460
+ categories["public"] += 1
461
+ elif "starred" in parts:
462
+ categories["starred"] += 1
463
+ elif "watched" in parts:
464
+ categories["watched"] += 1
465
+ elif "forks" in parts:
466
+ categories["forks"] += 1
467
+ elif "organizations" in parts:
468
+ categories["organizations"] += 1
469
+ elif "gists" in parts:
470
+ categories["gists"] += 1
471
+ else:
472
+ categories["other"] += 1
473
+
474
+ return dict(categories)
475
+
476
+ def generate_report(
477
+ self,
478
+ path: Path | None = None,
479
+ format: str = "text",
480
+ ) -> str:
481
+ """Generate a backup report."""
482
+ stats = self.analyze_directory(path)
483
+
484
+ if format == "json":
485
+ return json.dumps(stats.to_dict(), indent=2)
486
+
487
+ if format == "yaml":
488
+ import yaml
489
+ return yaml.dump(stats.to_dict(), default_flow_style=False)
490
+
491
+ # Text format
492
+ lines = [
493
+ "=" * 60,
494
+ "NAVIG-GITHUB BACKUP REPORT",
495
+ "=" * 60,
496
+ "",
497
+ f"📁 Backup Directory: {stats.path}",
498
+ f"📊 Analyzed: {stats.analyzed_at}",
499
+ "",
500
+ "SUMMARY",
501
+ "-" * 40,
502
+ f"Total Repositories: {stats.total_repositories}",
503
+ f"Total Size: {stats.total_size_mb:.2f} MB ({stats.total_size_gb:.3f} GB)",
504
+ f"Total Files: {stats.total_files:,}",
505
+ f"Total Commits: {stats.total_commits:,}",
506
+ f"Average Repo Size: {stats.avg_repo_size_mb:.2f} MB",
507
+ "",
508
+ ]
509
+
510
+ if stats.categories:
511
+ lines.extend([
512
+ "CATEGORIES",
513
+ "-" * 40,
514
+ ])
515
+ for category, count in sorted(stats.categories.items(), key=lambda x: -x[1]):
516
+ lines.append(f" {category}: {count}")
517
+ lines.append("")
518
+
519
+ if stats.languages:
520
+ lines.extend([
521
+ "LANGUAGES (Top 10)",
522
+ "-" * 40,
523
+ ])
524
+ top_languages = sorted(stats.languages.items(), key=lambda x: -x[1])[:10]
525
+ for lang, count in top_languages:
526
+ lines.append(f" {lang}: {count} files")
527
+ lines.append("")
528
+
529
+ if stats.repositories:
530
+ lines.extend([
531
+ "TOP 10 LARGEST REPOSITORIES",
532
+ "-" * 40,
533
+ ])
534
+ top_repos = sorted(stats.repositories, key=lambda x: -x.size_bytes)[:10]
535
+ for repo in top_repos:
536
+ lines.append(f" {repo.name}: {repo.size_mb:.2f} MB")
537
+
538
+ lines.extend(["", "=" * 60])
539
+
540
+ return "\n".join(lines)
541
+
542
+ def get_growth_stats(self) -> dict[str, Any]:
543
+ """Get backup growth statistics from history."""
544
+ if len(self._history) < 2:
545
+ return {
546
+ "has_data": False,
547
+ "message": "Not enough history for growth analysis",
548
+ }
549
+
550
+ first = self._history[0]
551
+ last = self._history[-1]
552
+
553
+ total_repos = sum(h.repos_cloned + h.repos_updated for h in self._history)
554
+ total_failures = sum(h.repos_failed for h in self._history)
555
+ total_duration = sum(h.duration_seconds for h in self._history)
556
+
557
+ return {
558
+ "has_data": True,
559
+ "backup_count": len(self._history),
560
+ "first_backup": first.started_at,
561
+ "last_backup": last.started_at,
562
+ "total_repos_processed": total_repos,
563
+ "total_failures": total_failures,
564
+ "success_rate": ((total_repos - total_failures) / total_repos * 100) if total_repos > 0 else 100,
565
+ "avg_duration_seconds": total_duration / len(self._history),
566
+ "total_duration_hours": total_duration / 3600,
567
+ }