diff --git a/README.md b/README.md index db81d51..783d6cb 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ also has its first deployment to each environment other than production: when th row, or a newer row, finished. With a Jira token, it also reads the summary and status of every key in one batched search, and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with -`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services. +`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services. An issue also joins +the Jira keys of every row its links point to, so a key that only a commit message inside a merge request names +still counts for that row and for the tags that ship it. ## Quickstart diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index 3390a5c..5a797c0 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -97,6 +97,12 @@ 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}) +def _holds(row: Row, link: LinkedChange) -> bool: + if link.iid is not None: + return any(item.iid == link.iid for item in row.merge_requests) + return link.sha is not None and any(commit.sha.startswith(link.sha) for commit in row.commits) + + @dataclasses.dataclass(slots=True, kw_only=True) class _Walk: truncated: bool @@ -145,11 +151,14 @@ def __call__( for project in resolved if not _excluded(project.path_with_namespace, exclude) ] + state: typing.Final = self._read_issues(self.jira, _row_keys(services), exclude) if self.jira else None + if state is not None: + self._join_linked_issues(services, state) return Report( collected_at=dt.datetime.now(dt.UTC), production_environment=self.settings.production_environment, services=services, - jira=self._read_issues(self.jira, _row_keys(services), exclude) if self.jira else None, + jira=state, ) def for_issues( @@ -175,6 +184,7 @@ def for_issues( state.issues.update(row_state.issues) state.missing.extend(row_state.missing) state.error = row_state.error + self._join_linked_issues(services, state) return Report( collected_at=dt.datetime.now(dt.UTC), production_environment=self.settings.production_environment, @@ -202,6 +212,27 @@ def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[Lin error=explain_failure(project.path_with_namespace, project.web_url, exc), ) + def _join_linked_issues(self, services: list[Service], state: JiraState) -> None: + for service in services: + links = [ + (key, link) + for key, issue in state.issues.items() + for link in issue.links + if link.project == service.project + ] + if not links: + continue + for row in service.rows: + named = {item.key for item in row.jira_keys} + for key, link in links: + if key not in named and _holds(row, link): + row.jira_keys.append(JiraKeyRef(key=key, url=self._jira_url(key))) + named.add(key) + production = next( + (item for item in service.environments if item.name == self.settings.production_environment), None + ) + service.candidates = build_candidates(service, production) + def _read_issues( self, jira: JiraApi, keys: collections.abc.Sequence[str], exclude: collections.abc.Sequence[str] ) -> JiraState: diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md index 43b5506..bc09600 100644 --- a/skills/release-scope/SKILL.md +++ b/skills/release-scope/SKILL.md @@ -82,7 +82,8 @@ Read `$out/site/report.json` and answer the user's question from it, briefly. `code` and `params` carry the same facts in structured form. - A row is one merged merge request or a direct commit: `merge_requests`, `commits`, `tags` pointing into it, `environments` running exactly its commit now, `first_deployed_at` (per environment other than production, when the - first deploy job that shipped this row or a newer one finished), `jira_keys`, and `main_pipeline` with + first deploy job that shipped this row or a newer one finished), `jira_keys` (the keys its merge request or commit + text names, plus, with Jira, every issue whose GitLab links point into the row), and `main_pipeline` with `failed_jobs`. Tag pipelines carry their own `failed_jobs`. - A service with no rows is up to date with production. - A service never deployed to production has a `no_production` warning; its rows run down to the first commit of the diff --git a/tests/test_scope.py b/tests/test_scope.py index 826eba6..be59e99 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -115,6 +115,22 @@ def test_release_tag_is_the_nearest_tag_above_an_untagged_target(scoped: respx.R assert service.release.tag.name == "1.2.0" +@pytest.mark.httpx2(assert_all_called=False) +def test_release_tag_ships_the_scope_issue_its_target_does_not_name(scoped: respx.Router) -> None: + scoped["remote_links:SHOP-12"].respond(json=_links(f"{ENDPOINT}/team/svc/-/merge_requests/11")) + scoped.get(f"{SERVICE_API}/merge_requests/11").respond( + json=merge_request(11, "Bump UTF-8 handling", merge_commit_sha="c2", sha="x11") + ) + + service: typing.Final = _service(_scope()) + + assert [[key.key for key in row.jira_keys] for row in service.rows][1:3] == [["SHOP-12", "SHOP-13"], ["SHOP-12"]] + assert [(item.tag.name, [key.key for key in item.jira_keys]) for item in service.candidates] == [ + ("1.2.0", ["SHOP-12"]), + ("1.1.0", []), + ] + + @pytest.mark.httpx2(assert_all_called=False) def test_release_has_no_tag_when_none_is_at_or_above_the_target(scoped: respx.Router) -> None: scoped["tags"].respond(json=[{"name": "1.1.0", "commit": {"id": "c1"}}]) diff --git a/tests/test_use_case.py b/tests/test_use_case.py index f062a70..1a15498 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -34,6 +34,7 @@ jira_page, pipeline, project, + remote_link, ) @@ -338,6 +339,36 @@ def test_jira_issues_list_the_gitlab_changes_linked_to_them(jira: respx.Router) assert jira["remote_links:SHOP-12"].call_count == 1 +def test_issue_linked_to_a_merge_request_joins_its_row_and_the_candidates_shipping_it(jira: respx.Router) -> None: + jira["remote_links:SHOP-9"].respond(json=[remote_link(f"{ENDPOINT}/team/svc/-/merge_requests/11")]) + + service: typing.Final = _only_service(_collect(with_jira=True)) + + assert [[key.key for key in row.jira_keys] for row in service.rows] == [ + ["SHOP-9"], + ["SHOP-12", "SHOP-13"], + ["SHOP-9"], + [], + [], + ] + assert service.rows[2].jira_keys[0].url == "https://jira.example.test/browse/SHOP-9" + assert [(item.tag.name, [key.key for key in item.jira_keys]) for item in service.candidates] == [ + ("1.2.0", ["SHOP-12", "SHOP-13", "SHOP-9"]), + ("1.1.0", []), + ] + + +@pytest.mark.httpx2(assert_all_called=False) +def test_issue_linked_to_a_commit_joins_the_row_holding_it(jira: respx.Router) -> None: + jira["commits"].respond(json=[commit("abcdef1234", "Hotfix", date="2026-09-25T00:00:00Z"), *COMMITS[1:]]) + jira.get(f"{SERVICE_API}/repository/commits/abcdef1234/merge_requests").respond(json=[]) + jira["remote_links:SHOP-12"].respond(json=[remote_link(f"{ENDPOINT}/team/svc/-/commit/abcdef1")]) + + rows: typing.Final = _only_service(_collect(with_jira=True)).rows + + assert [key.key for key in rows[0].jira_keys] == ["SHOP-12"] + + @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