diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py index 46b0482ad..2dbfb3ef5 100644 --- a/.gitea/scripts/gitea-merge-queue.py +++ b/.gitea/scripts/gitea-merge-queue.py @@ -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") diff --git a/.gitea/scripts/tests/test_gitea_merge_queue.py b/.gitea/scripts/tests/test_gitea_merge_queue.py index b01c6da22..4da7e433e 100644 --- a/.gitea/scripts/tests/test_gitea_merge_queue.py +++ b/.gitea/scripts/tests/test_gitea_merge_queue.py @@ -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, ) diff --git a/.gitea/scripts/tests/test_sop_checklist.py b/.gitea/scripts/tests/test_sop_checklist.py index 24fbc54ce..2dd5f2165 100644 --- a/.gitea/scripts/tests/test_sop_checklist.py +++ b/.gitea/scripts/tests/test_sop_checklist.py @@ -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 diff --git a/.gitea/workflows/.ci-trigger b/.gitea/workflows/.ci-trigger new file mode 100644 index 000000000..a3def383c --- /dev/null +++ b/.gitea/workflows/.ci-trigger @@ -0,0 +1 @@ +# CI trigger 2026-05-15 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 84767f345..be58e7ec6 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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():