github2gerrit 0.1.5__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,6 +39,7 @@ from dataclasses import dataclass
38
39
  from pathlib import Path
39
40
  from typing import Any
40
41
 
42
+ from .gerrit_urls import create_gerrit_url_builder
41
43
  from .github_api import build_client
42
44
  from .github_api import close_pr
43
45
  from .github_api import create_pr_comment
@@ -80,9 +82,7 @@ def _is_verbose_mode() -> bool:
80
82
  return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
81
83
 
82
84
 
83
- def _log_exception_conditionally(
84
- logger: logging.Logger, message: str, *args: Any
85
- ) -> None:
85
+ def _log_exception_conditionally(logger: logging.Logger, message: str, *args: Any) -> None:
86
86
  """Log exception with traceback only if verbose mode is enabled."""
87
87
  if _is_verbose_mode():
88
88
  logger.exception(message, *args)
@@ -125,10 +125,7 @@ def _insert_issue_id_into_commit_message(message: str, issue_id: str) -> str:
125
125
  raise ValueError(_MSG_ISSUE_ID_MULTILINE)
126
126
 
127
127
  # 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}"
128
+ issue_line = cleaned_issue_id if cleaned_issue_id.startswith("Issue-ID:") else f"Issue-ID: {cleaned_issue_id}"
132
129
 
133
130
  lines = message.splitlines()
134
131
  if not lines:
@@ -263,14 +260,34 @@ class Orchestrator:
263
260
 
264
261
  if inputs.dry_run:
265
262
  # Perform preflight validations and exit without making changes
266
- self._dry_run_preflight(
267
- gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names
268
- )
263
+ self._dry_run_preflight(gerrit=gerrit, inputs=inputs, gh=gh, repo=repo_names)
269
264
  log.info("Dry run complete; skipping write operations to Gerrit")
270
- return SubmissionResult(
271
- change_urls=[], change_numbers=[], commit_shas=[]
272
- )
265
+ return SubmissionResult(change_urls=[], change_numbers=[], commit_shas=[])
273
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,
279
+ )
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)
274
291
 
275
292
  if inputs.submit_single_commits:
276
293
  prep = self._prepare_single_commits(inputs, gh, gerrit)
@@ -354,13 +371,8 @@ class Orchestrator:
354
371
  repo_obj: Any = get_repo_from_env(client)
355
372
  # Prefer a specific ref when available; otherwise default branch
356
373
  ref = os.getenv("GITHUB_HEAD_REF") or os.getenv("GITHUB_SHA")
357
- if ref:
358
- content = repo_obj.get_contents(".gitreview", ref=ref)
359
- else:
360
- content = repo_obj.get_contents(".gitreview")
361
- text_remote = (
362
- getattr(content, "decoded_content", b"") or b""
363
- ).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")
364
376
  info_remote = self._parse_gitreview_text(text_remote)
365
377
  if info_remote:
366
378
  log.debug("Parsed remote .gitreview: %s", info_remote)
@@ -370,14 +382,7 @@ class Orchestrator:
370
382
  log.debug("Remote .gitreview not available: %s", exc)
371
383
  # Attempt raw.githubusercontent.com as a fallback
372
384
  try:
373
- repo_full = (
374
- (
375
- gh.repository
376
- if gh
377
- else os.getenv("GITHUB_REPOSITORY", "")
378
- )
379
- or ""
380
- ).strip()
385
+ repo_full = ((gh.repository if gh else os.getenv("GITHUB_REPOSITORY", "")) or "").strip()
381
386
  branches: list[str] = []
382
387
  # Prefer PR head/base refs via GitHub API when running
383
388
  # from a direct URL when a token is available
@@ -391,18 +396,8 @@ class Orchestrator:
391
396
  client = build_client()
392
397
  repo_obj = get_repo_from_env(client)
393
398
  pr_obj = get_pull(repo_obj, int(gh.pr_number))
394
- api_head = str(
395
- getattr(
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
- )
399
+ api_head = str(getattr(getattr(pr_obj, "head", object()), "ref", "") or "")
400
+ api_base = str(getattr(getattr(pr_obj, "base", object()), "ref", "") or "")
406
401
  if api_head:
407
402
  branches.append(api_head)
408
403
  if api_base:
@@ -422,15 +417,9 @@ class Orchestrator:
422
417
  if not br or br in tried:
423
418
  continue
424
419
  tried.add(br)
425
- url = (
426
- f"https://raw.githubusercontent.com/"
427
- f"{repo_full}/refs/heads/{br}/.gitreview"
428
- )
420
+ url = f"https://raw.githubusercontent.com/{repo_full}/refs/heads/{br}/.gitreview"
429
421
  parsed = urllib.parse.urlparse(url)
430
- if (
431
- parsed.scheme != "https"
432
- or parsed.netloc != "raw.githubusercontent.com"
433
- ):
422
+ if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
434
423
  continue
435
424
  log.info("Fetching .gitreview via raw URL: %s", url)
436
425
  with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
@@ -547,13 +536,8 @@ class Orchestrator:
547
536
 
548
537
  # Auto-discover host keys if not provided
549
538
  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
- )
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...")
557
541
  try:
558
542
  discovered_keys = auto_discover_gerrit_host_keys(
559
543
  gerrit_hostname=gerrit.host,
@@ -569,18 +553,12 @@ class Orchestrator:
569
553
  gerrit.port,
570
554
  )
