github2gerrit 1.2.4__tar.gz → 1.3.0__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 (134) hide show
  1. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/PKG-INFO +24 -1
  2. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/README.md +23 -0
  3. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/core.py +2 -0
  4. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gerrit_pr_closer.py +10 -1
  5. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gerrit_query.py +139 -22
  6. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_dependency_supersession.py +258 -3
  7. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.editorconfig +0 -0
  8. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.gitignore +0 -0
  9. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.gitlint +0 -0
  10. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.markdownlint.yaml +0 -0
  11. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.pre-commit-config.yaml +0 -0
  12. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.readthedocs.yml +0 -0
  13. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/.yamllint +0 -0
  14. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/LICENSE +0 -0
  15. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/LICENSES/Apache-2.0.txt +0 -0
  16. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/REUSE.toml +0 -0
  17. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/SECURITY.md +0 -0
  18. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/action.yaml +0 -0
  19. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/docs/COMMIT_RULES.md +0 -0
  20. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/docs/COMPOSITE_ACTION_TESTING.md +0 -0
  21. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/docs/PR_UPDATE_IMPLEMENTATION.md +0 -0
  22. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/docs/RELEASE-v0.2.0.md +0 -0
  23. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/docs/github2gerrit_token_permissions_classic.png +0 -0
  24. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/pyproject.toml +0 -0
  25. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/sitecustomize.py +0 -0
  26. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/__init__.py +0 -0
  27. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/cli.py +0 -0
  28. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/commit_normalization.py +0 -0
  29. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/commit_rules.py +0 -0
  30. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/config.py +0 -0
  31. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/constants.py +0 -0
  32. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/duplicate_detection.py +0 -0
  33. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/error_codes.py +0 -0
  34. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/external_api.py +0 -0
  35. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gerrit_rest.py +0 -0
  36. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gerrit_ssh.py +0 -0
  37. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gerrit_urls.py +0 -0
  38. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/github_api.py +0 -0
  39. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gitreview.py +0 -0
  40. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/gitutils.py +0 -0
  41. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/mapping_comment.py +0 -0
  42. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/models.py +0 -0
  43. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/netrc.py +0 -0
  44. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/orchestrator/__init__.py +0 -0
  45. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  46. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/pr_commands.py +0 -0
  47. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/pr_content_filter.py +0 -0
  48. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/reconcile_matcher.py +0 -0
  49. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/rich_display.py +0 -0
  50. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/rich_logging.py +0 -0
  51. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/similarity.py +0 -0
  52. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/ssh_agent_setup.py +0 -0
  53. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/ssh_common.py +0 -0
  54. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/ssh_config_parser.py +0 -0
  55. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/ssh_discovery.py +0 -0
  56. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/trailers.py +0 -0
  57. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/src/github2gerrit/utils.py +0 -0
  58. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/conftest.py +0 -0
  59. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/fixtures/__init__.py +0 -0
  60. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/fixtures/make_repo.py +0 -0
  61. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/fixtures/ssh_config_samples.py +0 -0
  62. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_action_environment_mapping.py +0 -0
  63. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_action_outputs.py +0 -0
  64. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_action_pr_number_handling.py +0 -0
  65. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_action_step_validation.py +0 -0
  66. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_automation_only.py +0 -0
  67. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_change_id_deduplication.py +0 -0
  68. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_clean_squash_title.py +0 -0
  69. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_cli.py +0 -0
  70. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_cli_helpers.py +0 -0
  71. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_cli_netrc_options.py +0 -0
  72. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_cli_outputs_file.py +0 -0
  73. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_cli_url_and_dryrun.py +0 -0
  74. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_commit_normalization.py +0 -0
  75. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_commit_rules.py +0 -0
  76. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_composite_action_coverage.py +0 -0
  77. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_config_and_reviewers.py +0 -0
  78. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_config_helpers.py +0 -0
  79. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_close_pr_policy.py +0 -0
  80. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_config_and_errors.py +0 -0
  81. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_gerrit_backref_comment.py +0 -0
  82. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_gerrit_push_errors.py +0 -0
  83. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_gerrit_rest_results.py +0 -0
  84. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_integration_fixture_repo.py +0 -0
  85. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_prepare_commits.py +0 -0
  86. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_shallow_clone.py +0 -0
  87. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_ssh_setup.py +0 -0
  88. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_core_ssrf_protection.py +0 -0
  89. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_dns_validation_and_no_gerrit.py +0 -0
  90. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_duplicate_detection.py +0 -0
  91. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_email_case_normalization.py +0 -0
  92. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_error_codes.py +0 -0
  93. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_external_api_framework.py +0 -0
  94. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_force_flag_cli.py +0 -0
  95. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_change_id_footer.py +0 -0
  96. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_change_status_checks.py +0 -0
  97. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_pr_closer.py +0 -0
  98. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_rest_client.py +0 -0
  99. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_ssh_abandon.py +0 -0
  100. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_urls.py +0 -0
  101. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gerrit_urls_more.py +0 -0
  102. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ghe_and_gitreview_args.py +0 -0
  103. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_github_api_error_handling.py +0 -0
  104. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_github_api_helpers.py +0 -0
  105. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_github_api_retry_and_helpers.py +0 -0
  106. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gitreview.py +0 -0
  107. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_gitutils_helpers.py +0 -0
  108. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_issue_157_regressions.py +0 -0
  109. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_mapping_comment_additional.py +0 -0
  110. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  111. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_metadata_and_reconciliation.py +0 -0
  112. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_metadata_trailer_separation_bug.py +0 -0
  113. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_misc_small_coverage.py +0 -0
  114. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_netrc.py +0 -0
  115. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_orphan_rest_side_effects.py +0 -0
  116. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_pr_commands.py +0 -0
  117. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_pr_content_filter.py +0 -0
  118. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_pr_content_filter_integration.py +0 -0
  119. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_pr_update_detection.py +0 -0
  120. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_reconciliation_extracted_module.py +0 -0
  121. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  122. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_reconciliation_scenarios.py +0 -0
  123. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ssh_agent.py +0 -0
  124. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ssh_agent_ownership.py +0 -0
  125. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ssh_artifact_prevention.py +0 -0
  126. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ssh_common.py +0 -0
  127. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ssh_discovery.py +0 -0
  128. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_ssh_discovery_dry_run.py +0 -0
  129. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_trailers_additional.py +0 -0
  130. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_url_parser.py +0 -0
  131. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/test_utils.py +0 -0
  132. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/unit/test_config_integration.py +0 -0
  133. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/tests/unit/test_ssh_config_parser.py +0 -0
  134. {github2gerrit-1.2.4 → github2gerrit-1.3.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.2.4
3
+ Version: 1.3.0
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
@@ -1430,6 +1430,29 @@ variable.
1430
1430
  **Note**: Unknown configuration keys will generate warnings to help catch typos
1431
1431
  and missing functionality.
1432
1432
 
1433
+ ### Supersession Sweep Without REST Credentials
1434
+
1435
+ The dependency **supersession sweep** (which reuses or abandons older open
1436
+ Gerrit changes when GitHub2Gerrit pushes a newer update for the same
1437
+ dependency) needs to list GitHub2Gerrit's own open changes. With Gerrit REST
1438
+ credentials it does this precisely via the `owner:self` query operator.
1439
+
1440
+ `owner:self` requires an authenticated session, so without Gerrit REST
1441
+ credentials the tool falls back to an **anonymous** query: it lists the
1442
+ project/branch's open GitHub2Gerrit changes (narrowed server-side via the
1443
+ `GitHub-PR` trailer, a public read-only operation) and then keeps only those
1444
+ whose trailer points at the current repository. This scopes the sweep to
1445
+ GitHub2Gerrit changes for this repository without needing a Gerrit HTTP
1446
+ password.
1447
+
1448
+ - Use `G2G_ANON_SUPERSEDE_FALLBACK` (default `true`) to control the fallback.
1449
+ - Set it to `false` to disable the fallback; without credentials the tool then
1450
+ skips the sweep and logs a warning, as before.
1451
+ - The fallback depends on the Gerrit server allowing anonymous change queries.
1452
+ If the server rejects that query, the tool skips the sweep gracefully.
1453
+ - On large projects the query has a result cap; if it reaches that cap the
1454
+ tool warns that the sweep may be incomplete.
1455
+
1433
1456
  ### Credential Derivation
