This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch v3-1-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-1-test by this push:
     new 8743c572e00 [v3-1-test] Deduplicate Slack CI notifications with 
artifact-based state tracking (#63676) (#63686)
8743c572e00 is described below

commit 8743c572e00ccb12b69155a760146d91cb38d2a3
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon Mar 16 00:46:34 2026 +0100

    [v3-1-test] Deduplicate Slack CI notifications with artifact-based state 
tracking (#63676) (#63686)
    
    Slack notifications for CI failures and missing doc inventories were
    posted on every failing run regardless of whether the failure was
    already reported. This adds per-branch state tracking via GitHub
    Actions artifacts so notifications are only sent when the set of
    failures changes or 24 hours pass (as a "still not fixed" reminder).
    Recovery notifications are posted when a previously-failing run passes.
    (cherry picked from commit 60e4393bfc80711a57f33ecbc28c3ae6810d5092)
---
 .github/workflows/ci-amd-arm.yml                   |  87 ++++++++-
 .github/workflows/ci-image-checks.yml              |  85 ++++++++-
 .github/workflows/ci-notification.yml              |  69 ++++++-
 .../src/airflow_breeze/utils/workflow_status.py    |  39 +++-
 scripts/ci/slack_notification_state.py             | 210 +++++++++++++++++++++
 5 files changed, 470 insertions(+), 20 deletions(-)

diff --git a/.github/workflows/ci-amd-arm.yml b/.github/workflows/ci-amd-arm.yml
index 0faa6f16b10..02672f043eb 100644
--- a/.github/workflows/ci-amd-arm.yml
+++ b/.github/workflows/ci-amd-arm.yml
@@ -923,16 +923,89 @@ jobs:
       use-uv: ${{ needs.build-info.outputs.use-uv }}
       debug-resources: ${{ needs.build-info.outputs.debug-resources }}
 
-  notify-slack-failure:
-    name: "Notify Slack on Failure"
+  notify-slack:
+    name: "Notify Slack"
     needs:
       - build-info
       - finalize-tests
-    if: github.event_name == 'schedule' && failure() && github.run_attempt == 1
+    if: >-
+      always() &&
+      !cancelled() &&
+      github.event_name == 'schedule' &&
+      github.run_attempt == 1
     runs-on: ["ubuntu-22.04"]
     steps:
-      - name: Notify Slack
-        id: slack
+      - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd  # 
v6.0.2
+        with:
+          persist-credentials: false
+      - name: "Get failing jobs"
+        id: get-failures
+        shell: bash
+        run: |
+          FAILED_JOBS=$(gh run view "${{ github.run_id }}" \
+            --repo "${{ github.repository }}" \
+            --json jobs \
+            --jq '[.jobs[] | select(.conclusion == "failure") | .name] | sort 
| .[]')
+          echo "failed-jobs<<EOF" >> "${GITHUB_OUTPUT}"
+          echo "${FAILED_JOBS}" >> "${GITHUB_OUTPUT}"
+          echo "EOF" >> "${GITHUB_OUTPUT}"
+          if [[ -n "${FAILED_JOBS}" ]]; then
+            echo "has-failures=true" >> "${GITHUB_OUTPUT}"
+          else
+            echo "has-failures=false" >> "${GITHUB_OUTPUT}"
+          fi
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: "Determine notification action"
+        id: notification
+        shell: bash
+        run: python3 scripts/ci/slack_notification_state.py
+        env:
+          ARTIFACT_NAME: "slack-state-tests-${{ github.ref_name }}"
+          CURRENT_FAILURES: "${{ steps.get-failures.outputs.failed-jobs }}"
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: "Upload notification state"
+        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f 
 # v7.0.0
+        with:
+          name: "slack-state-tests-${{ github.ref_name }}"
+          path: ./slack-state/
+          retention-days: 7
+          overwrite: true
+      - name: "Notify Slack (new/changed failures)"
+        if: steps.notification.outputs.action == 'notify_new'
+        uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
+        with:
+          method: chat.postMessage
+          token: ${{ env.SLACK_BOT_TOKEN }}
+          # yamllint disable rule:line-length
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: "🚨 Failure Alert: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name 
}}*\n\nFailing jobs:\n${{ steps.get-failures.outputs.failed-jobs 
}}\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{ 
github.run_id }}|View the failure log>"
+            blocks:
+              - type: "section"
+                text:
+                  type: "mrkdwn"
+                  text: "🚨 Failure Alert: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on *${{ github.ref_name }}*\n\nFailing 
jobs:\n${{ steps.get-failures.outputs.failed-jobs }}\n\n*Details:* 
<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id 
}}|View the failure log>"
+          # yamllint enable rule:line-length
+      - name: "Notify Slack (still not fixed)"
+        if: steps.notification.outputs.action == 'notify_reminder'
+        uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
+        with:
+          method: chat.postMessage
+          token: ${{ env.SLACK_BOT_TOKEN }}
+          # yamllint disable rule:line-length
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: "🚨🔁 Still not fixed: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name 
}}*\n\nFailing jobs:\n${{ steps.get-failures.outputs.failed-jobs 
}}\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{ 
github.run_id }}|View the failure log>"
+            blocks:
+              - type: "section"
+                text:
+                  type: "mrkdwn"
+                  text: "🚨🔁 Still not fixed: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on *${{ github.ref_name }}*\n\nFailing 
jobs:\n${{ steps.get-failures.outputs.failed-jobs }}\n\n*Details:* 
<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id 
}}|View the failure log>"
+          # yamllint enable rule:line-length
+      - name: "Notify Slack (all tests passing)"
+        if: steps.notification.outputs.action == 'notify_recovery'
         uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
         with:
           method: chat.postMessage
