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:
@@ -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)
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user