github2gerrit 2.2.2__tar.gz → 2.4.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 (154) hide show
  1. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/PKG-INFO +25 -6
  2. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/README.md +24 -5
  3. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/action.yaml +20 -4
  4. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/docs/cli.md +1 -0
  5. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/docs/features.md +55 -7
  6. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/cli.py +223 -50
  7. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/core.py +5 -1
  8. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/models.py +5 -2
  9. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/pr_approval.py +52 -0
  10. github2gerrit-2.4.0/tests/test_bulk_fan_out.py +721 -0
  11. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_cli_outputs_file.py +1 -0
  12. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_cli_url_and_dryrun.py +1 -0
  13. github2gerrit-2.4.0/tests/test_fork_transfer_record.py +471 -0
  14. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_precommit_mypy_pins.py +7 -0
  15. github2gerrit-2.4.0/tests/test_scheduled_sweep.py +410 -0
  16. github2gerrit-2.4.0/tests/workflow_harness.py +312 -0
  17. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.aislop/config.yml +0 -0
  18. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.editorconfig +0 -0
  19. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.gitignore +0 -0
  20. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.gitlint +0 -0
  21. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.markdownlint.yaml +0 -0
  22. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.pre-commit-config.yaml +0 -0
  23. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/.yamllint +0 -0
  24. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/LICENSE +0 -0
  25. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/LICENSES/Apache-2.0.txt +0 -0
  26. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/REUSE.toml +0 -0
  27. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/SECURITY.md +0 -0
  28. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/docs/COMMIT_RULES.md +0 -0
  29. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/docs/development.md +0 -0
  30. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/docs/github2gerrit_token_permissions_classic.png +0 -0
  31. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/pyproject.toml +0 -0
  32. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/sitecustomize.py +0 -0
  33. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/__init__.py +0 -0
  34. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/approvers.py +0 -0
  35. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/commit_normalization.py +0 -0
  36. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/commit_rules.py +0 -0
  37. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/config.py +0 -0
  38. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/config_ini.py +0 -0
  39. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/constants.py +0 -0
  40. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/duplicate_detection.py +0 -0
  41. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/error_codes.py +0 -0
  42. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/external_api.py +0 -0
  43. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_pr_closer.py +0 -0
  44. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_query.py +0 -0
  45. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_rest.py +0 -0
  46. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_ssh.py +0 -0
  47. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_urls.py +0 -0
  48. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/github_api.py +0 -0
  49. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gitreview.py +0 -0
  50. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/gitutils.py +0 -0
  51. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/mapping_comment.py +0 -0
  52. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/netrc.py +0 -0
  53. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/orchestrator/__init__.py +0 -0
  54. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  55. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/pr_commands.py +0 -0
  56. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/pr_content_filter.py +0 -0
  57. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/pr_directives.py +0 -0
  58. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/project_names.py +0 -0
  59. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/reconcile_matcher.py +0 -0
  60. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/rich_display.py +0 -0
  61. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/rich_logging.py +0 -0
  62. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/similarity.py +0 -0
  63. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/ssh_agent_setup.py +0 -0
  64. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/ssh_common.py +0 -0
  65. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/ssh_config_parser.py +0 -0
  66. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/ssh_discovery.py +0 -0
  67. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/trailers.py +0 -0
  68. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/trust.py +0 -0
  69. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/src/github2gerrit/utils.py +0 -0
  70. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/conftest.py +0 -0
  71. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/fixtures/__init__.py +0 -0
  72. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/fixtures/make_repo.py +0 -0
  73. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/fixtures/ssh_config_samples.py +0 -0
  74. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_action_environment_mapping.py +0 -0
  75. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_action_outputs.py +0 -0
  76. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_action_pr_number_handling.py +0 -0
  77. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_action_step_validation.py +0 -0
  78. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_approver_sources.py +0 -0
  79. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_automation_only.py +0 -0
  80. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_cache_isolation.py +0 -0
  81. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_change_id_deduplication.py +0 -0
  82. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_clean_squash_title.py +0 -0
  83. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_cli.py +0 -0
  84. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_cli_helpers.py +0 -0
  85. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_cli_netrc_options.py +0 -0
  86. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_commit_normalization.py +0 -0
  87. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_commit_rules.py +0 -0
  88. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_composite_action_coverage.py +0 -0
  89. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_config_and_reviewers.py +0 -0
  90. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_config_helpers.py +0 -0
  91. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_close_pr_policy.py +0 -0
  92. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_config_and_errors.py +0 -0
  93. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_gerrit_backref_comment.py +0 -0
  94. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_gerrit_push_errors.py +0 -0
  95. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_gerrit_rest_results.py +0 -0
  96. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_integration_fixture_repo.py +0 -0
  97. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_prepare_commits.py +0 -0
  98. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_shallow_clone.py +0 -0
  99. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_ssh_setup.py +0 -0
  100. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_core_ssrf_protection.py +0 -0
  101. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_dependency_supersession.py +0 -0
  102. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_dns_validation_and_no_gerrit.py +0 -0
  103. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_duplicate_detection.py +0 -0
  104. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_email_case_normalization.py +0 -0
  105. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_error_codes.py +0 -0
  106. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_external_api_framework.py +0 -0
  107. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_final_state_reconciliation.py +0 -0
  108. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_force_flag_cli.py +0 -0
  109. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_fork_approval_gate.py +0 -0
  110. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_fork_pr_hardening.py +0 -0
  111. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_change_id_footer.py +0 -0
  112. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_change_status_checks.py +0 -0
  113. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_pr_closer.py +0 -0
  114. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_rest_client.py +0 -0
  115. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_ssh_abandon.py +0 -0
  116. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_urls.py +0 -0
  117. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gerrit_urls_more.py +0 -0
  118. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ghe_and_gitreview_args.py +0 -0
  119. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_github_api_error_handling.py +0 -0
  120. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_github_api_helpers.py +0 -0
  121. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_github_api_retry_and_helpers.py +0 -0
  122. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gitreview.py +0 -0
  123. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_gitutils_helpers.py +0 -0
  124. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_issue_157_regressions.py +0 -0
  125. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_mapping_comment_additional.py +0 -0
  126. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  127. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_metadata_and_reconciliation.py +0 -0
  128. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_metadata_trailer_separation_bug.py +0 -0
  129. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_misc_small_coverage.py +0 -0
  130. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_netrc.py +0 -0
  131. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_orphan_rest_side_effects.py +0 -0
  132. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_pr_closed_handling.py +0 -0
  133. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_pr_command_authorisation.py +0 -0
  134. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_pr_commands.py +0 -0
  135. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_pr_content_filter.py +0 -0
  136. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_pr_content_filter_integration.py +0 -0
  137. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_pr_update_detection.py +0 -0
  138. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_project_names.py +0 -0
  139. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_reconciliation_extracted_module.py +0 -0
  140. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  141. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_reconciliation_scenarios.py +0 -0
  142. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ssh_agent.py +0 -0
  143. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ssh_agent_ownership.py +0 -0
  144. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ssh_artifact_prevention.py +0 -0
  145. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ssh_common.py +0 -0
  146. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ssh_discovery.py +0 -0
  147. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_ssh_discovery_dry_run.py +0 -0
  148. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_sync_mypy_pins.py +0 -0
  149. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_trailers_additional.py +0 -0
  150. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_url_parser.py +0 -0
  151. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/test_utils.py +0 -0
  152. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/unit/test_config_integration.py +0 -0
  153. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/tests/unit/test_ssh_config_parser.py +0 -0
  154. {github2gerrit-2.2.2 → github2gerrit-2.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github2gerrit
3
- Version: 2.2.2
3
+ Version: 2.4.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
@@ -145,6 +145,9 @@ on:
145
145
  # pull_request_review runs on fork PRs.
146
146
  issue_comment:
147
147
  types: [created]
148
+ # Optional: transfer approved fork PRs without anyone commenting.
149
+ # schedule:
150
+ # - cron: "17 * * * *"
148
151
  push:
149
152
  branches: [main, master]
150
153
  workflow_dispatch:
@@ -189,17 +192,26 @@ which closes a human-authored pull request before the approval gate
189
192
  sees it, so leaving it set would make the fork path unreachable. Drop
190
193
  both if the repository only ever receives automation PRs.
191
194
 
195
+ The optional `schedule` trigger does the comment's job unattended: each
196
+ run transfers fork pull requests whose current commit a maintainer has
197
+ approved, one job per pull request, and leaves every other pull request
198
+ alone. See
199
+ [scheduled sweeps](docs/features.md#which-triggers-can-lift-the-gate).
200
+
192
201
  Repositories using the Gerrit-side dispatch integration should also
193
202
  declare `GERRIT_CHANGE_URL`, `GERRIT_EVENT_TYPE`, and `GERRIT_BRANCH`
194
203
  as dispatch inputs and forward them the same way.
195
204
 
196
205
  Pin `@main` to a release tag or commit SHA for production use.
197
206
 
198
- A bulk `workflow_dispatch` (`PR_NUMBER` of `0`) processes every open
199
- pull request in one run, which does not serialise against the
200
- per-pull-request runs that events start. Avoid triggering one while
201
- pull request activity is in flight; see
202
- [#422](https://github.com/lfreleng-actions/github2gerrit-action/issues/422).
207
+ A bulk `workflow_dispatch` (`PR_NUMBER` of `0`) fans out: the reusable
208
+ workflow lists the open pull requests and runs one job per pull request,
209
+ four at a time, each in that pull request's own concurrency group, so a sweep
210
+ queues behind the runs that events start for the same pull request
211
+ instead of racing them. One further job runs the repository-wide cleanup once.
212
+ A sweep covers at most 255 open pull requests, one fewer than GitHub's 256-job
213
+ matrix limit; beyond that it fails, and you dispatch individual `PR_NUMBER`
214
+ values instead. A bulk dispatch sets no workflow outputs.
203
215
 
204
216
  ### Option B: composite action
205
217
 
@@ -256,6 +268,13 @@ jobs:
256
268
  AUTOMATION_ONLY: false
257
269
  ```
258
270
 
271
+ A composite action cannot start jobs, so here a bulk `workflow_dispatch`
272
+ (`PR_NUMBER` of `0`) still processes every open pull request in one job. That
273
+ job does not serialise against the per-pull-request runs events start, so
274
+ avoid triggering one while pull request activity is in flight, or use the
275
+ reusable workflow, which fans the sweep out. For the same reason a `schedule`
276
+ trigger works only through the reusable workflow.
277
+
259
278
  ### Option C: command-line tool
260
279
 
261
280
  The underlying Python CLI supports local and ad-hoc use (for example,
@@ -98,6 +98,9 @@ on:
98
98
  # pull_request_review runs on fork PRs.
99
99
  issue_comment:
100
100
  types: [created]
101
+ # Optional: transfer approved fork PRs without anyone commenting.
102
+ # schedule:
103
+ # - cron: "17 * * * *"
101
104
  push:
102
105
  branches: [main, master]
103
106
  workflow_dispatch:
@@ -142,17 +145,26 @@ which closes a human-authored pull request before the approval gate
142
145
  sees it, so leaving it set would make the fork path unreachable. Drop
143
146
  both if the repository only ever receives automation PRs.
144
147
 
148
+ The optional `schedule` trigger does the comment's job unattended: each
149
+ run transfers fork pull requests whose current commit a maintainer has
150
+ approved, one job per pull request, and leaves every other pull request
151
+ alone. See
152
+ [scheduled sweeps](docs/features.md#which-triggers-can-lift-the-gate).
153
+
145
154
  Repositories using the Gerrit-side dispatch integration should also
146
155
  declare `GERRIT_CHANGE_URL`, `GERRIT_EVENT_TYPE`, and `GERRIT_BRANCH`
147
156
  as dispatch inputs and forward them the same way.
148
157
 
149
158
  Pin `@main` to a release tag or commit SHA for production use.
150
159
 
151
- A bulk `workflow_dispatch` (`PR_NUMBER` of `0`) processes every open
152
- pull request in one run, which does not serialise against the
153
- per-pull-request runs that events start. Avoid triggering one while
154
- pull request activity is in flight; see
155
- [#422](https://github.com/lfreleng-actions/github2gerrit-action/issues/422).
160
+ A bulk `workflow_dispatch` (`PR_NUMBER` of `0`) fans out: the reusable
161
+ workflow lists the open pull requests and runs one job per pull request,
162
+ four at a time, each in that pull request's own concurrency group, so a sweep
163
+ queues behind the runs that events start for the same pull request
164
+ instead of racing them. One further job runs the repository-wide cleanup once.
165
+ A sweep covers at most 255 open pull requests, one fewer than GitHub's 256-job
166
+ matrix limit; beyond that it fails, and you dispatch individual `PR_NUMBER`
167
+ values instead. A bulk dispatch sets no workflow outputs.
156
168
 
157
169
  ### Option B: composite action
158
170
 
@@ -209,6 +221,13 @@ jobs:
209
221
  AUTOMATION_ONLY: false
210
222
  ```
211
223
 
224
+ A composite action cannot start jobs, so here a bulk `workflow_dispatch`
225
+ (`PR_NUMBER` of `0`) still processes every open pull request in one job. That
226
+ job does not serialise against the per-pull-request runs events start, so
227
+ avoid triggering one while pull request activity is in flight, or use the
228
+ reusable workflow, which fans the sweep out. For the same reason a `schedule`
229
+ trigger works only through the reusable workflow.
230
+
212
231
  ### Option C: command-line tool
213
232
 
214
233
  The underlying Python CLI supports local and ad-hoc use (for example,
@@ -293,17 +293,22 @@ runs:
293
293
  fi
294
294
 
295
295
  - name: "Validate PR_NUMBER usage"
296
+ # A scheduled sweep names its pull request the same way a dispatch
297
+ # does: the reusable workflow fans the schedule out to one job per
298
+ # pull request, each passing that pull request's number.
296
299
  # yamllint disable-line rule:line-length
297
- if: ${{ steps.disabled-check.outputs.disabled != 'true' && github.event_name != 'workflow_dispatch' && inputs.PR_NUMBER != '' && inputs.PR_NUMBER != '0' }}
300
+ if: ${{ steps.disabled-check.outputs.disabled != 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' && inputs.PR_NUMBER != '' && inputs.PR_NUMBER != '0' }}
298
301
  shell: bash
299
302
  run: |
300
303
  # Validate PR_NUMBER usage
301
304
  set -euo pipefail
302
- echo "Error: PR_NUMBER only valid during workflow_dispatch events."
305
+ echo "Error: PR_NUMBER only valid during workflow_dispatch events," \
306
+ "or on schedule for one job of a scheduled sweep."
303
307
  exit 2
304
308
 
305
309
  - name: "Normalize PR_NUMBER"
306
- if: ${{ steps.disabled-check.outputs.disabled != 'true' && github.event_name == 'workflow_dispatch' }}
310
+ # yamllint disable-line rule:line-length
311
+ if: ${{ steps.disabled-check.outputs.disabled != 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule') }}
307
312
  id: normalize
308
313
  shell: bash
309
314
  env:
@@ -347,7 +352,18 @@ runs:
347
352
  fi
348
353
 
349
354
  # Honor PR_NUMBER or sync-all from workflow_dispatch normalization
350
- if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
355
+ if [[ "${GITHUB_EVENT_NAME}" == "schedule" ]]; then
356
+ # One job of a scheduled sweep, which names its pull request.
357
+ # A schedule sweeping every pull request in one job would race
358
+ # the runs events start for them (#421), so it is refused.
359
+ if [[ -n "${DISPATCH_SYNC_ALL:-}" || -z "${DISPATCH_PR_NUMBER:-}" ]]; then
360
+ echo "Error: a scheduled run needs PR_NUMBER naming one pull request."
361
+ echo "The reusable workflow fans a schedule out to one job per pull"
362
+ echo "request; a composite action cannot start jobs of its own."
363
+ exit 2
364
+ fi
365
+ echo "pr_number=${DISPATCH_PR_NUMBER}" >> "$GITHUB_OUTPUT"
366
+ elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
351
367
  if [[ -n "${DISPATCH_SYNC_ALL:-}" ]]; then
352
368
  echo "Processing all open pull requests via workflow_dispatch."
353
369
  echo "sync_all=true" >> "$GITHUB_OUTPUT"
@@ -206,6 +206,7 @@ forwarded setting treats blank as unset and keeps its documented default.
206
206
  | `CLEANUP_GERRIT` | `true` | Abandon Gerrit changes for closed GitHub PRs |
207
207
  | `PR_NUMBER` | unset | Pull request number to process in CI/CD mode (`0` means bulk mode) |
208
208
  | `SYNC_ALL_OPEN_PRS` | `false` | Process all open PRs in the repository (bulk mode) |
209
+ | `G2G_SWEEP_LEG` | `false` | Set by the reusable workflow on each job of a fanned-out bulk sweep |
209
210
  | `GERRIT_HTTP_USER` | unset | Username for the Gerrit REST API (when required) |
210
211
  | `GERRIT_HTTP_PASSWORD` | unset | HTTP password/token for the Gerrit REST API |
211
212
  | `GERRIT_HTTP_BASE_PATH` | unset | Gerrit REST base path for non-standard deployments (e.g., `/r`) |
@@ -505,7 +505,8 @@ decides *whether* to proceed, and the tool always re-reads it from the pull
505
505
  request's reviews. So no trigger, and nobody who fires one, can grant
506
506
  anything.
507
507
 
508
- Of the privileged triggers, this mechanism uses `issue_comment`.
508
+ Of the privileged triggers, this mechanism uses `issue_comment` and, through
509
+ the reusable workflow, `schedule`.
509
510
 
510
511
  **A comment** is the privileged re-check. Subscribe to `issue_comment`:
511
512
 
@@ -531,11 +532,38 @@ This trigger needs `AUTOMATION_ONLY: false`. It defaults to `true` and closes
531
532
  a human-authored pull request before the gate sees it, which would leave
532
533
  nothing to unblock.
533
534
 
534
- A zero-touch variant — a periodic sweep that notices the approval without
535
- anyone commenting — remains open as
536
- [#421](https://github.com/lfreleng-actions/github2gerrit-action/issues/421).
537
- A repository-wide job needs cross-run serialisation that GitHub's per-job
538
- concurrency groups cannot express, so this mechanism deliberately omits it.
535
+ **A schedule** removes the comment. A maintainer approves in the ordinary way
536
+ and the next sweep transfers the change. Add a `schedule` trigger to a caller
537
+ of the reusable workflow:
538
+
539
+ ```yaml
540
+ on:
541
+ schedule:
542
+ - cron: "17 * * * *" # hourly, off the top of the hour
543
+ ```
544
+
545
+ A scheduled run lists the open pull requests and starts one job for each that
546
+ the gate could be holding back: a head not known to be in this repository,
547
+ with an approving review of its current commit. Same-repository pull requests
548
+ never enter a sweep. That also covers every automation pull request, which
549
+ the events already handle. The sweep still visits a head whose repository no
550
+ longer exists, since the gate applies to it. The approval check only narrows
551
+ the list; the job's own gate still decides whether that approval counts.
552
+
553
+ Each job runs in its pull request's concurrency group, so it queues behind a
554
+ comment re-check or a push for the same pull request rather than racing it.
555
+ Once a pull request has transferred, the transfer record described below has
556
+ later sweeps pass it over until its head moves. A pull request approved but
557
+ not yet transferred gets a job on every run until it transfers, so pick an
558
+ interval to match: hourly suits most projects.
559
+
560
+ The same caveats apply as for comments: the trigger needs
561
+ `AUTOMATION_ONLY: false`, and a scheduled workflow runs from the default
562
+ branch. A composite action cannot start jobs of its own, so a scheduled run
563
+ calling it directly fails with an error saying so, rather than sweeping every
564
+ pull request in one job and racing the runs events start for them. GitHub
565
+ suspends schedules in a public repository after 60 days without activity; the
566
+ comment re-check keeps working regardless.
539
567
 
540
568
  `pull_request_review` should be **removed** from any workflow that still
541
569
  carries it. It can no longer transfer anything: on a fork pull request it runs
@@ -592,7 +620,27 @@ covers an earlier commit, the comment says so, so a maintainer who did approve
592
620
  is not told that nobody has.
593
621
 
594
622
  Once approval arrives, the tool edits that comment again to record it, so a
595
- transferred pull request does not keep displaying a stale block.
623
+ transferred pull request does not keep displaying a stale block. After the
624
+ transfer succeeds it edits the comment once more to say so, recording the
625
+ transferred commit in a hidden marker.
626
+
627
+ A sweep reads that marker and passes over a pull request whose current head it
628
+ names, rather than submitting the same commit again. Sweeps are the bulk
629
+ dispatch (`PR_NUMBER` of `0`) and the scheduled run; through the reusable
630
+ workflow each pull request in either runs as a job of its own, and the marker
631
+ behaves the same there. Anything less means the sweep processes the pull
632
+ request as before: no comment, no marker, a comment it cannot read, or a head
633
+ that has moved since. Only a run that pushes records anything: a dry run does
634
+ not, nor does one that finds the pull request's changes already merged or
635
+ abandoned and acts on GitHub instead. Neither does a pull request approved
636
+ before its first run, which never had a comment to edit. Only sweeps consult
637
+ the marker. A dispatch naming the pull request, a push and a
638
+ `@github2gerrit check` comment transfer whatever it says.
639
+
640
+ The marker proves nothing about who wrote it, and the tool does not assume it
641
+ did. Believing a pasted copy is harmless: skipping transfers nothing, it
642
+ delays only the sweep, and the tool reads the marker only on pull requests
643
+ the gate applies to.
596
644
 
597
645
  #### If the head moves mid-run
598
646
 
@@ -84,8 +84,10 @@ from .pr_approval import APPROVAL_MARKER
84
84
  from .pr_approval import ApprovalStatus
85
85
  from .pr_approval import describe_approver_policy
86
86
  from .pr_approval import evaluate_fork_approval
87
+ from .pr_approval import recorded_transfer
87
88
  from .pr_approval import render_blocked_comment
88
89
  from .pr_approval import render_cleared_comment
90
+ from .pr_approval import render_transferred_comment
89
91
  from .pr_commands import CMD_CHECK
90
92
  from .pr_commands import MENTION_PREFIX
91
93
  from .pr_commands import find_open_command
@@ -120,6 +122,18 @@ def _exit_for_pr_state_error(pr_number: int, pr_state: str) -> None:
120
122
  exit_for_pr_state_error(pr_number, pr_state)
121
123
 
122
124
 
125
+ def _is_sweep_leg() -> bool:
126
+ """Report whether this run is one leg of a fanned-out bulk sweep.
127
+
128
+ The reusable workflow splits a bulk dispatch into a leg per open
129
+ pull request, plus a leg that runs only the repository-wide cleanup
130
+ (#422). ``G2G_SWEEP_LEG`` marks those legs. A leg naming a pull
131
+ request is still a sweep, not a request for that pull request, so
132
+ it keeps the bulk semantics the single-job sweep had.
133
+ """
134
+ return env_bool("G2G_SWEEP_LEG", False)
135
+
136
+
123
137
  def _stop_for_pr_state(
124
138
  gh: GitHubContext, pr_state: str, progress_tracker: Any = None
125
139
  ) -> NoReturn:
@@ -133,11 +147,17 @@ def _stop_for_pr_state(
133
147
  misreport that. The report behind #441 cited exactly this run and
134
148
  took it for the close handler failing.
135
149
 
150
+ A sweep leg is the same case: the pull request was open when the
151
+ sweep listed it and closed before its leg ran.
152
+
136
153
  A dispatch or direct invocation naming a closed pull request is
137
154
  different: somebody asked for that one specifically, and being told
138
155
  it cannot be processed is the useful answer.
139
156
  """
140
- if gh.event_name in ("pull_request", "pull_request_target"):
157
+ if _is_sweep_leg() or gh.event_name in (
158
+ "pull_request",
159
+ "pull_request_target",
160
+ ):
141
161
  log.info(
142
162
  "Pull request #%s is %s; nothing to do for a '%s' event. Cleanup "
143
163
  "for a closed pull request runs on the 'closed' event itself.",
@@ -266,7 +286,9 @@ def _check_automation_only(
266
286
 
267
287
  close_pr(pr_obj, comment=comment)
268
288
 
269
- sys.exit(1)
289
+ # A sweep leg counts this as the single bulk job did: a
290
+ # pull request skipped, not the sweep failed.
291
+ sys.exit(0 if _is_sweep_leg() else 1)
270
292
  except Exception:
271
293
  log.exception("Failed to close non-automation PR")
272
294
  raise
@@ -684,6 +706,25 @@ def _check_fork_approval(
684
706
  return False, ""
685
707
 
686
708
 
709
+ def _marker_comments_newest_first(pr_obj: Any) -> list[Any]:
710
+ """Return the comments carrying the approval marker, newest first.
711
+
712
+ Empty when the comments cannot be read, which every caller treats
713
+ as "no notice": the edit then falls back to posting one, and a
714
+ sweep to processing the pull request.
715
+ """
716
+ try:
717
+ comments = list(pr_obj.as_issue().get_comments())
718
+ except Exception as exc:
719
+ log.debug("Could not read comments for approval notice: %s", exc)
720
+ return []
721
+ return [
722
+ comment
723
+ for comment in reversed(comments)
724
+ if APPROVAL_MARKER in (getattr(comment, "body", "") or "")
725
+ ]
726
+
727
+
687
728
  def _edit_owned_marker_comment(pr_obj: Any, body: str) -> bool:
688
729
  """Replace the tool's own approval notice, if one exists.
689
730
 
@@ -695,17 +736,8 @@ def _edit_owned_marker_comment(pr_obj: Any, body: str) -> bool:
695
736
  Returns:
696
737
  ``True`` when a notice of ours was updated.
697
738
  """
698
- try:
699
- issue = pr_obj.as_issue()
700
- comments = list(issue.get_comments())
701
- except Exception as exc:
702
- log.debug("Could not read comments for approval notice: %s", exc)
703
- return False
704
-
705
739
  # Newest first: if several carry the marker, keep the most recent.
706
- for comment in reversed(comments):
707
- if APPROVAL_MARKER not in (getattr(comment, "body", "") or ""):
708
- continue
740
+ for comment in _marker_comments_newest_first(pr_obj):
709
741
  try:
710
742
  comment.edit(body)
711
743
  except Exception as exc:
@@ -741,6 +773,74 @@ def _clear_fork_approval_notice(
741
773
  )
742
774
 
743
775
 
776
+ def _record_fork_transfer(
777
+ pr_obj: Any | None, approved_sha: str, *, pushed: bool
778
+ ) -> None:
779
+ """Note on the approval notice that the approved head transferred.
780
+
781
+ Gives a later sweep a way to tell that this head has already gone
782
+ to Gerrit (#419). Only a gated pull request carries an approved
783
+ commit, and only a run that pushed transferred one: a dry run, and
784
+ a pull request reconciled against changes already merged or
785
+ abandoned, both succeed without pushing, and recording either
786
+ would have sweeps skip a head that never reached Gerrit. Called
787
+ after the push, never before, for the same reason.
788
+
789
+ Edits the existing notice without creating one, like the
790
+ retraction. Best-effort: the transfer has happened, and a missing
791
+ record only costs a later sweep a redundant visit.
792
+ """
793
+ if not approved_sha or not pushed or pr_obj is None:
794
+ return
795
+ if env_bool("CI_TESTING", False):
796
+ return
797
+ _edit_owned_marker_comment(
798
+ pr_obj, render_transferred_comment(head_sha=approved_sha)
799
+ )
800
+
801
+
802
+ def _sweep_can_skip(pr_obj: Any | None, gh: GitHubContext) -> bool:
803
+ """Report whether a sweep may pass over an already-transferred PR.
804
+
805
+ True only when the newest approval notice records the pull
806
+ request's current head as transferred (#419). Anything less — no
807
+ notice, no record, an unreadable comment list, a head that has
808
+ moved since — means "process it", so a record that failed to be
809
+ written degrades to visiting the pull request as before rather
810
+ than stranding it.
811
+
812
+ Sweeps alone consult this. A dispatch naming the pull request, a
813
+ push and a comment are each somebody asking for this pull request,
814
+ and must work whatever a comment says.
815
+
816
+ The record is not proof of authorship, and nothing here assumes the
817
+ tool wrote it: anyone may paste one into a comment. Believing a
818
+ forgery is safe because skipping is the harmless direction. It
819
+ transfers nothing and delays only the sweep, which the explicit
820
+ routes above still override. The record is read only for heads the
821
+ approval gate applies to, so a forgery cannot keep a
822
+ same-repository pull request out of a sweep either.
823
+ """
824
+ if gh.head_is_trusted or pr_obj is None:
825
+ return False
826
+ head_sha = str(
827
+ getattr(getattr(pr_obj, "head", None), "sha", "") or ""
828
+ ).strip()
829
+ if not head_sha:
830
+ return False
831
+ notices = _marker_comments_newest_first(pr_obj)
832
+ body = str(getattr(notices[0], "body", "") or "") if notices else ""
833
+ if recorded_transfer(body) != head_sha.lower():
834
+ return False
835
+ log.info(
836
+ "⏩ Pull request #%s already transferred at %s; nothing for a "
837
+ "sweep to add",
838
+ gh.pr_number,
839
+ head_sha[:7],
840
+ )
841
+ return True
842
+
843
+
744
844
  def _post_fork_approval_notice(
745
845
  pr_obj: Any,
746
846
  status: ApprovalStatus,
@@ -2115,6 +2215,10 @@ def _process_bulk_pr(
2115
2215
  log.debug("PR #%d rejected by automation_only check", pr_number)
2116
2216
  return "skipped", None, None
2117
2217
 
2218
+ # A sweep has nothing to add for a head already transferred
2219
+ if _sweep_can_skip(pr, per_ctx):
2220
+ return "skipped", None, None
2221
+
2118
2222
  # Fork PRs need a maintainer's approval before anything is fetched
2119
2223
  allowed, approved_sha = _check_fork_approval(pr, per_ctx, progress_tracker)
2120
2224
  if not allowed:
@@ -2126,9 +2230,13 @@ def _process_bulk_pr(
2126
2230
  if skip_result is not None:
2127
2231
  return skip_result
2128
2232
 
2129
- return _submit_bulk_pr(
2233
+ result = _submit_bulk_pr(
2130
2234
  data, per_ctx, pr_number, progress_tracker, approved_sha
2131
2235
  )
2236
+ status, submission, _exc = result
2237
+ if status == "success" and submission is not None:
2238
+ _record_fork_transfer(pr, approved_sha, pushed=submission.pushed)
2239
+ return result
2132
2240
 
2133
2241
 
2134
2242
  def _record_bulk_success(
@@ -3322,6 +3430,18 @@ def _handle_bulk_mode(
3322
3430
  ):
3323
3431
  return False
3324
3432
 
3433
+ if _is_sweep_leg():
3434
+ # The reusable workflow gave every pull request a leg of its
3435
+ # own (#422); this one keeps only the repository-wide part.
3436
+ log.info(
3437
+ "Bulk sweep fanned out per pull request; running the "
3438
+ "repository-wide cleanup only"
3439
+ )
3440
+ _run_gerrit_cleanup_tasks(
3441
+ data, gh, no_gerrit=no_gerrit, gerrit_cleanup_info_log=True
3442
+ )
3443
+ return True
3444
+
3325
3445
  bulk_success = _process_bulk(data, gh)
3326
3446
 
3327
3447
  try:
@@ -3445,9 +3565,35 @@ def _check_single_pr_duplicates(
3445
3565
  gh.pr_number,
3446
3566
  )
3447
3567
  except DuplicateChangeError as exc:
3568
+ if _is_sweep_leg():
3569
+ _skip_duplicate_in_sweep(exc, gh, progress_tracker)
3448
3570
  _handle_single_pr_duplicate_error(exc, progress_tracker)
3449
3571
 
3450
3572
 
3573
+ def _skip_duplicate_in_sweep(
3574
+ exc: DuplicateChangeError,
3575
+ gh: GitHubContext,
3576
+ progress_tracker: G2GProgressTracker | DummyProgressTracker,
3577
+ ) -> NoReturn:
3578
+ """End a sweep leg cleanly on a blocked duplicate.
3579
+
3580
+ With ``ALLOW_DUPLICATES`` off, the single bulk job skipped a
3581
+ duplicate and carried on; a run naming the pull request fails on
3582
+ one, which is the answer somebody asking for it needs. A leg is a
3583
+ sweep, so it keeps the bulk behaviour rather than marking the
3584
+ whole sweep failed.
3585
+ """
3586
+ progress_tracker.duplicate_skipped()
3587
+ progress_tracker.stop()
3588
+ log.warning(
3589
+ "Skipping PR #%s due to duplicate detection: %s. Use "
3590
+ "--allow-duplicates to override this check.",
3591
+ gh.pr_number,
3592
+ exc,
3593
+ )
3594
+ sys.exit(int(ExitCode.SUCCESS))
3595
+
3596
+
3451
3597
  def _print_single_pr_summary(
3452
3598
  progress_tracker: G2GProgressTracker | DummyProgressTracker,
3453
3599
  pipeline_success: bool,
@@ -3480,6 +3626,62 @@ def _print_single_pr_summary(
3480
3626
  safe_console_print(f"🔗 Gerrit change: {url}", style="green")
3481
3627
 
3482
3628
 
3629
+ def _gate_single_pr(
3630
+ gh: GitHubContext,
3631
+ data: Inputs,
3632
+ progress_tracker: G2GProgressTracker | DummyProgressTracker,
3633
+ ) -> tuple[GitHubContext, Any | None, str]:
3634
+ """Resolve the pull request and run every check ahead of transfer.
3635
+
3636
+ Exits successfully when there is nothing to transfer: a re-check
3637
+ with no gate to lift, a sweep leg whose head already transferred,
3638
+ or a fork pull request still awaiting approval.
3639
+
3640
+ Returns:
3641
+ The context with any metadata recovered from the pull request,
3642
+ the pull request itself, and the commit the approval gate
3643
+ authorised (empty when no gate applied).
3644
+ """
3645
+ pr_obj = _extract_and_display_pr_info(gh, data, progress_tracker)
3646
+
3647
+ # Fork PRs need a maintainer's approval before anything is
3648
+ # fetched and before the Gerrit key is materialised. Resolve
3649
+ # the PR here when the display step could not, so a missing
3650
+ # token cannot skip the gate.
3651
+ if pr_obj is None:
3652
+ pr_obj = _resolve_pr_for_gate(gh, data)
3653
+
3654
+ # Second chance at the pull request's own metadata, and a
3655
+ # second look at the short-circuit above. An issue_comment
3656
+ # payload carries none of it, so the check before this
3657
+ # depended on _augment_pr_refs_if_needed's API call; that
3658
+ # helper swallows a failure and returns the context
3659
+ # unresolved, while the fetch just above is a separate call
3660
+ # that may well have succeeded. Without this, one transient
3661
+ # failure would let a commenter resubmit an unchanged
3662
+ # same-repository pull request — exactly what the
3663
+ # short-circuit exists to prevent — and would leave the base
3664
+ # ref empty for the transfer that followed.
3665
+ gh = _recover_pr_metadata(gh, pr_obj)
3666
+ if _recheck_has_nothing_to_unblock(gh):
3667
+ log.info(
3668
+ "Re-check (%s) for PR #%s, whose head is in this "
3669
+ "repository; nothing to unblock, so no transfer is needed",
3670
+ gh.event_name,
3671
+ gh.pr_number,
3672
+ )
3673
+ sys.exit(int(ExitCode.SUCCESS))
3674
+
3675
+ # A sweep leg has nothing to add for a head already transferred
3676
+ if _is_sweep_leg() and _sweep_can_skip(pr_obj, gh):
3677
+ sys.exit(int(ExitCode.SUCCESS))
3678
+
3679
+ allowed, approved_sha = _check_fork_approval(pr_obj, gh, progress_tracker)
3680
+ if not allowed:
3681
+ sys.exit(int(ExitCode.SUCCESS))
3682
+ return gh, pr_obj, approved_sha
3683
+
3684
+
3483
3685
  def _handle_single_pr(
3484
3686
  data: Inputs,
3485
3687
  gh: GitHubContext,
@@ -3539,42 +3741,9 @@ def _handle_single_pr(
3539
3741
 
3540
3742
  # Display PR information with Rich formatting
3541
3743
  approved_sha = ""
3744
+ pr_obj: Any | None = None
3542
3745
  if gh.pr_number:
3543
- pr_obj = _extract_and_display_pr_info(gh, data, progress_tracker)
3544
-
3545
- # Fork PRs need a maintainer's approval before anything is
3546
- # fetched and before the Gerrit key is materialised. Resolve
3547
- # the PR here when the display step could not, so a missing
3548
- # token cannot skip the gate.
3549
- if pr_obj is None:
3550
- pr_obj = _resolve_pr_for_gate(gh, data)
3551
-
3552
- # Second chance at the pull request's own metadata, and a
3553
- # second look at the short-circuit above. An issue_comment
3554
- # payload carries none of it, so the check before this
3555
- # depended on _augment_pr_refs_if_needed's API call; that
3556
- # helper swallows a failure and returns the context
3557
- # unresolved, while the fetch just above is a separate call
3558
- # that may well have succeeded. Without this, one transient
3559
- # failure would let a commenter resubmit an unchanged
3560
- # same-repository pull request — exactly what the
3561
- # short-circuit exists to prevent — and would leave the base
3562
- # ref empty for the transfer that followed.
3563
- gh = _recover_pr_metadata(gh, pr_obj)
3564
- if _recheck_has_nothing_to_unblock(gh):
3565
- log.info(
3566
- "Re-check (%s) for PR #%s, whose head is in this "
3567
- "repository; nothing to unblock, so no transfer is needed",
3568
- gh.event_name,
3569
- gh.pr_number,
3570
- )
3571
- sys.exit(int(ExitCode.SUCCESS))
3572
-
3573
- allowed, approved_sha = _check_fork_approval(
3574
- pr_obj, gh, progress_tracker
3575
- )
3576
- if not allowed:
3577
- sys.exit(int(ExitCode.SUCCESS))
3746
+ gh, pr_obj, approved_sha = _gate_single_pr(gh, data, progress_tracker)
3578
3747
 
3579
3748
  # Check for duplicates in single-PR mode (before workspace setup)
3580
3749
  if gh.pr_number and not env_bool("SYNC_ALL_OPEN_PRS", False):
@@ -3591,9 +3760,13 @@ def _handle_single_pr(
3591
3760
  )
3592
3761
 
3593
3762
  # Run abandoned-PR and Gerrit cleanup if the pipeline was successful
3594
- # Skip in G2G_NO_GERRIT: no Gerrit server to query
3763
+ # Skip in G2G_NO_GERRIT: no Gerrit server to query. A sweep leg
3764
+ # leaves it to the sweep's cleanup leg, rather than every leg
3765
+ # racing to close and abandon the same things.
3595
3766
  if pipeline_success:
3596
- _run_gerrit_cleanup_tasks(data, gh, no_gerrit=no_gerrit)
3767
+ _record_fork_transfer(pr_obj, approved_sha, pushed=result.pushed)
3768
+ if not _is_sweep_leg():
3769
+ _run_gerrit_cleanup_tasks(data, gh, no_gerrit=no_gerrit)
3597
3770
 
3598
3771
  try:
3599
3772
  log_api_metrics_summary()
@@ -381,6 +381,10 @@ class SubmissionResult:
381
381
  change_numbers: list[str]
382
382
  # Associated patch set commit shas in Gerrit (if available).
383
383
  commit_shas: list[str]
384
+ # Whether this run pushed to Gerrit. A dry run, and a pull request
385
+ # reconciled against changes already merged or abandoned, succeed
386
+ # without pushing, and must not be taken for a transfer.
387
+ pushed: bool = False
384
388
 
385
389
 
386
390
  @dataclass(frozen=True)
@@ -2643,7 +2647,7 @@ class Orchestrator:
2643
2647
 
2644
2648
  log.debug("Pipeline complete: %s", result)
2645
2649
  self._cleanup_ssh()
2646
- return result
2650
+ return dataclass_replace(result, pushed=True)
2647
2651
 
2648
2652
  def _determine_operation_mode(
2649
2653
  self,