github2gerrit 2.2.1__py3-none-any.whl → 2.2.2__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
@@ -1523,10 +1523,10 @@ def main(
1523
1523
  help="Gerrit server hostname (optional; overrides .gitreview).",
1524
1524
  ),
1525
1525
  gerrit_server_port: int = typer.Option(
1526
- 29418,
1526
+ 0,
1527
1527
  "--gerrit-server-port",
1528
1528
  envvar="GERRIT_SERVER_PORT",
1529
- help="Gerrit SSH port (default: 29418).",
1529
+ help="Gerrit SSH port; .gitreview's when unset, else 29418.",
1530
1530
  ),
1531
1531
  gerrit_ssh_privkey_g2g: str = typer.Option(
1532
1532
  "",
@@ -1899,9 +1899,10 @@ def _build_inputs_from_env() -> Inputs:
1899
1899
  dry_run=env_bool("DRY_RUN", False),
1900
1900
  normalise_commit=env_bool("NORMALISE_COMMIT", True),
1901
1901
  gerrit_server=env_str("GERRIT_SERVER", ""),
1902
- gerrit_server_port=int(
1903
- env_str("GERRIT_SERVER_PORT", "29418") or "29418"
1904
- ),
1902
+ # 0 means unset. A blank value is what the action passes when the
1903
+ # caller gave none, and the distinction matters: an explicit
1904
+ # port outranks .gitreview's, a defaulted one must not.
1905
+ gerrit_server_port=int(env_str("GERRIT_SERVER_PORT", "").strip() or 0),
1905
1906
  gerrit_project=env_str("GERRIT_PROJECT"),
1906
1907
  issue_id=env_str("ISSUE_ID", ""),
1907
1908
  issue_id_lookup_json=env_str("ISSUE_ID_LOOKUP_JSON", ""),
@@ -4030,8 +4031,9 @@ def _add_full_config(
4030
4031
  # Show Gerrit settings if they have values
4031
4032
  if data.gerrit_server:
4032
4033
  config_info["GERRIT_SERVER"] = data.gerrit_server
4033
- # Only show non-default port (29418 is default)
4034
- if data.gerrit_server_port and data.gerrit_server_port != 29418:
4034
+ # Any set port is shown: 0 is the unset sentinel, and an explicit
4035
+ # 29418 is a real override of whatever .gitreview names.
4036
+ if data.gerrit_server_port:
4035
4037
  config_info["GERRIT_SERVER_PORT"] = str(data.gerrit_server_port)
4036
4038
  if data.gerrit_project:
4037
4039
  config_info["GERRIT_PROJECT"] = data.gerrit_project
github2gerrit/config.py CHANGED
@@ -54,9 +54,13 @@ from dataclasses import field
54
54
  from pathlib import Path
55
55
  from typing import Any
56
56
 
57
+ from .config_ini import DEFAULT_CONFIG_PATH as DEFAULT_CONFIG_PATH
57
58
  from .config_ini import _coerce_value
58
59
  from .config_ini import _load_ini
59
60
  from .config_ini import _select_section
61
+ from .config_ini import (
62
+ save_derived_parameters_to_config as save_derived_parameters_to_config,
63
+ )
60
64
  from .project_names import ProjectSource
61
65
  from .utils import env_bool
62
66
  from .utils import setting_bool
@@ -64,8 +68,6 @@ from .utils import setting_bool
64
68
 
65
69
  log = logging.getLogger("github2gerrit.config")
66
70
 
67
- DEFAULT_CONFIG_PATH = "~/.config/github2gerrit/configuration.txt"
68
-
69
71
  # Recognized keys. Unknown keys will be reported as warnings to help
70
72
  # users catch typos and missing functionality.
71
73
  KNOWN_KEYS: set[str] = {
@@ -424,14 +426,22 @@ class DerivedParameters:
424
426
  worth exporting for queries but must stay labelled so the
425
427
  push refuses it.
426
428
  host_from_gitreview: ``True`` when ``GERRIT_SERVER`` in *values*
427
- is the ``.gitreview`` host. That host travels with the
428
- project as one target and, like a confirmed project, may
429
- displace a per-organization configuration value.
429
+ is the ``.gitreview`` host. It travels with the project as
430
+ one target and, like a confirmed project, may displace a
431
+ per-organization configuration value.
432
+ port_from_gitreview: ``True`` when ``GERRIT_SERVER_PORT`` in
433
+ *values* is the ``.gitreview`` port. Tracked apart from the
434
+ host: each field has its own precedence, so the file can win
435
+ the port while an explicit server wins the host. The port
436
+ is exported only when the file supplies it; without a file
437
+ there is nothing to derive it from, and the Gerrit default
438
+ applies downstream.
430
439
  """
431
440
 
432
441
  values: dict[str, str] = field(default_factory=dict)
433
442
  project_source: ProjectSource | None = None
434
443
  host_from_gitreview: bool = False
444
+ port_from_gitreview: bool = False
435
445
 
436
446
  @property
437
447
  def project_confirmed(self) -> bool:
@@ -455,16 +465,71 @@ def derive_gerrit_parameters(
455
465
  return derive_gerrit_parameters_detailed(organization, repository).values
456
466
 
457
467
 
468
+ def _derive_gerrit_project(
469
+ repository: str | None,
470
+ gitreview: Any,
471
+ gerrit_host: str,
472
+ config: dict[str, str],
473
+ ) -> tuple[str, ProjectSource | None]:
474
+ """Derive ``GERRIT_PROJECT`` from ``.gitreview``, else the repository.
475
+
476
+ A ``.gitreview`` read on behalf of a pull request whose base ref is
477
+ not yet known came from a default branch, which may not be the
478
+ branch the pull request targets; its host is kept (servers do not
479
+ vary by branch, and local SSH-identity derivation needs one) but
480
+ its project is not this pull request's to export. Without a
481
+ ``.gitreview`` project the lookup may ask Gerrit, on *gerrit_host*,
482
+ the effective host the caller settled. The loaded configuration
483
+ goes along because this runs before the file has been exported to
484
+ the environment, and the paths that consume the result never see
485
+ the orchestrator's later, environment-only reading of the same
486
+ settings. An explicit ``GERRIT_PROJECT`` in the environment makes
487
+ the lookup moot, exactly as ``.gitreview`` does:
488
+ :func:`apply_config_to_env` would discard the answer.
489
+
490
+ Returns the project (``""`` when the repository is unusable) and its
491
+ :data:`project_names.ProjectSource`.
492
+ """
493
+ if not repository or "/" not in repository:
494
+ return "", None
495
+
496
+ from .gitreview import context_provenance
497
+ from .project_names import opted_in_gerrit_project_lister
498
+ from .project_names import resolve_repo_names
499
+
500
+ gitreview_project = (gitreview.project if gitreview else "").strip()
501
+ if gitreview_project and context_provenance(repository).provisional:
502
+ log.debug(
503
+ "Base ref not yet known for this pull request; not taking "
504
+ "project %r from a default-branch .gitreview",
505
+ gitreview_project,
506
+ )
507
+ gitreview_project = ""
508
+ lookup_moot = bool(gitreview_project) or bool(
509
+ (os.getenv("GERRIT_PROJECT") or "").strip()
510
+ )
511
+ names = resolve_repo_names(
512
+ repository,
513
+ gitreview_project=gitreview_project or None,
514
+ list_projects=None
515
+ if lookup_moot
516
+ else opted_in_gerrit_project_lister(gerrit_host, config=config),
517
+ )
518
+ return names.project_gerrit, names.source
519
+
520
+
458
521
  def derive_gerrit_parameters_detailed(
459
522
  organization: str | None, repository: str | None = None
460
523
  ) -> DerivedParameters:
461
524
  """Derive Gerrit parameters using SSH config, git config, and org fallback.
462
525
 
463
526
  Priority order for server derivation:
464
- 1. .gitreview host field (local file or fetched from GitHub); this
465
- is the host the orchestrator pushes to whenever there is a file
466
- 2. Per-org configuration file entry (GERRIT_SERVER)
467
- 3. Heuristic fallback: gerrit.[org].org
527
+ 1. An explicit ``GERRIT_SERVER`` already in the environment (one the
528
+ operator set, not one an earlier derivation pass exported); this
529
+ is the host the orchestrator pushes to
530
+ 2. .gitreview host field (local file or fetched from GitHub)
531
+ 3. Per-org configuration file entry (GERRIT_SERVER)
532
+ 4. Heuristic fallback: gerrit.[org].org
468
533
 
469
534
  The host and the project are exported as one target: the closed-
470
535
  pull-request handler and the cleanup sweeps query them together,
@@ -510,7 +575,8 @@ def derive_gerrit_parameters_detailed(
510
575
  among them is a guess:
511
576
  - GERRIT_SSH_USER_G2G: From SSH config or [org].gh2gerrit
512
577
  - GERRIT_SSH_USER_G2G_EMAIL: From git config or fallback email
513
- - GERRIT_SERVER: Resolved from config, .gitreview, or gerrit.[org].org
578
+ - GERRIT_SERVER: An explicit environment value, else .gitreview,
579
+ else config, else gerrit.[org].org
514
580
  - GERRIT_PROJECT: From .gitreview or Gerrit, else guessed
515
581
  """
516
582
  if not organization:
@@ -521,6 +587,12 @@ def derive_gerrit_parameters_detailed(
521
587
  # Check if we have a config file entry for this organization
522
588
  config = load_org_config(org)
523
589
  configured_server = config.get("GERRIT_SERVER", "").strip()
590
+ explicit_server = (os.getenv("GERRIT_SERVER") or "").strip()
591
+ if explicit_server and is_derived_key("GERRIT_SERVER"):
592
+ explicit_server = ""
593
+ explicit_port = (os.getenv("GERRIT_SERVER_PORT") or "").strip()
594
+ if explicit_port and is_derived_key("GERRIT_SERVER_PORT"):
595
+ explicit_port = ""
524
596
 
525
597
  # Read .gitreview once for both host and project. The project is
526
598
  # per-repository and the configuration file cannot supply it, so
@@ -535,16 +607,25 @@ def derive_gerrit_parameters_detailed(
535
607
  gitreview = _read_gitreview_info(repository) if not ci_testing else None
536
608
  gitreview_host = (gitreview.host if gitreview else "").strip()
537
609
 
538
- # Priority: .gitreview > config file > heuristic fallback. The
539
- # orchestrator pushes to the .gitreview host whenever there is a
540
- # file, so the host exported here -- which the closed-pull-request
541
- # handler and the cleanup sweeps query, paired with the project
542
- # below -- has to be the same one, or they query the right project
543
- # on the wrong server. The per-organization file is a fallback for
544
- # repositories that carry no .gitreview.
545
- gerrit_host = gitreview_host or configured_server or f"gerrit.{org}.org"
610
+ # One effective host, in the orchestrator's order: an explicit
611
+ # GERRIT_SERVER, else .gitreview, else the per-organization file,
612
+ # else the heuristic. It is the host the push will go to, so it is
613
+ # the host exported here -- paired with the project below for the
614
+ # closed-pull-request handler and the cleanup sweeps -- the host
615
+ # the project lookup asks, and the host SSH credentials are derived
616
+ # for. Confirming a project or picking an identity on any other
617
+ # host would be confirming or picking for the wrong server.
618
+ gerrit_host = (
619
+ explicit_server
620
+ or gitreview_host
621
+ or configured_server
622
+ or f"gerrit.{org}.org"
623
+ )
624
+ # Provenance follows the tier selected, not the value: an explicit
625
+ # server that happens to equal the file's host is still explicit.
626
+ host_from_gitreview = not explicit_server and bool(gitreview_host)
546
627
 
547
- if gitreview_host:
628
+ if host_from_gitreview:
548
629
  log.debug(
549
630
  "Using Gerrit host from .gitreview: %s%s",
550
631
  gitreview_host,
@@ -552,55 +633,18 @@ def derive_gerrit_parameters_detailed(
552
633
  if configured_server and configured_server != gitreview_host
553
634
  else "",
554
635
  )
555
-
556
- # Derive GERRIT_PROJECT from .gitreview, else from the repository.
557
- # A .gitreview read on behalf of a pull request whose base ref is
558
- # not yet known came from a default branch, which may not be the
559
- # branch the pull request targets; its host is kept (servers do not
560
- # vary by branch, and local SSH-identity derivation needs one) but
561
- # its project is not this pull request's to export. Without a
562
- # .gitreview project the lookup may ask Gerrit. The host asked is
563
- # the one the push will go to, in the orchestrator's order: the
564
- # file's when there is a file, else an explicit GERRIT_SERVER, else
565
- # the host resolved above. The loaded configuration goes along
566
- # because this runs before the file has been exported to the
567
- # environment, and the paths that consume the result never see the
568
- # orchestrator's later, environment-only reading of the same
569
- # settings. An explicit GERRIT_PROJECT in the environment makes the
570
- # lookup moot, exactly as .gitreview does: apply_config_to_env
571
- # would discard the answer.
572
- gerrit_project = ""
573
- project_source: ProjectSource | None = None
574
- if repository and "/" in repository:
575
- from .gitreview import context_provenance
576
- from .project_names import opted_in_gerrit_project_lister
577
- from .project_names import resolve_repo_names
578
-
579
- gitreview_project = (gitreview.project if gitreview else "").strip()
580
- if gitreview_project and context_provenance(repository).provisional:
581
- log.debug(
582
- "Base ref not yet known for this pull request; not taking "
583
- "project %r from a default-branch .gitreview",
584
- gitreview_project,
585
- )
586
- gitreview_project = ""
587
- lookup_moot = bool(gitreview_project) or bool(
588
- (os.getenv("GERRIT_PROJECT") or "").strip()
589
- )
590
- names = resolve_repo_names(
591
- repository,
592
- gitreview_project=gitreview_project or None,
593
- list_projects=None
594
- if lookup_moot
595
- else opted_in_gerrit_project_lister(
596
- (gitreview.host if gitreview else "")
597
- or os.getenv("GERRIT_SERVER", "").strip()
598
- or gerrit_host,
599
- config=config,
600
- ),
636
+ elif (
637
+ explicit_server and gitreview_host and gitreview_host != explicit_server
638
+ ):
639
+ log.debug(
640
+ "Using explicit GERRIT_SERVER %s over .gitreview host %s",
641
+ explicit_server,
642
+ gitreview_host,
601
643
  )
602
- gerrit_project = names.project_gerrit
603
- project_source = names.source
644
+
645
+ gerrit_project, project_source = _derive_gerrit_project(
646
+ repository, gitreview, gerrit_host, config
647
+ )
604
648
 
605
649
  # Try to use SSH config and git config for personalized credentials
606
650
  ssh_user: str | None = None
@@ -619,15 +663,97 @@ def derive_gerrit_parameters_detailed(
619
663
  or f"releng+{org}-gh2gerrit@linuxfoundation.org",
620
664
  "GERRIT_SERVER": gerrit_host,
621
665
  }
666
+ # The port has its own tier, settled by the same rule as the host:
667
+ # an explicit input, else the file, else nothing here (the Gerrit
668
+ # default applies downstream). The closed-pull-request handler
669
+ # abandons over SSH on whatever port the environment holds, so the
670
+ # file's port is exported whenever it is the winning tier -- with an
671
+ # explicit host too, since the orchestrator pairs that host with the
672
+ # file's port in exactly that case. A file that omits port= has
673
+ # nothing to say about it; the parser's default is not the file's
674
+ # word and must not displace a configured port.
675
+ port_from_gitreview = (
676
+ not explicit_port and gitreview is not None and gitreview.port_given
677
+ )
678
+ if port_from_gitreview and gitreview is not None:
679
+ result["GERRIT_SERVER_PORT"] = str(gitreview.port)
622
680
  if gerrit_project:
623
681
  result["GERRIT_PROJECT"] = gerrit_project
624
682
  return DerivedParameters(
625
683
  values=result,
626
684
  project_source=project_source,
627
- host_from_gitreview=bool(gitreview_host),
685
+ host_from_gitreview=host_from_gitreview,
686
+ port_from_gitreview=port_from_gitreview,
628
687
  )
629
688
 
630
689
 
690
+ def _merge_derived(
691
+ cfg: dict[str, str],
692
+ derived: DerivedParameters,
693
+ *,
694
+ organization: str,
695
+ repository: str | None,
696
+ ) -> tuple[dict[str, str], dict[str, str]]:
697
+ """Fill *cfg*'s missing keys from *derived*, displacing the target.
698
+
699
+ Only missing or empty keys are filled -- with one exception, for the
700
+ Gerrit target. A ``GERRIT_PROJECT`` that *cfg* carries from the
701
+ configuration file is a per-organization fallback, and a project
702
+ derived for this repository from its own ``.gitreview``, or
703
+ confirmed against Gerrit, outranks it: the CLOSE dispatch consumes
704
+ the resulting inputs before the orchestrator runs and never
705
+ consults provenance, so leaving the file's value in place would
706
+ keep cleanup on the wrong project despite the read. The
707
+ ``.gitreview`` host and port displace the file's ``GERRIT_SERVER``
708
+ and ``GERRIT_SERVER_PORT`` for the same reason: the three are
709
+ queried together, and the pipeline pushes to the ``.gitreview``
710
+ target, so they have to come from one place. Nothing inferred from
711
+ the name alone displaces a project, whether a guess or the one
712
+ reading of a hyphen-free name; the file's value was at least written
713
+ down by somebody. An explicit environment value is untouched either
714
+ way, since :func:`apply_config_to_env` never overwrites one.
715
+
716
+ Returns the merged mapping and the keys derivation supplied.
717
+ """
718
+ result = dict(cfg)
719
+ newly_derived: dict[str, str] = {}
720
+
721
+ def _env_blank(key: str) -> bool:
722
+ return (os.getenv(key) or "").strip() == ""
723
+
724
+ replaceable = {
725
+ key
726
+ for key, confirmed in (
727
+ ("GERRIT_PROJECT", derived.project_confirmed),
728
+ ("GERRIT_SERVER", derived.host_from_gitreview),
729
+ ("GERRIT_SERVER_PORT", derived.port_from_gitreview),
730
+ )
731
+ if confirmed and cfg.get(key, "").strip() and _env_blank(key)
732
+ }
733
+
734
+ for key, value in derived.values.items():
735
+ replacing = key in replaceable
736
+ if key not in result or not result[key].strip() or replacing:
737
+ if replacing:
738
+ log.debug(
739
+ "Replacing configuration-file %s %r with %r resolved "
740
+ "for repository %s",
741
+ key,
742
+ result[key],
743
+ value,
744
+ repository,
745
+ )
746
+ log.debug(
747
+ "Deriving %s from organization '%s': %s",
748
+ key,
749
+ organization,
750
+ value,
751
+ )
752
+ result[key] = value
753
+ newly_derived[key] = value
754
+ return result, newly_derived
755
+
756
+
631
757
  def apply_parameter_derivation(
632
758
  cfg: dict[str, str],
633
759
  organization: str | None = None,
@@ -674,19 +800,23 @@ def apply_parameter_derivation(
674
800
  # by releases before such writes stopped, and hand-written ones,
675
801
  # which are equally misscoped. Marking it derived keeps it usable as
676
802
  # a last resort while letting the per-pull-request .gitreview
677
- # outrank it.
803
+ # outrank it. A GERRIT_SERVER, and a GERRIT_SERVER_PORT, from the
804
+ # file are marked the same way: sensible per-organization defaults,
805
+ # but an explicit server or port now outranks .gitreview at the
806
+ # push, and a default must not carry that authority. Only a value
807
+ # the operator set for this run does.
678
808
  #
679
809
  # Recorded ahead of the early returns below, because this describes
680
810
  # what cfg already carries rather than anything derivation adds.
681
811
  # Neither a missing organization nor G2G_ENABLE_DERIVATION=false
682
- # makes a config-file project any more like operator intent, and
812
+ # makes a config-file value any more like operator intent, and
683
813
  # apply_config_to_env still exports it in both cases.
684
- if (
685
- mark_derived
686
- and cfg.get("GERRIT_PROJECT", "").strip()
687
- and (os.getenv("GERRIT_PROJECT") or "").strip() == ""
688
- ):
689
- mark_derived_keys(["GERRIT_PROJECT"])
814
+ if mark_derived:
815
+ mark_derived_keys(
816
+ key
817
+ for key in ("GERRIT_PROJECT", "GERRIT_SERVER", "GERRIT_SERVER_PORT")
818
+ if cfg.get(key, "").strip() and (os.getenv(key) or "").strip() == ""
819
+ )
690
820
 
691
821
  if not organization:
692
822
  return cfg
@@ -706,60 +836,12 @@ def apply_parameter_derivation(
706
836
  )
707
837
  return cfg
708
838
 
709
- # Only derive parameters that are missing or empty -- with one
710
- # exception, for the Gerrit target. A GERRIT_PROJECT that cfg
711
- # carries from the configuration file is the per-organization
712
- # fallback described above, and a project derived for this
713
- # repository from its own .gitreview, or confirmed against Gerrit,
714
- # outranks it: the CLOSE dispatch consumes the resulting Inputs
715
- # before the orchestrator runs and never consults provenance, so
716
- # leaving the file's value in place would keep cleanup on the wrong
717
- # project despite the read. The .gitreview host displaces the
718
- # file's GERRIT_SERVER for the same reason: host and project are
719
- # queried together, and the pipeline pushes to the .gitreview host,
720
- # so the pair has to come from one place. Nothing inferred from the
721
- # name alone displaces a project, whether a guess or the one
722
- # reading of a hyphen-free name; the file's value was at least
723
- # written down by somebody. An explicit environment value is
724
- # untouched either way, since apply_config_to_env never overwrites
725
- # one and the provenance record above agrees.
839
+ # Only derive parameters that are missing or empty, except for the
840
+ # Gerrit target; _merge_derived explains the exception.
726
841
  derived = derive_gerrit_parameters_detailed(organization, repository)
727
- result = dict(cfg)
728
- newly_derived = {}
729
-
730
- def _env_blank(key: str) -> bool:
731
- return (os.getenv(key) or "").strip() == ""
732
-
733
- replaceable = {
734
- key
735
- for key, confirmed in (
736
- ("GERRIT_PROJECT", derived.project_confirmed),
737
- ("GERRIT_SERVER", derived.host_from_gitreview),
738
- )
739
- if confirmed and cfg.get(key, "").strip() and _env_blank(key)
740
- }
741
-
742
- for key, value in derived.values.items():
743
- replacing = key in replaceable
744
- if key not in result or not result[key].strip() or replacing:
745
- if replacing:
746
- log.debug(
747
- "Replacing configuration-file %s %r with %r resolved "
748
- "for repository %s",
749
- key,
750
- result[key],
751
- value,
752
- repository,
753
- )
754
- log.debug(
755
- "Deriving %s from organization '%s': %s (context: %s)",
756
- key,
757
- organization,
758
- value,
759
- "GitHub Actions" if is_github_actions else "Local CLI",
760
- )
761
- result[key] = value
762
- newly_derived[key] = value
842
+ result, newly_derived = _merge_derived(
843
+ cfg, derived, organization=organization, repository=repository
844
+ )
763
845
 
764
846
  if newly_derived:
765
847
  log.debug(
@@ -816,95 +898,6 @@ def apply_parameter_derivation(
816
898
  return result
817
899
 
818
900
 
819
- def save_derived_parameters_to_config(
820
- organization: str,
821
- derived_params: dict[str, str],
822
- config_path: str | None = None,
823
- ) -> None:
824
- """Save derived parameters to the organization's configuration file.
825
-
826
- This function updates the configuration file to include any derived
827
- parameters that are not already present in the organization section.
828
- This creates a persistent configuration that users can modify if needed.
829
-
830
- Args:
831
- organization: GitHub organization name for config section
832
- derived_params: Dictionary of parameter names to values
833
- config_path: Path to config file (optional, uses default if not
834
- provided)
835
- """
836
- # Skip config file writes during dry-run mode
837
- if os.getenv("DRY_RUN", "").lower() in ("true", "1", "yes"):
838
- log.debug("Skipping config file write in dry-run mode")
839
- return
840
- if not organization or not derived_params:
841
- return
842
-
843
- # GERRIT_PROJECT is per-repository, but this file section is
844
- # per-organization: persisting it would hand every other repository
845
- # in the org one repository's project name, and would additionally
846
- # come back on the next run as configuration indistinguishable from
847
- # operator intent, defeating the provenance tracking that lets
848
- # duplicate detection prefer a per-pull-request .gitreview.
849
- derived_params = {
850
- k: v for k, v in derived_params.items() if k != "GERRIT_PROJECT"
851
- }
852
- if not derived_params:
853
- return
854
-
855
- if config_path is None:
856
- config_path = (
857
- os.getenv("G2G_CONFIG_PATH", "").strip() or DEFAULT_CONFIG_PATH
858
- )
859
-
860
- config_file = Path(config_path).expanduser()
861
-
862
- try:
863
- # Only update when a configuration file already exists
864
- if not config_file.exists():
865
- log.debug(
866
- "Configuration file does not exist; skipping auto-save of "
867
- "derived parameters: %s",
868
- config_file,
869
- )
870
- return
871
-
872
- cp = _load_ini(config_file)
873
-
874
- # Find or create the organization section
875
- org_section = _select_section(cp, organization)
876
- if org_section is None:
877
- # Section doesn't exist, we'll need to add it
878
- cp.add_section(organization)
879
- org_section = organization
880
-
881
- # Add derived parameters that don't already exist
882
- params_added = []
883
- for key, value in derived_params.items():
884
- if not cp.has_option(org_section, key):
885
- cp.set(org_section, key, f'"{value}"')
886
- params_added.append(key)
887
-
888
- # Only write if we added parameters
889
- if params_added:
890
- with config_file.open("w", encoding="utf-8") as f:
891
- cp.write(f)
892
-
893
- log.debug(
894
- "Saved derived parameters to configuration file %s [%s]: %s",
895
- config_file,
896
- organization,
897
- ", ".join(params_added),
898
- )
899
-
900
- except Exception as exc:
901
- log.warning(
902
- "Failed to save derived parameters to configuration file %s: %s",
903
- config_file,
904
- exc,
905
- )
906
-
907
-
908
901
  def overlay_missing(
909
902
  primary: dict[str, str],
910
903
  fallback: dict[str, str],
@@ -1,15 +1,16 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
- """INI parsing for the github2gerrit configuration file.
3
+ """INI reading and writing for the github2gerrit configuration file.
4
4
 
5
5
  The file format :mod:`github2gerrit.config` reads is INI with two
6
6
  liberties configparser does not take on its own: values may reference
7
7
  environment variables as ``${ENV:VAR}``, and a quoted value may span
8
8
  several lines, which is how SSH private keys and known-hosts entries
9
9
  are written inline. This module turns such a file into a
10
- :class:`configparser.RawConfigParser` and normalises the values it
11
- yields. It knows nothing about which keys mean what; that stays in
12
- :mod:`github2gerrit.config`.
10
+ :class:`configparser.RawConfigParser`, normalises the values it yields,
11
+ and writes derived parameters back into it. It knows nothing about
12
+ which keys mean what beyond the one it refuses to persist; that stays
13
+ in :mod:`github2gerrit.config`.
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -25,6 +26,8 @@ from typing import cast
25
26
 
26
27
  log = logging.getLogger("github2gerrit.config")
27
28
 
29
+ DEFAULT_CONFIG_PATH = "~/.config/github2gerrit/configuration.txt"
30
+
28
31
  _ENV_REF = re.compile(r"\$\{ENV:([A-Za-z_][A-Za-z0-9_]*)\}")
29
32
 
30
33
 
@@ -261,3 +264,92 @@ def _load_ini(path: Path) -> configparser.RawConfigParser:
261
264
  except Exception as exc:
262
265
  log.warning("Failed to read config file %s: %s", path, exc)
263
266
  return cp
267
+
268
+
269
+ def save_derived_parameters_to_config(
270
+ organization: str,
271
+ derived_params: dict[str, str],
272
+ config_path: str | None = None,
273
+ ) -> None:
274
+ """Save derived parameters to the organization's configuration file.
275
+
276
+ This function updates the configuration file to include any derived
277
+ parameters that are not already present in the organization section.
278
+ This creates a persistent configuration that users can modify if needed.
279
+
280
+ Args:
281
+ organization: GitHub organization name for config section
282
+ derived_params: Dictionary of parameter names to values
283
+ config_path: Path to config file (optional, uses default if not
284
+ provided)
285
+ """
286
+ # Skip config file writes during dry-run mode
287
+ if os.getenv("DRY_RUN", "").lower() in ("true", "1", "yes"):
288
+ log.debug("Skipping config file write in dry-run mode")
289
+ return
290
+ if not organization or not derived_params:
291
+ return
292
+
293
+ # GERRIT_PROJECT is per-repository, but this file section is
294
+ # per-organization: persisting it would hand every other repository
295
+ # in the org one repository's project name, and would additionally
296
+ # come back on the next run as configuration indistinguishable from
297
+ # operator intent, defeating the provenance tracking that lets
298
+ # duplicate detection prefer a per-pull-request .gitreview.
299
+ derived_params = {
300
+ k: v for k, v in derived_params.items() if k != "GERRIT_PROJECT"
301
+ }
302
+ if not derived_params:
303
+ return
304
+
305
+ if config_path is None:
306
+ config_path = (
307
+ os.getenv("G2G_CONFIG_PATH", "").strip() or DEFAULT_CONFIG_PATH
308
+ )
309
+
310
+ config_file = Path(config_path).expanduser()
311
+
312
+ try:
313
+ # Only update when a configuration file already exists
314
+ if not config_file.exists():
315
+ log.debug(
316
+ "Configuration file does not exist; skipping auto-save of "
317
+ "derived parameters: %s",
318
+ config_file,
319
+ )
320
+ return
321
+
322
+ cp = _load_ini(config_file)
323
+
324
+ # Find or create the organization section
325
+ org_section = _select_section(cp, organization)
326
+ if org_section is None:
327
+ # Section doesn't exist, we'll need to add it
328
+ cp.add_section(organization)
329
+ org_section = organization
330
+
331
+ # Add derived parameters that don't already exist
332
+ params_added = []
333
+ for key, value in derived_params.items():
334
+ if not cp.has_option(org_section, key):
335
+ cp.set(org_section, key, f'"{value}"')
336
+ params_added.append(key)
337
+
338
+ # Only write if we added parameters
339
+ if params_added:
340
+ with config_file.open("w", encoding="utf-8") as f:
341
+ cp.write(f)
342
+
343
+ log.debug(
344
+ "Saved derived parameters to configuration file %s [%s]: %s",
345
+ config_file,
346
+ organization,
347
+ ", ".join(params_added),
348
+ )
349
+
350
+ except Exception as exc:
351
+ log.warning(
352
+ "Failed to save derived parameters to configuration file %s: %s",
353
+ config_file,
354
+ exc,
355
+ )
github2gerrit/core.py CHANGED
@@ -3173,12 +3173,13 @@ class Orchestrator:
3173
3173
  """Resolve Gerrit connection info from .gitreview or inputs.
3174
3174
 
3175
3175
  The project is *repo*'s, settled by :meth:`_derive_repo_names`;
3176
- only the host and port are resolved here. An explicit
3177
- ``GERRIT_SERVER`` outranks ``.gitreview``, as an explicit
3178
- ``GERRIT_PROJECT`` does; otherwise the file's host and port
3179
- apply when there is a file, and the inputs' otherwise. A guessed
3180
- project is refused for any run that pushes, whichever supplied
3181
- the host.
3176
+ only the host and port are resolved here, each by the same rule
3177
+ as the project: an explicit input outranks ``.gitreview``, and
3178
+ ``.gitreview`` outranks a derived input. A port is explicit when
3179
+ it was set at all -- the action and the CLI pass none rather
3180
+ than 29418 when the caller gave none -- and not recorded as
3181
+ derived. 29418 is the final fallback. A guessed project is
3182
+ refused for any run that pushes, whichever supplied the host.
3182
3183
 
3183
3184
  After resolution, the Gerrit host is validated via DNS to
3184
3185
  catch bogus hostnames early — regardless of whether the host
@@ -3216,19 +3217,40 @@ class Orchestrator:
3216
3217
  explicit_host = _explicit_input(
3217
3218
  inputs, "GERRIT_SERVER", inputs.gerrit_server
3218
3219
  )
3220
+ explicit_port = _explicit_input(
3221
+ inputs,
3222
+ "GERRIT_SERVER_PORT",
3223
+ str(inputs.gerrit_server_port or ""),
3224
+ )
3225
+ # The file's port counts only when the file named one; the
3226
+ # parser's default is not the file's word.
3227
+ file_port = gitreview.port if gitreview and gitreview.port_given else 0
3219
3228
  if gitreview and explicit_host is None:
3220
3229
  log.debug("Using .gitreview settings: %s", gitreview)
3221
3230
  self._validate_resolved_gerrit_host(gitreview.host)
3222
- if gitreview.project == project:
3231
+ port = (
3232
+ (int(explicit_port) if explicit_port else 0)
3233
+ or file_port
3234
+ or inputs.gerrit_server_port
3235
+ or 29418
3236
+ )
3237
+ if explicit_port and port != gitreview.port:
3238
+ log.info(
3239
+ "Using Gerrit port %d over .gitreview's %d: an explicit "
3240
+ "GERRIT_SERVER_PORT outranks the file",
3241
+ port,
3242
+ gitreview.port,
3243
+ )
3244
+ if gitreview.project == project and port == gitreview.port:
3223
3245
  return gitreview
3224
- if gitreview.project.strip():
3246
+ if gitreview.project.strip() and gitreview.project != project:
3225
3247
  log.info(
3226
3248
  "Using Gerrit project %r over .gitreview's %r: an "
3227
3249
  "explicit GERRIT_PROJECT outranks the file",
3228
3250
  project,
3229
3251
  gitreview.project,
3230
3252
  )
3231
- return dataclass_replace(gitreview, project=project)
3253
+ return dataclass_replace(gitreview, project=project, port=port)
3232
3254
 
3233
3255
  host = inputs.gerrit_server.strip()
3234
3256
  if not host:
@@ -3240,12 +3262,14 @@ class Orchestrator:
3240
3262
  host,
3241
3263
  gitreview.host,
3242
3264
  )
3243
- port_s = str(inputs.gerrit_server_port).strip() or "29418"
3244
- try:
3245
- port = int(port_s)
3246
- except ValueError as exc:
3247
- msg = "bad GERRIT_SERVER_PORT"
3248
- raise OrchestratorError(msg) from exc
3265
+ # The port follows its own tier: an explicit input, else the
3266
+ # file's, else a derived input, else the Gerrit default.
3267
+ port = (
3268
+ (int(explicit_port) if explicit_port else 0)
3269
+ or file_port
3270
+ or inputs.gerrit_server_port
3271
+ or 29418
3272
+ )
3249
3273
 
3250
3274
  info = make_gitreview_info(host=host, port=port, project=project)
3251
3275
  log.debug("Resolved Gerrit info: %s", info)
@@ -72,12 +72,19 @@ class GitReviewInfo:
72
72
  ``gerrit.linuxfoundation.org``). ``None`` when the host is
73
73
  not in the known-hosts table — callers may fall back to
74
74
  dynamic discovery (see ``gerrit_urls.py``).
75
+ port_given: Whether the source named the port, or *port* is the
76
+ parser's default. A file that omits ``port=`` has nothing
77
+ to say about the port and must not outrank a configured
78
+ one. Defaults to ``True``: a caller constructing an
79
+ instance with a port is stating it. :func:`parse_gitreview`
80
+ sets it from the file.
75
81
  """
76
82
 
77
83
  host: str
78
84
  port: int = DEFAULT_GERRIT_PORT
79
85
  project: str = ""
80
86
  base_path: str | None = None
87
+ port_given: bool = True
81
88
 
82
89
  @property
83
90
  def is_valid(self) -> bool:
@@ -174,6 +181,7 @@ def parse_gitreview(text: str) -> GitReviewInfo | None:
174
181
  port=port,
175
182
  project=project,
176
183
  base_path=base_path,
184
+ port_given=port_match is not None,
177
185
  )
178
186
  log.debug(
179
187
  "Parsed .gitreview: host=%s, port=%d, project=%s, base_path=%s",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github2gerrit
3
- Version: 2.2.1
3
+ Version: 2.2.2
4
4
  Summary: Submit a GitHub pull request to a Gerrit repository.
5
5
  Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit-action
6
6
  Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit-action
@@ -277,7 +277,7 @@ uvx github2gerrit https://github.com/onap/portal-ng-bff/pull/33
277
277
  | `GERRIT_SSH_USER_G2G` | No | `""` | Gerrit SSH username; derived when not supplied |
278
278
  | `GERRIT_SSH_USER_G2G_EMAIL` | No | `""` | Gerrit user email address; derived when not supplied |
279
279
  | `GERRIT_SERVER` | No | `""` | Gerrit server hostname; overrides `.gitreview` when set |
280
- | `GERRIT_SERVER_PORT` | No | `"29418"` | Gerrit SSH port |
280
+ | `GERRIT_SERVER_PORT` | No | `""` | Gerrit SSH port; `.gitreview`'s when unset, else 29418 |
281
281
  | `GERRIT_PROJECT` | No | `""` | Gerrit project name; overrides `.gitreview` when set |
282
282
  | `GERRIT_HTTP_BASE_PATH` | No | `""` | HTTP base path for Gerrit REST API (e.g. `/r`) |
283
283
  | `GERRIT_HTTP_USER` | No | `""` | Gerrit HTTP user for REST queries |
@@ -377,7 +377,7 @@ Input defaults match the composite action defaults.
377
377
  | `GERRIT_SSH_USER_G2G` | string | `""` | Gerrit SSH username |
378
378
  | `GERRIT_SSH_USER_G2G_EMAIL` | string | `""` | Gerrit user email address |
379
379
  | `GERRIT_SERVER` | string | `""` | Gerrit server hostname |
380
- | `GERRIT_SERVER_PORT` | string | `"29418"` | Gerrit SSH port |
380
+ | `GERRIT_SERVER_PORT` | string | `""` | Gerrit SSH port; `.gitreview`'s when unset |
381
381
  | `GERRIT_PROJECT` | string | `""` | Gerrit project name |
382
382
  | `GERRIT_HTTP_BASE_PATH` | string | `""` | HTTP base path for Gerrit REST |
383
383
  | `GERRIT_HTTP_USER` | string | `""` | Gerrit HTTP user for REST queries |
@@ -1,12 +1,12 @@
1
1
  github2gerrit/__init__.py,sha256=N1Vj1HJ28LKCJLAynQdm5jFGQQAz9YSMzZhEfvbBgow,886
2
2
  github2gerrit/approvers.py,sha256=Lp1inG2n7-GhaC43WP6tYh-aquVmkj3vV_jUSrKCm0M,10298
3
- github2gerrit/cli.py,sha256=6PfAp3SfoqpmTdhGpIyvfrdCPlkkpxF2zU4lcsEu5nk,150669
3
+ github2gerrit/cli.py,sha256=V9F7KOGu3NrQY0Cqs9gFwg3Ud5ScGNh5X7_pftPGOzA,150909
4
4
  github2gerrit/commit_normalization.py,sha256=Kt_yrklKquNN1tdDakRmZVqQRZxRVBRsNQlv47Yp6vU,16744
5
5
  github2gerrit/commit_rules.py,sha256=SeWvVMqvPgXr-yRD5hOpmlHYGg9sroQD60zYSHOOUZU,15415
6
- github2gerrit/config.py,sha256=YJw7_BtYHLxbHMFczKyJS64qbCu1hqS-PqXlGI9Jx_o,35204
7
- github2gerrit/config_ini.py,sha256=5guT5L_GTlzOkRROv_9jwWCT1Lq1fRJ0LxX_iLCGcMU,8714
6
+ github2gerrit/config.py,sha256=WO2kwAmo07uNXbruoC_lkjx4wnda5LqHAHmmGQc2Xu0,35487
7
+ github2gerrit/config_ini.py,sha256=eXi_q1Fo5_c9s52zLES19Gw9TFDlLjIvzRiBzwHeqo4,12026
8
8
  github2gerrit/constants.py,sha256=uCAx-lZiyxlVSHaGmDx6TFbCajkK3VO2ggCOej0EeXk,1306
9
- github2gerrit/core.py,sha256=Wy6Ps4Hzs1b4QyGWijQvVgQdXNff6t3hidvB39Kxer4,289353
9
+ github2gerrit/core.py,sha256=nZWH4b1TpHXM_Xl27vbj3KFBeMpPjPs-TyrsrMip-LE,290472
10
10
  github2gerrit/duplicate_detection.py,sha256=2jRP-6m9enMdxAeL2svw9k2QzO4IoGx2-HQxJNDQEcU,35909
11
11
  github2gerrit/error_codes.py,sha256=1tWo717xFPDlbh7sLwaPzkjPJHqP24D3pOXkhQFHbUI,19561
12
12
  github2gerrit/external_api.py,sha256=h2pQYlj_dkr0Lfjo54TpLkIIdfKb3CPAi8MOoD1VOS0,20447
@@ -16,7 +16,7 @@ github2gerrit/gerrit_rest.py,sha256=I0c6Jeag9ygmHigtvFKIzqDZwVNjLTXoQW9w4P-bG2c,
16
16
  github2gerrit/gerrit_ssh.py,sha256=UOPI6rPMxUwSrQKRrRAqxuL_tyQabxNslWXTttye70A,9132
17
17
  github2gerrit/gerrit_urls.py,sha256=BmFIOQ9ILTbNrjAo4K6shHk_-6sPnucQxqHmT_FMN_Q,13682
18
18
  github2gerrit/github_api.py,sha256=GuncFKSyd8PYlTAzc4iIZIvNnQB4sN0pXn_-Gh5s3ck,15537
19
- github2gerrit/gitreview.py,sha256=NOmk9TPY-9G9Yt9Vm5G4KeNc-P7ej9izS-3COkNut1w,23785
19
+ github2gerrit/gitreview.py,sha256=JV7wIninafcEw5bIt9KxN6ZtGWCPsQ0eq-VeOQ-R2kA,24240
20
20
  github2gerrit/gitutils.py,sha256=PtaQMpYrf4hC2TjR8fyI0OjHyCLeDnyK8rsvOac2uoE,26512
21
21
  github2gerrit/mapping_comment.py,sha256=4jpiiMnoZCT-rRd3WIdMi-afrI3HriwCfdYZ8qLZHE0,9670
22
22
  github2gerrit/models.py,sha256=fwTR7HhEd6zrN2nivi1lvVYST0tC9r5EcFXTAPBHXrc,8441
@@ -39,8 +39,8 @@ github2gerrit/trust.py,sha256=_1T4qRmnpQNd6dcVMBGqrP0pcZsW5QKYgpHWYsAZuoI,4275
39
39
  github2gerrit/utils.py,sha256=2izB7vvp0-Lnej4dx5eE8mW3is-PdYBWQyor8eUcyhk,6934
40
40
  github2gerrit/orchestrator/__init__.py,sha256=HAEcdCAHOFr8LsdIwAdcIcFZn_ayMbX9rdVUULp8410,864
41
41
  github2gerrit/orchestrator/reconciliation.py,sha256=TJaAsuLo0_PRnWt3e_RxwKufETKX8b1unV_U_lsZp8E,22105
42
- github2gerrit-2.2.1.dist-info/METADATA,sha256=wBgfcz3tTGIKaezbighMCReEIkH6etxso666xSfOD0w,26490
43
- github2gerrit-2.2.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
44
- github2gerrit-2.2.1.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
45
- github2gerrit-2.2.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
46
- github2gerrit-2.2.1.dist-info/RECORD,,
42
+ github2gerrit-2.2.2.dist-info/METADATA,sha256=uCAJC9cEIsAnlFIn3wTurSdk4Na-iCAAHJMdKqwRcs0,26490
43
+ github2gerrit-2.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
44
+ github2gerrit-2.2.2.dist-info/entry_points.txt,sha256=MxN2_liIKo3-xJwtAulAeS5GcOS6JS96nvwOQIkP3W8,56
45
+ github2gerrit-2.2.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
46
+ github2gerrit-2.2.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.3
2
+ Generator: hatchling 1.32.4
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any