diff --git a/src/gitea_dashboard/cli.py b/src/gitea_dashboard/cli.py index 62eda56..7a55721 100644 --- a/src/gitea_dashboard/cli.py +++ b/src/gitea_dashboard/cli.py @@ -59,9 +59,35 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: dest="format", help="Format de sortie (defaut: table).", ) + parser.add_argument( + "--health", + action="store_true", + default=False, + help="Verifie la connexion Gitea et affiche la version. Exit 0 si OK, 1 sinon.", + ) + parser.add_argument( + "--no-desc", + action="store_true", + default=False, + help="Masque la colonne Description dans le tableau.", + ) return parser.parse_args(argv) +def _run_health_check(client: GiteaClient, console: Console) -> None: + """Execute le health check et affiche les resultats. + + 1. Appelle client.get_version() -> affiche "Gitea vX.Y.Z" + 2. Appelle client.get_repos() -> affiche "N repos accessibles" + """ + version_info = client.get_version() + version = version_info.get("version", "inconnue") + console.print(f"Gitea v{version}") + + repos = client.get_repos() + console.print(f"{len(repos)} repos accessibles") + + def main(argv: list[str] | None = None) -> None: """Point d'entree principal. @@ -90,6 +116,10 @@ def main(argv: list[str] | None = None) -> None: client = GiteaClient(url, token) try: + if args.health: + _run_health_check(client, console) + return + repos = collect_all(client, include=args.repo, exclude=args.exclude) except requests.ConnectionError: console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]") @@ -111,4 +141,4 @@ def main(argv: list[str] | None = None) -> None: sorted_repos = sort_repos(repos, args.sort) print(export_json(sorted_repos)) # noqa: T201 else: - render_dashboard(repos, sort_key=args.sort) + render_dashboard(repos, sort_key=args.sort, show_description=not args.no_desc) diff --git a/src/gitea_dashboard/client.py b/src/gitea_dashboard/client.py index 1249167..6965c55 100644 --- a/src/gitea_dashboard/client.py +++ b/src/gitea_dashboard/client.py @@ -126,6 +126,18 @@ class GiteaClient: params={"state": "open"}, ) + def get_version(self) -> dict: + """Retourne la version de l'instance Gitea. + + Endpoint: GET /api/v1/version + Retourne: {"version": "1.21.0"} + Leve HTTPError si l'appel echoue. + """ + url = f"{self.base_url}/api/v1/version" + resp = self._get_with_retry(url) + resp.raise_for_status() + return resp.json() + def get_latest_commit(self, owner: str, repo: str) -> dict | None: """Retourne le dernier commit du repo, ou None si aucun. diff --git a/src/gitea_dashboard/display.py b/src/gitea_dashboard/display.py index 628906a..057350e 100644 --- a/src/gitea_dashboard/display.py +++ b/src/gitea_dashboard/display.py @@ -93,6 +93,13 @@ def _colorize_milestone_due(due_on: str | None) -> str: return "green" +def _truncate(text: str, max_length: int = 40) -> str: + """Tronque le texte a max_length caracteres avec '...' si necessaire.""" + if len(text) <= max_length: + return text + return text[:max_length] + "..." + + def sort_repos(repos: list[RepoData], sort_key: str) -> list[RepoData]: """Trie la liste des repos selon le critere donne. @@ -126,15 +133,18 @@ def render_dashboard( repos: list[RepoData], console: Console | None = None, sort_key: str = "name", + show_description: bool = True, ) -> None: """Affiche le dashboard complet dans le terminal. - - Tableau principal : nom repo, indicateurs (fork/archive/mirror), - issues ouvertes, derniere release (tag + date relative) + - Tableau principal : nom repo, description (optionnelle, tronquee a 40 chars), + indicateurs (fork/archive/mirror), issues ouvertes, derniere release - Section milestones : par repo ayant des milestones, nom, progression (closed/total), date echeance Le parametre console permet l'injection pour les tests. + Si show_description est True, ajoute une colonne "Description" + entre "Repo" et "Issues", tronquee a 40 caracteres. """ if console is None: console = Console() @@ -149,6 +159,8 @@ def render_dashboard( # Tableau principal table = Table(title="Gitea Dashboard") table.add_column("Repo", style="bold") + if show_description: + table.add_column("Description") table.add_column("Issues", justify="right") table.add_column("Release") table.add_column("Dernier commit") @@ -162,13 +174,19 @@ def render_dashboard( _format_relative_date(repo.last_commit_date) if repo.last_commit_date else "\u2014" ) - table.add_row( - name, - f"[{issues_style}]{issues_str}[/{issues_style}]", - release_str, - commit_str, + row = [name] + if show_description: + row.append(_truncate(repo.description or "")) + row.extend( + [ + f"[{issues_style}]{issues_str}[/{issues_style}]", + release_str, + commit_str, + ] ) + table.add_row(*row) + console.print(table) # Section milestones — uniquement si au moins un repo en a diff --git a/tests/test_cli.py b/tests/test_cli.py index aebaad4..6442d55 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,7 +26,9 @@ class TestMainNominal: mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123") mock_collect.assert_called_once_with(mock_client, include=None, exclude=None) - mock_render.assert_called_once_with(mock_collect.return_value, sort_key="name") + mock_render.assert_called_once_with( + mock_collect.return_value, sort_key="name", show_description=True + ) @patch("gitea_dashboard.cli.render_dashboard") @patch("gitea_dashboard.cli.collect_all") @@ -259,6 +261,96 @@ class TestParseArgsFormat: parse_args(["--format", "invalid"]) +class TestParseArgsHealth: + """Test --health argument parsing.""" + + def test_parse_args_health(self): + """--health sets health=True.""" + from gitea_dashboard.cli import parse_args + + args = parse_args(["--health"]) + assert args.health is True + + def test_parse_args_no_health_default(self): + """Without --health, health is False.""" + from gitea_dashboard.cli import parse_args + + args = parse_args([]) + assert args.health is False + + +class TestParseArgsNoDesc: + """Test --no-desc argument parsing.""" + + def test_parse_args_no_desc(self): + """--no-desc sets no_desc=True.""" + from gitea_dashboard.cli import parse_args + + args = parse_args(["--no-desc"]) + assert args.no_desc is True + + def test_parse_args_no_desc_default(self): + """Without --no-desc, no_desc is False.""" + from gitea_dashboard.cli import parse_args + + args = parse_args([]) + assert args.no_desc is False + + +class TestMainHealth: + """Test main() with --health.""" + + @patch("gitea_dashboard.cli.GiteaClient") + def test_main_health_success(self, mock_client_cls, capsys): + """--health displays version and repo count, exits normally.""" + env = {"GITEA_TOKEN": "test-token"} + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.get_version.return_value = {"version": "1.21.0"} + mock_client.get_repos.return_value = [{"id": 1}, {"id": 2}, {"id": 3}] + + with patch.dict("os.environ", env, clear=True): + main(["--health"]) + + captured = capsys.readouterr() + assert "Gitea v1.21.0" in captured.err + assert "3 repos accessibles" in captured.err + + @patch("gitea_dashboard.cli.GiteaClient") + def test_main_health_connection_error(self, mock_client_cls): + """--health with connection error exits with code 1.""" + env = {"GITEA_TOKEN": "test-token"} + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.get_version.side_effect = requests.ConnectionError("refused") + + with patch.dict("os.environ", env, clear=True): + with pytest.raises(SystemExit) as exc_info: + main(["--health"]) + + assert exc_info.value.code == 1 + + +class TestMainNoDesc: + """Test main() with --no-desc.""" + + @patch("gitea_dashboard.cli.render_dashboard") + @patch("gitea_dashboard.cli.collect_all") + @patch("gitea_dashboard.cli.GiteaClient") + def test_main_passes_no_desc_to_render(self, mock_client_cls, mock_collect, mock_render): + """--no-desc passes show_description=False to render_dashboard.""" + env = {"GITEA_TOKEN": "test-token"} + mock_client_cls.return_value = MagicMock() + mock_collect.return_value = [] + + with patch.dict("os.environ", env, clear=True): + main(["--no-desc"]) + + mock_render.assert_called_once_with( + mock_collect.return_value, sort_key="name", show_description=False + ) + + class TestMainFormatJson: """Test main() with --format json.""" diff --git a/tests/test_client.py b/tests/test_client.py index 63b4496..abc9611 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -287,6 +287,30 @@ class TestGetWithRetry429: assert mock_sleep.call_count == 2 +class TestGetVersion: + """Test get_version method.""" + + def test_get_version_success(self): + """Returns version dict on success.""" + client = GiteaClient("http://gitea.local:3000", "tok") + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = {"version": "1.21.0"} + + with patch.object(client.session, "get", return_value=mock_resp): + result = client.get_version() + + assert result == {"version": "1.21.0"} + + def test_get_version_connection_error(self): + """ConnectionError propagates to caller.""" + client = GiteaClient("http://gitea.local:3000", "tok") + + with patch.object(client.session, "get", side_effect=requests.ConnectionError("refused")): + with pytest.raises(requests.ConnectionError): + client.get_version() + + class TestGetPaginatedEdgeCases: """Test edge cases for API responses.""" diff --git a/tests/test_display.py b/tests/test_display.py index b48e69a..6197b37 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -230,6 +230,66 @@ class TestRenderDashboardEmpty: assert "Aucun repo" in output +class TestDescriptionColumn: + """Test Description column in dashboard table.""" + + def test_description_column_displayed(self): + """Table contains a Description column by default.""" + console, buf = _make_console() + repos = [_make_repo(name="test", description="My project")] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "Description" in output + assert "My project" in output + + def test_description_truncated_at_40(self): + """Description longer than 40 chars is truncated with '...'.""" + console, buf = _make_console() + long_desc = "A" * 60 + repos = [_make_repo(name="test", description=long_desc)] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + # Should contain first 40 chars + "..." + assert "A" * 40 + "..." in output + # Should NOT contain the full 60-char string + assert "A" * 60 not in output + + def test_description_short_not_truncated(self): + """Description of 20 chars is displayed as-is.""" + console, buf = _make_console() + repos = [_make_repo(name="test", description="Short description")] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "Short description" in output + + def test_description_empty(self): + """Empty description renders without crash.""" + console, buf = _make_console() + repos = [_make_repo(name="test", description="")] + + render_dashboard(repos, console=console) + output = buf.getvalue() + + assert "test" in output + + def test_no_description_flag(self): + """show_description=False hides the Description column.""" + console, buf = _make_console() + repos = [_make_repo(name="test", description="My project")] + + render_dashboard(repos, console=console, show_description=False) + output = buf.getvalue() + + assert "Description" not in output + assert "test" in output + + class TestRenderDashboardEdgeCases: """Test edge cases for dashboard rendering."""