From 2fe1b3d1810e2f85507bc1bbbad9acf10719b544 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 4 Oct 2026 12:37:41 +0300 Subject: [PATCH] feat: say how many tags were skipped when no_tags fires on a tagged repo --- docs/providers/github.md | 10 ++++++++++ docs/providers/gitlab.md | 10 ++++++++++ semvertag/_outcome.py | 18 ++++++++++++----- semvertag/_output.py | 12 +++++++++--- semvertag/_use_case.py | 2 +- tests/unit/test_outcome.py | 35 +++++++++++++++++++++++++++++++--- tests/unit/test_output_rich.py | 17 ++++++++++++++--- tests/unit/test_use_case.py | 18 +++++++++++++++++ 8 files changed, 107 insertions(+), 15 deletions(-) diff --git a/docs/providers/github.md b/docs/providers/github.md index 08c52ee..707e920 100644 --- a/docs/providers/github.md +++ b/docs/providers/github.md @@ -287,6 +287,16 @@ The default endpoint is `https://api.github.com`. Set workflow-level env pointing to the instance's API root, e.g. `https://github.example.com/api/v3`. +### `None of the repo's N tags is SemVer-form (1.2.0 or v1.2.0)` + +The repo has tags, but semvertag reads none of them as a bump baseline, +so the run reports `no_tags` and exits 0 without tagging. It reads only +plain SemVer tags (`1.2.0`, `1.0.0-rc.1`) and the same with a lowercase +`v` (`v1.2.0`). It ignores other forms, such as `V1.2.0`, +`release-1.2.0`, PEP 440 prereleases like `0.9.0rc1`, and floating +major tags like `v1`. Push a SemVer tag on a default-branch commit to +start from, e.g. `git tag v0.1.0 && git push origin v0.1.0`. + ### A bump-worthy push was never tagged The run for that push failed or was skipped. Re-run it. Each run judges diff --git a/docs/providers/gitlab.md b/docs/providers/gitlab.md index d0a0529..8615966 100644 --- a/docs/providers/gitlab.md +++ b/docs/providers/gitlab.md @@ -201,6 +201,16 @@ The default endpoint is `https://gitlab.com` and is not auto-derived from `CI_SERVER_FQDN`. Set `SEMVERTAG_GITLAB__ENDPOINT` as a project-level CI/CD variable pointing to the instance's API root. +### `None of the repo's N tags is SemVer-form (1.2.0 or v1.2.0)` + +The repo has tags, but semvertag reads none of them as a bump baseline, +so the run reports `no_tags` and exits 0 without tagging. It reads only +plain SemVer tags (`1.2.0`, `1.0.0-rc.1`) and the same with a lowercase +`v` (`v1.2.0`). It ignores other forms, such as `V1.2.0`, +`release-1.2.0`, PEP 440 prereleases like `0.9.0rc1`, and floating +major tags like `v1`. Push a SemVer tag on a default-branch commit to +start from, e.g. `git tag v0.1.0 && git push origin v0.1.0`. + ### A bump-worthy push was never tagged The run for that push failed or was skipped. Re-run it. Each run judges diff --git a/semvertag/_outcome.py b/semvertag/_outcome.py index 31a9e23..1ac2e50 100644 --- a/semvertag/_outcome.py +++ b/semvertag/_outcome.py @@ -7,10 +7,10 @@ # These are the JSON wire reasons. The human terminal path (_output._format_outcome) # words NoTags/AlreadyTagged differently on purpose — edit both if you change the # message for one audience. -_NO_TAGS_REASON: typing.Final = ( - "No prior semver-conforming tags found; create an initial tag such as 0.1.0 " - "(or v0.1.0 for v-prefixed tags) on a default-branch commit." +_SEED_ADVICE: typing.Final = ( + "create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit." ) +_NO_TAGS_REASON: typing.Final = f"No prior semver-conforming tags found; {_SEED_ADVICE}" _ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged." @@ -37,6 +37,7 @@ class NoTags: """No prior semver tag to bump from; semvertag does not create the first one.""" commit: str + skipped_tag_count: int @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) @@ -72,14 +73,14 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult: return RunResult(strategy=strategy, bump=bump.value, status="created", tag=tag, commit=commit, reason=None) case DryRun(tag=tag, bump=bump, commit=commit): return RunResult(strategy=strategy, bump=bump.value, status="dry_run", tag=tag, commit=commit, reason=None) - case NoTags(commit=commit): + case NoTags(commit=commit, skipped_tag_count=skipped_tag_count): return RunResult( strategy=strategy, bump=Bump.NONE.value, status="no_tags", tag=None, commit=commit, - reason=_NO_TAGS_REASON, + reason=_no_tags_reason(skipped_tag_count), ) case AlreadyTagged(tag=tag, commit=commit): return RunResult( @@ -96,3 +97,10 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult: ) case _: # pragma: no cover - exhaustiveness guard; ty verifies every Outcome is matched typing.assert_never(outcome) + + +def _no_tags_reason(skipped_tag_count: int) -> str: + if not skipped_tag_count: + return _NO_TAGS_REASON + noun: typing.Final = "tag" if skipped_tag_count == 1 else "tags" + return f"None of the repo's {skipped_tag_count} {noun} is SemVer-form (1.2.0 or v1.2.0); {_SEED_ADVICE}" diff --git a/semvertag/_output.py b/semvertag/_output.py index fd18e21..e7d19a1 100644 --- a/semvertag/_output.py +++ b/semvertag/_output.py @@ -10,6 +10,9 @@ _COMMIT_SHORT_LEN: typing.Final = 7 +_SEED_ADVICE: typing.Final = ( + "create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit." +) class Output(typing.Protocol): @@ -62,10 +65,13 @@ def _format_outcome(outcome: Outcome, *, strategy: str) -> str: case DryRun(tag=tag, bump=bump, commit=commit): short = commit[:_COMMIT_SHORT_LEN] return f"Dry run: would create tag {tag} on commit {short} (strategy: {strategy}, bump: {bump.value})" - case NoTags(): + case NoTags(skipped_tag_count=0): + return f"No tag created — no prior semver-conforming tag to bump from; {_SEED_ADVICE}" + case NoTags(skipped_tag_count=skipped_tag_count): + noun: typing.Final = "tag" if skipped_tag_count == 1 else "tags" return ( - "No tag created — no prior semver-conforming tag to bump from; " - "create an initial tag such as 0.1.0 (or v0.1.0 for v-prefixed tags) on a default-branch commit." + f"No tag created — none of the repo's {skipped_tag_count} {noun} is SemVer-form " + f"(1.2.0 or v1.2.0); {_SEED_ADVICE}" ) case AlreadyTagged(tag=tag): return f"No tag created — latest commit is already tagged {tag}." diff --git a/semvertag/_use_case.py b/semvertag/_use_case.py index 6318c6c..9bbdab2 100644 --- a/semvertag/_use_case.py +++ b/semvertag/_use_case.py @@ -28,7 +28,7 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome: selected: typing.Final = _select_latest_semver_tag(tags) if selected is None: - return self._emit(output, NoTags(commit=commit.sha)) + return self._emit(output, NoTags(commit=commit.sha, skipped_tag_count=len(tags))) latest_tag, latest_version = selected if latest_tag.commit_sha == commit.sha: diff --git a/tests/unit/test_outcome.py b/tests/unit/test_outcome.py index de120ec..715b630 100644 --- a/tests/unit/test_outcome.py +++ b/tests/unit/test_outcome.py @@ -1,5 +1,7 @@ import typing +import pytest + from semvertag._outcome import ( _ALREADY_TAGGED_REASON, _NO_TAGS_REASON, @@ -32,14 +34,14 @@ def test_dry_run_maps_to_dry_run_wire_result() -> None: def test_no_tags_maps_with_none_bump_and_fixed_reason() -> None: - result: typing.Final = to_run_result(NoTags(commit=_COMMIT), strategy=_STRATEGY) + result: typing.Final = to_run_result(NoTags(commit=_COMMIT, skipped_tag_count=0), strategy=_STRATEGY) assert result == RunResult( strategy=_STRATEGY, bump="none", status="no_tags", tag=None, commit=_COMMIT, reason=_NO_TAGS_REASON ) def test_no_tags_reason_says_how_to_seed_the_first_tag() -> None: - reason: typing.Final = to_run_result(NoTags(commit=_COMMIT), strategy=_STRATEGY).reason + reason: typing.Final = to_run_result(NoTags(commit=_COMMIT, skipped_tag_count=0), strategy=_STRATEGY).reason assert reason is not None assert "create an initial tag such as 0.1.0" in reason assert "or v0.1.0 for v-prefixed tags" in reason @@ -74,5 +76,32 @@ def test_no_bump_passes_strategy_status_and_reason_through() -> None: def test_schema_version_is_preserved_on_the_wire() -> None: - result: typing.Final = to_run_result(NoTags(commit=_COMMIT), strategy=_STRATEGY) + result: typing.Final = to_run_result(NoTags(commit=_COMMIT, skipped_tag_count=0), strategy=_STRATEGY) assert result.schema_version == "1.0" + + +@pytest.mark.parametrize( + ("skipped_tag_count", "expected_reason"), + [ + ( + 1, + ( + "None of the repo's 1 tag is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0 " + "(or v0.1.0 for v-prefixed tags) on a default-branch commit." + ), + ), + ( + 3, + ( + "None of the repo's 3 tags is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0 " + "(or v0.1.0 for v-prefixed tags) on a default-branch commit." + ), + ), + ], +) +def test_no_tags_reason_counts_tags_that_are_not_semver_form(skipped_tag_count: int, expected_reason: str) -> None: + result: typing.Final = to_run_result( + NoTags(commit=_COMMIT, skipped_tag_count=skipped_tag_count), strategy=_STRATEGY + ) + assert result.status == "no_tags" + assert result.reason == expected_reason diff --git a/tests/unit/test_output_rich.py b/tests/unit/test_output_rich.py index a67a432..a9af6ee 100644 --- a/tests/unit/test_output_rich.py +++ b/tests/unit/test_output_rich.py @@ -94,9 +94,20 @@ def test_matrix_keeps_stderr_for_errors(quiet: bool) -> None: @pytest.mark.parametrize( ("outcome", "expected"), [ - (NoTags(commit="abc1234def"), "no prior semver-conforming tag"), - (NoTags(commit="abc1234def"), "create an initial tag such as 0.1.0"), - (NoTags(commit="abc1234def"), "or v0.1.0 for v-prefixed tags"), + (NoTags(commit="abc1234def", skipped_tag_count=0), "no prior semver-conforming tag"), + (NoTags(commit="abc1234def", skipped_tag_count=0), "create an initial tag such as 0.1.0"), + (NoTags(commit="abc1234def", skipped_tag_count=0), "or v0.1.0 for v-prefixed tags"), + ( + NoTags(commit="abc1234def", skipped_tag_count=3), + "none of the repo's 3 tags is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0", + ), + ( + NoTags(commit="abc1234def", skipped_tag_count=1), + ( + "none of the repo's 1 tag is SemVer-form (1.2.0 or v1.2.0); create an initial tag such as 0.1.0 " + "(or v0.1.0 for v-prefixed tags) on a default-branch commit." + ), + ), (AlreadyTagged(tag="1.2.0", commit="abc1234def"), "already tagged 1.2.0"), ( NoBump(status="no_merge_commit", reason="Latest commit is not a merge commit.", commit="abc1234def"), diff --git a/tests/unit/test_use_case.py b/tests/unit/test_use_case.py index 01f45d6..244fcfb 100644 --- a/tests/unit/test_use_case.py +++ b/tests/unit/test_use_case.py @@ -409,3 +409,21 @@ def test_skips_with_already_tagged_when_head_carries_the_v_prefixed_latest_tag() assert isinstance(result, AlreadyTagged) assert result.tag == "v1.4.2" assert provider.create_tag_calls == [] + + +@pytest.mark.parametrize( + ("tag_names", "expected_count"), + [ + ([], 0), + (["V1.2.0", "release-1.2.0", "v1"], 3), + ], +) +def test_no_tags_counts_the_tags_that_are_not_semver_form(tag_names: list[str], expected_count: int) -> None: + use_case, _provider, output = _make_use_case( + tags=[Tag(name=name, commit_sha=f"sha{index}") for index, name in enumerate(tag_names)], + ) + + result: typing.Final = use_case(output=output) + + assert isinstance(result, NoTags) + assert result.skipped_tag_count == expected_count