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/core.py CHANGED
@@ -29,6 +29,7 @@ from __future__ import annotations
29
29
  import logging
30
30
  import os
31
31
  import re
32
+ import shlex
32
33
  import stat
33
34
  import urllib.parse
34
35
  import urllib.request
@@ -38,6 +39,8 @@ from dataclasses import dataclass
38
39
  from pathlib import Path
39
40
  from typing import Any
40
41
 
42
+ from .commit_normalization import normalize_commit_title
43
+ from .gerrit_urls import create_gerrit_url_builder
41
44
  from .github_api import build_client
42
45
  from .github_api import close_pr
43
46
  from .github_api import create_pr_comment
@@ -48,6 +51,7 @@ from .github_api import get_repo_from_env
48
51
  from .github_api import iter_open_pulls
49
52
  from .gitutils import CommandError
50
53
  from .gitutils import GitError
54
+ from .gitutils import _parse_trailers
51
55
  from .gitutils import git_cherry_pick
52
56
  from .gitutils import git_commit_amend
53
57
  from .gitutils import git_commit_new
@@ -57,6 +61,10 @@ from .gitutils import git_show
57
61
  from .gitutils import run_cmd
58
62
  from .models import GitHubContext
59
63
  from .models import Inputs
64
+ from .pr_content_filter import filter_pr_body
65
+ from .ssh_common import merge_known_hosts_content
66
+ from .utils import env_bool
67
+ from .utils import log_exception_conditionally
60
68
 
61
69
 
62
70
  try:
@@ -74,20 +82,19 @@ except ImportError:
74
82
  auto_discover_gerrit_host_keys = None # type: ignore[assignment]
75
83
  SSHDiscoveryError = Exception # type: ignore[misc,assignment]
76
84
 
85
+ try:
86
+ from .ssh_agent_setup import SSHAgentManager
87
+ from .ssh_agent_setup import setup_ssh_agent_auth
88
+ except ImportError:
89
+ # Fallback if ssh_agent_setup module is not available
90
+ from typing import TYPE_CHECKING
77
91
 
78
- def _is_verbose_mode() -> bool:
79
- """Check if verbose mode is enabled via environment variable."""
80
- return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
81
-
82
-
83
- def _log_exception_conditionally(
84
- logger: logging.Logger, message: str, *args: Any
85
- ) -> None:
86
- """Log exception with traceback only if verbose mode is enabled."""
87
- if _is_verbose_mode():
88
- logger.exception(message, *args)
92
+ if TYPE_CHECKING:
93
+ from .ssh_agent_setup import SSHAgentManager
94
+ from .ssh_agent_setup import setup_ssh_agent_auth
89
95
  else:
90
- logger.error(message, *args)
96
+ SSHAgentManager = None
97
+ setup_ssh_agent_auth = None
91
98
 
92
99
 
93
100
  log = logging.getLogger("github2gerrit.core")
@@ -125,10 +132,7 @@ def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
125
132
  raise ValueError(_MSG_ISSUE_ID_MULTILINE)
126
133
 
127
134
  # Format as proper Issue-ID trailer
128
- if cleaned_issue_id.startswith("Issue-ID:"):
129
- issue_line = cleaned_issue_id
130
- else:
131
- issue_line = f"Issue-ID: {cleaned_issue_id}"
135
+ issue_line = cleaned_issue_id if cleaned_issue_id.startswith("Issue-ID:") else f"Issue-ID: {cleaned_issue_id}"
132
136
 
133
137
  lines = message.splitlines()
134
138
  if not lines:
@@ -169,11 +173,22 @@ def _match_first_group(pattern: str, text: str) -> str | None:
169
173
 
170
174
 
171
175
  def _is_valid_change_id(value: str) -> bool:
172
- # Gerrit Change-Id usually matches I<40-hex> but the shell workflow
173
- # uses a looser grep. Keep validation permissive for now.
176
+ # Gerrit Change-Id should match I<40-hex-chars> format
177
+ # Be more strict to avoid accepting invalid Change-IDs
174
178
  if not value:
175
179
  return False
176
- return bool(re.fullmatch(r"[A-Za-z0-9._-]+", value))
180
+ # Standard Gerrit format: I followed by exactly 40 hex characters
181
+ if len(value) == 41 and re.fullmatch(r"I[0-9a-fA-F]{40}", value):
182
+ return True
183
+ # Fallback for legacy or non-standard formats (keep some permissiveness)
184
+ # but require it to start with 'I' and be reasonable length (10-40 chars)
185
+ # and NOT look like a malformed hex ID
186
+ return (
187
+ value.startswith("I")
188
+ and 10 <= len(value) <= 40
189
+ and not re.fullmatch(r"I[0-9a-fA-F]+", value) # Exclude hex-like patterns
190
+ and bool(re.fullmatch(r"I[A-Za-z0-9._-]+", value))
191
+ )
177
192
 
178
193
 
179
194
  @dataclass(frozen=True)
@@ -234,6 +249,8 @@ class Orchestrator:
234
249
  # SSH configuration paths (set by _setup_ssh)
235
250
  self._ssh_key_path: Path | None = None
236
251
  self._ssh_known_hosts_path: Path | None = None
252
+ self._ssh_agent_manager: SSHAgentManager | None = None
253
+ self._use_ssh_agent: bool = False
237
254
 
238
255
  # ---------------
239
256
  # Public API
@@ -249,6 +266,10 @@ class Orchestrator:
249
266
  This method defines the high-level call order. Sub-steps are
250
267
  placeholders and must be implemented with real logic. Until then,
251
268
  this raises NotImplementedError after logging the intended plan.
