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.
github2gerrit/core.py CHANGED
@@ -30,7 +30,10 @@ import logging
30
30
  import os
31
31
  import re
32
32
  import shlex
33
+ import shutil
34
+ import socket
33
35
  import stat
36
+ import tempfile
34
37
  import urllib.parse
35
38
  import urllib.request
36
39
  from collections.abc import Iterable
@@ -39,6 +42,7 @@ from dataclasses import dataclass
39
42
  from pathlib import Path
40
43
  from typing import Any
41
44
 
45
+ from .commit_normalization import normalize_commit_title
42
46
  from .gerrit_urls import create_gerrit_url_builder
43
47
  from .github_api import build_client
44
48
  from .github_api import close_pr
@@ -50,6 +54,7 @@ from .github_api import get_repo_from_env
50
54
  from .github_api import iter_open_pulls
51
55
  from .gitutils import CommandError
52
56
  from .gitutils import GitError
57
+ from .gitutils import _parse_trailers
53
58
  from .gitutils import git_cherry_pick
54
59
  from .gitutils import git_commit_amend
55
60
  from .gitutils import git_commit_new
@@ -59,6 +64,10 @@ from .gitutils import git_show
59
64
  from .gitutils import run_cmd
60
65
  from .models import GitHubContext
61
66
  from .models import Inputs
67
+ from .pr_content_filter import filter_pr_body
68
+ from .ssh_common import merge_known_hosts_content
69
+ from .utils import env_bool
70
+ from .utils import log_exception_conditionally
62
71
 
63
72
 
64
73
  try:
@@ -76,18 +85,19 @@ except ImportError:
76
85
  auto_discover_gerrit_host_keys = None # type: ignore[assignment]
77
86
  SSHDiscoveryError = Exception # type: ignore[misc,assignment]
78
87
 
88
+ try:
89
+ from .ssh_agent_setup import SSHAgentManager
90
+ from .ssh_agent_setup import setup_ssh_agent_auth
91
+ except ImportError:
92
+ # Fallback if ssh_agent_setup module is not available
93
+ from typing import TYPE_CHECKING
79
94
 
80
- def _is_verbose_mode() -> bool:
81
- """Check if verbose mode is enabled via environment variable."""
82
- return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
83
-
84
-
85
- def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
86
- """Log exception with traceback only if verbose mode is enabled."""
87
- if _is_verbose_mode():
88
- logger.exception(message, *args)
95
+ if TYPE_CHECKING:
96
+ from .ssh_agent_setup import SSHAgentManager
97
+ from .ssh_agent_setup import setup_ssh_agent_auth
89
98
  else:
90
- logger.error(message, *args)
99
+ SSHAgentManager = None
100
+ setup_ssh_agent_auth = None
91
101
 
92
102
 
93
103
  log = logging.getLogger("github2gerrit.core")
@@ -166,11 +176,22 @@ def _match_first_group(pattern: str, text: str) -> str | None:
166
176
 
167
177
 
168
178
  def _is_valid_change_id(value: str) -> bool:
169
- # Gerrit Change-Id usually matches I<40-hex> but the shell workflow
170
- # uses a looser grep. Keep validation permissive for now.
179
+ # Gerrit Change-Id should match I<40-hex-chars> format
180
+ # Be more strict to avoid accepting invalid Change-IDs
171
181
  if not value:
172
182
  return False
173
- return bool(re.fullmatch(r"[A-Za-z0-9._-]+", value))
183
+ # Standard Gerrit format: I followed by exactly 40 hex characters
184
+ if len(value) == 41 and re.fullmatch(r"I[0-9a-fA-F]{40}", value):
185
+ return True
186
+ # Fallback for legacy or non-standard formats (keep some permissiveness)
187
+ # but require it to start with 'I' and be reasonable length (10-40 chars)
188
+ # and NOT look like a malformed hex ID
189
+ return (
190
+ value.startswith("I")
191
+ and 10 <= len(value) <= 40
192
+ and not re.fullmatch(r"I[0-9a-fA-F]+", value) # Exclude hex-like patterns
193
+ and bool(re.fullmatch(r"I[A-Za-z0-9._-]+", value))
194
+ )
174
195
 
175
196
 
176
197
  @dataclass(frozen=True)
@@ -231,6 +252,8 @@ class Orchestrator:
231
252
  # SSH configuration paths (set by _setup_ssh)
232
253
  self._ssh_key_path: Path | None = None
233
254
  self._ssh_known_hosts_path: Path | None = None
255
+ self._ssh_agent_manager: SSHAgentManager | None = None
256
+ self._use_ssh_agent: bool = False
234
257
 
235
258
  # ---------------
236
259
  # Public API
@@ -246,6 +269,10 @@ class Orchestrator:
246
269
  This method defines the high-level call order. Sub-steps are
247
270
  placeholders and must be implemented with real logic. Until then,
248
271
  this raises NotImplementedError after logging the intended plan.
272
+
273
+ Note: This method is "pure" with respect to external outputs (no direct
274
+ GitHub output writes), but does perform internal environment mutations
275
+ (e.g., G2G_TMP_BRANCH) for subprocess coordination within the workflow.
249
276
  """
250
277
  log.info("Starting PR -> Gerrit pipeline")
251
278
  self._guard_pull_request_context(gh)
@@ -256,9 +283,12 @@ class Orchestrator:
256
283
 
257
284
  gitreview = self._read_gitreview(self.workspace / ".gitreview", gh)
258
285
  repo_names = self._derive_repo_names(gitreview, gh)
286
+ log.debug("execute: inputs.dry_run=%s, inputs.ci_testing=%s", inputs.dry_run, inputs.ci_testing)
259
287
  gerrit = self._resolve_gerrit_info(gitreview, inputs, repo_names)
260
288
 
289
+ log.debug("execute: resolved gerrit info: %s", gerrit)
261
290
  if inputs.dry_run:
291
+ log.debug("execute: entering dry-run mode due to inputs.dry_run=True")
262
292
  # Perform preflight validations and exit without making changes
263
293
  self._dry_run_preflight(gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names)
264
294
  log.info("Dry run complete; skipping write operations to Gerrit")
@@ -485,7 +515,16 @@ class Orchestrator:
485
515
  repo: RepoNames,
486
516
  ) -> GerritInfo:
487
517
  """Resolve Gerrit connection info from .gitreview or inputs."""
518
+ log.debug("_resolve_gerrit_info: inputs.ci_testing=%s", inputs.ci_testing)
519
+ log.debug("_resolve_gerrit_info: gitreview=%s", gitreview)
520
+
521
+ # If CI testing flag is set, ignore .gitreview and use environment
522
+ if inputs.ci_testing:
523
+ log.info("CI_TESTING enabled: ignoring .gitreview file")
524
+ gitreview = None
525
+
488
526
  if gitreview:
527
+ log.debug("Using .gitreview settings: %s", gitreview)
489
528
  return gitreview
490
529
 
491
530
  host = inputs.gerrit_server.strip()
@@ -534,34 +573,126 @@ class Orchestrator:
534
573
  log.debug("SSH private key not provided, skipping SSH setup")
535
574
  return
536
575
 
537
- # Auto-discover host keys if not provided
576
+ # Auto-discover or augment host keys (merge missing types/[host]:port entries)
538
577
  effective_known_hosts = inputs.gerrit_known_hosts
539
- if not effective_known_hosts and auto_discover_gerrit_host_keys is not None:
540
- log.info("GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery...")
578
+ if auto_discover_gerrit_host_keys is not None:
541
579
  try:
542
- discovered_keys = auto_discover_gerrit_host_keys(
543
- gerrit_hostname=gerrit.host,
544
- gerrit_port=gerrit.port,
545
- organization=inputs.organization,
546
- save_to_config=True,
547
- )
548
- if discovered_keys:
549
- effective_known_hosts = discovered_keys
550
- log.info(
551
- "Successfully auto-discovered SSH host keys for %s:%d",
552
- gerrit.host,
553
- gerrit.port,
580
+ if not effective_known_hosts:
581
+ log.info("GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery...")
582
+ discovered_keys = auto_discover_gerrit_host_keys(
583
+ gerrit_hostname=gerrit.host,
584
+ gerrit_port=gerrit.port,
585
+ organization=inputs.organization,
586
+ save_to_config=True,
554
587
  )
588
+ if discovered_keys:
589
+ effective_known_hosts = discovered_keys
590
+ log.info(
591
+ "Successfully auto-discovered SSH host keys for %s:%d",
592
+ gerrit.host,
593
+ gerrit.port,
594
+ )
595
+ else:
596
+ log.warning("Auto-discovery failed, SSH host key verification may fail")
555
597
  else:
556
- log.warning("Auto-discovery failed, SSH host key verification may fail")
598
+ # Provided known_hosts exists; ensure it contains [host]:port entries and modern key types
599
+ lower = effective_known_hosts.lower()
600
+ bracket_host = f"[{gerrit.host}]:{gerrit.port}"
601
+ bracket_lower = bracket_host.lower()
602
+ needs_discovery = False
603
+ if bracket_lower not in lower:
604
+ needs_discovery = True
605
+ else:
606
+ # Confirm at least one known key type exists for the bracketed host
607
+ if (
608
+ f"{bracket_lower} ssh-ed25519" not in lower
609
+ and f"{bracket_lower} ecdsa-sha2" not in lower
610
+ and f"{bracket_lower} ssh-rsa" not in lower
611
+ ):
612
+ needs_discovery = True
613
+ if needs_discovery:
614
+ log.info(
615
+ "Augmenting provided GERRIT_KNOWN_HOSTS with discovered entries for %s:%d",
616
+ gerrit.host,
617
+ gerrit.port,
618
+ )
619
+ discovered_keys = auto_discover_gerrit_host_keys(
620
+ gerrit_hostname=gerrit.host,
621
+ gerrit_port=gerrit.port,
622
+ organization=inputs.organization,
623
+ save_to_config=True,
624
+ )
625
+ if discovered_keys:
626
+ # Use centralized merging logic
627
+ effective_known_hosts = merge_known_hosts_content(effective_known_hosts, discovered_keys)
628
+ log.info(
629
+ "Known hosts augmented with discovered entries for %s:%d",
630
+ gerrit.host,
631
+ gerrit.port,
632
+ )
633
+ else:
634
+ log.warning("Auto-discovery returned no keys; known_hosts not augmented")
557
635
  except Exception as exc:
558
- log.warning("SSH host key auto-discovery failed: %s", exc)
636
+ log.warning("SSH host key auto-discovery/augmentation failed: %s", exc)
559
637
 
560
638
  if not effective_known_hosts:
561
639
  log.debug("No SSH host keys available (manual or auto-discovered), skipping SSH setup")
562
640
  return
563
641
 
564
- log.info("Setting up temporary SSH configuration for Gerrit")
642
+ # Check if SSH agent authentication is preferred
643
+ use_ssh_agent = env_bool("G2G_USE_SSH_AGENT", default=True)
644
+
645
+ if use_ssh_agent and setup_ssh_agent_auth is not None:
646
+ # Try SSH agent first as it's more secure and avoids file permission issues
647
+ if self._try_ssh_agent_setup(inputs, effective_known_hosts):
648
+ return
649
+
650
+ # Fall back to file-based SSH if agent setup fails
651
+ log.info("Falling back to file-based SSH authentication")
652
+
653
+ self._setup_file_based_ssh(inputs, effective_known_hosts)
654
+
655
+ def _try_ssh_agent_setup(self, inputs: Inputs, effective_known_hosts: str) -> bool:
656
+ """Try to setup SSH agent-based authentication.
657
+
658
+ Args:
659
+ inputs: Validated input configuration
660
+ effective_known_hosts: Known hosts content
661
+
662
+ Returns:
663
+ True if SSH agent setup succeeded, False otherwise
664
+ """
665
+ if setup_ssh_agent_auth is None:
666
+ log.debug("SSH agent module not available, falling back to file-based SSH") # type: ignore[unreachable]
667
+ return False
668
+
669
+ try:
670
+ log.info("Setting up SSH agent-based authentication (more secure)")
671
+ self._ssh_agent_manager = setup_ssh_agent_auth(
672
+ workspace=self.workspace,
673
+ private_key_content=inputs.gerrit_ssh_privkey_g2g,
674
+ known_hosts_content=effective_known_hosts,
675
+ )
676
+ self._use_ssh_agent = True
677
+ log.info("SSH agent authentication configured successfully")
678
+
679
+ except Exception as exc:
680
+ log.warning("SSH agent setup failed, falling back to file-based SSH: %s", exc)
681
+ if self._ssh_agent_manager:
682
+ self._ssh_agent_manager.cleanup()
683
+ self._ssh_agent_manager = None
684
+ return False
685
+ else:
686
+ return True
687
+
688
+ def _setup_file_based_ssh(self, inputs: Inputs, effective_known_hosts: str) -> None:
689
+ """Setup file-based SSH authentication as fallback.
690
+
691
+ Args:
692
+ inputs: Validated input configuration
693
+ effective_known_hosts: Known hosts content
694
+ """
695
+ log.info("Setting up file-based SSH configuration for Gerrit")
565
696
  log.debug("Using workspace-specific SSH files to avoid user changes")
566
697
 
567
698
  # Create tool-specific SSH directory in workspace to avoid touching
@@ -569,13 +700,26 @@ class Orchestrator:
569
700
  tool_ssh_dir = self.workspace / ".ssh-g2g"
570
701
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
571
702
 
572
- # Write SSH private key to tool-specific location
703
+ # Write SSH private key to tool-specific location with secure permissions
573
704
  key_path = tool_ssh_dir / "gerrit_key"
574
- with open(key_path, "w", encoding="utf-8") as f:
575
- f.write(inputs.gerrit_ssh_privkey_g2g.strip() + "\n")
576
- key_path.chmod(0o600)
577
- log.debug("SSH private key written to %s", key_path)
578
- log.debug("Key file is tool-specific and won't interfere with user SSH")
705
+
706
+ # Use a more robust approach for creating the file with secure permissions
707
+ key_content = inputs.gerrit_ssh_privkey_g2g.strip() + "\n"
708
+
709
+ # Multiple strategies to create secure key file
710
+ success = self._create_secure_key_file(key_path, key_content)
711
+
712
+ if not success:
713
+ # If all permission strategies fail, create in memory directory
714
+ success = self._create_key_in_memory_fs(key_path, key_content)
715
+
716
+ if not success:
717
+ msg = (
718
+ "Failed to create SSH key file with secure permissions. "
719
+ "This may be due to CI environment restrictions. "
720
+ "Consider using G2G_USE_SSH_AGENT=true (default) for SSH agent authentication."
721
+ )
722
+ raise RuntimeError(msg)
579
723
 
580
724
  # Write known hosts to tool-specific location
581
725
  known_hosts_path = tool_ssh_dir / "known_hosts"
@@ -589,58 +733,191 @@ class Orchestrator:
589
733
  self._ssh_key_path = key_path
590
734
  self._ssh_known_hosts_path = known_hosts_path
591
735
 
736
+ def _create_secure_key_file(self, key_path: Path, key_content: str) -> bool:
737
+ """Try multiple strategies to create a secure SSH key file.
738
+
739
+ Args:
740
+ key_path: Path where to create the key file
741
+ key_content: SSH key content
742
+
743
+ Returns:
744
+ True if successful, False otherwise
745
+ """
746
+
747
+ strategies = [
748
+ ("touch+chmod", self._strategy_touch_chmod),
749
+ ("open+fchmod", self._strategy_open_fchmod),
750
+ ("umask+open", self._strategy_umask_open),
751
+ ("stat_constants", self._strategy_stat_constants),
752
+ ]
753
+
754
+ for strategy_name, strategy_func in strategies:
755
+ try:
756
+ log.debug("Trying SSH key creation strategy: %s", strategy_name)
757
+
758
+ # Remove file if it exists to start fresh
759
+ if key_path.exists():
760
+ key_path.unlink()
761
+
762
+ # Try the strategy
763
+ strategy_func(key_path, key_content)
764
+
765
+ # Verify permissions
766
+ actual_perms = oct(key_path.stat().st_mode)[-3:]
767
+ if actual_perms == "600":
768
+ log.debug("SSH key created successfully with strategy: %s", strategy_name)
769
+ return True
770
+ else:
771
+ log.debug("Strategy %s resulted in permissions %s", strategy_name, actual_perms)
772
+
773
+ except Exception as exc:
774
+ log.debug("Strategy %s failed: %s", strategy_name, exc)
775
+ if key_path.exists():
776
+ try:
777
+ key_path.unlink()
778
+ except Exception as cleanup_exc:
779
+ log.debug("Failed to cleanup key file: %s", cleanup_exc)
780
+
781
+ return False
782
+
783
+ def _strategy_touch_chmod(self, key_path: Path, key_content: str) -> None:
784
+ """Strategy: touch with mode, then write, then chmod."""
785
+ key_path.touch(mode=0o600)
786
+ with open(key_path, "w", encoding="utf-8") as f:
787
+ f.write(key_content)
788
+ key_path.chmod(0o600)
789
+
790
+ def _strategy_open_fchmod(self, key_path: Path, key_content: str) -> None:
791
+ """Strategy: open with os.open and specific flags, then fchmod."""
792
+
793
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
794
+ mode = stat.S_IRUSR | stat.S_IWUSR # 0o600
795
+
796
+ fd = os.open(str(key_path), flags, mode)
797
+ try:
798
+ os.fchmod(fd, mode)
799
+ os.write(fd, key_content.encode("utf-8"))
800
+ finally:
801
+ os.close(fd)
802
+
803
+ def _strategy_umask_open(self, key_path: Path, key_content: str) -> None:
804
+ """Strategy: set umask, create file, restore umask."""
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
+
817
+ with open(key_path, "w", encoding="utf-8") as f:
818
+ f.write(key_content)
819
+
820
+ # Try multiple permission setting approaches
821
+ mode = stat.S_IRUSR | stat.S_IWUSR
822
+ os.chmod(str(key_path), mode)
823
+ key_path.chmod(mode)
824
+
825
+ def _create_key_in_memory_fs(self, key_path: Path, key_content: str) -> bool:
826
+ """Fallback: try to create key in memory filesystem."""
827
+ try:
828
+ # Try to create in memory filesystem if available
829
+ # Use secure temporary directories
830
+
831
+ temp_dir = tempfile.gettempdir()
832
+ memory_dirs = [temp_dir]
833
+
834
+ # Only add /dev/shm if it exists and is accessible
835
+ import os
836
+
837
+ dev_shm = Path("/dev/shm") # noqa: S108
838
+ if dev_shm.exists() and os.access("/dev/shm", os.W_OK): # noqa: S108
839
+ memory_dirs.insert(0, "/dev/shm") # noqa: S108
840
+
841
+ for memory_dir in memory_dirs:
842
+ if not Path(memory_dir).exists():
843
+ continue
844
+
845
+ tmp_path = None
846
+ try:
847
+ with tempfile.NamedTemporaryFile(
848
+ mode="w", dir=memory_dir, prefix="g2g_key_", suffix=".tmp", delete=False
849
+ ) as tmp_file:
850
+ tmp_file.write(key_content)
851
+ tmp_path = Path(tmp_file.name)
852
+
853
+ # Try to set permissions
854
+ tmp_path.chmod(0o600)
855
+ actual_perms = oct(tmp_path.stat().st_mode)[-3:]
856
+
857
+ if actual_perms == "600":
858
+ # Move to final location
859
+ shutil.move(str(tmp_path), str(key_path))
860
+ log.debug("Successfully created SSH key using memory filesystem: %s", memory_dir)
861
+ return True
862
+ else:
863
+ tmp_path.unlink()
864
+
865
+ except Exception as exc:
866
+ log.debug("Memory filesystem strategy failed for %s: %s", memory_dir, exc)
867
+ try:
868
+ if tmp_path is not None and tmp_path.exists():
869
+ tmp_path.unlink()
870
+ except Exception as cleanup_exc:
871
+ log.debug("Failed to cleanup temporary key file: %s", cleanup_exc)
872
+
873
+ except Exception as exc:
874
+ log.debug("Memory filesystem fallback failed: %s", exc)
875
+
876
+ return False
877
+
592
878
  @property
593
- def _git_ssh_command(self) -> str | None:
879
+ def _build_git_ssh_command(self) -> str | None:
594
880
  """Generate GIT_SSH_COMMAND for secure, isolated SSH configuration.
