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 +0 -0
- semvertag/__main__.py +178 -0
- semvertag/_commit_parse.py +29 -0
- semvertag/_errors.py +17 -0
- semvertag/_output.py +78 -0
- semvertag/_redact.py +20 -0
- semvertag/_settings.py +107 -0
- semvertag/_transport.py +84 -0
- semvertag/_types.py +46 -0
- semvertag/_use_case.py +127 -0
- semvertag/ioc.py +101 -0
- semvertag/providers/__init__.py +0 -0
- semvertag/providers/_base.py +12 -0
- semvertag/providers/_http.py +62 -0
- semvertag/providers/gitlab.py +219 -0
- semvertag/py.typed +0 -0
- semvertag/strategies/__init__.py +0 -0
- semvertag/strategies/_base.py +11 -0
- semvertag/strategies/branch_prefix.py +36 -0
- semvertag/strategies/conventional_commits.py +55 -0
- semvertag-0.1.0.dist-info/METADATA +71 -0
- semvertag-0.1.0.dist-info/RECORD +24 -0
- semvertag-0.1.0.dist-info/WHEEL +4 -0
- semvertag-0.1.0.dist-info/entry_points.txt +3 -0
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})
|
semvertag/_transport.py
ADDED
|
@@ -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,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
|
+
[](https://github.com/modern-python/semvertag/actions/workflows/ci.yml)
|
|
30
|
+
[](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,,
|