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
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ The provider stays a flat dataclass with no base class. Reasons:
```python
T = TypeVar("T", bound=pydantic.BaseModel)


class HttpClient:
client: httpx2.Client
auth_headers: Callable[[], dict[str, str]]
Expand Down
13 changes: 10 additions & 3 deletions planning/changes/2026-05-31.05-ioc-idiomatic-modern-di-typer.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,13 @@ modern_di_typer.setup_di(MAIN_APP, ioc.container)
@MAIN_APP.callback()
def _root(
ctx: typer.Context,
project_id: ..., strategy: ..., provider: ..., token: ...,
default_branch: ..., gitlab_endpoint: ..., request_timeout: ...,
project_id: ...,
strategy: ...,
provider: ...,
token: ...,
default_branch: ...,
gitlab_endpoint: ...,
request_timeout: ...,
_version: ...,
) -> None:
try:
Expand Down Expand Up @@ -112,7 +117,9 @@ aren't known here).
def _tag(
use_case: typing.Annotated[SemvertagUseCase, modern_di_typer.FromDI(SemvertagUseCase)],
quiet: typing.Annotated[bool, typer.Option("--quiet", help="Suppress progress narrative.")] = False,
json_flag: typing.Annotated[bool, typer.Option("--json", help="Emit JSON envelope instead of human-readable output.")] = False,
json_flag: typing.Annotated[
bool, typer.Option("--json", help="Emit JSON envelope instead of human-readable output.")
] = False,
) -> None:
output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet)
try:
Expand Down
8 changes: 4 additions & 4 deletions planning/changes/2026-05-31.07-strategy-no-bump-cleanup.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,17 @@ class ConventionalCommitsStrategy:
Replace:

```python
status=_status_for_no_bump(self.strategy.name),
status = (_status_for_no_bump(self.strategy.name),)
# ...
reason=_reason_for_no_bump(self.strategy.name),
reason = (_reason_for_no_bump(self.strategy.name),)
```

With:

```python
status=self.strategy.no_bump_status,
status = (self.strategy.no_bump_status,)
# ...
reason=self.strategy.no_bump_reason,
reason = (self.strategy.no_bump_reason,)
```

Delete:
Expand Down
4 changes: 2 additions & 2 deletions planning/changes/2026-05-31.08-v0-1-0-release-prep.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,13 +157,13 @@ Drop the `--provider` typer Option (currently around lines 93-96 in
provider: typing.Annotated[
str | None,
typer.Option("--provider", help="Provider: gitlab | github | bitbucket."),
] = None,
] = (None,)
```

Drop the corresponding line in `_collect_overrides` call (~line 124):

```python
provider=provider,
provider = (provider,)
```