595
881
 
596
882
  This prevents SSH from scanning the user's SSH agent or using
597
883
  unintended keys by setting IdentitiesOnly=yes and specifying
598
884
  exact key and known_hosts files.
599
885
  """
886
+ if self._use_ssh_agent and self._ssh_agent_manager:
887
+ return self._ssh_agent_manager.get_git_ssh_command()
888
+
600
889
  if not self._ssh_key_path or not self._ssh_known_hosts_path:
601
890
  return None
602
891
 
603
- # Build SSH command with strict options to prevent key scanning
604
- ssh_options = [
605
- "-F /dev/null",
606
- f"-i {self._ssh_key_path}",
607
- f"-o UserKnownHostsFile={self._ssh_known_hosts_path}",
608
- "-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
609
- "-o IdentityAgent=none",
610
- "-o BatchMode=yes",
611
- "-o PreferredAuthentications=publickey",
612
- "-o StrictHostKeyChecking=yes",
613
- "-o PasswordAuthentication=no",
614
- "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
615
- "-o ConnectTimeout=10",
616
- ]
892
+ # Delegate to centralized SSH command builder
893
+ from .ssh_common import build_git_ssh_command
617
894
 
618
- ssh_cmd = f"ssh {' '.join(ssh_options)}"
619
- masked_cmd = ssh_cmd.replace(str(self._ssh_key_path), "[KEY_PATH]")
620
- log.debug("Generated SSH command: %s", masked_cmd)
621
- return ssh_cmd
895
+ return build_git_ssh_command(
896
+ key_path=self._ssh_key_path,
897
+ known_hosts_path=self._ssh_known_hosts_path,
898
+ )
622
899
 
623
900
  def _ssh_env(self) -> dict[str, str]:
624
901
  """Centralized non-interactive SSH/Git environment."""
625
- cmd = self._git_ssh_command or (
626
- "ssh -F /dev/null "
627
- "-o IdentitiesOnly=yes "
628
- "-o IdentityAgent=none "
629
- "-o BatchMode=yes "
630
- "-o PreferredAuthentications=publickey "
631
- "-o StrictHostKeyChecking=yes "
632
- "-o PasswordAuthentication=no "
633
- "-o PubkeyAcceptedKeyTypes=+ssh-rsa "
634
- "-o ConnectTimeout=10"
635
- )
636
- return {
637
- "GIT_SSH_COMMAND": cmd,
638
- "SSH_AUTH_SOCK": "",
639
- "SSH_AGENT_PID": "",
640
- "SSH_ASKPASS": "/usr/bin/false",
641
- "DISPLAY": "",
642
- "SSH_ASKPASS_REQUIRE": "never",
643
- }
902
+ from .ssh_common import build_non_interactive_ssh_env
903
+
904
+ env = build_non_interactive_ssh_env()
905
+
906
+ # Set GIT_SSH_COMMAND based on available configuration
907
+ cmd = self._build_git_ssh_command
908
+ if cmd:
909
+ env["GIT_SSH_COMMAND"] = cmd
910
+ else:
911
+ # Fallback to basic non-interactive SSH command
912
+ from .ssh_common import build_git_ssh_command
913
+
914
+ env["GIT_SSH_COMMAND"] = build_git_ssh_command()
915
+
916
+ # Override SSH agent settings if using SSH agent
917
+ if self._use_ssh_agent and self._ssh_agent_manager:
918
+ env.update(self._ssh_agent_manager.get_ssh_env())
919
+
920
+ return env
644
921
 
645
922
  def _cleanup_ssh(self) -> None:
646
923
  """Clean up temporary SSH files created by this tool.
