diff --git a/README.md b/README.md index 73242db..ae3e103 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,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. `--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 +the report without being queried, including from the GitLab links of Jira issues. 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 and also exits `1`; the GitLab part is still written. @@ -93,7 +94,8 @@ Each service lists its `candidates`: the tags a release could ship, newest first rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history when the service has no production deployment. When rows sit above the newest tag, `untagged` counts them and gives the head commit, the next tag (the highest `X.Y.Z` tag with its minor version bumped, or `null` when no tag has that -form), and `create_url`, GitLab's new-tag form filled in with both. One row, trimmed: +form), and `create_url`, GitLab's new-tag form filled in with both. When the tag list was cut short, the next tag +comes from one more request for the highest versions. One row, trimmed: ```json { @@ -121,8 +123,9 @@ Jira's own error details, stay as written. **Services** lists every service with a production deployment or with rows, and every service that failed to collect, as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, the failed jobs of the picked tag, or of the newest tag before a pick, with the ones allowed to fail counted apart, a mark -when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`, and how many rows have no tag yet. The failed jobs are those of -the tag's pipeline and of the main pipeline of its commit; failures in other rows show only when the line is opened. +when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`, and how many rows have no tag yet, with a `+` when the cut +range holds no tag, so more untagged rows may lie below it. The failed jobs are those of the tag's pipeline and of +the main pipeline of its commit; failures in other rows show only when the line is opened. Opening a line shows the service's environments, warnings, a link to create the next tag on the head when rows have no tag yet, merge requests that are not merged yet, and its rows with tags and their pipelines, merge requests or commits, Jira keys with their status, environments, and failed jobs; rows diff --git a/release_scope/_gitlab.py b/release_scope/_gitlab.py index 98028e3..5ffcd8a 100644 --- a/release_scope/_gitlab.py +++ b/release_scope/_gitlab.py @@ -216,6 +216,14 @@ def latest_deployment(self, project_id: int, environment: str) -> Deployment | N def list_tags(self, project_id: int) -> tuple[list[Tag], bool]: return self._pages(f"{_API}/projects/{project_id}/repository/tags", {}, _Tags, resource="repository") + def list_highest_tags(self, project_id: int) -> list[Tag]: + return self._get( + f"{_API}/projects/{project_id}/repository/tags", + {"order_by": "version", "sort": "desc", "per_page": _PER_PAGE}, + _Tags, + resource="repository", + ).root + def list_first_parent_commits( self, project_id: int, ref_range: str, *, max_items: int ) -> tuple[list[Commit], bool]: diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index e38109a..57eb2ab 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -131,7 +131,7 @@

- + @@ -149,7 +149,7 @@

- +

@@ -331,10 +331,15 @@

notInProduction: "not in production", pickTag: "pick a tag", untagged: "{count} untagged", + untaggedAtLeast: "{count}+ untagged", untaggedRows: { one: "{count} merge request or commit has no tag yet.", other: "{count} merge requests or commits have no tag yet.", }, + untaggedRowsAtLeast: { + one: "At least {count} merge request or commit has no tag yet.", + other: "At least {count} merge requests or commits have no tag yet.", + }, createTag: "Create tag {name}", createAnyTag: "Create a tag", upToDate: "up to date", @@ -399,11 +404,17 @@

notInProduction: "нет на проде", pickTag: "выберите тег", untagged: "{count} без тега", + untaggedAtLeast: "{count}+ без тега", untaggedRows: { one: "{count} MR или коммит ещё без тега.", few: "{count} MR или коммита ещё без тега.", many: "{count} MR или коммитов ещё без тега.", }, + untaggedRowsAtLeast: { + one: "Не меньше {count} MR или коммита ещё без тега.", + few: "Не меньше {count} MR или коммитов ещё без тега.", + many: "Не меньше {count} MR или коммитов ещё без тега.", + }, createTag: "Создать тег {name}", createAnyTag: "Создать тег", upToDate: "всё на проде", @@ -722,6 +733,11 @@

pipelineIcon(pipeline) { return pipeline ? ICONS[pipeline.status] || pipeline.status : "⚠️"; }, + untaggedText(service, exact, atLeast) { + if (!service.untagged) return ""; + const partial = service.truncated && service.untagged.rows === service.rows.length; + return this.t(partial ? atLeast : exact, { count: service.untagged.rows }); + }, failureCounts(service) { const candidate = this.pickOf(service) || service.candidates[0]; if (!candidate) return ""; diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index a68f572..d04ac90 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -101,6 +101,7 @@ class _Walk: truncated: bool drafts: list[RowDraft] = dataclasses.field(default_factory=list) tags_by_sha: dict[str, list[str]] = dataclasses.field(default_factory=dict) + tag_names: list[str] = dataclasses.field(default_factory=list) main_pipelines: dict[str, Pipeline] = dataclasses.field(default_factory=dict) @@ -146,7 +147,7 @@ def __call__( collected_at=dt.datetime.now(dt.UTC), production_environment=self.settings.production_environment, services=services, - jira=self._read_issues(self.jira, _row_keys(services)) if self.jira else None, + jira=self._read_issues(self.jira, _row_keys(services), exclude) if self.jira else None, ) def for_issues( @@ -155,19 +156,20 @@ def for_issues( if self.jira is None: msg = "--jira needs RELEASE_SCOPE_JIRA_ENDPOINT and RELEASE_SCOPE_JIRA_TOKEN." raise ConfigError(msg) - state: typing.Final = self._read_issues(self.jira, keys) + state: typing.Final = self._read_issues(self.jira, keys, exclude) services: typing.Final[list[Service]] = [] if state.error is None: links_by_project: dict[str, list[LinkedChange]] = {} for key in keys: issue = state.issues.get(key) for link in issue.links if issue else []: - if not _excluded(link.project, exclude): - links_by_project.setdefault(link.project, []).append(link) + 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) ) - row_state: typing.Final = self._read_issues(self.jira, sorted(set(_row_keys(services)) - set(keys))) + row_state: typing.Final = self._read_issues( + self.jira, sorted(set(_row_keys(services)) - set(keys)), exclude + ) state.issues.update(row_state.issues) state.missing.extend(row_state.missing) state.error = row_state.error @@ -198,7 +200,9 @@ def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[Lin error=explain_failure(project.path_with_namespace, project.web_url, exc), ) - def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> JiraState: + def _read_issues( + self, jira: JiraApi, keys: collections.abc.Sequence[str], exclude: collections.abc.Sequence[str] + ) -> JiraState: state: typing.Final = JiraState() if not keys: return state @@ -223,16 +227,16 @@ def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> Ji ) try: for key, issue in state.issues.items(): - issue.links = self._linked_changes(jira, key) + issue.links = self._linked_changes(jira, key, exclude) except JiraError as exc: state.error = exc.message return state - def _linked_changes(self, jira: JiraApi, key: str) -> list[LinkedChange]: + def _linked_changes(self, jira: JiraApi, key: str, exclude: collections.abc.Sequence[str]) -> list[LinkedChange]: changes: typing.Final[dict[str, LinkedChange]] = {} for link in jira.remote_links(key): change = parse_gitlab_link(link.target.url if link.target else None, self.settings.gitlab.endpoint) - if change is not None: + if change is not None and not _excluded(change.project, exclude): changes.setdefault(change.url, change) return list(changes.values()) @@ -298,7 +302,7 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked for draft, is_linked, is_in_scope in zip(drafts, linked, in_scope, strict=True) ) service.candidates.extend(build_candidates(service, production)) - service.untagged = build_untagged(service, (name for names in walk.tags_by_sha.values() for name in names)) + service.untagged = build_untagged(service, walk.tag_names) return service def _walk( @@ -320,8 +324,10 @@ def _walk( tags, tag_list_cut = self.api.list_tags(project.id) if tag_list_cut: service.warnings.append(tags_truncated()) + walk.tag_names.extend(tag.name for tag in self.api.list_highest_tags(project.id)) for tag in tags: walk.tags_by_sha.setdefault(tag.commit.id, []).append(tag.name) + walk.tag_names.append(tag.name) walk.main_pipelines = self._latest_by_sha( self.api.list_push_pipelines(project.id, ref=default_branch, updated_after=since) ) diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md index ee4287e..e2bb6b4 100644 --- a/skills/release-scope/SKILL.md +++ b/skills/release-scope/SKILL.md @@ -47,7 +47,7 @@ token into the chat. Never echo a token. - Jira issue keys: `--jira KEY`, repeatable. It collects only the projects the issues link to, from production up to the latest linked change. It cannot be combined with `--group` or `--project`. - Projects the user wants left out: `--exclude GLOB`, repeatable, matched against the full project path (`*` also - matches `/`). It works with every selection above, and excluded projects are not queried. + matches `/`). It works with every selection above. Excluded projects are not queried and do not appear in Jira issue `links`. ## Collect diff --git a/tests/test_scope.py b/tests/test_scope.py index 5408a5f..826eba6 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -67,6 +67,8 @@ def test_linked_projects_matching_an_exclude_glob_are_dropped(scoped: respx.Rout assert [item.project for item in report.services] == ["team/svc"] assert not scoped["project:team/web"].called + assert report.jira is not None + assert [item.project for item in report.jira.issues["SHOP-12"].links] == ["team/svc"] @pytest.mark.httpx2(assert_all_called=False) diff --git a/tests/test_use_case.py b/tests/test_use_case.py index b1189dd..149aeca 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -288,6 +288,14 @@ def test_jira_issues_list_the_gitlab_changes_linked_to_them(jira: respx.Router) assert jira["remote_links:SHOP-12"].call_count == 1 +@pytest.mark.usefixtures("jira") +def test_links_to_excluded_projects_are_dropped_from_issues() -> None: + state: typing.Final = _collect(with_jira=True, exclude=("team/w*",)).jira + + assert state is not None + assert [item.project for item in state.issues["SHOP-12"].links] == ["team/svc"] + + @pytest.mark.httpx2(assert_all_called=False) def test_remote_link_failure_is_reported_and_keeps_the_issues(jira: respx.Router) -> None: jira["remote_links:SHOP-12"].respond(404) @@ -562,14 +570,33 @@ def _tags(gitlab: respx.Router, tags: list[dict[str, typing.Any]]) -> None: gitlab.get(f"{SERVICE_API}/pipelines", params={"ref": tag["name"]}).respond(json=[]) -@pytest.mark.usefixtures("gitlab") -def test_rows_above_the_newest_tag_link_to_a_new_minor_tag_on_the_head() -> None: +def test_rows_above_the_newest_tag_link_to_a_new_minor_tag_on_the_head(gitlab: respx.Router) -> None: assert _only_service(_collect()).untagged == Untagged( rows=1, head_sha="head", next_tag="1.3.0", create_url=f"{ENDPOINT}/team/svc/-/tags/new?tag_name=1.3.0&ref=head", ) + assert all("order_by" not in call.request.url.params for call in gitlab["tags"].calls) + + +def _cut_tags_then_by_version(request: httpx.Request) -> httpx.Response: + if request.url.params.get("order_by") == "version": + return httpx.Response( + 200, json=[{"name": "3.0.0", "commit": {"id": "x"}}, {"name": "2.9.0", "commit": {"id": "y"}}] + ) + return httpx.Response(200, json=[{"name": "0.0.1", "commit": {"id": "old"}}], headers={"x-next-page": "2"}) + + +@pytest.mark.httpx2(assert_all_called=False) +def test_cut_tag_list_takes_the_next_tag_from_the_highest_versions(gitlab: respx.Router) -> None: + gitlab["tags"].side_effect = _cut_tags_then_by_version + + untagged: typing.Final = _only_service(_collect()).untagged + + assert untagged is not None + assert untagged.next_tag == "3.1.0" + assert [call.request.url.params.get("sort") for call in gitlab["tags"].calls].count("desc") == 1 @pytest.mark.httpx2(assert_all_called=False) @@ -665,7 +692,7 @@ def test_long_tag_list_is_truncated_with_a_warning(gitlab: respx.Router) -> None assert service.warnings == [ Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.") ] - assert gitlab["tags"].call_count == 50 + assert [call.request.url.params.get("order_by") for call in gitlab["tags"].calls].count(None) == 50 def test_commit_without_message_uses_its_title_for_jira_keys(gitlab: respx.Router) -> None: