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

potiuk pushed a commit to branch wiki-proposals-consistency-check
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit 5ef5e040cf8fd2d600cccd389573889eb32e6a14
Author: Jarek Potiuk <[email protected]>
AuthorDate: Tue Nov 10 08:37:11 2026 -0700

    Check the AIP and ADIP wiki trees daily and report problems to Slack
    
    The AIP and ADIP index pages are now generated: each list is a Page 
Properties
    Report over one folder, so an AIP sits in the folder for its state and 
carries
    a matching State badge and state label. A page in the wrong folder, a State 
or
    label that does not match it, or a stale count in a folded section silently
    puts an AIP in the wrong list or drops it.
    
    scripts/ci/verify_improvement_proposals_wiki.py reads the wiki anonymously 
and
    reports each such page; a scheduled workflow runs it daily and posts the 
list
    to #internal-airflow-ci-cd when anything is out of line, or a short notice 
when
    the check itself could not run.
    
    Generated-by: Claude Opus 5
---
 ...scheduled-verify-improvement-proposals-wiki.yml |  64 ++++
 dev/breeze/doc/ci/04_selective_checks.md           |   2 +-
 .../src/airflow_breeze/utils/selective_checks.py   |   3 +-
 dev/breeze/tests/test_selective_checks.py          |   5 +
 scripts/ci/verify_improvement_proposals_wiki.py    | 379 ++++++++++++++++++++
 .../ci/test_verify_improvement_proposals_wiki.py   | 396 +++++++++++++++++++++
 6 files changed, 847 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/scheduled-verify-improvement-proposals-wiki.yml 
b/.github/workflows/scheduled-verify-improvement-proposals-wiki.yml
new file mode 100644
index 00000000000..ed7a79b6c6a
--- /dev/null
+++ b/.github/workflows/scheduled-verify-improvement-proposals-wiki.yml
@@ -0,0 +1,64 @@
+# 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.
+#
+---
+name: "Scheduled verify improvement proposals wiki"
+on:  # yamllint disable-line rule:truthy
+  schedule:
+    # Daily at 06:30 UTC
+    - cron: '30 6 * * *'
+  workflow_dispatch:
+permissions:
+  contents: read
+jobs:
+  verify-improvement-proposals-wiki:
+    name: "Verify AIP and ADIP wiki trees"
+    if: github.event_name != 'schedule' || github.repository == 
'apache/airflow'
+    runs-on: ["ubuntu-slim"]
+    timeout-minutes: 15
+    steps:
+      - name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
+        with:
+          persist-credentials: false
+      - name: "Verify AIP and ADIP wiki trees"
+        id: verify
+        env:
+          RUN_URL: "${{ github.server_url }}/${{ github.repository 
}}/actions/runs/${{ github.run_id }}"
+        run: >
+          python3 scripts/ci/verify_improvement_proposals_wiki.py
+          --slack-payload slack-message.json
+          --run-url "${RUN_URL}"
+      - name: "Notify Slack about the problems"
+        if: failure() && hashFiles('slack-message.json') != ''
+        uses: 
slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d  # v4.0.0
+        with:
+          method: chat.postMessage
+          token: ${{ secrets.SLACK_BOT_TOKEN }}
+          payload-file-path: "slack-message.json"
+      # yamllint disable rule:line-length
+      - name: "Notify Slack that the check could not run"
+        if: failure() && hashFiles('slack-message.json') == ''
+        uses: 
slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d  # v4.0.0
+        with:
+          method: chat.postMessage
+          token: ${{ secrets.SLACK_BOT_TOKEN }}
+          payload: |
+            channel: "internal-airflow-ci-cd"
+            text: >-
+              :warning: The AIP/ADIP wiki consistency check could not run.
+              See: ${{ github.server_url }}/${{ github.repository 
}}/actions/runs/${{ github.run_id }}
diff --git a/dev/breeze/doc/ci/04_selective_checks.md 
b/dev/breeze/doc/ci/04_selective_checks.md
index 4c14a33e999..b2a1123994f 100644
--- a/dev/breeze/doc/ci/04_selective_checks.md
+++ b/dev/breeze/doc/ci/04_selective_checks.md
@@ -421,7 +421,7 @@ We have the following Groups of files for CI that determine 
which tests are run:
   drive prek static checks rather than the test matrix, so they only trigger 
the CI image build (via the
   `Prek files` group) — `mypy-scripts` and the image-based static checks still 
run, but the full test
   matrix does not; and **non-test workflows** (`.github/workflows/` for 
security scans, doc publishing,
-  notifications, backporting, calendar/stale bots, …) cannot change test 
outcomes. Workflows that run or
+  notifications, backporting, calendar/wiki/stale bots, …) cannot change test 
outcomes. Workflows that run or
   configure the test suite (`ci-amd`, `ci-arm`, `run-unit-tests`, `k8s-tests`, 
`helm-tests`, image builds,
   …) still force the full matrix.
 * `Prek files` - `scripts/ci/prek/...` static-check hooks. They do not force 
