semvertag 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
semvertag/__init__.py ADDED
File without changes
semvertag/__main__.py ADDED
@@ -0,0 +1,178 @@
1
+ import errno
2
+ import importlib.metadata
3
+ import typing
4
+
5
+ import modern_di_typer
6
+ import pydantic
7
+ import typer
8
+
9
+ from semvertag import ioc
10
+ from semvertag._errors import ConfigError, SemvertagError
11
+ from semvertag._output import Output, build_json_output, build_rich_output
12
+ from semvertag._settings import Settings, apply_cli_overlay
13
+ from semvertag._use_case import SemvertagUseCase
14
+
15
+
16
+ _PACKAGE_NAME: typing.Final = "semvertag"
17
+
18
+
19
+ MAIN_APP: typing.Final = typer.Typer(
20
+ name="semvertag",
21
+ help=("Auto-tag GitLab repos with semantic version tags — one tool, two strategies."),
22
+ no_args_is_help=True,
23
+ add_completion=True,
24
+ )
25
+
26
+ modern_di_typer.setup_di(MAIN_APP, ioc.container)
27
+
28
+
29
+ def _version_callback(value: bool) -> None:
30
+ if not value:
31
+ return
32
+ try:
33
+ version = importlib.metadata.version(_PACKAGE_NAME)
34
+ except importlib.metadata.PackageNotFoundError:
35
+ version = "0"
36
+ typer.echo(version)
37
+ raise typer.Exit
38
+
39
+
40
+ def _collect_overrides( # noqa: PLR0913
41
+ *,
42
+ project_id: int | None,
43
+ strategy: str | None,
44
+ token: str | None,
45
+ default_branch: str | None,
46
+ gitlab_endpoint: str | None,
47
+ request_timeout: float | None,
48
+ ) -> dict[str, typing.Any]:
49
+ overrides: dict[str, typing.Any] = {}
50
+ if project_id is not None:
51
+ overrides["project_id"] = project_id
52
+ if strategy is not None:
53
+ overrides["strategy"] = strategy
54
+ if token is not None:
55
+ overrides["gitlab.token"] = pydantic.SecretStr(token)
56
+ if default_branch is not None:
57
+ overrides["default_branch"] = default_branch
58
+ if gitlab_endpoint is not None:
59
+ overrides["gitlab.endpoint"] = gitlab_endpoint
60
+ if request_timeout is not None:
61
+ overrides["request_timeout"] = request_timeout
62
+ return overrides
63
+
64
+
65
+ def _config_error_from_validation(exc: pydantic.ValidationError) -> ConfigError:
66
+ first: typing.Final = exc.errors()[0]
67
+ loc: typing.Final = ".".join(str(part) for part in first.get("loc", ()))
68
+ detail: typing.Final = first.get("msg", "invalid value")
69
+ msg: typing.Final = f"Configuration error at '{loc}': {detail}. Check environment variables and command-line flags."
70
+ return ConfigError(msg)
71
+
72
+
73
+ @MAIN_APP.callback()
74
+ def _main_callback( # noqa: PLR0913
75
+ ctx: typer.Context,
76
+ project_id: typing.Annotated[
77
+ int | None,
78
+ typer.Option("--project-id", help="GitLab project id (or set CI_PROJECT_ID)."),
79
+ ] = None,
80
+ strategy: typing.Annotated[
81
+ str | None,
82
+ typer.Option("--strategy", help="Bump strategy: branch-prefix | conventional-commits."),
83
+ ] = None,
84
+ token: typing.Annotated[
85
+ str | None,
86
+ typer.Option("--token", help="API token (overrides SEMVERTAG_TOKEN)."),
87
+ ] = None,
88
+ default_branch: typing.Annotated[
89
+ str | None,
90
+ typer.Option("--default-branch", help="Default branch name override."),
91
+ ] = None,
92
+ gitlab_endpoint: typing.Annotated[
93
+ str | None,
94
+ typer.Option("--gitlab-endpoint", help="GitLab API endpoint URL."),
95
+ ] = None,
96
+ request_timeout: typing.Annotated[
97
+ float | None,
98
+ typer.Option("--request-timeout", help="Per-request timeout in seconds (clamped to 10)."),
99
+ ] = None,
100
+ _version: typing.Annotated[
101
+ bool | None,
102
+ typer.Option("--version", callback=_version_callback, is_eager=True, help="Show version and exit."),
103
+ ] = None,
104
+ ) -> None:
105
+ if ctx.resilient_parsing:
106
+ return
107
+
108
+ try:
109
+ settings = Settings()
110
+ try:
111
+ overrides = _collect_overrides(
112
+ project_id=project_id,
113
+ strategy=strategy,
114
+ token=token,
115
+ default_branch=default_branch,
116
+ gitlab_endpoint=gitlab_endpoint,
117
+ request_timeout=request_timeout,
118
+ )
119
+ settings = apply_cli_overlay(settings, overrides)
120
+ except ValueError as exc:
121
+ raise ConfigError(str(exc)) from exc
122
+ except pydantic.ValidationError as exc:
123
+ err = _config_error_from_validation(exc)
124
+ typer.echo(f"Error: {err}", err=True)
125
+ raise typer.Exit(code=err.exit_code) from err
126
+ except ConfigError as err:
127
+ typer.echo(f"Error: {err}", err=True)
128
+ raise typer.Exit(code=err.exit_code) from err
129
+
130
+ app_container = modern_di_typer.fetch_di_container(ctx)
131
+ app_container.set_context(Settings, settings)
132
+
133
+
134
+ @modern_di_typer.inject
135
+ def _resolve_use_case(
136
+ use_case: typing.Annotated[SemvertagUseCase, modern_di_typer.FromDI(SemvertagUseCase)],
137
+ ) -> SemvertagUseCase:
138
+ return use_case
139
+
140
+
141
+ @MAIN_APP.command("tag")
142
+ def _tag_command(
143
+ ctx: typer.Context,
144
+ quiet: typing.Annotated[
145
+ bool,
146
+ typer.Option("--quiet", help="Suppress progress narrative; final result still emits."),
147
+ ] = False,
148
+ json_flag: typing.Annotated[
149
+ bool,
150
+ typer.Option("--json", help="Emit a JSON envelope on stdout instead of human-readable output."),
151
+ ] = False,
152
+ ) -> None:
153
+ output: Output = build_json_output(quiet=quiet) if json_flag else build_rich_output(quiet=quiet)
154
+ try:
155
+ use_case = _resolve_use_case(ctx=ctx)
156
+ use_case(output=output)
157
+ except ImportError as exc:
158
+ err = ConfigError(f"Required module unavailable: {exc}.")
159
+ output.error(str(err))
160
+ raise typer.Exit(code=err.exit_code) from exc
161
+ except SemvertagError as err:
162
+ output.error(str(err))
163
+ raise typer.Exit(code=err.exit_code) from err
164
+ except BrokenPipeError as exc:
165
+ raise typer.Exit(code=0) from exc
166
+ except OSError as exc:
167
+ if exc.errno == errno.EPIPE:
168
+ raise typer.Exit(code=0) from exc
169
+ raise
170
+
171
+
172
+ def main() -> None:
173
+ with ioc.container:
174
+ MAIN_APP()
175
+
176
+
177
+ if __name__ == "__main__": # pragma: no cover
178
+ main()
@@ -0,0 +1,29 @@
1
+ import typing
2
+
3
+
4
+ def subject_line(message: str) -> str:
5
+ for line in message.splitlines():
6
+ if line.strip():
7
+ return line.rstrip()
8
+ return ""
9
+
10
+
11
+ def body_lines(message: str) -> list[str]:
12
+ lines: typing.Final = message.splitlines()
13
+ subject_seen = False
14
+ separator_seen = False
15
+ collected: list[str] = []
16
+ for line in lines:
17
+ if not subject_seen:
18
+ if line.strip():
19
+ subject_seen = True
20
+ continue
21
+ if not separator_seen:
22
+ if not line.strip():
23
+ separator_seen = True
24
+ continue
25
+ collected.append(line.rstrip())
26
+ continue
27
+ if line.strip():
28
+ collected.append(line.rstrip())
29
+ return collected
semvertag/_errors.py ADDED
@@ -0,0 +1,17 @@
1
+ import typing
2
+
3
+
4
+ class SemvertagError(Exception):
5
+ exit_code: typing.ClassVar[int] = 1
6
+
7
+
8
+ class ConfigError(SemvertagError):
9
+ exit_code: typing.ClassVar[int] = 2
10
+
11
+
12
+ class AuthError(SemvertagError):
13
+ exit_code: typing.ClassVar[int] = 3
14
+
15
+
16
+ class ProviderAPIError(SemvertagError):
17
+ exit_code: typing.ClassVar[int] = 4
semvertag/_output.py ADDED
@@ -0,0 +1,78 @@
1
+ import dataclasses
2
+ import json
3
+ import sys
4
+ import typing
5
+
6
+ import rich.console
7
+
8
+ from semvertag._redact import redact
9
+ from semvertag._types import RunResult
10
+
11
+
12
+ _COMMIT_SHORT_LEN: typing.Final = 7
13
+
14
+
15
+ class Output(typing.Protocol):
16
+ def progress(self, message: str) -> None: ...
17
+ def emit(self, result: RunResult) -> None: ...
18
+ def error(self, message: str) -> None: ...
19
+
20
+
21
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
22
+ class RichOutput:
23
+ info_console: rich.console.Console
24
+ error_console: rich.console.Console
25
+ quiet: bool = False
26
+
27
+ def progress(self, message: str) -> None:
28
+ if self.quiet:
29
+ return
30
+ self.info_console.print(redact(message), markup=False, highlight=False)
31
+
32
+ def emit(self, result: RunResult) -> None:
33
+ self.info_console.print(redact(_format_result(result)), markup=False, highlight=False)
34
+
35
+ def error(self, message: str) -> None:
36
+ self.error_console.print(redact(message), markup=False, highlight=False)
37
+
38
+
39
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
40
+ class JsonOutput:
41
+ error_console: rich.console.Console
42
+ quiet: bool = False
43
+
44
+ def progress(self, message: str) -> None:
45
+ _ = message
46
+
47
+ def emit(self, result: RunResult) -> None:
48
+ payload: typing.Final = json.dumps(dataclasses.asdict(result), separators=(",", ":"))
49
+ sys.stdout.write(payload + "\n")
50
+ sys.stdout.flush()
51
+
52
+ def error(self, message: str) -> None:
53
+ self.error_console.print(redact(message), markup=False, highlight=False)
54
+
55
+
56
+ def _format_result(result: RunResult) -> str:
57
+ if result.status == "created":
58
+ short: typing.Final = (result.commit or "")[:_COMMIT_SHORT_LEN]
59
+ return f"Created tag {result.tag} on commit {short} (strategy: {result.strategy}, bump: {result.bump})"
60
+ return (
61
+ f"No tag created (status: {result.status}, strategy: {result.strategy}, "
62
+ f"bump: {result.bump}, reason: {result.reason})"
63
+ )
64
+
65
+
66
+ def build_rich_output(*, quiet: bool = False) -> RichOutput:
67
+ return RichOutput(
68
+ info_console=rich.console.Console(),
69
+ error_console=rich.console.Console(stderr=True),
70
+ quiet=quiet,
71
+ )
72
+
73
+
74
+ def build_json_output(*, quiet: bool = False) -> JsonOutput:
75
+ return JsonOutput(
76
+ error_console=rich.console.Console(stderr=True),
77
+ quiet=quiet,
78
+ )
semvertag/_redact.py ADDED
@@ -0,0 +1,20 @@
1
+ import re
2
+ import typing
3
+
4
+
5
+ _REDACTION: typing.Final = "***"
6
+ _TOKEN_PATTERN: typing.Final = re.compile(
7
+ r"glpat-[A-Za-z0-9_\-]{20,}"
8
+ r"|github_pat_[A-Za-z0-9_]{20,}"
9
+ r"|ghp_[A-Za-z0-9]{20,}"
10
+ r"|gho_[A-Za-z0-9]{20,}"
11
+ r"|ghu_[A-Za-z0-9]{20,}"
12
+ r"|ghs_[A-Za-z0-9]{20,}"
13
+ r"|ghr_[A-Za-z0-9]{20,}"
14
+ r"|ATBB[A-Za-z0-9]{20,}"
15
+ r"|\b[0-9a-fA-F]{32,}\b",
16
+ )
17
+
18
+
19
+ def redact(text: str) -> str:
20
+ return _TOKEN_PATTERN.sub(_REDACTION, text)
semvertag/_settings.py ADDED
@@ -0,0 +1,107 @@
1
+ import logging
2
+ import typing
3
+
4
+ import pydantic
5
+ import pydantic_settings
6
+
7
+ from semvertag.strategies.branch_prefix import BranchPrefixConfig
8
+ from semvertag.strategies.conventional_commits import ConventionalCommitsConfig
9
+
10
+
11
+ _logger: typing.Final = logging.getLogger(__name__)
12
+
13
+ _REQUEST_TIMEOUT_CEILING: typing.Final = 10.0
14
+ _ENV_PREFIX: typing.Final = "SEMVERTAG_"
15
+ _ENV_NESTED_DELIMITER: typing.Final = "__"
16
+
17
+
18
+ class GitLabConfig(pydantic_settings.BaseSettings):
19
+ model_config = pydantic_settings.SettingsConfigDict(
20
+ env_prefix="SEMVERTAG_GITLAB__",
21
+ case_sensitive=False,
22
+ extra="ignore",
23
+ populate_by_name=True,
24
+ )
25
+
26
+ endpoint: str = "https://gitlab.com"
27
+ token: pydantic.SecretStr = pydantic.Field(
28
+ default=pydantic.SecretStr(""),
29
+ validation_alias=pydantic.AliasChoices(
30
+ "SEMVERTAG_GITLAB__TOKEN",
31
+ "SEMVERTAG_TOKEN",
32
+ "CI_JOB_TOKEN",
33
+ "GITLAB_TOKEN",
34
+ ),
35
+ )
36
+
37
+
38
+ class GitHubConfig(pydantic_settings.BaseSettings):
39
+ model_config = pydantic_settings.SettingsConfigDict(
40
+ env_prefix="SEMVERTAG_GITHUB__",
41
+ case_sensitive=False,
42
+ extra="ignore",
43
+ populate_by_name=True,
44
+ )
45
+
46
+ token: pydantic.SecretStr = pydantic.Field(
47
+ default=pydantic.SecretStr(""),
48
+ validation_alias=pydantic.AliasChoices(
49
+ "SEMVERTAG_GITHUB__TOKEN",
50
+ "SEMVERTAG_TOKEN",
51
+ "GITHUB_TOKEN",
52
+ ),
53
+ )
54
+
55
+
56
+ class Settings(pydantic_settings.BaseSettings):
57
+ model_config = pydantic_settings.SettingsConfigDict(
58
+ env_prefix=_ENV_PREFIX,
59
+ env_nested_delimiter=_ENV_NESTED_DELIMITER,
60
+ case_sensitive=False,
61
+ extra="ignore",
62
+ )
63
+
64
+ strategy: typing.Literal["branch-prefix", "conventional-commits"] = "branch-prefix"
65
+ default_branch: str | None = None
66
+ request_timeout: float = pydantic.Field(default=8.0, gt=0)
67
+ project_id: int | None = pydantic.Field(
68
+ default=None,
69
+ validation_alias=pydantic.AliasChoices("SEMVERTAG_PROJECT_ID", "CI_PROJECT_ID"),
70
+ )
71
+ gitlab: GitLabConfig = pydantic.Field(default_factory=GitLabConfig)
72
+ github: GitHubConfig = pydantic.Field(default_factory=GitHubConfig)
73
+ branch_prefix: BranchPrefixConfig = pydantic.Field(default_factory=BranchPrefixConfig)
74
+ conventional_commits: ConventionalCommitsConfig = pydantic.Field(default_factory=ConventionalCommitsConfig)
75
+
76
+ @pydantic.field_validator("request_timeout")
77
+ @classmethod
78
+ def _clamp_request_timeout(cls, value: float) -> float:
79
+ if value > _REQUEST_TIMEOUT_CEILING:
80
+ _logger.warning(
81
+ "request_timeout=%.3f exceeds ceiling %.1f; clamping to %.1f",
82
+ value,
83
+ _REQUEST_TIMEOUT_CEILING,
84
+ _REQUEST_TIMEOUT_CEILING,
85
+ )
86
+ return _REQUEST_TIMEOUT_CEILING
87
+ return value
88
+
89
+
90
+ def apply_cli_overlay(settings: Settings, overrides: dict[str, typing.Any]) -> Settings:
91
+ top_updates: dict[str, typing.Any] = {}
92
+ nested_updates: dict[str, dict[str, typing.Any]] = {}
93
+ for dotted_key, value in overrides.items():
94
+ head, _, leaf = dotted_key.partition(".")
95
+ if "." in leaf:
96
+ msg = f"CLI overlay key '{dotted_key}' exceeds nesting depth 2."
97
+ raise ValueError(msg)
98
+ if leaf:
99
+ nested_updates.setdefault(head, {})[leaf] = value
100
+ else:
101
+ top_updates[head] = value
102
+ for head, leaves in nested_updates.items():
103
+ top_updates[head] = getattr(settings, head).model_copy(update=leaves)
104
+ copied = settings.model_copy(update=top_updates)
105
+ # Re-validate to trigger field validators (e.g. _clamp_request_timeout).
106
+ # getattr (not model_dump) preserves live SecretStr values.
107
+ return type(settings).model_validate({name: getattr(copied, name) for name in type(copied).model_fields})
@@ -0,0 +1,84 @@
1
+ import datetime
2
+ import email.utils
3
+ import random
4
+ import time
5
+ import typing
6
+
7
+ import httpx2
8
+
9
+
10
+ RETRYABLE_STATUSES: typing.Final[frozenset[int]] = frozenset({408, 429, 500, 502, 503, 504})
11
+ RETRYABLE_EXCEPTIONS: typing.Final[tuple[type[BaseException], ...]] = (
12
+ httpx2.ConnectError,
13
+ httpx2.ReadTimeout,
14
+ httpx2.WriteTimeout,
15
+ httpx2.RemoteProtocolError,
16
+ )
17
+ MAX_ATTEMPTS: typing.Final = 3
18
+ MAX_WALL_SECONDS: typing.Final = 30.0
19
+ BACKOFF_BASE_SECONDS: typing.Final = 1.0
20
+
21
+
22
+ class RetryingTransport(httpx2.BaseTransport):
23
+ def __init__(self, inner: httpx2.BaseTransport | None = None) -> None:
24
+ self._inner: httpx2.BaseTransport = inner or httpx2.HTTPTransport()
25
+
26
+ def handle_request(self, request: httpx2.Request) -> httpx2.Response:
27
+ start: typing.Final = time.monotonic()
28
+ last_response: httpx2.Response | None = None
29
+ last_exc: BaseException | None = None
30
+ for attempt in range(MAX_ATTEMPTS):
31
+ try:
32
+ response = self._inner.handle_request(request)
33
+ except RETRYABLE_EXCEPTIONS as exc:
34
+ last_exc, last_response = exc, None
35
+ else:
36
+ if response.status_code not in RETRYABLE_STATUSES:
37
+ return response
38
+ last_response, last_exc = response, None
39
+ if attempt == MAX_ATTEMPTS - 1:
40
+ break
41
+ sleep_seconds = _compute_sleep(attempt, last_response)
42
+ if time.monotonic() - start + sleep_seconds > MAX_WALL_SECONDS:
43
+ break
44
+ time.sleep(sleep_seconds)
45
+ if last_response is not None:
46
+ return last_response
47
+ if last_exc is not None:
48
+ raise last_exc
49
+ msg = "RetryingTransport loop invariant violated" # pragma: no cover
50
+ raise RuntimeError(msg) # pragma: no cover
51
+
52
+ def close(self) -> None:
53
+ self._inner.close()
54
+
55
+
56
+ def _compute_sleep(attempt: int, last_response: httpx2.Response | None) -> float:
57
+ backoff: typing.Final = random.uniform(0.0, BACKOFF_BASE_SECONDS * (2**attempt)) # noqa: S311
58
+ if last_response is not None and last_response.status_code in RETRYABLE_STATUSES:
59
+ parsed: typing.Final = _parse_retry_after(last_response.headers.get("retry-after"), time.time())
60
+ if parsed is not None:
61
+ return max(parsed, backoff)
62
+ return backoff
63
+
64
+
65
+ def _parse_retry_after(value: str | None, now_epoch: float) -> float | None:
66
+ if value is None:
67
+ return None
68
+ stripped: typing.Final = value.strip()
69
+ if not stripped:
70
+ return None
71
+ try:
72
+ seconds = float(stripped)
73
+ except (TypeError, ValueError):
74
+ pass
75
+ else:
76
+ return seconds if seconds >= 0.0 else None
77
+ try:
78
+ parsed_dt = email.utils.parsedate_to_datetime(stripped)
79
+ if parsed_dt.tzinfo is None:
80
+ parsed_dt = parsed_dt.replace(tzinfo=datetime.timezone.utc)
81
+ delta: typing.Final = parsed_dt.timestamp() - now_epoch
82
+ except (TypeError, ValueError, OverflowError):
83
+ return None
84
+ return max(0.0, delta)
semvertag/_types.py ADDED
@@ -0,0 +1,46 @@
1
+ import dataclasses
2
+ import enum
3
+ import typing
4
+
5
+
6
+ class Bump(enum.Enum):
7
+ NONE = "none"
8
+ PATCH = "patch"
9
+ MINOR = "minor"
10
+ MAJOR = "major"
11
+
12
+
13
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
14
+ class ConfigSource:
15
+ layer: typing.Literal["cli", "env", "default"]
16
+ detail: str
17
+
18
+
19
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
20
+ class RunResult:
21
+ schema_version: str = "1.0"
22
+ strategy: str
23
+ bump: str
24
+ status: str
25
+ tag: str | None
26
+ commit: str | None
27
+ reason: str | None
28
+
29
+
30
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
31
+ class Commit:
32
+ sha: str
33
+ message: str
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
37
+ class Tag:
38
+ name: str
39
+ commit_sha: str
40
+
41
+
42
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
43
+ class CheckResult:
44
+ name: str
45
+ status: typing.Literal["passed", "failed", "skipped"]
46
+ cause: str
semvertag/_use_case.py ADDED
@@ -0,0 +1,127 @@
1
+ import dataclasses
2
+ import typing
3
+
4
+ import semver
5
+
6
+ from semvertag._output import Output
7
+ from semvertag._types import Bump, RunResult, Tag
8
+ from semvertag.providers._base import Provider
9
+ from semvertag.strategies._base import BumpStrategy
10
+
11
+
12
+ _NO_TAGS_REASON: typing.Final = "No prior semver-conforming tags found; not seeding an initial tag in v1.0."
13
+ _ALREADY_TAGGED_REASON: typing.Final = "Latest commit already tagged."
14
+
15
+
16
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
17
+ class SemvertagUseCase:
18
+ provider: Provider
19
+ strategy: BumpStrategy
20
+
21
+ def __call__(self, *, output: Output) -> RunResult:
22
+ output.progress(f"Detected strategy: {self.strategy.name}")
23
+ output.progress("Fetching latest commit on default branch...")
24
+ commit: typing.Final = self.provider.get_latest_commit_on_default_branch()
25
+
26
+ output.progress("Fetching tag history...")
27
+ tags: typing.Final = self.provider.list_tags()
28
+ latest_semver_tag: typing.Final = _pick_latest_semver_tag(tags)
29
+
30
+ if latest_semver_tag is None:
31
+ return self._emit(
32
+ output=output,
33
+ bump=Bump.NONE,
34
+ status="no_tags",
35
+ tag=None,
36
+ commit=commit.sha,
37
+ reason=_NO_TAGS_REASON,
38
+ )
39
+
40
+ if latest_semver_tag.commit_sha == commit.sha:
41
+ return self._emit(
42
+ output=output,
43
+ bump=Bump.NONE,
44
+ status="already_tagged",
45
+ tag=latest_semver_tag.name,
46
+ commit=commit.sha,
47
+ reason=_ALREADY_TAGGED_REASON,
48
+ )
49
+
50
+ output.progress("Computing bump...")
51
+ bump: typing.Final = self.strategy.decide(commit)
52
+ if bump is Bump.NONE:
53
+ return self._emit(
54
+ output=output,
55
+ bump=Bump.NONE,
56
+ status=self.strategy.no_bump_status,
57
+ tag=None,
58
+ commit=commit.sha,
59
+ reason=self.strategy.no_bump_reason,
60
+ )
61
+
62
+ new_version: typing.Final = _compute_new_version(latest_semver_tag, bump)
63
+ output.progress(f"Creating tag {new_version}...")
64
+ self.provider.create_tag(name=new_version, commit_sha=commit.sha)
65
+ return self._emit(
66
+ output=output,
67
+ bump=bump,
68
+ status="created",
69
+ tag=new_version,
70
+ commit=commit.sha,
71
+ reason=None,
72
+ )
73
+
74
+ def _emit( # noqa: PLR0913
75
+ self,
76
+ *,
77
+ output: Output,
78
+ bump: Bump,
79
+ status: str,
80
+ tag: str | None,
81
+ commit: str | None,
82
+ reason: str | None,
83
+ ) -> RunResult:
84
+ result: typing.Final = RunResult(
85
+ strategy=self.strategy.name,
86
+ bump=bump.value,
87
+ status=status,
88
+ tag=tag,
89
+ commit=commit,
90
+ reason=reason,
91
+ )
92
+ output.emit(result)
93
+ return result
94
+
95
+
96
+ def _pick_latest_semver_tag(tags: list[Tag]) -> Tag | None:
97
+ parsed: typing.Final = [(version, tag) for tag, version in _parse_semver_tags(tags)]
98
+ if not parsed:
99
+ return None
100
+ parsed.sort(key=lambda item: item[0])
101
+ return parsed[-1][1]
102
+
103
+
104
+ def _parse_semver_tags(tags: list[Tag]) -> typing.Iterator[tuple[Tag, semver.Version]]:
105
+ for tag in tags:
106
+ version = _try_parse_semver(tag.name)
107
+ if version is not None:
108
+ yield tag, version
109
+
110
+
111
+ def _try_parse_semver(name: str) -> semver.Version | None:
112
+ try:
113
+ return semver.Version.parse(name)
114
+ except ValueError:
115
+ return None
116
+
117
+
118
+ def _compute_new_version(last_tag: Tag, bump: Bump) -> str:
119
+ version: typing.Final = semver.Version.parse(last_tag.name)
120
+ if bump is Bump.MAJOR:
121
+ return str(version.bump_major())
122
+ if bump is Bump.MINOR:
123
+ return str(version.bump_minor())
124
+ if bump is Bump.PATCH:
125
+ return str(version.bump_patch())
126
+ msg = f"Cannot compute new version for bump={bump!r}."
127
+ raise ValueError(msg)
semvertag/ioc.py ADDED
@@ -0,0 +1,101 @@
1
+ import typing
2
+
3
+ import httpx2
4
+ import modern_di
5
+ from modern_di import Scope, providers
6
+
7
+ from semvertag._errors import ConfigError
8
+ from semvertag._settings import Settings
9
+ from semvertag._transport import RetryingTransport
10
+ from semvertag._use_case import SemvertagUseCase
11
+ from semvertag.providers._http import HttpClient
12
+ from semvertag.providers.gitlab import GitLabProvider, _translate_status, gitlab_auth_headers
13
+ from semvertag.strategies._base import BumpStrategy
14
+ from semvertag.strategies.branch_prefix import BranchPrefixStrategy
15
+ from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy
16
+
17
+
18
+ def _build_gitlab_provider(settings: Settings, transport: httpx2.BaseTransport) -> GitLabProvider:
19
+ if settings.project_id is None:
20
+ msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id."
21
+ raise ConfigError(msg)
22
+ project_id: typing.Final = settings.project_id
23
+ client: typing.Final = httpx2.Client(
24
+ transport=transport,
25
+ base_url=settings.gitlab.endpoint,
26
+ timeout=settings.request_timeout,
27
+ )
28
+ http: typing.Final = HttpClient(
29
+ client=client,
30
+ auth_headers=lambda: gitlab_auth_headers(settings.gitlab.token),
31
+ status_translator=lambda status: _translate_status(status, project_id),
32
+ )
33
+ return GitLabProvider(
34
+ config=settings.gitlab,
35
+ project_id=project_id,
36
+ http=http,
37
+ )
38
+
39
+
40
+ def _build_branch_prefix_strategy(settings: Settings) -> BranchPrefixStrategy:
41
+ return BranchPrefixStrategy(config=settings.branch_prefix)
42
+
43
+
44
+ def _build_conventional_commits_strategy(settings: Settings) -> ConventionalCommitsStrategy:
45
+ return ConventionalCommitsStrategy(config=settings.conventional_commits)
46
+
47
+
48
+ def _build_current_strategy(settings: Settings) -> BumpStrategy:
49
+ if settings.strategy == "conventional-commits":
50
+ return _build_conventional_commits_strategy(settings)
51
+ return _build_branch_prefix_strategy(settings)
52
+
53
+
54
+ def _close_provider_client(provider: GitLabProvider) -> None:
55
+ provider.http.client.close()
56
+
57
+
58
+ class SettingsGroup(modern_di.Group):
59
+ settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings)
60
+
61
+
62
+ class TransportsGroup(modern_di.Group):
63
+ transport = providers.Factory(scope=Scope.APP, creator=RetryingTransport)
64
+
65
+
66
+ class ProvidersGroup(modern_di.Group):
67
+ gitlab_provider = providers.Factory(
68
+ scope=Scope.APP,
69
+ creator=_build_gitlab_provider,
70
+ kwargs={"transport": TransportsGroup.transport},
71
+ cache_settings=providers.CacheSettings(finalizer=_close_provider_client),
72
+ )
73
+
74
+
75
+ class StrategiesGroup(modern_di.Group):
76
+ branch_prefix_strategy = providers.Factory(scope=Scope.APP, creator=_build_branch_prefix_strategy)
77
+ conventional_commits_strategy = providers.Factory(scope=Scope.APP, creator=_build_conventional_commits_strategy)
78
+ current_strategy = providers.Factory(scope=Scope.APP, creator=_build_current_strategy)
79
+
80
+
81
+ class UseCasesGroup(modern_di.Group):
82
+ semvertag_use_case = providers.Factory(
83
+ scope=Scope.APP,
84
+ creator=SemvertagUseCase,
85
+ kwargs={
86
+ "provider": ProvidersGroup.gitlab_provider,
87
+ "strategy": StrategiesGroup.current_strategy,
88
+ },
89
+ )
90
+
91
+
92
+ ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [
93
+ SettingsGroup,
94
+ TransportsGroup,
95
+ ProvidersGroup,
96
+ StrategiesGroup,
97
+ UseCasesGroup,
98
+ ]
99
+
100
+
101
+ container: typing.Final = modern_di.Container(groups=ALL_GROUPS)
File without changes
@@ -0,0 +1,12 @@
1
+ import typing
2
+
3
+ from semvertag._types import Commit, Tag
4
+
5
+
6
+ class Provider(typing.Protocol):
7
+ name: str
8
+
9
+ def get_default_branch(self) -> str: ...
10
+ def get_latest_commit_on_default_branch(self) -> Commit: ...
11
+ def list_tags(self) -> list[Tag]: ...
12
+ def create_tag(self, name: str, commit_sha: str) -> None: ...
@@ -0,0 +1,62 @@
1
+ import collections.abc
2
+ import dataclasses
3
+ import typing
4
+
5
+ import httpx2
6
+ import pydantic
7
+
8
+ from semvertag._errors import ProviderAPIError
9
+
10
+
11
+ T = typing.TypeVar("T", bound=pydantic.BaseModel)
12
+
13
+ AuthHeaders: typing.TypeAlias = collections.abc.Callable[[], dict[str, str]]
14
+ StatusTranslator: typing.TypeAlias = collections.abc.Callable[[int], None]
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
18
+ class HttpClient:
19
+ client: httpx2.Client
20
+ auth_headers: AuthHeaders
21
+ status_translator: StatusTranslator
22
+
23
+ def request(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> T: # noqa: ANN401
24
+ response = self._request_translated(method, url, **kwargs)
25
+ payload = self._decode_json(response)
26
+ try:
27
+ return schema.model_validate(payload)
28
+ except pydantic.ValidationError as exc:
29
+ msg = f"response shape invalid: {exc}"
30
+ raise ProviderAPIError(msg) from exc
31
+
32
+ def request_many(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> list[T]: # noqa: ANN401
33
+ response = self._request_translated(method, url, **kwargs)
34
+ payload = self._decode_json(response)
35
+ if not isinstance(payload, list):
36
+ msg = f"response shape invalid: expected list, got {type(payload).__name__}"
37
+ raise ProviderAPIError(msg)
38
+ try:
39
+ return [schema.model_validate(item) for item in payload]
40
+ except pydantic.ValidationError as exc:
41
+ msg = f"response shape invalid: {exc}"
42
+ raise ProviderAPIError(msg) from exc
43
+
44
+ def request_raw(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: # noqa: ANN401
45
+ try:
46
+ return self.client.request(method, url, headers=self.auth_headers(), **kwargs)
47
+ except httpx2.RequestError as exc:
48
+ msg = f"request failed: {type(exc).__name__}"
49
+ raise ProviderAPIError(msg) from exc
50
+
51
+ def _request_translated(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: # noqa: ANN401
52
+ response = self.request_raw(method, url, **kwargs)
53
+ self.status_translator(response.status_code)
54
+ return response
55
+
56
+ @staticmethod
57
+ def _decode_json(response: httpx2.Response) -> typing.Any: # noqa: ANN401
58
+ try:
59
+ return response.json()
60
+ except (ValueError, httpx2.DecodingError) as exc:
61
+ msg = "malformed JSON in response body"
62
+ raise ProviderAPIError(msg) from exc
@@ -0,0 +1,219 @@
1
+ import dataclasses
2
+ import re
3
+ import typing
4
+ import urllib.parse
5
+
6
+ import httpx2
7
+ import pydantic
8
+
9
+ from semvertag._errors import AuthError, ConfigError, ProviderAPIError
10
+ from semvertag._settings import GitLabConfig
11
+ from semvertag._types import Commit, Tag
12
+ from semvertag.providers._http import HttpClient
13
+
14
+
15
+ _API_PREFIX: typing.Final = "/api/v4/projects"
16
+ _PRIVATE_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN"
17
+ _TAGS_PER_PAGE: typing.Final = 100
18
+ _MAX_TAG_PAGES: typing.Final = 100
19
+
20
+ _HTTP_OK: typing.Final = 200
21
+ _HTTP_CREATED: typing.Final = 201
22
+ _HTTP_BAD_REQUEST: typing.Final = 400
23
+ _HTTP_UNAUTHORIZED: typing.Final = 401
24
+ _HTTP_FORBIDDEN: typing.Final = 403
25
+ _HTTP_NOT_FOUND: typing.Final = 404
26
+ _HTTP_UNPROCESSABLE: typing.Final = 422
27
+ _HTTP_TOO_MANY_REQUESTS: typing.Final = 429
28
+ _HTTP_SERVER_ERROR_MIN: typing.Final = 500
29
+ _HTTP_SERVER_ERROR_MAX: typing.Final = 600
30
+
31
+ _TAG_EXISTS_FRAGMENT: typing.Final = "already exists"
32
+
33
+
34
+ class _ProjectResponse(pydantic.BaseModel):
35
+ default_branch: str | None
36
+
37
+
38
+ class _CommitItem(pydantic.BaseModel):
39
+ id: str
40
+ message: str
41
+
42
+
43
+ class _TagCommit(pydantic.BaseModel):
44
+ id: str
45
+
46
+
47
+ class _TagItem(pydantic.BaseModel):
48
+ name: str
49
+ commit: _TagCommit
50
+
51
+
52
+ # RFC 8288 Link header: <uri-reference>;param=value;param="value";...
53
+ _LINK_ENTRY_RE: typing.Final = re.compile(
54
+ r"<\s*(?P<url>[^>]*?)\s*>(?P<params>(?:\s*;\s*[^,;]+)*)",
55
+ )
56
+
57
+
58
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
59
+ class GitLabProvider:
60
+ name: typing.ClassVar[str] = "gitlab"
61
+ config: GitLabConfig
62
+ project_id: int
63
+ http: HttpClient
64
+
65
+ def get_default_branch(self) -> str:
66
+ project = self.http.request(
67
+ "GET",
68
+ self._url(f"{_API_PREFIX}/{self.project_id}"),
69
+ schema=_ProjectResponse,
70
+ )
71
+ if not project.default_branch:
72
+ msg = "Default branch missing from GitLab response. Verify the project has a default branch configured."
73
+ raise ConfigError(msg)
74
+ return project.default_branch
75
+
76
+ def get_latest_commit_on_default_branch(self) -> Commit:
77
+ default_branch: typing.Final = self.get_default_branch()
78
+ items = self.http.request_many(
79
+ "GET",
80
+ self._url(f"{_API_PREFIX}/{self.project_id}/repository/commits"),
81
+ schema=_CommitItem,
82
+ params={"ref_name": default_branch, "per_page": 1},
83
+ )
84
+ if not items:
85
+ msg = f"No commits on default branch '{default_branch}'. The branch appears empty."
86
+ raise ProviderAPIError(msg)
87
+ head = items[0]
88
+ return Commit(sha=head.id, message=head.message)
89
+
90
+ def list_tags(self) -> list[Tag]:
91
+ tags: list[Tag] = []
92
+ base_url: typing.Final = self._url(f"{_API_PREFIX}/{self.project_id}/repository/tags")
93
+ url: str = base_url
94
+ params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1}
95
+ for _ in range(_MAX_TAG_PAGES):
96
+ response = self.http.request_raw("GET", url, params=params)
97
+ _translate_status(response.status_code, self.project_id)
98
+ items = _validate_tag_list(response)
99
+ tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in items)
100
+ next_url = _next_page_url(response, current_url=url)
101
+ if next_url is None:
102
+ return tags
103
+ if not _same_origin(next_url, self.config.endpoint):
104
+ msg = (
105
+ "GitLab pagination Link header points to a different host than SEMVERTAG_GITLAB__ENDPOINT. "
106
+ "Refusing to follow to protect credentials."
107
+ )
108
+ raise ProviderAPIError(msg)
109
+ url, params = next_url, None
110
+ msg = (
111
+ f"Tag pagination exceeded {_MAX_TAG_PAGES} pages. "
112
+ "The project has an unexpected number of tags; please file an issue."
113
+ )
114
+ raise ProviderAPIError(msg)
115
+
116
+ def create_tag(self, name: str, commit_sha: str) -> None:
117
+ response = self.http.request_raw(
118
+ "POST",
119
+ self._url(f"{_API_PREFIX}/{self.project_id}/repository/tags"),
120
+ json={"tag_name": name, "ref": commit_sha},
121
+ )
122
+ if response.status_code == _HTTP_CREATED:
123
+ return
124
+ if response.status_code == _HTTP_BAD_REQUEST:
125
+ body_message = ""
126
+ try:
127
+ payload = response.json()
128
+ body_message = str(payload.get("message", "")) if isinstance(payload, dict) else ""
129
+ except (ValueError, httpx2.DecodingError):
130
+ pass
131
+ if _TAG_EXISTS_FRAGMENT in body_message.lower():
132
+ msg = f"Tag already exists: '{name}'. The tag was created by a concurrent run or previous invocation."
133
+ raise ConfigError(msg)
134
+ msg = "Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists."
135
+ raise ConfigError(msg)
136
+ _translate_status(response.status_code, self.project_id)
137
+
138
+ def _url(self, path: str) -> str:
139
+ return f"{self.config.endpoint.rstrip('/')}{path}"
140
+
141
+
142
+ def gitlab_auth_headers(token: pydantic.SecretStr) -> dict[str, str]:
143
+ return {_PRIVATE_TOKEN_HEADER: token.get_secret_value()}
144
+
145
+
146
+ def _translate_status(status: int, project_id: int) -> None:
147
+ if status in {_HTTP_OK, _HTTP_CREATED}:
148
+ return
149
+ if status == _HTTP_UNAUTHORIZED:
150
+ msg = "Token rejected: 401. Verify SEMVERTAG_TOKEN is valid and has 'api' scope."
151
+ raise AuthError(msg)
152
+ if status == _HTTP_FORBIDDEN:
153
+ msg = (
154
+ "Token missing scope or insufficient permission: 403. "
155
+ "Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab."
156
+ )
157
+ raise AuthError(msg)
158
+ if status == _HTTP_NOT_FOUND:
159
+ msg = f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id."
160
+ raise ConfigError(msg)
161
+ if status == _HTTP_UNPROCESSABLE:
162
+ msg = "Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists."
163
+ raise ConfigError(msg)
164
+ if status == _HTTP_TOO_MANY_REQUESTS:
165
+ msg = "GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later."
166
+ raise ProviderAPIError(msg)
167
+ if _HTTP_SERVER_ERROR_MIN <= status < _HTTP_SERVER_ERROR_MAX:
168
+ msg = f"GitLab API failure: {status}. Retries exhausted after 3 attempts. Try again or check GitLab status."
169
+ raise ProviderAPIError(msg)
170
+ msg = f"Unexpected GitLab response: {status}. Please file an issue."
171
+ raise ProviderAPIError(msg)
172
+
173
+
174
+ def _next_page_url(response: httpx2.Response, current_url: str) -> str | None:
175
+ link_header: typing.Final = response.headers.get("link")
176
+ if not link_header:
177
+ return None
178
+ for match in _LINK_ENTRY_RE.finditer(link_header):
179
+ url_part = match.group("url").strip()
180
+ if not url_part:
181
+ continue
182
+ if "next" in _parse_rel_values(match.group("params")):
183
+ return urllib.parse.urljoin(current_url, url_part)
184
+ return None
185
+
186
+
187
+ def _validate_tag_list(response: httpx2.Response) -> list[_TagItem]:
188
+ try:
189
+ payload = response.json()
190
+ except (ValueError, httpx2.DecodingError) as exc:
191
+ msg = "GitLab tags response malformed JSON."
192
+ raise ProviderAPIError(msg) from exc
193
+ if not isinstance(payload, list):
194
+ msg = "GitLab tags response shape invalid: expected list."
195
+ raise ProviderAPIError(msg)
196
+ try:
197
+ return [_TagItem.model_validate(item) for item in payload]
198
+ except pydantic.ValidationError as exc:
199
+ msg = f"GitLab tags response shape invalid: {exc}"
200
+ raise ProviderAPIError(msg) from exc
201
+
202
+
203
+ def _parse_rel_values(params_blob: str) -> set[str]:
204
+ for raw_param in params_blob.split(";"):
205
+ param = raw_param.strip()
206
+ if not param:
207
+ continue
208
+ name, _, value = param.partition("=")
209
+ if name.strip().lower() != "rel":
210
+ continue
211
+ cleaned = value.strip().strip('"').strip("'").lower()
212
+ return set(cleaned.split())
213
+ return set()
214
+
215
+
216
+ def _same_origin(url: str, endpoint: str) -> bool:
217
+ parsed: typing.Final = urllib.parse.urlsplit(url)
218
+ expected: typing.Final = urllib.parse.urlsplit(endpoint)
219
+ return parsed.scheme == expected.scheme and parsed.netloc == expected.netloc
semvertag/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,11 @@
1
+ import typing
2
+
3
+ from semvertag._types import Bump, Commit
4
+
5
+
6
+ class BumpStrategy(typing.Protocol):
7
+ name: str
8
+ no_bump_status: str
9
+ no_bump_reason: str
10
+
11
+ def decide(self, commit: Commit) -> Bump: ...
@@ -0,0 +1,36 @@
1
+ import dataclasses
2
+ import typing
3
+
4
+ import pydantic
5
+
6
+ from semvertag._commit_parse import subject_line
7
+ from semvertag._types import Bump, Commit
8
+
9
+
10
+ _NonEmptyStr: typing.TypeAlias = typing.Annotated[str, pydantic.Field(min_length=1)]
11
+
12
+
13
+ class BranchPrefixConfig(pydantic.BaseModel):
14
+ model_config = pydantic.ConfigDict(frozen=True)
15
+
16
+ minor: tuple[_NonEmptyStr, ...] = pydantic.Field(default=("feature/",), min_length=1)
17
+ patch: tuple[_NonEmptyStr, ...] = pydantic.Field(default=("bugfix/", "hotfix/"), min_length=1)
18
+ merge_mark_text: _NonEmptyStr = "Merge branch"
19
+
20
+
21
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
22
+ class BranchPrefixStrategy:
23
+ name: typing.ClassVar[str] = "branch-prefix"
24
+ no_bump_status: typing.ClassVar[str] = "no_merge_commit"
25
+ no_bump_reason: typing.ClassVar[str] = "Latest commit on default branch is not a merge commit."
26
+ config: BranchPrefixConfig
27
+
28
+ def decide(self, commit: Commit) -> Bump:
29
+ subject: typing.Final = subject_line(commit.message)
30
+ if self.config.merge_mark_text not in subject:
31
+ return Bump.NONE
32
+ if any(prefix in subject for prefix in self.config.minor):
33
+ return Bump.MINOR
34
+ if any(prefix in subject for prefix in self.config.patch):
35
+ return Bump.PATCH
36
+ return Bump.NONE
@@ -0,0 +1,55 @@
1
+ import dataclasses
2
+ import re
3
+ import typing
4
+
5
+ import pydantic
6
+
7
+ from semvertag._commit_parse import body_lines, subject_line
8
+ from semvertag._types import Bump, Commit
9
+
10
+
11
+ _TYPE_PATTERN: typing.Final = re.compile(r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!?):")
12
+ _VALID_TYPE_RE: typing.Final = re.compile(r"^[a-z]+$")
13
+ _BREAKING_TOKENS: typing.Final = ("BREAKING CHANGE:", "BREAKING-CHANGE:")
14
+
15
+
16
+ class ConventionalCommitsConfig(pydantic.BaseModel):
17
+ model_config = pydantic.ConfigDict(frozen=True)
18
+
19
+ minor_types: tuple[str, ...] = ("feat",)
20
+ patch_types: tuple[str, ...] = ("fix", "perf")
21
+
22
+ @pydantic.field_validator("minor_types", "patch_types")
23
+ @classmethod
24
+ def _validate_types(cls, value: tuple[str, ...]) -> tuple[str, ...]:
25
+ for item in value:
26
+ if not _VALID_TYPE_RE.match(item):
27
+ msg = f"Conventional Commits type {item!r} must match {_VALID_TYPE_RE.pattern}."
28
+ raise ValueError(msg)
29
+ return value
30
+
31
+
32
+ @dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
33
+ class ConventionalCommitsStrategy:
34
+ name: typing.ClassVar[str] = "conventional-commits"
35
+ no_bump_status: typing.ClassVar[str] = "no_conforming_commit"
36
+ no_bump_reason: typing.ClassVar[str] = "No conforming Conventional Commits type found in commit message."
37
+ config: ConventionalCommitsConfig
38
+
39
+ def decide(self, commit: Commit) -> Bump:
40
+ subject: typing.Final = subject_line(commit.message)
41
+ match: typing.Final = _TYPE_PATTERN.match(subject)
42
+ if match is None:
43
+ return Bump.NONE
44
+ for line in body_lines(commit.message):
45
+ stripped = line.lstrip()
46
+ if any(stripped.startswith(token) for token in _BREAKING_TOKENS):
47
+ return Bump.MAJOR
48
+ if match["bang"] == "!":
49
+ return Bump.MAJOR
50
+ commit_type: typing.Final = match["type"]
51
+ if commit_type in self.config.minor_types:
52
+ return Bump.MINOR
53
+ if commit_type in self.config.patch_types:
54
+ return Bump.PATCH
55
+ return Bump.NONE
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: semvertag
3
+ Version: 0.1.0
4
+ Summary: Auto-tag GitLab repos with semantic version tags — one tool, two strategies.
5
+ Keywords: semver,gitlab,ci,auto-tag,conventional-commits
6
+ Author: Artur Shiriev
7
+ Author-email: Artur Shiriev <me@shiriev.ru>
8
+ License-Expression: MIT
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Dist: typer
17
+ Requires-Dist: rich
18
+ Requires-Dist: semver
19
+ Requires-Dist: pydantic-settings
20
+ Requires-Dist: modern-di-typer
21
+ Requires-Dist: httpx2
22
+ Requires-Python: >=3.10, <4
23
+ Project-URL: repository, https://github.com/modern-python/semvertag
24
+ Project-URL: docs, https://semvertag.readthedocs.io
25
+ Description-Content-Type: text/markdown
26
+
27
+ # semvertag
28
+
29
+ [![CI](https://github.com/modern-python/semvertag/actions/workflows/ci.yml/badge.svg)](https://github.com/modern-python/semvertag/actions/workflows/ci.yml)
30
+ [![codecov](https://codecov.io/gh/modern-python/semvertag/branch/main/graph/badge.svg)](https://codecov.io/gh/modern-python/semvertag)
31
+
32
+ Auto-tag your GitLab repository with semantic version tags from CI — one tool, two strategies.
33
+
34
+ ## Install
35
+
36
+ ```sh
37
+ uvx semvertag tag
38
+ ```
39
+
40
+ ## Use it in GitLab CI
41
+
42
+ Include the Catalog component in your `.gitlab-ci.yml`:
43
+
44
+ ```yaml
45
+ include:
46
+ - component: gitlab.com/modern-python/semvertag/semvertag@v0.1.0
47
+ inputs:
48
+ strategy: branch-prefix # or: conventional-commits
49
+ ```
50
+
51
+ The component runs `uvx semvertag tag` against your repo on the
52
+ default branch. semvertag inspects the latest commit + tag history,
53
+ decides the appropriate semver bump, and creates the new tag via the
54
+ GitLab API.
55
+
56
+ ## Strategies
57
+
58
+ - **branch-prefix** (default): the latest commit on the default branch
59
+ must be a merge commit whose source branch starts with `feature/`
60
+ (minor), `bugfix/`, or `hotfix/` (patch).
61
+ - **conventional-commits**: parses the latest commit's
62
+ [Conventional Commits](https://www.conventionalcommits.org/)
63
+ header (`feat:` minor, `fix:`/`perf:` patch, `!` or `BREAKING
64
+ CHANGE:` major).
65
+
66
+ Both are configurable via env vars. See [docs](https://semvertag.readthedocs.io)
67
+ for the full configuration surface.
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,24 @@
1
+ semvertag/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ semvertag/__main__.py,sha256=-qzEvmrAJ33JoeQ0-GKjOOVpS4fHV_N7FZM-k9F2etE,5771
3
+ semvertag/_commit_parse.py,sha256=cHmGJhJEqq0LWgvWVJE2VYYC_lMHed6pKHZtz6Aty4k,759
4
+ semvertag/_errors.py,sha256=hQmfcV3KoQYXz5Vk2jtApyDAljYyyySdl8Qp5J766LY,323
5
+ semvertag/_output.py,sha256=BZ00il_UUj7mZEbhIz5RNtkn489S4W4KDg_O6wXv4QA,2353
6
+ semvertag/_redact.py,sha256=hoj8hGtHcst_zy1rS8pjWKbRoUv6fXrX0dCi-kynaUI,458
7
+ semvertag/_settings.py,sha256=yhDlxS9yD_R_nQzWq-8PVtlt3ofpvG8eryV4ojdXteo,3865
8
+ semvertag/_transport.py,sha256=RTpPjbtQ-n3vf70inKiriFKQ9xWdyZnxIBVtSRBzxoA,3052
9
+ semvertag/_types.py,sha256=Ns5_KwJxi822loxYV_eEhOXYf5VNr6W4-FG90hw5TgE,913
10
+ semvertag/_use_case.py,sha256=ACDvYp8SXjBhn3Dk2inusUXx0v734AmfPPm50pn-dy8,3997
11
+ semvertag/ioc.py,sha256=-cZWS4L5m2RikfSh7yro4bBX5ZrcNN4vKldXhvOZcWY,3456
12
+ semvertag/providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ semvertag/providers/_base.py,sha256=PXZ8VIBBH6kdYaLuFPqDSIABH2wiKpXcNORYKs6Lczg,324
14
+ semvertag/providers/_http.py,sha256=JGYFSAJNbcxMzDaQIW_Y35jJnrk_0j8nvDqiw1WkY9Y,2506
15
+ semvertag/providers/gitlab.py,sha256=IQvbMhLaGajLVL4P9xquFw4W4-xN5igwi8XAkivT5Po,8344
16
+ semvertag/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ semvertag/strategies/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ semvertag/strategies/_base.py,sha256=UYYq11PWoGyydYKDdmECT45rYKaZCBruCkxf1uYDskI,209
19
+ semvertag/strategies/branch_prefix.py,sha256=Jrsk1I2t8VRfwYZH6nL1FfmGbKwu-HCpjN3uQAwBWhY,1314
20
+ semvertag/strategies/conventional_commits.py,sha256=VvOGllsVSfnSJMcPUVFeCtD95_AVE5GYCK8H0HqnBEc,2069
21
+ semvertag-0.1.0.dist-info/WHEEL,sha256=Q9FtwzuR2QE37l-JIkuyklGnJJiCBHKnsPVQ9vzCMzQ,81
22
+ semvertag-0.1.0.dist-info/entry_points.txt,sha256=WKyqu5SBJJDaTJWQHVZL7SuMdn6Xn1FvlVUfQPF2xnU,55
23
+ semvertag-0.1.0.dist-info/METADATA,sha256=LFSITFYu6mdzFSs2dJ-rF7HNhoqZI3nkKiPMRPeQhH8,2396
24
+ semvertag-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ semvertag = semvertag.__main__:main
3
+