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 ab918c9260a Auto-triage: flag area:UI PRs missing screenshots/demo 
from new contributors (#64516)
ab918c9260a is described below

commit ab918c9260a73f7fc7c39cdd5b86b90ab7ad4a76
Author: Shivam Rastogi <[email protected]>
AuthorDate: Mon Apr 6 01:46:37 2026 -0700

    Auto-triage: flag area:UI PRs missing screenshots/demo from new 
contributors (#64516)
    
    * Add UI demo/screenshot check to auto-triage for area:UI PRs
    
    Add a deterministic check to `breeze pr auto-triage` that flags
    `area:UI` PRs from non-collaborator contributors when their PR
    description lacks screenshots or demo videos.
    
    The check runs as part of the existing deterministic checks in
    `_assess_pr_deterministic()` alongside CI failure, merge conflict,
    and unresolved comment checks. PRs flagged only for missing UI demo
    receive a comment (not draft conversion), using a new soft-violation
    branch in `_compute_default_action`.
    
    Detection patterns cover GitHub drag-and-drop uploads (`<img>` tags,
    `user-attachments/assets/` URLs), markdown image syntax, and direct
    media file URLs (png, jpg, gif, mp4, etc).
    
    * Clarify that demo should be in PR description, not comments
    
    * Remove soft-violation special-case in _compute_default_action
    
    Per reviewer feedback from @pierrejeambrun and @potiuk: the
    separate elif branch for soft-only violations (e.g. missing UI
    demo) defaulting to COMMENT is unnecessary — maintainers can
    override DRAFT to COMMENT interactively. A future enhancement
    will let each check define its own suggested action severity.
    
    ---------
    
    Co-authored-by: Jarek Potiuk <[email protected]>
---
 .../src/airflow_breeze/commands/pr_commands.py     |  15 +-
 dev/breeze/src/airflow_breeze/utils/github.py      |  67 ++++++
 dev/breeze/tests/test_github_ui_demo.py            | 224 +++++++++++++++++++++
 3 files changed, 303 insertions(+), 3 deletions(-)

diff --git a/dev/breeze/src/airflow_breeze/commands/pr_commands.py 
b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
index bd701d469c8..c2f92873642 100644
--- a/dev/breeze/src/airflow_breeze/commands/pr_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/pr_commands.py
@@ -7249,6 +7249,7 @@ def _review_ready_prs_review_mode(
         PRAssessment,
         assess_pr_checks,
         assess_pr_conflicts,
+        assess_pr_ui_demo,
         assess_pr_unresolved_comments,
     )
 
@@ -7350,8 +7351,9 @@ def _review_ready_prs_review_mode(
             comments_assessment = assess_pr_unresolved_comments(
                 pr.number, pr.unresolved_review_comments, pr.unresolved_threads
             )
+            ui_demo_assessment = assess_pr_ui_demo(pr.number, pr.labels, 
pr.body, pr.author_association)
 
-            if ci_assessment or conflict_assessment or comments_assessment:
+            if ci_assessment or conflict_assessment or comments_assessment or 
ui_demo_assessment:
                 violations = []
                 summaries = []
                 if conflict_assessment:
@@ -7363,6 +7365,9 @@ def _review_ready_prs_review_mode(
                 if comments_assessment:
                     violations.extend(comments_assessment.violations)
                     summaries.append(comments_assessment.summary)
+                if ui_demo_assessment:
+                    violations.extend(ui_demo_assessment.violations)
+                    summaries.append(ui_demo_assessment.summary)
 
                 assessment = PRAssessment(
                     should_flag=True,
@@ -8922,6 +8927,7 @@ def _assess_pr_deterministic(
     from airflow_breeze.utils.github import (
         assess_pr_checks,
         assess_pr_conflicts,
+        assess_pr_ui_demo,
         assess_pr_unresolved_comments,
     )
 
@@ -8950,11 +8956,14 @@ def _assess_pr_deterministic(
     comments_assessment = assess_pr_unresolved_comments(
         pr.number, pr.unresolved_review_comments, pr.unresolved_threads
     )
+    ui_demo_assessment = assess_pr_ui_demo(pr.number, pr.labels, pr.body, 
pr.author_association)
 
-    if ci_assessment or conflict_assessment or comments_assessment:
+    if ci_assessment or conflict_assessment or comments_assessment or 
ui_demo_assessment:
         if pr.is_draft:
             return DeterministicResult(category="draft_skipped")
-        merged = _merge_pr_assessments(conflict_assessment, ci_assessment, 
comments_assessment)
+        merged = _merge_pr_assessments(
+            conflict_assessment, ci_assessment, comments_assessment, 
ui_demo_assessment
+        )
         return DeterministicResult(category="flagged", assessment=merged)
 
     # No deterministic issues found — needs further categorization
diff --git a/dev/breeze/src/airflow_breeze/utils/github.py 
b/dev/breeze/src/airflow_breeze/utils/github.py
index c1a6641105c..1a6885c57c0 100644
--- a/dev/breeze/src/airflow_breeze/utils/github.py
+++ b/dev/breeze/src/airflow_breeze/utils/github.py
@@ -646,3 +646,70 @@ def assess_pr_unresolved_comments(
         ],
         summary=f"PR #{pr_number} has {unresolved_review_comments} unresolved 
review {thread_word}.",
     )
+
+
+# Patterns that indicate screenshots or video demos in a PR body.
+# Based on analysis of real Airflow UI PRs — GitHub drag-and-drop uploads
+# produce HTML <img> tags or bare URLs with user-attachments/assets/ paths.
+_DEMO_EVIDENCE_PATTERNS = [
+    r"<img\s",  # HTML <img> tag (GitHub drag-and-drop screenshots)
+    r"https://github\.com/user-attachments/assets/";,  # GitHub-uploaded assets 
(images & videos)
+    r"!\[",  # Markdown image syntax (rare but valid)
+    r"https?://\S+\.(?:png|jpg|jpeg|gif|webp|mp4|mov|webm)",  # Direct media 
file URLs
+]
+
+# authorAssociation values that indicate the author has write access
+_COLLABORATOR_ASSOCIATIONS_FOR_UI = {"COLLABORATOR", "MEMBER", "OWNER"}
+
+
+def _has_demo_evidence(body: str | None) -> bool:
+    """Check if PR body contains screenshots, images, or video links."""
+    if not body:
+        return False
+    for pattern in _DEMO_EVIDENCE_PATTERNS:
+        if re.search(pattern, body, re.IGNORECASE):
+            return True
+    return False
+
+
+def assess_pr_ui_demo(
+    pr_number: int,
+    labels: list[str],
+    body: str | None,
+    author_association: str,
+) -> PRAssessment | None:
+    """Flag UI PRs from new contributors that lack screenshots or demo videos.
+
+    Returns None if the PR does not have the ``area:UI`` label, the author is a
+    collaborator/member/owner, or the PR body already contains demo evidence.
+    """
+    if "area:UI" not in labels:
+        return None
+
+    if author_association in _COLLABORATOR_ASSOCIATIONS_FOR_UI:
+        return None
+
+    if _has_demo_evidence(body):
+        return None
+
+    return PRAssessment(
+        should_flag=True,
+        violations=[
+            Violation(
+                category="Missing UI demo",
+                explanation=(
+                    "This PR changes UI code but the description does not 
include "
+                    "screenshots or a demo video."
+                ),
+                severity="warning",
+                details=(
+                    "For UI changes, please add screenshots or a short screen 
recording "
+                    "directly to the PR description (not in comments) so 
reviewers can "
+                    "verify the visual changes. You can paste images directly 
into the "
+                    "GitHub PR description or drag-and-drop a screen 
recording. "
+                    f"See [pull request 
guidelines]({_CONTRIBUTING_DOCS_URL}/05_pull_requests.rst)."
+                ),
+            )
+        ],
+        summary=f"PR #{pr_number} changes UI code but has no screenshots or 
demo.",
+    )
diff --git a/dev/breeze/tests/test_github_ui_demo.py 
b/dev/breeze/tests/test_github_ui_demo.py
new file mode 100644
index 00000000000..e38a7d8a98c
--- /dev/null
+++ b/dev/breeze/tests/test_github_ui_demo.py
@@ -0,0 +1,224 @@
+# 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 pytest
+
+from airflow_breeze.commands.pr_commands import _compute_default_action
+from airflow_breeze.utils.confirm import TriageAction
+from airflow_breeze.utils.github import PRAssessment, Violation, 
_has_demo_evidence, assess_pr_ui_demo
+from airflow_breeze.utils.pr_models import PRData
+
+
+class TestHasDemoEvidence:
+    """Tests for _has_demo_evidence helper function."""
+
+    def test_empty_body(self):
+        assert _has_demo_evidence("") is False
+
+    def test_none_body(self):
+        assert _has_demo_evidence(None) is False
+
+    def test_text_only_body(self):
+        assert _has_demo_evidence("This PR fixes a bug in the UI sidebar 
component.") is False
+
+    def test_html_img_tag_github_assets(self):
+        body = (
+            "## Changes\n"
+            '<img width="1159" alt="image" '
+            'src="https://github.com/user-attachments/assets/abc123-def456";>'
+        )
+        assert _has_demo_evidence(body) is True
+
+    def test_bare_github_assets_url(self):
+        body = "## 
Demo\n\nhttps://github.com/user-attachments/assets/abc123-def456-video\n";
+        assert _has_demo_evidence(body) is True
+
+    def test_markdown_image_syntax(self):
+        body = "![screenshot](https://example.com/image.png)"
+        assert _has_demo_evidence(body) is True
+
+    def test_direct_image_url(self):
+        body = "See the result at https://example.com/demo.png";
+        assert _has_demo_evidence(body) is True
+
+    def test_direct_video_url(self):
+        body = "Recording: https://example.com/screen.mp4";
+        assert _has_demo_evidence(body) is True
+
+    @pytest.mark.parametrize(
+        "extension",
+        ["png", "jpg", "jpeg", "gif", "webp", "mp4", "mov", "webm"],
+    )
+    def test_various_media_extensions(self, extension):
+        body = f"https://example.com/file.{extension}";
+        assert _has_demo_evidence(body) is True
+
+    def test_no_false_positive_on_code_mention(self):
+        body = "Updated the image component to handle png files better."
+        assert _has_demo_evidence(body) is False
+
+    def test_no_false_positive_on_url_without_media_extension(self):
+        body = "See https://github.com/apache/airflow/pull/12345 for context."
+        assert _has_demo_evidence(body) is False
+
+
+class TestAssessPrUiDemo:
+    """Tests for assess_pr_ui_demo deterministic check."""
+
+    def test_no_ui_label_returns_none(self):
+        result = assess_pr_ui_demo(
+            pr_number=123,
+            labels=["area:API", "kind:bug"],
+            body="No screenshots here.",
+            author_association="CONTRIBUTOR",
+        )
+        assert result is None
+
+    @pytest.mark.parametrize("association", ["COLLABORATOR", "MEMBER", 
"OWNER"])
+    def test_collaborator_association_returns_none(self, association):
+        result = assess_pr_ui_demo(
+            pr_number=123,
+            labels=["area:UI"],
+            body="No screenshots here.",
+            author_association=association,
+        )
+        assert result is None
+
+    def test_contributor_with_demo_returns_none(self):
+        body = '<img width="500" 
src="https://github.com/user-attachments/assets/abc123";>'
+        result = assess_pr_ui_demo(
+            pr_number=123,
+            labels=["area:UI"],
+            body=body,
+            author_association="CONTRIBUTOR",
+        )
+        assert result is None
+
+    def test_contributor_without_demo_returns_assessment(self):
+        result = assess_pr_ui_demo(
+            pr_number=123,
+            labels=["area:UI"],
+            body="This fixes a UI bug.",
+            author_association="CONTRIBUTOR",
+        )
+        assert result is not None
+        assert result.should_flag is True
+        assert len(result.violations) == 1
+        assert result.violations[0].category == "Missing UI demo"
+        assert result.violations[0].severity == "warning"
+
+    def test_none_association_without_demo_returns_assessment(self):
+        result = assess_pr_ui_demo(
+            pr_number=456,
+            labels=["area:UI", "kind:feature"],
+            body="Added new sidebar component.",
+            author_association="NONE",
+        )
+        assert result is not None
+        assert result.should_flag is True
+        assert "screenshots" in result.violations[0].explanation.lower()
+
+    def test_empty_body_returns_assessment(self):
+        result = assess_pr_ui_demo(
+            pr_number=789,
+            labels=["area:UI"],
+            body="",
+            author_association="CONTRIBUTOR",
+        )
+        assert result is not None
+        assert result.should_flag is True
+
+    def test_summary_includes_pr_number(self):
+        result = assess_pr_ui_demo(
+            pr_number=42,
+            labels=["area:UI"],
+            body="Some text",
+            author_association="CONTRIBUTOR",
+        )
+        assert result is not None
+        assert "42" in result.summary
+
+
+class TestComputeDefaultActionSoftViolations:
+    """Test default action for PRs with UI demo violations."""
+
+    @staticmethod
+    def _make_pr(**overrides) -> PRData:
+        defaults = {
+            "number": 123,
+            "title": "UI change",
+            "body": "Some text",
+            "url": "https://github.com/apache/airflow/pull/123";,
+            "created_at": "2026-01-01T00:00:00Z",
+            "updated_at": "2026-01-01T00:00:00Z",
+            "node_id": "PR_123",
+            "author_login": "contributor",
+            "author_association": "NONE",
+            "head_sha": "abc123",
+            "base_ref": "main",
+            "check_summary": "",
+            "checks_state": "SUCCESS",
+            "failed_checks": [],
+            "commits_behind": 10,
+            "is_draft": False,
+            "mergeable": "MERGEABLE",
+            "labels": ["area:UI"],
+            "unresolved_threads": [],
+        }
+        defaults.update(overrides)
+        return PRData(**defaults)
+
+    @staticmethod
+    def _make_assessment(summary: str) -> PRAssessment:
+        return PRAssessment(
+            should_flag=True,
+            violations=[
+                Violation(
+                    category="Missing UI demo",
+                    explanation="No screenshots",
+                    severity="warning",
+                    details="Add screenshots",
+                )
+            ],
+            summary=summary,
+        )
+
+    def test_soft_violation_only_suggests_draft(self):
+        """A PR with only a soft violation (no CI failures, conflicts, or 
comments) gets DRAFT."""
+        pr = self._make_pr()
+        assessment = self._make_assessment("PR #123 changes UI code but has no 
screenshots or demo.")
+        action, reason = _compute_default_action(pr, assessment, 
author_flagged_count={})
+        assert action == TriageAction.DRAFT
+        assert "draft" in reason
+
+    def test_soft_violation_with_ci_failures_suggests_draft(self):
+        """A PR with a soft violation AND multiple CI failures gets DRAFT 
(hard issue takes priority)."""
+        pr = self._make_pr(
+            failed_checks=["Tests / Core", "Tests / API", "Tests / CLI"],
+            checks_state="FAILURE",
+        )
+        assessment = self._make_assessment("PR has CI failures and no demo.")
+        action, _reason = _compute_default_action(pr, assessment, 
author_flagged_count={})
+        assert action == TriageAction.DRAFT
+
+    def test_soft_violation_with_conflicts_suggests_draft(self):
+        """A PR with a soft violation AND merge conflicts gets DRAFT."""
+        pr = self._make_pr(mergeable="CONFLICTING")
+        assessment = self._make_assessment("PR has conflicts and no demo.")
+        action, _reason = _compute_default_action(pr, assessment, 
author_flagged_count={})
+        assert action == TriageAction.DRAFT

Reply via email to