Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 14 additions & 2 deletions release_scope/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions release_scope/_use_case.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import collections.abc
import dataclasses
import datetime as dt
import fnmatch
import http
import typing
from urllib.parse import quote
Expand Down Expand Up @@ -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})

Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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)
)
Expand Down
9 changes: 9 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions tests/test_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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:
Expand All @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion tests/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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=[])

Expand Down
Loading