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