github2gerrit 2.0.0__tar.gz → 2.1.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 (141) hide show
  1. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.pre-commit-config.yaml +3 -3
  2. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/PKG-INFO +7 -1
  3. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/README.md +6 -0
  4. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/docs/cli.md +1 -0
  5. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/docs/features.md +132 -3
  6. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/cli.py +263 -9
  7. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/config.py +1 -0
  8. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/core.py +135 -21
  9. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/github_api.py +109 -0
  10. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/models.py +16 -4
  11. github2gerrit-2.1.0/src/github2gerrit/pr_approval.py +300 -0
  12. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/pr_commands.py +79 -15
  13. github2gerrit-2.1.0/src/github2gerrit/pr_directives.py +115 -0
  14. github2gerrit-2.1.0/src/github2gerrit/trust.py +122 -0
  15. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_cli_outputs_file.py +12 -3
  16. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_cli_url_and_dryrun.py +1 -1
  17. github2gerrit-2.1.0/tests/test_fork_approval_gate.py +679 -0
  18. github2gerrit-2.1.0/tests/test_pr_command_authorisation.py +357 -0
  19. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_pr_commands.py +13 -6
  20. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.aislop/config.yml +0 -0
  21. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.editorconfig +0 -0
  22. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.gitignore +0 -0
  23. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.gitlint +0 -0
  24. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.markdownlint.yaml +0 -0
  25. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/.yamllint +0 -0
  26. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/LICENSE +0 -0
  27. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/LICENSES/Apache-2.0.txt +0 -0
  28. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/REUSE.toml +0 -0
  29. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/SECURITY.md +0 -0
  30. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/action.yaml +0 -0
  31. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/docs/COMMIT_RULES.md +0 -0
  32. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/docs/development.md +0 -0
  33. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/docs/github2gerrit_token_permissions_classic.png +0 -0
  34. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/pyproject.toml +0 -0
  35. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/sitecustomize.py +0 -0
  36. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/__init__.py +0 -0
  37. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/commit_normalization.py +0 -0
  38. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/commit_rules.py +0 -0
  39. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/constants.py +0 -0
  40. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/duplicate_detection.py +0 -0
  41. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/error_codes.py +0 -0
  42. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/external_api.py +0 -0
  43. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gerrit_pr_closer.py +0 -0
  44. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gerrit_query.py +0 -0
  45. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gerrit_rest.py +0 -0
  46. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gerrit_ssh.py +0 -0
  47. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gerrit_urls.py +0 -0
  48. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gitreview.py +0 -0
  49. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/gitutils.py +0 -0
  50. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/mapping_comment.py +0 -0
  51. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/netrc.py +0 -0
  52. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/orchestrator/__init__.py +0 -0
  53. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  54. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/pr_content_filter.py +0 -0
  55. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/reconcile_matcher.py +0 -0
  56. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/rich_display.py +0 -0
  57. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/rich_logging.py +0 -0
  58. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/similarity.py +0 -0
  59. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/ssh_agent_setup.py +0 -0
  60. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/ssh_common.py +0 -0
  61. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/ssh_config_parser.py +0 -0
  62. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/ssh_discovery.py +0 -0
  63. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/trailers.py +0 -0
  64. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/src/github2gerrit/utils.py +0 -0
  65. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/conftest.py +0 -0
  66. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/fixtures/__init__.py +0 -0
  67. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/fixtures/make_repo.py +0 -0
  68. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/fixtures/ssh_config_samples.py +0 -0
  69. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_action_environment_mapping.py +0 -0
  70. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_action_outputs.py +0 -0
  71. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_action_pr_number_handling.py +0 -0
  72. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_action_step_validation.py +0 -0
  73. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_automation_only.py +0 -0
  74. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_change_id_deduplication.py +0 -0
  75. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_clean_squash_title.py +0 -0
  76. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_cli.py +0 -0
  77. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_cli_helpers.py +0 -0
  78. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_cli_netrc_options.py +0 -0
  79. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_commit_normalization.py +0 -0
  80. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_commit_rules.py +0 -0
  81. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_composite_action_coverage.py +0 -0
  82. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_config_and_reviewers.py +0 -0
  83. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_config_helpers.py +0 -0
  84. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_close_pr_policy.py +0 -0
  85. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_config_and_errors.py +0 -0
  86. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_gerrit_backref_comment.py +0 -0
  87. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_gerrit_push_errors.py +0 -0
  88. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_gerrit_rest_results.py +0 -0
  89. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_integration_fixture_repo.py +0 -0
  90. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_prepare_commits.py +0 -0
  91. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_shallow_clone.py +0 -0
  92. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_ssh_setup.py +0 -0
  93. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_core_ssrf_protection.py +0 -0
  94. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_dependency_supersession.py +0 -0
  95. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_dns_validation_and_no_gerrit.py +0 -0
  96. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_duplicate_detection.py +0 -0
  97. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_email_case_normalization.py +0 -0
  98. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_error_codes.py +0 -0
  99. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_external_api_framework.py +0 -0
  100. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_final_state_reconciliation.py +0 -0
  101. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_force_flag_cli.py +0 -0
  102. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_fork_pr_hardening.py +0 -0
  103. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_change_id_footer.py +0 -0
  104. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_change_status_checks.py +0 -0
  105. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_pr_closer.py +0 -0
  106. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_rest_client.py +0 -0
  107. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_ssh_abandon.py +0 -0
  108. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_urls.py +0 -0
  109. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gerrit_urls_more.py +0 -0
  110. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ghe_and_gitreview_args.py +0 -0
  111. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_github_api_error_handling.py +0 -0
  112. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_github_api_helpers.py +0 -0
  113. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_github_api_retry_and_helpers.py +0 -0
  114. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gitreview.py +0 -0
  115. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_gitutils_helpers.py +0 -0
  116. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_issue_157_regressions.py +0 -0
  117. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_mapping_comment_additional.py +0 -0
  118. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  119. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_metadata_and_reconciliation.py +0 -0
  120. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_metadata_trailer_separation_bug.py +0 -0
  121. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_misc_small_coverage.py +0 -0
  122. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_netrc.py +0 -0
  123. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_orphan_rest_side_effects.py +0 -0
  124. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_pr_content_filter.py +0 -0
  125. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_pr_content_filter_integration.py +0 -0
  126. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_pr_update_detection.py +0 -0
  127. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_reconciliation_extracted_module.py +0 -0
  128. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  129. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_reconciliation_scenarios.py +0 -0
  130. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ssh_agent.py +0 -0
  131. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ssh_agent_ownership.py +0 -0
  132. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ssh_artifact_prevention.py +0 -0
  133. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ssh_common.py +0 -0
  134. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ssh_discovery.py +0 -0
  135. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_ssh_discovery_dry_run.py +0 -0
  136. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_trailers_additional.py +0 -0
  137. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_url_parser.py +0 -0
  138. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/test_utils.py +0 -0
  139. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/unit/test_config_integration.py +0 -0
  140. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/tests/unit/test_ssh_config_parser.py +0 -0
  141. {github2gerrit-2.0.0 → github2gerrit-2.1.0}/uv.lock +0 -0
