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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
github2gerrit/cli.py CHANGED
@@ -7,8 +7,12 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import tempfile
10
+ from collections.abc import Callable
10
11
  from pathlib import Path
12
+ from typing import TYPE_CHECKING
11
13
  from typing import Any
14
+ from typing import Protocol
15
+ from typing import TypeVar
12
16
  from typing import cast
13
17
  from urllib.parse import urlparse
14
18
 
@@ -16,7 +20,9 @@ import click
16
20
  import typer
17
21
 
18
22
  from . import models
23
+ from .config import _is_github_actions_context
19
24
  from .config import apply_config_to_env
25
+ from .config import apply_parameter_derivation
20
26
  from .config import load_org_config
21
27
  from .core import Orchestrator
22
28
  from .core import SubmissionResult
@@ -31,6 +37,21 @@ from .models import GitHubContext
31
37
  from .models import Inputs
32
38
 
33
39
 
40
+ def _is_verbose_mode() -> bool:
41
+ """Check if verbose mode is enabled via environment variable."""
42
+ return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
43
+
44
+
45
+ def _log_exception_conditionally(
46
+ logger: logging.Logger, message: str, *args: Any
47
+ ) -> None:
48
+ """Log exception with traceback only if verbose mode is enabled."""
49
+ if _is_verbose_mode():
50
+ logger.exception(message, *args)
51
+ else:
52
+ logger.error(message, *args)
53
+
54
+
34
55
  class ConfigurationError(Exception):
35
56
  """Raised when configuration validation fails.
36
57
 
@@ -83,14 +104,36 @@ def _parse_github_target(url: str) -> tuple[str | None, str | None, int | None]:
83
104
  APP_NAME = "github2gerrit"
84
105
 
85
106
 
86
- class _SingleUsageGroup(click.Group): # type: ignore[misc]
87
- def format_usage(self, ctx: Any, formatter: Any) -> None:
107
+ if TYPE_CHECKING:
108
+ BaseGroup = object
109
+ else:
110
+ BaseGroup = click.Group
111
+
112
+
113
+ class _FormatterProto(Protocol):
114
+ def write_usage(self, prog: str, args: str, prefix: str = ...) -> None: ...
115
+
116
+
117
+ class _ContextProto(Protocol):
118
+ @property
119
+ def command_path(self) -> str: ...
120
+
121
+
122
+ class _SingleUsageGroup(BaseGroup):
123
+ def format_usage(
124
+ self, ctx: _ContextProto, formatter: _FormatterProto
125
+ ) -> None:
88
126
  # Force a simplified usage line without COMMAND [ARGS]...
89
127
  formatter.write_usage(
90
128
  ctx.command_path, "[OPTIONS] TARGET_URL", prefix="Usage: "
91
129
  )
92
130
 
93
131
 
132
+ # Error message constants to comply with TRY003
133
+ _MSG_MISSING_REQUIRED_INPUT = "Missing required input: {field_name}"
134
+ _MSG_INVALID_FETCH_DEPTH = "FETCH_DEPTH must be a positive integer"
135
+ _MSG_ISSUE_ID_MULTILINE = "Issue ID must be single line"
136
+
94
137
  app: typer.Typer = typer.Typer(
95
138
  add_completion=False,
96
139
  no_args_is_help=False,
@@ -108,7 +151,17 @@ def _resolve_org(default_org: str | None) -> str:
108
151
  return ""
109
152
 
110
153
 
111
- @app.command() # type: ignore[misc]
154
+ if TYPE_CHECKING:
155
+ F = TypeVar("F", bound=Callable[..., object])
156
+
157
+ def typed_app_command(
158
+ *args: object, **kwargs: object
159
+ ) -> Callable[[F], F]: ...
160
+ else:
161
+ typed_app_command = app.command
162
+
163
+
164
+ @typed_app_command()
112
165
  def main(
113
166
  ctx: typer.Context,
114
167
  target_url: str | None = typer.Argument(
@@ -400,7 +453,7 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
400
453
  per_ctx, allow_duplicates=data.allow_duplicates
401
454
  )
402
455
  except DuplicateChangeError as exc:
403
- log.exception("Skipping PR #%d", pr_number)
456
+ _log_exception_conditionally(log, "Skipping PR #%d", pr_number)
404
457
  typer.echo(f"Skipping PR #{pr_number}: {exc}")
405
458
  continue
406
459
 
@@ -426,7 +479,9 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
426
479
  result_multi.change_numbers,
427
480
  )
428
481
  except Exception as exc:
429
- log.exception("Failed to process PR #%d", pr_number)
482
+ _log_exception_conditionally(
483
+ log, "Failed to process PR #%d", pr_number
484
+ )
430
485
  typer.echo(f"Failed to process PR #{pr_number}: {exc}")
431
486
  log.info("Continuing to next PR despite failure")
432
487
  continue
@@ -577,6 +632,10 @@ def _load_effective_inputs() -> Inputs:
577
632
  or os.getenv("GITHUB_REPOSITORY_OWNER")
578
633
  )
579
634
  cfg = load_org_config(org_for_cfg)
635
+
636
+ # Apply dynamic parameter derivation for missing Gerrit parameters
637
+ cfg = apply_parameter_derivation(cfg, org_for_cfg, save_to_config=True)
638
+
580
639
  apply_config_to_env(cfg)
581
640
 
582
641
  # Refresh inputs after applying configuration to environment
@@ -677,7 +736,7 @@ def _process() -> None:
677
736
  try:
678
737
  _validate_inputs(data)
679
738
  except ConfigurationError as exc:
680
- log.exception("Configuration validation failed")
739
+ _log_exception_conditionally(log, "Configuration validation failed")
681
740
  typer.echo(f"Configuration validation failed: {exc}", err=True)
682
741
  raise typer.Exit(code=2) from exc
683
742
 
@@ -722,7 +781,7 @@ def _process() -> None:
722
781
  try:
723
782
  check_for_duplicates(gh, allow_duplicates=data.allow_duplicates)
724
783
  except DuplicateChangeError as exc:
725
- log.exception("Duplicate change detected")
784
+ _log_exception_conditionally(log, "Duplicate change detected")
726
785
  typer.echo(f"Error: {exc}", err=True)
727
786
  typer.echo(
728
787
  "Use --allow-duplicates to override this check.", err=True
@@ -820,25 +879,85 @@ def _validate_inputs(data: Inputs) -> None:
820
879
  )
821
880
  raise ConfigurationError(msg)
822
881
 
823
- # Presence checks for required fields used by existing action
824
- for field_name in (
825
- "gerrit_known_hosts",
826
- "gerrit_ssh_privkey_g2g",
827
- "gerrit_ssh_user_g2g",
828
- "gerrit_ssh_user_g2g_email",
829
- ):
882
+ # Context-aware validation: different requirements for GH Actions vs CLI
883
+ is_github_actions = _is_github_actions_context()
884
+
885
+ # SSH private key is always required
886
+ required_fields = ["gerrit_ssh_privkey_g2g"]
887
+
888
+ # Gerrit parameters can be derived in GH Actions if organization available
889
+ # In local CLI context, we're more strict about explicit configuration
890
+ if is_github_actions:
891
+ # In GitHub Actions: allow derivation if organization is available
892
+ if not data.organization:
893
+ # No organization means no derivation possible
894
+ required_fields.extend(
895
+ [
896
+ "gerrit_ssh_user_g2g",
897
+ "gerrit_ssh_user_g2g_email",
898
+ ]
899
+ )
900
+ else:
901
+ # In local CLI: require explicit values or organization + derivation
902
+ # This prevents unexpected behavior when running locally
903
+ missing_gerrit_params = [
904
+ field
905
+ for field in ["gerrit_ssh_user_g2g", "gerrit_ssh_user_g2g_email"]
906
+ if not getattr(data, field)
907
+ ]
908
+ if missing_gerrit_params:
909
+ if data.organization:
910
+ log.info(
911
+ "Local CLI usage: Gerrit parameters can be derived from "
912
+ "organization '%s'. Missing: %s. Consider setting "
913
+ "G2G_ENABLE_DERIVATION=true to enable derivation.",
914
+ data.organization,
915
+ ", ".join(missing_gerrit_params),
916
+ )
917
+ # Allow derivation in local mode only if explicitly enabled
918
+ if not _env_bool("G2G_ENABLE_DERIVATION", False):
919
+ required_fields.extend(missing_gerrit_params)
920
+ else:
921
+ required_fields.extend(missing_gerrit_params)
922
+
923
+ for field_name in required_fields:
830
924
  if not getattr(data, field_name):
831
925
  log.error("Missing required input: %s", field_name)
832
- raise ConfigurationError(f"Missing required input: {field_name}") # noqa: TRY003
926
+ if field_name in [
927
+ "gerrit_ssh_user_g2g",
928
+ "gerrit_ssh_user_g2g_email",
929
+ ]:
930
+ if data.organization:
931
+ if is_github_actions:
932
+ log.error(
933
+ "These fields can be derived automatically from "
934
+ "organization '%s'",
935
+ data.organization,
936
+ )
937
+ else:
938
+ log.error(
939
+ "These fields can be derived from organization "
940
+ "'%s'",
941
+ data.organization,
942
+ )
943
+ log.error("Set G2G_ENABLE_DERIVATION=true to enable")
944
+ else:
945
+ log.error(
946
+ "These fields require either explicit values or an "
947
+ "ORGANIZATION for derivation"
948
+ )
949
+ raise ConfigurationError(
950
+ _MSG_MISSING_REQUIRED_INPUT.format(field_name=field_name)
951
+ )
833
952
 
834
953
  # Validate fetch depth is a positive integer
835
954
  if data.fetch_depth <= 0:
836
955
  log.error("Invalid FETCH_DEPTH: %s", data.fetch_depth)
837
- raise ConfigurationError("FETCH_DEPTH must be a positive integer") # noqa: TRY003
956
+ raise ConfigurationError(_MSG_INVALID_FETCH_DEPTH)
838
957
 
839
958
  # Validate Issue ID is a single line string if provided
840
959
  if data.issue_id and ("\n" in data.issue_id or "\r" in data.issue_id):
841
- raise ConfigurationError("Issue ID must be single line") # noqa: TRY003
960
+ raise ConfigurationError(_MSG_ISSUE_ID_MULTILINE)
842
961
 
843
962
 
844
963
  def _log_effective_config(data: Inputs, gh: GitHubContext) -> None:
@@ -848,10 +967,10 @@ def _log_effective_config(data: Inputs, gh: GitHubContext) -> None:
848
967
  log.info(" SUBMIT_SINGLE_COMMITS: %s", data.submit_single_commits)
849
968
  log.info(" USE_PR_AS_COMMIT: %s", data.use_pr_as_commit)
850
969
  log.info(" FETCH_DEPTH: %s", data.fetch_depth)
851
- log.info(
852
- " GERRIT_KNOWN_HOSTS: %s",
853
- "<provided>" if data.gerrit_known_hosts else "<missing>",
970
+ known_hosts_status = (
971
+ "<provided>" if data.gerrit_known_hosts else "<will auto-discover>"
854
972
  )
973
+ log.info(" GERRIT_KNOWN_HOSTS: %s", known_hosts_status)
855
974
  log.info(" GERRIT_SSH_PRIVKEY_G2G: %s", safe_privkey)
856
975
  log.info(" GERRIT_SSH_USER_G2G: %s", data.gerrit_ssh_user_g2g)
857
976
  log.info(" GERRIT_SSH_USER_G2G_EMAIL: %s", data.gerrit_ssh_user_g2g_email)
github2gerrit/config.py CHANGED
@@ -80,6 +80,8 @@ KNOWN_KEYS: set[str] = {
80
80
  "ISSUE_ID",
81
81
  "G2G_VERBOSE",
82
82
  "G2G_SKIP_GERRIT_COMMENTS",
83
+ "G2G_ENABLE_DERIVATION",
84
+ "G2G_AUTO_SAVE_CONFIG",
83
85
  "GITHUB_TOKEN",
84
86
  # Optional inputs (reusable workflow compatibility)
85
87
  "GERRIT_SERVER",
@@ -313,6 +315,198 @@ def filter_known(
313
315
  return {k: v for k, v in cfg.items() if k in KNOWN_KEYS}
314
316
 
315
317
 
318
+ def _is_github_actions_context() -> bool:
319
+ """Detect if running in GitHub Actions environment."""
320
+ return (
321
+ os.getenv("GITHUB_ACTIONS") == "true"
322
+ or os.getenv("GITHUB_EVENT_NAME", "").strip() != ""
323
+ )
324
+
325
+
326
+ def _is_local_cli_context() -> bool:
327
+ """Detect if running as local CLI tool."""
328
+ return not _is_github_actions_context()
329
+
330
+
331
+ def derive_gerrit_parameters(organization: str | None) -> dict[str, str]:
332
+ """Derive Gerrit parameters from GitHub organization name.
333
+
334
+ Args:
335
+ organization: GitHub organization name
336
+
337
+ Returns:
338
+ Dict with derived parameter values:
339
+ - GERRIT_SSH_USER_G2G: [org].gh2gerrit
340
+ - GERRIT_SSH_USER_G2G_EMAIL: releng+[org]-gh2gerrit@linuxfoundation.org
341
+ - GERRIT_SERVER: gerrit.[org].org
342
+ """
343
+ if not organization:
344
+ return {}
345
+
346
+ org = organization.strip().lower()
347
+ return {
348
+ "GERRIT_SSH_USER_G2G": f"{org}.gh2gerrit",
349
+ "GERRIT_SSH_USER_G2G_EMAIL": (
350
+ f"releng+{org}-gh2gerrit@linuxfoundation.org"
351
+ ),
352
+ "GERRIT_SERVER": f"gerrit.{org}.org",
353
+ }
354
+
355
+
356
+ def apply_parameter_derivation(
357
+ cfg: dict[str, str],
358
+ organization: str | None = None,
359
+ save_to_config: bool = True,
360
+ ) -> dict[str, str]:
361
+ """Apply dynamic parameter derivation for missing Gerrit parameters.
362
+
363
+ This function derives standard Gerrit parameters when they are not
364
+ explicitly configured. The derivation is based on the GitHub organization:
365
+
366
+ - gerrit_ssh_user_g2g: [org].gh2gerrit
367
+ - gerrit_ssh_user_g2g_email: releng+[org]-gh2gerrit@linuxfoundation.org
368
+ - gerrit_server: gerrit.[org].org
369
+
370
+ Derivation behavior depends on execution context:
371
+ - GitHub Actions: Automatic derivation when organization is available
372
+ - Local CLI: Requires G2G_ENABLE_DERIVATION=true for automatic derivation
373
+
374
+ Args:
375
+ cfg: Configuration dictionary to augment
376
+ organization: GitHub organization name for derivation
377
+ save_to_config: Whether to save derived parameters to config file
378
+
379
+ Returns:
380
+ Configuration dictionary with derived values for missing parameters
381
+ """
382
+ if not organization:
383
+ return cfg
384
+
385
+ # Check execution context to determine derivation strategy
386
+ is_github_actions = _is_github_actions_context()
387
+ enable_derivation = is_github_actions or os.getenv(
388
+ "G2G_ENABLE_DERIVATION", ""
389
+ ).strip().lower() in ("1", "true", "yes", "on")
390
+
391
+ if not enable_derivation:
392
+ log.debug(
393
+ "Parameter derivation disabled for local CLI usage. "
394
+ "Set G2G_ENABLE_DERIVATION=true to enable automatic derivation."
395
+ )
396
+ return cfg
397
+
398
+ # Only derive parameters that are missing or empty
399
+ derived = derive_gerrit_parameters(organization)
400
+ result = dict(cfg)
401
+ newly_derived = {}
402
+
403
+ for key, value in derived.items():
404
+ if key not in result or not result[key].strip():
405
+ log.debug(
406
+ "Deriving %s from organization '%s': %s (context: %s)",
407
+ key,
408
+ organization,
409
+ value,
410
+ "GitHub Actions" if is_github_actions else "Local CLI",
411
+ )
412
+ result[key] = value
413
+ newly_derived[key] = value
414
+
415
+ # Save newly derived parameters to configuration file for future use
416
+ # Default to true for local CLI, false for GitHub Actions
417
+ default_auto_save = "false" if _is_github_actions_context() else "true"
418
+ auto_save_enabled = os.getenv(
419
+ "G2G_AUTO_SAVE_CONFIG", default_auto_save
420
+ ).strip().lower() in ("1", "true", "yes", "on")
421
+ if save_to_config and newly_derived and auto_save_enabled:
422
+ # Save to config in local CLI mode to create persistent configuration
423
+ try:
424
+ save_derived_parameters_to_config(organization, newly_derived)
425
+ log.info(
426
+ "Automatically saved derived parameters to configuration "
427
+ "file for organization '%s'. "
428
+ "This creates a persistent configuration that you can "
429
+ "customize if needed.",
430
+ organization,
431
+ )
432
+ except Exception as exc:
433
+ log.warning("Failed to save derived parameters to config: %s", exc)
434
+
435
+ return result
436
+
437
+
438
+ def save_derived_parameters_to_config(
439
+ organization: str,
440
+ derived_params: dict[str, str],
441
+ config_path: str | None = None,
442
+ ) -> None:
443
+ """Save derived parameters to the organization's configuration file.
444
+
445
+ This function updates the configuration file to include any derived
446
+ parameters that are not already present in the organization section.
447
+ This creates a persistent configuration that users can modify if needed.
448
+
449
+ Args:
450
+ organization: GitHub organization name for config section
451
+ derived_params: Dictionary of derived parameter key-value pairs
452
+ config_path: Path to config file (optional, uses default if not
453
+ provided)
454
+
455
+ Raises:
456
+ Exception: If saving fails
457
+ """
458
+ if not organization or not derived_params:
459
+ return
460
+
461
+ if config_path is None:
462
+ config_path = (
463
+ os.getenv("G2G_CONFIG_PATH", "").strip() or DEFAULT_CONFIG_PATH
464
+ )
465
+
466
+ config_file = Path(config_path).expanduser()
467
+
468
+ try:
469
+ # Ensure the directory exists
470
+ config_file.parent.mkdir(parents=True, exist_ok=True)
471
+
472
+ # Parse existing content using configparser
473
+ cp = _load_ini(config_file)
474
+
475
+ # Find or create the organization section
476
+ org_section = _select_section(cp, organization)
477
+ if org_section is None:
478
+ # Section doesn't exist, we'll need to add it
479
+ cp.add_section(organization)
480
+ org_section = organization
481
+
482
+ # Add derived parameters that don't already exist
483
+ params_added = []
484
+ for key, value in derived_params.items():
485
+ if not cp.has_option(org_section, key):
486
+ cp.set(org_section, key, f'"{value}"')
487
+ params_added.append(key)
488
+
489
+ # Only write if we added parameters
490
+ if params_added:
491
+ # Write the updated configuration
492
+ with config_file.open("w", encoding="utf-8") as f:
493
+ cp.write(f)
494
+
495
+ log.debug(
496
+ "Saved derived parameters to configuration file %s [%s]: %s",
497
+ config_file,
498
+ organization,
499
+ ", ".join(params_added),
500
+ )
501
+
502
+ except Exception as exc:
503
+ log.warning(
504
+ "Failed to save derived parameters to configuration file %s: %s",
505
+ config_file,
506
+ exc,
507
+ )
508
+
509
+
316
510
  def overlay_missing(
317
511
  primary: dict[str, str],
318
512
  fallback: dict[str, str],
github2gerrit/core.py CHANGED
@@ -38,14 +38,6 @@ from dataclasses import dataclass
38
38
  from pathlib import Path
39
39
  from typing import Any
40
40
 
41
-
42
- try:
43
- from pygerrit2 import GerritRestAPI
44
- from pygerrit2 import HTTPBasicAuth
45
- except ImportError:
46
- GerritRestAPI = None
47
- HTTPBasicAuth = None
48
-
49
41
  from .github_api import build_client
50
42
  from .github_api import close_pr
51
43
  from .github_api import create_pr_comment
@@ -67,9 +59,52 @@ from .models import GitHubContext
67
59
  from .models import Inputs
68
60
 
69
61
 
62
+ try:
63
+ from pygerrit2 import GerritRestAPI
64
+ from pygerrit2 import HTTPBasicAuth
65
+ except ImportError:
66
+ GerritRestAPI = None
67
+ HTTPBasicAuth = None
68
+
69
+ try:
70
+ from .ssh_discovery import SSHDiscoveryError
71
+ from .ssh_discovery import auto_discover_gerrit_host_keys
72
+ except ImportError:
73
+ # Fallback if ssh_discovery module is not available
74
+ auto_discover_gerrit_host_keys = None # type: ignore[assignment]
75
+ SSHDiscoveryError = Exception # type: ignore[misc,assignment]
76
+
77
+
78
+ def _is_verbose_mode() -> bool:
79
+ """Check if verbose mode is enabled via environment variable."""
80
+ return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
81
+
82
+
83
+ def _log_exception_conditionally(
84
+ logger: logging.Logger, message: str, *args: Any
85
+ ) -> None:
86
+ """Log exception with traceback only if verbose mode is enabled."""
87
+ if _is_verbose_mode():
88
+ logger.exception(message, *args)
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,7 +122,7 @@ 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
128
  if cleaned_issue_id.startswith("Issue-ID:"):
@@ -235,7 +270,7 @@ class Orchestrator:
235
270
  return SubmissionResult(
236
271
  change_urls=[], change_numbers=[], commit_shas=[]
237
272
  )
238
- self._setup_ssh(inputs)
273
+ self._setup_ssh(inputs, gerrit)
239
274
 
240
275
  if inputs.submit_single_commits:
241
276
  prep = self._prepare_single_commits(inputs, gh, gerrit)
@@ -281,7 +316,7 @@ class Orchestrator:
281
316
 
282
317
  def _guard_pull_request_context(self, gh: GitHubContext) -> None:
283
318
  if gh.pr_number is None:
284
- raise OrchestratorError("missing PR context") # noqa: TRY003
319
+ raise OrchestratorError(_MSG_MISSING_PR_CONTEXT)
285
320
  log.debug("PR context OK: #%s", gh.pr_number)
286
321
 
287
322
  def _parse_gitreview_text(self, text: str) -> GerritInfo | None:
@@ -446,7 +481,7 @@ class Orchestrator:
446
481
  # Fallback: use the repository name portion only.
447
482
  repo_full = gh.repository
448
483
  if not repo_full or "/" not in repo_full:
449
- raise OrchestratorError("bad repository context") # noqa: TRY003
484
+ raise OrchestratorError(_MSG_BAD_REPOSITORY_CONTEXT)
450
485
  owner, name = repo_full.split("/", 1)
451
486
  # Fallback: map all '-' to '/' for Gerrit path (e.g., 'my/repo/name')
452
487
  gerrit_name = name.replace("-", "/")
@@ -466,7 +501,7 @@ class Orchestrator:
466
501
 
467
502
  host = inputs.gerrit_server.strip()
468
503
  if not host:
469
- raise OrchestratorError("missing GERRIT_SERVER") # noqa: TRY003
504
+ raise OrchestratorError(_MSG_MISSING_GERRIT_SERVER)
470
505
  port_s = inputs.gerrit_server_port.strip() or "29418"
471
506
  try:
472
507
  port = int(port_s)
@@ -486,13 +521,13 @@ class Orchestrator:
486
521
  project,
487
522
  )
488
523
  else:
489
- raise OrchestratorError("missing GERRIT_PROJECT") # noqa: TRY003
524
+ raise OrchestratorError(_MSG_MISSING_GERRIT_PROJECT)
490
525
 
491
526
  info = GerritInfo(host=host, port=port, project=project)
492
527
  log.debug("Resolved Gerrit info: %s", info)
493
528
  return info
494
529
 
495
- def _setup_ssh(self, inputs: Inputs) -> None:
530
+ def _setup_ssh(self, inputs: Inputs, gerrit: GerritInfo) -> None:
496
531
  """Set up temporary SSH configuration for Gerrit access.
