merge-queue: auto-discovery (opt-OUT, label-optional) for self-sustaining autonomy
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 10s
E2E Chat / detect-changes (pull_request) Successful in 10s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 16s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 9s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 6s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 35s
lint-required-workflows-docker-host-pinned / Lint docker-host pin on docker-touching workflows (pull_request) Successful in 11s
CI / Canvas (Next.js) (pull_request) Successful in 7s
CI / Platform (Go) (pull_request) Successful in 15s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 3s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m9s
CI / all-required (pull_request) Successful in 12s
E2E Chat / E2E Chat (pull_request) Successful in 16s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m42s
gate-check-v3 / gate-check (pull_request_target) Successful in 17s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 11s
qa-review / approved (pull_request_target) Failing after 24s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request_target) Failing after 10s
security-review / approved (pull_request_target) Failing after 24s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m18s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m10s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m22s
CI / Canvas Deploy Status (pull_request) Has been skipped
qa-review / approved (pull_request_review) Has been skipped
security-review / approved (pull_request_review) Has been skipped
sop-tier-check / tier-check (pull_request_review) Failing after 12s
lint-required-no-paths / lint-required-no-paths (pull_request) Has been cancelled

The external Gitea merge queue only considered PRs that already carried the
`merge-queue` label. Agent Gitea tokens lack `write:issue` (labels are
issue-scoped), so agents could never self-label a ready PR — the queue stalled
waiting on a human to add the label, blocking core-PR autonomy (#2355).

Fix: merge-on-criteria, label-optional. The cron now AUTO-DISCOVERS every open
same-repo PR and considers any that meets the unchanged merge bar. The
`merge-queue` label is now optional metadata, not a gate — this fully removes
the write:issue dependency (the cron itself never needs to add a label).

SAFETY is preserved as opt-OUT: a PR carrying any opt-out label
(`merge-queue-hold`, `do-not-auto-merge`, or `wip`) or marked draft is skipped
(never auto-considered, never merged). A human keeps a PR out of autonomous
merging by adding one of those labels. `AUTO_DISCOVER=0` restores legacy opt-IN.

The merge bar is UNCHANGED: still 2 genuine official approvals on the CURRENT
head from {agent-reviewer, agent-researcher, agent-reviewer-cr2}, all
branch-protection-required contexts green, mergeable=True (fail-closed on
None/False per #2349/#2352), and no open REQUEST_CHANGES. Auto-discovery only
changes WHICH PRs are considered, not whether they may merge.

- new `do-not-auto-merge` (id 78) + `wip` (id 79) repo labels
- `choose_next_candidate_issue` / `list_candidate_issues` for the opt-OUT,
  draft-skipping selection; legacy `choose_next_queued_issue` retained
- defensive opt-out/draft re-check on the live pull payload (stale-listing race)
- 15 new §SOP-22 regression tests; existing 26 kept green (41 total)
- workflow + runbook updated (AUTO_DISCOVER / OPT_OUT_LABELS documented)

Verified live (dry-run): auto-discovery selects unlabeled PR #1519 (the old
code never touched it); AUTO_DISCOVER=0 still selects only labeled #2346.

Helps #2355 (autonomy expansion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
devops-engineer
2026-06-06 01:53:31 -07:00
parent be1f38b7b5
commit 0c311bbc1b
4 changed files with 519 additions and 25 deletions
+141 -5
View File
@@ -4,7 +4,8 @@
Gitea 1.22.6+ has auto-merge (`pull_auto_merge`) but no GitHub-style merge
queue. This script provides the missing serialized policy in user space:
1. Pick the oldest open PR carrying QUEUE_LABEL (skipping HOLD_LABEL).
1. Pick the oldest open same-repo PR that is NOT opted out (auto-discovery,
see below), skipping drafts.
2. Refuse to act unless main's BP-required contexts are green.
3. Refuse fork PRs; the queue may only mutate same-repo branches.
4. If the PR branch does not contain current main, call Gitea's
@@ -29,6 +30,26 @@ Authoritative gates (fail-closed):
approvals present). It is NEVER used to bypass a failing REQUIRED context
or missing approvals.
Auto-discovery (opt-OUT, label-optional):
The queue is SELF-SUSTAINING — a ready PR does NOT need a human (or an agent)
to add the `merge-queue` label first. When AUTO_DISCOVER is on (default), the
queue enumerates ALL open same-repo PRs and considers any that meets the full
merge bar (genuine approvals on current head + BP-required green + mergeable +
no open REQUEST_CHANGES). The merge bar above is UNCHANGED; auto-discovery only
changes WHICH PRs are considered, not whether they are mergeable.
This deliberately removes the historical dependency on an agent adding the
`merge-queue` label — agent Gitea tokens lack `write:issue` (labels are
issue-scoped), so they could never self-label and the queue stalled. The label
is now OPTIONAL metadata, not a gate.
SAFETY is preserved as opt-OUT: any PR carrying an opt-out label
(OPT_OUT_LABELS — `merge-queue-hold`, `do-not-auto-merge`, `wip` by default) is
skipped (never auto-considered, never merged). Draft PRs (draft=true) are also
skipped. A human who wants to keep a PR out of autonomous merging just adds one
of those labels. Setting AUTO_DISCOVER=0 restores the legacy opt-IN behaviour
(only PRs already carrying QUEUE_LABEL are considered).
Head-of-line (HOL) safety: a permanent permission/4xx merge error
(403/404/405) HOLDS the PR (applies HOLD_LABEL) so the queue advances to the
next PR instead of re-selecting the same wedged PR every tick.
@@ -64,6 +85,30 @@ WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
QUEUE_LABEL = _env("QUEUE_LABEL", default="merge-queue")
HOLD_LABEL = _env("HOLD_LABEL", default="merge-queue-hold")
UPDATE_STYLE = _env("UPDATE_STYLE", default="merge")
# Auto-discovery (opt-OUT). When truthy (default), the queue considers ALL open
# same-repo PRs that meet the merge bar, not only PRs already carrying
# QUEUE_LABEL — so the queue is self-sustaining without any human/agent labeling
# (agent tokens lack write:issue and cannot self-label). Set AUTO_DISCOVER=0 to
# restore the legacy opt-IN behaviour (QUEUE_LABEL required to be considered).
AUTO_DISCOVER = _env("AUTO_DISCOVER", default="1").strip().lower() not in {
"0",
"false",
"no",
"off",
"",
}
# Opt-OUT labels. A PR carrying ANY of these is skipped (never auto-considered,
# never merged) — the human escape hatch from autonomous merging. HOLD_LABEL is
# always included so the existing hold semantics keep working. `do-not-auto-merge`
# and `wip` let a human keep a PR out of the auto-merge path without removing it.
OPT_OUT_LABELS = {
name.strip()
for name in _env(
"OPT_OUT_LABELS",
default="do-not-auto-merge,wip",
).split(",")
if name.strip()
} | ({HOLD_LABEL} if HOLD_LABEL else set())
REQUIRED_CONTEXTS_RAW = _env(
"REQUIRED_CONTEXTS",
default=(
@@ -392,6 +437,58 @@ def choose_next_queued_issue(
return candidates[0] if candidates else None
def _issue_is_draft(issue: dict) -> bool:
"""True if the issue/PR is a draft.
The /issues listing exposes draft state under the `pull_request` sub-object
(`{"draft": true}`); some Gitea versions also surface a top-level `draft`.
Either is honoured. Drafts are never auto-considered for merging.
"""
pr = issue.get("pull_request")
if isinstance(pr, dict) and pr.get("draft") is True:
return True
return issue.get("draft") is True
def choose_next_candidate_issue(
issues: list[dict],
*,
queue_label: str,
opt_out_labels: set[str],
auto_discover: bool,
) -> dict | None:
"""Pick the oldest open PR eligible for a merge attempt this tick.
This is the auto-discovery selector. It does NOT change the merge bar — it
only changes WHICH PRs are considered:
- auto_discover=True (default): every open same-repo PR is a candidate,
EXCEPT those carrying an opt-out label or marked draft. The QUEUE_LABEL
is optional metadata, not a gate, so a ready PR reaches the queue with no
human/agent labeling (the write:issue gap is removed).
- auto_discover=False: legacy opt-IN — only PRs carrying queue_label are
candidates (still skipping opt-out labels and drafts).
Opt-out is the safety escape hatch: any opt_out_labels member present skips
the PR entirely (never considered, never merged). Selection is oldest-first
(created_at, then number) to preserve the serialized FIFO ordering.
"""
candidates = []
for issue in issues:
if "pull_request" not in issue:
continue
labels = label_names(issue)
if opt_out_labels & labels:
continue # opt-out: human kept this PR out of autonomous merging
if _issue_is_draft(issue):
continue # drafts are never auto-merged
if not auto_discover and queue_label not in labels:
continue # legacy opt-IN: require the queue label
candidates.append(issue)
candidates.sort(key=lambda issue: (issue.get("created_at") or "", int(issue["number"])))
return candidates[0] if candidates else None
def pr_contains_base_sha(commits: list[dict], base_sha: str) -> bool:
for commit in commits:
sha = commit.get("sha") or commit.get("id")
@@ -559,6 +656,31 @@ def list_queued_issues() -> list[dict]:
return body
def list_candidate_issues(*, auto_discover: bool) -> list[dict]:
"""Open PR issues eligible for consideration this tick.
With auto_discover=True (default) this enumerates ALL open PRs (no label
filter) so the queue is self-sustaining — a ready PR is considered without
any human/agent first adding QUEUE_LABEL. With auto_discover=False it falls
back to the legacy label-filtered listing (opt-IN). Opt-out filtering and
draft-skipping happen in choose_next_candidate_issue, not here.
"""
if not auto_discover:
return list_queued_issues()
_, body = api(
"GET",
f"/repos/{OWNER}/{NAME}/issues",
query={
"state": "open",
"type": "pulls",
"limit": "50",
},
)
if not isinstance(body, list):
raise ApiError("candidate issues response not list")
return body
def get_pull(pr_number: int) -> dict:
_, body = api("GET", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}")
if not isinstance(body, dict):
@@ -678,13 +800,17 @@ def process_once(*, dry_run: bool = False) -> int:
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
return 0
issue = choose_next_queued_issue(
list_queued_issues(),
issue = choose_next_candidate_issue(
list_candidate_issues(auto_discover=AUTO_DISCOVER),
queue_label=QUEUE_LABEL,
hold_label=HOLD_LABEL,
opt_out_labels=OPT_OUT_LABELS,
auto_discover=AUTO_DISCOVER,
)
if not issue:
print("::notice::merge queue empty")
print(
"::notice::no merge candidates "
f"(auto_discover={'on' if AUTO_DISCOVER else 'off'})"
)
return 0
pr_number = int(issue["number"])
@@ -692,6 +818,16 @@ def process_once(*, dry_run: bool = False) -> int:
if pr.get("state") != "open":
print(f"::notice::PR #{pr_number} is not open; skipping")
return 0
# Defensive opt-out/draft re-check on the authoritative pull payload: the
# /issues listing's label/draft view can lag, but the merge bar must respect
# the live pull state. (choose_next_candidate_issue already filtered on the
# listing; this guards against a stale listing racing a just-added opt-out.)
if OPT_OUT_LABELS & label_names(pr):
print(f"::notice::PR #{pr_number} carries an opt-out label; skipping")
return 0
if pr.get("draft") is True:
print(f"::notice::PR #{pr_number} is a draft; skipping")
return 0
if pr.get("base", {}).get("ref") != WATCH_BRANCH:
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
return 0
+329 -3
View File
@@ -308,6 +308,8 @@ def test_process_once_holds_pr_on_permanent_merge_error(monkeypatch):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
@@ -324,7 +326,7 @@ def test_process_once_holds_pr_on_permanent_merge_error(monkeypatch):
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_queued_issues", lambda: [
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
{"number": 100, "pull_request": {}, "labels": [{"name": "merge-queue"}],
"created_at": "2026-06-01T00:00:00Z"},
])
@@ -374,6 +376,8 @@ def _fully_ready_process_once_monkeypatch(monkeypatch, mergeable, calls):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
@@ -389,7 +393,7 @@ def _fully_ready_process_once_monkeypatch(monkeypatch, mergeable, calls):
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_queued_issues", lambda: [
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
{"number": 102, "pull_request": {}, "labels": [{"name": "merge-queue"}],
"created_at": "2026-06-01T00:00:00Z"},
])
@@ -484,6 +488,8 @@ def test_status_fetch_failure_is_fail_closed(monkeypatch):
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", {"merge-queue-hold", "do-not-auto-merge", "wip"})
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
@@ -501,7 +507,7 @@ def test_status_fetch_failure_is_fail_closed(monkeypatch):
raise mq.ApiError("GET /commits/HEAD/status -> HTTP 502: bad gateway")
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_queued_issues", lambda: [
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: [
{"number": 101, "pull_request": {}, "labels": [{"name": "merge-queue"}],
"created_at": "2026-06-01T00:00:00Z"},
])
@@ -539,3 +545,323 @@ 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
# --------------------------------------------------------------------------
# §SOP-22: AUTO-DISCOVERY (opt-OUT, label-optional). The queue must be
# self-sustaining — a ready PR is considered/merged with NO `merge-queue`
# label, while opt-out labels (merge-queue-hold / do-not-auto-merge / wip) and
# drafts are skipped. The merge bar (approvals/required-green/mergeable) is
# unchanged; only candidate selection changes.
# --------------------------------------------------------------------------
OPT_OUT = {"merge-queue-hold", "do-not-auto-merge", "wip"}
def _issue(number, labels, *, created="2026-06-01T00:00:00Z", draft=False, is_pr=True):
pr = {"draft": draft} if is_pr else None
out = {
"number": number,
"labels": [{"name": n} for n in labels],
"created_at": created,
}
if pr is not None:
out["pull_request"] = pr
return out
def test_auto_discover_selects_unlabeled_ready_pr():
"""A ready PR with NO merge-queue label is auto-considered (the autonomy fix:
agents cannot self-label because their token lacks write:issue)."""
issues = [_issue(50, labels=[])] # no merge-queue label at all
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is not None
assert selected["number"] == 50
def test_auto_discover_skips_opt_out_labels():
"""Each opt-out label keeps a PR OUT of autonomous merging (the human escape
hatch). A PR carrying any of them is never selected even though it is open."""
for optout in OPT_OUT:
issues = [_issue(60, labels=[optout])]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None, f"{optout!r} should opt the PR out"
def test_auto_discover_skips_opt_out_even_when_queue_labeled():
"""An opt-out label beats the merge-queue label: a held/wip PR that also
carries merge-queue is still skipped."""
issues = [_issue(61, labels=["merge-queue", "wip"])]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None
def test_auto_discover_skips_drafts():
issues = [_issue(62, labels=[], draft=True)]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None
def test_auto_discover_skips_non_pull_issues():
"""A plain issue (no pull_request key) is never a merge candidate."""
issues = [_issue(63, labels=[], is_pr=False)]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected is None
def test_auto_discover_oldest_first_skipping_opt_out():
"""Selection is FIFO (oldest created_at first), and the opt-out PR is passed
over for the next-oldest eligible PR."""
issues = [
_issue(70, labels=["do-not-auto-merge"], created="2026-06-01T01:00:00Z"),
_issue(71, labels=[], created="2026-06-01T02:00:00Z"),
_issue(72, labels=["merge-queue"], created="2026-06-01T03:00:00Z"),
]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=True
)
assert selected["number"] == 71 # 70 opted out, 71 is next-oldest eligible
def test_opt_in_mode_requires_queue_label():
"""AUTO_DISCOVER off restores legacy opt-IN: only merge-queue-labeled PRs are
candidates; an unlabeled ready PR is NOT selected."""
issues = [
_issue(80, labels=[], created="2026-06-01T01:00:00Z"),
_issue(81, labels=["merge-queue"], created="2026-06-01T02:00:00Z"),
]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=False
)
assert selected["number"] == 81
def test_opt_in_mode_still_honours_opt_out():
"""Even in opt-IN mode, an opt-out label on a queue-labeled PR skips it."""
issues = [_issue(82, labels=["merge-queue", "merge-queue-hold"])]
selected = mq.choose_next_candidate_issue(
issues, queue_label="merge-queue", opt_out_labels=OPT_OUT, auto_discover=False
)
assert selected is None
def test_list_candidate_issues_omits_label_filter_when_auto_discover(monkeypatch):
"""The auto-discovery listing must NOT pass a `labels` filter (so unlabeled
PRs are enumerated); the opt-IN listing must keep filtering by QUEUE_LABEL."""
captured = {}
def fake_api(method, path, *, query=None, **kw):
captured["query"] = dict(query or {})
return 200, []
monkeypatch.setattr(mq, "api", fake_api)
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
mq.list_candidate_issues(auto_discover=True)
assert "labels" not in captured["query"]
assert captured["query"].get("type") == "pulls"
mq.list_candidate_issues(auto_discover=False)
assert captured["query"].get("labels") == "merge-queue"
def _wire_ready_process_once(monkeypatch, *, issues, pr_payload, calls):
"""Wire process_once fully green EXCEPT candidate selection / pull payload,
which the caller supplies to exercise auto-discovery end-to-end."""
monkeypatch.setattr(mq, "OWNER", "molecule-ai")
monkeypatch.setattr(mq, "NAME", "molecule-core")
monkeypatch.setattr(mq, "WATCH_BRANCH", "main")
monkeypatch.setattr(mq, "QUEUE_LABEL", "merge-queue")
monkeypatch.setattr(mq, "HOLD_LABEL", "merge-queue-hold")
monkeypatch.setattr(mq, "AUTO_DISCOVER", True)
monkeypatch.setattr(mq, "OPT_OUT_LABELS", OPT_OUT)
monkeypatch.setattr(mq, "REVIEWER_SET", REVIEWERS)
monkeypatch.setattr(mq, "get_branch_protection", lambda branch: mq.BranchProtection(
required_contexts=["CI / all-required (pull_request)"],
required_approvals=2, block_on_rejected_reviews=True,
))
main_sha = "b" * 40
head_sha = "a" * 40
monkeypatch.setattr(mq, "get_branch_head", lambda branch: main_sha)
def fake_combined(sha):
ctx = "CI / all-required (push)" if sha == main_sha else "CI / all-required (pull_request)"
return {"state": "success", "statuses": [{"context": ctx, "status": "success"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
monkeypatch.setattr(mq, "list_candidate_issues", lambda *, auto_discover: issues)
monkeypatch.setattr(mq, "get_pull", lambda n: dict(pr_payload, number=n))
monkeypatch.setattr(mq, "get_pull_commits", lambda n: [{"sha": main_sha}, {"sha": head_sha}])
monkeypatch.setattr(mq, "get_pull_reviews", lambda n: [
{"state": "APPROVED", "user": {"login": "agent-researcher"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
{"state": "APPROVED", "user": {"login": "agent-reviewer-cr2"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
])
def fake_merge(pr_number, *, dry_run, force=False):
calls["merged"] = pr_number
monkeypatch.setattr(mq, "merge_pull", fake_merge)
monkeypatch.setattr(mq, "update_pull", lambda *a, **k: calls.__setitem__("updated", True))
monkeypatch.setattr(mq, "post_comment", lambda *a, **k: None)
monkeypatch.setattr(mq, "add_label_by_name", lambda *a, **k: None)
return main_sha, head_sha
def test_process_once_auto_merges_unlabeled_ready_pr(monkeypatch):
"""End-to-end: a fully-ready PR with NO merge-queue label is auto-merged.
This is the core autonomy fix — no human/agent labeling required."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(90, labels=[])], # NO merge-queue label
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] == 90 # merged despite no merge-queue label
def test_process_once_skips_opt_out_labeled_pr(monkeypatch):
"""A fully-ready PR carrying an opt-out label is NOT merged (skipped)."""
for optout in OPT_OUT:
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(91, labels=[optout])],
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [{"name": optout}],
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None, f"{optout!r} PR must not be merged"
def test_process_once_does_not_merge_unapproved_pr(monkeypatch):
"""A not-ready PR (only one genuine approval) is auto-considered but NOT
merged — auto-discovery does not lower the merge bar."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
main_sha, _ = _wire_ready_process_once(
monkeypatch,
issues=[_issue(92, labels=[])],
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
# Only ONE genuine approval → below the required 2.
monkeypatch.setattr(mq, "get_pull_reviews", lambda n: [
{"state": "APPROVED", "user": {"login": "agent-researcher"},
"official": True, "stale": False, "dismissed": False, "commit_id": head_sha},
])
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
def test_process_once_does_not_merge_red_required_pr(monkeypatch):
"""A not-ready PR (required context red) is auto-considered but NOT merged."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
main_sha = "b" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(93, labels=[])],
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
# Required PR context is FAILURE; main stays green.
def fake_combined(sha):
if sha == main_sha:
return {"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}]}
return {"state": "failure",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "failure"}]}
monkeypatch.setattr(mq, "get_combined_status", fake_combined)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
def test_process_once_does_not_merge_unmergeable_pr(monkeypatch):
"""A not-ready PR (mergeable False = conflicts) is auto-considered but NOT
merged."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(94, labels=[])],
pr_payload={
"state": "open", "mergeable": False, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [],
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
def test_process_once_defensive_skip_when_pull_payload_opted_out(monkeypatch):
"""If the listing missed an opt-out label but the authoritative pull payload
carries it (stale listing race), process_once must still skip the merge."""
calls = {"merged": None, "updated": False}
head_sha = "a" * 40
_wire_ready_process_once(
monkeypatch,
issues=[_issue(95, labels=[])], # listing shows no opt-out
pr_payload={
"state": "open", "mergeable": True, "draft": False,
"base": {"ref": "main", "repo_id": 1},
"head": {"sha": head_sha, "repo_id": 1},
"labels": [{"name": "do-not-auto-merge"}], # live pull is opted out
},
calls=calls,
)
rc = mq.process_once(dry_run=False)
assert rc == 0
assert calls["merged"] is None
+19 -3
View File
@@ -7,10 +7,13 @@ name: gitea-merge-queue
# the user-space queue bot, one PR per tick, using the non-bypass merge actor.
#
# Queue contract:
# - add label `merge-queue` to an open same-repo PR
# - auto-discovery (default): any open same-repo PR is considered — no
# `merge-queue` label required (the label is optional metadata now)
# - bot updates stale PR heads with current main, then waits for CI
# - bot merges only when current main is green and required PR contexts pass
# - add `merge-queue-hold` to pause a queued PR without removing it
# - bot merges only when current main is green, genuine approvals are present
# on the current head, required PR contexts pass, and the PR is mergeable
# - add `merge-queue-hold`, `do-not-auto-merge`, or `wip` to keep a PR OUT of
# autonomous merging; draft PRs are also skipped
on:
# Schedule moved to operator-config:
@@ -48,6 +51,19 @@ jobs:
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
QUEUE_LABEL: merge-queue
HOLD_LABEL: merge-queue-hold
# Auto-discovery (opt-OUT). When on (default), the queue considers ALL
# open same-repo PRs that meet the merge bar — it does NOT wait for a
# human/agent to add `merge-queue`. Agent Gitea tokens lack
# write:issue (labels are issue-scoped) and could never self-label,
# which stalled the queue; the label is now OPTIONAL metadata. The
# merge bar is UNCHANGED — only candidate selection widens. Set
# AUTO_DISCOVER=0 to restore legacy opt-IN (require the merge-queue
# label to be considered).
AUTO_DISCOVER: "1"
# Opt-OUT labels: any of these on a PR keeps it OUT of autonomous
# merging (the human escape hatch). HOLD_LABEL is always also honoured.
# A human who wants a PR held just adds one of these labels.
OPT_OUT_LABELS: do-not-auto-merge,wip
UPDATE_STYLE: merge
# Recognised official-reviewer set. A merge needs >= required_approvals
# DISTINCT genuine official approvals from these accounts on the
+30 -14
View File
@@ -8,26 +8,39 @@ against the latest `main`.
## Queue Contract
Add the `merge-queue` label to an open PR when it is ready to merge.
**Auto-discovery (opt-OUT, default).** You do NOT need to label a PR. The bot
auto-discovers every open same-repo PR and merges any that meets the bar. The
`merge-queue` label is now optional metadata, not a gate. This removed the
historical autonomy gap: agent Gitea tokens lack `write:issue` (labels are
issue-scoped), so agents could never self-label and ready PRs stalled.
To keep a PR OUT of autonomous merging, add an opt-OUT label:
`merge-queue-hold`, `do-not-auto-merge`, or `wip`. Draft PRs are also skipped.
The bot processes one PR per tick:
1. Confirms `main` is green.
2. Selects the oldest open PR carrying `merge-queue`.
3. Skips PRs with `merge-queue-hold`.
4. Rejects fork PRs because the queue may only update same-repo branches.
5. If the PR head does not contain current `main`, calls Gitea's
1. Confirms `main`'s branch-protection-required push contexts are green.
2. Selects the oldest open same-repo PR that is NOT opt-out-labeled and NOT a
draft (auto-discovery). With `AUTO_DISCOVER=0` it falls back to legacy
opt-IN: only PRs carrying `merge-queue` are considered.
3. Rejects fork PRs because the queue may only update same-repo branches.
4. If the PR head does not contain current `main`, calls Gitea's
`/pulls/{n}/update?style=merge` endpoint and waits for CI on the new head.
6. Merges only after the current PR head has required contexts green:
- `CI / all-required (pull_request)`
- `sop-checklist / all-items-acked (pull_request)`
5. Merges only when, on the PR's CURRENT head sha:
- `>= required_approvals` distinct genuine official `APPROVED` reviews from
the recognised reviewer set (read from branch protection; default 2),
- no open official `REQUEST_CHANGES`,
- every branch-protection-required status context is green, and
- the PR is `mergeable` (Gitea returns `True`; `None`/`False` = wait).
The workflow is serialized with `concurrency`, so two queued PRs cannot be
The merge bar is unchanged by auto-discovery — only WHICH PRs are considered
changes. The workflow is serialized with `concurrency`, so two PRs cannot be
merged against the same observed `main`.
## Operator Commands
Queue a PR:
Queue a PR (optional — auto-discovery already considers every ready PR; the
label is just visible metadata):
```bash
curl -fsS -X POST \
@@ -37,7 +50,8 @@ curl -fsS -X POST \
-d '{"labels":["merge-queue"]}'
```
Temporarily hold a queued PR:
Keep a PR OUT of autonomous merging (opt-OUT — use `merge-queue-hold`,
`do-not-auto-merge`, or `wip`):
```bash
curl -fsS -X POST \
@@ -56,9 +70,11 @@ REPO=molecule-ai/molecule-core \
WATCH_BRANCH=main \
QUEUE_LABEL=merge-queue \
HOLD_LABEL=merge-queue-hold \
AUTO_DISCOVER=1 \
OPT_OUT_LABELS=do-not-auto-merge,wip \
REVIEWER_SET=agent-reviewer,agent-researcher,agent-reviewer-cr2 \
UPDATE_STYLE=merge \
REQUIRED_CONTEXTS='CI / all-required (pull_request),sop-checklist / all-items-acked (pull_request)' \
python3 .gitea/scripts/gitea-merge-queue.py
python3 .gitea/scripts/gitea-merge-queue.py --dry-run
```
Dry run: