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.
Files changed (87) hide show
  1. auto_semver/__init__.py +5 -0
  2. auto_semver/adapters/__init__.py +4 -0
  3. auto_semver/adapters/git/__init__.py +6 -0
  4. auto_semver/adapters/git/base.py +22 -0
  5. auto_semver/adapters/git/constants.py +27 -0
  6. auto_semver/adapters/git/local.py +450 -0
  7. auto_semver/adapters/git/ops.py +17 -0
  8. auto_semver/adapters/git/promote.py +668 -0
  9. auto_semver/adapters/git/release_pr.py +668 -0
  10. auto_semver/adapters/git/verified.py +491 -0
  11. auto_semver/adapters/github/__init__.py +6 -0
  12. auto_semver/adapters/github/event.py +212 -0
  13. auto_semver/cli/__init__.py +6 -0
  14. auto_semver/cli/__main__.py +9 -0
  15. auto_semver/cli/bump.py +448 -0
  16. auto_semver/cli/finalize.py +223 -0
  17. auto_semver/cli/main.py +281 -0
  18. auto_semver/cli/promote.py +211 -0
  19. auto_semver/cli/setup.py +154 -0
  20. auto_semver/cli/utils.py +161 -0
  21. auto_semver/config/__init__.py +48 -0
  22. auto_semver/config/_models/__init__.py +35 -0
  23. auto_semver/config/_models/bump.py +22 -0
  24. auto_semver/config/_models/changelog.py +104 -0
  25. auto_semver/config/_models/commit_group.py +160 -0
  26. auto_semver/config/_models/commit_groups.py +63 -0
  27. auto_semver/config/_models/config.py +247 -0
  28. auto_semver/config/_models/lock_sync.py +48 -0
  29. auto_semver/config/_models/promotion.py +72 -0
  30. auto_semver/config/_models/pull_request.py +186 -0
  31. auto_semver/config/_models/release.py +31 -0
  32. auto_semver/config/config.py +128 -0
  33. auto_semver/config/constants.py +16 -0
  34. auto_semver/core/__init__.py +4 -0
  35. auto_semver/core/changelog/__init__.py +6 -0
  36. auto_semver/core/changelog/manager.py +289 -0
  37. auto_semver/core/commits/__init__.py +7 -0
  38. auto_semver/core/commits/grouper.py +276 -0
  39. auto_semver/core/commits/parser.py +164 -0
  40. auto_semver/core/pr/__init__.py +12 -0
  41. auto_semver/core/pr/builder.py +92 -0
  42. auto_semver/core/pr/github_builder.py +86 -0
  43. auto_semver/core/semver/__init__.py +8 -0
  44. auto_semver/core/semver/lock.py +200 -0
  45. auto_semver/core/semver/updater.py +75 -0
  46. auto_semver/core/semver/version.py +466 -0
  47. auto_semver/lock_sync/__init__.py +40 -0
  48. auto_semver/lock_sync/patch.py +206 -0
  49. auto_semver/lock_sync/pep440.py +56 -0
  50. auto_semver/lock_sync/registry.py +78 -0
  51. auto_semver/lock_sync/runner.py +100 -0
  52. auto_semver/lock_sync/strategies/__init__.py +17 -0
  53. auto_semver/lock_sync/strategies/npm.py +41 -0
  54. auto_semver/lock_sync/strategies/uv.py +41 -0
  55. auto_semver/lock_sync/strategy.py +59 -0
  56. auto_semver/lock_sync/sync.py +178 -0
  57. auto_semver/py.typed +0 -0
  58. auto_semver/setup/__init__.py +4 -0
  59. auto_semver/setup/check.py +93 -0
  60. auto_semver/setup/links.py +120 -0
  61. auto_semver/setup/scaffold.py +119 -0
  62. auto_semver/setup/scaffolds/__init__.py +3 -0
  63. auto_semver/setup/scaffolds/auto-semver.caller.yml +29 -0
  64. auto_semver/setup/scaffolds/auto_semver_config.yml +44 -0
  65. auto_semver/setup/scaffolds/promote.caller.yml +40 -0
  66. auto_semver/templates/__init__.py +26 -0
  67. auto_semver/templates/engine.py +595 -0
  68. auto_semver/templates/types.py +22 -0
  69. auto_semver/templates/utils.py +235 -0
  70. auto_semver_control-1.8.7.dev0.dist-info/METADATA +190 -0
  71. auto_semver_control-1.8.7.dev0.dist-info/RECORD +87 -0
  72. auto_semver_control-1.8.7.dev0.dist-info/WHEEL +5 -0
  73. auto_semver_control-1.8.7.dev0.dist-info/entry_points.txt +2 -0
  74. auto_semver_control-1.8.7.dev0.dist-info/licenses/LICENSE +674 -0
  75. auto_semver_control-1.8.7.dev0.dist-info/top_level.txt +2 -0
  76. runview/__init__.py +111 -0
  77. runview/bridge.py +43 -0
  78. runview/config.py +66 -0
  79. runview/file_sink.py +50 -0
  80. runview/record.py +42 -0
  81. runview/renderers/__init__.py +50 -0
  82. runview/renderers/base.py +76 -0
  83. runview/renderers/github.py +128 -0
  84. runview/renderers/plain.py +122 -0
  85. runview/renderers/rich_live.py +343 -0
  86. runview/reporter.py +178 -0
  87. 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