github2gerrit 1.0.7__tar.gz → 1.0.8__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 (122) hide show
  1. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.pre-commit-config.yaml +2 -2
  2. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/PKG-INFO +112 -1
  3. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/README.md +111 -0
  4. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/action.yaml +6 -1
  5. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/cli.py +80 -50
  6. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/core.py +160 -17
  7. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/models.py +3 -0
  8. github2gerrit-1.0.8/src/github2gerrit/pr_commands.py +355 -0
  9. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/rich_display.py +4 -4
  10. github2gerrit-1.0.8/tests/test_pr_commands.py +956 -0
  11. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/uv.lock +19 -19
  12. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.editorconfig +0 -0
  13. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.gitignore +0 -0
  14. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.gitlint +0 -0
  15. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.markdownlint.yaml +0 -0
  16. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.readthedocs.yml +0 -0
  17. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/.yamllint +0 -0
  18. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/LICENSE +0 -0
  19. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/LICENSES/Apache-2.0.txt +0 -0
  20. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/REUSE.toml +0 -0
  21. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/docs/COMPOSITE_ACTION_TESTING.md +0 -0
  22. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/docs/PR_UPDATE_IMPLEMENTATION.md +0 -0
  23. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/docs/RELEASE-v0.2.0.md +0 -0
  24. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/docs/github2gerrit_token_permissions_classic.png +0 -0
  25. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/pyproject.toml +0 -0
  26. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/sitecustomize.py +0 -0
  27. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/__init__.py +0 -0
  28. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/commit_normalization.py +0 -0
  29. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/config.py +0 -0
  30. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/constants.py +0 -0
  31. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/duplicate_detection.py +0 -0
  32. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/error_codes.py +0 -0
  33. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/external_api.py +0 -0
  34. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/gerrit_pr_closer.py +0 -0
  35. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/gerrit_query.py +0 -0
  36. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/gerrit_rest.py +0 -0
  37. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/gerrit_urls.py +0 -0
  38. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/github_api.py +0 -0
  39. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/gitutils.py +0 -0
  40. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/mapping_comment.py +0 -0
  41. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/netrc.py +0 -0
  42. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/orchestrator/__init__.py +0 -0
  43. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
  44. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/pr_content_filter.py +0 -0
  45. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/reconcile_matcher.py +0 -0
  46. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/rich_logging.py +0 -0
  47. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/similarity.py +0 -0
  48. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/ssh_agent_setup.py +0 -0
  49. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/ssh_common.py +0 -0
  50. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/ssh_config_parser.py +0 -0
  51. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/ssh_discovery.py +0 -0
  52. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/trailers.py +0 -0
  53. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/src/github2gerrit/utils.py +0 -0
  54. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/conftest.py +0 -0
  55. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/fixtures/__init__.py +0 -0
  56. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/fixtures/make_repo.py +0 -0
  57. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/fixtures/ssh_config_samples.py +0 -0
  58. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_action_environment_mapping.py +0 -0
  59. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_action_outputs.py +0 -0
  60. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_action_pr_number_handling.py +0 -0
  61. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_action_step_validation.py +0 -0
  62. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_automation_only.py +0 -0
  63. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_change_id_deduplication.py +0 -0
  64. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_cli.py +0 -0
  65. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_cli_helpers.py +0 -0
  66. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_cli_netrc_options.py +0 -0
  67. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_cli_outputs_file.py +0 -0
  68. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_cli_url_and_dryrun.py +0 -0
  69. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_commit_normalization.py +0 -0
  70. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_composite_action_coverage.py +0 -0
  71. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_config_and_reviewers.py +0 -0
  72. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_config_helpers.py +0 -0
  73. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_close_pr_policy.py +0 -0
  74. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_config_and_errors.py +0 -0
  75. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_gerrit_backref_comment.py +0 -0
  76. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_gerrit_push_errors.py +0 -0
  77. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_gerrit_rest_results.py +0 -0
  78. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_integration_fixture_repo.py +0 -0
  79. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_prepare_commits.py +0 -0
  80. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_shallow_clone.py +0 -0
  81. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_ssh_setup.py +0 -0
  82. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_core_ssrf_protection.py +0 -0
  83. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_duplicate_detection.py +0 -0
  84. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_email_case_normalization.py +0 -0
  85. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_error_codes.py +0 -0
  86. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_external_api_framework.py +0 -0
  87. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_force_flag_cli.py +0 -0
  88. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gerrit_change_id_footer.py +0 -0
  89. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gerrit_change_status_checks.py +0 -0
  90. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gerrit_pr_closer.py +0 -0
  91. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gerrit_rest_client.py +0 -0
  92. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gerrit_urls.py +0 -0
  93. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gerrit_urls_more.py +0 -0
  94. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ghe_and_gitreview_args.py +0 -0
  95. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_github_api_error_handling.py +0 -0
  96. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_github_api_helpers.py +0 -0
  97. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_github_api_retry_and_helpers.py +0 -0
  98. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_gitutils_helpers.py +0 -0
  99. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_mapping_comment_additional.py +0 -0
  100. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_mapping_comment_digest_and_backref.py +0 -0
  101. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_metadata_and_reconciliation.py +0 -0
  102. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_metadata_trailer_separation_bug.py +0 -0
  103. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_misc_small_coverage.py +0 -0
  104. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_netrc.py +0 -0
  105. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_orphan_rest_side_effects.py +0 -0
  106. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_pr_content_filter.py +0 -0
  107. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_pr_content_filter_integration.py +0 -0
  108. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_pr_update_detection.py +0 -0
  109. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_reconciliation_extracted_module.py +0 -0
  110. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_reconciliation_plan_and_orphans.py +0 -0
  111. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_reconciliation_scenarios.py +0 -0
  112. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ssh_agent.py +0 -0
  113. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ssh_agent_ownership.py +0 -0
  114. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ssh_artifact_prevention.py +0 -0
  115. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ssh_common.py +0 -0
  116. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ssh_discovery.py +0 -0
  117. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_ssh_discovery_dry_run.py +0 -0
  118. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_trailers_additional.py +0 -0
  119. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_url_parser.py +0 -0
  120. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/test_utils.py +0 -0
  121. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/unit/test_config_integration.py +0 -0
  122. {github2gerrit-1.0.7 → github2gerrit-1.0.8}/tests/unit/test_ssh_config_parser.py +0 -0
