auto-semver-control 1.8.7.dev0__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.
- auto_semver/__init__.py +5 -0
- auto_semver/adapters/__init__.py +4 -0
- auto_semver/adapters/git/__init__.py +6 -0
- auto_semver/adapters/git/base.py +22 -0
- auto_semver/adapters/git/constants.py +27 -0
- auto_semver/adapters/git/local.py +450 -0
- auto_semver/adapters/git/ops.py +17 -0
- auto_semver/adapters/git/promote.py +668 -0
- auto_semver/adapters/git/release_pr.py +668 -0
- auto_semver/adapters/git/verified.py +491 -0
- auto_semver/adapters/github/__init__.py +6 -0
- auto_semver/adapters/github/event.py +212 -0
- auto_semver/cli/__init__.py +6 -0
- auto_semver/cli/__main__.py +9 -0
- auto_semver/cli/bump.py +448 -0
- auto_semver/cli/finalize.py +223 -0
- auto_semver/cli/main.py +281 -0
- auto_semver/cli/promote.py +211 -0
- auto_semver/cli/setup.py +154 -0
- auto_semver/cli/utils.py +161 -0
- auto_semver/config/__init__.py +48 -0
- auto_semver/config/_models/__init__.py +35 -0
- auto_semver/config/_models/bump.py +22 -0
- auto_semver/config/_models/changelog.py +104 -0
- auto_semver/config/_models/commit_group.py +160 -0
- auto_semver/config/_models/commit_groups.py +63 -0
- auto_semver/config/_models/config.py +247 -0
- auto_semver/config/_models/lock_sync.py +48 -0
- auto_semver/config/_models/promotion.py +72 -0
- auto_semver/config/_models/pull_request.py +186 -0
- auto_semver/config/_models/release.py +31 -0
- auto_semver/config/config.py +128 -0
- auto_semver/config/constants.py +16 -0
- auto_semver/core/__init__.py +4 -0
- auto_semver/core/changelog/__init__.py +6 -0
- auto_semver/core/changelog/manager.py +289 -0
- auto_semver/core/commits/__init__.py +7 -0
- auto_semver/core/commits/grouper.py +276 -0
- auto_semver/core/commits/parser.py +164 -0
- auto_semver/core/pr/__init__.py +12 -0
- auto_semver/core/pr/builder.py +92 -0
- auto_semver/core/pr/github_builder.py +86 -0
- auto_semver/core/semver/__init__.py +8 -0
- auto_semver/core/semver/lock.py +200 -0
- auto_semver/core/semver/updater.py +75 -0
- auto_semver/core/semver/version.py +466 -0
- auto_semver/lock_sync/__init__.py +40 -0
- auto_semver/lock_sync/patch.py +206 -0
- auto_semver/lock_sync/pep440.py +56 -0
- auto_semver/lock_sync/registry.py +78 -0
- auto_semver/lock_sync/runner.py +100 -0
- auto_semver/lock_sync/strategies/__init__.py +17 -0
- auto_semver/lock_sync/strategies/npm.py +41 -0
- auto_semver/lock_sync/strategies/uv.py +41 -0
- auto_semver/lock_sync/strategy.py +59 -0
- auto_semver/lock_sync/sync.py +178 -0
- auto_semver/py.typed +0 -0
- auto_semver/setup/__init__.py +4 -0
- auto_semver/setup/check.py +93 -0
- auto_semver/setup/links.py +120 -0
- auto_semver/setup/scaffold.py +119 -0
- auto_semver/setup/scaffolds/__init__.py +3 -0
- auto_semver/setup/scaffolds/auto-semver.caller.yml +29 -0
- auto_semver/setup/scaffolds/auto_semver_config.yml +44 -0
- auto_semver/setup/scaffolds/promote.caller.yml +40 -0
- auto_semver/templates/__init__.py +26 -0
- auto_semver/templates/engine.py +595 -0
- auto_semver/templates/types.py +22 -0
- auto_semver/templates/utils.py +235 -0
- auto_semver_control-1.8.7.dev0.dist-info/METADATA +190 -0
- auto_semver_control-1.8.7.dev0.dist-info/RECORD +87 -0
- auto_semver_control-1.8.7.dev0.dist-info/WHEEL +5 -0
- auto_semver_control-1.8.7.dev0.dist-info/entry_points.txt +2 -0
- auto_semver_control-1.8.7.dev0.dist-info/licenses/LICENSE +674 -0
- auto_semver_control-1.8.7.dev0.dist-info/top_level.txt +2 -0
- runview/__init__.py +111 -0
- runview/bridge.py +43 -0
- runview/config.py +66 -0
- runview/file_sink.py +50 -0
- runview/record.py +42 -0
- runview/renderers/__init__.py +50 -0
- runview/renderers/base.py +76 -0
- runview/renderers/github.py +128 -0
- runview/renderers/plain.py +122 -0
- runview/renderers/rich_live.py +343 -0
- runview/reporter.py +178 -0
- runview/summary.py +102 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Configuration models for changelog generation."""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from textwrap import dedent
|
|
9
|
+
|
|
10
|
+
from jinja2 import Template, TemplateSyntaxError
|
|
11
|
+
from pydantic import BaseModel, Field, field_serializer, field_validator
|
|
12
|
+
|
|
13
|
+
from auto_semver.config._models.commit_group import CommitGroups
|
|
14
|
+
from auto_semver.config.constants import DEFAULT_CHANGELOG
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ChangelogTemplateVars:
|
|
19
|
+
"""Template variables for changelog rendering.
|
|
20
|
+
|
|
21
|
+
This dataclass defines the exact structure of variables that can be passed
|
|
22
|
+
to changelog templates, providing type safety and clear documentation of available
|
|
23
|
+
template variables.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
version (str): The semantic version being released (e.g., "1.2.3").
|
|
27
|
+
date (str): Formatted date string for the release date.
|
|
28
|
+
messages (list[str]): List of raw commit messages since last version.
|
|
29
|
+
commit_groups (CommitGroups | None): Pre-processed commit groups (optional).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
version: str
|
|
33
|
+
date: str
|
|
34
|
+
messages: list[str]
|
|
35
|
+
commit_groups: CommitGroups | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChangelogConfig(BaseModel):
|
|
39
|
+
"""
|
|
40
|
+
Defines how the changelog should be generated and formatted.
|
|
41
|
+
|
|
42
|
+
Includes file path, overwrite behavior, and Jinja2 templates for
|
|
43
|
+
formatting the changelog entries. The templates support variables such
|
|
44
|
+
as `version`, `date`, and `messages`.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
file (Path): The relative path to the changelog file to update.
|
|
48
|
+
truncate (bool): If True, overwrites the changelog file instead of prepending.
|
|
49
|
+
template (str): A Jinja2 template for formatting the changelog entry.
|
|
50
|
+
header (str | None): Optional header text placed above the changelog entry.
|
|
51
|
+
footer (str | None): Optional footer text placed below the changelog entry.
|
|
52
|
+
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
file: Path = Field(
|
|
56
|
+
default_factory=lambda: Path(DEFAULT_CHANGELOG), description="Path to the changelog file."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
truncate: bool = Field(
|
|
60
|
+
default=False, description="If true, overwrite the changelog instead of prepending."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
template: str = Field(
|
|
64
|
+
default=dedent(
|
|
65
|
+
"""
|
|
66
|
+
## [{{version}}] - {{date}}
|
|
67
|
+
{% for msg in messages -%}
|
|
68
|
+
- {{ msg }}
|
|
69
|
+
{%- endfor %}
|
|
70
|
+
"""
|
|
71
|
+
),
|
|
72
|
+
description="Jinja template for changelog entries.",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
header: str | None = Field(default=None, description="Optional header text for the changelog.")
|
|
76
|
+
footer: str | None = Field(default=None, description="Optional footer text for the changelog.")
|
|
77
|
+
|
|
78
|
+
@field_serializer("file")
|
|
79
|
+
def serialize_path(self, value: Path) -> str:
|
|
80
|
+
"""Serialize Path objects to strings."""
|
|
81
|
+
return str(value)
|
|
82
|
+
|
|
83
|
+
@field_validator("template")
|
|
84
|
+
@classmethod
|
|
85
|
+
def validate_template(cls, value: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Validate that the provided string is a valid Jinja2 template.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
value (str): The template string to validate.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
str: The original value if it's a valid Jinja2 template.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
ValueError: If the template contains syntax errors.
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
# Basic syntax validation using Jinja2 Template
|
|
101
|
+
Template(value)
|
|
102
|
+
return value
|
|
103
|
+
except TemplateSyntaxError as err:
|
|
104
|
+
raise ValueError(f"Invalid Jinja2 template: {err}") from err
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Commit group configuration models."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Constants
|
|
17
|
+
CONVENTIONAL_COMMIT_PARTS = 2
|
|
18
|
+
|
|
19
|
+
type RegexPattern = str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Commit:
|
|
24
|
+
r"""
|
|
25
|
+
Parsed commit message data structure for template rendering.
|
|
26
|
+
|
|
27
|
+
This dataclass represents a parsed commit message with extracted components
|
|
28
|
+
that can be used in Jinja2 templates for changelog and PR generation.
|
|
29
|
+
|
|
30
|
+
Attributes:
|
|
31
|
+
title (str): The cleaned, display-friendly commit title/summary line.
|
|
32
|
+
For conventional commits, this extracts just the description part.
|
|
33
|
+
Used for concise commit listings in changelogs and PRs.
|
|
34
|
+
Example: "add OAuth2 support" (from "feat(auth): add OAuth2 support")
|
|
35
|
+
|
|
36
|
+
body (str | None): The extended commit message body, if present.
|
|
37
|
+
Contains everything after the first line, preserving internal structure.
|
|
38
|
+
None if the commit only has a title line.
|
|
39
|
+
Used for detailed commit information in templates.
|
|
40
|
+
Example: "Implements OAuth2 authentication flow\n\nBREAKING CHANGE: ..."
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
title: str
|
|
44
|
+
body: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class CommitGroup:
|
|
49
|
+
"""
|
|
50
|
+
Runtime data structure representing a single commit group with its commits.
|
|
51
|
+
|
|
52
|
+
This dataclass represents a categorized group of commits that have been matched
|
|
53
|
+
against specific patterns. Used as template data for Jinja2 rendering in
|
|
54
|
+
changelogs and pull requests.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
title (str): The display title for this group of commits.
|
|
58
|
+
This appears as the section header in generated changelogs and PRs.
|
|
59
|
+
Examples: "🚀 Features", "🐛 Bug Fixes", "📝 Documentation"
|
|
60
|
+
|
|
61
|
+
commits (list[Commit]): List of parsed commit objects belonging to this group.
|
|
62
|
+
Each commit contains title and optional body text.
|
|
63
|
+
Sorted in the order they were processed from git history.
|
|
64
|
+
|
|
65
|
+
priority (int): Numeric priority for sorting groups in output.
|
|
66
|
+
Lower numbers appear first in generated content.
|
|
67
|
+
Used to ensure consistent ordering (e.g., Features before Bug Fixes).
|
|
68
|
+
|
|
69
|
+
Note:
|
|
70
|
+
This is typically created by CommitGroupConfig.group_messages() and consumed
|
|
71
|
+
by Jinja2 templates. Templates can iterate over commits and access their
|
|
72
|
+
title/body for formatting.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
Template usage:
|
|
76
|
+
```jinja2
|
|
77
|
+
{% for group in commit_groups %}
|
|
78
|
+
## {{ group.title }}
|
|
79
|
+
{% for commit in group.commits %}
|
|
80
|
+
- {{ commit.title }}
|
|
81
|
+
{% endfor %}
|
|
82
|
+
{% endfor %}
|
|
83
|
+
```
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
title: str
|
|
87
|
+
commits: list[Commit]
|
|
88
|
+
priority: int
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
type CommitGroups = list[CommitGroup]
|
|
92
|
+
"""
|
|
93
|
+
Collection of commit groups for template rendering.
|
|
94
|
+
|
|
95
|
+
This type alias represents the complete data structure returned by commit grouping
|
|
96
|
+
operations and consumed by changelog/PR templates. It contains all categorized
|
|
97
|
+
commits organized by their matching patterns.
|
|
98
|
+
|
|
99
|
+
Note:
|
|
100
|
+
This is the primary data structure passed to Jinja2 templates for rendering
|
|
101
|
+
changelogs and pull request descriptions. Templates iterate over this list
|
|
102
|
+
to generate formatted output with proper grouping and ordering.
|
|
103
|
+
|
|
104
|
+
Example:
|
|
105
|
+
```python
|
|
106
|
+
commit_groups = CommitGroupConfig.group_messages(messages, config_groups)
|
|
107
|
+
# Returns:
|
|
108
|
+
[
|
|
109
|
+
{"title": "🚀 Features", "commits": [...], "priority": 1},
|
|
110
|
+
{"title": "🐛 Bug Fixes", "commits": [...], "priority": 2}
|
|
111
|
+
]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Template usage:
|
|
115
|
+
```jinja2
|
|
116
|
+
{% for group in commit_groups %}
|
|
117
|
+
## {{ group.title }}
|
|
118
|
+
{% for commit in group.commits %}
|
|
119
|
+
- {{ commit.title }}
|
|
120
|
+
{% endfor %}
|
|
121
|
+
{% endfor %}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Note:
|
|
125
|
+
Processing details:
|
|
126
|
+
- Groups are sorted by priority (lower numbers first)
|
|
127
|
+
- Empty groups (no matching commits) are filtered out
|
|
128
|
+
- Unmatched commits are placed in a catch-all or "Other Changes" group
|
|
129
|
+
- Each group maintains the original git history order of its commits
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CommitGroupConfig(BaseModel):
|
|
134
|
+
"""
|
|
135
|
+
Defines a group for categorizing commit messages by regex patterns.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
title (str): Display title for this group of commits.
|
|
139
|
+
patterns (list[str]): List of regex patterns to match commit messages.
|
|
140
|
+
priority (int): Sort priority (lower numbers appear first).
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
title: str = Field(..., description="Display title for this commit group")
|
|
144
|
+
patterns: list[RegexPattern] = Field(..., description="List of regex patterns to match commits")
|
|
145
|
+
match_groups: list[str] = Field(
|
|
146
|
+
default_factory=list,
|
|
147
|
+
description="List of exact commit section headers to match (e.g. 'Bug Fixes')",
|
|
148
|
+
)
|
|
149
|
+
priority: int = Field(..., description="Sort priority (lower numbers first)")
|
|
150
|
+
|
|
151
|
+
@field_validator("patterns")
|
|
152
|
+
@classmethod
|
|
153
|
+
def validate_patterns(cls, patterns: list[RegexPattern]) -> list[RegexPattern]:
|
|
154
|
+
"""Validate that all patterns are valid regex."""
|
|
155
|
+
for pattern in patterns:
|
|
156
|
+
try:
|
|
157
|
+
re.compile(pattern)
|
|
158
|
+
except re.error as err:
|
|
159
|
+
raise ValueError(f"Invalid regex pattern '{pattern}': {err}") from err
|
|
160
|
+
return patterns
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Commit grouping settings and group definitions."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, field_validator
|
|
12
|
+
|
|
13
|
+
from auto_semver.config._models.commit_group import CommitGroupConfig
|
|
14
|
+
|
|
15
|
+
DEFAULT_IGNORE_LINE_PATTERNS: list[str] = [
|
|
16
|
+
r"^\[[ xX]\]",
|
|
17
|
+
r"^Co-authored-by:",
|
|
18
|
+
r"^Made with \[Cursor\]",
|
|
19
|
+
r"^---------",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CommitGroupsConfig(BaseModel):
|
|
24
|
+
"""Commit message grouping settings plus pattern-defined groups."""
|
|
25
|
+
|
|
26
|
+
summary_mode: Literal["header_only", "expand_body"] = Field(
|
|
27
|
+
default="header_only",
|
|
28
|
+
description="header_only emits one entry per git commit; expand_body parses bullets/sections",
|
|
29
|
+
)
|
|
30
|
+
ignore_line_patterns: list[str] = Field(
|
|
31
|
+
default_factory=lambda: list(DEFAULT_IGNORE_LINE_PATTERNS),
|
|
32
|
+
description="Regex patterns for lines dropped before grouping (expand_body mode)",
|
|
33
|
+
)
|
|
34
|
+
groups: list[CommitGroupConfig] = Field(
|
|
35
|
+
default_factory=list,
|
|
36
|
+
description="Pattern-defined commit groups for changelog and release PR templates",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@field_validator("ignore_line_patterns")
|
|
40
|
+
@classmethod
|
|
41
|
+
def validate_ignore_patterns(cls, patterns: list[str]) -> list[str]:
|
|
42
|
+
"""Validate ignore-line regex patterns."""
|
|
43
|
+
for pattern in patterns:
|
|
44
|
+
try:
|
|
45
|
+
re.compile(pattern)
|
|
46
|
+
except re.error as err:
|
|
47
|
+
msg = f"Invalid ignore_line_patterns regex '{pattern}': {err}"
|
|
48
|
+
raise ValueError(msg) from err
|
|
49
|
+
return patterns
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_yaml(cls, value: object | None) -> CommitGroupsConfig:
|
|
53
|
+
"""Parse commit_groups from legacy list or structured dict YAML."""
|
|
54
|
+
if value is None:
|
|
55
|
+
return cls()
|
|
56
|
+
if isinstance(value, list):
|
|
57
|
+
return cls(groups=value)
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
groups = value.get("groups", [])
|
|
60
|
+
settings = {k: v for k, v in value.items() if k != "groups"}
|
|
61
|
+
return cls(groups=groups, **settings)
|
|
62
|
+
msg = f"commit_groups must be a list or mapping, got {type(value).__name__}"
|
|
63
|
+
raise TypeError(msg)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Main configuration data model."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field, field_serializer, field_validator, model_validator
|
|
11
|
+
|
|
12
|
+
from auto_semver.config._models.bump import BumpConfig
|
|
13
|
+
from auto_semver.config._models.changelog import ChangelogConfig
|
|
14
|
+
from auto_semver.config._models.commit_group import CommitGroups
|
|
15
|
+
from auto_semver.config._models.commit_groups import CommitGroupsConfig
|
|
16
|
+
from auto_semver.config._models.lock_sync import LockSyncConfig
|
|
17
|
+
from auto_semver.config._models.promotion import PromotionRule
|
|
18
|
+
from auto_semver.config._models.pull_request import PullRequestConfig
|
|
19
|
+
from auto_semver.config._models.release import ReleaseConfig
|
|
20
|
+
from auto_semver.core.semver import Version
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigData(BaseModel):
|
|
26
|
+
"""
|
|
27
|
+
Holds all validated configuration values loaded from auto_semver_config.yml.
|
|
28
|
+
|
|
29
|
+
This model represents the user-defined configuration for the auto-semver
|
|
30
|
+
process, including versioning behavior, changelog generation, PR metadata, and
|
|
31
|
+
supported branch strategies. It ensures that required fields are present and typed.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
start_version (Version): The default version to use if no version file is found.
|
|
35
|
+
suffixes (dict[str, str]): Mapping of target branches to version suffixes
|
|
36
|
+
(e.g., {"main": "", "dev": "-dev"}).
|
|
37
|
+
version_files (list[str]): List of files to update with the new version string.
|
|
38
|
+
pull_request (PullRequestConfig): Configuration for the pull request title, body, and labels.
|
|
39
|
+
changelog (_ChangelogConfig): Changelog file behavior and formatting templates.
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
start_version: Version = Field(
|
|
44
|
+
default_factory=lambda: Version.parse("0.1.0"), description="Optional start_version."
|
|
45
|
+
)
|
|
46
|
+
suffixes: dict[str, str] = Field(..., description="Required branch suffix mapping.")
|
|
47
|
+
promotions: list[PromotionRule] = Field(
|
|
48
|
+
..., description="Allowed branch promotion rules (from → to)."
|
|
49
|
+
)
|
|
50
|
+
version_files: list[str] = Field(
|
|
51
|
+
default=["version.txt"], description="Optional files that hold version format to update."
|
|
52
|
+
)
|
|
53
|
+
commit_groups: CommitGroupsConfig = Field(
|
|
54
|
+
default_factory=CommitGroupsConfig,
|
|
55
|
+
description="Commit grouping settings and pattern-defined groups for templates",
|
|
56
|
+
)
|
|
57
|
+
release: ReleaseConfig = Field(
|
|
58
|
+
default_factory=ReleaseConfig,
|
|
59
|
+
description="Release branch lifecycle (single vs multi, prefix, cleanup)",
|
|
60
|
+
)
|
|
61
|
+
bump: BumpConfig = Field(
|
|
62
|
+
default_factory=BumpConfig,
|
|
63
|
+
description="Version bump mode (classic semver vs cumulative)",
|
|
64
|
+
)
|
|
65
|
+
lock_sync: LockSyncConfig = Field(
|
|
66
|
+
default_factory=LockSyncConfig,
|
|
67
|
+
description="Package-manager lockfile sync after version-file rewrites",
|
|
68
|
+
)
|
|
69
|
+
pull_request: PullRequestConfig
|
|
70
|
+
changelog: ChangelogConfig
|
|
71
|
+
|
|
72
|
+
@field_validator("commit_groups", mode="before")
|
|
73
|
+
@classmethod
|
|
74
|
+
def parse_commit_groups(cls, value: CommitGroupsConfig | object | None) -> CommitGroupsConfig:
|
|
75
|
+
"""Accept legacy YAML list format or structured commit_groups mapping."""
|
|
76
|
+
if isinstance(value, CommitGroupsConfig):
|
|
77
|
+
return value
|
|
78
|
+
return CommitGroupsConfig.from_yaml(value)
|
|
79
|
+
|
|
80
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
81
|
+
|
|
82
|
+
@field_validator("start_version", mode="before")
|
|
83
|
+
@classmethod
|
|
84
|
+
def validate_version(cls, value: str | Version) -> Version:
|
|
85
|
+
"""Validate and parse the start_version field."""
|
|
86
|
+
if isinstance(value, Version):
|
|
87
|
+
return value
|
|
88
|
+
try:
|
|
89
|
+
return Version.parse(value)
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
@field_serializer("start_version")
|
|
94
|
+
def serialize_version(self, value: Version) -> str:
|
|
95
|
+
"""Serialize Version objects to strings."""
|
|
96
|
+
return str(value)
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="after")
|
|
99
|
+
def validate_promotions_have_suffixes(self) -> ConfigData:
|
|
100
|
+
"""Validate that all branches in promotion rules have suffix definitions."""
|
|
101
|
+
# Validate that all promotion targets have suffixes
|
|
102
|
+
missing_suffixes = {
|
|
103
|
+
rule.to_branch for rule in self.promotions if rule.to_branch not in self.suffixes
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if missing_suffixes:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"The following promotion targets are missing suffix definitions: {sorted(missing_suffixes)}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Validate no reverse rules exist
|
|
112
|
+
seen_pairs = set()
|
|
113
|
+
for rule in self.promotions:
|
|
114
|
+
reverse = (rule.to_branch, rule.from_branch)
|
|
115
|
+
if reverse in seen_pairs:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Invalid promotion configuration: reverse rule found for "
|
|
118
|
+
f"'{rule.from_branch} → {rule.to_branch}'"
|
|
119
|
+
)
|
|
120
|
+
seen_pairs.add((rule.from_branch, rule.to_branch))
|
|
121
|
+
|
|
122
|
+
return self
|
|
123
|
+
|
|
124
|
+
@model_validator(mode="after")
|
|
125
|
+
def validate_unique_patterns(self) -> ConfigData:
|
|
126
|
+
"""
|
|
127
|
+
Validate that regex patterns are unique across all commit groups.
|
|
128
|
+
|
|
129
|
+
This ensures deterministic grouping of commits. If a pattern appears
|
|
130
|
+
in multiple groups, it creates ambiguity about which group a commit
|
|
131
|
+
should belong to.
|
|
132
|
+
"""
|
|
133
|
+
seen_patterns: dict[str, str] = {}
|
|
134
|
+
|
|
135
|
+
for group in self.commit_groups.groups:
|
|
136
|
+
for pattern in group.patterns:
|
|
137
|
+
# We check for exact string matches.
|
|
138
|
+
# Semantically identical regexes (e.g. [a-z] vs [a-z]) are hard to detect,
|
|
139
|
+
# but catching exact duplicates prevents most configuration errors.
|
|
140
|
+
if pattern in seen_patterns:
|
|
141
|
+
existing_group = seen_patterns[pattern]
|
|
142
|
+
# Allow duplicate patterns only if they are the catch-all ".*"
|
|
143
|
+
# strictly speaking catch-all should also be unique, but let's be strict for now.
|
|
144
|
+
if pattern == ".*":
|
|
145
|
+
logger.debug("Duplicate catch-all pattern '.*' detected, permitting.")
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"Duplicate pattern '{pattern}' found in groups: "
|
|
150
|
+
f"'{existing_group}' and '{group.title}'. "
|
|
151
|
+
"Patterns must be unique to ensure deterministic grouping."
|
|
152
|
+
)
|
|
153
|
+
seen_patterns[pattern] = group.title
|
|
154
|
+
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
def group_commit_messages(self, messages: list[str]) -> CommitGroups:
|
|
158
|
+
"""
|
|
159
|
+
Group commit messages using the configured commit groups.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
messages: List of commit messages to group
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
CommitGroups: list of CommitGroup dataclasses for template rendering.
|
|
166
|
+
"""
|
|
167
|
+
# Lazy import: config package init must not pull core.commits at module load.
|
|
168
|
+
from auto_semver.core.commits.grouper import CommitGrouper # noqa: PLC0415
|
|
169
|
+
|
|
170
|
+
return CommitGrouper.group_messages(messages, self.commit_groups)
|
|
171
|
+
|
|
172
|
+
def find_promotion_rule(self, *, from_branch: str, to_branch: str) -> PromotionRule | None:
|
|
173
|
+
"""
|
|
174
|
+
Find a promotion rule that allows promotion from source to target branch.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
from_branch: Source branch name
|
|
178
|
+
to_branch: Target branch name
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
PromotionRule if found, None otherwise
|
|
182
|
+
"""
|
|
183
|
+
for rule in self.promotions:
|
|
184
|
+
if rule.from_branch == from_branch and rule.to_branch == to_branch:
|
|
185
|
+
return rule
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def validate_promotion(
|
|
189
|
+
self, *, from_branch: str, to_branch: str, require_auto_promote: bool = False
|
|
190
|
+
) -> PromotionRule:
|
|
191
|
+
"""
|
|
192
|
+
Validate that a promotion between branches is allowed by configuration.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
from_branch: Source branch name
|
|
196
|
+
to_branch: Target branch name
|
|
197
|
+
require_auto_promote: If True, only allow promotions with auto_promote=True
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
PromotionRule: The matching promotion rule
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ValueError: If promotion is not allowed
|
|
204
|
+
"""
|
|
205
|
+
# Check if both branches are configured
|
|
206
|
+
if from_branch not in self.suffixes:
|
|
207
|
+
raise ValueError(f"Source branch '{from_branch}' is not configured in suffixes")
|
|
208
|
+
|
|
209
|
+
if to_branch not in self.suffixes:
|
|
210
|
+
raise ValueError(f"Target branch '{to_branch}' is not configured in suffixes")
|
|
211
|
+
|
|
212
|
+
# Find promotion rule
|
|
213
|
+
rule = self.find_promotion_rule(from_branch=from_branch, to_branch=to_branch)
|
|
214
|
+
|
|
215
|
+
if rule is None:
|
|
216
|
+
raise ValueError(
|
|
217
|
+
f"No promotion rule found from '{from_branch}' to '{to_branch}'. "
|
|
218
|
+
f"Available rules: {[(r.from_branch, r.to_branch) for r in self.promotions]}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Check auto_promote requirement if specified
|
|
222
|
+
if require_auto_promote and not rule.auto_promote:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"Promotion from '{from_branch}' to '{to_branch}' is not configured for auto-promotion"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
logger.info(
|
|
228
|
+
f"Promotion validated: {from_branch} → {to_branch} (auto_promote={rule.auto_promote})"
|
|
229
|
+
)
|
|
230
|
+
return rule
|
|
231
|
+
|
|
232
|
+
def get_auto_promotion_targets(self, *, from_branch: str) -> list[str]:
|
|
233
|
+
"""
|
|
234
|
+
Get all target branches that can be auto-promoted from the given branch.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
from_branch: Source branch name
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
List of target branch names that have auto_promote=True
|
|
241
|
+
"""
|
|
242
|
+
targets = []
|
|
243
|
+
for rule in self.promotions:
|
|
244
|
+
if rule.from_branch == from_branch and rule.auto_promote:
|
|
245
|
+
targets.append(rule.to_branch)
|
|
246
|
+
|
|
247
|
+
return targets
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Package-manager lockfile sync configuration."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
9
|
+
|
|
10
|
+
from auto_semver.lock_sync.registry import default_registry, ensure_builtins_registered
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LockSyncConfig(BaseModel):
|
|
14
|
+
"""Controls post-bump sync of language package lockfiles.
|
|
15
|
+
|
|
16
|
+
A missing host ``uv`` or ``npm`` patches only the project version. There is
|
|
17
|
+
no skip/fail switch for that case. Older configs may still contain
|
|
18
|
+
``on_missing``; that key is ignored.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(extra="ignore")
|
|
22
|
+
|
|
23
|
+
enabled: bool = Field(
|
|
24
|
+
default=True,
|
|
25
|
+
description="When true, sync known lockfiles after version-file rewrites",
|
|
26
|
+
)
|
|
27
|
+
ecosystems: list[str] | None = Field(
|
|
28
|
+
default=None,
|
|
29
|
+
description=(
|
|
30
|
+
"Allow-list of ecosystems to sync (registered strategy names). "
|
|
31
|
+
"Omit to auto-detect every known lockfile present at the repo root."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@field_validator("ecosystems", mode="before")
|
|
36
|
+
@classmethod
|
|
37
|
+
def validate_ecosystems(cls, value: object) -> object:
|
|
38
|
+
"""Reject unknown ecosystem names early with a clear message."""
|
|
39
|
+
if value is None:
|
|
40
|
+
return value
|
|
41
|
+
if not isinstance(value, list):
|
|
42
|
+
raise ValueError("ecosystems must be a list of ecosystem names")
|
|
43
|
+
ensure_builtins_registered()
|
|
44
|
+
known = default_registry.names()
|
|
45
|
+
unknown = [item for item in value if item not in known]
|
|
46
|
+
if unknown:
|
|
47
|
+
raise ValueError(f"Unknown lock_sync ecosystems: {unknown}. Supported: {sorted(known)}")
|
|
48
|
+
return value
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Promotion rule models."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field, field_validator
|
|
9
|
+
|
|
10
|
+
# Constants for branch name validation
|
|
11
|
+
MIN_BRANCH_NAME_LENGTH = 1
|
|
12
|
+
MAX_BRANCH_NAME_LENGTH = 250
|
|
13
|
+
|
|
14
|
+
# Git branch name validation pattern
|
|
15
|
+
BRANCH_NAME_PATTERN = re.compile(
|
|
16
|
+
r"^(?!.*(?:\.lock$|\.lock/|/\.|\.{2}|@{|\\))" # Cannot end with .lock, contain /.., @{, or \
|
|
17
|
+
r"(?!.*(?://|^/|/$))" # Cannot contain //, start/end with /
|
|
18
|
+
r"(?![~^:\s?*\[\]])" # Cannot start with ~^: or contain space?*[]
|
|
19
|
+
r"[^\000-\037\177~^:\s?*\[\]]*" # Cannot contain control chars, space, ~^:?*[]
|
|
20
|
+
r"(?<!\.lock)$" # Cannot end with .lock
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
type BranchName = str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromotionRule(BaseModel):
|
|
27
|
+
"""Represents a rule for promoting versions between branches."""
|
|
28
|
+
|
|
29
|
+
from_branch: BranchName = Field(
|
|
30
|
+
...,
|
|
31
|
+
min_length=MIN_BRANCH_NAME_LENGTH,
|
|
32
|
+
max_length=MAX_BRANCH_NAME_LENGTH,
|
|
33
|
+
description="Source branch name",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
to_branch: BranchName = Field(
|
|
37
|
+
...,
|
|
38
|
+
min_length=MIN_BRANCH_NAME_LENGTH,
|
|
39
|
+
max_length=MAX_BRANCH_NAME_LENGTH,
|
|
40
|
+
description="Target branch name",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
auto_promote: bool = Field(
|
|
44
|
+
default=False,
|
|
45
|
+
description=(
|
|
46
|
+
"Whether to automatically promote (direct branch update + tag) after "
|
|
47
|
+
"tagging the source branch. Does not open a promotion PR."
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@field_validator("from_branch", "to_branch")
|
|
52
|
+
@classmethod
|
|
53
|
+
def validate_branch_name(cls, value: str) -> str:
|
|
54
|
+
"""
|
|
55
|
+
Validate that the branch name follows Git branch naming rules.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
value: The branch name to validate.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
str: The validated branch name.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If the branch name is invalid.
|
|
65
|
+
"""
|
|
66
|
+
if not BRANCH_NAME_PATTERN.match(value):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Invalid branch name '{value}'. Branch names must follow Git naming rules: "
|
|
69
|
+
"cannot contain spaces, control characters, or special sequences like //, .., "
|
|
70
|
+
"cannot start with /, cannot end with .lock, etc."
|
|
71
|
+
)
|
|
72
|
+
return value
|