github2gerrit 1.4.1__tar.gz → 1.4.3__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.4.1 → github2gerrit-1.4.3}/.pre-commit-config.yaml +2 -2
  2. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/PKG-INFO +1 -1
  3. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/action.yaml +12 -2
  4. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/pyproject.toml +6 -0
  5. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/core.py +198 -2
  6. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/error_codes.py +19 -0
  7. github2gerrit-1.4.3/tests/test_final_state_reconciliation.py +333 -0
  8. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/uv.lock +149 -143
  9. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/.editorconfig +0 -0
  10. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/.gitignore +0 -0
  11. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/.gitlint +0 -0
  12. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/.markdownlint.yaml +0 -0
  13. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/.yamllint +0 -0
  14. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/LICENSE +0 -0
  15. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/LICENSES/Apache-2.0.txt +0 -0
  16. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/README.md +0 -0
  17. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/REUSE.toml +0 -0
  18. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/SECURITY.md +0 -0
  19. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/docs/COMMIT_RULES.md +0 -0
  20. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/docs/cli.md +0 -0
  21. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/docs/development.md +0 -0
  22. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/docs/features.md +0 -0
  23. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/docs/github2gerrit_token_permissions_classic.png +0 -0
  24. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/sitecustomize.py +0 -0
  25. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/__init__.py +0 -0
  26. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/cli.py +0 -0
  27. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/commit_normalization.py +0 -0
  28. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/commit_rules.py +0 -0
  29. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/config.py +0 -0
  30. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/constants.py +0 -0
  31. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/duplicate_detection.py +0 -0
  32. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/external_api.py +0 -0
  33. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gerrit_pr_closer.py +0 -0
  34. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gerrit_query.py +0 -0
  35. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gerrit_rest.py +0 -0
  36. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gerrit_ssh.py +0 -0
  37. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gerrit_urls.py +0 -0
  38. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/github_api.py +0 -0
  39. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gitreview.py +0 -0
  40. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/gitutils.py +0 -0
  41. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/mapping_comment.py +0 -0
  42. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/models.py +0 -0
  43. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/netrc.py +0 -0
  44. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/orchestrator/__init__.py +0 -0
  45. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  46. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/pr_commands.py +0 -0
  47. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/pr_content_filter.py +0 -0
  48. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/reconcile_matcher.py +0 -0
  49. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/rich_display.py +0 -0
  50. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/rich_logging.py +0 -0
  51. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/similarity.py +0 -0
  52. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/ssh_agent_setup.py +0 -0
  53. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/ssh_common.py +0 -0
  54. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/ssh_config_parser.py +0 -0
  55. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/ssh_discovery.py +0 -0
  56. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/trailers.py +0 -0
  57. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/src/github2gerrit/utils.py +0 -0
  58. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/conftest.py +0 -0
  59. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/fixtures/__init__.py +0 -0
  60. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/fixtures/make_repo.py +0 -0
  61. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/fixtures/ssh_config_samples.py +0 -0
  62. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_action_environment_mapping.py +0 -0
  63. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_action_outputs.py +0 -0
  64. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_action_pr_number_handling.py +0 -0
  65. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_action_step_validation.py +0 -0
  66. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_automation_only.py +0 -0
  67. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_change_id_deduplication.py +0 -0
  68. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_clean_squash_title.py +0 -0
  69. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_cli.py +0 -0
  70. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_cli_helpers.py +0 -0
  71. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_cli_netrc_options.py +0 -0
  72. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_cli_outputs_file.py +0 -0
  73. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_cli_url_and_dryrun.py +0 -0
  74. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_commit_normalization.py +0 -0
  75. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_commit_rules.py +0 -0
  76. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_composite_action_coverage.py +0 -0
  77. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_config_and_reviewers.py +0 -0
  78. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_config_helpers.py +0 -0
  79. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_close_pr_policy.py +0 -0
  80. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_config_and_errors.py +0 -0
  81. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_gerrit_backref_comment.py +0 -0
  82. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_gerrit_push_errors.py +0 -0
  83. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_gerrit_rest_results.py +0 -0
  84. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_integration_fixture_repo.py +0 -0
  85. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_prepare_commits.py +0 -0
  86. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_shallow_clone.py +0 -0
  87. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_ssh_setup.py +0 -0
  88. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_core_ssrf_protection.py +0 -0
  89. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_dependency_supersession.py +0 -0
  90. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_dns_validation_and_no_gerrit.py +0 -0
  91. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_duplicate_detection.py +0 -0
  92. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_email_case_normalization.py +0 -0
  93. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_error_codes.py +0 -0
  94. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_external_api_framework.py +0 -0
  95. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_force_flag_cli.py +0 -0
  96. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_change_id_footer.py +0 -0
  97. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_change_status_checks.py +0 -0
  98. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_pr_closer.py +0 -0
  99. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_rest_client.py +0 -0
  100. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_ssh_abandon.py +0 -0
  101. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_urls.py +0 -0
  102. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gerrit_urls_more.py +0 -0
  103. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ghe_and_gitreview_args.py +0 -0
  104. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_github_api_error_handling.py +0 -0
  105. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_github_api_helpers.py +0 -0
  106. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_github_api_retry_and_helpers.py +0 -0
  107. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gitreview.py +0 -0
  108. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_gitutils_helpers.py +0 -0
  109. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_issue_157_regressions.py +0 -0
  110. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_mapping_comment_additional.py +0 -0
  111. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  112. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_metadata_and_reconciliation.py +0 -0
  113. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_metadata_trailer_separation_bug.py +0 -0
  114. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_misc_small_coverage.py +0 -0
  115. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_netrc.py +0 -0
  116. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_orphan_rest_side_effects.py +0 -0
  117. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_pr_commands.py +0 -0
  118. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_pr_content_filter.py +0 -0
  119. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_pr_content_filter_integration.py +0 -0
  120. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_pr_update_detection.py +0 -0
  121. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_reconciliation_extracted_module.py +0 -0
  122. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  123. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_reconciliation_scenarios.py +0 -0
  124. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ssh_agent.py +0 -0
  125. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ssh_agent_ownership.py +0 -0
  126. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ssh_artifact_prevention.py +0 -0
  127. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ssh_common.py +0 -0
  128. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ssh_discovery.py +0 -0
  129. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_ssh_discovery_dry_run.py +0 -0
  130. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_trailers_additional.py +0 -0
  131. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_url_parser.py +0 -0
  132. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/test_utils.py +0 -0
  133. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/unit/test_config_integration.py +0 -0
  134. {github2gerrit-1.4.1 → github2gerrit-1.4.3}/tests/unit/test_ssh_config_parser.py +0 -0
@@ -59,7 +59,7 @@ repos:
59
59
  types: [yaml]
60
60
 
61
61
  - repo: https://github.com/astral-sh/ruff-pre-commit
62
- rev: c59bba8fb259db0fec2bbb77ad8ba51ea7341b56 # frozen: v0.15.20
62
+ rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # frozen: v0.15.21
63
63
  hooks:
64
64
  - id: ruff
65
65
  files: ^(src|scripts|tests)/.+\.py$
@@ -68,7 +68,7 @@ repos:
68
68
  files: ^(src|scripts|tests)/.+\.py$
69
69
 
70
70
  - repo: https://github.com/pre-commit/mirrors-mypy
71
- rev: d2823d321df3af8f878f7ee3414dc94d037145b9 # frozen: v2.1.0
71
+ rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0
72
72
  hooks:
73
73
  - id: mypy
74
74
  files: ^src/.+\.py$
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.4.1
3
+ Version: 1.4.3
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
@@ -22,6 +22,15 @@ inputs:
22
22
  description: "Fetch depth for checkout"
23
23
  required: false
24
24
  default: "10"
25
+ SKIP_CHECKOUT:
26
+ description: >-
27
+ Skip the internal repository checkout. Set when the caller has
28
+ already checked out the target repository at the desired ref
29
+ (e.g. the bundled reusable workflow, which also checks out this
30
+ action into the workspace and must not have it deleted by a
31
+ nested checkout)
32
+ required: false
33
+ default: "false"
25
34
  PR_NUMBER:
26
35
  description: "Pull request number to process, use 0 to process all open"
27
36
  required: false
@@ -205,7 +214,8 @@ runs:
205
214
  fi
206
215
 
207
216
  - name: "Checkout repository"
208
- if: steps.disabled-check.outputs.disabled != 'true'
217
+ # yamllint disable-line rule:line-length
218
+ if: steps.disabled-check.outputs.disabled != 'true' && inputs.SKIP_CHECKOUT != 'true'
209
219
  # yamllint disable-line rule:line-length
