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