fix(queue): correct status deduplication order so newest entry wins
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat
sop-checklist / na-declarations (pull_request) N/A: (none)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 57s
gate-check-v3 / gate-check (pull_request) Successful in 3s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
CI / Platform (Go) (pull_request) Successful in 4m21s
E2E Chat / E2E Chat (pull_request) Successful in 1s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 5m43s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m19s
CI / all-required (pull_request) Successful in 6m22s
sop-checklist / all-items-acked (pull_request) [info tier:low] acked: 5/7 — missing: root-cause, no-backwards-compat
sop-checklist / na-declarations (pull_request) N/A: (none)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
E2E Chat / detect-changes (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 57s
gate-check-v3 / gate-check (pull_request) Successful in 3s
qa-review / approved (pull_request) Failing after 3s
security-review / approved (pull_request) Failing after 3s
sop-tier-check / tier-check (pull_request) Successful in 4s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 1s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1s
CI / Platform (Go) (pull_request) Successful in 4m21s
E2E Chat / E2E Chat (pull_request) Successful in 1s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 1s
CI / Canvas (Next.js) (pull_request) Successful in 5m43s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 6m19s
CI / all-required (pull_request) Successful in 6m22s
The queue was incorrectly seeing main's CI/all-required (push) as "pending" instead of "success". Two bugs interacting: 1. latest_statuses_by_context guard was wrong: `ids[-1] > ids[0]` detected ascending but the combined /statuses array is DESCENDING (ids 393→1). Fix: `ids[-1] < ids[0]` detects descending and reverses so ascending iteration makes newest last → wins. 2. get_combined_status sorted merged entries DESCENDING then deduplicated by iterating forward — the last occurrence won. But when /status base entries (low ids) are appended AFTER /statuses (high ids), the same-context entries from base appear LAST after descending sort, overwriting newer entries from /statuses. Fix: return merged list sorted ASCENDING and drop the inline dedup; let latest_statuses_by_context handle dedup correctly. Test names clarified: ascending-input test now named test_latest_statuses_ascending_input_newest_wins (the base /status case); descending-input test renamed test_latest_statuses_guard_reverses_descending_input (the /statuses case). Both verify newest (largest id) wins. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -137,21 +137,19 @@ def status_state(status: dict) -> str:
|
||||
|
||||
|
||||
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
||||
# Gitea /statuses endpoint returns entries in descending id order
|
||||
# (newest first: id=55 → id=1). Iterate forward so newer entries
|
||||
# overwrite older ones — the last occurrence of each context is the
|
||||
# authoritative (newest) status.
|
||||
# Guard: if the input happens to be ascending (e.g. test fixtures),
|
||||
# iterate in reverse so the same newest-wins semantics apply.
|
||||
# Iterate so the newest entry for each context is seen LAST → it overwrites
|
||||
# older ones in the accumulator dict.
|
||||
# - Ascending input (oldest first, e.g. Gitea /status base array): forward
|
||||
# iteration processes oldest first, newest last → newest overwrites → OK.
|
||||
# - Descending input (newest first, e.g. Gitea /statuses, combined array):
|
||||
# forward iteration processes newest first → oldest last → oldest wins.
|
||||
# Must REVERSE so iteration is oldest→newest → newest wins.
|
||||
# Guard: detect ascending by checking last_id > first_id.
|
||||
if not statuses:
|
||||
return {}
|
||||
ids = [s.get("id", 0) for s in statuses if isinstance(s.get("id"), int)]
|
||||
if ids and ids[-1] < ids[0]:
|
||||
# Descending order (newest first) — reverse so oldest wins.
|
||||
# When we iterate forward on descending input, the FIRST (newest)
|
||||
# occurrence wins. We want the LAST (oldest) occurrence — the most
|
||||
# recent status for each context — to win. Reversing makes iteration
|
||||
# go newest-to-oldest, so the newest entry is seen LAST and wins.
|
||||
# Descending (newest first) — reverse to oldest→newest iteration.
|
||||
statuses = list(reversed(statuses))
|
||||
latest: dict[str, dict] = {}
|
||||
for status in statuses:
|
||||
@@ -262,15 +260,15 @@ def get_combined_status(sha: str) -> dict:
|
||||
The /status endpoint returns a `statuses` array capped at 30 entries.
|
||||
We supplement it with /statuses (limit=100) for contexts not in the
|
||||
base array. The combined `state` always comes from /status.
|
||||
|
||||
Returns the merged list sorted ASCENDING by id. Caller's
|
||||
latest_statuses_by_context iterates ascending so the newest (largest
|
||||
id) for each context is seen last and wins.
|
||||
"""
|
||||
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(combined, dict):
|
||||
raise ApiError(f"status for {sha} response not object")
|
||||
base_statuses: list[dict] = combined.get("statuses") or []
|
||||
# Build latest-per-context: collect ALL entries from both sources,
|
||||
# then iterate in descending id order so the newest entry for each
|
||||
# context wins. /status returns ascending; /statuses returns
|
||||
# descending (newest first). We need descending overall.
|
||||
all_entries: list[dict] = list(base_statuses)
|
||||
try:
|
||||
_, statuses_list = api(
|
||||
@@ -282,15 +280,10 @@ def get_combined_status(sha: str) -> dict:
|
||||
all_entries.extend(statuses_list)
|
||||
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
|
||||
# Sort descending by id so newest entries overwrite older ones for same context.
|
||||
# Gitea ids are monotonically increasing — higher id = newer entry.
|
||||
all_entries.sort(key=lambda s: s.get("id") or 0, reverse=True)
|
||||
latest: dict[str, dict] = {}
|
||||
for s in all_entries:
|
||||
ctx = s.get("context")
|
||||
if isinstance(ctx, str):
|
||||
latest[ctx] = s
|
||||
combined["statuses"] = list(latest.values())
|
||||
# Sort ascending by id. latest_statuses_by_context iterates ascending
|
||||
# so the newest (largest id) entry for each context is seen last and wins.
|
||||
all_entries.sort(key=lambda s: s.get("id") or 0)
|
||||
combined["statuses"] = all_entries
|
||||
return combined
|
||||
|
||||
|
||||
|
||||
@@ -11,14 +11,13 @@ sys.modules[spec.name] = mq
|
||||
spec.loader.exec_module(mq)
|
||||
|
||||
|
||||
def test_latest_statuses_dedupes_by_context_newest_first():
|
||||
# Gitea /statuses returns newest-first (desc id order). The guard detects
|
||||
# descending and reverses so we iterate ascending (oldest→newest). The last
|
||||
# occurrence of each context is therefore the newest, which wins.
|
||||
def test_latest_statuses_ascending_input_newest_wins():
|
||||
# Gitea /status (base array) returns ascending id order (oldest first).
|
||||
# Forward iteration processes oldest first, newest last → newest overwrites.
|
||||
statuses = [
|
||||
{"id": 54, "context": "CI / all-required (pull_request)", "status": "success"}, # newest (last in desc order = last processed after reversal)
|
||||
{"id": 27, "context": "sop-checklist / all-items-acked (pull_request)", "state": "success"},
|
||||
{"id": 18, "context": "CI / all-required (pull_request)", "status": "failure"}, # oldest
|
||||
{"id": 27, "context": "sop-checklist / all-items-acked (pull_request)", "state": "success"},
|
||||
{"id": 54, "context": "CI / all-required (pull_request)", "status": "success"}, # newest
|
||||
]
|
||||
|
||||
latest = mq.latest_statuses_by_context(statuses)
|
||||
@@ -28,19 +27,22 @@ def test_latest_statuses_dedupes_by_context_newest_first():
|
||||
assert latest["sop-checklist / all-items-acked (pull_request)"]["state"] == "success"
|
||||
|
||||
|
||||
def test_latest_statuses_guard_detects_ascending_input():
|
||||
# Test fixture with explicit ascending ids: guard must reverse so newest wins.
|
||||
def test_latest_statuses_guard_reverses_descending_input():
|
||||
# Gitea /statuses returns descending id order (newest first: id=54 → id=1).
|
||||
# Guard detects descending and reverses so we iterate ascending.
|
||||
# Forward on reversed = newest (id=54) is last → overwrites oldest.
|
||||
statuses = [
|
||||
{"id": 1, "context": "CI / all-required (pull_request)", "status": "failure"}, # oldest
|
||||
{"id": 54, "context": "CI / all-required (pull_request)", "status": "success"}, # newest
|
||||
{"id": 27, "context": "sop-checklist / all-items-acked (pull_request)", "state": "success"},
|
||||
{"id": 54, "context": "CI / all-required (pull_request)", "status": "success"}, # newest
|
||||
{"id": 18, "context": "CI / all-required (pull_request)", "status": "failure"}, # oldest
|
||||
]
|
||||
|
||||
latest = mq.latest_statuses_by_context(statuses)
|
||||
|
||||
# Guard reverses ascending, so we iterate desc: newest (id=54) is last → wins.
|
||||
# Guard reverses descending → asc iteration: 18 first, 27, 54 last → 54 wins.
|
||||
assert latest["CI / all-required (pull_request)"]["status"] == "success"
|
||||
assert latest["CI / all-required (pull_request)"]["id"] == 54
|
||||
assert latest["sop-checklist / all-items-acked (pull_request)"]["state"] == "success"
|
||||
|
||||
|
||||
def test_required_contexts_green_rejects_missing_and_pending():
|
||||
|
||||
Reference in New Issue
Block a user