github2gerrit 0.1.5__py3-none-any.whl → 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github2gerrit/cli.py +511 -271
- github2gerrit/commit_normalization.py +471 -0
- github2gerrit/config.py +32 -24
- github2gerrit/core.py +1092 -507
- github2gerrit/duplicate_detection.py +333 -217
- github2gerrit/external_api.py +518 -0
- github2gerrit/gerrit_rest.py +298 -0
- github2gerrit/gerrit_urls.py +353 -0
- github2gerrit/github_api.py +17 -95
- github2gerrit/gitutils.py +225 -41
- github2gerrit/models.py +3 -0
- github2gerrit/pr_content_filter.py +476 -0
- github2gerrit/similarity.py +458 -0
- github2gerrit/ssh_agent_setup.py +351 -0
- github2gerrit/ssh_common.py +244 -0
- github2gerrit/ssh_discovery.py +24 -67
- github2gerrit/utils.py +113 -0
- github2gerrit-0.1.7.dist-info/METADATA +798 -0
- github2gerrit-0.1.7.dist-info/RECORD +24 -0
- github2gerrit-0.1.5.dist-info/METADATA +0 -555
- github2gerrit-0.1.5.dist-info/RECORD +0 -15
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.5.dist-info → github2gerrit-0.1.7.dist-info}/top_level.txt +0 -0
github2gerrit/github_api.py
CHANGED
@@ -18,10 +18,7 @@ from __future__ import annotations
|
|
18
18
|
|
19
19
|
import logging
|
20
20
|
import os
|
21
|
-
import random
|
22
21
|
import re
|
23
|
-
import time
|
24
|
-
from collections.abc import Callable
|
25
22
|
from collections.abc import Iterable
|
26
23
|
from importlib import import_module
|
27
24
|
from typing import Any
|
@@ -29,6 +26,9 @@ from typing import Protocol
|
|
29
26
|
from typing import TypeVar
|
30
27
|
from typing import cast
|
31
28
|
|
29
|
+
from .external_api import ApiType
|
30
|
+
from .external_api import external_api_call
|
31
|
+
|
32
32
|
|
33
33
|
# Error message constants to comply with TRY003
|
34
34
|
_MSG_PYGITHUB_REQUIRED = "PyGithub required"
|
@@ -44,9 +44,7 @@ class RateLimitExceededExceptionType(GithubExceptionType):
|
|
44
44
|
pass
|
45
45
|
|
46
46
|
|
47
|
-
def _load_github_classes() -> tuple[
|
48
|
-
Any | None, type[BaseException], type[BaseException]
|
49
|
-
]:
|
47
|
+
def _load_github_classes() -> tuple[Any | None, type[BaseException], type[BaseException]]:
|
50
48
|
try:
|
51
49
|
exc_mod = import_module("github.GithubException")
|
52
50
|
ge = exc_mod.GithubException
|
@@ -113,7 +111,6 @@ __all__ = [
|
|
113
111
|
"get_recent_change_ids_from_comments",
|
114
112
|
"get_repo_from_env",
|
115
113
|
"iter_open_pulls",
|
116
|
-
"time",
|
117
114
|
]
|
118
115
|
|
119
116
|
log = logging.getLogger("github2gerrit.github_api")
|
@@ -126,75 +123,7 @@ def _getenv_str(name: str) -> str:
|
|
126
123
|
return val.strip()
|
127
124
|
|
128
125
|
|
129
|
-
|
130
|
-
# Exponential backoff with jitter; cap prevents unbounded waits.
|
131
|
-
delay: float = float(min(base * (2 ** max(0, attempt - 1)), cap))
|
132
|
-
# Using random.uniform for jitter is appropriate here - we only need
|
133
|
-
# pseudorandom distribution to avoid thundering herd, not crypto security
|
134
|
-
jitter: float = float(random.uniform(0.0, delay / 2.0)) # noqa: S311
|
135
|
-
return float(delay + jitter)
|
136
|
-
|
137
|
-
|
138
|
-
def _should_retry(exc: BaseException) -> bool:
|
139
|
-
# Retry on common transient conditions:
|
140
|
-
# - RateLimitExceededException
|
141
|
-
# - GithubException with 5xx codes
|
142
|
-
# - GithubException with 403 and rate limit hints
|
143
|
-
if isinstance(exc, _RATE_LIMIT_EXC | RateLimitExceededExceptionType):
|
144
|
-
return True
|
145
|
-
if isinstance(exc, _GITHUB_EXCEPTION | GithubExceptionType):
|
146
|
-
status = getattr(exc, "status", None)
|
147
|
-
if isinstance(status, int) and 500 <= status <= 599:
|
148
|
-
return True
|
149
|
-
data = getattr(exc, "data", "")
|
150
|
-
if status == 403 and isinstance(data, str | bytes):
|
151
|
-
try:
|
152
|
-
text = data.decode("utf-8") if isinstance(data, bytes) else data
|
153
|
-
except Exception:
|
154
|
-
text = str(data)
|
155
|
-
if "rate limit" in text.lower():
|
156
|
-
return True
|
157
|
-
return False
|
158
|
-
|
159
|
-
|
160
|
-
def _retry_on_github(
|
161
|
-
attempts: int = 5,
|
162
|
-
) -> Callable[[Callable[..., _T]], Callable[..., _T]]:
|
163
|
-
def decorator(func: Callable[..., _T]) -> Callable[..., _T]:
|
164
|
-
def wrapper(*args: Any, **kwargs: Any) -> _T:
|
165
|
-
last_exc: BaseException | None = None
|
166
|
-
for attempt in range(1, attempts + 1):
|
167
|
-
try:
|
168
|
-
return func(*args, **kwargs)
|
169
|
-
except BaseException as exc:
|
170
|
-
last_exc = exc
|
171
|
-
if not _should_retry(exc) or attempt == attempts:
|
172
|
-
log.debug(
|
173
|
-
"GitHub call failed (no retry) at attempt %d: %s",
|
174
|
-
attempt,
|
175
|
-
exc,
|
176
|
-
)
|
177
|
-
raise
|
178
|
-
delay = _backoff_delay(attempt)
|
179
|
-
log.warning(
|
180
|
-
"GitHub call failed (attempt %d): %s; retrying in "
|
181
|
-
"%.2fs",
|
182
|
-
attempt,
|
183
|
-
exc,
|
184
|
-
delay,
|
185
|
-
)
|
186
|
-
time.sleep(delay)
|
187
|
-
# Should not reach here, but raise if it does.
|
188
|
-
if last_exc is None:
|
189
|
-
raise RuntimeError("unreachable")
|
190
|
-
raise last_exc
|
191
|
-
|
192
|
-
return wrapper
|
193
|
-
|
194
|
-
return decorator
|
195
|
-
|
196
|
-
|
197
|
-
@_retry_on_github()
|
126
|
+
@external_api_call(ApiType.GITHUB, "build_client")
|
198
127
|
def build_client(token: str | None = None) -> GhClient:
|
199
128
|
"""Construct a PyGithub client from a token or environment.
|
200
129
|
|
@@ -212,7 +141,8 @@ def build_client(token: str | None = None) -> GhClient:
|
|
212
141
|
base_url = _getenv_str("GITHUB_API_URL")
|
213
142
|
if not base_url:
|
214
143
|
server_url = _getenv_str("GITHUB_SERVER_URL")
|
215
|
-
|
144
|
+
# Only synthesize API URL for non-github.com servers (GHE instances)
|
145
|
+
if server_url and server_url.rstrip("/") != "https://github.com":
|
216
146
|
base_url = server_url.rstrip("/") + "/api/v3"
|
217
147
|
client_any: Any
|
218
148
|
try:
|
@@ -221,29 +151,23 @@ def build_client(token: str | None = None) -> GhClient:
|
|
221
151
|
if auth_factory is not None and hasattr(auth_factory, "Token"):
|
222
152
|
auth_obj = auth_factory.Token(tok)
|
223
153
|
if base_url:
|
224
|
-
client_any = Github(
|
225
|
-
auth=auth_obj, per_page=100, base_url=base_url
|
226
|
-
)
|
154
|
+
client_any = Github(auth=auth_obj, per_page=100, base_url=base_url)
|
227
155
|
else:
|
228
156
|
client_any = Github(auth=auth_obj, per_page=100)
|
229
157
|
else:
|
230
158
|
if base_url:
|
231
|
-
client_any = Github(
|
232
|
-
login_or_token=tok, per_page=100, base_url=base_url
|
233
|
-
)
|
159
|
+
client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
|
234
160
|
else:
|
235
161
|
client_any = Github(login_or_token=tok, per_page=100)
|
236
162
|
except Exception:
|
237
163
|
if base_url:
|
238
|
-
client_any = Github(
|
239
|
-
login_or_token=tok, per_page=100, base_url=base_url
|
240
|
-
)
|
164
|
+
client_any = Github(login_or_token=tok, per_page=100, base_url=base_url)
|
241
165
|
else:
|
242
166
|
client_any = Github(login_or_token=tok, per_page=100)
|
243
167
|
return cast(GhClient, client_any)
|
244
168
|
|
245
169
|
|
246
|
-
@
|
170
|
+
@external_api_call(ApiType.GITHUB, "get_repo_from_env")
|
247
171
|
def get_repo_from_env(client: GhClient) -> GhRepository:
|
248
172
|
"""Return the repository object based on GITHUB_REPOSITORY."""
|
249
173
|
full = _getenv_str("GITHUB_REPOSITORY")
|
@@ -253,7 +177,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
|
|
253
177
|
return repo
|
254
178
|
|
255
179
|
|
256
|
-
@
|
180
|
+
@external_api_call(ApiType.GITHUB, "get_pull")
|
257
181
|
def get_pull(repo: GhRepository, number: int) -> GhPullRequest:
|
258
182
|
"""Fetch a pull request by number."""
|
259
183
|
pr = repo.get_pull(number)
|
@@ -275,14 +199,14 @@ def get_pr_title_body(pr: GhPullRequest) -> tuple[str, str]:
|
|
275
199
|
_CHANGE_ID_RE: re.Pattern[str] = re.compile(r"Change-Id:\s*([A-Za-z0-9._-]+)")
|
276
200
|
|
277
201
|
|
278
|
-
@
|
202
|
+
@external_api_call(ApiType.GITHUB, "get_pr_comments")
|
279
203
|
def _get_issue(pr: GhPullRequest) -> GhIssue:
|
280
204
|
"""Return the issue object corresponding to a pull request."""
|
281
205
|
issue = pr.as_issue()
|
282
206
|
return issue
|
283
207
|
|
284
208
|
|
285
|
-
@
|
209
|
+
@external_api_call(ApiType.GITHUB, "get_issue_from_pr")
|
286
210
|
def get_recent_change_ids_from_comments(
|
287
211
|
pr: GhPullRequest,
|
288
212
|
*,
|
@@ -317,7 +241,7 @@ def get_recent_change_ids_from_comments(
|
|
317
241
|
return found
|
318
242
|
|
319
243
|
|
320
|
-
@
|
244
|
+
@external_api_call(ApiType.GITHUB, "create_pr_comment")
|
321
245
|
def create_pr_comment(pr: GhPullRequest, body: str) -> None:
|
322
246
|
"""Create a new comment on the pull request."""
|
323
247
|
if not body.strip():
|
@@ -326,14 +250,12 @@ def create_pr_comment(pr: GhPullRequest, body: str) -> None:
|
|
326
250
|
issue.create_comment(body)
|
327
251
|
|
328
252
|
|
329
|
-
@
|
253
|
+
@external_api_call(ApiType.GITHUB, "close_pr")
|
330
254
|
def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
|
331
255
|
"""Close a pull request, optionally posting a comment first."""
|
332
256
|
if comment and comment.strip():
|
333
257
|
try:
|
334
258
|
create_pr_comment(pr, comment)
|
335
259
|
except Exception as exc:
|
336
|
-
log.warning(
|
337
|
-
"Failed to add close comment to PR #%s: %s", pr.number, exc
|
338
|
-
)
|
260
|
+
log.warning("Failed to add close comment to PR #%s: %s", pr.number, exc)
|
339
261
|
pr.edit(state="closed")
|
github2gerrit/gitutils.py
CHANGED
@@ -20,22 +20,8 @@ from collections.abc import Mapping
|
|
20
20
|
from collections.abc import Sequence
|
21
21
|
from dataclasses import dataclass
|
22
22
|
from pathlib import Path
|
23
|
-
from typing import Any
|
24
23
|
|
25
|
-
|
26
|
-
def _is_verbose_mode() -> bool:
|
27
|
-
"""Check if verbose mode is enabled via environment variable."""
|
28
|
-
return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
|
29
|
-
|
30
|
-
|
31
|
-
def _log_exception_conditionally(
|
32
|
-
logger: logging.Logger, message: str, *args: Any
|
33
|
-
) -> None:
|
34
|
-
"""Log exception with traceback only if verbose mode is enabled."""
|
35
|
-
if _is_verbose_mode():
|
36
|
-
logger.exception(message, *args)
|
37
|
-
else:
|
38
|
-
logger.error(message, *args)
|
24
|
+
from .utils import log_exception_conditionally
|
39
25
|
|
40
26
|
|
41
27
|
__all__ = [
|
@@ -67,10 +53,7 @@ if not log.handlers:
|
|
67
53
|
# Provide a minimal default if the app has not configured logging.
|
68
54
|
level_name = os.getenv("G2G_LOG_LEVEL", "INFO").upper()
|
69
55
|
level = getattr(logging, level_name, logging.INFO)
|
70
|
-
fmt = (
|
71
|
-
"%(asctime)s %(levelname)-8s %(name)s "
|
72
|
-
"%(filename)s:%(lineno)d | %(message)s"
|
73
|
-
)
|
56
|
+
fmt = "%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d | %(message)s"
|
74
57
|
logging.basicConfig(level=level, format=fmt)
|
75
58
|
|
76
59
|
|
@@ -189,6 +172,7 @@ def run_cmd(
|
|
189
172
|
- Raises CommandError on failure when check=True.
|
190
173
|
"""
|
191
174
|
masks = list(masks or [])
|
175
|
+
env = env or non_interactive_env()
|
192
176
|
env_full = _merge_env(None, env)
|
193
177
|
|
194
178
|
log.debug("Executing: %s", _format_cmd_for_log(cmd, masks))
|
@@ -206,7 +190,7 @@ def run_cmd(
|
|
206
190
|
)
|
207
191
|
except subprocess.TimeoutExpired as exc:
|
208
192
|
msg = f"Command timed out: {cmd!r}"
|
209
|
-
|
193
|
+
log_exception_conditionally(log, msg)
|
210
194
|
# TimeoutExpired carries 'output' and 'stderr' attributes,
|
211
195
|
# which may be bytes depending on invocation context.
|
212
196
|
out = getattr(exc, "output", None)
|
@@ -220,7 +204,7 @@ def run_cmd(
|
|
220
204
|
) from exc
|
221
205
|
except OSError as exc:
|
222
206
|
msg = f"Failed to execute command: {cmd!r} ({exc})"
|
223
|
-
|
207
|
+
log_exception_conditionally(log, msg)
|
224
208
|
raise CommandError(msg, cmd=cmd) from exc
|
225
209
|
|
226
210
|
result = CommandResult(
|
@@ -249,11 +233,50 @@ def run_cmd(
|
|
249
233
|
if result.stdout:
|
250
234
|
log.debug("stdout: %s", mask_text(result.stdout, masks))
|
251
235
|
if result.stderr:
|
252
|
-
|
236
|
+
# Filter out git init default branch hints to keep logs clean
|
237
|
+
stderr_lines = result.stderr.splitlines()
|
238
|
+
filtered_lines = []
|
239
|
+
skip_hint = False
|
240
|
+
for line in stderr_lines:
|
241
|
+
if "hint: Using '" in line and "as the name for the initial branch" in line:
|
242
|
+
skip_hint = True
|
243
|
+
elif (skip_hint and line.startswith("hint:")) or (skip_hint and not line.strip()):
|
244
|
+
continue
|
245
|
+
else:
|
246
|
+
skip_hint = False
|
247
|
+
filtered_lines.append(line)
|
248
|
+
|
249
|
+
if filtered_lines:
|
250
|
+
filtered_stderr = "\n".join(filtered_lines)
|
251
|
+
log.debug("stderr: %s", mask_text(filtered_stderr, masks))
|
253
252
|
|
254
253
|
return result
|
255
254
|
|
256
255
|
|
256
|
+
def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
|
257
|
+
"""Return a non-interactive SSH/Git environment to bypass local
|
258
|
+
agents/keychains.
|
259
|
+
|
260
|
+
Args:
|
261
|
+
include_git_ssh_command: Whether to include a default GIT_SSH_COMMAND.
|
262
|
+
Set to False when the caller will provide their own.
|
263
|
+
|
264
|
+
Returns:
|
265
|
+
Dictionary of environment variables for non-interactive operations.
|
266
|
+
"""
|
267
|
+
# Import here to avoid circular imports
|
268
|
+
from .ssh_common import build_non_interactive_ssh_env
|
269
|
+
|
270
|
+
env = build_non_interactive_ssh_env()
|
271
|
+
|
272
|
+
if include_git_ssh_command:
|
273
|
+
from .ssh_common import build_git_ssh_command
|
274
|
+
|
275
|
+
env["GIT_SSH_COMMAND"] = build_git_ssh_command()
|
276
|
+
|
277
|
+
return env
|
278
|
+
|
279
|
+
|
257
280
|
def run_cmd_with_retries(
|
258
281
|
cmd: Sequence[str],
|
259
282
|
*,
|
@@ -271,6 +294,7 @@ def run_cmd_with_retries(
|
|
271
294
|
The default retry predicate considers common transient git errors.
|
272
295
|
"""
|
273
296
|
masks = list(masks or [])
|
297
|
+
env = env or non_interactive_env()
|
274
298
|
|
275
299
|
def _default_retry_on(res: CommandResult) -> bool:
|
276
300
|
return res.returncode != 0 and _is_transient_git_error(res.stderr)
|
@@ -308,8 +332,7 @@ def run_cmd_with_retries(
|
|
308
332
|
if predicate(res):
|
309
333
|
delay = _backoff_delay(attempt)
|
310
334
|
log.warning(
|
311
|
-
"Retrying (attempt %d) after transient error; delay %.1fs. "
|
312
|
-
"cmd=%s",
|
335
|
+
"Retrying (attempt %d) after transient error; delay %.1fs. cmd=%s",
|
313
336
|
attempt,
|
314
337
|
delay,
|
315
338
|
_format_cmd_for_log(cmd, masks),
|
@@ -370,7 +393,7 @@ def git_quiet(
|
|
370
393
|
return run_cmd(
|
371
394
|
cmd,
|
372
395
|
cwd=cwd,
|
373
|
-
env=env,
|
396
|
+
env=env or non_interactive_env(),
|
374
397
|
timeout=timeout,
|
375
398
|
check=False,
|
376
399
|
)
|
@@ -455,16 +478,78 @@ def git_commit_amend(
|
|
455
478
|
|
456
479
|
If message is provided, it takes precedence over message_file.
|
457
480
|
"""
|
481
|
+
# Write message to a temp file to avoid shell-escaping issues
|
482
|
+
tmp_path: Path | None = None
|
483
|
+
if message is not None:
|
484
|
+
import tempfile as _tempfile
|
485
|
+
|
486
|
+
with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
487
|
+
_tf.write(message)
|
488
|
+
_tf.flush()
|
489
|
+
tmp_path = Path(_tf.name)
|
490
|
+
message_file = tmp_path
|
491
|
+
message = None
|
492
|
+
|
493
|
+
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
494
|
+
effective_signoff = bool(signoff)
|
495
|
+
try:
|
496
|
+
import os
|
497
|
+
import re
|
498
|
+
|
499
|
+
# Resolve committer email (prefer repo-local; fallback to global/env)
|
500
|
+
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
501
|
+
if not committer_email:
|
502
|
+
try:
|
503
|
+
res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
|
504
|
+
committer_email = (res.stdout or "").strip()
|
505
|
+
except Exception:
|
506
|
+
committer_email = ""
|
507
|
+
if not committer_email:
|
508
|
+
try:
|
509
|
+
ge = git_config_get("user.email", global_=True)
|
510
|
+
if ge:
|
511
|
+
committer_email = ge.strip()
|
512
|
+
except Exception:
|
513
|
+
committer_email = ""
|
514
|
+
|
515
|
+
def _has_committer_signoff(text: str) -> bool:
|
516
|
+
for ln in text.splitlines():
|
517
|
+
if ln.lower().startswith("signed-off-by:"):
|
518
|
+
m = re.search(r"<([^>]+)>", ln)
|
519
|
+
if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
|
520
|
+
return True
|
521
|
+
return False
|
522
|
+
|
523
|
+
msg_text: str | None = None
|
524
|
+
if message_file is not None:
|
525
|
+
try:
|
526
|
+
msg_text = Path(message_file).read_text(encoding="utf-8")
|
527
|
+
except Exception:
|
528
|
+
msg_text = None
|
529
|
+
|
530
|
+
if msg_text is not None:
|
531
|
+
if committer_email and _has_committer_signoff(msg_text):
|
532
|
+
effective_signoff = False
|
533
|
+
else:
|
534
|
+
# No explicit message provided; check current commit body
|
535
|
+
try:
|
536
|
+
body = git_show("HEAD", cwd=cwd, fmt="%B")
|
537
|
+
if committer_email and _has_committer_signoff(body):
|
538
|
+
effective_signoff = False
|
539
|
+
except GitError:
|
540
|
+
pass
|
541
|
+
except Exception:
|
542
|
+
# Best effort only; default to requested signoff
|
543
|
+
effective_signoff = bool(signoff)
|
544
|
+
|
458
545
|
args: list[str] = ["commit", "--amend"]
|
459
546
|
if no_edit and not message and not message_file:
|
460
547
|
args.append("--no-edit")
|
461
|
-
if
|
548
|
+
if effective_signoff:
|
462
549
|
args.append("-s")
|
463
550
|
if author:
|
464
551
|
args.extend(["--author", author])
|
465
|
-
if
|
466
|
-
args.extend(["-m", message])
|
467
|
-
elif message_file:
|
552
|
+
if message_file:
|
468
553
|
args.extend(["-F", str(message_file)])
|
469
554
|
|
470
555
|
try:
|
@@ -477,6 +562,12 @@ def git_commit_amend(
|
|
477
562
|
stdout=exc.stdout,
|
478
563
|
stderr=exc.stderr,
|
479
564
|
) from exc
|
565
|
+
finally:
|
566
|
+
if tmp_path is not None:
|
567
|
+
from contextlib import suppress
|
568
|
+
|
569
|
+
with suppress(Exception):
|
570
|
+
tmp_path.unlink(missing_ok=True)
|
480
571
|
|
481
572
|
|
482
573
|
def git_commit_new(
|
@@ -492,17 +583,67 @@ def git_commit_new(
|
|
492
583
|
if not message and not message_file:
|
493
584
|
raise ValueError(_MSG_COMMIT_NO_MESSAGE)
|
494
585
|
|
586
|
+
# Write message to a temp file to avoid shell-escaping issues
|
587
|
+
tmp_path: Path | None = None
|
588
|
+
if message is not None:
|
589
|
+
import tempfile as _tempfile
|
590
|
+
|
591
|
+
with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
|
592
|
+
_tf.write(message)
|
593
|
+
_tf.flush()
|
594
|
+
tmp_path = Path(_tf.name)
|
595
|
+
message_file = tmp_path
|
596
|
+
message = None
|
597
|
+
|
598
|
+
# Determine whether to add -s; only suppress if message already has a sign-off for current committer
|
599
|
+
effective_signoff = bool(signoff)
|
600
|
+
try:
|
601
|
+
import os
|
602
|
+
import re
|
603
|
+
|
604
|
+
# Resolve committer email (prefer repo-local; fallback to global/env)
|
605
|
+
committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
|
606
|
+
if not committer_email:
|
607
|
+
try:
|
608
|
+
res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
|
609
|
+
committer_email = (res.stdout or "").strip()
|
610
|
+
except Exception:
|
611
|
+
committer_email = ""
|
612
|
+
if not committer_email:
|
613
|
+
try:
|
614
|
+
ge = git_config_get("user.email", global_=True)
|
615
|
+
if ge:
|
616
|
+
committer_email = ge.strip()
|
617
|
+
except Exception:
|
618
|
+
committer_email = ""
|
619
|
+
|
620
|
+
def _has_committer_signoff(text: str) -> bool:
|
621
|
+
for ln in text.splitlines():
|
622
|
+
if ln.lower().startswith("signed-off-by:"):
|
623
|
+
m = re.search(r"<([^>]+)>", ln)
|
624
|
+
if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
|
625
|
+
return True
|
626
|
+
return False
|
627
|
+
|
628
|
+
if message_file is not None:
|
629
|
+
try:
|
630
|
+
msg_text = Path(message_file).read_text(encoding="utf-8")
|
631
|
+
except Exception:
|
632
|
+
msg_text = None
|
633
|
+
if msg_text and _has_committer_signoff(msg_text):
|
634
|
+
effective_signoff = False
|
635
|
+
except Exception:
|
636
|
+
effective_signoff = bool(signoff)
|
637
|
+
|
495
638
|
args: list[str] = ["commit"]
|
496
|
-
if
|
639
|
+
if effective_signoff:
|
497
640
|
args.append("-s")
|
498
641
|
if author:
|
499
642
|
args.extend(["--author", author])
|
500
643
|
if allow_empty:
|
501
644
|
args.append("--allow-empty")
|
502
645
|
|
503
|
-
if
|
504
|
-
args.extend(["-m", message])
|
505
|
-
else:
|
646
|
+
if message_file:
|
506
647
|
args.extend(["-F", str(message_file)])
|
507
648
|
|
508
649
|
try:
|
@@ -515,6 +656,12 @@ def git_commit_new(
|
|
515
656
|
stdout=exc.stdout,
|
516
657
|
stderr=exc.stderr,
|
517
658
|
) from exc
|
659
|
+
finally:
|
660
|
+
if tmp_path is not None:
|
661
|
+
from contextlib import suppress
|
662
|
+
|
663
|
+
with suppress(Exception):
|
664
|
+
tmp_path.unlink(missing_ok=True)
|
518
665
|
|
519
666
|
|
520
667
|
def git_show(
|
@@ -542,21 +689,60 @@ def git_show(
|
|
542
689
|
|
543
690
|
|
544
691
|
def _parse_trailers(text: str) -> dict[str, list[str]]:
|
545
|
-
"""Parse trailers from a commit message
|
692
|
+
"""Parse trailers from a commit message footer only.
|
546
693
|
|
547
|
-
|
694
|
+
Git trailers are key-value pairs that appear at the end of commit messages,
|
695
|
+
separated from the body by a blank line. This function only parses trailers
|
696
|
+
from the actual footer section to avoid false positives from the message body.
|
548
697
|
"""
|
549
698
|
trailers: dict[str, list[str]] = {}
|
550
|
-
|
551
|
-
|
699
|
+
lines = text.splitlines()
|
700
|
+
|
701
|
+
# Find the start of the trailer block by working backwards
|
702
|
+
# Trailers must be at the end, separated by a blank line from the body
|
703
|
+
trailer_start = len(lines)
|
704
|
+
in_trailer_block = True
|
705
|
+
|
706
|
+
for i in range(len(lines) - 1, -1, -1):
|
707
|
+
line = lines[i].strip()
|
708
|
+
|
709
|
+
if not line and in_trailer_block:
|
710
|
+
# Found blank line, trailers end here
|
711
|
+
trailer_start = i + 1
|
712
|
+
break
|
713
|
+
elif not line:
|
714
|
+
# Blank line in middle, not in trailer block anymore
|
715
|
+
in_trailer_block = False
|
716
|
+
trailer_start = len(lines)
|
717
|
+
elif in_trailer_block and ":" in line:
|
718
|
+
# Potential trailer line
|
719
|
+
key, val = line.split(":", 1)
|
720
|
+
k = key.strip()
|
721
|
+
v = val.strip()
|
722
|
+
if k and v and not k.startswith(" ") and not k.startswith("\t"):
|
723
|
+
# Valid trailer format
|
724
|
+
continue
|
725
|
+
else:
|
726
|
+
# Invalid trailer format, stop looking
|
727
|
+
in_trailer_block = False
|
728
|
+
trailer_start = len(lines)
|
729
|
+
elif in_trailer_block:
|
730
|
+
# Non-trailer line in what we thought was trailer block
|
731
|
+
in_trailer_block = False
|
732
|
+
trailer_start = len(lines)
|
733
|
+
|
734
|
+
# Parse only the trailer section
|
735
|
+
for i in range(trailer_start, len(lines)):
|
736
|
+
line = lines[i].strip()
|
552
737
|
if not line or ":" not in line:
|
553
738
|
continue
|
554
739
|
key, val = line.split(":", 1)
|
555
740
|
k = key.strip()
|
556
741
|
v = val.strip()
|
557
|
-
if not k or not v:
|
742
|
+
if not k or not v or k.startswith((" ", "\t")):
|
558
743
|
continue
|
559
744
|
trailers.setdefault(k, []).append(v)
|
745
|
+
|
560
746
|
return trailers
|
561
747
|
|
562
748
|
|
@@ -618,9 +804,7 @@ def git_config_get_all(
|
|
618
804
|
try:
|
619
805
|
res = git_quiet(args, cwd=None)
|
620
806
|
if res.returncode == 0:
|
621
|
-
values = [
|
622
|
-
ln.strip() for ln in res.stdout.splitlines() if ln.strip()
|
623
|
-
]
|
807
|
+
values = [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
|
624
808
|
return values
|
625
809
|
else:
|
626
810
|
return []
|
github2gerrit/models.py
CHANGED
@@ -45,6 +45,7 @@ class Inputs:
|
|
45
45
|
# Behavior toggles
|
46
46
|
preserve_github_prs: bool
|
47
47
|
dry_run: bool
|
48
|
+
normalise_commit: bool
|
48
49
|
|
49
50
|
# Optional (reusable workflow compatibility / overrides)
|
50
51
|
gerrit_server: str
|
@@ -52,6 +53,8 @@ class Inputs:
|
|
52
53
|
gerrit_project: str
|
53
54
|
issue_id: str
|
54
55
|
allow_duplicates: bool
|
56
|
+
ci_testing: bool
|
57
|
+
duplicates_filter: str = "open"
|
55
58
|
|
56
59
|
|
57
60
|
@dataclass(frozen=True)
|