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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions release_scope/_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
20 changes: 18 additions & 2 deletions release_scope/_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ <h2 x-text="t('services')"></h2>
<span class="muted" x-text="productionRef(service) + ' →'"></span>
<span x-show="pickOf(service)" class="picked-tag" x-text="picks[service.project]"></span>
<span x-show="!pickOf(service)" class="muted" x-text="pickHint(service)"></span>
<span x-show="service.untagged" class="warn" x-text="service.untagged ? t('untagged', { count: service.untagged.rows }) : ''"></span>
<span x-show="service.untagged" class="warn" x-text="untaggedText(service, 'untagged', 'untaggedAtLeast')"></span>
</span>
<span class="muted" x-text="lineDetails(service)"></span>
<span x-text="failureCounts(service)"></span>
Expand All @@ -149,7 +149,7 @@ <h2 x-text="t('services')"></h2>
<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>
</template>
<p x-show="service.untagged" class="warn">
<span x-text="service.untagged ? t('untaggedRows', { count: service.untagged.rows }) : ''"></span>
<span x-text="untaggedText(service, 'untaggedRows', 'untaggedRowsAtLeast')"></span>
<a :href="service.untagged && service.untagged.create_url"
x-text="service.untagged && service.untagged.next_tag ? t('createTag', { name: service.untagged.next_tag }) : t('createAnyTag')"></a>
</p>
Expand Down Expand Up @@ -331,10 +331,15 @@ <h3 x-text="t('releasePost')"></h3>
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",
Expand Down Expand Up @@ -399,11 +404,17 @@ <h3 x-text="t('releasePost')"></h3>
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: "всё на проде",
Expand Down Expand Up @@ -722,6 +733,11 @@ <h3 x-text="t('releasePost')"></h3>
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 "";
Expand Down
26 changes: 16 additions & 10 deletions release_scope/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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())

Expand Down Expand Up @@ -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(
Expand All @@ -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)
)
Expand Down
2 changes: 1 addition & 1 deletion skills/release-scope/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions tests/test_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 30 additions & 3 deletions tests/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Loading