497
532
 
498
533
  This method creates tool-specific SSH files in the workspace without
@@ -506,8 +541,46 @@ class Orchestrator:
506
541
 
507
542
  Does not modify user files.
508
543
  """
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")
544
+ if not inputs.gerrit_ssh_privkey_g2g:
545
+ log.debug("SSH private key not provided, skipping SSH setup")
546
+ return
547
+
548
+ # Auto-discover host keys if not provided
549
+ 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
+ )
557
+ try:
558
+ discovered_keys = auto_discover_gerrit_host_keys(
559
+ gerrit_hostname=gerrit.host,
560
+ gerrit_port=gerrit.port,
561
+ organization=inputs.organization,
562
+ save_to_config=True,
563
+ )
564
+ if discovered_keys:
565
+ effective_known_hosts = discovered_keys
566
+ log.info(
567
+ "Successfully auto-discovered SSH host keys for %s:%d",
568
+ gerrit.host,
569
+ gerrit.port,
570
+ )
571
+ else:
572
+ log.warning(
573
+ "Auto-discovery failed, SSH host key verification may "
574
+ "fail"
575
+ )
576
+ except Exception as exc:
577
+ log.warning("SSH host key auto-discovery failed: %s", exc)
578
+
579
+ if not effective_known_hosts:
580
+ log.debug(
581
+ "No SSH host keys available (manual or auto-discovered), "
582
+ "skipping SSH setup"
583
+ )
511
584
  return
512
585
 
513
586
  log.info("Setting up temporary SSH configuration for Gerrit")
@@ -529,7 +602,7 @@ class Orchestrator:
529
602
  # Write known hosts to tool-specific location
530
603
  known_hosts_path = tool_ssh_dir / "known_hosts"
531
604
  with open(known_hosts_path, "w", encoding="utf-8") as f:
532
- f.write(inputs.gerrit_known_hosts.strip() + "\n")
605
+ f.write(effective_known_hosts.strip() + "\n")
533
606
  known_hosts_path.chmod(0o644)
534
607
  log.debug("Known hosts written to %s", known_hosts_path)
535
608
  log.debug("Using isolated known_hosts to prevent user conflicts")
@@ -1123,7 +1196,9 @@ class Orchestrator:
1123
1196
  except CommandError as exc:
1124
1197
  # Analyze the specific failure reason from git review output
1125
1198
  error_details = self._analyze_gerrit_push_failure(exc)
1126
- log.exception("Gerrit push failed: %s", error_details)
1199
+ _log_exception_conditionally(
1200
+ log, "Gerrit push failed: %s", error_details
1201
+ )
1127
1202
  msg = (
1128
1203
  f"Failed to push changes to Gerrit with git-review: "
1129
1204
  f"{error_details}"
@@ -1151,7 +1226,81 @@ class Orchestrator:
1151
1226
  combined_output = f"{stdout}\n{stderr}"
1152
1227
  combined_lower = combined_output.lower()
1153
1228
 
1154
- if "missing issue-id" in combined_lower:
1229
+ # Check for SSH host key verification failures first
1230
+ if (
1231
+ "host key verification failed" in combined_lower
1232
+ or "no ed25519 host key is known" in combined_lower
1233
+ or "no rsa host key is known" in combined_lower
1234
+ or "no ecdsa host key is known" in combined_lower
1235
+ ):
1236
+ return (
1237
+ "SSH host key verification failed. The GERRIT_KNOWN_HOSTS "
1238
+ "value is missing or contains an outdated host key for the "
1239
+ "Gerrit server. The tool will attempt to auto-discover "
1240
+ "host keys "
1241
+ "on the next run, or you can manually run "
1242
+ "'ssh-keyscan -p 29418 <gerrit-host>' "
1243
+ "to get the current host keys."
1244
+ )
1245
+ elif (
1246
+ "authenticity of host" in combined_lower
1247
+ and "can't be established" in combined_lower
1248
+ ):
1249
+ return (
1250
+ "SSH host key unknown. The GERRIT_KNOWN_HOSTS value does not "
1251
+ "contain the host key for the Gerrit server. "
1252
+ "The tool will attempt "
1253
+ "to auto-discover host keys on the next run, or you can "
1254
+ "manually run "
1255
+ "'ssh-keyscan -p 29418 <gerrit-host>' to get the host keys."
1256
+ )
1257
+ # 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
+ )
1266
+ 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
+ )
1271
+ elif "authentication failed" in combined_lower:
1272
+ return (
1273
+ "SSH authentication failed - check SSH key, username, and "
1274
+ "server configuration"
1275
+ )
1276
+ # 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
+ )
1285
+ # Check for specific SSH publickey-only authentication failures
1286
+ elif "permission denied (publickey)" in combined_lower and not any(
1287
+ auth_method in combined_lower
1288
+ for auth_method in ["gssapi", "password", "keyboard"]
1289
+ ):
1290
+ return (
1291
+ "SSH public key authentication failed. The SSH key may be "
1292
+ "invalid, not authorized for this user, or the wrong key type."
1293
+ )
1294
+ # Check for general SSH permission issues
1295
+ elif "permission denied" in combined_lower:
1296
+ return "SSH permission denied - check SSH key and user permissions"
1297
+ 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
+ )
1302
+ # Check for Gerrit-specific issues
1303
+ elif "missing issue-id" in combined_lower:
1155
1304
  return "Missing Issue-ID in commit message."
1156
1305
  elif "commit not associated to any issue" in combined_lower:
1157
1306
  return "Commit not associated to any issue."
@@ -1169,15 +1318,6 @@ class Orchestrator:
1169
1318
  return f"Gerrit rejected the push: {reason}"
1170
1319
  return f"Gerrit rejected the push: {line.strip()}"
1171
1320
  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
1321
  else:
1182
1322
  return f"Unknown error: {exc}"
1183
1323
 
@@ -1203,14 +1343,10 @@ class Orchestrator:
1203
1343
  )
1204
1344
  http_pass = os.getenv("GERRIT_HTTP_PASSWORD", "").strip()
1205
1345
  if GerritRestAPI is None:
1206
- raise OrchestratorError( # noqa: TRY003
1207
- "pygerrit2 is required to query Gerrit REST API"
1208
- )
1346
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_REST)
1209
1347
  if http_user and http_pass:
1210
1348
  if HTTPBasicAuth is None:
1211
- raise OrchestratorError( # noqa: TRY003
1212
- "pygerrit2 is required for HTTP authentication"
1213
- )
1349
+ raise OrchestratorError(_MSG_PYGERRIT2_REQUIRED_AUTH)
1214
1350
  rest = GerritRestAPI(
1215
1351
  url=base_url, auth=HTTPBasicAuth(http_user, http_pass)
1216
1352
  )
@@ -1691,15 +1827,15 @@ class Orchestrator:
1691
1827
  def _build_client(url: str) -> Any:
1692
1828
  if http_user and http_pass:
1693
1829
  if GerritRestAPI is None:
1694
- raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1830
+ raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1695
1831
  if HTTPBasicAuth is None:
1696
- raise OrchestratorError("pygerrit2 auth missing") # noqa: TRY003
1832
+ raise OrchestratorError(_MSG_PYGERRIT2_AUTH_MISSING)
1697
1833
  return GerritRestAPI(
1698
1834
  url=url, auth=HTTPBasicAuth(http_user, http_pass)
1699
1835
  )
1700
1836
  else:
1701
1837
  if GerritRestAPI is None:
1702
- raise OrchestratorError("pygerrit2 missing") # noqa: TRY003
1838
+ raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
1703
1839
  return GerritRestAPI(url=url)
1704
1840
 
1705
1841
  def _probe(url: str) -> None:
@@ -30,6 +30,12 @@ from typing import TypeVar
30
30
  from typing import cast
31
31
 
32
32
 
33
+ # Error message constants to comply with TRY003
34
+ _MSG_PYGITHUB_REQUIRED = "PyGithub required"
35
+ _MSG_MISSING_GITHUB_TOKEN = "missing GITHUB_TOKEN" # noqa: S105
36
+ _MSG_BAD_GITHUB_REPOSITORY = "bad GITHUB_REPOSITORY"
37
+
38
+
33
39
  class GithubExceptionType(Exception):
34
40
  pass
35
41
 
@@ -65,7 +71,7 @@ else:
65
71
 
66
72
  class Github: # type: ignore[no-redef]
67
73
  def __init__(self, *args: Any, **kwargs: Any) -> None:
68
- raise RuntimeError("PyGithub required") # noqa: TRY003
74
+ raise RuntimeError(_MSG_PYGITHUB_REQUIRED)
69
75
 
70
76
 
71
77
  class GhIssueComment(Protocol):
@@ -201,7 +207,7 @@ def build_client(token: str | None = None) -> GhClient:
201
207
  """
