Skip to content

Commit c2e15f0

Browse files
authored
Drop excluded projects from Jira links, mark partial untagged counts, find the highest tag on cut lists (#24)
1 parent ecdfcf7 commit c2e15f0

7 files changed

Lines changed: 83 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ uvx release-scope collect --group team/backend --output public --cache cache.jso
4242
`--output` is a directory: `collect` writes `report.json` there, next to the page that shows it (see Site).
4343
`--group` and `--project` are repeatable and can be mixed. `--exclude` (`-x`) skips every project whose path
4444
matches a glob like `team/*-sandbox`. It is repeatable, `*` also matches `/`, and a skipped project is left out of
45-
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
46-
alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
45+
the report without being queried, including from the GitLab links of Jira issues. The command exits `1` when any
46+
service failed to collect; the report is still written and names the error on that service. A service GitLab denies
47+
access to fails alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments
4748
disabled is reported with a warning and no rows, without querying it. Only a rejected token, or a group or project
4849
passed on the command line that the token cannot see, stops the run. A failed Jira search is recorded in the report
4950
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
9394
rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
9495
when the service has no production deployment. When rows sit above the newest tag, `untagged` counts them and gives
9596
the head commit, the next tag (the highest `X.Y.Z` tag with its minor version bumped, or `null` when no tag has that
96-
form), and `create_url`, GitLab's new-tag form filled in with both. One row, trimmed:
97+
form), and `create_url`, GitLab's new-tag form filled in with both. When the tag list was cut short, the next tag
98+
comes from one more request for the highest versions. One row, trimmed:
9799