@@ -940,12 +1013,12 @@ jobs:
           # yamllint disable rule:line-length
           payload: |
             channel: "internal-airflow-ci-cd"
-            text: "🚨🕒 Failure Alert: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name }}* 
🕒🚨\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{ 
github.run_id }}|View the failure log>"
+            text: "✅ All tests passing: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on branch *${{ github.ref_name 
}}*\n\n*Details:* <https://github.com/${{ github.repository }}/actions/runs/${{ 
github.run_id }}|View the run log>"
             blocks:
               - type: "section"
                 text:
                   type: "mrkdwn"
-                  text: "🚨🕒 Failure Alert: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) 🕒🚨\n\n*Details:* <https://github.com/${{ 
github.repository }}/actions/runs/${{ github.run_id }}|View the failure log>"
+                  text: "✅ All tests passing: Scheduled CI (${{ 
needs.build-info.outputs.platform }}) on *${{ github.ref_name }}*\n\n*Details:* 
<https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id 
}}|View the run log>"
           # yamllint enable rule:line-length
 
   summarize-warnings:
diff --git a/.github/workflows/ci-image-checks.yml 
b/.github/workflows/ci-image-checks.yml
index 41b6e0cba6c..e6764de87bd 100644
--- a/.github/workflows/ci-image-checks.yml
+++ b/.github/workflows/ci-image-checks.yml
@@ -208,7 +208,7 @@ jobs:
           platform: ${{ inputs.platform }}
           save-cache: false
       - name: "MyPy checks for ${{ matrix.mypy-check }}"
-        run: prek --color always --verbose --hook-stage manual "$MYPY_CHECK" 
--all-files
+        run: prek --color always --verbose --stage manual "$MYPY_CHECK" 
--all-files
         env:
           VERBOSE: "false"
           COLUMNS: "202"
@@ -254,7 +254,7 @@ jobs:
         uses: 
apache/infrastructure-actions/stash/restore@1c35b5ccf8fba5d4c3fdf25a045ca91aa0cbc468
         with:
           path: ./generated/_inventory_cache/
