diff --git a/CONTEXT.md b/CONTEXT.md index bceb205..fd0bfac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -34,9 +34,15 @@ in the same file, where a *provider* is a DI recipe. Say **provider** for the fo **modern-di provider** for the other. **Strategy**: -The rule that turns **one** commit into a `Bump`. It receives the head commit of the default branch -and nothing else — no network, no tag history, no commit range. A strategy never picks a tag or a -version; the use-case does that around it. +The rule that turns **one** commit into a `Bump` or a **decline**. It receives the head commit of the +default branch and nothing else — no network, no tag history, no commit range. A strategy never picks +a tag or a version; the use-case does that around it. + +**Decline**: +A strategy's answer that the head commit warrants no bump, naming which of its rules applied as a +status token and a fixed reason. Each distinct rule gets its own token, so a status never claims +something false about the commit (a merge commit is never reported as `no_merge_commit`). +_Avoid_: "no bump" for the strategy's answer. `NoBump` is the **Outcome** variant a decline becomes. **Latest tag**: The bump baseline: the **highest by SemVer precedence** among the repo's SemVer-form tags, bare @@ -57,8 +63,9 @@ What a run did, as the closed sum `Created | DryRun | NoTags | AlreadyTagged | N `semvertag/_outcome.py`. It is internal and free to grow — the renderers `match` it exhaustively, so a new variant is a type error until handled. Distinct from **`RunResult`**, the JSON wire DTO it projects onto, and from **status**, the one string field on that DTO. The wire status tokens are a -frozen public contract (`schema_version` `"1.0"`, parsed by `jq` in `action.yml`); the `Outcome` -variant names are not. +frozen public contract (`schema_version` `"1.0"`, parsed by `jq` in `action.yml`): a token never +changes meaning, though new ones may be added. The `Outcome` variant names are not frozen, and neither +is **reason**, the human-facing sentence beside the status, which may be reworded. **Prerelease**: Two incompatible spellings live in this repo and only one is recognized as a bump baseline. diff --git a/action.yml b/action.yml index 29f6a94..0dc58c0 100644 --- a/action.yml +++ b/action.yml @@ -50,9 +50,9 @@ runs: # to no argument; non-empty expands to the literal `--dry-run`. result=$(uvx 'semvertag>=0.5.0,<1' tag --json $dry_run_flag) printf '%s\n' "$result" - # Normalize the CLI's internal status (`no_tags`, `already_tagged`, - # `no_merge_commit`, `no_conforming_commit`, `dry_run`, ...) to a stable - # consumer-facing enum. `set -euo pipefail` ensures we never reach + # Normalize the CLI's internal status to a stable consumer-facing enum: + # `created`, or `no-bump` for any other status (`no_tags`, `dry_run`, a + # strategy's decline, ...). `set -euo pipefail` ensures we never reach # here on CLI errors, so there is no `error` value to surface. case "$(jq -r '.status' <<<"$result")" in created) status='created' ;; diff --git a/docs/adr/0002-outcome-renderings-stay-split.md b/docs/adr/0002-outcome-renderings-stay-split.md index 2edc4aa..874f85e 100644 --- a/docs/adr/0002-outcome-renderings-stay-split.md +++ b/docs/adr/0002-outcome-renderings-stay-split.md @@ -3,9 +3,10 @@ `to_run_result` in `_outcome.py` and `_format_outcome` in `_output.py` are two exhaustive `match` statements over the same closed `Outcome` sum, rather than two renderings hung on the variants themselves. Only the match skeleton is shared, and no string is, by design: the wire arm builds a -frozen machine contract of fixed status tokens and reasons that `action.yml` parses with `jq`, while -the human arm builds a sentence that is free to be reworded. Co-locating them would trade -locality-of-concern for locality-of-variant and drag presentation phrasing into a module that today -depends only on `_types`. The drift a shared home would guard against is already type-enforced: -both matches end in `assert_never`, so a sixth variant is a type error in both arms until handled. +machine contract of frozen status tokens, which `action.yml` parses with `jq`, beside a +human-facing `reason`, while the human arm builds a sentence; both texts are free to be reworded. +Co-locating them would trade locality-of-concern for locality-of-variant and drag presentation +phrasing into a module that today depends only on `_types`. The drift a shared home would guard +against is already type-enforced: both matches end in `assert_never`, so a sixth variant is a type +error in both arms until handled. Unification earns its keep only once a variant's two renderings must be the identical string. diff --git a/docs/providers/github.md b/docs/providers/github.md index 707e920..8ed2e52 100644 --- a/docs/providers/github.md +++ b/docs/providers/github.md @@ -107,7 +107,7 @@ When you give the step an `id:`, downstream steps can read three outputs: |---|---| | `tag` | The created tag (e.g. `1.2.3`, or `v1.2.3` in a `v`-prefixed repo), or empty string when `status` is `no-bump`. | | `bump` | `none` \| `patch` \| `minor` \| `major`. | -| `status` | `created` (tag pushed) \| `no-bump` (nothing to tag: no prior tag, already tagged, no merge commit, or non-conforming commit). On CLI error the action itself exits non-zero and this output is not written. | +| `status` | `created` (tag pushed) \| `no-bump` (nothing to tag: no prior tag, already tagged, or the strategy declined the head commit). On CLI error the action itself exits non-zero and this output is not written. | Example: trigger a downstream release-notes job only when a tag was created. @@ -203,9 +203,9 @@ Pick `branch-prefix` if your team merges PRs with branch names that follow a `feature/...`, `bugfix/...`, `hotfix/...` convention and lands them as merge commits. semvertag reads the head commit's source-branch prefix and bumps accordingly: `feature/` bumps minor, `bugfix/` and -`hotfix/` bump patch, and any other prefix bumps nothing. With squash -merges the head is not a merge commit and the run reports -`no_merge_commit`. This is the default. See +`hotfix/` bump patch, and a merge with any other prefix bumps nothing +(`unmapped_branch_prefix`). With squash merges the head is not a merge +commit and the run reports `no_merge_commit`. This is the default. See [Branch-prefix strategy](../strategies/branch-prefix.md) for the full prefix-to-bump table and edge-case behavior. diff --git a/docs/providers/gitlab.md b/docs/providers/gitlab.md index 8615966..f9eb539 100644 --- a/docs/providers/gitlab.md +++ b/docs/providers/gitlab.md @@ -149,8 +149,8 @@ Pick `branch-prefix` if your team merges merge requests with branch names that follow a `feature/...`, `bugfix/...`, `hotfix/...` convention and lands them as merge commits. semvertag reads the head commit's source-branch prefix and bumps accordingly: `feature/` bumps -minor, `bugfix/` and `hotfix/` bump patch, and any other prefix bumps -nothing. With squash merges the +minor, `bugfix/` and `hotfix/` bump patch, and a merge with any other +prefix bumps nothing (`unmapped_branch_prefix`). With squash merges the head is not a merge commit and the run reports `no_merge_commit`. This is the default. See [Branch-prefix strategy](../strategies/branch-prefix.md) for the full diff --git a/docs/strategies/branch-prefix.md b/docs/strategies/branch-prefix.md index c8ab7ce..bd349e7 100644 --- a/docs/strategies/branch-prefix.md +++ b/docs/strategies/branch-prefix.md @@ -13,7 +13,7 @@ behavior. |---|---| | `feature/` | minor | | `bugfix/` or `hotfix/` | patch | -| anything else | none | +| anything else | none, status `unmapped_branch_prefix` | A bump of `none` means the commit contributes nothing to the release decision. `branch-prefix` never produces a major bump. Promote @@ -26,12 +26,12 @@ to a new major version manually, or switch to The strategy only fires on commits whose subject contains one of the default merge marks: the literal string `Merge branch` (the default `git merge` subject) or `Merge pull request` (GitHub's merge-commit -subject). Commits without one of those marks return `none` regardless -of prefix. For example: +subject). Commits without one of those marks are declined with +`no_merge_commit` regardless of prefix. For example: - Standard `git merge feature/foo` → subject `Merge branch 'feature/foo' into main` → bump = minor ✓ - GitHub's `Merge pull request #N from user/feature/foo` → bump = minor ✓ -- Direct pushes to the default branch → bump = none, unless +- Direct pushes to the default branch → bump = none (`no_merge_commit`), unless `patch_on_non_merge_commit` is enabled (see below), in which case bump = patch. diff --git a/docs/strategies/conventional-commits.md b/docs/strategies/conventional-commits.md index 8d27e3f..18a71fa 100644 --- a/docs/strategies/conventional-commits.md +++ b/docs/strategies/conventional-commits.md @@ -14,11 +14,13 @@ commits since the latest tag; see [Head commit only](#head-commit-only). | `!` suffix on the type (e.g. `feat!:`, `fix!:`, `refactor!:`) | major | | `feat:` (or `feat(scope):`) | minor | | `fix:` (or `fix(scope):`), `perf:` (or `perf(scope):`) | patch | -| Any other type (`chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci`, `revert`, ...) | none | -| Commits whose subject does not match the type-grammar at all | none | +| Any other type (`chore`, `docs`, `refactor`, `style`, `test`, `build`, `ci`, `revert`, ...) | none, status `no_bumping_type` | +| Commits whose subject does not match the type-grammar at all | none, status `no_conforming_commit` | The grammar checked is `^(type)(?:\((scope)\))?(!)?:`. Anything not -matching this pattern returns `none`. The `!` marker takes precedence +matching this pattern is declined with `no_conforming_commit`; a +matching commit whose type is in neither list is declined with +`no_bumping_type`. The `!` marker takes precedence over the type: `chore!:` is a major bump even though `chore` is otherwise unmapped. diff --git a/semvertag/_outcome.py b/semvertag/_outcome.py index 1ac2e50..bc00bc3 100644 --- a/semvertag/_outcome.py +++ b/semvertag/_outcome.py @@ -12,6 +12,7 @@ ) _NO_TAGS_REASON: typing.Final = f"No prior semver-conforming tags found; {_SEED_ADVICE}" _ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged." +_NO_BUMP: typing.Final = "none" @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) @@ -76,7 +77,7 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult: case NoTags(commit=commit, skipped_tag_count=skipped_tag_count): return RunResult( strategy=strategy, - bump=Bump.NONE.value, + bump=_NO_BUMP, status="no_tags", tag=None, commit=commit, @@ -85,16 +86,14 @@ def to_run_result(outcome: Outcome, *, strategy: str) -> RunResult: case AlreadyTagged(tag=tag, commit=commit): return RunResult( strategy=strategy, - bump=Bump.NONE.value, + bump=_NO_BUMP, status="already_tagged", tag=tag, commit=commit, reason=_ALREADY_TAGGED_REASON, ) case NoBump(status=status, reason=reason, commit=commit): - return RunResult( - strategy=strategy, bump=Bump.NONE.value, status=status, tag=None, commit=commit, reason=reason - ) + return RunResult(strategy=strategy, bump=_NO_BUMP, status=status, tag=None, commit=commit, reason=reason) case _: # pragma: no cover - exhaustiveness guard; ty verifies every Outcome is matched typing.assert_never(outcome) diff --git a/semvertag/_types.py b/semvertag/_types.py index 7cbaf5e..be8136b 100644 --- a/semvertag/_types.py +++ b/semvertag/_types.py @@ -3,7 +3,6 @@ class Bump(enum.Enum): - NONE = "none" PATCH = "patch" MINOR = "minor" MAJOR = "major" diff --git a/semvertag/_use_case.py b/semvertag/_use_case.py index 9bbdab2..da72d74 100644 --- a/semvertag/_use_case.py +++ b/semvertag/_use_case.py @@ -7,7 +7,7 @@ from semvertag._output import Output from semvertag._types import Bump, Tag from semvertag.providers._base import Provider -from semvertag.strategies._base import BumpStrategy +from semvertag.strategies._base import BumpStrategy, Decline _V_PREFIX: typing.Final = "v" @@ -35,20 +35,17 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> Outcome: return self._emit(output, AlreadyTagged(tag=latest_tag.name, commit=commit.sha)) output.progress("Computing bump...") - bump: typing.Final = self.strategy.decide(commit) - if bump is Bump.NONE: - return self._emit( - output, - NoBump(status=self.strategy.no_bump_status, reason=self.strategy.no_bump_reason, commit=commit.sha), - ) - - new_tag: typing.Final = _tag_prefix(latest_tag) + _compute_new_version(latest_version, bump) + decision: typing.Final = self.strategy.decide(commit) + if isinstance(decision, Decline): + return self._emit(output, NoBump(status=decision.status, reason=decision.reason, commit=commit.sha)) + + new_tag: typing.Final = _tag_prefix(latest_tag) + _compute_new_version(latest_version, decision) if dry_run: - return self._emit(output, DryRun(tag=new_tag, bump=bump, commit=commit.sha)) + return self._emit(output, DryRun(tag=new_tag, bump=decision, commit=commit.sha)) output.progress(f"Creating tag {new_tag}...") self.provider.create_tag(name=new_tag, commit_sha=commit.sha) - return self._emit(output, Created(tag=new_tag, bump=bump, commit=commit.sha)) + return self._emit(output, Created(tag=new_tag, bump=decision, commit=commit.sha)) def _emit(self, output: Output, outcome: Outcome) -> Outcome: output.emit(outcome, strategy=self.strategy.name) diff --git a/semvertag/strategies/_base.py b/semvertag/strategies/_base.py index f89728d..887cb4a 100644 --- a/semvertag/strategies/_base.py +++ b/semvertag/strategies/_base.py @@ -1,14 +1,17 @@ +import dataclasses import typing from semvertag._types import Bump, Commit +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class Decline: + status: str + reason: str + + class BumpStrategy(typing.Protocol): @property def name(self) -> str: ... - @property - def no_bump_status(self) -> str: ... - @property - def no_bump_reason(self) -> str: ... - def decide(self, commit: Commit) -> Bump: ... + def decide(self, commit: Commit) -> Bump | Decline: ... diff --git a/semvertag/strategies/branch_prefix.py b/semvertag/strategies/branch_prefix.py index bf443b7..34fb478 100644 --- a/semvertag/strategies/branch_prefix.py +++ b/semvertag/strategies/branch_prefix.py @@ -5,8 +5,15 @@ from semvertag._commit_parse import subject_line from semvertag._types import Bump, Commit +from semvertag.strategies._base import Decline +_NOT_A_MERGE_COMMIT: typing.Final = Decline( + status="no_merge_commit", reason="Latest commit on default branch is not a merge commit." +) +_UNMAPPED_BRANCH_PREFIX: typing.Final = Decline( + status="unmapped_branch_prefix", reason="Merge commit's source branch has no configured prefix." +) _NonEmptyStr: typing.TypeAlias = typing.Annotated[str, pydantic.Field(min_length=1)] @@ -25,18 +32,14 @@ class BranchPrefixConfig(pydantic.BaseModel): @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class BranchPrefixStrategy: name: typing.ClassVar[str] = "branch-prefix" - no_bump_status: typing.ClassVar[str] = "no_merge_commit" - no_bump_reason: typing.ClassVar[str] = "Latest commit on default branch is not a merge commit." config: BranchPrefixConfig - def decide(self, commit: Commit) -> Bump: + def decide(self, commit: Commit) -> Bump | Decline: subject: typing.Final = subject_line(commit.message) if not any(mark in subject for mark in self.config.merge_mark_texts): - # Non-merge commit: opt-in to a patch bump, else no bump - # (the no_bump_* ClassVars only surface on the Bump.NONE path). - return Bump.PATCH if self.config.patch_on_non_merge_commit else Bump.NONE + return Bump.PATCH if self.config.patch_on_non_merge_commit else _NOT_A_MERGE_COMMIT if any(prefix in subject for prefix in self.config.minor): return Bump.MINOR if any(prefix in subject for prefix in self.config.patch): return Bump.PATCH - return Bump.NONE + return _UNMAPPED_BRANCH_PREFIX diff --git a/semvertag/strategies/conventional_commits.py b/semvertag/strategies/conventional_commits.py index 6c11d8b..56f841f 100644 --- a/semvertag/strategies/conventional_commits.py +++ b/semvertag/strategies/conventional_commits.py @@ -6,11 +6,16 @@ from semvertag._commit_parse import body_lines, subject_line from semvertag._types import Bump, Commit +from semvertag.strategies._base import Decline _TYPE_PATTERN: typing.Final = re.compile(r"^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!?):") _VALID_TYPE_RE: typing.Final = re.compile(r"^[a-z]+$") _BREAKING_TOKENS: typing.Final = ("BREAKING CHANGE:", "BREAKING-CHANGE:") +_NOT_CONFORMING: typing.Final = Decline( + status="no_conforming_commit", reason="Commit subject is not a Conventional Commit." +) +_NO_BUMPING_TYPE: typing.Final = Decline(status="no_bumping_type", reason="Commit type is not configured to bump.") class ConventionalCommitsConfig(pydantic.BaseModel): @@ -32,15 +37,13 @@ def _validate_types(cls, value: tuple[str, ...]) -> tuple[str, ...]: @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class ConventionalCommitsStrategy: name: typing.ClassVar[str] = "conventional-commits" - no_bump_status: typing.ClassVar[str] = "no_conforming_commit" - no_bump_reason: typing.ClassVar[str] = "No conforming Conventional Commits type found in commit message." config: ConventionalCommitsConfig - def decide(self, commit: Commit) -> Bump: + def decide(self, commit: Commit) -> Bump | Decline: subject: typing.Final = subject_line(commit.message) match: typing.Final = _TYPE_PATTERN.match(subject) if match is None: - return Bump.NONE + return _NOT_CONFORMING for line in body_lines(commit.message): stripped = line.lstrip() if any(stripped.startswith(token) for token in _BREAKING_TOKENS): @@ -52,4 +55,4 @@ def decide(self, commit: Commit) -> Bump: return Bump.MINOR if commit_type in self.config.patch_types: return Bump.PATCH - return Bump.NONE + return _NO_BUMPING_TYPE diff --git a/tests/integration/test_strategy_switching.py b/tests/integration/test_strategy_switching.py index 6bc0070..1a1300e 100644 --- a/tests/integration/test_strategy_switching.py +++ b/tests/integration/test_strategy_switching.py @@ -68,7 +68,7 @@ def test_skips_with_no_conforming_commit_when_strategy_is_cc_and_message_has_no_ assert result.exit_code == 0 assert "No tag created" in result.stdout - assert "No conforming Conventional Commits type" in result.stdout + assert "Commit subject is not a Conventional Commit." in result.stdout def test_marina_journey_same_fixture_different_strategies_produces_different_bumps( diff --git a/tests/unit/test_branch_prefix_strategy.py b/tests/unit/test_branch_prefix_strategy.py index d43356d..dd66983 100644 --- a/tests/unit/test_branch_prefix_strategy.py +++ b/tests/unit/test_branch_prefix_strategy.py @@ -4,12 +4,18 @@ import pytest from semvertag._types import Bump, Commit -from semvertag.strategies._base import BumpStrategy +from semvertag.strategies._base import BumpStrategy, Decline from semvertag.strategies.branch_prefix import BranchPrefixConfig, BranchPrefixStrategy DEFAULT_STRATEGY: typing.Final = BranchPrefixStrategy(config=BranchPrefixConfig()) COMMIT_SHA: typing.Final = "0" * 40 +_NO_MERGE_COMMIT: typing.Final = Decline( + status="no_merge_commit", reason="Latest commit on default branch is not a merge commit." +) +_UNMAPPED_PREFIX: typing.Final = Decline( + status="unmapped_branch_prefix", reason="Merge commit's source branch has no configured prefix." +) def _commit(message: str) -> Commit: @@ -17,16 +23,16 @@ def _commit(message: str) -> Commit: _NON_MERGE_CASES: typing.Final = [ - ("feat: ship the new login", Bump.NONE), - ("docs: update README", Bump.NONE), - ("", Bump.NONE), - ("merge branch 'feature/x' into main", Bump.NONE), + ("feat: ship the new login", _NO_MERGE_COMMIT), + ("docs: update README", _NO_MERGE_COMMIT), + ("", _NO_MERGE_COMMIT), + ("merge branch 'feature/x' into main", _NO_MERGE_COMMIT), ] @pytest.mark.parametrize(("message", "expected"), _NON_MERGE_CASES) -def test_returns_none_when_message_is_not_a_merge_commit(message: str, expected: Bump) -> None: - assert DEFAULT_STRATEGY.decide(_commit(message)) is expected +def test_declines_as_no_merge_commit_when_message_is_not_a_merge_commit(message: str, expected: Bump | Decline) -> None: + assert DEFAULT_STRATEGY.decide(_commit(message)) == expected _MINOR_CASES: typing.Final = [ @@ -37,8 +43,8 @@ def test_returns_none_when_message_is_not_a_merge_commit(message: str, expected: @pytest.mark.parametrize(("message", "expected"), _MINOR_CASES) -def test_returns_minor_when_message_contains_feature_prefix(message: str, expected: Bump) -> None: - assert DEFAULT_STRATEGY.decide(_commit(message)) is expected +def test_returns_minor_when_message_contains_feature_prefix(message: str, expected: Bump | Decline) -> None: + assert DEFAULT_STRATEGY.decide(_commit(message)) == expected _PATCH_CASES: typing.Final = [ @@ -49,8 +55,8 @@ def test_returns_minor_when_message_contains_feature_prefix(message: str, expect @pytest.mark.parametrize(("message", "expected"), _PATCH_CASES) -def test_returns_patch_when_message_contains_bugfix_or_hotfix_prefix(message: str, expected: Bump) -> None: - assert DEFAULT_STRATEGY.decide(_commit(message)) is expected +def test_returns_patch_when_message_contains_bugfix_or_hotfix_prefix(message: str, expected: Bump | Decline) -> None: + assert DEFAULT_STRATEGY.decide(_commit(message)) == expected def test_returns_minor_when_message_contains_both_feature_and_bugfix_prefixes() -> None: @@ -59,15 +65,15 @@ def test_returns_minor_when_message_contains_both_feature_and_bugfix_prefixes() _UNRECOGNIZED_MERGE_CASES: typing.Final = [ - ("Merge branch 'release/2.0' into main", Bump.NONE), - ("Merge branch 'chore/cleanup' into main", Bump.NONE), - ("Merge branch 'develop' into main", Bump.NONE), + ("Merge branch 'release/2.0' into main", _UNMAPPED_PREFIX), + ("Merge branch 'chore/cleanup' into main", _UNMAPPED_PREFIX), + ("Merge branch 'develop' into main", _UNMAPPED_PREFIX), ] @pytest.mark.parametrize(("message", "expected"), _UNRECOGNIZED_MERGE_CASES) -def test_returns_none_when_merge_message_has_no_recognized_prefix(message: str, expected: Bump) -> None: - assert DEFAULT_STRATEGY.decide(_commit(message)) is expected +def test_declines_as_unmapped_branch_prefix_for_unknown_merge_prefix(message: str, expected: Bump | Decline) -> None: + assert DEFAULT_STRATEGY.decide(_commit(message)) == expected _ALL_CASES: typing.Final = _NON_MERGE_CASES + _MINOR_CASES + _PATCH_CASES + _UNRECOGNIZED_MERGE_CASES @@ -95,8 +101,8 @@ def test_honors_custom_minor_prefix_when_config_overrides_default() -> None: ) assert custom.decide(_commit("Auto-merge: feat/new-thing")) is Bump.MINOR assert custom.decide(_commit("Auto-merge: fix/bug-123")) is Bump.PATCH - assert custom.decide(_commit("Auto-merge: feature/x")) is Bump.NONE - assert custom.decide(_commit("Merge branch 'feat/x' into main")) is Bump.NONE + assert custom.decide(_commit("Auto-merge: feature/x")) == _UNMAPPED_PREFIX + assert custom.decide(_commit("Merge branch 'feat/x' into main")) == _NO_MERGE_COMMIT def test_recognizes_github_pr_merge_subject_under_defaults() -> None: @@ -104,7 +110,7 @@ def test_recognizes_github_pr_merge_subject_under_defaults() -> None: assert default.decide(_commit("Merge pull request #42 from org/feature/new-thing")) is Bump.MINOR assert default.decide(_commit("Merge pull request #43 from org/bugfix/bug-123")) is Bump.PATCH assert default.decide(_commit("Merge pull request #44 from org/hotfix/urgent")) is Bump.PATCH - assert default.decide(_commit("Merge pull request #45 from org/chore/cleanup")) is Bump.NONE + assert default.decide(_commit("Merge pull request #45 from org/chore/cleanup")) == _UNMAPPED_PREFIX def test_recognizes_gitlab_merge_branch_subject_under_defaults() -> None: @@ -132,12 +138,12 @@ def test_raises_validation_error_when_config_field_is_empty(invalid_kwargs: dict def test_ignores_body_lines_when_subject_is_not_a_merge() -> None: message: typing.Final = "feat: build pipeline\n\nReviewed-by: alice\nfeature/x mentioned in body" - assert DEFAULT_STRATEGY.decide(_commit(message)) is Bump.NONE + assert DEFAULT_STRATEGY.decide(_commit(message)) == _NO_MERGE_COMMIT def test_ignores_body_prefixes_when_subject_is_an_unrecognized_merge() -> None: message: typing.Final = "Merge branch 'release/2.0' into main\nfeature/foo touched in body\nbugfix/y also mentioned" - assert DEFAULT_STRATEGY.decide(_commit(message)) is Bump.NONE + assert DEFAULT_STRATEGY.decide(_commit(message)) == _UNMAPPED_PREFIX def test_returns_minor_when_subject_is_a_feature_merge_with_trailing_body() -> None: @@ -162,13 +168,13 @@ def test_returns_patch_for_non_merge_commit_when_flag_enabled(message: str) -> N ("Merge branch 'bugfix/y' into main", Bump.PATCH), ], ) -def test_flag_leaves_recognized_merge_paths_unchanged(message: str, expected: Bump) -> None: - assert _FALLBACK_STRATEGY.decide(_commit(message)) is expected +def test_flag_leaves_recognized_merge_paths_unchanged(message: str, expected: Bump | Decline) -> None: + assert _FALLBACK_STRATEGY.decide(_commit(message)) == expected @pytest.mark.parametrize("message", [message for message, _ in _UNRECOGNIZED_MERGE_CASES]) -def test_flag_leaves_unrecognized_merge_as_none(message: str) -> None: - assert _FALLBACK_STRATEGY.decide(_commit(message)) is Bump.NONE +def test_flag_leaves_unrecognized_merge_declined_as_unmapped_prefix(message: str) -> None: + assert _FALLBACK_STRATEGY.decide(_commit(message)) == _UNMAPPED_PREFIX def test_patch_on_non_merge_commit_defaults_to_false() -> None: diff --git a/tests/unit/test_conventional_commits_strategy.py b/tests/unit/test_conventional_commits_strategy.py index a97dc48..64b0811 100644 --- a/tests/unit/test_conventional_commits_strategy.py +++ b/tests/unit/test_conventional_commits_strategy.py @@ -4,6 +4,7 @@ import pytest from semvertag._types import Bump, Commit +from semvertag.strategies._base import Decline from semvertag.strategies.conventional_commits import ( ConventionalCommitsConfig, ConventionalCommitsStrategy, @@ -11,6 +12,10 @@ _SHA: typing.Final = "abc1234" +_NOT_CONFORMING: typing.Final = Decline( + status="no_conforming_commit", reason="Commit subject is not a Conventional Commit." +) +_NO_BUMPING_TYPE: typing.Final = Decline(status="no_bumping_type", reason="Commit type is not configured to bump.") @pytest.fixture @@ -84,10 +89,10 @@ def test_returns_major_when_footer_present_on_unrecognized_type( assert default_strategy.decide(_commit(msg)) is Bump.MAJOR -def test_returns_none_when_type_is_unrecognized_and_no_breaking_signal( +def test_declines_when_type_is_unrecognized_and_no_breaking_signal( default_strategy: ConventionalCommitsStrategy, ) -> None: - assert default_strategy.decide(_commit("chore: bump deps")) is Bump.NONE + assert default_strategy.decide(_commit("chore: bump deps")) == _NO_BUMPING_TYPE @pytest.mark.parametrize( @@ -102,17 +107,17 @@ def test_returns_none_when_type_is_unrecognized_and_no_breaking_signal( "revert: previous change", ], ) -def test_returns_none_for_unrecognized_types( +def test_declines_for_unrecognized_types( default_strategy: ConventionalCommitsStrategy, message: str, ) -> None: - assert default_strategy.decide(_commit(message)) is Bump.NONE + assert default_strategy.decide(_commit(message)) == _NO_BUMPING_TYPE -def test_returns_none_when_subject_has_no_cc_header( +def test_declines_when_subject_has_no_cc_header( default_strategy: ConventionalCommitsStrategy, ) -> None: - assert default_strategy.decide(_commit("Fixed thing")) is Bump.NONE + assert default_strategy.decide(_commit("Fixed thing")) == _NOT_CONFORMING def test_returns_minor_when_bang_appears_in_description_not_before_colon( @@ -128,8 +133,8 @@ def test_returns_minor_when_breaking_change_phrase_is_mid_body_not_a_footer( assert default_strategy.decide(_commit(msg)) is Bump.MINOR -def test_returns_none_when_type_is_uppercase(default_strategy: ConventionalCommitsStrategy) -> None: - assert default_strategy.decide(_commit("FEAT: shouting")) is Bump.NONE +def test_declines_when_type_is_uppercase(default_strategy: ConventionalCommitsStrategy) -> None: + assert default_strategy.decide(_commit("FEAT: shouting")) == _NOT_CONFORMING def test_returns_minor_when_breaking_change_footer_is_lowercase( @@ -139,14 +144,14 @@ def test_returns_minor_when_breaking_change_footer_is_lowercase( assert default_strategy.decide(_commit(msg)) is Bump.MINOR -def test_returns_none_when_message_is_empty(default_strategy: ConventionalCommitsStrategy) -> None: - assert default_strategy.decide(_commit("")) is Bump.NONE +def test_declines_when_message_is_empty(default_strategy: ConventionalCommitsStrategy) -> None: + assert default_strategy.decide(_commit("")) == _NOT_CONFORMING -def test_returns_none_when_message_is_whitespace_only( +def test_declines_when_message_is_whitespace_only( default_strategy: ConventionalCommitsStrategy, ) -> None: - assert default_strategy.decide(_commit(" \n\n \n")) is Bump.NONE + assert default_strategy.decide(_commit(" \n\n \n")) == _NOT_CONFORMING def test_returns_major_when_crlf_line_endings_used( @@ -174,16 +179,16 @@ def test_has_expected_class_var_name_and_satisfies_protocol_shape( assert callable(default_strategy.decide) -def test_returns_none_when_subject_has_leading_whitespace( +def test_declines_when_subject_has_leading_whitespace( default_strategy: ConventionalCommitsStrategy, ) -> None: - assert default_strategy.decide(_commit(" feat: foo")) is Bump.NONE + assert default_strategy.decide(_commit(" feat: foo")) == _NOT_CONFORMING -def test_returns_none_when_space_appears_before_colon( +def test_declines_when_space_appears_before_colon( default_strategy: ConventionalCommitsStrategy, ) -> None: - assert default_strategy.decide(_commit("feat : foo")) is Bump.NONE + assert default_strategy.decide(_commit("feat : foo")) == _NOT_CONFORMING @pytest.mark.parametrize( @@ -221,3 +226,11 @@ def test_config_accepts_only_lowercase_letter_types() -> None: ) assert "feature" in config.minor_types assert "hotfix" in config.patch_types + + +def test_declines_merge_commit_subject_as_not_conforming(default_strategy: ConventionalCommitsStrategy) -> None: + assert default_strategy.decide(_commit("Merge branch 'feature/x' into main")) == _NOT_CONFORMING + + +def test_declines_scoped_non_bumping_type_as_no_bumping_type(default_strategy: ConventionalCommitsStrategy) -> None: + assert default_strategy.decide(_commit("chore(deps): bump uv")) == _NO_BUMPING_TYPE diff --git a/tests/unit/test_use_case.py b/tests/unit/test_use_case.py index 244fcfb..8597071 100644 --- a/tests/unit/test_use_case.py +++ b/tests/unit/test_use_case.py @@ -7,6 +7,7 @@ from semvertag._outcome import AlreadyTagged, Created, DryRun, NoBump, NoTags, Outcome from semvertag._types import Bump, Commit, Tag from semvertag._use_case import SemvertagUseCase, _compute_new_version, _select_latest_semver_tag +from semvertag.strategies._base import Decline _MERGE_MESSAGE: typing.Final = "Merge branch 'feature/foo' into main" @@ -17,14 +18,7 @@ _EXPECTED_NEW_TAG: typing.Final = "1.5.0" _BRANCH_PREFIX_STRATEGY: typing.Final = "branch-prefix" _CONVENTIONAL_STRATEGY: typing.Final = "conventional-commits" -_NO_BUMP_STATUS_BY_STRATEGY: typing.Final = { - _BRANCH_PREFIX_STRATEGY: "no_merge_commit", - _CONVENTIONAL_STRATEGY: "no_conforming_commit", -} -_NO_BUMP_REASON_BY_STRATEGY: typing.Final = { - _BRANCH_PREFIX_STRATEGY: "Latest commit on default branch is not a merge commit.", - _CONVENTIONAL_STRATEGY: "No conforming Conventional Commits type found in commit message.", -} +_STUB_DECLINE: typing.Final = Decline(status="stub_declined", reason="Stub strategy declined.") @dataclasses.dataclass(slots=True, kw_only=True) @@ -66,12 +60,10 @@ def create_tag(self, name: str, commit_sha: str) -> None: @dataclasses.dataclass(slots=True, kw_only=True) class _StubStrategy: name: str - no_bump_status: str = "no_merge_commit" - no_bump_reason: str = "Latest commit on default branch is not a merge commit." - bump_to_return: Bump + decision: Bump | Decline - def decide(self, commit: Commit) -> Bump: # noqa: ARG002 - return self.bump_to_return + def decide(self, commit: Commit) -> Bump | Decline: # noqa: ARG002 + return self.decision def _make_use_case( @@ -79,19 +71,14 @@ def _make_use_case( commit_message: str = _MERGE_MESSAGE, commit_sha: str = _LATEST_SHA, tags: list[Tag] | None = None, - bump: Bump = Bump.MINOR, + decision: Bump | Decline = Bump.MINOR, strategy_name: str = _BRANCH_PREFIX_STRATEGY, ) -> tuple[SemvertagUseCase, _StubProvider, _RecordingOutput]: provider: typing.Final = _StubProvider( commit=Commit(sha=commit_sha, message=commit_message), tags=tags if tags is not None else [Tag(name=_LATEST_TAG_NAME, commit_sha=_PRIOR_SHA)], ) - strategy: typing.Final = _StubStrategy( - name=strategy_name, - no_bump_status=_NO_BUMP_STATUS_BY_STRATEGY[strategy_name], - no_bump_reason=_NO_BUMP_REASON_BY_STRATEGY[strategy_name], - bump_to_return=bump, - ) + strategy: typing.Final = _StubStrategy(name=strategy_name, decision=decision) output: typing.Final = _RecordingOutput() use_case: typing.Final = SemvertagUseCase( provider=typing.cast("typing.Any", provider), @@ -126,31 +113,17 @@ def test_skips_with_already_tagged_when_latest_commit_sha_matches_a_tag() -> Non assert provider.create_tag_calls == [] -def test_skips_with_no_merge_commit_under_branch_prefix_when_bump_is_none() -> None: +def test_strategy_decline_becomes_no_bump_with_its_status_and_reason() -> None: use_case, provider, output = _make_use_case( commit_message=_NON_MERGE_MESSAGE, - bump=Bump.NONE, - ) - - result: typing.Final = use_case(output=output) - - assert isinstance(result, NoBump) - assert result.status == "no_merge_commit" - assert result.reason - assert provider.create_tag_calls == [] - - -def test_skips_with_no_conforming_commit_under_conventional_commits_when_bump_is_none() -> None: - use_case, _provider, output = _make_use_case( - commit_message="random text", - bump=Bump.NONE, + decision=_STUB_DECLINE, strategy_name=_CONVENTIONAL_STRATEGY, ) result: typing.Final = use_case(output=output) - assert isinstance(result, NoBump) - assert result.status == "no_conforming_commit" + assert result == NoBump(status="stub_declined", reason="Stub strategy declined.", commit=_LATEST_SHA) + assert provider.create_tag_calls == [] assert output.emitted == [(result, _CONVENTIONAL_STRATEGY)] @@ -184,7 +157,7 @@ def test_picks_highest_semver_tag_not_first_in_list_when_computing_bump() -> Non Tag(name="2.0.0", commit_sha=_PRIOR_SHA), Tag(name="1.9.0", commit_sha="y"), ], - bump=Bump.PATCH, + decision=Bump.PATCH, ) result: typing.Final = use_case(output=output) @@ -203,7 +176,7 @@ def test_picks_highest_semver_tag_not_first_in_list_when_computing_bump() -> Non ], ) def test_bump_arithmetic_dispatches_to_semver_bump_kind(bump: Bump, expected_tag: str) -> None: - use_case, _provider, output = _make_use_case(bump=bump) + use_case, _provider, output = _make_use_case(decision=bump) result: typing.Final = use_case(output=output) assert isinstance(result, Created) assert result.tag == expected_tag @@ -260,16 +233,16 @@ def test_dry_run_does_not_affect_no_tags_path() -> None: assert provider.create_tag_calls == [] -def test_dry_run_does_not_affect_strategy_no_bump_path() -> None: +def test_dry_run_does_not_affect_strategy_decline_path() -> None: use_case, provider, output = _make_use_case( commit_message=_NON_MERGE_MESSAGE, - bump=Bump.NONE, + decision=_STUB_DECLINE, ) result: typing.Final = use_case(output=output, dry_run=True) assert isinstance(result, NoBump) - assert result.status == "no_merge_commit" + assert result.status == "stub_declined" assert provider.create_tag_calls == [] @@ -382,7 +355,7 @@ def test_v_prefixed_tag_wins_an_exact_precedence_tie(tag_names: list[str]) -> No def test_v_prefixed_prerelease_baseline_finalizes_and_keeps_its_prefix() -> None: use_case, _provider, output = _make_use_case( tags=[Tag(name="v1.0.0-rc.1", commit_sha=_PRIOR_SHA)], - bump=Bump.PATCH, + decision=Bump.PATCH, ) result: typing.Final = use_case(output=output, dry_run=True)