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