Drop the `provider` keyword from `_collect_overrides`'s signature
Expand Down
26 changes: 12 additions & 14 deletions planning/changes/2026-06-07.01-httpware-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ def translate_gitlab(exc: httpware.StatusError, *, project_id: int) -> Exception
"Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab."
)
if isinstance(exc, httpware.NotFoundError):
return ConfigError(
f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id."
)
return ConfigError(f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id.")
if isinstance(exc, httpware.UnprocessableEntityError):
return ConfigError(
"Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists."
Expand All @@ -106,8 +104,7 @@ def translate_gitlab(exc: httpware.StatusError, *, project_id: int) -> Exception
return ProviderAPIError("GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later.")
if isinstance(exc, httpware.ServerStatusError):
return ProviderAPIError(
f"GitLab API failure: {status}. Retries exhausted after 3 attempts. "
"Try again or check GitLab status."
f"GitLab API failure: {status}. Retries exhausted after 3 attempts. Try again or check GitLab status."
)
return ProviderAPIError(f"Unexpected GitLab response: {status}. Please file an issue.")

Expand All @@ -117,8 +114,7 @@ def translate_create_tag_bad_request(exc: httpware.BadRequestError, *, tag_name:
body = exc.response.text
if _TAG_EXISTS_FRAGMENT in body.lower():
return ConfigError(
f"Tag already exists: '{tag_name}'. "
"The tag was created by a concurrent run or previous invocation."
f"Tag already exists: '{tag_name}'. The tag was created by a concurrent run or previous invocation."
)
return ConfigError("Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists.")
```
Expand Down Expand Up @@ -162,8 +158,7 @@ class GitLabProvider:
raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc
if not project.default_branch:
raise ConfigError(
"Default branch missing from GitLab response. "
"Verify the project has a default branch configured."
"Default branch missing from GitLab response. Verify the project has a default branch configured."
)
return project.default_branch

Expand All @@ -175,11 +170,13 @@ class GitLabProvider:

def create_tag(self, name: str, commit_sha: str) -> None:
try:
self.http.send(self.http.build_request(
"POST",
f"{_API_PREFIX}/{self.project_id}/repository/tags",
json={"tag_name": name, "ref": commit_sha},
))
self.http.send(
self.http.build_request(
"POST",
f"{_API_PREFIX}/{self.project_id}/repository/tags",
json={"tag_name": name, "ref": commit_sha},
)
)
except httpware.BadRequestError as exc:
raise _errors.translate_create_tag_bad_request(exc, tag_name=name) from exc
except httpware.StatusError as exc:
Expand All @@ -195,6 +192,7 @@ The `_url(self, path)` helper (`f"{self.config.endpoint.rstrip('/')}{path}"`) de

import httpware


def _build_gitlab_provider(settings: Settings) -> GitLabProvider:
if settings.project_id is None:
raise ConfigError("Project id missing. Set CI_PROJECT_ID or pass --project-id.")
Expand Down
4 changes: 1 addition & 3 deletions planning/changes/2026-06-08.01-httpware-decoder-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,7 @@ The pagination helpers (`_next_page_url`, `_parse_rel_values`, `_same_origin`, `

```python
if isinstance(exc, httpware.DecodeError):
return ProviderAPIError(
f"GitLab {exc.model.__name__} response could not be decoded: {exc.original}"
)
return ProviderAPIError(f"GitLab {exc.model.__name__} response could not be decoded: {exc.original}")
```

Branch ordering: `DecodeError` should be checked **before** the generic `ClientError` fallback at the end of `_translate_gitlab_transport`, since `DecodeError` is a `ClientError` subclass and the fallback would otherwise swallow it with the less informative `f"GitLab request failed: {type(exc).__name__}"` message.
Expand Down
72 changes: 29 additions & 43 deletions planning/changes/2026-06-08.02-github-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ class _CommitAuthor(pydantic.BaseModel):

class _CommitItem(pydantic.BaseModel):
sha: str
commit: _CommitAuthor # GitHub nests message under .commit: {sha, commit: {message, author, ...}}
commit: _CommitAuthor # GitHub nests message under .commit: {sha, commit: {message, author, ...}}


class _TagCommit(pydantic.BaseModel):
Expand All @@ -170,7 +170,7 @@ class _TagCommit(pydantic.BaseModel):

class _TagItem(pydantic.BaseModel):
name: str
commit: _TagCommit # {name, commit: {sha, url}}
commit: _TagCommit # {name, commit: {sha, url}}


class _CommitList(pydantic.RootModel[list[_CommitItem]]):
Expand All @@ -185,7 +185,7 @@ class _TagList(pydantic.RootModel[list[_TagItem]]):
class GitHubProvider:
name: typing.ClassVar[str] = "github"
config: GitHubConfig
repo: str # "OWNER/REPO"
repo: str # "OWNER/REPO"
http: httpware.Client

def get_default_branch(self) -> str:
Expand Down Expand Up @@ -241,11 +241,13 @@ class GitHubProvider:

def create_tag(self, name: str, commit_sha: str) -> None:
try:
self.http.send(self.http.build_request(
"POST",
f"{_API_PREFIX}/{self.repo}/git/refs",
json={"ref": f"refs/tags/{name}", "sha": commit_sha},
))
self.http.send(
self.http.build_request(
"POST",
f"{_API_PREFIX}/{self.repo}/git/refs",
json={"ref": f"refs/tags/{name}", "sha": commit_sha},
)
)
except httpware.UnprocessableEntityError as exc:
raise _errors.translate_create_tag_github_unprocessable(exc, tag_name=name) from exc
except httpware.ClientError as exc:
Expand Down Expand Up @@ -275,17 +277,11 @@ Three changes in `semvertag/providers/_errors.py`:
```python
def _translate_transport(exc: httpware.ClientError, *, provider_label: str) -> Exception:
if isinstance(exc, httpware.DecodeError):
return ProviderAPIError(
f"{provider_label} {exc.model.__name__} response could not be decoded: {exc.original}"
)
return ProviderAPIError(f"{provider_label} {exc.model.__name__} response could not be decoded: {exc.original}")
if isinstance(exc, httpware.TimeoutError):
return ProviderAPIError(
f"{provider_label} request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT."
)
return ProviderAPIError(f"{provider_label} request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.")
if isinstance(exc, httpware.RetryBudgetExhaustedError):
return ProviderAPIError(
f"{provider_label} retries exhausted after {exc.attempts} attempts. Try again later."
)
return ProviderAPIError(f"{provider_label} retries exhausted after {exc.attempts} attempts. Try again later.")
if isinstance(exc, httpware.NetworkError):
return ProviderAPIError(f"{provider_label} unreachable. Check network connectivity.")
return ProviderAPIError(f"{provider_label} request failed: {type(exc).__name__}")
Expand All @@ -304,15 +300,11 @@ Three changes in `semvertag/providers/_errors.py`:
"(or 'public_repo' / 'repo' for classic PATs)."
)
if isinstance(exc, httpware.NotFoundError):
return ConfigError(
f"GitHub repo not found: repo='{repo}'. Verify GITHUB_REPOSITORY or --repo OWNER/REPO."
)
return ConfigError(f"GitHub repo not found: repo='{repo}'. Verify GITHUB_REPOSITORY or --repo OWNER/REPO.")
if isinstance(exc, httpware.UnprocessableEntityError):
# Generic 422. The create_tag-specific 422 ("already_exists") is handled separately
# by translate_create_tag_github_unprocessable below.
return ConfigError(
"Request rejected by GitHub: 422. Check ref format and that the referenced sha exists."
)
return ConfigError("Request rejected by GitHub: 422. Check ref format and that the referenced sha exists.")
if isinstance(exc, httpware.RateLimitedError):
return ProviderAPIError(
"GitHub rate limit: 429. Retries exhausted after 3 attempts; "
Expand All @@ -324,27 +316,20 @@ Three changes in `semvertag/providers/_errors.py`:
"Retries exhausted after 3 attempts. Try again or check https://www.githubstatus.com."
)
if isinstance(exc, httpware.StatusError):
return ProviderAPIError(
f"Unexpected GitHub response: {exc.response.status_code}. Please file an issue."
)
return ProviderAPIError(f"Unexpected GitHub response: {exc.response.status_code}. Please file an issue.")
return _translate_transport(exc, provider_label="GitHub")
```

3. **Add `translate_create_tag_github_unprocessable(exc, *, tag_name: str)`** for the 422-already-exists special case. Inspects body for the structured `"already_exists"` code (durable contract) OR the human-readable `"already exists"` substring (safety net):

```python
def translate_create_tag_github_unprocessable(
exc: httpware.UnprocessableEntityError, *, tag_name: str
) -> Exception:
def translate_create_tag_github_unprocessable(exc: httpware.UnprocessableEntityError, *, tag_name: str) -> Exception:
body = exc.response.text
if "already_exists" in body or "already exists" in body.lower():
return ConfigError(
f"Tag already exists: '{tag_name}'. "
"The tag was created by a concurrent run or previous invocation."
f"Tag already exists: '{tag_name}'. The tag was created by a concurrent run or previous invocation."
)
return ConfigError(
"Request rejected by GitHub: 422. Check ref format and that the referenced sha exists."
)
return ConfigError("Request rejected by GitHub: 422. Check ref format and that the referenced sha exists.")
```

`_translate_gitlab_transport` deletes; `translate_gitlab` is updated to call `_translate_transport(exc, provider_label="GitLab")` for its transport branches. No behavior change for the GitLab side — messages remain bit-identical because the parameterization only varies the label string.
Expand Down Expand Up @@ -469,18 +454,21 @@ def _close_github_provider(provider: GitHubProvider) -> None:
class ProvidersGroup(modern_di.Group):
gitlab_client = providers.Factory(scope=Scope.APP, creator=_build_gitlab_client)
gitlab_provider = providers.Factory(
scope=Scope.APP, creator=_build_gitlab_provider,
scope=Scope.APP,
creator=_build_gitlab_provider,
kwargs={"client": gitlab_client},
cache_settings=providers.CacheSettings(finalizer=_close_gitlab_provider),
)
github_client = providers.Factory(scope=Scope.APP, creator=_build_github_client)
github_provider = providers.Factory(
scope=Scope.APP, creator=_build_github_provider,
scope=Scope.APP,
creator=_build_github_provider,
kwargs={"client": github_client},
cache_settings=providers.CacheSettings(finalizer=_close_github_provider),
)
current_provider = providers.Factory(
scope=Scope.APP, creator=_build_current_provider,
scope=Scope.APP,
creator=_build_current_provider,
kwargs={"gitlab_provider": gitlab_provider, "github_provider": github_provider},
)
```
Expand All @@ -499,15 +487,15 @@ Three new flags + provider-aware `--token` routing:
provider: typing.Annotated[
str | None,
typer.Option("--provider", help="Provider: 'github' or 'gitlab' (default: auto-detect from CI env)."),
] = None,
] = (None,)
repo: typing.Annotated[
str | None,
typer.Option("--repo", help="GitHub repo as OWNER/REPO (or set GITHUB_REPOSITORY)."),
] = None,
] = (None,)
github_endpoint: typing.Annotated[
str | None,
typer.Option("--github-endpoint", help="GitHub API endpoint URL (for GitHub Enterprise)."),
] = None,
] = (None,)
```

`--project-id`, `--gitlab-endpoint`, `--strategy`, `--default-branch`, `--request-timeout`, `--token` stay as-is in signature. The `--project-id` and `--gitlab-endpoint` help text doesn't need adjusting — they're GitLab-specific and that's now explicit in the broader CLI surface.
Expand All @@ -517,9 +505,7 @@ github_endpoint: typing.Annotated[
```python
# In _main_callback, after the first apply_cli_overlay call:
if token is not None:
settings = apply_cli_overlay(
settings, {f"{settings.provider}.token": pydantic.SecretStr(token)}
)
settings = apply_cli_overlay(settings, {f"{settings.provider}.token": pydantic.SecretStr(token)})
```

Two-pass overlay is mildly awkward but it's the cleanest way to handle "the token belongs to whichever provider we end up using." Alternative (force users to pass `--gitlab-token` / `--github-token` explicitly) is uglier from a UX standpoint — `--token` as the catch-all is what the existing CLI promised.
Expand Down
40 changes: 30 additions & 10 deletions planning/changes/2026-06-09.02-dry-run-flag.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,36 +86,56 @@ def __call__(self, *, output: Output, dry_run: bool = False) -> RunResult:

if latest_semver_tag is None:
return self._emit(
output=output, bump=Bump.NONE, status="no_tags",
tag=None, commit=commit.sha, reason=_NO_TAGS_REASON,
output=output,
bump=Bump.NONE,
status="no_tags",
tag=None,
commit=commit.sha,
reason=_NO_TAGS_REASON,
)

if latest_semver_tag.commit_sha == commit.sha:
return self._emit(
output=output, bump=Bump.NONE, status="already_tagged",
tag=latest_semver_tag.name, commit=commit.sha, reason=_ALREADY_TAGGED_REASON,
output=output,
bump=Bump.NONE,
status="already_tagged",
tag=latest_semver_tag.name,
commit=commit.sha,
reason=_ALREADY_TAGGED_REASON,
)

output.progress("Computing bump...")
bump: typing.Final = self.strategy.decide(commit)
if bump is Bump.NONE:
return self._emit(
output=output, bump=Bump.NONE, status=self.strategy.no_bump_status,
tag=None, commit=commit.sha, reason=self.strategy.no_bump_reason,
output=output,
bump=Bump.NONE,
status=self.strategy.no_bump_status,
tag=None,
commit=commit.sha,
reason=self.strategy.no_bump_reason,
)

new_version: typing.Final = _compute_new_version(latest_semver_tag, bump)
if dry_run:
return self._emit(
output=output, bump=bump, status="dry_run",
tag=new_version, commit=commit.sha, reason=None,
output=output,
bump=bump,
status="dry_run",
tag=new_version,
commit=commit.sha,
reason=None,
)

output.progress(f"Creating tag {new_version}...")
self.provider.create_tag(name=new_version, commit_sha=commit.sha)
return self._emit(
output=output, bump=bump, status="created",
tag=new_version, commit=commit.sha, reason=None,
output=output,
bump=bump,
status="created",
tag=new_version,
commit=commit.sha,
reason=None,
)
```

Expand Down
Loading
Loading