This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new e5f2c87a3ab Cancel the workflow runs of pull requests that have
already merged (#75619)
e5f2c87a3ab is described below
commit e5f2c87a3ab06c7dde2b84c64d9fd9a555b3ad12
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Oct 25 20:10:11 2026 +0100
Cancel the workflow runs of pull requests that have already merged (#75619)
Merging a pull request does not stop its pull_request runs, so they keep
using the project's GitHub-hosted minutes and self-hosted runners for a
result nobody reads once the change is on its target branch. Until now
maintainers cancelled such runs by hand, when they noticed them.
Generated-by: Claude Opus 5
---
.github/fork-ci-gate.toml | 5 +
.github/workflows/fork-ci-monitor.yml | 4 +-
dev/breeze/doc/08_ci_tasks.rst | 3 +-
.../doc/ci/08_fork_ci_for_external_contributors.md | 33 ++-
.../src/airflow_breeze/utils/fork_ci_gate.py | 5 +
.../src/airflow_breeze/utils/fork_ci_github.py | 8 +
.../src/airflow_breeze/utils/fork_ci_reconcile.py | 8 +
.../utils/merged_pull_request_runs.py | 211 ++++++++++++++++
dev/breeze/tests/test_fork_ci_gate.py | 8 +
dev/breeze/tests/test_fork_ci_github.py | 17 ++
dev/breeze/tests/test_fork_ci_reconcile.py | 40 ++++
dev/breeze/tests/test_merged_pull_request_runs.py | 266 +++++++++++++++++++++
12 files changed, 604 insertions(+), 4 deletions(-)
diff --git a/.github/fork-ci-gate.toml b/.github/fork-ci-gate.toml
index 6fc7a0de1ce..6119f610ef2 100644
--- a/.github/fork-ci-gate.toml
+++ b/.github/fork-ci-gate.toml
@@ -34,6 +34,11 @@ close_after_days = 7
# status is posted either way.
job_statuses = true
+# Cancel the unfinished `pull_request` runs of pull requests that have already
merged, of any
+# author, so they stop using GitHub-hosted minutes and self-hosted runners for
a result nobody
+# reads. The fork CI monitor does it on its schedule.
+cancel_merged_runs = true
+
# Organisations exempted under AIP-120 ยง2.2. Keys are display names used in
comments,
# values are GitHub logins. Changes go through a reviewed pull request.
[allowlist]
diff --git a/.github/workflows/fork-ci-monitor.yml
b/.github/workflows/fork-ci-monitor.yml
index ec34b9615b0..278787be5af 100644
--- a/.github/workflows/fork-ci-monitor.yml
+++ b/.github/workflows/fork-ci-monitor.yml
@@ -41,8 +41,8 @@ jobs:
name: "Reconcile pull requests gated to fork CI"
if: github.repository == 'apache/airflow'
permissions:
- # Listing the runs and jobs of the gate tampering check, and deleting
the runs that only
- # gated a pull request to fork CI.
+ # Listing the runs and jobs of the gate tampering check, deleting the
runs that only gated a
+ # pull request to fork CI, and cancelling the unfinished runs of merged
pull requests.
actions: write
contents: read
# Labels, comments and closing of gated pull requests.
diff --git a/dev/breeze/doc/08_ci_tasks.rst b/dev/breeze/doc/08_ci_tasks.rst
index ca16b4483fe..b1bf0d9f5fb 100644
--- a/dev/breeze/doc/08_ci_tasks.rst
+++ b/dev/breeze/doc/08_ci_tasks.rst
@@ -188,7 +188,8 @@ Pull requests from external contributors can run their CI
in the contributor's f
gated pull requests ``fork ci required``, keeps them in draft until the fork
run is green, maintains
one state comment and a ``Fork CI / Tests (AMD)`` commit status linking to the
run in the fork, closes
pull requests without fork CI activity for a week, closes gated pull requests
that made project CI run
-by changing workflows, and writes everything it could not classify to a Slack
message. With
+by changing workflows, and writes everything it could not classify to a Slack
message. It also cancels
+the unfinished workflow runs of pull requests, of any author, that have
already merged. With
``--dry-run`` it prints the changes instead of applying them. See
`Fork CI for external contributors
<ci/08_fork_ci_for_external_contributors.md>`_ for the design.
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 799a233e9ca..1f398373944 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
@@ -787,6 +787,36 @@ name within its time limit, and its run is the evidence. A
re-run is kept too: d
deletes every attempt, and an earlier attempt may have run project CI. Nothing
is deleted for a
pull request in the `gate_bypassed` state. A dry run only reports the
deletions.
+### Cancelling runs of merged pull requests
+
+Merging a pull request does not stop its `pull_request` runs. They keep using
`apache/airflow`'s
+capacity, GitHub-hosted minutes from the ASF's allocation and the self-hosted
runners, for a
+result nobody reads once the change is on its target branch. This is not
specific to fork CI; the
+monitor does it because it already runs every 15 minutes with `actions: write`.
+
+Each monitor run lists the `pull_request` runs of `apache/airflow` that are
`queued`,
+`in_progress`, `waiting`, `pending` or `requested`, and looks up the pull
requests of their head
+branches in one GraphQL query per 25 branches, paging through every pull
request of a branch: a
+common branch name such as `main` has pull requests from many forks, and an
open one with the
+run's head must not be missed. A run is cancelled when:
+
+- a merged pull request has the run's head repository, branch and commit (runs
of pull requests
+ from forks do not list their pull request, so they are matched this way);
+- no open pull request has the same head, since a branch can be reused for a
new pull request;
+- the run was created before the merge. A workflow triggered by the merge
itself, such as one
+ on the `closed` event, is meant to run.
+
+It applies to the pull requests of every author, not only gated ones. A run is
cancelled with
+`POST /repos/apache/airflow/actions/runs/<id>/cancel`; one still `in_progress`
30 minutes after the
+merge, for example because a step ignores the cancellation, is force-cancelled
+(`.../force-cancel`). A run that finished in the meantime (HTTP 409) is not a
problem; any other
+failure is reported to Slack with the pull request, and a failure of the whole
pass is reported
+as a note, without affecting the reconciliation of gated pull requests. A dry
run only reports
+the cancellations. `cancel_merged_runs = false` in `.github/fork-ci-gate.toml`
turns it off.
+
+Only runs of the merged head commit are matched. A run of an earlier commit of
the pull request is
+normally cancelled by the workflow's concurrency group when the next commit is
pushed.
+
### Far behind the base branch
A fork run tests the pushed commit as it is, while project CI tests it merged
with the base
@@ -841,7 +871,8 @@ Per run, with ~700 gated pull requests: ~28 GraphQL calls
for pass 2, ~14 for fo
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
+change. Cancelling runs of merged pull requests adds five paginated run
listings, one GraphQL
+query per 25 head branches with unfinished runs, and one mutation per
cancelled run. 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". Well inside
the `GITHUB_TOKEN` limits at a 15-minute cadence. The reconciler logs its call
counts so the
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
index 8ce90a34d97..dc05a3b67a5 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_gate.py
@@ -129,6 +129,7 @@ class GateConfig:
allowlist: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
pilot: Mapping[str, datetime] = field(default_factory=dict)
job_statuses: bool = True
+ cancel_merged_runs: bool = True
def is_allowlisted(self, login: str) -> bool:
wanted = login.casefold()
@@ -177,6 +178,9 @@ def parse_config(text: str) -> GateConfig:
job_statuses = data.get("job_statuses", True)
if not isinstance(job_statuses, bool):
raise ValueError(f"job_statuses must be true or false, got
{job_statuses!r}")
+ cancel_merged_runs = data.get("cancel_merged_runs", True)
+ if not isinstance(cancel_merged_runs, bool):
+ raise ValueError(f"cancel_merged_runs must be true or false, got
{cancel_merged_runs!r}")
return GateConfig(
enabled=enabled,
enabled_since=enabled_since,
@@ -184,6 +188,7 @@ def parse_config(text: str) -> GateConfig:
allowlist=allowlist,
pilot=pilot,
job_statuses=job_statuses,
+ cancel_merged_runs=cancel_merged_runs,
)
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
index 73a44503acb..fe3fdfb651e 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
@@ -172,6 +172,14 @@ class GitHubClient:
None,
)
+ def cancel_run(self, pr: int, run_id: int, *, force: bool = False) -> None:
+ action = "force-cancel" if force else "cancel"
+ self._mutate(
+ f"#{pr}: {action} workflow run {run_id}",
+ ["api", "-X", "POST",
f"repos/{self.repo}/actions/runs/{run_id}/{action}"],
+ None,
+ )
+
def set_status(
self,
pr: int,
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 6b54f6c7fac..00698b78494 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -74,6 +74,7 @@ from airflow_breeze.utils.fork_ci_messages import (
tamper_close_comment,
ungated_comment,
)
+from airflow_breeze.utils.merged_pull_request_runs import
cancel_merged_pull_request_runs
CI_WORKFLOW_FILE = "ci-amd.yml"
# The files the gate job of ci-amd.yml reads from a pull request's base branch.
@@ -1547,6 +1548,13 @@ def run_reconcile(
unknown.append((listed.number, f"{type(e).__name__}: {e}"))
notes = [bypass_finder.incomplete] if bypass_finder.incomplete else []
+ if config.cancel_merged_runs:
+ # Not tied to gated pull requests; a failure here must not hide the
reconciliation's own.
+ try:
+ unknown.extend(cancel_merged_pull_request_runs(client, repo=repo,
now=now))
+ except Exception as e:
+ console_print(f"[error]Cancelling runs of merged pull requests
failed: {e}[/]")
+ notes.append(f"cancelling runs of merged pull requests failed:
{type(e).__name__}: {e}")
if job_reporter.deferred:
console_print(
f"[info]Job statuses of {len(job_reporter.deferred)} pull requests
left for the next run: "
diff --git a/dev/breeze/src/airflow_breeze/utils/merged_pull_request_runs.py
b/dev/breeze/src/airflow_breeze/utils/merged_pull_request_runs.py
new file mode 100644
index 00000000000..7eed315630b
--- /dev/null
+++ b/dev/breeze/src/airflow_breeze/utils/merged_pull_request_runs.py
@@ -0,0 +1,211 @@
+# 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.
+"""
+Cancels the workflow runs that are still going for pull requests that have
already merged.
+
+Merging a pull request does not stop its `pull_request` runs, which keep using
the project's
+GitHub-hosted minutes and self-hosted runners for a result nobody reads. The
fork CI monitor calls
+this on its schedule. See "Cancelling runs of merged pull requests" in
+dev/breeze/doc/ci/08_fork_ci_for_external_contributors.md.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING, Any
+
+from airflow_breeze.utils.console import console_print
+from airflow_breeze.utils.fork_ci_gate import parse_github_datetime
+
+if TYPE_CHECKING:
+ from airflow_breeze.utils.fork_ci_github import GitHubClient
+
+# The statuses of a run that has not finished. A run waiting for approval is
"completed".
+UNFINISHED_RUN_STATUSES = ("queued", "in_progress", "waiting", "pending",
"requested")
+# A cancelled run whose jobs keep running, for example in a step that ignores
the signal, is
+# force-cancelled once this long has passed since the merge, which leaves a
normal cancel at least
+# one monitor run to work.
+FORCE_CANCEL_AFTER = timedelta(minutes=30)
+BRANCH_BATCH_SIZE = 25
+
+
+@dataclass(frozen=True)
+class UnfinishedRun:
+ id: int
+ workflow: str
+ status: str
+ head_repo: str
+ head_branch: str
+ head_sha: str
+ created_at: datetime
+
+
+@dataclass(frozen=True)
+class PullRequestHead:
+ number: int
+ state: str
+ head_repo: str
+ head_sha: str
+ merged_at: datetime | None
+
+
+def list_unfinished_runs(client: GitHubClient, repo: str) ->
list[UnfinishedRun]:
+ runs = []
+ for status in UNFINISHED_RUN_STATUSES:
+ for page in client.rest_get_pages(
+
f"repos/{repo}/actions/runs?event=pull_request&status={status}&per_page=100"
+ ):
+ for run in page["workflow_runs"]:
+ runs.append(
+ UnfinishedRun(
+ id=run["id"],
+ workflow=run.get("name") or "",
+ status=run["status"],
+ head_repo=(run.get("head_repository") or
{}).get("full_name") or "",
+ head_branch=run.get("head_branch") or "",
+ head_sha=run["head_sha"],
+ created_at=parse_github_datetime(run["created_at"]),
# type: ignore[arg-type]
+ )
+ )
+ return runs
+
+
+PULL_REQUEST_FIELDS = (
+ "pageInfo { hasNextPage endCursor } "
+ "nodes { number state mergedAt headRefOid headRepository { nameWithOwner }
}"
+)
+BRANCH_PAGE_QUERY = (
+ "query($owner: String!, $name: String!, $branch: String!, $cursor: String)
"
+ "{ repository(owner: $owner, name: $name) { pullRequests(headRefName:
$branch, first: 100, "
+ f"after: $cursor) {{ {PULL_REQUEST_FIELDS} }} }} }}"
+)
+
+
+def _parse_pull_requests(connection: dict[str, Any] | None) ->
list[PullRequestHead]:
+ return [
+ PullRequestHead(
+ number=node["number"],
+ state=node["state"],
+ head_repo=(node.get("headRepository") or {}).get("nameWithOwner")
or "",
+ head_sha=node["headRefOid"],
+ merged_at=parse_github_datetime(node.get("mergedAt") or ""),
+ )
+ for node in (connection or {}).get("nodes") or []
+ ]
+
+
+def find_pull_requests_by_branch(
+ client: GitHubClient, repo: str, branches: Iterable[str]
+) -> dict[str, list[PullRequestHead]]:
+ """
+ Every pull request of each head branch name, in any state and from any
head repository.
+
+ All of them are read, not only the latest: a common branch name such as
``main`` has pull
+ requests from many forks, and an open one with a run's head must not be
missed.
+ """
+ owner, name = repo.split("/", 1)
+ found: dict[str, list[PullRequestHead]] = {}
+ unique = sorted(set(branches))
+ for start in range(0, len(unique), BRANCH_BATCH_SIZE):
+ batch = unique[start : start + BRANCH_BATCH_SIZE]
+ declarations = ", ".join(f"$b{index}: String!" for index in
range(len(batch)))
+ fields = " ".join(
+ f"b{index}: pullRequests(headRefName: $b{index}, first: 100) {{
{PULL_REQUEST_FIELDS} }}"
+ for index in range(len(batch))
+ )
+ data = client.graphql(
+ f"query($owner: String!, $name: String!, {declarations}) "
+ f"{{ repository(owner: $owner, name: $name) {{ {fields} }} }}",
+ {"owner": owner, "name": name, **{f"b{index}": branch for index,
branch in enumerate(batch)}},
+ )["repository"]
+ for index, branch in enumerate(batch):
+ connection = data[f"b{index}"] or {}
+ found[branch] = _parse_pull_requests(connection)
+ while (connection.get("pageInfo") or {}).get("hasNextPage"):
+ connection = client.graphql(
+ BRANCH_PAGE_QUERY,
+ {
+ "owner": owner,
+ "name": name,
+ "branch": branch,
+ "cursor": connection["pageInfo"]["endCursor"],
+ },
+ )["repository"]["pullRequests"]
+ found[branch].extend(_parse_pull_requests(connection))
+ return found
+
+
+def find_merged_pull_request(
+ run: UnfinishedRun, pull_requests: Iterable[PullRequestHead]
+) -> PullRequestHead | None:
+ """
+ The merged pull request a run was started for, if no open pull request has
the same head.
+
+ A run is matched by head repository, branch and commit, because runs of
pull requests from
+ forks do not list their pull request. Only runs started before the merge
count: a workflow
+ triggered by the merge itself, such as one on `closed`, is meant to run.
+ """
+ same_head = [
+ pr
+ for pr in pull_requests
+ if pr.head_repo.casefold() == run.head_repo.casefold() and pr.head_sha
== run.head_sha
+ ]
+ if any(pr.state == "OPEN" for pr in same_head):
+ return None
+ return next(
+ (
+ pr
+ for pr in same_head
+ if pr.state == "MERGED" and pr.merged_at is not None and
run.created_at < pr.merged_at
+ ),
+ None,
+ )
+
+
+def cancel_merged_pull_request_runs(
+ client: GitHubClient, *, repo: str, now: datetime
+) -> list[tuple[int, str]]:
+ """Cancel the unfinished runs of merged pull requests; return ``(pull
request, problem)`` for Slack."""
+ runs = list_unfinished_runs(client, repo)
+ if not runs:
+ return []
+ pull_requests = find_pull_requests_by_branch(client, repo,
(run.head_branch for run in runs))
+ problems: list[tuple[int, str]] = []
+ cancelled = 0
+ for run in runs:
+ merged = find_merged_pull_request(run,
pull_requests.get(run.head_branch, []))
+ if merged is None:
+ continue
+ force = (
+ run.status == "in_progress"
+ and merged.merged_at is not None
+ and now - merged.merged_at >= FORCE_CANCEL_AFTER
+ )
+ try:
+ client.cancel_run(merged.number, run.id, force=force)
+ cancelled += 1
+ except Exception as e:
+ # A run that finished meanwhile cannot be cancelled; that is the
outcome wanted anyway.
+ # gh reports the status as "(HTTP 409)"; a bare "409" could be
part of the run id.
+ if "(HTTP 409)" in str(e):
+ continue
+ problems.append((merged.number, f"cancelling run {run.id}
({run.workflow}) failed: {e}"))
+ verb = "Would cancel" if client.dry_run else "Cancelled"
+ console_print(f"[info]{verb} {cancelled} runs of merged pull requests[/]")
+ return problems
diff --git a/dev/breeze/tests/test_fork_ci_gate.py
b/dev/breeze/tests/test_fork_ci_gate.py
index b52a0d29a1c..e641e45ca82 100644
--- a/dev/breeze/tests/test_fork_ci_gate.py
+++ b/dev/breeze/tests/test_fork_ci_gate.py
@@ -321,12 +321,17 @@ def test_parse_config_defaults():
assert config.allowlist == {}
assert config.pilot == {}
assert config.job_statuses is True
+ assert config.cancel_merged_runs is True
def test_parse_config_turns_job_statuses_off():
assert parse_config("job_statuses = false\n").job_statuses is False
+def test_parse_config_turns_cancelling_merged_runs_off():
+ assert parse_config("cancel_merged_runs = false\n").cancel_merged_runs is
False
+
+
def test_parse_config_lowercases_pilot_logins():
config = parse_config('[pilot]\n"Some-User" = 2026-10-20T00:00:00Z\n')
@@ -340,6 +345,9 @@ def test_parse_config_lowercases_pilot_logins():
[
pytest.param("enabled = 1\n", "enabled must be",
id="enabled-not-bool"),
pytest.param('job_statuses = "no"\n', "job_statuses must be",
id="job-statuses-not-bool"),
+ pytest.param(
+ "cancel_merged_runs = 0\n", "cancel_merged_runs must be",
id="cancel-merged-runs-not-bool"
+ ),
pytest.param(
"enabled_since = 2026-01-01T00:00:00\n", "enabled_since must be",
id="enabled-since-naive"
),
diff --git a/dev/breeze/tests/test_fork_ci_github.py
b/dev/breeze/tests/test_fork_ci_github.py
index d993add8bb5..09257d0697c 100644
--- a/dev/breeze/tests/test_fork_ci_github.py
+++ b/dev/breeze/tests/test_fork_ci_github.py
@@ -128,6 +128,7 @@ def test_dry_run_does_not_mutate(mock_run):
client.close(1)
client.remove_label(1, "fork ci required")
client.delete_run(1, 7)
+ client.cancel_run(1, 7)
mock_run.assert_not_called()
@@ -170,3 +171,19 @@ def test_set_status_context(mock_run, kwargs, context):
GitHubClient().set_status(1, "abc", "success", "https://x", "desc",
**kwargs)
assert json.loads(mock_run.call_args.kwargs["input"])["context"] == context
+
+
[email protected](("force", "action"), [(False, "cancel"), (True,
"force-cancel")])
[email protected]("airflow_breeze.utils.fork_ci_github.subprocess.run",
autospec=True)
+def test_cancel_run(mock_run, force, action):
+ mock_run.return_value = _completed()
+
+ GitHubClient().cancel_run(1, 7, force=force)
+
+ assert mock_run.call_args.args[0] == [
+ "gh",
+ "api",
+ "-X",
+ "POST",
+ f"repos/apache/airflow/actions/runs/7/{action}",
+ ]
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py
b/dev/breeze/tests/test_fork_ci_reconcile.py
index 9f3aa61f751..3df786d4a83 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -2073,3 +2073,43 @@ def
test_job_status_reporter_compares_contexts_case_insensitively(client):
assert JobStatusReporter(client, repo=REPO).report(_pr(), COMPLETED_RUN)
== "1:1:FAILURE"
client.set_status.assert_not_called()
+
+
[email protected]("enabled", [True, False])
[email protected]("airflow_breeze.utils.fork_ci_reconcile.cancel_merged_pull_request_runs",
autospec=True)
+def test_run_reconcile_cancels_runs_of_merged_pull_requests(mock_cancel,
client, tmp_path, enabled):
+ mock_cancel.return_value = [(10, "cancelling run 7 failed")]
+ client.graphql.side_effect = [_search_result(), _search_result(),
_search_result()]
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+
+ has_alerts = run_reconcile(
+ client,
+ dataclasses.replace(CONFIG, cancel_merged_runs=enabled),
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+
+ assert mock_cancel.called is enabled
+ assert has_alerts is enabled
+
+
[email protected]("airflow_breeze.utils.fork_ci_reconcile.cancel_merged_pull_request_runs",
autospec=True)
+def test_run_reconcile_reports_a_failed_cancellation_pass(mock_cancel, client,
tmp_path):
+ mock_cancel.side_effect = GitHubError("HTTP 502")
+ client.graphql.side_effect = [_search_result(), _search_result(),
_search_result()]
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+
+ assert (
+ run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+ is True
+ )
+ assert "cancelling runs of merged pull requests failed" in (tmp_path /
"s.json").read_text()
diff --git a/dev/breeze/tests/test_merged_pull_request_runs.py
b/dev/breeze/tests/test_merged_pull_request_runs.py
new file mode 100644
index 00000000000..5c6223963c0
--- /dev/null
+++ b/dev/breeze/tests/test_merged_pull_request_runs.py
@@ -0,0 +1,266 @@
+# 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
+
+from datetime import UTC, datetime, timedelta
+from unittest import mock
+
+import pytest
+
+from airflow_breeze.utils.fork_ci_github import GitHubClient, GitHubError
+from airflow_breeze.utils.merged_pull_request_runs import (
+ BRANCH_BATCH_SIZE,
+ PullRequestHead,
+ UnfinishedRun,
+ cancel_merged_pull_request_runs,
+ find_merged_pull_request,
+ find_pull_requests_by_branch,
+)
+
+REPO = "apache/airflow"
+NOW = datetime(2026, 10, 25, 19, 0, tzinfo=UTC)
+MERGED_AT = NOW - timedelta(minutes=10)
+RUN = UnfinishedRun(
+ id=7,
+ workflow="Tests (AMD)",
+ status="in_progress",
+ head_repo="someone/airflow",
+ head_branch="fix",
+ head_sha="aaa",
+ created_at=MERGED_AT - timedelta(minutes=20),
+)
+MERGED = PullRequestHead(
+ number=10, state="MERGED", head_repo="someone/airflow", head_sha="aaa",
merged_at=MERGED_AT
+)
+
+
[email protected]
+def client():
+ github = mock.create_autospec(GitHubClient, instance=True)
+ github.repo = REPO
+ github.dry_run = False
+ return github
+
+
[email protected](
+ ("pull_requests", "expected"),
+ [
+ pytest.param([MERGED], MERGED, id="merged-pull-request"),
+ pytest.param(
+ [PullRequestHead(10, "MERGED", "Someone/Airflow", "aaa",
MERGED_AT)],
+ PullRequestHead(10, "MERGED", "Someone/Airflow", "aaa", MERGED_AT),
+ id="head-repository-in-another-case",
+ ),
+ pytest.param(
+ [PullRequestHead(11, "OPEN", "someone/airflow", "aaa", None),
MERGED],
+ None,
+ id="open-pr-same-head",
+ ),
+ pytest.param(
+ [PullRequestHead(10, "MERGED", "other/airflow", "aaa",
MERGED_AT)], None, id="other-fork"
+ ),
+ pytest.param(
+ [PullRequestHead(10, "MERGED", "someone/airflow", "bbb",
MERGED_AT)], None, id="other-commit"
+ ),
+ pytest.param(
+ [PullRequestHead(10, "MERGED", "someone/airflow", "aaa",
RUN.created_at - timedelta(minutes=1))],
+ None,
+ id="run-started-after-the-merge",
+ ),
+ pytest.param(
+ [PullRequestHead(10, "CLOSED", "someone/airflow", "aaa", None)],
None, id="closed-not-merged"
+ ),
+ pytest.param([], None, id="no-pull-request"),
+ ],
+)
+def test_find_merged_pull_request(pull_requests, expected):
+ assert find_merged_pull_request(RUN, pull_requests) == expected
+
+
+def _run(run_id, *, status="in_progress", branch="fix", sha="aaa",
created_at=RUN.created_at):
+ return {
+ "id": run_id,
+ "name": "Tests (AMD)",
+ "status": status,
+ "head_repository": {"full_name": "someone/airflow"},
+ "head_branch": branch,
+ "head_sha": sha,
+ "created_at": created_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ }
+
+
+def _pr_node(number, state, sha="aaa", merged_at=MERGED_AT):
+ return {
+ "number": number,
+ "state": state,
+ "mergedAt": merged_at.strftime("%Y-%m-%dT%H:%M:%SZ") if merged_at else
None,
+ "headRefOid": sha,
+ "headRepository": {"nameWithOwner": "someone/airflow"},
+ }
+
+
+def _runs_by_status(runs):
+ def pages(path):
+ status = path.split("status=", 1)[1].split("&", 1)[0]
+ return [{"workflow_runs": [run for run in runs if run["status"] ==
status]}]
+
+ return pages
+
+
[email protected](
+ ("merged_minutes_ago", "status", "force"),
+ [
+ pytest.param(10, "in_progress", False, id="recently-merged"),
+ pytest.param(31, "in_progress", True,
id="still-running-long-after-the-merge"),
+ pytest.param(31, "queued", False, id="queued-long-after-the-merge"),
+ ],
+)
+def test_cancel_merged_pull_request_runs(client, merged_minutes_ago, status,
force):
+ merged_at = NOW - timedelta(minutes=merged_minutes_ago)
+ client.rest_get_pages.side_effect = _runs_by_status(
+ [_run(7, status=status, created_at=merged_at - timedelta(minutes=5))]
+ )
+ client.graphql.return_value = {
+ "repository": {"b0": {"nodes": [_pr_node(10, "MERGED",
merged_at=merged_at)]}}
+ }
+
+ assert cancel_merged_pull_request_runs(client, repo=REPO, now=NOW) == []
+
+ client.cancel_run.assert_called_once_with(10, 7, force=force)
+
+
+def
test_cancel_merged_pull_request_runs_leaves_open_pull_requests_alone(client):
+ client.rest_get_pages.side_effect = _runs_by_status([_run(7)])
+ client.graphql.return_value = {"repository": {"b0": {"nodes":
[_pr_node(10, "OPEN", merged_at=None)]}}}
+
+ cancel_merged_pull_request_runs(client, repo=REPO, now=NOW)
+
+ client.cancel_run.assert_not_called()
+
+
+def test_cancel_merged_pull_request_runs_without_unfinished_runs(client):
+ client.rest_get_pages.side_effect = _runs_by_status([])
+
+ assert cancel_merged_pull_request_runs(client, repo=REPO, now=NOW) == []
+ client.graphql.assert_not_called()
+
+
[email protected](
+ ("error", "problems"),
+ [
+ pytest.param(
+ GitHubError(
+ "gh api -X POST repos/apache/airflow/actions/runs/7/cancel
failed: gh: done (HTTP 409)"
+ ),
+ [],
+ id="finished-meanwhile",
+ ),
+ pytest.param(
+ GitHubError("gh api repos/apache/airflow/actions/runs/1409/cancel
failed: HTTP 500"),
+ [
+ (
+ 10,
+ "cancelling run 7 (Tests (AMD)) failed: gh api
repos/apache/airflow/actions/runs/1409/cancel failed: HTTP 500",
+ )
+ ],
+ id="failed-with-409-in-the-path",
+ ),
+ ],
+)
+def test_cancel_merged_pull_request_runs_reports_failed_cancellations(client,
error, problems):
+ client.rest_get_pages.side_effect = _runs_by_status([_run(7), _run(8)])
+ client.graphql.return_value = {"repository": {"b0": {"nodes":
[_pr_node(10, "MERGED")]}}}
+ client.cancel_run.side_effect = [error, None]
+
+ assert cancel_merged_pull_request_runs(client, repo=REPO, now=NOW) ==
problems
+ assert client.cancel_run.call_count == 2
+
+
+def test_find_pull_requests_by_branch_batches_branches(client):
+ branches = [f"branch-{index:02d}" for index in range(BRANCH_BATCH_SIZE +
1)]
+
+ def graphql(query, variables):
+ batch = sorted(key for key in variables if key.startswith("b"))
+ return {"repository": {key: {"nodes": [_pr_node(1, "MERGED")]} for key
in batch}}
+
+ client.graphql.side_effect = graphql
+
+ found = find_pull_requests_by_branch(client, REPO, [*branches,
branches[0]])
+
+ assert sorted(found) == branches
+ assert client.graphql.call_count == 2
+ assert client.graphql.call_args_list[0].args[1]["owner"] == "apache"
+
+
+def test_find_pull_requests_by_branch_reads_every_page(client):
+ first = {"pageInfo": {"hasNextPage": True, "endCursor": "c1"}, "nodes":
[_pr_node(1, "MERGED")]}
+ second = {
+ "pageInfo": {"hasNextPage": False, "endCursor": None},
+ "nodes": [_pr_node(2, "OPEN", merged_at=None)],
+ }
+ client.graphql.side_effect = [
+ {"repository": {"b0": first}},
+ {"repository": {"pullRequests": second}},
+ ]
+
+ found = find_pull_requests_by_branch(client, REPO, ["main"])
+
+ assert [pr.number for pr in found["main"]] == [1, 2]
+ assert client.graphql.call_args.args[1] == {
+ "owner": "apache",
+ "name": "airflow",
+ "branch": "main",
+ "cursor": "c1",
+ }
+
+
+def test_open_pull_request_on_a_later_page_keeps_the_run(client):
+ client.rest_get_pages.side_effect = _runs_by_status([_run(7)])
+ client.graphql.side_effect = [
+ {
+ "repository": {
+ "b0": {
+ "pageInfo": {"hasNextPage": True, "endCursor": "c1"},
+ "nodes": [_pr_node(10, "MERGED")],
+ }
+ }
+ },
+ {
+ "repository": {
+ "pullRequests": {
+ "pageInfo": {"hasNextPage": False},
+ "nodes": [_pr_node(11, "OPEN", merged_at=None)],
+ }
+ }
+ },
+ ]
+
+ cancel_merged_pull_request_runs(client, repo=REPO, now=NOW)
+
+ client.cancel_run.assert_not_called()
+
+
[email protected](("dry_run", "summary"), [(False, "Cancelled 1 runs"),
(True, "Would cancel 1 runs")])
[email protected]("airflow_breeze.utils.merged_pull_request_runs.console_print",
autospec=True)
+def test_cancel_merged_pull_request_runs_summary_tells_a_dry_run(mock_print,
client, dry_run, summary):
+ client.dry_run = dry_run
+ client.rest_get_pages.side_effect = _runs_by_status([_run(7)])
+ client.graphql.return_value = {"repository": {"b0": {"nodes":
[_pr_node(10, "MERGED")]}}}
+
+ cancel_merged_pull_request_runs(client, repo=REPO, now=NOW)
+
+ assert summary in mock_print.call_args.args[0]