202
208
  tok = token or _getenv_str("GITHUB_TOKEN")
203
209
  if not tok:
204
- raise ValueError("missing GITHUB_TOKEN") # noqa: TRY003
210
+ raise ValueError(_MSG_MISSING_GITHUB_TOKEN)
205
211
  # per_page improves pagination; adjust as needed.
206
212
  base_url = _getenv_str("GITHUB_API_URL")
207
213
  if not base_url:
@@ -242,7 +248,7 @@ def get_repo_from_env(client: GhClient) -> GhRepository:
242
248
  """Return the repository object based on GITHUB_REPOSITORY."""
243
249
  full = _getenv_str("GITHUB_REPOSITORY")
244
250
  if not full or "/" not in full:
245
- raise ValueError("bad GITHUB_REPOSITORY") # noqa: TRY003
251
+ raise ValueError(_MSG_BAD_GITHUB_REPOSITORY)
246
252
  repo = client.get_repo(full)
247
253
  return repo
248
254
 
github2gerrit/gitutils.py CHANGED
@@ -20,6 +20,22 @@ from collections.abc import Mapping
20
20
  from collections.abc import Sequence
21
21
  from dataclasses import dataclass
22
22
  from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ def _is_verbose_mode() -> bool:
27
+ """Check if verbose mode is enabled via environment variable."""
28
+ return os.getenv("G2G_VERBOSE", "").lower() in ("true", "1", "yes")
29
+
30
+
31
+ def _log_exception_conditionally(
32
+ logger: logging.Logger, message: str, *args: Any
33
+ ) -> None:
34
+ """Log exception with traceback only if verbose mode is enabled."""
35
+ if _is_verbose_mode():
36
+ logger.exception(message, *args)
37
+ else:
38
+ logger.error(message, *args)
23
39
 
