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 +139 -20
- github2gerrit/config.py +194 -0
- github2gerrit/core.py +174 -38
- github2gerrit/github_api.py +9 -3
- github2gerrit/gitutils.py +22 -3
- github2gerrit/ssh_discovery.py +412 -0
- {github2gerrit-0.1.4.dist-info → github2gerrit-0.1.5.dist-info}/METADATA +3 -1
- github2gerrit-0.1.5.dist-info/RECORD +15 -0
- github2gerrit-0.1.4.dist-info/RECORD +0 -14
- {github2gerrit-0.1.4.dist-info → github2gerrit-0.1.5.dist-info}/WHEEL +0 -0
- {github2gerrit-0.1.4.dist-info → github2gerrit-0.1.5.dist-info}/entry_points.txt +0 -0
- {github2gerrit-0.1.4.dist-info → github2gerrit-0.1.5.dist-info}/licenses/LICENSE +0 -0
- {github2gerrit-0.1.4.dist-info → github2gerrit-0.1.5.dist-info}/top_level.txt +0 -0
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
|
-
|
87
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
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
|
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
|
-
#
|
824
|
-
|
825
|
-
|
826
|
-
|
827
|
-
|
828
|
-
|
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
|
-
|
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(
|
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(
|
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
|
-
|
852
|
-
"
|
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(
|
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(
|
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(
|
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(
|
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(
|
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
|
510
|
-
log.debug("SSH key
|
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(
|
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
|
-
|
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
|
-
|
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(
|
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(
|
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(
|
1830
|
+
raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
|
1695
1831
|
if HTTPBasicAuth is None:
|
1696
|
-
raise OrchestratorError(
|
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(
|
1838
|
+
raise OrchestratorError(_MSG_PYGERRIT2_MISSING)
|
1703
1839
|
return GerritRestAPI(url=url)
|
1704
1840
|
|
1705
1841
|
def _probe(url: str) -> None:
|
github2gerrit/github_api.py
CHANGED
@@ -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(
|
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(
|
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(
|
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
|
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
|
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(
|
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.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|