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:&#160;ci&#160;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:&#160;ci&#160;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&#160;pull&#160;requests&#160;whose&#160;CI&#160;runs&#160;in&#160;contributor&#160;forks&#160;(AIP-120).&#160;Run&#160;on&#160;a&#160;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)">&#160;Fork&#160;CI&#160;reconcile&#160;flags&#160;</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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</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&#160;&#160;&#160;&#160;&#160;&#160;</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&#160;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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</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)">&#160;Common&#160;options&#160;</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&#160;&#160;&#160;</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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</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&#160;&#160;&#160;&#160;&#160;&#160;&#160;</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&#160;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&#160;&#160;&#160;&#160;&#160;</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&#160;items&#160;the&#1
 [...]
+</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&#x27;s&#160;items.&#160;Without&#160;it&#160;every&#160;item&#160;is&#160;reported&#160;on&#160;every&#160;run.&#160;</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&#160;the&#160;draft&#160;state&#160;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&#160;the&#160;reconciler&#160;does&#160;not&#160;get.&#160;Without&#160;it&#160;they&#160;are&#160;only&#160;logged.&#160;</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&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</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&#160;</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)">&#160;Common&#160;options&#160;</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&#160;&#160;&#160;</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)]
 

Reply via email to