571
555
  else:
572
- log.warning(
573
- "Auto-discovery failed, SSH host key verification may "
574
- "fail"
575
- )
556
+ log.warning("Auto-discovery failed, SSH host key verification may fail")
576
557
  except Exception as exc:
577
558
  log.warning("SSH host key auto-discovery failed: %s", exc)
578
559
 
579
560
  if not effective_known_hosts:
580
- log.debug(
581
- "No SSH host keys available (manual or auto-discovered), "
582
- "skipping SSH setup"
583
- )
561
+ log.debug("No SSH host keys available (manual or auto-discovered), skipping SSH setup")
584
562
  return
585
563
 
586
564
  log.info("Setting up temporary SSH configuration for Gerrit")
@@ -624,9 +602,13 @@ class Orchestrator:
624
602
 
625
603
  # Build SSH command with strict options to prevent key scanning
626
604
  ssh_options = [
605
+ "-F /dev/null",
627
606
  f"-i {self._ssh_key_path}",
628
607
  f"-o UserKnownHostsFile={self._ssh_known_hosts_path}",
629
608
  "-o IdentitiesOnly=yes", # Critical: prevents SSH agent scanning
609
+ "-o IdentityAgent=none",
610
+ "-o BatchMode=yes",
611
+ "-o PreferredAuthentications=publickey",
630
612
  "-o StrictHostKeyChecking=yes",
631
613
  "-o PasswordAuthentication=no",
632
614
  "-o PubkeyAcceptedKeyTypes=+ssh-rsa",
@@ -638,15 +620,35 @@ class Orchestrator:
638
620
  log.debug("Generated SSH command: %s", masked_cmd)
639
621
  return ssh_cmd
640
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
+
641
645
  def _cleanup_ssh(self) -> None:
642
646
  """Clean up temporary SSH files created by this tool.
643
647
 
644
648
  Removes the workspace-specific .ssh-g2g directory and all contents.
645
649
  This ensures no temporary files are left behind.
646
650
  """
647
- if not hasattr(self, "_ssh_key_path") or not hasattr(
648
- self, "_ssh_known_hosts_path"
649
- ):
651
+ if not hasattr(self, "_ssh_key_path") or not hasattr(self, "_ssh_known_hosts_path"):
650
652
  return
651
653
 
652
654
  try:
@@ -656,9 +658,7 @@ class Orchestrator:
656
658
  import shutil
657
659
 
658
660
  shutil.rmtree(tool_ssh_dir)
659
- log.debug(
660
- "Cleaned up temporary SSH directory: %s", tool_ssh_dir
661
- )
661
+ log.debug("Cleaned up temporary SSH directory: %s", tool_ssh_dir)
662
662
  except Exception as exc:
663
663
  log.warning("Failed to clean up temporary SSH files: %s", exc)
664
664
 
@@ -678,9 +678,7 @@ class Orchestrator:
678
678
  cwd=self.workspace,
679
679
  )
680
680
  except GitError:
681
- git_config(
682
- "gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True
683
- )
681
+ git_config("gitreview.username", inputs.gerrit_ssh_user_g2g, global_=True)
684
682
  try:
685
683
  git_config(
686
684
  "user.name",
@@ -698,9 +696,26 @@ class Orchestrator:
698
696
  cwd=self.workspace,
699
697
  )
700
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:
702
+ git_config(
703
+ "commit.gpgsign",
704
+ "false",
705
+ global_=False,
706
+ cwd=self.workspace,
707
+ )
708
+ except GitError:
709
+ git_config("commit.gpgsign", "false", global_=True)
710
+ try:
701
711
  git_config(
702
- "user.email", inputs.gerrit_ssh_user_g2g_email, global_=True
712
+ "tag.gpgsign",
713
+ "false",
714
+ global_=False,
715
+ cwd=self.workspace,
703
716
  )
717
+ except GitError:
718
+ git_config("tag.gpgsign", "false", global_=True)
704
719
 
705
720
  # Ensure git-review host/port/project are configured
706
721
  # when .gitreview is absent
@@ -736,16 +751,10 @@ class Orchestrator:
736
751
  )
737
752
  except CommandError:
738
753
  ssh_user = inputs.gerrit_ssh_user_g2g.strip()
739
- remote_url = (
740
- f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
741
- )
754
+ remote_url = f"ssh://{ssh_user}@{gerrit.host}:{gerrit.port}/{gerrit.project}"
742
755
  log.info("Adding 'gerrit' remote: %s", remote_url)
743
756
  # 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
- )
757
+ env = self._ssh_env()
749
758
  run_cmd(
750
759
  ["git", "remote", "add", "gerrit", remote_url],
751
760
  check=False,
@@ -754,9 +763,7 @@ class Orchestrator:
754
763
  )
755
764
 
756
765
  # Workaround for submodules commit-msg hook
757
- hooks_path = run_cmd(
758
- ["git", "rev-parse", "--show-toplevel"], cwd=self.workspace
759
- ).stdout.strip()
766
+ hooks_path = run_cmd(["git", "rev-parse", "--show-toplevel"], cwd=self.workspace).stdout.strip()
760
767
  try:
761
768
  git_config(
762
769
  "core.hooksPath",
@@ -772,11 +779,7 @@ class Orchestrator:
772
779
  # Initialize git-review (copies commit-msg hook)
773
780
  try:
774
781
  # 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
- )
782
+ env = self._ssh_env()
780
783
  run_cmd(["git", "review", "-s", "-v"], cwd=self.workspace, env=env)
