greenmining 0.1.4__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.
- greenmining/__init__.py +20 -0
- greenmining/__main__.py +6 -0
- greenmining/__version__.py +3 -0
- greenmining/cli.py +370 -0
- greenmining/config.py +120 -0
- greenmining/controllers/__init__.py +11 -0
- greenmining/controllers/repository_controller.py +117 -0
- greenmining/gsf_patterns.py +802 -0
- greenmining/main.py +37 -0
- greenmining/models/__init__.py +12 -0
- greenmining/models/aggregated_stats.py +30 -0
- greenmining/models/analysis_result.py +48 -0
- greenmining/models/commit.py +71 -0
- greenmining/models/repository.py +89 -0
- greenmining/presenters/__init__.py +11 -0
- greenmining/presenters/console_presenter.py +141 -0
- greenmining/services/__init__.py +13 -0
- greenmining/services/commit_extractor.py +282 -0
- greenmining/services/data_aggregator.py +442 -0
- greenmining/services/data_analyzer.py +333 -0
- greenmining/services/github_fetcher.py +266 -0
- greenmining/services/reports.py +531 -0
- greenmining/utils.py +320 -0
- greenmining-0.1.4.dist-info/METADATA +335 -0
- greenmining-0.1.4.dist-info/RECORD +29 -0
- greenmining-0.1.4.dist-info/WHEEL +5 -0
- greenmining-0.1.4.dist-info/entry_points.txt +2 -0
- greenmining-0.1.4.dist-info/licenses/LICENSE +21 -0
- greenmining-0.1.4.dist-info/top_level.txt +1 -0
greenmining/main.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Main entry point for Green Microservices Mining CLI."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from cli import cli
|
|
6
|
+
|
|
7
|
+
from greenmining.utils import colored_print, print_banner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main():
|
|
11
|
+
"""Main entry point with error handling."""
|
|
12
|
+
try:
|
|
13
|
+
print_banner("š± Green Microservices Mining Tool")
|
|
14
|
+
colored_print("Analyze GitHub repositories for sustainability practices\n", "cyan")
|
|
15
|
+
|
|
16
|
+
cli(obj={})
|
|
17
|
+
|
|
18
|
+
except KeyboardInterrupt:
|
|
19
|
+
colored_print("\n\nā ļø Operation cancelled by user", "yellow")
|
|
20
|
+
sys.exit(130)
|
|
21
|
+
|
|
22
|
+
except Exception as e:
|
|
23
|
+
colored_print(f"\nā Unexpected error: {e}", "red")
|
|
24
|
+
|
|
25
|
+
if "--verbose" in sys.argv or "-v" in sys.argv:
|
|
26
|
+
import traceback
|
|
27
|
+
|
|
28
|
+
colored_print("\nFull traceback:", "red")
|
|
29
|
+
traceback.print_exc()
|
|
30
|
+
else:
|
|
31
|
+
colored_print("Run with --verbose for detailed error information", "yellow")
|
|
32
|
+
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models Package - Data models and entities for green microservices mining.
|
|
3
|
+
|
|
4
|
+
This package contains all data structures and domain models following MCP architecture.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .aggregated_stats import AggregatedStats
|
|
8
|
+
from .analysis_result import AnalysisResult
|
|
9
|
+
from .commit import Commit
|
|
10
|
+
from .repository import Repository
|
|
11
|
+
|
|
12
|
+
__all__ = ["Repository", "Commit", "AnalysisResult", "AggregatedStats"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Aggregated Statistics Model - Represents aggregated analysis data."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AggregatedStats:
|
|
9
|
+
"""Data model for aggregated statistics."""
|
|
10
|
+
|
|
11
|
+
summary: dict = field(default_factory=dict)
|
|
12
|
+
known_patterns: dict = field(default_factory=dict)
|
|
13
|
+
repositories: list[dict] = field(default_factory=list)
|
|
14
|
+
languages: dict = field(default_factory=dict)
|
|
15
|
+
timestamp: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
def to_dict(self) -> dict:
|
|
18
|
+
"""Convert to dictionary."""
|
|
19
|
+
return {
|
|
20
|
+
"summary": self.summary,
|
|
21
|
+
"known_patterns": self.known_patterns,
|
|
22
|
+
"repositories": self.repositories,
|
|
23
|
+
"languages": self.languages,
|
|
24
|
+
"timestamp": self.timestamp,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict) -> "AggregatedStats":
|
|
29
|
+
"""Create from dictionary."""
|
|
30
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Analysis Result Model - Represents commit analysis output."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AnalysisResult:
|
|
9
|
+
"""Data model for commit analysis results."""
|
|
10
|
+
|
|
11
|
+
commit_id: str
|
|
12
|
+
repo_name: str
|
|
13
|
+
date: str
|
|
14
|
+
commit_message: str
|
|
15
|
+
green_aware: bool
|
|
16
|
+
green_evidence: Optional[str] = None
|
|
17
|
+
known_pattern: Optional[str] = None
|
|
18
|
+
pattern_confidence: Optional[str] = None
|
|
19
|
+
emergent_pattern: Optional[str] = None
|
|
20
|
+
files_changed: list = None
|
|
21
|
+
lines_added: int = 0
|
|
22
|
+
lines_deleted: int = 0
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
if self.files_changed is None:
|
|
26
|
+
self.files_changed = []
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
"""Convert to dictionary."""
|
|
30
|
+
return {
|
|
31
|
+
"commit_id": self.commit_id,
|
|
32
|
+
"repo_name": self.repo_name,
|
|
33
|
+
"date": self.date,
|
|
34
|
+
"commit_message": self.commit_message,
|
|
35
|
+
"green_aware": self.green_aware,
|
|
36
|
+
"green_evidence": self.green_evidence,
|
|
37
|
+
"known_pattern": self.known_pattern,
|
|
38
|
+
"pattern_confidence": self.pattern_confidence,
|
|
39
|
+
"emergent_pattern": self.emergent_pattern,
|
|
40
|
+
"files_changed": self.files_changed,
|
|
41
|
+
"lines_added": self.lines_added,
|
|
42
|
+
"lines_deleted": self.lines_deleted,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: dict) -> "AnalysisResult":
|
|
47
|
+
"""Create from dictionary."""
|
|
48
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Commit Model - Represents a Git commit."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Commit:
|
|
8
|
+
"""Data model for a Git commit."""
|
|
9
|
+
|
|
10
|
+
commit_id: str
|
|
11
|
+
repo_name: str
|
|
12
|
+
date: str
|
|
13
|
+
author: str
|
|
14
|
+
author_email: str
|
|
15
|
+
message: str
|
|
16
|
+
files_changed: list[str] = field(default_factory=list)
|
|
17
|
+
lines_added: int = 0
|
|
18
|
+
lines_deleted: int = 0
|
|
19
|
+
insertions: int = 0
|
|
20
|
+
deletions: int = 0
|
|
21
|
+
is_merge: bool = False
|
|
22
|
+
branches: list[str] = field(default_factory=list)
|
|
23
|
+
in_main_branch: bool = True
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
"""Convert to dictionary."""
|
|
27
|
+
return {
|
|
28
|
+
"commit_id": self.commit_id,
|
|
29
|
+
"repo_name": self.repo_name,
|
|
30
|
+
"date": self.date,
|
|
31
|
+
"author": self.author,
|
|
32
|
+
"author_email": self.author_email,
|
|
33
|
+
"message": self.message,
|
|
34
|
+
"files_changed": self.files_changed,
|
|
35
|
+
"lines_added": self.lines_added,
|
|
36
|
+
"lines_deleted": self.lines_deleted,
|
|
37
|
+
"insertions": self.insertions,
|
|
38
|
+
"deletions": self.deletions,
|
|
39
|
+
"is_merge": self.is_merge,
|
|
40
|
+
"branches": self.branches,
|
|
41
|
+
"in_main_branch": self.in_main_branch,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: dict) -> "Commit":
|
|
46
|
+
"""Create from dictionary."""
|
|
47
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_pydriller_commit(cls, commit, repo_name: str) -> "Commit":
|
|
51
|
+
"""Create from PyDriller commit object."""
|
|
52
|
+
return cls(
|
|
53
|
+
commit_id=commit.hash,
|
|
54
|
+
repo_name=repo_name,
|
|
55
|
+
date=(
|
|
56
|
+
commit.committer_date.isoformat()
|
|
57
|
+
if commit.committer_date
|
|
58
|
+
else commit.author_date.isoformat()
|
|
59
|
+
),
|
|
60
|
+
author=commit.author.name,
|
|
61
|
+
author_email=commit.author.email,
|
|
62
|
+
message=commit.msg,
|
|
63
|
+
files_changed=[f.new_path or f.filename for f in commit.modified_files],
|
|
64
|
+
lines_added=commit.insertions,
|
|
65
|
+
lines_deleted=commit.deletions,
|
|
66
|
+
insertions=commit.insertions,
|
|
67
|
+
deletions=commit.deletions,
|
|
68
|
+
is_merge=commit.merge,
|
|
69
|
+
branches=commit.branches if hasattr(commit, "branches") else [],
|
|
70
|
+
in_main_branch=commit.in_main_branch if hasattr(commit, "in_main_branch") else True,
|
|
71
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Repository Model - Represents a GitHub repository."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Repository:
|
|
9
|
+
"""Data model for a GitHub repository."""
|
|
10
|
+
|
|
11
|
+
repo_id: int
|
|
12
|
+
name: str
|
|
13
|
+
owner: str
|
|
14
|
+
full_name: str
|
|
15
|
+
url: str
|
|
16
|
+
clone_url: str
|
|
17
|
+
language: Optional[str]
|
|
18
|
+
stars: int
|
|
19
|
+
forks: int
|
|
20
|
+
watchers: int
|
|
21
|
+
open_issues: int
|
|
22
|
+
last_updated: str
|
|
23
|
+
created_at: str
|
|
24
|
+
description: Optional[str]
|
|
25
|
+
main_branch: str
|
|
26
|
+
topics: list[str] = field(default_factory=list)
|
|
27
|
+
size: int = 0
|
|
28
|
+
has_issues: bool = True
|
|
29
|
+
has_wiki: bool = True
|
|
30
|
+
archived: bool = False
|
|
31
|
+
license: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict:
|
|
34
|
+
"""Convert to dictionary."""
|
|
35
|
+
return {
|
|
36
|
+
"repo_id": self.repo_id,
|
|
37
|
+
"name": self.name,
|
|
38
|
+
"owner": self.owner,
|
|
39
|
+
"full_name": self.full_name,
|
|
40
|
+
"url": self.url,
|
|
41
|
+
"clone_url": self.clone_url,
|
|
42
|
+
"language": self.language,
|
|
43
|
+
"stars": self.stars,
|
|
44
|
+
"forks": self.forks,
|
|
45
|
+
"watchers": self.watchers,
|
|
46
|
+
"open_issues": self.open_issues,
|
|
47
|
+
"last_updated": self.last_updated,
|
|
48
|
+
"created_at": self.created_at,
|
|
49
|
+
"description": self.description,
|
|
50
|
+
"main_branch": self.main_branch,
|
|
51
|
+
"topics": self.topics,
|
|
52
|
+
"size": self.size,
|
|
53
|
+
"has_issues": self.has_issues,
|
|
54
|
+
"has_wiki": self.has_wiki,
|
|
55
|
+
"archived": self.archived,
|
|
56
|
+
"license": self.license,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_dict(cls, data: dict) -> "Repository":
|
|
61
|
+
"""Create from dictionary."""
|
|
62
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__annotations__})
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_github_repo(cls, repo, repo_id: int) -> "Repository":
|
|
66
|
+
"""Create from PyGithub repository object."""
|
|
67
|
+
return cls(
|
|
68
|
+
repo_id=repo_id,
|
|
69
|
+
name=repo.name,
|
|
70
|
+
owner=repo.owner.login,
|
|
71
|
+
full_name=repo.full_name,
|
|
72
|
+
url=repo.html_url,
|
|
73
|
+
clone_url=repo.clone_url,
|
|
74
|
+
language=repo.language,
|
|
75
|
+
stars=repo.stargazers_count,
|
|
76
|
+
forks=repo.forks_count,
|
|
77
|
+
watchers=repo.watchers_count,
|
|
78
|
+
open_issues=repo.open_issues_count,
|
|
79
|
+
last_updated=repo.updated_at.isoformat() if repo.updated_at else None,
|
|
80
|
+
created_at=repo.created_at.isoformat() if repo.created_at else None,
|
|
81
|
+
description=repo.description,
|
|
82
|
+
main_branch=repo.default_branch,
|
|
83
|
+
topics=repo.topics or [],
|
|
84
|
+
size=repo.size,
|
|
85
|
+
has_issues=repo.has_issues,
|
|
86
|
+
has_wiki=repo.has_wiki,
|
|
87
|
+
archived=repo.archived,
|
|
88
|
+
license=repo.license.key if repo.license else None,
|
|
89
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Console Presenter - Handles console output formatting."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tabulate import tabulate
|
|
6
|
+
|
|
7
|
+
from greenmining.utils import colored_print
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConsolePresenter:
|
|
11
|
+
"""Presenter for console/terminal output."""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def show_banner():
|
|
15
|
+
"""Display application banner."""
|
|
16
|
+
banner = """
|
|
17
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
18
|
+
ā Green Microservices Mining ā
|
|
19
|
+
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
20
|
+
"""
|
|
21
|
+
colored_print(banner, "green")
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def show_repositories(repositories: list[dict], limit: int = 10):
|
|
25
|
+
"""Display repository table."""
|
|
26
|
+
if not repositories:
|
|
27
|
+
colored_print("No repositories to display", "yellow")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
colored_print(f"\nš Top {min(limit, len(repositories))} Repositories:\n", "cyan")
|
|
31
|
+
|
|
32
|
+
table_data = []
|
|
33
|
+
for repo in repositories[:limit]:
|
|
34
|
+
table_data.append(
|
|
35
|
+
[
|
|
36
|
+
repo.get("full_name", "N/A"),
|
|
37
|
+
repo.get("language", "N/A"),
|
|
38
|
+
f"{repo.get('stars', 0):,}",
|
|
39
|
+
(
|
|
40
|
+
repo.get("description", "")[:50] + "..."
|
|
41
|
+
if len(repo.get("description", "")) > 50
|
|
42
|
+
else repo.get("description", "")
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
headers = ["Repository", "Language", "Stars", "Description"]
|
|
48
|
+
print(tabulate(table_data, headers=headers, tablefmt="grid"))
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def show_commit_stats(stats: dict[str, Any]):
|
|
52
|
+
"""Display commit statistics."""
|
|
53
|
+
colored_print("\nš Commit Statistics:\n", "cyan")
|
|
54
|
+
|
|
55
|
+
table_data = [
|
|
56
|
+
["Total Commits", f"{stats.get('total_commits', 0):,}"],
|
|
57
|
+
["Repositories", stats.get("total_repos", 0)],
|
|
58
|
+
["Avg per Repo", f"{stats.get('avg_per_repo', 0):.1f}"],
|
|
59
|
+
["Date Range", stats.get("date_range", "N/A")],
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
print(tabulate(table_data, headers=["Metric", "Value"], tablefmt="grid"))
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def show_analysis_results(results: dict[str, Any]):
|
|
66
|
+
"""Display analysis results."""
|
|
67
|
+
colored_print("\nš¬ Analysis Results:\n", "cyan")
|
|
68
|
+
|
|
69
|
+
summary = results.get("summary", {})
|
|
70
|
+
table_data = [
|
|
71
|
+
["Total Commits Analyzed", f"{summary.get('total_commits', 0):,}"],
|
|
72
|
+
["Green-Aware Commits", f"{summary.get('green_commits', 0):,}"],
|
|
73
|
+
["Green Rate", f"{summary.get('green_commit_rate', 0):.1%}"],
|
|
74
|
+
["Patterns Detected", len(results.get("known_patterns", {}))],
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
print(tabulate(table_data, headers=["Metric", "Value"], tablefmt="grid"))
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def show_pattern_distribution(patterns: dict[str, Any], limit: int = 10):
|
|
81
|
+
"""Display pattern distribution."""
|
|
82
|
+
if not patterns:
|
|
83
|
+
colored_print("No patterns to display", "yellow")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
colored_print(f"\nšÆ Top {limit} Green Patterns:\n", "cyan")
|
|
87
|
+
|
|
88
|
+
# Sort by count
|
|
89
|
+
sorted_patterns = sorted(
|
|
90
|
+
patterns.items(), key=lambda x: x[1].get("count", 0), reverse=True
|
|
91
|
+
)[:limit]
|
|
92
|
+
|
|
93
|
+
table_data = []
|
|
94
|
+
for pattern_name, data in sorted_patterns:
|
|
95
|
+
table_data.append(
|
|
96
|
+
[
|
|
97
|
+
pattern_name,
|
|
98
|
+
data.get("count", 0),
|
|
99
|
+
f"{data.get('percentage', 0):.1f}%",
|
|
100
|
+
data.get("confidence_distribution", {}).get("HIGH", 0),
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
headers = ["Pattern", "Count", "Percentage", "High Confidence"]
|
|
105
|
+
print(tabulate(table_data, headers=headers, tablefmt="grid"))
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def show_pipeline_status(status: dict[str, Any]):
|
|
109
|
+
"""Display pipeline status."""
|
|
110
|
+
colored_print("\nāļø Pipeline Status:\n", "cyan")
|
|
111
|
+
|
|
112
|
+
table_data = []
|
|
113
|
+
for phase, info in status.items():
|
|
114
|
+
status_icon = "ā
" if info.get("completed") else "ā³"
|
|
115
|
+
table_data.append(
|
|
116
|
+
[status_icon, phase, info.get("file", "N/A"), info.get("size", "N/A")]
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
headers = ["Status", "Phase", "Output File", "Size"]
|
|
120
|
+
print(tabulate(table_data, headers=headers, tablefmt="grid"))
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def show_progress_message(phase: str, current: int, total: int):
|
|
124
|
+
"""Display progress message."""
|
|
125
|
+
percentage = (current / total * 100) if total > 0 else 0
|
|
126
|
+
colored_print(f"[{phase}] Progress: {current}/{total} ({percentage:.1f}%)", "cyan")
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def show_error(message: str):
|
|
130
|
+
"""Display error message."""
|
|
131
|
+
colored_print(f"ā Error: {message}", "red")
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def show_success(message: str):
|
|
135
|
+
"""Display success message."""
|
|
136
|
+
colored_print(f"ā
{message}", "green")
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def show_warning(message: str):
|
|
140
|
+
"""Display warning message."""
|
|
141
|
+
colored_print(f"ā ļø Warning: {message}", "yellow")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Services Package - Core business logic and data processing services.
|
|
3
|
+
|
|
4
|
+
Services implement the actual mining, extraction, analysis operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .commit_extractor import CommitExtractor
|
|
8
|
+
from .data_aggregator import DataAggregator
|
|
9
|
+
from .data_analyzer import DataAnalyzer
|
|
10
|
+
from .github_fetcher import GitHubFetcher
|
|
11
|
+
from .reports import ReportGenerator
|
|
12
|
+
|
|
13
|
+
__all__ = ["GitHubFetcher", "CommitExtractor", "DataAnalyzer", "DataAggregator", "ReportGenerator"]
|