@@ -64,7 +64,7 @@ repos:
64
64
  types: [yaml]
65
65
 
66
66
  - repo: https://github.com/astral-sh/ruff-pre-commit
67
- rev: 65dbdb59d2f2d9c3bdc343c566821ad3319ddaa3 # frozen: v0.16.3
67
+ rev: aab412d509121cb5f7533134b7e67f9fab59c682 # frozen: v0.16.4
68
68
  hooks:
69
69
  - id: ruff
70
70
  files: ^(src|scripts|tests)/.+\.py$
@@ -89,7 +89,7 @@ repos:
89
89
  args: ["--no-adverb"]
90
90
 
91
91
  - repo: https://github.com/shellcheck-py/shellcheck-py
92
- rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1
92
+ rev: a6564f5d57444e123433a5c9af733e3975c77229 # frozen: v0.11.0.1-1
93
93
  hooks:
94
94
  - id: shellcheck
95
95
 
@@ -126,7 +126,7 @@ repos:
126
126
  files: ^src/.+\.py$
127
127
 
128
128
  - repo: https://github.com/lfreleng-actions/gha-workflow-linter
129
- rev: 7f11ad46be1cd2b3d778428d40fcb09309bb3be5 # frozen: v1.4.1
129
+ rev: 42aa4db931ab4c9da0930ae5ca2259ab08da79cb # frozen: v1.5.2
130
130
  hooks:
131
131
  - id: gha-workflow-linter
132
132
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: github2gerrit
3
- Version: 2.0.0
3
+ Version: 2.1.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
@@ -139,6 +139,9 @@ name: github2gerrit
139
139
  on:
