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 @@ +@@ -324,7 +330,13 @@ 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" }, @@ -386,7 +398,14 @@ 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 или коммитов" }, @@ -656,7 +675,7 @@ }, 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); diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index 9282a01..a68f572 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -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 @@ -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( diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md index 501225b..ee4287e 100644 --- a/skills/release-scope/SKILL.md +++ b/skills/release-scope/SKILL.md @@ -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 @@ -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 @@ -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" ``` @@ -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`. diff --git a/tests/test_cli.py b/tests/test_cli.py index b4f7bb0..4d045b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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] diff --git a/tests/test_use_case.py b/tests/test_use_case.py index 82fdd01..b1189dd 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -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 ( @@ -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: