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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github2gerrit/core.py CHANGED
@@ -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,14 +39,7 @@ from dataclasses import dataclass
38
39
  from pathlib import Path
39
40
  from typing import Any
40
41
 
41
-
42
- try:
43
- from pygerrit2 import GerritRestAPI
44
- from pygerrit2 import HTTPBasicAuth
45
- except ImportError:
46
- GerritRestAPI = None
47
- HTTPBasicAuth = None
48
-
42
+ from .gerrit_urls import create_gerrit_url_builder
49
43
  from .github_api import build_client
50
44
  from .github_api import close_pr
51
45
  from .github_api import create_pr_comment
@@ -67,9 +61,50 @@ from .models import GitHubContext
67
61
  from .models import Inputs
68
62
 
69
63
 
64
+ try:
65
+ from pygerrit2 import GerritRestAPI
66
+ from pygerrit2 import HTTPBasicAuth
67
+ except ImportError:
68
+ GerritRestAPI = None
69
+ HTTPBasicAuth = None
70
+
71
+ try:
72
+ from .ssh_discovery import SSHDiscoveryError
73
+ from .ssh_discovery import auto_discover_gerrit_host_keys
74
+ except ImportError:
75
+ # Fallback if ssh_discovery module is not available
76
+ auto_discover_gerrit_host_keys = None # type: ignore[assignment]
77
+ SSHDiscoveryError = Exception # type: ignore[misc,assignment]
78
+
79
+
80
+ def _is_verbose_mode() -> bool:
81
+ """Check if verbose mode is enabled via environment variable."""
82
+ return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
83
+
84
+
85
+ def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
86
+ """Log exception with traceback only if verbose mode is enabled."""
87
+ if _is_verbose_mode():
88
+ logger.exception(message, *args)
89
+ else:
90
+ logger.error(message, *args)
91
+
92
+
70
93
  log = logging.getLogger("github2gerrit.core")
71
94
 
72
95
 
96
+ # Error message constants to comply with TRY003
97
+ _MSG_ISSUE_ID_MULTILINE = "Issue ID must be single line"
98
+ _MSG_MISSING_PR_CONTEXT = "missing PR context"
99
+ _MSG_BAD_REPOSITORY_CONTEXT = "bad repository context"
100
+ _MSG_MISSING_GERRIT_SERVER = "missing GERRIT_SERVER"
101
+ _MSG_MISSING_GERRIT_PROJECT = "missing GERRIT_PROJECT"
102
+ _MSG_PYGERRIT2_REQUIRED_REST = "pygerrit2 is required to query Gerrit REST API"
103
+ _MSG_PYGERRIT2_REQUIRED_AUTH = "pygerrit2 is required for HTTP authentication"
104
+ _MSG_PYGERRIT2_MISSING = "pygerrit2 missing"
105
+ _MSG_PYGERRIT2_AUTH_MISSING = "pygerrit2 auth missing"
106
+
107
+
73
108
  def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
74
109
  """
75
110
  Insert Issue ID into commit message after the first line.
@@ -87,13 +122,10 @@ def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
87
122
  # Validate that Issue ID is a single line string
88
123
  cleaned_issue_id = issue_id.strip()
89
124
  if "\n" in cleaned_issue_id or "\r" in cleaned_issue_id:
90
- raise ValueError("Issue ID must be single line") # noqa: TRY003
125
+ raise ValueError(_MSG_ISSUE_ID_MULTILINE)
91
126
 
92
127
  # Format as proper Issue-ID trailer
93
- if cleaned_issue_id.startswith("Issue-ID:"):
94
- issue_line = cleaned_issue_id
95
- else:
96
- issue_line = f"Issue-ID: {cleaned_issue_id}"
128
+ issue_line = cleaned_issue_id if cleaned_issue_id.startswith("Issue-ID:") else f"Issue-ID: {cleaned_issue_id}"
97
129
 
98
130
  lines = message.splitlines()
99
131
  if not lines:
@@ -228,14 +260,34 @@ class Orchestrator:
228
260
 
229
261
  if inputs.dry_run:
230
262
  # Perform preflight validations and exit without making changes
231
- self._dry_run_preflight(
232
- gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names
233
- )
263
+ self._dry_run_preflight(gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names)
234
264
  log.info("Dry run complete; skipping write operations to Gerrit")
235
- return SubmissionResult(
236
- change_urls=[], change_numbers=[], commit_shas=[]
265
+ return SubmissionResult(change_urls=[], change_numbers=[], commit_shas=[])
266
+ self._setup_ssh(inputs, gerrit)
267
+ # Establish baseline non-interactive SSH/Git environment
268
+ # for all child processes
269
+ os.environ.update(self._ssh_env())
270
+
271
+ # Ensure commit/tag signing is disabled before any commit operations
272
+ # to avoid agent prompts
273
+ try:
274
+ git_config(
275
+ "commit.gpgsign",
276
+ "false",
277
+ global_=False,
278
+ cwd=self.workspace,
237
279
  )
238
- self._setup_ssh(inputs)
280
+ except GitError:
281
+ git_config("commit.gpgsign", "false", global_=True)
282
+ try:
283
+ git_config(
284
+ "tag.gpgsign",
285
+ "false",
286
+ global_=False,
287
+ cwd=self.workspace,
288
+ )
289
+ except GitError:
290
+ git_config("tag.gpgsign", "false", global_=True)
239
291
 
240
292
  if inputs.submit_single_commits:
241
293
  prep = self._prepare_single_commits(inputs, gh, gerrit)
@@ -281,7 +333,7 @@ class Orchestrator:
281
333
 
282
334
  def _guard_pull_request_context(self, gh: GitHubContext) -> None:
283
335
  if gh.pr_number is None:
284
- raise OrchestratorError("missing PR context") # noqa: TRY003
336
+ raise OrchestratorError(_MSG_MISSING_PR_CONTEXT)
285
337
  log.debug("PR context OK: #%s", gh.pr_number)
286
338
 
287
339
  def _parse_gitreview_text(self, text: str) -> GerritInfo | None:
@@ -319,13 +371,8 @@ class Orchestrator:
319
371
  repo_obj: Any = get_repo_from_env(client)
320
372
  # Prefer a specific ref when available; otherwise default branch
321
373
  ref = os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_SHA")
322
- if ref:
323
- content = repo_obj.get_contents(".gitreview", ref=ref)
324
- else:
325
- content = repo_obj.get_contents(".gitreview")
326
- text_remote = (
327
- getattr(content, "decoded_content", b"") or b""
328
- ).decode("utf-8")
374
+ content = repo_obj.get_contents(".gitreview", ref=ref) if ref else repo_obj.get_contents(".gitreview")
375
+ text_remote = (getattr(content, "decoded_content", b"") or b"").decode("utf-8")
329
376
  info_remote = self._parse_gitreview_text(text_remote)
330
377
  if info_remote:
331
378
  log.debug("Parsed remote .gitreview: %s", info_remote)
@@ -335,14 +382,7 @@ class Orchestrator:
335
382
  log.debug("Remote .gitreview not available: %s", exc)
336
383
  # Attempt raw.githubusercontent.com as a fallback
337
384
  try:
338
- repo_full = (
339
- (
340
- gh.repository
341
- if gh
342
- else os.getenv("GITHUB_REPOSITORY", "")
343
- )
344
- or ""
345
- ).strip()
385
+ repo_full = ((gh.repository if gh else os.getenv("GITHUB_REPOSITORY", "")) or "").strip()
346
386
  branches: list[str] = []
347
387
  # Prefer PR head/base refs via GitHub API when running
348
388
  # from a direct URL when a token is available
@@ -356,18 +396,8 @@ class Orchestrator:
356
396
  client = build_client()
357
397
  repo_obj = get_repo_from_env(client)
358
398
  pr_obj = get_pull(repo_obj, int(gh.pr_number))
359
- api_head = str(
360
- getattr(
361
- getattr(pr_obj, "head", object()), "ref", ""
362
- )
363
- or ""
364
- )
365
- api_base = str(
366
- getattr(
367
- getattr(pr_obj, "base", object()), "ref", ""
368
- )
369
- or ""
370
- )
399
+ api_head = str(getattr(getattr(pr_obj, "head", object()), "ref", "") or "")
400
+ api_base = str(getattr(getattr(pr_obj, "base", object()), "ref", "") or "")
371
401
  if api_head:
372
402
  branches.append(api_head)
373
403
  if api_base:
@@ -387,15 +417,9 @@ class Orchestrator:
387
417
  if not br or br in tried:
388
418
  continue
389
419
  tried.add(br)
390
- url = (
391
- f"https://raw.githubusercontent.com/"
392
- f"{repo_full}/refs/heads/{br}/.gitreview"
393
- )
420
+ url = f"https://raw.githubusercontent.com/{repo_full}/refs/heads/{br}/.gitreview"
394
421
  parsed = urllib.parse.urlparse(url)
395
- if (
396
- parsed.scheme != "https"
397
- or parsed.netloc != "raw.githubusercontent.com"
398
- ):
422
+ if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
399
423
  continue
400
424
  log.info("Fetching .gitreview via raw URL: %s", url)
401
425
  with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
@@ -446,7 +470,7 @@ class Orchestrator:
446
470
  # Fallback: use the repository name portion only.
447
471
  repo_full = gh.repository
448
472
  if not repo_full or "/" not in repo_full:
449
- raise OrchestratorError("bad repository context") # noqa: TRY003
473
+ raise OrchestratorError(_MSG_BAD_REPOSITORY_CONTEXT)
450
474
  owner, name = repo_full.split("/", 1)
451
475
  # Fallback: map all '-' to '/' for Gerrit path (e.g., 'my/repo/name')
452
476
  gerrit_name = name.replace("-", "/")
@@ -466,7 +490,7 @@ class Orchestrator:
466
490
 
467
491
  host = inputs.gerrit_server.strip()
468
492
  if not host:
469
- raise OrchestratorError("missing GERRIT_SERVER") # noqa: TRY003
493
+ raise OrchestratorError(_MSG_MISSING_GERRIT_SERVER)
470
494
  port_s = inputs.gerrit_server_port.strip() or "29418"
471
495
  try:
472
496
  port = int(port_s)
@@ -486,13 +510,13 @@ class Orchestrator:
486
510
  project,
487
511
  )
488
512
  else:
489
- raise OrchestratorError("missing GERRIT_PROJECT") # noqa: TRY003
513
+ raise OrchestratorError(_MSG_MISSING_GERRIT_PROJECT)
490
514
 
491
515
  info = GerritInfo(host=host, port=port, project=project)
492
516
  log.debug("Resolved Gerrit info: %s", info)
493
517
  return info
494
518
 
495
- def _setup_ssh(self, inputs: Inputs) -> None:
519
+ def _setup_ssh(self, inputs: Inputs, gerrit: GerritInfo) -> None:
496
520
  """Set up temporary SSH configuration for Gerrit access.
497
521
 
498
522
  This method creates tool-specific SSH files in the workspace without
@@ -506,8 +530,35 @@ class Orchestrator:
506
530
 
507
531
  Does not modify user files.
508
532
  """
509
- if not inputs.gerrit_ssh_privkey_g2g or not inputs.gerrit_known_hosts:
510
- log.debug("SSH key or known hosts not provided, skipping SSH setup")
533
+ if not inputs.gerrit_ssh_privkey_g2g:
534
+ log.debug("SSH private key not provided, skipping SSH setup")
535
+ return
536
+
537
+ # Auto-discover host keys if not provided
538
+ effective_known_hosts = inputs.gerrit_known_hosts
539
+ if not effective_known_hosts and auto_discover_gerrit_host_keys is not None:
540
+ log.info("GERRIT_KNOWN_HOSTS not provided, attempting auto-discovery...")
541
+ try:
542
+ discovered_keys = auto_discover_gerrit_host_keys(
543
+ gerrit_hostname=gerrit.host,
544
+ gerrit_port=gerrit.port,
545
+ organization=inputs.organization,
546
+ save_to_config=True,
547
+ )
548
+ if discovered_keys:
549
+ effective_known_hosts = discovered_keys
550
+ log.info(
551
+ "Successfully auto-discovered SSH host keys for %s:%d",
552
+ gerrit.host,
553
+ gerrit.port,
554
+ )
555
+ else:
556
+ log.warning("Auto-discovery failed, SSH host key verification may fail")
557
+ except Exception as exc:
558
+ log.warning("SSH host key auto-discovery failed: %s", exc)
559
+
560
+ if not effective_known_hosts:
561
+ log.debug("No SSH host keys available (manual or auto-discovered), skipping SSH setup")
511
562
  return
512
563
 
513
564
  log.info("Setting up temporary SSH configuration for Gerrit")
@@ -529,7 +580,7 @@ class Orchestrator:
529
580
  # Write known hosts to tool-specific location
530
581
  known_hosts_path = tool_ssh_dir / "known_hosts"
531
582
  with open(known_hosts_path, "w", encoding="utf-8") as f:
532
- f.write(inputs.gerrit_known_hosts.strip() + "\n")
583
+ f.write(effective_known_hosts.strip() + "\n")
533
584
  known_hosts_path.chmod(0o644)
534
585
  log.debug("Known hosts written to %s", known_hosts_path)
535
586
  log.debug("Using isolated known_hosts to prevent user conflicts")
@@ -551,9 +602,13 @@ class Orchestrator:
551
602
 
552
603
  # Build SSH command with strict options to prevent key scanning
553
604
  ssh_options = [
605
+ "-F /dev/null",
554
606
  f"-i {self._ssh_key_path}",
555
607
  f"-o UserKnownHostsFile={self._ssh_known_hosts_path}",
556
608
  "-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
609
+ "-o IdentityAgent=none",
610
+ "-o BatchMode=yes",
611
+ "-o PreferredAuthentications=publickey",
557
612
  "-o StrictHostKeyChecking=yes",
558
613
  "-o PasswordAuthentication=no",
559
614
  "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
@@ -565,15 +620,35 @@ class Orchestrator:
565
620
  log.debug("Generated SSH command: %s", masked_cmd)
566
621
  return ssh_cmd
567
622
 
623
+ def _ssh_env(self) -> dict[str, str]:
624
+ """Centralized non-interactive SSH/Git environment."""
625
+ cmd = self._git_ssh_command or (
626
+ "ssh -F /dev/null "
627
+ "-o IdentitiesOnly=yes "
628
+ "-o IdentityAgent=none "
629
+ "-o BatchMode=yes "
630
+ "-o PreferredAuthentications=publickey "
631
+ "-o StrictHostKeyChecking=yes "
632
+ "-o PasswordAuthentication=no "
633
+ "-o PubkeyAcceptedKeyTypes=+ssh-rsa "
634
+ "-o ConnectTimeout=10"
635
+ )
636
+ return {
637
+ "GIT_SSH_COMMAND": cmd,
638
+ "SSH_AUTH_SOCK": "",
639
+ "SSH_AGENT_PID": "",
640
+ "SSH_ASKPASS": "/usr/bin/false",
641
+ "DISPLAY": "",
642
+ "SSH_ASKPASS_REQUIRE": "never",
643
+ }
644
+
568
645
  def _cleanup_ssh(self) -> None:
569
646
  """Clean up temporary SSH files created by this tool.
570
647
 
571
648
  Removes the workspace-specific .ssh-g2g directory and all contents.
572
649
  This ensures no temporary files are left behind.
573
650
  """
574
- if not hasattr(self, "_ssh_key_path") or not hasattr(
575
- self, "_ssh_known_hosts_path"
576
- ):
651
+ if not hasattr(self, "_ssh_key_path") or not hasattr(self, "_ssh_known_hosts_path"):
577
652
  return
578
653
 
579
654
  try:
@@ -583,9 +658,7 @@ class Orchestrator:
583
658
  import shutil
584
659
 
585
660
  shutil.rmtree(tool_ssh_dir)
586
- log.debug(
587
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
588
- )
661
+ log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
589
662
  except Exception as exc:
590
663
  log.warning("Failed to clean up temporary SSH files: %s", exc)
591
664
 
@@ -605,9 +678,7 @@ class Orchestrator:
605
678
  cwd=self.workspace,
606
679
  )
607
680
  except GitError:
608
- git_config(
609
- "gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True
610
- )
681
+ git_config("gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True)
611
682
  try:
612
683
  git_config(
613
684
  "user.name",
@@ -625,9 +696,26 @@ class Orchestrator:
625
696
  cwd=self.workspace,
626
697
  )
627
698
  except GitError:
699
+ git_config("user.email", inputs.gerrit_ssh_user_g2g_email, global_=True)
700
+ # Disable GPG signing to avoid interactive prompts for signing keys
701
+ try:
628
702
  git_config(
629
- "user.email", inputs.gerrit_ssh_user_g2g_email, global_=True
703
+ "commit.gpgsign",
704
+ "false",
705
+ global_=False,
706
+ cwd=self.workspace,
630
707
  )
708
+ except GitError:
709
+ git_config("commit.gpgsign", "false", global_=True)
710
+ try:
711
+ git_config(
712
+ "tag.gpgsign",
713
+ "false",
714
+ global_=False,
715
+ cwd=self.workspace,
716
+ )
717
+ except GitError:
718
+ git_config("tag.gpgsign", "false", global_=True)
631
719
 
632
720
  # Ensure git-review host/port/project are configured
633
721
  # when .gitreview is absent
@@ -663,16 +751,10 @@ class Orchestrator:
663
751
  )
664
752
  except CommandError:
665
753
  ssh_user = inputs.gerrit_ssh_user_g2g.strip()
666
- remote_url = (
667
- f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
668
- )
754
+ remote_url = f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
669
755
  log.info("Adding 'gerrit' remote: %s", remote_url)
670
756
  # Use our specific SSH configuration for adding remote
671
- env = (
672
- {"GIT_SSH_COMMAND": self._git_ssh_command}
673
- if self._git_ssh_command
674
- else None
675
- )
757
+ env = self._ssh_env()
676
758
  run_cmd(
677
759
  ["git", "remote", "add", "gerrit", remote_url],
678
760
  check=False,
@@ -681,9 +763,7 @@ class Orchestrator:
681
763
  )
682
764
 
683
765
  # Workaround for submodules commit-msg hook
684
- hooks_path = run_cmd(
685
- ["git", "rev-parse", "--show-toplevel"], cwd=self.workspace
686
- ).stdout.strip()
766
+ hooks_path = run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=self.workspace).stdout.strip()
687
767
  try:
688
768
  git_config(
689
769
  "core.hooksPath",
@@ -699,11 +779,7 @@ class Orchestrator:
699
779
  # Initialize git-review (copies commit-msg hook)
700
780
  try:
701
781
  # Use our specific SSH configuration for git-review setup
702
- env = (
703
- {"GIT_SSH_COMMAND": self._git_ssh_command}
704
- if self._git_ssh_command
705
- else None
706
- )
782
+ env = self._ssh_env()
707
783
  run_cmd(["git", "review", "-s", "-v"], cwd=self.workspace, env=env)
708
784
  except CommandError as exc:
709
785
  msg = f"Failed to initialize git-review: {exc}"
@@ -721,12 +797,12 @@ class Orchestrator:
721
797
  # Determine commit range: commits in HEAD not in base branch
722
798
  base_ref = f"origin/{branch}"
723
799
  # Use our SSH command for git operations that might need SSH
724
- env = (
725
- {"GIT_SSH_COMMAND": self._git_ssh_command}
726
- if self._git_ssh_command
727
- else None
800
+
801
+ run_cmd(
802
+ ["git", "fetch", "origin", branch],
803
+ cwd=self.workspace,
804
+ env=self._ssh_env(),
728
805
  )
729
- run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
730
806
  revs = run_cmd(
731
807
  ["git", "rev-list", "--reverse", f"{base_ref}..HEAD"],
732
808
  cwd=self.workspace,
@@ -736,14 +812,10 @@ class Orchestrator:
736
812
  log.info("No commits to submit; returning empty PreparedChange")
737
813
  return PreparedChange(change_ids=[], commit_shas=[])
738
814
  # Create temp branch from base sha; export for downstream
739
- base_sha = run_cmd(
740
- ["git", "rev-parse", base_ref], cwd=self.workspace
741
- ).stdout.strip()
815
+ base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
742
816
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
743
817
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
744
- run_cmd(
745
- ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
746
- )
818
+ run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
747
819
  change_ids: list[str] = []
748
820
  for csha in commit_list:
749
821
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
@@ -753,13 +825,9 @@ class Orchestrator:
753
825
  ["git", "show", "-s", "--pretty=format:%an <%ae>", csha],
754
826
  cwd=self.workspace,
755
827
  ).stdout.strip()
756
- git_commit_amend(
757
- author=author, no_edit=True, signoff=True, cwd=self.workspace
758
- )
828
+ git_commit_amend(author=author, no_edit=True, signoff=True, cwd=self.workspace)
759
829
  # Extract newly added Change-Id from last commit trailers
760
- trailers = git_last_commit_trailers(
761
- keys=["Change-Id"], cwd=self.workspace
762
- )
830
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
763
831
  for cid in trailers.get("Change-Id", []):
764
832
  if cid:
765
833
  change_ids.append(cid)
@@ -793,26 +861,20 @@ class Orchestrator:
793
861
  """Squash PR commits into a single commit and handle Change-Id."""
794
862
  log.info("Preparing squashed commit for PR #%s", gh.pr_number)
795
863
  branch = self._resolve_target_branch()
796
- env = (
797
- {"GIT_SSH_COMMAND": self._git_ssh_command}
798
- if self._git_ssh_command
799
- else None
864
+
865
+ run_cmd(
866
+ ["git", "fetch", "origin", branch],
867
+ cwd=self.workspace,
868
+ env=self._ssh_env(),
800
869
  )
801
- run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
802
870
  base_ref = f"origin/{branch}"
803
- base_sha = run_cmd(
804
- ["git", "rev-parse", base_ref], cwd=self.workspace
805
- ).stdout.strip()
806
- head_sha = run_cmd(
807
- ["git", "rev-parse", "HEAD"], cwd=self.workspace
808
- ).stdout.strip()
871
+ base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
872
+ head_sha = run_cmd(["git", "rev-parse", "HEAD"], cwd=self.workspace).stdout.strip()
809
873
 
810
874
  # Create temp branch from base and merge-squash PR head
811
875
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
812
876
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
813
- run_cmd(
814
- ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
815
- )
877
+ run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
816
878
  run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
817
879
 
818
880
  def _collect_log_lines() -> list[str]:
@@ -840,18 +902,13 @@ class Orchestrator:
840
902
  message_lines: list[str] = []
841
903
  in_metadata_section = False
842
904
  for ln in lines:
843
- if ln.strip() in ("---", "```") or ln.startswith(
844
- "updated-dependencies:"
845
- ):
905
+ if ln.strip() in ("---", "```") or ln.startswith("updated-dependencies:"):
846
906
  in_metadata_section = True
847
907
  continue
848
908
  if in_metadata_section:
849
909
  if ln.startswith(("- dependency-", " dependency-")):
850
910
  continue
851
- if (
852
- not ln.startswith((" ", "-", "dependency-"))
853
- and ln.strip()
854
- ):
911
+ if not ln.startswith((" ", "-", "dependency-")) and ln.strip():
855
912
  in_metadata_section = False
856
913
  if ln.startswith("Change-Id:"):
857
914
  cid = ln.split(":", 1)[1].strip()
@@ -882,17 +939,11 @@ class Orchestrator:
882
939
  break_points = [". ", "! ", "? ", " - ", ": "]
883
940
  for bp in break_points:
884
941
  if bp in title_line[:100]:
885
- title_line = title_line[
886
- : title_line.index(bp) + len(bp.strip())
887
- ]
942
+ title_line = title_line[: title_line.index(bp) + len(bp.strip())]
888
943
  break
889
944
  else:
890
945
  words = title_line[:100].split()
891
- title_line = (
892
- " ".join(words[:-1])
893
- if len(words) > 1
894
- else title_line[:100].rstrip()
895
- )
946
+ title_line = " ".join(words[:-1]) if len(words) > 1 else title_line[:100].rstrip()
896
947
  return title_line
897
948
 
898
949
  def _build_clean_message_lines(message_lines: list[str]) -> list[str]:
@@ -902,10 +953,7 @@ class Orchestrator:
902
953
  out: list[str] = [title_line]
903
954
  if len(message_lines) > 1:
904
955
  body_start = 1
905
- while (
906
- body_start < len(message_lines)
907
- and not message_lines[body_start].strip()
908
- ):
956
+ while body_start < len(message_lines) and not message_lines[body_start].strip():
909
957
  body_start += 1
910
958
  if body_start < len(message_lines):
911
959
  out.append("")
@@ -914,9 +962,7 @@ class Orchestrator:
914
962
 
915
963
  def _maybe_reuse_change_id(pr_str: str) -> str:
916
964
  reuse = ""
917
- sync_all_prs = (
918
- os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
919
- )
965
+ sync_all_prs = os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
920
966
  if (
921
967
  not sync_all_prs
922
968
  and gh.event_name == "pull_request_target"
@@ -926,9 +972,7 @@ class Orchestrator:
926
972
  client = build_client()
927
973
  repo = get_repo_from_env(client)
928
974
  pr_obj = get_pull(repo, int(pr_str))
929
- cand = get_recent_change_ids_from_comments(
930
- pr_obj, max_comments=50
931
- )
975
+ cand = get_recent_change_ids_from_comments(pr_obj, max_comments=50)
932
976
  if cand:
933
977
  reuse = cand[-1]
934
978
  log.debug(
@@ -950,12 +994,8 @@ class Orchestrator:
950
994
  signed_off: list[str],
951
995
  reuse_cid: str,
952
996
  ) -> str:
953
- from .duplicate_detection import DuplicateDetector
954
-
955
997
  msg = "\n".join(lines_in).strip()
956
998
  msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
957
- github_hash = DuplicateDetector._generate_github_change_hash(gh)
958
- msg += f"\n\nGitHub-Hash: {github_hash}"
959
999
  if signed_off:
960
1000
  msg += "\n\n" + "\n".join(signed_off)
961
1001
  if reuse_cid:
@@ -964,9 +1004,7 @@ class Orchestrator:
964
1004
 
965
1005
  # Build message parts
966
1006
  raw_lines = _collect_log_lines()
967
- message_lines, signed_off, _existing_cids = _parse_message_parts(
968
- raw_lines
969
- )
1007
+ message_lines, signed_off, _existing_cids = _parse_message_parts(raw_lines)
970
1008
  clean_lines = _build_clean_message_lines(message_lines)
971
1009
  pr_str = str(gh.pr_number or "").strip()
972
1010
  reuse_cid = _maybe_reuse_change_id(pr_str)
@@ -1040,29 +1078,51 @@ class Orchestrator:
1040
1078
  title = re.sub(r"[*_`]", "", title)
1041
1079
  title = title.strip()
1042
1080
 
1043
- # Compose message; preserve any Signed-off-by lines
1044
- current_body = git_show("HEAD", fmt="%B")
1045
- signed = [
1046
- ln
1047
- for ln in current_body.splitlines()
1048
- if ln.startswith("Signed-off-by:")
1049
- ]
1081
+ # Compose message; preserve existing trailers at footer
1082
+ # (Signed-off-by, Change-Id)
1083
+ current_body = git_show("HEAD", fmt="%B", cwd=self.workspace)
1084
+ # Extract existing trailers from current commit body
1085
+ lines_cur = current_body.splitlines()
1086
+ signed_lines = [ln for ln in lines_cur if ln.startswith("Signed-off-by:")]
1087
+ change_id_lines = [ln for ln in lines_cur if ln.startswith("Change-Id:")]
1088
+ github_hash_lines = [ln for ln in lines_cur if ln.startswith("GitHub-Hash:")]
1089
+
1050
1090
  msg_parts = [title, "", body] if title or body else [current_body]
1051
1091
  commit_message = "\n".join(msg_parts).strip()
1052
1092
 
1053
1093
  # Add Issue-ID if provided
1054
- commit_message = _insert_issue_id_into_commit_message(
1055
- commit_message, inputs.issue_id
1056
- )
1094
+ commit_message = _insert_issue_id_into_commit_message(commit_message, inputs.issue_id)
1095
+
1096
+ # Ensure GitHub-Hash is part of the body (not trailers)
1097
+ # to keep a blank line before Signed-off-by/Change-Id trailers.
1098
+ if github_hash_lines:
1099
+ gh_hash_line = github_hash_lines[-1]
1100
+ else:
1101
+ from .duplicate_detection import DuplicateDetector
1102
+
1103
+ gh_val = DuplicateDetector._generate_github_change_hash(gh)
1104
+ gh_hash_line = f"GitHub-Hash: {gh_val}"
1105
+
1106
+ commit_message += "\n\n" + gh_hash_line
1107
+
1108
+ # Build trailers: Signed-off-by first, Change-Id last.
1109
+ trailers_out: list[str] = []
1110
+ if signed_lines:
1111
+ trailers_out.extend(signed_lines)
1112
+ if change_id_lines:
1113
+ trailers_out.append(change_id_lines[-1])
1114
+ if trailers_out:
1115
+ commit_message += "\n\n" + "\n".join(trailers_out)
1057
1116
 
1058
- if signed:
1059
- commit_message += "\n\n" + "\n".join(signed)
1060
1117
  author = run_cmd(
1061
- ["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"]
1118
+ ["git", "show", "-s", "--pretty=format:%an <%ae>", "HEAD"],
1119
+ cwd=self.workspace,
1120
+ env=self._ssh_env(),
1062
1121
  ).stdout.strip()
1063
1122
  git_commit_amend(
1123
+ cwd=self.workspace,
1064
1124
  no_edit=False,
1065
- signoff=not bool(signed),
1125
+ signoff=not bool(signed_lines),
1066
1126
  author=author,
1067
1127
  message=commit_message,
1068
1128
  )
@@ -1090,10 +1150,7 @@ class Orchestrator:
1090
1150
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
1091
1151
  prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1092
1152
  pr_num = os.getenv("PR_NUMBER", "").strip()
1093
- if pr_num:
1094
- topic = f"{prefix}-{repo.project_github}-{pr_num}"
1095
- else:
1096
- topic = f"{prefix}-{repo.project_github}"
1153
+ topic = f"{prefix}-{repo.project_github}-{pr_num}" if pr_num else f"{prefix}-{repo.project_github}"
1097
1154
  try:
1098
1155
  args = [
1099
1156
  "git",
@@ -1103,31 +1160,22 @@ class Orchestrator:
1103
1160
  "-t",
1104
1161
  topic,
1105
1162
  ]
1106
- revs = [
1107
- r.strip() for r in (reviewers or "").split(",") if r.strip()
1108
- ]
1163
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip()]
1109
1164
  for r in revs:
1110
1165
  args.extend(["--reviewer", r])
1111
1166
  # Branch as positional argument (not a flag)
1112
1167
  args.append(branch)
1113
1168
 
1114
1169
  # Use our specific SSH configuration
1115
- env = (
1116
- {"GIT_SSH_COMMAND": self._git_ssh_command}
1117
- if self._git_ssh_command
1118
- else None
1119
- )
1170
+ env = self._ssh_env()
1120
1171
  log.debug("Executing git review command: %s", " ".join(args))
1121
1172
  run_cmd(args, cwd=self.workspace, env=env)
1122
1173
  log.info("Successfully pushed changes to Gerrit")
1123
1174
  except CommandError as exc:
1124
1175
  # Analyze the specific failure reason from git review output
1125
1176
  error_details = self._analyze_gerrit_push_failure(exc)
1126
- log.exception("Gerrit push failed: %s", error_details)
1127
- msg = (
1128
- f"Failed to push changes to Gerrit with git-review: "
1129
- f"{error_details}"
1130
- )
1177
+ _log_exception_conditionally(log, "Gerrit push failed: %s", error_details)
1178
+ msg = f"Failed to push changes to Gerrit with git-review: {error_details}"
1131
1179
  raise OrchestratorError(msg) from exc
1132
1180
  # Cleanup temporary branch used during preparation
1133
1181
  tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
@@ -1137,11 +1185,13 @@ class Orchestrator:
1137
1185
  ["git", "checkout", f"origin/{branch}"],
1138
1186
  check=False,
1139
1187
  cwd=self.workspace,
1188
+ env=env,
1140
1189
  )
1141
1190
  run_cmd(
1142
1191
  ["git", "branch", "-D", tmp_branch],
1143
1192
  check=False,
1144
1193
  cwd=self.workspace,
1194
+ env=env,
1145
1195
  )
1146
1196
 
1147
1197
  def _analyze_gerrit_push_failure(self, exc: CommandError) -> str:
@@ -1151,14 +1201,60 @@ class Orchestrator:
1151
1201
  combined_output = f"{stdout}\n{stderr}"
1152
1202
  combined_lower = combined_output.lower()
1153
1203
 
1154
- if "missing issue-id" in combined_lower:
1204
+ # Check for SSH host key verification failures first
1205
+ if (
1206
+ "host key verification failed" in combined_lower
1207
+ or "no ed25519 host key is known" in combined_lower
1208
+ or "no rsa host key is known" in combined_lower
1209
+ or "no ecdsa host key is known" in combined_lower
1210
+ ):
1211
+ return (
1212
+ "SSH host key verification failed. The GERRIT_KNOWN_HOSTS "
1213
+ "value is missing or contains an outdated host key for the "
1214
+ "Gerrit server. The tool will attempt to auto-discover "
1215
+ "host keys "
1216
+ "on the next run, or you can manually run "
1217
+ "'ssh-keyscan -p 29418 <gerrit-host>' "
1218
+ "to get the current host keys."
1219
+ )
1220
+ elif "authenticity of host" in combined_lower and "can't be established" in combined_lower:
1221
+ return (
1222
+ "SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
1223
+ "contain the host key for the Gerrit server. "
1224
+ "The tool will attempt "
1225
+ "to auto-discover host keys on the next run, or you can "
1226
+ "manually run "
1227
+ "'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
1228
+ )
1229
+ # Check for specific SSH key issues before general permission denied
1230
+ elif "key_load_public" in combined_lower and "invalid format" in combined_lower:
1231
+ return "SSH key format is invalid. Check that the SSH private key is properly formatted."
1232
+ elif "no matching host key type found" in combined_lower:
1233
+ return "SSH key type not supported by server. The server may not accept this SSH key algorithm."
1234
+ elif "authentication failed" in combined_lower:
1235
+ return "SSH authentication failed - check SSH key, username, and server configuration"
1236
+ # Check for connection timeout/refused before "could not read" check
1237
+ elif "connection timed out" in combined_lower or "connection refused" in combined_lower:
1238
+ return "Connection failed - check network connectivity and Gerrit server availability"
1239
+ # Check for specific SSH publickey-only authentication failures
1240
+ elif "permission denied (publickey)" in combined_lower and not any(
1241
+ auth_method in combined_lower for auth_method in ["gssapi", "password", "keyboard"]
1242
+ ):
1243
+ return (
1244
+ "SSH public key authentication failed. The SSH key may be "
1245
+ "invalid, not authorized for this user, or the wrong key type."
1246
+ )
1247
+ # Check for general SSH permission issues
1248
+ elif "permission denied" in combined_lower:
1249
+ return "SSH permission denied - check SSH key and user permissions"
1250
+ elif "could not read from remote repository" in combined_lower:
1251
+ return "Could not read from remote repository - check SSH authentication and repository access permissions"
1252
+ # Check for Gerrit-specific issues
1253
+ elif "missing issue-id" in combined_lower:
1155
1254
  return "Missing Issue-ID in commit message."
1156
1255
  elif "commit not associated to any issue" in combined_lower:
1157
1256
  return "Commit not associated to any issue."
1158
- elif (
1159
- "remote rejected" in combined_lower
1160
- and "refs/for/" in combined_lower
1161
- ):
1257
+ elif "remote rejected" in combined_lower and "refs/for/" in combined_lower:
1162
1258
  # Extract specific rejection reason from output
1163
1259
  lines = combined_output.split("\n")
1164
1260
  for line in lines:
@@ -1169,15 +1265,6 @@ class Orchestrator:
1169
1265
  return f"Gerrit rejected the push: {reason}"
1170
1266
  return f"Gerrit rejected the push: {line.strip()}"
1171
1267
  return "Gerrit rejected the push for an unknown reason"
1172
- elif "permission denied" in combined_lower:
1173
- return "Permission denied - check SSH key and user permissions"
1174
- elif "connection" in combined_lower and (
1175
- "refused" in combined_lower or "timeout" in combined_lower
1176
- ):
1177
- return (
1178
- "Connection failed - check network connectivity and "
1179
- "Gerrit server availability"
1180
- )
1181
1268
  else:
1182
1269
  return f"Unknown error: {exc}"
1183
1270
 
@@ -1190,32 +1277,28 @@ class Orchestrator:
1190
1277
  ) -> SubmissionResult:
1191
1278
  """Query Gerrit for change URL/number and patchset sha via REST."""
1192
1279
  log.info("Querying Gerrit for submitted change(s) via REST")
1193
- # Build Gerrit REST client (prefer HTTP basic auth if provided)
1194
- base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1195
- base_url = (
1196
- f"https://{gerrit.host}/"
1197
- if not base_path
1198
- else f"https://{gerrit.host}/{base_path}/"
1199
- )
1200
- http_user = (
1201
- os.getenv("GERRIT_HTTP_USER", "").strip()
1202
- or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1203
- )
1280
+
1281
+ # Create centralized URL builder
1282
+ url_builder = create_gerrit_url_builder(gerrit.host)
1283
+
1284
+ # Get authentication credentials
1285
+ http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1204
1286
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1287
+
1288
+ # Build Gerrit REST client (prefer HTTP basic auth if provided)
1205
1289
  if GerritRestAPI is None:
1206
- raise OrchestratorError( # noqa: TRY003
1207
- "pygerrit2 is required to query Gerrit REST API"
1208
- )
1209
- if http_user and http_pass:
1210
- if HTTPBasicAuth is None:
1211
- raise OrchestratorError( # noqa: TRY003
1212
- "pygerrit2 is required for HTTP authentication"
1213
- )
1214
- rest = GerritRestAPI(
1215
- url=base_url, auth=HTTPBasicAuth(http_user, http_pass)
1216
- )
1217
- else:
1218
- rest = GerritRestAPI(url=base_url)
1290
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1291
+
1292
+ def _create_rest_client(base_url: str) -> Any:
1293
+ """Helper to create REST client with optional auth."""
1294
+ if http_user and http_pass:
1295
+ if HTTPBasicAuth is None:
1296
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1297
+ return GerritRestAPI(url=base_url, auth=HTTPBasicAuth(http_user, http_pass))
1298
+ else:
1299
+ return GerritRestAPI(url=base_url)
1300
+
1301
+ # Try API URLs in order of preference (client creation happens in retry loop)
1219
1302
  urls: list[str] = []
1220
1303
  nums: list[str] = []
1221
1304
  shas: list[str] = []
@@ -1226,56 +1309,44 @@ class Orchestrator:
1226
1309
  # include current revision
1227
1310
  query = f"limit:1 is:open project:{repo.project_gerrit} {cid}"
1228
1311
  path = f"/changes/?q={query}&o=CURRENT_REVISION&n=1"
1229
- try:
1230
- changes = rest.get(path)
1231
- except Exception as exc:
1232
- status = getattr(
1233
- getattr(exc, "response", None), "status_code", None
1234
- )
1235
- if not base_path and status == 404:
1236
- try:
1237
- fallback_url = f"https://{gerrit.host}/r/"
1238
- if GerritRestAPI is None:
1239
- log.warning(
1240
- "pygerrit2 missing; skipping REST fallback"
1241
- )
1242
- continue
1243
- if http_user and http_pass:
1244
- if HTTPBasicAuth is None:
1245
- log.warning(
1246
- "pygerrit2 auth missing; skipping fallback"
1247
- )
1248
- continue
1249
- rest_fallback = GerritRestAPI(
1250
- url=fallback_url,
1251
- auth=HTTPBasicAuth(http_user, http_pass),
1252
- )
1253
- else:
1254
- rest_fallback = GerritRestAPI(url=fallback_url)
1255
- changes = rest_fallback.get(path)
1256
- except Exception as exc2:
1257
- log.warning(
1258
- "Failed to query change via REST for %s "
1259
- "(including '/r' fallback): %s",
1260
- cid,
1261
- exc2,
1262
- )
1263
- continue
1264
- else:
1265
- log.warning(
1266
- "Failed to query change via REST for %s: %s", cid, exc
1267
- )
1312
+ # Try API URLs in order of preference (handles fallbacks automatically)
1313
+ api_candidates = url_builder.get_api_url_candidates(path)
1314
+ changes = None
1315
+ last_error = None
1316
+
1317
+ for api_url in api_candidates:
1318
+ try:
1319
+ # Create a new client for each URL attempt
1320
+ current_rest = _create_rest_client(api_url.rsplit(path, 1)[0])
1321
+ changes = current_rest.get(path)
1322
+ break # Success, exit the retry loop
1323
+ except Exception as exc:
1324
+ last_error = exc
1325
+ log.debug("Failed API attempt for %s at %s: %s", cid, api_url, exc)
1268
1326
  continue
1327
+
1328
+ if changes is None:
1329
+ log.warning(
1330
+ "Failed to query change via REST for %s (tried %d URLs): %s",
1331
+ cid,
1332
+ len(api_candidates),
1333
+ last_error,
1334
+ )
1335
+ continue
1269
1336
  if not changes:
1270
1337
  continue
1271
1338
  change = changes[0]
1272
- num = str(change.get("_number", ""))
1273
- current_rev = change.get("current_revision", "")
1339
+ # Type guard to ensure mapping-like before dict access
1340
+ if isinstance(change, dict):
1341
+ num = str(change.get("_number", ""))
1342
+ current_rev = change.get("current_revision", "")
1343
+ else:
1344
+ # Unexpected type; skip this result
1345
+ continue
1274
1346
  # Construct a stable web URL for the change
1275
1347
  if num:
1276
- urls.append(
1277
- f"https://{gerrit.host}/c/{repo.project_gerrit}/+/{num}"
1278
- )
1348
+ change_url = url_builder.change_url(repo.project_gerrit, int(num))
1349
+ urls.append(change_url)
1279
1350
  nums.append(num)
1280
1351
  if current_rev:
1281
1352
  shas.append(current_rev)
@@ -1286,9 +1357,7 @@ class Orchestrator:
1286
1357
  os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(nums)
1287
1358
  if shas:
1288
1359
  os.environ["GERRIT_COMMIT_SHA"] = "\n".join(shas)
1289
- return SubmissionResult(
1290
- change_urls=urls, change_numbers=nums, commit_shas=shas
1291
- )
1360
+ return SubmissionResult(change_urls=urls, change_numbers=nums, commit_shas=shas)
1292
1361
 
1293
1362
  def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1294
1363
  """Initialize and set up git workspace for PR processing."""
@@ -1309,10 +1378,7 @@ class Orchestrator:
1309
1378
 
1310
1379
  # Fetch PR head
1311
1380
  if gh.pr_number:
1312
- pr_ref = (
1313
- f"refs/pull/{gh.pr_number}/head:"
1314
- f"refs/remotes/origin/pr/{gh.pr_number}/head"
1315
- )
1381
+ pr_ref = f"refs/pull/{gh.pr_number}/head:refs/remotes/origin/pr/{gh.pr_number}/head"
1316
1382
  run_cmd(
1317
1383
  [
1318
1384
  "git",
@@ -1341,13 +1407,48 @@ class Orchestrator:
1341
1407
  # Download commit-msg hook using SSH
1342
1408
  try:
1343
1409
  # Use curl to download the hook (more reliable than scp)
1344
- curl_cmd = [
1345
- "curl",
1346
- "-o",
1347
- str(hook_path),
1348
- f"https://{gerrit.host}/r/tools/hooks/commit-msg",
1349
- ]
1350
- run_cmd(curl_cmd, cwd=self.workspace)
1410
+ # Create centralized URL builder for hook URLs
1411
+ url_builder = create_gerrit_url_builder(gerrit.host)
1412
+ candidates = url_builder.get_hook_url_candidates("commit-msg")
1413
+
1414
+ last_error = ""
1415
+ installed = False
1416
+ for candidate_url in candidates:
1417
+ try:
1418
+ curl_cmd = [
1419
+ "curl",
1420
+ "-fL",
1421
+ "-o",
1422
+ str(hook_path),
1423
+ candidate_url,
1424
+ ]
1425
+ run_cmd(curl_cmd, cwd=self.workspace)
1426
+ # Make hook executable
1427
+ hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
1428
+ log.debug(
1429
+ "Successfully installed commit-msg hook from %s",
1430
+ candidate_url,
1431
+ )
1432
+ installed = True
1433
+ break
1434
+ except Exception as exc2:
1435
+ last_error = f"{candidate_url}: {exc2}"
1436
+ log.debug(
1437
+ "Failed to fetch commit-msg hook from %s: %s",
1438
+ candidate_url,
1439
+ exc2,
1440
+ )
1441
+
1442
+ def _raise_install_error() -> None:
1443
+ raise RuntimeError("Hook install failed") # noqa: TRY301, TRY003
1444
+
1445
+ if not installed:
1446
+ # Log detailed reason separately to satisfy linting rules
1447
+ log.error(
1448
+ "All commit-msg hook URLs failed. Last error: %s",
1449
+ last_error,
1450
+ )
1451
+ _raise_install_error()
1351
1452
 
1352
1453
  # Make hook executable
1353
1454
  hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
@@ -1358,34 +1459,55 @@ class Orchestrator:
1358
1459
  msg = f"Could not install commit-msg hook: {exc}"
1359
1460
  raise OrchestratorError(msg) from exc
1360
1461
 
1361
- def _ensure_change_id_present(
1362
- self, gerrit: GerritInfo, author: str
1363
- ) -> list[str]:
1462
+ def _ensure_change_id_present(self, gerrit: GerritInfo, author: str) -> list[str]:
1364
1463
  """Ensure the last commit has a Change-Id.
