diff --git a/src/repohealth/reporters/terminal.py b/src/repohealth/reporters/terminal.py new file mode 100644 index 0000000..e675df9 --- /dev/null +++ b/src/repohealth/reporters/terminal.py @@ -0,0 +1,258 @@ +"""Terminal reporter using Rich library.""" + +from typing import Optional +from datetime import datetime + +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text +from rich.style import Style +from rich.align import Align +from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn +from rich import box + +from repohealth.models.file_stats import FileAnalysis +from repohealth.models.result import RepositoryResult +from repohealth.analyzers.risk_analyzer import Hotspot, DiversificationSuggestion + + +class TerminalReporter: + """Reporter for terminal output using Rich.""" + + RISK_COLORS = { + "critical": "red", + "high": "orange3", + "medium": "yellow", + "low": "green", + "unknown": "grey" + } + + def __init__(self, console: Optional[Console] = None): + """Initialize the reporter. + + Args: + console: Rich Console instance. + """ + self.console = console or Console() + + def display_result(self, result: RepositoryResult) -> None: + """Display a complete analysis result. + + Args: + result: RepositoryResult to display. + """ + self.console.print(Panel( + self._get_overview_text(result), + title="Repository Health Analysis", + subtitle=f"Analyzed: {result.analyzed_at.strftime('%Y-%m-%d %H:%M')}", + expand=False + )) + + self._display_risk_summary(result) + self._display_file_stats(result) + self._display_hotspots(result) + self._display_suggestions(result) + + def _get_overview_text(self, result: RepositoryResult) -> Text: + """Get overview text for the result. + + Args: + result: RepositoryResult to display. + + Returns: + Rich Text object. + """ + text = Text() + text.append(f"Repository: ", style="bold") + text.append(f"{result.repository_path}\n") + text.append(f"Files Analyzed: ", style="bold") + text.append(f"{result.files_analyzed}\n") + text.append(f"Total Commits: ", style="bold") + text.append(f"{result.total_commits}\n") + text.append(f"Unique Authors: ", style="bold") + text.append(f"{result.unique_authors}\n") + text.append(f"Overall Bus Factor: ", style="bold") + text.append(f"{result.overall_bus_factor:.2f}\n") + text.append(f"Gini Coefficient: ", style="bold") + text.append(f"{result.gini_coefficient:.3f}\n") + return text + + def _display_risk_summary(self, result: RepositoryResult) -> None: + """Display risk summary. + + Args: + result: RepositoryResult to display. + """ + summary = result.risk_summary + if not summary: + return + + table = Table(title="Risk Summary", box=box.ROUNDED) + table.add_column("Risk Level", justify="center") + table.add_column("Count", justify="center") + table.add_column("Percentage", justify="center") + + levels = ["critical", "high", "medium", "low"] + for level in levels: + count = summary.get(level, 0) + pct = summary.get(f"percentage_{level}", 0) + color = self.RISK_COLORS.get(level, "grey") + table.add_row( + f"[{color}]{level.upper()}[/]", + str(count), + f"{pct:.1f}%" + ) + + self.console.print(Panel(table, title="Risk Overview", expand=False)) + + def _display_file_stats(self, result: RepositoryResult) -> None: + """Display file statistics table. + + Args: + result: RepositoryResult to display. + """ + if not result.files: + return + + table = Table(title="Top Files by Risk", box=box.ROUNDED) + table.add_column("File", style="dim", width=40) + table.add_column("Commits", justify="right") + table.add_column("Authors", justify="right") + table.add_column("Bus Factor", justify="right") + table.add_column("Risk", justify="center") + table.add_column("Top Author %", justify="right") + + sorted_files = 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) + ) + )[:15] + + for file_data in sorted_files: + risk_level = file_data.get("risk_level", "unknown") + color = self.RISK_COLORS.get(risk_level, "grey") + + table.add_row( + file_data.get("path", "")[:40], + str(file_data.get("total_commits", 0)), + str(file_data.get("num_authors", 0)), + f"{file_data.get('bus_factor', 1):.2f}", + f"[{color}]{risk_level.upper()}[/]", + f"{file_data.get('top_author_share', 0):.0%}" + ) + + self.console.print(Panel(table, title="File Analysis", expand=False)) + + def _display_hotspots(self, result: RepositoryResult) -> None: + """Display knowledge hotspots. + + Args: + result: RepositoryResult to display. + """ + if not result.hotspots: + return + + table = Table(title="Knowledge Hotspots", box=box.ROUNDED) + table.add_column("File", style="dim", width=35) + table.add_column("Top Author", width=20) + table.add_column("Ownership", justify="right") + table.add_column("Bus Factor", justify="right") + table.add_column("Risk", justify="center") + + for hotspot in result.hotspots[:10]: + color = self.RISK_COLORS.get(hotspot.risk_level, "grey") + table.add_row( + hotspot.file_path[:35], + hotspot.top_author[:20], + f"{hotspot.top_author_share:.0%}", + f"{hotspot.bus_factor:.2f}", + f"[{color}]{hotspot.risk_level.upper()}[/]" + ) + + self.console.print(Panel(table, title="Hotspots", expand=False)) + + def _display_suggestions(self, result: RepositoryResult) -> None: + """Display diversification suggestions. + + Args: + result: RepositoryResult to display. + """ + if not result.suggestions: + return + + table = Table(title="Diversification Suggestions", box=box.ROUNDED) + table.add_column("Priority", width=10) + table.add_column("File", style="dim", width=30) + table.add_column("Action", width=40) + + priority_colors = { + "critical": "red", + "high": "orange3", + "medium": "yellow" + } + + for suggestion in result.suggestions[:10]: + color = priority_colors.get(suggestion.priority, "grey") + table.add_row( + f"[{color}]{suggestion.priority.upper()}[/]", + suggestion.file_path[:30], + suggestion.action[:40] + ) + + self.console.print(Panel(table, title="Suggestions", expand=False)) + + def display_progress(self, message: str) -> Progress: + """Display a progress indicator. + + Args: + message: Progress message. + + Returns: + Progress instance for updating. + """ + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=self.console + ) + + def display_error(self, message: str) -> None: + """Display an error message. + + Args: + message: Error message to display. + """ + self.console.print(Panel( + Text(message, style="red"), + title="Error", + expand=False + )) + + def display_warning(self, message: str) -> None: + """Display a warning message. + + Args: + message: Warning message to display. + """ + self.console.print(Panel( + Text(message, style="yellow"), + title="Warning", + expand=False + )) + + def display_info(self, message: str) -> None: + """Display an info message. + + Args: + message: Info message to display. + """ + self.console.print(Panel( + Text(message, style="blue"), + title="Info", + expand=False + ))