Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions architecture/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion docs/strategies/branch-prefix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions planning/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
status: shipped
date: 2026-06-16
slug: httpware-0.12-get-with-response
supersedes: null
superseded_by: 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

**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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
---
status: shipped
date: 2026-06-16
slug: branch-prefix-patch-on-non-merge
supersedes: null
superseded_by: 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)

## 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.
Loading