github2gerrit 0.1.4__tar.gz → 0.1.5__tar.gz

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.
Files changed (67) hide show
  1. {github2gerrit-0.1.4/src/github2gerrit.egg-info → github2gerrit-0.1.5}/PKG-INFO +3 -1
  2. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/action.yaml +18 -5
  3. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/pyproject.toml +20 -2
  4. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/sitecustomize.py +7 -2
  5. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/cli.py +139 -20
  6. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/config.py +194 -0
  7. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/core.py +174 -38
  8. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/github_api.py +9 -3
  9. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/gitutils.py +22 -3
  10. github2gerrit-0.1.5/src/github2gerrit/ssh_discovery.py +412 -0
  11. {github2gerrit-0.1.4 → github2gerrit-0.1.5/src/github2gerrit.egg-info}/PKG-INFO +3 -1
  12. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit.egg-info/SOURCES.txt +3 -0
  13. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit.egg-info/requires.txt +2 -0
  14. github2gerrit-0.1.5/tests/test_cli.py +270 -0
  15. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_cli_helpers.py +14 -1
  16. github2gerrit-0.1.5/tests/test_config_helpers.py +683 -0
  17. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_config_and_errors.py +12 -1
  18. github2gerrit-0.1.5/tests/test_core_gerrit_push_errors.py +471 -0
  19. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_ssh_setup.py +215 -9
  20. github2gerrit-0.1.5/tests/test_ssh_discovery.py +362 -0
  21. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_url_parser.py +14 -2
  22. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/uv.lock +273 -15
  23. github2gerrit-0.1.4/tests/test_cli.py +0 -137
  24. github2gerrit-0.1.4/tests/test_config_helpers.py +0 -271
  25. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.editorconfig +0 -0
  26. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/actionlint.yaml +0 -0
  27. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/dependabot.yml +0 -0
  28. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/release-drafter.yml +0 -0
  29. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/build-test-release.yaml +0 -0
  30. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/build-test.yaml +0 -0
  31. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/dependencies.yaml +0 -0
  32. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/github2gerrit.yaml +0 -0
  33. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/release-drafter.yaml +0 -0
  34. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/semantic-pull-request.yaml +0 -0
  35. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.github/workflows/sha-pinned-actions.yaml +0 -0
  36. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.gitignore +0 -0
  37. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.gitlint +0 -0
  38. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.pre-commit-config.yaml +0 -0
  39. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.readthedocs.yml +0 -0
  40. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/.yamllint +0 -0
  41. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/LICENSE +0 -0
  42. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/LICENSES/Apache-2.0.txt +0 -0
  43. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/README.md +0 -0
  44. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/REUSE.toml +0 -0
  45. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/setup.cfg +0 -0
  46. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/__init__.py +0 -0
  47. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/duplicate_detection.py +0 -0
  48. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit/models.py +0 -0
  49. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit.egg-info/dependency_links.txt +0 -0
  50. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit.egg-info/entry_points.txt +0 -0
  51. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/src/github2gerrit.egg-info/top_level.txt +0 -0
  52. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/conftest.py +0 -0
  53. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/fixtures/__init__.py +0 -0
  54. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/fixtures/make_repo.py +0 -0
  55. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_cli_outputs_file.py +0 -0
  56. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_cli_url_and_dryrun.py +0 -0
  57. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_config_and_reviewers.py +0 -0
  58. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_close_pr_policy.py +0 -0
  59. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_gerrit_backref_comment.py +0 -0
  60. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_gerrit_rest_results.py +0 -0
  61. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_integration_fixture_repo.py +0 -0
  62. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_core_prepare_commits.py +0 -0
  63. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_duplicate_detection.py +0 -0
  64. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_ghe_and_gitreview_args.py +0 -0
  65. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_github_api_helpers.py +0 -0
  66. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_github_api_retry_and_helpers.py +0 -0
  67. {github2gerrit-0.1.4 → github2gerrit-0.1.5}/tests/test_gitutils_helpers.py +0 -0
@@ -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
  <!--
@@ -31,11 +31,20 @@ inputs:
31
31
  description: "SSH private key content used to authenticate to Gerrit"
32
32
  required: true
33
33
  GERRIT_SSH_USER_G2G:
34
- description: "Gerrit SSH username (e.g. automation bot account)"
35
- required: true
34
+ description: >
35
+ Gerrit SSH username (e.g. automation bot account). In GitHub Actions,
36
+ automatically derived as [ORGANIZATION].gh2gerrit when not provided.
37
+ For local CLI usage, set G2G_ENABLE_DERIVATION=true to enable derivation.
38
+ required: false
39
+ default: ""
36
40
  GERRIT_SSH_USER_G2G_EMAIL:
37
- description: "Email address associated to the Gerrit SSH user"
38
- required: true
41
+ description: >
42
+ Email address associated to the Gerrit SSH user. In GitHub Actions,
43
+ automatically derived as
44
+ releng+[ORGANIZATION]-gh2gerrit@linuxfoundation.org when not provided.
45
+ For local CLI usage, set G2G_ENABLE_DERIVATION=true to enable derivation.
46
+ required: false
47
+ default: ""
39
48
 
40
49
  # Behavior and metadata
41
50
  ORGANIZATION:
@@ -69,7 +78,11 @@ inputs:
69
78
 
70
79
  # Optional Gerrit overrides (used when .gitreview is missing)
71
80
  GERRIT_SERVER:
72
- description: "Gerrit server hostname (optional; use .gitreview if present)"
81
+ description: >
82
+ Gerrit server hostname (optional; use .gitreview if present). In GitHub
83
+ Actions, automatically derived as gerrit.[ORGANIZATION].org when not
84
+ provided. For local CLI usage, set G2G_ENABLE_DERIVATION=true to enable
85
+ derivation.
73
86
  required: false
74
87
  default: ""
75
88
  GERRIT_SERVER_PORT:
@@ -71,6 +71,8 @@ dev = [
71
71
 
72
72
  # Type checking helpers
73
73
  "pytest-mock>=3.14.0",
74
+ "types-requests>=2.31.0",
75
+ "types-click>=7.1.8",
74
76
  ]
75
77
 
76
78
  [tool.ruff]
@@ -91,6 +93,7 @@ ignore = [
91
93
 
92
94
  [tool.ruff.lint.per-file-ignores]
93
95
  "tests/*.py" = ["S101", "S105", "PLW0603", "E501", "TRY003"]
96
+ "sitecustomize.py" = ["S110", "SIM103", "SIM105", "PLW2901", "E501"]
94
97
 
95
98
  [tool.ruff.lint.isort]
96
99
  known-first-party = ["github2gerrit"]
@@ -127,6 +130,23 @@ exclude = [
127
130
  module = ["pygerrit2", "pygerrit2.*"]
128
131
  ignore_missing_imports = true
129
132
 
133
+ [[tool.mypy.overrides]]
134
+ module = ["click", "click.*"]
135
+ ignore_missing_imports = true
136
+
137
+ [[tool.mypy.overrides]]
138
+ module = ["typer", "typer.*"]
139
+ ignore_missing_imports = true
140
+
141
+ [[tool.mypy.overrides]]
142
+ module = ["pytest", "pytest.*"]
143
+ ignore_missing_imports = true
144
+
145
+ [[tool.mypy.overrides]]
146
+ module = ["tests.*"]
147
+ disallow_untyped_defs = false
148
+ disallow_untyped_calls = false
149
+
130
150
  [tool.coverage.run]
131
151
  branch = true
132
152
  source = ["src/github2gerrit"]
@@ -153,5 +173,3 @@ testpaths = ["tests"]
153
173
  # No extra settings are required here; this stanza reserves the
154
174
  # namespace for future use if needed.
155
175
  managed = true
156
-
157
-
@@ -81,7 +81,9 @@ def _iter_cov_candidates(base: Path) -> Iterable[Path]:
81
81
  _dbg(f"Failed to iterate {base}: {exc}")
82
82
 
83
83
 
84
- def _clean_stale_coverage_files(bases: Iterable[Path], protect: Path | None) -> None:
84
+ def _clean_stale_coverage_files(
85
+ bases: Iterable[Path], protect: Path | None
86
+ ) -> None:
85
87
  protected = str(protect.resolve()) if protect else None
86
88
  for base in bases:
87
89
  try:
@@ -109,7 +111,10 @@ def _ensure_unique_coverage_file() -> Path:
109
111
  except Exception:
110
112
  # Fall through to create a sane default
111
113
  pass
112
- unique = Path(tempfile.gettempdir()) / f".coverage.pytest.{os.getpid()}.{uuid.uuid4().hex}"
114
+ unique = (
115
+ Path(tempfile.gettempdir())
116
+ / f".coverage.pytest.{os.getpid()}.{uuid.uuid4().hex}"
117
+ )
113
118
  os.environ["COVERAGE_FILE"] = str(unique)
114
119
  return unique
115
120
 
@@ -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)
@@ -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],