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,668 @@
|
|
|
1
|
+
# Copyright (c) 2025-2026 Guy Erreich
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
"""Release PR ownership and discovery."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from git import Commit, GitCommandError
|
|
14
|
+
from git.remote import PushInfo, Remote
|
|
15
|
+
from github.GithubException import GithubException
|
|
16
|
+
|
|
17
|
+
from auto_semver.adapters.git.base import GitOpsBase
|
|
18
|
+
from auto_semver.adapters.git.constants import (
|
|
19
|
+
DEFAULT_RELEASE_PREFIX,
|
|
20
|
+
LEGACY_RELEASE_PREFIX,
|
|
21
|
+
)
|
|
22
|
+
from auto_semver.config.constants import (
|
|
23
|
+
INTERNAL_COMMIT_PREFIXES,
|
|
24
|
+
PR_HIDDEN_MARKER,
|
|
25
|
+
)
|
|
26
|
+
from auto_semver.core.semver import SemverLock, Version
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from github.PullRequest import PullRequest
|
|
30
|
+
from github.Repository import Repository
|
|
31
|
+
|
|
32
|
+
from auto_semver.config import Config
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__package__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GitReleasePr(GitOpsBase):
|
|
38
|
+
"""Release PR ownership and discovery."""
|
|
39
|
+
|
|
40
|
+
def get_lock_at_ref(self, ref: str) -> SemverLock | None:
|
|
41
|
+
"""Load `.semver.lock` from a branch or tag ref."""
|
|
42
|
+
try:
|
|
43
|
+
blob = self.repo.git.show(f"{ref}:{SemverLock.path}")
|
|
44
|
+
return SemverLock.from_dict(yaml.safe_load(blob))
|
|
45
|
+
except GitCommandError as err:
|
|
46
|
+
logger.debug("No lockfile at ref %s: %s", ref, err)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
def delete_branch(self, *, branch_name: str, remote_name: str = "origin") -> None:
|
|
50
|
+
"""Delete a remote branch ref."""
|
|
51
|
+
logger.info("Deleting remote branch '%s' on '%s'", branch_name, remote_name)
|
|
52
|
+
if self.signed_commits:
|
|
53
|
+
gh_repo = self._gh_repo()
|
|
54
|
+
ref_name = f"heads/{branch_name}"
|
|
55
|
+
ref = gh_repo.get_git_ref(ref_name)
|
|
56
|
+
ref.delete()
|
|
57
|
+
self.fetch(remote_name=remote_name)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
remote: Remote = self.repo.remote(name=remote_name)
|
|
61
|
+
push_infos = remote.push(refspec=f":{branch_name}")
|
|
62
|
+
for info in push_infos:
|
|
63
|
+
if info.flags & (PushInfo.ERROR | PushInfo.REJECTED | PushInfo.REMOTE_FAILURE):
|
|
64
|
+
raise RuntimeError(f"Failed to delete branch {branch_name}: {info.summary}")
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _normalize_branch_name(ref: str) -> str:
|
|
68
|
+
"""Strip remote prefix from a git ref name."""
|
|
69
|
+
for prefix in ("origin/", "refs/heads/"):
|
|
70
|
+
if ref.startswith(prefix):
|
|
71
|
+
return ref.removeprefix(prefix)
|
|
72
|
+
return ref
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _branch_matches_prefix(branch_name: str, branch_prefix: str) -> bool:
|
|
76
|
+
"""Return True when branch_name matches `{prefix}{semver}`."""
|
|
77
|
+
prefix = branch_prefix if branch_prefix.endswith("/") else f"{branch_prefix}/"
|
|
78
|
+
pattern = re.compile(re.escape(prefix) + r"\d+\.\d+\.\d+(?:-[\w.]+)?$")
|
|
79
|
+
return bool(pattern.match(branch_name))
|
|
80
|
+
|
|
81
|
+
def get_merged_source_branches_since(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
base_sha: str,
|
|
85
|
+
target_branch: str,
|
|
86
|
+
github_token: str,
|
|
87
|
+
) -> list[str]:
|
|
88
|
+
"""List merged PR head branch names on target_branch since base_sha."""
|
|
89
|
+
repo_full_name = self._repo_full_name
|
|
90
|
+
gh = self._github_client_for(github_token)
|
|
91
|
+
repo: Repository = gh.get_repo(full_name_or_id=repo_full_name)
|
|
92
|
+
branches: list[str] = []
|
|
93
|
+
|
|
94
|
+
for pr in repo.get_pulls(
|
|
95
|
+
state="closed", base=target_branch, sort="updated", direction="desc"
|
|
96
|
+
):
|
|
97
|
+
if not pr.merged or not pr.merge_commit_sha:
|
|
98
|
+
continue
|
|
99
|
+
merge_sha = pr.merge_commit_sha
|
|
100
|
+
try:
|
|
101
|
+
self.repo.git.merge_base("--is-ancestor", base_sha, merge_sha)
|
|
102
|
+
self.repo.git.merge_base("--is-ancestor", merge_sha, f"origin/{target_branch}")
|
|
103
|
+
except GitCommandError:
|
|
104
|
+
continue
|
|
105
|
+
branches.append(pr.head.ref)
|
|
106
|
+
|
|
107
|
+
return list(reversed(branches))
|
|
108
|
+
|
|
109
|
+
def get_open_release_version(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
github_token: str,
|
|
113
|
+
target_branch: str,
|
|
114
|
+
branch_prefix: str,
|
|
115
|
+
labels: list[str] | None = None,
|
|
116
|
+
) -> Version | None:
|
|
117
|
+
"""Return the highest version from open owned release PRs targeting target_branch."""
|
|
118
|
+
repo_full_name = self._repo_full_name
|
|
119
|
+
gh = self._github_client_for(github_token)
|
|
120
|
+
repo: Repository = gh.get_repo(full_name_or_id=repo_full_name)
|
|
121
|
+
highest: Version | None = None
|
|
122
|
+
|
|
123
|
+
for pr in repo.get_pulls(state="open"):
|
|
124
|
+
head_ref = pr.head.ref
|
|
125
|
+
if pr.base.ref != target_branch:
|
|
126
|
+
continue
|
|
127
|
+
if not self._is_candidate_release_branch(head_ref, branch_prefix):
|
|
128
|
+
continue
|
|
129
|
+
pr_labels = [label.name for label in pr.labels]
|
|
130
|
+
if labels and not any(label in pr_labels for label in labels):
|
|
131
|
+
continue
|
|
132
|
+
if PR_HIDDEN_MARKER not in (pr.body or ""):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
lock = self.get_lock_at_ref(f"origin/{head_ref}")
|
|
136
|
+
if lock is None:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
if highest is None or lock.version > highest:
|
|
140
|
+
highest = lock.version
|
|
141
|
+
|
|
142
|
+
return highest
|
|
143
|
+
|
|
144
|
+
def _is_candidate_release_branch(self, branch_name: str, branch_prefix: str) -> bool:
|
|
145
|
+
"""Return True for configured or legacy release branch names."""
|
|
146
|
+
return self._branch_matches_prefix(branch_name, branch_prefix) or (
|
|
147
|
+
branch_name.startswith(LEGACY_RELEASE_PREFIX)
|
|
148
|
+
and branch_name != LEGACY_RELEASE_PREFIX.rstrip("/")
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _find_release_pr(
|
|
152
|
+
self,
|
|
153
|
+
*,
|
|
154
|
+
github_token: str,
|
|
155
|
+
branch_name: str,
|
|
156
|
+
target_branch: str | None = None,
|
|
157
|
+
) -> PullRequest | None:
|
|
158
|
+
repo: Repository = self._get_github_repo(
|
|
159
|
+
github_token=github_token,
|
|
160
|
+
repo_full_name=self._repo_full_name,
|
|
161
|
+
)
|
|
162
|
+
for pr in repo.get_pulls(state="all"):
|
|
163
|
+
if pr.head.ref != branch_name:
|
|
164
|
+
continue
|
|
165
|
+
if target_branch and pr.base.ref != target_branch:
|
|
166
|
+
continue
|
|
167
|
+
return pr
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def is_auto_semver_release_branch(
|
|
171
|
+
self,
|
|
172
|
+
*,
|
|
173
|
+
branch_name: str,
|
|
174
|
+
github_token: str,
|
|
175
|
+
branch_prefix: str,
|
|
176
|
+
labels: list[str] | None = None,
|
|
177
|
+
require_closed_pr: bool = False,
|
|
178
|
+
skip_pr_check: bool = False,
|
|
179
|
+
) -> tuple[bool, str]:
|
|
180
|
+
"""
|
|
181
|
+
Verify branch ownership for cleanup/delete operations.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Tuple of (is_owned, skip_reason). skip_reason is empty when owned.
|
|
185
|
+
"""
|
|
186
|
+
skip_reason = ""
|
|
187
|
+
if not self._is_candidate_release_branch(branch_name, branch_prefix):
|
|
188
|
+
skip_reason = "branch name does not match release prefix"
|
|
189
|
+
else:
|
|
190
|
+
lock = self.get_lock_at_ref(f"origin/{branch_name}")
|
|
191
|
+
if lock is None:
|
|
192
|
+
skip_reason = "no lockfile at branch tip"
|
|
193
|
+
elif not lock.is_release_branch_lock() and not lock.is_legacy_managed_lock():
|
|
194
|
+
skip_reason = "lock is not auto-semver managed"
|
|
195
|
+
|
|
196
|
+
if skip_reason:
|
|
197
|
+
return False, skip_reason
|
|
198
|
+
|
|
199
|
+
if skip_pr_check:
|
|
200
|
+
return True, ""
|
|
201
|
+
|
|
202
|
+
pr = self._find_release_pr(github_token=github_token, branch_name=branch_name)
|
|
203
|
+
if pr is None:
|
|
204
|
+
return False, "no associated pull request"
|
|
205
|
+
|
|
206
|
+
pr_reason = self._release_pr_ownership_reason(
|
|
207
|
+
pr=pr,
|
|
208
|
+
labels=labels,
|
|
209
|
+
require_closed_pr=require_closed_pr,
|
|
210
|
+
)
|
|
211
|
+
if pr_reason:
|
|
212
|
+
return False, pr_reason
|
|
213
|
+
|
|
214
|
+
return True, ""
|
|
215
|
+
|
|
216
|
+
def _release_pr_ownership_reason(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
pr: PullRequest,
|
|
220
|
+
labels: list[str] | None,
|
|
221
|
+
require_closed_pr: bool,
|
|
222
|
+
) -> str:
|
|
223
|
+
"""Return a skip reason when the release PR fails ownership checks."""
|
|
224
|
+
if require_closed_pr and pr.state == "open":
|
|
225
|
+
return "pull request still open"
|
|
226
|
+
|
|
227
|
+
pr_labels = [label.name for label in pr.labels]
|
|
228
|
+
if labels and not any(label in pr_labels for label in labels):
|
|
229
|
+
return "pull request missing configured label"
|
|
230
|
+
|
|
231
|
+
if PR_HIDDEN_MARKER not in (pr.body or ""):
|
|
232
|
+
return "pull request missing auto-semver marker"
|
|
233
|
+
|
|
234
|
+
return ""
|
|
235
|
+
|
|
236
|
+
def _is_closeable_release_pr(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
branch_name: str,
|
|
240
|
+
github_token: str,
|
|
241
|
+
branch_prefix: str,
|
|
242
|
+
labels: list[str] | None,
|
|
243
|
+
) -> tuple[bool, str]:
|
|
244
|
+
"""
|
|
245
|
+
Decide whether an open release PR can be superseded in single mode.
|
|
246
|
+
|
|
247
|
+
Accepts pre-ownership locks (no managed_by metadata) on candidate release
|
|
248
|
+
branches so legacy release/* PRs are closed when a new release opens.
|
|
249
|
+
"""
|
|
250
|
+
owned, reason = self.is_auto_semver_release_branch(
|
|
251
|
+
branch_name=branch_name,
|
|
252
|
+
github_token=github_token,
|
|
253
|
+
branch_prefix=branch_prefix,
|
|
254
|
+
labels=labels,
|
|
255
|
+
skip_pr_check=True,
|
|
256
|
+
)
|
|
257
|
+
if owned:
|
|
258
|
+
return True, ""
|
|
259
|
+
|
|
260
|
+
lock = self.get_lock_at_ref(f"origin/{branch_name}")
|
|
261
|
+
if lock is None:
|
|
262
|
+
return False, reason or "no lockfile at branch tip"
|
|
263
|
+
|
|
264
|
+
if lock.finalized:
|
|
265
|
+
return False, "lock already finalized"
|
|
266
|
+
|
|
267
|
+
if lock.is_preownership_release_lock() and self._is_candidate_release_branch(
|
|
268
|
+
branch_name, branch_prefix
|
|
269
|
+
):
|
|
270
|
+
return True, ""
|
|
271
|
+
|
|
272
|
+
return False, reason or "lock is not auto-semver managed"
|
|
273
|
+
|
|
274
|
+
def close_old_release_prs(
|
|
275
|
+
self,
|
|
276
|
+
*,
|
|
277
|
+
github_token: str,
|
|
278
|
+
target_branch: str,
|
|
279
|
+
labels: list[str] | None = None,
|
|
280
|
+
branch_prefix: str = DEFAULT_RELEASE_PREFIX,
|
|
281
|
+
exclude_branch: str | None = None,
|
|
282
|
+
delete_branches: bool = False,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""
|
|
285
|
+
Close open owned release PRs targeting the specified branch.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
github_token: GitHub access token.
|
|
289
|
+
target_branch: The target branch (e.g., 'dev' or 'main').
|
|
290
|
+
labels: Optional list of label names to match.
|
|
291
|
+
branch_prefix: Configured release branch prefix.
|
|
292
|
+
exclude_branch: Optional branch name to keep open (current release).
|
|
293
|
+
delete_branches: When True, delete each superseded release branch after closing.
|
|
294
|
+
|
|
295
|
+
Raises:
|
|
296
|
+
GithubException: If there is an error with the GitHub API.
|
|
297
|
+
|
|
298
|
+
"""
|
|
299
|
+
repo_full_name = self._repo_full_name
|
|
300
|
+
|
|
301
|
+
logger.info(f"Checking for existing PRs for target branch: {target_branch}")
|
|
302
|
+
|
|
303
|
+
gh = self._github_client_for(github_token)
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
repo: Repository = gh.get_repo(full_name_or_id=repo_full_name)
|
|
307
|
+
open_prs = repo.get_pulls(state="open")
|
|
308
|
+
|
|
309
|
+
for pr in open_prs:
|
|
310
|
+
head_ref: str = pr.head.ref
|
|
311
|
+
base_ref: str = pr.base.ref
|
|
312
|
+
|
|
313
|
+
if head_ref == exclude_branch:
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
if base_ref != target_branch:
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
if not self._is_candidate_release_branch(head_ref, branch_prefix):
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
pr_labels: list[str] = [label.name for label in pr.labels]
|
|
323
|
+
if labels and not any(label in pr_labels for label in labels):
|
|
324
|
+
continue
|
|
325
|
+
|
|
326
|
+
if PR_HIDDEN_MARKER not in (pr.body or ""):
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
owned, reason = self._is_closeable_release_pr(
|
|
330
|
+
branch_name=head_ref,
|
|
331
|
+
github_token=github_token,
|
|
332
|
+
branch_prefix=branch_prefix,
|
|
333
|
+
labels=labels,
|
|
334
|
+
)
|
|
335
|
+
if not owned:
|
|
336
|
+
logger.info("Skipping PR #%s (%s): %s", pr.number, head_ref, reason)
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
logger.info(f"Closing old PR #{pr.number}: {head_ref} → {base_ref}")
|
|
340
|
+
pr.edit(state="closed")
|
|
341
|
+
if delete_branches:
|
|
342
|
+
self._delete_superseded_release_branch(branch_name=head_ref)
|
|
343
|
+
|
|
344
|
+
except GithubException as err:
|
|
345
|
+
logger.error(f"GitHub API error while closing PRs: {err}")
|
|
346
|
+
raise
|
|
347
|
+
|
|
348
|
+
def cleanup_stale_release_branches(
|
|
349
|
+
self,
|
|
350
|
+
*,
|
|
351
|
+
github_token: str,
|
|
352
|
+
target_branch: str,
|
|
353
|
+
labels: list[str] | None = None,
|
|
354
|
+
branch_prefix: str = DEFAULT_RELEASE_PREFIX,
|
|
355
|
+
exclude_branch: str | None = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""
|
|
358
|
+
Delete owned release branches that no longer have an open PR (single mode).
|
|
359
|
+
|
|
360
|
+
Handles branches left behind when a previous bump closed the PR but did not
|
|
361
|
+
delete the remote ref.
|
|
362
|
+
"""
|
|
363
|
+
self.fetch()
|
|
364
|
+
remote: Remote = self.repo.remote()
|
|
365
|
+
candidates: list[str] = []
|
|
366
|
+
|
|
367
|
+
for ref in remote.refs:
|
|
368
|
+
branch_name = self._normalize_branch_name(ref.name)
|
|
369
|
+
if branch_name == exclude_branch:
|
|
370
|
+
continue
|
|
371
|
+
if not self._is_candidate_release_branch(branch_name, branch_prefix):
|
|
372
|
+
continue
|
|
373
|
+
candidates.append(branch_name)
|
|
374
|
+
|
|
375
|
+
for branch_name in sorted(set(candidates)):
|
|
376
|
+
pr = self._find_release_pr(
|
|
377
|
+
github_token=github_token,
|
|
378
|
+
branch_name=branch_name,
|
|
379
|
+
target_branch=target_branch,
|
|
380
|
+
)
|
|
381
|
+
if pr is not None and pr.state == "open":
|
|
382
|
+
continue
|
|
383
|
+
if pr is not None:
|
|
384
|
+
pr_labels = [label.name for label in pr.labels]
|
|
385
|
+
if labels and not any(label in pr_labels for label in labels):
|
|
386
|
+
continue
|
|
387
|
+
if PR_HIDDEN_MARKER not in (pr.body or ""):
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
closeable, reason = self._is_closeable_release_pr(
|
|
391
|
+
branch_name=branch_name,
|
|
392
|
+
github_token=github_token,
|
|
393
|
+
branch_prefix=branch_prefix,
|
|
394
|
+
labels=labels,
|
|
395
|
+
)
|
|
396
|
+
if not closeable:
|
|
397
|
+
logger.info("Skipping stale branch delete for %s: %s", branch_name, reason)
|
|
398
|
+
continue
|
|
399
|
+
|
|
400
|
+
self._delete_superseded_release_branch(branch_name=branch_name)
|
|
401
|
+
|
|
402
|
+
def _delete_superseded_release_branch(self, *, branch_name: str) -> None:
|
|
403
|
+
"""Delete a superseded release branch, logging failures without raising."""
|
|
404
|
+
try:
|
|
405
|
+
self.delete_branch(branch_name=branch_name)
|
|
406
|
+
logger.info("Deleted superseded release branch %s", branch_name)
|
|
407
|
+
except Exception as err:
|
|
408
|
+
logger.warning("Failed to delete superseded release branch %s: %s", branch_name, err)
|
|
409
|
+
|
|
410
|
+
def create_pr(
|
|
411
|
+
self,
|
|
412
|
+
*,
|
|
413
|
+
github_token: str,
|
|
414
|
+
title: str,
|
|
415
|
+
body: str,
|
|
416
|
+
source: str,
|
|
417
|
+
target: str,
|
|
418
|
+
labels: list[str] | None = None,
|
|
419
|
+
) -> int:
|
|
420
|
+
"""
|
|
421
|
+
Create a pull request from the source branch to the target branch.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
github_token (str): GitHub API token.
|
|
425
|
+
title (str): Title for the PR.
|
|
426
|
+
body (str): Body for the PR content.
|
|
427
|
+
source (str): Source branch.
|
|
428
|
+
target (str): Target branch.
|
|
429
|
+
labels (str | None): Optional label to add to the PR.
|
|
430
|
+
If None, no label is added.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
The PR number.
|
|
434
|
+
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
# Get the repository name (uses cached value)
|
|
438
|
+
repo_full_name = self._repo_full_name
|
|
439
|
+
|
|
440
|
+
logger.debug("Creating PR with the following parameters:")
|
|
441
|
+
logger.debug(f" Repo: {repo_full_name}")
|
|
442
|
+
logger.debug(f" Title: {title}")
|
|
443
|
+
logger.debug(f" Body: {body}")
|
|
444
|
+
logger.debug(f" Source: {source}")
|
|
445
|
+
logger.debug(f" Target: {target}")
|
|
446
|
+
logger.debug(f" Labels: {labels}")
|
|
447
|
+
|
|
448
|
+
gh = self._github_client_for(github_token)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
repo: Repository = gh.get_repo(full_name_or_id=repo_full_name)
|
|
452
|
+
|
|
453
|
+
for pr in repo.get_pulls(state="open"):
|
|
454
|
+
if pr.head.ref == source and pr.base.ref == target:
|
|
455
|
+
logger.warning(
|
|
456
|
+
f"PR already exists for branch '{source}' → '{target}', skipping creation."
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return pr.number
|
|
460
|
+
|
|
461
|
+
new_pr: PullRequest = repo.create_pull(title=title, body=body, head=source, base=target)
|
|
462
|
+
|
|
463
|
+
if labels:
|
|
464
|
+
try:
|
|
465
|
+
new_pr.add_to_labels(*labels)
|
|
466
|
+
|
|
467
|
+
label_str = ", ".join(f"'{label}'" for label in labels)
|
|
468
|
+
logger.info(f"Labels [{label_str}] added to PR #{new_pr.number}.")
|
|
469
|
+
|
|
470
|
+
except GithubException as err:
|
|
471
|
+
logger.error(f"Failed to add labels '{labels}' to PR #{new_pr.number}: {err}")
|
|
472
|
+
|
|
473
|
+
raise
|
|
474
|
+
|
|
475
|
+
logger.info(f"PR created successfully: #{new_pr.number}")
|
|
476
|
+
|
|
477
|
+
return new_pr.number
|
|
478
|
+
|
|
479
|
+
except GithubException as err:
|
|
480
|
+
logger.error(f"GitHub API error during PR creation: {err}")
|
|
481
|
+
|
|
482
|
+
raise
|
|
483
|
+
|
|
484
|
+
def get_recent_commits(
|
|
485
|
+
self,
|
|
486
|
+
commit_sha: str,
|
|
487
|
+
*,
|
|
488
|
+
filter_release_commits: bool = True,
|
|
489
|
+
config: Config | None = None,
|
|
490
|
+
) -> list[str]:
|
|
491
|
+
"""
|
|
492
|
+
Get commit messages between the specified commit SHA and HEAD.
|
|
493
|
+
|
|
494
|
+
This method attempts to fetch the commit SHA from the remote if it doesn't exist locally.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
commit_sha (str): The base commit SHA to compare against HEAD.
|
|
498
|
+
filter_release_commits (bool): If True, filters out release-related commits.
|
|
499
|
+
config (Config): Configuration object to determine release commit patterns.
|
|
500
|
+
|
|
501
|
+
Returns:
|
|
502
|
+
list[str]: A list of commit messages.
|
|
503
|
+
|
|
504
|
+
Raises:
|
|
505
|
+
RuntimeError: If the git command fails.
|
|
506
|
+
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
logger.info(f"Fetching recent commits between {commit_sha} and HEAD.")
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
# Check if SHA exists locally
|
|
513
|
+
self.repo.git.rev_parse(commit_sha)
|
|
514
|
+
except GitCommandError:
|
|
515
|
+
logger.warning(f"SHA {commit_sha} not found locally. Attempting to fetch...")
|
|
516
|
+
try:
|
|
517
|
+
self.repo.git.fetch("origin", commit_sha, "--depth=1")
|
|
518
|
+
except GitCommandError as fetch_err:
|
|
519
|
+
logger.error(f"Failed to fetch missing SHA '{commit_sha}': {fetch_err}")
|
|
520
|
+
raise RuntimeError(f"Failed to fetch SHA {commit_sha}: {fetch_err}") from fetch_err
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
commits: list[Commit] = list(self.repo.iter_commits(f"{commit_sha}..HEAD"))
|
|
524
|
+
messages: list[str] = [str(commit.message).strip() for commit in reversed(commits)]
|
|
525
|
+
|
|
526
|
+
if filter_release_commits and config:
|
|
527
|
+
original_count = len(messages)
|
|
528
|
+
messages = self._filter_internal_commits(messages, config)
|
|
529
|
+
filtered_count = original_count - len(messages)
|
|
530
|
+
|
|
531
|
+
if filtered_count > 0:
|
|
532
|
+
logger.debug(f"Filtered out {filtered_count} auto-semver commits")
|
|
533
|
+
else:
|
|
534
|
+
logger.debug("No auto-semver commits found to filter out.")
|
|
535
|
+
elif not filter_release_commits:
|
|
536
|
+
logger.debug("Filtering of release commits is disabled.")
|
|
537
|
+
elif not config:
|
|
538
|
+
logger.warning("No config provided, skipping release commit filtering.")
|
|
539
|
+
|
|
540
|
+
for message in messages:
|
|
541
|
+
logger.debug(f"Commit message: {message}")
|
|
542
|
+
|
|
543
|
+
logger.debug(f"Found {len(messages)} commits.")
|
|
544
|
+
|
|
545
|
+
return messages
|
|
546
|
+
|
|
547
|
+
except GitCommandError as err:
|
|
548
|
+
logger.error(f"Failed to fetch recent commits: {err}")
|
|
549
|
+
|
|
550
|
+
raise RuntimeError(f"Failed to fetch recent commits: {err}") from err
|
|
551
|
+
|
|
552
|
+
def get_lock_version_from_branch(
|
|
553
|
+
self,
|
|
554
|
+
branch_name: str,
|
|
555
|
+
remote_name: str = "origin",
|
|
556
|
+
) -> Version | None:
|
|
557
|
+
"""
|
|
558
|
+
Get the version from the lockfile on a specific branch.
|
|
559
|
+
|
|
560
|
+
Args:
|
|
561
|
+
branch_name (str): The branch to check.
|
|
562
|
+
remote_name (str): The name of the remote to check.
|
|
563
|
+
|
|
564
|
+
Returns:
|
|
565
|
+
The Version object found, or None if none found.
|
|
566
|
+
|
|
567
|
+
"""
|
|
568
|
+
try:
|
|
569
|
+
logger.info(f"Fetching branch '{branch_name}' from remote '{remote_name}'...")
|
|
570
|
+
self.repo.git.fetch(remote_name, branch_name)
|
|
571
|
+
|
|
572
|
+
full_branch_ref = f"{remote_name}/{branch_name}"
|
|
573
|
+
logger.debug(f"Checking branch for lockfile: {full_branch_ref}")
|
|
574
|
+
|
|
575
|
+
try:
|
|
576
|
+
blob = self.repo.git.show(f"{full_branch_ref}:{SemverLock.path}")
|
|
577
|
+
lock = SemverLock.from_dict(yaml.safe_load(blob))
|
|
578
|
+
logger.debug(f"Loaded lockfile from {branch_name}: {lock}")
|
|
579
|
+
return lock.version
|
|
580
|
+
except Exception as err:
|
|
581
|
+
logger.warning(f"No lockfile in {branch_name}: {err}")
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
except Exception as err:
|
|
585
|
+
logger.error(f"Failed to get lock version from branch {branch_name}: {err}")
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
def get_file_content_at_commit(self, commit_sha: str, file_path: str) -> str | None:
|
|
589
|
+
"""
|
|
590
|
+
Retrieve file content from a specific commit SHA.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
commit_sha (str): The git commit SHA to read from.
|
|
594
|
+
file_path (str): Relative path to the file in the repository.
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
str | None: The file content as a string, or None if the file/commit is missing
|
|
598
|
+
or cannot be read.
|
|
599
|
+
"""
|
|
600
|
+
try:
|
|
601
|
+
logger.debug(f"Reading {file_path} at {commit_sha}")
|
|
602
|
+
return str(self.repo.git.show(f"{commit_sha}:{file_path}"))
|
|
603
|
+
except GitCommandError as e:
|
|
604
|
+
logger.warning(f"Failed to read {file_path} at {commit_sha}: {e}")
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
def get_lock_version_from_tag(self, tag_name: str) -> Version | None:
|
|
608
|
+
"""
|
|
609
|
+
Get the version from the lockfile at a specific tag.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
tag_name (str): The tag to check.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
The Version object found, or None if none found.
|
|
616
|
+
"""
|
|
617
|
+
try:
|
|
618
|
+
logger.debug(f"Checking tag for lockfile: {tag_name}")
|
|
619
|
+
blob = self.repo.git.show(f"{tag_name}:{SemverLock.path}")
|
|
620
|
+
lock = SemverLock.from_dict(yaml.safe_load(blob))
|
|
621
|
+
logger.debug(f"Loaded lockfile from {tag_name}: {lock}")
|
|
622
|
+
return lock.version
|
|
623
|
+
except Exception as err:
|
|
624
|
+
logger.warning(f"No lockfile in tag {tag_name}: {err}")
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
def _filter_internal_commits(self, messages: list[str], config: Config) -> list[str]:
|
|
628
|
+
"""
|
|
629
|
+
Filter out auto-semver housekeeping and release commit messages.
|
|
630
|
+
|
|
631
|
+
Drops release title commits plus finalize/promote lock metadata commits so
|
|
632
|
+
they never appear in generated changelogs or release PR bodies.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
messages (list[str]): List of commit messages to filter.
|
|
636
|
+
config (Config): Configuration object to get release title template.
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
list[str]: Filtered list of commit messages with internal commits removed.
|
|
640
|
+
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
# Get the release commit prefix from config
|
|
644
|
+
release_prefix = config.data.pull_request.get_release_commit_prefix()
|
|
645
|
+
|
|
646
|
+
# Robust fallback: strict "Release " check if config extraction fails
|
|
647
|
+
if not release_prefix:
|
|
648
|
+
logger.warning(
|
|
649
|
+
"No release prefix found in config title template. Falling back to default 'Release '."
|
|
650
|
+
)
|
|
651
|
+
# Default fallback for most common case
|
|
652
|
+
release_prefix = "Release "
|
|
653
|
+
else:
|
|
654
|
+
logger.debug(f"Using config-based release prefix: '{release_prefix}'")
|
|
655
|
+
|
|
656
|
+
prefixes = [release_prefix, *INTERNAL_COMMIT_PREFIXES]
|
|
657
|
+
filtered_messages = []
|
|
658
|
+
|
|
659
|
+
for message in messages:
|
|
660
|
+
# Normalize message for checking - only check the first line (title)
|
|
661
|
+
first_line = message.splitlines()[0].strip() if message else ""
|
|
662
|
+
|
|
663
|
+
if any(first_line.startswith(prefix) for prefix in prefixes if prefix):
|
|
664
|
+
logger.debug(f"Filtering out auto-semver commit: {first_line}")
|
|
665
|
+
else:
|
|
666
|
+
filtered_messages.append(message)
|
|
667
|
+
|
|
668
|
+
return filtered_messages
|