@@ -59,7 +59,7 @@ repos:
59
59
  types: [yaml]
60
60
 
61
61
  - repo: https://github.com/astral-sh/ruff-pre-commit
62
- rev: 0839f92796ae388643a08a21640a029b322be5c2 # frozen: v0.15.2
62
+ rev: a27a2e47c7751b639d2b5badf0ef6ff11fee893f # frozen: v0.15.4
63
63
  hooks:
64
64
  - id: ruff
65
65
  files: ^(src|scripts|tests)/.+\.py$
@@ -121,7 +121,7 @@ repos:
121
121
  - id: codespell
122
122
 
123
123
  - repo: https://github.com/python-jsonschema/check-jsonschema
124
- rev: ec368acd16deee9c560c105ab6d27db4ee19a5ec # frozen: 0.36.2
124
+ rev: 8db279a37c552206d2df62269ff6f9d31125815a # frozen: 0.37.0
125
125
  hooks:
126
126
  - id: check-github-actions
127
127
  - id: check-github-workflows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github2gerrit
3
- Version: 1.0.7
3
+ Version: 1.0.8
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
@@ -147,6 +147,117 @@ If UPDATE fails to find existing change:
147
147
  To create a new change, trigger the 'opened' workflow action.
148
148
  ```
149
149
 
150
+ ## PR Comment Commands
151
+
152
+ GitHub2Gerrit supports an extensible set of directives issued through
153
+ pull request comments. Add a comment containing `@github2gerrit`
154
+ followed by a command phrase and the tool will act on it during
155
+ the next workflow run.
156
+
157
+ ### Command Format
158
+
159
+ <!-- markdownlint-disable MD013 -->
160
+
161
+ ```text
162
+ @github2gerrit <command>
163
+ ```
164
+
165
+ <!-- markdownlint-enable MD013 -->
166
+
167
+ - Commands are **case-insensitive** — `@github2gerrit Create Missing Change`
168
+ works the same as `@github2gerrit create missing change`.
169
+ - Only the **latest** occurrence of each command takes effect when the same
170
+ command appears in more than one comment.
171
+ - The tool logs unrecognised directives at debug level and ignores them.
172
+
173
+ ### Available Commands
174
+
175
+ <!-- markdownlint-disable MD013 MD060 -->
176
+
177
+ | Command | Aliases | Description |
178
+ | --- | --- | --- |
179
+ | `create missing change` | `create-missing`, `create missing` | Create a Gerrit change when an UPDATE operation cannot find an existing one |
180
+
181
+ <!-- markdownlint-enable MD013 MD060 -->
182
+
183
+ ### Create Missing Change
184
+
185
+ When a PR `synchronize` event fires, GitHub2Gerrit treats it as an
186
+ **UPDATE** operation and expects a Gerrit change to exist. If the
187
+ original `opened` event failed (for example due to a bug or transient
188
+ error), no Gerrit change exists and every following update fails with:
189
+
190
+ ```text
191
+ ❌ UPDATE FAILED: Cannot update non-existent Gerrit change
192
+ ```
193
+
194
+ The **create missing change** command resolves this without manual
195
+ intervention in Gerrit. Two mechanisms trigger it:
196
+
197
+ #### 1. PR Comment Directive
198
+
199
+ Add a comment on the stuck pull request:
200
+
201
+ ```text
202
+ @github2gerrit create missing change
203
+ ```
204
+
205
+ Then re-trigger the workflow (push a trivial change or re-run the
206
+ workflow manually). GitHub2Gerrit detects the directive, switches
207
+ from UPDATE to CREATE mode, and pushes a new Gerrit change.
208
+
209
+ #### 2. CLI Flag
210
+
211
+ Outside GitHub Actions you can pass the flag directly:
212
+
213
+ ```shell
214
+ github2gerrit \
215
+ --create-missing \
216
+ https://github.com/MyOrg/my-repo/pull/42
217
+ ```
218
+
219
+ Or set the environment variable:
220
+
221
+ ```shell
222
+ export CREATE_MISSING=true
223
+ github2gerrit https://github.com/MyOrg/my-repo/pull/42
224
+ ```
225
+
226
+ #### What Happens During Fallback
227
+
228
+ 1. The tool attempts the normal UPDATE flow and finds no existing
229
+ Gerrit change.
230
+ 2. It checks for `--create-missing` **or** scans PR comments for the
231
+ `@github2gerrit create missing change` directive.
232
+ 3. If authorised, the operation mode switches from UPDATE to CREATE.
233
+ 4. The tool posts a notice on the PR:
234
+
235
+ ```text
236
+ 🔄 GitHub2Gerrit: No existing Gerrit change found for this PR.
237
+ Creating a new Gerrit change (fallback from UPDATE operation).
238
+ ```
239
+
240
+ 5. The pipeline continues as a normal CREATE — preparing commits,
241
+ pushing to Gerrit, posting the change URL back on the PR.
242
+
243
+ #### GitHub Actions Workflow Example
244
+
245
+ <!-- markdownlint-disable MD013 -->
246
+
247
+ ```yaml
248
+ - name: Submit PR to Gerrit
249
+ uses: lfreleng-actions/github2gerrit-action@main
250
+ with:
251
+ GERRIT_SSH_PRIVKEY_G2G: ${{ secrets.GERRIT_SSH_PRIVKEY_G2G }}
252
+ CREATE_MISSING: "true" # always allow fallback
253
+ ```
254
+
255
+ <!-- markdownlint-enable MD013 -->
256
+
257
+ > **Tip:** Setting `CREATE_MISSING` to `true` in your workflow means
258
+ > stuck PRs self-heal on the next `synchronize` event without requiring
259
+ > a comment directive.
260
+
150
261
  ## Close Merged PRs Feature
151
262
 
152
263
  GitHub2Gerrit now includes **automatic PR closure** when Gerrit merges changes
@@ -101,6 +101,117 @@ If UPDATE fails to find existing change:
101
101
  To create a new change, trigger the 'opened' workflow action.
102
102
  ```
