github2gerrit 0.1.3__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,31 @@ 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
+
55
+ class ConfigurationError(Exception):
56
+ """Raised when configuration validation fails.
57
+
58
+ This custom exception is used instead of typer.BadParameter to provide
59
+ cleaner error messages to end users without exposing Python tracebacks.
60
+ When caught, it displays user-friendly messages prefixed with
61
+ "Configuration validation failed:" rather than raw exception details.
62
+ """
63
+
64
+
34
65
  def _parse_github_target(url: str) -> tuple[str | None, str | None, int | None]:
35
66
  """
36
67
  Parse a GitHub repository or pull request URL.
@@ -73,14 +104,36 @@ def _parse_github_target(url: str) -> tuple[str | None, str | None, int | None]:
73
104
  APP_NAME = "github2gerrit"
74
105
 
75
106
 
76
- class _SingleUsageGroup(click.Group): # type: ignore[misc]
77
- 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:
78
126
  # Force a simplified usage line without COMMAND [ARGS]...
79
127
  formatter.write_usage(
80
128
  ctx.command_path, "[OPTIONS] TARGET_URL", prefix="Usage: "
81
129
  )
82
130
 
83
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
+
84
137
  app: typer.Typer = typer.Typer(
85
138
  add_completion=False,
86
139
  no_args_is_help=False,
@@ -98,7 +151,17 @@ def _resolve_org(default_org: str | None) -> str:
98
151
  return ""
99
152
 
100
153
 
101
- @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()
102
165
  def main(
103
166
  ctx: typer.Context,
104
167
  target_url: str | None = typer.Argument(
@@ -390,7 +453,7 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
390
453
  per_ctx, allow_duplicates=data.allow_duplicates
391
454
  )
392
455
  except DuplicateChangeError as exc:
393
- log.exception("Skipping PR #%d", pr_number)
456
+ _log_exception_conditionally(log, "Skipping PR #%d", pr_number)
394
457
  typer.echo(f"Skipping PR #{pr_number}: {exc}")
395
458
  continue
396
459
 
@@ -402,7 +465,7 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
402
465
  if result_multi.change_urls:
403
466
  all_urls.extend(result_multi.change_urls)
404
467
  for url in result_multi.change_urls:
405
- typer.echo(f"Gerrit change URL: {url}")
468
+ log.info("Gerrit change URL: %s", url)
406
469
  log.info(
407
470
  "PR #%d created Gerrit change: %s",
408
471
  pr_number,
@@ -416,7 +479,9 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> None:
416
479
  result_multi.change_numbers,
417
480
  )
418
481
  except Exception as exc:
419
- log.exception("Failed to process PR #%d", pr_number)
482
+ _log_exception_conditionally(
483
+ log, "Failed to process PR #%d", pr_number
484
+ )
420
485
  typer.echo(f"Failed to process PR #{pr_number}: {exc}")
421
486
  log.info("Continuing to next PR despite failure")
422
487
  continue
@@ -452,8 +517,10 @@ def _process_single(data: Inputs, gh: GitHubContext) -> None:
452
517
  log.debug("Local checkout preparation failed: %s", exc)
453
518
 
454
519
  orch = Orchestrator(workspace=workspace)
520
+ pipeline_success = False
455
521
  try:
456
522
  result = orch.execute(inputs=data, gh=gh)
523
+ pipeline_success = True
457
524
  except Exception as exc:
458
525
  log.debug("Execution failed; continuing to write outputs: %s", exc)
459
526
 
@@ -466,7 +533,7 @@ def _process_single(data: Inputs, gh: GitHubContext) -> None:
466
533
  )
467
534
  # Output Gerrit change URL(s) to console
468
535
  for url in result.change_urls:
469
- typer.echo(f"Gerrit change URL: {url}")
536
+ log.info("Gerrit change URL: %s", url)
470
537
  if result.change_numbers:
471
538
  os.environ["GERRIT_CHANGE_REQUEST_NUM"] = "\n".join(
472
539
  result.change_numbers
@@ -485,7 +552,10 @@ def _process_single(data: Inputs, gh: GitHubContext) -> None:
485
552
  }
486
553
  )
487
554
 
488
- log.info("Submission pipeline complete.")
555
+ if pipeline_success:
556
+ log.info("Submission pipeline completed SUCCESSFULLY ✅")
557
+ else:
558
+ log.error("Submission pipeline FAILED ❌")
489
559
  return
490
560
 
491
561
 
@@ -562,6 +632,10 @@ def _load_effective_inputs() -> Inputs:
562
632
  or os.getenv("GITHUB_REPOSITORY_OWNER")
563
633
  )
564
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
+
565
639
  apply_config_to_env(cfg)
566
640
 
567
641
  # Refresh inputs after applying configuration to environment
@@ -661,9 +735,9 @@ def _process() -> None:
661
735
  # Validate inputs
662
736
  try:
663
737
  _validate_inputs(data)
664
- except typer.BadParameter as exc:
665
- log.exception("Validation failed")
666
- typer.echo(str(exc), err=True)
738
+ except ConfigurationError as exc:
739
+ _log_exception_conditionally(log, "Configuration validation failed")
740
+ typer.echo(f"Configuration validation failed: {exc}", err=True)
667
741
  raise typer.Exit(code=2) from exc
668
742
 
669
743
  gh = _read_github_context()
@@ -707,7 +781,7 @@ def _process() -> None:
707
781
  try:
708
782
  check_for_duplicates(gh, allow_duplicates=data.allow_duplicates)
709
783
  except DuplicateChangeError as exc:
710
- log.exception("Duplicate change detected")
784
+ _log_exception_conditionally(log, "Duplicate change detected")
711
785
  typer.echo(f"Error: {exc}", err=True)
712
786
  typer.echo(
713
787
  "Use --allow-duplicates to override this check.", err=True
@@ -803,27 +877,87 @@ def _validate_inputs(data: Inputs) -> None:
803
877
  "USE_PR_AS_COMMIT and SUBMIT_SINGLE_COMMITS cannot be enabled at "
804
878
  "the same time"
805
879
  )
806
- raise typer.BadParameter(msg)
807
-
808
- # Presence checks for required fields used by existing action
809
- for field_name in (
810
- "gerrit_known_hosts",
811
- "gerrit_ssh_privkey_g2g",
812
- "gerrit_ssh_user_g2g",
813
- "gerrit_ssh_user_g2g_email",
814
- ):
880
+ raise ConfigurationError(msg)
881
+
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:
815
924
  if not getattr(data, field_name):
816
925
  log.error("Missing required input: %s", field_name)
817
- raise typer.BadParameter(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
+ )
818
952
 
819
953
  # Validate fetch depth is a positive integer
820
954
  if data.fetch_depth <= 0:
821
955
  log.error("Invalid FETCH_DEPTH: %s", data.fetch_depth)
822
- raise typer.BadParameter("FETCH_DEPTH must be a positive integer") # noqa: TRY003
956
+ raise ConfigurationError(_MSG_INVALID_FETCH_DEPTH)
823
957
 
824
958
  # Validate Issue ID is a single line string if provided
825
959
  if data.issue_id and ("\n" in data.issue_id or "\r" in data.issue_id):
826
- raise typer.BadParameter("Issue ID must be single line") # noqa: TRY003
960
+ raise ConfigurationError(_MSG_ISSUE_ID_MULTILINE)
827
961
 
828
962
 
829
963
  def _log_effective_config(data: Inputs, gh: GitHubContext) -> None:
@@ -833,10 +967,10 @@ def _log_effective_config(data: Inputs, gh: GitHubContext) -> None:
833
967
  log.info(" SUBMIT_SINGLE_COMMITS: %s", data.submit_single_commits)
834
968
  log.info(" USE_PR_AS_COMMIT: %s", data.use_pr_as_commit)
835
969
  log.info(" FETCH_DEPTH: %s", data.fetch_depth)
836
- log.info(
837
- " GERRIT_KNOWN_HOSTS: %s",
838
- "<provided>" if data.gerrit_known_hosts else "<missing>",
970
+ known_hosts_status = (
971
+ "<provided>" if data.gerrit_known_hosts else "<will auto-discover>"
839
972
  )
973
+ log.info(" GERRIT_KNOWN_HOSTS: %s", known_hosts_status)
840
974
  log.info(" GERRIT_SSH_PRIVKEY_G2G: %s", safe_privkey)
841
975
  log.info(" GERRIT_SSH_USER_G2G: %s", data.gerrit_ssh_user_g2g)
842
976
  log.info(" GERRIT_SSH_USER_G2G_EMAIL: %s", data.gerrit_ssh_user_g2g_email)
github2gerrit/config.py CHANGED
@@ -58,8 +58,8 @@ log = logging.getLogger("github2gerrit.config")
58
58
 
59
59
  DEFAULT_CONFIG_PATH = "~/.config/github2gerrit/configuration.txt"
60
60
 
61
- # Recognized keys. Unknown keys are still passed through, but these are
62
- # the most relevant to current tooling and action inputs/options.
61
+ # Recognized keys. Unknown keys will be reported as warnings to help
62
+ # users catch typos and missing functionality.
63
63
  KNOWN_KEYS: set[str] = {
64
64
  # Action inputs
65
65
  "SUBMIT_SINGLE_COMMITS",
@@ -74,6 +74,15 @@ KNOWN_KEYS: set[str] = {
74
74
  "PR_NUMBER",
75
75
  "SYNC_ALL_OPEN_PRS",
76
76
  "PRESERVE_GITHUB_PRS",
77
+ "ALLOW_GHE_URLS",
78
+ "DRY_RUN",
79
+ "ALLOW_DUPLICATES",
80
+ "ISSUE_ID",
81
+ "G2G_VERBOSE",
82
+ "G2G_SKIP_GERRIT_COMMENTS",
83
+ "G2G_ENABLE_DERIVATION",
84
+ "G2G_AUTO_SAVE_CONFIG",
85
+ "GITHUB_TOKEN",
77
86
  # Optional inputs (reusable workflow compatibility)
78
87
  "GERRIT_SERVER",
79
88
  "GERRIT_SERVER_PORT",
@@ -264,6 +273,17 @@ def load_org_config(
264
273
  )
265
274
 
266
275
  normalized = _normalize_keys(result)
276
+
277
+ # Report unknown configuration keys to help users catch typos
278
+ unknown_keys = set(normalized.keys()) - KNOWN_KEYS
279
+ if unknown_keys:
280
+ log.warning(
281
+ "Unknown configuration keys found in [%s]: %s. "
282
+ "These will be ignored. Check for typos or missing functionality.",
283
+ effective_org or "default",
284
+ ", ".join(sorted(unknown_keys)),
285
+ )
286
+
267
287
  return normalized
268
288
 
269
289
 
@@ -295,6 +315,198 @@ def filter_known(
295
315
  return {k: v for k, v in cfg.items() if k in KNOWN_KEYS}
296
316
 
297
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
+
298
510
  def overlay_missing(
299
511
  primary: dict[str, str],
300
512
  fallback: dict[str, str],