1365
1464
 
1366
1465
  Installs the commit-msg hook and amends the commit if needed.
1367
1466
  """
1368
- trailers = git_last_commit_trailers(
1369
- keys=["Change-Id"], cwd=self.workspace
1370
- )
1467
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1371
1468
  if not trailers.get("Change-Id"):
1372
- log.debug(
1373
- "No Change-Id found, installing commit-msg hook and amending "
1374
- "commit"
1375
- )
1376
- self._install_commit_msg_hook(gerrit)
1377
- git_commit_amend(
1378
- no_edit=True, signoff=True, author=author, cwd=self.workspace
1379
- )
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
1486
+
1487
+ current_msg = run_cmd(
1488
+ ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1489
+ cwd=self.workspace,
1490
+ ).stdout
1491
+ seed = f"{current_msg}\n{time.time()}"
1492
+ import hashlib as _hashlib # local alias to satisfy linters
1493
+
1494
+ change_id = "I" + _hashlib.sha256(seed.encode("utf-8")).hexdigest()[:40]
1495
+ if "Change-Id:" not in current_msg:
1496
+ new_msg = current_msg.rstrip() + "\n\n" + f"Change-Id: {change_id}\n"
1497
+ git_commit_amend(
1498
+ no_edit=False,
1499
+ signoff=True,
1500
+ author=author,
1501
+ message=new_msg,
1502
+ cwd=self.workspace,
1503
+ )
1380
1504
  # Debug: Check commit message after amend
1381
1505
  actual_msg = run_cmd(
1382
1506
  ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1383
1507
  cwd=self.workspace,
1384
1508
  ).stdout.strip()
1385
1509
  log.debug("Commit message after amend:\n%s", actual_msg)
1386
- trailers = git_last_commit_trailers(
1387
- keys=["Change-Id"], cwd=self.workspace
1388
- )
1510
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1389
1511
  return [c for c in trailers.get("Change-Id", []) if c]
1390
1512
 
1391
1513
  def _add_backref_comment_in_gerrit(
@@ -1408,21 +1530,14 @@ class Orchestrator:
1408
1530
  "1",
1409
1531
  "yes",
1410
1532
  ):
1411
- log.info(
1412
- "Skipping back-reference comments "
1413
- "(G2G_SKIP_GERRIT_COMMENTS=true)"
1414
- )
1533
+ log.info("Skipping back-reference comments (G2G_SKIP_GERRIT_COMMENTS=true)")
1415
1534
  return
1416
1535
 
1417
1536
  log.info("Adding back-reference comment in Gerrit")
1418
1537
  user = os.getenv("GERRIT_SSH_USER_G2G", "")
1419
1538
  server = gerrit.host
1420
1539
  pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
1421
- run_url = (
1422
- f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}"
1423
- if gh.run_id
1424
- else "N/A"
1425
- )
1540
+ run_url = f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}" if gh.run_id else "N/A"
1426
1541
  message = f"GHPR: {pr_url} | Action-Run: {run_url}"
1427
1542
  log.info("Adding back-reference comment: %s", message)
1428
1543
  for csha in commit_shas:
@@ -1430,39 +1545,84 @@ class Orchestrator:
1430
1545
  continue
1431
1546
  try:
1432
1547
  log.debug("Executing SSH command for commit %s", csha)
1433
- # Build SSH command with our configured SSH options
1434
- ssh_cmd = ["ssh", "-n", "-p", str(gerrit.port)]
1435
-
1436
- # Add our SSH options if we have custom SSH config
1437
- if self._git_ssh_command:
1438
- # Extract SSH options from GIT_SSH_COMMAND
1439
- # Format: "ssh -i /path/to/key -o Option=value ..."
1440
- git_ssh_parts = self._git_ssh_command.split()
1441
- if len(git_ssh_parts) > 1: # Skip the "ssh" part
1442
- ssh_options = git_ssh_parts[1:]
1443
- log.debug("Adding SSH options: %s", ssh_options)
1444
- ssh_cmd.extend(ssh_options)
1548
+ # Build SSH command. If isolated SSH key/known_hosts are
1549
+ # available, use strict options; otherwise fall back to the
1550
+ # minimal form expected by tests.
1551
+ if self._ssh_key_path and self._ssh_known_hosts_path:
1552
+ ssh_cmd = [
1553
+ "ssh",
1554
+ "-F",
1555
+ "/dev/null",
1556
+ "-i",
1557
+ str(self._ssh_key_path),
1558
+ "-o",
1559
+ f"UserKnownHostsFile={self._ssh_known_hosts_path}",
1560
+ "-o",
1561
+ "IdentitiesOnly=yes",
1562
+ "-o",
1563
+ "IdentityAgent=none",
1564
+ "-o",
1565
+ "BatchMode=yes",
1566
+ "-o",
1567
+ "StrictHostKeyChecking=yes",
1568
+ "-o",
1569
+ "PasswordAuthentication=no",
1570
+ "-o",
1571
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
1572
+ "-n",
1573
+ "-p",
1574
+ str(gerrit.port),
1575
+ f"{user}@{server}",
1576
+ (
1577
+ "gerrit review -m "
1578
+ f"{shlex.quote(message)} "
1579
+ "--branch "
1580
+ f"{shlex.quote(branch)} "
1581
+ "--project "
1582
+ f"{shlex.quote(repo.project_gerrit)} "
1583
+ f"{shlex.quote(csha)}"
1584
+ ),
1585
+ ]
1445
1586
  else:
1446
- log.debug("No custom SSH config, using default SSH options")
1447
-
1448
- # Add the target and gerrit command
1449
- ssh_cmd.extend(
1450
- [
1587
+ # Strict non-interactive SSH without
1588
+ # isolated key/known_hosts
1589
+ ssh_cmd = [
1590
+ "ssh",
1591
+ "-F",
1592
+ "/dev/null",
1593
+ "-o",
1594
+ "IdentitiesOnly=yes",
1595
+ "-o",
1596
+ "IdentityAgent=none",
1597
+ "-o",
1598
+ "BatchMode=yes",
1599
+ "-o",
1600
+ "StrictHostKeyChecking=yes",
1601
+ "-o",
1602
+ "PasswordAuthentication=no",
1603
+ "-o",
1604
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
1605
+ "-n",
1606
+ "-p",
1607
+ str(gerrit.port),
1451
1608
  f"{user}@{server}",
1452
- "gerrit",
1453
- "review",
1454
- "-m",
1455
- message,
1456
- "--branch",
1457
- branch,
1458
- "--project",
1459
- repo.project_gerrit,
1460
- csha,
1609
+ (
1610
+ "gerrit review -m "
1611
+ f"{shlex.quote(message)} "
1612
+ "--branch "
1613
+ f"{shlex.quote(branch)} "
1614
+ "--project "
1615
+ f"{shlex.quote(repo.project_gerrit)} "
1616
+ f"{shlex.quote(csha)}"
1617
+ ),
1461
1618
  ]
1462
- )
1463
1619
 
1464
1620
  log.debug("Final SSH command: %s", " ".join(ssh_cmd))
1465
- run_cmd(ssh_cmd, cwd=self.workspace)
1621
+ run_cmd(
1622
+ ssh_cmd,
1623
+ cwd=self.workspace,
1624
+ env=self._ssh_env(),
1625
+ )
1466
1626
  log.info(
1467
1627
  "Successfully added back-reference comment for %s: %s",
1468
1628
  csha,
@@ -1470,8 +1630,7 @@ class Orchestrator:
1470
1630
  )
1471
1631
  except CommandError as exc:
1472
1632
  log.warning(
1473
- "Failed to add back-reference comment for %s "
1474
- "(non-fatal): %s",
1633
+ "Failed to add back-reference comment for %s (non-fatal): %s",
1475
1634
  csha,
1476
1635
  exc,
1477
1636
  )
@@ -1487,14 +1646,11 @@ class Orchestrator:
1487
1646
  # Continue processing - this is not a fatal error
1488
1647
  except Exception as exc:
1489
1648
  log.warning(
1490
- "Failed to add back-reference comment for %s "
1491
- "(non-fatal): %s",
1649
+ "Failed to add back-reference comment for %s (non-fatal): %s",
1492
1650
  csha,
1493
1651
  exc,
1494
1652
  )
1495
- log.debug(
1496
- "Back-reference comment failure details:", exc_info=True
1497
- )
1653
+ log.debug("Back-reference comment failure details:", exc_info=True)
1498
1654
  # Continue processing - this is not a fatal error
1499
1655
 
1500
1656
  def _comment_on_pull_request(
@@ -1509,10 +1665,10 @@ class Orchestrator:
1509
1665
  return
1510
1666
  urls = result.change_urls or []
1511
1667
  org = os.getenv("ORGANIZATION", gh.repository_owner)
1512
- text = (
1513
- f"The pull-request PR-{gh.pr_number} is submitted to Gerrit "
1514
- f"[{org}](https://{gerrit.host})!\n\n"
1515
- )
1668
+ # Create centralized URL builder for organization link
1669
+ url_builder = create_gerrit_url_builder(gerrit.host)
1670
+ org_url = url_builder.web_url()
1671
+ text = f"The pull-request PR-{gh.pr_number} is submitted to Gerrit [{org}]({org_url})!\n\n"
1516
1672
  if urls:
1517
1673
  text += "To follow up on the change visit:\n\n" + "\n".join(urls)
1518
1674
  try:
@@ -1521,6 +1677,13 @@ class Orchestrator:
1521
1677
  # At this point, gh.pr_number is non-None due to earlier guard.
1522
1678
  pr_obj = get_pull(repo, int(gh.pr_number))
1523
1679
  create_pr_comment(pr_obj, text)
1680
+ # Also post a succinct one-line comment
1681
+ # for each Gerrit change URL
1682
+ for u in urls:
1683
+ create_pr_comment(
1684
+ pr_obj,
1685
+ f"Change raised in Gerrit by GitHub2Gerrit: {u}",
1686
+ )
1524
1687
  except Exception as exc:
1525
1688
  log.warning("Failed to add PR comment: %s", exc)
1526
1689
 
@@ -1582,20 +1745,15 @@ class Orchestrator:
1582
1745
  "yes",
1583
1746
  "on",
1584
1747
  ):
1748
+ log.info("Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)")
1585
1749
  log.info(
1586
- "Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)"
1587
- )
1588
- log.info(
1589
- "Dry-run targets: Gerrit project=%s branch=%s "
1590
- "topic_prefix=GH-%s",
1750
+ "Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
1591
1751
  repo.project_gerrit,
1592
1752
  self._resolve_target_branch(),
1593
1753
  repo.project_github,
1594
1754
  )
1595
1755
  if inputs.reviewers_email:
1596
- log.info(
1597
- "Reviewers (from inputs/config): %s", inputs.reviewers_email
1598
- )
1756
+ log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
1599
1757
  elif os.getenv("REVIEWERS_EMAIL"):
1600
1758
  log.info(
1601
1759
  "Reviewers (from environment): %s",
@@ -1606,18 +1764,14 @@ class Orchestrator:
1606
1764
  # DNS resolution for Gerrit host
1607
1765
  try:
1608
1766
  socket.getaddrinfo(gerrit.host, None)
1609
- log.info(
1610
- "DNS resolution for Gerrit host '%s' succeeded", gerrit.host
1611
- )
1767
+ log.info("DNS resolution for Gerrit host '%s' succeeded", gerrit.host)
1612
1768
  except Exception as exc:
1613
1769
  msg = "DNS resolution failed"
1614
1770
  raise OrchestratorError(msg) from exc
1615
1771
 
1616
1772
  # SSH (TCP) reachability on Gerrit port
1617
1773
  try:
1618
- with socket.create_connection(
1619
- (gerrit.host, gerrit.port), timeout=5
1620
- ):
1774
+ with socket.create_connection((gerrit.host, gerrit.port), timeout=5):
1621
1775
  pass
1622
1776
  log.info(
1623
1777
  "SSH TCP connectivity to %s:%s verified",
@@ -1630,10 +1784,7 @@ class Orchestrator:
1630
1784
 
1631
1785
  # Gerrit REST reachability and optional auth check
1632
1786
  base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1633
- http_user = (
1634
- os.getenv("GERRIT_HTTP_USER", "").strip()
1635
- or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1636
- )
1787
+ http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1637
1788
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1638
1789
  self._verify_gerrit_rest(gerrit.host, base_path, http_user, http_pass)
1639
1790
 
@@ -1643,9 +1794,7 @@ class Orchestrator:
1643
1794
  repo_obj = get_repo_from_env(client)
1644
1795
  if gh.pr_number is not None:
1645
1796
  pr_obj = get_pull(repo_obj, gh.pr_number)
1646
- log.info(
1647
- "GitHub PR #%s metadata loaded successfully", gh.pr_number
1648
- )
1797
+ log.info("GitHub PR #%s metadata loaded successfully", gh.pr_number)
1649
1798
  try:
1650
1799
  title, _ = get_pr_title_body(pr_obj)
1651
1800
  log.info("GitHub PR title: %s", title)
@@ -1671,13 +1820,9 @@ class Orchestrator:
1671
1820
  repo.project_github,
1672
1821
  )
1673
1822
  if inputs.reviewers_email:
1674
- log.info(
1675
- "Reviewers (from inputs/config): %s", inputs.reviewers_email
1676
- )
1823
+ log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
1677
1824
  elif os.getenv("REVIEWERS_EMAIL"):
1678
- log.info(
1679
- "Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL")
1680
- )
1825
+ log.info("Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL"))
1681
1826
 
1682
1827
  def _verify_gerrit_rest(
1683
1828
  self,
@@ -1691,15 +1836,13 @@ class Orchestrator:
1691
1836
  def _build_client(url: str) -> Any:
1692
1837
  if http_user and http_pass:
1693
1838
  if GerritRestAPI is None:
1694
- raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1839
+ raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1695
1840
  if HTTPBasicAuth is None:
1696
- raise OrchestratorError("pygerrit2 auth missing") # noqa: TRY003
1697
- return GerritRestAPI(
1698
- url=url, auth=HTTPBasicAuth(http_user, http_pass)
1699
- )
1841
+ raise OrchestratorError(_MSG_PYGERRIT2_AUTH_MISSING)
1842
+ return GerritRestAPI(url=url, auth=HTTPBasicAuth(http_user, http_pass))
1700
1843
  else:
1701
1844
  if GerritRestAPI is None:
1702
- raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1845
+ raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1703
1846
  return GerritRestAPI(url=url)
1704
1847
 
1705
1848
  def _probe(url: str) -> None:
@@ -1714,29 +1857,30 @@ class Orchestrator:
1714
1857
  _ = rest.get("/dashboard/self")
1715
1858
  log.info("Gerrit REST endpoint reachable (unauthenticated)")
1716
1859
 
1717
- base_url = (
1718
- f"https://{host}/"
1719
- if not base_path
1720
- else f"https://{host}/{base_path}/"
1721
- )
1722
- try:
1723
- _probe(base_url)
1724
- except Exception as exc:
1725
- status = getattr(
1726
- getattr(exc, "response", None), "status_code", None
1860
+ # Create centralized URL builder for REST probing
1861
+ url_builder = create_gerrit_url_builder(host, base_path)
1862
+ api_candidates = url_builder.get_api_url_candidates()
1863
+
1864
+ # Try API URLs in order of preference
1865
+ probe_successful = False
1866
+ last_error = None
1867
+
1868
+ for api_url in api_candidates:
1869
+ try:
1870
+ _probe(api_url)
1871
+ probe_successful = True
1872
+ break
1873
+ except Exception as exc:
1874
+ last_error = exc
1875
+ log.debug("Gerrit REST probe failed for %s: %s", api_url, exc)
1876
+ continue
1877
+
1878
+ if not probe_successful and last_error:
1879
+ log.warning(
1880
+ "Gerrit REST probe did not succeed (tried %d URLs): %s",
1881
+ len(api_candidates),
1882
+ last_error,
1727
1883
  )
1728
- if not base_path and status == 404:
1729
- try:
1730
- fallback_url = f"https://{host}/r/"
1731
- _probe(fallback_url)
1732
- except Exception as exc2:
1733
- log.warning(
1734
- "Gerrit REST probe did not succeed "
1735
- "(including '/r' fallback): %s",
1736
- exc2,
1737
- )
1738
- else:
1739
- log.warning("Gerrit REST probe did not succeed: %s", exc)
1740
1884
 
1741
1885
  # ---------------
1742
1886
  # Helpers