github2gerrit 2.3.0__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.3.0 → github2gerrit-2.4.0}/PKG-INFO +12 -2
  2. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/README.md +11 -1
  3. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/action.yaml +20 -4
  4. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/docs/features.md +44 -17
  5. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/models.py +5 -2
  6. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_bulk_fan_out.py +15 -258
  7. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_precommit_mypy_pins.py +7 -0
  8. github2gerrit-2.4.0/tests/test_scheduled_sweep.py +410 -0
  9. github2gerrit-2.4.0/tests/workflow_harness.py +312 -0
  10. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.aislop/config.yml +0 -0
  11. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.editorconfig +0 -0
  12. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.gitignore +0 -0
  13. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.gitlint +0 -0
  14. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.markdownlint.yaml +0 -0
  15. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.pre-commit-config.yaml +0 -0
  16. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/.yamllint +0 -0
  17. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/LICENSE +0 -0
  18. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/LICENSES/Apache-2.0.txt +0 -0
  19. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/REUSE.toml +0 -0
  20. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/SECURITY.md +0 -0
  21. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/docs/COMMIT_RULES.md +0 -0
  22. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/docs/cli.md +0 -0
  23. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/docs/development.md +0 -0
  24. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/docs/github2gerrit_token_permissions_classic.png +0 -0
  25. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/pyproject.toml +0 -0
  26. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/sitecustomize.py +0 -0
  27. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/__init__.py +0 -0
  28. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/approvers.py +0 -0
  29. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/cli.py +0 -0
  30. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/commit_normalization.py +0 -0
  31. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/commit_rules.py +0 -0
  32. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/config.py +0 -0
  33. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/config_ini.py +0 -0
  34. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/constants.py +0 -0
  35. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/core.py +0 -0
  36. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/duplicate_detection.py +0 -0
  37. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/error_codes.py +0 -0
  38. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/external_api.py +0 -0
  39. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_pr_closer.py +0 -0
  40. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_query.py +0 -0
  41. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_rest.py +0 -0
  42. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_ssh.py +0 -0
  43. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gerrit_urls.py +0 -0
  44. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/github_api.py +0 -0
  45. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gitreview.py +0 -0
  46. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/gitutils.py +0 -0
  47. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/mapping_comment.py +0 -0
  48. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/netrc.py +0 -0
  49. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/orchestrator/__init__.py +0 -0
  50. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  51. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/pr_approval.py +0 -0
  52. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/pr_commands.py +0 -0
  53. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/pr_content_filter.py +0 -0
  54. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/pr_directives.py +0 -0
  55. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/project_names.py +0 -0
  56. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/reconcile_matcher.py +0 -0
  57. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/rich_display.py +0 -0
  58. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/rich_logging.py +0 -0
  59. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/similarity.py +0 -0
  60. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/ssh_agent_setup.py +0 -0
  61. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/ssh_common.py +0 -0
  62. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/ssh_config_parser.py +0 -0
  63. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/ssh_discovery.py +0 -0
  64. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/trailers.py +0 -0
  65. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/trust.py +0 -0
  66. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/src/github2gerrit/utils.py +0 -0
  67. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/conftest.py +0 -0
  68. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/fixtures/__init__.py +0 -0
  69. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/fixtures/make_repo.py +0 -0
  70. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/fixtures/ssh_config_samples.py +0 -0
  71. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_action_environment_mapping.py +0 -0
  72. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_action_outputs.py +0 -0
  73. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_action_pr_number_handling.py +0 -0
  74. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_action_step_validation.py +0 -0
  75. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_approver_sources.py +0 -0
  76. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_automation_only.py +0 -0
  77. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_cache_isolation.py +0 -0
  78. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_change_id_deduplication.py +0 -0
  79. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_clean_squash_title.py +0 -0
  80. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_cli.py +0 -0
  81. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_cli_helpers.py +0 -0
  82. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_cli_netrc_options.py +0 -0
  83. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_cli_outputs_file.py +0 -0
  84. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_cli_url_and_dryrun.py +0 -0
  85. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_commit_normalization.py +0 -0
  86. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_commit_rules.py +0 -0
  87. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_composite_action_coverage.py +0 -0
  88. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_config_and_reviewers.py +0 -0
  89. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_config_helpers.py +0 -0
  90. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_close_pr_policy.py +0 -0
  91. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_config_and_errors.py +0 -0
  92. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_gerrit_backref_comment.py +0 -0
  93. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_gerrit_push_errors.py +0 -0
  94. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_gerrit_rest_results.py +0 -0
  95. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_integration_fixture_repo.py +0 -0
  96. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_prepare_commits.py +0 -0
  97. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_shallow_clone.py +0 -0
  98. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_ssh_setup.py +0 -0
  99. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_core_ssrf_protection.py +0 -0
  100. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_dependency_supersession.py +0 -0
  101. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_dns_validation_and_no_gerrit.py +0 -0
  102. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_duplicate_detection.py +0 -0
  103. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_email_case_normalization.py +0 -0
  104. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_error_codes.py +0 -0
  105. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_external_api_framework.py +0 -0
  106. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_final_state_reconciliation.py +0 -0
  107. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_force_flag_cli.py +0 -0
  108. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_fork_approval_gate.py +0 -0
  109. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_fork_pr_hardening.py +0 -0
  110. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_fork_transfer_record.py +0 -0
  111. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_change_id_footer.py +0 -0
  112. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_change_status_checks.py +0 -0
  113. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_pr_closer.py +0 -0
  114. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_rest_client.py +0 -0
  115. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_ssh_abandon.py +0 -0
  116. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_urls.py +0 -0
  117. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gerrit_urls_more.py +0 -0
  118. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ghe_and_gitreview_args.py +0 -0
  119. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_github_api_error_handling.py +0 -0
  120. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_github_api_helpers.py +0 -0
  121. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_github_api_retry_and_helpers.py +0 -0
  122. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gitreview.py +0 -0
  123. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_gitutils_helpers.py +0 -0
  124. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_issue_157_regressions.py +0 -0
  125. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_mapping_comment_additional.py +0 -0
  126. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  127. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_metadata_and_reconciliation.py +0 -0
  128. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_metadata_trailer_separation_bug.py +0 -0
  129. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_misc_small_coverage.py +0 -0
  130. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_netrc.py +0 -0
  131. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_orphan_rest_side_effects.py +0 -0
  132. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_pr_closed_handling.py +0 -0
  133. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_pr_command_authorisation.py +0 -0
  134. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_pr_commands.py +0 -0
  135. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_pr_content_filter.py +0 -0
  136. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_pr_content_filter_integration.py +0 -0
  137. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_pr_update_detection.py +0 -0
  138. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_project_names.py +0 -0
  139. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_reconciliation_extracted_module.py +0 -0
  140. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  141. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_reconciliation_scenarios.py +0 -0
  142. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ssh_agent.py +0 -0
  143. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ssh_agent_ownership.py +0 -0
  144. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ssh_artifact_prevention.py +0 -0
  145. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ssh_common.py +0 -0
  146. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ssh_discovery.py +0 -0
  147. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_ssh_discovery_dry_run.py +0 -0
  148. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_sync_mypy_pins.py +0 -0
  149. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_trailers_additional.py +0 -0
  150. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_url_parser.py +0 -0
  151. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/test_utils.py +0 -0
  152. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/unit/test_config_integration.py +0 -0
  153. {github2gerrit-2.3.0 → github2gerrit-2.4.0}/tests/unit/test_ssh_config_parser.py +0 -0
  154. {github2gerrit-2.3.0 → 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.3.0
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,6 +192,12 @@ 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.
@@ -263,7 +272,8 @@ A composite action cannot start jobs, so here a bulk `workflow_dispatch`
263
272
  (`PR_NUMBER` of `0`) still processes every open pull request in one job. That
264
273
  job does not serialise against the per-pull-request runs events start, so
265
274
  avoid triggering one while pull request activity is in flight, or use the
266
- reusable workflow, which fans the sweep out.
275
+ reusable workflow, which fans the sweep out. For the same reason a `schedule`
276
+ trigger works only through the reusable workflow.
267
277
 
268
278
  ### Option C: command-line tool
269
279
 
@@ -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,6 +145,12 @@ 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.
@@ -216,7 +225,8 @@ A composite action cannot start jobs, so here a bulk `workflow_dispatch`
216
225
  (`PR_NUMBER` of `0`) still processes every open pull request in one job. That
217
226
  job does not serialise against the per-pull-request runs events start, so
218
227
  avoid triggering one while pull request activity is in flight, or use the
219
- reusable workflow, which fans the sweep out.
228
+ reusable workflow, which fans the sweep out. For the same reason a `schedule`
229
+ trigger works only through the reusable workflow.
220
230
 
221
231
  ### Option C: command-line tool
222
232
 
@@ -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"
@@ -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,13 +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
- It can build on two pieces that bulk dispatch now uses: a job per pull
538
- request, so each transfer takes that pull request's own lock, and the
539
- transfer record described below, so an already-transferred head gets no
540
- second visit.
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.
541
567
 
542
568
  `pull_request_review` should be **removed** from any workflow that still
543
569
  carries it. It can no longer transfer anything: on a fork pull request it runs
@@ -598,15 +624,16 @@ transferred pull request does not keep displaying a stale block. After the
598
624
  transfer succeeds it edits the comment once more to say so, recording the
599
625
  transferred commit in a hidden marker.
600
626
 
601
- A bulk sweep (`PR_NUMBER` of `0`) reads that marker and passes over a pull
602
- request whose current head it names, rather than submitting the same commit
603
- again. Through the reusable workflow each pull request in a sweep runs as a
604
- job of its own, and the marker behaves the same there. Anything less means the sweep processes the pull request as before:
605
- no comment, no marker, a comment it cannot read, or a head that has moved
606
- since. Only a run that pushes records anything: a dry run does not, nor does
607
- one that finds the pull request's changes already merged or abandoned and
608
- acts on GitHub instead. Neither does a pull request approved before its
609
- first run, which never had a comment to edit. Only sweeps consult
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
610
637
  the marker. A dispatch naming the pull request, a push and a
611
638
  `@github2gerrit check` comment transfer whatever it says.
612
639
 
@@ -27,18 +27,21 @@ RECHECK_EVENTS: frozenset[str] = frozenset(
27
27
  {
28
28
  "issue_comment",
29
29
  "pull_request_review",
30
+ "schedule",
30
31
  }
31
32
  )
32
33
  """Events that ask the tool to look at a pull request again.
33
34
 
34
- Neither changes the pull request's code, so each maps to UPDATE: an
35
+ None changes the pull request's code, so each maps to UPDATE: an
35
36
  existing Gerrit change should gain a patchset rather than a sibling.
36
37
 
37
38
  They exist because the fork approval gate needs a **privileged** run to
38
39
  notice that a maintainer has approved. GitHub withholds secrets from
39
40
  ``pull_request_review`` on a fork pull request, so the approval itself
40
41
  cannot be the run that acts on it (see
41
- :func:`github2gerrit.cli._skip_unprivileged_fork_run`).
42
+ :func:`github2gerrit.cli._skip_unprivileged_fork_run`). A comment
43
+ asks for the re-check; a schedule makes it without anyone asking, one
44
+ pull request per job of the reusable workflow's sweep (#421).
42
45
 
43
46
  The important property is that these are *triggers only*. They decide
44
47
  when to re-evaluate, never whether to proceed: authorisation is always
@@ -18,12 +18,9 @@ from __future__ import annotations
18
18
 
19
19
  import copy
20
20
  import json
21
- import math
22
- import os
23
- import re
24
21
  import shutil
25
22
  import subprocess
26
- from collections.abc import Callable
23
+ from collections.abc import Sequence
27
24
  from pathlib import Path
28
25
  from typing import Any
29
26
  from unittest.mock import MagicMock
@@ -31,7 +28,15 @@ from unittest.mock import patch
31
28
 
32
29
  import pytest
33
30
  import typer
34
- import yaml
31
+ from workflow_harness import REPO
32
+ from workflow_harness import Page
33
+ from workflow_harness import evaluate as _evaluate
34
+ from workflow_harness import load_jobs
35
+ from workflow_harness import matrix_of
36
+ from workflow_harness import render as _render
37
+ from workflow_harness import request_of
38
+ from workflow_harness import run_enumeration
39
+ from workflow_harness import truthy as _truthy
35
40
 
36
41
  from github2gerrit.cli import _check_automation_only
37
42
  from github2gerrit.cli import _check_single_pr_duplicates
@@ -45,13 +50,6 @@ from github2gerrit.models import PROperationMode
45
50
  from github2gerrit.pr_approval import render_transferred_comment
46
51
 
47
52
 
48
- WORKFLOW = (
49
- Path(__file__).parent.parent
50
- / ".github"
51
- / "workflows"
52
- / "github2gerrit.yaml"
53
- )
54
- REPO = "opendaylight/mdsal"
55
53
  HEAD_SHA = "0b2abdcf7bb2fb5ed6620f214968ae2b3c5e70e6"
56
54
  ACTION_STEP = "Run github2gerrit composite action"
57
55
  LEG_ONLY = {
@@ -62,172 +60,6 @@ LEG_ONLY = {
62
60
  """The action-step keys that make a sweep leg, and all that may differ."""
63
61
 
64
62
 
65
- # ---------------------------------------------------------------------
66
- # A small evaluator for the subset of GitHub expressions the workflow's
67
- # conditions and concurrency groups use. Comparing the expressions as
68
- # strings could not tell whether two groups actually render the same.
69
- # ---------------------------------------------------------------------
70
-
71
- _TOKEN = re.compile(
72
- r"\s*(?:(?P<op>\|\||&&|==|!=|!|\(|\)|,)"
73
- r"|'(?P<str>(?:[^']|'')*)'"
74
- r"|(?P<num>\d+)"
75
- r"|(?P<name>[A-Za-z_][\w-]*(?:\.[\w-]+)*))"
76
- )
77
- _TEMPLATE = re.compile(r"\$\{\{(.*?)\}\}", re.DOTALL)
78
-
79
-
80
- def _truthy(value: Any) -> bool:
81
- if isinstance(value, float) and math.isnan(value):
82
- return False
83
- return value not in (None, False, 0, "")
84
-
85
-
86
- def _as_number(value: Any) -> float:
87
- if value is None:
88
- return 0.0
89
- if isinstance(value, bool):
90
- return float(value)
91
- if isinstance(value, (int, float)):
92
- return float(value)
93
- try:
94
- return float(str(value).strip() or 0)
95
- except ValueError:
96
- return math.nan
97
-
98
-
99
- def _equal(left: Any, right: Any) -> bool:
100
- # GitHub compares strings case-insensitively and coerces mixed types
101
- # to numbers.
102
- if isinstance(left, str) and isinstance(right, str):
103
- return left.lower() == right.lower()
104
- if type(left) is type(right):
105
- return bool(left == right)
106
- return _as_number(left) == _as_number(right)
107
-
108
-
109
- def _contains(haystack: Any, needle: Any) -> bool:
110
- if isinstance(haystack, list):
111
- return any(_equal(item, needle) for item in haystack)
112
- return str(needle or "").lower() in str(haystack or "").lower()
113
-
114
-
115
- def _format(template: str, *args: Any) -> str:
116
- return re.sub(
117
- r"\{(\d+)\}", lambda m: _render_value(args[int(m.group(1))]), template
118
- )
119
-
120
-
121
- _FUNCTIONS: dict[str, Callable[..., Any]] = {
122
- "contains": _contains,
123
- "format": _format,
124
- "fromJSON": json.loads,
125
- }
126
-
127
-
128
- class _Expression:
129
- """Recursive descent over ``||``, ``&&``, ``==``/``!=``, ``!``."""
130
-
131
- def __init__(self, text: str, context: dict[str, Any]) -> None:
132
- self.tokens = [m for m in _TOKEN.finditer(text) if m.group().strip()]
133
- assert "".join(m.group() for m in self.tokens).strip() == text.strip()
134
- self.pos = 0
135
- self.context = context
136
-
137
- def evaluate(self) -> Any:
138
- value = self._or()
139
- assert self.pos == len(self.tokens), "trailing tokens"
140
- return value
141
-
142
- def _peek(self, op: str) -> bool:
143
- return (
144
- self.pos < len(self.tokens)
145
- and self.tokens[self.pos].group("op") == op
146
- )
147
-
148
- def _take(self, op: str) -> None:
149
- assert self._peek(op), f"expected {op!r}"
150
- self.pos += 1
151
-
152
- def _or(self) -> Any:
153
- value = self._and()
154
- while self._peek("||"):
155
- self._take("||")
156
- right = self._and()
157
- value = value if _truthy(value) else right
158
- return value
159
-
160
- def _and(self) -> Any:
161
- value = self._compare()
162
- while self._peek("&&"):
163
- self._take("&&")
164
- right = self._compare()
165
- value = right if _truthy(value) else value
166
- return value
167
-
168
- def _compare(self) -> Any:
169
- value = self._unary()
170
- while self._peek("==") or self._peek("!="):
171
- negate = self._peek("!=")
172
- self.pos += 1
173
- right = self._unary()
174
- value = _equal(value, right) != negate
175
- return value
176
-
177
- def _unary(self) -> Any:
178
- if self._peek("!"):
179
- self._take("!")
180
- return not _truthy(self._unary())
181
- return self._primary()
182
-
183
- def _primary(self) -> Any:
184
- if self._peek("("):
185
- self._take("(")
186
- value = self._or()
187
- self._take(")")
188
- return value
189
- token = self.tokens[self.pos]
190
- self.pos += 1
191
- if token.group("str") is not None:
192
- return token.group("str").replace("''", "'")
193
- if token.group("num") is not None:
194
- return int(token.group("num"))
195
- name = token.group("name")
196
- if self._peek("("):
197
- return _FUNCTIONS[name](*self._arguments())
198
- literals = {"true": True, "false": False, "null": None}
199
- return literals.get(name, self.context.get(name))
200
-
201
- def _arguments(self) -> list[Any]:
202
- self._take("(")
203
- args = [self._or()]
204
- while self._peek(","):
205
- self._take(",")
206
- args.append(self._or())
207
- self._take(")")
208
- return args
209
-
210
-
211
- def _evaluate(expression: str, context: dict[str, Any]) -> Any:
212
- match = _TEMPLATE.fullmatch(expression.strip())
213
- inner = match.group(1) if match else expression
214
- return _Expression(inner, {"github.repository": REPO, **context}).evaluate()
215
-
216
-
217
- def _render_value(value: Any) -> str:
218
- if value is None:
219
- return ""
220
- if isinstance(value, bool):
221
- return "true" if value else "false"
222
- return str(value)
223
-
224
-
225
- def _render(template: str, context: dict[str, Any]) -> str:
226
- return _TEMPLATE.sub(
227
- lambda m: _render_value(_evaluate(m.group(1), context)), template
228
- )
229
-
230
-
231
63
  class TestTheEvaluatorItself:
232
64
  """Enough confidence in the evaluator for the tests built on it."""
233
65
 
@@ -257,7 +89,7 @@ class TestTheEvaluatorItself:
257
89
 
258
90
  @pytest.fixture(scope="module")
259
91
  def jobs() -> dict[str, Any]:
260
- return dict(yaml.safe_load(WORKFLOW.read_text())["jobs"])
92
+ return load_jobs()
261
93
 
262
94
 
263
95
  def _dispatch(pr_number: str, change_url: str = "") -> dict[str, Any]:
@@ -465,97 +297,22 @@ class TestTheLegIsTheSingleJob:
465
297
  # The enumeration script, executed out of the workflow
466
298
  # ---------------------------------------------------------------------
467
299
 
468
- _CURL_STUB = r"""#!/usr/bin/env bash
469
- # Stands in for the GraphQL endpoint. Cursors here are page numbers;
470
- # the real ones are opaque, which the script must not rely on either way.
471
- python3 -c 'import json, sys; print(json.dumps(sys.argv[1:]))' "$@" \
472
- >> "${CURL_LOG}"
473
- args=("$@")
474
- data=''
475
- for ((i = 0; i < ${#args[@]}; i++)); do
476
- if [[ "${args[i]}" == "--data" ]]; then data="${args[i + 1]}"; fi
477
- done
478
- if [[ -n "${CURL_FAIL:-}" ]]; then
479
- echo '{"message": "Bad credentials"}'
480
- exit 22
481
- fi
482
- if [[ -n "${GRAPHQL_ERRORS:-}" ]]; then
483
- echo '{"data": null, "errors": [{"message": "Resource not accessible"}]}'
484
- exit 0
485
- fi
486
- page=$(( $(jq -r '.variables.after // "0"' <<< "${data}") + 1 ))
487
- pages=$(find "${CURL_PAGES}" -name '*.json' | wc -l)
488
- if (( page < pages )); then next=true; else next=false; fi
489
- jq -nc --slurpfile nodes "${CURL_PAGES}/${page}.json" \
490
- --argjson next "${next}" --arg cursor "${page}" \
491
- '{data: {repository: {pullRequests: {nodes: $nodes[0],
492
- pageInfo: {hasNextPage: $next, endCursor: $cursor}}}}}'
493
- """
494
-
495
300
 
496
301
  @pytest.mark.skipif(shutil.which("jq") is None, reason="needs jq")
497
302
  class TestTheEnumeration:
498
303
  """The step that builds the sweep's matrix."""
