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 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.0] - 2026-03-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -15,15 +16,50 @@ from gitea_dashboard.display import render_dashboard
|
|||||||
_DEFAULT_URL = "http://192.168.0.106:3000"
|
_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.
|
"""Point d'entree principal.
|
||||||
|
|
||||||
1. Lit GITEA_URL (defaut: http://192.168.0.106:3000) et GITEA_TOKEN (requis)
|
Args:
|
||||||
2. Cree le GiteaClient
|
argv: Arguments CLI. Si None, utilise sys.argv (via argparse).
|
||||||
3. Collecte les donnees via collect_all()
|
|
||||||
4. Affiche via render_dashboard()
|
1. Parse les options CLI (--repo, --exclude)
|
||||||
5. Gere les erreurs : config manquante, connexion refusee, timeout
|
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)
|
console = Console(stderr=True)
|
||||||
|
|
||||||
token = os.environ.get("GITEA_TOKEN")
|
token = os.environ.get("GITEA_TOKEN")
|
||||||
@@ -38,7 +74,7 @@ def main() -> None:
|
|||||||
client = GiteaClient(url, token)
|
client = GiteaClient(url, token)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
repos = collect_all(client)
|
repos = collect_all(client, include=args.repo, exclude=args.exclude)
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
|
console.print("[red]Erreur : connexion refusee. Verifiez l'URL et le serveur Gitea.[/red]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -22,13 +22,37 @@ class RepoData:
|
|||||||
milestones: list[dict] # [{title, open_issues, closed_issues, due_on}]
|
milestones: list[dict] # [{title, open_issues, closed_issues, due_on}]
|
||||||
|
|
||||||
|
|
||||||
def collect_all(client: GiteaClient) -> list[RepoData]:
|
def _matches_any(name: str, patterns: list[str]) -> bool:
|
||||||
"""Collecte les donnees de tous les repos.
|
"""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()
|
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] = []
|
result: list[RepoData] = []
|
||||||
|
|
||||||
for repo in repos:
|
for repo in repos:
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ class TestMainNominal:
|
|||||||
mock_collect.return_value = []
|
mock_collect.return_value = []
|
||||||
|
|
||||||
with patch.dict("os.environ", env, clear=False):
|
with patch.dict("os.environ", env, clear=False):
|
||||||
main()
|
main([])
|
||||||
|
|
||||||
mock_client_cls.assert_called_once_with("http://localhost:3000", "test-token-123")
|
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)
|
mock_render.assert_called_once_with(mock_collect.return_value)
|
||||||
|
|
||||||
@patch("gitea_dashboard.cli.render_dashboard")
|
@patch("gitea_dashboard.cli.render_dashboard")
|
||||||
@@ -38,7 +38,7 @@ class TestMainNominal:
|
|||||||
mock_collect.return_value = []
|
mock_collect.return_value = []
|
||||||
|
|
||||||
with patch.dict("os.environ", env, clear=True):
|
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")
|
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."""
|
"""main() exits with code 1 and prints message mentioning GITEA_TOKEN."""
|
||||||
with patch.dict("os.environ", {}, clear=True):
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main([])
|
||||||
|
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
@@ -70,7 +70,7 @@ class TestMainConnectionErrors:
|
|||||||
|
|
||||||
with patch.dict("os.environ", env, clear=True):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main([])
|
||||||
|
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class TestMainConnectionErrors:
|
|||||||
|
|
||||||
with patch.dict("os.environ", env, clear=True):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main([])
|
||||||
|
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ class TestMainConnectionErrors:
|
|||||||
|
|
||||||
with patch.dict("os.environ", env, clear=True):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main([])
|
||||||
|
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
@@ -120,8 +120,76 @@ class TestMainConnectionErrors:
|
|||||||
with patch.dict("os.environ", env, clear=True):
|
with patch.dict("os.environ", env, clear=True):
|
||||||
mock_collect.side_effect = make_exc(_os.environ)
|
mock_collect.side_effect = make_exc(_os.environ)
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
main()
|
main([])
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert env["GITEA_TOKEN"] not in captured.out
|
assert env["GITEA_TOKEN"] not in captured.out
|
||||||
assert env["GITEA_TOKEN"] not in captured.err
|
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)
|
||||||
|
|||||||
@@ -128,3 +128,81 @@ class TestCollectAll:
|
|||||||
assert result[0].is_fork is True
|
assert result[0].is_fork is True
|
||||||
assert result[0].is_archived is True
|
assert result[0].is_archived is True
|
||||||
assert result[0].is_mirror 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 == []
|
||||||
|
|||||||
Reference in New Issue
Block a user