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/cli.py +457 -186
- github2gerrit/commit_normalization.py +471 -0
- github2gerrit/core.py +828 -251
- github2gerrit/duplicate_detection.py +1 -67
- github2gerrit/external_api.py +518 -0
- github2gerrit/gerrit_rest.py +298 -0
- github2gerrit/gerrit_urls.py +149 -52
- github2gerrit/github_api.py +12 -79
- github2gerrit/gitutils.py +216 -49
- github2gerrit/models.py +2 -0
- github2gerrit/pr_content_filter.py +476 -0
- github2gerrit/similarity.py +2 -2
- github2gerrit/ssh_agent_setup.py +351 -0
- github2gerrit/ssh_common.py +244 -0
- github2gerrit/ssh_discovery.py +4 -0
- github2gerrit/utils.py +113 -0
- github2gerrit-0.1.7.dist-info/METADATA +798 -0
- github2gerrit-0.1.7.dist-info/RECORD +24 -0
- github2gerrit-0.1.6.dist-info/METADATA +0 -552
- github2gerrit-0.1.6.dist-info/RECORD +0 -17
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.6.dist-info → github2gerrit-0.1.7.dist-info}/top_level.txt +0 -0
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
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
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
|
170
|
-
#
|
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
|
-
|
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
|
573
|
+
# Auto-discover or augment host keys (merge missing types/[host]:port entries)
|
538
574
|
effective_known_hosts = inputs.gerrit_known_hosts
|
539
|
-
if
|
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
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
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
|
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
|
-
#
|
604
|
-
|
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
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
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
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
"
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
"GIT_SSH_COMMAND"
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
642
|
-
|
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
|
-
|
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.
|
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
|
-
|
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
|
-
|
1307
|
+
footer_parts.extend(signed_off)
|
1001
1308
|
if reuse_cid:
|
1002
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
1170
|
-
|
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
|
-
|
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
|
-
#
|
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
|
-
#
|
1313
|
-
|
1314
|
-
|
1315
|
-
|
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
|
-
|
1329
|
-
|
1330
|
-
|
1331
|
-
|
1332
|
-
|
1333
|
-
|
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
|
-
|
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
|
-
#
|
1367
|
-
|
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 .
|
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
|
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
|
-
|
1413
|
-
|
1414
|
-
|
1415
|
-
|
1416
|
-
|
1417
|
-
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
1423
|
-
|
1424
|
-
|
1425
|
-
|
1426
|
-
|
1427
|
-
|
1428
|
-
|
1429
|
-
|
1430
|
-
|
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
|
-
|
1446
|
-
|
1447
|
-
|
1448
|
-
|
1449
|
-
|
1450
|
-
|
1451
|
-
|
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
|
-
#
|
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
|
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
|
-
|
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
|
-
|
1488
|
-
|
1489
|
-
|
1490
|
-
|
1491
|
-
|
1492
|
-
|
1493
|
-
|
1494
|
-
|
1495
|
-
|
1496
|
-
|
1497
|
-
|
1498
|
-
|
1499
|
-
|
1500
|
-
|
1501
|
-
|
1502
|
-
|
1503
|
-
|
1504
|
-
|
1505
|
-
|
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
|
1509
|
-
|
1510
|
-
|
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
|
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
|
-
#
|
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
|
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
|
-
|
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
|
-
|
1869
|
-
|
1870
|
-
|
1871
|
-
|
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)
|