1434
1457
 
1435
1458
  When `GERRIT_SSH_USER_G2G` and `GERRIT_SSH_USER_G2G_EMAIL` are not explicitly provided,
@@ -1384,6 +1384,29 @@ variable.
1384
1384
  **Note**: Unknown configuration keys will generate warnings to help catch typos
1385
1385
  and missing functionality.
1386
1386
 
1387
+ ### Supersession Sweep Without REST Credentials
1388
+
1389
+ The dependency **supersession sweep** (which reuses or abandons older open
1390
+ Gerrit changes when GitHub2Gerrit pushes a newer update for the same
1391
+ dependency) needs to list GitHub2Gerrit's own open changes. With Gerrit REST
1392
+ credentials it does this precisely via the `owner:self` query operator.
1393
+
1394
+ `owner:self` requires an authenticated session, so without Gerrit REST
1395
+ credentials the tool falls back to an **anonymous** query: it lists the
1396
+ project/branch's open GitHub2Gerrit changes (narrowed server-side via the
1397
+ `GitHub-PR` trailer, a public read-only operation) and then keeps only those
1398
+ whose trailer points at the current repository. This scopes the sweep to
1399
+ GitHub2Gerrit changes for this repository without needing a Gerrit HTTP
1400
+ password.
1401
+
1402
+ - Use `G2G_ANON_SUPERSEDE_FALLBACK` (default `true`) to control the fallback.
1403
+ - Set it to `false` to disable the fallback; without credentials the tool then
1404
+ skips the sweep and logs a warning, as before.
1405
+ - The fallback depends on the Gerrit server allowing anonymous change queries.
1406
+ If the server rejects that query, the tool skips the sweep gracefully.
1407
+ - On large projects the query has a result cap; if it reaches that cap the
1408
+ tool warns that the sweep may be incomplete.
1409
+
1387
1410
  ### Credential Derivation
