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,491 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ """Verified commit transport (GraphQL + REST)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from git import GitCommandError
14
+ from github import Github
15
+ from github.GithubException import GithubException
16
+ from github.InputGitTreeElement import InputGitTreeElement
17
+
18
+ from auto_semver.adapters.git.base import GitOpsBase
19
+ from auto_semver.adapters.git.constants import (
20
+ _CREATE_COMMIT_ON_BRANCH_MUTATION,
21
+ _KNOWN_GIT_FILE_MODES,
22
+ _RAW_DIFF_MIN_MODE_FIELDS,
23
+ _REST_TREE_MODES,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ from github.Repository import Repository
28
+ from github.Requester import Requester
29
+
30
+
31
+ logger = logging.getLogger(__package__)
32
+
33
+
34
+ class GitVerifiedCommits(GitOpsBase):
35
+ """Verified commit transport (GraphQL + REST)."""
36
+
37
+ def _get_github_repo(self, *, github_token: str, repo_full_name: str) -> Repository:
38
+ return self._github_client_for(github_token).get_repo(repo_full_name)
39
+
40
+ def _github_client_for(self, token: str) -> Github:
41
+ """Return a cached Github client for ``token`` (closed via ``close``)."""
42
+ cache: dict[str, Github] = getattr(self, "_github_clients", None) or {}
43
+ self._github_clients = cache
44
+ client = cache.get(token)
45
+ if client is None:
46
+ client = Github(login_or_token=token)
47
+ cache[token] = client
48
+ return client
49
+
50
+ def _github_client(self) -> Github:
51
+ if not self.github_token:
52
+ raise ValueError("github_token is required for API git operations")
53
+ return self._github_client_for(self.github_token)
54
+
55
+ def close(self) -> None:
56
+ """Close all cached PyGithub clients."""
57
+ cache: dict[str, Github] = getattr(self, "_github_clients", {})
58
+ for client in cache.values():
59
+ client.close()
60
+ cache.clear()
61
+
62
+ def _gh_repo(self) -> Repository:
63
+ if not self.github_token:
64
+ raise ValueError("github_token is required for API git operations")
65
+ return self._get_github_repo(
66
+ github_token=self.github_token,
67
+ repo_full_name=self._repo_full_name,
68
+ )
69
+
70
+ def _github_requester(self) -> Requester:
71
+ return self._github_client().requester
72
+
73
+ def _collect_staged_paths(self) -> list[str]:
74
+ """Return repository-relative paths staged for the next commit (all change types)."""
75
+ additions, deletions = self._collect_staged_file_changes()
76
+ return additions + deletions
77
+
78
+ def _collect_staged_file_changes(self) -> tuple[list[str], list[str]]:
79
+ """
80
+ Return staged paths split into additions/modifications and deletions.
81
+
82
+ Uses ``git diff --cached --no-renames --diff-filter`` so GraphQL
83
+ ``createCommitOnBranch`` receives correct ``fileChanges.additions`` and
84
+ ``fileChanges.deletions``. ``--no-renames`` turns moves into delete+add
85
+ so overlay publishes drop the old path.
86
+ """
87
+ added = self.repo.git.diff("--cached", "--no-renames", "--name-only", "--diff-filter=ACMR")
88
+ deleted = self.repo.git.diff("--cached", "--no-renames", "--name-only", "--diff-filter=D")
89
+ additions = [line.strip() for line in added.splitlines() if line.strip()]
90
+ deletions = [line.strip() for line in deleted.splitlines() if line.strip()]
91
+ return additions, deletions
92
+
93
+ def _collect_dirty_tracked_paths(self) -> list[str]:
94
+ """Return tracked paths with unstaged modifications."""
95
+ output = self.repo.git.diff("--name-only")
96
+ return [line.strip() for line in output.splitlines() if line.strip()]
97
+
98
+ @staticmethod
99
+ def _normalize_repo_rel_path(rel_path: str) -> str:
100
+ return rel_path.replace("\\", "/")
101
+
102
+ @staticmethod
103
+ def _split_commit_message(message: str) -> tuple[str, str | None]:
104
+ """Split a commit message into GraphQL headline and optional body."""
105
+ headline, sep, body = message.partition("\n")
106
+ headline = headline.strip() or message.strip()
107
+ body_text = body.strip() if sep and body.strip() else None
108
+ return headline, body_text
109
+
110
+ def _worktree_git_mode(self, *, rel_path: str, repo_root: Path) -> str | None:
111
+ """Return git mode for a worktree path, or None if missing."""
112
+ full_path = repo_root / rel_path
113
+ if not full_path.exists() and not full_path.is_symlink():
114
+ return None
115
+ if full_path.is_symlink():
116
+ return "120000"
117
+ if full_path.is_file() and full_path.stat().st_mode & 0o111:
118
+ return "100755"
119
+ if full_path.is_file():
120
+ return "100644"
121
+ return None
122
+
123
+ def _ls_tree_mode(self, *, ref: str, rel_path: str) -> str | None:
124
+ """Return ``git ls-tree`` mode for ``rel_path`` at ``ref``, or None."""
125
+ norm = self._normalize_repo_rel_path(rel_path)
126
+ try:
127
+ output = self.repo.git.ls_tree(ref, "--", norm)
128
+ except GitCommandError:
129
+ return None
130
+ if not isinstance(output, str) or not output.strip():
131
+ return None
132
+ line = output.strip().splitlines()[0]
133
+ # Format: <mode> <type> <sha>\t<path>
134
+ mode = line.split(maxsplit=1)[0]
135
+ if mode not in _KNOWN_GIT_FILE_MODES:
136
+ return None
137
+ return mode
138
+
139
+ def _raw_diff_has_special_modes(self, *, base_sha: str, head_ref: str = "HEAD") -> bool:
140
+ """True when ``git diff --raw`` shows 100755/120000 or a mode change."""
141
+ try:
142
+ raw = self.repo.git.diff(base_sha, head_ref, "--raw")
143
+ except GitCommandError:
144
+ return False
145
+ if not isinstance(raw, str):
146
+ return False
147
+ for line in raw.splitlines():
148
+ if not line.startswith(":"):
149
+ continue
150
+ # :oldmode newmode oldsha newsha status\tpath
151
+ meta, _, _path = line.partition("\t")
152
+ parts = meta.split()
153
+ if len(parts) < _RAW_DIFF_MIN_MODE_FIELDS:
154
+ continue
155
+ old_mode = parts[0][1:] # strip leading ':'
156
+ new_mode = parts[1]
157
+ if old_mode not in _KNOWN_GIT_FILE_MODES or new_mode not in _KNOWN_GIT_FILE_MODES:
158
+ continue
159
+ if old_mode in _REST_TREE_MODES or new_mode in _REST_TREE_MODES:
160
+ return True
161
+ if old_mode != new_mode:
162
+ return True
163
+ return False
164
+
165
+ def _worktree_paths_require_rest(self, *, file_paths: list[str], repo_root: Path) -> bool:
166
+ """True when any path is a symlink or executable in the worktree."""
167
+ for rel_path in file_paths:
168
+ mode = self._worktree_git_mode(rel_path=rel_path, repo_root=repo_root)
169
+ if mode in _REST_TREE_MODES:
170
+ return True
171
+ return False
172
+
173
+ def _paths_mode_conflict_with_base(
174
+ self, *, base_sha: str, file_paths: list[str], repo_root: Path
175
+ ) -> bool:
176
+ """True when worktree mode differs from ``base_sha`` for any path."""
177
+ for rel_path in file_paths:
178
+ desired = self._worktree_git_mode(rel_path=rel_path, repo_root=repo_root)
179
+ if desired is None:
180
+ continue
181
+ base_mode = self._ls_tree_mode(ref=base_sha, rel_path=rel_path)
182
+ if base_mode is not None and base_mode != desired:
183
+ return True
184
+ if desired in _REST_TREE_MODES:
185
+ return True
186
+ return False
187
+
188
+ def _tree_diff_requires_rest(self, *, base_sha: str, head_ref: str = "HEAD") -> bool:
189
+ """True when publishing ``head_ref`` over ``base_sha`` needs REST modes."""
190
+ if self._raw_diff_has_special_modes(base_sha=base_sha, head_ref=head_ref):
191
+ return True
192
+ additions, _deletions = self._diff_paths_between(base_sha=base_sha, head_ref=head_ref)
193
+ repo_root = Path(self.repo.working_tree_dir or ".")
194
+ return self._worktree_paths_require_rest(file_paths=additions, repo_root=repo_root)
195
+
196
+ def _require_commit_verified(self, sha: str) -> None:
197
+ """
198
+ Abort unless GitHub reports ``verification.verified`` for ``sha``.
199
+
200
+ Required for the REST Git Data fallback under ``signed-commits: true``.
201
+ """
202
+ git_commit = self._gh_repo().get_git_commit(sha)
203
+ verification = git_commit.verification
204
+ verified = bool(getattr(verification, "verified", False))
205
+ reason = getattr(verification, "reason", "unknown")
206
+ if not verified:
207
+ raise RuntimeError(
208
+ f"Signed REST commit {sha} is not GitHub-verified "
209
+ f"(reason={reason}). Refusing to update the branch. "
210
+ "Omit author/committer/signature on create_git_commit and use "
211
+ "an App installation token."
212
+ )
213
+ logger.info("REST commit %s verified (reason=%s)", sha, reason)
214
+
215
+ def _ensure_remote_branch(self, *, branch_name: str) -> str:
216
+ """
217
+ Ensure ``branch_name`` exists on the remote; return its tip OID.
218
+
219
+ When the branch is missing, create it at the local HEAD commit.
220
+ """
221
+ gh_repo = self._gh_repo()
222
+ ref_name = f"heads/{branch_name}"
223
+ try:
224
+ ref = gh_repo.get_git_ref(ref_name)
225
+ return str(ref.object.sha)
226
+ except GithubException:
227
+ parent_sha = self.repo.head.commit.hexsha
228
+ gh_repo.create_git_ref(f"refs/{ref_name}", parent_sha)
229
+ logger.info("Created remote branch %s at %s", branch_name, parent_sha)
230
+ return parent_sha
231
+
232
+ def _build_file_additions(
233
+ self, *, file_paths: list[str], repo_root: Path
234
+ ) -> list[dict[str, str]]:
235
+ """Build GraphQL ``FileAddition`` payloads (base64 contents, always 100644)."""
236
+ additions: list[dict[str, str]] = []
237
+ for rel_path in file_paths:
238
+ full_path = repo_root / rel_path
239
+ content = full_path.read_bytes()
240
+ additions.append(
241
+ {
242
+ "path": self._normalize_repo_rel_path(rel_path),
243
+ "contents": base64.b64encode(content).decode("ascii"),
244
+ }
245
+ )
246
+ return additions
247
+
248
+ def _rest_blob_for_path(self, *, rel_path: str, repo_root: Path) -> tuple[str, str]:
249
+ """Return ``(mode, blob_sha)`` for a worktree path via Git Data ``create_git_blob``."""
250
+ gh_repo = self._gh_repo()
251
+ full_path = repo_root / rel_path
252
+ mode = self._worktree_git_mode(rel_path=rel_path, repo_root=repo_root)
253
+ if mode is None:
254
+ raise ValueError(f"Cannot create REST blob for missing path: {rel_path}")
255
+
256
+ if mode == "120000":
257
+ target = str(full_path.readlink())
258
+ blob = gh_repo.create_git_blob(target, "utf-8")
259
+ return mode, str(blob.sha)
260
+
261
+ raw = full_path.read_bytes()
262
+ if mode == "100755":
263
+ payload = base64.b64encode(raw).decode("ascii")
264
+ blob = gh_repo.create_git_blob(payload, "base64")
265
+ return mode, str(blob.sha)
266
+
267
+ try:
268
+ text = raw.decode("utf-8")
269
+ blob = gh_repo.create_git_blob(text, "utf-8")
270
+ except UnicodeDecodeError:
271
+ payload = base64.b64encode(raw).decode("ascii")
272
+ blob = gh_repo.create_git_blob(payload, "base64")
273
+ return mode, str(blob.sha)
274
+
275
+ def _rest_create_commit_on_branch(
276
+ self,
277
+ *,
278
+ branch_name: str,
279
+ message: str,
280
+ expected_head_oid: str,
281
+ file_paths: list[str],
282
+ deletions: list[str],
283
+ ) -> str:
284
+ """
285
+ Create a verified commit via Git Database REST (blob → tree → commit → ref).
286
+
287
+ Used when GraphQL ``createCommitOnBranch`` cannot represent the tree
288
+ (executables, symlinks, or mode changes). Omits author/committer/signature
289
+ so GitHub App-signs the commit; refuses to move the ref unless
290
+ ``verification.verified`` is true.
291
+ """
292
+ if not file_paths and not deletions:
293
+ raise ValueError("file_paths/deletions must not be empty for REST commit")
294
+
295
+ gh_repo = self._gh_repo()
296
+ repo_root = Path(self.repo.working_tree_dir or ".")
297
+ parent = gh_repo.get_git_commit(expected_head_oid)
298
+ elements: list[InputGitTreeElement] = []
299
+
300
+ for rel_path in file_paths:
301
+ norm = self._normalize_repo_rel_path(rel_path)
302
+ mode, blob_sha = self._rest_blob_for_path(rel_path=rel_path, repo_root=repo_root)
303
+ elements.append(InputGitTreeElement(path=norm, mode=mode, type="blob", sha=blob_sha))
304
+
305
+ for rel_path in deletions:
306
+ norm = self._normalize_repo_rel_path(rel_path)
307
+ elements.append(InputGitTreeElement(path=norm, mode="100644", type="blob", sha=None))
308
+
309
+ tree = gh_repo.create_git_tree(elements, parent.tree)
310
+ # Do not pass author/committer/signature — required for App auto-signing.
311
+ commit = gh_repo.create_git_commit(message, tree, [parent])
312
+ commit_sha = str(commit.sha)
313
+ self._require_commit_verified(commit_sha)
314
+
315
+ ref = gh_repo.get_git_ref(f"heads/{branch_name}")
316
+ current = str(ref.object.sha)
317
+ if current != expected_head_oid:
318
+ raise GithubException(
319
+ 409,
320
+ {
321
+ "message": (
322
+ f"expectedHeadOid mismatch on {branch_name}: "
323
+ f"expected {expected_head_oid}, remote is {current}"
324
+ )
325
+ },
326
+ None,
327
+ )
328
+ ref.edit(sha=commit_sha, force=False)
329
+ logger.info(
330
+ "Verified REST commit %s on %s (modes via Git Data API)",
331
+ commit_sha,
332
+ branch_name,
333
+ )
334
+ return commit_sha
335
+
336
+ def _graphql_create_commit_on_branch(
337
+ self,
338
+ *,
339
+ branch_name: str,
340
+ message: str,
341
+ expected_head_oid: str,
342
+ additions: list[dict[str, str]],
343
+ deletions: list[dict[str, str]],
344
+ ) -> str:
345
+ """Run ``createCommitOnBranch`` and return the new commit OID."""
346
+ headline, body = self._split_commit_message(message)
347
+ message_input: dict[str, str] = {"headline": headline}
348
+ if body is not None:
349
+ message_input["body"] = body
350
+
351
+ variables = {
352
+ "input": {
353
+ "branch": {
354
+ "repositoryNameWithOwner": self._repo_full_name,
355
+ "branchName": branch_name,
356
+ },
357
+ "message": message_input,
358
+ "expectedHeadOid": expected_head_oid,
359
+ "fileChanges": {
360
+ "additions": additions,
361
+ "deletions": deletions,
362
+ },
363
+ }
364
+ }
365
+
366
+ _, data = self._github_requester().graphql_query(
367
+ _CREATE_COMMIT_ON_BRANCH_MUTATION,
368
+ variables,
369
+ )
370
+ try:
371
+ oid = data["data"]["createCommitOnBranch"]["commit"]["oid"]
372
+ except (KeyError, TypeError) as err:
373
+ raise RuntimeError(f"createCommitOnBranch returned unexpected payload: {data}") from err
374
+ return str(oid)
375
+
376
+ @staticmethod
377
+ def _is_expected_head_mismatch(exc: BaseException) -> bool:
378
+ text = str(exc).lower()
379
+ return "expectedheadoid" in text or "expected the head" in text
380
+
381
+ def _api_commit_files_on_branch(
382
+ self,
383
+ *,
384
+ branch_name: str,
385
+ message: str,
386
+ file_paths: list[str] | None = None,
387
+ deletions: list[str] | None = None,
388
+ force: bool = False,
389
+ ) -> str:
390
+ """
391
+ Create a verified commit on a branch (GraphQL or REST Git Data).
392
+
393
+ Default: GraphQL ``createCommitOnBranch`` for ordinary ``100644`` files.
394
+ Falls back to REST when the change includes executables, symlinks, or a
395
+ mode change GraphQL cannot apply. Both paths must be GitHub-verified;
396
+ REST refuses to update the ref if ``verification.verified`` is false.
397
+
398
+ Args:
399
+ branch_name: Target branch (created on the remote if missing).
400
+ message: Commit message (first line = headline, rest = body).
401
+ file_paths: Paths to add or update (read from the local worktree).
402
+ deletions: Paths to delete in the commit.
403
+ force: Kept for call-site compatibility; both paths require a
404
+ matching expected head (no force-push of divergent history).
405
+ """
406
+ del force # Signed API paths cannot force-update divergent refs.
407
+ addition_paths = list(file_paths or [])
408
+ deletion_paths = list(deletions or [])
409
+ if not addition_paths and not deletion_paths:
410
+ raise ValueError("file_paths/deletions must not be empty for API commit")
411
+
412
+ repo_root = Path(self.repo.working_tree_dir or ".")
413
+ expected_head = self._ensure_remote_branch(branch_name=branch_name)
414
+ use_rest = self._worktree_paths_require_rest(
415
+ file_paths=addition_paths, repo_root=repo_root
416
+ ) or self._paths_mode_conflict_with_base(
417
+ base_sha=expected_head,
418
+ file_paths=addition_paths,
419
+ repo_root=repo_root,
420
+ )
421
+
422
+ def _publish(expected: str) -> str:
423
+ if use_rest:
424
+ logger.info(
425
+ "Using verified REST Git Data commit on %s (GraphQL cannot "
426
+ "represent executable/symlink/mode changes)",
427
+ branch_name,
428
+ )
429
+ return self._rest_create_commit_on_branch(
430
+ branch_name=branch_name,
431
+ message=message,
432
+ expected_head_oid=expected,
433
+ file_paths=addition_paths,
434
+ deletions=deletion_paths,
435
+ )
436
+ return self._graphql_create_commit_on_branch(
437
+ branch_name=branch_name,
438
+ message=message,
439
+ expected_head_oid=expected,
440
+ additions=self._build_file_additions(
441
+ file_paths=addition_paths, repo_root=repo_root
442
+ ),
443
+ deletions=[
444
+ {"path": self._normalize_repo_rel_path(path)} for path in deletion_paths
445
+ ],
446
+ )
447
+
448
+ try:
449
+ commit_sha = _publish(expected_head)
450
+ except GithubException as err:
451
+ if not self._is_expected_head_mismatch(err):
452
+ raise
453
+ logger.warning(
454
+ "expectedHeadOid mismatch on %s; fetching and retrying once: %s",
455
+ branch_name,
456
+ err,
457
+ )
458
+ self.fetch()
459
+ expected_head = self._ensure_remote_branch(branch_name=branch_name)
460
+ commit_sha = _publish(expected_head)
461
+
462
+ self.fetch()
463
+ logger.info("Verified API commit %s on %s", commit_sha, branch_name)
464
+ return commit_sha
465
+
466
+ def _api_merge(self, *, base: str, head: str, message: str) -> str:
467
+ """
468
+ Merge ``head`` into ``base`` via the GitHub REST merges API.
469
+
470
+ Kept on REST (not GraphQL) because ``POST /repos/{owner}/{repo}/merges``
471
+ already produces a GitHub-signed, verified merge commit. There is no
472
+ GraphQL equivalent that preserves merge semantics.
473
+ """
474
+ gh_repo = self._gh_repo()
475
+ result = gh_repo.merge(base=base, head=head, commit_message=message)
476
+ if result is None or not getattr(result, "sha", None):
477
+ raise RuntimeError(f"Merge API returned no commit for {head} -> {base}")
478
+ logger.info("Verified API merge %s into %s (%s)", head, base, result.sha)
479
+ return str(result.sha)
480
+
481
+ def _api_create_lightweight_tag(self, *, tag: str, sha: str) -> str:
482
+ """Create a lightweight tag ref via the GitHub API."""
483
+ gh_repo = self._gh_repo()
484
+ ref_name = f"tags/{tag}"
485
+ try:
486
+ ref = gh_repo.get_git_ref(ref_name)
487
+ ref.edit(sha=sha, force=True)
488
+ except GithubException:
489
+ gh_repo.create_git_ref(f"refs/{ref_name}", sha)
490
+ logger.info("Verified API tag %s -> %s", tag, sha)
491
+ return tag
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2025-2026 Guy Erreich
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ from auto_semver.adapters.github.event import GitHubEvent
5
+
6
+ __all__ = ["GitHubEvent"]