Initial upload: Add repohealth-cli project with CI/CD workflow
Some checks failed
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled

This commit is contained in:
2026-02-05 17:13:56 +00:00
parent 64512e61f5
commit 02737bb857

365
src/repohealth/cli/cli.py Normal file
View 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)}")