Initial upload: Add repohealth-cli project with CI/CD workflow
This commit is contained in:
365
src/repohealth/cli/cli.py
Normal file
365
src/repohealth/cli/cli.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""CLI interface using Click."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from repohealth.analyzers.git_analyzer import GitAnalyzer
|
||||
from repohealth.analyzers.bus_factor import BusFactorCalculator
|
||||
from repohealth.analyzers.risk_analyzer import RiskAnalyzer
|
||||
from repohealth.models.file_stats import FileAnalysis
|
||||
from repohealth.models.result import RepositoryResult
|
||||
from repohealth.reporters.terminal import TerminalReporter
|
||||
from repohealth.reporters.json_reporter import JSONReporter
|
||||
from repohealth.reporters.html_reporter import HTMLReporter
|
||||
|
||||
|
||||
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()
|
||||
|
||||
commit_count = 0
|
||||
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)}")
|
||||
|
||||
|
||||
@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)}")
|
||||
|
||||
|
||||
@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)}")
|
||||
Reference in New Issue
Block a user