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: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,9 @@ A tag in the range that a release could ship. It carries what shipping it means:
production baseline, the Jira keys of those rows that are in scope, its tag pipeline, and the compare link from
production.

**Untagged rows**:
The rows above the newest row with a tag, or every row when none has one. Only a tag that does not exist yet can
ship them. The next tag is the highest `X.Y.Z` tag with its minor version bumped.

**Settled fact**:
Data GitLab will not change for the same key, and so the only data the cache may hold.
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ issue type, linked GitLab changes), the `missing` keys Jira did not return, and
Warnings and errors, on services and in `jira`, are messages: a `code`, its `params`, and the English `text`.
Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
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. One row, trimmed:
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:

```json
{
Expand All @@ -118,8 +120,10 @@ 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, failed jobs
with the ones allowed to fail counted apart, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`.
Opening a line shows the service's environments, warnings, merge requests that are not merged yet, and its rows with
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.
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
out of scope are dimmed. Links, including the GitLab settings pages that warnings and errors point to, open in a new
tab. Each tag has a **pick** button: picking it highlights the rows it ships and closes the line again. A `--jira`
Expand Down Expand Up @@ -155,7 +159,7 @@ release-report:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- uvx --from 'release-scope>=0.6,<0.7' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
- uvx --from 'release-scope>=0.7,<0.8' release-scope collect --group team/backend --output public || [ $? -eq 1 ]
pages: true
```

Expand Down Expand Up @@ -194,4 +198,4 @@ npx skills add modern-python/release-scope
```

The agent reads the same environment variables as the CLI, so set them first as described under Configuration.
The skill runs `release-scope>=0.6,<0.7`, the range whose flags and report schema it describes.
The skill runs `release-scope>=0.7,<0.8`, the range whose flags and report schema it describes.
31 changes: 29 additions & 2 deletions release_scope/_candidates.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from urllib.parse import quote
import collections.abc
import re
import typing
from urllib.parse import quote, urlencode

from release_scope._report import Candidate, EnvironmentState, Service
from release_scope._report import Candidate, EnvironmentState, Service, Untagged


_VERSION_PATTERN: typing.Final = re.compile(r"(v?)(\d+)\.(\d+)\.(\d+)")


def build_candidates(service: Service, production: EnvironmentState | None) -> list[Candidate]:
Expand All @@ -23,3 +29,24 @@ def build_candidates(service: Service, production: EnvironmentState | None) -> l
for tag in row.tags
)
return candidates


def build_untagged(service: Service, tag_names: collections.abc.Iterable[str]) -> Untagged | None:
rows: typing.Final = next((index for index, row in enumerate(service.rows) if row.tags), len(service.rows))
if not rows:
return None
head_sha: typing.Final = service.rows[0].commits[0].sha
next_tag: typing.Final = _next_minor(tag_names)
query: typing.Final = urlencode({"tag_name": next_tag, "ref": head_sha} if next_tag else {"ref": head_sha})
return Untagged(
rows=rows, head_sha=head_sha, next_tag=next_tag, create_url=f"{service.project_url}/-/tags/new?{query}"
)


def _next_minor(tag_names: collections.abc.Iterable[str]) -> str | None:
versions: typing.Final = [match for name in tag_names if (match := _VERSION_PATTERN.fullmatch(name))]
if not versions:
return None
highest: typing.Final = max(versions, key=lambda match: tuple(int(part) for part in match.groups()[1:]))
prefix, major, minor, _ = highest.groups()
return f"{prefix}{major}.{int(minor) + 1}.0"
12 changes: 10 additions & 2 deletions release_scope/_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pydantic


SCHEMA_VERSION: typing.Final = 4
SCHEMA_VERSION: typing.Final = 5


class MessageCode(enum.StrEnum):
Expand Down Expand Up @@ -110,6 +110,13 @@ class Candidate(pydantic.BaseModel):
jira_keys: list[JiraKeyRef]


class Untagged(pydantic.BaseModel):
rows: int
head_sha: str
next_tag: str | None
create_url: str


class Service(pydantic.BaseModel):
project: str
project_url: str
Expand All @@ -121,6 +128,7 @@ class Service(pydantic.BaseModel):
warnings: list[Message] = pydantic.Field(default_factory=list)
error: Message | None = None
release: Release | None = None
untagged: Untagged | None = None


