This is an automated email from the ASF dual-hosted git repository. potiuk pushed a commit to branch fork-ci-test-merge-with-target in repository https://gitbox.apache.org/repos/asf/airflow.git
commit 302d22d73c0dddfaf3d95733f844b3c95063e193 Author: Jarek Potiuk <[email protected]> AuthorDate: Tue Nov 10 05:40:41 2026 -0700 Report fork CI's tested base and withhold CI passed on an old base Fork CI tests the head commit of a gated pull request, while project CI tests it merged into the target branch, so a pull request could go green on a stale base and merge on that result. When a head commit turns green, the fork CI monitor now compares it with the base branch: the merge base it was tested on, how many commits the base branch has since, and which of the pull request's files the base branch changed since. `CI passed` is reported only when the base is fewer than 200 commits behind and changed none of those files; otherwise it is posted as pending with a rebase hint, and the state comment lists the overlapping files. Every green state comment and the `CI passed` description name the base commit the run tested. Testing the merge commit in the fork itself was rejected: it would have to change every checkout of the fork run, there is no merge ref before the pull request exists, GitHub recomputes it asynchronously, and the monitor would have to trust the run's own report of what it tested. The design document lists the reasons. Generated-by: Claude Opus 5 --- contributing-docs/18_contribution_workflow.rst | 6 + .../doc/ci/08_fork_ci_for_external_contributors.md | 68 +++++++-- .../src/airflow_breeze/utils/fork_ci_messages.py | 101 ++++++++++--- .../src/airflow_breeze/utils/fork_ci_reconcile.py | 83 +++++++--- dev/breeze/tests/test_fork_ci_messages.py | 99 ++++++++++-- dev/breeze/tests/test_fork_ci_reconcile.py | 167 ++++++++++++++++----- 6 files changed, 421 insertions(+), 103 deletions(-) diff --git a/contributing-docs/18_contribution_workflow.rst b/contributing-docs/18_contribution_workflow.rst index 663808a4ed3..065dfb0cab1 100644 --- a/contributing-docs/18_contribution_workflow.rst +++ b/contributing-docs/18_contribution_workflow.rst @@ -188,6 +188,12 @@ does not run for 30 days is closed with an explanation; you can reopen it after files that decide which CI jobs run and where, such as ``.github/``, the breeze sources or ``scripts/ci/prek/``, a green fork run is not enough: a maintainer reviews the change and runs project CI for it. +Fork CI tests your branch as it is, while project CI tests it merged with the target branch. The +``CI passed`` check that merging requires is therefore reported only when your branch is fewer than +200 commits behind the target branch and the target branch has not changed any file your pull request +changes since your branch's base. Otherwise the pull request comment lists those files and asks you +to rebase on ``upstream/main`` (or the target branch) and push, which runs fork CI again. + **Joining the pilot.** Open a pull request that adds your GitHub login to the ``[pilot]`` table in ``.github/fork-ci-gate.toml``, with a join instant after the expected merge, for example the next day at 00:00 UTC: 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 1e73184cf61..abe89ba3a7c 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 @@ -1013,6 +1013,8 @@ is the source the ruleset accepts. pending or failing one would say, and it never competes with project CI's own check. - `green_unverified` gets no `CI passed`: the fork run used CI files the pull request changed, so it is not evidence. Once a maintainer adds `use project ci`, project CI reports the check. +- A `green` run whose base is not current gets a `pending` `CI passed` asking to rebase, and one + whose base could not be compared gets none (see "Tested base"). - When the head commit leaves `green`, for example a re-run in the fork that fails, a `success` posted earlier is overwritten with `pending` so the pull request cannot merge on it. - A pull request released with `use project ci` keeps any `success` the reconciler posted for @@ -1076,14 +1078,46 @@ conditions: completed, first attempt, its commit does not change `.github/workfl did project CI work by the tampering check's rules. Of its jobs only the gate and the jobs that also run in a gated run started, none failed, and at least one was cancelled. -### Far behind the base branch - -A fork run tests the pushed commit as it is, while project CI tests it merged with the base -branch. When a head commit turns green, the reconciler counts the commits it is behind its base -branch (`behind_by` of `GET /repos/apache/airflow/compare/<base>...<fork-owner>:<sha>`, once per -head commit, stored in the marker). From 200 commits behind, about four days of `main`, the -state comment says so and asks the contributor to rebase, since the merged result can still -fail. The count is advisory and does not change the state. +### Tested base + +A fork run tests the pushed commit as it is, while project CI's `pull_request` run tests GitHub's +merge commit of the pull request into its base branch. A fork run on an old base could otherwise go +green and merge although the merged result was never tested. So `CI passed` is only reported for a +fork run whose base is close enough to the base branch that the merge changes nothing the pull +request touches. + +When a head commit turns green, the reconciler compares it with the base branch, once per head +commit, and stores the result in the marker as `"base": {"sha", "behind", "overlap"}`: + +- `GET /repos/apache/airflow/compare/<base>...<fork-owner>:<sha>` gives the merge base, the commit + of the base branch the fork run tested on top of (`sha`), the commits the base branch has since + (`behind`), and the files the pull request changes. +- When `behind` is not zero, `GET /repos/apache/airflow/compare/<merge base>...<base>` gives the + files the base branch changed since. `overlap` is the paths in both lists, a renamed file + counting under both paths. + +`CI passed` is reported only when the base is current: fewer than `MAX_BEHIND_COMMITS` (200, about +four days of `main`) commits behind and no overlap. Otherwise the state stays `green` (the pull +request can be reviewed, and its status, labels and draft state are those of `green`), the state +comment lists the overlapping files or says how far behind the base is and gives the rebase +commands, and `CI passed` is posted as `pending` with a description saying to rebase. A rebase and +push starts a new fork run, which is compared again when it turns green. Every green state comment +names the base commit the run tested and how far behind it was, and the `CI passed` description +names it as well. + +The compare API lists at most 300 files. A pull request with more is listed with +`GET /repos/apache/airflow/pulls/<n>/files` instead (up to 3000); a base branch that changed 300 or +more files since the merge base cannot be checked and counts as overlapping, which a rebase +resolves. When the compare fails, `CI passed` is not reported, the failure is reported to Slack and +the next run compares again. A marker written before this check, with no `base`, is compared again +on the next run, so a pull request that already had `CI passed` loses it if its base turns out not +to be current. A retarget compares again, as it decides green trust again. + +This narrows the gap to project CI without closing it. A base change in other files can still break +the pull request, within at most 200 commits, which matches what project CI leaves open after its +run: a `pull_request` run is not repeated when the base branch moves, so the merged result at merge +time is not the one it tested either. Testing the merge commit in the fork itself was considered and +rejected; see "Known limitations". ### Unknown failures and attention items go to Slack @@ -1130,8 +1164,8 @@ Per run, with ~700 gated pull requests: ~28 GraphQL calls for pass 2, ~14 for fo ~1 for pass 1, plus one REST call per pull request in `setup_required`, a few paginated REST calls for the recent `pull_request` runs, one REST call per base branch for its workflow files, a jobs call and a compare call only per long `ci-amd.yml` run of a gated pull request, -one changed-files call per head commit that turns green, plus one mutation per actual state -change. Job statuses add a jobs call and a status call per pull request with a fork run in +one changed-files call and one or two compare calls per head commit that turns green, plus one +mutation per actual state change. Job statuses add a jobs call and a status call per pull request with a fork run in progress or a completed run not yet reported, and up to two status mutations per job, at most `MAX_JOB_STATUSES_PER_RUN` per run; see "Write budget". Pull requests that pass 1 labelled and the search does not list yet add one GraphQL call per 25. Well inside @@ -1335,6 +1369,17 @@ as a failed check rather than a crash. - An external pull request that legitimately changes workflow files, was released with `use project ci` and then had that label removed is closed on the next run, because both signals hold. Keeping `use project ci` on such a pull request is the intended handling. +- Fork CI tests the head commit, not the pull request merged into its base branch; "Tested base" + only bounds the difference. Testing the merged result in the fork was rejected because: + every job of the fork run checks out the pushed commit (about 60 `actions/checkout` steps across + `ci-amd.yml` and its reusable workflows, plus the image builds and caches keyed by the commit), + and each would have to check out one merge commit fixed for the whole run; the first push of a + branch happens before its pull request exists, so there is no `refs/pull/<n>/merge` to test yet; + GitHub computes that ref asynchronously after each push and replaces it whenever the base branch + moves, so a run would have to wait for it and could find its commit gone mid-run; and the + reconciler would have to trust the run's own report of which merge it tested. Merging the base + branch into the head in every job instead needs the shared history of both, which shallow + checkouts do not have. ## Testing @@ -1351,7 +1396,8 @@ as a failed check rather than a crash. close), a fork with a name other than `airflow`, marker round-trips, status desired-vs-current comparison, the unknown-failure collection, green trust (a change to CI-defining files keeps the status pending without drafting the pull request, decided once - per commit) and the gate + per commit), the tested base (`CI passed` only on a current base, the marker and comment naming + the base, compared again for a new commit, a retarget or a marker without a base) and the gate tampering check: a tampered run on an earlier commit is caught after a clean push, each signal alone does not close, short runs and gate-only jobs do not count, an in-progress non-gate job, a removed, duplicated or long-running gate job and an injected workflow with a diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py index a68acd8d8ea..d4e5ec44cab 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_messages.py @@ -43,7 +43,6 @@ JOB_NAME_MAX_LENGTH = 100 # CI's gate job reports it; for a gated pull request, which never runs project CI, the reconciler # reports it from the fork run. CI_PASSED_CONTEXT = "CI passed" -CI_PASSED_DESCRIPTION = "Fork CI passed for this commit" CI_PASSED_REVOKED_DESCRIPTION = "Fork CI result no longer trusted for this commit" # Also required by the merge rulesets, so GitHub lists it as expected and waiting on every pull # request from the moment it is pushed. The reconciler reports it, linking the run: for a gated pull @@ -51,9 +50,12 @@ CI_PASSED_REVOKED_DESCRIPTION = "Fork CI result no longer trusted for this commi CI_RUN_STARTED_CONTEXT = "CI run started" CI_RUN_STARTED_DESCRIPTION = "CI runs in the contributor's fork - details open the run" CI_RUN_STARTED_PROJECT_DESCRIPTION = "Project CI runs in apache/airflow - details open the run" -# A fork run tests the head commit as it is, not merged with the base branch. Further behind than -# this, it says little about the result after merge. -BEHIND_WARNING_COMMITS = 200 +# A fork run tests the head commit as it is, not merged with the base branch as project CI does. From +# this many commits behind, `CI passed` is withheld until the branch is rebased, as it is when the +# base branch changed any file the pull request changes. +MAX_BEHIND_COMMITS = 200 +# Overlapping files named in the state comment; the marker keeps them all. +OVERLAP_LISTED = 10 MARKER_PREFIX = "<!-- fork-ci-gate: " MARKER_PATTERN = re.compile(r"<!-- fork-ci-gate: (?P<payload>.*?) -->") DESIGN_URL = ( @@ -82,13 +84,34 @@ WHY_FORK_CI = ( ) +@dataclass(frozen=True) +class BaseCheck: + """ + The base a green fork run tested, compared with the base branch when the run turned green. + + :param sha: the merge base of the head commit and the base branch, the base the run tested. + :param behind: commits on the base branch since ``sha``. + :param overlap: paths that both the pull request and the base branch since ``sha`` change; + None when GitHub listed too many changed files on either side to tell. + """ + + sha: str + behind: int + overlap: tuple[str, ...] | None = () + + @property + def is_current(self) -> bool: + """Whether the run tested close enough to the merged result for `CI passed`.""" + return self.behind < MAX_BEHIND_COMMITS and self.overlap == () + + @dataclass(frozen=True) class Marker: state: str sha: str since: datetime - # Commits the head commit was behind its base branch when its fork CI turned green. - behind: int | None = None + # Set when the head commit's fork CI turned green and its base could be compared. + base: BaseCheck | None = None # The completed fork run (``<run id>:<attempt>:<conclusion>``) whose jobs are all reported. jobs: str | None = None # Set only by a reconciler that ran the gate protection check for this head commit, so a @@ -98,8 +121,12 @@ class Marker: def render_marker(marker: Marker) -> str: data: dict[str, object] = {"state": marker.state, "sha": marker.sha, "since": marker.since.isoformat()} - if marker.behind is not None: - data["behind"] = marker.behind + if marker.base is not None: + data["base"] = { + "sha": marker.base.sha, + "behind": marker.base.behind, + "overlap": None if marker.base.overlap is None else list(marker.base.overlap), + } if marker.jobs is not None: data["jobs"] = marker.jobs if marker.gate_checked: @@ -114,11 +141,19 @@ def parse_marker(body: str) -> Marker | None: return None try: data = json.loads(match.group("payload")) + # A marker written before the base was checked carries only "behind"; its base is checked again. + base = data.get("base") return Marker( state=data["state"], sha=data["sha"], since=datetime.fromisoformat(data["since"]), - behind=data.get("behind"), + base=None + if base is None + else BaseCheck( + sha=base["sha"], + behind=base["behind"], + overlap=None if base["overlap"] is None else tuple(base["overlap"]), + ), jobs=data.get("jobs"), gate_checked=data.get("gate_checked") is True, ) @@ -178,25 +213,53 @@ _AUTHOR_DRAFT_TEXTS = { } -def behind_warning(behind: int, *, base_ref: str) -> str: +def tested_base_text(base: BaseCheck, *, base_ref: str) -> str: + text = ( + f"Fork CI tested this branch on top of `{base_ref}` at " + f"[`{base.sha[:10]}`](https://github.com/apache/airflow/commit/{base.sha}), " + f"{base.behind} commits behind `{base_ref}` when the run turned green. Fork CI tests the branch " + f"as it is, while project CI would test it merged with the current `{base_ref}`." + ) + if base.is_current: + return f"{text} No commit since changed a file this pull request changes." + if base.overlap is None: + reason = f"`{base_ref}` changed too many files since to tell whether it changed the same files." + elif base.overlap: + listed = ", ".join(f"`{path}`" for path in base.overlap[:OVERLAP_LISTED]) + more = len(base.overlap) - OVERLAP_LISTED + reason = f"`{base_ref}` changed files this pull request changes since: {listed}" + ( + f" and {more} more." if more > 0 else "." + ) + else: + reason = "That is too far behind to say much about the merged result." return ( - f"**This branch is {behind} commits behind `{base_ref}`.** Fork CI tests the branch as it is, " - f"while the merged result also contains those commits, so it can still fail after the merge. " - f"Rebase on `{base_ref}` and push to test it against the current code." + f"{text}\n\n**Rebase needed.** {reason} The `CI passed` check is not reported until fork CI is " + f"green for a branch rebased on `{base_ref}`:\n\n" + f"```bash\ngit fetch upstream\ngit rebase upstream/{base_ref}\ngit push --force-with-lease\n```" ) +def ci_passed_description(base: BaseCheck, *, base_ref: str) -> str: + return f"Fork CI passed; base {base_ref}@{base.sha[:10]} was {base.behind} commits behind, no overlap" + + +def rebase_needed_description(base: BaseCheck, *, base_ref: str) -> str: + if base.overlap is None: + why = f"{base_ref} changed too many files since to check" + elif base.overlap: + why = f"{base_ref} changed {len(base.overlap)} of the same files since" + else: + why = f"base is {base.behind} commits behind" + return f"Rebase on {base_ref} and push: {why}" + + def state_comment( marker: Marker, *, fork: str, run_url: str | None, base_ref: str = "main", author_draft: bool = False ) -> str: text = _AUTHOR_DRAFT_TEXTS.get(marker.state) if author_draft else None parts = [render_marker(marker), text or _STATE_TEXTS[marker.state], WHY_FORK_CI] - if ( - marker.state in ("green", "green_unverified") - and marker.behind is not None - and marker.behind >= BEHIND_WARNING_COMMITS - ): - parts.append(behind_warning(marker.behind, base_ref=base_ref)) + if marker.state in ("green", "green_unverified") and marker.base is not None: + parts.append(tested_base_text(marker.base, base_ref=base_ref)) if run_url: parts.append(f"Latest run: {run_url}") if marker.state == "setup_required": diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py index 6d666772e38..7b3324d4d0c 100644 --- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py +++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py @@ -59,7 +59,6 @@ from airflow_breeze.utils.fork_ci_gate import ( from airflow_breeze.utils.fork_ci_github import GitHubClient, GitHubError from airflow_breeze.utils.fork_ci_messages import ( CI_PASSED_CONTEXT, - CI_PASSED_DESCRIPTION, CI_PASSED_REVOKED_DESCRIPTION, CI_RUN_STARTED_CONTEXT, CI_RUN_STARTED_DESCRIPTION, @@ -69,13 +68,16 @@ from airflow_breeze.utils.fork_ci_messages import ( STALE_JOB_STATUS, STATUS_CONTEXT, STATUS_STATES, + BaseCheck, Marker, + ci_passed_description, fork_actions_url, gate_modified_close_comment, inactivity_close_comment, job_status_context, nudge_comment, parse_marker, + rebase_needed_description, released_comment, state_comment, status_description, @@ -580,17 +582,24 @@ def _set_status( plan.status = desired -def _set_ci_passed(plan: Plan, pr: PullRequestSnapshot, *, passed: bool, target_url: str) -> None: +def _set_ci_passed( + plan: Plan, pr: PullRequestSnapshot, state: ForkState, base: BaseCheck | None, *, target_url: str +) -> None: """ Report fork CI to the `CI passed` check the merge ruleset requires. - Only success is ever posted: a gated pull request without the status cannot merge, which is - what a pending or failing one would say. A success that no longer holds for the head commit, - such as a failed re-run in the fork, is overwritten so the pull request cannot merge on it. + Success is posted only for a trusted green run whose base is current enough; a gated pull + request without the status cannot merge. A green run on an old base gets a pending status that + says to rebase. A success that no longer holds for the head commit, such as a failed re-run in + the fork, is overwritten so the pull request cannot merge on it. """ - if passed and pr.ci_passed_state != "success": - plan.ci_passed = ("success", target_url, CI_PASSED_DESCRIPTION) - elif not passed and pr.ci_passed_state == "success": + if state == ForkState.GREEN and base is not None and base.is_current: + if pr.ci_passed_state != "success": + plan.ci_passed = ("success", target_url, ci_passed_description(base, base_ref=pr.base_ref)) + elif state == ForkState.GREEN and base is not None: + if pr.ci_passed_state != "pending": + plan.ci_passed = ("pending", target_url, rebase_needed_description(base, base_ref=pr.base_ref)) + elif pr.ci_passed_state == "success": plan.ci_passed = ("pending", target_url, CI_PASSED_REVOKED_DESCRIPTION) @@ -614,7 +623,7 @@ def classify( repo: str, find_bypass_run: Callable[[], str | None], changes_ci_files: Callable[[], bool | None], - count_behind: Callable[[], int | None] = lambda: None, + compare_base: Callable[[], BaseCheck | None] = lambda: None, jobs_reported: str | None = None, head_shared_across_bases: bool = False, find_gate_change: Callable[[], str | None] = lambda: None, @@ -630,8 +639,9 @@ def classify( cannot be told for the snapshot's head commit; only called when a new head commit turns green in the fork. On None nothing is changed, since the decision would be recorded on a commit the answer may not belong to. - :param count_behind: how many commits the head commit is behind the base branch, None when - unknown; called at the same time as ``changes_ci_files``. + :param compare_base: compares the head commit's base with the base branch, None when that + cannot be read; called when a new head commit turns green, alongside ``changes_ci_files``, + and on later runs until it succeeds. :param jobs_reported: the completed fork run whose jobs are all reported as statuses, recorded in the marker so that later runs do not read its jobs again. :param head_shared_across_bases: another gated pull request has the same head commit and a @@ -754,7 +764,7 @@ def classify( plan.unknown.append(unknown_reason or "unknown fork state") return plan refresh_status = False - behind = marker.behind if marker and marker.sha == pr.head_sha else None + base = marker.base if marker and marker.sha == pr.head_sha else None if state == ForkState.GREEN: # The decision is read back from the commit status on the head commit, which only # apache/airflow's own tokens can write, never from a comment. The changed files depend @@ -784,9 +794,16 @@ def classify( if changes is None: return Plan() unverified = changes - behind = count_behind() + base = None if unverified: state = ForkState.GREEN_UNVERIFIED + if base is None: + base = compare_base() + if base is None and state == ForkState.GREEN: + plan.unknown.append( + f"fork CI is green, but the head commit could not be compared with `{pr.base_ref}`, " + "so `CI passed` waits for the next run" + ) plan.state = state since = marker.since if marker and marker.state == state.value else now @@ -822,7 +839,7 @@ def classify( target_url = fork.run_url or fork_actions_url(pr.head_repo) _set_status(plan, pr, state.value, target_url, force=refresh_status) - _set_ci_passed(plan, pr, passed=state == ForkState.GREEN, target_url=target_url) + _set_ci_passed(plan, pr, state, base, target_url=target_url) _set_ci_run_started(plan, pr, fork) green = state in (ForkState.GREEN, ForkState.GREEN_UNVERIFIED) _set_marker_comment( @@ -833,7 +850,7 @@ def classify( state.value, pr.head_sha, since, - behind=behind if green else None, + base=base if green else None, jobs=jobs_reported, gate_checked=gate_checked, ), @@ -2059,17 +2076,37 @@ def pull_request_changes_ci_files(client: GitHubClient, repo: str, number: int, return changes_ci_definition(changed_paths(files), file_count=len(files)) -def count_commits_behind(client: GitHubClient, repo: str, pr: PullRequestSnapshot) -> int | None: - """Commits on the base branch that the head commit does not have; None when not readable.""" +def compare_with_base(client: GitHubClient, repo: str, pr: PullRequestSnapshot) -> BaseCheck | None: + """ + Compare the base a fork run tested with the base branch; None when not readable. + + The fork run tests the head commit, so its base is the merge base with the base branch. Two + three-dot compares from it give the files the pull request changes and the files the base branch + changed since; a file in both means the merged result differs from what the fork run tested + in a file the pull request changes. Compares list at most 300 files (on their first page, which + ``per_page`` does not limit): a pull request with more is listed with the files endpoint instead, + while a base branch that changed more cannot be checked until the branch is rebased. + """ owner = (pr.head_repo or "").split("/", 1)[0] + base_ref = quote(pr.base_ref, safe="") try: - compare = client.rest_get( - f"repos/{repo}/compare/{quote(pr.base_ref, safe='')}...{owner}:{pr.head_sha}?per_page=1" + head_side = client.rest_get(f"repos/{repo}/compare/{base_ref}...{owner}:{pr.head_sha}?per_page=1") + merge_base = head_side["merge_base_commit"]["sha"] + behind = head_side["behind_by"] + base_side = ( + client.rest_get(f"repos/{repo}/compare/{merge_base}...{base_ref}?per_page=1") if behind else {} ) - except GitHubError as e: - console_print(f"[warning]#{pr.number}: could not count commits behind {pr.base_ref}: {e}[/]") + head_files = head_side.get("files") or [] + if len(head_files) >= COMPARE_FILES_CAP: + head_files = client.rest_get(f"repos/{repo}/pulls/{pr.number}/files?per_page=100", paginate=True) + except (GitHubError, KeyError, TypeError) as e: + console_print(f"[warning]#{pr.number}: could not compare with {pr.base_ref}: {e}[/]") return None - return compare.get("behind_by") + base_files = base_side.get("files") or [] + if len(head_files) >= PULL_REQUEST_FILES_CAP or len(base_files) >= COMPARE_FILES_CAP: + return BaseCheck(merge_base, behind, None) + overlap = set(changed_paths(head_files)) & set(changed_paths(base_files)) + return BaseCheck(merge_base, behind, tuple(sorted(overlap))) def find_heads_shared_across_bases(prs: Iterable[PullRequestSnapshot]) -> set[str]: @@ -2403,7 +2440,7 @@ def run_reconcile( changes_ci_files=functools.partial( pull_request_changes_ci_files, client, repo, pr.number, pr.head_sha ), - count_behind=functools.partial(count_commits_behind, client, repo, pr), + compare_base=functools.partial(compare_with_base, client, repo, pr), jobs_reported=jobs_reported, head_shared_across_bases=pr.head_sha in shared_heads, find_gate_change=functools.partial(gate_change_finder.find, pr), diff --git a/dev/breeze/tests/test_fork_ci_messages.py b/dev/breeze/tests/test_fork_ci_messages.py index 8d565cbecb4..b9b564120eb 100644 --- a/dev/breeze/tests/test_fork_ci_messages.py +++ b/dev/breeze/tests/test_fork_ci_messages.py @@ -25,24 +25,27 @@ import pytest from airflow_breeze.utils.fork_ci_messages import ( _STATE_TEXTS, _STATUS_DESCRIPTIONS, - BEHIND_WARNING_COMMITS, CI_CHANGE_MARKER, CI_MODIFICATION_MARKER, - CI_PASSED_DESCRIPTION, CI_PASSED_REVOKED_DESCRIPTION, FOOTER, GATED_NOTE_SENTENCE, JOB_NAME_MAX_LENGTH, JOB_STATUS_CONTEXT_PREFIX, + MAX_BEHIND_COMMITS, MAX_LISTED_CI_FILES, + OVERLAP_LISTED, + BaseCheck, Marker, ci_change_approval_comment, ci_modification_note_comment, + ci_passed_description, gate_modified_close_comment, inactivity_close_comment, job_status_context, nudge_comment, parse_marker, + rebase_needed_description, released_comment, render_marker, state_comment, @@ -57,8 +60,9 @@ MARKER = Marker(state="awaiting_push", sha="abc123", since=datetime(2026, 10, 15 @pytest.mark.parametrize( "marker", [ - pytest.param(MARKER, id="without-behind"), - pytest.param(replace(MARKER, behind=12), id="behind"), + pytest.param(MARKER, id="without-base"), + pytest.param(replace(MARKER, base=BaseCheck("def456", 12, ("a.py",))), id="base"), + pytest.param(replace(MARKER, base=BaseCheck("def456", 12, None)), id="base-unchecked"), pytest.param(replace(MARKER, jobs="123:SUCCESS"), id="jobs"), pytest.param(replace(MARKER, gate_checked=True), id="gate-checked"), ], @@ -67,27 +71,81 @@ def test_marker_round_trip(marker): assert parse_marker(f"text before\n{render_marker(marker)}\ntext after") == marker -def test_marker_without_behind_keeps_its_rendering(): - assert "behind" not in render_marker(MARKER) +def test_marker_without_base_keeps_its_rendering(): + assert "base" not in render_marker(MARKER) assert "jobs" not in render_marker(MARKER) +def test_marker_from_before_the_base_check_has_no_base(): + marker = parse_marker( + '<!-- fork-ci-gate: {"behind": 3, "sha": "a", "since": "2026-10-15T10:00:00+00:00", "state": "green"} -->' + ) + + assert marker is not None + assert marker.base is None + + @pytest.mark.parametrize( - ("state", "behind", "warned"), + ("base", "current"), [ - pytest.param("green", BEHIND_WARNING_COMMITS, True, id="green-far-behind"), - pytest.param("green_unverified", BEHIND_WARNING_COMMITS + 1, True, id="unverified-far-behind"), - pytest.param("green", BEHIND_WARNING_COMMITS - 1, False, id="green-close"), - pytest.param("green", None, False, id="green-not-counted"), - pytest.param("failed", BEHIND_WARNING_COMMITS, False, id="failed"), + pytest.param(BaseCheck("d", MAX_BEHIND_COMMITS - 1), True, id="close-no-overlap"), + pytest.param(BaseCheck("d", MAX_BEHIND_COMMITS), False, id="far-behind"), + pytest.param(BaseCheck("d", 1, ("a.py",)), False, id="overlap"), + pytest.param(BaseCheck("d", 1, None), False, id="unchecked"), + ], +) +def test_base_check_is_current(base, current): + assert base.is_current is current + + [email protected]( + ("state", "base", "expected", "unexpected"), + [ + pytest.param( + "green", + BaseCheck("d" * 40, 3), + [ + "`v3-1-test` at [`dddddddddd`](https://github.com/apache/airflow/commit/" + "d" * 40, + "3 commits", + ], + ["Rebase needed"], + id="current", + ), + pytest.param( + "green", + BaseCheck("d", MAX_BEHIND_COMMITS), + ["Rebase needed", "too far behind", "git rebase upstream/v3-1-test"], + [], + id="far-behind", + ), + pytest.param( + "green_unverified", + BaseCheck("d", 4, ("a.py", "b.py")), + ["Rebase needed", "`a.py`, `b.py`."], + [], + id="overlap", + ), + pytest.param( + "green", + BaseCheck("d", 4, tuple(f"f{i:02}.py" for i in range(OVERLAP_LISTED + 2))), + [f"`f{OVERLAP_LISTED - 1:02}.py` and 2 more."], + [f"`f{OVERLAP_LISTED:02}.py`"], + id="overlap-shortened", + ), + pytest.param( + "green", BaseCheck("d", 4, None), ["Rebase needed", "too many files"], [], id="unchecked" + ), + pytest.param("failed", BaseCheck("d", MAX_BEHIND_COMMITS), [], ["tested this branch"], id="failed"), + pytest.param("green", None, [], ["tested this branch"], id="not-compared"), ], ) -def test_state_comment_warns_about_a_branch_far_behind(state, behind, warned): +def test_state_comment_says_which_base_was_tested(state, base, expected, unexpected): text = state_comment( - Marker(state, "a", MARKER.since, behind=behind), fork="u/airflow", run_url=None, base_ref="v3-1-test" + Marker(state, "a", MARKER.since, base=base), fork="u/airflow", run_url=None, base_ref="v3-1-test" ) - assert ("commits behind `v3-1-test`" in text) is warned + assert all(part in text for part in expected) + assert not any(part in text for part in unexpected) @pytest.mark.parametrize( @@ -164,7 +222,16 @@ def test_setup_texts_give_the_exact_command(text): def test_status_descriptions_fit_github_limit(): - descriptions = [*_STATUS_DESCRIPTIONS.values(), CI_PASSED_DESCRIPTION, CI_PASSED_REVOKED_DESCRIPTION] + base_ref = "providers-cncf-kubernetes/v10-22" + descriptions = [ + *_STATUS_DESCRIPTIONS.values(), + CI_PASSED_REVOKED_DESCRIPTION, + ci_passed_description(BaseCheck("d" * 40, 199), base_ref=base_ref), + *( + rebase_needed_description(BaseCheck("d" * 40, 9999, overlap), base_ref=base_ref) + for overlap in ((), ("a",) * 300, None) + ), + ] assert all(len(description) < 140 for description in descriptions) diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py b/dev/breeze/tests/test_fork_ci_reconcile.py index 7b02221f062..cac8c08b9b5 100644 --- a/dev/breeze/tests/test_fork_ci_reconcile.py +++ b/dev/breeze/tests/test_fork_ci_reconcile.py @@ -36,6 +36,7 @@ from airflow_breeze.utils.fork_ci_messages import ( JOB_STATUS_CONTEXT_PREFIX, STALE_JOB_STATUS, STATUS_CONTEXT, + BaseCheck, Marker, parse_marker, render_marker, @@ -60,8 +61,8 @@ from airflow_breeze.utils.fork_ci_reconcile import ( build_slack_payload, changes_ci_definition, classify, + compare_with_base, compute_projection, - count_commits_behind, discover, drop_reported, fetch_fork_observations, @@ -97,6 +98,8 @@ CONFIG = GateConfig(enabled=True, enabled_since=datetime(2026, 10, 1, tzinfo=UTC RUN_URL = "https://github.com/someone/airflow/actions/runs/1" PROJECT_RUN_URL = "https://github.com/apache/airflow/actions/runs/2" GREEN = ForkObservation(suite_status="COMPLETED", suite_conclusion="SUCCESS", run_url=RUN_URL) +MERGE_BASE = "def4567890aa" +CURRENT_BASE = BaseCheck(MERGE_BASE, 3) def _pr(**overrides) -> PullRequestSnapshot: @@ -129,7 +132,7 @@ def _classify( bypass_run=None, changes_ci_files=False, config=CONFIG, - behind=None, + base=CURRENT_BASE, head_shared_across_bases=False, gate_change=None, ): @@ -143,7 +146,7 @@ def _classify( repo=REPO, find_bypass_run=find_bypass, changes_ci_files=changes, - count_behind=mock.Mock(return_value=behind), + compare_base=mock.Mock(return_value=base), head_shared_across_bases=head_shared_across_bases, find_gate_change=lambda: gate_change, ) @@ -472,66 +475,116 @@ UNVERIFIED_STATUS = ( ) -CI_PASSED = ("success", RUN_URL, "Fork CI passed for this commit") +CI_PASSED = ("success", RUN_URL, "Fork CI passed; base main@def4567890 was 3 commits behind, no overlap") CI_PASSED_REVOKED = ("pending", RUN_URL, "Fork CI result no longer trusted for this commit") +OVERLAPPING_BASE = BaseCheck(MERGE_BASE, 3, ("a.py",)) +REBASE_NEEDED = ("pending", RUN_URL, "Rebase on main and push: main changed 1 of the same files since") @pytest.mark.parametrize( - ("fork", "changes_ci_files", "ci_passed_state", "expected"), + ("fork", "changes_ci_files", "base", "ci_passed_state", "expected"), [ - pytest.param(GREEN, False, None, CI_PASSED, id="green-posts-success"), - pytest.param(GREEN, False, "success", None, id="green-already-posted"), - pytest.param(GREEN, False, "pending", CI_PASSED, id="green-after-revoke"), - pytest.param(GREEN, True, None, None, id="unverified-gets-none"), + pytest.param(GREEN, False, CURRENT_BASE, None, CI_PASSED, id="green-posts-success"), + pytest.param(GREEN, False, CURRENT_BASE, "success", None, id="green-already-posted"), + pytest.param(GREEN, False, CURRENT_BASE, "pending", CI_PASSED, id="green-after-revoke"), + pytest.param(GREEN, True, CURRENT_BASE, None, None, id="unverified-gets-none"), + pytest.param(GREEN, False, OVERLAPPING_BASE, None, REBASE_NEEDED, id="old-base-asks-to-rebase"), + pytest.param(GREEN, False, OVERLAPPING_BASE, "success", REBASE_NEEDED, id="old-base-revokes"), + pytest.param(GREEN, False, OVERLAPPING_BASE, "pending", None, id="old-base-already-pending"), + pytest.param(GREEN, False, None, None, None, id="base-not-compared-gets-none"), + pytest.param(GREEN, False, None, "success", CI_PASSED_REVOKED, id="base-not-compared-revokes"), pytest.param( ForkObservation(suite_status="COMPLETED", suite_conclusion="FAILURE", run_url=RUN_URL), False, + CURRENT_BASE, "success", CI_PASSED_REVOKED, id="failed-rerun-revokes", ), - pytest.param(GREEN, True, "success", CI_PASSED_REVOKED, id="unverified-after-retarget-revokes"), + pytest.param( + GREEN, True, CURRENT_BASE, "success", CI_PASSED_REVOKED, id="unverified-after-retarget-revokes" + ), pytest.param( ForkObservation(suite_status="IN_PROGRESS", run_url=RUN_URL), False, + CURRENT_BASE, None, None, id="running-gets-none", ), ], ) -def test_ci_passed_reports_trusted_green_only(fork, changes_ci_files, ci_passed_state, expected): - plan, _, _ = _classify(_pr(ci_passed_state=ci_passed_state), fork, changes_ci_files=changes_ci_files) +def test_ci_passed_reports_trusted_green_on_a_current_base_only( + fork, changes_ci_files, base, ci_passed_state, expected +): + plan, _, _ = _classify( + _pr(ci_passed_state=ci_passed_state), fork, changes_ci_files=changes_ci_files, base=base + ) assert plan.ci_passed == expected +def test_green_whose_base_cannot_be_compared_is_reported(): + plan, _, _ = _classify(_pr(), GREEN, base=None) + + assert plan.state == ForkState.GREEN + assert any("could not be compared with `main`" in item for item in plan.unknown) + + +KEPT_BASE = BaseCheck("kept", 300, None) + + @pytest.mark.parametrize( - ("pr_overrides", "behind", "expected_behind", "warned"), + ("pr_overrides", "compared", "expected_base"), [ - pytest.param({}, 250, 250, True, id="counted-on-decision"), - pytest.param({}, 3, 3, False, id="close-to-base"), + pytest.param({}, True, CURRENT_BASE, id="compared-on-decision"), pytest.param( - {"current_status": GREEN_STATUS, "marker": Marker("green", "abc123", NOW, behind=300)}, - None, - 300, - True, + {"current_status": GREEN_STATUS, "marker": Marker("green", "abc123", NOW, base=KEPT_BASE)}, + False, + KEPT_BASE, id="kept-from-the-marker", ), pytest.param( - {"current_status": GREEN_STATUS, "marker": Marker("green", "old-sha", NOW, behind=300)}, - None, - None, - False, + {"current_status": GREEN_STATUS, "marker": Marker("green", "old-sha", NOW, base=KEPT_BASE)}, + True, + CURRENT_BASE, id="not-kept-for-another-commit", ), + pytest.param( + {"current_status": GREEN_STATUS, "marker": Marker("green", "abc123", NOW)}, + True, + CURRENT_BASE, + id="compared-for-a-marker-without-base", + ), + pytest.param( + { + "current_status": GREEN_STATUS, + "marker": Marker("green", "abc123", NOW, base=KEPT_BASE), + "last_base_change_at": NOW, + "status_created_at": NOW - timedelta(hours=1), + }, + True, + CURRENT_BASE, + id="compared-again-after-retarget", + ), ], ) -def test_green_records_how_far_behind_the_base_branch(pr_overrides, behind, expected_behind, warned): - plan, _, _ = _classify(_pr(**pr_overrides), GREEN, behind=behind) +def test_green_records_the_base_it_tested(pr_overrides, compared, expected_base): + compare_base = mock.Mock(return_value=CURRENT_BASE) + plan = classify( + _pr(**pr_overrides), + GREEN, + config=CONFIG, + now=NOW, + repo=REPO, + find_bypass_run=lambda: None, + changes_ci_files=lambda: False, + compare_base=compare_base, + ) - assert parse_marker(plan.marker_comment).behind == expected_behind - assert ("commits behind `main`" in plan.marker_comment) is warned + assert compare_base.called is compared + assert parse_marker(plan.marker_comment).base == expected_base + assert f"[`{expected_base.sha[:10]}`]" in plan.marker_comment @pytest.mark.parametrize( @@ -2481,18 +2534,64 @@ def test_apply_plan_posts_ci_passed_under_its_own_context(client): assert mock.call(42, "abc123", *CI_PASSED, context="CI passed") in client.set_status.call_args_list +def _compare(behind, files, merge_base=MERGE_BASE): + return {"merge_base_commit": {"sha": merge_base}, "behind_by": behind, "files": files} + + +def _files(*paths): + return [{"filename": path} for path in paths] + + +HEAD_COMPARE = f"repos/{REPO}/compare/providers-fab%2Fv1-5...someone:abc123?per_page=1" +BASE_COMPARE = f"repos/{REPO}/compare/{MERGE_BASE}...providers-fab%2Fv1-5?per_page=1" +PR_FILES = f"repos/{REPO}/pulls/42/files?per_page=100" + + @pytest.mark.parametrize( - ("side_effect", "expected"), + ("responses", "expected", "expected_calls"), [ - pytest.param([{"behind_by": 17}], 17, id="counted"), - pytest.param(GitHubError("gh api failed: HTTP 500"), None, id="unreadable"), + pytest.param( + [_compare(0, _files("a.py"))], BaseCheck(MERGE_BASE, 0, ()), [HEAD_COMPARE], id="up-to-date" + ), + pytest.param( + [_compare(5, _files("a.py", "b.py")), _compare(0, _files("c.py"))], + BaseCheck(MERGE_BASE, 5, ()), + [HEAD_COMPARE, BASE_COMPARE], + id="no-overlap", + ), + pytest.param( + [ + _compare(5, [*_files("b.py"), {"filename": "new.py", "previous_filename": "a.py"}]), + _compare(0, _files("b.py", "a.py", "c.py")), + ], + BaseCheck(MERGE_BASE, 5, ("a.py", "b.py")), + [HEAD_COMPARE, BASE_COMPARE], + id="overlap-with-rename", + ), + pytest.param( + [_compare(5, _files("a.py")), _compare(0, _files(*(f"f{i}" for i in range(300))))], + BaseCheck(MERGE_BASE, 5, None), + [HEAD_COMPARE, BASE_COMPARE], + id="base-side-at-cap", + ), + pytest.param( + [ + _compare(5, _files(*(f"f{i}" for i in range(300)))), + _compare(0, _files("g.py", "f301")), + _files(*(f"f{i}" for i in range(400))), + ], + BaseCheck(MERGE_BASE, 5, ("f301",)), + [HEAD_COMPARE, BASE_COMPARE, PR_FILES], + id="large-pull-request-listed-in-full", + ), + pytest.param(GitHubError("gh api failed: HTTP 500"), None, [HEAD_COMPARE], id="unreadable"), ], ) -def test_count_commits_behind(client, side_effect, expected): - client.rest_get.side_effect = side_effect +def test_compare_with_base(client, responses, expected, expected_calls): + client.rest_get.side_effect = responses - assert count_commits_behind(client, REPO, _pr(base_ref="v3-1-test")) == expected - assert client.rest_get.call_args.args[0] == f"repos/{REPO}/compare/v3-1-test...someone:abc123?per_page=1" + assert compare_with_base(client, REPO, _pr(base_ref="providers-fab/v1-5")) == expected + assert [call.args[0] for call in client.rest_get.call_args_list] == expected_calls def _fork_job(name, status="completed", conclusion="success", url=None):
