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