-          key: cache-docs-inventory-v1
+          key: cache-docs-inventory
         id: restore-docs-inventory-cache
       - name: "Building docs with ${{ matrix.flag }} flag"
         env:
@@ -277,11 +277,82 @@ jobs:
           else
             echo "missing=false" >> "${GITHUB_OUTPUT}"
           fi
-      - name: "Notify Slack about missing inventories (canary only)"
+      - name: "Get docs build job URL"
+        id: get-job-url
         if: >-
+          always() &&
           inputs.canary-run == 'true' &&
-          steps.check-missing-inventories.outputs.missing == 'true' &&
           matrix.flag == '--docs-only'
+        shell: bash
+        run: |
+          JOB_URL=$(gh api "repos/${{ github.repository }}/actions/runs/${{ 
github.run_id }}/jobs" \
+            --jq '[.jobs[] | select(.name | test("Build 
documentation.*docs-only"))][0].html_url // empty')
+          if [[ -z "${JOB_URL}" ]]; then
+            JOB_URL="https://github.com/${{ github.repository 
}}/actions/runs/${{ github.run_id }}"
+          fi
+          echo "url=${JOB_URL}" >> "${GITHUB_OUTPUT}"
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: "Determine inventory notification action"
+        id: inventory-notification
+        if: >-
+          always() &&
+          inputs.canary-run == 'true' &&
+          matrix.flag == '--docs-only'
+        shell: bash
+        run: python3 scripts/ci/slack_notification_state.py
+        env:
+          ARTIFACT_NAME: "slack-state-inventory-${{ inputs.branch }}"
+          CURRENT_FAILURES: "${{ 
steps.check-missing-inventories.outputs.packages }}"
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: "Upload inventory notification state"
+        if: >-
+          always() &&
+          inputs.canary-run == 'true' &&
+          matrix.flag == '--docs-only'
+        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f 
 # v7.0.0
+        with:
+          name: "slack-state-inventory-${{ inputs.branch }}"
+          path: ./slack-state/
+          retention-days: 7
+          overwrite: true
+      - name: "Notify Slack about missing inventories (new/changed)"
+        if: >-
+          inputs.canary-run == 'true' &&
+          matrix.flag == '--docs-only' &&
+          steps.inventory-notification.outputs.action == 'notify_new'
+        uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
+        with:
+          method: chat.postMessage
+          token: ${{ env.SLACK_BOT_TOKEN }}
+          # yamllint disable rule:line-length
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: "⚠️ Missing 3rd-party doc inventories in canary build on 
*${{ github.ref_name }}*: ${{ steps.check-missing-inventories.outputs.packages 
}}\n\n<${{ steps.get-job-url.outputs.url }}|View job log>"
+          # yamllint enable rule:line-length
+        env:
+          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+      - name: "Notify Slack about missing inventories (still not fixed)"
+        if: >-
+          inputs.canary-run == 'true' &&
+          matrix.flag == '--docs-only' &&
+          steps.inventory-notification.outputs.action == 'notify_reminder'
+        uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
+        with:
+          method: chat.postMessage
+          token: ${{ env.SLACK_BOT_TOKEN }}
+          # yamllint disable rule:line-length
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: "⚠️🔁 Still not fixed: Missing 3rd-party doc inventories in 
canary build on *${{ github.ref_name }}*: ${{ 
steps.check-missing-inventories.outputs.packages }}\n\n<${{ 
steps.get-job-url.outputs.url }}|View job log>"
+          # yamllint enable rule:line-length
+        env:
+          SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+      - name: "Notify Slack about inventory recovery"
+        if: >-
+          inputs.canary-run == 'true' &&
+          matrix.flag == '--docs-only' &&
+          steps.inventory-notification.outputs.action == 'notify_recovery'
         uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
         with:
           method: chat.postMessage
@@ -289,7 +360,7 @@ jobs:
           # yamllint disable rule:line-length
           payload: |
             channel: "internal-airflow-ci-cd"