269
+
270
+ Note: This method is "pure" with respect to external outputs (no direct
271
+ GitHub output writes), but does perform internal environment mutations
272
+ (e.g., G2G_TMP_BRANCH) for subprocess coordination within the workflow.
252
273
  """
253
274
  log.info("Starting PR -> Gerrit pipeline")
254
275
  self._guard_pull_request_context(gh)
@@ -259,18 +280,41 @@ class Orchestrator:
259
280
 
260
281
  gitreview = self._read_gitreview(self.workspace / ".gitreview", gh)
261
282
  repo_names = self._derive_repo_names(gitreview, gh)
283
+ log.debug("execute: inputs.dry_run=%s, inputs.ci_testing=%s", inputs.dry_run, inputs.ci_testing)
262
284
  gerrit = self._resolve_gerrit_info(gitreview, inputs, repo_names)
263
285
 
286
+ log.debug("execute: resolved gerrit info: %s", gerrit)
264
287
  if inputs.dry_run:
288
+ log.debug("execute: entering dry-run mode due to inputs.dry_run=True")
265
289
  # Perform preflight validations and exit without making changes
266
- self._dry_run_preflight(
267
- gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names
268
- )
290
+ self._dry_run_preflight(gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names)
269
291
  log.info("Dry run complete; skipping write operations to Gerrit")
270
- return SubmissionResult(
271
- change_urls=[], change_numbers=[], commit_shas=[]
272
- )
292
+ return SubmissionResult(change_urls=[], change_numbers=[], commit_shas=[])
273
293
  self._setup_ssh(inputs, gerrit)
294
+ # Establish baseline non-interactive SSH/Git environment
295
+ # for all child processes
296
+ os.environ.update(self._ssh_env())
297
+
298
+ # Ensure commit/tag signing is disabled before any commit operations
299
+ # to avoid agent prompts
300
+ try:
301
+ git_config(
302
+ "commit.gpgsign",
303
+ "false",
304
+ global_=False,
305
+ cwd=self.workspace,
306
+ )
307
+ except GitError:
308
+ git_config("commit.gpgsign", "false", global_=True)
309
+ try:
310
+ git_config(
311
+ "tag.gpgsign",
312
+ "false",
313
+ global_=False,
314
+ cwd=self.workspace,
315
+ )
316
+ except GitError:
317
+ git_config("tag.gpgsign", "false", global_=True)
274
318
 
275
319
  if inputs.submit_single_commits:
276
320
  prep = self._prepare_single_commits(inputs, gh, gerrit)
@@ -354,13 +398,8 @@ class Orchestrator:
354
398
  repo_obj: Any = get_repo_from_env(client)
355
399
  # Prefer a specific ref when available; otherwise default branch
356
400
  ref = os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_SHA")
357
- if ref:
358
- content = repo_obj.get_contents(".gitreview", ref=ref)
359
- else:
360
- content = repo_obj.get_contents(".gitreview")
361
- text_remote = (
362
- getattr(content, "decoded_content", b"") or b""
363
- ).decode("utf-8")
401
+ content = repo_obj.get_contents(".gitreview", ref=ref) if ref else repo_obj.get_contents(".gitreview")
402
+ text_remote = (getattr(content, "decoded_content", b"") or b"").decode("utf-8")
364
403
  info_remote = self._parse_gitreview_text(text_remote)
365
404
  if info_remote:
366
405
  log.debug("Parsed remote .gitreview: %s", info_remote)
@@ -370,14 +409,7 @@ class Orchestrator:
370
409
  log.debug("Remote .gitreview not available: %s", exc)
371
410
  # Attempt raw.githubusercontent.com as a fallback
372
411
  try:
373
- repo_full = (
374
- (
375
- gh.repository
376
- if gh
377
- else os.getenv("GITHUB_REPOSITORY", "")
378
- )
379
- or ""
380
- ).strip()
412
+ repo_full = ((gh.repository if gh else os.getenv("GITHUB_REPOSITORY", "")) or "").strip()
381
413
  branches: list[str] = []
382
414
  # Prefer PR head/base refs via GitHub API when running
383
415
  # from a direct URL when a token is available
@@ -391,18 +423,8 @@ class Orchestrator:
391
423
  client = build_client()
392
424
  repo_obj = get_repo_from_env(client)
393
425
  pr_obj = get_pull(repo_obj, int(gh.pr_number))
394
- api_head = str(
395
- getattr(
396
- getattr(pr_obj, "head", object()), "ref", ""
397
- )
398
- or ""
399
- )
400
- api_base = str(
401
- getattr(
402
- getattr(pr_obj, "base", object()), "ref", ""
403
- )
404
- or ""
405
- )
426
+ api_head = str(getattr(getattr(pr_obj, "head", object()), "ref", "") or "")
427
+ api_base = str(getattr(getattr(pr_obj, "base", object()), "ref", "") or "")
406
428
  if api_head:
407
429
  branches.append(api_head)
408
430
  if api_base:
@@ -422,15 +444,9 @@ class Orchestrator:
422
444
  if not br or br in tried:
423
445
  continue
424
446
  tried.add(br)
425
- url = (
426
- f"https://raw.githubusercontent.com/"
427
- f"{repo_full}/refs/heads/{br}/.gitreview"
428
- )
447
+ url = f"https://raw.githubusercontent.com/{repo_full}/refs/heads/{br}/.gitreview"
429
448
  parsed = urllib.parse.urlparse(url)
430
- if (
431
- parsed.scheme != "https"
432
- or parsed.netloc != "raw.githubusercontent.com"
433
- ):
449
+ if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
434
450
  continue
435
451
  log.info("Fetching .gitreview via raw URL: %s", url)
436
452
  with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
@@ -496,7 +512,16 @@ class Orchestrator:
496
512
  repo: RepoNames,
497
513
  ) -> GerritInfo:
498
514
  """Resolve Gerrit connection info from .gitreview or inputs."""
515
+ log.debug("_resolve_gerrit_info: inputs.ci_testing=%s", inputs.ci_testing)
516
+ log.debug("_resolve_gerrit_info: gitreview=%s", gitreview)
517
+
518
+ # If CI testing flag is set, ignore .gitreview and use environment
519
+ if inputs.ci_testing:
520
+ log.info("CI_TESTING enabled: ignoring .gitreview file")
521
+ gitreview = None
522
+
499
523
  if gitreview:
524
+ log.debug("Using .gitreview settings: %s", gitreview)
500
525
  return gitreview
501
526
 
502
527
  host = inputs.gerrit_server.strip()
@@ -545,45 +570,126 @@ class Orchestrator:
545
570
  log.debug("SSH private key not provided, skipping SSH setup")
546
571
  return
547
572
 
548
- # Auto-discover host keys if not provided
573
+ # Auto-discover or augment host keys (merge missing types/[host]:port entries)
549
574
  effective_known_hosts = inputs.gerrit_known_hosts
550
- if (
551
- not effective_known_hosts
552
- and auto_discover_gerrit_host_keys is not None
553
- ):
554
- log.info(
555
- "GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery..."
556
- )
575
+ if auto_discover_gerrit_host_keys is not None:
557
576
  try:
558
- discovered_keys = auto_discover_gerrit_host_keys(
559
- gerrit_hostname=gerrit.host,
560
- gerrit_port=gerrit.port,
561
- organization=inputs.organization,
562
- save_to_config=True,
563
- )
564
- if discovered_keys:
565
- effective_known_hosts = discovered_keys
566
- log.info(
567
- "Successfully auto-discovered SSH host keys for %s:%d",
568
- gerrit.host,
569
- gerrit.port,
577
+ if not effective_known_hosts:
578
+ log.info("GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery...")
579
+ discovered_keys = auto_discover_gerrit_host_keys(
580
+ gerrit_hostname=gerrit.host,
581
+ gerrit_port=gerrit.port,
582
+ organization=inputs.organization,
583
+ save_to_config=True,
570
584
  )
585
+ if discovered_keys:
586
+ effective_known_hosts = discovered_keys
587
+ log.info(
588
+ "Successfully auto-discovered SSH host keys for %s:%d",
589
+ gerrit.host,
590
+ gerrit.port,
591
+ )
592
+ else:
593
+ log.warning("Auto-discovery failed, SSH host key verification may fail")
571
594
  else:
572
- log.warning(
573
- "Auto-discovery failed, SSH host key verification may "
574
- "fail"
575
- )
595
+ # Provided known_hosts exists; ensure it contains [host]:port entries and modern key types
596
+ lower = effective_known_hosts.lower()
597
+ bracket_host = f"[{gerrit.host}]:{gerrit.port}"
598
+ bracket_lower = bracket_host.lower()
599
+ needs_discovery = False
600
+ if bracket_lower not in lower:
601
+ needs_discovery = True
602
+ else:
603
+ # Confirm at least one known key type exists for the bracketed host
604
+ if (
605
+ f"{bracket_lower} ssh-ed25519" not in lower
606
+ and f"{bracket_lower} ecdsa-sha2" not in lower
607
+ and f"{bracket_lower} ssh-rsa" not in lower
608
+ ):
609
+ needs_discovery = True
610
+ if needs_discovery:
611
+ log.info(
612
+ "Augmenting provided GERRIT_KNOWN_HOSTS with discovered entries for %s:%d",
613
+ gerrit.host,
614
+ gerrit.port,
615
+ )
616
+ discovered_keys = auto_discover_gerrit_host_keys(
617
+ gerrit_hostname=gerrit.host,
618
+ gerrit_port=gerrit.port,
619
+ organization=inputs.organization,
620
+ save_to_config=True,
621
+ )
622
+ if discovered_keys:
623
+ # Use centralized merging logic
624
+ effective_known_hosts = merge_known_hosts_content(effective_known_hosts, discovered_keys)
625
+ log.info(
626
+ "Known hosts augmented with discovered entries for %s:%d",
627
+ gerrit.host,
628
+ gerrit.port,
629
+ )
630
+ else:
631
+ log.warning("Auto-discovery returned no keys; known_hosts not augmented")
576
632
  except Exception as exc:
577
- log.warning("SSH host key auto-discovery failed: %s", exc)
633
+ log.warning("SSH host key auto-discovery/augmentation failed: %s", exc)
578
634
 
579
635
  if not effective_known_hosts:
580
- log.debug(
581
- "No SSH host keys available (manual or auto-discovered), "
582
- "skipping SSH setup"
583
- )
636
+ log.debug("No SSH host keys available (manual or auto-discovered), skipping SSH setup")
584
637
  return
585
638
 
586
- log.info("Setting up temporary SSH configuration for Gerrit")
639
+ # Check if SSH agent authentication is preferred
640
+ use_ssh_agent = env_bool("G2G_USE_SSH_AGENT", default=True)
641
+
642
+ if use_ssh_agent and setup_ssh_agent_auth is not None:
643
+ # Try SSH agent first as it's more secure and avoids file permission issues
644
+ if self._try_ssh_agent_setup(inputs, effective_known_hosts):
645
+ return
646
+
647
+ # Fall back to file-based SSH if agent setup fails
648
+ log.info("Falling back to file-based SSH authentication")
649
+
650
+ self._setup_file_based_ssh(inputs, effective_known_hosts)
651
+
652
+ def _try_ssh_agent_setup(self, inputs: Inputs, effective_known_hosts: str) -> bool:
653
+ """Try to setup SSH agent-based authentication.
654
+
655
+ Args:
656
+ inputs: Validated input configuration
657
+ effective_known_hosts: Known hosts content
658
+
659
+ Returns:
660
+ True if SSH agent setup succeeded, False otherwise
661
+ """
662
+ if setup_ssh_agent_auth is None:
663
+ log.debug("SSH agent module not available, falling back to file-based SSH") # type: ignore[unreachable]
664
+ return False
665
+
666
+ try:
667
+ log.info("Setting up SSH agent-based authentication (more secure)")
668
+ self._ssh_agent_manager = setup_ssh_agent_auth(
669
+ workspace=self.workspace,
670
+ private_key_content=inputs.gerrit_ssh_privkey_g2g,
671
+ known_hosts_content=effective_known_hosts,
672
+ )
673
+ self._use_ssh_agent = True
674
+ log.info("SSH agent authentication configured successfully")
675
+
676
+ except Exception as exc:
677
+ log.warning("SSH agent setup failed, falling back to file-based SSH: %s", exc)
678
+ if self._ssh_agent_manager:
679
+ self._ssh_agent_manager.cleanup()
680
+ self._ssh_agent_manager = None
681
+ return False
682
+ else:
683
+ return True
684
+
685
+ def _setup_file_based_ssh(self, inputs: Inputs, effective_known_hosts: str) -> None:
686
+ """Setup file-based SSH authentication as fallback.
687
+
688
+ Args:
689
+ inputs: Validated input configuration
690
+ effective_known_hosts: Known hosts content
691
+ """
692
+ log.info("Setting up file-based SSH configuration for Gerrit")
587
693
  log.debug("Using workspace-specific SSH files to avoid user changes")
588
694
 
589
695
  # Create tool-specific SSH directory in workspace to avoid touching
@@ -591,13 +697,26 @@ class Orchestrator:
591
697
  tool_ssh_dir = self.workspace / ".ssh-g2g"
592
698
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
593
699
 
594
- # Write SSH private key to tool-specific location
700
+ # Write SSH private key to tool-specific location with secure permissions
595
701
  key_path = tool_ssh_dir / "gerrit_key"
596
- with open(key_path, "w", encoding="utf-8") as f:
597
- f.write(inputs.gerrit_ssh_privkey_g2g.strip() + "\n")
598
- key_path.chmod(0o600)
599
- log.debug("SSH private key written to %s", key_path)
600
- log.debug("Key file is tool-specific and won't interfere with user SSH")
702
+
703
+ # Use a more robust approach for creating the file with secure permissions
704
+ key_content = inputs.gerrit_ssh_privkey_g2g.strip() + "\n"
705
+
706
+ # Multiple strategies to create secure key file
707
+ success = self._create_secure_key_file(key_path, key_content)
708
+
709
+ if not success:
710
+ # If all permission strategies fail, create in memory directory
711
+ success = self._create_key_in_memory_fs(key_path, key_content)
712
+
713
+ if not success:
714
+ msg = (
715
+ "Failed to create SSH key file with secure permissions. "
716
+ "This may be due to CI environment restrictions. "
717
+ "Consider using G2G_USE_SSH_AGENT=true (default) for SSH agent authentication."
718
+ )
719
+ raise RuntimeError(msg)
601
720
 
602
721
  # Write known hosts to tool-specific location
603
722
  known_hosts_path = tool_ssh_dir / "known_hosts"
@@ -611,32 +730,200 @@ class Orchestrator:
611
730
  self._ssh_key_path = key_path
612
731
  self._ssh_known_hosts_path = known_hosts_path
613
732
 
733
+ def _create_secure_key_file(self, key_path: Path, key_content: str) -> bool:
734
+ """Try multiple strategies to create a secure SSH key file.
735
+
736
+ Args:
737
+ key_path: Path where to create the key file
738
+ key_content: SSH key content
739
+
740
+ Returns:
741
+ True if successful, False otherwise
742
+ """
743
+
744
+ strategies = [
745
+ ("touch+chmod", self._strategy_touch_chmod),
746
+ ("open+fchmod", self._strategy_open_fchmod),
747
+ ("umask+open", self._strategy_umask_open),
748
+ ("stat_constants", self._strategy_stat_constants),
749
+ ]
750
+
751
+ for strategy_name, strategy_func in strategies:
752
+ try:
753
+ log.debug("Trying SSH key creation strategy: %s", strategy_name)
754
+
755
+ # Remove file if it exists to start fresh
756
+ if key_path.exists():
757
+ key_path.unlink()
758
+
759
+ # Try the strategy
760
+ strategy_func(key_path, key_content)
761
+
762
+ # Verify permissions
763
+ actual_perms = oct(key_path.stat().st_mode)[-3:]
764
+ if actual_perms == "600":
765
+ log.debug("SSH key created successfully with strategy: %s", strategy_name)
766
+ return True
767
+ else:
768
+ log.debug("Strategy %s resulted in permissions %s", strategy_name, actual_perms)
769
+
770
+ except Exception as exc:
771
+ log.debug("Strategy %s failed: %s", strategy_name, exc)
772
+ if key_path.exists():
773
+ try:
774
+ key_path.unlink()
775
+ except Exception as cleanup_exc:
776
+ log.debug("Failed to cleanup key file: %s", cleanup_exc)
777
+
778
+ return False
779
+
780
+ def _strategy_touch_chmod(self, key_path: Path, key_content: str) -> None:
781
+ """Strategy: touch with mode, then write, then chmod."""
782
+ key_path.touch(mode=0o600)
783
+ with open(key_path, "w", encoding="utf-8") as f:
784
+ f.write(key_content)
785
+ key_path.chmod(0o600)
786
+
787
+ def _strategy_open_fchmod(self, key_path: Path, key_content: str) -> None:
788
+ """Strategy: open with os.open and specific flags, then fchmod."""
789
+ import os
790
+ import stat
791
+
792
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
793
+ mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
794
+
795
+ fd = os.open(str(key_path), flags, mode)
796
+ try:
797
+ os.fchmod(fd, mode)
798
+ os.write(fd, key_content.encode("utf-8"))
799
+ finally:
800
+ os.close(fd)
801
+
802
+ def _strategy_umask_open(self, key_path: Path, key_content: str) -> None:
803
+ """Strategy: set umask, create file, restore umask."""
804
+ import os
805
+
806
+ original_umask = os.umask(0o077) # Only owner can read/write
807
+ try:
808
+ with open(key_path, "w", encoding="utf-8") as f:
809
+ f.write(key_content)
810
+ key_path.chmod(0o600)
811
+ finally:
812
+ os.umask(original_umask)
813
+
814
+ def _strategy_stat_constants(self, key_path: Path, key_content: str) -> None:
815
+ """Strategy: use stat constants for permission setting."""
816
+ import os
817
+ import stat
818
+
819
+ with open(key_path, "w", encoding="utf-8") as f:
820
+ f.write(key_content)
821
+
822
+ # Try multiple permission setting approaches
823
+ mode = stat.S_IRUSR | stat.S_IWUSR
824
+ os.chmod(str(key_path), mode)
825
+ key_path.chmod(mode)
826
+
827
+ def _create_key_in_memory_fs(self, key_path: Path, key_content: str) -> bool:
828
+ """Fallback: try to create key in memory filesystem."""
829
+ import shutil
830
+ import tempfile
831
+
832
+ try:
833
+ # Try to create in memory filesystem if available
834
+ # Use secure temporary directories
835
+ import tempfile
836
+
837
+ temp_dir = tempfile.gettempdir()
838
+ memory_dirs = [temp_dir]
839
+
840
+ # Only add /dev/shm if it exists and is accessible
841
+ import os
842
+
843
+ dev_shm = Path("/dev/shm") # noqa: S108
844
+ if dev_shm.exists() and os.access("/dev/shm", os.W_OK): # noqa: S108
845
+ memory_dirs.insert(0, "/dev/shm") # noqa: S108
846
+
847
+ for memory_dir in memory_dirs:
848
+ if not Path(memory_dir).exists():
849
+ continue
850
+
851
+ tmp_path = None
852
+ try:
853
+ with tempfile.NamedTemporaryFile(
854
+ mode="w", dir=memory_dir, prefix="g2g_key_", suffix=".tmp", delete=False
855
+ ) as tmp_file:
856
+ tmp_file.write(key_content)
857
+ tmp_path = Path(tmp_file.name)
858
+
859
+ # Try to set permissions
860
+ tmp_path.chmod(0o600)
861
+ actual_perms = oct(tmp_path.stat().st_mode)[-3:]
862
+
863
+ if actual_perms == "600":
864
+ # Move to final location
865
+ shutil.move(str(tmp_path), str(key_path))
866
+ log.debug("Successfully created SSH key using memory filesystem: %s", memory_dir)
867
+ return True
868
+ else:
869
+ tmp_path.unlink()
870
+
871
+ except Exception as exc:
872
+ log.debug("Memory filesystem strategy failed for %s: %s", memory_dir, exc)
873
+ try:
874
+ if tmp_path is not None and tmp_path.exists():
875
+ tmp_path.unlink()
876
+ except Exception as cleanup_exc:
877
+ log.debug("Failed to cleanup temporary key file: %s", cleanup_exc)
878
+
879
+ except Exception as exc:
880
+ log.debug("Memory filesystem fallback failed: %s", exc)
881
+
882
+ return False
883
+
614
884
  @property
615
- def _git_ssh_command(self) -> str | None:
885
+ def _build_git_ssh_command(self) -> str | None:
616
886
  """Generate GIT_SSH_COMMAND for secure, isolated SSH configuration.