781
784
  except CommandError as exc:
782
785
  msg = f"Failed to initialize git-review: {exc}"
@@ -794,12 +797,12 @@ class Orchestrator:
794
797
  # Determine commit range: commits in HEAD not in base branch
795
798
  base_ref = f"origin/{branch}"
796
799
  # Use our SSH command for git operations that might need SSH
797
- env = (
798
- {"GIT_SSH_COMMAND": self._git_ssh_command}
799
- if self._git_ssh_command
800
- else None
800
+
801
+ run_cmd(
802
+ ["git", "fetch", "origin", branch],
803
+ cwd=self.workspace,
804
+ env=self._ssh_env(),
801
805
  )
802
- run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
803
806
  revs = run_cmd(
804
807
  ["git", "rev-list", "--reverse", f"{base_ref}..HEAD"],
805
808
  cwd=self.workspace,
@@ -809,14 +812,10 @@ class Orchestrator:
809
812
  log.info("No commits to submit; returning empty PreparedChange")
810
813
  return PreparedChange(change_ids=[], commit_shas=[])
811
814
  # 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()
815
+ base_sha = run_cmd(["git", "rev-parse", base_ref], cwd=self.workspace).stdout.strip()
815
816
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
816
817
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
817
- run_cmd(
818
- ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
819
- )
818
+ run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
820
819
  change_ids: list[str] = []
821
820
  for csha in commit_list:
822
821
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
@@ -826,13 +825,9 @@ class Orchestrator:
826
825
  ["git", "show", "-s", "--pretty=format:%an <%ae>", csha],
827
826
  cwd=self.workspace,
828
827
  ).stdout.strip()
829
- git_commit_amend(
830
- author=author, no_edit=True, signoff=True, cwd=self.workspace
831
- )
828
+ git_commit_amend(author=author, no_edit=True, signoff=True, cwd=self.workspace)
832
829
  # Extract newly added Change-Id from last commit trailers
833
- trailers = git_last_commit_trailers(
834
- keys=["Change-Id"], cwd=self.workspace
835
- )
830
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
836
831
  for cid in trailers.get("Change-Id", []):
837
832
  if cid:
838
833
  change_ids.append(cid)
@@ -866,26 +861,20 @@ class Orchestrator:
866
861
  """Squash PR commits into a single commit and handle Change-Id."""
867
862
  log.info("Preparing squashed commit for PR #%s", gh.pr_number)
868
863
  branch = self._resolve_target_branch()
869
- env = (
870
- {"GIT_SSH_COMMAND": self._git_ssh_command}
871
- if self._git_ssh_command
872
- else None
864
+
865
+ run_cmd(
866
+ ["git", "fetch", "origin", branch],
867
+ cwd=self.workspace,
868
+ env=self._ssh_env(),
873
869
  )
874
- run_cmd(["git", "fetch", "origin", branch], cwd=self.workspace, env=env)
875
870
  base_ref = f"origin/{branch}"
876
- base_sha = run_cmd(
877
- ["git", "rev-parse", base_ref], cwd=self.workspace
878
- ).stdout.strip()
879
- head_sha = run_cmd(
880
- ["git", "rev-parse", "HEAD"], cwd=self.workspace
881
- ).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()
882
873
 
883
874
  # Create temp branch from base and merge-squash PR head
884
875
  tmp_branch = f"g2g_tmp_{gh.pr_number or 'pr'!s}_{os.getpid()}"
885
876
  os.environ["G2G_TMP_BRANCH"] = tmp_branch
886
- run_cmd(
887
- ["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace
888
- )
877
+ run_cmd(["git", "checkout", "-b", tmp_branch, base_sha], cwd=self.workspace)
889
878
  run_cmd(["git", "merge", "--squash", head_sha], cwd=self.workspace)
890
879
 
891
880
  def _collect_log_lines() -> list[str]:
@@ -913,18 +902,13 @@ class Orchestrator:
913
902
  message_lines: list[str] = []
914
903
  in_metadata_section = False
915
904
  for ln in lines:
916
- if ln.strip() in ("---", "```") or ln.startswith(
917
- "updated-dependencies:"
918
- ):
905
+ if ln.strip() in ("---", "```") or ln.startswith("updated-dependencies:"):
919
906
  in_metadata_section = True
920
907
  continue
921
908
  if in_metadata_section:
922
909
  if ln.startswith(("- dependency-", " dependency-")):
923
910
  continue
924
- if (
925
- not ln.startswith((" ", "-", "dependency-"))
926
- and ln.strip()
927
- ):
911
+ if not ln.startswith((" ", "-", "dependency-")) and ln.strip():
928
912
  in_metadata_section = False
929
913
  if ln.startswith("Change-Id:"):
930
914
  cid = ln.split(":", 1)[1].strip()
@@ -955,17 +939,11 @@ class Orchestrator:
955
939
  break_points = [". ", "! ", "? ", " - ", ": "]
956
940
  for bp in break_points:
957
941
  if bp in title_line[:100]:
958
- title_line = title_line[
959
- : title_line.index(bp) + len(bp.strip())
960
- ]
942
+ title_line = title_line[: title_line.index(bp) + len(bp.strip())]
961
943
  break
962
944
  else:
963
945
  words = title_line[:100].split()
964
- title_line = (
965
- " ".join(words[:-1])
966
- if len(words) > 1
967
- else title_line[:100].rstrip()
968
- )
946
+ title_line = " ".join(words[:-1]) if len(words) > 1 else title_line[:100].rstrip()
969
947
  return title_line
970
948
 
971
949
  def _build_clean_message_lines(message_lines: list[str]) -> list[str]:
@@ -975,10 +953,7 @@ class Orchestrator:
975
953
  out: list[str] = [title_line]
976
954
  if len(message_lines) > 1:
977
955
  body_start = 1
978
- while (
979
- body_start < len(message_lines)
980
- and not message_lines[body_start].strip()
981
- ):
956
+ while body_start < len(message_lines) and not message_lines[body_start].strip():
982
957
  body_start += 1
983
958
  if body_start < len(message_lines):
984
959
  out.append("")
@@ -987,9 +962,7 @@ class Orchestrator:
987
962
 
988
963
  def _maybe_reuse_change_id(pr_str: str) -> str:
989
964
  reuse = ""
990
- sync_all_prs = (
991
- os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
992
- )
965
+ sync_all_prs = os.getenv("SYNC_ALL_OPEN_PRS", "false").lower() == "true"
993
966
  if (
994
967
  not sync_all_prs
995
968
  and gh.event_name == "pull_request_target"
@@ -999,9 +972,7 @@ class Orchestrator:
999
972
  client = build_client()
1000
973
  repo = get_repo_from_env(client)
1001
974
  pr_obj = get_pull(repo, int(pr_str))
1002
- cand = get_recent_change_ids_from_comments(
1003
- pr_obj, max_comments=50
1004
- )
975
+ cand = get_recent_change_ids_from_comments(pr_obj, max_comments=50)
1005
976
  if cand:
1006
977
  reuse = cand[-1]
1007
978
  log.debug(
@@ -1023,12 +994,8 @@ class Orchestrator:
1023
994
  signed_off: list[str],
1024
995
  reuse_cid: str,
1025
996
  ) -> str:
1026
- from .duplicate_detection import DuplicateDetector
1027
-
1028
997
  msg = "\n".join(lines_in).strip()
1029
998
  msg = _insert_issue_id_into_commit_message(msg, inputs.issue_id)
1030
- github_hash = DuplicateDetector._generate_github_change_hash(gh)
1031
- msg += f"\n\nGitHub-Hash: {github_hash}"
1032
999
  if signed_off:
1033
1000
  msg += "\n\n" + "\n".join(signed_off)
1034
1001
  if reuse_cid:
@@ -1037,9 +1004,7 @@ class Orchestrator:
1037
1004
 
1038
1005
  # Build message parts
1039
1006
  raw_lines = _collect_log_lines()
1040
- message_lines, signed_off, _existing_cids = _parse_message_parts(
1041
- raw_lines
1042
- )
1007
+ message_lines, signed_off, _existing_cids = _parse_message_parts(raw_lines)
1043
1008
  clean_lines = _build_clean_message_lines(message_lines)
1044
1009
  pr_str = str(gh.pr_number or "").strip()
1045
1010
  reuse_cid = _maybe_reuse_change_id(pr_str)
@@ -1113,29 +1078,51 @@ class Orchestrator:
1113
1078
  title = re.sub(r"[*_`]", "", title)
1114
1079
  title = title.strip()
1115
1080
 
1116
- # Compose message; preserve any Signed-off-by lines
1117
- current_body = git_show("HEAD", fmt="%B")
1118
- signed = [
1119
- ln
1120
- for ln in current_body.splitlines()
1121
- if ln.startswith("Signed-off-by:")
1122
- ]
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
+
1123
1090
  msg_parts = [title, "", body] if title or body else [current_body]
1124
1091
  commit_message = "\n".join(msg_parts).strip()
1125
1092
 
1126
1093
  # Add Issue-ID if provided
1127
- commit_message = _insert_issue_id_into_commit_message(
1128
- commit_message, inputs.issue_id
1129
- )
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)
1130
1116
 
1131
- if signed:
1132
- commit_message += "\n\n" + "\n".join(signed)
1133
1117
  author = run_cmd(
1134
- ["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(),
1135
1121
  ).stdout.strip()
1136
1122
  git_commit_amend(
1123
+ cwd=self.workspace,
1137
1124
  no_edit=False,
1138
- signoff=not bool(signed),
1125
+ signoff=not bool(signed_lines),
1139
1126
  author=author,
1140
1127
  message=commit_message,
1141
1128
  )
@@ -1163,10 +1150,7 @@ class Orchestrator:
1163
1150
  run_cmd(["git", "checkout", tmp_branch], cwd=self.workspace)
1164
1151
  prefix = os.getenv("G2G_TOPIC_PREFIX", "GH").strip() or "GH"
1165
1152
  pr_num = os.getenv("PR_NUMBER", "").strip()
1166
- if pr_num:
1167
- topic = f"{prefix}-{repo.project_github}-{pr_num}"
1168
- else:
1169
- topic = f"{prefix}-{repo.project_github}"
1153
+ topic = f"{prefix}-{repo.project_github}-{pr_num}" if pr_num else f"{prefix}-{repo.project_github}"
1170
1154
  try:
1171
1155
  args = [
1172
1156
  "git",
@@ -1176,33 +1160,22 @@ class Orchestrator:
1176
1160
  "-t",
1177
1161
  topic,
1178
1162
  ]
1179
- revs = [
1180
- r.strip() for r in (reviewers or "").split(",") if r.strip()
1181
- ]
1163
+ revs = [r.strip() for r in (reviewers or "").split(",") if r.strip()]
1182
1164
  for r in revs:
1183
1165
  args.extend(["--reviewer", r])
1184
1166
  # Branch as positional argument (not a flag)
1185
1167
  args.append(branch)
1186
1168
 
1187
1169
  # Use our specific SSH configuration
1188
- env = (
1189
- {"GIT_SSH_COMMAND": self._git_ssh_command}
1190
- if self._git_ssh_command
1191
- else None
1192
- )
1170
+ env = self._ssh_env()
1193
1171
  log.debug("Executing git review command: %s", " ".join(args))
1194
1172
  run_cmd(args, cwd=self.workspace, env=env)
1195
1173
  log.info("Successfully pushed changes to Gerrit")
1196
1174
  except CommandError as exc:
1197
1175
  # Analyze the specific failure reason from git review output
1198
1176
  error_details = self._analyze_gerrit_push_failure(exc)
1199
- _log_exception_conditionally(
1200
- log, "Gerrit push failed: %s", error_details
1201
- )
1202
- msg = (
1203
- f"Failed to push changes to Gerrit with git-review: "
1204
- f"{error_details}"
1205
- )
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}"
1206
1179
  raise OrchestratorError(msg) from exc
1207
1180
  # Cleanup temporary branch used during preparation
1208
1181
  tmp_branch = (os.getenv("G2G_TMP_BRANCH", "") or "").strip()
@@ -1212,11 +1185,13 @@ class Orchestrator:
1212
1185
  ["git", "checkout", f"origin/{branch}"],
1213
1186
  check=False,
1214
1187
  cwd=self.workspace,
1188
+ env=env,
1215
1189
  )
1216
1190
  run_cmd(
1217
1191
  ["git", "branch", "-D", tmp_branch],
1218
1192
  check=False,
1219
1193
  cwd=self.workspace,
1194
+ env=env,
1220
1195
  )
1221
1196
 
1222
1197
  def _analyze_gerrit_push_failure(self, exc: CommandError) -> str:
@@ -1242,10 +1217,7 @@ class Orchestrator:
1242
1217
  "'ssh-keyscan -p 29418 <gerrit-host>' "
1243
1218
  "to get the current host keys."
1244
1219
  )
1245
- elif (
1246
- "authenticity of host" in combined_lower
1247
- and "can't be established" in combined_lower
1248
- ):
1220
+ elif "authenticity of host" in combined_lower and "can't be established" in combined_lower:
1249
1221
  return (
1250
1222
  "SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
1251
1223
  "contain the host key for the Gerrit server. "
@@ -1255,37 +1227,18 @@ class Orchestrator:
1255
1227
  "'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
1256
1228
  )
1257
1229
  # Check for specific SSH key issues before general permission denied
1258
- elif (
1259
- "key_load_public" in combined_lower
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
- )
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."
1266
1232
  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
- )
1233
+ return "SSH key type not supported by server. The server may not accept this SSH key algorithm."
1271
1234
  elif "authentication failed" in combined_lower:
1272
- return (
1273
- "SSH authentication failed - check SSH key, username, and "
1274
- "server configuration"
1275
- )
1235
+ return "SSH authentication failed - check SSH key, username, and server configuration"
1276
1236
  # Check for connection timeout/refused before "could not read" check
1277
- elif (
1278
- "connection timed out" in combined_lower
1279
- or "connection refused" in combined_lower
1280
- ):
1281
- return (
1282
- "Connection failed - check network connectivity and "
1283
- "Gerrit server availability"
1284
- )
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"
1285
1239
  # Check for specific SSH publickey-only authentication failures
1286
1240
  elif "permission denied (publickey)" in combined_lower and not any(
1287
- auth_method in combined_lower
1288
- for auth_method in ["gssapi", "password", "keyboard"]
1241
+ auth_method in combined_lower for auth_method in ["gssapi", "password", "keyboard"]
1289
1242
  ):
1290
1243
  return (
1291
1244
  "SSH public key authentication failed. The SSH key may be "
@@ -1295,19 +1248,13 @@ class Orchestrator:
1295
1248
  elif "permission denied" in combined_lower:
1296
1249
  return "SSH permission denied - check SSH key and user permissions"
1297
1250
  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
- )
1251
+ return "Could not read from remote repository - check SSH authentication and repository access permissions"
1302
1252
  # Check for Gerrit-specific issues