1388
1411
 
1389
1412
  When `GERRIT_SSH_USER_G2G` and `GERRIT_SSH_USER_G2G_EMAIL` are not explicitly provided,
@@ -969,6 +969,7 @@ class Orchestrator:
969
969
  gerrit.project,
970
970
  branch=gh.base_ref,
971
971
  max_results=200,
972
+ github_repository=gh.repository,
972
973
  )
973
974
 
974
975
  # Collect all matching changes, then select the
@@ -2178,6 +2179,7 @@ class Orchestrator:
2178
2179
  exclude_change_ids=prep.change_ids,
2179
2180
  dry_run=False,
2180
2181
  target_branch=self._resolve_target_branch(),
2182
+ github_repository=gh.repository,
2181
2183
  )
2182
2184
  except Exception as exc:
2183
2185
  log.debug("Post-push supersession sweep skipped: %s", exc)
@@ -1780,6 +1780,7 @@ def abandon_superseded_dependency_changes(
1780
1780
  *,
1781
1781
  dry_run: bool = False,
1782
1782
  target_branch: str | None = None,
1783
+ github_repository: str | None = None,
1783
1784
  ) -> list[str]:
1784
1785
  """Abandon open Gerrit changes superseded by a newer dependency update.
1785
1786
 
@@ -1801,6 +1802,10 @@ def abandon_superseded_dependency_changes(
1801
1802
  dry_run: If True, log but do not actually abandon.
1802
1803
  target_branch: Optional Gerrit branch name. When provided,
1803
1804
  only changes targeting this branch are considered.
1805
+ github_repository: Current GitHub repository in ``owner/repo``
1806
+ form. Enables the anonymous supersession fallback when no
1807
+ Gerrit REST credentials are available (the discovery query is
1808
+ then scoped to this repository via the ``GitHub-PR`` trailer).
1804
1809
 
1805
1810
  Returns:
1806
1811
  List of Gerrit change numbers that were abandoned
@@ -1830,7 +1835,11 @@ def abandon_superseded_dependency_changes(
1830
1835
  try:
1831
1836
  client = build_client_for_host(gerrit_server)
1832
1837
  open_changes = query_open_changes_by_project(
1833
- client, gerrit_project, branch=target_branch, max_results=200
1838
+ client,
1839
+ gerrit_project,
1840
+ branch=target_branch,
1841
+ max_results=200,
1842
+ github_repository=github_repository,
1834
1843
  )
1835
1844
 
1836
1845
  url_builder = create_gerrit_url_builder(gerrit_server)
@@ -11,9 +11,14 @@ import logging
11
11
  from dataclasses import dataclass
12
12
  from typing import Any
13
13
  from urllib.parse import quote
14
+ from urllib.parse import urlparse
14
15
 
15
16
  from .gerrit_rest import GerritRestClient
16
17
  from .gerrit_rest import warn_gerrit_credentials_unavailable
18
+ from .trailers import GITHUB_PR_TRAILER
19
+ from .trailers import parse_trailers
20
+ from .utils import env_bool
21
+ from .utils import log_warning_once
17
22
 
18
23
 
19
24
  log = logging.getLogger(__name__)
@@ -121,19 +126,71 @@ def query_changes_by_topic(
121
126
  return changes
122
127
 
123
128
 
129
+ def _change_belongs_to_repository(
130
+ change: GerritChange, github_repository: str
131
+ ) -> bool:
132
+ """Return True if a change's GitHub-PR trailer targets the given repo.
133
+
134
+ The ``GitHub-PR`` trailer is a pull request URL of the form
135
+ ``https://github.com/{owner}/{repo}/pull/{n}``. Matching on it scopes
136
+ anonymous supersession queries to changes created by GitHub2Gerrit for
137
+ the current repository -- a precise replacement for ``owner:self`` that
138
+ does not require an authenticated session.
139
+
140
+ The trailer is parsed as a URL and matched on its **path** prefix
141
+ (``/{owner}/{repo}/pull/``) rather than a bare substring. The value must
142
+ be an absolute ``http(s)`` URL (scheme + host), so malformed or
143
+ relative values (e.g. ``/org/repo/pull/1``) and unrelated text that
144
+ merely contains the substring (for example inside a query string) do
145
+ not produce false positives. The host itself is not compared, so
146
+ GitHub Enterprise URLs still match.
147
+ """
148
+ target = github_repository.strip().strip("/").lower()
149
+ if not target:
150
+ return False
151
+ prefix = f"/{target}/pull/"
152
+ trailers = parse_trailers(change.commit_message or "")
153
+ for value in trailers.get(GITHUB_PR_TRAILER, []):
154
+ if not value:
155
+ continue
156
+ try:
157
+ parsed = urlparse(value.strip())
158
+ except ValueError:
159
+ continue
160
+ # Require an absolute http(s) URL; reject relative/malformed values.
161
+ if parsed.scheme not in ("http", "https") or not parsed.netloc:
162
+ continue
163
+ if parsed.path.lower().startswith(prefix):
164
+ return True
165
+ return False
166
+
167
+
124
168
  def query_open_changes_by_project(
125
169
  client: GerritRestClient,
126
170
  project: str,
127
171
  *,
128
172
  branch: str | None = None,
129
173
  max_results: int = 100,
174
+ github_repository: str | None = None,
130
175
  ) -> list[GerritChange]:
131
- """Query open changes owned by the current user in a Gerrit project.
176
+ """Query open GitHub2Gerrit changes in a Gerrit project.
132
177
 
133
- Used by the supersession sweep to discover open changes that
134
- may be superseded by a newer dependency update. Only returns
135
- changes owned by the authenticated user (``owner:self``) to
136
- avoid acting on unrelated human-authored changes.
178
+ Used by the supersession sweep to discover open changes that may be
179
+ superseded by a newer dependency update.
180
+
181
+ When the REST client is authenticated, the query is restricted to
182
+ changes owned by the authenticated user via the ``owner:self``
183
+ predicate (the original, most precise behaviour).
184
+
185
+ When the client is unauthenticated, ``owner:self`` cannot be used (the
186
+ server rejects it with HTTP 403). If the anonymous fallback is enabled
187
+ (``G2G_ANON_SUPERSEDE_FALLBACK``, default on) and ``github_repository``
188
+ is provided, the query instead lists all open changes in the
189
+ project/branch and keeps only those whose ``GitHub-PR`` trailer targets
190
+ ``github_repository``. This scopes results to GitHub2Gerrit changes for
191
+ the current repository without requiring credentials. If the fallback
192
+ is disabled or no repository is available, the sweep is skipped (with a
193
+ warning) as before.
137
194
 
138
195
  Args:
139
196
  client: Gerrit REST client.
@@ -142,26 +199,55 @@ def query_open_changes_by_project(
142
199
  When provided, only changes targeting this branch are
143
200
  returned.
144
201
  max_results: Maximum number of results to return.
202
+ github_repository: Current GitHub repository in ``owner/repo``
203
+ form. Required to enable the anonymous fallback.
145
204
 
146
205
  Returns:
147
206
  List of open ``GerritChange`` objects.
148
207
  """