617
887
 
618
888
  This prevents SSH from scanning the user's SSH agent or using
619
889
  unintended keys by setting IdentitiesOnly=yes and specifying
620
890
  exact key and known_hosts files.
621
891
  """
892
+ if self._use_ssh_agent and self._ssh_agent_manager:
893
+ return self._ssh_agent_manager.get_git_ssh_command()
894
+
622
895
  if not self._ssh_key_path or not self._ssh_known_hosts_path:
623
896
  return None
624
897
 
625
- # Build SSH command with strict options to prevent key scanning
626
- ssh_options = [
627
- f"-i {self._ssh_key_path}",
628
- f"-o UserKnownHostsFile={self._ssh_known_hosts_path}",
629
- "-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
630
- "-o StrictHostKeyChecking=yes",
631
- "-o PasswordAuthentication=no",
632
- "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
633
- "-o ConnectTimeout=10",
634
- ]
898
+ # Delegate to centralized SSH command builder
899
+ from .ssh_common import build_git_ssh_command
900
+
901
+ return build_git_ssh_command(
902
+ key_path=self._ssh_key_path,
903
+ known_hosts_path=self._ssh_known_hosts_path,
904
+ )
905
+
906
+ def _ssh_env(self) -> dict[str, str]:
907
+ """Centralized non-interactive SSH/Git environment."""
908
+ from .ssh_common import build_non_interactive_ssh_env
909
+
910
+ env = build_non_interactive_ssh_env()
635
911
 
636
- ssh_cmd = f"ssh {' '.join(ssh_options)}"
637
- masked_cmd = ssh_cmd.replace(str(self._ssh_key_path), "[KEY_PATH]")
638
- log.debug("Generated SSH command: %s", masked_cmd)
639
- return ssh_cmd
912
+ # Set GIT_SSH_COMMAND based on available configuration
913
+ cmd = self._build_git_ssh_command
914
+ if cmd:
915
+ env["GIT_SSH_COMMAND"] = cmd
916
+ else:
917
+ # Fallback to basic non-interactive SSH command
918
+ from .ssh_common import build_git_ssh_command
919
+
920
+ env["GIT_SSH_COMMAND"] = build_git_ssh_command()
921
+
922
+ # Override SSH agent settings if using SSH agent
923
+ if self._use_ssh_agent and self._ssh_agent_manager:
924
+ env.update(self._ssh_agent_manager.get_ssh_env())
925
+
926
+ return env
640
927
 
641
928
  def _cleanup_ssh(self) -> None:
642
929
  """Clean up temporary SSH files created by this tool.
@@ -644,21 +931,22 @@ class Orchestrator:
644
931
  Removes the workspace-specific .ssh-g2g directory and all contents.
645
932
  This ensures no temporary files are left behind.