1303
1253
  elif "missing issue-id" in combined_lower:
1304
1254
  return "Missing Issue-ID in commit message."
1305
1255
  elif "commit not associated to any issue" in combined_lower:
1306
1256
  return "Commit not associated to any issue."
1307
- elif (
1308
- "remote rejected" in combined_lower
1309
- and "refs/for/" in combined_lower
1310
- ):
1257
+ elif "remote rejected" in combined_lower and "refs/for/" in combined_lower:
1311
1258
  # Extract specific rejection reason from output
1312
1259
  lines = combined_output.split("\n")
1313
1260
  for line in lines:
@@ -1330,28 +1277,28 @@ class Orchestrator:
1330
1277
  ) -> SubmissionResult:
1331
1278
  """Query Gerrit for change URL/number and patchset sha via REST."""
1332
1279
  log.info("Querying Gerrit for submitted change(s) via REST")
1333
- # Build Gerrit REST client (prefer HTTP basic auth if provided)
1334
- base_path = os.getenv("GERRIT_HTTP_BASE_PATH", "").strip().strip("/")
1335
- base_url = (
1336
- f"https://{gerrit.host}/"
1337
- if not base_path
1338
- else f"https://{gerrit.host}/{base_path}/"
1339
- )
1340
- http_user = (
1341
- os.getenv("GERRIT_HTTP_USER", "").strip()
1342
- or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1343
- )
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()
1344
1286
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1287
+
1288
+ # Build Gerrit REST client (prefer HTTP basic auth if provided)
1345
1289
  if GerritRestAPI is None:
1346
1290
  raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1347
- if http_user and http_pass:
1348
- if HTTPBasicAuth is None:
1349
- raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1350
- rest = GerritRestAPI(
1351
- url=base_url, auth=HTTPBasicAuth(http_user, http_pass)
1352
- )
1353
- else:
1354
- rest = GerritRestAPI(url=base_url)
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)
1355
1302
  urls: list[str] = []
1356
1303
  nums: list[str] = []
1357
1304
  shas: list[str] = []
@@ -1362,56 +1309,44 @@ class Orchestrator:
1362
1309
  # include current revision
1363
1310
  query = f"limit:1 is:open project:{repo.project_gerrit} {cid}"
1364
1311
  path = f"/changes/?q={query}&o=CURRENT_REVISION&n=1"
1365
- try:
1366
- changes = rest.get(path)
1367
- except Exception as exc:
1368
- status = getattr(
1369
- getattr(exc, "response", None), "status_code", None
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
- )
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)
1404
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
1405
1336
  if not changes:
1406
1337
  continue
1407
1338
  change = changes[0]
1408
- num = str(change.get("_number", ""))
1409
- 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
1410
1346
  # Construct a stable web URL for the change
1411
1347
  if num:
1412
- urls.append(
1413
- f"https://{gerrit.host}/c/{repo.project_gerrit}/+/{num}"
1414
- )
1348
+ change_url = url_builder.change_url(repo.project_gerrit, int(num))
1349
+ urls.append(change_url)
1415
1350
  nums.append(num)
1416
1351
  if current_rev:
1417
1352
  shas.append(current_rev)
@@ -1422,9 +1357,7 @@ class Orchestrator:
1422
1357
  os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(nums)
1423
1358
  if shas:
1424
1359
  os.environ["GERRIT_COMMIT_SHA"] = "\n".join(shas)
1425
- return SubmissionResult(
1426
- change_urls=urls, change_numbers=nums, commit_shas=shas
1427
- )
1360
+ return SubmissionResult(change_urls=urls, change_numbers=nums, commit_shas=shas)
1428
1361
 
1429
1362
  def _setup_git_workspace(self, inputs: Inputs, gh: GitHubContext) -> None:
1430
1363
  """Initialize and set up git workspace for PR processing."""
@@ -1445,10 +1378,7 @@ class Orchestrator:
1445
1378
 
1446
1379
  # Fetch PR head
1447
1380
  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
- )
1381
+ pr_ref = f"refs/pull/{gh.pr_number}/head:refs/remotes/origin/pr/{gh.pr_number}/head"
1452
1382
  run_cmd(
1453
1383
  [
1454
1384
  "git",
@@ -1477,13 +1407,48 @@ class Orchestrator:
1477
1407
  # Download commit-msg hook using SSH
1478
1408
  try:
1479
1409
  # Use curl to download the hook (more reliable than scp)
1480
- curl_cmd = [
1481
- "curl",
1482
- "-o",
1483
- str(hook_path),
1484
- f"https://{gerrit.host}/r/tools/hooks/commit-msg",
1485
- ]
1486
- 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()
1487
1452
 
1488
1453
  # Make hook executable
1489
1454
  hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC)
@@ -1494,34 +1459,55 @@ class Orchestrator:
1494
1459
  msg = f"Could not install commit-msg hook: {exc}"
1495
1460
  raise OrchestratorError(msg) from exc
1496
1461
 
1497
- def _ensure_change_id_present(
1498
- self, gerrit: GerritInfo, author: str
1499
- ) -> list[str]:
1462
+ def _ensure_change_id_present(self, gerrit: GerritInfo, author: str) -> list[str]:
1500
1463
  """Ensure the last commit has a Change-Id.
1501
1464
 
1502
1465
  Installs the commit-msg hook and amends the commit if needed.
1503
1466
  """
1504
- trailers = git_last_commit_trailers(
1505
- keys=["Change-Id"], cwd=self.workspace
1506
- )
1467
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1507
1468
  if not trailers.get("Change-Id"):
1508
- log.debug(
1509
- "No Change-Id found, installing commit-msg hook and amending "
1510
- "commit"
1511
- )
1512
- self._install_commit_msg_hook(gerrit)
1513
- git_commit_amend(
1514
- no_edit=True, signoff=True, author=author, cwd=self.workspace
1515
- )
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
+ )
1516
1504
  # Debug: Check commit message after amend
1517
1505
  actual_msg = run_cmd(
1518
1506
  ["git", "show", "-s", "--pretty=format:%B", "HEAD"],
1519
1507
  cwd=self.workspace,
1520
1508
  ).stdout.strip()