103
103
 
104
+ ## PR Comment Commands
105
+
106
+ GitHub2Gerrit supports an extensible set of directives issued through
107
+ pull request comments. Add a comment containing `@github2gerrit`
108
+ followed by a command phrase and the tool will act on it during
109
+ the next workflow run.
110
+
111
+ ### Command Format
112
+
113
+ <!-- markdownlint-disable MD013 -->
114
+
115
+ ```text
116
+ @github2gerrit <command>
117
+ ```
118
+
119
+ <!-- markdownlint-enable MD013 -->
120
+
121
+ - Commands are **case-insensitive** — `@github2gerrit Create Missing Change`
122
+ works the same as `@github2gerrit create missing change`.
123
+ - Only the **latest** occurrence of each command takes effect when the same
124
+ command appears in more than one comment.
125
+ - The tool logs unrecognised directives at debug level and ignores them.
126
+
127
+ ### Available Commands
128
+
129
+ <!-- markdownlint-disable MD013 MD060 -->
130
+
131
+ | Command | Aliases | Description |
132
+ | --- | --- | --- |
133
+ | `create missing change` | `create-missing`, `create missing` | Create a Gerrit change when an UPDATE operation cannot find an existing one |
134
+
135
+ <!-- markdownlint-enable MD013 MD060 -->
136
+
137
+ ### Create Missing Change
138
+
139
+ When a PR `synchronize` event fires, GitHub2Gerrit treats it as an
140
+ **UPDATE** operation and expects a Gerrit change to exist. If the
141
+ original `opened` event failed (for example due to a bug or transient
142
+ error), no Gerrit change exists and every following update fails with:
143
+
144
+ ```text
145
+ ❌ UPDATE FAILED: Cannot update non-existent Gerrit change
146
+ ```
147
+
148
+ The **create missing change** command resolves this without manual
149
+ intervention in Gerrit. Two mechanisms trigger it:
150
+
151
+ #### 1. PR Comment Directive
152
+
153
+ Add a comment on the stuck pull request:
154
+
155
+ ```text
156
+ @github2gerrit create missing change
157
+ ```
158
+
159
+ Then re-trigger the workflow (push a trivial change or re-run the
160
+ workflow manually). GitHub2Gerrit detects the directive, switches
161
+ from UPDATE to CREATE mode, and pushes a new Gerrit change.
162
+
163
+ #### 2. CLI Flag
164
+
165
+ Outside GitHub Actions you can pass the flag directly:
166
+
167
+ ```shell
168
+ github2gerrit \
169
+ --create-missing \
170
+ https://github.com/MyOrg/my-repo/pull/42
171
+ ```
172
+
173
+ Or set the environment variable:
174
+
175
+ ```shell
176
+ export CREATE_MISSING=true
177
+ github2gerrit https://github.com/MyOrg/my-repo/pull/42
178
+ ```
179
+
180
+ #### What Happens During Fallback
181
+
182
+ 1. The tool attempts the normal UPDATE flow and finds no existing
183
+ Gerrit change.
184
+ 2. It checks for `--create-missing` **or** scans PR comments for the
185
+ `@github2gerrit create missing change` directive.
186
+ 3. If authorised, the operation mode switches from UPDATE to CREATE.
187
+ 4. The tool posts a notice on the PR:
188
+
189
+ ```text
190
+ 🔄 GitHub2Gerrit: No existing Gerrit change found for this PR.
191
+ Creating a new Gerrit change (fallback from UPDATE operation).
192
+ ```
193
+
194
+ 5. The pipeline continues as a normal CREATE — preparing commits,
195
+ pushing to Gerrit, posting the change URL back on the PR.
196
+
197
+ #### GitHub Actions Workflow Example
198
+
199
+ <!-- markdownlint-disable MD013 -->
200
+
201
+ ```yaml
202
+ - name: Submit PR to Gerrit
203
+ uses: lfreleng-actions/github2gerrit-action@main
204
+ with:
205
+ GERRIT_SSH_PRIVKEY_G2G: ${{ secrets.GERRIT_SSH_PRIVKEY_G2G }}
206
+ CREATE_MISSING: "true" # always allow fallback
207
+ ```
208
+
209
+ <!-- markdownlint-enable MD013 -->
210
+
211
+ > **Tip:** Setting `CREATE_MISSING` to `true` in your workflow means
212
+ > stuck PRs self-heal on the next `synchronize` event without requiring
213
+ > a comment directive.
214
+
104
215
  ## Close Merged PRs Feature