149
- query = f'project:"{_gerrit_quote(project)}" status:open owner:self'
208
+ base_query = f'project:"{_gerrit_quote(project)}" status:open'
150
209
  if branch:
151
- query += f' branch:"{_gerrit_quote(branch)}"'
210
+ base_query += f' branch:"{_gerrit_quote(branch)}"'
152
211
 
153
- # The ``owner:self`` predicate requires an authenticated Gerrit
154
- # session; an anonymous request is rejected with HTTP 403. Skip the
155
- # query (warning once per run) instead of issuing a request that is
156
- # guaranteed to fail and would otherwise emit error-level noise.
157
- if not client.is_authenticated:
212
+ repo_filter: str | None = None
213
+ if client.is_authenticated:
214
+ query = f"{base_query} owner:self"
215
+ else:
216
+ # owner:self requires an authenticated session. Fall back to an
217
+ # anonymous project/branch query scoped to the current repository
218
+ # via the GitHub-PR trailer, when permitted and possible.
219
+ normalized_repo = (github_repository or "").strip().strip("/")
220
+ fallback_enabled = env_bool("G2G_ANON_SUPERSEDE_FALLBACK", True)
221
+ if not fallback_enabled or not normalized_repo:
222
+ warn_gerrit_credentials_unavailable()
223
+ log.debug(
224
+ "Skipping owner:self query for project '%s': no Gerrit "
225
+ "REST credentials and anonymous fallback unavailable "
226
+ "(enabled=%s, repository=%s)",
227
+ project,
228
+ fallback_enabled,
229
+ normalized_repo,
230
+ )
231
+ return []
232
+ # owner:self is unavailable without authentication. Narrow the
233
+ # anonymous query server-side to GitHub2Gerrit changes (which all
234
+ # carry the GitHub-PR trailer in their commit message) so the
235
+ # result set stays small even on busy projects; the precise
236
+ # repository scoping is then applied client-side below.
237
+ query = f'{base_query} message:"{GITHUB_PR_TRAILER}"'
238
+ repo_filter = normalized_repo
239
+ # Surface the missing-credentials condition once at default level
240
+ # via the shared warn-once helper (its message already notes that
241
+ # fallback behavior may apply); keep the per-call detail at debug,
242
+ # since this can run multiple times per invocation (e.g. Strategy 5
243
+ # and the post-push sweep).
158
244
  warn_gerrit_credentials_unavailable()
159
245
  log.debug(
160
- "Skipping owner:self query for project '%s': "
161
- "no Gerrit REST credentials available",
246
+ "No Gerrit REST credentials; using anonymous supersession "
247
+ "fallback for project '%s' scoped to repository '%s'",
162
248
  project,
249
+ normalized_repo,
163
250
  )
164
- return []
165
251
 
166
252
  log.debug("Querying Gerrit for open changes: %s", query)
167
253
 
@@ -169,11 +255,6 @@ def query_open_changes_by_project(
169
255
  changes = _execute_query_with_pagination(
170
256
  client, query, max_results=max_results
171
257
  )
172
- log.debug(
173
- "Found %d open changes in project '%s'",
174
- len(changes),
175
- project,
176
- )
177
258
  except Exception as exc:
178
259
  log.warning(
179
260
  "Failed to query open Gerrit changes for project '%s': %s",
@@ -181,8 +262,44 @@ def query_open_changes_by_project(
181
262
  exc,
182
263
  )
183
264
  return []
184
- else:
185
- return changes
265
+
266
+ if repo_filter is not None:
267
+ # The cap may simply have been met exactly, but it can also mean
268
+ # the result set was truncated; surface the possibility once rather
269
+ # than silently missing changes (which could leave a duplicate
270
+ # un-reused/un-abandoned).
271
+ if len(changes) >= max_results:
272
+ log_warning_once(
273
+ log,
274
+ "gerrit_anon_supersede_truncated",
275
+ "Anonymous supersession query for project '%s' returned the "
276
+ "maximum of %d result(s); results may be truncated and some "
277
+ "GitHub2Gerrit changes may not have been examined. Provide "
278
+ "Gerrit REST credentials for a precise owner-scoped query.",
279
+ project,
280
+ max_results,
281
+ )
282
+ scoped = [
283
+ change
284
+ for change in changes
285
+ if _change_belongs_to_repository(change, repo_filter)
286
+ ]
287
+ log.debug(
288
+ "Anonymous fallback: %d of %d open change(s) in project "
289
+ "'%s' belong to repository '%s'",
290
+ len(scoped),
291
+ len(changes),
292
+ project,
293
+ repo_filter,
294
+ )
295
+ return scoped
296
+
297
+ log.debug(
298
+ "Found %d open changes in project '%s'",
299
+ len(changes),
300
+ project,
301
+ )
302
+ return changes
186
303
 
187
304
 
188
305
  def _execute_query_with_pagination(
@@ -22,6 +22,8 @@ from typing import Any
22
22
  from unittest.mock import MagicMock
23
23
  from unittest.mock import patch
24
24
 
25
+ import pytest
26
+
25
27
  from github2gerrit.core import Orchestrator
26
28
  from github2gerrit.gerrit_pr_closer import abandon_superseded_dependency_changes
27
29
  from github2gerrit.gerrit_query import GerritChange
@@ -63,6 +65,35 @@ def _gerrit_change(
63
65
  )
64
66
 
65
67
 
68
+ def _raw_change(
69
+ number: int,
70
+ subject: str,
71
+ pr_url: str | None = None,
72
+ ) -> dict[str, Any]:
73
+ """Build a raw Gerrit REST change dict (as returned by /changes/).
74
+
75
+ When ``pr_url`` is provided, a ``GitHub-PR`` trailer is added to the
76
+ current revision's commit message so the anonymous repo-scoping filter
77
+ can be exercised.
78
+ """
79
+ message = subject
80
+ if pr_url:
81
+ message = f"{subject}\n\nGitHub-PR: {pr_url}\n"
82
+ return {
83
+ "change_id": f"I{number}",
84
+ "_number": number,
85
+ "subject": subject,
86
+ "status": "NEW",
87
+ "current_revision": "rev1",
88
+ "revisions": {
89
+ "rev1": {
90
+ "files": {},
91
+ "commit": {"message": message},
92
+ }
93
+ },
94
+ }
95
+
96
+
66
97
  # -------------------------------------------------------------------
67
98
  # extract_dependency_package_from_subject — baseline
68
99
  # -------------------------------------------------------------------
@@ -164,11 +195,12 @@ class TestQueryOpenChangesByProject:
164
195
  assert result == []
165
196
 
166
197
  def test_skips_when_unauthenticated(self) -> None:
167
- """owner:self requires auth; skip (no request) when unauthenticated.
198
+ """Unauthenticated with no repository: skip (no request issued).
168
199
 
169
200
  Without Gerrit REST credentials the ``owner:self`` query is
170
- guaranteed to fail with HTTP 403, so the helper must short-circuit
171
- and never issue the request, returning an empty list.
201
+ guaranteed to fail with HTTP 403. When no repository is available
202
+ to drive the anonymous fallback, the helper must short-circuit and
203
+ never issue the request, returning an empty list.
172
204
  """
173
205
  from github2gerrit.utils import reset_warning_once
174
206
 
@@ -182,6 +214,227 @@ class TestQueryOpenChangesByProject:
182
214
  assert result == []
183
215
  fake_client.get.assert_not_called()
184
216
 
217
+ def test_authenticated_uses_owner_self(self) -> None:
218
+ """Authenticated client must use the owner:self predicate."""
219
+ fake_client = MagicMock()
220
+ fake_client.is_authenticated = True
221
+ fake_client.get.return_value = []
222
+
223
+ query_open_changes_by_project(fake_client, "org/repo", branch="main")
224
+
225
+ path = fake_client.get.call_args[0][0]
226
+ # owner:self is URL-encoded as owner%3Aself in the query string
227
+ assert "owner%3Aself" in path
228
+
229
+ def test_anonymous_fallback_scopes_to_repository(self) -> None:
230
+ """Unauthenticated + repo: query anonymously, scope by trailer.
231
+
232
+ The query must NOT use owner:self, and only changes whose
233
+ GitHub-PR trailer targets the current repository are returned.
234
+ """
235
+ from github2gerrit.utils import reset_warning_once
236
+
237
+ reset_warning_once()
238
+
239
+ ours = _raw_change(
240
+ 100,
241
+ "chore: bump requests from 2.31.0 to 2.32.0",
242
+ "https://github.com/org/repo/pull/42",
243
+ )
244
+ other_repo = _raw_change(
245
+ 101,
246
+ "chore: bump requests from 2.31.0 to 2.32.0",
247
+ "https://github.com/other/project/pull/7",
248
+ )
249
+ human = _raw_change(102, "Fix: unrelated human change")
250
+
251
+ fake_client = MagicMock()
252
+ fake_client.is_authenticated = False
253
+ fake_client.get.return_value = [ours, other_repo, human]
254
+
255
+ result = query_open_changes_by_project(
256
+ fake_client, "org/repo", github_repository="org/repo"
257
+ )
258
+
259
+ path = fake_client.get.call_args[0][0]
260
+ assert "owner%3Aself" not in path
261
+ assert [change.number for change in result] == ["100"]
262
+
263
+ def test_anonymous_fallback_repo_match_is_case_insensitive(self) -> None:
264
+ """Repo trailer matching ignores case differences."""
265
+ from github2gerrit.utils import reset_warning_once
266
+
267
+ reset_warning_once()
268
+
269
+ ours = _raw_change(
270
+ 200,
271
+ "chore: bump requests from 2.31.0 to 2.32.0",
272
+ "https://github.com/Org/Repo/pull/9",
273
+ )
274
+
275
+ fake_client = MagicMock()
276
+ fake_client.is_authenticated = False
277
+ fake_client.get.return_value = [ours]
278
+
279
+ result = query_open_changes_by_project(
280
+ fake_client, "org/repo", github_repository="org/repo"
281
+ )
282
+ assert [change.number for change in result] == ["200"]
283
+
284
+ def test_anonymous_fallback_matches_ghes_host(self) -> None:
285
+ """The host is ignored, so GitHub Enterprise URLs still match."""
286
+ from github2gerrit.utils import reset_warning_once
287
+
288
+ reset_warning_once()
289
+
290
+ ours = _raw_change(
291
+ 210,
292
+ "chore: bump requests from 2.31.0 to 2.32.0",
293
+ "https://ghe.example.com/org/repo/pull/9",
294
+ )
295
+
296
+ fake_client = MagicMock()
297
+ fake_client.is_authenticated = False
298
+ fake_client.get.return_value = [ours]
299
+
300
+ result = query_open_changes_by_project(
301
+ fake_client, "org/repo", github_repository="org/repo"
302
+ )
303
+ assert [change.number for change in result] == ["210"]
304
+
305
+ def test_anonymous_fallback_rejects_substring_in_query(self) -> None:
306
+ """Non-absolute or substring-only repo references must not match.
307
+
308
+ The matcher requires an absolute http(s) URL and checks its path
309
+ prefix, so a different repo's URL that merely mentions the target
310
+ repo in its query string, a non-URL value, and a relative path
311
+ (e.g. ``/org/repo/pull/1``) are all correctly excluded.
312
+ """
313
+ from github2gerrit.utils import reset_warning_once
314
+
315
+ reset_warning_once()
316
+
317
+ other = _raw_change(
318
+ 220,
319
+ "chore: bump requests from 2.31.0 to 2.32.0",
320
+ "https://github.com/other/project/pull/7?ref=/org/repo/pull/1",
321
+ )
322
+ malformed = _raw_change(
323
+ 221,
324
+ "chore: bump requests from 2.31.0 to 2.32.0",
325
+ "not-a-url-/org/repo/pull/1",
326
+ )
327
+ relative = _raw_change(
328
+ 222,
329
+ "chore: bump requests from 2.31.0 to 2.32.0",
330
+ "/org/repo/pull/1",
331
+ )
332
+
333
+ fake_client = MagicMock()
334
+ fake_client.is_authenticated = False
335
+ fake_client.get.return_value = [other, malformed, relative]
336
+
337
+ result = query_open_changes_by_project(
338
+ fake_client, "org/repo", github_repository="org/repo"
339
+ )
340
+ assert result == []
341
+
342
+ def test_anonymous_fallback_narrows_query_by_message(self) -> None:
343
+ """The anonymous query narrows server-side via message:GitHub-PR.
344
+
345
+ Without owner:self, the result set is reduced server-side to
346
+ GitHub2Gerrit changes (which carry the GitHub-PR trailer) so busy
347
+ projects do not truncate before the relevant changes are seen.
348
+ """
349
+ from github2gerrit.utils import reset_warning_once
350
+
351
+ reset_warning_once()
352
+
353
+ fake_client = MagicMock()
354
+ fake_client.is_authenticated = False
355
+ fake_client.get.return_value = []
356
+
357
+ query_open_changes_by_project(
358
+ fake_client, "org/repo", github_repository="org/repo"
359
+ )
360
+
361
+ path = fake_client.get.call_args[0][0]
362
+ assert "owner%3Aself" not in path
363
+ # message:"GitHub-PR" is URL-encoded in the query string
364
+ assert "message%3A%22GitHub-PR%22" in path
365
+
366
+ def test_anonymous_fallback_warns_on_truncation(
367
+ self, caplog: pytest.LogCaptureFixture
368
+ ) -> None:
369
+ """Hitting the result cap emits a one-time truncation warning."""
370
+ import logging
371
+
372
+ from github2gerrit.utils import reset_warning_once
373
+
374
+ reset_warning_once()
375
+
376
+ c1 = _raw_change(
377
+ 300,
378
+ "chore: bump x from 1 to 2",
379
+ "https://github.com/org/repo/pull/1",
380
+ )
381
+ c2 = _raw_change(
382
+ 301,
383
+ "chore: bump y from 1 to 2",
384
+ "https://github.com/org/repo/pull/2",
385
+ )
386
+
387
+ fake_client = MagicMock()
388
+ fake_client.is_authenticated = False
389
+ fake_client.get.return_value = [c1, c2]
390
+
391
+ with caplog.at_level(logging.WARNING):
392
+ result = query_open_changes_by_project(
393
+ fake_client,
394
+ "org/repo",
395
+ github_repository="org/repo",
396
+ max_results=2,
397
+ )
398
+
399
+ assert {change.number for change in result} == {"300", "301"}
400
+ assert any(
401
+ "results may be truncated" in rec.getMessage()
402
+ for rec in caplog.records
403
+ )
404
+
405
+ def test_anonymous_fallback_disabled_returns_empty(
406
+ self, monkeypatch: pytest.MonkeyPatch
407
+ ) -> None:
408
+ """Disabling the fallback flag skips even when repo is known."""
409
+ from github2gerrit.utils import reset_warning_once
410
+
411
+ reset_warning_once()
412
+ monkeypatch.setenv("G2G_ANON_SUPERSEDE_FALLBACK", "false")
413
+
414
+ fake_client = MagicMock()
415
+ fake_client.is_authenticated = False
416
+
417
+ result = query_open_changes_by_project(
418
+ fake_client, "org/repo", github_repository="org/repo"
419
+ )
420
+ assert result == []
421
+ fake_client.get.assert_not_called()
422
+
423
+ def test_anonymous_fallback_whitespace_repo_skips(self) -> None:
424
+ """A blank/whitespace-only repository must not trigger a query."""
425
+ from github2gerrit.utils import reset_warning_once
426
+
427
+ reset_warning_once()
428
+
429
+ fake_client = MagicMock()
430
+ fake_client.is_authenticated = False
431
+
432
+ result = query_open_changes_by_project(
433
+ fake_client, "org/repo", github_repository=" "
434
+ )
435
+ assert result == []
436
+ fake_client.get.assert_not_called()
437
+
185
438
 
186
439
  # -------------------------------------------------------------------
187
440
  # abandon_superseded_dependency_changes
@@ -467,6 +720,7 @@ class TestAbandonSupersededDependencyChanges:
467
720
  current_subject="chore: bump requests from 2.31.0 to 2.32.0",
468
721
  exclude_change_ids=[],
469
722
  target_branch="main",
723
+ github_repository="org/repo-gh",
470
724
  )
471
725
 
472
726
  mock_query.assert_called_once_with(
@@ -474,6 +728,7 @@ class TestAbandonSupersededDependencyChanges:
474
728
  "org/repo",
475
729
  branch="main",
476
730
  max_results=200,
731
+ github_repository="org/repo-gh",
477
732
  )
478
733
 
479
734
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes