github2gerrit 1.2.1__tar.gz → 1.2.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 (133) hide show
  1. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.pre-commit-config.yaml +5 -5
  2. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/PKG-INFO +4 -3
  3. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/README.md +2 -2
  4. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/action.yaml +1 -1
  5. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/pyproject.toml +8 -1
  6. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/gerrit_pr_closer.py +58 -2
  7. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/gerrit_rest.py +13 -0
  8. github2gerrit-1.2.3/src/github2gerrit/gerrit_ssh.py +284 -0
  9. github2gerrit-1.2.3/tests/test_gerrit_ssh_abandon.py +258 -0
  10. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/uv.lock +238 -193
  11. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.editorconfig +0 -0
  12. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.gitignore +0 -0
  13. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.gitlint +0 -0
  14. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.markdownlint.yaml +0 -0
  15. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.readthedocs.yml +0 -0
  16. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/.yamllint +0 -0
  17. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/LICENSE +0 -0
  18. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/LICENSES/Apache-2.0.txt +0 -0
  19. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/REUSE.toml +0 -0
  20. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/docs/COMMIT_RULES.md +0 -0
  21. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/docs/COMPOSITE_ACTION_TESTING.md +0 -0
  22. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/docs/PR_UPDATE_IMPLEMENTATION.md +0 -0
  23. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/docs/RELEASE-v0.2.0.md +0 -0
  24. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/docs/github2gerrit_token_permissions_classic.png +0 -0
  25. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/sitecustomize.py +0 -0
  26. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/__init__.py +0 -0
  27. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/cli.py +0 -0
  28. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/commit_normalization.py +0 -0
  29. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/commit_rules.py +0 -0
  30. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/config.py +0 -0
  31. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/constants.py +0 -0
  32. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/core.py +0 -0
  33. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/duplicate_detection.py +0 -0
  34. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/error_codes.py +0 -0
  35. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/external_api.py +0 -0
  36. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/gerrit_query.py +0 -0
  37. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/gerrit_urls.py +0 -0
  38. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/github_api.py +0 -0
  39. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/gitreview.py +0 -0
  40. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/gitutils.py +0 -0
  41. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/mapping_comment.py +0 -0
  42. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/models.py +0 -0
  43. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/netrc.py +0 -0
  44. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/orchestrator/__init__.py +0 -0
  45. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  46. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/pr_commands.py +0 -0
  47. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/pr_content_filter.py +0 -0
  48. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/reconcile_matcher.py +0 -0
  49. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/rich_display.py +0 -0
  50. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/rich_logging.py +0 -0
  51. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/similarity.py +0 -0
  52. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/ssh_agent_setup.py +0 -0
  53. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/ssh_common.py +0 -0
  54. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/ssh_config_parser.py +0 -0
  55. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/ssh_discovery.py +0 -0
  56. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/trailers.py +0 -0
  57. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/src/github2gerrit/utils.py +0 -0
  58. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/conftest.py +0 -0
  59. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/fixtures/__init__.py +0 -0
  60. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/fixtures/make_repo.py +0 -0
  61. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/fixtures/ssh_config_samples.py +0 -0
  62. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_action_environment_mapping.py +0 -0
  63. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_action_outputs.py +0 -0
  64. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_action_pr_number_handling.py +0 -0
  65. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_action_step_validation.py +0 -0
  66. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_automation_only.py +0 -0
  67. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_change_id_deduplication.py +0 -0
  68. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_clean_squash_title.py +0 -0
  69. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_cli.py +0 -0
  70. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_cli_helpers.py +0 -0
  71. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_cli_netrc_options.py +0 -0
  72. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_cli_outputs_file.py +0 -0
  73. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_cli_url_and_dryrun.py +0 -0
  74. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_commit_normalization.py +0 -0
  75. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_commit_rules.py +0 -0
  76. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_composite_action_coverage.py +0 -0
  77. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_config_and_reviewers.py +0 -0
  78. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_config_helpers.py +0 -0
  79. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_close_pr_policy.py +0 -0
  80. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_config_and_errors.py +0 -0
  81. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_gerrit_backref_comment.py +0 -0
  82. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_gerrit_push_errors.py +0 -0
  83. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_gerrit_rest_results.py +0 -0
  84. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_integration_fixture_repo.py +0 -0
  85. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_prepare_commits.py +0 -0
  86. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_shallow_clone.py +0 -0
  87. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_ssh_setup.py +0 -0
  88. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_core_ssrf_protection.py +0 -0
  89. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_dependency_supersession.py +0 -0
  90. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_dns_validation_and_no_gerrit.py +0 -0
  91. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_duplicate_detection.py +0 -0
  92. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_email_case_normalization.py +0 -0
  93. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_error_codes.py +0 -0
  94. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_external_api_framework.py +0 -0
  95. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_force_flag_cli.py +0 -0
  96. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gerrit_change_id_footer.py +0 -0
  97. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gerrit_change_status_checks.py +0 -0
  98. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gerrit_pr_closer.py +0 -0
  99. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gerrit_rest_client.py +0 -0
  100. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gerrit_urls.py +0 -0
  101. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gerrit_urls_more.py +0 -0
  102. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ghe_and_gitreview_args.py +0 -0
  103. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_github_api_error_handling.py +0 -0
  104. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_github_api_helpers.py +0 -0
  105. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_github_api_retry_and_helpers.py +0 -0
  106. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gitreview.py +0 -0
  107. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_gitutils_helpers.py +0 -0
  108. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_issue_157_regressions.py +0 -0
  109. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_mapping_comment_additional.py +0 -0
  110. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  111. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_metadata_and_reconciliation.py +0 -0
  112. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_metadata_trailer_separation_bug.py +0 -0
  113. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_misc_small_coverage.py +0 -0
  114. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_netrc.py +0 -0
  115. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_orphan_rest_side_effects.py +0 -0
  116. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_pr_commands.py +0 -0
  117. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_pr_content_filter.py +0 -0
  118. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_pr_content_filter_integration.py +0 -0
  119. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_pr_update_detection.py +0 -0
  120. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_reconciliation_extracted_module.py +0 -0
  121. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  122. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_reconciliation_scenarios.py +0 -0
  123. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ssh_agent.py +0 -0
  124. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ssh_agent_ownership.py +0 -0
  125. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ssh_artifact_prevention.py +0 -0
  126. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ssh_common.py +0 -0
  127. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ssh_discovery.py +0 -0
  128. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_ssh_discovery_dry_run.py +0 -0
  129. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_trailers_additional.py +0 -0
  130. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_url_parser.py +0 -0
  131. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/test_utils.py +0 -0
  132. {github2gerrit-1.2.1 → github2gerrit-1.2.3}/tests/unit/test_config_integration.py +0 -0
  133. {github2gerrit-1.2.1 → github2gerrit-1.2.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: b831c3dc5d27d9da294ae4e915773b99aa24a7c5 # frozen: v0.15.10
62
+ rev: 0671d8ab202c4ac093b78433ae5baf74f3fc7246 # frozen: v0.15.15
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: 0f369d245750787ce34997d464ed9605391a5283 # frozen: v1.20.1
71
+ rev: d2823d321df3af8f878f7ee3414dc94d037145b9 # frozen: v2.1.0
72
72
  hooks:
73
73
  - id: mypy
74
74
  files: ^src/.+\.py$
@@ -115,18 +115,18 @@ repos:
115
115
 
116
116
  # Requires a mirror, primary repo lacks .pre-commit-hooks.yaml
117
117
  - repo: https://github.com/DetachHead/basedpyright-prek-mirror
118
- rev: 7664ed7e31234c8369d85ee9a13a1ca3361c0aa1 # frozen: 1.39.0
118
+ rev: 78e6efd50b63647fecb7e65fc7032745d861e2c5 # frozen: 1.39.6
119
119
  hooks:
120
120
  - id: basedpyright
121
121
  files: ^src/.+\.py$
122
122
 
123
123
  - repo: https://github.com/lfreleng-actions/gha-workflow-linter
124
- rev: a7caf8f3a1a05688d1cee46615ff94def617e5a3 # frozen: v1.0.2
124
+ rev: 2c315e461ec3379bf9c1682360fdc1a3899f88c9 # frozen: v1.0.4
125
125
  hooks:
126
126
  - id: gha-workflow-linter
127
127
 
128
128
  - repo: https://github.com/python-jsonschema/check-jsonschema
129
- rev: ed81924a8b1cecdaa570b072528fa80c9c4d6ccd # frozen: 0.37.1
129
+ rev: 943377262562a12b57292fc98fabd7dbf81451fe # frozen: 0.37.2
130
130
  hooks:
131
131
  - id: check-github-actions
132
132
  - id: check-github-workflows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.2.1
3
+ Version: 1.2.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,7 @@ Classifier: Topic :: Software Development :: Build Tools
22
22
  Classifier: Topic :: Software Development :: Version Control
23
23
  Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
+ Requires-Dist: click>=8.1.7
25
26
  Requires-Dist: cryptography>=46.0.5
26
27
  Requires-Dist: git-review>=2.5.0
27
28
  Requires-Dist: pygerrit2>=2.0.15
@@ -962,7 +963,7 @@ name: github2gerrit
962
963
 
963
964
  on:
964
965
  pull_request_target:
965
- types: [opened, reopened, edited, synchronize]
966
+ types: [opened, reopened, edited, synchronize, closed]
966
967
  workflow_dispatch:
967
968
 
968
969
  permissions:
@@ -1191,7 +1192,7 @@ name: github2gerrit (advanced)
1191
1192
 
1192
1193
  on:
1193
1194
  pull_request_target:
1194
- types: [opened, reopened, edited, synchronize]
1195
+ types: [opened, reopened, edited, synchronize, closed]
1195
1196
  workflow_dispatch:
1196
1197
 
1197
1198
  permissions:
@@ -917,7 +917,7 @@ name: github2gerrit
917
917
 
918
918
  on:
919
919
  pull_request_target:
920
- types: [opened, reopened, edited, synchronize]
920
+ types: [opened, reopened, edited, synchronize, closed]
921
921
  workflow_dispatch:
922
922
 
923
923
  permissions:
@@ -1146,7 +1146,7 @@ name: github2gerrit (advanced)
1146
1146
 
1147
1147
  on:
1148
1148
  pull_request_target:
1149
- types: [opened, reopened, edited, synchronize]
1149
+ types: [opened, reopened, edited, synchronize, closed]
1150
1150
  workflow_dispatch:
1151
1151
 
1152
1152
  permissions:
@@ -221,7 +221,7 @@ runs:
221
221
  - name: "Setup uv"
222
222
  if: steps.disabled-check.outputs.disabled != 'true'
223
223
  # yamllint disable-line rule:line-length
224
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
224
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
225
225
  with:
226
226
  enable-cache: false
227
227
 
@@ -30,6 +30,13 @@ dependencies = [
30
30
  # CLI framework (Typer)
31
31
  "typer>=0.20.1",
32
32
 
33
+ # Imported directly by the CLI (click.Group subclass for custom usage
34
+ # formatting; click.core.ParameterSource to detect explicit CLI flags)
35
+ # rather than only transitively via typer. Declared as a direct
36
+ # dependency so our import contract doesn't depend on typer's internal
37
+ # dependency choices.
38
+ "click>=8.1.7",
39
+
33
40
  # Rich formatting for CLI output
34
41
  "rich>=14.2.0",
35
42
 
@@ -208,7 +215,7 @@ disallow_untyped_calls = false
208
215
 
209
216
  [tool.coverage.run]
210
217
  branch = true
211
- source = ["src/github2gerrit"]
218
+ source = ["github2gerrit"]
212
219
  omit = [
213
220
  "tests/*",
214
221
  "src/github2gerrit/gerrit_rest.py",
@@ -11,6 +11,7 @@ merged and close the corresponding GitHub pull request that originated it.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
+ import os
14
15
  import re
15
16
  from typing import Any
16
17
  from typing import Literal
@@ -1679,11 +1680,57 @@ def _build_gerrit_abandon_message(pr_obj: Any, pr_url: str) -> str:
1679
1680
  )
1680
1681
 
1681
1682
 
1683
+ def _abandon_change_via_ssh_if_possible(
1684
+ client: Any, change_number: str, message: str
1685
+ ) -> bool:
1686
+ """Attempt to abandon a change over SSH using ambient credentials.
1687
+
1688
+ Reads the Gerrit SSH connection details from the environment
1689
+ (populated by the action) and the host from the REST *client*.
1690
+
1691
+ Returns ``True`` only if the change was abandoned via SSH; ``False``
1692
+ when SSH is not configured or the SSH abandon did not succeed (in
1693
+ which case the caller should fall back to REST).
1694
+ """
1695
+ ssh_privkey = os.getenv("GERRIT_SSH_PRIVKEY_G2G", "").strip()
1696
+ ssh_user = os.getenv("GERRIT_SSH_USER_G2G", "").strip()
1697
+ if not ssh_privkey or not ssh_user:
1698
+ return False
1699
+
1700
+ host = os.getenv("GERRIT_SERVER", "").strip()
1701
+ if not host:
1702
+ host = getattr(client, "host", "") or ""
1703
+ if not host:
1704
+ return False
1705
+
1706
+ try:
1707
+ port = int(os.getenv("GERRIT_SERVER_PORT", "29418") or "29418")
1708
+ except ValueError:
1709
+ port = 29418
1710
+
1711
+ from .gerrit_ssh import abandon_change_via_ssh
1712
+
1713
+ return abandon_change_via_ssh(
1714
+ host=host,
1715
+ change_number=str(change_number),
1716
+ message=message,
1717
+ user=ssh_user,
1718
+ ssh_privkey=ssh_privkey,
1719
+ known_hosts=os.getenv("GERRIT_KNOWN_HOSTS", ""),
1720
+ port=port,
1721
+ )
1722
+
1723
+
1682
1724
  def _abandon_gerrit_change(
1683
1725
  client: Any, change_number: str, message: str
1684
1726
  ) -> None:
1685
1727
  """
1686
- Abandon a Gerrit change via REST API.
1728
+ Abandon a Gerrit change.
1729
+
1730
+ Prefers SSH (``gerrit review --abandon``) because mutating REST calls
1731
+ are rejected with HTTP 403 on Gerrit servers that do not allow
1732
+ unauthenticated REST writes and where only SSH credentials are
1733
+ configured. Falls back to the REST API when SSH is unavailable.
1687
1734
 
1688
1735
  Args:
1689
1736
  client: Gerrit REST client
@@ -1691,9 +1738,18 @@ def _abandon_gerrit_change(
1691
1738
  message: Abandon message
1692
1739
 
1693
1740
  Raises:
1694
- Exception: If abandon operation fails
1741
+ Exception: If the abandon operation fails
1695
1742
  """
1743
+ if _abandon_change_via_ssh_if_possible(client, change_number, message):
1744
+ log.debug(
1745
+ "Successfully abandoned Gerrit change %s via SSH", change_number
1746
+ )
1747
+ return
1748
+
1696
1749
  try:
1750
+ log.debug(
1751
+ "Falling back to REST abandon for Gerrit change %s", change_number
1752
+ )
1697
1753
  abandon_path = f"/changes/{change_number}/abandon"
1698
1754
  abandon_data = {"message": message}
1699
1755
  client.post(abandon_path, data=abandon_data)
@@ -163,6 +163,19 @@ class GerritRestClient:
163
163
  """Return True if client has authentication credentials."""
164
164
  return self._auth is not None
165
165
 
166
+ @property
167
+ def host(self) -> str:
168
+ """Return the Gerrit hostname derived from the base URL.
169
+
170
+ Returns an empty string when the host cannot be determined.
171
+ """
172
+ from urllib.parse import urlparse
173
+
174
+ try:
175
+ return urlparse(self._base_url).hostname or ""
176
+ except Exception:
177
+ return ""
178
+
166
179
  def get(self, path: str) -> Any:
167
180
  """HTTP GET, returning parsed JSON."""
168
181
  return self._request_json_with_retry("GET", path)
@@ -0,0 +1,284 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2025 The Linux Foundation
3
+
4
+ """
5
+ SSH-based Gerrit operations.
6
+
7
+ Some Gerrit deployments (notably those fronted by a CDN/WAF, or that
8
+ disallow anonymous REST writes) reject mutating REST calls such as
9
+ ``POST /changes/{id}/abandon`` with HTTP 403 unless dedicated HTTP API
10
+ credentials are supplied. The github2gerrit action authenticates to
11
+ Gerrit over SSH (the same channel used to push changes), so this module
12
+ performs the abandon over SSH instead, reusing the already-configured
13
+ SSH key and known_hosts.
14
+
15
+ The single public entry point :func:`abandon_change_via_ssh` is designed
16
+ to be used as the preferred path with a REST fallback: it never raises
17
+ and returns ``True`` only when the change was actually abandoned.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import secrets
26
+ import shlex
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ from .gitutils import CommandError
31
+ from .gitutils import run_cmd
32
+ from .ssh_common import augment_known_hosts_with_bracketed_entries
33
+ from .ssh_common import build_non_interactive_ssh_env
34
+
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ DEFAULT_GERRIT_SSH_PORT = 29418
39
+ _SSH_TIMEOUT_SECONDS = 30.0
40
+
41
+
42
+ def _write_secure_file(path: Path, content: str, mode: int) -> None:
43
+ """Write *content* to *path* with restrictive *mode* permissions."""
44
+ # Create with secure permissions from the start (avoid a window where
45
+ # the file is world-readable).
46
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
47
+ try:
48
+ handle = os.fdopen(fd, "w", encoding="utf-8")
49
+ except BaseException:
50
+ # os.fdopen did not take ownership of fd; close it to avoid a leak.
51
+ os.close(fd)
52
+ raise
53
+ with handle:
54
+ handle.write(content)
55
+ # Re-assert mode in case umask altered it.
56
+ path.chmod(mode)
57
+
58
+
59
+ def _build_ssh_base_argv(
60
+ *,
61
+ key_path: Path,
62
+ known_hosts_path: Path | None,
63
+ port: int,
64
+ user: str,
65
+ host: str,
66
+ ) -> list[str]:
67
+ """Build the common ``ssh`` argv prefix for non-interactive auth."""
68
+ argv: list[str] = [
69
+ "ssh",
70
+ "-F",
71
+ "/dev/null",
72
+ "-i",
73
+ str(key_path),
74
+ "-o",
75
+ "IdentitiesOnly=yes",
76
+ "-o",
77
+ "IdentityAgent=none",
78
+ "-o",
79
+ "BatchMode=yes",
80
+ "-o",
81
+ "PreferredAuthentications=publickey",
82
+ "-o",
83
+ "PasswordAuthentication=no",
84
+ "-o",
85
+ "PubkeyAcceptedKeyTypes=+ssh-rsa",
86
+ "-o",
87
+ "ConnectTimeout=10",
88
+ ]
89
+ if known_hosts_path is not None:
90
+ argv += [
91
+ "-o",
92
+ f"UserKnownHostsFile={known_hosts_path}",
93
+ "-o",
94
+ "StrictHostKeyChecking=yes",
95
+ ]
96
+ else:
97
+ # No known_hosts supplied: we cannot verify the host key, but we
98
+ # still want a non-interactive connection. accept-new records the
99
+ # key on first use; pair it with an explicit (throwaway)
100
+ # UserKnownHostsFile so OpenSSH does not mutate the runner's
101
+ # default ~/.ssh/known_hosts (which may be missing/unwritable).
102
+ log.warning(
103
+ "No GERRIT_KNOWN_HOSTS available for SSH abandon; using "
104
+ "StrictHostKeyChecking=accept-new with a throwaway known_hosts"
105
+ )
106
+ argv += [
107
+ "-o",
108
+ "UserKnownHostsFile=/dev/null",
109
+ "-o",
110
+ "StrictHostKeyChecking=accept-new",
111
+ ]
112
+ argv += ["-n", "-p", str(port), f"{user}@{host}"]
113
+ return argv
114
+
115
+
116
+ def _resolve_current_patchset(
117
+ base_argv: list[str],
118
+ change_number: str,
119
+ env: dict[str, str],
120
+ ) -> str | None:
121
+ """Return the current patch-set number for *change_number*, or None."""
122
+ remote_cmd = (
123
+ "gerrit query --format=JSON --current-patch-set "
124
+ f"change:{shlex.quote(change_number)}"
125
+ )
126
+ try:
127
+ result = run_cmd(
128
+ [*base_argv, remote_cmd],
129
+ timeout=_SSH_TIMEOUT_SECONDS,
130
+ env=env,
131
+ )
132
+ except CommandError as exc:
133
+ log.debug(
134
+ "Gerrit SSH query for change %s failed: %s",
135
+ change_number,
136
+ exc,
137
+ )
138
+ return None
139
+
140
+ for raw_line in result.stdout.splitlines():
141
+ line = raw_line.strip()
142
+ if not line:
143
+ continue
144
+ try:
145
+ record = json.loads(line)
146
+ except (ValueError, TypeError):
147
+ continue
148
+ patch_set = record.get("currentPatchSet")
149
+ if isinstance(patch_set, dict) and patch_set.get("number") is not None:
150
+ return str(patch_set["number"])
151
+ log.debug(
152
+ "No currentPatchSet found in Gerrit query output for change %s",
153
+ change_number,
154
+ )
155
+ return None
156
+
157
+
158
+ def abandon_change_via_ssh(
159
+ *,
160
+ host: str,
161
+ change_number: str,
162
+ message: str,
163
+ user: str,
164
+ ssh_privkey: str,
165
+ known_hosts: str | None = None,
166
+ port: int = DEFAULT_GERRIT_SSH_PORT,
167
+ ) -> bool:
168
+ """Abandon a Gerrit change over SSH using ``gerrit review --abandon``.
169
+
170
+ This is the preferred abandon path because it uses the same SSH
171
+ credentials that the action already relies on to push changes, and
172
+ therefore works on Gerrit servers that reject unauthenticated REST
173
+ writes.
174
+
175
+ Args:
176
+ host: Gerrit SSH hostname (no scheme).
177
+ change_number: Numeric Gerrit change number.
178
+ message: Abandon message (may be multi-line).
179
+ user: Gerrit SSH username.
180
+ ssh_privkey: SSH private key content.
181
+ known_hosts: Optional known_hosts content for host verification.
182
+ port: Gerrit SSH port (default 29418).
183
+
184
+ Returns:
185
+ ``True`` if the change was abandoned successfully; ``False`` when
186
+ prerequisites are missing or the SSH operation failed (so the
187
+ caller may fall back to another mechanism). This function never
188
+ raises.
189
+ """
190
+ if not (host and user and ssh_privkey and str(change_number).strip()):
191
+ log.debug(
192
+ "SSH abandon prerequisites missing (host=%s, user=%s, "
193
+ "key=%s, change=%s); skipping SSH abandon",
194
+ bool(host),
195
+ bool(user),
196
+ bool(ssh_privkey),
197
+ change_number,
198
+ )
199
+ return False
200
+
201
+ change_number = str(change_number).strip()
202
+ try:
203
+ tmp_dir = Path(
204
+ tempfile.mkdtemp(prefix=f"g2g_abandon_{secrets.token_hex(8)}_")
205
+ )
206
+ except OSError as exc:
207
+ # Honor the "never raises" contract so callers can fall back to REST.
208
+ log.debug("Could not create temp dir for SSH abandon: %s", exc)
209
+ return False
210
+ try:
211
+ tmp_dir.chmod(0o700)
212
+ key_path = tmp_dir / "gerrit_key"
213
+ _write_secure_file(key_path, ssh_privkey.strip() + "\n", 0o600)
214
+
215
+ known_hosts_path: Path | None = None
216
+ if known_hosts and known_hosts.strip():
217
+ # OpenSSH looks up host keys under a bracketed "[host]:port"
218
+ # entry when connecting on a non-default port (Gerrit uses
219
+ # 29418). Augment the provided content with bracketed variants
220
+ # so StrictHostKeyChecking can verify the key and we don't fall
221
+ # back to REST unnecessarily.
222
+ augmented = augment_known_hosts_with_bracketed_entries(
223
+ known_hosts.strip(), host, port
224
+ )
225
+ known_hosts_path = tmp_dir / "known_hosts"
226
+ _write_secure_file(known_hosts_path, augmented, 0o644)
227
+
228
+ base_argv = _build_ssh_base_argv(
229
+ key_path=key_path,
230
+ known_hosts_path=known_hosts_path,
231
+ port=port,
232
+ user=user,
233
+ host=host,
234
+ )
235
+
236
+ # Disable any ambient SSH agent so only the provided key is used.
237
+ ssh_env = build_non_interactive_ssh_env()
238
+
239
+ patch_set = _resolve_current_patchset(base_argv, change_number, ssh_env)
240
+ if patch_set is None:
241
+ log.debug(
242
+ "Could not resolve current patch-set for change %s via SSH",
243
+ change_number,
244
+ )
245
+ return False
246
+
247
+ target = f"{change_number},{patch_set}"
248
+ remote_cmd = (
249
+ "gerrit review --abandon "
250
+ f"-m {shlex.quote(message)} {shlex.quote(target)}"
251
+ )
252
+ try:
253
+ run_cmd(
254
+ [*base_argv, remote_cmd],
255
+ timeout=_SSH_TIMEOUT_SECONDS,
256
+ env=ssh_env,
257
+ )
258
+ except CommandError as exc:
259
+ log.warning(
260
+ "SSH abandon failed for change %s: %s",
261
+ change_number,
262
+ exc,
263
+ )
264
+ return False
265
+ else:
266
+ log.debug(
267
+ "Successfully abandoned Gerrit change %s via SSH",
268
+ change_number,
269
+ )
270
+ return True
271
+ except Exception:
272
+ log.warning(
273
+ "Unexpected error during SSH abandon for change %s",
274
+ change_number,
275
+ exc_info=True,
276
+ )
277
+ return False
278
+ finally:
279
+ try:
280
+ import shutil
281
+
282
+ shutil.rmtree(tmp_dir, ignore_errors=True)
283
+ except Exception:
284
+ log.debug("Failed to clean up SSH temp dir %s", tmp_dir)