105
216
 
106
217
  GitHub2Gerrit now includes **automatic PR closure** when Gerrit merges changes
@@ -141,6 +141,10 @@ inputs:
141
141
  description: "Abandon Gerrit changes when their GitHub PRs are closed"
142
142
  required: false
143
143
  default: "true"
144
+ CREATE_MISSING:
145
+ description: "Create a Gerrit change when an UPDATE operation cannot find an existing one"
146
+ required: false
147
+ default: "false"
144
148
 
145
149
  outputs:
146
150
  gerrit_change_request_url:
@@ -164,7 +168,7 @@ runs:
164
168
 
165
169
  - name: "Setup uv"
166
170
  # yamllint disable-line rule:line-length
167
- uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
171
+ uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
168
172
  with:
169
173
  enable-cache: false
170
174
 
@@ -300,6 +304,7 @@ runs:
300
304
  AUTOMATION_ONLY: ${{ inputs.AUTOMATION_ONLY }}
301
305
  CLEANUP_ABANDONED: ${{ inputs.CLEANUP_ABANDONED }}
302
306
  CLEANUP_GERRIT: ${{ inputs.CLEANUP_GERRIT }}
307
+ CREATE_MISSING: ${{ inputs.CREATE_MISSING }}
303
308
 
304
309
  # Optional Gerrit overrides (when .gitreview is missing)
