From 2232260821c513dd792fbd59b75c887066bc4b7f Mon Sep 17 00:00:00 2001 From: sylvain Date: Wed, 11 Mar 2026 04:35:42 +0100 Subject: [PATCH] feat(cli): add --repo and --exclude filtering (fixes #5) Add argparse-based CLI parsing with repeatable --repo/-r (include) and --exclude/-x (exclude) options. Filtering is case-insensitive substring matching, applied post-fetch in collect_all() per ADR-005. - parse_args() separated from main() for testability - main(argv=None) accepts argv for test injection - collect_all() gains optional include/exclude parameters - 14 new tests (8 filtering + 6 CLI parsing/integration) - All 51 tests pass, backward compatible (no args = v1.0.0 behavior) Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 +++ src/gitea_dashboard/cli.py | 50 ++++++++++++++++--- src/gitea_dashboard/collector.py | 32 ++++++++++-- tests/test_cli.py | 84 +++++++++++++++++++++++++++++--- tests/test_collector.py | 78 +++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb14fe0..a6df7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- Options CLI `--repo`/`-r` et `--exclude`/`-x` pour filtrer les repos par nom (sous-chaine, insensible a la casse) +- Parsing CLI via argparse avec `parse_args()` separee pour testabilite +- Parametres `include`/`exclude` dans `collect_all()` pour filtrage post-fetch + ## [1.0.0] - 2026-03-10 ### Added diff --git a/src/gitea_dashboard/cli.py b/src/gitea_dashboard/cli.py index 1a5f145..279043f 100644 --- a/src/gitea_dashboard/cli.py +++ b/src/gitea_dashboard/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import argparse import os import sys @@ -15,15 +16,50 @@ from gitea_dashboard.display import render_dashboard _DEFAULT_URL = "http://192.168.0.106:3000" -def main() -> None: +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse les arguments CLI. + + Options: + --repo / -r : noms de repos a inclure (repeatable) + --exclude / -x : noms de repos a exclure (repeatable) + + Returns: + Namespace avec .repo (list[str] | None) et .exclude (list[str] | None) + """ + parser = argparse.ArgumentParser( + description="Dashboard CLI affichant l'etat des repos Gitea.", + ) + parser.add_argument( + "--repo", + "-r", + action="append", + default=None, + help="Filtrer par nom de repo (sous-chaine, insensible a la casse). Repeatable.", + ) + parser.add_argument( + "--exclude", + "-x", + action="append", + default=None, + help="Exclure les repos par nom (sous-chaine, insensible a la casse). Repeatable.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> None: """Point d'entree principal. - 1. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis) - 2. Cree le GiteaClient - 3. Collecte les donnees via collect_all() - 4. Affiche via render_dashboard() - 5. Gere les erreurs : config manquante, connexion refusee, timeout + Args: + argv: Arguments CLI. Si None, utilise sys.argv (via argparse). + + 1. Parse les options CLI (--repo, --exclude) + 2. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis) + 3. Cree le GiteaClient + 4. Collecte les donnees via collect_all() avec filtres + 5. Affiche via render_dashboard() + 6. Gere les erreurs : config manquante, connexion refusee, timeout """ + args = parse_args(argv) console = Console(stderr=True) token = os.environ.get("GITEA_TOKEN") @@ -38,7 +74,7 @@ def main() -> None: client = GiteaClient(url, token) try: - repos = collect_all(client) + 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]") sys.exit(1) diff --git a/src/gitea_dashboard/collector.py b/src/gitea_dashboard/collector.py index c194f15..2bed6a8 100644 --- a/src/gitea_dashboard/collector.py +++ b/src/gitea_dashboard/collector.py @@ -22,13 +22,37 @@ class RepoData: milestones: list[dict] # [{title, open_issues, closed_issues, due_on}] -def collect_all(client: GiteaClient) -> list[RepoData]: - """Collecte les donnees de tous les repos. +def _matches_any(name: str, patterns: list[str]) -> bool: + """Return True if name contains any of the patterns (case-insensitive).""" + name_lower = name.lower() + return any(p.lower() in name_lower for p in patterns) - Pour chaque repo : enrichit avec release et milestones. - Calcule open_issues = open_issues_count - open_pr_counter. + +def collect_all( + client: GiteaClient, + include: list[str] | None = None, + exclude: list[str] | None = None, +) -> list[RepoData]: + """Collecte les donnees des repos, avec filtrage optionnel. + + Args: + client: Client API Gitea. + include: Si fourni, ne garde que les repos dont le nom contient + au moins une des sous-chaines (insensible a la casse). + exclude: Si fourni, exclut les repos dont le nom contient + au moins une des sous-chaines (insensible a la casse). + + Ordre d'application : include d'abord (si present), puis exclude. + Si include est None ou vide, tous les repos sont inclus avant l'etape exclude. """ repos = client.get_repos() + + # Filtrage post-fetch : l'API Gitea ne supporte pas le filtre par nom + if include: + repos = [r for r in repos if _matches_any(r["name"], include)] + if exclude: + repos = [r for r in repos if not _matches_any(r["name"], exclude)] + result: list[RepoData] = [] for repo in repos: diff --git a/tests/test_cli.py b/tests/test_cli.py index 58bb341..d4070b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,10 +22,10 @@ class TestMainNominal: mock_collect.return_value = [] with patch.dict("os.environ", env, clear=False): - main() + main([]) mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123") - mock_collect.assert_called_once_with(mock_client) + mock_collect.assert_called_once_with(mock_client, include=None, exclude=None) mock_render.assert_called_once_with(mock_collect.return_value) @patch("gitea_dashboard.cli.render_dashboard") @@ -38,7 +38,7 @@ class TestMainNominal: mock_collect.return_value = [] with patch.dict("os.environ", env, clear=True): - main() + main([]) mock_client_cls.assert_called_once_with("http://192.168.0.106:3000", "my-token") @@ -50,7 +50,7 @@ class TestMainMissingToken: """main() exits with code 1 and prints message mentioning GITEA_TOKEN.""" with patch.dict("os.environ", {}, clear=True): with pytest.raises(SystemExit) as exc_info: - main() + main([]) assert exc_info.value.code == 1 captured = capsys.readouterr() @@ -70,7 +70,7 @@ class TestMainConnectionErrors: with patch.dict("os.environ", env, clear=True): with pytest.raises(SystemExit) as exc_info: - main() + main([]) assert exc_info.value.code == 1 @@ -84,7 +84,7 @@ class TestMainConnectionErrors: with patch.dict("os.environ", env, clear=True): with pytest.raises(SystemExit) as exc_info: - main() + main([]) assert exc_info.value.code == 1 @@ -98,7 +98,7 @@ class TestMainConnectionErrors: with patch.dict("os.environ", env, clear=True): with pytest.raises(SystemExit) as exc_info: - main() + main([]) assert exc_info.value.code == 1 @@ -120,8 +120,76 @@ class TestMainConnectionErrors: with patch.dict("os.environ", env, clear=True): mock_collect.side_effect = make_exc(_os.environ) with pytest.raises(SystemExit): - main() + main([]) captured = capsys.readouterr() assert env["GITEA_TOKEN"] not in captured.out assert env["GITEA_TOKEN"] not in captured.err + + +class TestParseArgs: + """Test parse_args function.""" + + def test_no_options(self): + """No arguments returns None for both repo and exclude.""" + from gitea_dashboard.cli import parse_args + + args = parse_args([]) + assert args.repo is None + assert args.exclude is None + + def test_single_repo(self): + """--repo foo returns repo=["foo"].""" + from gitea_dashboard.cli import parse_args + + args = parse_args(["--repo", "foo"]) + assert args.repo == ["foo"] + + def test_multiple_repo(self): + """--repo foo --repo bar returns repo=["foo", "bar"].""" + from gitea_dashboard.cli import parse_args + + args = parse_args(["--repo", "foo", "--repo", "bar"]) + assert args.repo == ["foo", "bar"] + + def test_short_flags(self): + """-r foo -x bar works like long forms.""" + from gitea_dashboard.cli import parse_args + + args = parse_args(["-r", "foo", "-x", "bar"]) + assert args.repo == ["foo"] + assert args.exclude == ["bar"] + + +class TestMainWithFilters: + """Test main() passes filters to collect_all.""" + + @patch("gitea_dashboard.cli.render_dashboard") + @patch("gitea_dashboard.cli.collect_all") + @patch("gitea_dashboard.cli.GiteaClient") + def test_main_passes_filters(self, mock_client_cls, mock_collect, mock_render): + """main() passes include/exclude from CLI args to collect_all.""" + env = {"GITEA_TOKEN": "test-token"} + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_collect.return_value = [] + + with patch.dict("os.environ", env, clear=True): + main(["--repo", "dash", "--exclude", "old"]) + + mock_collect.assert_called_once_with(mock_client, include=["dash"], exclude=["old"]) + + @patch("gitea_dashboard.cli.render_dashboard") + @patch("gitea_dashboard.cli.collect_all") + @patch("gitea_dashboard.cli.GiteaClient") + def test_main_no_filters_passes_none(self, mock_client_cls, mock_collect, mock_render): + """Without options, collect_all is called with include=None, exclude=None.""" + env = {"GITEA_TOKEN": "test-token"} + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_collect.return_value = [] + + with patch.dict("os.environ", env, clear=True): + main([]) + + mock_collect.assert_called_once_with(mock_client, include=None, exclude=None) diff --git a/tests/test_collector.py b/tests/test_collector.py index d9870c3..40773be 100644 --- a/tests/test_collector.py +++ b/tests/test_collector.py @@ -128,3 +128,81 @@ class TestCollectAll: assert result[0].is_fork is True assert result[0].is_archived is True assert result[0].is_mirror is True + + +class TestCollectAllFiltering: + """Test collect_all filtering (include/exclude).""" + + def _setup_client(self, repo_names: list[str]) -> MagicMock: + """Create a mock client returning repos with the given names.""" + client = MagicMock() + client.get_repos.return_value = [ + _make_repo(name=n, full_name=f"admin/{n}") for n in repo_names + ] + client.get_latest_release.return_value = None + client.get_milestones.return_value = [] + return client + + def test_no_filter_returns_all(self): + """Without include/exclude, all repos are returned (backward compat).""" + client = self._setup_client(["alpha", "beta", "gamma"]) + + result = collect_all(client) + + assert [r.name for r in result] == ["alpha", "beta", "gamma"] + + def test_include_single(self): + """Include filters repos by substring match.""" + client = self._setup_client(["gitea-dashboard", "infra-core", "notes"]) + + result = collect_all(client, include=["dashboard"]) + + assert [r.name for r in result] == ["gitea-dashboard"] + + def test_include_multiple(self): + """Multiple include patterns are OR-combined.""" + client = self._setup_client(["gitea-dashboard", "infra-core", "notes"]) + + result = collect_all(client, include=["dashboard", "infra"]) + + assert [r.name for r in result] == ["gitea-dashboard", "infra-core"] + + def test_exclude_single(self): + """Exclude removes repos matching the substring.""" + client = self._setup_client(["gitea-dashboard", "old-fork", "notes"]) + + result = collect_all(client, exclude=["fork"]) + + assert [r.name for r in result] == ["gitea-dashboard", "notes"] + + def test_include_and_exclude(self): + """Include is applied first, then exclude.""" + client = self._setup_client(["projet-web", "projet-old", "infra"]) + + result = collect_all(client, include=["projet"], exclude=["old"]) + + assert [r.name for r in result] == ["projet-web"] + + def test_case_insensitive(self): + """Filtering is case-insensitive.""" + client = self._setup_client(["Gitea-Dashboard", "infra"]) + + result = collect_all(client, include=["dashboard"]) + + assert [r.name for r in result] == ["Gitea-Dashboard"] + + def test_no_match_returns_empty(self): + """Returns empty list when no repo matches include filter.""" + client = self._setup_client(["alpha", "beta"]) + + result = collect_all(client, include=["inexistant"]) + + assert result == [] + + def test_exclude_all_returns_empty(self): + """Returns empty list when all repos are excluded.""" + client = self._setup_client(["alpha", "beta"]) + + result = collect_all(client, exclude=["alpha", "beta"]) + + assert result == []