From 313da39e2a2488a2655f3e15aa4189c980d78076 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 13:31:06 +0300 Subject: [PATCH 1/7] providers: bump httpware to 0.12.0, adopt get_with_response Raise the dependency floor from >=0.8.2 to >=0.12.0 and collapse the two Link-header pagination call sites from send_with_response(build_request(...)) to the 0.12.0 get_with_response shortcut. No behavior change; picks up 0.11.0 security/correctness hardening via the version move. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../change.md | 52 +++++++++++++++++++ pyproject.toml | 2 +- semvertag/providers/github.py | 5 +- semvertag/providers/gitlab.py | 5 +- 4 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md diff --git a/planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md b/planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md new file mode 100644 index 0000000..4273b62 --- /dev/null +++ b/planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md @@ -0,0 +1,52 @@ +--- +status: draft +date: 2026-06-16 +slug: httpware-0.12-get-with-response +supersedes: null +superseded_by: null +pr: null +outcome: null +--- + +# Change: Bump httpware to 0.12.0 and adopt get_with_response in pagination + +**Lane:** lightweight — net change is a dependency-floor bump plus a two-line +mechanical refactor at the two pagination call sites. Four files touched, but +`pyproject.toml` + `uv.lock` are dependency config and the code delta is a +straight 1:1 method swap with no behavior change. No public-API change. + +## Goal + +We pin `httpware[pydantic]>=0.8.2` (lock 0.8.2) while latest is 0.12.0 — every +intervening release is additive/no-break. Raise the floor to `>=0.12.0` and +adopt 0.12.0's `get_with_response`, which collapses the +`send_with_response(build_request("GET", ...))` pair used for Link-header +pagination into a single call. Picks up 0.11.0's security/correctness hardening +(URL secret redaction, RetryBudget fix) for free via the version move. + +## Approach + +`get_with_response(url, *, params=..., response_model=...) -> tuple[Response, T]` +is the ergonomic shortcut for "raw response + typed body in one call" — exactly +the pagination shape in both providers. Swap the two call sites; behavior is +identical (same request, same returned `(response, page)` tuple), so existing +pagination tests stay green. No `architecture/` contract moves — the providers' +external behavior is unchanged; the HTTP-client prose in `providers.md` does not +name `send_with_response`/`build_request`, so no doc edit is required. + +Deferred (not in this bundle): adopting `max_error_body_bytes` / +`ResponseTooLargeError` from 0.11.0 — that's a real behavior/config decision. + +## Files + +- `pyproject.toml` — `httpware[pydantic]>=0.8.2` → `>=0.12.0` +- `uv.lock` — relock via `uv lock` +- `semvertag/providers/gitlab.py` — `list_tags` call site → `get_with_response` +- `semvertag/providers/github.py` — `list_tags` call site → `get_with_response` + +## Verification + +- [x] `uv lock` resolves httpware to 0.12.0. +- [x] Refactor both call sites to `get_with_response`. +- [x] `just test` — 428 passed (existing pagination tests cover behavior). +- [x] `just lint-ci` — clean (ruff format, ruff check, ty all pass). diff --git a/pyproject.toml b/pyproject.toml index 3bc1837..9025d76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "pydantic-settings", "modern-di-typer", "httpx2", - "httpware[pydantic]>=0.8.2", + "httpware[pydantic]>=0.12.0", ] [project.scripts] diff --git a/semvertag/providers/github.py b/semvertag/providers/github.py index c04f7d0..7556108 100644 --- a/semvertag/providers/github.py +++ b/semvertag/providers/github.py @@ -88,10 +88,7 @@ def list_tags(self) -> list[Tag]: params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} for _ in range(_MAX_TAG_PAGES): try: - response, page = self.http.send_with_response( - self.http.build_request("GET", url, params=params), - response_model=_TagList, - ) + response, page = self.http.get_with_response(url, params=params, response_model=_TagList) except httpware.ClientError as exc: raise _errors.translate_github(exc, repo=self.repo) from exc tags.extend(Tag(name=item.name, commit_sha=item.commit.sha) for item in page.root) diff --git a/semvertag/providers/gitlab.py b/semvertag/providers/gitlab.py index 7e4194b..4d0b865 100644 --- a/semvertag/providers/gitlab.py +++ b/semvertag/providers/gitlab.py @@ -84,10 +84,7 @@ def list_tags(self) -> list[Tag]: params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} for _ in range(_MAX_TAG_PAGES): try: - response, page = self.http.send_with_response( - self.http.build_request("GET", url, params=params), - response_model=_TagList, - ) + response, page = self.http.get_with_response(url, params=params, response_model=_TagList) except httpware.ClientError as exc: raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in page.root) From 7668705513e2460a4387aa33a3d986405a7dc7e9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 13:31:15 +0300 Subject: [PATCH 2/7] docs(planning): add branch-prefix patch-on-non-merge change bundle Spec (design.md) and implementation plan (plan.md) for an opt-in branch-prefix flag that patch-bumps plain (non-merge) commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../design.md | 144 +++++++++++ .../plan.md | 228 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md create mode 100644 planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md diff --git a/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md b/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md new file mode 100644 index 0000000..7faab2d --- /dev/null +++ b/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md @@ -0,0 +1,144 @@ +--- +status: draft +date: 2026-06-16 +slug: branch-prefix-patch-on-non-merge +supersedes: null +superseded_by: null +pr: null +outcome: null +--- + +# Design: Opt-in patch bump for non-merge commits (branch-prefix) + +## Summary + +Add an opt-in `branch-prefix` config flag, `patch_on_non_merge_commit` (default +`False`), that makes the strategy return `Bump.PATCH` instead of `Bump.NONE` +when the HEAD commit on the default branch is a plain (non-merge) commit. Teams +that allow direct pushes to the default branch can then auto-tag a patch release +for each such push instead of silently skipping. Default-off preserves today's +behavior byte-for-byte. Scope: the `branch-prefix` strategy only, the +non-merge exit only. + +## Motivation + +`branch-prefix` exists to bump on merge commits: the merged branch name carries +the level (`feature/` → minor, `bugfix/`/`hotfix/` → patch). A commit pushed +directly to the default branch — not via an MR/PR — carries no merge mark, so +`BranchPrefixStrategy.decide` returns `Bump.NONE` and the run ends with status +`no_merge_commit` (`semvertag/strategies/branch_prefix.py:33-34`, +`semvertag/_use_case.py:52`). For a team that permits direct pushes, that means +real changes land on the default branch with no version movement at all. A +sensible default for "code changed but no merge told me how much" is the +smallest bump: patch. Making it opt-in lets those teams get an automatic patch +release per direct push without affecting anyone relying on merge-only bumping. + +## Non-goals + +- Not changing the default behavior: the flag defaults to `False`, so existing + users see no change. +- Not touching the `conventional-commits` strategy; a non-conforming commit + there still returns `Bump.NONE`. An analogous fallback can be a later change. +- Not changing the merge-with-unrecognized-prefix exit + (`branch_prefix.py:39`): a commit that *is* a merge but matches neither the + minor nor patch tables still returns `Bump.NONE`. The flag only governs the + "not a merge at all" case, matching the request's framing. +- No CLI flag: the existing `branch_prefix` config fields (`minor`, `patch`, + `merge_mark_texts`) are env/config-only; the new field follows suit. +- No use-case or output-messaging change (see Design §3). + +## Design + +### 1. Config field + +Add one field to `BranchPrefixConfig` +(`semvertag/strategies/branch_prefix.py`): + +```python +class BranchPrefixConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(frozen=True) + + minor: tuple[_NonEmptyStr, ...] = pydantic.Field(default=("feature/",), min_length=1) + patch: tuple[_NonEmptyStr, ...] = pydantic.Field(default=("bugfix/", "hotfix/"), min_length=1) + merge_mark_texts: tuple[_NonEmptyStr, ...] = pydantic.Field( + default=("Merge branch", "Merge pull request"), + min_length=1, + ) + patch_on_non_merge_commit: bool = False +``` + +It rides the existing `Settings.branch_prefix: BranchPrefixConfig` wiring +(`semvertag/_settings.py:98`) and the IoC builder +(`semvertag/ioc.py:68-69`), so it is settable via +`SEMVERTAG_BRANCH_PREFIX__PATCH_ON_NON_MERGE_COMMIT=true` with no new plumbing. +Standard pydantic-settings bool coercion applies (`true`/`1`/`yes` → `True`). + +### 2. `decide` change + +The non-merge exit gains a single ternary; nothing else in the method moves: + +```python +def decide(self, commit: Commit) -> Bump: + subject: typing.Final = subject_line(commit.message) + if not any(mark in subject for mark in self.config.merge_mark_texts): + return Bump.PATCH if self.config.patch_on_non_merge_commit else Bump.NONE + 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 +``` + +### 3. Why no use-case or messaging change + +`semvertag/_use_case.py:51-60` turns any non-`NONE` bump into a real tag and +only emits the strategy's `no_bump_status` / `no_bump_reason` on the `NONE` +path. When the flag fires, a non-merge commit yields `Bump.PATCH`, so the run +produces a normal `tagged` (or `dry_run`) result through the existing path. The +`no_merge_commit` status/reason ClassVars are still correct: they are read only +when `decide` returns `NONE`, which still happens for unrecognized merges and +for non-merge commits when the flag is off. No new status string is introduced. + +## Testing + +Unit tests in `tests/unit/test_branch_prefix_strategy.py`. The global pytest +config runs `--cov-branch` with `fail_under = 100`, so the new ternary's `True` +and `False` arms must both be exercised. + +- **Flag on, non-merge → PATCH.** A `BranchPrefixStrategy` built with + `BranchPrefixConfig(patch_on_non_merge_commit=True)` returns `Bump.PATCH` for + each existing `_NON_MERGE_CASES` subject (`feat: ...`, `docs: ...`, `""`, + lowercase `merge branch ...`). +- **Flag on does not disturb the merge paths.** With the flag on, a recognized + feature merge still returns `MINOR`, a bugfix/hotfix merge still `PATCH`, and + an unrecognized *merge* (`_UNRECOGNIZED_MERGE_CASES`) still `NONE` — proving + the flag governs only the non-merge exit, not line 39. +- **Flag off (default) unchanged.** The existing + `test_returns_none_when_message_is_not_a_merge_commit` already covers the + `False` arm; it stays green untouched. +- **Default value.** Assert `BranchPrefixConfig().patch_on_non_merge_commit is + False`. + +No config-validation case is needed (a bool cannot be empty). No +integration-level test is required: the `decide → PATCH → tag` wiring is already +covered by existing use-case tests that exercise a non-`NONE` bump. + +## Docs + +On merge (per the planning convention, the change promotes its conclusions into +the affected capability doc): + +- `architecture/strategies.md` — note the opt-in `patch_on_non_merge_commit` + under the `branch-prefix` section's step 1. +- mkdocs config/reference page for `branch-prefix` — document the new env var + and its default. + +## Risk + +Low. The behavior change is gated behind a default-`False` flag, so existing +installs are unaffected. The single new branch is fully covered by the +100%-branch gate. The main user-facing consideration is conceptual, not a code +risk: with the flag on, *any* direct push to the default branch (including a +docs typo fix) triggers a patch tag — which is exactly the intended semantics, +and is documented as such. No rollback concern: flipping the flag back to +`False` restores prior behavior immediately. diff --git a/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md b/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md new file mode 100644 index 0000000..31098ce --- /dev/null +++ b/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md @@ -0,0 +1,228 @@ +--- +status: draft +date: 2026-06-16 +slug: branch-prefix-patch-on-non-merge +spec: branch-prefix-patch-on-non-merge +pr: null +--- + +# branch-prefix-patch-on-non-merge — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an opt-in `branch-prefix` config flag, +`patch_on_non_merge_commit` (default `False`), that makes a plain (non-merge) +HEAD commit bump patch instead of producing no bump. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/branch-prefix-patch-on-non-merge` + +**Commit strategy:** Per-task commits (two tasks: code, then docs). + +**Context for an engineer new to this codebase:** + +- The strategy lives in `semvertag/strategies/branch_prefix.py`. `decide(commit)` + returns a `Bump` enum (`NONE | PATCH | MINOR | MAJOR` from + `semvertag/_types.py`). The config is a frozen pydantic model + `BranchPrefixConfig` in the same file. +- `Commit` is a frozen dataclass with `sha` and `message`. +- Tests use `pytest` with `--cov-branch` and `fail_under = 100` (see + `pyproject.toml [tool.pytest.ini_options]` / `[tool.coverage.report]`). Every + branch must be covered or the suite fails. The new ternary has a `True` arm + (new tests) and a `False` arm (existing default-off tests). +- Run tests with `just test`; lint with `just lint-ci`. `just test` uses + `--no-sync`, so if dependencies changed run `uv sync` first — not needed here. +- The config field needs no IoC/settings wiring: `Settings.branch_prefix` + (`semvertag/_settings.py:98`) already carries `BranchPrefixConfig` whole, so a + new field is automatically settable via + `SEMVERTAG_BRANCH_PREFIX__PATCH_ON_NON_MERGE_COMMIT`. + +--- + +### Task 1: Add `patch_on_non_merge_commit` flag and the non-merge fallback + +**Files:** +- Modify: `semvertag/strategies/branch_prefix.py` +- Test: `tests/unit/test_branch_prefix_strategy.py` + +Add the config field and the single `decide` ternary, test-first. + +- [ ] **Step 1: Write the failing tests** + + Append to `tests/unit/test_branch_prefix_strategy.py` (the helpers + `_commit`, `_NON_MERGE_CASES`, `_UNRECOGNIZED_MERGE_CASES`, and the imports + `Bump`, `BranchPrefixConfig`, `BranchPrefixStrategy` already exist at the top + of the file — reuse them, do not redefine): + + ```python + _FALLBACK_STRATEGY: typing.Final = BranchPrefixStrategy( + config=BranchPrefixConfig(patch_on_non_merge_commit=True), + ) + + + @pytest.mark.parametrize("message", [message for message, _ in _NON_MERGE_CASES]) + def test_returns_patch_for_non_merge_commit_when_flag_enabled(message: str) -> None: + assert _FALLBACK_STRATEGY.decide(_commit(message)) is Bump.PATCH + + + def test_flag_leaves_recognized_merge_paths_unchanged() -> None: + assert _FALLBACK_STRATEGY.decide(_commit("Merge branch 'feature/x' into main")) is Bump.MINOR + assert _FALLBACK_STRATEGY.decide(_commit("Merge branch 'bugfix/y' into main")) is Bump.PATCH + + + @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_patch_on_non_merge_commit_defaults_to_false() -> None: + assert BranchPrefixConfig().patch_on_non_merge_commit is False + ``` + +- [ ] **Step 2: Run the new tests, verify they fail** + + Run: `just test tests/unit/test_branch_prefix_strategy.py -p no:randomly --override-ini="addopts=" -q` + + Expected: FAIL. `BranchPrefixConfig` uses `ConfigDict(frozen=True)` without + `extra="forbid"`, so pydantic v2 silently *ignores* the unknown + `patch_on_non_merge_commit=True` kwarg — construction does not raise. The + failures are therefore behavioral: `test_returns_patch_for_non_merge_commit_when_flag_enabled` + fails with `AssertionError` (`decide` still returns `Bump.NONE`), and + `test_patch_on_non_merge_commit_defaults_to_false` fails with `AttributeError` + (no such attribute). + +- [ ] **Step 3: Add the config field** + + In `semvertag/strategies/branch_prefix.py`, add the field to + `BranchPrefixConfig` (after `merge_mark_texts`): + + ```python + merge_mark_texts: tuple[_NonEmptyStr, ...] = pydantic.Field( + default=("Merge branch", "Merge pull request"), + min_length=1, + ) + patch_on_non_merge_commit: bool = False + ``` + +- [ ] **Step 4: Add the fallback to `decide`** + + Replace the non-merge exit (currently `return Bump.NONE` under the + `if not any(mark in subject ...)` guard) with: + + ```python + def decide(self, commit: Commit) -> Bump: + subject: typing.Final = subject_line(commit.message) + if not any(mark in subject for mark in self.config.merge_mark_texts): + return Bump.PATCH if self.config.patch_on_non_merge_commit else Bump.NONE + 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 + ``` + + Leave the trailing `return Bump.NONE` (the unrecognized-merge exit) untouched. + +- [ ] **Step 5: Run the strategy tests, verify they pass** + + Run: `just test tests/unit/test_branch_prefix_strategy.py -p no:randomly --override-ini="addopts=" -q` + + Expected: PASS — all existing + 4 new test functions green. + +- [ ] **Step 6: Run the full gated suite and lint** + + Run: `just test` + Expected: PASS — full suite green at 100% branch coverage (the new ternary's + `True` arm is covered by the new tests, the `False` arm by the existing + default-off tests). + + Run: `just lint-ci` + Expected: PASS — eof-fixer, ruff format, ruff check, ty all clean. + +- [ ] **Step 7: Commit** + + ```bash + git add semvertag/strategies/branch_prefix.py tests/unit/test_branch_prefix_strategy.py + git commit -m "strategies: add opt-in patch bump for non-merge commits + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +### Task 2: Document the flag (architecture + user docs) + +**Files:** +- Modify: `architecture/strategies.md` +- Modify: `docs/strategies/branch-prefix.md` + +Promote the new behavior into the capability doc and the user-facing docs. + +- [ ] **Step 1: Update `architecture/strategies.md`** + + In the `## branch-prefix` section, extend step 1 (the `Bump.NONE` rule for a + subject with no merge mark) to note the opt-in. Add after the existing step-1 + sentence: + + > When `config.patch_on_non_merge_commit` is `True` (default `False`), this + > non-merge case returns `Bump.PATCH` instead of `Bump.NONE`, so a direct push + > to the default branch auto-tags a patch release. The flag governs only this + > exit — a merge commit with an unrecognized prefix (step 4) still returns + > `Bump.NONE`. + +- [ ] **Step 2: Update `docs/strategies/branch-prefix.md` — detection section** + + In `## Merge-commit detection`, change the bullet: + + ```markdown + - Direct pushes to the default branch → bump = none. + ``` + + to: + + ```markdown + - Direct pushes to the default branch → bump = none, unless + `patch_on_non_merge_commit` is enabled (see below), in which case + bump = patch. + ``` + +- [ ] **Step 3: Update `docs/strategies/branch-prefix.md` — config list** + + In `## Customizing the prefixes`, add a fourth bullet after `merge_mark_texts`: + + ```markdown + - `patch_on_non_merge_commit` — when `true`, a plain (non-merge) commit on + the default branch bumps patch instead of producing no bump (default + `false`). Set via `SEMVERTAG_BRANCH_PREFIX__PATCH_ON_NON_MERGE_COMMIT=true`. + Affects only the non-merge case; a merge commit with an unrecognized prefix + still produces no bump. + ``` + +- [ ] **Step 4: Verify the docs build** + + Run: `mkdocs build --strict` + Expected: PASS — no broken links or warnings. + +- [ ] **Step 5: Commit** + + ```bash + git add architecture/strategies.md docs/strategies/branch-prefix.md + git commit -m "docs: document branch-prefix patch_on_non_merge_commit flag + + Co-Authored-By: Claude Opus 4.8 (1M context) " + ``` + +--- + +## Notes for finishing + +- This bundle is on lane **full** (`design.md` + `plan.md`). On merge: move the + bundle to `planning/changes/archive/` with `status: shipped`, `pr:`, and + `outcome:` filled, and confirm the `architecture/strategies.md` edit from + Task 2 landed (that hand-edit is what keeps `architecture/` true). +- Release tags are bare semver — not relevant to this change, but the flag, once + enabled by a consumer, will cause their next direct push to emit a patch tag. From e34839b5e184b240de16a8c3643e39b71cdfae25 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 13:33:25 +0300 Subject: [PATCH 3/7] strategies: add opt-in patch bump for non-merge commits Co-Authored-By: Claude Opus 4.8 (1M context) --- semvertag/strategies/branch_prefix.py | 5 +++- tests/unit/test_branch_prefix_strategy.py | 30 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/semvertag/strategies/branch_prefix.py b/semvertag/strategies/branch_prefix.py index 5266e80..bf443b7 100644 --- a/semvertag/strategies/branch_prefix.py +++ b/semvertag/strategies/branch_prefix.py @@ -19,6 +19,7 @@ class BranchPrefixConfig(pydantic.BaseModel): default=("Merge branch", "Merge pull request"), min_length=1, ) + patch_on_non_merge_commit: bool = False @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) @@ -31,7 +32,9 @@ class BranchPrefixStrategy: def decide(self, commit: Commit) -> Bump: subject: typing.Final = subject_line(commit.message) if not any(mark in subject for mark in self.config.merge_mark_texts): - return Bump.NONE + # 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 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): diff --git a/tests/unit/test_branch_prefix_strategy.py b/tests/unit/test_branch_prefix_strategy.py index 8d40a86..d43356d 100644 --- a/tests/unit/test_branch_prefix_strategy.py +++ b/tests/unit/test_branch_prefix_strategy.py @@ -143,3 +143,33 @@ def test_ignores_body_prefixes_when_subject_is_an_unrecognized_merge() -> None: def test_returns_minor_when_subject_is_a_feature_merge_with_trailing_body() -> None: message: typing.Final = "Merge branch 'feature/new-thing' into main\n\nReviewed-by: bob" assert DEFAULT_STRATEGY.decide(_commit(message)) is Bump.MINOR + + +_FALLBACK_STRATEGY: typing.Final = BranchPrefixStrategy( + config=BranchPrefixConfig(patch_on_non_merge_commit=True), +) + + +@pytest.mark.parametrize("message", [message for message, _ in _NON_MERGE_CASES]) +def test_returns_patch_for_non_merge_commit_when_flag_enabled(message: str) -> None: + assert _FALLBACK_STRATEGY.decide(_commit(message)) is Bump.PATCH + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("Merge branch 'feature/x' into main", Bump.MINOR), + ("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 + + +@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_patch_on_non_merge_commit_defaults_to_false() -> None: + assert BranchPrefixConfig().patch_on_non_merge_commit is False From 3e2c6b8fbc8e5aa66c372d3b184d9b88baf90526 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 13:39:29 +0300 Subject: [PATCH 4/7] docs: document branch-prefix patch_on_non_merge_commit flag Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/strategies.md | 5 +++++ docs/strategies/branch-prefix.md | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/architecture/strategies.md b/architecture/strategies.md index 3824fbe..022bb6c 100644 --- a/architecture/strategies.md +++ b/architecture/strategies.md @@ -41,6 +41,11 @@ of the message (via `subject_line`, below) and applies, in order: request"`, so an ordinary GitHub PR merge-commit subject (`Merge pull request #N from owner/feature/...`) and a GitLab merge-commit subject both qualify under the defaults; a plain non-merge commit does not. + When `config.patch_on_non_merge_commit` is `True` (default `False`), this + non-merge case returns `Bump.PATCH` instead of `Bump.NONE`, so a direct push + to the default branch auto-tags a patch release. The flag governs only this + exit — a merge commit with an unrecognized prefix (step 4) still returns + `Bump.NONE`. 2. If any string in `config.minor` appears in the subject, return `Bump.MINOR`. Default: `("feature/",)`. 3. If any string in `config.patch` appears, return `Bump.PATCH`. Default: diff --git a/docs/strategies/branch-prefix.md b/docs/strategies/branch-prefix.md index 450b790..5d9cf66 100644 --- a/docs/strategies/branch-prefix.md +++ b/docs/strategies/branch-prefix.md @@ -30,7 +30,9 @@ means: - 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. +- Direct pushes to the default branch → bump = none, unless + `patch_on_non_merge_commit` is enabled (see below), in which case + bump = patch. The `merge_mark_texts` tuple is configurable (defaults to `("Merge branch", "Merge pull request")`); adapt it for non-default @@ -46,6 +48,11 @@ The strategy reads its prefixes from the application's settings layer: `("bugfix/", "hotfix/")`). - `merge_mark_texts` — tuple of substrings that mark a subject as a merge commit (default `("Merge branch", "Merge pull request")`). +- `patch_on_non_merge_commit` — when `true`, a plain (non-merge) commit on + the default branch bumps patch instead of producing no bump (default + `false`). Set via `SEMVERTAG_BRANCH_PREFIX__PATCH_ON_NON_MERGE_COMMIT=true`. + Affects only the non-merge case; a merge commit with an unrecognized prefix + still produces no bump. These are set via the same pydantic-settings env-var mechanism used for tokens / endpoints — see the provider docs for the variable From a1033724c37aa2f4124a41a90cbe51b623babbb5 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 13:45:15 +0300 Subject: [PATCH 5/7] docs: correct stale Justfile recipe references in CLAUDE.md Drop the nonexistent test-branch / test-branch-strategies / test-cc-strategies recipes; document that just test already runs --cov-branch with a project-wide fail_under=100 gate, and reference just docs-build for the strict docs gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index effb1ec..9ccde37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,11 +66,13 @@ belong to the retired BMad workflow. See `Justfile` for the canonical commands. Quick reference: - `just lint-ci` — eof-fixer, ruff format check, ruff check, ty check -- `just test` — pytest with coverage -- `just test-branch` — pytest with branch coverage -- `just test-branch-strategies` / `just test-cc-strategies` - — 100% branch coverage gates on specific modules -- `mkdocs build --strict` — docs build gate + (check-only; `just lint` is the autofixing variant) +- `just test` — pytest. The `addopts` in `pyproject.toml` add `--cov-branch` + with a project-wide `fail_under = 100` gate, so every branch (strategy + modules included) must be covered. Pass args through, e.g. + `just test tests/unit/test_branch_prefix_strategy.py -q`. +- `just docs-build` — strict mkdocs build (`mkdocs build --strict`), the docs + gate. ## What the codebase ships From fa8bfd39a87faa20ebdc351241239bb8c097e18f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 13:49:42 +0300 Subject: [PATCH 6/7] docs(planning): archive the two 2026-06-16 change bundles (#24) Mark both bundles shipped (status: shipped, pr: 24, outcome filled), move them from changes/active/ to changes/archive/, and move their Index lines from Active to Archived. architecture/ conclusions were already promoted in their respective commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- planning/README.md | 6 ++++++ .../change.md | 8 +++++--- .../design.md | 8 +++++--- .../plan.md | 4 ++-- 4 files changed, 18 insertions(+), 8 deletions(-) rename planning/changes/{active => archive}/2026-06-16.01-httpware-0.12-get-with-response/change.md (91%) rename planning/changes/{active => archive}/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md (96%) rename planning/changes/{active => archive}/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md (99%) diff --git a/planning/README.md b/planning/README.md index 32f015b..197f8f8 100644 --- a/planning/README.md +++ b/planning/README.md @@ -74,6 +74,12 @@ _None._ ### Archived (shipped) +- **[branch-prefix-patch-on-non-merge](changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md)** + (#24, 2026-06-16) — Opt-in `patch_on_non_merge_commit` flag: a non-merge HEAD + commit bumps patch instead of nothing. +- **[httpware-0.12-get-with-response](changes/archive/2026-06-16.01-httpware-0.12-get-with-response/change.md)** + (#24, 2026-06-16) — Bump httpware to 0.12.0; adopt `get_with_response` at the + pagination call sites. - **[portable-planning-convention](changes/archive/2026-06-13.01-portable-planning-convention/design.md)** (#21, 2026-06-13) — Adopt the portable two-axis convention: `architecture/` truth home + `changes/` bundles, migrate the 15 spec/plan pairs, fresh Index. diff --git a/planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md b/planning/changes/archive/2026-06-16.01-httpware-0.12-get-with-response/change.md similarity index 91% rename from planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md rename to planning/changes/archive/2026-06-16.01-httpware-0.12-get-with-response/change.md index 4273b62..7a930ce 100644 --- a/planning/changes/active/2026-06-16.01-httpware-0.12-get-with-response/change.md +++ b/planning/changes/archive/2026-06-16.01-httpware-0.12-get-with-response/change.md @@ -1,11 +1,13 @@ --- -status: draft +status: shipped date: 2026-06-16 slug: httpware-0.12-get-with-response supersedes: null superseded_by: null -pr: null -outcome: null +pr: 24 +outcome: Shipped. httpware floor raised to >=0.12.0; both Link-header pagination + call sites now use get_with_response. No behavior change; no architecture + contract moved. --- # Change: Bump httpware to 0.12.0 and adopt get_with_response in pagination diff --git a/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md b/planning/changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md similarity index 96% rename from planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md rename to planning/changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md index 7faab2d..f399bc3 100644 --- a/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md +++ b/planning/changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md @@ -1,11 +1,13 @@ --- -status: draft +status: shipped date: 2026-06-16 slug: branch-prefix-patch-on-non-merge supersedes: null superseded_by: null -pr: null -outcome: null +pr: 24 +outcome: Shipped. Opt-in patch_on_non_merge_commit flag (default False) added to + branch-prefix; a non-merge HEAD commit bumps patch when enabled. Conclusions + promoted into architecture/strategies.md. --- # Design: Opt-in patch bump for non-merge commits (branch-prefix) diff --git a/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md b/planning/changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md similarity index 99% rename from planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md rename to planning/changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md index 31098ce..3aaa2fd 100644 --- a/planning/changes/active/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md +++ b/planning/changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/plan.md @@ -1,9 +1,9 @@ --- -status: draft +status: shipped date: 2026-06-16 slug: branch-prefix-patch-on-non-merge spec: branch-prefix-patch-on-non-merge -pr: null +pr: 24 --- # branch-prefix-patch-on-non-merge — implementation plan From e68959d8b6cef9c1c625bd8f06e1ca2f7b4e5b4c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 16 Jun 2026 14:52:39 +0300 Subject: [PATCH 7/7] docs(planning): register deferred follow-ups from PR #24 review Open the deferred register with two real-but-unscheduled items surfaced in the PR #24 review, each with a revisit trigger: conventional-commits parity for the non-merge/non-conforming fallback flag, and httpware max_error_body_bytes adoption. Co-Authored-By: Claude Opus 4.8 (1M context) --- planning/deferred.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/planning/deferred.md b/planning/deferred.md index f7f1770..bb59ee8 100644 --- a/planning/deferred.md +++ b/planning/deferred.md @@ -8,4 +8,30 @@ bundle in [`changes/active/`](changes/active/); see [CLAUDE.md](../CLAUDE.md#wor ## Open -_None._ +### conventional-commits non-conforming fallback flag + +A `conventional-commits` flag analogous to branch-prefix's +`patch_on_non_merge_commit` (e.g. `patch_on_non_conforming_commit`), so a commit +whose subject is not a Conventional Commits header bumps patch instead of +returning `Bump.NONE`. + +- **Deferred because:** the branch-prefix flag shipped alone + ([branch-prefix-patch-on-non-merge](changes/archive/2026-06-16.02-branch-prefix-patch-on-non-merge/design.md), + #24) to keep that change focused; the conventional-commits side was an + explicit non-goal. The two strategies are now asymmetric. +- **Trigger:** a user requests the parallel behavior; or we add any other new + config knob to `conventional-commits` (fold it in then); or we write a + strategy-comparison doc where the asymmetry would mislead. + +### httpware bounded-error-body adoption + +Adopt httpware 0.11.0's opt-in `max_error_body_bytes` cap (raises +`ResponseTooLargeError`) when building the provider clients, to bound the bytes +read from a 4xx/5xx error body. + +- **Deferred because:** the 0.12.0 bump + ([httpware-0.12-get-with-response](changes/archive/2026-06-16.01-httpware-0.12-get-with-response/change.md), + #24) skipped it — picking a cap value is a real config decision, not a + mechanical bump, and the default (`None`, unbounded) matches prior behavior. +- **Trigger:** when we next harden provider error handling, or if a forge is + observed returning large error bodies that bloat logs / memory in practice.