305
310
  GERRIT_SERVER: ${{ inputs.GERRIT_SERVER }}
@@ -557,7 +557,7 @@ def main(
557
557
  ),
558
558
  allow_duplicates: bool = typer.Option(
559
559
  True,
560
- "--allow-duplicates",
560
+ "--allow-duplicates/--no-allow-duplicates",
561
561
  envvar="ALLOW_DUPLICATES",
562
562
  help="Allow submitting duplicate changes without error.",
563
563
  ),
@@ -582,7 +582,7 @@ def main(
582
582
  ),
583
583
  close_merged_prs: bool = typer.Option(
584
584
  True,
585
- "--close-merged-prs",
585
+ "--close-merged-prs/--no-close-merged-prs",
586
586
  envvar="CLOSE_MERGED_PRS",
587
587
  help="Close GitHub PRs when corresponding Gerrit changes are merged.",
588
588
  ),
@@ -700,7 +700,7 @@ def main(
700
700
  ),
701
701
  preserve_github_prs: bool = typer.Option(
702
702
  True,
703
- "--preserve-github-prs",
703
+ "--preserve-github-prs/--no-preserve-github-prs",
704
704
  envvar="PRESERVE_GITHUB_PRS",
705
705
  help="Do not close GitHub PRs after pushing to Gerrit.",
706
706
  ),