class LinkedChange(pydantic.BaseModel):
Expand Down Expand Up @@ -149,7 +157,7 @@ class JiraState(pydantic.BaseModel):


class Report(pydantic.BaseModel):
schema_version: typing.Literal[4] = SCHEMA_VERSION
schema_version: typing.Literal[5] = SCHEMA_VERSION
collected_at: dt.datetime
production_environment: str
services: list[Service]
Expand Down
25 changes: 22 additions & 3 deletions release_scope/_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +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>
<span class="muted" x-text="lineDetails(service)"></span>
<span x-text="failureCounts(service)"></span>
Expand All @@ -147,6 +148,11 @@ <h2 x-text="t('services')"></h2>
<template x-for="warning in service.warnings" :key="warning.text">
<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>
<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>
<p x-show="service.release && service.release.pending_merge_requests.length">
<span x-text="t('notMerged')"></span>
<template x-for="item in service.release ? service.release.pending_merge_requests : []" :key="item.iid">
Expand Down Expand Up @@ -324,7 +330,13 @@ <h3 x-text="t('releasePost')"></h3>
failedToCollect: "failed to collect",
notInProduction: "not in production",
pickTag: "pick a tag",
noTagYet: "no tag yet",
untagged: "{count} untagged",
untaggedRows: {
one: "{count} merge request or commit has no tag yet.",
other: "{count} merge requests or commits have no tag yet.",
},
createTag: "Create tag {name}",
createAnyTag: "Create a tag",
upToDate: "up to date",
rows: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
rowsOf: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
Expand Down Expand Up @@ -386,7 +398,14 @@ <h3 x-text="t('releasePost')"></h3>
failedToCollect: "не удалось собрать",
notInProduction: "нет на проде",
pickTag: "выберите тег",
noTagYet: "тега ещё нет",
untagged: "{count} без тега",
untaggedRows: {
one: "{count} MR или коммит ещё без тега.",
few: "{count} MR или коммита ещё без тега.",
many: "{count} MR или коммитов ещё без тега.",
},
createTag: "Создать тег {name}",
createAnyTag: "Создать тег",
upToDate: "всё на проде",
rows: { one: "{count} MR или коммит", few: "{count} MR или коммита", many: "{count} MR или коммитов" },
rowsOf: { one: "{count} MR или коммита", few: "{count} MR или коммитов", many: "{count} MR или коммитов" },
Expand Down Expand Up @@ -656,7 +675,7 @@ <h3 x-text="t('releasePost')"></h3>
},
pickHint(service) {
if (service.candidates.length) return this.t("pickTag");
return service.rows.length ? this.t("noTagYet") : this.t("upToDate");
return service.rows.length ? "" : this.t("upToDate");
},
lineDetails(service) {
const candidate = this.pickOf(service);
Expand Down
3 changes: 2 additions & 1 deletion release_scope/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from urllib.parse import quote

from release_scope._cache import Cache, CachedPipeline
from release_scope._candidates import build_candidates
from release_scope._candidates import build_candidates, build_untagged
from release_scope._errors import AuthError, ConfigError, GitLabError, JiraError
from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project
from release_scope._jira import JiraApi
Expand Down Expand Up @@ -298,6 +298,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))
return service

def _walk(
Expand Down
9 changes: 7 additions & 2 deletions skills/release-scope/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ description: >
through uvx with this version range; it matches the flags and report schema described here:

```bash
uvx --from 'release-scope>=0.6,<0.7' release-scope --help
uvx --from 'release-scope>=0.7,<0.8' release-scope --help
```

## Check the settings
Expand Down Expand Up @@ -46,6 +46,8 @@ token into the chat. Never echo a token.
`--include-subgroups` also collects subgroups of each group.
- 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.

## Collect

Expand All @@ -56,7 +58,7 @@ run and only saves requests:
```bash
out="${XDG_CACHE_HOME:-$HOME/.cache}/release-scope"
mkdir -p "$out"
uvx --from 'release-scope>=0.6,<0.7' release-scope collect --project team/backend/shop \
uvx --from 'release-scope>=0.7,<0.8' release-scope collect --project team/backend/shop \
--output "$out/site" --cache "$out/cache.json"
```

Expand Down Expand Up @@ -85,6 +87,9 @@ Read `$out/site/report.json` and answer the user's question from it, briefly.
- A service never deployed to production has a `no_production` warning; its rows run down to the first commit of the
default branch and its `compare_url` lists the tag's commits.
- `truncated: true`: the walk stopped at `RELEASE_SCOPE_MAX_COMMITS`, so the oldest candidates miss rows and keys.
- `untagged`: the rows above the newest tag, which no existing tag ships. It has `rows`, `head_sha`, `next_tag` (the
highest `X.Y.Z` tag with its minor version bumped, or `null`), and `create_url`, GitLab's new-tag form for the head.
It is `null` when the head is tagged or the service has no rows.
- `candidates` (newest first): the tags a release could ship, each with `tag` and its `pipeline`, `rows` (how many
rows run from that tag down to production), `jira_keys` (the in-scope keys of those rows, without duplicates), and
`compare_url`.
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib
"report.json",
]
report: typing.Final = json.loads((output / "report.json").read_text())
assert report["schema_version"] == 4
assert report["schema_version"] == 5
assert report["jira"] is None
assert report["production_environment"] == "production"
assert [len(item["rows"]) for item in report["services"]] == [5]
Expand Down
63 changes: 62 additions & 1 deletion tests/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from release_scope._errors import AuthError, GitLabError
from release_scope._gitlab import GitLabApi
from release_scope._jira import JiraApi
from release_scope._report import Message, MessageCode, Report, Service
from release_scope._report import Message, MessageCode, Report, Service, Untagged
from release_scope._settings import GitLabConfig, Settings
from release_scope._use_case import CollectUseCase
from tests.payloads import (
Expand Down Expand Up @@ -553,6 +553,67 @@ def test_service_already_on_production_has_no_rows(gitlab: respx.Router) -> None

assert service.rows == []
assert service.warnings == []
assert service.untagged is None


def _tags(gitlab: respx.Router, tags: list[dict[str, typing.Any]]) -> None:
gitlab["tags"].respond(json=tags)
for tag in tags:
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:
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",
)


@pytest.mark.httpx2(assert_all_called=False)
def test_tagged_head_has_no_untagged_rows(gitlab: respx.Router) -> None:
_tags(gitlab, [{"name": "1.3.0", "commit": {"id": "head"}}, *TAGS])

assert _only_service(_collect()).untagged is None


@pytest.mark.httpx2(assert_all_called=False)
def test_range_without_tags_is_untagged_from_head_to_production(gitlab: respx.Router) -> None:
_tags(gitlab, [{"name": "1.0.0", "commit": {"id": "prod"}}])

untagged: typing.Final = _only_service(_collect()).untagged

assert untagged is not None
assert (untagged.rows, untagged.next_tag) == (5, "1.1.0")


@pytest.mark.httpx2(assert_all_called=False)
@pytest.mark.parametrize(
("names", "next_tag"),
[
(["1.9.0", "1.10.0"], "1.11.0"),
(["v2.4.1"], "v2.5.0"),
(["2.0.0rc1", "1.4.2"], "1.5.0"),
],
)
def test_next_tag_bumps_the_minor_of_the_highest_version(gitlab: respx.Router, names: list[str], next_tag: str) -> None:
_tags(gitlab, [{"name": name, "commit": {"id": "c3"}} for name in names])

untagged: typing.Final = _only_service(_collect()).untagged

assert untagged is not None
assert untagged.next_tag == next_tag


@pytest.mark.httpx2(assert_all_called=False)
def test_without_version_tags_the_link_only_picks_the_head(gitlab: respx.Router) -> None:
_tags(gitlab, [{"name": "release-7", "commit": {"id": "c3"}}])

assert _only_service(_collect()).untagged == Untagged(
rows=1, head_sha="head", next_tag=None, create_url=f"{ENDPOINT}/team/svc/-/tags/new?ref=head"
)


def test_range_spanning_pages_is_read_to_the_end(gitlab: respx.Router) -> None:
Expand Down
Loading