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,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