fix(queue): fetch all PRs and filter by label name in Python #1177

Open
infra-sre wants to merge 7 commits from fix/queue-label-filter-all-ids into staging
5 changed files with 40 additions and 18 deletions
+19 -9
View File
@@ -138,13 +138,13 @@ def status_state(status: dict) -> str:
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
# Gitea /statuses endpoint returns entries in ascending id order (oldest
# first). We need the LAST occurrence of each context, so iterate in
# reverse to prefer newer entries.
# first). We need the LAST occurrence of each context. Iterate in normal
# order and overwrite so the newest entry wins.
latest: dict[str, dict] = {}
for status in reversed(statuses):
for status in statuses:
context = status.get("context")
if isinstance(context, str):
latest[context] = status # overwrite: reverse order → newest wins
latest[context] = status # overwrite: normal order → newest wins
return latest
@@ -278,19 +278,23 @@ def get_combined_status(sha: str) -> dict:
def list_queued_issues() -> list[dict]:
# Fetch all open PRs and filter by queue label in Python.
# Gitea allows multiple labels with the same name (IDs 27, 30, 31 for
# "merge-queue"). The issues API `labels=NAME` filter matches at most one
# of those IDs, silently excluding PRs that carry the label under a
# different ID. Filtering in Python sidesteps this ambiguity.
_, body = api(
"GET",
f"/repos/{OWNER}/{NAME}/issues",
query={
"state": "open",
"type": "pulls",
"labels": QUEUE_LABEL,
"limit": "50",
"limit": "200",
},
)
if not isinstance(body, list):
raise ApiError("queued issues response not list")
return body
return [issue for issue in body if QUEUE_LABEL in label_names(issue)]
def get_pull(pr_number: int) -> dict:
@@ -350,7 +354,9 @@ def process_once(*, dry_run: bool = False) -> int:
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
not_green = ", ".join(main_bad)
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} "
f"required contexts not green: {not_green}")
return 0
issue = choose_next_queued_issue(
@@ -371,7 +377,11 @@ def process_once(*, dry_run: bool = False) -> int:
post_comment(pr_number, f"merge-queue: skipped; base branch is not `{WATCH_BRANCH}`.", dry_run=dry_run)
return 0
if pr.get("head", {}).get("repo_id") != pr.get("base", {}).get("repo_id"):
post_comment(pr_number, "merge-queue: skipped; fork PRs are not supported by the serialized queue.", dry_run=dry_run)
post_comment(
pr_number,
"merge-queue: skipped; fork PRs are not supported by the serialized queue.",
dry_run=dry_run,
)
return 0
head_sha = pr.get("head", {}).get("sha")
@@ -19,7 +19,8 @@ def test_latest_statuses_dedupes_by_context_newest_first():
latest = mq.latest_statuses_by_context(statuses)
assert latest["CI / all-required (pull_request)"]["status"] == "failure"
# Newest entry wins (reverse iteration), so success overwrites failure.
assert latest["CI / all-required (pull_request)"]["status"] == "success"
assert latest["sop-checklist / all-items-acked (pull_request)"]["state"] == "success"
@@ -111,7 +112,10 @@ def test_merge_decision_updates_stale_pr_before_merge():
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
},
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
pr_status={
"state": "success",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]
},
required_contexts=["CI / all-required (pull_request)"],
pr_has_current_base=False,
)
+5 -5
View File
@@ -135,9 +135,9 @@ class TestParseDirectives(unittest.TestCase):
self.aliases = _numeric_aliases()
def parse_ack_revoke(self, body):
directives, na_directives = sop.parse_directives(body, self.aliases)
self.assertEqual(na_directives, [])
return directives
# parse_directives returns a combined list of (kind, slug, note) tuples.
# Return it directly; the old two-list interface no longer applies.
return sop.parse_directives(body, self.aliases)
def test_simple_ack(self):
d = self.parse_ack_revoke("/sop-ack comprehensive-testing")
@@ -201,8 +201,8 @@ class TestParseDirectives(unittest.TestCase):
self.assertEqual(len(d), 1)
def test_empty_body(self):
self.assertEqual(sop.parse_directives("", self.aliases), ([], []))
self.assertEqual(sop.parse_directives(None, self.aliases), ([], []))
self.assertEqual(sop.parse_directives("", self.aliases), [])
self.assertEqual(sop.parse_directives(None, self.aliases), [])
def test_normalization_applied(self):
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing
+1
View File
@@ -0,0 +1 @@
# CI trigger 2026-05-15
+9 -2
View File
@@ -552,6 +552,12 @@ jobs:
# required commit-status contexts for this SHA and fails if any fail, skip,
# or never emit.
#
# Timeout: 55min job-level, 50min internal deadline. Cold runners can take
# 16+min for Platform (Go) + 18min for Canvas + ~8min for Python Lint
# = ~42min of required context wall time. 50min deadline gives headroom
# for polling overhead and runner scheduling variance. mc#1099 cold-runner
# fix addresses the root cause (golangci-lint timeout, step-level ceilings).
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
# it in this dependency list lets a skipped reminder skip the required
@@ -559,7 +565,7 @@ jobs:
#
continue-on-error: false
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 55
steps:
- name: Wait for required CI contexts
env:
@@ -589,9 +595,10 @@ jobs:
f"CI / Canvas (Next.js) ({event})",
f"CI / Shellcheck (E2E scripts) ({event})",
f"CI / Python Lint & Test ({event})",
f"CI / Canvas Deploy Reminder ({event})",
]
terminal_bad = {"failure", "error"}
deadline = time.time() + 40 * 60
deadline = time.time() + 50 * 60
last_summary = None
def fetch_statuses():