From 7c77f87076a87cc1a5472bce332e52190f6a8af7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 10 Oct 2026 12:20:11 +0300 Subject: [PATCH] Show untagged rows and link to create the next tag (schema 5) --- CONTEXT.md | 4 ++ README.md | 14 ++++--- release_scope/_candidates.py | 31 +++++++++++++++- release_scope/_report.py | 12 +++++- release_scope/_static/index.html | 25 +++++++++++-- release_scope/_use_case.py | 3 +- skills/release-scope/SKILL.md | 9 ++++- tests/test_cli.py | 2 +- tests/test_use_case.py | 63 +++++++++++++++++++++++++++++++- 9 files changed, 146 insertions(+), 17 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4571eab..9ca64ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/README.md b/README.md index df804c5..48e4b05 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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` @@ -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 ``` @@ -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. diff --git a/release_scope/_candidates.py b/release_scope/_candidates.py index 09d91ff..8523840 100644 --- a/release_scope/_candidates.py +++ b/release_scope/_candidates.py @@ -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]: @@ -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" diff --git a/release_scope/_report.py b/release_scope/_report.py index 74f7e2f..0874e31 100644 --- a/release_scope/_report.py +++ b/release_scope/_report.py @@ -5,7 +5,7 @@ import pydantic -SCHEMA_VERSION: typing.Final = 4 +SCHEMA_VERSION: typing.Final = 5 class MessageCode(enum.StrEnum): @@ -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 @@ -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): @@ -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] diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index b08181a..1c23038 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -131,6 +131,7 @@

+ @@ -147,6 +148,11 @@

+

+ + +