auto-semver-control 1.8.7__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.dist-info/METADATA +190 -0
- auto_semver_control-1.8.7.dist-info/RECORD +87 -0
- auto_semver_control-1.8.7.dist-info/WHEEL +5 -0
- auto_semver_control-1.8.7.dist-info/entry_points.txt +2 -0
- auto_semver_control-1.8.7.dist-info/licenses/LICENSE +674 -0
- auto_semver_control-1.8.7.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
auto_semver/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Shared attribute declarations for the GitOps composition."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from git import Repo
|
|
12
|
+
from github import Github
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitOpsBase:
|
|
16
|
+
"""Attribute surface shared by the GitOps composition on ``self``."""
|
|
17
|
+
|
|
18
|
+
repo: Repo
|
|
19
|
+
github_token: str | None
|
|
20
|
+
signed_commits: bool
|
|
21
|
+
_repo_full_name: str
|
|
22
|
+
_github_clients: dict[str, Github]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Shared constants for git adapters."""
|
|
5
|
+
|
|
6
|
+
LEGACY_RELEASE_PREFIX = "release/"
|
|
7
|
+
DEFAULT_RELEASE_PREFIX = "auto-semver/release/"
|
|
8
|
+
|
|
9
|
+
# Git tree modes that GraphQL createCommitOnBranch cannot express (FileAddition
|
|
10
|
+
# always lands as 100644). REST Git Data is required for these.
|
|
11
|
+
_REST_TREE_MODES = frozenset({"100755", "120000"})
|
|
12
|
+
_KNOWN_GIT_FILE_MODES = frozenset({"100644", "100755", "120000", "040000", "160000"})
|
|
13
|
+
_RAW_DIFF_MIN_MODE_FIELDS = 2
|
|
14
|
+
|
|
15
|
+
# GraphQL mutation that GitHub auto-signs (verified) in one request for multiple
|
|
16
|
+
# file changes. Default for ordinary 100644 metadata. When the tree needs
|
|
17
|
+
# 100755/120000 or a dest↔source mode change, use the REST Git Data fallback
|
|
18
|
+
# (#286) which must still produce verification.verified=true.
|
|
19
|
+
_CREATE_COMMIT_ON_BRANCH_MUTATION = """
|
|
20
|
+
mutation($input: CreateCommitOnBranchInput!) {
|
|
21
|
+
createCommitOnBranch(input: $input) {
|
|
22
|
+
commit {
|
|
23
|
+
oid
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
"""
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Local GitPython plumbing."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
13
|
+
|
|
14
|
+
from git import Actor, Commit, GitCommandError, Head, Repo
|
|
15
|
+
from git.remote import PushInfo, Remote
|
|
16
|
+
|
|
17
|
+
from auto_semver.adapters.git.base import GitOpsBase
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__package__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitLocal(GitOpsBase):
|
|
26
|
+
"""Local GitPython plumbing."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
repo_path: str = ".",
|
|
32
|
+
ensure_safe: bool = False,
|
|
33
|
+
signed_commits: bool = False,
|
|
34
|
+
github_token: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Initialize GitOps for the given repository path.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
repo_path (str): Path to the local Git repository (default: current directory).
|
|
41
|
+
ensure_safe (bool): If True, ensures the repository is marked as a safe directory in Git config.
|
|
42
|
+
signed_commits (bool): When True, create commits/merges/tags via the GitHub API so
|
|
43
|
+
GitHub signs them as verified (requires ``github_token``).
|
|
44
|
+
github_token (str | None): Token for API-backed commits (App installation token).
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
self.repo = Repo(path=repo_path)
|
|
49
|
+
self._repo_full_name: str = self._parse_repository_name() # Cache repository name on init
|
|
50
|
+
self.signed_commits = signed_commits
|
|
51
|
+
self.github_token = github_token
|
|
52
|
+
self._github_clients = {}
|
|
53
|
+
if signed_commits and not github_token:
|
|
54
|
+
raise ValueError("github_token is required when signed_commits=True")
|
|
55
|
+
if ensure_safe:
|
|
56
|
+
self.__ensure_git_safe_directory()
|
|
57
|
+
self._identity = self.__ensure_git_identity()
|
|
58
|
+
|
|
59
|
+
def __ensure_git_safe_directory(self) -> None:
|
|
60
|
+
"""
|
|
61
|
+
Ensure the repository path is listed as a safe Git directory in the repository config.
|
|
62
|
+
|
|
63
|
+
This is important for CI environments that may require explicitly trusting the repo.
|
|
64
|
+
Uses repository-level config which doesn't require elevated privileges and is scoped to this repo.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
logger.debug("Ensuring the repository is marked as a safe directory.")
|
|
68
|
+
|
|
69
|
+
safe_key: str = "safe"
|
|
70
|
+
directory_key: str = "directory"
|
|
71
|
+
|
|
72
|
+
path: str = str(self.repo.working_tree_dir)
|
|
73
|
+
|
|
74
|
+
logger.debug(f"Working tree directory: {path}")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
git_config = self.repo.config_writer(config_level="global")
|
|
78
|
+
|
|
79
|
+
logger.debug(f"Checking if {path} is in safe directories.")
|
|
80
|
+
|
|
81
|
+
raw_values = git_config.get_values(section=safe_key, option=directory_key, default="")
|
|
82
|
+
safe_dirs: list[str] = [v for v in raw_values if isinstance(v, str)]
|
|
83
|
+
|
|
84
|
+
if path not in safe_dirs:
|
|
85
|
+
logger.debug(f"{path} is not in safe directories.")
|
|
86
|
+
logger.debug(f"Adding {path} to safe directories.")
|
|
87
|
+
|
|
88
|
+
git_config.set_value(section=safe_key, option=directory_key, value=path)
|
|
89
|
+
|
|
90
|
+
git_config.release()
|
|
91
|
+
|
|
92
|
+
except (OSError, PermissionError) as e:
|
|
93
|
+
logger.error(f"Failed to configure git safe directory due to permission error: {e}")
|
|
94
|
+
logger.error(
|
|
95
|
+
"Git safe directory configuration is required for proper operation in CI environments."
|
|
96
|
+
)
|
|
97
|
+
raise RuntimeError(f"Unable to configure git safe directory: {e}") from e
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def _redact_remote_url(url: str) -> str:
|
|
101
|
+
"""Strip userinfo (e.g. tokens) from a remote URL before logging or raising."""
|
|
102
|
+
try:
|
|
103
|
+
parts = urlsplit(url)
|
|
104
|
+
if parts.scheme in {"http", "https"} and (parts.username or parts.password):
|
|
105
|
+
host = parts.hostname or ""
|
|
106
|
+
if parts.port:
|
|
107
|
+
host = f"{host}:{parts.port}"
|
|
108
|
+
return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
|
|
109
|
+
except ValueError:
|
|
110
|
+
pass
|
|
111
|
+
# Fallback for non-standard URLs that still embed userinfo before '@'
|
|
112
|
+
return re.sub(r"(https?://)[^/@\s]+@", r"\1", url)
|
|
113
|
+
|
|
114
|
+
def _parse_repository_name(self, *, remote_name: str = "origin") -> str:
|
|
115
|
+
"""
|
|
116
|
+
Extract the repository name from the Git remote URL.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
remote_name (str): Name of the Git remote (default: 'origin').
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
str: Repository name in "owner/repo" format.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If remote URL cannot be parsed or is not a GitHub URL.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
remote: Remote = self.repo.remote(name=remote_name)
|
|
129
|
+
remote_url = remote.url
|
|
130
|
+
|
|
131
|
+
# Handle both SSH and HTTPS GitHub URLs
|
|
132
|
+
# SSH: git@github.com:owner/repo.git
|
|
133
|
+
# HTTPS: https://github.com/owner/repo.git
|
|
134
|
+
|
|
135
|
+
patterns = [
|
|
136
|
+
r"git@github\.com:([^/]+)/(.+?)(?:\.git)?$", # SSH format
|
|
137
|
+
r"https://(?:.*?@)?github\.com/([^/]+)/(.+?)(?:\.git)?$", # HTTPS format (supports auth)
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
for pattern in patterns:
|
|
141
|
+
match = re.match(pattern, remote_url)
|
|
142
|
+
if match:
|
|
143
|
+
owner, repo = match.groups()
|
|
144
|
+
return f"{owner}/{repo}"
|
|
145
|
+
|
|
146
|
+
safe_url = self._redact_remote_url(remote_url)
|
|
147
|
+
raise ValueError(f"Unable to parse GitHub repository from remote URL: {safe_url}")
|
|
148
|
+
|
|
149
|
+
except ValueError:
|
|
150
|
+
raise
|
|
151
|
+
except Exception as e:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Failed to get repository name from remote '{remote_name}': {e}"
|
|
154
|
+
) from e
|
|
155
|
+
|
|
156
|
+
def get_repository_name(self) -> str:
|
|
157
|
+
"""
|
|
158
|
+
Get the cached repository name.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
str: Repository name in "owner/repo" format.
|
|
162
|
+
"""
|
|
163
|
+
return self._repo_full_name
|
|
164
|
+
|
|
165
|
+
def create_branch(self, *, branch_name: str, force: bool = False) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Create a new Git branch or overwrites an existing one if specified.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
branch_name (str): The name of the branch to create.
|
|
171
|
+
force (bool): If True, overwrites the existing branch with the same name.
|
|
172
|
+
Defaults to False, which prevents overwriting.
|
|
173
|
+
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
if branch_name in self.repo.heads:
|
|
177
|
+
if not force:
|
|
178
|
+
logger.info(f"Branch '{branch_name}' already exists and force is False.")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
logger.info(f"Deleting existing branch '{branch_name}'")
|
|
182
|
+
|
|
183
|
+
existing_branch: Head = self.repo.heads[branch_name]
|
|
184
|
+
existing_branch.delete(repo=self.repo, force=True)
|
|
185
|
+
|
|
186
|
+
logger.info(f"Creating new branch '{branch_name}'")
|
|
187
|
+
|
|
188
|
+
new_branch: Head = self.repo.create_head(path=branch_name)
|
|
189
|
+
new_branch.checkout()
|
|
190
|
+
|
|
191
|
+
def add(self, files: list[str] | list[Path] | list[str | Path]) -> None:
|
|
192
|
+
"""
|
|
193
|
+
Stage the specified files for commit.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
files (list[str]): List of file paths to add to the Git index.
|
|
197
|
+
|
|
198
|
+
"""
|
|
199
|
+
files = [str(f) for f in files]
|
|
200
|
+
|
|
201
|
+
logger.info(f"Adding files: {files}")
|
|
202
|
+
|
|
203
|
+
if not self.repo.is_dirty(untracked_files=True):
|
|
204
|
+
logger.warning("Repo is not dirty — no changes staged or committed.")
|
|
205
|
+
else:
|
|
206
|
+
logger.debug("Repo has staged/committed changes.")
|
|
207
|
+
|
|
208
|
+
for file_path in files:
|
|
209
|
+
try:
|
|
210
|
+
self.repo.index.add(items=[file_path])
|
|
211
|
+
|
|
212
|
+
logger.debug(f"Added {file_path} to git.")
|
|
213
|
+
|
|
214
|
+
except GitCommandError as err:
|
|
215
|
+
logger.error(f"Failed to add {file_path} to git: {err}")
|
|
216
|
+
raise
|
|
217
|
+
|
|
218
|
+
def commit(self, message: str, *, force: bool = False) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Commit staged changes with the provided message.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
message (str): Commit message.
|
|
224
|
+
force (bool): Kept for compatibility with signed commits. GraphQL
|
|
225
|
+
``createCommitOnBranch`` cannot force-update divergent refs;
|
|
226
|
+
concurrent updates retry once on ``expectedHeadOid`` mismatch.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
logger.info(f"Committing changes with message: {message}")
|
|
230
|
+
logger.debug(f"Staged changes: {self.repo.index.diff('HEAD')}")
|
|
231
|
+
|
|
232
|
+
if self.signed_commits:
|
|
233
|
+
branch_name = self.repo.active_branch.name
|
|
234
|
+
additions, deletions = self._collect_staged_file_changes()
|
|
235
|
+
if not additions and not deletions:
|
|
236
|
+
logger.warning("No staged files for signed commit")
|
|
237
|
+
return
|
|
238
|
+
commit_sha = self._api_commit_files_on_branch(
|
|
239
|
+
branch_name=branch_name,
|
|
240
|
+
message=message,
|
|
241
|
+
file_paths=additions,
|
|
242
|
+
deletions=deletions,
|
|
243
|
+
force=force,
|
|
244
|
+
)
|
|
245
|
+
# GraphQL commits update the remote only; sync the runner worktree so
|
|
246
|
+
# later checkouts (e.g. auto-promote to staging) are not blocked by
|
|
247
|
+
# leftover dirty files such as .semver.lock.
|
|
248
|
+
self.repo.git.reset("--hard", commit_sha)
|
|
249
|
+
logger.info("Synced local worktree to signed commit %s", commit_sha)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
self._local_commit(message)
|
|
253
|
+
logger.info("Committed changes.")
|
|
254
|
+
|
|
255
|
+
def _local_commit(self, message: str) -> Commit:
|
|
256
|
+
"""
|
|
257
|
+
Create a local commit as the bot, bypassing repository hooks.
|
|
258
|
+
|
|
259
|
+
Two deliberate deviations from ``git commit``:
|
|
260
|
+
- ``GIT_AUTHOR_*``/``GIT_COMMITTER_*`` are ignored so bot attribution is
|
|
261
|
+
deterministic; ``user.*`` config is the override channel.
|
|
262
|
+
- Hooks are skipped (``git commit -n``). Lint/commit-msg policy belongs on
|
|
263
|
+
the PR, and the signed GraphQL path cannot run hooks, so skipping keeps
|
|
264
|
+
both paths symmetric.
|
|
265
|
+
"""
|
|
266
|
+
return self.repo.index.commit(
|
|
267
|
+
message, author=self._identity, committer=self._identity, skip_hooks=True
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def push(self, *, branch_name: str, remote_name: str = "origin", force: bool = False) -> None:
|
|
271
|
+
"""
|
|
272
|
+
Push the specified branch to the remote repository, optionally forcing the push.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
branch_name (str): The branch to push.
|
|
276
|
+
remote_name (str): Name of the Git remote (default: 'origin').
|
|
277
|
+
force (bool): If True, force push the branch.
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
logger.info(f"Pushing branch '{branch_name}' to remote '{remote_name}' with force={force}.")
|
|
282
|
+
|
|
283
|
+
if self.signed_commits:
|
|
284
|
+
logger.info(
|
|
285
|
+
"Signed commit path: branch/tag refs updated via GitHub API; syncing local clone"
|
|
286
|
+
)
|
|
287
|
+
self.fetch(remote_name=remote_name)
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
remote: Remote = self.repo.remote(name=remote_name)
|
|
292
|
+
push_infos = remote.push(refspec=branch_name, force=force)
|
|
293
|
+
|
|
294
|
+
for info in push_infos:
|
|
295
|
+
if info.flags & (
|
|
296
|
+
PushInfo.ERROR
|
|
297
|
+
| PushInfo.REJECTED
|
|
298
|
+
| PushInfo.REMOTE_REJECTED
|
|
299
|
+
| PushInfo.REMOTE_FAILURE
|
|
300
|
+
):
|
|
301
|
+
error_msg = f"Push failed for {branch_name}: {info.summary}"
|
|
302
|
+
if info.flags & PushInfo.REJECTED:
|
|
303
|
+
error_msg += ". Check if remote has diverged or if 'force' is required."
|
|
304
|
+
|
|
305
|
+
logger.error(error_msg)
|
|
306
|
+
raise RuntimeError(error_msg)
|
|
307
|
+
|
|
308
|
+
logger.debug(f"Push result: {push_infos}")
|
|
309
|
+
|
|
310
|
+
except GitCommandError as err:
|
|
311
|
+
logger.error(f"Failed to push branch '{branch_name}' to remote '{remote_name}': {err}")
|
|
312
|
+
raise
|
|
313
|
+
|
|
314
|
+
def tag(self, *, tag: str, branch: str) -> str:
|
|
315
|
+
"""
|
|
316
|
+
Create a new tag on the given branch.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
tag (str): Tag name.
|
|
320
|
+
branch (str): Branch name.
|
|
321
|
+
|
|
322
|
+
"""
|
|
323
|
+
if self.signed_commits:
|
|
324
|
+
gh_repo = self._gh_repo()
|
|
325
|
+
branch_ref = gh_repo.get_git_ref(f"heads/{branch}")
|
|
326
|
+
return self._api_create_lightweight_tag(tag=tag, sha=branch_ref.object.sha)
|
|
327
|
+
|
|
328
|
+
return self.repo.create_tag(path=tag, ref=branch, message="").name
|
|
329
|
+
|
|
330
|
+
def fetch(self, *, remote_name: str = "origin") -> None:
|
|
331
|
+
"""
|
|
332
|
+
Fetch all refs from the remote repository.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
remote_name (str): Name of the Git remote (default: 'origin').
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
GitCommandError: If fetch operation fails.
|
|
339
|
+
"""
|
|
340
|
+
logger.info(f"Fetching from remote '{remote_name}'")
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
remote: Remote = self.repo.remote(name=remote_name)
|
|
344
|
+
remote.fetch()
|
|
345
|
+
logger.debug(f"Fetch from '{remote_name}' completed")
|
|
346
|
+
except GitCommandError as err:
|
|
347
|
+
logger.error(f"Failed to fetch from remote '{remote_name}': {err}")
|
|
348
|
+
raise
|
|
349
|
+
|
|
350
|
+
def checkout(self, *, branch_name: str, create_from: str | None = None) -> None:
|
|
351
|
+
"""
|
|
352
|
+
Checkout an existing branch or create and checkout a new branch.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
branch_name (str): Name of the branch to checkout.
|
|
356
|
+
create_from (str | None): If provided, create the branch from this ref before checkout.
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
GitCommandError: If checkout operation fails.
|
|
360
|
+
"""
|
|
361
|
+
try:
|
|
362
|
+
if create_from:
|
|
363
|
+
logger.info(
|
|
364
|
+
f"Creating and checking out branch '{branch_name}' from '{create_from}'"
|
|
365
|
+
)
|
|
366
|
+
new_branch = self.repo.create_head(branch_name, create_from)
|
|
367
|
+
new_branch.checkout()
|
|
368
|
+
else:
|
|
369
|
+
logger.info(f"Checking out branch '{branch_name}'")
|
|
370
|
+
self.repo.heads[branch_name].checkout()
|
|
371
|
+
|
|
372
|
+
logger.debug(f"Checked out branch '{branch_name}'")
|
|
373
|
+
except (GitCommandError, IndexError) as err:
|
|
374
|
+
logger.error(f"Failed to checkout branch '{branch_name}': {err}")
|
|
375
|
+
raise GitCommandError(f"Checkout failed for branch '{branch_name}'") from err
|
|
376
|
+
|
|
377
|
+
def __ensure_git_identity(
|
|
378
|
+
self,
|
|
379
|
+
*,
|
|
380
|
+
email: str = "256984269+auto-semver-bot[bot]@users.noreply.github.com",
|
|
381
|
+
name: str = "auto-semver-bot[bot]",
|
|
382
|
+
) -> Actor:
|
|
383
|
+
"""
|
|
384
|
+
Resolve and cache the bot Actor used for local commits.
|
|
385
|
+
|
|
386
|
+
Prefer an existing ``user.name`` / ``user.email`` from git config (CI
|
|
387
|
+
usually sets these from App token ``user-name`` / ``user-email``
|
|
388
|
+
outputs). Otherwise write the App bot defaults into repo config so
|
|
389
|
+
subprocess git operations (merge, etc.) also attribute correctly.
|
|
390
|
+
|
|
391
|
+
When the config write fails (read-only FS, permissions), still return
|
|
392
|
+
the in-memory defaults so ``_local_commit`` can proceed.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
email: Git user email (default: App bot users.noreply address).
|
|
396
|
+
name: Git user name (default: auto-semver-bot[bot]).
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
Actor to use as both author and committer for local commits.
|
|
400
|
+
"""
|
|
401
|
+
defaults = Actor(name=name, email=email)
|
|
402
|
+
try:
|
|
403
|
+
with self.repo.config_reader() as config:
|
|
404
|
+
try:
|
|
405
|
+
existing_email = config.get_value("user", "email")
|
|
406
|
+
existing_name = config.get_value("user", "name")
|
|
407
|
+
logger.debug(
|
|
408
|
+
f"Git identity already configured: {existing_name} <{existing_email}>"
|
|
409
|
+
)
|
|
410
|
+
return Actor(name=str(existing_name), email=str(existing_email))
|
|
411
|
+
except Exception:
|
|
412
|
+
# Not configured, will set below
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
logger.debug(f"Configuring Git identity: {name} <{email}>")
|
|
416
|
+
with self.repo.config_writer() as config:
|
|
417
|
+
config.set_value("user", "email", email)
|
|
418
|
+
config.set_value("user", "name", name)
|
|
419
|
+
|
|
420
|
+
logger.debug("Git identity configured successfully")
|
|
421
|
+
return defaults
|
|
422
|
+
except Exception as err:
|
|
423
|
+
logger.warning(
|
|
424
|
+
"Failed to configure Git identity: %s; using in-memory defaults %s <%s>",
|
|
425
|
+
err,
|
|
426
|
+
name,
|
|
427
|
+
email,
|
|
428
|
+
)
|
|
429
|
+
return defaults
|
|
430
|
+
|
|
431
|
+
def pull(self, *, branch_name: str, remote_name: str = "origin") -> None:
|
|
432
|
+
"""
|
|
433
|
+
Pull the latest changes for the current branch from remote.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
branch_name (str): Name of the branch to pull.
|
|
437
|
+
remote_name (str): Name of the Git remote (default: 'origin').
|
|
438
|
+
|
|
439
|
+
Raises:
|
|
440
|
+
GitCommandError: If pull operation fails.
|
|
441
|
+
"""
|
|
442
|
+
logger.info(f"Pulling latest changes for '{branch_name}' from '{remote_name}'")
|
|
443
|
+
|
|
444
|
+
try:
|
|
445
|
+
remote: Remote = self.repo.remote(name=remote_name)
|
|
446
|
+
remote.pull(branch_name)
|
|
447
|
+
logger.debug(f"Pull for '{branch_name}' completed")
|
|
448
|
+
except GitCommandError as err:
|
|
449
|
+
logger.error(f"Failed to pull '{branch_name}' from '{remote_name}': {err}")
|
|
450
|
+
raise
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""GitOps facade composing local, verified, promote, and release-PR parts."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from auto_semver.adapters.git.local import GitLocal
|
|
9
|
+
from auto_semver.adapters.git.promote import GitPromote
|
|
10
|
+
from auto_semver.adapters.git.release_pr import GitReleasePr
|
|
11
|
+
from auto_semver.adapters.git.verified import GitVerifiedCommits
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitOps(GitLocal, GitVerifiedCommits, GitPromote, GitReleasePr):
|
|
15
|
+
"""Unified Git / GitHub operations for auto-semver pipelines."""
|
|
16
|
+
|
|
17
|
+
pass
|