@@ -648,10 +925,15 @@ class Orchestrator:
648
925
  Removes the workspace-specific .ssh-g2g directory and all contents.
649
926
  This ensures no temporary files are left behind.
650
927
  """
651
- if not hasattr(self, "_ssh_key_path") or not hasattr(self, "_ssh_known_hosts_path"):
652
- return
928
+ log.debug("Cleaning up temporary SSH configuration files")
653
929
 
654
930
  try:
931
+ # Clean up SSH agent if we used it
932
+ if self._ssh_agent_manager:
933
+ self._ssh_agent_manager.cleanup()
934
+ self._ssh_agent_manager = None
935
+ self._use_ssh_agent = False
936
+
655
937
  # Remove temporary SSH directory and all contents
656
938
  tool_ssh_dir = self.workspace / ".ssh-g2g"
657
939
  if tool_ssh_dir.exists():
@@ -849,7 +1131,10 @@ class Orchestrator:
849
1131
  ", ".join(uniq_ids),
850
1132
  )
851
1133
  else:
852
- log.warning("No Change-IDs generated for PR #%s", gh.pr_number)
1134
+ log.debug(
1135
+ "No Change-IDs collected during preparation for PR #%s (will be ensured via commit-msg hook)",
1136
+ gh.pr_number,
1137
+ )
853
1138
  return PreparedChange(change_ids=uniq_ids, commit_shas=[])
854
1139
 
855
1140
  def _prepare_squashed_commit(
@@ -910,10 +1195,9 @@ class Orchestrator:
910
1195
  continue
911
1196
  if not ln.startswith((" ", "-", "dependency-")) and ln.strip():
912
1197
  in_metadata_section = False
1198
+ # Skip Change-Id lines from body - they should only be in footer
913
1199
  if ln.startswith("Change-Id:"):
914
- cid = ln.split(":", 1)[1].strip()
915
- if cid:
916
- change_ids.append(cid)
1200
+ log.debug("Skipping Change-Id from commit body: %s", ln.strip())
917
1201
  continue
918
1202
  if ln.startswith("Signed-off-by:"):
919
1203
  signed_off.append(ln)
@@ -944,6 +1228,20 @@ class Orchestrator:
944
1228
  else:
945
1229
  words = title_line[:100].split()
946
1230
  title_line = " ".join(words[:-1]) if len(words) > 1 else title_line[:100].rstrip()
1231
+
1232
+ # Apply conventional commit normalization if enabled
1233
+ if inputs.normalise_commit and gh.pr_number:
1234
+ try:
1235
+ # Get PR author for normalization context
1236
+ client = build_client()
1237
+ repo = get_repo_from_env(client)
1238
+ pr_obj = get_pull(repo, int(gh.pr_number))
1239
+ author = getattr(pr_obj, "user", {})
1240
+ author_login = getattr(author, "login", "") if author else ""
1241
+ title_line = normalize_commit_title(title_line, author_login, self.workspace)
1242
+ except Exception as e:
1243
+ log.debug("Failed to apply commit normalization in squash mode: %s", e)
1244
+
947
1245
  return title_line
948
1246
 
949
1247
  def _build_clean_message_lines(message_lines: list[str]) -> list[str]:
@@ -996,10 +1294,16 @@ class Orchestrator:
996
1294
  ) -> str:
997
1295
  msg = "\n".join(lines_in).strip()
998
1296
  msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
1297
+
1298
+ # Build footer with proper trailer ordering
1299
+ footer_parts = []
999
1300
  if signed_off:
1000
- msg += "\n\n" + "\n".join(signed_off)
1301
+ footer_parts.extend(signed_off)
1001
1302
  if reuse_cid:
1002
- msg += f"\n\nChange-Id: {reuse_cid}"
1303
+ footer_parts.append(f"Change-Id: {reuse_cid}")
1304
+
1305
+ if footer_parts:
1306
+ msg += "\n\n" + "\n".join(footer_parts)
1003
1307
  return msg
1004
1308
 
1005
1309
  # Build message parts
@@ -1038,7 +1342,19 @@ class Orchestrator:
1038
1342
  ", ".join(cids),
1039
1343
  )
1040
1344
  else:
1041
- log.warning("No Change-ID generated for PR #%s", gh.pr_number)
1345
+ # Fallback detection: re-scan commit message for Change-Id trailers
1346
+ msg_after = run_cmd(["git", "show", "-s", "--pretty=format:%B", "HEAD"], cwd=self.workspace).stdout
1347
+
1348
+ found = [m.strip() for m in re.findall(r"(?mi)^Change-Id:\s*([A-Za-z0-9._-]+)\s*$", msg_after)]
1349
+ if found:
1350
+ log.info(
1351
+ "Detected Change-ID(s) after amend for PR #%s: %s",
1352
+ gh.pr_number,
1353
+ ", ".join(found),
1354
+ )
1355
+ cids = found
1356
+ else:
1357
+ log.warning("No Change-Id detected for PR #%s", gh.pr_number)
1042
1358
  return PreparedChange(change_ids=cids, commit_shas=[])
1043
1359
 
1044
1360
  def _apply_pr_title_body_if_requested(
@@ -1062,6 +1378,11 @@ class Orchestrator:
1062
1378
  title = (title or "").strip()
1063
1379
  body = (body or "").strip()
1064
1380
 
1381
+ # Filter PR body content for Dependabot and other automated PRs
1382
+ author = getattr(pr_obj, "user", {})
1383
+ author_login = getattr(author, "login", "") if author else ""
1384
+ body = filter_pr_body(title, body, author_login)
1385
+
1065
1386
  # Clean up title to ensure it's a proper first line for commit message
1066
1387
  if title:
1067
1388
  # Remove markdown links like [text](url) and keep just the text
@@ -1078,6 +1399,10 @@ class Orchestrator:
1078
1399
  title = re.sub(r"[*_`]", "", title)
1079
1400
  title = title.strip()
1080
1401
 
1402
+ # Apply conventional commit normalization if enabled
1403
+ if inputs.normalise_commit:
1404
+ title = normalize_commit_title(title, author_login, self.workspace)
1405
+
1081
1406
  # Compose message; preserve existing trailers at footer
1082
1407
  # (Signed-off-by, Change-Id)
1083
1408
  current_body = git_show("HEAD", fmt="%B", cwd=self.workspace)
@@ -1108,7 +1433,11 @@ class Orchestrator:
1108
1433
  # Build trailers: Signed-off-by first, Change-Id last.
1109
1434
  trailers_out: list[str] = []
1110
1435
  if signed_lines:
1111
- trailers_out.extend(signed_lines)
1436
+ seen_so: set[str] = set()
1437
+ for ln in signed_lines:
1438
+ if ln not in seen_so:
1439
+ trailers_out.append(ln)
1440
+ seen_so.add(ln)
1112
1441
  if change_id_lines:
1113
1442
  trailers_out.append(change_id_lines[-1])
1114
1443
  if trailers_out:
@@ -1151,6 +1480,10 @@ class Orchestrator:
1151
1480
  prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1152
1481
  pr_num = os.getenv("PR_NUMBER", "").strip()
1153
1482
  topic = f"{prefix}-{repo.project_github}-{pr_num}" if pr_num else f"{prefix}-{repo.project_github}"
1483
+
1484
+ # Use our specific SSH configuration
1485
+ env = self._ssh_env()
1486
+
1154
1487
  try:
1155
1488
  args = [
1156
1489
  "git",
@@ -1160,21 +1493,29 @@ class Orchestrator:
1160
1493
  "-t",
1161
1494
  topic,
1162
1495
  ]
1163
- revs = [r.strip() for r in (reviewers or "").split(",") if r.strip()]
1496
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
1164
1497
  for r in revs:
1165
1498
  args.extend(["--reviewer", r])
1166
1499
  # Branch as positional argument (not a flag)
1167
1500
  args.append(branch)
1168
1501
 
1169
- # Use our specific SSH configuration
1170
- env = self._ssh_env()
1502
+ if env_bool("CI_TESTING", False):
1503
+ log.info("CI_TESTING enabled: using synthetic orphan commit push path")
1504
+ self._create_orphan_commit_and_push(gerrit, repo, branch, reviewers, topic, env)
1505
+ return
1171
1506
  log.debug("Executing git review command: %s", " ".join(args))
1172
1507
  run_cmd(args, cwd=self.workspace, env=env)
1173
1508
  log.info("Successfully pushed changes to Gerrit")
1174
1509
  except CommandError as exc:
1510
+ # Check if this is a "no common ancestry" error in CI_TESTING mode
1511
+ if self._should_handle_unrelated_history(exc):
1512
+ log.info("Detected unrelated repository history. Creating orphan commit for CI testing...")
1513
+ self._create_orphan_commit_and_push(gerrit, repo, branch, reviewers, topic, env)
1514
+ return
1515
+
1175
1516
  # Analyze the specific failure reason from git review output
1176
1517
  error_details = self._analyze_gerrit_push_failure(exc)
1177
- _log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1518
+ log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1178
1519
  msg = f"Failed to push changes to Gerrit with git-review: {error_details}"
1179
1520
  raise OrchestratorError(msg) from exc
1180
1521
  # Cleanup temporary branch used during preparation
@@ -1194,6 +1535,113 @@ class Orchestrator:
1194
1535
  env=env,
1195
1536
  )
1196
1537
 
1538
+ def _should_handle_unrelated_history(self, exc: CommandError) -> bool:
1539
+ """Check if we should handle unrelated repository history in CI testing mode."""
1540
+ if not env_bool("CI_TESTING", False):
1541
+ return False
1542
+
1543
+ stdout = exc.stdout or ""
1544
+ stderr = exc.stderr or ""
1545
+ combined_output = f"{stdout}\n{stderr}"
1546
+
1547
+ combined_lower = combined_output.lower()
1548
+ phrases = (
1549
+ "no common ancestry",
1550
+ "no common ancestor",
1551
+ "do not have a common ancestor",
1552
+ "have no common ancestor",
1553
+ "have no commits in common",
1554
+ "refusing to merge unrelated histories",
1555
+ "unrelated histories",
1556
+ "unrelated history",
1557
+ "no merge base",
1558
+ )
1559
+ return any(p in combined_lower for p in phrases)
1560
+
1561
+ def _create_orphan_commit_and_push(
1562
+ self, gerrit: GerritInfo, repo: RepoNames, branch: str, reviewers: str, topic: str, env: dict[str, str]
1563
+ ) -> None:
1564
+ """Create a synthetic commit on top of the remote base with the PR tree (CI testing mode)."""
1565
+ log.info("CI_TESTING: Creating synthetic commit on top of remote base for unrelated repository")
1566
+
1567
+ try:
1568
+ # Capture the current PR commit message and tree
1569
+ commit_msg = run_cmd(["git", "log", "--format=%B", "-n", "1", "HEAD"], cwd=self.workspace).stdout.strip()
1570
+ pr_tree = run_cmd(["git", "show", "--quiet", "--format=%T", "HEAD"], cwd=self.workspace).stdout.strip()
1571
+
1572
+ # Create/update a synthetic branch based on the remote base branch
1573
+ synth_branch = f"synth-{topic}"
1574
+ # Ensure remote ref exists locally (best-effort)
1575
+ run_cmd(["git", "fetch", "gerrit", branch], cwd=self.workspace, env=env, check=False)
1576
+ run_cmd(["git", "checkout", "-B", synth_branch, f"remotes/gerrit/{branch}"], cwd=self.workspace, env=env)
1577
+
1578
+ # Replace working tree contents with the PR tree
1579
+ # 1) Remove current tracked files (ignore errors if none)
1580
+ run_cmd(["git", "rm", "-r", "--quiet", "."], cwd=self.workspace, env=env, check=False)
1581
+ # 2) Clean untracked files/dirs (preserve our SSH known_hosts dir)
1582
+ run_cmd(
1583
+ ["git", "clean", "-fdx", "-e", ".ssh-g2g", "-e", ".ssh-g2g/**"],
1584
+ cwd=self.workspace,
1585
+ env=env,
1586
+ check=False,
1587
+ )
1588
+ # 3) Checkout the PR tree into working directory
1589
+ run_cmd(["git", "checkout", pr_tree, "--", "."], cwd=self.workspace, env=env)
1590
+ run_cmd(["git", "add", "-A"], cwd=self.workspace, env=env)
1591
+
1592
+ # Commit synthetic change with the same message (should already include Change-Id)
1593
+ import tempfile as _tempfile
1594
+ from pathlib import Path as _Path
1595
+
1596
+ with _tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8") as _tf:
1597
+ # Ensure Signed-off-by for current committer (uploader) is present in the footer
1598
+ try:
1599
+ committer_name = run_cmd(
1600
+ ["git", "config", "--get", "user.name"],
1601
+ cwd=self.workspace,
1602
+ ).stdout.strip()
1603
+ except Exception:
1604
+ committer_name = ""
1605
+ try:
1606
+ committer_email = run_cmd(
1607
+ ["git", "config", "--get", "user.email"],
1608
+ cwd=self.workspace,
1609
+ ).stdout.strip()
1610
+ except Exception:
1611
+ committer_email = ""
1612
+ msg_to_write = commit_msg
1613
+ if committer_name and committer_email:
1614
+ sob_line = f"Signed-off-by: {committer_name} <{committer_email}>"
1615
+ if sob_line not in msg_to_write:
1616
+ if not msg_to_write.endswith("\n"):
1617
+ msg_to_write += "\n"
1618
+ if not msg_to_write.endswith("\n\n"):
1619
+ msg_to_write += "\n"
1620
+ msg_to_write += sob_line
1621
+ _tf.write(msg_to_write)
1622
+ _tf.flush()
1623
+ _tmp_msg_path = _Path(_tf.name)
1624
+ try:
1625
+ run_cmd(["git", "commit", "-F", str(_tmp_msg_path)], cwd=self.workspace, env=env)
1626
+ finally:
1627
+ from contextlib import suppress
1628
+
1629
+ with suppress(Exception):
1630
+ _tmp_msg_path.unlink(missing_ok=True)
1631
+
1632
+ # Push directly to refs/for/<branch> with topic and reviewers to avoid rebase behavior
1633
+ push_ref = f"refs/for/{branch}%topic={topic}"
1634
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
1635
+ for r in revs:
1636
+ push_ref += f",r={r}"
1637
+ run_cmd(["git", "push", "--no-follow-tags", "gerrit", f"HEAD:{push_ref}"], cwd=self.workspace, env=env)
1638
+ log.info("Successfully pushed synthetic commit to Gerrit")
1639
+
1640
+ except CommandError as orphan_exc:
1641
+ error_details = self._analyze_gerrit_push_failure(orphan_exc)
1642
+ msg = f"Failed to push orphan commit to Gerrit: {error_details}"
1643
+ raise OrchestratorError(msg) from orphan_exc
1644
+
1197
1645
  def _analyze_gerrit_push_failure(self, exc: CommandError) -> str:
1198
1646
  """Analyze git review failure and provide helpful error message."""
1199
1647
  stdout = exc.stdout or ""
@@ -1278,7 +1726,9 @@ class Orchestrator:
1278
1726
  """Query Gerrit for change URL/number and patchset sha via REST."""
1279
1727
  log.info("Querying Gerrit for submitted change(s) via REST")
1280
1728
 
1281
- # Create centralized URL builder
1729
+ # pygerrit2 netrc filter is already applied in execute() unless verbose mode
1730
+
1731
+ # Create centralized URL builder (auto-discovers base path)
1282
1732
  url_builder = create_gerrit_url_builder(gerrit.host)
1283
1733
 
1284
1734
  # Get authentication credentials
@@ -1294,8 +1744,12 @@ class Orchestrator:
1294
1744
  if http_user and http_pass:
1295
1745
  if HTTPBasicAuth is None:
1296
1746
  raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1747
+ if GerritRestAPI is None:
1748
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1297
1749
  return GerritRestAPI(url=base_url, auth=HTTPBasicAuth(http_user, http_pass))
1298
1750
  else:
1751
+ if GerritRestAPI is None:
1752
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1299
1753
  return GerritRestAPI(url=base_url)
1300
1754
 
1301
1755
  # Try API URLs in order of preference (client creation happens in retry loop)
@@ -1309,29 +1763,23 @@ class Orchestrator:
1309
1763
  # include current revision
1310
1764
  query = f"limit:1 is:open project:{repo.project_gerrit} {cid}"
1311
1765
  path = f"/changes/?q={query}&o=CURRENT_REVISION&n=1"
1312
- # Try API URLs in order of preference (handles fallbacks automatically)
1313
- api_candidates = url_builder.get_api_url_candidates(path)
1314
- changes = None
1315
- last_error = None
1316
-
1317
- for api_url in api_candidates:
1318
- try:
1319
- # Create a new client for each URL attempt
1320
- current_rest = _create_rest_client(api_url.rsplit(path, 1)[0])
1321
- changes = current_rest.get(path)
1322
- break # Success, exit the retry loop
1323
- except Exception as exc:
1324
- last_error = exc
1325
- log.debug("Failed API attempt for %s at %s: %s", cid, api_url, exc)
1326
- continue
1766
+ # Build single API base URL via centralized discovery
1767
+ api_base_url = url_builder.api_url()
1768
+ # Build Gerrit REST client with retry/timeout
1769
+ from .gerrit_rest import build_client_for_host
1327
1770
 
1328
- if changes is None:
1329
- log.warning(
1330
- "Failed to query change via REST for %s (tried %d URLs): %s",
1331
- cid,
1332
- len(api_candidates),
1333
- last_error,
1334
- )
1771
+ client = build_client_for_host(
1772
+ gerrit.host,
1773
+ timeout=8.0,
1774
+ max_attempts=5,
1775
+ http_user=http_user or None,
1776
+ http_password=http_pass or None,
1777
+ )
1778
+ try:
1779
+ log.debug("Gerrit API base URL (discovered): %s", api_base_url)
1780
+ changes = client.get(path)
1781
+ except Exception as exc:
1782
+ log.warning("Failed to query change via REST for %s: %s", cid, exc)
1335
1783
  continue
1336
1784
  if not changes:
1337
1785
  continue
@@ -1350,21 +1798,19 @@ class Orchestrator:
1350
1798
  nums.append(num)
1351
1799
  if current_rev:
1352
1800
  shas.append(current_rev)
1353
- # Export env variables (compat)
1354
- if urls:
1355
- os.environ["GERRIT_CHANGE_REQUEST_URL"] = "\n".join(urls)
1356
- if nums:
1357
- os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(nums)
1358
- if shas:
1359
- os.environ["GERRIT_COMMIT_SHA"] = "\n".join(shas)
1801
+
1360
1802
  return SubmissionResult(change_urls=urls, change_numbers=nums, commit_shas=shas)
1361
1803
 
1362
1804
  def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1363
1805
  """Initialize and set up git workspace for PR processing."""
1364
1806
  from .gitutils import run_cmd
1365
1807
 
1366
- # Initialize git repository
1367
- run_cmd(["git", "init"], cwd=self.workspace)
1808
+ # Try modern git init with explicit branch first
1809
+ try:
1810
+ run_cmd(["git", "init", "--initial-branch=master"], cwd=self.workspace)
1811
+ except Exception:
1812
+ # Fallback for older git versions (hint filtered at logging level)
1813
+ run_cmd(["git", "init"], cwd=self.workspace)
1368
1814
 
1369
1815
  # Add GitHub remote
1370
1816
  repo_full = gh.repository.strip() if gh.repository else ""
@@ -1398,64 +1844,69 @@ class Orchestrator:
1398
1844
 
1399
1845
  def _install_commit_msg_hook(self, gerrit: GerritInfo) -> None:
1400
1846
  """Manually install commit-msg hook from Gerrit."""
1401
- from .gitutils import run_cmd
1847
+ from .external_api import curl_download
1402
1848
 
1403
1849
  hooks_dir = self.workspace / ".git" / "hooks"
1404
1850
  hooks_dir.mkdir(exist_ok=True)
1405
1851
  hook_path = hooks_dir / "commit-msg"
1406
1852
 
1407
- # Download commit-msg hook using SSH
1853
+ # Download commit-msg hook using centralized curl framework
1408
1854
  try:
1409
- # Use curl to download the hook (more reliable than scp)
1410
1855
  # Create centralized URL builder for hook URLs
1411
1856
  url_builder = create_gerrit_url_builder(gerrit.host)
1412
- candidates = url_builder.get_hook_url_candidates("commit-msg")
1413
-
1414
- last_error = ""
1415
- installed = False
1416
- for candidate_url in candidates:
1417
- try:
1418
- curl_cmd = [
1419
- "curl",
1420
- "-fL",
1421
- "-o",
1422
- str(hook_path),
1423
- candidate_url,
1424
- ]
1425
- run_cmd(curl_cmd, cwd=self.workspace)
1426
- # Make hook executable
1427
- hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1428
- log.debug(
1429
- "Successfully installed commit-msg hook from %s",
1430
- candidate_url,
1431
- )
1432
- installed = True
1433
- break
1434
- except Exception as exc2:
1435
- last_error = f"{candidate_url}: {exc2}"
1436
- log.debug(
1437
- "Failed to fetch commit-msg hook from %s: %s",
1438
- candidate_url,
1439
- exc2,
1440
- )
1441
-
1442
- def _raise_install_error() -> None:
1443
- raise RuntimeError("Hook install failed") # noqa: TRY301, TRY003
1857
+ hook_url = url_builder.hook_url("commit-msg")
1858
+
1859
+ # Localized error raiser and short messages to satisfy TRY rules
1860
+ def _raise_orch(msg: str) -> None:
1861
+ raise OrchestratorError(msg) # noqa: TRY301
1862
+
1863
+ _MSG_HOOK_SIZE_BOUNDS = "commit-msg hook size outside expected bounds"
1864
+ _MSG_HOOK_READ_FAILED = "failed reading commit-msg hook"
1865
+ _MSG_HOOK_NO_SHEBANG = "commit-msg hook missing shebang"
1866
+ _MSG_HOOK_BAD_CONTENT = "commit-msg hook content lacks expected markers"
1867
+
1868
+ # Use centralized curl download with retry/logging/metrics
1869
+ return_code, status_code = curl_download(
1870
+ url=hook_url,
1871
+ output_path=str(hook_path),
1872
+ timeout=30.0,
1873
+ follow_redirects=True,
1874
+ silent=True,
1875
+ )
1444
1876
 
1445
- if not installed:
1446
- # Log detailed reason separately to satisfy linting rules
1447
- log.error(
1448
- "All commit-msg hook URLs failed. Last error: %s",
1449
- last_error,
1450
- )
1451
- _raise_install_error()
1877
+ size = hook_path.stat().st_size
1878
+ log.debug(
1879
+ "curl fetch of commit-msg: url=%s http_status=%s size=%dB rc=%s",
1880
+ hook_url,
1881
+ status_code,
1882
+ size,
1883
+ return_code,
1884
+ )
1885
+ # Sanity checks on size
1886
+ if size < 128 or size > 65536:
1887
+ _raise_orch(_MSG_HOOK_SIZE_BOUNDS)
1452
1888
 
1453
- # Make hook executable
1889
+ # Validate content characteristics
1890
+ text_head = ""
1891
+ try:
1892
+ with open(hook_path, "rb") as fh:
1893
+ head = fh.read(2048)
1894
+ text_head = head.decode("utf-8", errors="ignore")
1895
+ except Exception:
1896
+ _raise_orch(_MSG_HOOK_READ_FAILED)
1897
+
1898
+ if not text_head.startswith("#!"):
1899
+ _raise_orch(_MSG_HOOK_NO_SHEBANG)
1900
+ # Look for recognizable strings
1901
+ if not any(m in text_head for m in ("Change-Id", "Gerrit Code Review", "add_change_id")):
1902
+ _raise_orch(_MSG_HOOK_BAD_CONTENT)
1903
+
1904
+ # Make hook executable (single chmod)
1454
1905
  hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1455
- log.debug("Successfully installed commit-msg hook via curl")
1906
+ log.debug("Successfully installed commit-msg hook from %s", hook_url)
1456
1907
 
1457
1908
  except Exception as exc:
1458
- log.warning("Failed to install commit-msg hook via curl: %s", exc)
1909
+ log.warning("Failed to install commit-msg hook via centralized curl: %s", exc)
1459
1910
  msg = f"Could not install commit-msg hook: {exc}"
1460
1911
  raise OrchestratorError(msg) from exc
1461
1912
 
@@ -1465,51 +1916,163 @@ class Orchestrator:
1465
1916
  Installs the commit-msg hook and amends the commit if needed.
1466
1917
  """