1521
1509
  log.debug("Commit message after amend:\n%s", actual_msg)
1522
- trailers = git_last_commit_trailers(
1523
- keys=["Change-Id"], cwd=self.workspace
1524
- )
1510
+ trailers = git_last_commit_trailers(keys=["Change-Id"], cwd=self.workspace)
1525
1511
  return [c for c in trailers.get("Change-Id", []) if c]
1526
1512
 
1527
1513
  def _add_backref_comment_in_gerrit(
@@ -1544,21 +1530,14 @@ class Orchestrator:
1544
1530
  "1",
1545
1531
  "yes",
1546
1532
  ):
1547
- log.info(
1548
- "Skipping back-reference comments "
1549
- "(G2G_SKIP_GERRIT_COMMENTS=true)"
1550
- )
1533
+ log.info("Skipping back-reference comments (G2G_SKIP_GERRIT_COMMENTS=true)")
1551
1534
  return
1552
1535
 
1553
1536
  log.info("Adding back-reference comment in Gerrit")
1554
1537
  user = os.getenv("GERRIT_SSH_USER_G2G", "")
1555
1538
  server = gerrit.host
1556
1539
  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
- )
1540
+ run_url = f"{gh.server_url}/{gh.repository}/actions/runs/{gh.run_id}" if gh.run_id else "N/A"
1562
1541
  message = f"GHPR: {pr_url} | Action-Run: {run_url}"
1563
1542
  log.info("Adding back-reference comment: %s", message)
1564
1543
  for csha in commit_shas:
@@ -1566,39 +1545,84 @@ class Orchestrator:
1566
1545
  continue
1567
1546
  try:
1568
1547
  log.debug("Executing SSH command for commit %s", csha)
1569
- # Build SSH command with our configured SSH options
1570
- ssh_cmd = ["ssh", "-n", "-p", str(gerrit.port)]
1571
-
1572
- # Add our SSH options if we have custom SSH config
1573
- if self._git_ssh_command:
1574
- # Extract SSH options from GIT_SSH_COMMAND
1575
- # Format: "ssh -i /path/to/key -o Option=value ..."
1576
- git_ssh_parts = self._git_ssh_command.split()
1577
- if len(git_ssh_parts) > 1: # Skip the "ssh" part
1578
- ssh_options = git_ssh_parts[1:]
1579
- log.debug("Adding SSH options: %s", ssh_options)
1580
- 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
+ ]
1581
1586
  else:
1582
- log.debug("No custom SSH config, using default SSH options")
1583
-
1584
- # Add the target and gerrit command
1585
- ssh_cmd.extend(
1586
- [
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),
1587
1608
  f"{user}@{server}",
1588
- "gerrit",
1589
- "review",
1590
- "-m",
1591
- message,
1592
- "--branch",
1593
- branch,
1594
- "--project",
1595
- repo.project_gerrit,
1596
- 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
+ ),
1597
1618
  ]
1598
- )
1599
1619
 
1600
1620
  log.debug("Final SSH command: %s", " ".join(ssh_cmd))
1601
- run_cmd(ssh_cmd, cwd=self.workspace)
1621
+ run_cmd(
1622
+ ssh_cmd,
1623
+ cwd=self.workspace,
1624
+ env=self._ssh_env(),
1625
+ )
1602
1626
  log.info(
1603
1627
  "Successfully added back-reference comment for %s: %s",
1604
1628
  csha,
@@ -1606,8 +1630,7 @@ class Orchestrator:
1606
1630
  )
1607
1631
  except CommandError as exc:
1608
1632
  log.warning(
1609
- "Failed to add back-reference comment for %s "
1610
- "(non-fatal): %s",
1633
+ "Failed to add back-reference comment for %s (non-fatal): %s",
1611
1634
  csha,
1612
1635
  exc,
1613
1636
  )
@@ -1623,14 +1646,11 @@ class Orchestrator:
1623
1646
  # Continue processing - this is not a fatal error
1624
1647
  except Exception as exc:
1625
1648
  log.warning(
1626
- "Failed to add back-reference comment for %s "
1627
- "(non-fatal): %s",
1649
+ "Failed to add back-reference comment for %s (non-fatal): %s",
1628
1650
  csha,
1629
1651
  exc,
1630
1652
  )
1631
- log.debug(
1632
- "Back-reference comment failure details:", exc_info=True
1633
- )
1653
+ log.debug("Back-reference comment failure details:", exc_info=True)
1634
1654
  # Continue processing - this is not a fatal error
1635
1655
 
1636
1656
  def _comment_on_pull_request(
@@ -1645,10 +1665,10 @@ class Orchestrator:
1645
1665
  return
1646
1666
  urls = result.change_urls or []
1647
1667
  org = os.getenv("ORGANIZATION", gh.repository_owner)
1648
- text = (
1649
- f"The pull-request PR-{gh.pr_number} is submitted to Gerrit "
1650
- f"[{org}](https://{gerrit.host})!\n\n"
1651
- )
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"
1652
1672
  if urls:
1653
1673
  text += "To follow up on the change visit:\n\n" + "\n".join(urls)
1654
1674
  try:
@@ -1657,6 +1677,13 @@ class Orchestrator:
1657
1677
  # At this point, gh.pr_number is non-None due to earlier guard.
1658
1678
  pr_obj = get_pull(repo, int(gh.pr_number))
1659
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
+ )
1660
1687
  except Exception as exc:
1661
1688
  log.warning("Failed to add PR comment: %s", exc)
1662
1689
 
@@ -1718,20 +1745,15 @@ class Orchestrator:
1718
1745
  "yes",
1719
1746
  "on",
1720
1747
  ):
1748
+ log.info("Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)")
1721
1749
  log.info(
1722
- "Dry-run: network checks disabled (G2G_DRYRUN_DISABLE_NETWORK)"
1723
- )
1724
- log.info(
1725
- "Dry-run targets: Gerrit project=%s branch=%s "
1726
- "topic_prefix=GH-%s",
1750
+ "Dry-run targets: Gerrit project=%s branch=%s topic_prefix=GH-%s",
1727
1751
  repo.project_gerrit,
1728
1752
  self._resolve_target_branch(),
1729
1753
  repo.project_github,
1730
1754
  )
1731
1755
  if inputs.reviewers_email:
1732
- log.info(
1733
- "Reviewers (from inputs/config): %s", inputs.reviewers_email
1734
- )
1756
+ log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
1735
1757
  elif os.getenv("REVIEWERS_EMAIL"):
1736
1758
  log.info(
1737
1759
  "Reviewers (from environment): %s",
@@ -1742,18 +1764,14 @@ class Orchestrator:
1742
1764
  # DNS resolution for Gerrit host
1743
1765
  try:
1744
1766
  socket.getaddrinfo(gerrit.host, None)
1745
- log.info(
1746
- "DNS resolution for Gerrit host '%s' succeeded", gerrit.host
1747
- )
1767
+ log.info("DNS resolution for Gerrit host '%s' succeeded", gerrit.host)
1748
1768
  except Exception as exc:
1749
1769
  msg = "DNS resolution failed"
1750
1770
  raise OrchestratorError(msg) from exc
1751
1771
 
1752
1772
  # SSH (TCP) reachability on Gerrit port
1753
1773
  try:
1754
- with socket.create_connection(
1755
- (gerrit.host, gerrit.port), timeout=5
1756
- ):
1774
+ with socket.create_connection((gerrit.host, gerrit.port), timeout=5):
1757
1775
  pass
1758
1776
  log.info(
1759
1777
  "SSH TCP connectivity to %s:%s verified",
@@ -1766,10 +1784,7 @@ class Orchestrator:
1766
1784
 
1767
1785
  # Gerrit REST reachability and optional auth check
1768
1786
  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
- )
1787
+ http_user = os.getenv("GERRIT_HTTP_USER", "").strip() or os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1773
1788
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1774
1789
  self._verify_gerrit_rest(gerrit.host, base_path, http_user, http_pass)
1775
1790
 
@@ -1779,9 +1794,7 @@ class Orchestrator:
1779
1794
  repo_obj = get_repo_from_env(client)
1780
1795
  if gh.pr_number is not None:
1781
1796
  pr_obj = get_pull(repo_obj, gh.pr_number)
1782
- log.info(
1783
- "GitHub PR #%s metadata loaded successfully", gh.pr_number
1784
- )
1797
+ log.info("GitHub PR #%s metadata loaded successfully", gh.pr_number)
1785
1798
  try:
1786
1799
  title, _ = get_pr_title_body(pr_obj)
1787
1800
  log.info("GitHub PR title: %s", title)
@@ -1807,13 +1820,9 @@ class Orchestrator:
1807
1820
  repo.project_github,
1808
1821
  )
1809
1822
  if inputs.reviewers_email:
1810
- log.info(
1811
- "Reviewers (from inputs/config): %s", inputs.reviewers_email
1812
- )
1823
+ log.info("Reviewers (from inputs/config): %s", inputs.reviewers_email)
1813
1824
  elif os.getenv("REVIEWERS_EMAIL"):
1814
- log.info(
1815
- "Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL")
1816
- )
1825
+ log.info("Reviewers (from environment): %s", os.getenv("REVIEWERS_EMAIL"))
1817
1826
 
1818
1827
  def _verify_gerrit_rest(
1819
1828
  self,
@@ -1830,9 +1839,7 @@ class Orchestrator:
1830
1839
  raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1831
1840
  if HTTPBasicAuth is None:
1832
1841
  raise OrchestratorError(_MSG_PYGERRIT2_AUTH_MISSING)
1833
- return GerritRestAPI(
1834
- url=url, auth=HTTPBasicAuth(http_user, http_pass)
1835
- )
1842
+ return GerritRestAPI(url=url, auth=HTTPBasicAuth(http_user, http_pass))
1836
1843
  else:
1837
1844
  if GerritRestAPI is None:
1838
1845
  raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
@@ -1850,29 +1857,30 @@ class Orchestrator:
1850
1857
  _ = rest.get("/dashboard/self")
1851
1858
  log.info("Gerrit REST endpoint reachable (unauthenticated)")
1852
1859
 
1853
- base_url = (
1854
- f"https://{host}/"
1855
- if not base_path
1856
- else f"https://{host}/{base_path}/"
1857
- )
1858
- try:
1859
- _probe(base_url)
1860
- except Exception as exc:
1861
- status = getattr(
1862
- 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,
1863
1883
  )
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)
1876
1884
 
1877
1885
  # ---------------
1878
1886
  # Helpers