210
220
  uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
211
221
  with:
@@ -227,7 +237,7 @@ runs:
227
237
  - name: "Setup uv"
228
238
  if: steps.disabled-check.outputs.disabled != 'true'
229
239
  # yamllint disable-line rule:line-length
230
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
240
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
231
241
  with:
232
242
  enable-cache: false
233
243
 
@@ -216,6 +216,12 @@ disallow_untyped_calls = false
216
216
 
217
217
  [tool.coverage.run]
218
218
  branch = true
219
+ # Keep transient coverage data out of the working tree so tests that
220
+ # inspect on-disk repository content never see .coverage* files.
221
+ # During pytest runs tests/conftest.py sets COVERAGE_FILE (env), which
222
+ # takes precedence; this value covers direct coverage invocations and
223
+ # satisfies python-test-action's data_file configuration check.
224
+ data_file = "${TMPDIR-/tmp}/.coverage.github2gerrit"
219
225
  source = ["github2gerrit"]
220
226
  omit = [
221
227
  "tests/*",
@@ -41,6 +41,7 @@ from collections.abc import Sequence
41
41
  from dataclasses import dataclass
42
42
  from pathlib import Path
43
43
  from typing import Any
44
+ from typing import Literal
44
45
  from urllib.request import Request
45
46
  from urllib.request import urlopen
46
47
 
@@ -1860,6 +1861,173 @@ class Orchestrator:
1860
1861
  )
1861
1862
  raise OrchestratorError(_MSG_DNS_RESOLUTION_FAILED % host) from exc
1862
1863
 