140
140
  pull_request_target:
141
141
  types: [opened, reopened, edited, synchronize, closed]
142
+ # Re-runs when a maintainer approves a fork pull request
143
+ pull_request_review:
144
+ types: [submitted, dismissed]
142
145
  push:
143
146
  branches: [main, master]
144
147
  workflow_dispatch:
@@ -186,6 +189,9 @@ name: github2gerrit
186
189
  on:
187
190
  pull_request_target:
188
191
  types: [opened, reopened, edited, synchronize, closed]
192
+ # Re-runs when a maintainer approves a fork pull request
193
+ pull_request_review:
194
+ types: [submitted, dismissed]
189
195
  workflow_dispatch:
190
196
 
191
197
  permissions:
@@ -93,6 +93,9 @@ name: github2gerrit
93
93
  on:
94
94
  pull_request_target:
95
95
  types: [opened, reopened, edited, synchronize, closed]
96
+ # Re-runs when a maintainer approves a fork pull request
97
+ pull_request_review:
98
+ types: [submitted, dismissed]
96
99
  push:
97
100
  branches: [main, master]
98
101
  workflow_dispatch:
@@ -140,6 +143,9 @@ name: github2gerrit
140
143
  on:
141
144
  pull_request_target:
142
145
  types: [opened, reopened, edited, synchronize, closed]
146
+ # Re-runs when a maintainer approves a fork pull request
147
+ pull_request_review:
148
+ types: [submitted, dismissed]
143
149
  workflow_dispatch:
144
150
 
145
151
  permissions:
@@ -165,6 +165,7 @@ Some settings have no CLI flag and use environment variables:
165
165
  | `G2G_LOG_LEVEL` | `WARNING` | Logging level; set `DEBUG` for verbose output |
166
166
  | `G2G_TOPIC_PREFIX` | `GH` | Prefix used when generating Gerrit topics |
167
167
  | `G2G_SKIP_GERRIT_COMMENTS` | `false` | Skip posting back-reference comments on Gerrit changes |
168
+ | `G2G_TRUSTED_ASSOCIATIONS` | `OWNER,MEMBER,COLLABORATOR` | Author associations trusted to issue `@github2gerrit` comment directives |
168
169
  | `G2G_ENABLE_DERIVATION` | `true` | Enable automatic derivation of Gerrit parameters from organization defaults |
169
170
  | `G2G_AUTO_SAVE_CONFIG` | context-dependent | Save derived parameters back to the configuration file |
170
171
  | `G2G_RESPECT_USER_SSH` | `false` (`true` in direct URL mode) | Use the local user's SSH configuration and keys instead of provided key material |
@@ -102,6 +102,57 @@ acts on it during the next workflow run.
102
102
  - When the same command appears in more than one comment, only the latest
103
103
  occurrence takes effect.
104
104
  - The tool logs unrecognized directives at debug level and ignores them.
105
+ - Only comments from trusted authors count. See below.
106
+
107
+ ### Who May Issue Commands
108
+
109
+ These repositories are public, so the tool checks who wrote a comment before
110
+ acting on it. Without that check, any GitHub user able to leave a comment
111
+ could direct the tool.
112
+
113
+ A comment counts only when GitHub reports its author's `author_association`
114
+ as one of:
115
+
116
+ | Association | Meaning |
117
+ | -------------- | ------------------------------------------ |
118
+ | `OWNER` | Owns the repository |
119
+ | `MEMBER` | Member of the owning organization |
120
+ | `COLLABORATOR` | Invited collaborator on the repository |
121
+
122
+ `CONTRIBUTOR` is **not** trusted. It means only that the author has had a
123
+ pull request merged at some point, which any outside contributor can achieve
124
+ and which carries no authority.
125
+
126
+ The tool ignores directives from anyone else and logs a warning naming the
127
+ author and their association, so a refused command is visible rather than
128
+ silently dropped.
129
+
130
+ #### For contributors adding commands
131
+
132
+ Adding a `CommandDefinition` to the registry gives the new command the same
133
+ authorisation as the existing one, so long as consumers reach it through
134
+ `github2gerrit.pr_directives`, which fetches comments, discards untrusted
135
+ authors and parses in a single step.
136
+
137
+ The parsing module performs no authorisation of its own. Calling
138
+ `parse_commands`, `find_command` or `has_command` with comments taken straight
139
+ from the GitHub API skips the check and recreates the original defect.
140
+
141
+ Override the trusted set with `G2G_TRUSTED_ASSOCIATIONS`, a comma-separated
142
+ list. Setting it to `OWNER` alone is the strictest useful value:
143
+
144
+ ```yaml
145
+ env:
146
+ G2G_TRUSTED_ASSOCIATIONS: "OWNER,MEMBER"
147
+ ```
148
+
149
+ An empty or blank value keeps the default rather than trusting nobody or
150
+ everyone.
151
+
152
+ Note that `MEMBER` means organization member, which is not the same as write
153
+ access to a given repository. On a Gerrit mirror that remains the only
154
+ available signal, because the GitHub collaborator list holds infrastructure
155
+ accounts rather than the project's reviewers.
105
156
 
106
157
  ### Available Commands
107
158
 
@@ -349,9 +400,87 @@ Same-repository pull requests keep their previous behaviour and still read
349
400
 
350
401
  ### Approval gating
351
402
 
352
- The tool does not yet require a maintainer approval before it transfers a fork
353
- pull request to Gerrit. Until it does, use `AUTOMATION_ONLY` (see above) to
354
- control which pull requests the tool accepts.
403
+ A fork pull request does not transfer to Gerrit until a maintainer approves it.
404
+ The check runs before the tool fetches anything from the pull request and before
405
+ it unlocks the Gerrit SSH key, so a pull request awaiting review reaches
406
+ neither.
407
+
408
+ Same-repository pull requests skip the check entirely. Pushing a branch to the
409
+ base repository already requires write access, and automation such as
410
+ Dependabot, pre-commit.ci and Copilot works that way, so the gate never affects
411
+ them.
412
+
413
+ #### What counts as approval
414
+
415
+ An approving review, where every one of the following holds:
416
+
417
+ - the reviewer's `author_association` falls in the trusted set, the same one
418
+ used for [comment commands](#who-may-issue-commands);
419
+ - the review targets the pull request's **current head commit**;
420
+ - no trusted reviewer has since requested changes; and
421
+ - the reviewer is not the pull request author.
422
+
423
+ A pull request whose provenance the tool cannot establish takes the same path as
424
+ a fork. Approval is a question about authority, and an absent signal is not an
425
+ answer.
426
+
427
+ The gate applies to unattended runs. Running the CLI directly against a pull
428
+ request URL does not require an approval: the operator chose that pull request
429
+ and is using their own credentials, so they are already the authority the gate
430
+ looks for. It exists for the automated path, where the tool acts on a shared
431
+ identity with nobody watching.
432
+
433
+ Binding to the head commit matters. GitHub keeps approvals across pushes unless
434
+ branch protection dismisses them, so an approval that was not checked against a
435
+ commit would let a contributor gain approval for one revision and then push a
436
+ different one. The cost is that maintainers re-approve after each push, which is
437
+ the correct trade.
438
+
439
+ The author exclusion is belt and braces. GitHub already rejects approving your
440
+ own pull request, and that guarantee is a large part of why the tool uses
441
+ reviews rather than comment directives — on a Gerrit mirror the pull request
442
+ author often holds the same organization membership as the reviewers.
443
+
444
+ #### Enabling re-runs on approval
445
+
446
+ `pull_request_target` carries no event for a submitted review, so approving a
447
+ pull request does nothing unless the calling workflow listens for it:
448
+
449
+ ```yaml
450
+ on:
451
+ pull_request_target:
452
+ types: [opened, reopened, edited, synchronize, closed]
453
+ pull_request_review:
454
+ types: [submitted, dismissed]
455
+ ```
456
+
457
+ Without that trigger the tool only reconsiders a pull request on its next
458
+ push, or when a maintainer dispatches the workflow by hand.
459
+
460
+ #### When the gate blocks
461
+
462
+ The run finishes successfully rather than failing. A pull request waiting for a
463
+ human is not broken, and a red check would suggest otherwise.
464
+
465
+ The tool posts one comment explaining what is missing, and edits that same
466
+ comment on later runs rather than adding another. Where an approval exists but
467
+ covers an earlier commit, the comment says so, so a maintainer who did approve
468
+ is not told that nobody has.
469
+
470
+ Once approval arrives, the tool edits that comment again to record it, so a
471
+ transferred pull request does not keep displaying a stale block.
472
+
473
+ #### If the head moves mid-run
474
+
475
+ The gate reads the head commit from the API, while the workspace fetch reads
476
+ `refs/pull/<N>/head`, which the contributor can move meanwhile. The tool
477
+ compares the commit it fetched against the commit the maintainer approved and
478
+ refuses a mismatch, so a push timed against a running workflow cannot slip an
479
+ unreviewed commit into Gerrit.
480
+
481
+ That refusal fails the run, unlike the ordinary blocked case, because it falls
482
+ outside the normal course of events. The push that caused it triggers a fresh
483
+ run, which asks for approval of the new commit in the usual way.
355
484
 
356
485
  ## Duplicate Detection
357
486
 
@@ -69,6 +69,7 @@ from .gerrit_pr_closer import extract_pr_url_from_gerrit_change
69
69
  from .gerrit_pr_closer import parse_pr_url
70
70
  from .gerrit_pr_closer import process_recent_commits_for_pr_closure
71
71
  from .github_api import build_client
72
+ from .github_api import create_pr_comment
72
73
  from .github_api import get_pr_title_body
73
74
  from .github_api import get_pull
74
75
  from .github_api import get_repo_from_env
@@ -80,6 +81,11 @@ from .models import GitHubContext
80
81
  from .models import Inputs
81
82
  from .netrc import NetrcParseError
82
83
  from .netrc import get_credentials_for_host
84
+ from .pr_approval import APPROVAL_MARKER
85
+ from .pr_approval import ApprovalStatus
86
+ from .pr_approval import evaluate_fork_approval
87
+ from .pr_approval import render_blocked_comment
88
+ from .pr_approval import render_cleared_comment
83
89
  from .rich_display import RICH_AVAILABLE
84
90
  from .rich_display import DummyProgressTracker
85
91
  from .rich_display import G2GProgressTracker
@@ -87,6 +93,7 @@ from .rich_display import display_pr_info
87
93
  from .rich_display import safe_console_print
88
94
  from .rich_display import safe_typer_echo
89
95
  from .rich_logging import setup_rich_aware_logging
96
+ from .trust import describe_trust_policy
90
97
  from .utils import append_github_output
91
98
  from .utils import env_bool
92
99
  from .utils import env_str
@@ -228,14 +235,222 @@ def _check_automation_only(
228
235
  )
229
236
 
230
237
 
238
+ def _resolve_pr_for_gate(gh: GitHubContext, data: Inputs) -> Any | None:
239
+ """Fetch the pull request for the approval gate.
240
+
241
+ Used when the display step returned nothing — most notably the
242
+ no-token branch, which returns early. Without this the gate would
243
+ fail open exactly when the environment is least well configured.
244
+ """
245
+ if not gh.pr_number:
246
+ return None
247
+ try:
248
+ token = getattr(data, "github_token", "") or os.getenv(
249
+ "GITHUB_TOKEN", ""
250
+ )
251
+ client = build_client(token)
252
+ repo = get_repo_from_env(client)
253
+ return get_pull(repo, int(gh.pr_number))
254
+ except Exception as exc:
255
+ log.debug("Could not resolve PR for the approval gate: %s", exc)
256
+ return None
257
+
258
+
259
+ def _check_fork_approval(
260
+ pr_obj: Any | None,
261
+ gh: GitHubContext,
262
+ progress_tracker: Any = None,
263
+ ) -> tuple[bool, str]:
264
+ """Report whether a pull request may transfer to Gerrit.
265
+
266
+ Same-repository pull requests are unaffected: their head branch
267
+ already implies write access. Everything else — forks, and pull
268
+ requests whose provenance could not be established — requires an
269
+ approving review from a trusted maintainer, bound to the current
270
+ head commit.
271
+
272
+ Only a head positively known to live in the base repository skips
273
+ the gate. Unknown provenance does not: `is_fork_pr` answers a
274
+ factual question and reports ``False`` when it cannot tell, whereas
275
+ ``head_is_trusted`` answers the authorisation question and reports
276
+ ``False`` in the same case. Using the latter keeps the gate closed
277
+ when metadata is missing, which is exactly when it matters most.
278
+
279
+ The gate applies to unattended runs only. A direct CLI invocation
280
+ is a person acting deliberately with their own credentials, and
281
+ needs no second signature.
282
+
283
+ Runs before any fork content is fetched and before the Gerrit key
284
+ is materialised, so a blocked pull request never reaches either.
285
+
286
+ Returns:
287
+ ``(allowed, approved_sha)``. ``approved_sha`` is the commit the
288
+ gate authorised, empty when no gate applied. It is returned
289
+ rather than stored globally because bulk runs process several
290
+ pull requests concurrently, where shared state would let one
291
+ worker clear or overwrite another's constraint.
292
+
293
+ Blocking is not an error: the pull request is simply waiting
294
+ for a human, and a failed check would misrepresent that.
295
+ """
296
+ if gh.head_is_trusted:
297
+ return True, ""
298
+
299
+ if not _is_github_actions_context():
300
+ # Direct CLI invocation. The operator chose this pull request
301
+ # and is using their own credentials, so they are the authority
302
+ # the gate would otherwise be looking for. The gate exists for
303
+ # the unattended path, where the tool acts on a shared identity
304
+ # with nobody watching.
305
+ log.debug(
306
+ "Not running under GitHub Actions; approval gate not applied "
307
+ "to PR #%s",
308
+ gh.pr_number,
309
+ )
310
+ return True, ""
311
+
312
+ if pr_obj is None:
313
+ # Fail closed. Reaching here means the pull request could not
314
+ # be resolved, so approval cannot be established either way.
315
+ log.warning(
316
+ "🛑 Pull request #%s not transferred to Gerrit: the tool "
317
+ "could not resolve the pull request to check for approval",
318
+ gh.pr_number,
319
+ )
320
+ return False, ""
321
+
322
+ head_sha = str(
323
+ getattr(getattr(pr_obj, "head", None), "sha", "") or ""
324
+ ).strip()
325
+ author = str(
326
+ getattr(getattr(pr_obj, "user", None), "login", "") or ""
327
+ ).strip()
328
+
329
+ status = evaluate_fork_approval(
330
+ pr_obj, head_sha=head_sha, author_login=author
331
+ )
332
+
333
+ if status.approved:
334
+ log.info(
335
+ "✅ Pull request #%s authorised: %s",
336
+ gh.pr_number,
337
+ status.reason,
338
+ )
339
+ # The workspace fetch reads refs/pull/<N>/head, which the
340
+ # contributor can move after this check. Carry what was
341
+ # approved so the fetch can refuse anything else.
342
+ _clear_fork_approval_notice(pr_obj, status, head_sha)
343
+ return True, head_sha
344
+
345
+ log.warning(
346
+ "🛑 Pull request #%s not transferred to Gerrit: %s. "
347
+ "Reviews count from: %s",
348
+ gh.pr_number,
349
+ status.reason,
350
+ describe_trust_policy(),
351
+ )
352
+ safe_console_print(
353
+ f"🛑 Awaiting maintainer approval: {status.reason}",
354
+ style="yellow",
355
+ progress_tracker=progress_tracker,
356
+ )
357
+
358
+ _post_fork_approval_notice(pr_obj, status, head_sha)
359
+ return False, ""
360
+
361
+
362
+ def _edit_owned_marker_comment(pr_obj: Any, body: str) -> bool:
363
+ """Replace the tool's own approval notice, if one exists.
364
+
365
+ The marker is not proof of authorship — anyone may paste it into a
366
+ comment. Ownership is established by *attempting the edit*: the API
367
+ refuses to edit another user's comment, so a failure means the
368
+ comment was not ours and the search continues.
369
+
370
+ Returns:
371
+ ``True`` when a notice of ours was updated.
372
+ """
373
+ try:
374
+ issue = pr_obj.as_issue()
375
+ comments = list(issue.get_comments())
376
+ except Exception as exc:
377
+ log.debug("Could not read comments for approval notice: %s", exc)
378
+ return False
379
+
380
+ # Newest first: if several carry the marker, keep the most recent.
381
+ for comment in reversed(comments):
382
+ if APPROVAL_MARKER not in (getattr(comment, "body", "") or ""):
383
+ continue
384
+ try:
385
+ comment.edit(body)
386
+ except Exception as exc:
387
+ log.debug(
388
+ "Could not edit comment carrying the approval marker; "
389
+ "it is probably not ours: %s",
390
+ exc,
391
+ )
392
+ continue
393
+ else:
394
+ return True
395
+ return False
396
+
397
+
398
+ def _clear_fork_approval_notice(
399
+ pr_obj: Any,
400
+ status: ApprovalStatus,
401
+ head_sha: str,
402
+ ) -> None:
403
+ """Retract an earlier block notice once approval arrives.
404
+
405
+ Only edits an existing notice; it does not create one. A pull
406
+ request that was never blocked has nothing to retract, and adding a
407
+ comment to say so would be noise.
408
+
409
+ Best-effort: failing to tidy a comment must not stop a transfer
410
+ that has been authorised.
411
+ """
412
+ if env_bool("CI_TESTING", False):
413
+ return
414
+ _edit_owned_marker_comment(
415
+ pr_obj, render_cleared_comment(status, head_sha=head_sha)
416
+ )
417
+
418
+
419
+ def _post_fork_approval_notice(
420
+ pr_obj: Any,
421
+ status: ApprovalStatus,
422
+ head_sha: str,
423
+ ) -> None:
424
+ """Explain the block on the pull request, editing any prior notice.
425
+
426
+ Best-effort throughout: a comment API failure must never turn a
427
+ block into a crash, nor a block into a pass.
428
+ """
429
+ if env_bool("CI_TESTING", False):
430
+ return
431
+
432
+ body = render_blocked_comment(status, head_sha=head_sha)
433
+ if _edit_owned_marker_comment(pr_obj, body):
434
+ return
435
+
436
+ try:
437
+ create_pr_comment(pr_obj, body)
438
+ except Exception as exc:
439
+ log.debug("Could not post fork approval notice: %s", exc)
440
+
441
+
231
442
  def _extract_and_display_pr_info(
232
443
  gh: GitHubContext,
233
444
  data: Inputs,
234
445
  progress_tracker: Any = None,
235
- ) -> None:
236
- """Extract PR information and display it with Rich formatting."""
446
+ ) -> Any | None:
447
+ """Extract PR information and display it with Rich formatting.
448
+
449
+ Returns the pull request object when one was fetched, so callers
450
+ can reuse it instead of making a further API call.
451
+ """
237
452
  if not gh.pr_number:
238
- return
453
+ return None
239
454
 
240
455
  try:
241
456
  # Get GitHub token from inputs if available, fallback to environment
@@ -250,7 +465,7 @@ def _extract_and_display_pr_info(
250
465
  style="yellow",
251
466
  progress_tracker=progress_tracker,
252
467
  )
253
- return
468
+ return None
254
469
 
255
470
  client = build_client(token)
256
471
  repo = get_repo_from_env(client)
@@ -298,6 +513,9 @@ def _extract_and_display_pr_info(
298
513
  _exit_for_pr_not_found(gh.pr_number, gh.repository)
299
514
  else:
300
515
  _exit_for_pr_fetch_error(exc)
516
+ else:
517
+ return pr_obj
518
+ return None
301
519
 
302
520
 
303
521
  class ConfigurationError(Exception):
@@ -1522,12 +1740,13 @@ def _submit_bulk_pr(
1522
1740
  per_ctx: models.GitHubContext,
1523
1741
  pr_number: int,
1524
1742
  progress_tracker: G2GProgressTracker | DummyProgressTracker,
1743
+ approved_sha: str = "",
1525
1744
  ) -> _BulkPrResult:
1526
1745
  """Run the orchestrator for a single PR in multi-PR mode."""
1527
1746
  try:
1528
1747
  with tempfile.TemporaryDirectory() as temp_dir:
1529
1748
  workspace = Path(temp_dir)
1530
- orch = Orchestrator(workspace=workspace)
1749
+ orch = Orchestrator(workspace=workspace, approved_sha=approved_sha)
1531
1750
  result_multi = orch.execute(inputs=data, gh=per_ctx)
1532
1751
  _record_change_tracker_result(progress_tracker, result_multi)
1533
1752
  return "success", result_multi, None
@@ -1576,13 +1795,20 @@ def _process_bulk_pr(
1576
1795
  log.debug("PR #%d rejected by automation_only check", pr_number)
1577
1796
  return "skipped", None, None
1578
1797
 
1798
+ # Fork PRs need a maintainer's approval before anything is fetched
1799
+ allowed, approved_sha = _check_fork_approval(pr, per_ctx, progress_tracker)
1800
+ if not allowed:
1801
+ return "skipped", None, None
1802
+
1579
1803
  skip_result = _check_bulk_pr_duplicates(
1580
1804
  data, per_ctx, pr_number, progress_tracker
1581
1805
  )
1582
1806
  if skip_result is not None:
1583
1807
  return skip_result
1584
1808
 
1585
- return _submit_bulk_pr(data, per_ctx, pr_number, progress_tracker)
1809
+ return _submit_bulk_pr(
1810
+ data, per_ctx, pr_number, progress_tracker, approved_sha
1811
+ )
1586
1812
 
1587
1813
 
1588
1814
  def _record_bulk_success(
@@ -2079,10 +2305,11 @@ def _process_single(
2079
2305
  data: Inputs,
2080
2306
  gh: GitHubContext,
2081
2307
  progress_tracker: G2GProgressTracker | DummyProgressTracker | None = None,
2308
+ approved_sha: str = "",
2082
2309
  ) -> tuple[bool, SubmissionResult]:
2083
2310
  with tempfile.TemporaryDirectory() as temp_dir:
2084
2311
  workspace = Path(temp_dir)
2085
- orch = Orchestrator(workspace=workspace)
2312
+ orch = Orchestrator(workspace=workspace, approved_sha=approved_sha)
2086
2313
  _prepare_single_checkout(orch, workspace, data, gh, progress_tracker)
2087
2314
  _log_single_pre_submit(data, progress_tracker)
2088
2315
  pipeline_success, result = _run_single_submission(
@@ -2932,9 +3159,34 @@ def _handle_single_pr(
2932
3159
  # Augment PR refs via API when in URL mode and token present
2933
3160
  gh = _augment_pr_refs_if_needed(gh)
2934
3161
 
3162
+ # A review only exists to unblock a gated pull request. On a
3163
+ # same-repository head there is nothing to unblock, so stop rather
3164
+ # than resubmitting an unchanged commit every time somebody
3165
+ # comments on, approves or requests changes to a pull request.
3166
+ if gh.event_name == "pull_request_review" and gh.head_is_trusted:
3167
+ log.info(
3168
+ "Review on PR #%s, whose head is in this repository; "
3169
+ "nothing to unblock, so no transfer is needed",
3170
+ gh.pr_number,
3171
+ )
3172
+ sys.exit(int(ExitCode.SUCCESS))
3173
+
2935
3174
  # Display PR information with Rich formatting
3175
+ approved_sha = ""
2936
3176
  if gh.pr_number:
2937
- _extract_and_display_pr_info(gh, data, progress_tracker)
3177
+ pr_obj = _extract_and_display_pr_info(gh, data, progress_tracker)
3178
+
3179
+ # Fork PRs need a maintainer's approval before anything is
3180
+ # fetched and before the Gerrit key is materialised. Resolve
3181
+ # the PR here when the display step could not, so a missing
3182
+ # token cannot skip the gate.
3183
+ if pr_obj is None:
3184
+ pr_obj = _resolve_pr_for_gate(gh, data)
3185
+ allowed, approved_sha = _check_fork_approval(
3186
+ pr_obj, gh, progress_tracker
3187
+ )
3188
+ if not allowed:
3189
+ sys.exit(int(ExitCode.SUCCESS))
2938
3190
 
2939
3191
  # Check for duplicates in single-PR mode (before workspace setup)
2940
3192
  if gh.pr_number and not env_bool("SYNC_ALL_OPEN_PRS", False):
@@ -2946,7 +3198,9 @@ def _handle_single_pr(
2946
3198
  log.debug("Processing PR #%s from %s", gh.pr_number, gh.repository)
2947
3199
  log.debug("Target Gerrit server: %s", data.gerrit_server)
2948
3200
  log.debug("Target Gerrit project: %s", data.gerrit_project)
2949
- pipeline_success, result = _process_single(data, gh, progress_tracker)
3201
+ pipeline_success, result = _process_single(
3202
+ data, gh, progress_tracker, approved_sha
3203
+ )
2950
3204
 
2951
3205
  # Run abandoned-PR and Gerrit cleanup if the pipeline was successful
2952
3206
  # Skip in G2G_NO_GERRIT: no Gerrit server to query
@@ -99,6 +99,7 @@ KNOWN_KEYS: set[str] = {
99
99
  "G2G_NO_GERRIT",
100
100
  "G2G_DISABLED",
101
101
  "G2G_TOPIC_PREFIX",
102
+ "G2G_TRUSTED_ASSOCIATIONS",
102
103
  "G2G_LOG_LEVEL",
103
104
  "G2G_SHOW_PROGRESS",
104
105
  "G2G_RESPECT_USER_SSH",