@@ -776,6 +776,16 @@ def main(
776
776
  "--version",
777
777
  help="Show version and exit.",
778
778
  ),
779
+ create_missing: bool = typer.Option(
780
+ False,
781
+ "--create-missing/--no-create-missing",
782
+ envvar="CREATE_MISSING",
783
+ help=(
784
+ "Create a Gerrit change when an UPDATE operation cannot find "
785
+ "an existing one. Also triggered by '@github2gerrit create "
786
+ "missing change' PR comment."
787
+ ),
788
+ ),
779
789
  automation_only: bool = typer.Option(
780
790
  True,
781
791
  "--automation-only/--no-automation-only",
@@ -824,44 +834,59 @@ def main(
824
834
  typer.echo("Version information not available")
825
835
  sys.exit(int(ExitCode.SUCCESS))
826
836
 
827
- # Override boolean parameters with properly parsed environment variables
828
- # This ensures that string "false" from GitHub Actions is handled correctly
829
- if os.getenv("SUBMIT_SINGLE_COMMITS"):
830
- submit_single_commits = parse_bool_env(
831
- os.getenv("SUBMIT_SINGLE_COMMITS")
832
- )
833
-
834
- if os.getenv("USE_PR_AS_COMMIT"):
835
- use_pr_as_commit = parse_bool_env(os.getenv("USE_PR_AS_COMMIT"))
836
-
837
- if os.getenv("PRESERVE_GITHUB_PRS"):
838
- preserve_github_prs = parse_bool_env(os.getenv("PRESERVE_GITHUB_PRS"))
839
-
840
- if os.getenv("DRY_RUN"):
841
- dry_run = parse_bool_env(os.getenv("DRY_RUN"))
842
-
843
- if os.getenv("ALLOW_DUPLICATES"):
844
- allow_duplicates = parse_bool_env(os.getenv("ALLOW_DUPLICATES"))
845
-
846
- if os.getenv("CI_TESTING"):
847
- ci_testing = parse_bool_env(os.getenv("CI_TESTING"))
848
-
849
- if os.getenv("SIMILARITY_FILES"):
850
- similarity_files = parse_bool_env(os.getenv("SIMILARITY_FILES"))
851
-
852
- if os.getenv("ALLOW_ORPHAN_CHANGES"):
853
- allow_orphan_changes = parse_bool_env(os.getenv("ALLOW_ORPHAN_CHANGES"))
854
-
855
- if os.getenv("PERSIST_SINGLE_MAPPING_COMMENT"):
856
- persist_single_mapping_comment = parse_bool_env(
857
- os.getenv("PERSIST_SINGLE_MAPPING_COMMENT")
858
- )
859
-
860
- if os.getenv("LOG_RECONCILE_JSON"):
861
- log_reconcile_json = parse_bool_env(os.getenv("LOG_RECONCILE_JSON"))
862
-
863
- if os.getenv("AUTOMATION_ONLY"):
864
- automation_only = parse_bool_env(os.getenv("AUTOMATION_ONLY"))
837
+ # Override boolean parameters with properly parsed environment variables.
838
+ # This ensures that string "false" from GitHub Actions is handled
839
+ # correctly (Typer/Click treats any non-empty string as truthy).
840
+ #
841
+ # We only apply the env-var override when the parameter was NOT
842
+ # explicitly provided on the command line, so that CLI flags always
843
+ # take precedence over environment variables.
844
+ def _env_bool_override(
845
+ param_name: str, env_var: str, current: bool
846
+ ) -> bool:
847
+ """Return *current* if the CLI flag was explicit, else parse env."""
848
+ source = ctx.get_parameter_source(param_name)
849
+ if source == click.core.ParameterSource.COMMANDLINE:
850
+ return current
851
+ env_val = os.getenv(env_var)
852
+ if env_val is not None:
853
+ return parse_bool_env(env_val)
854
+ return current
855
+
856
+ submit_single_commits = _env_bool_override(
857
+ "submit_single_commits", "SUBMIT_SINGLE_COMMITS", submit_single_commits
858
+ )
859
+ use_pr_as_commit = _env_bool_override(
860
+ "use_pr_as_commit", "USE_PR_AS_COMMIT", use_pr_as_commit
861
+ )
862
+ preserve_github_prs = _env_bool_override(
863
+ "preserve_github_prs", "PRESERVE_GITHUB_PRS", preserve_github_prs
864
+ )
865
+ dry_run = _env_bool_override("dry_run", "DRY_RUN", dry_run)
866
+ allow_duplicates = _env_bool_override(
867
+ "allow_duplicates", "ALLOW_DUPLICATES", allow_duplicates
868
+ )
869
+ ci_testing = _env_bool_override("ci_testing", "CI_TESTING", ci_testing)
870
+ similarity_files = _env_bool_override(
871
+ "similarity_files", "SIMILARITY_FILES", similarity_files
872
+ )
873
+ allow_orphan_changes = _env_bool_override(
874
+ "allow_orphan_changes", "ALLOW_ORPHAN_CHANGES", allow_orphan_changes
875
+ )
876
+ persist_single_mapping_comment = _env_bool_override(
877
+ "persist_single_mapping_comment",
878
+ "PERSIST_SINGLE_MAPPING_COMMENT",
879
+ persist_single_mapping_comment,
880
+ )
881
+ log_reconcile_json = _env_bool_override(
882
+ "log_reconcile_json", "LOG_RECONCILE_JSON", log_reconcile_json
883
+ )
884
+ create_missing = _env_bool_override(
885
+ "create_missing", "CREATE_MISSING", create_missing
886
+ )
887
+ automation_only = _env_bool_override(
888
+ "automation_only", "AUTOMATION_ONLY", automation_only
889
+ )
865
890
 
866
891
  # Store netrc options in environment for use by processing functions
867
892
  os.environ["G2G_NO_NETRC"] = "true" if no_netrc else "false"
@@ -1002,6 +1027,7 @@ def main(
1002
1027
  "true" if persist_single_mapping_comment else "false"
1003
1028
  )
1004
1029
  os.environ["LOG_RECONCILE_JSON"] = "true" if log_reconcile_json else "false"
1030
+ os.environ["CREATE_MISSING"] = "true" if create_missing else "false"
1005
1031
  os.environ["AUTOMATION_ONLY"] = "true" if automation_only else "false"
1006
1032
  # URL mode handling
1007
1033
  if target_url:
@@ -1161,6 +1187,7 @@ def _build_inputs_from_env() -> Inputs:
1161
1187
  "PERSIST_SINGLE_MAPPING_COMMENT", True
1162
1188
  ),
1163
1189
  log_reconcile_json=env_bool("LOG_RECONCILE_JSON", True),
1190
+ create_missing=env_bool("CREATE_MISSING", False),
1164
1191
  )
1165
1192
 
1166
1193
 
@@ -1441,7 +1468,7 @@ def _process_bulk(data: Inputs, gh: GitHubContext) -> bool:
1441
1468
  if show_progress and RICH_AVAILABLE:
1442
1469
  summary = progress_tracker.get_summary()
1443
1470
  safe_console_print(
1444
- f"⏱️ Total time: {summary.get('elapsed_time', 'unknown')}"
1471
+ f" Total time: {summary.get('elapsed_time', 'unknown')}"
1445
1472
  )
1446
1473
  safe_console_print(f"📊 PRs processed: {processed_count}")
1447
1474
  safe_console_print(f"✅ Succeeded: {succeeded_count}")
@@ -1486,7 +1513,7 @@ def _process_single(
1486
1513
 
1487
1514
  try:
1488
1515
  if progress_tracker:
1489
- progress_tracker.update_operation("📋 Preparing local checkout")
1516
+ progress_tracker.update_operation("📂 Preparing local checkout")
1490
1517
  log.debug(
1491
1518
  "Preparing workspace checkout in temporary directory: %s",
1492
1519
  workspace,
@@ -1518,7 +1545,9 @@ def _process_single(
1518
1545
  )
1519
1546
 
1520
1547
  if progress_tracker:
1521
- progress_tracker.update_operation("⬆️ Extracting commit information")
1548
+ progress_tracker.update_operation(
1549
+ "🔍 Extracting commit information"
1550
+ )
1522
1551
 
1523
1552
  log.debug("Extracting commit information from PR")
1524
1553
  log.debug("PR commits range: base_sha..head_sha (not available)")
@@ -1781,6 +1810,7 @@ def _load_effective_inputs() -> Inputs:
1781
1810
  allow_orphan_changes=data.allow_orphan_changes,
1782
1811
  persist_single_mapping_comment=data.persist_single_mapping_comment,
1783
1812
  log_reconcile_json=data.log_reconcile_json,
1813
+ create_missing=data.create_missing,
1784
1814
  )
1785
1815
  log.debug("Derived reviewers: %s", data.reviewers_email)
1786
1816
  except Exception as exc:
@@ -1859,7 +1889,7 @@ def _process_close_gerrit_change(
1859
1889
  pr_url = extract_pr_url_from_gerrit_change(gerrit_change_url)
1860
1890
  if not pr_url:
1861
1891
  no_action_msg = (
1862
- "☑️ No action required: Gerrit change did NOT originate in GitHub"
1892
+ " No action required: Gerrit change did NOT originate in GitHub"
1863
1893
  )
1864
1894
  log.debug(no_action_msg)
1865
1895
  safe_console_print(no_action_msg)
@@ -2572,7 +2602,7 @@ def _process() -> None:
2572
2602
  style="green" if pipeline_success else "red",
2573
2603
  )
2574
2604
  safe_console_print(
2575
- f"⏱️ Total time: {summary.get('elapsed_time', 'unknown')}"
2605
+ f" Total time: {summary.get('elapsed_time', 'unknown')}"
2576
2606
  )
2577
2607
  if summary.get("prs_processed", 0) > 0:
2578
2608
  safe_console_print(f"📊 PRs processed: {summary['prs_processed']}")
@@ -2804,13 +2834,13 @@ def _get_ssh_agent_status() -> str:
2804
2834
  elif has_private_key:
2805
2835
  # SSH key explicitly provided - don't use agent
2806
2836
  if agent_running:
2807
- return "☑️ Available, Unused"
2837
+ return " Available, Unused"
2808
2838
  else:
2809
2839
  return "❎ Unavailable, Unused"
2810
2840
  elif use_ssh_agent and agent_running:
2811
2841
  return "✅ Available, Used"
2812
2842
  elif agent_running:
2813
- return "☑️ Available, Unused"
2843
+ return " Available, Unused"
2814
2844
  else:
2815
2845
  return "❎ Unavailable, Unused"
2816
2846
 
@@ -2947,11 +2977,11 @@ def _display_effective_config(data: Inputs, gh: GitHubContext) -> None:
2947
2977
 
2948
2978
  # Show cleanup abandoned status if enabled
2949
2979
  if cleanup_abandoned:
2950
- config_info["CLEANUP_ABANDONED"] = "☑️"
2980
+ config_info["CLEANUP_ABANDONED"] = ""
2951
2981
 
2952
2982
  # Show Gerrit cleanup status if enabled
2953
2983
  if cleanup_gerrit:
2954
- config_info["CLEANUP_GERRIT"] = "☑️"
2984
+ config_info["CLEANUP_GERRIT"] = ""
2955
2985
 
2956
2986
  # Display the configuration table
2957
2987
  display_pr_info(config_info, "GitHub2Gerrit Configuration")