24
40
 
25
41
  __all__ = [
@@ -41,6 +57,9 @@ __all__ = [
41
57
  "run_cmd_with_retries",
42
58
  ]
43
59
 
60
+ # Error message constants to comply with TRY003
61
+ _MSG_COMMIT_NO_MESSAGE = "Either message or message_file must be provided"
62
+
44
63
 
45
64
  _LOGGER_NAME = "github2gerrit.git"
46
65
  log = logging.getLogger(_LOGGER_NAME)
@@ -187,7 +206,7 @@ def run_cmd(
187
206
  )
188
207
  except subprocess.TimeoutExpired as exc:
189
208
  msg = f"Command timed out: {cmd!r}"
190
- log.exception(msg)
209
+ _log_exception_conditionally(log, msg)
191
210
  # TimeoutExpired carries 'output' and 'stderr' attributes,
192
211
  # which may be bytes depending on invocation context.
193
212
  out = getattr(exc, "output", None)
@@ -201,7 +220,7 @@ def run_cmd(
201
220
  ) from exc
202
221
  except OSError as exc:
203
222
  msg = f"Failed to execute command: {cmd!r} ({exc})"
204
- log.exception(msg)
223
+ _log_exception_conditionally(log, msg)
205
224
  raise CommandError(msg, cmd=cmd) from exc
206
225
 
207
226
  result = CommandResult(
@@ -471,7 +490,7 @@ def git_commit_new(
471
490
  ) -> None:
472
491
  """Create a new commit using message or message_file."""
473
492
  if not message and not message_file:
474
- raise ValueError("Either message or message_file must be provided") # noqa: TRY003
493
+ raise ValueError(_MSG_COMMIT_NO_MESSAGE)
475
494
 
476
495
  args: list[str] = ["commit"]
477
496
  if signoff:
@@ -0,0 +1,412 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """
5
+ SSH host key auto-discovery for github2gerrit.
6
+
7
+ This module provides functionality to automatically discover and fetch SSH
8
+ host keys for Gerrit servers, eliminating the need for manual
9
+ GERRIT_KNOWN_HOSTS configuration.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import socket
17
+ from pathlib import Path
18
+
19
+ from .gitutils import CommandError
20
+ from .gitutils import run_cmd
21
+
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+
26
+ class SSHDiscoveryError(Exception):
27
+ """Raised when SSH host key discovery fails."""
28
+
29
+
30
+ # Error message constants to comply with TRY003
31
+ _MSG_HOST_UNREACHABLE = (
32
+ "Host {hostname}:{port} is not reachable. "
33
+ "Check network connectivity and server availability."
34
+ )
35
+ _MSG_NO_KEYS_FOUND = (
36
+ "No SSH host keys found for {hostname}:{port}. "
37
+ "The server may not be running SSH or may be blocking connections."
38
+ )
39
+ _MSG_NO_VALID_KEYS = (
40
+ "No valid SSH host keys found for {hostname}:{port}. "
41
+ "The ssh-keyscan output was empty or malformed."
42
+ )
43
+ _MSG_CONNECTION_FAILED = (
44
+ "Failed to connect to {hostname}:{port} for SSH key discovery. "
45
+ "Error: {error}"
46
+ )
47
+ _MSG_KEYSCAN_FAILED = (
48
+ "ssh-keyscan failed with return code {returncode}: {error}"
49
+ )
50
+ _MSG_UNEXPECTED_ERROR = (
51
+ "Unexpected error during SSH key discovery for {hostname}:{port}: {error}"
52
+ )
53
+ _MSG_SAVE_FAILED = (
54
+ "Failed to save host keys to configuration file {config_file}: {error}"
55
+ )
56
+
57
+
58
+ def is_host_reachable(hostname: str, port: int, timeout: int = 5) -> bool:
59
+ """Check if a host and port are reachable via TCP."""
60
+ try:
61
+ with socket.create_connection((hostname, port), timeout=timeout):
62
+ return True
63
+ except OSError:
64
+ return False
65
+
66
+
67
+ def fetch_ssh_host_keys(
68
+ hostname: str, port: int = 22, timeout: int = 10
69
+ ) -> str:
70
+ """
71
+ Fetch SSH host keys for a given hostname and port using ssh-keyscan.
72
+
73
+ Args:
74
+ hostname: The hostname to scan
75
+ port: The SSH port (default: 22)
76
+ timeout: Connection timeout in seconds (default: 10)
77
+
78
+ Returns:
79
+ A string containing the host keys in known_hosts format
80
+
81
+ Raises:
82
+ SSHDiscoveryError: If the host keys cannot be fetched
83
+ """
84
+ log.debug("Fetching SSH host keys for %s:%d", hostname, port)
85
+
86
+ # First check if the host is reachable
87
+ if not is_host_reachable(hostname, port, timeout=5):
88
+ raise SSHDiscoveryError(
89
+ _MSG_HOST_UNREACHABLE.format(hostname=hostname, port=port)
90
+ )
91
+
92
+ try:
93
+ # Use ssh-keyscan to fetch all available key types
94
+ cmd = [
95
+ "ssh-keyscan",
96
+ "-p",
97
+ str(port),
98
+ "-T",
99
+ str(timeout),
100
+ "-t",
101
+ "rsa,ecdsa,ed25519",
102
+ hostname,
103
+ ]
104
+
105
+ result = run_cmd(cmd, timeout=timeout + 5)
106
+
107
+ if not result.stdout or not result.stdout.strip():
108
+ raise SSHDiscoveryError( # noqa: TRY301
109
+ _MSG_NO_KEYS_FOUND.format(hostname=hostname, port=port)
110
+ )
111
+
112
+ # Validate that we got proper known_hosts format
113
+ lines = result.stdout.strip().split("\n")
114
+ valid_lines = []
115
+
116
+ for line in lines:
117
+ stripped_line = line.strip()
118
+ if not stripped_line or stripped_line.startswith("#"):
119
+ continue
120
+
121
+ # Basic validation: should have hostname, key type, and key
122
+ parts = stripped_line.split()
123
+ if len(parts) >= 3:
124
+ valid_lines.append(stripped_line)
125
+
126
+ if not valid_lines:
127
+ raise SSHDiscoveryError( # noqa: TRY301
128
+ _MSG_NO_VALID_KEYS.format(hostname=hostname, port=port)
129
+ )
130
+
131
+ discovered_keys = "\n".join(valid_lines)
132
+ log.info(
133
+ "Successfully discovered %d SSH host key(s) for %s:%d",
134
+ len(valid_lines),
135
+ hostname,
136
+ port,
137
+ )
138
+ log.debug("Discovered keys:\n%s", discovered_keys)
139
+
140
+ except CommandError as exc:
141
+ if exc.returncode == 1:
142
+ # ssh-keyscan returns 1 when it can't connect
143
+ error_msg = exc.stderr or exc.stdout or "Connection failed"
144
+ raise SSHDiscoveryError(
145
+ _MSG_CONNECTION_FAILED.format(
146
+ hostname=hostname, port=port, error=error_msg
147
+ )
148
+ ) from exc
149
+ else:
150
+ error_msg = exc.stderr or exc.stdout or "Unknown error"
151
+ raise SSHDiscoveryError(
152
+ _MSG_KEYSCAN_FAILED.format(
153
+ returncode=exc.returncode, error=error_msg
154
+ )
155
+ ) from exc
156
+ except Exception as exc:
157
+ raise SSHDiscoveryError(
158
+ _MSG_UNEXPECTED_ERROR.format(
159
+ hostname=hostname, port=port, error=exc
160
+ )
161
+ ) from exc
162
+ else:
163
+ return discovered_keys
164
+
165
+
166
+ def extract_gerrit_info_from_gitreview(content: str) -> tuple[str, int] | None:
167
+ """
168
+ Extract Gerrit hostname and port from .gitreview file content.
169
+
170
+ Args:
171
+ content: The content of a .gitreview file
172
+
173
+ Returns:
174
+ A tuple of (hostname, port) or None if not found
175
+ """
176
+ hostname = None
177
+ port = 29418 # Default Gerrit SSH port
178
+
179
+ for line in content.split("\n"):
180
+ stripped_line = line.strip()
181
+ if "=" not in stripped_line:
182
+ continue
183
+
184
+ key, value = stripped_line.split("=", 1)
185
+ key = key.strip().lower()
186
+ value = value.strip()
187
+
188
+ if key == "host":
189
+ hostname = value
190
+ elif key == "port":
191
+ try:
192
+ port = int(value)
193
+ except ValueError:
194
+ log.warning("Invalid port in .gitreview: %s", value)
195
+
196
+ return (hostname, port) if hostname else None
197
+
198
+
199
+ def discover_and_save_host_keys(
200
+ hostname: str, port: int, organization: str, config_path: str | None = None
201
+ ) -> str:
202
+ """
203
+ Discover SSH host keys and save them to the organization's configuration.
204
+
205
+ Args:
206
+ hostname: Gerrit hostname
207
+ port: Gerrit SSH port
208
+ organization: GitHub organization name for config section
209
+ config_path: Path to config file (optional, uses default if not
210
+ provided)
211
+
212
+ Returns:
213
+ The discovered host keys string
214
+
215
+ Raises:
216
+ SSHDiscoveryError: If discovery or saving fails
217
+ """
218
+ # Discover the host keys
219
+ host_keys = fetch_ssh_host_keys(hostname, port)
220
+
221
+ # Save to configuration file
222
+ save_host_keys_to_config(host_keys, organization, config_path)
223
+
224
+ return host_keys
225
+
226
+
227
+ def save_host_keys_to_config(
228
+ host_keys: str, organization: str, config_path: str | None = None
229
+ ) -> None:
230
+ """
231
+ Save SSH host keys to the organization's configuration file.
232
+
233
+ Args:
234
+ host_keys: The host keys in known_hosts format
235
+ organization: GitHub organization name for config section
236
+ config_path: Path to config file (optional, uses default if not
237
+ provided)
238
+
239
+ Raises:
240
+ SSHDiscoveryError: If saving fails
241
+ """
242
+ from .config import DEFAULT_CONFIG_PATH
243
+
244
+ if config_path is None:
245
+ config_path = (
246
+ os.getenv("G2G_CONFIG_PATH", "").strip() or DEFAULT_CONFIG_PATH
247
+ )
248
+
249
+ config_file = Path(config_path).expanduser()
250
+
251
+ try:
252
+ # Ensure the directory exists
253
+ config_file.parent.mkdir(parents=True, exist_ok=True)
254
+
255
+ # Read existing configuration
256
+ existing_content = ""
257
+ if config_file.exists():
258
+ existing_content = config_file.read_text(encoding="utf-8")
259
+
260
+ # Parse existing content to find the organization section
261
+ lines = existing_content.split("\n")
262
+ new_lines = []
263
+ in_org_section = False
264
+ org_section_found = False
265
+ gerrit_known_hosts_updated = False
266
+
267
+ for line in lines:
268
+ stripped = line.strip()
269
+
270
+ # Check for section headers
271
+ if stripped.startswith("[") and stripped.endswith("]"):
272
+ section_name = stripped[1:-1].strip().lower()
273
+ in_org_section = section_name == organization.lower()
274
+ if in_org_section:
275
+ org_section_found = True
276
+
277
+ # If we're in the org section and find GERRIT_KNOWN_HOSTS, replace
278
+ elif in_org_section and "=" in line:
279
+ key = line.split("=", 1)[0].strip().upper()
280
+ if key == "GERRIT_KNOWN_HOSTS":
281
+ # Replace with new host keys (properly escaped for INI)
282
+ escaped_keys = host_keys.replace("\n", "\\n")
283
+ new_lines.append(f'GERRIT_KNOWN_HOSTS = "{escaped_keys}"')
284
+ gerrit_known_hosts_updated = True
285
+ continue
286
+
287
+ new_lines.append(line)
288
+
289
+ # If organization section wasn't found, add it
290
+ if not org_section_found:
291
+ if new_lines and new_lines[-1].strip():
292
+ new_lines.append("") # Add blank line before new section
293
+ new_lines.append(f"[{organization}]")
294
+ escaped_keys = host_keys.replace("\n", "\\n")
295
+ new_lines.append(f'GERRIT_KNOWN_HOSTS = "{escaped_keys}"')
296
+ gerrit_known_hosts_updated = True
297
+
298
+ # If section existed but didn't have GERRIT_KNOWN_HOSTS, add it
299
+ elif not gerrit_known_hosts_updated:
300
+ # Find the end of the organization section and add the key there
301
+ section_end = len(new_lines)
302
+ for i, line in enumerate(new_lines):
303
+ stripped = line.strip()
304
+ if stripped.startswith("[") and stripped.endswith("]"):
305
+ section_name = stripped[1:-1].strip().lower()
306
+ if section_name == organization.lower():
307
+ # Find the end of this section
308
+ for j in range(i + 1, len(new_lines)):
309
+ if new_lines[j].strip().startswith("["):
310
+ section_end = j
311
+ break
312
+ break
313
+
314
+ # Insert the GERRIT_KNOWN_HOSTS entry
315
+ escaped_keys = host_keys.replace("\n", "\\n")
316
+ new_lines.insert(
317
+ section_end, f'GERRIT_KNOWN_HOSTS = "{escaped_keys}"'
318
+ )
319
+
320
+ # Write the updated configuration
321
+ config_file.write_text("\n".join(new_lines), encoding="utf-8")
322
+
323
+ log.info(
324
+ "Successfully saved SSH host keys to configuration file: %s [%s]",
325
+ config_file,
326
+ organization,
327
+ )
328
+
329
+ except Exception as exc:
330
+ raise SSHDiscoveryError(
331
+ _MSG_SAVE_FAILED.format(config_file=config_file, error=exc)
332
+ ) from exc
333
+
334
+
335
+ def auto_discover_gerrit_host_keys(
336
+ gerrit_hostname: str | None = None,
337
+ gerrit_port: int | None = None,
338
+ organization: str | None = None,
339
+ save_to_config: bool = True,
340
+ ) -> str | None:
341
+ """
342
+ Automatically discover Gerrit SSH host keys and optionally save to config.
343
+
344
+ This is the main entry point for auto-discovery functionality.
345
+
346
+ Args:
347
+ gerrit_hostname: Gerrit hostname (if not provided, tries to detect
348
+ from context)
349
+ gerrit_port: Gerrit SSH port (defaults to 29418)
350
+ organization: GitHub organization (if not provided, tries to detect
351
+ from env)
352
+ save_to_config: Whether to save discovered keys to config file
353
+
354
+ Returns:
355
+ The discovered host keys string, or None if discovery failed
356
+ """
357
+ try:
358
+ # Set defaults
359
+ if gerrit_port is None:
360
+ gerrit_port = 29418
361
+
362
+ if organization is None:
363
+ organization = (
364
+ os.getenv("ORGANIZATION")
365
+ or os.getenv("GITHUB_REPOSITORY_OWNER")
366
+ or ""
367
+ ).strip()
368
+
369
+ if not gerrit_hostname:
370
+ log.debug("No Gerrit hostname provided for auto-discovery")
371
+ return None
372
+
373
+ if not organization:
374
+ log.warning(
375
+ "No organization specified for SSH host key auto-discovery. "
376
+ "Cannot save to configuration file."
377
+ )
378
+ save_to_config = False
379
+
380
+ log.info(
381
+ "Attempting to auto-discover SSH host keys for %s:%d",
382
+ gerrit_hostname,
383
+ gerrit_port,
384
+ )
385
+
386
+ # Discover the host keys
387
+ host_keys = fetch_ssh_host_keys(gerrit_hostname, gerrit_port)
388
+
389
+ # Save to configuration if requested and possible
390
+ if save_to_config and organization:
391
+ save_host_keys_to_config(host_keys, organization)
392
+ log.info(
393
+ "SSH host keys automatically discovered and saved to config "
394
+ "for organization '%s'. Future runs will use the cached keys.",
395
+ organization,
396
+ )
397
+ else:
398
+ log.info(
399
+ "SSH host keys discovered but not saved to configuration. "
400
+ "Set ORGANIZATION environment variable to enable auto-saving."
401
+ )
402
+
403
+ except SSHDiscoveryError as exc:
404
+ log.warning("SSH host key auto-discovery failed: %s", exc)
405
+ return None
406
+ except Exception as exc:
407
+ log.warning(
408
+ "Unexpected error during SSH host key auto-discovery: %s", exc
409
+ )
410
+ return None
411
+ else:
412
+ return host_keys
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Author-email: Matthew Watkins <mwatkins@linuxfoundation.org>
6
6
  License-Expression: Apache-2.0
@@ -34,6 +34,8 @@ Requires-Dist: ruff>=0.6.3; extra == "dev"
34
34
  Requires-Dist: black>=24.8.0; extra == "dev"
35
35
  Requires-Dist: mypy>=1.11.2; extra == "dev"
36
36
  Requires-Dist: pytest-mock>=3.14.0; extra == "dev"
37
+ Requires-Dist: types-requests>=2.31.0; extra == "dev"
38
+ Requires-Dist: types-click>=7.1.8; extra == "dev"
37
39
  Dynamic: license-file
38
40
 
39
41
  <!--
@@ -0,0 +1,15 @@
1
+ github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
+ github2gerrit/cli.py,sha256=30hvpwZCs-xeWfP6TJmcluebmPVU88AUUNprd0Szt-8,34043
3
+ github2gerrit/config.py,sha256=4RmAyRFs1CxeGlAjbCaVW63EqEnBt5Vag0jTTMzfKyU,16948
4
+ github2gerrit/core.py,sha256=HKgSmh792sbdTV_vuNLos-eaYgj3W0F0H72N7KBV6IA,75175
5
+ github2gerrit/duplicate_detection.py,sha256=J6a8t3ih-ebr6FEhWsaKnXYPQCzwcnFEWhdstmtjnMo,19475
6
+ github2gerrit/github_api.py,sha256=G_VRvIzpugDeNRyw1y-KGQQ_wvDRl-L6UCqP8BRh-gU,10697
7
+ github2gerrit/gitutils.py,sha256=8Q94BCLC924zIG2kcCSzxkajTpUamQ3Ul07OqzEv9ic,18664
8
+ github2gerrit/models.py,sha256=DAm0pEWvAexOInnxTVrvTnKWhLMd86TfSqT78UohOCo,1791
9
+ github2gerrit/ssh_discovery.py,sha256=xildpri60eQZtnXJuRxcEEb-q71h6D8QUiQvp2P9LlU,13300
10
+ github2gerrit-0.1.5.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
11
+ github2gerrit-0.1.5.dist-info/METADATA,sha256=5rLt8uNLd0FLcNGqNncQbM7cYm3Ns_cDECz385lqwpk,21545
12
+ github2gerrit-0.1.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ github2gerrit-0.1.5.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
14
+ github2gerrit-0.1.5.dist-info/top_level.txt,sha256=bWTYXjvuu4sSU90KLT1JlnjD7xV_iXZ-vKoulpjLTy8,14
15
+ github2gerrit-0.1.5.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
- github2gerrit/cli.py,sha256=gvgyoKvNzOdh5H_BaBAkAFXvJEQLsSa4ACqYg_9QdyA,29768
3
- github2gerrit/config.py,sha256=_r5BAowI3x5vRKSGcZsJn6NGJqkiPF8hAmfqT1id3I8,10282
4
- github2gerrit/core.py,sha256=qatoJ_M6I8kiQeAA9kFT32uuw5Xo7pnUUWht0RL24io,69593
5
- github2gerrit/duplicate_detection.py,sha256=J6a8t3ih-ebr6FEhWsaKnXYPQCzwcnFEWhdstmtjnMo,19475
6
- github2gerrit/github_api.py,sha256=mgiz55GrTgAVozmoOKSLrnUcX59YxV3p2Llch2COmyE,10523
7
- github2gerrit/gitutils.py,sha256=1KmBACvvVDIte0WiuR-AlgswbWEm69G0J2OpmAgPn7Y,18058
8
- github2gerrit/models.py,sha256=DAm0pEWvAexOInnxTVrvTnKWhLMd86TfSqT78UohOCo,1791
9
- github2gerrit-0.1.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
10
- github2gerrit-0.1.4.dist-info/METADATA,sha256=jJml8yKtMJgtQSfZ1F-UE5dhQmtdEQeo1kyGoBjf17w,21441
11
- github2gerrit-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- github2gerrit-0.1.4.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
13
- github2gerrit-0.1.4.dist-info/top_level.txt,sha256=bWTYXjvuu4sSU90KLT1JlnjD7xV_iXZ-vKoulpjLTy8,14
14
- github2gerrit-0.1.4.dist-info/RECORD,,