-            text: "⚠️ Missing 3rd-party doc inventories in canary build on 
*${{ github.ref_name }}*\n\nPackages:\n${{ 
steps.check-missing-inventories.outputs.packages }}\n\n<https://github.com/${{ 
github.repository }}/actions/runs/${{ github.run_id }}/job/${{ job.check_run_id 
}}|View job log>"
+            text: "✅ All 3rd-party doc inventories are now available in canary 
build on *${{ github.ref_name }}*\n\n<${{ steps.get-job-url.outputs.url }}|View 
job log>"
           # yamllint enable rule:line-length
         env:
           SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
@@ -297,12 +368,12 @@ jobs:
         uses: 
apache/infrastructure-actions/stash/save@1c35b5ccf8fba5d4c3fdf25a045ca91aa0cbc468
         with:
           path: ./generated/_inventory_cache/
-          key: cache-docs-inventory-v1
+          key: cache-docs-inventory-v1-${{ hashFiles('**/pyproject.toml') }}
           if-no-files-found: 'error'
           retention-days: '2'
         # If we upload from multiple matrix jobs we could end up with a race 
condition. so just pick one job
         # to be responsible for updating it. 
https://github.com/actions/upload-artifact/issues/506
-        if: steps.restore-docs-inventory-cache.outputs.stash-hit != 'true' && 
matrix.flag == '--docs-only'
+        if: steps.restore-docs-inventory-cache != 'true' && matrix.flag == 
'--docs-only'
       - name: "Upload build docs"
         uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f 
 # v7.0.0
         with:
diff --git a/.github/workflows/ci-notification.yml 
b/.github/workflows/ci-notification.yml
index e009e380e92..2cad9aec1e9 100644
--- a/.github/workflows/ci-notification.yml
+++ b/.github/workflows/ci-notification.yml
@@ -55,9 +55,67 @@ jobs:
           workflow_branch: ${{ matrix.branch }}
           workflow_id: ${{ matrix.workflow-id }}
 
-      - name: "Send Slack notification"
-        if: steps.find-workflow-run-status.outputs.conclusion == 'failure'
-        id: slack
+      - name: "Determine notification action"
+        id: notification
+        shell: bash
+        run: python3 scripts/ci/slack_notification_state.py
+        env:
+          ARTIFACT_NAME: "slack-state-ci-${{ matrix.branch }}-${{ 
matrix.workflow-id }}"
+          CURRENT_FAILURES: "${{ 
steps.find-workflow-run-status.outputs.failed-jobs }}"
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: "Upload notification state"
+        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f 
 # v7.0.0
+        with:
+          name: "slack-state-ci-${{ matrix.branch }}-${{ matrix.workflow-id }}"
+          path: ./slack-state/
+          retention-days: 7
+          overwrite: true
+
+      - name: "Send Slack notification (new/changed failures)"
+        if: steps.notification.outputs.action == 'notify_new'
+        uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
+        with:
+          method: chat.postMessage
+          token: ${{ env.SLACK_BOT_TOKEN }}
+          # yamllint disable rule:line-length
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: "🚨 Failure Alert: ${{ env.workflow_id }} on branch *${{ 
env.branch }}*\n\nFailing jobs:\n${{ 
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{ 
env.run_url }}|View the failure log>"
+            blocks:
+              - type: "section"
+                text:
+                  type: "mrkdwn"
+                  text: "🚨 Failure Alert: ${{ env.workflow_id }} on *${{ 
env.branch }}*\n\nFailing jobs:\n${{ 
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{ 
env.run_url }}|View the failure log>"
+          # yamllint enable rule:line-length
+        env:
+          run_url: ${{ steps.find-workflow-run-status.outputs.run-url }}
+          branch: ${{ matrix.branch }}
+          workflow_id: ${{ matrix.workflow-id }}
+
+      - name: "Send Slack notification (still not fixed)"
+        if: steps.notification.outputs.action == 'notify_reminder'
+        uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
+        with:
+          method: chat.postMessage
+          token: ${{ env.SLACK_BOT_TOKEN }}
+          # yamllint disable rule:line-length
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: "🚨🔁 Still not fixed: ${{ env.workflow_id }} on branch *${{ 
env.branch }}*\n\nFailing jobs:\n${{ 
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{ 
env.run_url }}|View the failure log>"
+            blocks:
+              - type: "section"
+                text:
+                  type: "mrkdwn"
+                  text: "🚨🔁 Still not fixed: ${{ env.workflow_id }} on *${{ 
env.branch }}*\n\nFailing jobs:\n${{ 
steps.find-workflow-run-status.outputs.failed-jobs }}\n\n*Details:* <${{ 
env.run_url }}|View the failure log>"
+          # yamllint enable rule:line-length
+        env:
+          run_url: ${{ steps.find-workflow-run-status.outputs.run-url }}
+          branch: ${{ matrix.branch }}
+          workflow_id: ${{ matrix.workflow-id }}
+
+      - name: "Send Slack notification (all passing)"
+        if: steps.notification.outputs.action == 'notify_recovery'
         uses: 
slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a  # v2.1.1
         with:
           method: chat.postMessage
@@ -65,12 +123,13 @@ jobs:
           # yamllint disable rule:line-length
           payload: |
             channel: "internal-airflow-ci-cd"
-            text: "🚨🕒 Failure Alert: ${{ env.workflow_id }} on branch *${{ 
env.branch }}* 🕒🚨\n\n*Details:* <${{ env.run_url }}|View the failure log>"
+            text: "✅ All passing: ${{ env.workflow_id }} on branch *${{ 
env.branch }}*\n\n*Details:* <${{ env.run_url }}|View the run log>"
             blocks:
               - type: "section"
                 text:
                   type: "mrkdwn"
-                  text: "🚨🕒 Failure Alert: ${{ env.workflow_id }} ${{ 
env.branch }} 🕒🚨\n\n*Details:* <${{ env.run_url }}|View the failure log>"
+                  text: "✅ All passing: ${{ env.workflow_id }} on *${{ 
env.branch }}*\n\n*Details:* <${{ env.run_url }}|View the run log>"
+          # yamllint enable rule:line-length
         env:
           run_url: ${{ steps.find-workflow-run-status.outputs.run-url }}
           branch: ${{ matrix.branch }}
diff --git a/dev/breeze/src/airflow_breeze/utils/workflow_status.py 
b/dev/breeze/src/airflow_breeze/utils/workflow_status.py
index 482d702b5e0..6ed3bd55390 100644
--- a/dev/breeze/src/airflow_breeze/utils/workflow_status.py
+++ b/dev/breeze/src/airflow_breeze/utils/workflow_status.py
@@ -51,7 +51,7 @@ def workflow_status(
         "--repo",
         "apache/airflow",
         "--json",
-        "conclusion,url",
+        "conclusion,url,databaseId",
     ]
     result = subprocess.run(
         cmd,
@@ -71,6 +71,33 @@ def workflow_status(
     return run_info
 
 
+def get_failed_jobs(run_id: int) -> list[str]:
+    """Get list of failed job names from a workflow run."""
+    cmd = [
+        "gh",
+        "run",
+        "view",
+        str(run_id),
+        "--repo",
+        "apache/airflow",
+        "--json",
+        "jobs",
+        "--jq",
+        '[.jobs[] | select(.conclusion == "failure") | .name] | sort | .[]',
+    ]
+    result = subprocess.run(
+        cmd,
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode != 0:
+        console.print(f"[red]Error fetching failed jobs: 
{result.stderr}[/red]")
+        return []
+
+    return [line.strip() for line in result.stdout.strip().splitlines() if 
line.strip()]
+
+
 if __name__ == "__main__":
     branch = os.environ.get("workflow_branch")
     workflow_id = os.environ.get("workflow_id")
@@ -84,6 +111,13 @@ if __name__ == "__main__":
     data: list[dict] = workflow_status(branch, workflow_id)
     conclusion = data[0].get("conclusion")
     url = data[0].get("url")
+    run_id = data[0].get("databaseId")
+
+    failed_jobs: list[str] = []
+    if conclusion == "failure" and run_id:
+        console.print(f"[blue]Fetching failed jobs for run {run_id}[/blue]")
+        failed_jobs = get_failed_jobs(run_id)
+        console.print(f"[blue]Failed jobs: {failed_jobs}[/blue]")
 
     if os.environ.get("GITHUB_OUTPUT") is None:
         console.print("[red]GITHUB_OUTPUT environment variable is not set. 
Cannot write output.[/red]")
@@ -92,3 +126,6 @@ if __name__ == "__main__":
     with open(os.environ["GITHUB_OUTPUT"], "a") as f:
         f.write(f"conclusion={conclusion}\n")
         f.write(f"run-url={url}\n")
+        f.write(f"run-id={run_id}\n")
+        failed_jobs_str = "\n".join(failed_jobs)
+        f.write(f"failed-jobs<<EOF\n{failed_jobs_str}\nEOF\n")
diff --git a/scripts/ci/slack_notification_state.py 
b/scripts/ci/slack_notification_state.py
new file mode 100644
index 00000000000..f6dc0a035c2
--- /dev/null
+++ b/scripts/ci/slack_notification_state.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# /// script
+# requires-python = ">=3.10"
+# ///
+"""
+Determine whether to send a Slack notification based on previous state.
+
+Downloads previous state from GitHub Actions artifacts, compares with current
+failures, and outputs the appropriate action to take.
+
+Outputs (written to GITHUB_OUTPUT):
+  action             - One of: notify_new, notify_reminder, notify_recovery, 
skip
+  current-failures   - JSON list of current failure names
+  previous-failures  - JSON list of previous failure names
+
+Environment variables (required):
+  ARTIFACT_NAME      - Name of the artifact storing notification state
+  GITHUB_REPOSITORY  - Owner/repo (e.g. apache/airflow)
+
+Environment variables (optional):
+  CURRENT_FAILURES   - Newline-separated list of current failures (empty if 
none)
+  GITHUB_OUTPUT      - Path to GitHub Actions output file
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+REMINDER_INTERVAL_HOURS = 24
+STATE_DIR = Path("./slack-state")
+PREV_STATE_DIR = Path("./prev-slack-state")
+
+
+def download_previous_state(artifact_name: str, repo: str) -> dict | None:
+    """Download previous notification state artifact from GitHub Actions."""
+    result = subprocess.run(
+        [
+            "gh",
+            "api",
+            f"repos/{repo}/actions/artifacts",
+            "-f",
+            f"name={artifact_name}",
+            "-f",
+            "per_page=1",
+            "--jq",
+            ".artifacts[0]",
+        ],
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+    if result.returncode != 0:
+        print(f"Could not query artifacts API: {result.stderr}", 
file=sys.stderr)
+        return None
+
+    output = result.stdout.strip()
+    if not output or output == "null":
+        print("No previous state artifact found.")
+        return None
+
+    try:
+        artifact = json.loads(output)
+    except json.JSONDecodeError:
+        print(f"Invalid JSON from artifacts API: {output}", file=sys.stderr)
+        return None
+
+    if artifact.get("expired", False):
+        print("Previous state artifact has expired.")
+        return None
+
+    run_id = artifact.get("workflow_run", {}).get("id")
+    if not run_id:
+        print("No workflow run ID in artifact metadata.")
+        return None
+
+    PREV_STATE_DIR.mkdir(parents=True, exist_ok=True)
+    dl_result = subprocess.run(
+        [
+            "gh",
+            "run",
+            "download",
+            str(run_id),
+            "--name",
+            artifact_name,
+            "--dir",
+            str(PREV_STATE_DIR),
+            "--repo",
+            repo,
+        ],
+        check=False,
+        capture_output=True,
+        text=True,
+    )
+    if dl_result.returncode != 0:
+        print(f"Could not download previous state: {dl_result.stderr}", 
file=sys.stderr)
+        return None
+
+    state_file = PREV_STATE_DIR / "state.json"
+    if not state_file.exists():
+        print("Downloaded artifact does not contain state.json.")
+        return None
+
+    try:
+        return json.loads(state_file.read_text())
+    except json.JSONDecodeError:
+        print("Invalid JSON in state.json.", file=sys.stderr)
+        return None
+
+
+def determine_action(current_failures: list[str], prev_state: dict | None) -> 
str:
+    """Determine what notification action to take.
+
+    Returns one of: notify_new, notify_reminder, notify_recovery, skip.
+    """
+    prev_failures = sorted(prev_state.get("failures", [])) if prev_state else 
[]
+    prev_notified = prev_state.get("last_notified") if prev_state else None
+    now = datetime.now(timezone.utc)
+
+    if current_failures:
+        if not prev_failures:
+            return "notify_new"
+        if current_failures != prev_failures:
+            return "notify_new"
+        # Same failures as before — check if reminder is due
+        if prev_notified:
+            prev_time = datetime.fromisoformat(prev_notified)
+            hours_since = (now - prev_time).total_seconds() / 3600
+            if hours_since >= REMINDER_INTERVAL_HOURS:
+                return "notify_reminder"
+        else:
+            return "notify_new"
+    elif prev_failures:
+        # Was failing, now all clear
+        return "notify_recovery"
+
+    return "skip"
+
+
+def save_state(current_failures: list[str], action: str, prev_notified: str | 
None) -> None:
+    """Save current state to file for upload as artifact."""
+    now = datetime.now(timezone.utc)
+    STATE_DIR.mkdir(parents=True, exist_ok=True)
+
+    new_state = {
+        "failures": current_failures,
+        "last_notified": (now.isoformat() if action != "skip" else 
(prev_notified or now.isoformat())),
+    }
+    (STATE_DIR / "state.json").write_text(json.dumps(new_state, indent=2))
+    print(f"Saved state to {STATE_DIR / 'state.json'}: {new_state}")
+
+
+def main() -> None:
+    artifact_name = os.environ.get("ARTIFACT_NAME")
+    if not artifact_name:
+        print("ERROR: ARTIFACT_NAME environment variable is required.", 
file=sys.stderr)
+        sys.exit(1)
+
+    repo = os.environ.get("GITHUB_REPOSITORY", "apache/airflow")
+    current_failures_str = os.environ.get("CURRENT_FAILURES", "")
+    current_failures = sorted([f.strip() for f in 
current_failures_str.strip().splitlines() if f.strip()])
+
+    # Download previous state
+    print(f"Looking up previous state for artifact: {artifact_name}")
+    prev_state = download_previous_state(artifact_name, repo)
+    print(f"Previous state: {prev_state}")
+
+    # Determine action
+    action = determine_action(current_failures, prev_state)
+    prev_failures = sorted(prev_state.get("failures", [])) if prev_state else 
[]
+
+    print(f"Action: {action}")
+    print(f"Current failures: {current_failures}")
+    print(f"Previous failures: {prev_failures}")
+
+    # Save new state
+    prev_notified = prev_state.get("last_notified") if prev_state else None
+    save_state(current_failures, action, prev_notified)
+
+    # Output for GitHub Actions
+    github_output = os.environ.get("GITHUB_OUTPUT")
+    if github_output:
+        with open(github_output, "a") as f:
+            f.write(f"action={action}\n")
+            f.write(f"current-failures={json.dumps(current_failures)}\n")
+            f.write(f"previous-failures={json.dumps(prev_failures)}\n")
+
+
+if __name__ == "__main__":
+    main()

Reply via email to