full tests, but they trigger
diff --git a/dev/breeze/src/airflow_breeze/utils/selective_checks.py 
b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
index 1f57fc93871..7c6b8cba59c 100644
--- a/dev/breeze/src/airflow_breeze/utils/selective_checks.py
+++ b/dev/breeze/src/airflow_breeze/utils/selective_checks.py
@@ -246,7 +246,8 @@ CI_FILE_GROUP_MATCHES: HashableDict[FileGroupForCi] = 
HashableDict(
             r"^\.github/workflows/(?!("
             
r"asf-allowlist-check|automatic-backport|backport-cli|ci-duration-monitor|ci-notification|"
             
r"codeql-analysis|e2e-flaky-tests-report|milestone-tag-assistant|notify-uv-lock-conflicts|"
-            
r"publish-docs-to-s3|recheck-old-bug-report|scheduled-verify-release-calendar|stale"
+            
r"publish-docs-to-s3|recheck-old-bug-report|scheduled-verify-improvement-proposals-wiki|"
+            r"scheduled-verify-release-calendar|stale"
             r")\.yml$)",
             r"^dev/breeze/src",
             r"^dev/breeze/pyproject\.toml",
diff --git a/dev/breeze/tests/test_selective_checks.py 
b/dev/breeze/tests/test_selective_checks.py
index d5bf33d73d9..b871f515ce5 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -2747,6 +2747,11 @@ def 
test_ui_compile_hooks_not_skipped_when_ui_openapi_spec_changes(files: tuple[
             {"full-tests-needed": "false"},
             id="Non-test workflow (ci-notification) does not force full tests",
         ),
+        pytest.param(
+            
(".github/workflows/scheduled-verify-improvement-proposals-wiki.yml",),
+            {"full-tests-needed": "false"},
+            id="Non-test workflow (improvement proposals wiki check) does not 
force full tests",
+        ),
     ],
 )
 def test_non_test_workflows_do_not_force_full_tests(files: tuple[str, ...], 
expected_outputs: dict[str, str]):
diff --git a/scripts/ci/verify_improvement_proposals_wiki.py 
b/scripts/ci/verify_improvement_proposals_wiki.py
new file mode 100644
index 00000000000..d273ea6dee3
--- /dev/null
+++ b/scripts/ci/verify_improvement_proposals_wiki.py
@@ -0,0 +1,379 @@
+#!/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.11"
+# ///
+"""
+Check that the AIP and ADIP pages on the Airflow wiki are laid out the way 
their index pages expect.
+
+The index pages are generated: each list is a Page Properties Report over the 
pages of one
+folder, so a page in the wrong place, or with a State that does not match 
where it sits, shows
+up in the wrong list or not at all. This script reads the wiki anonymously and 
reports every
+such page.
+
+AIP tree 
(https://cwiki.apache.org/confluence/display/AIRFLOW/Airflow+Improvement+Proposals):
+
+  * the index page has only the state folders (and the folder of ADIP stubs) 
as children;
+  * every page in a state folder has one Page Properties block whose State 
badge matches the
+    folder, the ``airflow-improvement-proposal`` label, and the state label of 
that folder only;
+  * a page carrying a state label sits in that state's folder;
+  * the counts in the titles of the folded sections on the index page match 
the folders.
+
+ADIP tree: every child of the ADIP page is titled ``ADIP-<n> <title>`` with a 
unique number,
+has the ``adip`` label and one Page Properties block with a State badge and a 
Formerly row.
+
+Exits 1 when anything is out of line. With ``--slack-payload <file>`` it then 
also writes a
+``chat.postMessage`` payload listing the problems, for the workflow to post.
+"""
+
+from __future__ import annotations
+
+import argparse
+import gzip
+import http.client
+import json
+import re
+import sys
+import time
+import urllib.parse
+import urllib.request
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+
+WIKI = "https://cwiki.apache.org/confluence";
+AIP_INDEX_ID = "89066602"
+ADIP_INDEX_ID = "462816431"
+ADIP_STUBS_FOLDER_ID = "462816661"
+BASE_LABEL = "airflow-improvement-proposal"
+ADIP_LABEL = "adip"
+SLACK_CHANNEL = "internal-airflow-ci-cd"
+MAX_PROBLEMS_IN_SLACK = 25
+MAX_TITLE_LENGTH = 100
+# Slack rejects a section block whose text is longer than 3000 characters.
+MAX_SECTION_LENGTH = 2900
+ATTEMPTS = 4
+
+
+@dataclass(frozen=True)
+class StateFolder:
+    page_id: str
+    label: str
+    badge: str
+    # Title of the folded section on the AIP index, with "{count}" where the 
count goes;
+    # None when the section is not folded.
+    folded_title: str | None = None
+
+
+STATE_FOLDERS = (
+    StateFolder("462816682", f"{BASE_LABEL}/wip", "WIP"),
+    StateFolder("462816536", f"{BASE_LABEL}/draft", "Under discussion"),
+    StateFolder("462816537", f"{BASE_LABEL}/accepted", "Accepted"),
+    StateFolder(
+        "173087665", f"{BASE_LABEL}/completed", "Completed", "{count} 
completed AIPs (click to expand)"
+    ),
+    StateFolder(
+        "173087786",
+        f"{BASE_LABEL}/abandoned",
+        "Abandoned",
+        "{count} abandoned or replaced AIPs (click to expand)",
+    ),
+)
+STATE_LABELS = {folder.label for folder in STATE_FOLDERS}
+ADIP_BADGES = {"Draft", "Accepted", "In progress", "Completed", "Abandoned"}
+
+# Pages in a state folder that the people who keep the wiki in order cannot 
edit, with the
+# reason. Their State badge and labels are not checked; their place in the 
tree still is.
+RESTRICTED_PAGES = {
+    "247827224": "AIP-52 has edit restrictions",
+}
+
+
+@dataclass
+class Page:
+    page_id: str
+    title: str
+    body: str = ""
+    labels: list[str] = field(default_factory=list)
+
+    @property
+    def url(self) -> str:
+        return f"{WIKI}/pages/viewpage.action?pageId={self.page_id}"
+
+
+def get_json(path: str, params: dict[str, str], sleep: Callable[[float], None] 
= time.sleep) -> dict:
+    url = f"{WIKI}/rest/api/{path}?{urllib.parse.urlencode(params)}"
+    # Uncompressed responses of more than a few tens of kilobytes get cut off 
mid-transfer.
+    request = urllib.request.Request(
+        url,
+        headers={
+            "Accept": "application/json",
+            "Accept-Encoding": "gzip",
+            "User-Agent": "apache-airflow-wiki-consistency-check",
+        },
+    )
+    for attempt in range(1, ATTEMPTS):
+        try:
+            return read_json(request)
+        except (http.client.HTTPException, OSError):
+            sleep(5 * attempt)
+    return read_json(request)
+
+
+def read_json(request: urllib.request.Request) -> dict:
+    with urllib.request.urlopen(request, timeout=60) as response:
+        raw = response.read()
+        if response.headers.get("Content-Encoding") == "gzip":
+            raw = gzip.decompress(raw)
+    return json.loads(raw)
+
+
+def build_page(raw: dict) -> Page:
+    return Page(
+        page_id=raw["id"],
+        title=raw["title"],
+        body=raw.get("body", {}).get("storage", {}).get("value", ""),
+        labels=[label["name"] for label in raw.get("metadata", 
{}).get("labels", {}).get("results", [])],
+    )
+
+
+def fetch_all(path: str, params: dict[str, str], fetch: Callable[[str, 
dict[str, str]], dict]) -> list[Page]:
+    pages: list[Page] = []
+    start = 0
+    while True:
+        data = fetch(path, {**params, "limit": "50", "start": str(start)})
+        pages.extend(build_page(raw) for raw in data["results"])
+        if not data["results"] or "next" not in data.get("_links", {}):
+            return pages
+        start += len(data["results"])
+
+
+def fetch_page(page_id: str, fetch: Callable[[str, dict[str, str]], dict] = 
get_json) -> Page:
+    return build_page(fetch(f"content/{page_id}", {"expand": 
"body.storage,metadata.labels"}))
+
+
+def fetch_children(page_id: str, fetch: Callable[[str, dict[str, str]], dict] 
= get_json) -> list[Page]:
+    # Bodies are fetched one page at a time: a response carrying several AIP 
bodies is large
+    # enough to be cut off mid-transfer.
+    listed = fetch_all(f"content/{page_id}/child/page", {}, fetch)
+    return [fetch_page(page.page_id, fetch) for page in listed]
+
+
+def fetch_labelled(label: str, fetch: Callable[[str, dict[str, str]], dict] = 
get_json) -> list[Page]:
+    return fetch_all("content/search", {"cql": f'label = "{label}" and type = 
page'}, fetch)
+
+
+def get_text(html: str) -> str:
+    return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html).replace("&nbsp;", 
" ")).strip()
+
+
+def find_macro_body_end(body: str, start: int) -> int:
+    """Return where the rich-text body opened after ``start`` closes, skipping 
nested macro bodies."""
+    depth = 0
+    for tag in re.finditer(r"<(/?)ac:rich-text-body>", body[start:]):
+        depth += -1 if tag.group(1) else 1
+        if depth == 0:
+            return start + tag.start()
+    return len(body)
+
+
+def find_properties(body: str) -> list[dict[str, str]]:
+    """Return the rows of each Page Properties block as {key: value storage 
HTML}."""
+    blocks = []
+    for match in re.finditer(r'<ac:structured-macro ac:name="details"', body):
+        # Only a table inside the macro's own rich-text body is read by the 
index report; a
+        # self-closing macro, or one with no body, has no properties.
+        opening = re.match(
+            
r"[^>]*?(/?)>(?:\s*<ac:parameter[^>]*>.*?</ac:parameter>)*\s*<ac:rich-text-body>",
+            body[match.end() :],
+            re.S,
+        )
+        if not opening or opening.group(1):
+            macro_body = ""
+        else:
+            body_start = match.end() + opening.end() - 
len("<ac:rich-text-body>")
+            macro_body = body[body_start : find_macro_body_end(body, 
body_start)]
+        table_end = macro_body.find("</table>")
+        rows: dict[str, str] = {}
+        for row in re.findall(r"<tr[^>]*>(.*?)</tr>", macro_body[: 
max(table_end, 0)], re.S):
+            cells = re.findall(r"<t([dh])[^>]*>(.*?)</t\1>", row, re.S)
+            if len(cells) >= 2:
+                rows.setdefault(get_text(cells[0][1]).rstrip(":"), cells[1][1])
+        blocks.append(rows)
+    return blocks
+
+
+def find_badge(cell: str) -> str | None:
+    match = re.search(
+        r'<ac:structured-macro ac:name="status".*?<ac:parameter 
ac:name="title">([^<]*)</ac:parameter>',
+        cell,
+        re.S,
+    )
+    return match.group(1) if match else None
+
+
+def check_properties(page: Page, allowed_badges: set[str]) -> tuple[list[str], 
dict[str, str]]:
+    blocks = find_properties(page.body)
+    if len(blocks) != 1:
+        return [f"has {len(blocks)} Page Properties blocks, expected 1"], 
blocks[0] if blocks else {}
+    properties = blocks[0]
+    if "State" not in properties:
+        return ["has no State row in its Page Properties block"], properties
+    badge = find_badge(properties["State"])
+    if badge not in allowed_badges:
+        return [f"State badge is {badge!r}, expected one of 
{sorted(allowed_badges)}"], properties
+    return [], properties
+
+
+def escape_for_slack(text: str) -> str:
+    # Wiki titles are free text: unescaped, "<!channel>" would notify the 
channel and a "<" or
+    # "|" would change the link.
+    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", 
"&gt;").replace("|", "&#124;")
+
+
+def shorten(title: str) -> str:
+    return title if len(title) <= MAX_TITLE_LENGTH else title[: 
MAX_TITLE_LENGTH - 1] + "…"
+
+
+def describe(page: Page, problem: str) -> str:
+    return f"<{page.url}|{escape_for_slack(shorten(page.title))}>: 
{escape_for_slack(problem)}"
+
+
+def check_aip_tree(
+    index: Page,
+    index_children: list[Page],
+    folders: dict[str, list[Page]],
+    labelled: dict[str, list[Page]],
+) -> list[str]:
+    problems = []
+    allowed_children = {folder.page_id for folder in STATE_FOLDERS} | 
{ADIP_STUBS_FOLDER_ID}
+    for page in index_children:
+        if page.page_id not in allowed_children:
+            problems.append(describe(page, "sits directly under the AIP index; 
move it into a state folder"))
+    for missing in sorted(allowed_children - {page.page_id for page in 
index_children}):
+        problems.append(
+            describe(Page(missing, f"page {missing}"), "folder is no longer 
directly under the AIP index")
+        )
+    for folder in STATE_FOLDERS:
+        pages = folders[folder.page_id]
+        for page in pages:
+            if page.page_id in RESTRICTED_PAGES:
+                continue
+            page_problems, _ = check_properties(page, {folder.badge})
+            state_labels = STATE_LABELS.intersection(page.labels)
+            if state_labels != {folder.label}:
+                page_problems.append(f"state labels are 
{sorted(state_labels)}, expected ['{folder.label}']")
+            if BASE_LABEL not in page.labels:
+                page_problems.append(f"lacks the {BASE_LABEL!r} label")
+            problems.extend(describe(page, problem) for problem in 
page_problems)
+        in_folder = {page.page_id for page in pages}
+        for page in labelled[folder.label]:
+            if page.page_id not in in_folder:
+                problems.append(
+                    describe(page, f"has the {folder.label!r} label but is not 
in that state's folder")
+                )
+        if folder.folded_title:
+            expected = folder.folded_title.format(count=len(pages))
+            if f'<ac:parameter ac:name="title">{expected}</ac:parameter>' not 
in index.body:
+                problems.append(describe(index, f"the folded section should be 
titled {expected!r}"))
+    return problems
+
+
+def check_adip_tree(children: list[Page]) -> list[str]:
+    problems = []
+    numbers: dict[int, Page] = {}
+    for page in children:
+        match = re.match(r"ADIP-(\d+) \S", page.title)
+        if not match:
+            problems.append(describe(page, "title does not start with 'ADIP-' 
and its number"))
+        elif (number := int(match.group(1))) in numbers:
+            problems.append(describe(page, f"reuses the number of 
{shorten(numbers[number].title)!r}"))
+        else:
+            numbers[number] = page
+        if "(formerly" in page.title:
+            problems.append(
+                describe(page, "title repeats the former AIP number; it is the 
Formerly property")
+            )
+        if ADIP_LABEL not in page.labels:
+            problems.append(describe(page, f"lacks the {ADIP_LABEL!r} label"))
+        page_problems, properties = check_properties(page, ADIP_BADGES)
+        if not page_problems and not re.fullmatch(r"AIP-\d+", 
get_text(properties.get("Formerly", ""))):
+            page_problems.append(
+                "has no Formerly row with the former AIP number in its Page 
Properties block"
+            )
+        problems.extend(describe(page, problem) for problem in page_problems)
+    return problems
+
+
+def collect_problems(fetch: Callable[[str, dict[str, str]], dict] = get_json) 
-> list[str]:
+    index = fetch_page(AIP_INDEX_ID, fetch)
+    folders = {folder.page_id: fetch_children(folder.page_id, fetch) for 
folder in STATE_FOLDERS}
+    labelled = {folder.label: fetch_labelled(folder.label, fetch) for folder 
in STATE_FOLDERS}
+    return check_aip_tree(index, fetch_children(AIP_INDEX_ID, fetch), folders, 
labelled) + check_adip_tree(
+        fetch_children(ADIP_INDEX_ID, fetch)
+    )
+
+
+def build_slack_payload(problems: list[str], run_url: str | None) -> dict:
+    shown = problems[:MAX_PROBLEMS_IN_SLACK]
+    lines = [
+        f":warning: *AIP/ADIP wiki consistency check found {len(problems)} 
problem(s)*",
+        "Move each AIP page to the folder for its state and keep its State 
badge and labels in line "
+        "with that folder.",
+        *(f"• {problem}" for problem in shown),
+    ]
+    if len(problems) > len(shown):
+        lines.append(f"… and {len(problems) - len(shown)} more")
+    if run_url:
+        lines.append(f"<{run_url}|View the run>")
+    sections: list[str] = []
+    for line in lines:
+        if sections and len(sections[-1]) + 1 + len(line) <= 
MAX_SECTION_LENGTH:
+            sections[-1] += "\n" + line
+        else:
+            sections.append(line)
+    return {
+        "channel": SLACK_CHANNEL,
+        "text": f":warning: AIP/ADIP wiki consistency check found 
{len(problems)} problem(s)",
+        "blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": 
text}} for text in sections],
+    }
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        description=__doc__, 
formatter_class=argparse.RawDescriptionHelpFormatter
+    )
+    parser.add_argument(
+        "--slack-payload", type=Path, help="write a Slack payload here when 
problems are found"
+    )
+    parser.add_argument("--run-url", help="link to the CI run, added to the 
Slack message")
+    args = parser.parse_args(argv)
+    problems = collect_problems()
+    if not problems:
+        print("The AIP and ADIP wiki trees are consistent.")
+        return 0
+    print(f"Found {len(problems)} problem(s):")
+    for problem in problems:
+        print(f"  - {problem}")
+    if args.slack_payload:
+        args.slack_payload.write_text(json.dumps(build_slack_payload(problems, 
args.run_url), indent=2))
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/tests/ci/test_verify_improvement_proposals_wiki.py 
b/scripts/tests/ci/test_verify_improvement_proposals_wiki.py
new file mode 100644
index 00000000000..26bf4d8af92
--- /dev/null
+++ b/scripts/tests/ci/test_verify_improvement_proposals_wiki.py
@@ -0,0 +1,396 @@
+# 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.
+from __future__ import annotations
+
+import gzip
+import http.client
+import importlib.util
+import json
+import sys
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+MODULE_PATH = Path(__file__).resolve().parents[3] / "scripts" / "ci" / 
"verify_improvement_proposals_wiki.py"
+
+
[email protected]
+def wiki():
+    module_name = "test_verify_improvement_proposals_wiki_module"
+    sys.modules.pop(module_name, None)
+    spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
+    assert spec is not None
+    assert spec.loader is not None
+    module = importlib.util.module_from_spec(spec)
+    sys.modules[module_name] = module
+    spec.loader.exec_module(module)
+    return module
+
+
+def badge(title, colour="Grey"):
+    return (
+        '<ac:structured-macro ac:name="status" ac:schema-version="1" 
ac:macro-id="x">'
+        f'<ac:parameter ac:name="colour">{colour}</ac:parameter>'
+        f'<ac:parameter 
ac:name="title">{title}</ac:parameter></ac:structured-macro>'
+    )
+
+
+def properties(rows):
+    cells = "".join(
+        f"<tr><td><strong>{key}</strong></td><td>{value}</td></tr>" for key, 
value in rows.items()
+    )
+    return (
+        '<ac:structured-macro ac:name="details" ac:schema-version="1" 
ac:macro-id="y">'
+        
f"<ac:rich-text-body><table><tbody>{cells}</tbody></table></ac:rich-text-body></ac:structured-macro>"
+    )
+
+
+def folded(count, kind):
+    return (
+        '<ac:structured-macro ac:name="expand" 
ac:schema-version="1"><ac:parameter ac:name="title">'
+        f"{count} {kind} AIPs (click to 
expand)</ac:parameter></ac:structured-macro>"
+    )
+
+
+def aip(wiki, folder, page_id="1", title="AIP-1 Something", state=None, 
labels=None):
+    return wiki.Page(
+        page_id,
+        title,
+        properties({"State": badge(state or folder.badge) + " extra detail"}),
+        [wiki.BASE_LABEL, folder.label] if labels is None else labels,
+    )
+
+
+def build_tree(wiki, pages=None, index_children=None, labelled=None, 
index_body=None):
+    """A consistent AIP tree with one AIP in each state folder, with 
overrides."""
+    folders = {f.page_id: [aip(wiki, f, page_id=f"p{f.page_id}")] for f in 
wiki.STATE_FOLDERS}
+    folders.update(pages or {})
+    children = [wiki.Page(f.page_id, f.badge) for f in wiki.STATE_FOLDERS] + [
+        wiki.Page(wiki.ADIP_STUBS_FOLDER_ID, "AIPs moved to ADIP")
+    ]
+    by_label = {f.label: folders[f.page_id] for f in wiki.STATE_FOLDERS}
+    by_label.update(labelled or {})
+    body = (
+        index_body if index_body is not None else folded(1, "completed") + 
folded(1, "abandoned or replaced")
+    )
+    return (
+        wiki.Page(wiki.AIP_INDEX_ID, "AIP index", body),
+        children + (index_children or []),
+        folders,
+        by_label,
+    )
+
+
+def folder_of(wiki, badge_title):
+    return next(f for f in wiki.STATE_FOLDERS if f.badge == badge_title)
+
+
+def test_consistent_aip_tree_has_no_problems(wiki):
+    assert wiki.check_aip_tree(*build_tree(wiki)) == []
+
+
+def test_page_directly_under_the_aip_index_is_reported(wiki):
+    stray = wiki.Page("99", "AIP-200 New")
+    problems = wiki.check_aip_tree(*build_tree(wiki, index_children=[stray]))
+    assert problems == [
+        f"<{stray.url}|AIP-200 New>: sits directly under the AIP index; move 
it into a state folder"
+    ]
+
+
[email protected](
+    ("body", "expected"),
+    [
+        pytest.param("", "has 0 Page Properties blocks, expected 1", 
id="no-block"),
+        pytest.param(
+            properties({"State": badge("Completed")}) * 2,
+            "has 2 Page Properties blocks, expected 1",
+            id="two-blocks",
+        ),
+        pytest.param(
+            properties({"Discussion Thread": "x"}),
+            "has no State row in its Page Properties block",
+            id="no-state",
+        ),
+        pytest.param(
+            properties({"State": "Completed"}),
+            "State badge is None, expected one of ['Completed']",
+            id="plain-text-state",
+        ),
+        pytest.param(
+            properties({"State": badge("Accepted")}),
+            "State badge is 'Accepted', expected one of ['Completed']",
+            id="badge-of-another-state",
+        ),
+    ],
+)
+def test_aip_properties_must_match_the_folder(wiki, body, expected):
+    completed = folder_of(wiki, "Completed")
+    page = wiki.Page("5", "AIP-5 X", body, [wiki.BASE_LABEL, completed.label])
+    problems = wiki.check_aip_tree(*build_tree(wiki, pages={completed.page_id: 
[page]}))
+    assert problems == [f"<{page.url}|AIP-5 X>: {expected}"]
+
+
[email protected](
+    ("labels", "expected"),
+    [
+        pytest.param(
+            ["airflow-improvement-proposal"],
+            ["state labels are [], expected 
['airflow-improvement-proposal/completed']"],
+            id="no-state-label",
+        ),
+        pytest.param(
+            [
+                "airflow-improvement-proposal",
+                "airflow-improvement-proposal/completed",
+                "airflow-improvement-proposal/draft",
+            ],
+            [
+                "state labels are ['airflow-improvement-proposal/completed', 
'airflow-improvement-proposal/draft'],"
+                " expected ['airflow-improvement-proposal/completed']"
+            ],
+            id="two-state-labels",
+        ),
+        pytest.param(
+            ["airflow-improvement-proposal/completed"],
+            ["lacks the 'airflow-improvement-proposal' label"],
+            id="no-base-label",
+        ),
+    ],
+)
+def test_aip_labels_must_match_the_folder(wiki, labels, expected):
+    completed = folder_of(wiki, "Completed")
+    page = aip(wiki, completed, page_id="5", title="AIP-5 X", labels=labels)
+    problems = wiki.check_aip_tree(*build_tree(wiki, pages={completed.page_id: 
[page]}))
+    assert problems == [f"<{page.url}|AIP-5 X>: {problem}" for problem in 
expected]
+
+
+def test_restricted_page_is_not_checked_for_badge_or_labels(wiki):
+    completed = folder_of(wiki, "Completed")
+    page = wiki.Page(next(iter(wiki.RESTRICTED_PAGES)), "AIP-52 X", 
properties({"State": "Completed"}), [])
+    assert wiki.check_aip_tree(*build_tree(wiki, pages={completed.page_id: 
[page]})) == []
+
+
+def test_titles_and_problems_are_escaped_and_shortened_for_slack(wiki):
+    page = wiki.Page("9", "AIP-9 <!channel> & a|b")
+    assert (
+        wiki.describe(page, "see <x>") == f"<{page.url}|AIP-9 &lt;!channel&gt; 
&amp; a&#124;b>: see &lt;x&gt;"
+    )
+    long_page = wiki.Page("10", "A" * 300)
+    assert wiki.describe(long_page, "x") == f"<{long_page.url}|{'A' * 99}…>: x"
+
+
[email protected](
+    "macro",
+    [
+        pytest.param('<ac:structured-macro ac:name="details" 
ac:schema-version="1" />', id="self-closing"),
+        pytest.param(
+            '<ac:structured-macro ac:name="details" 
ac:schema-version="1"></ac:structured-macro>',
+            id="no-body",
+        ),
+    ],
+)
+def test_macro_without_a_body_has_no_properties_even_before_a_table(wiki, 
macro):
+    body = macro + 
f"<table><tbody><tr><td>State</td><td>{badge('Completed')}</td></tr></tbody></table>"
+    assert wiki.find_properties(body) == [{}]
+
+
+def test_folder_moved_out_of_the_aip_index_is_reported(wiki):
+    index, children, folders, labelled = build_tree(wiki)
+    moved = children[0]
+    problems = wiki.check_aip_tree(index, children[1:], folders, labelled)
+    assert problems == [
+        f"<{moved.url}|page {moved.page_id}>: folder is no longer directly 
under the AIP index"
+    ]
+
+
+def test_properties_with_a_nested_macro_in_a_cell_are_read(wiki):
+    nested = (
+        '<ac:structured-macro 
ac:name="info"><ac:rich-text-body><p>note</p></ac:rich-text-body>'
+        "</ac:structured-macro>"
+    )
+    body = properties({"Discussion Thread": nested, "State": 
badge("Completed")})
+    assert wiki.find_badge(wiki.find_properties(body)[0]["State"]) == 
"Completed"
+
+
+def test_properties_table_outside_the_macro_is_not_read(wiki):
+    body = (
+        '<ac:structured-macro ac:name="details" 
ac:schema-version="1"><ac:rich-text-body><p></p>'
+        "</ac:rich-text-body></ac:structured-macro>"
+        
f"<table><tbody><tr><td>State</td><td>{badge('Completed')}</td></tr></tbody></table>"
+    )
+    assert wiki.find_properties(body) == [{}]
+
+
+def test_state_label_outside_its_folder_is_reported(wiki):
+    draft = folder_of(wiki, "Under discussion")
+    elsewhere = wiki.Page("77", "AIP-101: Appendix", labels=[draft.label])
+    tree = build_tree(wiki)
+    tree[3][draft.label] = [*tree[2][draft.page_id], elsewhere]
+    problems = wiki.check_aip_tree(*tree)
+    assert problems == [
+        f"<{elsewhere.url}|AIP-101: Appendix>: has the '{draft.label}' label 
but is not in that state's folder"
+    ]
+
+
+def test_stale_count_in_a_folded_section_is_reported(wiki):
+    tree = build_tree(wiki, index_body=folded(7, "completed") + folded(1, 
"abandoned or replaced"))
+    problems = wiki.check_aip_tree(*tree)
+    assert problems == [
+        f"<{tree[0].url}|AIP index>: the folded section should be titled '1 
completed AIPs (click to expand)'"
+    ]
+
+
+def adip(wiki, number=1, title=None, state="Completed", formerly="AIP-4", 
labels=("adip",)):
+    rows = {"State": badge(state, "Green")}
+    if formerly is not None:
+        rows["Formerly"] = f'<span style="color: 
rgb(151,160,175);">{formerly}</span>'
+    return wiki.Page(str(number), title or f"ADIP-{number} Something", 
properties(rows), list(labels))
+
+
+def test_consistent_adip_tree_has_no_problems(wiki):
+    assert wiki.check_adip_tree([adip(wiki, 1), adip(wiki, 2, 
formerly="AIP-6")]) == []
+
+
[email protected](
+    ("page_kwargs", "expected"),
+    [
+        pytest.param(
+            {"title": "Automation of tests"}, ["title does not start with 
'ADIP-' and its number"], id="title"
+        ),
+        pytest.param(
+            {"title": "ADIP-2 Something (formerly AIP-6)"},
+            ["title repeats the former AIP number; it is the Formerly 
property"],
+            id="formerly-in-title",
+        ),
+        pytest.param({"labels": ()}, ["lacks the 'adip' label"], 
id="no-label"),
+        pytest.param(
+            {"state": "Done"},
+            [
+                "State badge is 'Done', expected one of ['Abandoned', 
'Accepted', 'Completed', 'Draft', 'In progress']"
+            ],
+            id="unknown-badge",
+        ),
+        pytest.param(
+            {"formerly": None},
+            ["has no Formerly row with the former AIP number in its Page 
Properties block"],
+            id="no-formerly",
+        ),
+    ],
+)
+def test_adip_page_problems(wiki, page_kwargs, expected):
+    page = adip(wiki, 2, **page_kwargs)
+    assert wiki.check_adip_tree([adip(wiki, 1), page]) == [
+        f"<{page.url}|{page.title}>: {p}" for p in expected
+    ]
+
+
+def test_duplicate_adip_number_is_reported_with_the_other_title_escaped(wiki):
+    first, second = adip(wiki, 3, title="ADIP-3 <!channel>"), adip(wiki, 4, 
title="ADIP-3 Two")
+    assert wiki.check_adip_tree([first, second]) == [
+        f"<{second.url}|ADIP-3 Two>: reuses the number of 'ADIP-3 
&lt;!channel&gt;'"
+    ]
+
+
+def test_fetch_children_pages_through_the_listing_and_reads_each_body(wiki):
+    calls = []
+
+    def fetch(path, params):
+        calls.append((path, params.get("start")))
+        if path.endswith("/child/page"):
+            if params["start"] == "0":
+                return {"results": [{"id": "1", "title": "A"}], "_links": 
{"next": "more"}}
+            return {"results": [{"id": "2", "title": "B"}], "_links": {}}
+        page_id = path.rsplit("/", 1)[1]
+        return {
+            "id": page_id,
+            "title": f"T{page_id}",
+            "body": {"storage": {"value": f"<p>{page_id}</p>"}},
+            "metadata": {"labels": {"results": [{"name": "adip"}]}},
+        }
+
+    pages = wiki.fetch_children("10", fetch)
+    assert [(p.page_id, p.title, p.body, p.labels) for p in pages] == [
+        ("1", "T1", "<p>1</p>", ["adip"]),
+        ("2", "T2", "<p>2</p>", ["adip"]),
+    ]
+    assert calls == [
+        ("content/10/child/page", "0"),
+        ("content/10/child/page", "1"),
+        ("content/1", None),
+        ("content/2", None),
+    ]
+
+
+def test_fetch_labelled_searches_by_label(wiki):
+    fetch = mock.Mock(return_value={"results": [{"id": "3", "title": 
"AIP-3"}], "_links": {}})
+    assert [p.page_id for p in 
wiki.fetch_labelled("airflow-improvement-proposal/wip", fetch)] == ["3"]
+    assert fetch.call_args.args[1]["cql"] == 'label = 
"airflow-improvement-proposal/wip" and type = page'
+
+
[email protected]("urllib.request.urlopen")
+def test_get_json_retries_and_decompresses_gzip(urlopen, wiki):
+    response = mock.MagicMock()
+    response.__enter__.return_value = response
+    response.read.return_value = gzip.compress(json.dumps({"ok": 
True}).encode())
+    response.headers = {"Content-Encoding": "gzip"}
+    urlopen.side_effect = [http.client.RemoteDisconnected("gone"), response]
+    sleep = mock.Mock()
+    assert wiki.get_json("content/1", {}, sleep=sleep) == {"ok": True}
+    sleep.assert_called_once_with(5)
+    assert urlopen.call_args.args[0].get_header("Accept-encoding") == "gzip"
+
+
[email protected]("urllib.request.urlopen", 
side_effect=http.client.IncompleteRead(b""))
+def test_get_json_gives_up_after_the_last_attempt(urlopen, wiki):
+    with pytest.raises(http.client.IncompleteRead):
+        wiki.get_json("content/1", {}, sleep=mock.Mock())
+    assert urlopen.call_count == wiki.ATTEMPTS
+
+
+def test_slack_payload_lists_problems_and_truncates(wiki):
+    problems = [f"problem {i}" for i in range(wiki.MAX_PROBLEMS_IN_SLACK + 2)]
+    payload = wiki.build_slack_payload(problems, "https://run";)
+    text = payload["blocks"][0]["text"]["text"]
+    assert payload["channel"] == wiki.SLACK_CHANNEL
+    assert f"found {len(problems)} problem(s)" in payload["text"]
+    assert "• problem 0" in text
+    assert f"• problem {wiki.MAX_PROBLEMS_IN_SLACK}" not in text
+    assert "… and 2 more" in text
+    assert text.endswith("<https://run|View the run>")
+
+
+def test_main_exits_0_and_writes_no_payload_when_consistent(wiki, tmp_path):
+    payload = tmp_path / "slack.json"
+    with mock.patch.object(wiki, "collect_problems", return_value=[]):
+        assert wiki.main(["--slack-payload", str(payload)]) == 0
+    assert not payload.exists()
+
+
+def test_main_exits_1_and_writes_the_payload_on_problems(wiki, tmp_path):
+    payload = tmp_path / "slack.json"
+    with mock.patch.object(wiki, "collect_problems", return_value=["bad 
page"]):
+        assert wiki.main(["--slack-payload", str(payload), "--run-url", 
"https://run";]) == 1
+    assert "• bad page" in 
json.loads(payload.read_text())["blocks"][0]["text"]["text"]
+
+
+def test_slack_payload_splits_long_lists_into_sections_within_the_limit(wiki):
+    problems = [f"problem {i} " + "x" * 400 for i in 
range(wiki.MAX_PROBLEMS_IN_SLACK)]
+    sections = [block["text"]["text"] for block in 
wiki.build_slack_payload(problems, None)["blocks"]]
+    assert len(sections) > 1
+    assert all(len(text) <= wiki.MAX_SECTION_LENGTH for text in sections)
+    assert "\n".join(sections).count("• problem") == wiki.MAX_PROBLEMS_IN_SLACK

Reply via email to