github2gerrit 0.1.6__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.
@@ -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"
@@ -111,7 +111,6 @@ __all__ = [
111
111
  "get_recent_change_ids_from_comments",
112
112
  "get_repo_from_env",
113
113
  "iter_open_pulls",
114
- "time",
115
114
  ]
116
115
 
117
116
  log = logging.getLogger("github2gerrit.github_api")
@@ -124,74 +123,7 @@ def _getenv_str(name: str) -> str:
124
123
  return val.strip()
125
124
 
126
125
 
127
- def _backoff_delay(attempt: int, base: float = 0.5, cap: float = 6.0) -> float:
128
- # Exponential backoff with jitter; cap prevents unbounded waits.
129
- delay: float = float(min(base * (2 ** max(0, attempt - 1)), cap))
130
- # Using random.uniform for jitter is appropriate here - we only need
131
- # pseudorandom distribution to avoid thundering herd, not crypto security
132
- jitter: float = float(random.uniform(0.0, delay / 2.0)) # noqa: S311
133
- return float(delay + jitter)
134
-
135
-
136
- def _should_retry(exc: BaseException) -> bool:
137
- # Retry on common transient conditions:
138
- # - RateLimitExceededException
139
- # - GithubException with 5xx codes
140
- # - GithubException with 403 and rate limit hints
141
- if isinstance(exc, _RATE_LIMIT_EXC | RateLimitExceededExceptionType):
142
- return True
143
- if isinstance(exc, _GITHUB_EXCEPTION | GithubExceptionType):
144
- status = getattr(exc, "status", None)
145
- if isinstance(status, int) and 500 <= status <= 599:
146
- return True
147
- data = getattr(exc, "data", "")
148
- if status == 403 and isinstance(data, str | bytes):
149
- try:
150
- text = data.decode("utf-8") if isinstance(data, bytes) else data
151
- except Exception:
152
- text = str(data)
153
- if "rate limit" in text.lower():
154
- return True
155
- return False
156
-
157
-
158
- def _retry_on_github(
159
- attempts: int = 5,
160
- ) -> Callable[[Callable[..., _T]], Callable[..., _T]]:
161
- def decorator(func: Callable[..., _T]) -> Callable[..., _T]:
162
- def wrapper(*args: Any, **kwargs: Any) -> _T:
163
- last_exc: BaseException | None = None
164
- for attempt in range(1, attempts + 1):
165
- try:
166
- return func(*args, **kwargs)
167
- except BaseException as exc:
168
- last_exc = exc
169
- if not _should_retry(exc) or attempt == attempts:
170
- log.debug(
171
- "GitHub call failed (no retry) at attempt %d: %s",
172
- attempt,
173
- exc,
174
- )
175
- raise
176
- delay = _backoff_delay(attempt)
177
- log.warning(
178
- "GitHub call failed (attempt %d): %s; retrying in %.2fs",
179
- attempt,
180
- exc,
181
- delay,
182
- )
183
- time.sleep(delay)
184
- # Should not reach here, but raise if it does.
185
- if last_exc is None:
186
- raise RuntimeError("unreachable")
187
- raise last_exc
188
-
189
- return wrapper
190
-
191
- return decorator
192
-
193
-
194
- @_retry_on_github()
126
+ @external_api_call(ApiType.GITHUB, "build_client")
195
127
  def build_client(token: str | None = None) -> GhClient:
196
128
  """Construct a PyGithub client from a token or environment.
197
129
 
@@ -209,7 +141,8 @@ def build_client(token: str | None = None) -> GhClient:
209
141
  base_url = _getenv_str("GITHUB_API_URL")
210
142
  if not base_url:
211
143
  server_url = _getenv_str("GITHUB_SERVER_URL")
212
- if server_url:
144
+ # Only synthesize API URL for non-github.com servers (GHE instances)
145
+ if server_url and server_url.rstrip("/") != "https://github.com":
213
146
  base_url = server_url.rstrip("/") + "/api/v3"
214
147
  client_any: Any
215
148
  try:
@@ -234,7 +167,7 @@ def build_client(token: str | None = None) -> GhClient:
234
167
  return cast(GhClient, client_any)
235
168
 
236
169
 
237
- @_retry_on_github()
170
+ @external_api_call(ApiType.GITHUB, "get_repo_from_env")
238
171
  def get_repo_from_env(client: GhClient) -> GhRepository:
239
172
  """Return the repository object based on GITHUB_REPOSITORY."""
240
173
  full = _getenv_str("GITHUB_REPOSITORY")
@@ -244,7 +177,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
244
177
  return repo
245
178
 
246
179
 
247
- @_retry_on_github()
180
+ @external_api_call(ApiType.GITHUB, "get_pull")
248
181
  def get_pull(repo: GhRepository, number: int) -> GhPullRequest:
249
182
  """Fetch a pull request by number."""
250
183
  pr = repo.get_pull(number)
@@ -266,14 +199,14 @@ def get_pr_title_body(pr: GhPullRequest) -> tuple[str, str]:
266
199
  _CHANGE_ID_RE: re.Pattern[str] = re.compile(r"Change-Id:\s*([A-Za-z0-9._-]+)")
267
200
 
268
201
 
269
- @_retry_on_github()
202
+ @external_api_call(ApiType.GITHUB, "get_pr_comments")
270
203
  def _get_issue(pr: GhPullRequest) -> GhIssue:
271
204
  """Return the issue object corresponding to a pull request."""
272
205
  issue = pr.as_issue()
273
206
  return issue
274
207
 
275
208
 
276
- @_retry_on_github()
209
+ @external_api_call(ApiType.GITHUB, "get_issue_from_pr")
277
210
  def get_recent_change_ids_from_comments(
278
211
  pr: GhPullRequest,
279
212
  *,
@@ -308,7 +241,7 @@ def get_recent_change_ids_from_comments(
308
241
  return found
309
242
 
310
243
 
311
- @_retry_on_github()
244
+ @external_api_call(ApiType.GITHUB, "create_pr_comment")
312
245
  def create_pr_comment(pr: GhPullRequest, body: str) -> None:
313
246
  """Create a new comment on the pull request."""
314
247
  if not body.strip():
@@ -317,7 +250,7 @@ def create_pr_comment(pr: GhPullRequest, body: str) -> None:
317
250
  issue.create_comment(body)
318
251
 
319
252
 
320
- @_retry_on_github()
253
+ @external_api_call(ApiType.GITHUB, "close_pr")
321
254
  def close_pr(pr: GhPullRequest, *, comment: str | None = None) -> None:
322
255
  """Close a pull request, optionally posting a comment first."""
323
256
  if comment and comment.strip():
github2gerrit/gitutils.py CHANGED
@@ -20,20 +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(logger: logging.Logger, message: str, *args: Any) -> None:
32
- """Log exception with traceback only if verbose mode is enabled."""
33
- if _is_verbose_mode():
34
- logger.exception(message, *args)
35
- else:
36
- logger.error(message, *args)
24
+ from .utils import log_exception_conditionally
37
25
 
38
26
 
39
27
  __all__ = [
@@ -202,7 +190,7 @@ def run_cmd(
202
190
  )
203
191
  except subprocess.TimeoutExpired as exc:
204
192
  msg = f"Command timed out: {cmd!r}"
205
- _log_exception_conditionally(log, msg)
193
+ log_exception_conditionally(log, msg)
206
194
  # TimeoutExpired carries 'output' and 'stderr' attributes,
207
195
  # which may be bytes depending on invocation context.
208
196
  out = getattr(exc, "output", None)
@@ -216,7 +204,7 @@ def run_cmd(
216
204
  ) from exc
217
205
  except OSError as exc:
218
206
  msg = f"Failed to execute command: {cmd!r} ({exc})"
219
- _log_exception_conditionally(log, msg)
207
+ log_exception_conditionally(log, msg)
220
208
  raise CommandError(msg, cmd=cmd) from exc
221
209
 
222
210
  result = CommandResult(
@@ -245,32 +233,48 @@ def run_cmd(
245
233
  if result.stdout:
246
234
  log.debug("stdout: %s", mask_text(result.stdout, masks))
247
235
  if result.stderr:
248
- log.debug("stderr: %s", mask_text(result.stderr, masks))
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))
249
252
 
250
253
  return result
251
254
 
252
255
 
253
- def non_interactive_env() -> dict[str, str]:
256
+ def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
254
257
  """Return a non-interactive SSH/Git environment to bypass local