98100
```json
99101
{
@@ -121,8 +123,9 @@ Jira's own error details, stay as written.
121123
**Services** lists every service with a production deployment or with rows, and every service that failed to collect,
122124
as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, the
123125
failed jobs of the picked tag, or of the newest tag before a pick, with the ones allowed to fail counted apart, a mark
124-
when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`, and how many rows have no tag yet. The failed jobs are those of
125-
the tag's pipeline and of the main pipeline of its commit; failures in other rows show only when the line is opened.
126+
when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`, and how many rows have no tag yet, with a `+` when the cut
127+
range holds no tag, so more untagged rows may lie below it. The failed jobs are those of the tag's pipeline and of
128+
the main pipeline of its commit; failures in other rows show only when the line is opened.
126129
Opening a line shows the service's environments, warnings, a link to create the next tag on the head when rows have no
127130
tag yet, merge requests that are not merged yet, and its rows with
128131
tags and their pipelines, merge requests or commits, Jira keys with their status, environments, and failed jobs; rows

‎release_scope/_gitlab.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,14 @@ def latest_deployment(self, project_id: int, environment: str) -> Deployment | N
216216
def list_tags(self, project_id: int) -> tuple[list[Tag], bool]:
217217
return self._pages(f"{_API}/projects/{project_id}/repository/tags", {}, _Tags, resource="repository")
218218

219+
def list_highest_tags(self, project_id: int) -> list[Tag]:
220+
return self._get(
221+
f"{_API}/projects/{project_id}/repository/tags",
222+
{"order_by": "version", "sort": "desc", "per_page": _PER_PAGE},
223+
_Tags,
224+
resource="repository",
225+
).root
226+
219227
def list_first_parent_commits(
220228
self, project_id: int, ref_range: str, *, max_items: int
221229
) -> tuple[list[Commit], bool]:

‎release_scope/_static/index.html‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ <h2 x-text="t('services')"></h2>
131131
<span class="muted" x-text="productionRef(service) + ' →'"></span>
132132
<span x-show="pickOf(service)" class="picked-tag" x-text="picks[service.project]"></span>
133133
<span x-show="!pickOf(service)" class="muted" x-text="pickHint(service)"></span>
134-
<span x-show="service.untagged" class="warn" x-text="service.untagged ? t('untagged', { count: service.untagged.rows }) : ''"></span>
134+
<span x-show="service.untagged" class="warn" x-text="untaggedText(service, 'untagged', 'untaggedAtLeast')"></span>
135135
</span>
136136
<span class="muted" x-text="lineDetails(service)"></span>
137137
<span x-text="failureCounts(service)"></span>
@@ -149,7 +149,7 @@ <h2 x-text="t('services')"></h2>
149149
<p class="warn message"><template x-for="(part, index) in messageParts(warning)" :key="index"><span><template x-if="part.url"><a :href="part.url" x-text="part.text"></a></template><template x-if="!part.url"><span x-text="part.text"></span></template></span></template></p>
150150
</template>
151151
<p x-show="service.untagged" class="warn">
152-
<span x-text="service.untagged ? t('untaggedRows', { count: service.untagged.rows }) : ''"></span>
152+
<span x-text="untaggedText(service, 'untaggedRows', 'untaggedRowsAtLeast')"></span>
153153
<a :href="service.untagged && service.untagged.create_url"
154154
x-text="service.untagged && service.untagged.next_tag ? t('createTag', { name: service.untagged.next_tag }) : t('createAnyTag')"></a>
155155
</p>
@@ -331,10 +331,15 @@ <h3 x-text="t('releasePost')"></h3>
331331
notInProduction: "not in production",
332332
pickTag: "pick a tag",
333333
untagged: "{count} untagged",
334+
untaggedAtLeast: "{count}+ untagged",
334335
untaggedRows: {
335336
one: "{count} merge request or commit has no tag yet.",
336337
other: "{count} merge requests or commits have no tag yet.",
337338
},
339+
untaggedRowsAtLeast: {
340+
one: "At least {count} merge request or commit has no tag yet.",
341+
other: "At least {count} merge requests or commits have no tag yet.",
342+
},
338343
createTag: "Create tag {name}",
339344
createAnyTag: "Create a tag",
340345
upToDate: "up to date",
@@ -399,11 +404,17 @@ <h3 x-text="t('releasePost')"></h3>
399404
notInProduction: "нет на проде",
400405
pickTag: "выберите тег",
401406
untagged: "{count} без тега",
407+
untaggedAtLeast: "{count}+ без тега",
402408
untaggedRows: {
403409
one: "{count} MR или коммит ещё без тега.",
404410
few: "{count} MR или коммита ещё без тега.",
405411
many: "{count} MR или коммитов ещё без тега.",
406412
},
413+
untaggedRowsAtLeast: {
414+
one: "Не меньше {count} MR или коммита ещё без тега.",
415+
few: "Не меньше {count} MR или коммитов ещё без тега.",
416+
many: "Не меньше {count} MR или коммитов ещё без тега.",
417+
},
407418
createTag: "Создать тег {name}",
408419
createAnyTag: "Создать тег",
409420
upToDate: "всё на проде",
@@ -722,6 +733,11 @@ <h3 x-text="t('releasePost')"></h3>
722733
pipelineIcon(pipeline) {
723734
return pipeline ? ICONS[pipeline.status] || pipeline.status : "⚠️";
724735
},
736+
untaggedText(service, exact, atLeast) {
737+
if (!service.untagged) return "";
738+
const partial = service.truncated && service.untagged.rows === service.rows.length;
739+
return this.t(partial ? atLeast : exact, { count: service.untagged.rows });
740+
},
725741
failureCounts(service) {
726742
const candidate = this.pickOf(service) || service.candidates[0];
727743
if (!candidate) return "";

‎release_scope/_use_case.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class _Walk:
101101
truncated: bool
102102
drafts: list[RowDraft] = dataclasses.field(default_factory=list)
103103
tags_by_sha: dict[str, list[str]] = dataclasses.field(default_factory=dict)
104+
tag_names: list[str] = dataclasses.field(default_factory=list)
104105
main_pipelines: dict[str, Pipeline] = dataclasses.field(default_factory=dict)
105106

106107

@@ -146,7 +147,7 @@ def __call__(
146147
collected_at=dt.datetime.now(dt.UTC),
147148
production_environment=self.settings.production_environment,
148149
services=services,
149-
jira=self._read_issues(self.jira, _row_keys(services)) if self.jira else None,
150+
jira=self._read_issues(self.jira, _row_keys(services), exclude) if self.jira else None,
150151
)
151152

152153
def for_issues(
@@ -155,19 +156,20 @@ def for_issues(
155156
if self.jira is None:
156157
msg = "--jira needs RELEASE_SCOPE_JIRA_ENDPOINT and RELEASE_SCOPE_JIRA_TOKEN."
157158
raise ConfigError(msg)
158-
state: typing.Final = self._read_issues(self.jira, keys)
159+
state: typing.Final = self._read_issues(self.jira, keys, exclude)
159160
services: typing.Final[list[Service]] = []
160161
if state.error is None:
161162
links_by_project: dict[str, list[LinkedChange]] = {}
162163
for key in keys:
163164
issue = state.issues.get(key)
164165
for link in issue.links if issue else []:
165-
if not _excluded(link.project, exclude):
166-
links_by_project.setdefault(link.project, []).append(link)
166+
links_by_project.setdefault(link.project, []).append(link)
167167
services.extend(
168168
self._scoped_service(path, links_by_project[path], cache) for path in sorted(links_by_project)
169169
)
170-
row_state: typing.Final = self._read_issues(self.jira, sorted(set(_row_keys(services)) - set(keys)))
170+
row_state: typing.Final = self._read_issues(
171+
self.jira, sorted(set(_row_keys(services)) - set(keys)), exclude
172+
)
171173
state.issues.update(row_state.issues)
172174
state.missing.extend(row_state.missing)
173175
state.error = row_state.error
@@ -198,7 +200,9 @@ def _collect_or_explain(self, project: Project, cache: Cache, *, links: list[Lin
198200
error=explain_failure(project.path_with_namespace, project.web_url, exc),
199201
)
200202

201-
def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> JiraState:
203+
def _read_issues(
204+
self, jira: JiraApi, keys: collections.abc.Sequence[str], exclude: collections.abc.Sequence[str]
205+
) -> JiraState:
202206
state: typing.Final = JiraState()
203207
if not keys:
204208
return state
@@ -223,16 +227,16 @@ def _read_issues(self, jira: JiraApi, keys: collections.abc.Sequence[str]) -> Ji
223227
)
224228
try:
225229
for key, issue in state.issues.items():
226-
issue.links = self._linked_changes(jira, key)
230+
issue.links = self._linked_changes(jira, key, exclude)
227231
except JiraError as exc:
228232
state.error = exc.message
229233
return state
230234

231-
def _linked_changes(self, jira: JiraApi, key: str) -> list[LinkedChange]:
235+
def _linked_changes(self, jira: JiraApi, key: str, exclude: collections.abc.Sequence[str]) -> list[LinkedChange]:
232236
changes: typing.Final[dict[str, LinkedChange]] = {}
233237
for link in jira.remote_links(key):
234238
change = parse_gitlab_link(link.target.url if link.target else None, self.settings.gitlab.endpoint)
235-
if change is not None:
239+
if change is not None and not _excluded(change.project, exclude):
236240
changes.setdefault(change.url, change)
237241
return list(changes.values())
238242

@@ -298,7 +302,7 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked
298302
for draft, is_linked, is_in_scope in zip(drafts, linked, in_scope, strict=True)
299303
)
300304
service.candidates.extend(build_candidates(service, production))
301-
service.untagged = build_untagged(service, (name for names in walk.tags_by_sha.values() for name in names))
305+
service.untagged = build_untagged(service, walk.tag_names)
302306
return service
303307

304308
def _walk(
@@ -320,8 +324,10 @@ def _walk(
320324
tags, tag_list_cut = self.api.list_tags(project.id)
321325
if tag_list_cut:
322326
service.warnings.append(tags_truncated())
327+
walk.tag_names.extend(tag.name for tag in self.api.list_highest_tags(project.id))
323328
for tag in tags:
324329
walk.tags_by_sha.setdefault(tag.commit.id, []).append(tag.name)
330+
walk.tag_names.append(tag.name)
325331
walk.main_pipelines = self._latest_by_sha(
326332
self.api.list_push_pipelines(project.id, ref=default_branch, updated_after=since)
327333
)

‎skills/release-scope/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ token into the chat. Never echo a token.
4747
- Jira issue keys: `--jira KEY`, repeatable. It collects only the projects the issues link to, from production up to
4848
the latest linked change. It cannot be combined with `--group` or `--project`.
4949
- Projects the user wants left out: `--exclude GLOB`, repeatable, matched against the full project path (`*` also
50-
matches `/`). It works with every selection above, and excluded projects are not queried.
50+
matches `/`). It works with every selection above. Excluded projects are not queried and do not appear in Jira issue `links`.
5151

5252
## Collect
5353

‎tests/test_scope.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ def test_linked_projects_matching_an_exclude_glob_are_dropped(scoped: respx.Rout
6767

6868
assert [item.project for item in report.services] == ["team/svc"]
6969
assert not scoped["project:team/web"].called
70+
assert report.jira is not None
71+
assert [item.project for item in report.jira.issues["SHOP-12"].links] == ["team/svc"]
7072

7173

7274
@pytest.mark.httpx2(assert_all_called=False)

‎tests/test_use_case.py‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,14 @@ def test_jira_issues_list_the_gitlab_changes_linked_to_them(jira: respx.Router)
288288
assert jira["remote_links:SHOP-12"].call_count == 1
289289

290290

291+
@pytest.mark.usefixtures("jira")
292+
def test_links_to_excluded_projects_are_dropped_from_issues() -> None:
293+
state: typing.Final = _collect(with_jira=True, exclude=("team/w*",)).jira
294+
295+
assert state is not None
296+
assert [item.project for item in state.issues["SHOP-12"].links] == ["team/svc"]
297+
298+
291299
@pytest.mark.httpx2(assert_all_called=False)
292300
def test_remote_link_failure_is_reported_and_keeps_the_issues(jira: respx.Router) -> None:
293301
jira["remote_links:SHOP-12"].respond(404)
@@ -562,14 +570,33 @@ def _tags(gitlab: respx.Router, tags: list[dict[str, typing.Any]]) -> None:
562570
gitlab.get(f"{SERVICE_API}/pipelines", params={"ref": tag["name"]}).respond(json=[])
563571

564572

565-
@pytest.mark.usefixtures("gitlab")
566-
def test_rows_above_the_newest_tag_link_to_a_new_minor_tag_on_the_head() -> None:
573+
def test_rows_above_the_newest_tag_link_to_a_new_minor_tag_on_the_head(gitlab: respx.Router) -> None:
567574
assert _only_service(_collect()).untagged == Untagged(
568575
rows=1,
569576
head_sha="head",
570577
next_tag="1.3.0",
571578
create_url=f"{ENDPOINT}/team/svc/-/tags/new?tag_name=1.3.0&ref=head",
572579
)
580+
assert all("order_by" not in call.request.url.params for call in gitlab["tags"].calls)
581+
582+
583+
def _cut_tags_then_by_version(request: httpx.Request) -> httpx.Response:
584+
if request.url.params.get("order_by") == "version":
585+
return httpx.Response(
586+
200, json=[{"name": "3.0.0", "commit": {"id": "x"}}, {"name": "2.9.0", "commit": {"id": "y"}}]
587+
)
588+
return httpx.Response(200, json=[{"name": "0.0.1", "commit": {"id": "old"}}], headers={"x-next-page": "2"})
589+
590+
591+
@pytest.mark.httpx2(assert_all_called=False)
592+
def test_cut_tag_list_takes_the_next_tag_from_the_highest_versions(gitlab: respx.Router) -> None:
593+
gitlab["tags"].side_effect = _cut_tags_then_by_version
594+
595+
untagged: typing.Final = _only_service(_collect()).untagged
596+
597+
assert untagged is not None
598+
assert untagged.next_tag == "3.1.0"
599+
assert [call.request.url.params.get("sort") for call in gitlab["tags"].calls].count("desc") == 1
573600

574601

575602
@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
665692
assert service.warnings == [
666693
Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.")
667694
]
668-
assert gitlab["tags"].call_count == 50
695+
assert [call.request.url.params.get("order_by") for call in gitlab["tags"].calls].count(None) == 50
669696

670697

671698
def test_commit_without_message_uses_its_title_for_jira_keys(gitlab: respx.Router) -> None:

0 commit comments

Comments
 (0)