fix(merge-queue): treat 409-conflict-on-update as HOLD so queue advances (#2352) #2353

Closed
core-be wants to merge 1 commits from fix/2352-merge-queue-409-hold into main
2 changed files with 82 additions and 7 deletions
+42 -7
View File
@@ -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,
(
@@ -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)