diff --git a/README.md b/README.md index 55e2312..df804c5 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ uvx release-scope collect --group team/backend --output public --cache cache.jso ``` `--output` is a directory: `collect` writes `report.json` there, next to the page that shows it (see Site). -`--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to -collect; the report is still written and names the error on that service. A service GitLab denies access to fails +`--group` and `--project` are repeatable and can be mixed. `--exclude` (`-x`) skips every project whose path +matches a glob like `team/*-sandbox`. It is repeatable, `*` also matches `/`, and a skipped project is left out of +the report without being queried. The command exits `1` when any service failed to collect; the report is still written and names the error on that service. A service GitLab denies access to fails alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments disabled is reported with a warning and no rows, without querying it. Only a rejected token, or a group or project passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report @@ -62,6 +63,7 @@ neither looked up nor counted as tasks of a release. The service records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way. +`--exclude` drops linked projects the same way. `--jira` is repeatable and cannot be combined with `--group` or `--project`; an issue Jira does not return exits `1`. ## Configuration diff --git a/release_scope/__main__.py b/release_scope/__main__.py index fa8d669..1ab38c7 100644 --- a/release_scope/__main__.py +++ b/release_scope/__main__.py @@ -74,6 +74,12 @@ def _collect_command( # noqa: PLR0913, PLR0917 project: typing.Annotated[ list[str] | None, typer.Option("--project", "-p", help="GitLab project path; repeatable.") ] = None, + exclude: typing.Annotated[ + list[str] | None, + typer.Option( + "--exclude", "-x", help="Glob of GitLab project paths to skip, like 'team/*-sandbox'; repeatable." + ), + ] = None, include_subgroups: typing.Annotated[ bool, typer.Option("--include-subgroups", help="Also collect projects in subgroups of each --group.") ] = False, @@ -91,9 +97,15 @@ def _collect_command( # noqa: PLR0913, PLR0917 if cache_warning: typer.echo(f"Warning: {cache_warning}", err=True) if jira: - report = use_case.for_issues(keys=list(dict.fromkeys(jira)), cache=cache) + report = use_case.for_issues(keys=list(dict.fromkeys(jira)), exclude=exclude or [], cache=cache) else: - report = use_case(groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache) + report = use_case( + groups=group or [], + projects=project or [], + exclude=exclude or [], + include_subgroups=include_subgroups, + cache=cache, + ) write_site(output, report.model_dump_json(indent=2)) if cache_path: cache.save(cache_path) diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index e9a7ea6..9282a01 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -1,6 +1,7 @@ import collections.abc import dataclasses import datetime as dt +import fnmatch import http import typing from urllib.parse import quote @@ -87,6 +88,10 @@ def _commit_ref(commit: Commit) -> CommitRef: ) +def _excluded(path: str, patterns: collections.abc.Sequence[str]) -> bool: + return any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns) + + def _row_keys(services: list[Service]) -> list[str]: return sorted({key.key for service in services for row in service.rows if row.in_scope for key in row.jira_keys}) @@ -123,12 +128,19 @@ def __call__( *, groups: collections.abc.Sequence[str], projects: collections.abc.Sequence[str], + exclude: collections.abc.Sequence[str], include_subgroups: bool, cache: Cache, ) -> Report: + resolved: typing.Final = self._resolve_projects( + groups=groups, + projects=[path for path in projects if not _excluded(path, exclude)], + include_subgroups=include_subgroups, + ) services: typing.Final = [ self._collect_or_explain(project, cache, links=None) - for project in self._resolve_projects(groups=groups, projects=projects, include_subgroups=include_subgroups) + for project in resolved + if not _excluded(project.path_with_namespace, exclude) ] return Report( collected_at=dt.datetime.now(dt.UTC), @@ -137,7 +149,9 @@ def __call__( jira=self._read_issues(self.jira, _row_keys(services)) if self.jira else None, ) - def for_issues(self, *, keys: collections.abc.Sequence[str], cache: Cache) -> Report: + def for_issues( + self, *, keys: collections.abc.Sequence[str], exclude: collections.abc.Sequence[str], cache: Cache + ) -> Report: if self.jira is None: msg = "--jira needs RELEASE_SCOPE_JIRA_ENDPOINT and RELEASE_SCOPE_JIRA_TOKEN." raise ConfigError(msg) @@ -148,7 +162,8 @@ def for_issues(self, *, keys: collections.abc.Sequence[str], cache: Cache) -> Re for key in keys: issue = state.issues.get(key) for link in issue.links if issue else []: - links_by_project.setdefault(link.project, []).append(link) + if not _excluded(link.project, exclude): + links_by_project.setdefault(link.project, []).append(link) services.extend( self._scoped_service(path, links_by_project[path], cache) for path in sorted(links_by_project) ) diff --git a/tests/test_cli.py b/tests/test_cli.py index c918aa3..b4f7bb0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -63,6 +63,15 @@ def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib assert [gitlab[f"jobs:{pipeline_id}"].call_count for pipeline_id in (104, 103)] == [2, 1] +@pytest.mark.httpx2(assert_all_called=False) +@pytest.mark.usefixtures("cli_env", "gitlab") +def test_collect_skips_excluded_projects(tmp_path: pathlib.Path) -> None: + result: typing.Final = _invoke("collect", "-g", "team", "--exclude", "team/*", "-o", str(tmp_path / "public")) + + assert result.exit_code == 0, result.output + assert "0 services, 0 rows, 0 failed" in result.output + + @pytest.mark.usefixtures("cli_env", "gitlab") def test_unreadable_cache_is_ignored_with_a_warning(tmp_path: pathlib.Path) -> None: cache: typing.Final = tmp_path / "cache.json" diff --git a/tests/test_scope.py b/tests/test_scope.py index d0c5491..5408a5f 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -23,7 +23,7 @@ ) -def _scope(*keys: str, cache: Cache | None = None) -> Report: +def _scope(*keys: str, cache: Cache | None = None, exclude: tuple[str, ...] = ()) -> Report: use_case: typing.Final = CollectUseCase( api=GitLabApi(http=httpware.Client(base_url=ENDPOINT)), jira=JiraApi(http=httpware.Client(base_url=JIRA_ENDPOINT)), @@ -34,7 +34,7 @@ def _scope(*keys: str, cache: Cache | None = None) -> Report: jira_project_keys=["SHOP"], ), ) - return use_case.for_issues(keys=list(keys) or ["SHOP-12"], cache=cache or Cache()) + return use_case.for_issues(keys=list(keys) or ["SHOP-12"], exclude=exclude, cache=cache or Cache()) def _service(report: Report, path: str = "team/svc") -> Service: @@ -61,6 +61,14 @@ def test_scope_collects_every_service_the_issues_link_to() -> None: assert _service(report, "team/worker").warnings[0].code == MessageCode.CI_DISABLED +@pytest.mark.httpx2(assert_all_called=False) +def test_linked_projects_matching_an_exclude_glob_are_dropped(scoped: respx.Router) -> None: + report: typing.Final = _scope(exclude=("team/w*",)) + + assert [item.project for item in report.services] == ["team/svc"] + assert not scoped["project:team/web"].called + + @pytest.mark.httpx2(assert_all_called=False) @pytest.mark.usefixtures("scoped") def test_rows_above_the_latest_linked_change_are_kept_out_of_scope() -> None: diff --git a/tests/test_use_case.py b/tests/test_use_case.py index 467e1f3..82fdd01 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -51,6 +51,7 @@ def _collect( *, groups: tuple[str, ...] = ("team",), projects: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), with_jira: bool = False, **overrides: typing.Any, # noqa: ANN401 ) -> Report: @@ -59,7 +60,7 @@ def _collect( jira=JiraApi(http=httpware.Client(base_url=JIRA_ENDPOINT)) if with_jira else None, settings=_settings(**overrides), ) - return use_case(groups=groups, projects=projects, include_subgroups=False, cache=cache or Cache()) + return use_case(groups=groups, projects=projects, exclude=exclude, include_subgroups=False, cache=cache or Cache()) def _only_service(report: Report) -> Service: @@ -501,6 +502,19 @@ def test_project_listed_twice_is_collected_once(gitlab: respx.Router) -> None: assert [item.project for item in report.services] == ["team/svc"] +@pytest.mark.usefixtures("gitlab") +def test_excluded_project_is_not_fetched() -> None: + report: typing.Final = _collect(projects=("team/old",), exclude=("team/old", "other/*")) + + assert [item.project for item in report.services] == ["team/svc"] + + +@pytest.mark.httpx2(assert_all_called=False) +@pytest.mark.usefixtures("gitlab") +def test_group_projects_matching_an_exclude_glob_are_dropped() -> None: + assert _collect(exclude=("team/s*",)).services == [] + + def test_service_without_production_deployment_lists_the_whole_default_branch(gitlab: respx.Router) -> None: gitlab["deploy:production"].respond(json=[])