github2gerrit 1.4.1__tar.gz → 1.4.2__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.
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/.pre-commit-config.yaml +2 -2
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/PKG-INFO +1 -1
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/action.yaml +12 -2
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/pyproject.toml +6 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/core.py +198 -2
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/error_codes.py +19 -0
- github2gerrit-1.4.2/tests/test_final_state_reconciliation.py +333 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/uv.lock +149 -143
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/.editorconfig +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/.gitignore +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/.gitlint +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/.markdownlint.yaml +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/.yamllint +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/LICENSE +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/LICENSES/Apache-2.0.txt +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/README.md +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/REUSE.toml +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/SECURITY.md +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/docs/COMMIT_RULES.md +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/docs/cli.md +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/docs/development.md +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/docs/features.md +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/docs/github2gerrit_token_permissions_classic.png +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/sitecustomize.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/__init__.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/cli.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/commit_normalization.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/commit_rules.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/config.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/constants.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/duplicate_detection.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/external_api.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gerrit_pr_closer.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gerrit_query.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gerrit_rest.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gerrit_ssh.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gerrit_urls.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/github_api.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gitreview.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/gitutils.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/mapping_comment.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/models.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/netrc.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/orchestrator/__init__.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/orchestrator/reconciliation.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/pr_commands.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/pr_content_filter.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/reconcile_matcher.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/rich_display.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/rich_logging.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/similarity.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/ssh_agent_setup.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/ssh_common.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/ssh_config_parser.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/ssh_discovery.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/trailers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/src/github2gerrit/utils.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/conftest.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/fixtures/__init__.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/fixtures/make_repo.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/fixtures/ssh_config_samples.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_action_environment_mapping.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_action_outputs.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_action_pr_number_handling.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_action_step_validation.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_automation_only.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_change_id_deduplication.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_clean_squash_title.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_cli.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_cli_helpers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_cli_netrc_options.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_cli_outputs_file.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_cli_url_and_dryrun.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_commit_normalization.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_commit_rules.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_composite_action_coverage.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_config_and_reviewers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_config_helpers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_close_pr_policy.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_config_and_errors.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_gerrit_backref_comment.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_gerrit_push_errors.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_gerrit_rest_results.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_integration_fixture_repo.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_prepare_commits.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_shallow_clone.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_ssh_setup.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_core_ssrf_protection.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_dependency_supersession.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_dns_validation_and_no_gerrit.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_duplicate_detection.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_email_case_normalization.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_error_codes.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_external_api_framework.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_force_flag_cli.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_change_id_footer.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_change_status_checks.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_pr_closer.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_rest_client.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_ssh_abandon.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_urls.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gerrit_urls_more.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ghe_and_gitreview_args.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_github_api_error_handling.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_github_api_helpers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_github_api_retry_and_helpers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gitreview.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_gitutils_helpers.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_issue_157_regressions.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_mapping_comment_additional.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_mapping_comment_digest_and_backref.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_metadata_and_reconciliation.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_metadata_trailer_separation_bug.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_misc_small_coverage.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_netrc.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_orphan_rest_side_effects.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_pr_commands.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_pr_content_filter.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_pr_content_filter_integration.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_pr_update_detection.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_reconciliation_extracted_module.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_reconciliation_plan_and_orphans.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_reconciliation_scenarios.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ssh_agent.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ssh_agent_ownership.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ssh_artifact_prevention.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ssh_common.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ssh_discovery.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_ssh_discovery_dry_run.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_trailers_additional.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_url_parser.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/test_utils.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/tests/unit/test_config_integration.py +0 -0
- {github2gerrit-1.4.1 → github2gerrit-1.4.2}/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:
|
|
62
|
+
rev: 01a675ea018f2fb714478a5ffb83fcea8374bb06 # frozen: v0.15.21
|
|
63
63
|
hooks:
|
|
64
64
|
- id: ruff
|
|
65
65
|
files: ^(src|scripts|tests)/.+\.py$
|
|
@@ -68,7 +68,7 @@ repos:
|
|
|
68
68
|
files: ^(src|scripts|tests)/.+\.py$
|
|
69
69
|
|
|
70
70
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
71
|
-
rev:
|
|
71
|
+
rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0
|
|
72
72
|
hooks:
|
|
73
73
|
- id: mypy
|
|
74
74
|
files: ^src/.+\.py$
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github2gerrit
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.2
|
|
4
4
|
Summary: Submit a GitHub pull request to a Gerrit repository.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lfreleng-actions/github2gerrit-action
|
|
6
6
|
Project-URL: Repository, https://github.com/lfreleng-actions/github2gerrit-action
|
|
@@ -22,6 +22,15 @@ inputs:
|
|
|
22
22
|
description: "Fetch depth for checkout"
|
|
23
23
|
required: false
|
|
24
24
|
default: "10"
|
|
25
|
+
SKIP_CHECKOUT:
|
|
26
|
+
description: >-
|
|
27
|
+
Skip the internal repository checkout. Set when the caller has
|
|
28
|
+
already checked out the target repository at the desired ref
|
|
29
|
+
(e.g. the bundled reusable workflow, which also checks out this
|
|
30
|
+
action into the workspace and must not have it deleted by a
|
|
31
|
+
nested checkout)
|
|
32
|
+
required: false
|
|
33
|
+
default: "false"
|
|
25
34
|
PR_NUMBER:
|
|
26
35
|
description: "Pull request number to process, use 0 to process all open"
|
|
27
36
|
required: false
|
|
@@ -205,7 +214,8 @@ runs:
|
|
|
205
214
|
fi
|
|
206
215
|
|
|
207
216
|
- name: "Checkout repository"
|
|
208
|
-
|
|
217
|
+
# yamllint disable-line rule:line-length
|
|
218
|
+
if: steps.disabled-check.outputs.disabled != 'true' && inputs.SKIP_CHECKOUT != 'true'
|
|
209
219
|
# yamllint disable-line rule:line-length
|
|
210
220
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
|
211
221
|
with:
|
|
@@ -227,7 +237,7 @@ runs:
|
|
|
227
237
|
- name: "Setup uv"
|
|
228
238
|
if: steps.disabled-check.outputs.disabled != 'true'
|
|
229
239
|
# yamllint disable-line rule:line-length
|
|
230
|
-
uses: astral-sh/setup-uv@
|
|
240
|
+
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
|
231
241
|
with:
|
|
232
242
|
enable-cache: false
|
|
233
243
|
|
|
@@ -216,6 +216,12 @@ disallow_untyped_calls = false
|
|
|
216
216
|
|
|
217
217
|
[tool.coverage.run]
|
|
218
218
|
branch = true
|
|
219
|
+
# Keep transient coverage data out of the working tree so tests that
|
|
220
|
+
# inspect on-disk repository content never see .coverage* files.
|
|
221
|
+
# During pytest runs tests/conftest.py sets COVERAGE_FILE (env), which
|
|
222
|
+
# takes precedence; this value covers direct coverage invocations and
|
|
223
|
+
# satisfies python-test-action's data_file configuration check.
|
|
224
|
+
data_file = "${TMPDIR-/tmp}/.coverage.github2gerrit"
|
|
219
225
|
source = ["github2gerrit"]
|
|
220
226
|
omit = [
|
|
221
227
|
"tests/*",
|
|
@@ -41,6 +41,7 @@ from collections.abc import Sequence
|
|
|
41
41
|
from dataclasses import dataclass
|
|
42
42
|
from pathlib import Path
|
|
43
43
|
from typing import Any
|
|
44
|
+
from typing import Literal
|
|
44
45
|
from urllib.request import Request
|
|
45
46
|
from urllib.request import urlopen
|
|
46
47
|
|
|
@@ -1860,6 +1861,173 @@ class Orchestrator:
|
|
|
1860
1861
|
)
|
|
1861
1862
|
raise OrchestratorError(_MSG_DNS_RESOLUTION_FAILED % host) from exc
|
|
1862
1863
|
|
|
1864
|
+
def _lookup_change_state(
|
|
1865
|
+
self,
|
|
1866
|
+
gerrit: GerritInfo,
|
|
1867
|
+
change_id: str,
|
|
1868
|
+
) -> dict[str, str] | None:
|
|
1869
|
+
"""Query Gerrit for the current state of a change by Change-Id.
|
|
1870
|
+
|
|
1871
|
+
Scopes the query to the Gerrit project to avoid ambiguous
|
|
1872
|
+
Change-Id lookups across projects.
|
|
1873
|
+
|
|
1874
|
+
Returns:
|
|
1875
|
+
Dict with 'status', 'number' and 'current_revision' keys,
|
|
1876
|
+
or None when the change cannot be found or queried.
|
|
1877
|
+
"""
|
|
1878
|
+
try:
|
|
1879
|
+
from .gerrit_rest import build_client_for_host
|
|
1880
|
+
|
|
1881
|
+
client = build_client_for_host(
|
|
1882
|
+
gerrit.host, timeout=8.0, max_attempts=3
|
|
1883
|
+
)
|
|
1884
|
+
query = f"change:{change_id} project:{gerrit.project}"
|
|
1885
|
+
encoded_q = urllib.parse.quote(query, safe="")
|
|
1886
|
+
data = client.get(f"/changes/?q={encoded_q}&n=1&o=CURRENT_REVISION")
|
|
1887
|
+
if isinstance(data, list) and data:
|
|
1888
|
+
change = data[0]
|
|
1889
|
+
if isinstance(change, dict):
|
|
1890
|
+
return {
|
|
1891
|
+
"status": str(change.get("status", "")),
|
|
1892
|
+
"number": str(change.get("_number", "")),
|
|
1893
|
+
"current_revision": str(
|
|
1894
|
+
change.get("current_revision", "")
|
|
1895
|
+
),
|
|
1896
|
+
}
|
|
1897
|
+
except Exception as exc:
|
|
1898
|
+
log.debug("Failed to query state for change %s: %s", change_id, exc)
|
|
1899
|
+
return None
|
|
1900
|
+
|
|
1901
|
+
def _reconcile_final_state_changes(
|
|
1902
|
+
self,
|
|
1903
|
+
*,
|
|
1904
|
+
gh: GitHubContext,
|
|
1905
|
+
gerrit: GerritInfo,
|
|
1906
|
+
change_ids: list[str],
|
|
1907
|
+
) -> SubmissionResult | None:
|
|
1908
|
+
"""Reconcile GitHub PR state against final-state Gerrit changes.
|
|
1909
|
+
|
|
1910
|
+
When reconciliation resolves existing Change-Id(s) for a PR,
|
|
1911
|
+
verify their status in Gerrit before pushing. If every resolved
|
|
1912
|
+
change is already MERGED or ABANDONED, pushing a new patchset
|
|
1913
|
+
would be rejected ("change ... closed"). Instead, act on the
|
|
1914
|
+
GitHub PR:
|
|
1915
|
+
|
|
1916
|
+
- MERGED: close the PR with an explanatory comment (honoring
|
|
1917
|
+
CLOSE_MERGED_PRS; when false, comment only).
|
|
1918
|
+
- ABANDONED: close or comment per CLOSE_MERGED_PRS.
|
|
1919
|
+
|
|
1920
|
+
Returns:
|
|
1921
|
+
SubmissionResult describing the existing change(s) when the
|
|
1922
|
+
pipeline should stop (all changes final), or None to
|
|
1923
|
+
proceed with the normal push flow.
|
|
1924
|
+
"""
|
|
1925
|
+
if not change_ids or not gh.pr_number:
|
|
1926
|
+
return None
|
|
1927
|
+
|
|
1928
|
+
states: list[tuple[str, dict[str, str]]] = []
|
|
1929
|
+
for cid in change_ids:
|
|
1930
|
+
info = self._lookup_change_state(gerrit, cid)
|
|
1931
|
+
if info is None:
|
|
1932
|
+
# Fail open: unknown state must not block submission
|
|
1933
|
+
return None
|
|
1934
|
+
states.append((cid, info))
|
|
1935
|
+
|
|
1936
|
+
final_states = {"MERGED", "ABANDONED"}
|
|
1937
|
+
if not all(info["status"] in final_states for _, info in states):
|
|
1938
|
+
open_ids = [
|
|
1939
|
+
cid
|
|
1940
|
+
for cid, info in states
|
|
1941
|
+
if info["status"] not in final_states
|
|
1942
|
+
]
|
|
1943
|
+
closed_ids = [
|
|
1944
|
+
cid for cid, info in states if info["status"] in final_states
|
|
1945
|
+
]
|
|
1946
|
+
if closed_ids:
|
|
1947
|
+
log.warning(
|
|
1948
|
+
"Some reused Gerrit change(s) are already closed "
|
|
1949
|
+
"(%s); proceeding with open change(s): %s",
|
|
1950
|
+
", ".join(closed_ids),
|
|
1951
|
+
", ".join(open_ids),
|
|
1952
|
+
)
|
|
1953
|
+
return None
|
|
1954
|
+
|
|
1955
|
+
# All resolved changes are in a final state
|
|
1956
|
+
url_builder = create_gerrit_url_builder(gerrit.host)
|
|
1957
|
+
urls: list[str] = []
|
|
1958
|
+
nums: list[str] = []
|
|
1959
|
+
shas: list[str] = []
|
|
1960
|
+
for _cid, info in states:
|
|
1961
|
+
if info["number"]:
|
|
1962
|
+
try:
|
|
1963
|
+
change_number = int(info["number"])
|
|
1964
|
+
except ValueError:
|
|
1965
|
+
continue
|
|
1966
|
+
urls.append(
|
|
1967
|
+
url_builder.change_url(gerrit.project, change_number)
|
|
1968
|
+
)
|
|
1969
|
+
nums.append(info["number"])
|
|
1970
|
+
if info["current_revision"]:
|
|
1971
|
+
shas.append(info["current_revision"])
|
|
1972
|
+
|
|
1973
|
+
# Overall status: MERGED wins for messaging purposes
|
|
1974
|
+
overall_status: Literal["MERGED", "ABANDONED"] = (
|
|
1975
|
+
"MERGED"
|
|
1976
|
+
if any(info["status"] == "MERGED" for _, info in states)
|
|
1977
|
+
else "ABANDONED"
|
|
1978
|
+
)
|
|
1979
|
+
change_ref = ", ".join(urls) if urls else ", ".join(change_ids)
|
|
1980
|
+
log.info(
|
|
1981
|
+
"Gerrit change(s) for PR #%s already %s: %s",
|
|
1982
|
+
gh.pr_number,
|
|
1983
|
+
overall_status.lower(),
|
|
1984
|
+
change_ref,
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
close_merged_prs = env_bool("CLOSE_MERGED_PRS", True)
|
|
1988
|
+
pr_url = f"{gh.server_url}/{gh.repository}/pull/{gh.pr_number}"
|
|
1989
|
+
try:
|
|
1990
|
+
if close_merged_prs or overall_status == "ABANDONED":
|
|
1991
|
+
# Closes the PR, or posts a comment-only notification
|
|
1992
|
+
# for ABANDONED changes when CLOSE_MERGED_PRS is false
|
|
1993
|
+
from .gerrit_pr_closer import close_pr_with_status
|
|
1994
|
+
|
|
1995
|
+
close_pr_with_status(
|
|
1996
|
+
pr_url=pr_url,
|
|
1997
|
+
gerrit_change_url=change_ref if urls else None,
|
|
1998
|
+
gerrit_status=overall_status,
|
|
1999
|
+
dry_run=False,
|
|
2000
|
+
progress_tracker=None,
|
|
2001
|
+
close_merged_prs=close_merged_prs,
|
|
2002
|
+
)
|
|
2003
|
+
else:
|
|
2004
|
+
# close_pr_with_status posts no comment for MERGED
|
|
2005
|
+
# changes when CLOSE_MERGED_PRS is false; comment here
|
|
2006
|
+
# so the early pipeline stop is explained on the PR
|
|
2007
|
+
client = build_client()
|
|
2008
|
+
repo_obj = get_repo_from_env(client)
|
|
2009
|
+
pr_obj = get_pull(repo_obj, int(gh.pr_number))
|
|
2010
|
+
comment = (
|
|
2011
|
+
"The Gerrit change for this pull request has "
|
|
2012
|
+
f"merged: {change_ref}\n\n"
|
|
2013
|
+
"No further patchsets can be submitted; this pull "
|
|
2014
|
+
"request is now redundant and can be closed."
|
|
2015
|
+
)
|
|
2016
|
+
create_pr_comment(pr_obj, comment)
|
|
2017
|
+
except Exception as exc:
|
|
2018
|
+
log.warning(
|
|
2019
|
+
"Failed to reconcile GitHub PR #%s for %s Gerrit change: %s",
|
|
2020
|
+
gh.pr_number,
|
|
2021
|
+
overall_status.lower(),
|
|
2022
|
+
exc,
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
return SubmissionResult(
|
|
2026
|
+
change_urls=urls,
|
|
2027
|
+
change_numbers=nums,
|
|
2028
|
+
commit_shas=shas,
|
|
2029
|
+
)
|
|
2030
|
+
|
|
1863
2031
|
def execute(
|
|
1864
2032
|
self,
|
|
1865
2033
|
inputs: Inputs,
|
|
@@ -2032,6 +2200,18 @@ class Orchestrator:
|
|
|
2032
2200
|
inputs, gh, gerrit, single_commit
|
|
2033
2201
|
)
|
|
2034
2202
|
|
|
2203
|
+
# State reconciliation: when every Gerrit change resolved for
|
|
2204
|
+
# this PR is already in a final state (merged/abandoned), the
|
|
2205
|
+
# PR is redundant. Act on the GitHub PR instead of pushing a
|
|
2206
|
+
# patchset that Gerrit will reject with "change ... closed".
|
|
2207
|
+
early_result = self._reconcile_final_state_changes(
|
|
2208
|
+
gh=gh,
|
|
2209
|
+
gerrit=gerrit,
|
|
2210
|
+
change_ids=reuse_ids,
|
|
2211
|
+
)
|
|
2212
|
+
if early_result is not None:
|
|
2213
|
+
return early_result
|
|
2214
|
+
|
|
2035
2215
|
# Now prepare commits once with the correct reuse_ids
|
|
2036
2216
|
if inputs.submit_single_commits:
|
|
2037
2217
|
prep = self._prepare_single_commits(
|
|
@@ -4276,8 +4456,13 @@ class Orchestrator:
|
|
|
4276
4456
|
# Analyze the specific failure reason from git review output
|
|
4277
4457
|
error_details = self._analyze_gerrit_push_failure(exc)
|
|
4278
4458
|
|
|
4279
|
-
#
|
|
4280
|
-
|
|
4459
|
+
# Log the analyzed error; reserve the full traceback for
|
|
4460
|
+
# verbose/debug mode to keep console output actionable
|
|
4461
|
+
if is_verbose_mode():
|
|
4462
|
+
log.exception("Gerrit push failed: %s", error_details)
|
|
4463
|
+
else:
|
|
4464
|
+
# Deliberately omit the traceback outside verbose mode
|
|
4465
|
+
log.error("Gerrit push failed: %s", error_details) # noqa: TRY400
|
|
4281
4466
|
|
|
4282
4467
|
# In debug mode, also show the raw command output
|
|
4283
4468
|
if is_verbose_mode():
|
|
@@ -4827,6 +5012,11 @@ class Orchestrator:
|
|
|
4827
5012
|
)
|
|
4828
5013
|
if rejection_match:
|
|
4829
5014
|
reason = rejection_match.group(1).strip()
|
|
5015
|
+
if re.search(r"change\s+\S+\s+closed", reason):
|
|
5016
|
+
return (
|
|
5017
|
+
"Gerrit change is closed (merged or abandoned) "
|
|
5018
|
+
f"and cannot accept new patchsets: {reason}"
|
|
5019
|
+
)
|
|
4830
5020
|
return f"Gerrit rejected the push: {reason}"
|
|
4831
5021
|
|
|
4832
5022
|
# Fallback: look line by line
|
|
@@ -4835,6 +5025,12 @@ class Orchestrator:
|
|
|
4835
5025
|
if "! [remote rejected]" in line:
|
|
4836
5026
|
if "(" in line and ")" in line:
|
|
4837
5027
|
reason = line[line.find("(") + 1 : line.find(")")]
|
|
5028
|
+
if re.search(r"change\s+\S+\s+closed", reason):
|
|
5029
|
+
return (
|
|
5030
|
+
"Gerrit change is closed (merged or "
|
|
5031
|
+
"abandoned) and cannot accept new "
|
|
5032
|
+
f"patchsets: {reason}"
|
|
5033
|
+
)
|
|
4838
5034
|
return f"Gerrit rejected the push: {reason}"
|
|
4839
5035
|
return f"Gerrit rejected the push: {line.strip()}"
|
|
4840
5036
|
return "Gerrit rejected the push for an unknown reason"
|
|
@@ -473,6 +473,19 @@ def map_orchestrator_error_to_exit_code(
|
|
|
473
473
|
if original_exception and is_network_error(original_exception):
|
|
474
474
|
return ExitCode.NETWORK_ERROR
|
|
475
475
|
|
|
476
|
+
# Final-state Gerrit change patterns: pushing a patchset to a
|
|
477
|
+
# merged/abandoned change is a state problem, not a connection
|
|
478
|
+
# problem. Check before the connection patterns because these
|
|
479
|
+
# messages also contain "failed to push".
|
|
480
|
+
final_state_patterns = [
|
|
481
|
+
r"change is closed \(merged or abandoned\)",
|
|
482
|
+
r"cannot accept new patchsets",
|
|
483
|
+
r"change\s+\S+\s+closed",
|
|
484
|
+
]
|
|
485
|
+
|
|
486
|
+
if any(re.search(pattern, error_lower) for pattern in final_state_patterns):
|
|
487
|
+
return ExitCode.GERRIT_CHANGE_ALREADY_FINAL
|
|
488
|
+
|
|
476
489
|
# Gerrit connection error patterns
|
|
477
490
|
gerrit_patterns = [
|
|
478
491
|
r"failed to push",
|
|
@@ -542,6 +555,12 @@ def convert_orchestrator_error(
|
|
|
542
555
|
"❌ Git repository access failed; check repository permissions"
|
|
543
556
|
)
|
|
544
557
|
details = f"Repository issue: {error_msg}"
|
|
558
|
+
elif exit_code == ExitCode.GERRIT_CHANGE_ALREADY_FINAL:
|
|
559
|
+
user_message = (
|
|
560
|
+
"❌ Gerrit change is already merged or abandoned; "
|
|
561
|
+
"no new patchsets can be pushed"
|
|
562
|
+
)
|
|
563
|
+
details = f"Gerrit change state issue: {error_msg}"
|
|
545
564
|
else:
|
|
546
565
|
user_message = "❌ Operation failed; check logs for details"
|
|
547
566
|
details = error_msg
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 The Linux Foundation
|
|
3
|
+
|
|
4
|
+
"""Tests for final-state (merged/abandoned) Gerrit change reconciliation.
|
|
5
|
+
|
|
6
|
+
Covers the regression where a re-run against a PR whose Gerrit change
|
|
7
|
+
had already merged attempted to push a new patchset, which Gerrit
|
|
8
|
+
rejected with "change ... closed" and the tool then misreported as an
|
|
9
|
+
SSH/connection failure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from unittest import mock
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from github2gerrit.core import GerritInfo
|
|
22
|
+
from github2gerrit.core import Orchestrator
|
|
23
|
+
from github2gerrit.error_codes import ExitCode
|
|
24
|
+
from github2gerrit.error_codes import map_orchestrator_error_to_exit_code
|
|
25
|
+
from github2gerrit.gitutils import CommandError
|
|
26
|
+
from github2gerrit.models import GitHubContext
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gh_context(pr_number: int | None = 33) -> GitHubContext:
|
|
30
|
+
return GitHubContext(
|
|
31
|
+
event_name="pull_request_target",
|
|
32
|
+
event_action="synchronize",
|
|
33
|
+
event_path=None,
|
|
34
|
+
repository="onap/policy-opa-pdp",
|
|
35
|
+
repository_owner="onap",
|
|
36
|
+
server_url="https://github.com",
|
|
37
|
+
run_id="123",
|
|
38
|
+
sha="abc123",
|
|
39
|
+
base_ref="master",
|
|
40
|
+
head_ref="dependabot/github_actions/foo",
|
|
41
|
+
pr_number=pr_number,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _gerrit_info() -> GerritInfo:
|
|
46
|
+
return GerritInfo(
|
|
47
|
+
host="gerrit.example.org",
|
|
48
|
+
port=29418,
|
|
49
|
+
project="policy/opa-pdp",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestClosedChangePushAnalysis:
|
|
54
|
+
"""Push failure analysis for closed-change rejections."""
|
|
55
|
+
|
|
56
|
+
def setup_method(self) -> None:
|
|
57
|
+
self.workspace = Path(tempfile.mkdtemp())
|
|
58
|
+
self.orchestrator = Orchestrator(workspace=self.workspace)
|
|
59
|
+
|
|
60
|
+
def test_closed_change_rejection_message(self) -> None:
|
|
61
|
+
output = (
|
|
62
|
+
"remote: error: change is closed\n"
|
|
63
|
+
" ! [remote rejected] HEAD -> refs/for/master "
|
|
64
|
+
"(change https://gerrit.example.org/r/c/policy/opa-pdp/+/146080 "
|
|
65
|
+
"closed)\n"
|
|
66
|
+
"error: failed to push some refs\n"
|
|
67
|
+
)
|
|
68
|
+
exc = CommandError(
|
|
69
|
+
"Command failed",
|
|
70
|
+
cmd=["git", "review"],
|
|
71
|
+
returncode=1,
|
|
72
|
+
stdout="",
|
|
73
|
+
stderr=output,
|
|
74
|
+
)
|
|
75
|
+
result = self.orchestrator._analyze_gerrit_push_failure(exc)
|
|
76
|
+
|
|
77
|
+
assert "closed (merged or abandoned)" in result
|
|
78
|
+
assert "146080" in result
|
|
79
|
+
# Must not be reported as a generic rejection
|
|
80
|
+
assert not result.startswith("Gerrit rejected the push:")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestClosedChangeExitCode:
|
|
84
|
+
"""Exit code mapping for closed-change push failures."""
|
|
85
|
+
|
|
86
|
+
def test_closed_change_maps_to_final_state_exit_code(self) -> None:
|
|
87
|
+
msg = (
|
|
88
|
+
"Failed to push changes to Gerrit with git-review: "
|
|
89
|
+
"Gerrit change is closed (merged or abandoned) and cannot "
|
|
90
|
+
"accept new patchsets: change "
|
|
91
|
+
"https://gerrit.example.org/r/c/policy/opa-pdp/+/146080 closed"
|
|
92
|
+
)
|
|
93
|
+
assert (
|
|
94
|
+
map_orchestrator_error_to_exit_code(msg)
|
|
95
|
+
== ExitCode.GERRIT_CHANGE_ALREADY_FINAL
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_raw_closed_rejection_maps_to_final_state_exit_code(self) -> None:
|
|
99
|
+
msg = (
|
|
100
|
+
"Gerrit rejected the push: change "
|
|
101
|
+
"https://gerrit.example.org/r/c/x/+/1 closed"
|
|
102
|
+
)
|
|
103
|
+
assert (
|
|
104
|
+
map_orchestrator_error_to_exit_code(msg)
|
|
105
|
+
== ExitCode.GERRIT_CHANGE_ALREADY_FINAL
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def test_ssh_failure_still_maps_to_connection_error(self) -> None:
|
|
109
|
+
msg = (
|
|
110
|
+
"Failed to push changes to Gerrit with git-review: "
|
|
111
|
+
"SSH public key authentication failed."
|
|
112
|
+
)
|
|
113
|
+
assert (
|
|
114
|
+
map_orchestrator_error_to_exit_code(msg)
|
|
115
|
+
== ExitCode.GERRIT_CONNECTION_ERROR
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestReconcileFinalStateChanges:
|
|
120
|
+
"""Pre-push reconciliation of PRs whose Gerrit changes are final."""
|
|
121
|
+
|
|
122
|
+
def setup_method(self) -> None:
|
|
123
|
+
self.workspace = Path(tempfile.mkdtemp())
|
|
124
|
+
self.orchestrator = Orchestrator(workspace=self.workspace)
|
|
125
|
+
self.gh = _gh_context()
|
|
126
|
+
self.gerrit = _gerrit_info()
|
|
127
|
+
|
|
128
|
+
def _run(self, change_ids: list[str]) -> Any:
|
|
129
|
+
return self.orchestrator._reconcile_final_state_changes(
|
|
130
|
+
gh=self.gh,
|
|
131
|
+
gerrit=self.gerrit,
|
|
132
|
+
change_ids=change_ids,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def test_no_change_ids_proceeds(self) -> None:
|
|
136
|
+
assert self._run([]) is None
|
|
137
|
+
|
|
138
|
+
def test_open_change_proceeds(self) -> None:
|
|
139
|
+
with mock.patch.object(
|
|
140
|
+
self.orchestrator,
|
|
141
|
+
"_lookup_change_state",
|
|
142
|
+
return_value={
|
|
143
|
+
"status": "NEW",
|
|
144
|
+
"number": "146080",
|
|
145
|
+
"current_revision": "deadbeef",
|
|
146
|
+
},
|
|
147
|
+
):
|
|
148
|
+
assert self._run(["I" + "a" * 40]) is None
|
|
149
|
+
|
|
150
|
+
def test_unknown_state_fails_open(self) -> None:
|
|
151
|
+
with mock.patch.object(
|
|
152
|
+
self.orchestrator,
|
|
153
|
+
"_lookup_change_state",
|
|
154
|
+
return_value=None,
|
|
155
|
+
):
|
|
156
|
+
assert self._run(["I" + "a" * 40]) is None
|
|
157
|
+
|
|
158
|
+
def test_merged_change_closes_pr_and_stops(
|
|
159
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
160
|
+
) -> None:
|
|
161
|
+
monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
|
|
162
|
+
with (
|
|
163
|
+
mock.patch.object(
|
|
164
|
+
self.orchestrator,
|
|
165
|
+
"_lookup_change_state",
|
|
166
|
+
return_value={
|
|
167
|
+
"status": "MERGED",
|
|
168
|
+
"number": "146080",
|
|
169
|
+
"current_revision": "deadbeef",
|
|
170
|
+
},
|
|
171
|
+
),
|
|
172
|
+
mock.patch(
|
|
173
|
+
"github2gerrit.gerrit_pr_closer.close_pr_with_status",
|
|
174
|
+
return_value=True,
|
|
175
|
+
) as mock_close,
|
|
176
|
+
):
|
|
177
|
+
result = self._run(["I" + "a" * 40])
|
|
178
|
+
|
|
179
|
+
assert result is not None
|
|
180
|
+
assert result.change_numbers == ["146080"]
|
|
181
|
+
assert result.commit_shas == ["deadbeef"]
|
|
182
|
+
assert len(result.change_urls) == 1
|
|
183
|
+
assert "146080" in result.change_urls[0]
|
|
184
|
+
|
|
185
|
+
mock_close.assert_called_once()
|
|
186
|
+
kwargs = mock_close.call_args.kwargs
|
|
187
|
+
assert kwargs["pr_url"] == (
|
|
188
|
+
"https://github.com/onap/policy-opa-pdp/pull/33"
|
|
189
|
+
)
|
|
190
|
+
assert kwargs["gerrit_status"] == "MERGED"
|
|
191
|
+
assert kwargs["close_merged_prs"] is True
|
|
192
|
+
|
|
193
|
+
def test_merged_change_comments_when_close_disabled(
|
|
194
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
195
|
+
) -> None:
|
|
196
|
+
monkeypatch.setenv("CLOSE_MERGED_PRS", "false")
|
|
197
|
+
with (
|
|
198
|
+
mock.patch.object(
|
|
199
|
+
self.orchestrator,
|
|
200
|
+
"_lookup_change_state",
|
|
201
|
+
return_value={
|
|
202
|
+
"status": "MERGED",
|
|
203
|
+
"number": "146080",
|
|
204
|
+
"current_revision": "deadbeef",
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
mock.patch(
|
|
208
|
+
"github2gerrit.gerrit_pr_closer.close_pr_with_status",
|
|
209
|
+
) as mock_close,
|
|
210
|
+
mock.patch("github2gerrit.core.build_client"),
|
|
211
|
+
mock.patch("github2gerrit.core.get_repo_from_env"),
|
|
212
|
+
mock.patch("github2gerrit.core.get_pull"),
|
|
213
|
+
mock.patch("github2gerrit.core.create_pr_comment") as mock_comment,
|
|
214
|
+
):
|
|
215
|
+
result = self._run(["I" + "a" * 40])
|
|
216
|
+
|
|
217
|
+
# Pipeline still stops, the PR stays open, and users get an
|
|
218
|
+
# explanatory comment instead of silence
|
|
219
|
+
assert result is not None
|
|
220
|
+
mock_close.assert_not_called()
|
|
221
|
+
mock_comment.assert_called_once()
|
|
222
|
+
comment_body = mock_comment.call_args.args[1]
|
|
223
|
+
assert "merged" in comment_body
|
|
224
|
+
assert "146080" in comment_body
|
|
225
|
+
|
|
226
|
+
def test_multiple_merged_changes_reference_all_urls(
|
|
227
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
228
|
+
) -> None:
|
|
229
|
+
monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
|
|
230
|
+
states = iter(
|
|
231
|
+
[
|
|
232
|
+
{
|
|
233
|
+
"status": "MERGED",
|
|
234
|
+
"number": "101",
|
|
235
|
+
"current_revision": "aaa",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
"status": "MERGED",
|
|
239
|
+
"number": "102",
|
|
240
|
+
"current_revision": "bbb",
|
|
241
|
+
},
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
with (
|
|
245
|
+
mock.patch.object(
|
|
246
|
+
self.orchestrator,
|
|
247
|
+
"_lookup_change_state",
|
|
248
|
+
side_effect=lambda *_a, **_k: next(states),
|
|
249
|
+
),
|
|
250
|
+
mock.patch(
|
|
251
|
+
"github2gerrit.gerrit_pr_closer.close_pr_with_status",
|
|
252
|
+
return_value=True,
|
|
253
|
+
) as mock_close,
|
|
254
|
+
):
|
|
255
|
+
result = self._run(["I" + "a" * 40, "I" + "b" * 40])
|
|
256
|
+
|
|
257
|
+
assert result is not None
|
|
258
|
+
assert result.change_numbers == ["101", "102"]
|
|
259
|
+
# The PR close/comment must reference every reconciled change
|
|
260
|
+
change_url_arg = mock_close.call_args.kwargs["gerrit_change_url"]
|
|
261
|
+
assert "101" in change_url_arg
|
|
262
|
+
assert "102" in change_url_arg
|
|
263
|
+
|
|
264
|
+
def test_abandoned_change_stops_with_abandoned_status(
|
|
265
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
266
|
+
) -> None:
|
|
267
|
+
monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
|
|
268
|
+
with (
|
|
269
|
+
mock.patch.object(
|
|
270
|
+
self.orchestrator,
|
|
271
|
+
"_lookup_change_state",
|
|
272
|
+
return_value={
|
|
273
|
+
"status": "ABANDONED",
|
|
274
|
+
"number": "146081",
|
|
275
|
+
"current_revision": "",
|
|
276
|
+
},
|
|
277
|
+
),
|
|
278
|
+
mock.patch(
|
|
279
|
+
"github2gerrit.gerrit_pr_closer.close_pr_with_status",
|
|
280
|
+
return_value=True,
|
|
281
|
+
) as mock_close,
|
|
282
|
+
):
|
|
283
|
+
result = self._run(["I" + "b" * 40])
|
|
284
|
+
|
|
285
|
+
assert result is not None
|
|
286
|
+
assert mock_close.call_args.kwargs["gerrit_status"] == "ABANDONED"
|
|
287
|
+
|
|
288
|
+
def test_mixed_states_proceeds_with_warning(self) -> None:
|
|
289
|
+
states = iter(
|
|
290
|
+
[
|
|
291
|
+
{
|
|
292
|
+
"status": "MERGED",
|
|
293
|
+
"number": "1",
|
|
294
|
+
"current_revision": "aaa",
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"status": "NEW",
|
|
298
|
+
"number": "2",
|
|
299
|
+
"current_revision": "bbb",
|
|
300
|
+
},
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
with mock.patch.object(
|
|
304
|
+
self.orchestrator,
|
|
305
|
+
"_lookup_change_state",
|
|
306
|
+
side_effect=lambda *_a, **_k: next(states),
|
|
307
|
+
):
|
|
308
|
+
assert self._run(["I" + "a" * 40, "I" + "b" * 40]) is None
|
|
309
|
+
|
|
310
|
+
def test_close_failure_still_stops_pipeline(
|
|
311
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
312
|
+
) -> None:
|
|
313
|
+
monkeypatch.setenv("CLOSE_MERGED_PRS", "true")
|
|
314
|
+
with (
|
|
315
|
+
mock.patch.object(
|
|
316
|
+
self.orchestrator,
|
|
317
|
+
"_lookup_change_state",
|
|
318
|
+
return_value={
|
|
319
|
+
"status": "MERGED",
|
|
320
|
+
"number": "146080",
|
|
321
|
+
"current_revision": "deadbeef",
|
|
322
|
+
},
|
|
323
|
+
),
|
|
324
|
+
mock.patch(
|
|
325
|
+
"github2gerrit.gerrit_pr_closer.close_pr_with_status",
|
|
326
|
+
side_effect=RuntimeError("GitHub API error"),
|
|
327
|
+
),
|
|
328
|
+
):
|
|
329
|
+
result = self._run(["I" + "a" * 40])
|
|
330
|
+
|
|
331
|
+
# Even when the PR closure fails, we must not attempt the
|
|
332
|
+
# doomed push to a closed change.
|
|
333
|
+
assert result is not None
|