1467
1918
  trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1468
- if not trailers.get("Change-Id"):
1469
- log.debug("No Change-Id found; attempting to install commit-msg hook and amend commit")
1470
- try:
1471
- self._install_commit_msg_hook(gerrit)
1472
- git_commit_amend(
1473
- no_edit=True,
1474
- signoff=True,
1475
- author=author,
1476
- cwd=self.workspace,
1477
- )
1478
- except Exception as exc:
1479
- log.warning(
1480
- "Commit-msg hook installation failed, falling back to direct Change-Id injection: %s",
1481
- exc,
1482
- )
1483
- # Fallback: generate a Change-Id and append to the commit
1484
- # message directly
1485
- import time
1919
+ existing_change_ids = trailers.get("Change-Id", [])
1486
1920
 
1487
- current_msg = run_cmd(
1488
- ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1489
- cwd=self.workspace,
1490
- ).stdout
1491
- seed = f"{current_msg}\n{time.time()}"
1492
- import hashlib as _hashlib # local alias to satisfy linters
1493
-
1494
- change_id = "I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
1495
- if "Change-Id:" not in current_msg:
1496
- new_msg = current_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
1497
- git_commit_amend(
1498
- no_edit=False,
1499
- signoff=True,
1500
- author=author,
1501
- message=new_msg,
1502
- cwd=self.workspace,
1503
- )
1504
- # Debug: Check commit message after amend
1505
- actual_msg = run_cmd(
1921
+ if existing_change_ids:
1922
+ log.debug("Found existing Change-Id(s) in footer: %s", existing_change_ids)
1923
+ # Clean up any duplicate Change-IDs in the message body
1924
+ self._clean_change_ids_from_body(author)
1925
+ return [c for c in existing_change_ids if c]
1926
+
1927
+ log.debug("No Change-Id found; attempting to install commit-msg hook and amend commit")
1928
+ try:
1929
+ self._install_commit_msg_hook(gerrit)
1930
+ git_commit_amend(
1931
+ no_edit=True,
1932
+ signoff=True,
1933
+ author=author,
1934
+ cwd=self.workspace,
1935
+ )
1936
+ except Exception as exc:
1937
+ log.warning(
1938
+ "Commit-msg hook installation failed, falling back to direct Change-Id injection: %s",
1939
+ exc,
1940
+ )
1941
+ # Fallback: generate a Change-Id and append to the commit
1942
+ # message directly
1943
+ import time
1944
+
1945
+ current_msg = run_cmd(
1506
1946
  ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1507
1947
  cwd=self.workspace,
1508
- ).stdout.strip()
1509
- log.debug("Commit message after amend:\n%s", actual_msg)
1510
- trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1948
+ ).stdout
1949
+ seed = f"{current_msg}\n{time.time()}"
1950
+ import hashlib as _hashlib # local alias to satisfy linters
1951
+
1952
+ change_id = "I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
1953
+
1954
+ # Clean message and ensure proper footer placement
1955
+ cleaned_msg = self._clean_commit_message_for_change_id(current_msg)
1956
+ new_msg = cleaned_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
1957
+ git_commit_amend(
1958
+ no_edit=False,
1959
+ signoff=True,
1960
+ author=author,
1961
+ message=new_msg,
1962
+ cwd=self.workspace,
1963
+ )
1964
+ # Debug: Check commit message after amend
1965
+ actual_msg = run_cmd(
1966
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1967
+ cwd=self.workspace,
1968
+ ).stdout.strip()
1969
+ log.debug("Commit message after amend:\n%s", actual_msg)
1970
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1511
1971
  return [c for c in trailers.get("Change-Id", []) if c]
1512
1972
 
1973
+ def _clean_change_ids_from_body(self, author: str) -> None:
1974
+ """Remove any Change-Id lines from the commit message body, keeping only footer trailers."""
1975
+ current_msg = run_cmd(
1976
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1977
+ cwd=self.workspace,
1978
+ ).stdout
1979
+
1980
+ cleaned_msg = self._clean_commit_message_for_change_id(current_msg)
1981
+
1982
+ if cleaned_msg != current_msg:
1983
+ log.debug("Cleaned Change-Id lines from commit message body")
1984
+ git_commit_amend(
1985
+ no_edit=False,
1986
+ signoff=True,
1987
+ author=author,
1988
+ message=cleaned_msg,
1989
+ cwd=self.workspace,
1990
+ )
1991
+
1992
+ def _clean_commit_message_for_change_id(self, message: str) -> str:
1993
+ """Remove Change-Id lines from message body while preserving footer trailers."""
1994
+ lines = message.splitlines()
1995
+
1996
+ # Parse proper trailers using the fixed trailer parser
1997
+ trailers = _parse_trailers(message)
1998
+ change_id_trailers = trailers.get("Change-Id", [])
1999
+ signed_off_trailers = trailers.get("Signed-off-by", [])
2000
+ other_trailers = {k: v for k, v in trailers.items() if k not in ["Change-Id", "Signed-off-by"]}
2001
+
2002
+ # Find trailer section by working backwards to find continuous trailer block
2003
+ trailer_start = len(lines)
2004
+
2005
+ # Work backwards to find where trailers start
2006
+ for i in range(len(lines) - 1, -1, -1):
2007
+ line = lines[i].strip()
2008
+ if not line:
2009
+ # Found blank line - trailers are after this
2010
+ trailer_start = i + 1
2011
+ break
2012
+ elif ":" not in line:
2013
+ # Non-trailer line - trailers start after this
2014
+ trailer_start = i + 1
2015
+ break
2016
+ else:
2017
+ # Potential trailer line - check if it's a valid trailer
2018
+ key, val = line.split(":", 1)
2019
+ k = key.strip()
2020
+ v = val.strip()
2021
+ if not (k and v and not k.startswith(" ") and not k.startswith("\t")):
2022
+ # Invalid trailer format - trailers start after this
2023
+ trailer_start = i + 1
2024
+ break
2025
+
2026
+ # Process body lines (before trailers) and remove any Change-Id references
2027
+ body_lines = []
2028
+ for i in range(trailer_start):
2029
+ line = lines[i]
2030
+ # Remove any Change-Id references from body lines
2031
+ if "Change-Id:" in line:
2032
+ # If line starts with Change-Id:, skip it entirely
2033
+ if line.strip().startswith("Change-Id:"):
2034
+ log.debug("Removing Change-Id line from body: %s", line.strip())
2035
+ continue
2036
+ else:
2037
+ # If Change-Id is mentioned within the line, remove that part
2038
+ original_line = line
2039
+ # Remove Change-Id: followed by the ID value
2040
+
2041
+ # Pattern to match "Change-Id: <value>" where value can contain word chars, hyphens, etc.
2042
+ line = re.sub(r"Change-Id:\s*[A-Za-z0-9._-]+\b", "", line)
2043
+ # Clean up extra whitespace
2044
+ line = re.sub(r"\s+", " ", line).strip()
2045
+ if line != original_line:
2046
+ log.debug("Cleaned Change-Id reference from body line: %s -> %s", original_line.strip(), line)
2047
+ body_lines.append(line)
2048
+
2049
+ # Remove trailing empty lines from body
2050
+ while body_lines and not body_lines[-1].strip():
2051
+ body_lines.pop()
2052
+
2053
+ result = "\n".join(body_lines)
2054
+
2055
+ # Add proper footer trailers if any exist
2056
+ footer_parts = []
2057
+ if signed_off_trailers:
2058
+ _seen_so: set[str] = set()
2059
+ _uniq_so: list[str] = []
2060
+ for s in signed_off_trailers:
2061
+ if s not in _seen_so:
2062
+ _uniq_so.append(s)
2063
+ _seen_so.add(s)
2064
+ footer_parts.extend([f"Signed-off-by: {s}" for s in _uniq_so])
2065
+ # Add other trailers
2066
+ for key, values in other_trailers.items():
2067
+ footer_parts.extend([f"{key}: {v}" for v in values])
2068
+ if change_id_trailers:
2069
+ footer_parts.extend([f"Change-Id: {c}" for c in change_id_trailers])
2070
+
2071
+ if footer_parts:
2072
+ result += "\n\n" + "\n".join(footer_parts)
2073
+
2074
+ return result
2075
+
1513
2076
  def _add_backref_comment_in_gerrit(
1514
2077
  self,
1515
2078
  *,
@@ -1545,10 +2108,9 @@ class Orchestrator:
1545
2108
  continue
1546
2109
  try:
1547
2110
  log.debug("Executing SSH command for commit %s", csha)
1548
- # Build SSH command. If isolated SSH key/known_hosts are
1549
- # available, use strict options; otherwise fall back to the
1550
- # minimal form expected by tests.
2111
+ # Build SSH command based on available authentication method
1551
2112
  if self._ssh_key_path and self._ssh_known_hosts_path:
2113
+ # File-based SSH authentication
1552
2114
  ssh_cmd = [
1553
2115
  "ssh",
1554
2116
  "-F",
@@ -1583,9 +2145,44 @@ class Orchestrator:
1583
2145
  f"{shlex.quote(csha)}"
1584
2146
  ),
1585
2147
  ]
2148
+ elif self._use_ssh_agent and self._ssh_agent_manager and self._ssh_agent_manager.known_hosts_path:
2149
+ # SSH agent authentication with known_hosts
2150
+ ssh_cmd = [
2151
+ "ssh",
2152
+ "-F",
2153
+ "/dev/null",
2154
+ "-o",
2155
+ f"UserKnownHostsFile={self._ssh_agent_manager.known_hosts_path}",
2156
+ "-o",
2157
+ "IdentitiesOnly=no",
2158
+ "-o",
2159
+ "BatchMode=yes",
2160
+ "-o",
2161
+ "PreferredAuthentications=publickey",
2162
+ "-o",
2163
+ "StrictHostKeyChecking=yes",
2164
+ "-o",
2165
+ "PasswordAuthentication=no",
2166
+ "-o",
2167
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
2168
+ "-o",
2169
+ "ConnectTimeout=10",
2170
+ "-n",
2171
+ "-p",
2172
+ str(gerrit.port),
2173
+ f"{user}@{server}",
2174
+ (
2175
+ "gerrit review -m "
2176
+ f"{shlex.quote(message)} "
2177
+ "--branch "
2178
+ f"{shlex.quote(branch)} "
2179
+ "--project "
2180
+ f"{shlex.quote(repo.project_gerrit)} "
2181
+ f"{shlex.quote(csha)}"
2182
+ ),
2183
+ ]
1586
2184
  else:
1587
- # Strict non-interactive SSH without
1588
- # isolated key/known_hosts
2185
+ # Fallback - minimal SSH command (for tests)
1589
2186
  ssh_cmd = [
1590
2187
  "ssh",
1591
2188
  "-F",
@@ -1660,6 +2257,10 @@ class Orchestrator:
1660
2257
  result: SubmissionResult,
1661
2258
  ) -> None:
1662
2259
  """Post a comment on the PR with the Gerrit change URL(s)."""
2260
+ # Respect CI_TESTING: do not attempt to update the source/origin PR
2261
+ if os.getenv("CI_TESTING", "").strip().lower() in ("1", "true", "yes"):
2262
+ log.debug("Source/origin pull request will NOT be updated with Gerrit change when CI_TESTING set true")
2263
+ return
1663
2264
  log.info("Adding reference comment on PR #%s", gh.pr_number)
1664
2265
  if not gh.pr_number:
1665
2266
  return
@@ -1736,7 +2337,6 @@ class Orchestrator:
1736
2337
  - Verify GitHub token by fetching repository and PR metadata
1737
2338
  - Do NOT perform any write operations
1738
2339
  """
1739
- import socket
1740
2340
 
1741
2341
  log.info("Dry-run: starting preflight checks")
1742
2342
  if os.getenv("G2G_DRYRUN_DISABLE_NETWORK", "").strip().lower() in (
@@ -1831,7 +2431,11 @@ class Orchestrator:
1831
2431
  http_user: str,
1832
2432
  http_pass: str,
1833
2433
  ) -> None:
1834
- """Probe Gerrit REST endpoint with optional auth and '/r' fallback."""
2434
+ """Probe Gerrit REST endpoint with optional auth.
2435
+
2436
+ Uses the centralized URL builder to construct the API endpoint
2437
+ for consistent URL handling across the application.
2438
+ """
1835
2439
 
1836
2440
  def _build_client(url: str) -> Any:
1837
2441
  if http_user and http_pass:
@@ -1859,51 +2463,17 @@ class Orchestrator:
1859
2463
 
1860
2464
  # Create centralized URL builder for REST probing
1861
2465
  url_builder = create_gerrit_url_builder(host, base_path)
1862
- api_candidates = url_builder.get_api_url_candidates()
1863
-
1864
- # Try API URLs in order of preference
1865
- probe_successful = False
1866
- last_error = None
2466
+ api_url = url_builder.api_url()
1867
2467
 
1868
- for api_url in api_candidates:
1869
- try:
1870
- _probe(api_url)
1871
- probe_successful = True
1872
- break
1873
- except Exception as exc:
1874
- last_error = exc
1875
- log.debug("Gerrit REST probe failed for %s: %s", api_url, exc)
1876
- continue
1877
-
1878
- if not probe_successful and last_error:
1879
- log.warning(
1880
- "Gerrit REST probe did not succeed (tried %d URLs): %s",
1881
- len(api_candidates),
1882
- last_error,
1883
- )
2468
+ try:
2469
+ _probe(api_url)
2470
+ except Exception as exc:
2471
+ log.warning("Gerrit REST probe failed for %s: %s", api_url, exc)
1884
2472
 
1885
2473
  # ---------------
1886
2474
  # Helpers
1887
2475
  # ---------------
1888
2476
 
1889
- def _append_github_output(self, outputs: dict[str, str]) -> None:
1890
- gh_out = os.getenv("GITHUB_OUTPUT")
1891
- if not gh_out:
1892
- return
1893
- try:
1894
- with open(gh_out, "a", encoding="utf-8") as fh:
1895
- for key, val in outputs.items():
1896
- if not val:
1897
- continue
1898
- if "\n" in val and os.getenv("GITHUB_ACTIONS") == "true":
1899
- fh.write(f"{key}<<G2G\n")
1900
- fh.write(f"{val}\n")
1901
- fh.write("G2G\n")
1902
- else:
1903
- fh.write(f"{key}={val}\n")
1904
- except Exception as exc:
1905
- log.debug("Failed to write GITHUB_OUTPUT: %s", exc)
1906
-
1907
2477
  def _resolve_target_branch(self) -> str:
1908
2478
  # Preference order:
1909
2479
  # 1) GERRIT_BRANCH (explicit override)