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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 32 additions & 1 deletion release_scope/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion skills/release-scope/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/test_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}])
Expand Down
31 changes: 31 additions & 0 deletions tests/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
jira_page,
pipeline,
project,
remote_link,
)


Expand Down Expand Up @@ -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
Expand Down
Loading