1864
+ def _lookup_change_state(
1865
+ self,
1866
+ gerrit: GerritInfo,
1867
+ change_id: str,
1868
+ ) -> dict[str, str] | None:
1869
+ """Query Gerrit for the current state of a change by Change-Id.
1870
+
1871
+ Scopes the query to the Gerrit project to avoid ambiguous
1872
+ Change-Id lookups across projects.
1873
+
1874
+ Returns:
1875
+ Dict with 'status', 'number' and 'current_revision' keys,
1876
+ or None when the change cannot be found or queried.
1877
+ """
1878
+ try:
1879
+ from .gerrit_rest import build_client_for_host
1880
+
1881
+ client = build_client_for_host(
1882
+ gerrit.host, timeout=8.0, max_attempts=3
1883
+ )
1884
+ query = f"change:{change_id} project:{gerrit.project}"
1885
+ encoded_q = urllib.parse.quote(query, safe="")
1886
+ data = client.get(f"/changes/?q={encoded_q}&n=1&o=CURRENT_REVISION")
1887
+ if isinstance(data, list) and data:
1888
+ change = data[0]
1889
+ if isinstance(change, dict):
1890
+ return {
1891
+ "status": str(change.get("status", "")),
1892
+ "number": str(change.get("_number", "")),
1893
+ "current_revision": str(
1894
+ change.get("current_revision", "")
1895
+ ),
1896
+ }
1897
+ except Exception as exc:
1898
+ log.debug("Failed to query state for change %s: %s", change_id, exc)
1899
+ return None
1900
+
1901
+ def _reconcile_final_state_changes(
1902
+ self,
1903
+ *,
1904
+ gh: GitHubContext,
1905
+ gerrit: GerritInfo,
1906
+ change_ids: list[str],
1907
+ ) -> SubmissionResult | None:
1908
+ """Reconcile GitHub PR state against final-state Gerrit changes.
1909
+
1910
+ When reconciliation resolves existing Change-Id(s) for a PR,
1911
+ verify their status in Gerrit before pushing. If every resolved
1912
+ change is already MERGED or ABANDONED, pushing a new patchset
1913
+ would be rejected ("change ... closed"). Instead, act on the
1914
+ GitHub PR:
1915
+
1916
+ - MERGED: close the PR with an explanatory comment (honoring
1917
+ CLOSE_MERGED_PRS; when false, comment only).
1918
+ - ABANDONED: close or comment per CLOSE_MERGED_PRS.
1919
+
1920
+ Returns:
1921
+ SubmissionResult describing the existing change(s) when the
1922
+ pipeline should stop (all changes final), or None to
1923
+ proceed with the normal push flow.
1924
+ """
1925
+ if not change_ids or not gh.pr_number:
1926
+ return None
1927
+
1928
+ states: list[tuple[str, dict[str, str]]] = []
1929
+ for cid in change_ids:
1930
+ info = self._lookup_change_state(gerrit, cid)
1931
+ if info is None:
1932
+ # Fail open: unknown state must not block submission
1933
+ return None
1934
+ states.append((cid, info))
1935
+
1936
+ final_states = {"MERGED", "ABANDONED"}
1937
+ if not all(info["status"] in final_states for _, info in states):
1938
+ open_ids = [
1939
+ cid
1940
+ for cid, info in states
1941
+ if info["status"] not in final_states
1942
+ ]
1943
+ closed_ids = [
1944
+ cid for cid, info in states if info["status"] in final_states
1945
+ ]
1946
+ if closed_ids:
1947
+ log.warning(
1948
+ "Some reused Gerrit change(s) are already closed "
1949
+ "(%s); proceeding with open change(s): %s",
1950
+ ", ".join(closed_ids),
1951
+ ", ".join(open_ids),
1952
+ )
1953
+ return None
1954
+
1955
+ # All resolved changes are in a final state
1956
+ url_builder = create_gerrit_url_builder(gerrit.host)
1957
+ urls: list[str] = []
1958
+ nums: list[str] = []
1959
+ shas: list[str] = []
1960
+ for _cid, info in states:
1961
+ if info["number"]:
1962
+ try:
1963
+ change_number = int(info["number"])
1964
+ except ValueError:
1965
+ continue
1966
+ urls.append(
1967
+ url_builder.change_url(gerrit.project, change_number)
1968
+ )
1969
+ nums.append(info["number"])
1970
+ if info["current_revision"]:
1971
+ shas.append(info["current_revision"])
1972
+
1973
+ # Overall status: MERGED wins for messaging purposes
1974
+ overall_status: Literal["MERGED", "ABANDONED"] = (
1975
+ "MERGED"
1976
+ if any(info["status"] == "MERGED" for _, info in states)
1977
+ else "ABANDONED"
1978
+ )
1979
+ change_ref = ", ".join(urls) if urls else ", ".join(change_ids)
1980
+ log.info(
1981
+ "Gerrit change(s) for PR #%s already %s: %s",
1982
+ gh.pr_number,
1983
+ overall_status.lower(),
1984
+ change_ref,
1985
+ )
1986
+
1987
+ close_merged_prs = env_bool("CLOSE_MERGED_PRS", True)
1988
+ pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
1989
+ try:
1990
+ if close_merged_prs or overall_status == "ABANDONED":
1991
+ # Closes the PR, or posts a comment-only notification
1992
+ # for ABANDONED changes when CLOSE_MERGED_PRS is false
1993
+ from .gerrit_pr_closer import close_pr_with_status
1994
+
1995
+ close_pr_with_status(
1996
+ pr_url=pr_url,
1997
+ gerrit_change_url=change_ref if urls else None,
1998
+ gerrit_status=overall_status,
1999
+ dry_run=False,
2000
+ progress_tracker=None,
2001
+ close_merged_prs=close_merged_prs,
2002
+ )
2003
+ else:
2004
+ # close_pr_with_status posts no comment for MERGED
2005
+ # changes when CLOSE_MERGED_PRS is false; comment here
2006
+ # so the early pipeline stop is explained on the PR
2007
+ client = build_client()
2008
+ repo_obj = get_repo_from_env(client)
2009
+ pr_obj = get_pull(repo_obj, int(gh.pr_number))
2010
+ comment = (
2011
+ "The Gerrit change for this pull request has "
2012
+ f"merged: {change_ref}\n\n"
2013
+ "No further patchsets can be submitted; this pull "
2014
+ "request is now redundant and can be closed."
2015
+ )
2016
+ create_pr_comment(pr_obj, comment)
2017
+ except Exception as exc:
2018
+ log.warning(
2019
+ "Failed to reconcile GitHub PR #%s for %s Gerrit change: %s",
2020
+ gh.pr_number,
2021
+ overall_status.lower(),
2022
+ exc,
2023
+ )
2024
+
2025
+ return SubmissionResult(
2026
+ change_urls=urls,
2027
+ change_numbers=nums,
2028
+ commit_shas=shas,
2029
+ )
2030
+
1863
2031
  def execute(
1864
2032
  self,
1865
2033
  inputs: Inputs,
@@ -2032,6 +2200,18 @@ class Orchestrator:
2032
2200
  inputs, gh, gerrit, single_commit
2033
2201
  )
