diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py index 3aa0edf64..27305dc2e 100644 --- a/.gitea/scripts/gitea-merge-queue.py +++ b/.gitea/scripts/gitea-merge-queue.py @@ -112,6 +112,11 @@ class MergePermissionError(ApiError): The queue should HOLD this PR and move to the next one.""" +class MergeConflictError(ApiError): + """Branch update failed with merge conflict (409). + The queue should HOLD this PR and move to the next one.""" + + class BranchProtectionUnavailable(ApiError): """Branch protection (the authoritative required-context source) could not be enumerated. The queue must HOLD rather than merge with an unverified @@ -584,12 +589,18 @@ def update_pull(pr_number: int, *, dry_run: bool) -> None: print(f"::notice::updating PR #{pr_number} with base branch via style={UPDATE_STYLE}") if dry_run: return - api( - "POST", - f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/update", - query={"style": UPDATE_STYLE}, - expect_json=False, - ) + try: + api( + "POST", + f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/update", + query={"style": UPDATE_STYLE}, + expect_json=False, + ) + except ApiError as exc: + msg = str(exc) + if "409" in msg: + raise MergeConflictError(msg) from exc + raise def add_label_by_name(pr_number: int, label_name: str, *, dry_run: bool) -> None: @@ -737,7 +748,31 @@ def process_once(*, dry_run: bool = False) -> int: print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}") if decision.action == "update": - update_pull(pr_number, dry_run=dry_run) + try: + update_pull(pr_number, dry_run=dry_run) + except MergeConflictError as exc: + # Persistent 409-conflict-on-update: the queue cannot auto-resolve + # this. Hold the PR so the queue advances to other ready PRs; a + # human/agent must rebase manually. + sys.stderr.write(f"::error::merge-queue conflict on update for PR #{pr_number}: {exc}\n") + hold_note = ( + "merge-queue: branch update failed with HTTP 409 merge conflict. " + "This PR cannot be automatically updated because it conflicts with the base branch. " + f"Applied `{HOLD_LABEL}` to unblock the queue. " + "A human or agent must rebase it manually, then remove the hold label to requeue." + ) + try: + add_label_by_name(pr_number, HOLD_LABEL, dry_run=dry_run) + except ApiError as label_exc: + sys.stderr.write( + f"::error::could not apply HOLD_LABEL to PR #{pr_number}: {label_exc}\n" + ) + hold_note += ( + f"\n\n(NOTE: could not apply the hold label automatically: " + f"{label_exc}. Please add `{HOLD_LABEL}` manually.)" + ) + post_comment(pr_number, hold_note, dry_run=dry_run) + return 0 post_comment( pr_number, ( diff --git a/.gitea/scripts/tests/test_gitea_merge_queue.py b/.gitea/scripts/tests/test_gitea_merge_queue.py index 440f90dfe..a7653fdcd 100644 --- a/.gitea/scripts/tests/test_gitea_merge_queue.py +++ b/.gitea/scripts/tests/test_gitea_merge_queue.py @@ -2,6 +2,8 @@ import importlib.util import sys from pathlib import Path +import pytest + SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py" spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT) mq = importlib.util.module_from_spec(spec) @@ -539,3 +541,41 @@ def test_process_once_holds_tick_when_branch_protection_unavailable(monkeypatch) rc = mq.process_once(dry_run=False) assert rc == 0 assert merged["called"] is False + + +def test_MergeConflictError_inherits_from_ApiError(): + assert issubclass(mq.MergeConflictError, mq.ApiError) + + +def test_MergeConflictError_message_preserved(): + exc = mq.MergeConflictError("POST /update -> HTTP 409: merge conflict") + assert "409" in str(exc) + assert "merge conflict" in str(exc) + + +def test_update_pull_raises_MergeConflictError_on_409(monkeypatch): + calls = [] + + def fake_api(method, path, **kwargs): + calls.append((method, path)) + raise mq.ApiError("POST /pulls/42/update -> HTTP 409: merge conflict") + + monkeypatch.setattr(mq, "api", fake_api) + with pytest.raises(mq.MergeConflictError) as exc_info: + mq.update_pull(42, dry_run=False) + assert "409" in str(exc_info.value) + assert calls == [("POST", "/repos///pulls/42/update")] + + +def test_update_pull_re_raises_non_409_ApiError(monkeypatch): + calls = [] + + def fake_api(method, path, **kwargs): + calls.append((method, path)) + raise mq.ApiError("POST /pulls/42/update -> HTTP 500: internal server error") + + monkeypatch.setattr(mq, "api", fake_api) + with pytest.raises(mq.ApiError) as exc_info: + mq.update_pull(42, dry_run=False) + assert "500" in str(exc_info.value) + assert not isinstance(exc_info.value, mq.MergeConflictError)