Show fork CI progress sooner and gate rebased pilot pull requests (#76148)

Feedback from the first day of the contributor pilot:

Pilot participants expected their open pull requests to move to fork CI
once they rebased them after joining, but only pull requests created
after the join instant were gated.

A push soon after opening a pull request cancels the first project CI
run, and its cancelled checks stayed next to the green fork results,
giving the pull request a confusing red/green status.

Fork CI reports to the pull request only when the scheduled monitor
runs, so for a while a contributor saw nothing telling them their CI
runs in the fork. The gate cannot post a status itself (it runs with a
read-only token on the contributor's event), so a required "CI run
started" check now shows as expected and waiting from the moment of the
push, and the monitor fills it in with a link to the run. Doing that in
the scheduled monitor, rather than in a job of every CI run, keeps it
off the shared runner queue, and its writes come out of the monitor's
existing per-run budget so the repository's shared rate limit is not
spent any faster.

One full-matrix fork run needs more job statuses than that budget
allows in a single monitor run, so the Slack note about deferred job
statuses fired on ordinary pilot runs. It now waits until deferring
persists, which is what it is meant to signal.

Generated-by: Claude Opus 5
This commit is contained in:
Jarek Potiuk authored and GitHub committed 2026-10-31 17:14:09 +01:00
1 parent 615068e7cf
commit a3567e948e
14 files changed
+1186 -57

No files matched your search

+9 -1
View File
@@ -136,9 +136,13 @@ github:
strict_required_status_checks_policy: false
required_status_checks:
- context: "CI passed"
# Reported by the fork CI monitor once the head commit has a CI run, in the
# contributor's fork for a gated pull request or in apache/airflow otherwise; until
# then GitHub lists it on the pull request as expected and waiting.
- context: "CI run started"
# The branches `ci-amd.yml` runs pull request CI for, apart from `main`. A release branch gets
# the "CI passed" job only when it is backported there; until then its PRs need a committer to
# bypass the rule.
# bypass the rule. "CI run started" comes from the fork CI monitor on `main`, for every base.
- name: "Require CI on release branches"
target: branch
enforcement: active
@@ -165,6 +169,10 @@ github:
strict_required_status_checks_policy: false
required_status_checks:
- context: "CI passed"
# Reported by the fork CI monitor once the head commit has a CI run, in the
# contributor's fork for a gated pull request or in apache/airflow otherwise; until
# then GitHub lists it on the pull request as expected and waiting.
- context: "CI run started"
- name: "Branch protection"
type: branch
branches:
+4
View File
@@ -171,6 +171,10 @@ jobs:
name: "Fork CI gate"
runs-on: ["ubuntu-slim"]
timeout-minutes: 5
permissions:
contents: read
# Reads the creation time of this run, which decides for a pilot's older pull request.
actions: read
outputs:
project-ci: ${{ steps.gate.outputs.project-ci }}
reason: ${{ steps.gate.outputs.reason }}
+2 -1
View File
@@ -269,7 +269,8 @@ and approves them automatically when all of these hold:
at them first.
If your PR's CI runs in your fork (it carries the ``fork ci required`` label), the CI run in
``apache/airflow`` only checks that, so none of the rules above apply. Its runs are approved
``apache/airflow`` only checks that, so none of the rules above apply. The ``CI run started`` check
waits until the fork CI monitor sees your fork's run, usually within 15 minutes, and then links it. Its runs are approved
automatically unless the PR changes files that define CI: anything under ``.github/``,
``dev/breeze/``, ``scripts/ci/``, ``scripts/docker/`` or ``scripts/in_container/``, a top-level
``Dockerfile*``, or a ``.pre-commit-config.yaml`` in any directory. Then the runs wait for a maintainer and a comment on the PR explains
@@ -185,8 +185,9 @@ at 00:00 UTC:
[pilot]
"your-login" = YYYY-MM-DDT00:00:00Z
Every pull request you open after that instant uses fork CI; your earlier pull requests keep project
CI. To leave the pilot, open a pull request that removes the line. Your own ``[pilot]`` line is
Every pull request you open after that instant uses fork CI. Your earlier pull requests keep project
CI until you push to them, rebase them or close and reopen them after that instant, and use fork CI
from then on. To leave the pilot, open a pull request that removes the line. Your own ``[pilot]`` line is
the only part of the fork CI gate a pull request using fork CI may change: one that changes the
gate's code, the rest of its configuration or the ``gate`` job of ``ci-amd.yml`` is closed with an
explanation. A pilot entry gates your pull
+2 -1
View File
@@ -54,7 +54,8 @@ workflow of `apache/airflow` shares (see "Write budget" in
If the budget is exceeded, turn the job statuses off: set `job_statuses = false` in
`.github/fork-ci-gate.toml`. Signs that it is exceeded are the monitor's Slack note that job
statuses "did not fit in the budget" persisting across runs, rate-limit retries ("GitHub rate limit,
statuses "have not fit in the budget" (posted only after three runs in a row deferred them)
staying for hours, rate-limit retries ("GitHub rate limit,
retrying") or "still rate limited" errors in the monitor's run log or its Slack messages, or other
workflows of the repository failing on secondary rate limits. The aggregate `Fork CI / Tests (AMD)`
status and `CI passed` are posted either way, so turning job statuses off loses detail, not gating.
@@ -174,7 +174,7 @@ concurrency cap. A manual `workflow_dispatch` in a fork still runs.
| Pull request carries `use project ci` | run | `label_project_ci` |
| Pull request head repository is `apache/airflow` | run | `head_is_upstream` |
| Pull request carries `use fork ci` | skip | `label_fork_ci` |
| Author login is in `[pilot]` and the pull request was created at or after that login's join instant | skip | `pilot` |
| Author login is in `[pilot]` and the pull request was created, or its `ci-amd.yml` run was created, at or after that login's join instant | skip | `pilot` |
| `enabled` is false | run | `gate_disabled` |
| Pull request created before `enabled_since` | run | `before_enabled_since` |
| Author association is `MEMBER`, `OWNER` or `COLLABORATOR` (see below for private membership) | run | `trusted_author` |
@@ -248,7 +248,21 @@ login and a join instant to `[pilot]`; any committer can merge it. Once the entr
base branch, every pull request they open is gated from its first run, whether or not
`enabled` is set, and goes through exactly the flow described in this document: setup
comment, draft, fork run, commit status, undraft, close after inactivity, gate tampering
check. Pull requests they opened before the join instant keep project CI.
check. Pull requests they opened before the join instant keep project CI until their first
`ci-amd.yml` run created at or after it -- the run of a push, a rebase, a reopen or marking the
pull request ready for review -- and are gated from then on. A re-run of an earlier run keeps
that run's creation time, so it does not switch the pull request.
The gate reads the creation time of its own run (`GET /repos/apache/airflow/actions/runs/<id>`,
hence `actions: read` on the gate job) and fails, to be re-run, when it cannot; the event's
`updated_at` would not do, because discovery cannot read it back for the same decision.
Discovery reads the same time back for such a pull request: the latest `ci-amd.yml`
`pull_request` run of its head commit created at or after the join instant, one REST call per
pull request, made only for a `[pilot]` author's pull request created before they joined. Once
discovery labelled it, the reconciler takes the `fork ci required` label as the evidence instead,
because it deletes the gated run that discovery saw. The gate tampering check applies to the pull
request from then on and, when project CI was allowed for it before the join instant (not, for
example, when it was already gated as `external`), leaves out its runs from before that instant.
The gate reads the table from the base branch, so the entry takes effect when it merges,
while the join instant is compared with pull request creation. The join instant must
@@ -353,8 +367,10 @@ gated run that never ran.
pull request's author, association, creation time, head repository and current labels, the inputs
the gate job of the waiting run uses. When the reason is gated (`external`, `pilot` or
`label_fork_ci`), the rules for pull requests that run project CI (estimated job-minutes, lines of
source code, dependency files, full tests) do not apply, because none of those jobs run. One rule
applies instead:
source code, dependency files, full tests) do not apply, because none of those jobs run. A pilot's
pull request created before they joined counts as gated from the join instant: its waiting runs
created after it are approved this way, while earlier ones, whose gate ran project CI, are left to a
maintainer. One rule applies instead:
- The waiting runs of the head commit are approved when the pull request's merge commit changes no
file that the reconciler counts as CI-defining: `.github/`, `dev/breeze/`, `scripts/ci/`,
@@ -599,9 +615,9 @@ in `apache/airflow`.
The check applies only to pull requests whose `fork_ci_gate.decide` reason is `external` or
`pilot`, recomputed from the pass 2 fields. A pilot participant's pull request is gated from
its creation, so it never ran project CI legitimately unless `use project ci` was on it,
which is accounted for below, or it was opened before the `[pilot]` entry merged
(see "Contributor pilot"). Committers piloting with `use fork ci` are excluded:
its creation, or from its first run after the join instant, so it never ran project CI
legitimately since unless `use project ci` was on it, which is accounted for below, or it was
opened before the `[pilot]` entry merged (see "Contributor pilot"). Committers piloting with `use fork ci` are excluded:
their pull request legitimately ran project CI before the label was added.
The check works from the runs `apache/airflow` recorded, not from the head commit. Once per
@@ -798,9 +814,13 @@ suites); its jobs come from `GET /repos/<fork>/actions/runs/<id>/jobs?filter=lat
why check runs do not fit), so each one is a REST call, and they count towards GitHub's limits on
content-creating requests: a reconciler run posts at most `MAX_JOB_STATUSES_PER_RUN` (60) of
them, each write charged as it is made. What does not fit is posted by the next run, and once
the budget is spent no further pull request's jobs or statuses are read in that run. A run that
defers job statuses adds a note to the Slack message, with fixed wording so it is posted once
while deferrals persist; the run log lists the pull requests.
the budget is spent no further pull request's jobs or statuses are read in that run. One fork run
of the full matrix (about 130 jobs) needs more than a run's budget on its own, which the next
run catches up on, so deferring alone is not a problem: the run log lists the deferred pull
requests, and only when three runs in a row deferred, about 45 minutes of the budget not
keeping up, is a note added to the Slack message. The count is kept in the record the monitor
carries between runs (`--reported-file`); without a readable record the note is added at once.
Its wording is fixed, so it is posted once while deferrals persist.
- Commit statuses cannot be deleted and have no neutral state. When a run has completed, a job
context posted for an earlier run of the same commit that the completed run does not have, or
skipped, is overwritten with `success` and the description "Not in the latest fork run - see
@@ -851,7 +871,10 @@ requests a minute and 500 an hour. Assuming about 35 reported jobs for an averag
limit and more at peaks. The cap of 60 writes a run, four runs an hour, holds job statuses to at
most 240 an hour: 48% of the content-creating limit and 24% of the REST limit, plus two reads per
pull request with a run in progress. Job statuses then lag behind at busy times, the aggregate
status does not.
status does not. The `CI run started` statuses of pull requests whose CI runs in
`apache/airflow` come out of the same 60 writes a run, first and at most 30 of them (see "The
`CI run started` check"), so they do not raise the total; at about 11 pull request runs an hour
they take about 3 writes a run.
These are estimates: the share of gated runs, the jobs per push and the pushes per pull request
are assumptions, not measurements. Watch the monitor's call counts in its run log after enabling
@@ -888,6 +911,35 @@ is the source the ruleset accepts.
job must not report success there; it reports under another name when `build-info` is
skipped, and the reconciler's status is the only `CI passed` the pull request has.
### The `CI run started` check
Fork CI reports to the pull request only when the scheduled monitor runs, about every 15 minutes
and later when GitHub delays scheduled workflows, and until then a gated pull request shows only
skipped checks. The gate cannot say more itself: it runs on the contributor's `pull_request`
event with a read-only token. Instead both CI rulesets in `.asf.yaml` also require a
`CI run started` check, which nothing reports when a commit is pushed, so GitHub lists it on every
pull request as expected and waiting for its status. The reconciler reports it, as a commit status
with state `success` linking the run:
- for a gated pull request, once the fork has a run of `ci-amd.yml` for the head commit, whatever
its state; the fork run is linked, so the contributor can follow it before the state comment
appears.
- for every other open pull request, once project CI has a `ci-amd.yml` `pull_request` run for the
head commit that is not waiting for a maintainer's approval. One GraphQL search lists the open
pull requests without `fork ci required`, most recently updated first, with the head commit's
`CI run started` status and its GitHub Actions check suites. Pull requests discovery labelled in
the same run are skipped, since the search index can still list them without the label. When the
last commit listed is not the head, as after a rebase that keeps old commit dates, two REST calls
read the head commit's statuses and latest run instead.
Folding the check into the scheduled monitor costs no runner per push, at the price of a pull
request waiting for the next monitor run before it can merge, which a CI run takes longer than
anyway. Each new head commit needs one write, taken first out of the per-run budget of job
statuses (see "Job statuses"), since merging waits for it; at most 30 such writes per run, so job
statuses keep at least half of the budget, and after the check became required the open pull
requests are caught up a batch at a time. A release branch has no monitor of its own, but `main`'s monitor covers pull
requests to every base branch.
### Gated project CI runs are deleted
A gated run of `ci-amd.yml` lists every project CI job on the pull request as a skipped check,
@@ -902,6 +954,13 @@ 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.
A push soon after opening a pull request cancels its first run, often while the gate is still
deciding, and the run that replaces it is the one left to fork CI. The cancelled run lists its
jobs as cancelled checks next to the fork's green result, so it is deleted too, under the same
conditions: completed, first attempt, its commit does not change `.github/workflows/`, and no job
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
@@ -146,6 +146,18 @@ class GateConfig:
return joined_at
return None
def find_pilot_join_after(self, login: str, created_at: datetime | None) -> datetime | None:
"""
The pilot join instant of an author whose pull request was created before it, else None.
Such a pull request is gated from its first CI run at or after that instant, so deciding
needs the creation time of that run.
"""
joined_at = self.pilot_joined_at(login)
if joined_at is None or created_at is None or created_at >= joined_at:
return None
return joined_at
def _require_aware_datetime(value: Any, name: str) -> datetime:
if not isinstance(value, datetime) or value.tzinfo is None:
@@ -206,6 +218,11 @@ class GateEvent:
author_association: str = ""
created_at: datetime | None = None
labels: tuple[str, ...] = ()
# When the ci-amd.yml run deciding for the pull request was created: a pilot's pull request
# created before they joined is gated from its first run at or after joining, such as the run
# of a push, a rebase or a reopen. The run's time, unlike the event's `updated_at`, is what the
# reconciler can read back for the same decision.
ci_run_at: datetime | None = None
@dataclass(frozen=True)
@@ -218,6 +235,10 @@ class LabelsUnavailableError(RuntimeError):
"""The current labels of a pull request could not be read."""
class RunTimeUnavailableError(RuntimeError):
"""The creation time of the gate's own workflow run could not be read."""
def _is_canonical_branch(ref: str) -> bool:
branch = ref.removeprefix("refs/heads/")
return any(pattern.match(branch) for pattern in CANONICAL_PUSH_BRANCH_PATTERNS)
@@ -244,7 +265,9 @@ def decide(event: GateEvent, config: GateConfig) -> Decision:
if LABEL_USE_FORK_CI in event.labels:
return Decision(run=False, reason=Reason.LABEL_FORK_CI)
joined_at = config.pilot_joined_at(event.author_login)
if joined_at is not None and event.created_at is not None and event.created_at >= joined_at:
if joined_at is not None and any(
moment is not None and moment >= joined_at for moment in (event.created_at, event.ci_run_at)
):
return Decision(run=False, reason=Reason.PILOT)
if not config.enabled:
return Decision(run=True, reason=Reason.GATE_DISABLED)
@@ -290,6 +313,18 @@ def fetch_pull_request_labels(repository: str, number: str) -> tuple[str, ...] |
return tuple(labels)
def fetch_run_created_at(repository: str, run_id: str) -> datetime | None:
"""The creation time of a workflow run, which stays the same across its re-runs."""
output = _run_gh("api", f"repos/{repository}/actions/runs/{run_id}", "--jq", ".created_at")
if output is None:
return None
try:
return parse_github_datetime(output.strip())
except ValueError:
print(f"Unexpected created_at of run {run_id}: {output!r}", file=sys.stderr)
return None
def _write_line(env_var: str, line: str, env: Mapping[str, str]) -> None:
path = env.get(env_var)
if path:
@@ -311,6 +346,16 @@ def evaluate_from_env(env: Mapping[str, str]) -> Decision:
f"Could not read the labels of pull request #{env.get('PR_NUMBER', '')}; re-run the job"
)
labels = fetched
config = load_config(Path(env.get("GATE_CONFIG", ".github/fork-ci-gate.toml")))
created_at = parse_github_datetime(env.get("PR_CREATED_AT", ""))
ci_run_at = None
if event_name == "pull_request" and config.find_pilot_join_after(env.get("PR_AUTHOR", ""), created_at):
ci_run_at = fetch_run_created_at(repository, env.get("GITHUB_RUN_ID", ""))
if ci_run_at is None:
# Project CI would run for a pull request the reconciler may treat as gated.
raise RunTimeUnavailableError(
f"Could not read the creation time of run {env.get('GITHUB_RUN_ID', '')}; re-run the job"
)
event = GateEvent(
event_name=event_name,
repository=repository,
@@ -318,10 +363,10 @@ def evaluate_from_env(env: Mapping[str, str]) -> Decision:
head_repository=env.get("PR_HEAD_REPO", ""),
author_login=env.get("PR_AUTHOR", ""),
author_association=env.get("PR_AUTHOR_ASSOCIATION", ""),
created_at=parse_github_datetime(env.get("PR_CREATED_AT", "")),
created_at=created_at,
labels=labels,
ci_run_at=ci_run_at,
)
config = load_config(Path(env.get("GATE_CONFIG", ".github/fork-ci-gate.toml")))
return decide(event, config)
@@ -329,7 +374,7 @@ def main(env: Mapping[str, str] | None = None) -> int:
env = os.environ if env is None else env
try:
decision = evaluate_from_env(env)
except LabelsUnavailableError as e:
except (LabelsUnavailableError, RunTimeUnavailableError) as e:
print(e, file=sys.stderr)
return 1
project_ci = "true" if decision.run else "false"
@@ -35,7 +35,8 @@ from datetime import datetime
STATUS_CONTEXT = "Fork CI / Tests (AMD)"
# Each job of the fork run is reported under this prefix. Job names come from the contributor's
# workflow, so the prefix keeps them from posting STATUS_CONTEXT or CI_PASSED_CONTEXT.
# workflow, so the prefix keeps them from posting STATUS_CONTEXT, CI_PASSED_CONTEXT or
# CI_RUN_STARTED_CONTEXT.
JOB_STATUS_CONTEXT_PREFIX = "Fork CI job / "
JOB_NAME_MAX_LENGTH = 100
# The check the "Require CI on main" ruleset requires before a pull request can (auto-)merge. Project
@@ -44,6 +45,12 @@ JOB_NAME_MAX_LENGTH = 100
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
# request once the fork has a run for the head commit, for any other once project CI has one.
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
@@ -31,9 +31,9 @@ import base64
import functools
import json
import re
from collections.abc import Callable, Iterable, Sequence
from collections.abc import Callable, Collection, Iterable, Sequence
from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import Any
@@ -61,6 +61,9 @@ 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,
CI_RUN_STARTED_PROJECT_DESCRIPTION,
JOB_STATUS_CONTEXT_PREFIX,
JOB_STATUSES,
STALE_JOB_STATUS,
@@ -153,12 +156,23 @@ JOB_STATE_RANKS = {"failure": 0, "pending": 1, "success": 2}
# every workflow of the repository. At four runs an hour this keeps job statuses to at most about
# half of it; the rest follow on the next run, failures first. See "Job statuses" in the design doc.
MAX_JOB_STATUSES_PER_RUN = 60
# `CI run started` statuses of pull requests whose CI runs in apache/airflow come out of that budget
# first: one write per new head commit, which merging waits for, while job statuses are informational.
# At most this many, so job statuses keep the rest; the first runs after the check became required
# catch up on the open pull requests a batch at a time, most recently updated first.
MAX_CI_RUN_STARTED_PER_RUN = 30
# The GitHub Actions app, whose check suites are the workflow runs of a commit.
GITHUB_ACTIONS_APP_ID = 15368
# A single large fork run can need more writes than one run's budget, which the next run catches up
# on, so deferring is only reported when it lasts this many consecutive runs: then the budget does
# not keep up with the pull requests.
DEFERRED_RUNS_BEFORE_ALERT = 3
# Fixed text, so the note is posted to Slack once while deferrals persist; the run log lists the pull
# requests.
JOB_STATUSES_DEFERRED_NOTE = (
f"job statuses did not fit in the budget of {MAX_JOB_STATUSES_PER_RUN} writes per run and are "
"posted by later runs; if this persists, set `job_statuses = false` in .github/fork-ci-gate.toml "
"(see dev/AGENTS.md)"
f"job statuses have not fit in the budget of {MAX_JOB_STATUSES_PER_RUN} writes per run for "
f"{DEFERRED_RUNS_BEFORE_ALERT} runs in a row and are posted by later runs; if this persists, set "
"`job_statuses = false` in .github/fork-ci-gate.toml (see dev/AGENTS.md)"
)
RUNNING_STATUSES = frozenset({"QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED"})
@@ -236,6 +250,13 @@ class PullRequestSnapshot:
current_status: tuple[str, str, str] | None = None
status_created_at: datetime | None = None
ci_passed_state: str | None = None
ci_run_started_state: str | None = None
# GateEvent.ci_run_at of a pilot's pull request created before they joined; set from its
# `fork ci required` label by `mark_older_pilot_pull_request`.
ci_run_at: datetime | None = None
# The join instant of such a pull request when project CI was allowed for it before then, so its
# runs from before it are not gate tampering.
pilot_joined_at: datetime | None = None
marker: Marker | None = None
marker_comment_id: int | None = None
marker_comment_body: str | None = None
@@ -269,6 +290,8 @@ class Plan:
status: tuple[str, str, str] | None = None
# (state, target URL, description) of the `CI passed` status.
ci_passed: tuple[str, str, str] | None = None
# (state, target URL, description) of the `CI run started` status.
ci_run_started: tuple[str, str, str] | None = None
marker_comment: str | None = None
new_comments: list[str] = field(default_factory=list)
close: bool = False
@@ -370,6 +393,22 @@ def is_gated_skip_run(jobs: Iterable[dict[str, Any]], now: datetime) -> bool:
)
def is_cancelled_gate_run(jobs: Iterable[dict[str, Any]], now: datetime) -> bool:
"""
Whether a completed ci-amd.yml run was cancelled before it did any project CI work.
A push soon after opening a pull request cancels the first run, often before its gate has
decided, and the run that replaces it is the one left to fork CI. The cancelled run's jobs stay
on the pull request as cancelled checks next to the fork's result. Only the gate and the jobs
that also run in a gated run started, none failed, and at least one was cancelled.
"""
jobs = list(jobs)
if jobs_show_project_ci(jobs, now):
return False
conclusions = {job.get("conclusion") for job in jobs}
return "cancelled" in conclusions and conclusions <= {"success", "skipped", "cancelled"}
def touches(files: Iterable[str], prefixes: tuple[str, ...]) -> bool:
return any(path.startswith(prefixes) for path in files)
@@ -413,6 +452,7 @@ def _gate_event(pr: PullRequestSnapshot, repo: str) -> GateEvent:
author_association=pr.author_association,
created_at=pr.created_at,
labels=pr.labels,
ci_run_at=pr.ci_run_at,
)
@@ -452,6 +492,12 @@ def _set_ci_passed(plan: Plan, pr: PullRequestSnapshot, *, passed: bool, target_
plan.ci_passed = ("pending", target_url, CI_PASSED_REVOKED_DESCRIPTION)
def _set_ci_run_started(plan: Plan, pr: PullRequestSnapshot, fork: ForkObservation) -> None:
"""Report the `CI run started` check the merge rulesets require once the fork runs the head commit."""
if fork.run_url and pr.ci_run_started_state != "success":
plan.ci_run_started = ("success", fork.run_url, CI_RUN_STARTED_DESCRIPTION)
def _set_marker_comment(plan: Plan, pr: PullRequestSnapshot, body: str) -> None:
if body != pr.marker_comment_body:
plan.marker_comment = body
@@ -702,6 +748,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_run_started(plan, pr, fork)
green = state in (ForkState.GREEN, ForkState.GREEN_UNVERIFIED)
_set_marker_comment(
plan,
@@ -732,7 +779,7 @@ query($q: String!, $cursor: String) {
search(type: ISSUE, first: 100, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes { ... on PullRequest {
number createdAt authorAssociation baseRefName
number createdAt authorAssociation baseRefName headRefName headRefOid
author { login }
headRepository { nameWithOwner }
labels(first: 50) { nodes { name } }
@@ -767,6 +814,7 @@ query($q: String!, $cursor: String) {
status {
context(name: "%(context)s") { state targetUrl description createdAt }
ciPassed: context(name: "%(ci_passed)s") { state }
ciRunStarted: context(name: "%(ci_run_started)s") { state }
}
} } }
comments(first: 100) {
@@ -838,12 +886,15 @@ def parse_pull_request(node: dict[str, Any]) -> PullRequestSnapshot:
current_status = None
status_created_at = None
ci_passed_state = None
ci_run_started_state = None
commits = (node.get("commits") or {}).get("nodes") or []
head_status_unread = bool(commits) and commits[0]["commit"].get("oid") != node["headRefOid"]
if commits and not head_status_unread:
status = commits[0]["commit"].get("status") or {}
if status.get("ciPassed"):
ci_passed_state = status["ciPassed"]["state"].lower()
if status.get("ciRunStarted"):
ci_run_started_state = status["ciRunStarted"]["state"].lower()
context = status.get("context")
if context:
current_status = (
@@ -883,6 +934,7 @@ def parse_pull_request(node: dict[str, Any]) -> PullRequestSnapshot:
current_status=current_status,
status_created_at=status_created_at,
ci_passed_state=ci_passed_state,
ci_run_started_state=ci_run_started_state,
marker=marker,
marker_comment_id=marker_id,
marker_comment_body=marker_body,
@@ -892,6 +944,116 @@ def parse_pull_request(node: dict[str, Any]) -> PullRequestSnapshot:
)
PROJECT_CI_RUNS_QUERY = """
query($q: String!, $cursor: String) {
search(type: ISSUE, first: 50, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes { ... on PullRequest {
number headRefOid
commits(last: 1) { nodes { commit {
oid
status { context(name: "%(context)s") { state } }
checkSuites(first: 30, filterBy: {appId: %(app_id)d}) { nodes {
createdAt conclusion
workflowRun { url event workflow { resourcePath } }
} }
} } }
} }
}
}
"""
def find_project_ci_run_url(node: dict[str, Any]) -> str | None:
"""
The latest ci-amd.yml pull_request run of a pull request's head commit that still needs `CI run started`.
None when the commit already has the status, when its latest run has not started because it waits
for a maintainer's approval or there is none yet, and when the last commit listed is not the head:
GitHub orders the commit list by date, so after a rebase that keeps old dates it can be another one,
and ``read_project_ci_run_url`` reads the head instead.
"""
if not is_head_listed(node):
return None
commit = node["commits"]["nodes"][0]["commit"]
if ((commit.get("status") or {}).get("context") or {}).get("state") == "SUCCESS":
return None
runs = [
suite
for suite in (commit.get("checkSuites") or {}).get("nodes") or []
if (run := suite.get("workflowRun") or {}).get("event") == "pull_request"
and _workflow_file((run.get("workflow") or {}).get("resourcePath")) == CI_WORKFLOW_FILE
]
if not runs:
return None
latest = max(runs, key=lambda suite: suite["createdAt"])
if latest.get("conclusion") == "ACTION_REQUIRED":
return None
return latest["workflowRun"]["url"]
def is_head_listed(node: dict[str, Any]) -> bool:
commits = (node.get("commits") or {}).get("nodes") or []
return bool(commits) and commits[0]["commit"].get("oid") == node.get("headRefOid")
def read_project_ci_run_url(client: GitHubClient, repo: str, head_sha: str) -> str | None:
"""``find_project_ci_run_url`` for a head commit the search did not list, read from REST."""
statuses = client.rest_get(
f"repos/{repo}/commits/{head_sha}/status?per_page=100", paginate=True, items_key="statuses"
)
if any(
status["context"] == CI_RUN_STARTED_CONTEXT and status["state"] == "success" for status in statuses
):
return None
runs = client.rest_get(
f"repos/{repo}/actions/workflows/{CI_WORKFLOW_FILE}/runs"
f"?event=pull_request&head_sha={head_sha}&per_page=1"
).get("workflow_runs", [])
if not runs or runs[0].get("conclusion") == "action_required":
return None
return runs[0]["html_url"]
def report_project_ci_runs_started(
client: GitHubClient, *, repo: str, skip: Collection[int], budget: int = MAX_CI_RUN_STARTED_PER_RUN
) -> list[int]:
"""
Report `CI run started` for open pull requests whose CI runs in apache/airflow; return their numbers.
:param skip: the pull requests this run treated as gated, which get the status from their fork
run instead; the search index can still list one without `fork ci required`.
"""
reported: list[int] = []
for node in _search(
client,
PROJECT_CI_RUNS_QUERY % {"context": CI_RUN_STARTED_CONTEXT, "app_id": GITHUB_ACTIONS_APP_ID},
f'repo:{repo} is:pr is:open -label:"{LABEL_FORK_CI_REQUIRED}" sort:updated-desc',
):
if len(reported) >= budget:
console_print(f"[info]`{CI_RUN_STARTED_CONTEXT}` of more pull requests left for the next run[/]")
break
if node["number"] in skip:
continue
run_url = (
find_project_ci_run_url(node)
if is_head_listed(node)
else read_project_ci_run_url(client, repo, node["headRefOid"])
)
if run_url is None:
continue
client.set_status(
node["number"],
node["headRefOid"],
"success",
run_url,
CI_RUN_STARTED_PROJECT_DESCRIPTION,
context=CI_RUN_STARTED_CONTEXT,
)
reported.append(node["number"])
return reported
def _search(client: GitHubClient, query: str, search_query: str) -> Iterable[dict[str, Any]]:
cursor = None
while True:
@@ -950,6 +1112,68 @@ class BaseGateConfigs:
return self._configs[base_ref]
def find_ci_run_since(
client: GitHubClient, repo: str, *, head_repo: str, head_ref: str, head_sha: str, since: datetime
) -> datetime | None:
"""
When the latest ci-amd.yml pull_request run of a branch's commit created at or after ``since`` was created.
The gate decides with the creation time of its own run (GateEvent.ci_run_at); this reads the
same time back, so both decide alike for a pilot's pull request created before they joined.
Runs are matched by branch and fork too: the same commit pushed to another branch is another
pull request's.
"""
created = quote(f">={since.astimezone(UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}", safe="")
runs = client.rest_get(
f"repos/{repo}/actions/workflows/{CI_WORKFLOW_FILE}/runs?event=pull_request"
f"&branch={quote(head_ref, safe='')}&head_sha={head_sha}&created={created}&per_page=100"
).get("workflow_runs", [])
times = [
parse_github_datetime(run["created_at"])
for run in runs
if ((run.get("head_repository") or {}).get("full_name") or "").casefold() == head_repo.casefold()
]
return max((time for time in times if time is not None), default=None)
def read_ci_run_at(
client: GitHubClient,
repo: str,
config: GateConfig,
*,
author: str,
created_at: datetime | None,
head_repo: str,
head_ref: str,
head_sha: str,
) -> datetime | None:
"""The ``ci_run_at`` of a gate event; only read for a pilot's pull request created before they joined."""
joined_at = config.find_pilot_join_after(author, created_at)
if joined_at is None or not (head_repo and head_ref and head_sha):
return None
return find_ci_run_since(
client, repo, head_repo=head_repo, head_ref=head_ref, head_sha=head_sha, since=joined_at
)
def mark_older_pilot_pull_request(
pr: PullRequestSnapshot, config: GateConfig, *, repo: str, now: datetime
) -> PullRequestSnapshot:
"""
Record that a pilot's pull request created before they joined is gated, for the tampering check.
Its `fork ci required` label shows that discovery found a run created after joining, which is
what gated it; that run is not read again, because the reconciler deletes gated runs. Before the
join instant the label came from something else, such as `use fork ci`. Its runs from before
joining are left out of the tampering check only when project CI was allowed then.
"""
joined_at = config.find_pilot_join_after(pr.author_login, pr.created_at)
if joined_at is None or joined_at > now:
return pr
allowed_before = decide(_gate_event(pr, repo), config).reason not in GATED_REASONS
return replace(pr, ci_run_at=joined_at, pilot_joined_at=joined_at if allowed_before else None)
def discover(
client: GitHubClient,
configs: BaseGateConfigs,
@@ -981,15 +1205,27 @@ def discover(
continue
if config is None:
continue
author = _login(node.get("author"))
created_at = parse_github_datetime(node["createdAt"])
event = GateEvent(
event_name="pull_request",
repository=repo,
ref="",
head_repository=(node.get("headRepository") or {}).get("nameWithOwner") or "",
author_login=_login(node.get("author")),
author_login=author,
author_association=node["authorAssociation"],
created_at=parse_github_datetime(node["createdAt"]),
created_at=created_at,
labels=_labels(node),
ci_run_at=read_ci_run_at(
client,
repo,
config,
author=author,
created_at=created_at,
head_repo=(node.get("headRepository") or {}).get("nameWithOwner") or "",
head_ref=node.get("headRefName") or "",
head_sha=node.get("headRefOid") or "",
),
)
if decide(event, config).reason in GATED_REASONS:
client.add_labels(node["number"], [LABEL_FORK_CI_REQUIRED])
@@ -1293,7 +1529,8 @@ class BypassFinder:
def find_gated_runs(self, pr: PullRequestSnapshot) -> list[WorkflowRun]:
"""
Completed ci-amd.yml pull_request runs of the pull request's branch that only gated it.
Completed ci-amd.yml pull_request runs of the pull request's branch that only gated it, or
that a newer push cancelled before they did any project CI work.
A commit that changes workflows could do project CI work under the gate's name within its
time limit, so its runs are kept as evidence, as are runs whose change set is too large
@@ -1310,7 +1547,10 @@ class BypassFinder:
and run.status == "completed"
and run.run_attempt == 1
and not self._did_run_project_ci(run)
and is_gated_skip_run(self._get_jobs(run), self._now)
and (
is_gated_skip_run(self._get_jobs(run), self._now)
or is_cancelled_gate_run(self._get_jobs(run), self._now)
)
and self._commit_changes_workflows(run, pr.base_ref) is False
]
@@ -1360,6 +1600,9 @@ class BypassFinder:
for run in sorted(runs, key=lambda r: r.created_at, reverse=True):
if not_before is not None and run.created_at <= not_before:
continue
# A pilot's pull request created before they joined was not gated until then.
if pr.pilot_joined_at is not None and run.created_at < pr.pilot_joined_at:
continue
base_ref = base_at(pr, run.created_at)
# Project CI against a base branch without the gate is what its gate job decides.
if not self._is_gated_base(base_ref):
@@ -1634,6 +1877,7 @@ def read_head_status(client: GitHubClient, repo: str, pr: PullRequestSnapshot) -
}
status = statuses.get(STATUS_CONTEXT)
ci_passed = statuses.get(CI_PASSED_CONTEXT)
ci_run_started = statuses.get(CI_RUN_STARTED_CONTEXT)
return replace(
pr,
current_status=(
@@ -1643,6 +1887,7 @@ def read_head_status(client: GitHubClient, repo: str, pr: PullRequestSnapshot) -
),
status_created_at=parse_github_datetime(status.get("created_at") or "") if status else None,
ci_passed_state=ci_passed["state"] if ci_passed else None,
ci_run_started_state=ci_run_started["state"] if ci_run_started else None,
head_status_unread=False,
)
@@ -1724,6 +1969,8 @@ def apply_plan(client: GitHubClient, pr: PullRequestSnapshot, plan: Plan) -> Dra
client.set_status(pr.number, pr.head_sha, *plan.status)
if plan.ci_passed is not None:
client.set_status(pr.number, pr.head_sha, *plan.ci_passed, context=CI_PASSED_CONTEXT)
if plan.ci_run_started is not None:
client.set_status(pr.number, pr.head_sha, *plan.ci_run_started, context=CI_RUN_STARTED_CONTEXT)
if plan.close:
client.close(pr.number)
# Last, because the label is what brings the pull request back to the next run: when an
@@ -1791,17 +2038,21 @@ def drop_reported(
attention: list[tuple[int, str]],
notes: list[str],
record: bool = True,
deferred_runs: int = 0,
) -> tuple[list[tuple[int, str]], list[tuple[int, str]], list[str]]:
"""
Leave out the items the previous run already reported, and record this run's items.
Only the previous run's items are kept, so an item that went away and comes back is posted
again. A missing or unreadable file reports everything.
:param deferred_runs: the consecutive runs that deferred job statuses, recorded for the next run.
"""
current: dict[str, list[Any]] = {
current: dict[str, Any] = {
"unknown": [list(item) for item in unknown],
"attention": [list(item) for item in attention],
"notes": notes,
"deferred_runs": deferred_runs,
}
try:
previous = json.loads(reported_file.read_text())
@@ -1819,6 +2070,21 @@ def drop_reported(
return drop("unknown", unknown), drop("attention", attention), drop("notes", notes)
def read_deferred_runs(reported_file: Path | None) -> int | None:
"""The consecutive runs before this one that deferred job statuses; None without a readable record."""
if reported_file is None:
return None
try:
previous = json.loads(reported_file.read_text())
except (OSError, ValueError):
return None
if not isinstance(previous, dict):
return None
# A record written before the count was kept has none.
count = previous.get("deferred_runs", 0)
return count if isinstance(count, int) and not isinstance(count, bool) else 0
def run_reconcile(
client: GitHubClient,
config: GateConfig,
@@ -1850,14 +2116,19 @@ def run_reconcile(
draft_changes: list[DraftChange] = []
configs = BaseGateConfigs(client, repo=repo, default_branch=default_branch, default_config=config)
discover(client, configs, repo=repo, now=now, unknown=unknown)
labelled = discover(client, configs, repo=repo, now=now, unknown=unknown)
prs = [
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},
% {
"page": PASS_TWO_PAGE_SIZE,
"context": STATUS_CONTEXT,
"ci_passed": CI_PASSED_CONTEXT,
"ci_run_started": CI_RUN_STARTED_CONTEXT,
},
f'repo:{repo} is:pr is:open label:"{LABEL_FORK_CI_REQUIRED}"',
)
]
@@ -1877,7 +2148,21 @@ def run_reconcile(
base_triggers=load_workflow_triggers(workflows_dir),
is_gated_base=is_gated_base,
)
job_reporter = JobStatusReporter(client, repo=repo)
ci_run_started_note = None
try:
ci_run_started_writes = len(
report_project_ci_runs_started(client, repo=repo, skip={*labelled, *(pr.number for pr in prs)})
)
except Exception as e:
console_print(f"[error]Reporting `{CI_RUN_STARTED_CONTEXT}` for project CI failed: {e}[/]")
# Writes made before the failure are not known, so the whole allowance is charged.
ci_run_started_writes = MAX_CI_RUN_STARTED_PER_RUN
ci_run_started_note = (
f"reporting `{CI_RUN_STARTED_CONTEXT}` for project CI failed: {type(e).__name__}"
)
job_reporter = JobStatusReporter(
client, repo=repo, budget=max(MAX_JOB_STATUSES_PER_RUN - ci_run_started_writes, 0)
)
shared_heads = find_heads_shared_across_bases(prs)
gate_change_finder = GateChangeFinder(client, repo=repo)
@@ -1901,10 +2186,13 @@ def run_reconcile(
console_print(f"[warning]#{pr.number}: reporting job statuses failed: {e}[/]")
unknown.append((pr.number, f"reporting job statuses failed: {type(e).__name__}: {e}"))
base_config = configs.get(pr.base_ref)
if base_config is not None:
pr = mark_older_pilot_pull_request(pr, base_config, repo=repo, now=now)
plan = classify(
pr,
fork,
config=configs.get(pr.base_ref),
config=base_config,
now=now,
repo=repo,
find_bypass_run=functools.partial(bypass_finder.find, pr),
@@ -1920,8 +2208,8 @@ def run_reconcile(
if draft_change is not None:
draft_changes.append(draft_change)
# The fork's result is in the `Fork CI / Tests (AMD)` status; the gated run's skipped
# checks only clutter the pull request. Runs with any project CI work stay for the
# tampering check.
# checks, and the cancelled ones of a run a newer push cancelled, only clutter the pull
# request. Runs with any project CI work stay for the tampering check.
if plan.state != ForkState.GATE_BYPASSED:
for run in bypass_finder.find_gated_runs(pr):
client.delete_run(pr.number, run.id)
@@ -1935,12 +2223,19 @@ def run_reconcile(
attention.extend(bypass_finder.ambiguous)
notes = [bypass_finder.incomplete] if bypass_finder.incomplete else []
if ci_run_started_note:
notes.append(ci_run_started_note)
previous_deferred_runs = read_deferred_runs(reported_file)
deferred_runs = (previous_deferred_runs or 0) + 1 if job_reporter.deferred else 0
if job_reporter.deferred:
console_print(
f"[info]Job statuses of {len(job_reporter.deferred)} pull requests left for the next run: "
f"{job_reporter.deferred}[/]"
f"[info]Job statuses of {len(job_reporter.deferred)} pull requests left for the next run "
f"({deferred_runs} runs in a row): {job_reporter.deferred}[/]"
)
notes.append(JOB_STATUSES_DEFERRED_NOTE)
# Without a record of earlier runs, deferring is reported at once, as it cannot be told apart
# from a budget that does not keep up.
if previous_deferred_runs is None or deferred_runs >= DEFERRED_RUNS_BEFORE_ALERT:
notes.append(JOB_STATUSES_DEFERRED_NOTE)
# The full list for the run log; Slack may show only part of a very long one.
for number, reason in [*unknown, *attention]:
console_print(f"[warning]#{number}: {reason}[/]")
@@ -1949,7 +2244,12 @@ def run_reconcile(
draft_changes_file.write_text(json.dumps([asdict(change) for change in draft_changes]))
if reported_file is not None:
unknown, attention, notes = drop_reported(
reported_file, unknown=unknown, attention=attention, notes=notes, record=not client.dry_run
reported_file,
unknown=unknown,
attention=attention,
notes=notes,
record=not client.dry_run,
deferred_runs=deferred_runs,
)
has_alerts = bool(unknown or attention or notes)
if has_alerts:
@@ -43,7 +43,7 @@ import os
import re
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from datetime import UTC, datetime
from typing import Any
import requests
@@ -170,6 +170,8 @@ class ApprovalDecision:
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
# Runs started before this are not approved either.
not_before: datetime | None = None
def count_source_lines(changed_files: list[ChangedFile]) -> int:
@@ -196,8 +198,14 @@ def find_new_provider_files(files: list[str]) -> list[str]:
]
def is_gated_to_fork_ci(*, pr: dict[str, Any], github_repository: str, config: GateConfig) -> bool:
"""Whether the PR's ``Tests (AMD)`` run only executes the fork CI gate (AIP-120)."""
def is_gated_to_fork_ci(
*, pr: dict[str, Any], github_repository: str, config: GateConfig, ci_run_at: datetime | None = None
) -> bool:
"""
Whether the PR's ``Tests (AMD)`` run only executes the fork CI gate (AIP-120).
:param ci_run_at: when the run was created, as the gate reads it; see ``GateEvent.ci_run_at``.
"""
event = GateEvent(
event_name="pull_request",
repository=github_repository,
@@ -207,6 +215,7 @@ def is_gated_to_fork_ci(*, pr: dict[str, Any], github_repository: str, config: G
author_association=pr.get("author_association") or "",
created_at=parse_github_datetime(pr["created_at"]),
labels=tuple(label["name"] for label in pr.get("labels", [])),
ci_run_at=ci_run_at,
)
return decide(event, config).reason in GATED_REASONS
@@ -663,7 +672,11 @@ def process_waiting_runs(api: GitHubApi, *, created_since: datetime) -> list[Pul
# A trusted repository's runs do not depend on the base branch or labels they started with.
if decision.approve and stakeholder is None:
pr_run_ids = find_runs_started_for_current_pr(
api=api, pr=pr, runs=head_runs, decision_labels=decision.decision_labels
api=api,
pr=pr,
runs=head_runs,
decision_labels=decision.decision_labels,
not_before=decision.not_before,
)
if not pr_run_ids:
decision = ApprovalDecision(
@@ -726,6 +739,7 @@ def find_runs_started_for_current_pr(
pr: dict[str, Any],
runs: list[dict[str, Any]],
decision_labels: frozenset[str] = TEST_SELECTION_LABELS,
not_before: datetime | None = None,
) -> list[int]:
"""
Return the runs started after the PR was opened and after its base branch last changed.
@@ -744,11 +758,13 @@ def find_runs_started_for_current_pr(
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)])
started_after = max([started_after, *(time for time in (*changes, not_before) if time is not None)])
return [run["id"] for run in runs if parse_github_time(run["created_at"]) >= started_after]
def decide_for_pull_request(*, api: GitHubApi, pr: dict[str, Any]) -> ApprovalDecision:
def decide_for_pull_request(
*, api: GitHubApi, pr: dict[str, Any], now: datetime | None = None
) -> ApprovalDecision:
merge_sha = fetch_merge_commit(
github_repository=api.github_repository, pr_number=pr["number"], head_sha=pr["head"]["sha"]
)
@@ -760,10 +776,20 @@ def decide_for_pull_request(*, api: GitHubApi, pr: dict[str, Any]) -> ApprovalDe
changed_files = get_changed_files(merge_sha)
except ValueError:
return ApprovalDecision(approve=False, reasons=["its changed files could not be listed"])
config = load_config(FORK_CI_GATE_CONFIG_PATH)
# A pilot's PR created before they joined is gated from its first run created after joining; its
# runs from before, and every run until the join instant has passed, stay with the rules below.
joined_at = config.find_pilot_join_after(
(pr.get("user") or {}).get("login") or "", parse_github_time(pr["created_at"])
)
if joined_at is not None and joined_at > (now or datetime.now(UTC)):
joined_at = None
if pr["base"]["ref"] == AUTO_APPROVABLE_BASE_BRANCH and is_gated_to_fork_ci(
pr=pr, github_repository=api.github_repository, config=load_config(FORK_CI_GATE_CONFIG_PATH)
pr=pr, github_repository=api.github_repository, config=config, ci_run_at=joined_at
):
return decide_gated_approval(changed_files=changed_files)
decision = decide_gated_approval(changed_files=changed_files)
decision.not_before = joined_at
return decision
pr_labels = tuple(label["name"] for label in pr.get("labels", []))
return decide_approval(
base_branch=pr["base"]["ref"],
+110
View File
@@ -62,6 +62,7 @@ def _pr(
created_at: datetime = AFTER,
labels: tuple[str, ...] = (),
head_repository: str = "someone/airflow",
ci_run_at: datetime | None = None,
) -> GateEvent:
return GateEvent(
event_name="pull_request",
@@ -72,6 +73,7 @@ def _pr(
author_association=association,
created_at=created_at,
labels=labels,
ci_run_at=ci_run_at,
)
@@ -202,6 +204,28 @@ def _push(repository: str, branch: str) -> GateEvent:
Decision(run=True, reason=Reason.GATE_DISABLED),
id="pilot-before-join-instant",
),
pytest.param(
_pr(author="pilot-user", created_at=JOINED - timedelta(days=5), ci_run_at=JOINED),
{"enabled": False},
Decision(run=False, reason=Reason.PILOT),
id="pilot-older-pr-run-at-join-instant",
),
pytest.param(
_pr(
author="pilot-user",
created_at=JOINED - timedelta(days=5),
ci_run_at=JOINED - timedelta(seconds=1),
),
{"enabled": False},
Decision(run=True, reason=Reason.GATE_DISABLED),
id="pilot-older-pr-run-before-join-instant",
),
pytest.param(
_pr(created_at=SINCE - timedelta(days=5), ci_run_at=AFTER),
{},
Decision(run=True, reason=Reason.BEFORE_ENABLED_SINCE),
id="run-time-does-not-move-enabled-since",
),
pytest.param(
_pr(author="pilot-user", created_at=AFTER, labels=("use project ci",)),
{},
@@ -428,3 +452,89 @@ def test_main_fork_push_does_not_read_labels(mock_run, gate_env):
assert _outputs(gate_env) == ["project-ci=true", "reason=fork_push"]
mock_run.assert_not_called()
PILOT_CONFIG = '[pilot]\n"pilot-user" = 2026-10-20T00:00:00Z\n'
def _gh_responses(labels: str, run: subprocess.CompletedProcess | Exception):
def run_gh(args, **kwargs):
if args[2].startswith("repos/apache/airflow/pulls/"):
return subprocess.CompletedProcess([], 0, stdout=labels, stderr="")
assert args[2] == "repos/apache/airflow/actions/runs/123"
if isinstance(run, Exception):
raise run
return run
return run_gh
def _older_pilot_pull_request(env: dict[str, str]) -> None:
Path(env["GATE_CONFIG"]).write_text(PILOT_CONFIG)
env.update(PR_AUTHOR="pilot-user", PR_CREATED_AT="2026-10-15T00:00:00Z", GITHUB_RUN_ID="123")
@mock.patch("airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True)
@pytest.mark.parametrize(
("run_created_at", "expected"),
[
pytest.param("2026-10-20T00:00:00Z\n", ["project-ci=false", "reason=pilot"], id="run-after-joining"),
pytest.param(
"2026-10-19T23:59:59Z\n", ["project-ci=true", "reason=gate_disabled"], id="run-before-joining"
),
],
)
def test_main_decides_a_pilots_older_pull_request_by_its_run_time(
mock_run, gate_env, run_created_at, expected
):
_older_pilot_pull_request(gate_env)
mock_run.side_effect = _gh_responses(
"[]\n", subprocess.CompletedProcess([], 0, stdout=run_created_at, stderr="")
)
assert main(gate_env) == 0
assert _outputs(gate_env) == expected
@mock.patch("airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True)
@pytest.mark.parametrize(
"run",
[
pytest.param(subprocess.CompletedProcess([], 1, stdout="", stderr="boom"), id="gh-fails"),
pytest.param(subprocess.CompletedProcess([], 0, stdout="not a date\n", stderr=""), id="bad-output"),
pytest.param(FileNotFoundError("gh"), id="no-gh"),
],
)
def test_main_fails_when_the_run_time_of_a_pilots_older_pull_request_cannot_be_read(mock_run, gate_env, run):
_older_pilot_pull_request(gate_env)
mock_run.side_effect = _gh_responses("[]\n", run)
assert main(gate_env) == 1
assert not Path(gate_env["GITHUB_OUTPUT"]).exists()
@mock.patch("airflow_breeze.utils.fork_ci_gate.subprocess.run", autospec=True)
def test_main_reads_the_run_time_only_for_a_pilots_older_pull_request(mock_run, gate_env):
Path(gate_env["GATE_CONFIG"]).write_text(PILOT_CONFIG)
gate_env.update(PR_AUTHOR="pilot-user", GITHUB_RUN_ID="123")
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="[]\n", stderr="")
assert main(gate_env) == 0
assert _outputs(gate_env) == ["project-ci=false", "reason=pilot"]
mock_run.assert_called_once()
@pytest.mark.parametrize(
("login", "created_at", "expected"),
[
pytest.param("Pilot-User", JOINED - timedelta(seconds=1), JOINED, id="created-before"),
pytest.param("pilot-user", JOINED, None, id="created-at-join-instant"),
pytest.param("someone", JOINED - timedelta(days=1), None, id="not-a-pilot"),
pytest.param("pilot-user", None, None, id="no-creation-time"),
],
)
def test_find_pilot_join_after(login, created_at, expected):
assert _config().find_pilot_join_after(login, created_at) == expected
+508 -7
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import base64
import dataclasses
import json
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime, timedelta, timezone
from unittest import mock
import pytest
@@ -30,6 +30,9 @@ from airflow_breeze.utils.fork_ci_gate import GateConfig
from airflow_breeze.utils.fork_ci_github import GitHubClient, GitHubError
from airflow_breeze.utils.fork_ci_messages import (
CI_PASSED_CONTEXT,
CI_RUN_STARTED_CONTEXT,
CI_RUN_STARTED_DESCRIPTION,
CI_RUN_STARTED_PROJECT_DESCRIPTION,
JOB_STATUS_CONTEXT_PREFIX,
STALE_JOB_STATUS,
STATUS_CONTEXT,
@@ -59,15 +62,22 @@ from airflow_breeze.utils.fork_ci_reconcile import (
drop_reported,
fetch_fork_observations,
fetch_workflow_state,
find_ci_run_since,
find_heads_shared_across_bases,
find_project_ci_run_url,
find_state_comment,
is_cancelled_gate_run,
is_gated_skip_run,
is_own_pilot_change,
jobs_show_project_ci,
load_workflow_triggers,
mark_older_pilot_pull_request,
parse_pull_request,
pull_request_changes_ci_files,
read_ci_run_at,
read_head_status,
read_project_ci_run_url,
report_project_ci_runs_started,
run_reconcile,
)
from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
@@ -75,6 +85,7 @@ from airflow_breeze.utils.path_utils import AIRFLOW_ROOT_PATH
REPO = "apache/airflow"
NOW = datetime(2026, 11, 10, 12, 0, tzinfo=UTC)
CREATED = datetime(2026, 11, 1, tzinfo=UTC)
JOINED = CREATED + timedelta(days=2)
CONFIG = GateConfig(enabled=True, enabled_since=datetime(2026, 10, 1, tzinfo=UTC), close_after_days=7)
RUN_URL = "https://github.com/someone/airflow/actions/runs/1"
PROJECT_RUN_URL = "https://github.com/apache/airflow/actions/runs/2"
@@ -940,6 +951,21 @@ def test_finder_catches_a_tampered_run_still_in_progress(client):
assert finder.find(_pr()) == "https://github.com/apache/airflow/actions/runs/7"
@pytest.mark.parametrize(
("joined_hours_ago", "expected"),
[
pytest.param(0.5, None, id="run-before-joining-was-legitimate"),
pytest.param(2, "https://github.com/apache/airflow/actions/runs/7", id="run-after-joining"),
],
)
def test_finder_leaves_out_runs_of_an_older_pilot_pull_request_before_joining(
client, joined_hours_ago, expected
):
finder = _finder(client, [_run(7, age_hours=1)], jobs=TAMPERED_JOBS)
assert finder.find(_pr(pilot_joined_at=NOW - timedelta(hours=joined_hours_ago))) == expected
def test_finder_catches_a_tampered_run_on_an_earlier_commit(client):
finder = _finder(client, [_run(7, sha="tampered")], jobs=TAMPERED_JOBS)
@@ -1141,10 +1167,49 @@ def test_is_gated_skip_run(jobs, expected):
assert is_gated_skip_run(jobs, NOW) is expected
CANCELLED_JOBS = [
_job("Check if the run is needed", "success"),
_job("Fork CI gate", "cancelled"),
_job("Build info", "cancelled", started=False),
_job("Tests", "cancelled", started=False),
_job("CI passed (run cancelled)", "success"),
]
@pytest.mark.parametrize(
("jobs", "expected"),
[
pytest.param(CANCELLED_JOBS, True, id="cancelled-while-the-gate-ran"),
pytest.param(
[*CANCELLED_JOBS[:2], _job("Build info", "cancelled"), *CANCELLED_JOBS[3:]],
False,
id="cancelled-after-build-info-started",
),
pytest.param([*CANCELLED_JOBS[:4], _job("CI passed", "failure")], False, id="failed-ci-passed"),
pytest.param(GATED_JOBS, False, id="gated-not-cancelled"),
pytest.param(
[_job("Fork CI gate", "cancelled", minutes=30), *CANCELLED_JOBS[2:]],
False,
id="work-under-the-gate-name",
),
],
)
def test_is_cancelled_gate_run(jobs, expected):
assert is_cancelled_gate_run(jobs, NOW) is expected
@pytest.mark.parametrize(
("runs", "jobs", "compare_files", "expected_ids"),
[
pytest.param([_run(7, minutes=1)], GATED_JOBS, ("README.md",), [7], id="gated"),
pytest.param([_run(7, minutes=1)], CANCELLED_JOBS, ("README.md",), [7], id="cancelled"),
pytest.param(
[_run(7, minutes=1)],
CANCELLED_JOBS,
(".github/workflows/ci-amd.yml",),
[],
id="cancelled-with-workflows-changed",
),
pytest.param([_run(7, minutes=1)], TAMPERED_JOBS, ("README.md",), [], id="project-ci-ran"),
pytest.param(
[_run(7, minutes=1, status="in_progress")], GATED_JOBS, ("README.md",), [], id="in-progress"
@@ -1380,6 +1445,256 @@ def test_read_head_status(client, statuses, expected_status, expected_ci_passed)
assert read.head_status_unread is False
def test_parse_pull_request_reads_ci_run_started():
node = _pr_node(
1,
commits={
"nodes": [
{"commit": {"oid": "sha1", "status": {"context": None, "ciRunStarted": {"state": "SUCCESS"}}}}
]
},
)
assert parse_pull_request(node).ci_run_started_state == "success"
def test_read_head_status_reads_ci_run_started(client):
client.rest_get.return_value = [{"context": CI_RUN_STARTED_CONTEXT, "state": "success"}]
pr = dataclasses.replace(parse_pull_request(_pr_node(1)), head_status_unread=True)
assert read_head_status(client, REPO, pr).ci_run_started_state == "success"
def _branch_run(created_at: str, head_repo: str = "someone/airflow") -> dict:
return {"created_at": created_at, "head_repository": {"full_name": head_repo}}
def _find_ci_run_since(client, since=datetime(2026, 11, 1, tzinfo=UTC)):
return find_ci_run_since(
client, REPO, head_repo="Someone/airflow", head_ref="my/change", head_sha="sha1", since=since
)
@pytest.mark.parametrize(
("runs", "expected"),
[
pytest.param(
[_branch_run("2026-11-02T10:00:00Z"), _branch_run("2026-11-03T10:00:00Z")],
datetime(2026, 11, 3, 10, tzinfo=UTC),
id="latest-run",
),
pytest.param([_branch_run("2026-11-02T10:00:00Z", "other/airflow")], None, id="other-fork"),
pytest.param([], None, id="no-run"),
],
)
def test_find_ci_run_since(client, runs, expected):
client.rest_get.return_value = {"workflow_runs": runs}
assert _find_ci_run_since(client) == expected
client.rest_get.assert_called_once_with(
f"repos/{REPO}/actions/workflows/ci-amd.yml/runs?event=pull_request&branch=my%2Fchange"
"&head_sha=sha1&created=%3E%3D2026-11-01T00%3A00%3A00Z&per_page=100"
)
def test_find_ci_run_since_converts_the_join_instant_to_utc(client):
client.rest_get.return_value = {"workflow_runs": []}
_find_ci_run_since(client, since=datetime(2026, 11, 1, 10, tzinfo=timezone(timedelta(hours=2))))
assert "created=%3E%3D2026-11-01T08%3A00%3A00Z" in client.rest_get.call_args.args[0]
@pytest.mark.parametrize(
("author", "created_at"),
[
pytest.param("someone", datetime(2026, 10, 1, tzinfo=UTC), id="not-a-pilot"),
pytest.param("pilot-user", datetime(2026, 11, 1, tzinfo=UTC), id="created-after-joining"),
],
)
def test_read_ci_run_at_only_reads_for_older_pilot_pull_requests(client, author, created_at):
config = GateConfig(pilot={"pilot-user": datetime(2026, 11, 1, tzinfo=UTC)})
assert (
read_ci_run_at(
client,
REPO,
config,
author=author,
created_at=created_at,
head_repo="someone/airflow",
head_ref="branch",
head_sha="sha1",
)
is None
)
client.rest_get.assert_not_called()
def _project_node(number, *, status=None, suites=(), oid=None):
return {
"number": number,
"headRefOid": f"sha{number}",
"commits": {
"nodes": [
{
"commit": {
"oid": oid or f"sha{number}",
"status": {"context": {"state": status}} if status else None,
"checkSuites": {"nodes": list(suites)},
}
}
]
},
}
def _suite(
run_id,
*,
created="2026-11-10T10:00:00Z",
event="pull_request",
path=".github/workflows/ci-amd.yml",
conclusion=None,
):
return {
"createdAt": created,
"conclusion": conclusion,
"workflowRun": {
"url": f"https://github.com/apache/airflow/actions/runs/{run_id}",
"event": event,
"workflow": {"resourcePath": path},
},
}
@pytest.mark.parametrize(
("node", "expected"),
[
pytest.param(
_project_node(1, suites=[_suite(7), _suite(8, created="2026-11-10T11:00:00Z")]),
"https://github.com/apache/airflow/actions/runs/8",
id="latest-run",
),
pytest.param(_project_node(1, status="SUCCESS", suites=[_suite(7)]), None, id="already-reported"),
pytest.param(
_project_node(1, status="PENDING", suites=[_suite(7)]),
"https://github.com/apache/airflow/actions/runs/7",
id="pending-is-replaced",
),
pytest.param(_project_node(1), None, id="no-run-yet"),
pytest.param(
_project_node(
1, suites=[_suite(7, event="push"), _suite(8, path=".github/workflows/codeql.yml")]
),
None,
id="other-runs-only",
),
pytest.param(
_project_node(1, suites=[_suite(7, conclusion="ACTION_REQUIRED")]),
None,
id="waiting-for-approval",
),
pytest.param(_project_node(1, suites=[_suite(7)], oid="other"), None, id="not-the-head"),
],
)
def test_find_project_ci_run_url(node, expected):
assert find_project_ci_run_url(node) == expected
@pytest.mark.parametrize(
("statuses", "runs", "expected"),
[
pytest.param(
[],
[{"html_url": "https://github.com/apache/airflow/actions/runs/9", "conclusion": None}],
"https://github.com/apache/airflow/actions/runs/9",
id="run-found",
),
pytest.param(
[{"context": CI_RUN_STARTED_CONTEXT, "state": "success"}],
[{"html_url": "https://github.com/apache/airflow/actions/runs/9", "conclusion": None}],
None,
id="already-reported",
),
pytest.param(
[],
[
{
"html_url": "https://github.com/apache/airflow/actions/runs/9",
"conclusion": "action_required",
}
],
None,
id="waiting-for-approval",
),
pytest.param([], [], None, id="no-run-yet"),
],
)
def test_read_project_ci_run_url(client, statuses, runs, expected):
def rest_get(path, paginate=False, items_key=None):
if path == f"repos/{REPO}/commits/sha1/status?per_page=100":
assert (paginate, items_key) == (True, "statuses")
return statuses
assert (
path
== f"repos/{REPO}/actions/workflows/ci-amd.yml/runs?event=pull_request&head_sha=sha1&per_page=1"
)
return {"workflow_runs": runs}
client.rest_get.side_effect = rest_get
assert read_project_ci_run_url(client, REPO, "sha1") == expected
def test_report_project_ci_runs_started_reads_a_head_commit_the_search_did_not_list(client):
client.graphql.return_value = _search_result(_project_node(1, suites=[_suite(7)], oid="older"))
client.rest_get.side_effect = [
[],
{
"workflow_runs": [
{"html_url": "https://github.com/apache/airflow/actions/runs/9", "conclusion": None}
]
},
]
assert report_project_ci_runs_started(client, repo=REPO, skip=set()) == [1]
assert client.set_status.call_args.args[:4] == (
1,
"sha1",
"success",
"https://github.com/apache/airflow/actions/runs/9",
)
def test_report_project_ci_runs_started_skips_gated_pull_requests_and_keeps_to_the_budget(client):
client.graphql.return_value = _search_result(
_project_node(1, suites=[_suite(1)]),
_project_node(2, suites=[_suite(2)]),
_project_node(3),
_project_node(4, suites=[_suite(4)]),
_project_node(5, suites=[_suite(5)]),
)
reported = report_project_ci_runs_started(client, repo=REPO, skip={2}, budget=2)
assert reported == [1, 4]
assert client.set_status.call_args_list == [
mock.call(
number,
f"sha{number}",
"success",
f"https://github.com/apache/airflow/actions/runs/{number}",
CI_RUN_STARTED_PROJECT_DESCRIPTION,
context=CI_RUN_STARTED_CONTEXT,
)
for number in (1, 4)
]
assert client.graphql.call_args.args[1]["q"] == (
f'repo:{REPO} is:pr is:open -label:"fork ci required" sort:updated-desc'
)
def test_discover_labels_pilot_pull_requests_while_disabled(client):
config = GateConfig(enabled=False, pilot={"pilot-user": datetime(2026, 11, 1, tzinfo=UTC)})
new = {**_pr_node(1), "author": {"login": "pilot-user"}, "labels": {"nodes": []}}
@@ -1387,12 +1702,37 @@ def test_discover_labels_pilot_pull_requests_while_disabled(client):
other = {**_pr_node(3), "labels": {"nodes": []}}
client.graphql.side_effect = [_search_result(new, old, other), _search_result()]
client.rest_get.return_value = {"workflow_runs": []}
configs = BaseGateConfigs(client, repo=REPO, default_branch="main", default_config=config)
labelled = discover(client, configs, repo=REPO, now=NOW)
assert labelled == [1]
client.add_labels.assert_called_once_with(1, ["fork ci required"])
client.rest_get.assert_called_once_with(
f"repos/{REPO}/actions/workflows/ci-amd.yml/runs?event=pull_request&branch=branch-2"
"&head_sha=sha2&created=%3E%3D2026-11-01T00%3A00%3A00Z&per_page=100"
)
def test_discover_labels_an_older_pilot_pull_request_after_a_run_since_joining(client):
config = GateConfig(enabled=False, pilot={"pilot-user": datetime(2026, 11, 1, tzinfo=UTC)})
old = {
**_pr_node(2),
"author": {"login": "pilot-user"},
"createdAt": "2026-10-20T00:00:00Z",
"labels": {"nodes": []},
}
client.graphql.side_effect = [_search_result(old), _search_result()]
client.rest_get.return_value = {
"workflow_runs": [_branch_run("2026-11-02T10:00:00Z", "someone/my-airflow")]
}
configs = BaseGateConfigs(client, repo=REPO, default_branch="main", default_config=config)
labelled = discover(client, configs, repo=REPO, now=NOW)
assert labelled == [2]
client.add_labels.assert_called_once_with(2, ["fork ci required"])
def _contents(files: dict[tuple[str, str], str]):
@@ -1586,6 +1926,7 @@ def test_run_reconcile_isolates_failures_and_reports_them(client, tmp_path):
_search_result(),
_search_result(_pr_node(1), _pr_node(2, isDraft=False)),
{"pr0": {"object": None}, "pr1": {"object": None}},
_search_result(),
]
workflow_state_calls = []
client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}]
@@ -1640,6 +1981,7 @@ def test_run_reconcile_rereads_workflow_state_while_awaiting_push(client, tmp_pa
_search_result(),
_search_result(_pr_node(1, comments={"nodes": [marker_comment]})),
{"pr0": {"object": None}},
_search_result(),
]
client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}]
@@ -1684,6 +2026,7 @@ def test_run_reconcile_deletes_gated_runs(mock_classify, client, tmp_path, state
_pr_node(1, headRefName="my-change", headRepository={"nameWithOwner": "someone/airflow"})
),
{"pr0": {"object": None}},
_search_result(),
]
client.rest_get_pages.side_effect = lambda path: [
{"total_count": 1, "workflow_runs": [_run(7, minutes=1)] if "event=pull_request&" in path else []}
@@ -1749,7 +2092,7 @@ def test_command_writes_has_alerts_output(mock_run, tmp_path, monkeypatch):
)
def test_run_reconcile_without_gated_pull_requests(client, tmp_path, total_count, has_alerts):
output = tmp_path / "slack-message.json"
client.graphql.side_effect = [_search_result(), _search_result(), _search_result()]
client.graphql.side_effect = [_search_result(), _search_result(), _search_result(), _search_result()]
client.rest_get_pages.return_value = [{"total_count": total_count, "workflow_runs": []}]
assert (
@@ -1833,7 +2176,12 @@ def test_drop_reported_reports_everything_without_a_readable_previous_run(tmp_pa
reported_file.write_text(content)
assert drop_reported(reported_file, unknown=[(1, "a")], attention=[], notes=[]) == ([(1, "a")], [], [])
assert json.loads(reported_file.read_text()) == {"unknown": [[1, "a"]], "attention": [], "notes": []}
assert json.loads(reported_file.read_text()) == {
"unknown": [[1, "a"]],
"attention": [],
"notes": [],
"deferred_runs": 0,
}
@pytest.mark.parametrize(
@@ -1849,7 +2197,7 @@ def test_run_reconcile_posts_a_persisting_item_once(client, tmp_path, first_dry_
def reconcile(dry_run=False):
client.dry_run = dry_run
client.graphql.side_effect = [_search_result(), _search_result(), _search_result()]
client.graphql.side_effect = [_search_result(), _search_result(), _search_result(), _search_result()]
return run_reconcile(
client,
CONFIG,
@@ -1881,6 +2229,54 @@ def test_slack_payload_stays_within_slack_limits():
assert "more sections are in the <https://run|run log>" in texts[-1]
@pytest.mark.parametrize(
("fork", "ci_run_started_state", "expected"),
[
pytest.param(GREEN, None, ("success", RUN_URL, CI_RUN_STARTED_DESCRIPTION), id="green"),
pytest.param(
ForkObservation(suite_status="IN_PROGRESS", run_url=RUN_URL),
"pending",
("success", RUN_URL, CI_RUN_STARTED_DESCRIPTION),
id="running",
),
pytest.param(GREEN, "success", None, id="already-reported"),
pytest.param(ForkObservation(workflow_state="active"), None, None, id="no-fork-run"),
],
)
def test_ci_run_started_is_reported_once_the_fork_runs_the_head_commit(fork, ci_run_started_state, expected):
plan, _, _ = _classify(_pr(ci_run_started_state=ci_run_started_state), fork)
assert plan.ci_run_started == expected
def test_apply_plan_posts_ci_run_started_under_its_own_context(client):
plan, _, _ = _classify(_pr(), GREEN)
apply_plan(client, _pr(), plan)
assert (
mock.call(
42, "abc123", "success", RUN_URL, CI_RUN_STARTED_DESCRIPTION, context=CI_RUN_STARTED_CONTEXT
)
in client.set_status.call_args_list
)
@pytest.mark.parametrize(
("ci_run_at", "checked"),
[
pytest.param(CREATED + timedelta(days=1), True, id="run-since-joining"),
pytest.param(None, False, id="no-run-since-joining"),
],
)
def test_older_pilot_pull_request_is_checked_for_tampering_after_a_run_since_joining(ci_run_at, checked):
config = dataclasses.replace(CONFIG, enabled=False, pilot={"someone": CREATED + timedelta(hours=1)})
_, find_bypass, _ = _classify(_pr(ci_run_at=ci_run_at), GREEN, config=config)
assert find_bypass.called is checked
def test_apply_plan_posts_ci_passed_under_its_own_context(client):
plan, _, _ = _classify(_pr(), GREEN)
@@ -2072,6 +2468,7 @@ def test_run_reconcile_reports_job_statuses_before_classifying(
_search_result(),
_search_result(_pr_node(1, headRepository={"nameWithOwner": "someone/airflow"})),
{"pr0": {"object": None}},
_search_result(),
]
client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}]
client.rest_get.return_value = {"state": "active"}
@@ -2148,12 +2545,13 @@ def test_job_status_reporter_charges_each_write_even_when_a_later_one_fails(clie
assert reporter.remaining == 9
def _reconcile_with_reporter(client, tmp_path, config=CONFIG):
def _reconcile_with_reporter(client, tmp_path, config=CONFIG, reported_file=None):
client.graphql.side_effect = [
_search_result(),
_search_result(),
_search_result(_pr_node(1, headRepository={"nameWithOwner": "someone/airflow"})),
{"pr0": {"object": None}},
_search_result(),
]
client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}]
client.rest_get.return_value = {"state": "active"}
@@ -2164,6 +2562,7 @@ def _reconcile_with_reporter(client, tmp_path, config=CONFIG):
run_url="https://run",
output_file=tmp_path / "s.json",
workflows_dir=tmp_path,
reported_file=reported_file,
)
@@ -2218,6 +2617,26 @@ def test_run_reconcile_reports_deferred_job_statuses_to_slack(
assert JOB_STATUSES_DEFERRED_NOTE in (tmp_path / "s.json").read_text()
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.JobStatusReporter", autospec=True)
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.classify", autospec=True)
def test_run_reconcile_reports_deferred_job_statuses_only_when_deferring_persists(
mock_classify, mock_reporter, client, tmp_path
):
mock_classify.return_value = Plan(state=ForkState.RUNNING)
mock_reporter.return_value.report.return_value = None
client.dry_run = False
reported_file = tmp_path / "reported.json"
reported_file.write_text("{}")
alerts = []
for deferred in ([1], [1], [], [1], [1], [1]):
mock_reporter.return_value.deferred = deferred
alerts.append(_reconcile_with_reporter(client, tmp_path, reported_file=reported_file))
assert alerts == [False, False, False, False, False, True]
assert JOB_STATUSES_DEFERRED_NOTE in (tmp_path / "s.json").read_text()
assert json.loads(reported_file.read_text())["deferred_runs"] == 3
def test_build_job_statuses_treats_contexts_case_insensitively():
statuses = build_job_statuses(
[_fork_job("Tests", conclusion="failure", url="bad"), _fork_job("tests", url="ok")]
@@ -2281,7 +2700,7 @@ def test_finder_ignores_an_added_workflow_whose_jobs_did_not_run(client, jobs):
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.BypassFinder", autospec=True)
def test_run_reconcile_reports_ambiguous_runs(mock_finder, client, tmp_path):
output = tmp_path / "slack-message.json"
client.graphql.side_effect = [_search_result(), _search_result(), _search_result()]
client.graphql.side_effect = [_search_result(), _search_result(), _search_result(), _search_result()]
mock_finder.return_value.incomplete = None
mock_finder.return_value.ambiguous = [(42, "could also belong to #43")]
@@ -2308,7 +2727,7 @@ def test_run_reconcile_reports_ambiguous_runs(mock_finder, client, tmp_path):
def test_run_reconcile_tells_the_finder_which_bases_are_gated(
mock_finder, client, tmp_path, base_ref, rest_get, expected
):
client.graphql.side_effect = [_search_result(), _search_result(), _search_result()]
client.graphql.side_effect = [_search_result(), _search_result(), _search_result(), _search_result()]
mock_finder.return_value.incomplete = None
mock_finder.return_value.ambiguous = []
run_reconcile(
@@ -2698,6 +3117,7 @@ def test_run_reconcile_retries_a_pull_request_whose_head_moved(client, tmp_path)
_search_result(),
_search_result(_pr_node(1)),
{"pr0": {"object": None}},
_search_result(),
]
client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}]
@@ -2719,3 +3139,84 @@ def test_run_reconcile_retries_a_pull_request_whose_head_moved(client, tmp_path)
assert has_alerts is False
client.set_status.assert_not_called()
client.create_comment.assert_not_called()
@pytest.mark.parametrize(
("fails", "job_status_budget"),
[pytest.param(False, 58, id="reported"), pytest.param(True, 30, id="fails")],
)
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.JobStatusReporter", autospec=True)
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.report_project_ci_runs_started", autospec=True)
@mock.patch("airflow_breeze.utils.fork_ci_reconcile.classify", autospec=True)
def test_run_reconcile_marks_older_pilot_pull_requests_and_reports_project_ci_runs(
mock_classify, mock_report, mock_reporter, client, tmp_path, fails, job_status_budget
):
config = dataclasses.replace(CONFIG, pilot={"someone": datetime(2026, 11, 2, tzinfo=UTC)})
mock_classify.return_value = Plan(state=ForkState.AWAITING_PUSH)
mock_report.side_effect = GitHubError("gh api failed: HTTP 502") if fails else None
mock_report.return_value = [7, 8]
mock_reporter.return_value.report.return_value = None
mock_reporter.return_value.deferred = []
client.graphql.side_effect = [
_search_result(),
_search_result(),
_search_result(_pr_node(1)),
{"pr0": {"object": None}},
]
client.rest_get_pages.return_value = [{"total_count": 0, "workflow_runs": []}]
def rest_get(path, paginate=False, items_key=None):
if path.endswith("/actions/workflows/ci-amd.yml"):
return {"state": "active"}
raise AssertionError(path)
client.rest_get.side_effect = rest_get
output = tmp_path / "s.json"
has_alerts = run_reconcile(
client, config, now=NOW, run_url="https://run", output_file=output, workflows_dir=tmp_path
)
assert mock_classify.call_args.args[0].ci_run_at == datetime(2026, 11, 2, tzinfo=UTC)
mock_report.assert_called_once_with(client, repo=REPO, skip={1})
mock_reporter.assert_called_once_with(client, repo=REPO, budget=job_status_budget)
assert has_alerts is fails
if fails:
assert "reporting `CI run started` for project CI failed: GitHubError" in output.read_text()
@pytest.mark.parametrize(
("config_overrides", "author", "expected_ci_run_at", "expected_pilot_joined_at"),
[
pytest.param(
{"enabled": False}, "pilot-user", JOINED, JOINED, id="project-ci-allowed-before-joining"
),
pytest.param({}, "pilot-user", JOINED, None, id="already-gated-as-external"),
pytest.param({"enabled": False}, "someone", None, None, id="not-a-pilot"),
pytest.param(
{"enabled": False, "pilot": {"pilot-user": NOW + timedelta(days=1)}},
"pilot-user",
None,
None,
id="joining-later",
),
],
)
def test_mark_older_pilot_pull_request(
config_overrides, author, expected_ci_run_at, expected_pilot_joined_at
):
config = dataclasses.replace(CONFIG, **{"pilot": {"pilot-user": JOINED}, **config_overrides})
marked = mark_older_pilot_pull_request(_pr(author_login=author), config, repo=REPO, now=NOW)
assert (marked.ci_run_at, marked.pilot_joined_at) == (expected_ci_run_at, expected_pilot_joined_at)
def test_older_pilot_pull_request_stays_checked_for_tampering_after_its_gated_run_is_deleted():
config = dataclasses.replace(CONFIG, enabled=False, pilot={"someone": JOINED})
_, find_bypass, _ = _classify(
mark_older_pilot_pull_request(_pr(), config, repo=REPO, now=NOW), GREEN, config=config
)
assert find_bypass.called
@@ -1308,3 +1308,65 @@ def test_process_waiting_runs_clears_a_failed_pull_request_whose_runs_were_appro
api.is_run_waiting_for_approval.assert_called_once_with(1)
assert api.set_approval_status.call_args_list[-1] == mock.call("aaa", "success", "Workflow runs approved")
api.remove_label.assert_called_once_with(10, NEED_WORKFLOW_APPROVAL_LABEL)
@mock.patch(f"{MODULE}.decide_for_pull_request", autospec=True)
def test_process_waiting_runs_does_not_approve_runs_before_the_decisions_cutoff(mock_decide):
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.return_value = None
mock_decide.return_value = ApprovalDecision(
approve=True, reasons=["gated"], not_before=datetime(2026, 10, 22, 10, 30, tzinfo=UTC)
)
process_waiting_runs(api, created_since=CREATED_SINCE)
assert api.approve_run.call_args_list == [mock.call(3)]
@pytest.mark.parametrize(
("joined_at", "gated", "not_before"),
[
pytest.param(
datetime(2026, 10, 23, tzinfo=UTC),
True,
datetime(2026, 10, 23, tzinfo=UTC),
id="pilot-joined-after-opening",
),
pytest.param(datetime(2026, 10, 21, tzinfo=UTC), True, None, id="pilot-joined-before-opening"),
pytest.param(datetime(2026, 10, 31, tzinfo=UTC), False, None, id="pilot-joining-later"),
],
)
@mock.patch(f"{MODULE}.decide_approval", autospec=True)
@mock.patch(f"{MODULE}.build_selective_checks", autospec=True)
@mock.patch(f"{MODULE}.load_config", autospec=True)
@mock.patch(f"{MODULE}.get_changed_files", autospec=True, return_value=LARGE_SOURCE_CHANGE)
@mock.patch(f"{MODULE}.fetch_merge_commit", autospec=True, return_value="merge")
def test_decide_for_pull_request_gates_an_older_pilot_pull_request_from_joining(
mock_fetch,
mock_get_changed_files,
mock_load_config,
mock_selective_checks,
mock_decide_approval,
joined_at,
gated,
not_before,
):
mock_load_config.return_value = GateConfig(pilot={"contributor": joined_at})
api = mock.MagicMock(spec=GitHubApi)
api.github_repository = "apache/airflow"
decision = decide_for_pull_request(api=api, pr=_gated_pr(), now=datetime(2026, 10, 30, tzinfo=UTC))
if gated:
assert decision.approve is True
assert decision.not_before == not_before
mock_decide_approval.assert_not_called()
else:
assert decision is mock_decide_approval.return_value
@@ -168,6 +168,10 @@ AMD_ONLY_GATE_BLOCK = """ gate:
name: "Fork CI gate"
runs-on: ["ubuntu-slim"]
timeout-minutes: 5
permissions:
contents: read
# Reads the creation time of this run, which decides for a pilot's older pull request.
actions: read
outputs:
project-ci: ${{ steps.gate.outputs.project-ci }}
reason: ${{ steps.gate.outputs.reason }}