github2gerrit 0.1.6__py3-none-any.whl → 0.1.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github2gerrit/core.py CHANGED
@@ -39,6 +39,7 @@ from dataclasses import dataclass
39
39
  from pathlib import Path
40
40
  from typing import Any
41
41
 
42
+ from .commit_normalization import normalize_commit_title
42
43
  from .gerrit_urls import create_gerrit_url_builder
43
44
  from .github_api import build_client
44
45
  from .github_api import close_pr
@@ -50,6 +51,7 @@ from .github_api import get_repo_from_env
50
51
  from .github_api import iter_open_pulls
51
52
  from .gitutils import CommandError
52
53
  from .gitutils import GitError
54
+ from .gitutils import _parse_trailers
53
55
  from .gitutils import git_cherry_pick
54
56
  from .gitutils import git_commit_amend
55
57
  from .gitutils import git_commit_new
@@ -59,6 +61,10 @@ from .gitutils import git_show
59
61
  from .gitutils import run_cmd
60
62
  from .models import GitHubContext
61
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
62
68
 
63
69
 
64
70
  try:
@@ -76,18 +82,19 @@ except ImportError:
76
82
  auto_discover_gerrit_host_keys = None # type: ignore[assignment]
77
83
  SSHDiscoveryError = Exception # type: ignore[misc,assignment]
78
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
79
91
 
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)
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")
@@ -166,11 +173,22 @@ def _match_first_group(pattern: str, text: str) -> str | None:
166
173
 
167
174
 
168
175
  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.
176
+ # Gerrit Change-Id should match I<40-hex-chars> format
177
+ # Be more strict to avoid accepting invalid Change-IDs
171
178
  if not value:
172
179
  return False
173
- 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
+ )
174
192
 
175
193
 
176
194
  @dataclass(frozen=True)
@@ -231,6 +249,8 @@ class Orchestrator:
231
249
  # SSH configuration paths (set by _setup_ssh)
232
250
  self._ssh_key_path: Path | None = None
233
251
  self._ssh_known_hosts_path: Path | None = None
252
+ self._ssh_agent_manager: SSHAgentManager | None = None
253
+ self._use_ssh_agent: bool = False
234
254
 
235
255
  # ---------------
236
256
  # Public API
@@ -246,6 +266,10 @@ class Orchestrator:
246
266
  This method defines the high-level call order. Sub-steps are
247
267
  placeholders and must be implemented with real logic. Until then,
248
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.
249
273
  """
250
274
  log.info("Starting PR -> Gerrit pipeline")
251
275
  self._guard_pull_request_context(gh)
@@ -256,9 +280,12 @@ class Orchestrator:
256
280
 
257
281
  gitreview = self._read_gitreview(self.workspace / ".gitreview", gh)
258
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)
259
284
  gerrit = self._resolve_gerrit_info(gitreview, inputs, repo_names)
260
285
 
286
+ log.debug("execute: resolved gerrit info: %s", gerrit)
261
287
  if inputs.dry_run:
288
+ log.debug("execute: entering dry-run mode due to inputs.dry_run=True")
262
289
  # Perform preflight validations and exit without making changes
263
290
  self._dry_run_preflight(gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names)
264
291
  log.info("Dry run complete; skipping write operations to Gerrit")
@@ -485,7 +512,16 @@ class Orchestrator:
485
512
  repo: RepoNames,
486
513
  ) -> GerritInfo:
487
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
+
488
523
  if gitreview:
524
+ log.debug("Using .gitreview settings: %s", gitreview)
489
525
  return gitreview
490
526
 
491
527
  host = inputs.gerrit_server.strip()
@@ -534,34 +570,126 @@ class Orchestrator:
534
570
  log.debug("SSH private key not provided, skipping SSH setup")
535
571
  return
536
572
 
537
- # Auto-discover host keys if not provided
573
+ # Auto-discover or augment host keys (merge missing types/[host]:port entries)
538
574
  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...")
575
+ if auto_discover_gerrit_host_keys is not None:
541
576
  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,
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,
554
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")
555
594
  else:
556
- log.warning("Auto-discovery failed, SSH host key verification may fail")
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")
557
632
  except Exception as exc:
558
- log.warning("SSH host key auto-discovery failed: %s", exc)
633
+ log.warning("SSH host key auto-discovery/augmentation failed: %s", exc)
559
634
 
560
635
  if not effective_known_hosts:
561
636
  log.debug("No SSH host keys available (manual or auto-discovered), skipping SSH setup")
562
637
  return
563
638
 
564
- 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")
565
693
  log.debug("Using workspace-specific SSH files to avoid user changes")
566
694
 
567
695
  # Create tool-specific SSH directory in workspace to avoid touching
@@ -569,13 +697,26 @@ class Orchestrator:
569
697
  tool_ssh_dir = self.workspace / ".ssh-g2g"
570
698
  tool_ssh_dir.mkdir(mode=0o700, exist_ok=True)
571
699
 
572
- # Write SSH private key to tool-specific location
700
+ # Write SSH private key to tool-specific location with secure permissions
573
701
  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")
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)
579
720
 
580
721
  # Write known hosts to tool-specific location
581
722
  known_hosts_path = tool_ssh_dir / "known_hosts"
@@ -589,58 +730,200 @@ class Orchestrator:
589
730
  self._ssh_key_path = key_path
590
731
  self._ssh_known_hosts_path = known_hosts_path
591
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
+
592
884
  @property
593
- def _git_ssh_command(self) -> str | None:
885
+ def _build_git_ssh_command(self) -> str | None:
594
886
  """Generate GIT_SSH_COMMAND for secure, isolated SSH configuration.
595
887
 
596
888
  This prevents SSH from scanning the user's SSH agent or using
597
889
  unintended keys by setting IdentitiesOnly=yes and specifying
598
890
  exact key and known_hosts files.
599
891
  """
892
+ if self._use_ssh_agent and self._ssh_agent_manager:
893
+ return self._ssh_agent_manager.get_git_ssh_command()
894
+
600
895
  if not self._ssh_key_path or not self._ssh_known_hosts_path:
601
896
  return None
602
897
 
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
- ]
898
+ # Delegate to centralized SSH command builder
899
+ from .ssh_common import build_git_ssh_command
617
900
 
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
901
+ return build_git_ssh_command(
902
+ key_path=self._ssh_key_path,
903
+ known_hosts_path=self._ssh_known_hosts_path,
904
+ )
622
905
 
623
906
  def _ssh_env(self) -> dict[str, str]:
624
907
  """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
- }
908
+ from .ssh_common import build_non_interactive_ssh_env
909
+
910
+ env = build_non_interactive_ssh_env()
911
+
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
644
927
 
645
928
  def _cleanup_ssh(self) -> None:
646
929
  """Clean up temporary SSH files created by this tool.
@@ -648,10 +931,15 @@ class Orchestrator:
648
931
  Removes the workspace-specific .ssh-g2g directory and all contents.
649
932
  This ensures no temporary files are left behind.
650
933
  """
651
- if not hasattr(self, "_ssh_key_path") or not hasattr(self, "_ssh_known_hosts_path"):
652
- return
934
+ log.debug("Cleaning up temporary SSH configuration files")
653
935
 
654
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
+
655
943
  # Remove temporary SSH directory and all contents
656
944
  tool_ssh_dir = self.workspace / ".ssh-g2g"
657
945
  if tool_ssh_dir.exists():
@@ -849,7 +1137,10 @@ class Orchestrator:
849
1137
  ", ".join(uniq_ids),
850
1138
  )
851
1139
  else:
852
- 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
+ )
853
1144
  return PreparedChange(change_ids=uniq_ids, commit_shas=[])
854
1145
 