646
933
  """
647
- if not hasattr(self, "_ssh_key_path") or not hasattr(
648
- self, "_ssh_known_hosts_path"
649
- ):
650
- return
934
+ log.debug("Cleaning up temporary SSH configuration files")
651
935
 
652
936
  try:
937
+ # Clean up SSH agent if we used it
938
+ if self._ssh_agent_manager:
939
+ self._ssh_agent_manager.cleanup()
940
+ self._ssh_agent_manager = None
941
+ self._use_ssh_agent = False
942
+
653
943
  # Remove temporary SSH directory and all contents
654
944
  tool_ssh_dir = self.workspace / ".ssh-g2g"
655
945
  if tool_ssh_dir.exists():
656
946
  import shutil
657
947
 
658
948
  shutil.rmtree(tool_ssh_dir)
659
- log.debug(
660
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
661
- )
949
+ log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
662
950
  except Exception as exc:
663
951
  log.warning("Failed to clean up temporary SSH files: %s", exc)
664
952
 
@@ -678,9 +966,7 @@ class Orchestrator:
678
966
  cwd=self.workspace,
679
967
  )
680
968
  except GitError:
681
- git_config(
682
- "gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True
683
- )
969
+ git_config("gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True)
684
970
  try:
685
971
  git_config(
686
972
  "user.name",
@@ -698,9 +984,26 @@ class Orchestrator:
698
984
  cwd=self.workspace,
699
985
  )
700
986
  except GitError:
987
+ git_config("user.email", inputs.gerrit_ssh_user_g2g_email, global_=True)
988
+ # Disable GPG signing to avoid interactive prompts for signing keys
989
+ try:
990
+ git_config(
991
+ "commit.gpgsign",
992
+ "false",
993
+ global_=False,
994
+ cwd=self.workspace,
995
+ )
996
+ except GitError:
997
+ git_config("commit.gpgsign", "false", global_=True)
998
+ try:
701
999
  git_config(
702
- "user.email", inputs.gerrit_ssh_user_g2g_email, global_=True
1000
+ "tag.gpgsign",
1001
+ "false",
1002
+ global_=False,
1003
+ cwd=self.workspace,
703
1004
  )
1005
+ except GitError:
1006
+ git_config("tag.gpgsign", "false", global_=True)
704
1007
 
705
1008
  # Ensure git-review host/port/project are configured
706
1009
  # when .gitreview is absent
@@ -736,16 +1039,10 @@ class Orchestrator:
736
1039
  )
737
1040
  except CommandError:
738
1041
  ssh_user = inputs.gerrit_ssh_user_g2g.strip()
739
- remote_url = (
740
- f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
741
- )
1042
+ remote_url = f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
742
1043
  log.info("Adding 'gerrit' remote: %s", remote_url)
743
1044
  # Use our specific SSH configuration for adding remote
744
- env = (
745
- {"GIT_SSH_COMMAND": self._git_ssh_command}
746
- if self._git_ssh_command
747
- else None
748
- )
1045
+ env = self._ssh_env()
749
1046
  run_cmd(
750
1047
  ["git", "remote", "add", "gerrit", remote_url],
751
1048
  check=False,
@@ -754,9 +1051,7 @@ class Orchestrator:
754
1051
  )
755
1052
 
756
1053
  # Workaround for submodules commit-msg hook
757
- hooks_path = run_cmd(
758
- ["git", "rev-parse", "--show-toplevel"], cwd=self.workspace
759
- ).stdout.strip()
1054
+ hooks_path = run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=self.workspace).stdout.strip()
760
1055
  try:
761
1056
  git_config(
762
1057
  "core.hooksPath",
@@ -772,11 +1067,7 @@ class Orchestrator:
772
1067
  # Initialize git-review (copies commit-msg hook)
773
1068
  try:
774
1069
  # Use our specific SSH configuration for git-review setup
775
- env = (
776
- {"GIT_SSH_COMMAND": self._git_ssh_command}
777
- if self._git_ssh_command
778
- else None
779
- )
1070
+ env = self._ssh_env()
780
1071
  run_cmd(["git", "review", "-s", "-v"], cwd=self.workspace, env=env)
781
1072
  except CommandError as exc:
782
1073
  msg = f"Failed to initialize git-review: {exc}"
@@ -794,12 +1085,12 @@ class Orchestrator:
794
1085
  # Determine commit range: commits in HEAD not in base branch
795
1086
  base_ref = f"origin/{branch}"
796
1087
  # Use our SSH command for git operations that might need SSH
797
- env = (
798
- {"GIT_SSH_COMMAND": self._git_ssh_command}
799
- if self._git_ssh_command
800
- else None
1088
+
1089
+ run_cmd(
1090
+ ["git", "fetch", "origin", branch],
1091
+ cwd=self.workspace,
1092
+ env=self._ssh_env(),
801
1093
  )
802
- run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
803
1094
  revs = run_cmd(
804
1095
  ["git", "rev-list", "--reverse", f"{base_ref}..HEAD"],
805
1096
  cwd=self.workspace,
@@ -809,14 +1100,10 @@ class Orchestrator:
809
1100
  log.info("No commits to submit; returning empty PreparedChange")
810
1101
  return PreparedChange(change_ids=[], commit_shas=[])
811
1102
  # Create temp branch from base sha; export for downstream
812
- base_sha = run_cmd(
813
- ["git", "rev-parse", base_ref], cwd=self.workspace
814
- ).stdout.strip()
1103
+ base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
815
1104
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
816
1105
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
817
- run_cmd(
818
- ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
819
- )
1106
+ run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
820
1107
  change_ids: list[str] = []
821
1108
  for csha in commit_list:
822
1109
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
@@ -826,13 +1113,9 @@ class Orchestrator:
826
1113
  ["git", "show", "-s", "--pretty=format:%an <%ae>", csha],
827
1114
  cwd=self.workspace,
828
1115
  ).stdout.strip()
829
- git_commit_amend(
830
- author=author, no_edit=True, signoff=True, cwd=self.workspace
831
- )
1116
+ git_commit_amend(author=author, no_edit=True, signoff=True, cwd=self.workspace)
832
1117
  # Extract newly added Change-Id from last commit trailers
833
- trailers = git_last_commit_trailers(
834
- keys=["Change-Id"], cwd=self.workspace
835
- )
1118
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
836
1119
  for cid in trailers.get("Change-Id", []):
837
1120
  if cid:
838
1121
  change_ids.append(cid)
@@ -854,7 +1137,10 @@ class Orchestrator:
854
1137
  ", ".join(uniq_ids),
855
1138
  )
856
1139
  else:
857
- log.warning("No Change-IDs generated for PR #%s", gh.pr_number)
1140
+ log.debug(
1141
+ "No Change-IDs collected during preparation for PR #%s (will be ensured via commit-msg hook)",
1142
+ gh.pr_number,
1143
+ )
858
1144
  return PreparedChange(change_ids=uniq_ids, commit_shas=[])
859
1145
 
860
1146
  def _prepare_squashed_commit(
@@ -866,26 +1152,20 @@ class Orchestrator:
866
1152
  """Squash PR commits into a single commit and handle Change-Id."""
867
1153
  log.info("Preparing squashed commit for PR #%s", gh.pr_number)
868
1154
  branch = self._resolve_target_branch()
869
- env = (
870
- {"GIT_SSH_COMMAND": self._git_ssh_command}
871
- if self._git_ssh_command
872
- else None
1155
+
1156
+ run_cmd(
1157
+ ["git", "fetch", "origin", branch],
1158
+ cwd=self.workspace,
1159
+ env=self._ssh_env(),
873
1160
  )
874
- run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
875
1161
  base_ref = f"origin/{branch}"
876
- base_sha = run_cmd(
877
- ["git", "rev-parse", base_ref], cwd=self.workspace
878
- ).stdout.strip()
879
- head_sha = run_cmd(
880
- ["git", "rev-parse", "HEAD"], cwd=self.workspace
881
- ).stdout.strip()
1162
+ base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
1163
+ head_sha = run_cmd(["git", "rev-parse", "HEAD"], cwd=self.workspace).stdout.strip()
882
1164
 
883
1165
  # Create temp branch from base and merge-squash PR head
884
1166
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
885
1167
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
886
- run_cmd(
887
- ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
888
- )
1168
+ run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
889
1169
  run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
890
1170
 
891
1171
  def _collect_log_lines() -> list[str]:
@@ -913,23 +1193,17 @@ class Orchestrator:
913
1193
  message_lines: list[str] = []
914
1194
  in_metadata_section = False
915
1195
  for ln in lines:
916
- if ln.strip() in ("---", "```") or ln.startswith(
917
- "updated-dependencies:"
918
- ):
1196
+ if ln.strip() in ("---", "```") or ln.startswith("updated-dependencies:"):
919
1197
  in_metadata_section = True
920
1198
  continue
921
1199
  if in_metadata_section:
922
1200
  if ln.startswith(("- dependency-", " dependency-")):
923
1201
  continue
924
- if (
925
- not ln.startswith((" ", "-", "dependency-"))
926
- and ln.strip()
927
- ):
1202
+ if not ln.startswith((" ", "-", "dependency-")) and ln.strip():
928
1203
  in_metadata_section = False
1204
+ # Skip Change-Id lines from body - they should only be in footer
929
1205
  if ln.startswith("Change-Id:"):
930
- cid = ln.split(":", 1)[1].strip()
931
- if cid:
932
- change_ids.append(cid)
1206
+ log.debug("Skipping Change-Id from commit body: %s", ln.strip())
933
1207
  continue
934
1208
  if ln.startswith("Signed-off-by:"):
935
1209
  signed_off.append(ln)
@@ -955,17 +1229,25 @@ class Orchestrator:
955
1229
  break_points = [". ", "! ", "? ", " - ", ": "]
956
1230
  for bp in break_points:
957
1231
  if bp in title_line[:100]:
958
- title_line = title_line[
959
- : title_line.index(bp) + len(bp.strip())
960
- ]
1232
+ title_line = title_line[: title_line.index(bp) + len(bp.strip())]
961
1233
  break
962
1234
  else:
963
1235
  words = title_line[:100].split()
964
- title_line = (
965
- " ".join(words[:-1])
966
- if len(words) > 1
967
- else title_line[:100].rstrip()
968
- )
1236
+ title_line = " ".join(words[:-1]) if len(words) > 1 else title_line[:100].rstrip()
1237
+
1238
+ # Apply conventional commit normalization if enabled
1239
+ if inputs.normalise_commit and gh.pr_number:
1240
+ try:
1241
+ # Get PR author for normalization context
1242
+ client = build_client()
1243
+ repo = get_repo_from_env(client)
1244
+ pr_obj = get_pull(repo, int(gh.pr_number))
1245
+ author = getattr(pr_obj, "user", {})
1246
+ author_login = getattr(author, "login", "") if author else ""
1247
+ title_line = normalize_commit_title(title_line, author_login, self.workspace)
1248
+ except Exception as e:
1249
+ log.debug("Failed to apply commit normalization in squash mode: %s", e)
1250
+
969
1251
  return title_line
970
1252
 
971
1253
  def _build_clean_message_lines(message_lines: list[str]) -> list[str]:
@@ -975,10 +1257,7 @@ class Orchestrator:
975
1257
  out: list[str] = [title_line]
976
1258
  if len(message_lines) > 1:
977
1259
  body_start = 1
978
- while (
979
- body_start < len(message_lines)
980
- and not message_lines[body_start].strip()
981
- ):
1260
+ while body_start < len(message_lines) and not message_lines[body_start].strip():
982
1261
  body_start += 1
983
1262
  if body_start < len(message_lines):
984
1263
  out.append("")
@@ -987,9 +1266,7 @@ class Orchestrator:
987
1266
 
988
1267
  def _maybe_reuse_change_id(pr_str: str) -> str:
989
1268
  reuse = ""
990
- sync_all_prs = (
991
- os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
992
- )
1269
+ sync_all_prs = os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
993
1270
  if (
994
1271
  not sync_all_prs
995
1272
  and gh.event_name == "pull_request_target"
@@ -999,9 +1276,7 @@ class Orchestrator:
999
1276
  client = build_client()
1000
1277
  repo = get_repo_from_env(client)
1001
1278
  pr_obj = get_pull(repo, int(pr_str))
1002
- cand = get_recent_change_ids_from_comments(
1003
- pr_obj, max_comments=50
1004
- )
1279
+ cand = get_recent_change_ids_from_comments(pr_obj, max_comments=50)
1005
1280
  if cand:
1006
1281
  reuse = cand[-1]
1007
1282
  log.debug(
@@ -1023,23 +1298,23 @@ class Orchestrator:
1023
1298
  signed_off: list[str],
1024
1299
  reuse_cid: str,
1025
1300
  ) -> str:
1026
- from .duplicate_detection import DuplicateDetector
1027
-
1028
1301
  msg = "\n".join(lines_in).strip()
1029
1302
  msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
1030
- github_hash = DuplicateDetector._generate_github_change_hash(gh)
1031
- msg += f"\n\nGitHub-Hash: {github_hash}"
1303
+
1304
+ # Build footer with proper trailer ordering
1305
+ footer_parts = []
1032
1306
  if signed_off:
1033
- msg += "\n\n" + "\n".join(signed_off)
1307
+ footer_parts.extend(signed_off)
1034
1308
  if reuse_cid:
1035
- msg += f"\n\nChange-Id: {reuse_cid}"
1309
+ footer_parts.append(f"Change-Id: {reuse_cid}")
1310
+
1311
+ if footer_parts:
1312
+ msg += "\n\n" + "\n".join(footer_parts)
1036
1313
  return msg
1037
1314
 
1038
1315
  # Build message parts
1039
1316
  raw_lines = _collect_log_lines()
1040
- message_lines, signed_off, _existing_cids = _parse_message_parts(
1041
- raw_lines
1042
- )
1317
+ message_lines, signed_off, _existing_cids = _parse_message_parts(raw_lines)
1043
1318
  clean_lines = _build_clean_message_lines(message_lines)
1044
1319
  pr_str = str(gh.pr_number or "").strip()
1045
1320
  reuse_cid = _maybe_reuse_change_id(pr_str)
@@ -1073,7 +1348,19 @@ class Orchestrator:
1073
1348
  ", ".join(cids),
1074
1349
  )
1075
1350
  else:
1076
- log.warning("No Change-ID generated for PR #%s", gh.pr_number)
1351
+ # Fallback detection: re-scan commit message for Change-Id trailers
1352
+ msg_after = run_cmd(["git", "show", "-s", "--pretty=format:%B", "HEAD"], cwd=self.workspace).stdout
1353
+
1354
+ found = [m.strip() for m in re.findall(r"(?mi)^Change-Id:\s*([A-Za-z0-9._-]+)\s*$", msg_after)]
1355
+ if found:
1356
+ log.info(
1357
+ "Detected Change-ID(s) after amend for PR #%s: %s",
1358
+ gh.pr_number,
1359
+ ", ".join(found),
1360
+ )
1361
+ cids = found
1362
+ else:
1363
+ log.warning("No Change-Id detected for PR #%s", gh.pr_number)
1077
1364
  return PreparedChange(change_ids=cids, commit_shas=[])
1078
1365
 
1079
1366
  def _apply_pr_title_body_if_requested(
@@ -1097,6 +1384,11 @@ class Orchestrator:
1097
1384
  title = (title or "").strip()
1098
1385
  body = (body or "").strip()
1099
1386
 
1387
+ # Filter PR body content for Dependabot and other automated PRs
1388
+ author = getattr(pr_obj, "user", {})
1389
+ author_login = getattr(author, "login", "") if author else ""
1390
+ body = filter_pr_body(title, body, author_login)
1391
+
1100
1392
  # Clean up title to ensure it's a proper first line for commit message
1101
1393
  if title:
1102
1394
  # Remove markdown links like [text](url) and keep just the text
@@ -1113,29 +1405,59 @@ class Orchestrator:
1113
1405
  title = re.sub(r"[*_`]", "", title)
1114
1406
  title = title.strip()
1115
1407
 
1116
- # Compose message; preserve any Signed-off-by lines
1117
- current_body = git_show("HEAD", fmt="%B")
1118
- signed = [
1119
- ln
1120
- for ln in current_body.splitlines()
1121
- if ln.startswith("Signed-off-by:")
1122
- ]
1408
+ # Apply conventional commit normalization if enabled
1409
+ if inputs.normalise_commit:
1410
+ title = normalize_commit_title(title, author_login, self.workspace)
1411
+
1412
+ # Compose message; preserve existing trailers at footer
1413
+ # (Signed-off-by, Change-Id)
1414
+ current_body = git_show("HEAD", fmt="%B", cwd=self.workspace)
1415
+ # Extract existing trailers from current commit body
1416
+ lines_cur = current_body.splitlines()
1417
+ signed_lines = [ln for ln in lines_cur if ln.startswith("Signed-off-by:")]
1418
+ change_id_lines = [ln for ln in lines_cur if ln.startswith("Change-Id:")]
1419
+ github_hash_lines = [ln for ln in lines_cur if ln.startswith("GitHub-Hash:")]
1420
+
1123
1421
  msg_parts = [title, "", body] if title or body else [current_body]
1124
1422
  commit_message = "\n".join(msg_parts).strip()
1125
1423
 
1126
1424
  # Add Issue-ID if provided
1127
- commit_message = _insert_issue_id_into_commit_message(
1128
- commit_message, inputs.issue_id
1129
- )
1425
+ commit_message = _insert_issue_id_into_commit_message(commit_message, inputs.issue_id)
1426
+
1427
+ # Ensure GitHub-Hash is part of the body (not trailers)
1428
+ # to keep a blank line before Signed-off-by/Change-Id trailers.
1429
+ if github_hash_lines:
1430
+ gh_hash_line = github_hash_lines[-1]
1431
+ else:
1432
+ from .duplicate_detection import DuplicateDetector
1433
+
1434
+ gh_val = DuplicateDetector._generate_github_change_hash(gh)
1435
+ gh_hash_line = f"GitHub-Hash: {gh_val}"
1436
+
1437
+ commit_message += "\n\n" + gh_hash_line
1438
+
1439
+ # Build trailers: Signed-off-by first, Change-Id last.
1440
+ trailers_out: list[str] = []
1441
+ if signed_lines:
1442
+ seen_so: set[str] = set()
1443
+ for ln in signed_lines:
1444
+ if ln not in seen_so:
1445
+ trailers_out.append(ln)
1446
+ seen_so.add(ln)
1447
+ if change_id_lines:
1448
+ trailers_out.append(change_id_lines[-1])
1449
+ if trailers_out:
1450
+ commit_message += "\n\n" + "\n".join(trailers_out)
1130
1451
 
1131
- if signed:
1132
- commit_message += "\n\n" + "\n".join(signed)
1133
1452
  author = run_cmd(
1134
- ["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"]
1453
+ ["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"],
1454
+ cwd=self.workspace,
1455
+ env=self._ssh_env(),
1135
1456
  ).stdout.strip()
1136
1457
  git_commit_amend(
1458
+ cwd=self.workspace,
1137
1459
  no_edit=False,
1138
- signoff=not bool(signed),
1460
+ signoff=not bool(signed_lines),
1139
1461
  author=author,
1140
1462
  message=commit_message,
1141
1463
  )
@@ -1163,10 +1485,11 @@ class Orchestrator:
1163
1485
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
1164
1486
  prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1165
1487
  pr_num = os.getenv("PR_NUMBER", "").strip()
1166
- if pr_num:
1167
- topic = f"{prefix}-{repo.project_github}-{pr_num}"
1168
- else:
1169
- topic = f"{prefix}-{repo.project_github}"
1488
+ topic = f"{prefix}-{repo.project_github}-{pr_num}" if pr_num else f"{prefix}-{repo.project_github}"
1489
+
1490
+ # Use our specific SSH configuration
1491
+ env = self._ssh_env()
1492
+
1170
1493
  try:
1171
1494
  args = [
1172
1495
  "git",
@@ -1176,33 +1499,30 @@ class Orchestrator:
1176
1499
  "-t",
1177
1500
  topic,
1178
1501
  ]
1179
- revs = [
1180
- r.strip() for r in (reviewers or "").split(",") if r.strip()
1181
- ]
1502
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
1182
1503
  for r in revs:
1183
1504
  args.extend(["--reviewer", r])
1184
1505
  # Branch as positional argument (not a flag)
1185
1506
  args.append(branch)
1186
1507
 
1187
- # Use our specific SSH configuration
1188
- env = (
1189
- {"GIT_SSH_COMMAND": self._git_ssh_command}
1190
- if self._git_ssh_command
1191
- else None
1192
- )
1508
+ if env_bool("CI_TESTING", False):
1509
+ log.info("CI_TESTING enabled: using synthetic orphan commit push path")
1510
+ self._create_orphan_commit_and_push(gerrit, repo, branch, reviewers, topic, env)
1511
+ return
1193
1512
  log.debug("Executing git review command: %s", " ".join(args))
1194
1513
  run_cmd(args, cwd=self.workspace, env=env)
1195
1514
  log.info("Successfully pushed changes to Gerrit")
1196
1515
  except CommandError as exc:
1516
+ # Check if this is a "no common ancestry" error in CI_TESTING mode
1517
+ if self._should_handle_unrelated_history(exc):
1518
+ log.info("Detected unrelated repository history. Creating orphan commit for CI testing...")
1519
+ self._create_orphan_commit_and_push(gerrit, repo, branch, reviewers, topic, env)
1520
+ return
1521
+
1197
1522
  # Analyze the specific failure reason from git review output
1198
1523
  error_details = self._analyze_gerrit_push_failure(exc)
1199
- _log_exception_conditionally(
1200
- log, "Gerrit push failed: %s", error_details
1201
- )
1202
- msg = (
1203
- f"Failed to push changes to Gerrit with git-review: "
1204
- f"{error_details}"
1205
- )
1524
+ log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1525
+ msg = f"Failed to push changes to Gerrit with git-review: {error_details}"
1206
1526
  raise OrchestratorError(msg) from exc
1207
1527
  # Cleanup temporary branch used during preparation
1208
1528
  tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
@@ -1212,13 +1532,122 @@ class Orchestrator:
1212
1532
  ["git", "checkout", f"origin/{branch}"],
1213
1533
  check=False,
1214
1534
  cwd=self.workspace,
1535
+ env=env,
1215
1536
  )
1216
1537
  run_cmd(
1217
1538
  ["git", "branch", "-D", tmp_branch],
1218
1539
  check=False,
1219
1540
  cwd=self.workspace,
1541
+ env=env,
1220
1542
  )
1221
1543
 
1544
+ def _should_handle_unrelated_history(self, exc: CommandError) -> bool:
1545
+ """Check if we should handle unrelated repository history in CI testing mode."""
1546
+ if not env_bool("CI_TESTING", False):
1547
+ return False
1548
+
1549
+ stdout = exc.stdout or ""
1550
+ stderr = exc.stderr or ""
1551
+ combined_output = f"{stdout}\n{stderr}"
1552
+
1553
+ combined_lower = combined_output.lower()
1554
+ phrases = (
1555
+ "no common ancestry",
1556
+ "no common ancestor",
1557
+ "do not have a common ancestor",
1558
+ "have no common ancestor",
1559
+ "have no commits in common",
1560
+ "refusing to merge unrelated histories",
1561
+ "unrelated histories",
1562
+ "unrelated history",
1563
+ "no merge base",
1564
+ )
1565
+ return any(p in combined_lower for p in phrases)
1566
+
1567
+ def _create_orphan_commit_and_push(
1568
+ self, gerrit: GerritInfo, repo: RepoNames, branch: str, reviewers: str, topic: str, env: dict[str, str]
1569
+ ) -> None:
1570
+ """Create a synthetic commit on top of the remote base with the PR tree (CI testing mode)."""
1571
+ log.info("CI_TESTING: Creating synthetic commit on top of remote base for unrelated repository")
1572
+
1573
+ try:
1574
+ # Capture the current PR commit message and tree
1575
+ commit_msg = run_cmd(["git", "log", "--format=%B", "-n", "1", "HEAD"], cwd=self.workspace).stdout.strip()
1576
+ pr_tree = run_cmd(["git", "show", "--quiet", "--format=%T", "HEAD"], cwd=self.workspace).stdout.strip()
1577
+
1578
+ # Create/update a synthetic branch based on the remote base branch
1579
+ synth_branch = f"synth-{topic}"
1580
+ # Ensure remote ref exists locally (best-effort)
1581
+ run_cmd(["git", "fetch", "gerrit", branch], cwd=self.workspace, env=env, check=False)
1582
+ run_cmd(["git", "checkout", "-B", synth_branch, f"remotes/gerrit/{branch}"], cwd=self.workspace, env=env)
1583
+
1584
+ # Replace working tree contents with the PR tree
1585
+ # 1) Remove current tracked files (ignore errors if none)
1586
+ run_cmd(["git", "rm", "-r", "--quiet", "."], cwd=self.workspace, env=env, check=False)
1587
+ # 2) Clean untracked files/dirs (preserve our SSH known_hosts dir)
1588
+ run_cmd(
1589
+ ["git", "clean", "-fdx", "-e", ".ssh-g2g", "-e", ".ssh-g2g/**"],
1590
+ cwd=self.workspace,
1591
+ env=env,
1592
+ check=False,
1593
+ )
1594
+ # 3) Checkout the PR tree into working directory
1595
+ run_cmd(["git", "checkout", pr_tree, "--", "."], cwd=self.workspace, env=env)
1596
+ run_cmd(["git", "add", "-A"], cwd=self.workspace, env=env)
1597
+
1598
+ # Commit synthetic change with the same message (should already include Change-Id)
1599
+ import tempfile as _tempfile
1600
+ from pathlib import Path as _Path
1601
+
1602
+ with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
1603
+ # Ensure Signed-off-by for current committer (uploader) is present in the footer
1604
+ try:
1605
+ committer_name = run_cmd(
1606
+ ["git", "config", "--get", "user.name"],
1607
+ cwd=self.workspace,
1608
+ ).stdout.strip()
1609
+ except Exception:
1610
+ committer_name = ""
1611
+ try:
1612
+ committer_email = run_cmd(
1613
+ ["git", "config", "--get", "user.email"],
1614
+ cwd=self.workspace,
1615
+ ).stdout.strip()
1616
+ except Exception:
1617
+ committer_email = ""
1618
+ msg_to_write = commit_msg
1619
+ if committer_name and committer_email:
1620
+ sob_line = f"Signed-off-by: {committer_name} <{committer_email}>"
1621
+ if sob_line not in msg_to_write:
1622
+ if not msg_to_write.endswith("\n"):
1623
+ msg_to_write += "\n"
1624
+ if not msg_to_write.endswith("\n\n"):
1625
+ msg_to_write += "\n"
1626
+ msg_to_write += sob_line
1627
+ _tf.write(msg_to_write)
1628
+ _tf.flush()
1629
+ _tmp_msg_path = _Path(_tf.name)
1630
+ try:
1631
+ run_cmd(["git", "commit", "-F", str(_tmp_msg_path)], cwd=self.workspace, env=env)
1632
+ finally:
1633
+ from contextlib import suppress
1634
+
1635
+ with suppress(Exception):
1636
+ _tmp_msg_path.unlink(missing_ok=True)
1637
+
1638
+ # Push directly to refs/for/<branch> with topic and reviewers to avoid rebase behavior
1639
+ push_ref = f"refs/for/{branch}%topic={topic}"
1640
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
1641
+ for r in revs:
1642
+ push_ref += f",r={r}"
1643
+ run_cmd(["git", "push", "--no-follow-tags", "gerrit", f"HEAD:{push_ref}"], cwd=self.workspace, env=env)
1644
+ log.info("Successfully pushed synthetic commit to Gerrit")
1645
+
1646
+ except CommandError as orphan_exc:
1647
+ error_details = self._analyze_gerrit_push_failure(orphan_exc)
1648
+ msg = f"Failed to push orphan commit to Gerrit: {error_details}"
1649
+ raise OrchestratorError(msg) from orphan_exc
1650
+
1222
1651
  def _analyze_gerrit_push_failure(self, exc: CommandError) -> str:
1223
1652
  """Analyze git review failure and provide helpful error message."""
1224
1653
  stdout = exc.stdout or ""
@@ -1242,10 +1671,7 @@ class Orchestrator:
1242
1671
  "'ssh-keyscan -p 29418 <gerrit-host>' "
1243
1672
  "to get the current host keys."
1244
1673
  )
1245
- elif (
1246
- "authenticity of host" in combined_lower
1247
- and "can't be established" in combined_lower
1248
- ):
1674
+ elif "authenticity of host" in combined_lower and "can't be established" in combined_lower:
1249
1675
  return (
1250
1676
  "SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
1251
1677
  "contain the host key for the Gerrit server. "
@@ -1255,37 +1681,18 @@ class Orchestrator:
1255
1681
  "'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
1256
1682
  )
1257
1683
  # Check for specific SSH key issues before general permission denied
1258
- elif (
1259
- "key_load_public" in combined_lower
1260
- and "invalid format" in combined_lower
1261
- ):
1262
- return (
1263
- "SSH key format is invalid. Check that the SSH private key "
1264
- "is properly formatted."
1265
- )
1684
+ elif "key_load_public" in combined_lower and "invalid format" in combined_lower:
1685
+ return "SSH key format is invalid. Check that the SSH private key is properly formatted."
1266
1686
  elif "no matching host key type found" in combined_lower:
1267
- return (
1268
- "SSH key type not supported by server. The server may not "
1269
- "accept this SSH key algorithm."
1270
- )
1687
+ return "SSH key type not supported by server. The server may not accept this SSH key algorithm."
1271
1688
  elif "authentication failed" in combined_lower:
1272
- return (
1273
- "SSH authentication failed - check SSH key, username, and "
1274
- "server configuration"
1275
- )
1689
+ return "SSH authentication failed - check SSH key, username, and server configuration"
1276
1690
  # Check for connection timeout/refused before "could not read" check
1277
- elif (
1278
- "connection timed out" in combined_lower
1279
- or "connection refused" in combined_lower
1280
- ):
1281
- return (
1282
- "Connection failed - check network connectivity and "
1283
- "Gerrit server availability"
1284
- )
1691
+ elif "connection timed out" in combined_lower or "connection refused" in combined_lower:
1692
+ return "Connection failed - check network connectivity and Gerrit server availability"
1285
1693
  # Check for specific SSH publickey-only authentication failures
1286
1694
  elif "permission denied (publickey)" in combined_lower and not any(
1287
- auth_method in combined_lower
1288
- for auth_method in ["gssapi", "password", "keyboard"]
1695
+ auth_method in combined_lower for auth_method in ["gssapi", "password", "keyboard"]
1289
1696
  ):
1290
1697
  return (
1291
1698
  "SSH public key authentication failed. The SSH key may be "
@@ -1295,19 +1702,13 @@ class Orchestrator:
1295
1702
  elif "permission denied" in combined_lower:
1296
1703
  return "SSH permission denied - check SSH key and user permissions"
1297
1704
  elif "could not read from remote repository" in combined_lower:
1298
- return (
1299
- "Could not read from remote repository - check SSH "
1300
- "authentication and repository access permissions"
1301
- )
1705
+ return "Could not read from remote repository - check SSH authentication and repository access permissions"
1302
1706
  # Check for Gerrit-specific issues
1303
1707
  elif "missing issue-id" in combined_lower:
1304
1708
  return "Missing Issue-ID in commit message."
1305
1709
  elif "commit not associated to any issue" in combined_lower:
1306
1710
  return "Commit not associated to any issue."
1307
- elif (
1308
- "remote rejected" in combined_lower
1309
- and "refs/for/" in combined_lower
1310
- ):
1711
+ elif "remote rejected" in combined_lower and "refs/for/" in combined_lower:
1311
1712
  # Extract specific rejection reason from output
1312
1713
  lines = combined_output.split("\n")
1313
1714
  for line in lines:
@@ -1330,28 +1731,34 @@ class Orchestrator:
1330
1731
  ) -> SubmissionResult:
1331
1732
  """Query Gerrit for change URL/number and patchset sha via REST."""
1332
1733
  log.info("Querying Gerrit for submitted change(s) via REST")
1333
- # Build Gerrit REST client (prefer HTTP basic auth if provided)
1334
- base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1335
- base_url = (
1336
- f"https://{gerrit.host}/"
1337
- if not base_path
1338
- else f"https://{gerrit.host}/{base_path}/"
1339
- )
1340
- http_user = (
1341
- os.getenv("GERRIT_HTTP_USER", "").strip()
1342
- or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1343
- )
1734
+
1735
+ # pygerrit2 netrc filter is already applied in execute() unless verbose mode
1736
+
1737
+ # Create centralized URL builder (auto-discovers base path)
1738
+ url_builder = create_gerrit_url_builder(gerrit.host)
1739
+
1740
+ # Get authentication credentials
1741
+ http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1344
1742
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1743
+
1744
+ # Build Gerrit REST client (prefer HTTP basic auth if provided)
1345
1745
  if GerritRestAPI is None:
1346
1746
  raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1347
- if http_user and http_pass:
1348
- if HTTPBasicAuth is None:
1349
- raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1350
- rest = GerritRestAPI(
1351
- url=base_url, auth=HTTPBasicAuth(http_user, http_pass)
1352
- )
1353
- else:
1354
- rest = GerritRestAPI(url=base_url)
1747
+
1748
+ def _create_rest_client(base_url: str) -> Any:
1749
+ """Helper to create REST client with optional auth."""
1750
+ if http_user and http_pass:
1751
+ if HTTPBasicAuth is None:
1752
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1753
+ if GerritRestAPI is None:
1754
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1755
+ return GerritRestAPI(url=base_url, auth=HTTPBasicAuth(http_user, http_pass))
1756
+ else:
1757
+ if GerritRestAPI is None:
1758
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1759
+ return GerritRestAPI(url=base_url)
1760
+
1761
+ # Try API URLs in order of preference (client creation happens in retry loop)
1355
1762
  urls: list[str] = []
1356
1763
  nums: list[str] = []
1357
1764
  shas: list[str] = []
@@ -1362,76 +1769,54 @@ class Orchestrator:
1362
1769
  # include current revision
1363
1770
  query = f"limit:1 is:open project:{repo.project_gerrit} {cid}"
1364
1771
  path = f"/changes/?q={query}&o=CURRENT_REVISION&n=1"
1772
+ # Build single API base URL via centralized discovery
1773
+ api_base_url = url_builder.api_url()
1774
+ # Build Gerrit REST client with retry/timeout
1775
+ from .gerrit_rest import build_client_for_host
1776
+
1777
+ client = build_client_for_host(
1778
+ gerrit.host,
1779
+ timeout=8.0,
1780
+ max_attempts=5,
1781
+ http_user=http_user or None,
1782
+ http_password=http_pass or None,
1783
+ )
1365
1784
  try:
1366
- changes = rest.get(path)
1785
+ log.debug("Gerrit API base URL (discovered): %s", api_base_url)
1786
+ changes = client.get(path)
1367
1787
  except Exception as exc:
1368
- status = getattr(
1369
- getattr(exc, "response", None), "status_code", None
1370
- )
1371
- if not base_path and status == 404:
1372
- try:
1373
- fallback_url = f"https://{gerrit.host}/r/"
1374
- if GerritRestAPI is None:
1375
- log.warning(
1376
- "pygerrit2 missing; skipping REST fallback"
1377
- )
1378
- continue
1379
- if http_user and http_pass:
1380
- if HTTPBasicAuth is None:
1381
- log.warning(
1382
- "pygerrit2 auth missing; skipping fallback"
1383
- )
1384
- continue
1385
- rest_fallback = GerritRestAPI(
1386
- url=fallback_url,
1387
- auth=HTTPBasicAuth(http_user, http_pass),
1388
- )
1389
- else:
1390
- rest_fallback = GerritRestAPI(url=fallback_url)
1391
- changes = rest_fallback.get(path)
1392
- except Exception as exc2:
1393
- log.warning(
1394
- "Failed to query change via REST for %s "
1395
- "(including '/r' fallback): %s",
1396
- cid,
1397
- exc2,
1398
- )
1399
- continue
1400
- else:
1401
- log.warning(
1402
- "Failed to query change via REST for %s: %s", cid, exc
1403
- )
1404
- continue
1788
+ log.warning("Failed to query change via REST for %s: %s", cid, exc)
1789
+ continue
1405
1790
  if not changes:
1406
1791
  continue
1407
1792
  change = changes[0]
1408
- num = str(change.get("_number", ""))
1409
- current_rev = change.get("current_revision", "")
1793
+ # Type guard to ensure mapping-like before dict access
1794
+ if isinstance(change, dict):
1795
+ num = str(change.get("_number", ""))
1796
+ current_rev = change.get("current_revision", "")
1797
+ else:
1798
+ # Unexpected type; skip this result
1799
+ continue
1410
1800
  # Construct a stable web URL for the change
1411
1801
  if num:
1412
- urls.append(
1413
- f"https://{gerrit.host}/c/{repo.project_gerrit}/+/{num}"
1414
- )
1802
+ change_url = url_builder.change_url(repo.project_gerrit, int(num))
1803
+ urls.append(change_url)
1415
1804
  nums.append(num)
1416
1805
  if current_rev:
1417
1806
  shas.append(current_rev)
1418
- # Export env variables (compat)
1419
- if urls:
1420
- os.environ["GERRIT_CHANGE_REQUEST_URL"] = "\n".join(urls)
1421
- if nums:
1422
- os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(nums)
1423
- if shas:
1424
- os.environ["GERRIT_COMMIT_SHA"] = "\n".join(shas)
1425
- return SubmissionResult(
1426
- change_urls=urls, change_numbers=nums, commit_shas=shas
1427
- )
1807
+
1808
+ return SubmissionResult(change_urls=urls, change_numbers=nums, commit_shas=shas)
1428
1809
 
1429
1810
  def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1430
1811
  """Initialize and set up git workspace for PR processing."""
1431
1812
  from .gitutils import run_cmd
1432
1813
 
1433
- # Initialize git repository
1434
- run_cmd(["git", "init"], cwd=self.workspace)
1814
+ # Try modern git init with explicit branch first
1815
+ try:
1816
+ run_cmd(["git", "init", "--initial-branch=master"], cwd=self.workspace)
1817
+ except Exception:
1818
+ # Fallback for older git versions (hint filtered at logging level)
1819
+ run_cmd(["git", "init"], cwd=self.workspace)
1435
1820
 
1436
1821
  # Add GitHub remote
1437
1822
  repo_full = gh.repository.strip() if gh.repository else ""
@@ -1445,10 +1830,7 @@ class Orchestrator:
1445
1830
 
1446
1831
  # Fetch PR head
1447
1832
  if gh.pr_number:
1448
- pr_ref = (
1449
- f"refs/pull/{gh.pr_number}/head:"
1450
- f"refs/remotes/origin/pr/{gh.pr_number}/head"
1451
- )
1833
+ pr_ref = f"refs/pull/{gh.pr_number}/head:refs/remotes/origin/pr/{gh.pr_number}/head"
1452
1834
  run_cmd(
1453
1835
  [
1454
1836
  "git",
@@ -1468,62 +1850,235 @@ class Orchestrator:
1468
1850
 
1469
1851
  def _install_commit_msg_hook(self, gerrit: GerritInfo) -> None:
1470
1852
  """Manually install commit-msg hook from Gerrit."""
1471
- from .gitutils import run_cmd
1853
+ from .external_api import curl_download
1472
1854
 
1473
1855
  hooks_dir = self.workspace / ".git" / "hooks"
1474
1856
  hooks_dir.mkdir(exist_ok=True)
1475
1857
  hook_path = hooks_dir / "commit-msg"
1476
1858
 
1477
- # Download commit-msg hook using SSH
1859
+ # Download commit-msg hook using centralized curl framework
1478
1860
  try:
1479
- # Use curl to download the hook (more reliable than scp)
1480
- curl_cmd = [
1481
- "curl",
1482
- "-o",
1483
- str(hook_path),
1484
- f"https://{gerrit.host}/r/tools/hooks/commit-msg",
1485
- ]
1486
- run_cmd(curl_cmd, cwd=self.workspace)
1861
+ # Create centralized URL builder for hook URLs
1862
+ url_builder = create_gerrit_url_builder(gerrit.host)
1863
+ hook_url = url_builder.hook_url("commit-msg")
1864
+
1865
+ # Localized error raiser and short messages to satisfy TRY rules
1866
+ def _raise_orch(msg: str) -> None:
1867
+ raise OrchestratorError(msg) # noqa: TRY301
1868
+
1869
+ _MSG_HOOK_SIZE_BOUNDS = "commit-msg hook size outside expected bounds"
1870
+ _MSG_HOOK_READ_FAILED = "failed reading commit-msg hook"
1871
+ _MSG_HOOK_NO_SHEBANG = "commit-msg hook missing shebang"
1872
+ _MSG_HOOK_BAD_CONTENT = "commit-msg hook content lacks expected markers"
1873
+
1874
+ # Use centralized curl download with retry/logging/metrics
1875
+ return_code, status_code = curl_download(
1876
+ url=hook_url,
1877
+ output_path=str(hook_path),
1878
+ timeout=30.0,
1879
+ follow_redirects=True,
1880
+ silent=True,
1881
+ )
1882
+
1883
+ size = hook_path.stat().st_size
1884
+ log.debug(
1885
+ "curl fetch of commit-msg: url=%s http_status=%s size=%dB rc=%s",
1886
+ hook_url,
1887
+ status_code,
1888
+ size,
1889
+ return_code,
1890
+ )
1891
+ # Sanity checks on size
1892
+ if size < 128 or size > 65536:
1893
+ _raise_orch(_MSG_HOOK_SIZE_BOUNDS)
1487
1894
 
1488
- # Make hook executable
1895
+ # Validate content characteristics
1896
+ text_head = ""
1897
+ try:
1898
+ with open(hook_path, "rb") as fh:
1899
+ head = fh.read(2048)
1900
+ text_head = head.decode("utf-8", errors="ignore")
1901
+ except Exception:
1902
+ _raise_orch(_MSG_HOOK_READ_FAILED)
1903
+
1904
+ if not text_head.startswith("#!"):
1905
+ _raise_orch(_MSG_HOOK_NO_SHEBANG)
1906
+ # Look for recognizable strings
1907
+ if not any(m in text_head for m in ("Change-Id", "Gerrit Code Review", "add_change_id")):
1908
+ _raise_orch(_MSG_HOOK_BAD_CONTENT)
1909
+
1910
+ # Make hook executable (single chmod)
1489
1911
  hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1490
- log.debug("Successfully installed commit-msg hook via curl")
1912
+ log.debug("Successfully installed commit-msg hook from %s", hook_url)
1491
1913
 
1492
1914
  except Exception as exc:
1493
- log.warning("Failed to install commit-msg hook via curl: %s", exc)
1915
+ log.warning("Failed to install commit-msg hook via centralized curl: %s", exc)
1494
1916
  msg = f"Could not install commit-msg hook: {exc}"
1495
1917
  raise OrchestratorError(msg) from exc
1496
1918
 
1497
- def _ensure_change_id_present(
1498
- self, gerrit: GerritInfo, author: str
1499
- ) -> list[str]:
1919
+ def _ensure_change_id_present(self, gerrit: GerritInfo, author: str) -> list[str]:
1500
1920
  """Ensure the last commit has a Change-Id.