499
304
 
500
- def _script(self, jobs: dict[str, Any]) -> str:
501
- return str(jobs["enumerate"]["steps"][0]["run"])
502
-
503
305
  def _run(
504
306
  self,
505
307
  jobs: dict[str, Any],
506
308
  tmp_path: Path,
507
- pages: list[list[int]],
309
+ pages: Sequence[Page],
508
310
  **env: str,
509
311
  ) -> tuple[subprocess.CompletedProcess[str], str, list[list[str]]]:
510
- stub_dir = tmp_path / "bin"
511
- stub_dir.mkdir()
512
- curl = stub_dir / "curl"
513
- curl.write_text(_CURL_STUB)
514
- curl.chmod(0o755)
515
- page_dir = tmp_path / "pages"
516
- page_dir.mkdir()
517
- for index, numbers in enumerate(pages, start=1):
518
- (page_dir / f"{index}.json").write_text(
519
- json.dumps([{"number": n} for n in numbers])
520
- )
521
- output = tmp_path / "github_output"
522
- output.touch()
523
- log = tmp_path / "curl.log"
524
- log.touch()
525
- result = subprocess.run(
526
- ["bash", "-c", self._script(jobs)],
527
- capture_output=True,
528
- text=True,
529
- check=False,
530
- # A cursor that never advances must fail the test, not hang it
531
- timeout=60,
532
- env={
533
- **os.environ,
534
- "PATH": f"{stub_dir}{os.pathsep}{os.environ['PATH']}",
535
- "GITHUB_TOKEN": "t0ken",
536
- "GITHUB_GRAPHQL_URL": "https://api.github.example/graphql",
537
- "GITHUB_REPOSITORY": REPO,
538
- "GITHUB_OUTPUT": str(output),
539
- "G2G_DISABLED": "",
540
- "CURL_PAGES": str(page_dir),
541
- "CURL_LOG": str(log),
542
- **env,
543
- },
544
- )
545
- calls = [json.loads(line) for line in log.read_text().splitlines()]
546
- return result, output.read_text(), calls
547
-
548
- @staticmethod
549
- def _matrix(output: str) -> str:
550
- return next(
551
- line.split("=", 1)[1]
552
- for line in output.splitlines()
553
- if line.startswith("matrix=")
554
- )
312
+ return run_enumeration(jobs, tmp_path, pages, **env)
555
313
 
556
- @staticmethod
557
- def _request(call: list[str]) -> dict[str, Any]:
558
- return dict(json.loads(call[call.index("--data") + 1]))
314
+ _matrix = staticmethod(matrix_of)
315
+ _request = staticmethod(request_of)
559
316
 
560
317
  def test_a_leg_per_pull_request_and_one_cleanup(
561
318
  self, jobs: dict[str, Any], tmp_path: Path
@@ -195,6 +195,13 @@ def _third_party_imports() -> set[str]:
195
195
  import sys
196
196
 
197
197
  first_party = {"github2gerrit", "tests", "fixtures", "conftest"}
198
+ # Shared helpers beside the tests import as top-level modules
199
+ # (pytest puts tests/ on sys.path), so each is first-party too.
200
+ first_party |= {
201
+ Path(relative).stem
202
+ for relative in _tracked_python_files()
203
+ if Path(relative).parent == Path("tests")
204
+ }
198
205
  names: set[str] = set()
199
206
  for relative in _tracked_python_files():
200
207
  if not _scan_selects(relative):