2034
2202
 
2203
+ # State reconciliation: when every Gerrit change resolved for
2204
+ # this PR is already in a final state (merged/abandoned), the
2205
+ # PR is redundant. Act on the GitHub PR instead of pushing a
2206
+ # patchset that Gerrit will reject with "change ... closed".
2207
+ early_result = self._reconcile_final_state_changes(
2208
+ gh=gh,
2209
+ gerrit=gerrit,
2210
+ change_ids=reuse_ids,
2211
+ )
2212
+ if early_result is not None:
2213
+ return early_result
2214
+
2035
2215
  # Now prepare commits once with the correct reuse_ids
2036
2216
  if inputs.submit_single_commits:
2037
2217
  prep = self._prepare_single_commits(
@@ -4276,8 +4456,13 @@ class Orchestrator:
4276
4456
  # Analyze the specific failure reason from git review output
4277
4457
  error_details = self._analyze_gerrit_push_failure(exc)
4278
4458
 
4279
- # Always log the error details, even if not in verbose mode
4280
- log.exception("Gerrit push failed: %s", error_details)
4459
+ # Log the analyzed error; reserve the full traceback for
4460
+ # verbose/debug mode to keep console output actionable
4461
+ if is_verbose_mode():
4462
+ log.exception("Gerrit push failed: %s", error_details)
4463
+ else:
4464
+ # Deliberately omit the traceback outside verbose mode
4465
+ log.error("Gerrit push failed: %s", error_details) # noqa: TRY400
4281
4466
 
4282
4467
  # In debug mode, also show the raw command output
4283
4468
  if is_verbose_mode():
@@ -4827,6 +5012,11 @@ class Orchestrator:
4827
5012
  )
4828
5013
  if rejection_match:
4829
5014
  reason = rejection_match.group(1).strip()
5015
+ if re.search(r"change\s+\S+\s+closed", reason):
5016
+ return (
5017
+ "Gerrit change is closed (merged or abandoned) "
5018
+ f"and cannot accept new patchsets: {reason}"
5019
+ )
4830
5020
  return f"Gerrit rejected the push: {reason}"
4831
5021
 
4832
5022
  # Fallback: look line by line
@@ -4835,6 +5025,12 @@ class Orchestrator:
4835
5025
  if "! [remote rejected]" in line:
4836
5026
  if "(" in line and ")" in line:
4837
5027
  reason = line[line.find("(") + 1 : line.find(")")]
5028
+ if re.search(r"change\s+\S+\s+closed", reason):
5029
+ return (
5030
+ "Gerrit change is closed (merged or "
5031
+ "abandoned) and cannot accept new "
5032
+ f"patchsets: {reason}"
5033
+ )
4838
5034
  return f"Gerrit rejected the push: {reason}"
4839
5035
  return f"Gerrit rejected the push: {line.strip()}"
4840
5036
  return "Gerrit rejected the push for an unknown reason"