855
1146
  def _prepare_squashed_commit(
@@ -910,10 +1201,9 @@ class Orchestrator:
910
1201
  continue
911
1202
  if not ln.startswith((" ", "-", "dependency-")) and ln.strip():
912
1203
  in_metadata_section = False
1204
+ # Skip Change-Id lines from body - they should only be in footer
913
1205
  if ln.startswith("Change-Id:"):
914
- cid = ln.split(":", 1)[1].strip()
915
- if cid:
916
- change_ids.append(cid)
1206
+ log.debug("Skipping Change-Id from commit body: %s", ln.strip())
917
1207
  continue
918
1208
  if ln.startswith("Signed-off-by:"):
919
1209
  signed_off.append(ln)
@@ -944,6 +1234,20 @@ class Orchestrator:
944
1234
  else:
945
1235
  words = title_line[:100].split()
946
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
+
947
1251
  return title_line
948
1252
 
949
1253
  def _build_clean_message_lines(message_lines: list[str]) -> list[str]:
@@ -996,10 +1300,16 @@ class Orchestrator:
996
1300
  ) -> str:
997
1301
  msg = "\n".join(lines_in).strip()
998
1302
  msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
1303
+
1304
+ # Build footer with proper trailer ordering
1305
+ footer_parts = []
999
1306
  if signed_off:
1000
- msg += "\n\n" + "\n".join(signed_off)
1307
+ footer_parts.extend(signed_off)
1001
1308
  if reuse_cid:
1002
- 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)
1003
1313
  return msg
1004
1314
 
1005
1315
  # Build message parts
@@ -1038,7 +1348,19 @@ class Orchestrator:
1038
1348
  ", ".join(cids),
1039
1349
  )
1040
1350
  else:
1041
- 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)
1042
1364
  return PreparedChange(change_ids=cids, commit_shas=[])
1043
1365
 
1044
1366
  def _apply_pr_title_body_if_requested(
@@ -1062,6 +1384,11 @@ class Orchestrator:
1062
1384
  title = (title or "").strip()
1063
1385
  body = (body or "").strip()
1064
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
+
1065
1392
  # Clean up title to ensure it's a proper first line for commit message
1066
1393
  if title:
1067
1394
  # Remove markdown links like [text](url) and keep just the text
@@ -1078,6 +1405,10 @@ class Orchestrator:
1078
1405
  title = re.sub(r"[*_`]", "", title)
1079
1406
  title = title.strip()
1080
1407
 
1408
+ # Apply conventional commit normalization if enabled
1409
+ if inputs.normalise_commit:
1410
+ title = normalize_commit_title(title, author_login, self.workspace)
1411
+
1081
1412
  # Compose message; preserve existing trailers at footer
1082
1413
  # (Signed-off-by, Change-Id)
1083
1414
  current_body = git_show("HEAD", fmt="%B", cwd=self.workspace)
@@ -1108,7 +1439,11 @@ class Orchestrator:
1108
1439
  # Build trailers: Signed-off-by first, Change-Id last.
1109
1440
  trailers_out: list[str] = []
1110
1441
  if signed_lines:
1111
- trailers_out.extend(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)
1112
1447
  if change_id_lines:
1113
1448
  trailers_out.append(change_id_lines[-1])
1114
1449
  if trailers_out:
@@ -1151,6 +1486,10 @@ class Orchestrator:
1151
1486
  prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1152
1487
  pr_num = os.getenv("PR_NUMBER", "").strip()
1153
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
+
1154
1493
  try:
1155
1494
  args = [
1156
1495
  "git",
@@ -1160,21 +1499,29 @@ class Orchestrator:
1160
1499
  "-t",
1161
1500
  topic,
1162
1501
  ]
1163
- revs = [r.strip() for r in (reviewers or "").split(",") if r.strip()]
1502
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip() and "@" in r and r.strip() != branch]
1164
1503
  for r in revs:
1165
1504
  args.extend(["--reviewer", r])
1166
1505
  # Branch as positional argument (not a flag)
1167
1506
  args.append(branch)
1168
1507
 
1169
- # Use our specific SSH configuration
1170
- env = self._ssh_env()
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
1171
1512
  log.debug("Executing git review command: %s", " ".join(args))
