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 05d86b21c28 Post the first fork CI comment without waiting for the
next run (#76510)
05d86b21c28 is described below
commit 05d86b21c282d9b18bc91825d0875a5276b4fbee
Author: Andrew Chang <[email protected]>
AuthorDate: Sat Nov 7 08:05:50 2026 +0800
Post the first fork CI comment without waiting for the next run (#76510)
* Comment on newly gated pull requests in the run that labels them
The fork CI reconciler labels newly gated pull requests, then searches
for the pull requests that carry the label. The search index often does
not list a pull request labelled seconds before. Its state comment and
setup instructions then waited for the next run, about 15 minutes later.
6 of the 19 pilot pull requests opened since 2026-10-31 waited for this
extra run.
* Leave pull requests for the next run when the repository read is null
A partial GraphQL response with a null repository made
fetch_open_pull_requests raise AttributeError, which aborted the whole
reconciler run instead of leaving the pull requests for the next run.
Generated-by: Claude Opus 5
---------
Co-authored-by: Jarek Potiuk <[email protected]>
---
.../doc/ci/08_fork_ci_for_external_contributors.md | 11 ++-
.../src/airflow_breeze/utils/fork_ci_reconcile.py | 77 ++++++++++++---
dev/breeze/tests/test_fork_ci_reconcile.py | 104 +++++++++++++++++++++
3 files changed, 176 insertions(+), 16 deletions(-)
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 f1007448d61..e60311dfa06 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
@@ -501,8 +501,12 @@ state targetUrl description } } } } }`), and the
reconciler's own state comment
(`comments(first: 100)`, the earliest comment by `github-actions` with the
marker, paging
through the remaining comments of the rare pull request with more). The
reconciler creates that
comment on first contact and only edits it afterwards, so a marker posted by
anyone else, or
-echoed later by another workflow, is ignored. The fork state is then fetched
in batches of 50 with one GraphQL
-query per batch using aliases. Owner and name come from the pull request's
+echoed later by another workflow, is ignored. The search index can take
minutes to list a pull
+request under the label pass 1 has just added, so the pull requests pass 1
labelled that the
+search does not list yet are read by number with the same fields, 25 per
query, and reconciled in
+the same run. If that read fails, they are left for the next run. The fork
state is then
+fetched in batches of 50 with one GraphQL query per batch using aliases. Owner
and name come
+from the pull request's
`headRepository { nameWithOwner }`, never a fixed `airflow`, because a fork
can be renamed or
created as `airflow-1` on a name collision:
@@ -1064,7 +1068,8 @@ files, a jobs call and a compare call only per long
`ci-amd.yml` run of a gated
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
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
+`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
the `GITHUB_TOKEN` limits at a 15-minute cadence. The reconciler logs its call
counts so the
budget stays observable.
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 c9a6865cd96..4e2f8cfe6ef 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -832,11 +832,8 @@ query($q: String!, $cursor: String) {
}
"""
-PASS_TWO_QUERY = """
-query($q: String!, $cursor: String) {
- search(type: ISSUE, first: %(page)d, after: $cursor, query: $q) {
- pageInfo { hasNextPage endCursor }
- nodes { ... on PullRequest {
+PASS_TWO_FIELDS = """
+fragment PassTwoFields on PullRequest {
number id isDraft createdAt authorAssociation baseRefName headRefName
headRefOid
author { login }
headRepository { nameWithOwner }
@@ -865,10 +862,35 @@ query($q: String!, $cursor: String) {
pageInfo { hasNextPage endCursor }
nodes { databaseId body author { login } }
}
- } }
+}
+"""
+
+PASS_TWO_QUERY = (
+ """
+query($q: String!, $cursor: String) {
+ search(type: ISSUE, first: %(page)d, after: $cursor, query: $q) {
+ pageInfo { hasNextPage endCursor }
+ nodes { ...PassTwoFields }
}
}
"""
+ + PASS_TWO_FIELDS
+)
+
+PULL_REQUESTS_BY_NUMBER_QUERY = (
+ """
+query($owner: String!, $name: String!) {
+ repository(owner: $owner, name: $name) { %(pull_requests)s }
+}
+"""
+ + PASS_TWO_FIELDS
+)
+
+PASS_TWO_CONTEXTS = {
+ "context": STATUS_CONTEXT,
+ "ci_passed": CI_PASSED_CONTEXT,
+ "ci_run_started": CI_RUN_STARTED_CONTEXT,
+}
def _labels(node: dict[str, Any]) -> tuple[str, ...]:
@@ -1112,6 +1134,35 @@ def _search(client: GitHubClient, query: str,
search_query: str) -> Iterable[dic
cursor = result["pageInfo"]["endCursor"]
+def fetch_open_pull_requests(
+ client: GitHubClient, numbers: Sequence[int], *, repo: str
+) -> list[dict[str, Any]]:
+ """
+ Return the pass 2 nodes of the pull requests among these that are still
open.
+
+ Reads them by number rather than through the search index, which can take
minutes to list a
+ pull request under a label added moments before.
+ """
+ owner, name = repo.split("/", 1)
+ nodes: list[dict[str, Any]] = []
+ for start in range(0, len(numbers), PASS_TWO_PAGE_SIZE):
+ batch = numbers[start : start + PASS_TWO_PAGE_SIZE]
+ pull_requests = " ".join(
+ f"pr{index}: pullRequest(number: {number}) {{ state
...PassTwoFields }}"
+ for index, number in enumerate(batch)
+ )
+ result = (
+ client.graphql(
+ PULL_REQUESTS_BY_NUMBER_QUERY % {"pull_requests":
pull_requests, **PASS_TWO_CONTEXTS},
+ {"owner": owner, "name": name},
+ allow_partial=True,
+ )["repository"]
+ or {}
+ )
+ nodes.extend(node for node in result.values() if node and
node["state"] == "OPEN")
+ return nodes
+
+
def _is_not_found(error: GitHubError) -> bool:
return "404" in str(error) or "Not Found" in str(error)
@@ -2235,16 +2286,16 @@ def run_reconcile(
parse_pull_request(node)
for node in _search(
client,
- PASS_TWO_QUERY
- % {
- "page": PASS_TWO_PAGE_SIZE,
- "context": STATUS_CONTEXT,
- "ci_passed": CI_PASSED_CONTEXT,
- "ci_run_started": CI_RUN_STARTED_CONTEXT,
- },
+ PASS_TWO_QUERY % {"page": PASS_TWO_PAGE_SIZE, **PASS_TWO_CONTEXTS},
f'repo:{repo} is:pr is:open label:"{LABEL_FORK_CI_REQUIRED}"',
)
]
+ found = {pr.number for pr in prs}
+ unlisted = [number for number in labelled if number not in found]
+ try:
+ prs.extend(parse_pull_request(node) for node in
fetch_open_pull_requests(client, unlisted, repo=repo))
+ except GitHubError as e:
+ console_print(f"[warning]Pull requests {unlisted} are left for the
next run: {e}[/]")
observations = fetch_fork_observations(client, prs)
def is_gated_base(base_ref: str) -> bool:
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py
b/dev/breeze/tests/test_fork_ci_reconcile.py
index 81962145a43..de08f6a6f46 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -63,6 +63,7 @@ from airflow_breeze.utils.fork_ci_reconcile import (
discover,
drop_reported,
fetch_fork_observations,
+ fetch_open_pull_requests,
fetch_workflow_state,
find_ci_run_since,
find_heads_shared_across_bases,
@@ -3240,6 +3241,109 @@ def
test_gate_change_finder_aborts_when_the_pull_request_changed(client, head_sh
finder.find(_pr())
+NEWLY_GATED = {**_pr_node(1), "labels": {"nodes": []}}
+
+
+def _graphql_after_gating_pull_request_1(*, listed: list[dict], by_number:
dict | Exception | None):
+ def graphql(query, variables=None, *, allow_partial=False):
+ if "pullRequest(number:" in query:
+ if by_number is None:
+ raise AssertionError("read by number although the search
listed it")
+ if isinstance(by_number, Exception):
+ raise by_number
+ return by_number
+ if "q" not in variables:
+ return {f"pr{key[1:]}": {"object": None} for key in variables if
key.startswith("s")}
+ if "updated:>=" in variables["q"]:
+ return _search_result(NEWLY_GATED)
+ if variables["q"] == f'repo:{REPO} is:pr is:open label:"fork ci
required"':
+ return _search_result(*listed)
+ return _search_result()
+
+ return graphql
+
+
+def _rest_get_for_pull_request_1(path, paginate=False, items_key=None):
+ if path.endswith("/actions/workflows/ci-amd.yml"):
+ return {"state": "active"}
+ if path == f"repos/{REPO}/pulls/1/files?per_page=100":
+ return []
+ if path == f"repos/{REPO}/pulls/1":
+ return {"head": {"sha": "sha1"}, "base": {"ref": "main"}}
+ raise AssertionError(path)
+
+
[email protected](
+ ("listed", "by_number", "commented"),
+ [
+ pytest.param(
+ [], {"repository": {"pr0": {**_pr_node(1), "state": "OPEN"}}},
[1], id="not-yet-listed-by-search"
+ ),
+ pytest.param([_pr_node(1)], None, [1], id="listed-by-search"),
+ pytest.param(
+ [], {"repository": {"pr0": {**_pr_node(1), "state": "CLOSED"}}},
[], id="closed-since-labelled"
+ ),
+ pytest.param([], {"repository": {"pr0": None}}, [],
id="deleted-since-labelled"),
+ pytest.param([], {"repository": None}, [],
id="repository-not-resolved"),
+ ],
+)
+def test_run_reconcile_reconciles_pull_requests_labelled_in_the_same_run(
+ client, tmp_path, listed, by_number, commented
+):
+ client.graphql.side_effect =
_graphql_after_gating_pull_request_1(listed=listed, by_number=by_number)
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+ client.rest_get.side_effect = _rest_get_for_pull_request_1
+
+ has_alerts = run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+
+ assert has_alerts is False
+ client.add_labels.assert_called_once_with(1, ["fork ci required"])
+ assert [call.args[0] for call in client.create_comment.call_args_list] ==
commented
+
+
+def
test_run_reconcile_leaves_pull_requests_it_cannot_read_for_the_next_run(client,
tmp_path):
+ client.graphql.side_effect = _graphql_after_gating_pull_request_1(
+ listed=[], by_number=GitHubError("gh api failed: HTTP 502")
+ )
+ client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs":
[]}]
+ client.rest_get.side_effect = _rest_get_for_pull_request_1
+
+ has_alerts = run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=tmp_path / "s.json",
+ workflows_dir=tmp_path,
+ )
+
+ assert has_alerts is False
+ client.create_comment.assert_not_called()
+
+
[email protected]("airflow_breeze.utils.fork_ci_reconcile.PASS_TWO_PAGE_SIZE", 2)
+def test_fetch_open_pull_requests_reads_in_batches(client):
+ client.graphql.side_effect = [
+ {"repository": {"pr0": {"number": 1, "state": "OPEN"}, "pr1":
{"number": 2, "state": "OPEN"}}},
+ {"repository": {"pr0": {"number": 3, "state": "OPEN"}}},
+ ]
+
+ nodes = fetch_open_pull_requests(client, [1, 2, 3], repo=REPO)
+
+ assert [node["number"] for node in nodes] == [1, 2, 3]
+ queries = [call.args[0] for call in client.graphql.call_args_list]
+ assert "pr1: pullRequest(number: 2)" in queries[0]
+ assert "pr0: pullRequest(number: 3)" in queries[1]
+ assert "number: 3" not in queries[0]
+
+
def test_run_reconcile_retries_a_pull_request_whose_head_moved(client,
tmp_path):
output = tmp_path / "slack-message.json"
client.graphql.side_effect = [