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 06771a97969 Let the fork CI monitor gate pull requests and stop
repeating Slack alerts (#75611)
06771a97969 is described below
commit 06771a97969594dc6833797cf5d787da690fe565
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Oct 25 16:40:45 2026 +0100
Let the fork CI monitor gate pull requests and stop repeating Slack alerts
(#75611)
GitHub authorises convertPullRequestToDraft and
markPullRequestReadyForReview
against the contents permission, and the monitor's token only had read
access. Every attempt to draft a gated pull request failed with "Resource
not accessible by integration", and because drafting was the first mutation,
the state comment, the Fork CI and CI passed statuses were never applied
either: the gate did nothing for the only gated pull request so far.
contents: write also allows pushing to the repository, so it must not reach
the job that installs and runs Breeze and its dependencies. The draft state
is advisory, the required statuses gate merging, so it can be changed by a
separate job that runs nothing but gh and jq.
The same failure was posted to Slack every 15 minutes. A persisting problem
should be posted once, and again only if it goes away and comes back.
Generated-by: Claude Opus 5
---
.github/workflows/fork-ci-monitor.yml | 170 +++++++++++++++++++--
.../doc/ci/08_fork_ci_for_external_contributors.md | 45 ++++--
.../doc/images/output_ci_fork-ci-reconcile.svg | 46 ++++--
.../doc/images/output_ci_fork-ci-reconcile.txt | 2 +-
.../src/airflow_breeze/commands/ci_commands.py | 25 ++-
.../airflow_breeze/commands/ci_commands_config.py | 2 +
.../src/airflow_breeze/utils/fork_ci_github.py | 22 ---
.../src/airflow_breeze/utils/fork_ci_reconcile.py | 90 ++++++++++-
dev/breeze/tests/test_fork_ci_reconcile.py | 126 ++++++++++++++-
9 files changed, 451 insertions(+), 77 deletions(-)
diff --git a/.github/workflows/fork-ci-monitor.yml
b/.github/workflows/fork-ci-monitor.yml
index e94f8fd0457..ec34b9615b0 100644
--- a/.github/workflows/fork-ci-monitor.yml
+++ b/.github/workflows/fork-ci-monitor.yml
@@ -31,15 +31,7 @@ on: # yamllint disable-line rule:truthy
description: "Compute the changes without applying them to pull
requests"
type: boolean
default: false
-permissions:
- # Listing the runs and jobs of the gate tampering check, and deleting the
runs that only gated a
- # pull request to fork CI; unlisted scopes are none.
- actions: write
- contents: read
- # Labels, draft state, comments and closing of gated pull requests.
- pull-requests: write
- # The `Fork CI / Tests (AMD)` commit status on pull request head commits.
- statuses: write
+permissions: {}
concurrency:
group: fork-ci-monitor
cancel-in-progress: false
@@ -48,10 +40,32 @@ jobs:
reconcile:
name: "Reconcile pull requests gated to fork CI"
if: github.repository == 'apache/airflow'
+ permissions:
+ # Listing the runs and jobs of the gate tampering check, and deleting
the runs that only
+ # gated a pull request to fork CI.
+ actions: write
+ contents: read
+ # Labels, comments and closing of gated pull requests.
+ pull-requests: write
+ # The `Fork CI / Tests (AMD)` commit status on pull request head commits.
+ statuses: write
+ outputs:
+ draft-changes: ${{ steps.reconcile.outputs.draft-changes }}
# The breeze action frees disk space with sudo and docker, which
ubuntu-slim does not provide.
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
+ # What earlier runs reported, so that a problem that persists is posted
to Slack once
+ # rather than every 15 minutes. Losing it, for example to cache
eviction, re-posts once.
+ # Restored before anything that can fail, so a failing setup is reported
once too, and
+ # outside the checkout, where nothing but the steps below reads it.
+ - name: "Restore what earlier runs reported"
+ id: restore-reported
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
# v6.1.0
+ with:
+ path: ${{ runner.temp }}/fork-ci-monitor
+ key: fork-ci-monitor-reported-${{ github.run_id }}-${{
github.run_attempt }}
+ restore-keys: fork-ci-monitor-reported-
- name: "Checkout main"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #
v7.0.1
with:
@@ -59,31 +73,163 @@ jobs:
persist-credentials: false
- name: "Install Breeze"
uses: ./.github/actions/breeze
+ # The reconciler updates a working copy; it replaces the recorded one
only once Slack has
+ # the message, so a failed post is retried by the next run instead of
being suppressed.
- name: "Reconcile"
id: reconcile
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry-run && 'true' || 'false' }}
RUN_URL: ${{ github.server_url }}/${{ github.repository
}}/actions/runs/${{ github.run_id }}
- run: breeze ci fork-ci-reconcile --output-file slack-message.json
+ run: |
+ cp "${RUNNER_TEMP}/fork-ci-monitor/reported.json" reported.json
2>/dev/null || true
+ breeze ci fork-ci-reconcile --output-file slack-message.json
--reported-file reported.json \
+ --draft-changes-file draft-changes.json
+ echo "draft-changes=$(jq -c . draft-changes.json)" >>
"${GITHUB_OUTPUT}"
# A dry run reports what it would have done, which must not read as done
on Slack.
- name: "Post to Slack"
+ id: post
if: steps.reconcile.outputs.has-alerts == 'true' && !inputs.dry-run
uses:
slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
+ # Fail the step on a rejected post, so it is not recorded as
reported.
+ errors: true
payload-file-path: "slack-message.json"
+ - name: "Record what this run reported"
+ if: steps.reconcile.outcome == 'success' && steps.post.outcome !=
'failure' && !inputs.dry-run
+ run: |
+ mkdir -p "${RUNNER_TEMP}/fork-ci-monitor"
+ cp reported.json "${RUNNER_TEMP}/fork-ci-monitor/reported.json"
+ rm -f "${RUNNER_TEMP}/fork-ci-monitor/failed"
# A run that fails before writing its message, for example on a
discovery query or a rate
- # limit it cannot wait out, would otherwise stay silent.
- - name: "Report a failed run to Slack"
+ # limit it cannot wait out, would otherwise stay silent. Only the first
of consecutive failed
+ # runs is reported.
+ - name: "Check whether the previous run's failure was reported"
+ id: previous-failure
if: failure() && !inputs.dry-run
+ run: |
+ if [[ -f "${RUNNER_TEMP}/fork-ci-monitor/failed" ]]; then
+ echo "reported=true" >> "${GITHUB_OUTPUT}"
+ else
+ echo "reported=false" >> "${GITHUB_OUTPUT}"
+ fi
+ - name: "Report a failed run to Slack"
+ id: report-failure
+ if: failure() && !inputs.dry-run &&
steps.previous-failure.outputs.reported == 'false'
uses:
slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.postMessage
token: ${{ secrets.SLACK_BOT_TOKEN }}
+ errors: true
payload: |
channel: "internal-airflow-ci-cd"
text: >-
Fork CI monitor run failed:
${{ github.server_url }}/${{ github.repository
}}/actions/runs/${{ github.run_id }}
+ - name: "Record the reported failure"
+ if: failure() && steps.report-failure.outcome == 'success'
+ run: mkdir -p "${RUNNER_TEMP}/fork-ci-monitor" && touch
"${RUNNER_TEMP}/fork-ci-monitor/failed"
+ # A dry run posts nothing, so what it saw must not suppress the next
run's message.
+ - name: "Save what was reported"
+ if: always() && !inputs.dry-run && steps.restore-reported.outcome ==
'success'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #
v6.1.0
+ with:
+ path: ${{ runner.temp }}/fork-ci-monitor
+ key: fork-ci-monitor-reported-${{ github.run_id }}-${{
github.run_attempt }}
+
+ # GitHub authorises the convertPullRequestToDraft and
markPullRequestReadyForReview mutations
+ # against `contents`, not `pull-requests`: with `contents: read` they fail
with "Resource not
+ # accessible by integration". `contents: write` would also let the token
push to the
+ # repository, so it is kept out of the reconcile job, which installs and
runs Breeze and its
+ # dependencies. This job runs no code but `gh` and `jq` on the reconciler's
list.
+ draft-state:
+ name: "Change the draft state of pull requests gated to fork CI"
+ needs: reconcile
+ # Runs whenever the reconciler wrote its list, even when only its Slack
post failed, and with
+ # an empty list too, so that a failure set that cleared is recorded as
cleared.
+ if: >-
+ !cancelled() && !inputs.dry-run && github.repository == 'apache/airflow'
+ && needs.reconcile.outputs.draft-changes != ''
+ permissions:
+ contents: write
+ # The draft state, and removing `fork ci required` from a released pull
request once it
+ # is undrafted.
+ pull-requests: write
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ # The pull requests whose change failed last time, so a persisting
failure is posted to
+ # Slack once, as in the reconcile job.
+ - name: "Restore the previously failed changes"
+ id: restore-failed
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
# v6.1.0
+ with:
+ path: ${{ runner.temp }}/fork-ci-draft-state
+ key: fork-ci-monitor-draft-state-${{ github.run_id }}-${{
github.run_attempt }}
+ restore-keys: fork-ci-monitor-draft-state-
+ - name: "Change the draft state"
+ id: change
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ DRAFT_CHANGES: ${{ needs.reconcile.outputs.draft-changes }}
+ run: |
+ failed=()
+ while IFS=$'\t' read -r number node_id draft release; do
+ # This token can push to the repository; act only on well-formed
entries.
+ if [[ ! "${number}" =~ ^[0-9]+$ || ! "${node_id}" =~
^PR_[A-Za-z0-9_-]+$ \
+ || ! "${draft}" =~ ^(true|false)$ || ! "${release}" =~
^(true|false)$ ]]; then
+ echo "::error::Skipped a malformed draft change"
+ failed+=("malformed")
+ continue
+ fi
+ if [[ "${draft}" == "true" ]]; then
+ mutation="convertPullRequestToDraft"
+ else
+ mutation="markPullRequestReadyForReview"
+ fi
+ echo "#${number}: ${mutation}"
+ if ! gh api graphql -f id="${node_id}" -f query="mutation(\$id:
ID!) {
+ ${mutation}(input: {pullRequestId: \$id}) { clientMutationId }
}" > /dev/null; then
+ failed+=("${number}")
+ continue
+ fi
+ # The label brings the pull request back to the reconciler, which
retries the
+ # undraft while it is there.
+ label="repos/${REPO}/issues/${number}/labels/fork%20ci%20required"
+ if [[ "${release}" == "true" ]] && ! gh api -X DELETE "${label}" >
/dev/null; then
+ failed+=("${number}")
+ fi
+ done < <(jq -r '.[] | [.number, .node_id, .draft, .release] | @tsv'
<<< "${DRAFT_CHANGES}")
+ mkdir -p "${RUNNER_TEMP}/fork-ci-draft-state"
+ printf '%s\n' "${failed[@]}" | sort -n | sed '/^$/d' > failed.txt
+ previous="${RUNNER_TEMP}/fork-ci-draft-state/failed.txt"
+ if [[ -s failed.txt ]] && ! cmp -s failed.txt "${previous}"; then
+ echo "new-failures=true" >> "${GITHUB_OUTPUT}"
+ fi
+ echo "failed=$(paste -sd, failed.txt)" >> "${GITHUB_OUTPUT}"
+ - name: "Post failed changes to Slack"
+ id: post
+ if: steps.change.outputs.new-failures == 'true'
+ uses:
slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
+ with:
+ method: chat.postMessage
+ token: ${{ secrets.SLACK_BOT_TOKEN }}
+ errors: true
+ payload: |
+ channel: "internal-airflow-ci-cd"
+ text: >-
+ Fork CI monitor could not change the draft state of pull requests
+ ${{ steps.change.outputs.failed }}:
+ ${{ github.server_url }}/${{ github.repository
}}/actions/runs/${{ github.run_id }}
+ - name: "Record the failed changes"
+ if: steps.change.outcome == 'success' && steps.post.outcome !=
'failure'
+ run: cp failed.txt "${RUNNER_TEMP}/fork-ci-draft-state/failed.txt"
+ - name: "Save the failed changes"
+ if: always() && steps.restore-failed.outcome == 'success'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 #
v6.1.0
+ with:
+ path: ${{ runner.temp }}/fork-ci-draft-state
+ key: fork-ci-monitor-draft-state-${{ github.run_id }}-${{
github.run_attempt }}
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 effe96065e7..1b2bd742f90 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
@@ -727,6 +727,14 @@ over 3000 characters or more than 50 blocks, so items are
split across sections,
shortened to 500 characters, and sections past the 50th are replaced by a
pointer to the run
log, which always lists every item in full.
+Each item is posted once while it persists. The run records its items in a file
+(`--reported-file`) that the workflow carries to the next run through the
Actions cache, and the
+message holds only the items the previous run did not report. The workflow
keeps the new record
+only once Slack accepted the message, so a failed post is retried by the next
run. An item that
+goes away and comes back is posted again; a lost cache entry re-posts
everything once. The
+message for a run that failed before writing its message is posted for the
first of consecutive
+failed runs only. A dry run saves nothing, so it never suppresses the next
message.
+
Attention items are expected outcomes that a maintainer should still see: a
pull request
closed for gate tampering, and a pull request carrying both override labels.
They are listed
in their own section of the message so they are not mistaken for reconciler
failures.
@@ -737,7 +745,7 @@ Unknown means any of:
- A check suite for `Tests (AMD)`, in the fork or in `apache/airflow`, with a
status or
conclusion outside the modelled set.
- A workflow state outside `active` / `disabled_fork` / `disabled_manually` /
not found.
-- A mutation that returned an error (label, draft, undraft, comment, close,
status).
+- A mutation that returned an error (label, comment, close, status).
- A head repository that no longer exists.
- A state comment whose marker cannot be parsed.
@@ -761,18 +769,35 @@ budget stays observable.
`.github/workflows/fork-ci-monitor.yml`:
- `on: schedule: cron '*/15 * * * *'` and `workflow_dispatch` with a `dry-run`
input.
-- `permissions: actions: write, contents: read, pull-requests: write,
statuses: write` with a
- justification comment. Comments and labels on pull requests are covered by
- `pull-requests: write`; the commit status needs `statuses: write`; the gate
tampering check
- lists runs and their jobs, and the reconciler deletes gated runs, which
needs `actions: write`
- because every scope a `permissions` block leaves out is `none`.
+- Two jobs, each with its own `permissions` block and a justification comment;
the workflow
+ grants nothing by default.
+- The `reconcile` job: `actions: write, contents: read, pull-requests: write,
statuses: write`.
+ Comments and labels on pull requests are covered by `pull-requests: write`;
the commit status
+ needs `statuses: write`; the gate tampering check lists runs and their jobs,
and the
+ reconciler deletes gated runs, which needs `actions: write` because every
scope a
+ `permissions` block leaves out is `none`.
+- The `draft-state` job: `contents: write, pull-requests: write`. GitHub
authorises the draft
+ and undraft mutations (`convertPullRequestToDraft`,
`markPullRequestReadyForReview`) against
+ `contents: write` and refuses them with `contents: read` ("Resource not
accessible by
+ integration"). That scope also allows pushing to the repository, so the
reconciler, which
+ installs and runs Breeze and its dependencies, does not get it: it writes
the draft changes
+ as a job output (`--draft-changes-file`), and this job applies them with
`gh` and `jq` only,
+ without a checkout. On a release that undrafts, it also removes `fork ci
required` after
+ the undraft, so a failed undraft keeps the label and the next run retries
it. Pull requests
+ whose change failed are posted to Slack once while the same set keeps
failing. It validates
+ every entry before acting on it, runs even when only the reconciler's Slack
post failed, and
+ runs with an empty list too, so a failure set that cleared is recorded as
cleared. It is
+ skipped on a dry run. The draft state is advisory: the commit statuses
+ keep a pull request from merging, so a failed draft change does not weaken
the gate.
- `runs-on: ubuntu-latest` (the breeze setup action frees disk space with
`sudo` and `docker`,
which `ubuntu-slim` does not provide), `timeout-minutes: 20`, `concurrency:
fork-ci-monitor`
with `cancel-in-progress: false` so runs never overlap.
-- Steps: checkout `main` with `persist-credentials: false`,
`./.github/actions/breeze`,
- `breeze ci fork-ci-reconcile` with `GH_TOKEN`, then the Slack post step
guarded by
- `has-alerts` and skipped on a dry run, whose alerts describe changes it did
not make, and a
- second Slack step that reports a run failing before it could write its
message.
+- Steps: restoring the reported items from the Actions cache into
`$RUNNER_TEMP`, checkout
+ `main` with `persist-credentials: false`, `./.github/actions/breeze`,
`breeze ci fork-ci-reconcile` with `GH_TOKEN`,
+ then the Slack post step guarded by `has-alerts` and skipped on a dry run,
whose alerts
+ describe changes it did not make, a second Slack step that reports the first
of consecutive
+ runs failing before they could write their message, and saving what Slack
accepted to the
+ cache.
- Every `gh` call times out after 120 seconds, and a rate limit is retried
with backoff up to
four attempts, without waiting after the last one.
diff --git a/dev/breeze/doc/images/output_ci_fork-ci-reconcile.svg
b/dev/breeze/doc/images/output_ci_fork-ci-reconcile.svg
index 1d374d7a871..d57c68e0978 100644
--- a/dev/breeze/doc/images/output_ci_fork-ci-reconcile.svg
+++ b/dev/breeze/doc/images/output_ci_fork-ci-reconcile.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 489.2"
xmlns="http://www.w3.org/2000/svg">
+<svg class="rich-terminal" viewBox="0 0 1482 586.8"
xmlns="http://www.w3.org/2000/svg">
<!-- Generated with Rich https://www.textualize.io -->
<style>
@@ -43,7 +43,7 @@
<defs>
<clipPath id="breeze-ci-fork-ci-reconcile-clip-terminal">
- <rect x="0" y="0" width="1463.0" height="438.2" />
+ <rect x="0" y="0" width="1463.0" height="535.8" />
</clipPath>
<clipPath id="breeze-ci-fork-ci-reconcile-line-0">
<rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -96,9 +96,21 @@
<clipPath id="breeze-ci-fork-ci-reconcile-line-16">
<rect x="0" y="391.9" width="1464" height="24.65"/>
</clipPath>
+<clipPath id="breeze-ci-fork-ci-reconcile-line-17">
+ <rect x="0" y="416.3" width="1464" height="24.65"/>
+ </clipPath>
+<clipPath id="breeze-ci-fork-ci-reconcile-line-18">
+ <rect x="0" y="440.7" width="1464" height="24.65"/>
+ </clipPath>
+<clipPath id="breeze-ci-fork-ci-reconcile-line-19">
+ <rect x="0" y="465.1" width="1464" height="24.65"/>
+ </clipPath>
+<clipPath id="breeze-ci-fork-ci-reconcile-line-20">
+ <rect x="0" y="489.5" width="1464" height="24.65"/>
+ </clipPath>
</defs>
- <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1"
x="1" y="1" width="1480" height="487.2" rx="8"/><text
class="breeze-ci-fork-ci-reconcile-title" fill="#c5c8c6" text-anchor="middle"
x="740" y="27">Command: ci fork-ci-reconcile</text>
+ <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1"
x="1" y="1" width="1480" height="584.8" rx="8"/><text
class="breeze-ci-fork-ci-reconcile-title" fill="#c5c8c6" text-anchor="middle"
x="740" y="27">Command: ci fork-ci-reconcile</text>
<g transform="translate(26,22)">
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -114,18 +126,22 @@
</text><text class="breeze-ci-fork-ci-reconcile-r1" x="12.2" y="93.2"
textLength="1073.6"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-3)">Reconcile pull requests whose CI runs in contributor forks (AIP-120). Run on a schedule.</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="1464" y="93.2" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-3)">
</text><text class="breeze-ci-fork-ci-reconcile-r1" x="1464" y="117.6"
textLength="12.2" clip-path="url(#breeze-ci-fork-ci-reconcile-line-4)">
</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="142"
textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-5)">╭─</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="24.4" y="142" textLength="305"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-5)"> Fork CI reconcile flags </text><text
class="breeze-ci-fork-ci-reconcile-r5" x="329.4" y="142" textLength="1110.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-5)">───────────────────── [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="166.4"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-6)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="166.4" textLength="231.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-6)">--config           </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="329.4" y="166.4" textLength="1110.2"
clip-path="url(#breeze-ci-fork-ci-reconcile [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="190.8"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-7)">│</text><text
class="breeze-ci-fork-ci-reconcile-r6" x="329.4" y="190.8" textLength="73.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-7)">(FILE)</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="1451.8" y="190.8" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-7)">│</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="146 [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="215.2"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-8)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="215.2" textLength="231.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-8)">--output-file      </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="329.4" y="215.2" textLength="890.6"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-8)">Where the&# [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="239.6"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-9)">│</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="329.4" y="239.6" textLength="231.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-9)">slack-message.json]</text><text
class="breeze-ci-fork-ci-reconcile-r6" x="573.4" y="239.6" textLength="73.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-9)">(FILE)</text><text
class="breeze-ci-fork-ci-re [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="264"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-10)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="264" textLength="231.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-10)">--run-url          </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="329.4" y="264" textLength="683.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-10)" [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="288.4"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-11)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="288.4" textLength="231.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-11)">--github-repository</text><text
class="breeze-ci-fork-ci-reconcile-r7" x="280.6" y="288.4" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-11)">-g</text><text
class="breeze-ci-fork-ci-reco [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="312.8"
textLength="1464"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-12)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="1464" y="312.8" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-12)">
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="337.2"
textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-13)">╭─</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="24.4" y="337.2" textLength="195.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-13)"> Common options </text><text
class="breeze-ci-fork-ci-reconcile-r5" x="219.6" y="337.2" textLength="1220"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-13)">───────────────────────────────
[...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="361.6"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-14)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="361.6" textLength="109.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-14)">--verbose</text><text
class="breeze-ci-fork-ci-reconcile-r7" x="158.6" y="361.6" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-14)">-v</text><text
class="breeze-ci-fork-ci-reconcile-r1" [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="386"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-15)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="386" textLength="109.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-15)">--dry-run</text><text
class="breeze-ci-fork-ci-reconcile-r7" x="158.6" y="386" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-15)">-D</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="207 [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="410.4"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-16)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="410.4" textLength="109.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-16)">--help   </text><text
class="breeze-ci-fork-ci-reconcile-r7" x="158.6" y="410.4" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-16)">-h</text><text
class="breeze-ci-fork-ci [...]
-</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="434.8"
textLength="1464"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-17)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="1464" y="434.8" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-17)">
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="166.4"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-6)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="166.4" textLength="244"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-6)">--config            </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="166.4" textLength="1098"
clip-path="url(#breeze-ci-fork-ci-reconci [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="190.8"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-7)">│</text><text
class="breeze-ci-fork-ci-reconcile-r6" x="341.6" y="190.8" textLength="73.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-7)">(FILE)</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="1451.8" y="190.8" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-7)">│</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="146 [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="215.2"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-8)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="215.2" textLength="244"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-8)">--output-file       </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="215.2" textLength="890.6"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-8)">Where t [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="239.6"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-9)">│</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="341.6" y="239.6" textLength="231.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-9)">slack-message.json]</text><text
class="breeze-ci-fork-ci-reconcile-r6" x="585.6" y="239.6" textLength="73.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-9)">(FILE)</text><text
class="breeze-ci-fork-ci-re [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="264"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-10)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="264" textLength="244"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-10)">--reported-file     </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="264" textLength="1098"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-10)">The items the
[...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="288.4"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-11)">│</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="288.4" textLength="744.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-11)">run's items. Without it every item is reported on every run. </text><text
class="breeze-ci-fork-ci-reconcile-r6" x="1085.8" y="288.4" textLength=" [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="312.8"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-12)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="312.8" textLength="244"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-12)">--draft-changes-file</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="312.8" textLength="1098"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-12)">Where the draft state cha
[...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="337.2"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-13)">│</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="337.2" textLength="890.6"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-13)">permission the reconciler does not get. Without it they are only logged. </text><text
class="breeze-ci-fork-ci-reconcile-r6" x="1232.2" y="337.2" [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="361.6"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-14)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="361.6" textLength="244"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-14)">--run-url           </text><text
class="breeze-ci-fork-ci-reconcile-r1" x="341.6" y="361.6" textLength="683.2"
clip-path="url(#breeze-ci-fork-ci-reconcile [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="386"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-15)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="386" textLength="244"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-15)">--github-repository </text><text
class="breeze-ci-fork-ci-reconcile-r7" x="292.8" y="386" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-15)">-g</text><text
class="breeze-ci-fork-ci-reconc [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="410.4"
textLength="1464"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-16)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="1464" y="410.4" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-16)">
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="434.8"
textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-17)">╭─</text><text
class="breeze-ci-fork-ci-reconcile-r5" x="24.4" y="434.8" textLength="195.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-17)"> Common options </text><text
class="breeze-ci-fork-ci-reconcile-r5" x="219.6" y="434.8" textLength="1220"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-17)">───────────────────────────────
[...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="459.2"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-18)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="459.2" textLength="109.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-18)">--verbose</text><text
class="breeze-ci-fork-ci-reconcile-r7" x="158.6" y="459.2" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-18)">-v</text><text
class="breeze-ci-fork-ci-reconcile-r1" [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="483.6"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-19)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="483.6" textLength="109.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-19)">--dry-run</text><text
class="breeze-ci-fork-ci-reconcile-r7" x="158.6" y="483.6" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-19)">-D</text><text
class="breeze-ci-fork-ci-reconcile-r1" [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="508"
textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-20)">│</text><text
class="breeze-ci-fork-ci-reconcile-r4" x="24.4" y="508" textLength="109.8"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-20)">--help   </text><text
class="breeze-ci-fork-ci-reconcile-r7" x="158.6" y="508" textLength="24.4"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-20)">-h</text><text
class="breeze-ci-fork-ci-recon [...]
+</text><text class="breeze-ci-fork-ci-reconcile-r5" x="0" y="532.4"
textLength="1464"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-21)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
class="breeze-ci-fork-ci-reconcile-r1" x="1464" y="532.4" textLength="12.2"
clip-path="url(#breeze-ci-fork-ci-reconcile-line-21)">
</text>
</g>
</g>
diff --git a/dev/breeze/doc/images/output_ci_fork-ci-reconcile.txt
b/dev/breeze/doc/images/output_ci_fork-ci-reconcile.txt
index d57a8fbe16b..a439cd53386 100644
--- a/dev/breeze/doc/images/output_ci_fork-ci-reconcile.txt
+++ b/dev/breeze/doc/images/output_ci_fork-ci-reconcile.txt
@@ -1 +1 @@
-5ce2992bb7c33fd4d23d854f2bfa1cef
+78f39cbb63bd960f9da120ee39eeffd9
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index 0dbc5027942..1f173e5b588 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -1694,6 +1694,20 @@ def set_milestone(
envvar="OUTPUT_FILE",
help="Where the Slack message is written when there are items for
maintainers.",
)
[email protected](
+ "--reported-file",
+ type=click.Path(dir_okay=False, path_type=Path),
+ envvar="REPORTED_FILE",
+ help="The items the previous run reported, left out of the Slack message,
and replaced by "
+ "this run's items. Without it every item is reported on every run.",
+)
[email protected](
+ "--draft-changes-file",
+ type=click.Path(dir_okay=False, path_type=Path),
+ envvar="DRAFT_CHANGES_FILE",
+ help="Where the draft state changes are written, as JSON, for the job that
applies them with "
+ "the permission the reconciler does not get. Without it they are only
logged.",
+)
@click.option(
"--run-url",
default="",
@@ -1703,7 +1717,14 @@ def set_milestone(
@option_github_repository
@option_verbose
@option_dry_run
-def fork_ci_reconcile(config_path: Path | None, output_file: Path, run_url:
str, github_repository: str):
+def fork_ci_reconcile(
+ config_path: Path | None,
+ output_file: Path,
+ reported_file: Path | None,
+ draft_changes_file: Path | None,
+ run_url: str,
+ github_repository: str,
+):
from datetime import UTC, datetime
from airflow_breeze.utils.fork_ci_gate import load_config
@@ -1719,6 +1740,8 @@ def fork_ci_reconcile(config_path: Path | None,
output_file: Path, run_url: str,
run_url=run_url,
output_file=output_file,
workflows_dir=AIRFLOW_ROOT_PATH / ".github" / "workflows",
+ reported_file=reported_file,
+ draft_changes_file=draft_changes_file,
)
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
index cc99bcf1c14..99042fc78a3 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
@@ -125,6 +125,8 @@ CI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]]
= {
"options": [
"--config",
"--output-file",
+ "--reported-file",
+ "--draft-changes-file",
"--run-url",
"--github-repository",
],
diff --git a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
index 73501627e77..73a44503acb 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_github.py
@@ -144,28 +144,6 @@ class GitHubClient:
None,
)
- def convert_to_draft(self, pr: int, node_id: str) -> None:
- self._mutate(
- f"#{pr}: convert to draft",
- ["api", "graphql"],
- {
- "query": "mutation($id: ID!) {
convertPullRequestToDraft(input: {pullRequestId: $id}) "
- "{ clientMutationId } }",
- "variables": {"id": node_id},
- },
- )
-
- def mark_ready(self, pr: int, node_id: str) -> None:
- self._mutate(
- f"#{pr}: mark ready for review",
- ["api", "graphql"],
- {
- "query": "mutation($id: ID!) {
markPullRequestReadyForReview(input: {pullRequestId: $id}) "
- "{ clientMutationId } }",
- "variables": {"id": node_id},
- },
- )
-
def create_comment(self, pr: int, body: str) -> None:
self._mutate(
f"#{pr}: create comment",
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 61b44ff7899..41ffaab6f26 100644
--- a/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
+++ b/dev/breeze/src/airflow_breeze/utils/fork_ci_reconcile.py
@@ -28,7 +28,7 @@ import base64
import functools
import json
from collections.abc import Callable, Iterable, Sequence
-from dataclasses import dataclass, field, replace
+from dataclasses import asdict, dataclass, field, replace
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
@@ -1179,9 +1179,29 @@ def count_commits_behind(client: GitHubClient, repo:
str, pr: PullRequestSnapsho
return compare.get("behind_by")
-def apply_plan(client: GitHubClient, pr: PullRequestSnapshot, plan: Plan) ->
None:
- if plan.draft is True:
- client.convert_to_draft(pr.number, pr.node_id)
+@dataclass(frozen=True)
+class DraftChange:
+ """A draft state change, applied by the monitor's draft-state job rather
than the reconciler."""
+
+ number: int
+ node_id: str
+ draft: bool
+ # Remove `fork ci required` once the pull request is undrafted.
+ release: bool = False
+
+
+def apply_plan(client: GitHubClient, pr: PullRequestSnapshot, plan: Plan) ->
DraftChange | None:
+ """
+ Apply the plan, except for the draft state change, which is returned.
+
+ GitHub authorises draft changes against `contents: write`, which the
reconciler, running
+ Breeze and its dependencies, does not get; a separate job with no other
code applies them.
+ """
+ draft_change = None
+ if plan.draft is not None:
+ verb = "convert to draft" if plan.draft else "mark ready for review"
+ console_print(f"[info]#{pr.number}: {verb} (left to the draft-state
job)[/]")
+ draft_change = DraftChange(pr.number, pr.node_id, plan.draft)
if plan.add_labels:
client.add_labels(pr.number, plan.add_labels)
for label in plan.remove_labels:
@@ -1198,14 +1218,17 @@ def apply_plan(client: GitHubClient, pr:
PullRequestSnapshot, plan: Plan) -> Non
client.set_status(pr.number, pr.head_sha, *plan.ci_passed,
context=CI_PASSED_CONTEXT)
for comment in plan.new_comments:
client.create_comment(pr.number, comment)
- if plan.draft is False:
- client.mark_ready(pr.number, pr.node_id)
if plan.close:
client.close(pr.number)
# Last, because the label is what brings the pull request back to the next
run: when an
# earlier mutation fails, the next run retries the release instead of
losing track of it.
+ # A release that undrafts leaves it to the draft-state job, for the same
reason.
if LABEL_FORK_CI_REQUIRED in plan.remove_labels:
- client.remove_label(pr.number, LABEL_FORK_CI_REQUIRED)
+ if draft_change is not None and not draft_change.draft:
+ draft_change = replace(draft_change, release=True)
+ else:
+ client.remove_label(pr.number, LABEL_FORK_CI_REQUIRED)
+ return draft_change
def _escape_slack(text: str) -> str:
@@ -1255,6 +1278,41 @@ def build_slack_payload(
}
+def drop_reported(
+ reported_file: Path,
+ *,
+ unknown: list[tuple[int, str]],
+ attention: list[tuple[int, str]],
+ notes: list[str],
+ record: bool = True,
+) -> 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.
+ """
+ current: dict[str, list[Any]] = {
+ "unknown": [list(item) for item in unknown],
+ "attention": [list(item) for item in attention],
+ "notes": notes,
+ }
+ try:
+ previous = json.loads(reported_file.read_text())
+ except (OSError, ValueError):
+ previous = {}
+ if not isinstance(previous, dict):
+ previous = {}
+ if record:
+ reported_file.write_text(json.dumps(current, indent=2))
+
+ def drop(key: str, items: list[Any]) -> list[Any]:
+ seen = previous.get(key) or []
+ return [item for item, stored in zip(items, current[key]) if stored
not in seen]
+
+ return drop("unknown", unknown), drop("attention", attention),
drop("notes", notes)
+
+
def run_reconcile(
client: GitHubClient,
config: GateConfig,
@@ -1263,6 +1321,8 @@ def run_reconcile(
run_url: str,
output_file: Path,
workflows_dir: Path,
+ reported_file: Path | None = None,
+ draft_changes_file: Path | None = None,
channel: str = "internal-airflow-ci-cd",
default_branch: str = "main",
) -> bool:
@@ -1272,10 +1332,16 @@ def run_reconcile(
:param config: the gate configuration of the default branch's checkout.
:param workflows_dir: ``.github/workflows`` of a checkout of the default
branch, whose
triggers decide which recorded runs were legitimate.
+ :param reported_file: the items of the previous run, replaced by this
run's. Items in it are
+ left out of the Slack message, so an item that persists is posted
once. A dry run reads
+ it but leaves it unchanged, because it posts nothing.
+ :param draft_changes_file: where the draft state changes for the
draft-state job are written,
+ as a JSON list; always written, empty when there are none.
"""
repo = client.repo
unknown: list[tuple[int, str]] = []
attention: list[tuple[int, str]] = []
+ 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)
@@ -1320,7 +1386,9 @@ def run_reconcile(
),
count_behind=functools.partial(count_commits_behind, client,
repo, pr),
)
- apply_plan(client, pr, plan)
+ draft_change = apply_plan(client, pr, plan)
+ 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.
@@ -1338,6 +1406,12 @@ def run_reconcile(
for number, reason in [*unknown, *attention]:
console_print(f"[warning]#{number}: {reason}[/]")
console_print(f"[info]Reconciled {len(prs)} pull requests; GitHub calls:
{dict(client.calls)}[/]")
+ if draft_changes_file is not None:
+ 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
+ )
has_alerts = bool(unknown or attention or notes)
if has_alerts:
payload = build_slack_payload(
diff --git a/dev/breeze/tests/test_fork_ci_reconcile.py
b/dev/breeze/tests/test_fork_ci_reconcile.py
index aaa5e40bbc6..ec8828ac9b7 100644
--- a/dev/breeze/tests/test_fork_ci_reconcile.py
+++ b/dev/breeze/tests/test_fork_ci_reconcile.py
@@ -38,6 +38,7 @@ from airflow_breeze.utils.fork_ci_messages import (
from airflow_breeze.utils.fork_ci_reconcile import (
BaseGateConfigs,
BypassFinder,
+ DraftChange,
ForkObservation,
ForkState,
Plan,
@@ -48,6 +49,7 @@ from airflow_breeze.utils.fork_ci_reconcile import (
classify,
count_commits_behind,
discover,
+ drop_reported,
fetch_fork_observations,
fetch_workflow_state,
find_state_comment,
@@ -1440,7 +1442,7 @@ def
test_run_reconcile_isolates_failures_and_reports_them(client, tmp_path):
client.graphql.side_effect = [
_search_result(),
_search_result(),
- _search_result(_pr_node(1), _pr_node(2)),
+ _search_result(_pr_node(1), _pr_node(2, isDraft=False)),
{"pr0": {"object": None}, "pr1": {"object": None}},
]
workflow_state_calls = []
@@ -1456,11 +1458,22 @@ def
test_run_reconcile_isolates_failures_and_reports_them(client, tmp_path):
client.rest_get.side_effect = rest_get
+ draft_changes_file = tmp_path / "draft-changes.json"
+
has_alerts = run_reconcile(
- client, CONFIG, now=NOW, run_url="https://run", output_file=output,
workflows_dir=tmp_path
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=output,
+ workflows_dir=tmp_path,
+ draft_changes_file=draft_changes_file,
)
assert has_alerts is True
+ assert json.loads(draft_changes_file.read_text()) == [
+ {"number": 2, "node_id": "PR_2", "draft": True, "release": False}
+ ]
payload = json.loads(output.read_text())
assert payload["channel"] == "internal-airflow-ci-cd"
text = "\n".join(block["text"]["text"] for block in payload["blocks"])
@@ -1521,13 +1534,26 @@ def test_command_writes_has_alerts_output(mock_run,
tmp_path, monkeypatch):
github_output = tmp_path / "github_output"
monkeypatch.setenv("GITHUB_OUTPUT", str(github_output))
- result = CliRunner().invoke(fork_ci_reconcile, ["--dry-run",
"--output-file", str(tmp_path / "s.json")])
+ result = CliRunner().invoke(
+ fork_ci_reconcile,
+ [
+ "--dry-run",
+ "--output-file",
+ str(tmp_path / "s.json"),
+ "--reported-file",
+ str(tmp_path / "reported.json"),
+ "--draft-changes-file",
+ str(tmp_path / "draft-changes.json"),
+ ],
+ )
assert result.exit_code == 0, result.output
assert github_output.read_text() == "has-alerts=true\n"
github_client = mock_run.call_args.args[0]
assert github_client.dry_run is True
assert github_client.repo == "apache/airflow"
+ assert mock_run.call_args.kwargs["reported_file"] == tmp_path /
"reported.json"
+ assert mock_run.call_args.kwargs["draft_changes_file"] == tmp_path /
"draft-changes.json"
@pytest.mark.parametrize(
@@ -1557,21 +1583,105 @@ def
test_run_reconcile_without_gated_pull_requests(client, tmp_path, total_count
"fails", [pytest.param(False, id="all-succeed"), pytest.param(True,
id="status-fails")]
)
def test_fork_ci_required_is_removed_after_everything_else(client, fails):
- plan, _, _ = _classify(_pr(labels=("fork ci required", "use project ci")),
GREEN)
+ pr = _pr(is_draft=False, labels=("fork ci required", "use project ci"))
+ plan, _, _ = _classify(pr, GREEN)
calls = mock.Mock()
- client.mark_ready.side_effect = lambda *args: calls("mark_ready")
client.remove_label.side_effect = lambda pr, label: calls("remove_label",
label)
- client.set_status.side_effect = GitHubError("gh api failed: HTTP 500") if
fails else None
+ client.set_status.side_effect = (
+ GitHubError("gh api failed: HTTP 500") if fails else lambda *args,
**kwargs: calls("set_status")
+ )
if fails:
with pytest.raises(GitHubError):
- apply_plan(client, _pr(), plan)
+ apply_plan(client, pr, plan)
client.remove_label.assert_not_called()
else:
- apply_plan(client, _pr(), plan)
+ apply_plan(client, pr, plan)
assert calls.call_args_list[-1] == mock.call("remove_label", "fork ci
required")
[email protected](
+ ("plan", "expected", "removes_fork_ci_required"),
+ [
+ pytest.param(
+ Plan(draft=True, remove_labels=["fork ci required"]),
+ DraftChange(42, "PR_node", draft=True),
+ True,
+ id="draft",
+ ),
+ pytest.param(Plan(draft=False), DraftChange(42, "PR_node",
draft=False), False, id="undraft"),
+ pytest.param(
+ Plan(draft=False, remove_labels=["fork ci required"]),
+ DraftChange(42, "PR_node", draft=False, release=True),
+ False,
+ id="undraft-on-release",
+ ),
+ pytest.param(Plan(), None, False, id="unchanged"),
+ ],
+)
+def test_apply_plan_leaves_draft_changes_to_the_draft_state_job(
+ client, plan, expected, removes_fork_ci_required
+):
+ assert apply_plan(client, _pr(), plan) == expected
+ assert (mock.call(42, "fork ci required") in
client.remove_label.call_args_list) is (
+ removes_fork_ci_required
+ )
+
+
+def test_drop_reported_posts_each_item_once_while_it_persists(tmp_path):
+ reported_file = tmp_path / "reported.json"
+
+ def report(unknown=(), attention=(), notes=()):
+ return drop_reported(
+ reported_file, unknown=list(unknown), attention=list(attention),
notes=list(notes)
+ )
+
+ assert report(unknown=[(1, "a")], notes=["n"]) == ([(1, "a")], [], ["n"])
+ assert report(unknown=[(1, "a")], attention=[(2, "b")], notes=["n"]) ==
([], [(2, "b")], [])
+ assert report(attention=[(2, "b")]) == ([], [], [])
+ assert report(unknown=[(1, "a")], attention=[(2, "b")]) == ([(1, "a")],
[], [])
+
+
[email protected]("content", [None, "not json", "[]"])
+def
test_drop_reported_reports_everything_without_a_readable_previous_run(tmp_path,
content):
+ reported_file = tmp_path / "reported.json"
+ if content is not None:
+ 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": []}
+
+
[email protected](
+ ("first_dry_run", "second_has_alerts"),
+ [
+ pytest.param(False, False, id="posted"),
+ pytest.param(True, True, id="dry-run-posts-nothing"),
+ ],
+)
+def test_run_reconcile_posts_a_persisting_item_once(client, tmp_path,
first_dry_run, second_has_alerts):
+ output = tmp_path / "slack-message.json"
+ client.rest_get_pages.return_value = [{"total_count": 1500,
"workflow_runs": []}]
+
+ def reconcile(dry_run=False):
+ client.dry_run = dry_run
+ client.graphql.side_effect = [_search_result(), _search_result(),
_search_result()]
+ return run_reconcile(
+ client,
+ CONFIG,
+ now=NOW,
+ run_url="https://run",
+ output_file=output,
+ workflows_dir=tmp_path,
+ reported_file=tmp_path / "reported.json",
+ )
+
+ assert reconcile(dry_run=first_dry_run) is True
+ output.unlink()
+ assert reconcile() is second_has_alerts
+ assert output.exists() is second_has_alerts
+
+
def test_slack_payload_stays_within_slack_limits():
attention = [(n, "x" * 2000) for n in range(500)]