fix(queue): fetch all PRs and filter by label name in Python #1177
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# CI trigger 2026-05-15
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user