1172
1513
  run_cmd(args, cwd=self.workspace, env=env)
1173
1514
  log.info("Successfully pushed changes to Gerrit")
1174
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
+
1175
1522
  # Analyze the specific failure reason from git review output
1176
1523
  error_details = self._analyze_gerrit_push_failure(exc)
1177
- _log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1524
+ log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1178
1525
  msg = f"Failed to push changes to Gerrit with git-review: {error_details}"
1179
1526
  raise OrchestratorError(msg) from exc
1180
1527
  # Cleanup temporary branch used during preparation
@@ -1194,6 +1541,113 @@ class Orchestrator:
1194
1541
  env=env,
1195
1542
  )
1196
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
+
1197
1651
  def _analyze_gerrit_push_failure(self, exc: CommandError) -> str:
1198
1652
  """Analyze git review failure and provide helpful error message."""
1199
1653
  stdout = exc.stdout or ""
@@ -1278,7 +1732,9 @@ class Orchestrator:
1278
1732
  """Query Gerrit for change URL/number and patchset sha via REST."""
1279
1733
  log.info("Querying Gerrit for submitted change(s) via REST")
1280
1734
 
1281
- # Create centralized URL builder
1735
+ # pygerrit2 netrc filter is already applied in execute() unless verbose mode
1736
+
1737
+ # Create centralized URL builder (auto-discovers base path)
1282
1738
  url_builder = create_gerrit_url_builder(gerrit.host)
1283
1739
 
1284
1740
  # Get authentication credentials
@@ -1294,8 +1750,12 @@ class Orchestrator:
1294
1750
  if http_user and http_pass:
1295
1751
  if HTTPBasicAuth is None:
1296
1752
  raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1753
+ if GerritRestAPI is None:
1754
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1297
1755
  return GerritRestAPI(url=base_url, auth=HTTPBasicAuth(http_user, http_pass))
1298
1756
  else:
1757
+ if GerritRestAPI is None:
1758
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1299
1759
  return GerritRestAPI(url=base_url)
1300
1760
 
1301
1761
  # Try API URLs in order of preference (client creation happens in retry loop)
@@ -1309,29 +1769,23 @@ class Orchestrator:
1309
1769
  # include current revision
1310
1770
  query = f"limit:1 is:open project:{repo.project_gerrit} {cid}"
1311
1771
  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
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
1327
1776
 
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
- )
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
+ )
1784
+ try:
1785
+ log.debug("Gerrit API base URL (discovered): %s", api_base_url)
1786
+ changes = client.get(path)
1787
+ except Exception as exc:
1788
+ log.warning("Failed to query change via REST for %s: %s", cid, exc)
1335
1789
  continue
1336
1790
  if not changes:
1337
1791
  continue
@@ -1350,21 +1804,19 @@ class Orchestrator:
1350
1804
  nums.append(num)
1351
1805
  if current_rev:
1352
1806
  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)
1807
+
1360
1808
  return SubmissionResult(change_urls=urls, change_numbers=nums, commit_shas=shas)
1361
1809
 
1362
1810
  def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1363
1811
  """Initialize and set up git workspace for PR processing."""
1364
1812
  from .gitutils import run_cmd
1365
1813
 
1366
- # Initialize git repository
1367
- 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)
1368
1820
 
1369
1821
  # Add GitHub remote
1370
1822
  repo_full = gh.repository.strip() if gh.repository else ""
@@ -1398,64 +1850,69 @@ class Orchestrator:
1398
1850
 
1399
1851
  def _install_commit_msg_hook(self, gerrit: GerritInfo) -> None:
1400
1852
  """Manually install commit-msg hook from Gerrit."""
1401
- from .gitutils import run_cmd
1853
+ from .external_api import curl_download
1402
1854
 
1403
1855
  hooks_dir = self.workspace / ".git" / "hooks"
1404
1856
  hooks_dir.mkdir(exist_ok=True)
1405
1857
  hook_path = hooks_dir / "commit-msg"
1406
1858
 
1407
- # Download commit-msg hook using SSH
1859
+ # Download commit-msg hook using centralized curl framework
1408
1860
  try:
1409
- # Use curl to download the hook (more reliable than scp)
1410
1861
  # Create centralized URL builder for hook URLs
1411
1862
  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
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
+ )
1444
1882
 
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()
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)
1452
1894
 
1453
- # 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)
1454
1911
  hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1455
- log.debug("Successfully installed commit-msg hook via curl")
1912
+ log.debug("Successfully installed commit-msg hook from %s", hook_url)
1456
1913
 
1457
1914
  except Exception as exc:
1458
- 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)
1459
1916
  msg = f"Could not install commit-msg hook: {exc}"
1460
1917
  raise OrchestratorError(msg) from exc
1461
1918
 
@@ -1465,51 +1922,163 @@ class Orchestrator:
1465
1922
  Installs the commit-msg hook and amends the commit if needed.
1466
1923
  """
1467
1924
  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
1925
+ existing_change_ids = trailers.get("Change-Id", [])
1486
1926
 
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(
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:
1935
+ self._install_commit_msg_hook(gerrit)
1936
+ git_commit_amend(
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,
1946
+ )
1947
+ # Fallback: generate a Change-Id and append to the commit
1948
+ # message directly
1949
+ import time
1950
+
1951
+ current_msg = run_cmd(
1506
1952
  ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1507
1953
  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)
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,
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)
1511
1977
  return [c for c in trailers.get("Change-Id", []) if c]
1512
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
+
1513
2082
  def _add_backref_comment_in_gerrit(
1514
2083
  self,
1515
2084
  *,
@@ -1545,10 +2114,9 @@ class Orchestrator:
1545
2114
  continue
1546
2115
  try:
1547
2116
  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.
2117
+ # Build SSH command based on available authentication method
1551
2118
  if self._ssh_key_path and self._ssh_known_hosts_path:
2119
+ # File-based SSH authentication
1552
2120
  ssh_cmd = [
1553
2121
  "ssh",
1554
2122
  "-F",
@@ -1583,9 +2151,44 @@ class Orchestrator:
1583
2151
  f"{shlex.quote(csha)}"
1584
2152
  ),
1585
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
+ ]
1586
2190
  else:
1587
- # Strict non-interactive SSH without
1588
- # isolated key/known_hosts
2191
+ # Fallback - minimal SSH command (for tests)
1589
2192
  ssh_cmd = [
1590
2193
  "ssh",
1591
2194
  "-F",
@@ -1660,6 +2263,10 @@ class Orchestrator:
1660
2263
  result: SubmissionResult,
1661
2264
  ) -> None:
1662
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
1663
2270
  log.info("Adding reference comment on PR #%s", gh.pr_number)
1664
2271
  if not gh.pr_number:
1665
2272
  return
@@ -1831,7 +2438,11 @@ class Orchestrator:
1831
2438
  http_user: str,
1832
2439
  http_pass: str,
1833
2440
  ) -> None:
1834
- """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
+ """
1835
2446
 
1836
2447
  def _build_client(url: str) -> Any:
1837
2448
  if http_user and http_pass:
@@ -1859,51 +2470,17 @@ class Orchestrator:
1859
2470
 
1860
2471
  # Create centralized URL builder for REST probing
1861
2472
  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
2473
+ api_url = url_builder.api_url()
1867
2474
 
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
- )
2475
+ try:
2476
+ _probe(api_url)
2477
+ except Exception as exc:
2478
+ log.warning("Gerrit REST probe failed for %s: %s", api_url, exc)
1884
2479
 
1885
2480
  # ---------------
1886
2481
  # Helpers
1887
2482
  # ---------------
1888
2483
 
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
2484
  def _resolve_target_branch(self) -> str:
1908
2485
  # Preference order:
1909
2486
  # 1) GERRIT_BRANCH (explicit override)