255
- agents/keychains."""
256
- return {
257
- "GIT_SSH_COMMAND": (
258
- "ssh -F /dev/null "
259
- "-o IdentitiesOnly=yes "
260
- "-o IdentityAgent=none "
261
- "-o BatchMode=yes "
262
- "-o PreferredAuthentications=publickey "
263
- "-o StrictHostKeyChecking=yes "
264
- "-o PasswordAuthentication=no "
265
- "-o PubkeyAcceptedKeyTypes=+ssh-rsa "
266
- "-o ConnectTimeout=10"
267
- ),
268
- "SSH_AUTH_SOCK": "",
269
- "SSH_AGENT_PID": "",
270
- "SSH_ASKPASS": "/usr/bin/false",
271
- "DISPLAY": "",
272
- "SSH_ASKPASS_REQUIRE": "never",
273
- }
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
274
278
 
275
279
 
276
280
  def run_cmd_with_retries(
@@ -474,16 +478,78 @@ def git_commit_amend(
474
478
 
475
479
  If message is provided, it takes precedence over message_file.
476
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
+
477
545
  args: list[str] = ["commit", "--amend"]
478
546
  if no_edit and not message and not message_file:
479
547
  args.append("--no-edit")
480
- if signoff:
548
+ if effective_signoff:
481
549
  args.append("-s")
482
550
  if author:
483
551
  args.extend(["--author", author])
484
- if message:
485
- args.extend(["-m", message])
486
- elif message_file:
552
+ if message_file:
487
553
  args.extend(["-F", str(message_file)])
488
554
 
489
555
  try:
@@ -496,6 +562,12 @@ def git_commit_amend(
496
562
  stdout=exc.stdout,
497
563
  stderr=exc.stderr,
498
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)
499
571
 
500
572
 
501
573
  def git_commit_new(
@@ -511,17 +583,67 @@ def git_commit_new(
511
583
  if not message and not message_file:
512
584
  raise ValueError(_MSG_COMMIT_NO_MESSAGE)
513
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
+
514
638
  args: list[str] = ["commit"]
515
- if signoff:
639
+ if effective_signoff:
516
640
  args.append("-s")
517
641
  if author:
518
642
  args.extend(["--author", author])
519
643
  if allow_empty:
520
644
  args.append("--allow-empty")
521
645
 
522
- if message:
523
- args.extend(["-m", message])
524
- else:
646
+ if message_file:
525
647
  args.extend(["-F", str(message_file)])
526
648
 
527
649
  try:
@@ -534,6 +656,12 @@ def git_commit_new(
534
656
  stdout=exc.stdout,
535
657
  stderr=exc.stderr,
536
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)
537
665
 
538
666
 
539
667
  def git_show(
@@ -561,21 +689,60 @@ def git_show(
561
689
 
562
690
 
563
691
  def _parse_trailers(text: str) -> dict[str, list[str]]:
564
- """Parse trailers from a commit message body.
692
+ """Parse trailers from a commit message footer only.
565
693
 
566
- Expects lines like 'Key: Value'. Multiple values per key are supported.
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.
567
697
  """
568
698
  trailers: dict[str, list[str]] = {}
569
- for raw in text.splitlines():
570
- line = raw.strip()
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()
571
737
  if not line or ":" not in line:
572
738
  continue
573
739
  key, val = line.split(":", 1)
574
740
  k = key.strip()
575
741
  v = val.strip()
576
- if not k or not v:
742
+ if not k or not v or k.startswith((" ", "\t")):
577
743
  continue
578
744
  trailers.setdefault(k, []).append(v)
745
+
579
746
  return trailers
580
747
 
581
748
 
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,7 @@ class Inputs:
52
53
  gerrit_project: str
53
54
  issue_id: str
54
55
  allow_duplicates: bool
56
+ ci_testing: bool
55
57
  duplicates_filter: str = "open"
56
58
 
57
59