1501
1921
 
1502
1922
  Installs the commit-msg hook and amends the commit if needed.
1503
1923
  """
1504
- trailers = git_last_commit_trailers(
1505
- keys=["Change-Id"], cwd=self.workspace
1506
- )
1507
- if not trailers.get("Change-Id"):
1508
- log.debug(
1509
- "No Change-Id found, installing commit-msg hook and amending "
1510
- "commit"
1511
- )
1924
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1925
+ existing_change_ids = trailers.get("Change-Id", [])
1926
+
1927
+ if existing_change_ids:
1928
+ log.debug("Found existing Change-Id(s) in footer: %s", existing_change_ids)
1929
+ # Clean up any duplicate Change-IDs in the message body
1930
+ self._clean_change_ids_from_body(author)
1931
+ return [c for c in existing_change_ids if c]
1932
+
1933
+ log.debug("No Change-Id found; attempting to install commit-msg hook and amend commit")
1934
+ try:
1512
1935
  self._install_commit_msg_hook(gerrit)
1513
1936
  git_commit_amend(
1514
- no_edit=True, signoff=True, author=author, cwd=self.workspace
1937
+ no_edit=True,
1938
+ signoff=True,
1939
+ author=author,
1940
+ cwd=self.workspace,
1941
+ )
1942
+ except Exception as exc:
1943
+ log.warning(
1944
+ "Commit-msg hook installation failed, falling back to direct Change-Id injection: %s",
1945
+ exc,
1515
1946
  )
1516
- # Debug: Check commit message after amend
1517
- actual_msg = run_cmd(
1947
+ # Fallback: generate a Change-Id and append to the commit
1948
+ # message directly
1949
+ import time
1950
+
1951
+ current_msg = run_cmd(
1518
1952
  ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1519
1953
  cwd=self.workspace,
1520
- ).stdout.strip()
1521
- log.debug("Commit message after amend:\n%s", actual_msg)
1522
- trailers = git_last_commit_trailers(
1523
- keys=["Change-Id"], cwd=self.workspace
1954
+ ).stdout
1955
+ seed = f"{current_msg}\n{time.time()}"
1956
+ import hashlib as _hashlib # local alias to satisfy linters
1957
+
1958
+ change_id = "I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
1959
+
1960
+ # Clean message and ensure proper footer placement
1961
+ cleaned_msg = self._clean_commit_message_for_change_id(current_msg)
1962
+ new_msg = cleaned_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
1963
+ git_commit_amend(
1964
+ no_edit=False,
1965
+ signoff=True,
1966
+ author=author,
1967
+ message=new_msg,
1968
+ cwd=self.workspace,
1524
1969
  )
1970
+ # Debug: Check commit message after amend
1971
+ actual_msg = run_cmd(
1972
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1973
+ cwd=self.workspace,
1974
+ ).stdout.strip()
1975
+ log.debug("Commit message after amend:\n%s", actual_msg)
1976
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1525
1977
  return [c for c in trailers.get("Change-Id", []) if c]
1526
1978
 
1979
+ def _clean_change_ids_from_body(self, author: str) -> None:
1980
+ """Remove any Change-Id lines from the commit message body, keeping only footer trailers."""
1981
+ current_msg = run_cmd(
1982
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1983
+ cwd=self.workspace,
1984
+ ).stdout
1985
+
1986
+ cleaned_msg = self._clean_commit_message_for_change_id(current_msg)
1987
+
1988
+ if cleaned_msg != current_msg:
1989
+ log.debug("Cleaned Change-Id lines from commit message body")
1990
+ git_commit_amend(
1991
+ no_edit=False,
1992
+ signoff=True,
1993
+ author=author,
1994
+ message=cleaned_msg,
1995
+ cwd=self.workspace,
1996
+ )
1997
+
1998
+ def _clean_commit_message_for_change_id(self, message: str) -> str:
1999
+ """Remove Change-Id lines from message body while preserving footer trailers."""
2000
+ lines = message.splitlines()
2001
+
2002
+ # Parse proper trailers using the fixed trailer parser
2003
+ trailers = _parse_trailers(message)
2004
+ change_id_trailers = trailers.get("Change-Id", [])
2005
+ signed_off_trailers = trailers.get("Signed-off-by", [])
2006
+ other_trailers = {k: v for k, v in trailers.items() if k not in ["Change-Id", "Signed-off-by"]}
2007
+
2008
+ # Find trailer section by working backwards to find continuous trailer block
2009
+ trailer_start = len(lines)
2010
+
2011
+ # Work backwards to find where trailers start
2012
+ for i in range(len(lines) - 1, -1, -1):
2013
+ line = lines[i].strip()
2014
+ if not line:
2015
+ # Found blank line - trailers are after this
2016
+ trailer_start = i + 1
2017
+ break
2018
+ elif ":" not in line:
2019
+ # Non-trailer line - trailers start after this
2020
+ trailer_start = i + 1
2021
+ break
2022
+ else:
2023
+ # Potential trailer line - check if it's a valid trailer
2024
+ key, val = line.split(":", 1)
2025
+ k = key.strip()
2026
+ v = val.strip()
2027
+ if not (k and v and not k.startswith(" ") and not k.startswith("\t")):
2028
+ # Invalid trailer format - trailers start after this
2029
+ trailer_start = i + 1
2030
+ break
2031
+
2032
+ # Process body lines (before trailers) and remove any Change-Id references
2033
+ body_lines = []
2034
+ for i in range(trailer_start):
2035
+ line = lines[i]
2036
+ # Remove any Change-Id references from body lines
2037
+ if "Change-Id:" in line:
2038
+ # If line starts with Change-Id:, skip it entirely
2039
+ if line.strip().startswith("Change-Id:"):
2040
+ log.debug("Removing Change-Id line from body: %s", line.strip())
2041
+ continue
2042
+ else:
2043
+ # If Change-Id is mentioned within the line, remove that part
2044
+ original_line = line
2045
+ # Remove Change-Id: followed by the ID value
2046
+
2047
+ # Pattern to match "Change-Id: <value>" where value can contain word chars, hyphens, etc.
2048
+ line = re.sub(r"Change-Id:\s*[A-Za-z0-9._-]+\b", "", line)
2049
+ # Clean up extra whitespace
2050
+ line = re.sub(r"\s+", " ", line).strip()
2051
+ if line != original_line:
2052
+ log.debug("Cleaned Change-Id reference from body line: %s -> %s", original_line.strip(), line)
2053
+ body_lines.append(line)
2054
+
2055
+ # Remove trailing empty lines from body
2056
+ while body_lines and not body_lines[-1].strip():
2057
+ body_lines.pop()
2058
+
2059
+ result = "\n".join(body_lines)
2060
+
2061
+ # Add proper footer trailers if any exist
2062
+ footer_parts = []
2063
+ if signed_off_trailers:
2064
+ _seen_so: set[str] = set()
2065
+ _uniq_so: list[str] = []
2066
+ for s in signed_off_trailers:
2067
+ if s not in _seen_so:
2068
+ _uniq_so.append(s)
2069
+ _seen_so.add(s)
2070
+ footer_parts.extend([f"Signed-off-by: {s}" for s in _uniq_so])
2071
+ # Add other trailers
2072
+ for key, values in other_trailers.items():
2073
+ footer_parts.extend([f"{key}: {v}" for v in values])
2074
+ if change_id_trailers:
2075
+ footer_parts.extend([f"Change-Id: {c}" for c in change_id_trailers])
2076
+
2077
+ if footer_parts:
2078
+ result += "\n\n" + "\n".join(footer_parts)
2079
+
2080
+ return result
2081
+
1527
2082
  def _add_backref_comment_in_gerrit(
1528
2083
  self,
1529
2084
  *,
@@ -1544,21 +2099,14 @@ class Orchestrator:
1544
2099
  "1",
1545
2100
  "yes",
1546
2101
  ):
1547
- log.info(
1548
- "Skipping back-reference comments "
1549
- "(G2G_SKIP_GERRIT_COMMENTS=true)"
1550
- )
2102
+ log.info("Skipping back-reference comments (G2G_SKIP_GERRIT_COMMENTS=true)")
1551
2103
  return
1552
2104
 
1553
2105
  log.info("Adding back-reference comment in Gerrit")
1554
2106
  user = os.getenv("GERRIT_SSH_USER_G2G", "")
1555
2107
  server = gerrit.host
1556
2108
  pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
1557
- run_url = (
1558
- f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}"
1559
- if gh.run_id
1560
- else "N/A"
1561
- )
2109
+ run_url = f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}" if gh.run_id else "N/A"
1562
2110
  message = f"GHPR: {pr_url} | Action-Run: {run_url}"
1563
2111
  log.info("Adding back-reference comment: %s", message)
1564
2112
  for csha in commit_shas:
@@ -1566,39 +2114,118 @@ class Orchestrator:
1566
2114
  continue
1567
2115
  try:
1568
2116
  log.debug("Executing SSH command for commit %s", csha)
1569
- # Build SSH command with our configured SSH options
1570
- ssh_cmd = ["ssh", "-n", "-p", str(gerrit.port)]
1571
-
1572
- # Add our SSH options if we have custom SSH config
1573
- if self._git_ssh_command:
1574
- # Extract SSH options from GIT_SSH_COMMAND
1575
- # Format: "ssh -i /path/to/key -o Option=value ..."
1576
- git_ssh_parts = self._git_ssh_command.split()
1577
- if len(git_ssh_parts) > 1: # Skip the "ssh" part
1578
- ssh_options = git_ssh_parts[1:]
1579
- log.debug("Adding SSH options: %s", ssh_options)
1580
- ssh_cmd.extend(ssh_options)
2117
+ # Build SSH command based on available authentication method
2118
+ if self._ssh_key_path and self._ssh_known_hosts_path:
2119
+ # File-based SSH authentication
2120
+ ssh_cmd = [
2121
+ "ssh",
2122
+ "-F",
2123
+ "/dev/null",
2124
+ "-i",
2125
+ str(self._ssh_key_path),
2126
+ "-o",
2127
+ f"UserKnownHostsFile={self._ssh_known_hosts_path}",
2128
+ "-o",
2129
+ "IdentitiesOnly=yes",
2130
+ "-o",
2131
+ "IdentityAgent=none",
2132
+ "-o",
2133
+ "BatchMode=yes",
2134
+ "-o",
2135
+ "StrictHostKeyChecking=yes",
2136
+ "-o",
2137
+ "PasswordAuthentication=no",
2138
+ "-o",
2139
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
2140
+ "-n",
2141
+ "-p",
2142
+ str(gerrit.port),
2143
+ f"{user}@{server}",
2144
+ (
2145
+ "gerrit review -m "
2146
+ f"{shlex.quote(message)} "
2147
+ "--branch "
2148
+ f"{shlex.quote(branch)} "
2149
+ "--project "
2150
+ f"{shlex.quote(repo.project_gerrit)} "
2151
+ f"{shlex.quote(csha)}"
2152
+ ),
2153
+ ]
2154
+ elif self._use_ssh_agent and self._ssh_agent_manager and self._ssh_agent_manager.known_hosts_path:
2155
+ # SSH agent authentication with known_hosts
2156
+ ssh_cmd = [
2157
+ "ssh",
2158
+ "-F",
2159
+ "/dev/null",
2160
+ "-o",
2161
+ f"UserKnownHostsFile={self._ssh_agent_manager.known_hosts_path}",
2162
+ "-o",
2163
+ "IdentitiesOnly=no",
2164
+ "-o",
2165
+ "BatchMode=yes",
2166
+ "-o",
2167
+ "PreferredAuthentications=publickey",
2168
+ "-o",
2169
+ "StrictHostKeyChecking=yes",
2170
+ "-o",
2171
+ "PasswordAuthentication=no",
2172
+ "-o",
2173
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
2174
+ "-o",
2175
+ "ConnectTimeout=10",
2176
+ "-n",
2177
+ "-p",
2178
+ str(gerrit.port),
2179
+ f"{user}@{server}",
2180
+ (
2181
+ "gerrit review -m "
2182
+ f"{shlex.quote(message)} "
2183
+ "--branch "
2184
+ f"{shlex.quote(branch)} "
2185
+ "--project "
2186
+ f"{shlex.quote(repo.project_gerrit)} "
2187
+ f"{shlex.quote(csha)}"
2188
+ ),
2189
+ ]
1581
2190
  else:
1582
- log.debug("No custom SSH config, using default SSH options")
1583
-
1584
- # Add the target and gerrit command
1585
- ssh_cmd.extend(
1586
- [
2191
+ # Fallback - minimal SSH command (for tests)
2192
+ ssh_cmd = [
2193
+ "ssh",
2194
+ "-F",
2195
+ "/dev/null",
2196
+ "-o",
2197
+ "IdentitiesOnly=yes",
2198
+ "-o",
2199
+ "IdentityAgent=none",
2200
+ "-o",
2201
+ "BatchMode=yes",
2202
+ "-o",
2203
+ "StrictHostKeyChecking=yes",
2204
+ "-o",
2205
+ "PasswordAuthentication=no",
2206
+ "-o",
2207
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
2208
+ "-n",
2209
+ "-p",
2210
+ str(gerrit.port),
1587
2211
  f"{user}@{server}",
1588
- "gerrit",
1589
- "review",
1590
- "-m",
1591
- message,
1592
- "--branch",
1593
- branch,
1594
- "--project",
1595
- repo.project_gerrit,
1596
- csha,
2212
+ (
2213
+ "gerrit review -m "
2214
+ f"{shlex.quote(message)} "
2215
+ "--branch "
2216
+ f"{shlex.quote(branch)} "
2217
+ "--project "
2218
+ f"{shlex.quote(repo.project_gerrit)} "
2219
+ f"{shlex.quote(csha)}"
2220
+ ),
1597
2221
  ]
1598
- )
1599
2222
 
1600
2223
  log.debug("Final SSH command: %s", " ".join(ssh_cmd))
1601
- run_cmd(ssh_cmd, cwd=self.workspace)
2224
+ run_cmd(
2225
+ ssh_cmd,
2226
+ cwd=self.workspace,
2227
+ env=self._ssh_env(),
2228
+ )
1602
2229
  log.info(
1603
2230
  "Successfully added back-reference comment for %s: %s",
1604
2231
  csha,
@@ -1606,8 +2233,7 @@ class Orchestrator:
1606
2233
  )
1607
2234
  except CommandError as exc:
1608
2235
  log.warning(
1609
- "Failed to add back-reference comment for %s "
1610
- "(non-fatal): %s",
2236
+ "Failed to add back-reference comment for %s (non-fatal): %s",
1611
2237
  csha,
1612
2238
  exc,
1613
2239
  )
@@ -1623,14 +2249,11 @@ class Orchestrator:
1623
2249
  # Continue processing - this is not a fatal error
1624
2250
  except Exception as exc:
1625
2251
  log.warning(
1626
- "Failed to add back-reference comment for %s "
1627
- "(non-fatal): %s",
2252
+ "Failed to add back-reference comment for %s (non-fatal): %s",
1628
2253
  csha,
1629
2254
  exc,
1630
2255
  )
1631
- log.debug(
1632
- "Back-reference comment failure details:", exc_info=True
1633
- )
2256
+ log.debug("Back-reference comment failure details:", exc_info=True)
1634
2257
  # Continue processing - this is not a fatal error
1635
2258
 
1636
2259
  def _comment_on_pull_request(
@@ -1640,15 +2263,19 @@ class Orchestrator:
1640
2263
  result: SubmissionResult,
1641
2264
  ) -> None:
1642
2265
  """Post a comment on the PR with the Gerrit change URL(s)."""
2266
+ # Respect CI_TESTING: do not attempt to update the source/origin PR
2267
+ if os.getenv("CI_TESTING", "").strip().lower() in ("1", "true", "yes"):
2268
+ log.debug("Source/origin pull request will NOT be updated with Gerrit change when CI_TESTING set true")
2269
+ return
1643
2270
  log.info("Adding reference comment on PR #%s", gh.pr_number)
1644
2271
  if not gh.pr_number:
1645
2272
  return
1646
2273
  urls = result.change_urls or []
1647
2274
  org = os.getenv("ORGANIZATION", gh.repository_owner)
1648
- text = (
1649
- f"The pull-request PR-{gh.pr_number} is submitted to Gerrit "
1650
- f"[{org}](https://{gerrit.host})!\n\n"
1651
- )
2275
+ # Create centralized URL builder for organization link
2276
+ url_builder = create_gerrit_url_builder(gerrit.host)
2277
+ org_url = url_builder.web_url()
2278
+ text = f"The pull-request PR-{gh.pr_number} is submitted to Gerrit [{org}]({org_url})!\n\n"
1652
2279
  if urls:
1653
2280
  text += "To follow up on the change visit:\n\n" + "\n".join(urls)
1654
2281
  try:
@@ -1657,6 +2284,13 @@ class Orchestrator:
1657
2284
  # At this point, gh.pr_number is non-None due to earlier guard.
1658
2285
  pr_obj = get_pull(repo, int(gh.pr_number))
1659
2286
  create_pr_comment(pr_obj, text)
2287
+ # Also post a succinct one-line comment
2288
+ # for each Gerrit change URL
2289
+ for u in urls:
2290
+ create_pr_comment(
2291
+ pr_obj,
2292
+ f"Change raised in Gerrit by GitHub2Gerrit: {u}",
2293
+ )
1660
2294
  except Exception as exc:
1661
2295
  log.warning("Failed to add PR comment: %s", exc)
1662
2296
 
@@ -1718,20 +2352,15 @@ class Orchestrator:
1718
2352
  "yes",
1719
2353
  "on",
1720
2354
  ):
2355
+ log.info("Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)")
1721
2356
  log.info(
1722
- "Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)"
1723
- )
1724
- log.info(
1725
- "Dry-run targets: Gerrit project=%s branch=%s "
1726
- "topic_prefix=GH-%s",
2357
+ "Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
1727
2358
  repo.project_gerrit,
1728
2359
  self._resolve_target_branch(),
1729
2360
  repo.project_github,
1730
2361
  )
1731
2362
  if inputs.reviewers_email:
1732
- log.info(
1733
- "Reviewers (from inputs/config): %s", inputs.reviewers_email
1734
- )
2363
+ log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
1735
2364
  elif os.getenv("REVIEWERS_EMAIL"):
1736
2365
  log.info(
1737
2366
  "Reviewers (from environment): %s",
@@ -1742,18 +2371,14 @@ class Orchestrator:
1742
2371
  # DNS resolution for Gerrit host
1743
2372
  try:
1744
2373
  socket.getaddrinfo(gerrit.host, None)
1745
- log.info(
1746
- "DNS resolution for Gerrit host '%s' succeeded", gerrit.host
1747
- )
2374
+ log.info("DNS resolution for Gerrit host '%s' succeeded", gerrit.host)
1748
2375
  except Exception as exc:
1749
2376
  msg = "DNS resolution failed"
1750
2377
  raise OrchestratorError(msg) from exc
1751
2378
 
1752
2379
  # SSH (TCP) reachability on Gerrit port
1753
2380
  try:
1754
- with socket.create_connection(
1755
- (gerrit.host, gerrit.port), timeout=5
1756
- ):
2381
+ with socket.create_connection((gerrit.host, gerrit.port), timeout=5):
1757
2382
  pass
1758
2383
  log.info(
1759
2384
  "SSH TCP connectivity to %s:%s verified",
@@ -1766,10 +2391,7 @@ class Orchestrator:
1766
2391
 
1767
2392
  # Gerrit REST reachability and optional auth check
1768
2393
  base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1769
- http_user = (
1770
- os.getenv("GERRIT_HTTP_USER", "").strip()
1771
- or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1772
- )
2394
+ http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1773
2395
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1774
2396
  self._verify_gerrit_rest(gerrit.host, base_path, http_user, http_pass)
1775
2397
 
@@ -1779,9 +2401,7 @@ class Orchestrator:
1779
2401
  repo_obj = get_repo_from_env(client)
1780
2402
  if gh.pr_number is not None:
1781
2403
  pr_obj = get_pull(repo_obj, gh.pr_number)
1782
- log.info(
1783
- "GitHub PR #%s metadata loaded successfully", gh.pr_number
1784
- )
2404
+ log.info("GitHub PR #%s metadata loaded successfully", gh.pr_number)
1785
2405
  try:
1786
2406
  title, _ = get_pr_title_body(pr_obj)
1787
2407
  log.info("GitHub PR title: %s", title)
@@ -1807,13 +2427,9 @@ class Orchestrator:
1807
2427
  repo.project_github,
1808
2428
  )
1809
2429
  if inputs.reviewers_email:
1810
- log.info(
1811
- "Reviewers (from inputs/config): %s", inputs.reviewers_email
1812
- )
2430
+ log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
1813
2431
  elif os.getenv("REVIEWERS_EMAIL"):
1814
- log.info(
1815
- "Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL")
1816
- )
2432
+ log.info("Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL"))
1817
2433
 
1818
2434
  def _verify_gerrit_rest(
1819
2435
  self,
@@ -1822,7 +2438,11 @@ class Orchestrator:
1822
2438
  http_user: str,
1823
2439
  http_pass: str,
1824
2440
  ) -> None:
1825
- """Probe Gerrit REST endpoint with optional auth and '/r' fallback."""
2441
+ """Probe Gerrit REST endpoint with optional auth.
2442
+
2443
+ Uses the centralized URL builder to construct the API endpoint
2444
+ for consistent URL handling across the application.
2445
+ """
1826
2446
 
1827
2447
  def _build_client(url: str) -> Any:
1828
2448
  if http_user and http_pass:
@@ -1830,9 +2450,7 @@ class Orchestrator:
1830
2450
  raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1831
2451
  if HTTPBasicAuth is None:
1832
2452
  raise OrchestratorError(_MSG_PYGERRIT2_AUTH_MISSING)
1833
- return GerritRestAPI(
1834
- url=url, auth=HTTPBasicAuth(http_user, http_pass)
1835
- )
2453
+ return GerritRestAPI(url=url, auth=HTTPBasicAuth(http_user, http_pass))
1836
2454
  else:
1837
2455
  if GerritRestAPI is None:
1838
2456
  raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
@@ -1850,52 +2468,19 @@ class Orchestrator:
1850
2468
  _ = rest.get("/dashboard/self")
1851
2469
  log.info("Gerrit REST endpoint reachable (unauthenticated)")
1852
2470
 
1853
- base_url = (
1854
- f"https://{host}/"
1855
- if not base_path
1856
- else f"https://{host}/{base_path}/"
1857
- )
2471
+ # Create centralized URL builder for REST probing
2472
+ url_builder = create_gerrit_url_builder(host, base_path)
2473
+ api_url = url_builder.api_url()
2474
+
1858
2475
  try:
1859
- _probe(base_url)
2476
+ _probe(api_url)
1860
2477
  except Exception as exc:
1861
- status = getattr(
1862
- getattr(exc, "response", None), "status_code", None
1863
- )
1864
- if not base_path and status == 404:
1865
- try:
1866
- fallback_url = f"https://{host}/r/"
1867
- _probe(fallback_url)
1868
- except Exception as exc2:
1869
- log.warning(
1870
- "Gerrit REST probe did not succeed "
1871
- "(including '/r' fallback): %s",
1872
- exc2,
1873
- )
1874
- else:
1875
- log.warning("Gerrit REST probe did not succeed: %s", exc)
2478
+ log.warning("Gerrit REST probe failed for %s: %s", api_url, exc)
1876
2479
 
1877
2480
  # ---------------
1878
2481
  # Helpers
1879
2482
  # ---------------
1880
2483
 
1881
- def _append_github_output(self, outputs: dict[str, str]) -> None:
1882
- gh_out = os.getenv("GITHUB_OUTPUT")
1883
- if not gh_out:
1884
- return
1885
- try:
1886
- with open(gh_out, "a", encoding="utf-8") as fh:
1887
- for key, val in outputs.items():
1888
- if not val:
1889
- continue
1890
- if "\n" in val and os.getenv("GITHUB_ACTIONS") == "true":
1891
- fh.write(f"{key}<<G2G\n")
1892
- fh.write(f"{val}\n")
1893
- fh.write("G2G\n")
1894
- else:
1895
- fh.write(f"{key}={val}\n")
1896
- except Exception as exc:
1897
- log.debug("Failed to write GITHUB_OUTPUT: %s", exc)
1898
-
1899
2484
  def _resolve_target_branch(self) -> str:
1900
2485
  # Preference order:
1901
2486
  # 1) GERRIT_BRANCH (explicit override)