This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch auto-approve-workflow-runs in repository https://gitbox.apache.org/repos/asf/airflow.git
commit b84ff18873a22b76196ad8135873891c1e1cec6d Author: Jarek Potiuk <[email protected]> AuthorDate: Sun Oct 25 18:06:35 2026 +0100 Keep CI-minutes estimates true to the runs they approve A run selects its tests from the labels the PR had when it started, so approving it on the current labels could start a full-tests run whose label was removed while it waited. Calibrating from self-hosted and all-versions runs, and leaving out the jobs that need no CI image, made the estimate describe runs other than those it is used to approve. Generated-by: Claude Opus 5 --- dev/breeze/doc/ci/05_workflows.md | 10 +- .../doc/ci/08_fork_ci_for_external_contributors.md | 4 + .../airflow_breeze/utils/ci_minutes_estimate.py | 52 +++++++--- .../airflow_breeze/utils/workflow_run_approval.py | 83 +++++++++++++-- .../tests/test_calibrate_ci_minutes_estimates.py | 101 ++++++++++++++++++ dev/breeze/tests/test_ci_minutes_estimate.py | 61 +++++++++++ dev/breeze/tests/test_workflow_run_approval.py | 115 +++++++++++++++++++++ dev/calibrate_ci_minutes_estimates.py | 24 ++++- 8 files changed, 425 insertions(+), 25 deletions(-) diff --git a/dev/breeze/doc/ci/05_workflows.md b/dev/breeze/doc/ci/05_workflows.md index df0615ffe4e..c06d4f8a267 100644 --- a/dev/breeze/doc/ci/05_workflows.md +++ b/dev/breeze/doc/ci/05_workflows.md @@ -132,7 +132,15 @@ Only runs created in the last 24 hours (`--max-run-age-hours`) are considered, s approves a backlog of older waiting runs at once; those stay with maintainers. A commit that is the head of several open PRs is never approved automatically, because its runs cannot be told apart by PR, and only runs started after the PR was opened and after its base branch last changed are -approved (retargeting a PR does not start new runs). Other PRs get the +approved (retargeting a PR does not start new runs). A run selects its jobs from the labels the PR +had when it started, while the decision uses the current labels, so for PRs that run project CI a +run started before a label that changes which tests run was added or removed +(`full tests needed`, `all versions`, `default versions only`, `latest versions only`, `canary`, +`upgrade to newer dependencies`, `area:e2e-tests`, `area:kubernetes-tests`) is left for a +maintainer, since it still runs the selection it started with; the next push is decided afresh. +For a PR whose CI runs in the fork the selection does not matter, but the gate falls back to the +labels a run started with when it cannot read the current ones, so a run started before +`use fork ci` or `use project ci` was added or removed is left for a maintainer instead. Other PRs get the `need workflow approval` label and wait for a maintainer. When processing a PR fails (for example on a GitHub API error), the workflow tries to add the label to it and the workflow run fails, so the failure stays visible even if the label could not be added. The label is removed when a later commit diff --git a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md index 080a0554b21..762e920e2d8 100644 --- a/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md +++ b/dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md @@ -367,6 +367,10 @@ applies instead: `github-actions[bot]` with the marker count. A change to `.github/` can remove the gate or add a workflow that does run, so such a run must not start without a review. +A run started before `use fork ci` or `use project ci` was last added or removed is not approved +automatically: the gate falls back to the labels of the run's event when it cannot read the current +ones, and those can still send the run to project CI. The next push is decided afresh. + Both rules use the same list as "Trusting a green fork run", so a gated pull request whose runs are approved automatically is also one whose green fork run is trusted, and one that changes CI is both left waiting for approval and labelled `needs project ci` once its fork run is green. The gate diff --git a/dev/breeze/src/airflow_breeze/utils/ci_minutes_estimate.py b/dev/breeze/src/airflow_breeze/utils/ci_minutes_estimate.py index 65df3d6b208..fd51a2140b5 100644 --- a/dev/breeze/src/airflow_breeze/utils/ci_minutes_estimate.py +++ b/dev/breeze/src/airflow_breeze/utils/ci_minutes_estimate.py @@ -55,6 +55,9 @@ AGENT_FRAMEWORK_ESTIMATE = (11, 5) JAVA_SDK_ESTIMATE = (7, 4) GO_SDK_ESTIMATE = (1, 1) TS_SDK_ESTIMATE = (1, 1) +TS_SDK_DOCS_ESTIMATE = (1, 1) +# The provider mypy job runs whenever provider code changes, also without provider unit tests. +MYPY_PROVIDERS_ESTIMATE = (8, 8) AIRFLOW_CTL_ESTIMATE = (1, 1) # Core tests: about 38 job-minutes for the first test type and 61 for each further one, up to @@ -65,13 +68,14 @@ CORE_ALL_TEST_TYPES_MINUTES = 222 CORE_LONGEST_JOB_MINUTES = {1: 11, 2: 29} CORE_LONGEST_JOB_MINUTES_DEFAULT = 32 -# Provider tests (DB/non-DB, lowest dependencies, compatibility, distributions, mypy) scale with the -# size of the tested providers' test suites: amazon or google cost far more than a small provider. -# Fitted on 224 runs testing 2 to 22 providers: job-minutes ~= 105 + 0.161 per provider test file, and -# the longest provider job ~= 16 + 0.015 per test file, both capped at the cost of testing all providers. -PROVIDERS_BASE_MINUTES = 105 +# Provider tests (DB/non-DB, lowest dependencies, compatibility, distributions) scale with the size of +# the tested providers' test suites: amazon or google cost far more than a small provider. Fitted on +# 224 runs testing 2 to 22 providers, together with the provider mypy job: job-minutes ~= 105 + 0.161 +# per provider test file, and the longest provider job ~= 16 + 0.015 per test file, both capped at the +# cost of testing all providers. The mypy job's median is taken off the base and the all-providers cost. +PROVIDERS_BASE_MINUTES = 105 - MYPY_PROVIDERS_ESTIMATE[0] PROVIDERS_MINUTES_PER_TEST_FILE = 0.161 -PROVIDERS_ALL_MINUTES = 315 +PROVIDERS_ALL_MINUTES = 315 - MYPY_PROVIDERS_ESTIMATE[0] PROVIDERS_LONGEST_JOB_BASE_MINUTES = 16 PROVIDERS_LONGEST_JOB_MINUTES_PER_TEST_FILE = 0.015 PROVIDERS_ALL_LONGEST_JOB_MINUTES = 36 @@ -153,10 +157,34 @@ def estimate_providers_group(provider_test_types: list[str]) -> EstimatedJobGrou ) +# Jobs that need only build-info, so they also run when no CI image is built. The Java SDK group +# includes its docs job. +IMAGE_INDEPENDENT_GROUPS = ( + ("run_java_sdk_tests", "Java SDK tests", JAVA_SDK_ESTIMATE), + ("run_go_sdk_tests", "Go SDK tests", GO_SDK_ESTIMATE), + ("run_ts_sdk_tests", "TypeScript SDK tests", TS_SDK_ESTIMATE), + ("run_ts_sdk_docs", "TypeScript SDK docs", TS_SDK_DOCS_ESTIMATE), + ("run_airflow_ctl_tests", "Airflow CTL tests", AIRFLOW_CTL_ESTIMATE), +) + + +def estimate_image_independent_groups(selective_checks: Any) -> list[EstimatedJobGroup]: + return [ + EstimatedJobGroup(name, *estimate) + for flag, name, estimate in IMAGE_INDEPENDENT_GROUPS + if getattr(selective_checks, flag) + ] + + def estimate_ci_minutes(selective_checks: Any) -> CiMinutesEstimate: """Estimate the job groups, job-minutes and wall-clock time of a ``Tests (AMD)`` PR run.""" if selective_checks.basic_checks_only: - return CiMinutesEstimate([EstimatedJobGroup("Basic checks only", *BASIC_CHECKS_ONLY_ESTIMATE)]) + return CiMinutesEstimate( + [ + EstimatedJobGroup("Basic checks only", *BASIC_CHECKS_ONLY_ESTIMATE), + *estimate_image_independent_groups(selective_checks), + ] + ) groups = [EstimatedJobGroup("CI image, basic tests and static checks", *BASE_ESTIMATE)] if selective_checks.docs_build: groups.append(EstimatedJobGroup("Documentation build", *DOCS_ESTIMATE)) @@ -172,12 +200,15 @@ def estimate_ci_minutes(selective_checks: Any) -> CiMinutesEstimate: ) ) groups.append(EstimatedJobGroup("Integration and system tests", *INTEGRATION_ESTIMATE)) - if not selective_checks.skip_providers_tests: + # Like the provider test jobs of ci-amd.yml, which all need run-unit-tests. + if selective_checks.run_unit_tests and not selective_checks.skip_providers_tests: providers_group = estimate_providers_group( selective_checks._get_providers_test_types_to_run(split_to_individual_providers=True) ) if providers_group: groups.append(providers_group) + if selective_checks.run_mypy_providers: + groups.append(EstimatedJobGroup("Provider mypy checks", *MYPY_PROVIDERS_ESTIMATE)) if selective_checks.full_tests_needed: groups.append(EstimatedJobGroup("Special tests", *SPECIAL_TESTS_ESTIMATE)) optional_groups = ( @@ -187,12 +218,9 @@ def estimate_ci_minutes(selective_checks: Any) -> CiMinutesEstimate: ("run_kustomize_overlays_tests", "Kustomize overlays tests", KUSTOMIZE_ESTIMATE), ("run_task_sdk_tests", "Task SDK tests", TASK_SDK_ESTIMATE), ("run_agent_framework_tests", "Agent framework tests", AGENT_FRAMEWORK_ESTIMATE), - ("run_java_sdk_tests", "Java SDK tests", JAVA_SDK_ESTIMATE), - ("run_go_sdk_tests", "Go SDK tests", GO_SDK_ESTIMATE), - ("run_ts_sdk_tests", "TypeScript SDK tests", TS_SDK_ESTIMATE), - ("run_airflow_ctl_tests", "Airflow CTL tests", AIRFLOW_CTL_ESTIMATE), ) for flag, name, estimate in optional_groups: if getattr(selective_checks, flag): groups.append(EstimatedJobGroup(name, *estimate)) + groups.extend(estimate_image_independent_groups(selective_checks)) return CiMinutesEstimate(groups) diff --git a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py index f0c4d0d7325..2f7fc1fe34b 100644 --- a/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py +++ b/dev/breeze/src/airflow_breeze/utils/workflow_run_approval.py @@ -49,6 +49,8 @@ from airflow_breeze.utils.ci_minutes_estimate import CiMinutesEstimate, estimate from airflow_breeze.utils.console import console_print from airflow_breeze.utils.fork_ci_gate import ( GATED_REASONS, + LABEL_USE_FORK_CI, + LABEL_USE_PROJECT_CI, GateConfig, GateEvent, decide, @@ -59,6 +61,16 @@ from airflow_breeze.utils.fork_ci_messages import CI_CHANGE_MARKER, ci_change_ap from airflow_breeze.utils.fork_ci_reconcile import changes_ci_definition from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH from airflow_breeze.utils.run_utils import run_command +from airflow_breeze.utils.selective_checks import ( + ALL_VERSIONS_LABEL, + AREA_E2E_TESTS_LABEL, + AREA_KUBERNETES_TESTS_LABEL, + CANARY_LABEL, + DEFAULT_VERSIONS_ONLY_LABEL, + FULL_TESTS_NEEDED_LABEL, + LATEST_VERSIONS_ONLY_LABEL, + UPGRADE_TO_NEWER_DEPENDENCIES_LABEL, +) NEED_WORKFLOW_APPROVAL_LABEL = "need workflow approval" @@ -104,6 +116,27 @@ NON_SOURCE_FILE_PATTERN = re.compile( GITHUB_API_URL = "https://api.github.com" +# Labels with which selective checks select a different set of tests or versions. Selective checks +# read the labels of the run's event, so a run started before one was added or removed still runs +# the selection of the labels it started with, which the current labels do not show. +TEST_SELECTION_LABELS = frozenset( + { + ALL_VERSIONS_LABEL, + AREA_E2E_TESTS_LABEL, + AREA_KUBERNETES_TESTS_LABEL, + CANARY_LABEL, + DEFAULT_VERSIONS_ONLY_LABEL, + FULL_TESTS_NEEDED_LABEL, + LATEST_VERSIONS_ONLY_LABEL, + UPGRADE_TO_NEWER_DEPENDENCIES_LABEL, + } +) + +# Labels that move a PR between project CI and fork CI. The gate reads them when it runs, but falls +# back to the labels of the run's event when it cannot, so a run started before one changed could +# still run project CI. +GATE_OVERRIDE_LABELS = frozenset({LABEL_USE_FORK_CI, LABEL_USE_PROJECT_CI}) + # Read from the trusted main checkout, which is also the base branch whose gate job decides the # PRs that are approved automatically. FORK_CI_GATE_CONFIG_PATH = AIRFLOW_ROOT_PATH / ".github" / "fork-ci-gate.toml" @@ -122,6 +155,8 @@ class ApprovalDecision: estimate: CiMinutesEstimate | None = None # Posted on the PR once, when the runs are left for a maintainer. comment: str | None = None + # The labels the decision depends on: runs started before one of them changed are not approved. + decision_labels: frozenset[str] = TEST_SELECTION_LABELS def count_source_lines(changed_files: list[ChangedFile]) -> int: @@ -190,6 +225,7 @@ def decide_gated_approval(*, changed_files: list[ChangedFile]) -> ApprovalDecisi return ApprovalDecision( approve=True, reasons=["its CI runs in the contributor's fork, so its project CI run only executes the gate"], + decision_labels=GATE_OVERRIDE_LABELS, ) @@ -287,6 +323,7 @@ class GitHubApi: def __init__(self, *, github_repository: str, github_token: str, report_only: bool): self.github_repository = github_repository self.report_only = report_only + self._timelines: dict[int, list[dict[str, Any]]] = {} self._session = requests.Session() self._session.headers.update( { @@ -356,15 +393,29 @@ class GitHubApi: ) response.raise_for_status() + def _get_timeline(self, pr_number: int) -> list[dict[str, Any]]: + if pr_number not in self._timelines: + self._timelines[pr_number] = self._get_paginated(f"issues/{pr_number}/timeline?per_page=100") + return self._timelines[pr_number] + def get_last_base_change(self, pr_number: int) -> datetime | None: - events = self._get_paginated(f"issues/{pr_number}/timeline?per_page=100") changes = [ parse_github_time(event["created_at"]) - for event in events + for event in self._get_timeline(pr_number) if event.get("event") == "base_ref_changed" ] return max(changes, default=None) + def get_last_label_change(self, pr_number: int, labels: frozenset[str]) -> datetime | None: + """When one of ``labels`` was last added to or removed from the PR.""" + changes = [ + parse_github_time(event["created_at"]) + for event in self._get_timeline(pr_number) + if event.get("event") in ("labeled", "unlabeled") + and (event.get("label") or {}).get("name") in labels + ] + return max(changes, default=None) + def remove_label(self, pr_number: int, label: str) -> None: if self.report_only: console_print(f"[info]Report only: would remove label '{label}' from PR #{pr_number}[/]") @@ -543,11 +594,16 @@ def process_waiting_runs(api: GitHubApi, *, created_since: datetime) -> list[Pul decision = decide_for_pull_request(api=api, pr=pr) pr_run_ids = run_ids if decision.approve: - pr_run_ids = find_runs_started_for_current_pr(api=api, pr=pr, runs=head_runs) + pr_run_ids = find_runs_started_for_current_pr( + api=api, pr=pr, runs=head_runs, decision_labels=decision.decision_labels + ) if not pr_run_ids: decision = ApprovalDecision( approve=False, - reasons=["its waiting runs started before the PR was opened or retargeted"], + reasons=[ + "its waiting runs started before the PR was opened, retargeted " + "or changed a label its decision depends on" + ], estimate=decision.estimate, ) pr_run_ids = run_ids @@ -583,7 +639,11 @@ def process_waiting_runs(api: GitHubApi, *, created_since: datetime) -> list[Pul def find_runs_started_for_current_pr( - *, api: GitHubApi, pr: dict[str, Any], runs: list[dict[str, Any]] + *, + api: GitHubApi, + pr: dict[str, Any], + runs: list[dict[str, Any]], + decision_labels: frozenset[str] = TEST_SELECTION_LABELS, ) -> list[int]: """ Return the runs started after the PR was opened and after its base branch last changed. @@ -591,11 +651,18 @@ def find_runs_started_for_current_pr( Retargeting a PR does not start new runs, and a closed PR can leave runs with the same head behind. Such runs were started for another base branch, so the decision about the current PR must not approve them. + + :param decision_labels: the labels the decision depends on. It is made from the current labels, + while a run can act on those of its event: with ``full tests needed`` removed, or + ``default versions only`` added to a run of all versions, the estimate is smaller than the + run, and without ``use fork ci`` in its event a run can fall back to project CI. """ started_after = parse_github_time(pr["created_at"]) - last_base_change = api.get_last_base_change(pr["number"]) - if last_base_change is not None: - started_after = max(started_after, last_base_change) + changes = [ + api.get_last_base_change(pr["number"]), + api.get_last_label_change(pr["number"], decision_labels), + ] + started_after = max([started_after, *(change for change in changes if change is not None)]) return [run["id"] for run in runs if parse_github_time(run["created_at"]) >= started_after] diff --git a/dev/breeze/tests/test_calibrate_ci_minutes_estimates.py b/dev/breeze/tests/test_calibrate_ci_minutes_estimates.py new file mode 100644 index 00000000000..f420a38c3e8 --- /dev/null +++ b/dev/breeze/tests/test_calibrate_ci_minutes_estimates.py @@ -0,0 +1,101 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import importlib.util +import json + +import pytest + +from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH + +# The script is standalone (run with `uv run`), so it is loaded from its path. +SCRIPT_PATH = AIRFLOW_ROOT_PATH / "dev" / "calibrate_ci_minutes_estimates.py" +_spec = importlib.util.spec_from_file_location("calibrate_ci_minutes_estimates", SCRIPT_PATH) +assert _spec is not None +assert _spec.loader is not None +calibrate = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(calibrate) + +JOBS = [ + { + "name": "Basic tests / Static checks", + "conclusion": "success", + "started_at": "2026-10-22T10:00:00Z", + "completed_at": "2026-10-22T10:06:00Z", + }, + { + "name": "MyPy providers checks", + "conclusion": "success", + "started_at": "2026-10-22T10:00:00Z", + "completed_at": "2026-10-22T10:08:00Z", + }, + { + "name": "TypeScript SDK docs", + "conclusion": "success", + "started_at": "2026-10-22T10:00:00Z", + "completed_at": "2026-10-22T10:01:00Z", + }, +] + + +def _cache_run(cache_dir, run_id, *, runner_type='["ubuntu-22.04"]', all_versions="false"): + (cache_dir / f"{run_id}.jobs.json").write_text(json.dumps(JOBS)) + (cache_dir / f"{run_id}.build-info.log").write_text( + "2026-10-22T10:00:00Z full-tests-needed = false\n" + f"2026-10-22T10:00:00Z runner-type = {runner_type}\n" + f"2026-10-22T10:00:00Z all-versions = {all_versions}\n" + ) + + [email protected]( + ("runner_type", "all_versions", "counted"), + [ + pytest.param('["ubuntu-22.04"]', "false", True, id="public runners, default versions"), + pytest.param( + "runs-on=1/family=m7a/image=ubuntu22-full-x64/volume=150gb/cpu={0}/ram={1}", + "false", + False, + id="self-hosted runners", + ), + pytest.param('["ubuntu-22.04"]', "true", False, id="all versions"), + ], +) +def test_build_rows_counts_only_the_runs_the_estimate_models(tmp_path, runner_type, all_versions, counted): + _cache_run(tmp_path, 1, runner_type=runner_type, all_versions=all_versions) + + rows = calibrate.build_rows([1], tmp_path) + + assert len(rows) == int(counted) + + +def test_build_rows_groups_ts_sdk_docs_separately(tmp_path): + _cache_run(tmp_path, 1) + + (row,) = calibrate.build_rows([1], tmp_path) + + assert row["minutes"]["ts-sdk-docs"] == 1 + assert "ts-sdk" not in row["minutes"] + + +def test_build_rows_groups_provider_mypy_separately(tmp_path): + _cache_run(tmp_path, 1) + + (row,) = calibrate.build_rows([1], tmp_path) + + assert row["minutes"]["mypy-providers"] == 8 + assert "providers" not in row["minutes"] diff --git a/dev/breeze/tests/test_ci_minutes_estimate.py b/dev/breeze/tests/test_ci_minutes_estimate.py index 5396e3e1465..b849ed9f967 100644 --- a/dev/breeze/tests/test_ci_minutes_estimate.py +++ b/dev/breeze/tests/test_ci_minutes_estimate.py @@ -24,9 +24,11 @@ from airflow_breeze.utils.ci_minutes_estimate import ( BASE_ESTIMATE, BASIC_CHECKS_ONLY_ESTIMATE, CORE_ALL_TEST_TYPES_MINUTES, + MYPY_PROVIDERS_ESTIMATE, PROVIDERS_ALL_LONGEST_JOB_MINUTES, PROVIDERS_ALL_MINUTES, RUN_OVERHEAD_MINUTES, + TS_SDK_DOCS_ESTIMATE, estimate_ci_minutes, estimate_core_job_minutes, estimate_providers_group, @@ -47,7 +49,9 @@ OPTIONAL_FLAGS = ( "run_java_sdk_tests", "run_go_sdk_tests", "run_ts_sdk_tests", + "run_ts_sdk_docs", "run_airflow_ctl_tests", + "run_mypy_providers", ) @@ -118,6 +122,32 @@ def test_estimate_basic_checks_only(): assert estimate.wall_clock_minutes == BASIC_CHECKS_ONLY_ESTIMATE[1] [email protected]( + ("flags", "expected_groups"), + [ + pytest.param( + {"run_ts_sdk_docs": True}, ["Basic checks only", "TypeScript SDK docs"], id="ts-sdk docs" + ), + pytest.param( + {"run_java_sdk_tests": True, "run_go_sdk_tests": True, "run_airflow_ctl_tests": True}, + ["Basic checks only", "Java SDK tests", "Go SDK tests", "Airflow CTL tests"], + id="sdk tests", + ), + ], +) +def test_estimate_basic_checks_only_counts_jobs_that_need_no_ci_image(flags, expected_groups): + estimate = estimate_ci_minutes(_selective_checks(basic_checks_only=True, **flags)) + + assert [group.name for group in estimate.job_groups] == expected_groups + + +def test_estimate_counts_ts_sdk_docs_with_the_ci_image(): + estimate = estimate_ci_minutes(_selective_checks(run_ts_sdk_docs=True)) + + assert estimate.job_groups[-1].name == "TypeScript SDK docs" + assert estimate.job_minutes == BASE_ESTIMATE[0] + TS_SDK_DOCS_ESTIMATE[0] + + @mock.patch("airflow_breeze.utils.ci_minutes_estimate.count_provider_test_files", return_value=100) def test_estimate_lists_the_job_groups_that_run(mock_count): estimate = estimate_ci_minutes( @@ -166,3 +196,34 @@ def test_estimate_without_unit_tests_has_only_base_group(): estimate = estimate_ci_minutes(_selective_checks()) assert estimate.job_minutes == BASE_ESTIMATE[0] + + [email protected]( + ("run_unit_tests", "expected_groups"), + [ + pytest.param( + False, ["CI image, basic tests and static checks", "Provider mypy checks"], id="metadata only" + ), + pytest.param( + True, + [ + "CI image, basic tests and static checks", + "Integration and system tests", + "Provider tests (1 providers, 100 test files)", + "Provider mypy checks", + ], + id="with unit tests", + ), + ], +) [email protected]("airflow_breeze.utils.ci_minutes_estimate.count_provider_test_files", return_value=100) +def test_estimate_charges_provider_tests_only_with_unit_tests(mock_count, run_unit_tests, expected_groups): + estimate = estimate_ci_minutes( + _selective_checks( + provider_test_types=["Providers[google]"], run_unit_tests=run_unit_tests, run_mypy_providers=True + ) + ) + + assert [group.name for group in estimate.job_groups] == expected_groups + if not run_unit_tests: + assert estimate.job_minutes == BASE_ESTIMATE[0] + MYPY_PROVIDERS_ESTIMATE[0] diff --git a/dev/breeze/tests/test_workflow_run_approval.py b/dev/breeze/tests/test_workflow_run_approval.py index ef6751a9c59..0877efa90d3 100644 --- a/dev/breeze/tests/test_workflow_run_approval.py +++ b/dev/breeze/tests/test_workflow_run_approval.py @@ -31,10 +31,12 @@ from airflow_breeze.utils.fork_ci_messages import CI_CHANGE_MARKER from airflow_breeze.utils.selective_checks import SelectiveChecks from airflow_breeze.utils.shared_options import set_dry_run from airflow_breeze.utils.workflow_run_approval import ( + GATE_OVERRIDE_LABELS, MAX_AUTO_APPROVED_JOB_MINUTES, MAX_AUTO_APPROVED_SOURCE_LINES, MAX_ERROR_TEXT_LENGTH, NEED_WORKFLOW_APPROVAL_LABEL, + TEST_SELECTION_LABELS, ApprovalDecision, ChangedFile, GitHubApi, @@ -360,6 +362,7 @@ def test_process_waiting_runs_groups_runs_by_pr_commit(mock_decide): api = mock.MagicMock(spec=GitHubApi) api.github_repository = "apache/airflow" api.get_last_base_change.return_value = None + api.get_last_label_change.return_value = None api.get_runs_waiting_for_approval.return_value = [ _run(1, "aaa"), _run(2, "aaa"), @@ -617,6 +620,7 @@ def test_process_waiting_runs_only_approves_runs_started_for_the_current_pr( ] api.get_open_pull_requests.return_value = [_pr(10, "aaa")] api.get_last_base_change.return_value = last_base_change + api.get_last_label_change.return_value = None mock_decide.return_value = ApprovalDecision(approve=True, reasons=["cheap"]) results = process_waiting_runs(api, created_since=CREATED_SINCE) @@ -655,6 +659,7 @@ def test_process_waiting_runs_continues_after_a_pull_request_fails(mock_decide, api = mock.MagicMock(spec=GitHubApi) api.github_repository = "apache/airflow" api.get_last_base_change.return_value = None + api.get_last_label_change.return_value = None api.get_runs_waiting_for_approval.return_value = [_run(1, "aaa"), _run(2, "bbb")] api.get_open_pull_requests.return_value = [_pr(10, "aaa"), _pr(11, "bbb")] mock_decide.side_effect = [error, ApprovalDecision(approve=True, reasons=["cheap"])] @@ -674,6 +679,7 @@ def test_process_waiting_runs_records_a_failure_while_approving_as_not_approved( api = mock.MagicMock(spec=GitHubApi) api.github_repository = "apache/airflow" api.get_last_base_change.return_value = None + api.get_last_label_change.return_value = None api.get_runs_waiting_for_approval.return_value = [_run(1, "aaa"), _run(2, "aaa")] api.get_open_pull_requests.return_value = [_pr(10, "aaa")] api.approve_run.side_effect = [None, requests.HTTPError("409 Conflict")] @@ -692,6 +698,7 @@ def _api_with_prs(prs, runs): api = mock.MagicMock(spec=GitHubApi) api.github_repository = "apache/airflow" api.get_last_base_change.return_value = None + api.get_last_label_change.return_value = None api.get_runs_waiting_for_approval.return_value = runs api.get_open_pull_requests.return_value = prs return api @@ -927,3 +934,111 @@ def test_has_bot_comment_only_counts_the_workflow_comments(login, expected): comments = [{"user": {"login": login}, "body": f"{CI_CHANGE_MARKER}\n\ntext"}] with mock.patch.object(api, "_get_paginated", autospec=True, return_value=comments): assert api.has_bot_comment(10, CI_CHANGE_MARKER) is expected + + [email protected]( + ("decision_labels", "approved_runs"), + [ + pytest.param(frozenset({"full tests needed"}), [3], id="runs started before the change"), + pytest.param(frozenset(), [2, 3], id="no change of the decision's labels"), + ], +) [email protected](f"{MODULE}.decide_for_pull_request", autospec=True) +def test_process_waiting_runs_skips_runs_started_before_a_test_selection_label_changed( + mock_decide, decision_labels, approved_runs +): + api = mock.MagicMock(spec=GitHubApi) + api.github_repository = "apache/airflow" + api.get_runs_waiting_for_approval.return_value = [ + _run(2, "aaa", created_at="2026-10-22T10:00:00Z"), + _run(3, "aaa", created_at="2026-10-22T11:00:00Z"), + ] + api.get_open_pull_requests.return_value = [_pr(10, "aaa")] + api.get_last_base_change.return_value = None + api.get_last_label_change.side_effect = lambda number, labels: ( + datetime(2026, 10, 22, 10, 30, tzinfo=UTC) if labels else None + ) + mock_decide.return_value = ApprovalDecision( + approve=True, reasons=["cheap"], decision_labels=decision_labels + ) + + process_waiting_runs(api, created_since=CREATED_SINCE) + + assert api.approve_run.call_args_list == [mock.call(run_id) for run_id in approved_runs] + + +def test_get_last_label_change_ignores_other_labels_and_reads_the_timeline_once(): + api = GitHubApi(github_repository="apache/airflow", github_token="token", report_only=True) + events = [ + {"event": "unlabeled", "created_at": "2026-10-22T09:00:00Z", "label": {"name": "full tests needed"}}, + { + "event": "unlabeled", + "created_at": "2026-10-22T11:00:00Z", + "label": {"name": NEED_WORKFLOW_APPROVAL_LABEL}, + }, + {"event": "labeled", "created_at": "2026-10-22T11:30:00Z", "label": {"name": "area:UI"}}, + { + "event": "unlabeled", + "created_at": "2026-10-22T11:45:00Z", + "label": {"name": "use public runners"}, + }, + {"event": "base_ref_changed", "created_at": "2026-10-22T08:00:00Z"}, + ] + with mock.patch.object(api, "_get_paginated", autospec=True, return_value=events) as get_paginated: + assert api.get_last_label_change(10, TEST_SELECTION_LABELS) == datetime( + 2026, 10, 22, 9, 0, tzinfo=UTC + ) + assert api.get_last_base_change(10) == datetime(2026, 10, 22, 8, 0, tzinfo=UTC) + get_paginated.assert_called_once() + + [email protected](f"{MODULE}.load_config", autospec=True, return_value=GATE_ENABLED) [email protected](f"{MODULE}.get_changed_files", autospec=True, return_value=CORE_CHANGE) [email protected](f"{MODULE}.fetch_merge_commit", autospec=True, return_value="merge") +def test_gated_approval_depends_on_the_gate_override_labels( + mock_fetch, mock_get_changed_files, mock_load_config +): + api = mock.MagicMock(spec=GitHubApi) + api.github_repository = "apache/airflow" + + assert decide_for_pull_request(api=api, pr=_gated_pr()).decision_labels == GATE_OVERRIDE_LABELS + + [email protected]("event", ["labeled", "unlabeled"]) [email protected]("label", sorted(TEST_SELECTION_LABELS)) +def test_get_last_label_change_counts_every_test_selection_label(label, event): + api = GitHubApi(github_repository="apache/airflow", github_token="token", report_only=True) + events = [{"event": event, "created_at": "2026-10-22T09:00:00Z", "label": {"name": label}}] + with mock.patch.object(api, "_get_paginated", autospec=True, return_value=events): + assert api.get_last_label_change(10, TEST_SELECTION_LABELS) == datetime( + 2026, 10, 22, 9, 0, tzinfo=UTC + ) + + [email protected](f"{MODULE}.decide_for_pull_request", autospec=True) +def test_process_waiting_runs_leaves_gated_runs_started_before_use_fork_ci_was_added(mock_decide): + api = GitHubApi(github_repository="apache/airflow", github_token="token", report_only=False) + timeline = [ + {"event": "labeled", "created_at": "2026-10-22T10:30:00Z", "label": {"name": "use fork ci"}}, + {"event": "unlabeled", "created_at": "2026-10-22T10:40:00Z", "label": {"name": "full tests needed"}}, + ] + mock_decide.return_value = ApprovalDecision( + approve=True, reasons=["fork CI"], decision_labels=GATE_OVERRIDE_LABELS + ) + with ( + mock.patch.object( + api, + "get_runs_waiting_for_approval", + autospec=True, + return_value=[ + _run(2, "aaa", created_at="2026-10-22T10:00:00Z"), + _run(3, "aaa", created_at="2026-10-22T11:00:00Z"), + ], + ), + mock.patch.object(api, "get_open_pull_requests", autospec=True, return_value=[_pr(10, "aaa")]), + mock.patch.object(api, "_get_paginated", autospec=True, return_value=timeline), + mock.patch.object(api, "approve_run", autospec=True) as approve_run, + ): + process_waiting_runs(api, created_since=CREATED_SINCE) + + assert approve_run.call_args_list == [mock.call(3)] diff --git a/dev/calibrate_ci_minutes_estimates.py b/dev/calibrate_ci_minutes_estimates.py index c6d3cc14283..eac8583b686 100644 --- a/dev/calibrate_ci_minutes_estimates.py +++ b/dev/calibrate_ci_minutes_estimates.py @@ -23,6 +23,7 @@ Measure the numbers behind ``dev/breeze/src/airflow_breeze/utils/ci_minutes_esti Downloads recent successful ``Tests (AMD)`` PR runs, their job timings and the selective-check outputs their ``Build info`` job printed, then prints the medians and fits to copy into the estimate module. +Only runs on public runners with the default versions are counted, as the estimate models them. Downloads are cached in ``--cache-dir``, so re-runs only fetch new runs. Needs an authenticated ``gh`` CLI. Run from the repository root: @@ -45,16 +46,17 @@ from pathlib import Path REPOSITORY = "apache/airflow" WORKFLOW = "ci-amd.yml" ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") +# What ci_minutes_estimate.py models: jobs on GitHub's public AMD runners, default versions only. +# Self-hosted (RunsOn) runs and all-versions runs take different times and would skew the medians. +PUBLIC_AMD_RUNNERS = '["ubuntu-22.04"]' # Job-name patterns for each job group of ci_minutes_estimate.py; the first match wins. JOB_GROUPS = [ ("docs", r"^CI image checks / (Build documentation|Publish documentation)"), ("core", r"^(MySQL|Postgres|Sqlite|Non-DB) tests: core|^Low dep tests:core"), ("special", r"^Special tests"), - ( - "providers", - r"^Provider distributions tests|tests: providers|^Low dep tests: providers|^MyPy providers checks", - ), + ("mypy-providers", r"^MyPy providers checks"), + ("providers", r"^Provider distributions tests|tests: providers|^Low dep tests: providers"), ("prod", r"^Build PROD images|^Additional PROD image tests"), ("kubernetes", r"^Kubernetes tests"), ("helm", r"^Helm tests"), @@ -64,6 +66,7 @@ JOB_GROUPS = [ ("agent-framework", r"^Agent framework tests"), ("java-sdk", r"^Java SDK"), ("go-sdk", r"^Go SDK"), + ("ts-sdk-docs", r"^TypeScript SDK docs"), ("ts-sdk", r"^TypeScript SDK"), ("airflow-ctl", r"^Airflow CTL"), ("base", r".*"), @@ -151,6 +154,15 @@ def count_provider_test_files(provider_id: str) -> int: return sum(1 for _ in tests_path.rglob("test_*.py")) +def get_unmodelled_reason(outputs: dict[str, str]) -> str | None: + """Why a run cannot calibrate the estimate, or None when it matches what the estimate models.""" + if outputs.get("runner-type") != PUBLIC_AMD_RUNNERS: + return f"it ran on {outputs.get('runner-type', 'unknown runners')}" + if outputs.get("all-versions") != "false": + return "it ran all Python and database versions" + return None + + def build_rows(run_ids: list[int], cache_dir: Path) -> list[dict]: rows = [] for run_id in run_ids: @@ -161,6 +173,10 @@ def build_rows(run_ids: list[int], cache_dir: Path) -> list[dict]: outputs = read_outputs(log_file) if "full-tests-needed" not in outputs: continue + reason = get_unmodelled_reason(outputs) + if reason is not None: + print(f"skipping run {run_id}: {reason}") + continue minutes: dict[str, float] = defaultdict(float) longest: dict[str, float] = defaultdict(float) for job in json.loads(jobs_file.read_text()):
