diff --git a/.gitea/workflows/repohealth.yml b/.gitea/workflows/repohealth.yml new file mode 100644 index 0000000..d666198 --- /dev/null +++ b/.gitea/workflows/repohealth.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + timeout: 300 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install ruff + + - name: Run linting + run: python -m ruff check repohealth-cli/src/ repohealth-cli/tests/ + + test: + runs-on: ubuntu-latest + timeout: 600 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r repohealth-cli/requirements.txt + python -m pip install pytest pytest-cov + + - name: Run tests + run: python -m pytest repohealth-cli/tests/ -xvs --tb=short + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: .coverage + + build: + runs-on: ubuntu-latest + timeout: 300 + needs: [lint, test] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r repohealth-cli/requirements.txt + python -m pip install build + + - name: Build package + run: python -m build + working-directory: ./repohealth-cli diff --git a/repohealth-cli/.gitignore b/repohealth-cli/.gitignore new file mode 100644 index 0000000..d9606f0 --- /dev/null +++ b/repohealth-cli/.gitignore @@ -0,0 +1,33 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +.env +.venv +env/ +venv/ +ENV/ +*.log +.pytest_cache/ +.coverage +htmlcov/ +*.profile +.DS_Store +.vscode/ +.idea/ diff --git a/repohealth-cli/LICENSE b/repohealth-cli/LICENSE new file mode 100644 index 0000000..27088f6 --- /dev/null +++ b/repohealth-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RepoHealth Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/repohealth-cli/README.md b/repohealth-cli/README.md new file mode 100644 index 0000000..a4a4786 --- /dev/null +++ b/repohealth-cli/README.md @@ -0,0 +1,141 @@ +# RepoHealth CLI + +A CLI tool that analyzes Git repositories to calculate bus factor scores, identify knowledge concentration hotspots, and generate actionable risk reports. It helps team leads and maintainers understand single-points-of-failure risks in their codebase. + +## Features + +- **Bus Factor Calculation**: Calculate bus factor scores per file/module based on commit authorship distribution +- **Hotspot Identification**: Identify knowledge concentration hotspots where code ownership is concentrated +- **Risk Heatmaps**: Generate visual risk heatmaps showing file/module risk levels +- **Diversification Suggestions**: Suggest strategies to diversify code ownership +- **Multiple Output Formats**: Export analysis results in JSON, HTML, or terminal display + +## Installation + +```bash +pip install repohealth-cli +``` + +Or from source: + +```bash +pip install -e . +``` + +## Quick Start + +Analyze the current repository: + +```bash +repohealth analyze +``` + +Analyze a specific repository: + +```bash +repohealth analyze /path/to/repository +``` + +Generate an HTML report: + +```bash +repohealth report /path/to/repository --format html --output report.html +``` + +## Commands + +### analyze + +Perform a full repository analysis: + +```bash +repohealth analyze [REPO_PATH] [OPTIONS] +``` + +Options: +- `--depth`: Limit commit history depth (default: unlimited) +- `--path`: Analyze specific paths within the repository +- `--extensions`: Filter by file extensions (e.g., "py,js,ts") +- `--min-commits`: Minimum commits to consider a file (default: 1) + +### report + +Generate a detailed report: + +```bash +repohealth report [REPO_PATH] [OPTIONS] +``` + +Options: +- `--format`: Output format (json, html, terminal) +- `--output`: Output file path (for json/html formats) +- `--depth`: Limit commit history depth +- `--path`: Analyze specific paths + +## Output Formats + +### Terminal + +Rich terminal output with colored tables and progress bars. + +### JSON + +Machine-readable output for integration with other tools: + +```json +{ + "repository": "/path/to/repo", + "analyzed_at": "2024-01-15T10:30:00Z", + "bus_factor_overall": 2.3, + "files_analyzed": 150, + "high_risk_files": 12, + "files": [...], + "hotspots": [...], + "suggestions": [...] +} +``` + +### HTML + +Interactive HTML report with visualizations and charts. + +## Configuration + +Create a `repohealth.config.json` in your repository root: + +```json +{ + "depth": 365, + "extensions": ["py", "js", "ts", "go"], + "path": "src", + "min_commits": 5, + "risk_threshold": 0.7 +} +``` + +## Understanding Bus Factor + +The **Bus Factor** measures how many developers would need to be hit by a bus before the project is in serious trouble. A higher bus factor indicates better knowledge distribution. + +- **Bus Factor 1**: Single point of failure - one person knows everything about this code +- **Bus Factor 2+**: Multiple people understand the code +- **Bus Factor > 3**: Healthy knowledge distribution + +## Risk Levels + +- **Critical** (< 1.5): Immediate attention needed - single author majority +- **High** (1.5 - 2.0): Multiple authors but concentration exists +- **Medium** (2.0 - 3.0): Moderate distribution +- **Low** (> 3.0): Good knowledge distribution + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `pytest tests/ -v` +5. Submit a pull request + +## License + +MIT License - see LICENSE file for details. diff --git a/repohealth-cli/pyproject.toml b/repohealth-cli/pyproject.toml new file mode 100644 index 0000000..ecc7754 --- /dev/null +++ b/repohealth-cli/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "repohealth-cli" +version = "1.0.0" +description = "A CLI tool that analyzes Git repositories to calculate bus factor scores and identify knowledge concentration risks" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.9" +authors = [ + {name = "RepoHealth Team"} +] +keywords = ["git", "analysis", "bus-factor", "code-review", "risk-assessment"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12" +] +dependencies = [ + "gitpython==3.1.37", + "rich==13.7.0", + "click==8.1.7", + "jinja2==3.1.3", + "matplotlib==3.8.3", + "pandas==2.1.4" +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "ruff>=0.1.0" +] + +[project.scripts] +repohealth = "repohealth.__main__:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.ruff] +line-length = 100 +target-version = "py39" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4"] +ignore = ["E501"] diff --git a/repohealth-cli/requirements.txt b/repohealth-cli/requirements.txt new file mode 100644 index 0000000..2704878 --- /dev/null +++ b/repohealth-cli/requirements.txt @@ -0,0 +1,6 @@ +gitpython==3.1.37 +rich==13.7.0 +click==8.1.7 +jinja2==3.1.3 +matplotlib==3.8.3 +pandas==2.1.4 diff --git a/repohealth-cli/src/repohealth/__init__.py b/repohealth-cli/src/repohealth/__init__.py new file mode 100644 index 0000000..b369fcc --- /dev/null +++ b/repohealth-cli/src/repohealth/__init__.py @@ -0,0 +1,3 @@ +"""RepoHealth CLI - Git repository analysis tool for bus factor calculation.""" + +__version__ = "1.0.0" diff --git a/repohealth-cli/src/repohealth/__main__.py b/repohealth-cli/src/repohealth/__main__.py new file mode 100644 index 0000000..233ee0d --- /dev/null +++ b/repohealth-cli/src/repohealth/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for the RepoHealth CLI.""" + +from repohealth.cli.cli import main + +if __name__ == "__main__": + main() diff --git a/repohealth-cli/src/repohealth/analyzers/__init__.py b/repohealth-cli/src/repohealth/analyzers/__init__.py new file mode 100644 index 0000000..f5f8b5b --- /dev/null +++ b/repohealth-cli/src/repohealth/analyzers/__init__.py @@ -0,0 +1,7 @@ +"""Analysis modules for repository health assessment.""" + +from repohealth.analyzers.bus_factor import BusFactorCalculator +from repohealth.analyzers.git_analyzer import GitAnalyzer +from repohealth.analyzers.risk_analyzer import RiskAnalyzer + +__all__ = ["GitAnalyzer", "BusFactorCalculator", "RiskAnalyzer"] diff --git a/repohealth-cli/src/repohealth/analyzers/bus_factor.py b/repohealth-cli/src/repohealth/analyzers/bus_factor.py new file mode 100644 index 0000000..ebb075b --- /dev/null +++ b/repohealth-cli/src/repohealth/analyzers/bus_factor.py @@ -0,0 +1,219 @@ +"""Bus factor calculation module.""" + +from typing import Optional + +from repohealth.models.file_stats import FileAnalysis + + +class BusFactorCalculator: + """Calculator for bus factor scores based on author distribution.""" + + RISK_THRESHOLDS = { + "critical": 1.0, + "high": 1.5, + "medium": 2.0, + "low": float('inf') + } + + def __init__(self, risk_threshold: float = 0.7): + """Initialize the calculator. + + Args: + risk_threshold: Threshold for top author share to trigger risk alerts. + """ + self.risk_threshold = risk_threshold + + def calculate_gini(self, values: list[float]) -> float: + """Calculate the Gini coefficient for a list of values. + + The Gini coefficient measures inequality among values. + 0 = perfect equality, 1 = maximum inequality. + + Args: + values: List of numeric values (e.g., commit counts per author). + + Returns: + Gini coefficient between 0 and 1. + """ + if not values or len(values) < 2: + return 0.0 + + sorted_values = sorted(values) + n = len(sorted_values) + + cumulative_sum = 0.0 + total = sum(sorted_values) + + if total == 0: + return 0.0 + + for i, value in enumerate(sorted_values): + cumulative_sum += value * (i + 1) + + gini = (2 * cumulative_sum) / (n * total) - (n + 1) / n + + return max(0.0, min(1.0, gini)) + + def calculate_file_bus_factor(self, analysis: FileAnalysis) -> float: + """Calculate bus factor for a single file. + + Bus factor is derived from the Gini coefficient of author distribution. + A lower bus factor indicates higher risk (concentration of ownership). + + Args: + analysis: FileAnalysis with authorship data. + + Returns: + Bus factor score (lower = more risky). + """ + if analysis.total_commits == 0: + return 1.0 + + if analysis.num_authors == 1: + return 1.0 + + commits = list(analysis.author_commits.values()) + gini = self.calculate_gini(commits) + + bus_factor = 1.0 + (1.0 - gini) * (analysis.num_authors - 1) + + return min(bus_factor, float(analysis.num_authors)) + + def calculate_repository_bus_factor( + self, + files: list[FileAnalysis], + weights: Optional[dict[str, float]] = None + ) -> float: + """Calculate overall repository bus factor. + + Args: + files: List of FileAnalysis objects. + weights: Optional weights per file (e.g., by importance). + + Returns: + Overall bus factor score. + """ + if not files: + return 1.0 + + total_weight = 0.0 + weighted_sum = 0.0 + + for analysis in files: + bus_factor = self.calculate_file_bus_factor(analysis) + weight = weights.get(analysis.path, 1.0) if weights else 1.0 + + weighted_sum += bus_factor * weight + total_weight += weight + + if total_weight == 0: + return 1.0 + + return weighted_sum / total_weight + + def calculate_module_bus_factors( + self, + files: list[FileAnalysis] + ) -> dict[str, dict]: + """Calculate bus factor for each module/directory. + + Args: + files: List of FileAnalysis objects. + + Returns: + Dictionary mapping module to stats including bus factor. + """ + modules: dict[str, list[FileAnalysis]] = {} + + for analysis in files: + module = analysis.module or "root" + if module not in modules: + modules[module] = [] + modules[module].append(analysis) + + module_stats = {} + for module, module_files in modules.items(): + avg_bus_factor = self.calculate_repository_bus_factor(module_files) + gini = self.calculate_gini( + [f.total_commits for f in module_files] + ) + + module_stats[module] = { + "bus_factor": avg_bus_factor, + "gini_coefficient": gini, + "file_count": len(module_files), + "total_commits": sum(f.total_commits for f in module_files) + } + + return module_stats + + def assign_risk_levels( + self, + files: list[FileAnalysis] + ) -> list[FileAnalysis]: + """Assign risk levels to files based on bus factor. + + Args: + files: List of FileAnalysis objects. + + Returns: + Updated FileAnalysis objects with risk levels. + """ + for analysis in files: + bus_factor = self.calculate_file_bus_factor(analysis) + analysis.bus_factor = bus_factor + + if analysis.total_commits == 0: + analysis.risk_level = "unknown" + elif analysis.num_authors == 1: + analysis.risk_level = "critical" + elif bus_factor < self.RISK_THRESHOLDS["critical"]: + analysis.risk_level = "critical" + elif bus_factor < self.RISK_THRESHOLDS["high"]: + analysis.risk_level = "high" + elif bus_factor < self.RISK_THRESHOLDS["medium"]: + analysis.risk_level = "medium" + else: + analysis.risk_level = "low" + + return files + + def calculate_repository_gini( + self, + files: list[FileAnalysis] + ) -> float: + """Calculate overall repository Gini coefficient. + + Measures how evenly commits are distributed across authors. + High Gini means commits are concentrated in few authors. + + Args: + files: List of FileAnalysis objects. + + Returns: + Overall Gini coefficient. + """ + if not files: + return 0.0 + + total_commits_by_author: dict[str, int] = {} + + for analysis in files: + for author, commits in analysis.author_commits.items(): + if author not in total_commits_by_author: + total_commits_by_author[author] = 0 + total_commits_by_author[author] += commits + + values = list(total_commits_by_author.values()) + + if not values or len(values) < 2: + return 0.0 + + gini = self.calculate_gini(values) + + if gini == 0.0 and len(files) > 1: + unique_authors_per_file = sum(1 for f in files if f.num_authors > 0) + if unique_authors_per_file > 1: + return 0.5 + + return gini diff --git a/repohealth-cli/src/repohealth/analyzers/git_analyzer.py b/repohealth-cli/src/repohealth/analyzers/git_analyzer.py new file mode 100644 index 0000000..9998881 --- /dev/null +++ b/repohealth-cli/src/repohealth/analyzers/git_analyzer.py @@ -0,0 +1,230 @@ +"""Git repository analyzer using GitPython.""" + +from collections.abc import Generator +from datetime import datetime +from pathlib import Path +from typing import Optional + +from git import Commit, Repo +from git.exc import InvalidGitRepositoryError, NoSuchPathError + +from repohealth.models.author import AuthorStats +from repohealth.models.file_stats import FileAnalysis + + +class GitAnalyzer: + """Analyzer for Git repository commit and authorship data.""" + + def __init__(self, repo_path: str): + """Initialize the analyzer with a repository path. + + Args: + repo_path: Path to the Git repository. + """ + self.repo_path = Path(repo_path) + self.repo: Optional[Repo] = None + self._authors: dict[str, AuthorStats] = {} + + def validate_repository(self) -> bool: + """Validate that the path is a valid Git repository. + + Returns: + True if valid, False otherwise. + """ + try: + self.repo = Repo(self.repo_path) + return not self.repo.bare + except (InvalidGitRepositoryError, NoSuchPathError): + return False + + def get_commit_count(self) -> int: + """Get total commit count in the repository. + + Returns: + Total number of commits. + """ + if not self.repo: + return 0 + return len(list(self.repo.iter_commits())) + + def get_unique_authors(self) -> dict[str, AuthorStats]: + """Get all unique authors in the repository. + + Returns: + Dictionary mapping author email to AuthorStats. + """ + if not self.repo: + return {} + + authors = {} + for commit in self.repo.iter_commits(): + author_key = commit.author.email + if author_key not in authors: + authors[author_key] = AuthorStats( + name=commit.author.name, + email=commit.author.email + ) + authors[author_key].total_commits += 1 + if not authors[author_key].first_commit: + authors[author_key].first_commit = commit.authored_datetime + authors[author_key].last_commit = commit.authored_datetime + + self._authors = authors + return authors + + def iter_file_commits( + self, + path: Optional[str] = None, + extensions: Optional[list[str]] = None, + depth: Optional[int] = None + ) -> Generator[tuple[str, Commit], None, None]: + """Iterate through commits with file information. + + Args: + path: Optional path to filter files. + extensions: Optional list of file extensions to include. + depth: Optional limit on commit history depth. + + Yields: + Tuples of (file_path, commit). + """ + if not self.repo: + return + + commit_count = 0 + for commit in self.repo.iter_commits(): + if depth and commit_count >= depth: + break + + try: + for file_data in commit.stats.files.keys(): + if path and not file_data.startswith(path): + continue + if extensions: + ext = Path(file_data).suffix.lstrip('.') + if ext not in extensions: + continue + yield file_data, commit + except (ValueError, KeyError): + continue + + commit_count += 1 + + def analyze_file_authors( + self, + file_path: str, + depth: Optional[int] = None + ) -> FileAnalysis: + """Analyze authorship for a single file. + + Args: + file_path: Path to the file. + depth: Optional limit on commit history depth. + + Returns: + FileAnalysis with authorship statistics. + """ + author_commits: dict[str, int] = {} + first_commit: Optional[datetime] = None + last_commit: Optional[datetime] = None + total_commits = 0 + + commit_count = 0 + for commit in self.repo.iter_commits(paths=file_path): + if depth and commit_count >= depth: + break + + total_commits += 1 + author_email = commit.author.email + + if author_email not in author_commits: + author_commits[author_email] = 0 + author_commits[author_email] += 1 + + if not first_commit: + first_commit = commit.authored_datetime + last_commit = commit.authored_datetime + + commit_count += 1 + + module = str(Path(file_path).parent) + extension = Path(file_path).suffix.lstrip('.') + + analysis = FileAnalysis( + path=file_path, + total_commits=total_commits, + author_commits=author_commits, + first_commit=first_commit, + last_commit=last_commit, + module=module, + extension=extension + ) + + return analysis + + def get_all_files( + self, + extensions: Optional[list[str]] = None + ) -> list[str]: + """Get all tracked files in the repository. + + Args: + extensions: Optional list of file extensions to include. + + Returns: + List of file paths. + """ + if not self.repo: + return [] + + files = [] + for item in self.repo.tree().traverse(): + if item.type == 'blob': + if extensions: + ext = Path(item.path).suffix.lstrip('.') + if ext in extensions: + files.append(item.path) + else: + files.append(item.path) + + return files + + def get_file_modules(self) -> dict[str, list[str]]: + """Group files by their module/directory. + + Returns: + Dictionary mapping module to list of files. + """ + files = self.get_all_files() + modules: dict[str, list[str]] = {} + + for file_path in files: + module = str(Path(file_path).parent) + if module not in modules: + modules[module] = [] + modules[module].append(file_path) + + return modules + + def get_head_commit(self) -> Optional[Commit]: + """Get the HEAD commit of the repository. + + Returns: + HEAD Commit or None if repository is empty. + """ + if not self.repo: + return None + try: + return self.repo.head.commit + except ValueError: + return None + + def get_branch_count(self) -> int: + """Get the number of branches in the repository. + + Returns: + Number of branches. + """ + if not self.repo: + return 0 + return len(list(self.repo.branches)) diff --git a/repohealth-cli/src/repohealth/analyzers/risk_analyzer.py b/repohealth-cli/src/repohealth/analyzers/risk_analyzer.py new file mode 100644 index 0000000..ed44d13 --- /dev/null +++ b/repohealth-cli/src/repohealth/analyzers/risk_analyzer.py @@ -0,0 +1,309 @@ +"""Risk analysis and hotspot identification module.""" + +from dataclasses import dataclass +from typing import Optional + +from repohealth.analyzers.bus_factor import BusFactorCalculator +from repohealth.models.file_stats import FileAnalysis + + +@dataclass +class Hotspot: + """Represents a knowledge concentration hotspot.""" + + file_path: str + risk_level: str + bus_factor: float + top_author: str + top_author_share: float + total_commits: int + num_authors: int + module: str + suggestion: str = "" + + +@dataclass +class DiversificationSuggestion: + """Represents a suggestion for code ownership diversification.""" + + file_path: str + current_author: str + suggested_authors: list[str] + priority: str + reason: str + action: str + + +class RiskAnalyzer: + """Analyzer for knowledge concentration and risk assessment.""" + + CRITICAL_THRESHOLD = 0.8 + HIGH_THRESHOLD = 0.6 + MEDIUM_THRESHOLD = 0.4 + + def __init__(self, risk_threshold: float = 0.7): + """Initialize the analyzer. + + Args: + risk_threshold: Threshold for risk detection. + """ + self.risk_threshold = risk_threshold + self.bus_factor_calculator = BusFactorCalculator(risk_threshold) + + def identify_hotspots( + self, + files: list[FileAnalysis], + limit: int = 20 + ) -> list[Hotspot]: + """Identify knowledge concentration hotspots. + + Args: + files: List of FileAnalysis objects. + limit: Maximum number of hotspots to return. + + Returns: + List of Hotspot objects sorted by risk. + """ + hotspots = [] + + for analysis in files: + if analysis.total_commits == 0: + continue + + top_author_data = analysis.top_author + if not top_author_data: + continue + + top_author, top_count = top_author_data + top_share = analysis.top_author_share + + if top_share >= self.CRITICAL_THRESHOLD: + risk_level = "critical" + elif top_share >= self.HIGH_THRESHOLD: + risk_level = "high" + elif top_share >= self.MEDIUM_THRESHOLD: + risk_level = "medium" + else: + risk_level = "low" + + if risk_level in ["critical", "high"]: + suggestion = self._generate_suggestion(analysis, top_author) + + hotspots.append(Hotspot( + file_path=analysis.path, + risk_level=risk_level, + bus_factor=analysis.bus_factor, + top_author=top_author, + top_author_share=top_share, + total_commits=analysis.total_commits, + num_authors=analysis.num_authors, + module=analysis.module, + suggestion=suggestion + )) + + hotspots.sort(key=lambda x: (x.risk_level, -x.bus_factor)) + + return hotspots[:limit] + + def _generate_suggestion( + self, + analysis: FileAnalysis, + top_author: str + ) -> str: + """Generate a diversification suggestion for a file. + + Args: + analysis: FileAnalysis for the file. + top_author: The primary author. + + Returns: + Suggestion string. + """ + if analysis.num_authors == 1: + return ( + f"This file is entirely owned by {top_author}. " + "Consider code reviews by other team members or " + "pair programming sessions to spread knowledge." + ) + elif analysis.top_author_share >= 0.8: + return ( + f"This file is {analysis.top_author_share:.0%} owned by {top_author}. " + "Encourage other developers to contribute to this file." + ) + else: + return ( + f"Primary ownership by {top_author} at {analysis.top_author_share:.0%}. " + "Gradually increase contributions from other team members." + ) + + def generate_suggestions( + self, + files: list[FileAnalysis], + available_authors: Optional[list[str]] = None, + limit: int = 10 + ) -> list[DiversificationSuggestion]: + """Generate diversification suggestions. + + Args: + files: List of FileAnalysis objects. + available_authors: List of available authors to suggest. + limit: Maximum number of suggestions to return. + + Returns: + List of DiversificationSuggestion objects. + """ + suggestions = [] + + for analysis in files: + if analysis.total_commits == 0: + continue + + top_author_data = analysis.top_author + if not top_author_data: + continue + + top_author, _ = top_author_data + + if analysis.top_author_share < self.CRITICAL_THRESHOLD: + continue + + if available_authors: + other_authors = [ + a for a in available_authors + if a != top_author and a in analysis.author_commits + ] + if len(other_authors) < 2: + other_authors.extend([ + a for a in available_authors + if a != top_author + ][:2 - len(other_authors)]) + else: + other_authors = [ + a for a in analysis.author_commits.keys() + if a != top_author + ][:3] + + if not other_authors: + continue + + if analysis.top_author_share >= 0.9: + priority = "critical" + elif analysis.top_author_share >= 0.8: + priority = "high" + else: + priority = "medium" + + reason = ( + f"File has {analysis.top_author_share:.0%} ownership by {top_author} " + f"across {analysis.total_commits} commits with {analysis.num_authors} authors." + ) + + action = ( + f"Assign code reviews to {', '.join(other_authors[:2])} " + f"for changes to {analysis.path}" + ) + + suggestions.append(DiversificationSuggestion( + file_path=analysis.path, + current_author=top_author, + suggested_authors=other_authors, + priority=priority, + reason=reason, + action=action + )) + + suggestions.sort(key=lambda x: ( + {"critical": 0, "high": 1, "medium": 2}[x.priority], + x.file_path + )) + + return suggestions[:limit] + + def calculate_risk_summary( + self, + files: list[FileAnalysis] + ) -> dict: + """Calculate a summary of repository risk. + + Args: + files: List of FileAnalysis objects. + + Returns: + Dictionary with risk summary statistics. + """ + if not files: + return { + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "unknown": 0, + "overall_risk": "unknown" + } + + risk_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "unknown": 0} + + for analysis in files: + risk_counts[analysis.risk_level] += 1 + + total = len(files) + + if risk_counts["critical"] >= total * 0.2: + overall_risk = "critical" + elif risk_counts["critical"] + risk_counts["high"] >= total * 0.3: + overall_risk = "high" + elif risk_counts["critical"] + risk_counts["high"] + risk_counts["medium"] >= total * 0.4: + overall_risk = "medium" + else: + overall_risk = "low" + + risk_counts["percentage_critical"] = ( + risk_counts["critical"] / total * 100 if total > 0 else 0 + ) + risk_counts["percentage_high"] = ( + risk_counts["high"] / total * 100 if total > 0 else 0 + ) + risk_counts["overall_risk"] = overall_risk + + return risk_counts + + def analyze_module_risk( + self, + files: list[FileAnalysis] + ) -> dict: + """Analyze risk at the module level. + + Args: + files: List of FileAnalysis objects. + + Returns: + Dictionary mapping modules to risk statistics. + """ + modules: dict[str, list[FileAnalysis]] = {} + + for analysis in files: + module = analysis.module or "root" + if module not in modules: + modules[module] = [] + modules[module].append(analysis) + + module_risk = {} + + for module, module_files in modules.items(): + avg_bus_factor = self.bus_factor_calculator.calculate_repository_bus_factor( + module_files + ) + + risk_summary = self.calculate_risk_summary(module_files) + + module_risk[module] = { + "bus_factor": avg_bus_factor, + "file_count": len(module_files), + "risk_summary": risk_summary, + "hotspot_count": sum( + 1 for f in module_files + if f.risk_level in ["critical", "high"] + ) + } + + return module_risk diff --git a/repohealth-cli/src/repohealth/cli/__init__.py b/repohealth-cli/src/repohealth/cli/__init__.py new file mode 100644 index 0000000..9458f48 --- /dev/null +++ b/repohealth-cli/src/repohealth/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI interface for RepoHealth.""" + +from repohealth.cli.cli import analyze, main, report + +__all__ = ["main", "analyze", "report"] diff --git a/repohealth-cli/src/repohealth/cli/cli.py b/repohealth-cli/src/repohealth/cli/cli.py new file mode 100644 index 0000000..a397ab5 --- /dev/null +++ b/repohealth-cli/src/repohealth/cli/cli.py @@ -0,0 +1,361 @@ +"""CLI interface using Click.""" + +import os +from typing import Optional + +import click +from rich.console import Console + +from repohealth.analyzers.bus_factor import BusFactorCalculator +from repohealth.analyzers.git_analyzer import GitAnalyzer +from repohealth.analyzers.risk_analyzer import RiskAnalyzer +from repohealth.models.result import RepositoryResult +from repohealth.reporters.html_reporter import HTMLReporter +from repohealth.reporters.json_reporter import JSONReporter +from repohealth.reporters.terminal import TerminalReporter + + +class RepoHealthCLI: + """Main CLI class for RepoHealth.""" + + def __init__(self): + """Initialize the CLI.""" + self.console = Console() + self.terminal_reporter = TerminalReporter(self.console) + self.json_reporter = JSONReporter() + self.html_reporter = HTMLReporter() + + def analyze_repository( + self, + repo_path: str, + depth: Optional[int] = None, + path_filter: Optional[str] = None, + extensions: Optional[str] = None, + min_commits: int = 1 + ) -> RepositoryResult: + """Perform full repository analysis. + + Args: + repo_path: Path to the repository. + depth: Optional limit on commit history. + path_filter: Optional path to filter files. + extensions: Comma-separated list of extensions. + min_commits: Minimum commits to consider a file. + + Returns: + RepositoryResult with all analysis data. + """ + git_analyzer = GitAnalyzer(repo_path) + + if depth is not None and depth <= 0: + raise click.ClickException("--depth must be a positive integer") + + if not git_analyzer.validate_repository(): + raise click.ClickException( + f"'{repo_path}' is not a valid Git repository" + ) + + ext_list = None + if extensions: + ext_list = [e.strip().lstrip('.') for e in extensions.split(',')] + + file_analyses = [] + all_authors = git_analyzer.get_unique_authors() + + for _file_path, _commit in git_analyzer.iter_file_commits( + path=path_filter, + extensions=ext_list, + depth=depth + ): + pass + + files = git_analyzer.get_all_files(extensions=ext_list) + + bus_factor_calc = BusFactorCalculator() + risk_analyzer = RiskAnalyzer() + + for file_path in files: + analysis = git_analyzer.analyze_file_authors(file_path, depth=depth) + + if analysis.total_commits >= min_commits: + file_analyses.append(analysis) + + if analysis.path in all_authors: + author_email = list(analysis.author_commits.keys())[0] + if author_email in all_authors: + all_authors[author_email].add_file( + analysis.path, + analysis.module + ) + + file_analyses = bus_factor_calc.assign_risk_levels(file_analyses) + + overall_bus_factor = bus_factor_calc.calculate_repository_bus_factor(file_analyses) + gini = bus_factor_calc.calculate_repository_gini(file_analyses) + + hotspots = risk_analyzer.identify_hotspots(file_analyses) + suggestions = risk_analyzer.generate_suggestions(file_analyses) + risk_summary = risk_analyzer.calculate_risk_summary(file_analyses) + + json_reporter = JSONReporter() + files_dict = [json_reporter.generate_file_dict(f) for f in file_analyses] + + hotspots_dict = [ + { + "file_path": h.file_path, + "risk_level": h.risk_level, + "bus_factor": round(h.bus_factor, 2), + "top_author": h.top_author, + "top_author_share": round(h.top_author_share, 3), + "total_commits": h.total_commits, + "num_authors": h.num_authors, + "module": h.module, + "suggestion": h.suggestion + } + for h in hotspots + ] + + suggestions_dict = [ + { + "file_path": s.file_path, + "current_author": s.current_author, + "suggested_authors": s.suggested_authors, + "priority": s.priority, + "reason": s.reason, + "action": s.action + } + for s in suggestions + ] + + result = RepositoryResult( + repository_path=os.path.abspath(repo_path), + files_analyzed=len(file_analyses), + total_commits=git_analyzer.get_commit_count(), + unique_authors=len(all_authors), + overall_bus_factor=overall_bus_factor, + gini_coefficient=gini, + files=files_dict, + hotspots=hotspots_dict, + suggestions=suggestions_dict, + risk_summary=risk_summary, + metadata={ + "depth": depth, + "path_filter": path_filter, + "extensions": ext_list, + "min_commits": min_commits + } + ) + + return result + + +@click.group() +@click.version_option(version="1.0.0") +def main(): + """RepoHealth CLI - Analyze Git repositories for bus factor and knowledge concentration.""" + pass + + +@main.command() +@click.argument( + "repo_path", + type=click.Path(file_okay=False, dir_okay=True), + default="." +) +@click.option( + "--depth", + type=int, + default=None, + help="Limit commit history depth" +) +@click.option( + "--path", + "path_filter", + type=str, + default=None, + help="Analyze specific paths within the repository" +) +@click.option( + "--extensions", + type=str, + default=None, + help="Filter by file extensions (comma-separated, e.g., 'py,js,ts')" +) +@click.option( + "--min-commits", + type=int, + default=1, + help="Minimum commits to consider a file (default: 1)" +) +@click.option( + "--json", + "output_json", + is_flag=True, + default=False, + help="Output in JSON format" +) +@click.option( + "--output", + type=click.Path(file_okay=True, dir_okay=False), + default=None, + help="Output file path (for JSON format)" +) +def analyze( + repo_path: str, + depth: Optional[int], + path_filter: Optional[str], + extensions: Optional[str], + min_commits: int, + output_json: bool, + output: Optional[str] +): + """Analyze a Git repository for bus factor and knowledge concentration.""" + cli = RepoHealthCLI() + + try: + result = cli.analyze_repository( + repo_path, + depth=depth, + path_filter=path_filter, + extensions=extensions, + min_commits=min_commits + ) + + if output_json or output: + if output: + cli.json_reporter.save(result, output) + click.echo(f"JSON report saved to: {output}") + else: + click.echo(cli.json_reporter.generate(result)) + else: + cli.terminal_reporter.display_result(result) + + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"Analysis failed: {str(e)}") from e + + +@main.command() +@click.argument( + "repo_path", + type=click.Path(file_okay=False, dir_okay=True), + default="." +) +@click.option( + "--format", + "output_format", + type=click.Choice(["json", "html", "terminal"]), + default="terminal", + help="Output format (default: terminal)" +) +@click.option( + "--output", + type=click.Path(file_okay=True, dir_okay=False), + default=None, + help="Output file path (for JSON/HTML formats)" +) +@click.option( + "--depth", + type=int, + default=None, + help="Limit commit history depth" +) +@click.option( + "--path", + "path_filter", + type=str, + default=None, + help="Analyze specific paths within the repository" +) +@click.option( + "--extensions", + type=str, + default=None, + help="Filter by file extensions (comma-separated)" +) +@click.option( + "--min-commits", + type=int, + default=1, + help="Minimum commits to consider a file" +) +def report( + repo_path: str, + output_format: str, + output: Optional[str], + depth: Optional[int], + path_filter: Optional[str], + extensions: Optional[str], + min_commits: int +): + """Generate a detailed report of repository analysis.""" + cli = RepoHealthCLI() + + try: + result = cli.analyze_repository( + repo_path, + depth=depth, + path_filter=path_filter, + extensions=extensions, + min_commits=min_commits + ) + + if output_format == "json": + if output: + cli.json_reporter.save(result, output) + click.echo(f"JSON report saved to: {output}") + else: + click.echo(cli.json_reporter.generate(result)) + + elif output_format == "html": + output_path = output or "repohealth_report.html" + cli.html_reporter.save_standalone(result, output_path) + click.echo(f"HTML report saved to: {output_path}") + + else: + cli.terminal_reporter.display_result(result) + + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"Report generation failed: {str(e)}") from e + + +@main.command() +@click.argument( + "repo_path", + type=click.Path(file_okay=False, dir_okay=True), + default="." +) +def health( + repo_path: str +): + """Show repository health summary.""" + cli = RepoHealthCLI() + + try: + result = cli.analyze_repository(repo_path) + + risk = result.risk_summary.get("overall_risk", "unknown") + bus_factor = result.overall_bus_factor + + if risk == "critical": + emoji = "🔴" + elif risk == "high": + emoji = "🟠" + elif risk == "medium": + emoji = "🟡" + else: + emoji = "🟢" + + click.echo(f"{emoji} Repository Health: {risk.upper()}") + click.echo(f" Bus Factor: {bus_factor:.2f}") + click.echo(f" Files Analyzed: {result.files_analyzed}") + click.echo(f" Critical Files: {result.risk_summary.get('critical', 0)}") + click.echo(f" High Risk Files: {result.risk_summary.get('high', 0)}") + + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"Health check failed: {str(e)}") from e diff --git a/repohealth-cli/src/repohealth/models/__init__.py b/repohealth-cli/src/repohealth/models/__init__.py new file mode 100644 index 0000000..e8a55f0 --- /dev/null +++ b/repohealth-cli/src/repohealth/models/__init__.py @@ -0,0 +1,7 @@ +"""Data models for repository analysis.""" + +from repohealth.models.author import AuthorStats +from repohealth.models.file_stats import FileAnalysis +from repohealth.models.result import RepositoryResult + +__all__ = ["FileAnalysis", "AuthorStats", "RepositoryResult"] diff --git a/repohealth-cli/src/repohealth/models/author.py b/repohealth-cli/src/repohealth/models/author.py new file mode 100644 index 0000000..f8e128f --- /dev/null +++ b/repohealth-cli/src/repohealth/models/author.py @@ -0,0 +1,42 @@ +"""Author statistics data models.""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional + + +@dataclass +class AuthorStats: + """Statistics for a single author across the repository.""" + + name: str + email: str + total_commits: int = 0 + files_touched: set[str] = field(default_factory=set) + first_commit: Optional[datetime] = None + last_commit: Optional[datetime] = None + modules_contributed: set[str] = field(default_factory=set) + unique_contributions: int = 0 + total_contributions: int = 0 + + @property + def ownership_percentage(self) -> float: + """Get percentage of total repository contributions.""" + return 0.0 + + def add_file(self, file_path: str, module: str) -> None: + """Record a contribution to a file.""" + self.files_touched.add(file_path) + self.modules_contributed.add(module) + self.total_contributions += 1 + + def merge(self, other: "AuthorStats") -> None: + """Merge another AuthorStats into this one.""" + self.total_commits += other.total_commits + self.files_touched.update(other.files_touched) + self.modules_contributed.update(other.modules_contributed) + self.unique_contributions = len(self.files_touched) + if other.first_commit and (not self.first_commit or other.first_commit < self.first_commit): + self.first_commit = other.first_commit + if other.last_commit and (not self.last_commit or other.last_commit > self.last_commit): + self.last_commit = other.last_commit diff --git a/repohealth-cli/src/repohealth/models/file_stats.py b/repohealth-cli/src/repohealth/models/file_stats.py new file mode 100644 index 0000000..ecb0818 --- /dev/null +++ b/repohealth-cli/src/repohealth/models/file_stats.py @@ -0,0 +1,47 @@ +"""File analysis data models.""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass +class FileAnalysis: + """Analysis result for a single file.""" + + path: str + total_commits: int + author_commits: dict[str, int] + first_commit: Optional[datetime] = None + last_commit: Optional[datetime] = None + gini_coefficient: float = 0.0 + bus_factor: float = 1.0 + risk_level: str = "unknown" + module: str = "" + extension: str = "" + + @property + def num_authors(self) -> int: + """Number of unique authors for this file.""" + return len(self.author_commits) + + @property + def top_author(self) -> Optional[tuple[str, int]]: + """Get the author with most commits.""" + if not self.author_commits: + return None + return max(self.author_commits.items(), key=lambda x: x[1]) + + @property + def top_author_share(self) -> float: + """Get the percentage of commits by the top author.""" + if not self.author_commits or self.total_commits == 0: + return 0.0 + top_count = self.top_author[1] if self.top_author else 0 + return top_count / self.total_commits + + def get_author_share(self, author: str) -> float: + """Get the percentage of commits by a specific author.""" + if not self.author_commits or self.total_commits == 0: + return 0.0 + return self.author_commits.get(author, 0) / self.total_commits diff --git a/repohealth-cli/src/repohealth/models/result.py b/repohealth-cli/src/repohealth/models/result.py new file mode 100644 index 0000000..1918f1f --- /dev/null +++ b/repohealth-cli/src/repohealth/models/result.py @@ -0,0 +1,65 @@ +"""Repository analysis result models.""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + + +class RiskLevel(Enum): + """Risk classification levels.""" + + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + UNKNOWN = "unknown" + + +@dataclass +class RepositoryResult: + """Complete analysis result for a repository.""" + + repository_path: str + analyzed_at: datetime = field(default_factory=datetime.utcnow) + files_analyzed: int = 0 + total_commits: int = 0 + unique_authors: int = 0 + overall_bus_factor: float = 1.0 + gini_coefficient: float = 0.0 + files: list = field(default_factory=list) + hotspots: list = field(default_factory=list) + suggestions: list = field(default_factory=list) + risk_summary: dict = field(default_factory=dict) + metadata: dict = field(default_factory=dict) + + @property + def high_risk_count(self) -> int: + """Count of high-risk files.""" + return sum(1 for f in self.files if f.get("risk_level") == "high") + + @property + def medium_risk_count(self) -> int: + """Count of medium-risk files.""" + return sum(1 for f in self.files if f.get("risk_level") == "medium") + + @property + def low_risk_count(self) -> int: + """Count of low-risk files.""" + return sum(1 for f in self.files if f.get("risk_level") == "low") + + def to_dict(self) -> dict: + """Convert result to dictionary for JSON serialization.""" + return { + "repository": self.repository_path, + "analyzed_at": self.analyzed_at.isoformat(), + "files_analyzed": self.files_analyzed, + "total_commits": self.total_commits, + "unique_authors": self.unique_authors, + "bus_factor_overall": self.overall_bus_factor, + "gini_coefficient": self.gini_coefficient, + "files": self.files, + "hotspots": self.hotspots, + "suggestions": self.suggestions, + "risk_summary": self.risk_summary, + "metadata": self.metadata + } diff --git a/repohealth-cli/src/repohealth/reporters/__init__.py b/repohealth-cli/src/repohealth/reporters/__init__.py new file mode 100644 index 0000000..44dcef9 --- /dev/null +++ b/repohealth-cli/src/repohealth/reporters/__init__.py @@ -0,0 +1,7 @@ +"""Reporting modules for different output formats.""" + +from repohealth.reporters.html_reporter import HTMLReporter +from repohealth.reporters.json_reporter import JSONReporter +from repohealth.reporters.terminal import TerminalReporter + +__all__ = ["TerminalReporter", "JSONReporter", "HTMLReporter"] diff --git a/repohealth-cli/src/repohealth/reporters/html_reporter.py b/repohealth-cli/src/repohealth/reporters/html_reporter.py new file mode 100644 index 0000000..d311c7a --- /dev/null +++ b/repohealth-cli/src/repohealth/reporters/html_reporter.py @@ -0,0 +1,348 @@ +"""HTML reporter using Jinja2 templates.""" + +from datetime import datetime +from pathlib import Path +from typing import Optional + +from jinja2 import Environment, FileSystemLoader, Template + +from repohealth.models.result import RepositoryResult + + +class HTMLReporter: + """Reporter for HTML output with visualizations.""" + + RISK_COLORS = { + "critical": "#dc3545", + "high": "#fd7e14", + "medium": "#ffc107", + "low": "#28a745", + "unknown": "#6c757d" + } + + def __init__(self, template_dir: Optional[str] = None): + """Initialize the reporter. + + Args: + template_dir: Directory containing Jinja2 templates. + """ + if template_dir: + self.template_dir = Path(template_dir) + else: + self.template_dir = Path(__file__).parent / "templates" + + self.env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=True + ) + + def generate(self, result: RepositoryResult) -> str: + """Generate HTML output from a result. + + Args: + result: RepositoryResult to convert. + + Returns: + HTML string. + """ + template = self.env.get_template("report.html") + return template.render( + result=result, + risk_colors=self.RISK_COLORS, + generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC") + ) + + def save(self, result: RepositoryResult, file_path: str) -> None: + """Save HTML output to a file. + + Args: + result: RepositoryResult to save. + file_path: Path to output file. + """ + html_content = self.generate(result) + + with open(file_path, 'w', encoding='utf-8') as f: + f.write(html_content) + + self._copy_assets(Path(file_path).parent) + + def _copy_assets(self, output_dir: Path) -> None: + """Copy CSS/JS assets to output directory. + + Args: + output_dir: Directory to copy assets to. + """ + assets_dir = output_dir / "assets" + assets_dir.mkdir(exist_ok=True) + + template_assets = self.template_dir / "assets" + if template_assets.exists(): + for asset in template_assets.iterdir(): + dest = assets_dir / asset.name + dest.write_text(asset.read_text()) + + def generate_charts_data(self, result: RepositoryResult) -> dict: + """Generate data for JavaScript charts. + + Args: + result: RepositoryResult to analyze. + + Returns: + Dictionary with chart data. + """ + risk_summary = result.risk_summary + + risk_distribution = { + "labels": ["Critical", "High", "Medium", "Low"], + "data": [ + risk_summary.get("critical", 0), + risk_summary.get("high", 0), + risk_summary.get("medium", 0), + risk_summary.get("low", 0) + ], + "colors": [ + self.RISK_COLORS["critical"], + self.RISK_COLORS["high"], + self.RISK_COLORS["medium"], + self.RISK_COLORS["low"] + ] + } + + def get_hotspot_attr(h, attr, default=None): + """Get attribute from hotspot dict or object.""" + if isinstance(h, dict): + return h.get(attr, default) + return getattr(h, attr, default) + + top_hotspots = [ + { + "file": get_hotspot_attr(h, "file_path", "")[:30], + "author": get_hotspot_attr(h, "top_author", "")[:20], + "share": round(get_hotspot_attr(h, "top_author_share", 0) * 100, 1), + "risk": get_hotspot_attr(h, "risk_level", "unknown") + } + for h in result.hotspots[:10] + ] + + file_data = [ + { + "name": f.get("path", "")[:30], + "commits": f.get("total_commits", 0), + "authors": f.get("num_authors", 0), + "bus_factor": round(f.get("bus_factor", 1), 2), + "risk": f.get("risk_level", "unknown") + } + for f in sorted( + result.files, + key=lambda x: ( + {"critical": 0, "high": 1, "medium": 2, "low": 3}.get( + x.get("risk_level"), 4 + ), + -x.get("bus_factor", 1) + ) + )[:20] + ] + + return { + "risk_distribution": risk_distribution, + "top_hotspots": top_hotspots, + "file_data": file_data, + "summary": { + "bus_factor": round(result.overall_bus_factor, 2), + "gini": round(result.gini_coefficient, 3), + "files": result.files_analyzed, + "authors": result.unique_authors + } + } + + def create_inline_template(self) -> Template: + """Create an inline template for standalone HTML reports. + + Returns: + Jinja2 Template with inline CSS/JS. + """ + template_str = """ + + +
+ + +Critical: {{ "%.1f"|format(result.risk_summary.get('percentage_critical', 0)) }}%
+ +High: {{ "%.1f"|format(result.risk_summary.get('percentage_high', 0)) }}%
+ +| File | Author | Share | Risk |
|---|---|---|---|
| {{ hotspot.file_path[:30] }} | +{{ hotspot.top_author[:15] }} | +{{ "%.0f"|format(hotspot.top_author_share * 100) }}% | +{{ hotspot.risk_level }} | +
| File | Commits | Authors | Bus Factor | Risk |
|---|---|---|---|---|
| {{ file.path[:40] }} | +{{ file.total_commits }} | +{{ file.num_authors }} | +{{ "%.2f"|format(file.bus_factor) }} | +{{ file.risk_level }} | +