github2gerrit 0.1.6__py3-none-any.whl → 0.1.8__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
@@ -11,8 +11,10 @@ from __future__ import annotations
11
11
 
12
12
  import logging
13
13
  import os
14
+ import re
14
15
  import shlex
15
16
  import subprocess
17
+ import tempfile
16
18
  import time
17
19
  from collections.abc import Callable
18
20
  from collections.abc import Iterable
@@ -20,20 +22,8 @@ from collections.abc import Mapping
20
22
  from collections.abc import Sequence
21
23
  from dataclasses import dataclass
22
24
  from pathlib import Path
23
- from typing import Any
24
25
 
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)
26
+ from .utils import log_exception_conditionally
37
27
 
38
28
 
39
29
  __all__ = [
@@ -202,7 +192,7 @@ def run_cmd(
202
192
  )
203
193
  except subprocess.TimeoutExpired as exc:
204
194
  msg = f"Command timed out: {cmd!r}"
205
- _log_exception_conditionally(log, msg)
195
+ log_exception_conditionally(log, msg)
206
196
  # TimeoutExpired carries 'output' and 'stderr' attributes,
207
197
  # which may be bytes depending on invocation context.
208
198
  out = getattr(exc, "output", None)
@@ -216,7 +206,7 @@ def run_cmd(
216
206
  ) from exc
217
207
  except OSError as exc:
218
208
  msg = f"Failed to execute command: {cmd!r} ({exc})"
219
- _log_exception_conditionally(log, msg)
209
+ log_exception_conditionally(log, msg)
220
210
  raise CommandError(msg, cmd=cmd) from exc
221
211
 
222
212
  result = CommandResult(
@@ -245,32 +235,48 @@ def run_cmd(
245
235
  if result.stdout:
246
236
  log.debug("stdout: %s", mask_text(result.stdout, masks))
247
237
  if result.stderr:
248
- log.debug("stderr: %s", mask_text(result.stderr, masks))
238
+ # Filter out git init default branch hints to keep logs clean
239
+ stderr_lines = result.stderr.splitlines()
240
+ filtered_lines = []
241
+ skip_hint = False
242
+ for line in stderr_lines:
243
+ if "hint: Using '" in line and "as the name for the initial branch" in line:
244
+ skip_hint = True
245
+ elif (skip_hint and line.startswith("hint:")) or (skip_hint and not line.strip()):
246
+ continue
247
+ else:
248
+ skip_hint = False
249
+ filtered_lines.append(line)
250
+
251
+ if filtered_lines:
252
+ filtered_stderr = "\n".join(filtered_lines)
253
+ log.debug("stderr: %s", mask_text(filtered_stderr, masks))
249
254
 
250
255
  return result
251
256
 
252
257
 
253
- def non_interactive_env() -> dict[str, str]:
258
+ def non_interactive_env(include_git_ssh_command: bool = True) -> dict[str, str]:
254
259
  """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
- }
260
+ agents/keychains.
261
+
262
+ Args:
263
+ include_git_ssh_command: Whether to include a default GIT_SSH_COMMAND.
264
+ Set to False when the caller will provide their own.
265
+
266
+ Returns:
267
+ Dictionary of environment variables for non-interactive operations.
268
+ """
269
+ # Import here to avoid circular imports
270
+ from .ssh_common import build_non_interactive_ssh_env
271
+
272
+ env = build_non_interactive_ssh_env()
273
+
274
+ if include_git_ssh_command:
275
+ from .ssh_common import build_git_ssh_command
276
+
277
+ env["GIT_SSH_COMMAND"] = build_git_ssh_command()
278
+
279
+ return env
274
280
 
275
281
 
276
282
  def run_cmd_with_retries(
@@ -474,16 +480,73 @@ def git_commit_amend(
474
480
 
475
481
  If message is provided, it takes precedence over message_file.
476
482
  """
483
+ # Write message to a temp file to avoid shell-escaping issues
484
+ tmp_path: Path | None = None
485
+ if message is not None:
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
+ # Resolve committer email (prefer repo-local; fallback to global/env)
497
+ committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
498
+ if not committer_email:
499
+ try:
500
+ res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
501
+ committer_email = (res.stdout or "").strip()
502
+ except Exception:
503
+ committer_email = ""
504
+ if not committer_email:
505
+ try:
506
+ ge = git_config_get("user.email", global_=True)
507
+ if ge:
508
+ committer_email = ge.strip()
509
+ except Exception:
510
+ committer_email = ""
511
+
512
+ def _has_committer_signoff(text: str) -> bool:
513
+ for ln in text.splitlines():
514
+ if ln.lower().startswith("signed-off-by:"):
515
+ m = re.search(r"<([^>]+)>", ln)
516
+ if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
517
+ return True
518
+ return False
519
+
520
+ msg_text: str | None = None
521
+ if message_file is not None:
522
+ try:
523
+ msg_text = Path(message_file).read_text(encoding="utf-8")
524
+ except Exception:
525
+ msg_text = None
526
+
527
+ if msg_text is not None:
528
+ if committer_email and _has_committer_signoff(msg_text):
529
+ effective_signoff = False
530
+ else:
531
+ # No explicit message provided; check current commit body
532
+ try:
533
+ body = git_show("HEAD", cwd=cwd, fmt="%B")
534
+ if committer_email and _has_committer_signoff(body):
535
+ effective_signoff = False
536
+ except GitError:
537
+ pass
538
+ except Exception:
539
+ # Best effort only; default to requested signoff
540
+ effective_signoff = bool(signoff)
541
+
477
542
  args: list[str] = ["commit", "--amend"]
478
543
  if no_edit and not message and not message_file:
479
544
  args.append("--no-edit")
480
- if signoff:
545
+ if effective_signoff:
481
546
  args.append("-s")
482
547
  if author:
483
548
  args.extend(["--author", author])
484
- if message:
485
- args.extend(["-m", message])
486
- elif message_file:
549
+ if message_file:
487
550
  args.extend(["-F", str(message_file)])
488
551
 
489
552
  try:
@@ -496,6 +559,12 @@ def git_commit_amend(
496
559
  stdout=exc.stdout,
497
560
  stderr=exc.stderr,
498
561
  ) from exc
562
+ finally:
563
+ if tmp_path is not None:
564
+ from contextlib import suppress
565
+
566
+ with suppress(Exception):
567
+ tmp_path.unlink(missing_ok=True)
499
568
 
500
569
 
501
570
  def git_commit_new(
@@ -511,17 +580,62 @@ def git_commit_new(
511
580
  if not message and not message_file:
512
581
  raise ValueError(_MSG_COMMIT_NO_MESSAGE)
513
582
 
583
+ # Write message to a temp file to avoid shell-escaping issues
584
+ tmp_path: Path | None = None
585
+ if message is not None:
586
+ with tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
587
+ _tf.write(message)
588
+ _tf.flush()
589
+ tmp_path = Path(_tf.name)
590
+ message_file = tmp_path
591
+ message = None
592
+
593
+ # Determine whether to add -s; only suppress if message already has a sign-off for current committer
594
+ effective_signoff = bool(signoff)
595
+ try:
596
+ # Resolve committer email (prefer repo-local; fallback to global/env)
597
+ committer_email = os.getenv("GIT_COMMITTER_EMAIL", "").strip()
598
+ if not committer_email:
599
+ try:
600
+ res = run_cmd(["git", "config", "--get", "user.email"], cwd=cwd)
601
+ committer_email = (res.stdout or "").strip()
602
+ except Exception:
603
+ committer_email = ""
604
+ if not committer_email:
605
+ try:
606
+ ge = git_config_get("user.email", global_=True)
607
+ if ge:
608
+ committer_email = ge.strip()
609
+ except Exception:
610
+ committer_email = ""
611
+
612
+ def _has_committer_signoff(text: str) -> bool:
613
+ for ln in text.splitlines():
614
+ if ln.lower().startswith("signed-off-by:"):
615
+ m = re.search(r"<([^>]+)>", ln)
616
+ if m and committer_email and m.group(1).strip().lower() == committer_email.lower():
617
+ return True
618
+ return False
619
+
620
+ if message_file is not None:
621
+ try:
622
+ msg_text = Path(message_file).read_text(encoding="utf-8")
623
+ except Exception:
624
+ msg_text = None
625
+ if msg_text and _has_committer_signoff(msg_text):
626
+ effective_signoff = False
627
+ except Exception:
628
+ effective_signoff = bool(signoff)
629
+
514
630
  args: list[str] = ["commit"]
515
- if signoff:
631
+ if effective_signoff:
516
632
  args.append("-s")
517
633
  if author:
518
634
  args.extend(["--author", author])
519
635
  if allow_empty:
520
636
  args.append("--allow-empty")
521
637
 
522
- if message:
523
- args.extend(["-m", message])
524
- else:
638
+ if message_file:
525
639
  args.extend(["-F", str(message_file)])
526
640
 
527
641
  try:
@@ -534,6 +648,12 @@ def git_commit_new(
534
648
  stdout=exc.stdout,
535
649
  stderr=exc.stderr,
536
650
  ) from exc
651
+ finally:
652
+ if tmp_path is not None:
653
+ from contextlib import suppress
654
+
655
+ with suppress(Exception):
656
+ tmp_path.unlink(missing_ok=True)
537
657
 
538
658
 
539
659
  def git_show(
@@ -561,21 +681,60 @@ def git_show(
561
681
 
562
682
 
563
683
  def _parse_trailers(text: str) -> dict[str, list[str]]:
564
- """Parse trailers from a commit message body.
684
+ """Parse trailers from a commit message footer only.
565
685
 
566
- Expects lines like 'Key: Value'. Multiple values per key are supported.
686
+ Git trailers are key-value pairs that appear at the end of commit messages,
687
+ separated from the body by a blank line. This function only parses trailers
688
+ from the actual footer section to avoid false positives from the message body.
567
689
  """
568
690
  trailers: dict[str, list[str]] = {}
569
- for raw in text.splitlines():
570
- line = raw.strip()
691
+ lines = text.splitlines()
692
+
693
+ # Find the start of the trailer block by working backwards
694
+ # Trailers must be at the end, separated by a blank line from the body
695
+ trailer_start = len(lines)
696
+ in_trailer_block = True
697
+
698
+ for i in range(len(lines) - 1, -1, -1):
699
+ line = lines[i].strip()
700
+
701
+ if not line and in_trailer_block:
702
+ # Found blank line, trailers end here
703
+ trailer_start = i + 1
704
+ break
705
+ elif not line:
706
+ # Blank line in middle, not in trailer block anymore
707
+ in_trailer_block = False
708
+ trailer_start = len(lines)
709
+ elif in_trailer_block and ":" in line:
710
+ # Potential trailer line
711
+ key, val = line.split(":", 1)
712
+ k = key.strip()
713
+ v = val.strip()
714
+ if k and v and not k.startswith(" ") and not k.startswith("\t"):
715
+ # Valid trailer format
716
+ continue
717
+ else:
718
+ # Invalid trailer format, stop looking
719
+ in_trailer_block = False
720
+ trailer_start = len(lines)
721
+ elif in_trailer_block:
722
+ # Non-trailer line in what we thought was trailer block
723
+ in_trailer_block = False
724
+ trailer_start = len(lines)
725
+
726
+ # Parse only the trailer section
727
+ for i in range(trailer_start, len(lines)):
728
+ line = lines[i].strip()
571
729
  if not line or ":" not in line:
572
730
  continue
573
731
  key, val = line.split(":", 1)
574
732
  k = key.strip()
575
733
  v = val.strip()
576
- if not k or not v:
734
+ if not k or not v or k.startswith((" ", "\t")):
577
735
  continue
578
736
  trailers.setdefault(k, []).append(v)
737
+
579
738
  return trailers
580
739
 
581
740
 
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