@@ -473,6 +473,19 @@ def map_orchestrator_error_to_exit_code(
473
473
  if original_exception and is_network_error(original_exception):
474
474
  return ExitCode.NETWORK_ERROR
475
475
 
476
+ # Final-state Gerrit change patterns: pushing a patchset to a
477
+ # merged/abandoned change is a state problem, not a connection
478
+ # problem. Check before the connection patterns because these
479
+ # messages also contain "failed to push".
480
+ final_state_patterns = [
481
+ r"change is closed \(merged or abandoned\)",
482
+ r"cannot accept new patchsets",
483
+ r"change\s+\S+\s+closed",
484
+ ]
485
+
486
+ if any(re.search(pattern, error_lower) for pattern in final_state_patterns):
487
+ return ExitCode.GERRIT_CHANGE_ALREADY_FINAL
488
+
476
489
  # Gerrit connection error patterns
477
490
  gerrit_patterns = [
478
491
  r"failed to push",
@@ -542,6 +555,12 @@ def convert_orchestrator_error(
542
555
  "❌ Git repository access failed; check repository permissions"
543
556
  )
544
557
  details = f"Repository issue: {error_msg}"
558
+ elif exit_code == ExitCode.GERRIT_CHANGE_ALREADY_FINAL:
559
+ user_message = (
560
+ "❌ Gerrit change is already merged or abandoned; "
561
+ "no new patchsets can be pushed"
562
+ )
563
+ details = f"Gerrit change state issue: {error_msg}"
545
564
  else:
546
565
  user_message = "❌ Operation failed; check logs for details"
547
566
  details = error_msg
@@ -0,0 +1,333 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 The Linux Foundation
3
+
4
+ """Tests for final-state (merged/abandoned) Gerrit change reconciliation.
5
+
6
+ Covers the regression where a re-run against a PR whose Gerrit change
7
+ had already merged attempted to push a new patchset, which Gerrit
8
+ rejected with "change ... closed" and the tool then misreported as an
9
+ SSH/connection failure.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from unittest import mock
18
+
19
+ import pytest
20
+
21
+ from github2gerrit.core import GerritInfo
22
+ from github2gerrit.core import Orchestrator
23
+ from github2gerrit.error_codes import ExitCode
24
+ from github2gerrit.error_codes import map_orchestrator_error_to_exit_code
25
+ from github2gerrit.gitutils import CommandError
26
+ from github2gerrit.models import GitHubContext
27
+
28
+
29
+ def _gh_context(pr_number: int | None = 33) -> GitHubContext:
30
+ return GitHubContext(
31
+ event_name="pull_request_target",
32
+ event_action="synchronize",
33
+ event_path=None,
34
+ repository="onap/policy-opa-pdp",
35
+ repository_owner="onap",
36
+ server_url="https://github.com",
37
+ run_id="123",
38
+ sha="abc123",
39
+ base_ref="master",
40
+ head_ref="dependabot/github_actions/foo",
41
+ pr_number=pr_number,
42
+ )
43
+
44
+
45
+ def _gerrit_info() -> GerritInfo:
46
+ return GerritInfo(
47
+ host="gerrit.example.org",
48
+ port=29418,
49
+ project="policy/opa-pdp",
50
+ )
51
+
52
+
53
+ class TestClosedChangePushAnalysis:
54
+ """Push failure analysis for closed-change rejections."""
55
+
56
+ def setup_method(self) -> None:
57
+ self.workspace = Path(tempfile.mkdtemp())
58
+ self.orchestrator = Orchestrator(workspace=self.workspace)
59
+
60
+ def test_closed_change_rejection_message(self) -> None:
61
+ output = (
62
+ "remote: error: change is closed\n"
63
+ " ! [remote rejected] HEAD -> refs/for/master "
64
+ "(change https://gerrit.example.org/r/c/policy/opa-pdp/+/146080 "
65
+ "closed)\n"
66
+ "error: failed to push some refs\n"
67
+ )
68
+ exc = CommandError(
69
+ "Command failed",
70
+ cmd=["git", "review"],
71
+ returncode=1,
72
+ stdout="",
73
+ stderr=output,
74
+ )
75
+ result = self.orchestrator._analyze_gerrit_push_failure(exc)
76
+
77
+ assert "closed (merged or abandoned)" in result
78
+ assert "146080" in result
79
+ # Must not be reported as a generic rejection
80
+ assert not result.startswith("Gerrit rejected the push:")
81
+
82
+
83
+ class TestClosedChangeExitCode:
84
+ """Exit code mapping for closed-change push failures."""
85
+
86
+ def test_closed_change_maps_to_final_state_exit_code(self) -> None:
87
+ msg = (
88
+ "Failed to push changes to Gerrit with git-review: "
89
+ "Gerrit change is closed (merged or abandoned) and cannot "
90
+ "accept new patchsets: change "
91
+ "https://gerrit.example.org/r/c/policy/opa-pdp/+/146080 closed"
92
+ )
93
+ assert (
94
+ map_orchestrator_error_to_exit_code(msg)
95
+ == ExitCode.GERRIT_CHANGE_ALREADY_FINAL
96
+ )
97
+
98
+ def test_raw_closed_rejection_maps_to_final_state_exit_code(self) -> None:
99
+ msg = (
100
+ "Gerrit rejected the push: change "
101
+ "https://gerrit.example.org/r/c/x/+/1 closed"
102
+ )
103
+ assert (
104
+ map_orchestrator_error_to_exit_code(msg)
105
+ == ExitCode.GERRIT_CHANGE_ALREADY_FINAL
106
+ )
107
+
108
+ def test_ssh_failure_still_maps_to_connection_error(self) -> None:
109
+ msg = (
110
+ "Failed to push changes to Gerrit with git-review: "
111
+ "SSH public key authentication failed."
112
+ )
113
+ assert (
114
+ map_orchestrator_error_to_exit_code(msg)
115
+ == ExitCode.GERRIT_CONNECTION_ERROR
116
+ )
117
+
118
+
119
+ class TestReconcileFinalStateChanges:
120
+ """Pre-push reconciliation of PRs whose Gerrit changes are final."""
121
+
122
+ def setup_method(self) -> None:
123
+ self.workspace = Path(tempfile.mkdtemp())
124
+ self.orchestrator = Orchestrator(workspace=self.workspace)
125
+ self.gh = _gh_context()
126
+ self.gerrit = _gerrit_info()
127
+
128
+ def _run(self, change_ids: list[str]) -> Any:
129
+ return self.orchestrator._reconcile_final_state_changes(
130
+ gh=self.gh,
131
+ gerrit=self.gerrit,
132
+ change_ids=change_ids,
133
+ )
134
+
135
+ def test_no_change_ids_proceeds(self) -> None:
136
+ assert self._run([]) is None
137
+
138
+ def test_open_change_proceeds(self) -> None:
139
+ with mock.patch.object(
140
+ self.orchestrator,
141
+ "_lookup_change_state",
142
+ return_value={
143
+ "status": "NEW",
144
+ "number": "146080",
145
+ "current_revision": "deadbeef",
146
+ },
147
+ ):
148
+ assert self._run(["I" + "a" * 40]) is None
149
+
150
+ def test_unknown_state_fails_open(self) -> None:
151
+ with mock.patch.object(
152
+ self.orchestrator,
153
+ "_lookup_change_state",
154
+ return_value=None,
155
+ ):
156
+ assert self._run(["I" + "a" * 40]) is None
157
+
158
+ def test_merged_change_closes_pr_and_stops(
159
+ self, monkeypatch: pytest.MonkeyPatch
160
+ ) -> None:
161
+ monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
162
+ with (
163
+ mock.patch.object(
164
+ self.orchestrator,
165
+ "_lookup_change_state",
166
+ return_value={
167
+ "status": "MERGED",
168
+ "number": "146080",
169
+ "current_revision": "deadbeef",
170
+ },
171
+ ),
172
+ mock.patch(
173
+ "github2gerrit.gerrit_pr_closer.close_pr_with_status",
174
+ return_value=True,
175
+ ) as mock_close,
176
+ ):
177
+ result = self._run(["I" + "a" * 40])
178
+
179
+ assert result is not None
180
+ assert result.change_numbers == ["146080"]
181
+ assert result.commit_shas == ["deadbeef"]
182
+ assert len(result.change_urls) == 1
183
+ assert "146080" in result.change_urls[0]
184
+
185
+ mock_close.assert_called_once()
186
+ kwargs = mock_close.call_args.kwargs
187
+ assert kwargs["pr_url"] == (
188
+ "https://github.com/onap/policy-opa-pdp/pull/33"
189
+ )
190
+ assert kwargs["gerrit_status"] == "MERGED"
191
+ assert kwargs["close_merged_prs"] is True
192
+
193
+ def test_merged_change_comments_when_close_disabled(
194
+ self, monkeypatch: pytest.MonkeyPatch
195
+ ) -> None:
196
+ monkeypatch.setenv("CLOSE_MERGED_PRS", "false")
197
+ with (
198
+ mock.patch.object(
199
+ self.orchestrator,
200
+ "_lookup_change_state",
201
+ return_value={
202
+ "status": "MERGED",
203
+ "number": "146080",
204
+ "current_revision": "deadbeef",
205
+ },
206
+ ),
207
+ mock.patch(
208
+ "github2gerrit.gerrit_pr_closer.close_pr_with_status",
209
+ ) as mock_close,
210
+ mock.patch("github2gerrit.core.build_client"),
211
+ mock.patch("github2gerrit.core.get_repo_from_env"),
212
+ mock.patch("github2gerrit.core.get_pull"),
213
+ mock.patch("github2gerrit.core.create_pr_comment") as mock_comment,
214
+ ):
215
+ result = self._run(["I" + "a" * 40])
216
+
217
+ # Pipeline still stops, the PR stays open, and users get an
218
+ # explanatory comment instead of silence
219
+ assert result is not None
220
+ mock_close.assert_not_called()
221
+ mock_comment.assert_called_once()
222
+ comment_body = mock_comment.call_args.args[1]
223
+ assert "merged" in comment_body
224
+ assert "146080" in comment_body
225
+
226
+ def test_multiple_merged_changes_reference_all_urls(
227
+ self, monkeypatch: pytest.MonkeyPatch
228
+ ) -> None:
229
+ monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
230
+ states = iter(
231
+ [
232
+ {
233
+ "status": "MERGED",
234
+ "number": "101",
235
+ "current_revision": "aaa",
236
+ },
237
+ {
238
+ "status": "MERGED",
239
+ "number": "102",
240
+ "current_revision": "bbb",
241
+ },
242
+ ]
243
+ )
244
+ with (
245
+ mock.patch.object(
246
+ self.orchestrator,
247
+ "_lookup_change_state",
248
+ side_effect=lambda *_a, **_k: next(states),
249
+ ),
250
+ mock.patch(
251
+ "github2gerrit.gerrit_pr_closer.close_pr_with_status",
252
+ return_value=True,
253
+ ) as mock_close,
254
+ ):
255
+ result = self._run(["I" + "a" * 40, "I" + "b" * 40])
256
+
257
+ assert result is not None
258
+ assert result.change_numbers == ["101", "102"]
259
+ # The PR close/comment must reference every reconciled change
260
+ change_url_arg = mock_close.call_args.kwargs["gerrit_change_url"]
261
+ assert "101" in change_url_arg
262
+ assert "102" in change_url_arg
263
+
264
+ def test_abandoned_change_stops_with_abandoned_status(
265
+ self, monkeypatch: pytest.MonkeyPatch
266
+ ) -> None:
267
+ monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
268
+ with (
269
+ mock.patch.object(
270
+ self.orchestrator,
271
+ "_lookup_change_state",
272
+ return_value={
273
+ "status": "ABANDONED",
274
+ "number": "146081",
275
+ "current_revision": "",
276
+ },
277
+ ),
278
+ mock.patch(
279
+ "github2gerrit.gerrit_pr_closer.close_pr_with_status",
280
+ return_value=True,
281
+ ) as mock_close,
282
+ ):
283
+ result = self._run(["I" + "b" * 40])
284
+
285
+ assert result is not None
286
+ assert mock_close.call_args.kwargs["gerrit_status"] == "ABANDONED"
287
+
288
+ def test_mixed_states_proceeds_with_warning(self) -> None:
289
+ states = iter(
290
+ [
291
+ {
292
+ "status": "MERGED",
293
+ "number": "1",
294
+ "current_revision": "aaa",
295
+ },
296
+ {
297
+ "status": "NEW",
298
+ "number": "2",
299
+ "current_revision": "bbb",
300
+ },
301
+ ]
302
+ )
303
+ with mock.patch.object(
304
+ self.orchestrator,
305
+ "_lookup_change_state",
306
+ side_effect=lambda *_a, **_k: next(states),
307
+ ):
308
+ assert self._run(["I" + "a" * 40, "I" + "b" * 40]) is None
309
+
310
+ def test_close_failure_still_stops_pipeline(
311
+ self, monkeypatch: pytest.MonkeyPatch
312
+ ) -> None:
313
+ monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
314
+ with (
315
+ mock.patch.object(
316
+ self.orchestrator,
317
+ "_lookup_change_state",
318
+ return_value={
319
+ "status": "MERGED",
320
+ "number": "146080",
321
+ "current_revision": "deadbeef",
322
+ },
323
+ ),
324
+ mock.patch(
325
+ "github2gerrit.gerrit_pr_closer.close_pr_with_status",
326
+ side_effect=RuntimeError("GitHub API error"),
327
+ ),
328
+ ):
329
+ result = self._run(["I" + "a" * 40])
330
+
331
+ # Even when the PR closure fails, we must not attempt the
332
+ # doomed push to a closed change.
333
+ assert result is not None