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 6d54333537f Add Milestone Tag Assistant (#61626)
6d54333537f is described below

commit 6d54333537f6d1311b71740f975b6194a1890fa0
Author: Jason(Zhe-You) Liu <[email protected]>
AuthorDate: Mon Feb 16 03:51:04 2026 +0800

    Add Milestone Tag Assistant (#61626)
    
    * Add Milestone Tag Assistant
    
    * Refactor the set-milestone utils to breeze ci_group
    
    * Improves milestone assignment and adds tests for PR tagging
    
    Refines the logic for auto-assigning GitHub milestones to merged PRs, 
prioritizing backport labels, version branches, and latest open milestones for 
main branch merges. Introduces user notification comments when no suitable 
milestone is found and prompts manual action. Adds comprehensive tests covering 
milestone detection, assignment, and notification flows to ensure robust 
behavior and edge case handling.
    
    * Refactor milestone comment generation and improve tests for mention 
handling
    
    * Add CLI testing fixtures and enhance milestone comment generation in tests
    
    * Fix test scenarios
    
    * Add check to skip setting milestone if already assigned in Breeze
    
    * Remove merging into main logic
    
    * Update Milestone Tag Assistant documentation to clarify triggering 
conditions and rules for milestone assignment
    
    * Fix mypy error
---
 .github/workflows/milestone-tag-assistant.yml      | 122 +++++
 dev/README_AIRFLOW3_DEV.md                         |   4 +-
 dev/breeze/doc/08_ci_tasks.rst                     |  18 +
 dev/breeze/doc/images/output_ci.svg                |  16 +-
 dev/breeze/doc/images/output_ci.txt                |   2 +-
 dev/breeze/doc/images/output_ci_set-milestone.svg  | 146 ++++++
 dev/breeze/doc/images/output_ci_set-milestone.txt  |   1 +
 .../output_setup_check-all-params-in-groups.svg    |   4 +-
 .../output_setup_check-all-params-in-groups.txt    |   2 +-
 .../output_setup_regenerate-command-images.svg     |  12 +-
 .../output_setup_regenerate-command-images.txt     |   2 +-
 .../src/airflow_breeze/commands/ci_commands.py     | 331 +++++++++++-
 .../airflow_breeze/commands/ci_commands_config.py  |  20 +
 dev/breeze/src/airflow_breeze/global_constants.py  |   6 +
 dev/breeze/tests/test_set_milestone.py             | 569 +++++++++++++++++++++
 15 files changed, 1237 insertions(+), 18 deletions(-)

diff --git a/.github/workflows/milestone-tag-assistant.yml 
b/.github/workflows/milestone-tag-assistant.yml
new file mode 100644
index 00000000000..18fd030e9b9
--- /dev/null
+++ b/.github/workflows/milestone-tag-assistant.yml
@@ -0,0 +1,122 @@
+# 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: Milestone Tag Assistant
+on:  # yamllint disable-line rule:truthy
+  push:
+    branches:
+      - main
+      - v3-1-test
+
+permissions:
+  # Those permissions are only active for workflow dispatch (only committers 
can trigger it) and workflow call
+  # Which is triggered automatically by "automatic-backport" push workflow 
(only when merging by committer)
+  # Branch protection  prevents from pushing to the "code" branches
+  contents: write  # zizmor: ignore[excessive-permissions]
+  pull-requests: write  # zizmor: ignore[excessive-permissions]
+
+jobs:
+  get-pr-info:
+    name: "Get PR information"
+    runs-on: ubuntu-latest
+    outputs:
+      should-run: ${{ steps.pr-info.outputs.should-run }}
+      pr-number: ${{ steps.pr-info.outputs.pr-number }}
+      pr-title: ${{ steps.pr-info.outputs.pr-title }}
+      pr-labels: ${{ steps.pr-info.outputs.pr-labels }}
+      base-branch: ${{ steps.pr-info.outputs.base-branch }}
+      merged-by: ${{ steps.pr-info.outputs.merged-by }}
+    steps:
+      # Adding a slight delay to allow GitHub's API to associate the merge 
commit with the PR.
+      # This is needed because GH has a consistency of 6-10+ seconds
+      # to process the commit and PR association after a merge based on some 
of our past runs.
+      - name: Add delay for GitHub to process PR merge
+        run: sleep 15
+
+      - name: Find PR information
+        id: pr-info
+        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea  
# v7.0.1
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          script: |
+            const { data: pullRequests } = await 
github.rest.repos.listPullRequestsAssociatedWithCommit({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                commit_sha: process.env.GITHUB_SHA
+            });
+
+            if (pullRequests.length === 0) {
+                console.log('⚠️ No pull request found for this commit.');
+                core.setOutput('should-run', 'false');
+                return;
+            }
+
+            const pr = pullRequests[0];
+
+            // Skip if PR already has a milestone
+            if (pr.milestone !== null) {
+                console.log(`PR #${pr.number} already has milestone: 
${pr.milestone.title}`);
+                core.setOutput('should-run', 'false');
+                return;
+            }
+
+            const labels = pr.labels.map(label => label.name);
+
+            console.log(`Commit ${process.env.GITHUB_SHA} is associated with 
PR #${pr.number}`);
+            console.log(`Title: ${pr.title}`);
+            console.log(`Labels: ${JSON.stringify(labels)}`);
+            console.log(`Base branch: ${pr.base.ref}`);
+            console.log(`Merged by: ${pr.merged_by?.login || 'unknown'}`);
+
+            core.setOutput('should-run', 'true');
+            core.setOutput('pr-number', pr.number.toString());
+            core.setOutput('pr-title', pr.title);
+            core.setOutput('pr-labels', JSON.stringify(labels));
+            core.setOutput('base-branch', pr.base.ref);
+            core.setOutput('merged-by', pr.merged_by?.login || 'unknown');
+
+  set-milestone:
+    name: "Set milestone on merged PR"
+    runs-on: ubuntu-latest
+    needs: get-pr-info
+    if: ${{ needs.get-pr-info.outputs.should-run == 'true' }}
+
+    steps:
+      - name: "Checkout repository"
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # 
v4.2.2
+        with:
+          persist-credentials: false
+          # Always checkout main to ensure Breeze with set-milestone command 
is available
+          ref: main
+
+      - name: "Install Breeze"
+        uses: ./.github/actions/breeze
+        id: breeze
+
+      - name: Check criteria and set milestone
+        env:
+          GH_TOKEN: ${{ github.token }}
+          GITHUB_REPOSITORY: ${{ github.repository }}
+          PR_NUMBER: ${{ needs.get-pr-info.outputs.pr-number }}
+          PR_TITLE: ${{ needs.get-pr-info.outputs.pr-title }}
+          PR_LABELS: ${{ needs.get-pr-info.outputs.pr-labels }}
+          BASE_BRANCH: ${{ needs.get-pr-info.outputs.base-branch }}
+          MERGED_BY: ${{ needs.get-pr-info.outputs.merged-by }}
+        run: |
+          breeze ci set-milestone
diff --git a/dev/README_AIRFLOW3_DEV.md b/dev/README_AIRFLOW3_DEV.md
index dd7c5656792..c97431bf042 100644
--- a/dev/README_AIRFLOW3_DEV.md
+++ b/dev/README_AIRFLOW3_DEV.md
@@ -95,7 +95,7 @@ Do not treat PR approval (Green V) as exclusion approval.
 
 ## Merging PRs targeted for Airflow 3.X
 
-The committer who merges the PR is responsible for backporting the PRs that 
are 3.1 bug fixes (generally speaking)
+The committer who merges the PR **is responsible for backporting the PRs that 
are 3.1 bug fixes (generally speaking)**
 to `v3-1-test` (latest active branch we release bugfixes from). See next 
chapter to see what kind of changes we cherry-pick.
 
 It means that they should create a new PR where the original commit from main 
is cherry-picked and take care for resolving conflicts.
@@ -105,7 +105,7 @@ Note: tracking that the PRs merged as expected is the 
responsibility of committe
 Committer may also request from PR author to raise 2 PRs one against `main` 
branch and one against `v3-1-test` prior to accepting the code change.
 
 Mistakes happen, and such backport PR work might fall through cracks. 
Therefore, if the committer thinks
-that certain PRs should be backported, they should set 3.1.x milestone for 
them.
+that certain PRs should be backported, they **should set 3.1.x milestone for 
them.**
 
 This way release manager can verify (as usual) if all the "expected" PRs have
 been backported and cherry-pick remaining PRS.
diff --git a/dev/breeze/doc/08_ci_tasks.rst b/dev/breeze/doc/08_ci_tasks.rst
index cf2e8ba2060..7165a598756 100644
--- a/dev/breeze/doc/08_ci_tasks.rst
+++ b/dev/breeze/doc/08_ci_tasks.rst
@@ -132,6 +132,24 @@ These are all available flags of ``get-workflow-info`` 
command:
   :width: 100%
   :alt: Breeze ci get-workflow-info
 
+Milestone Tag Assistant
+-----------------------
+
+The bot will only be triggered when a new push event occurs on the ``main`` or 
``v3-1-test`` branches. It will leave a comment in the corresponding PR 
(similar to how the current ``automatic-backport`` bot behaves) and set the 
milestone if it matches the rules below, and it will skip if the PRs that 
already have a milestone set.
+
+There are two cases for the current rules:
+- **CI-related**: no need to set milestone
+- **Patch release**: default to the **latest patch-release milestone**
+  - has a label like ``backport-to-v3-1-test``
+  - is merged to ``v3-1-test`` version branch
+
+If it cannot determine which milestone should be added, it also adds a comment 
to remind the committer who merged the PR to add the corresponding milestone. 
This automation ensures that all PRs that should be included in the patch 
release are properly tagged, making the release manager's life easier.
+
+.. image:: ./images/output_ci_set-milestone.svg
+  :target: 
https://raw.githubusercontent.com/apache/airflow/main/dev/breeze/images/output_milestone-tag-assistant.svg
+  :width: 100%
+  :alt: Milestone Tag Assistant
+
 -----
 
 Next step: Follow the `Release management tasks 
<09_release_management_tasks.rst>`_ guide to learn how
diff --git a/dev/breeze/doc/images/output_ci.svg 
b/dev/breeze/doc/images/output_ci.svg
index fba2a6f87ff..393d111e6e5 100644
--- a/dev/breeze/doc/images/output_ci.svg
+++ b/dev/breeze/doc/images/output_ci.svg
@@ -1,4 +1,4 @@
-<svg class="rich-terminal" viewBox="0 0 1482 464.79999999999995" 
xmlns="http://www.w3.org/2000/svg";>
+<svg class="rich-terminal" viewBox="0 0 1482 513.5999999999999" 
xmlns="http://www.w3.org/2000/svg";>
     <!-- Generated with Rich https://www.textualize.io -->
     <style>
 
@@ -42,7 +42,7 @@
 
     <defs>
     <clipPath id="breeze-ci-clip-terminal">
-      <rect x="0" y="0" width="1463.0" height="413.79999999999995" />
+      <rect x="0" y="0" width="1463.0" height="462.59999999999997" />
     </clipPath>
     <clipPath id="breeze-ci-line-0">
     <rect x="0" y="1.5" width="1464" height="24.65"/>
@@ -92,9 +92,15 @@
 <clipPath id="breeze-ci-line-15">
     <rect x="0" y="367.5" width="1464" height="24.65"/>
             </clipPath>
+<clipPath id="breeze-ci-line-16">
+    <rect x="0" y="391.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-line-17">
+    <rect x="0" y="416.3" 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="462.8" rx="8"/><text class="breeze-ci-title" 
fill="#c5c8c6" text-anchor="middle" x="740" y="27">Command:&#160;ci</text>
+    <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" 
x="1" y="1" width="1480" height="511.6" rx="8"/><text class="breeze-ci-title" 
fill="#c5c8c6" text-anchor="middle" x="740" y="27">Command:&#160;ci</text>
             <g transform="translate(26,22)">
             <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
             <circle cx="22" cy="0" r="7" fill="#febc2e"/>
@@ -120,7 +126,9 @@
 </text><text class="breeze-ci-r5" x="0" y="337.2" textLength="12.2" 
clip-path="url(#breeze-ci-line-13)">│</text><text class="breeze-ci-r4" x="24.4" 
y="337.2" textLength="207.4" 
clip-path="url(#breeze-ci-line-13)">get-workflow-info</text><text 
class="breeze-ci-r1" x="256.2" y="337.2" textLength="1183.4" 
clip-path="url(#breeze-ci-line-13)">Retrieve&#160;information&#160;about&#160;current&#160;workflow&#160;in&#160;the&#160;CIand&#160;produce&#160;github&#160;actions&#160;output&#160;extra
 [...]
 </text><text class="breeze-ci-r5" x="0" y="361.6" textLength="12.2" 
clip-path="url(#breeze-ci-line-14)">│</text><text class="breeze-ci-r1" 
x="256.2" y="361.6" textLength="1183.4" 
clip-path="url(#breeze-ci-line-14)">from&#160;it.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;
 [...]
 </text><text class="breeze-ci-r5" x="0" y="386" textLength="12.2" 
clip-path="url(#breeze-ci-line-15)">│</text><text class="breeze-ci-r4" x="24.4" 
y="386" textLength="207.4" 
clip-path="url(#breeze-ci-line-15)">upgrade&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-r1" x="256.2" y="386" textLength="1183.4" 
clip-path="url(#breeze-ci-line-15)">Perform&#160;important&#160;upgrade&#160;steps&#160;of&#160;the&#160;CI&#160;environment.&#160;And&#160;crea
 [...]
-</text><text class="breeze-ci-r5" x="0" y="410.4" textLength="1464" 
clip-path="url(#breeze-ci-line-16)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-r1" x="1464" y="410.4" textLength="12.2" 
clip-path="url(#breeze-ci-line-16)">
+</text><text class="breeze-ci-r5" x="0" y="410.4" textLength="12.2" 
clip-path="url(#breeze-ci-line-16)">│</text><text class="breeze-ci-r4" x="24.4" 
y="410.4" textLength="207.4" 
clip-path="url(#breeze-ci-line-16)">set-milestone&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-r1" x="256.2" y="410.4" textLength="1183.4" 
clip-path="url(#breeze-ci-line-16)">Set&#160;milestone&#160;on&#160;a&#160;merged&#160;PR&#160;if&#160;it&#160;doesn&#x27;t&#160;have&#160;one.&#160;Used&#160;by&#160;t
 [...]
+</text><text class="breeze-ci-r5" x="0" y="434.8" textLength="12.2" 
clip-path="url(#breeze-ci-line-17)">│</text><text class="breeze-ci-r1" 
x="256.2" y="434.8" textLength="1183.4" 
clip-path="url(#breeze-ci-line-17)">workflow.&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#16
 [...]
+</text><text class="breeze-ci-r5" x="0" y="459.2" textLength="1464" 
clip-path="url(#breeze-ci-line-18)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-r1" x="1464" y="459.2" textLength="12.2" 
clip-path="url(#breeze-ci-line-18)">
 </text>
     </g>
     </g>
diff --git a/dev/breeze/doc/images/output_ci.txt 
b/dev/breeze/doc/images/output_ci.txt
index c85d607cef2..19755f15fd3 100644
--- a/dev/breeze/doc/images/output_ci.txt
+++ b/dev/breeze/doc/images/output_ci.txt
@@ -1 +1 @@
-3444c2578188542be84b14f53be8fa42
+99f967b52c1e9d1e9ec92dbd4a30fe92
diff --git a/dev/breeze/doc/images/output_ci_set-milestone.svg 
b/dev/breeze/doc/images/output_ci_set-milestone.svg
new file mode 100644
index 00000000000..8d66fbb8b55
--- /dev/null
+++ b/dev/breeze/doc/images/output_ci_set-milestone.svg
@@ -0,0 +1,146 @@
+<svg class="rich-terminal" viewBox="0 0 1482 562.4" 
xmlns="http://www.w3.org/2000/svg";>
+    <!-- Generated with Rich https://www.textualize.io -->
+    <style>
+
+    @font-face {
+        font-family: "Fira Code";
+        src: local("FiraCode-Regular"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2";)
 format("woff2"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff";)
 format("woff");
+        font-style: normal;
+        font-weight: 400;
+    }
+    @font-face {
+        font-family: "Fira Code";
+        src: local("FiraCode-Bold"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2";)
 format("woff2"),
+                
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff";)
 format("woff");
+        font-style: bold;
+        font-weight: 700;
+    }
+
+    .breeze-ci-set-milestone-matrix {
+        font-family: Fira Code, monospace;
+        font-size: 20px;
+        line-height: 24.4px;
+        font-variant-east-asian: full-width;
+    }
+
+    .breeze-ci-set-milestone-title {
+        font-size: 18px;
+        font-weight: bold;
+        font-family: arial;
+    }
+
+    .breeze-ci-set-milestone-r1 { fill: #c5c8c6 }
+.breeze-ci-set-milestone-r2 { fill: #d0b344 }
+.breeze-ci-set-milestone-r3 { fill: #c5c8c6;font-weight: bold }
+.breeze-ci-set-milestone-r4 { fill: #68a0b3;font-weight: bold }
+.breeze-ci-set-milestone-r5 { fill: #868887 }
+.breeze-ci-set-milestone-r6 { fill: #cc555a }
+.breeze-ci-set-milestone-r7 { fill: #8a4346 }
+.breeze-ci-set-milestone-r8 { fill: #8d7b39 }
+.breeze-ci-set-milestone-r9 { fill: #98a84b;font-weight: bold }
+    </style>
+
+    <defs>
+    <clipPath id="breeze-ci-set-milestone-clip-terminal">
+      <rect x="0" y="0" width="1463.0" height="511.4" />
+    </clipPath>
+    <clipPath id="breeze-ci-set-milestone-line-0">
+    <rect x="0" y="1.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-1">
+    <rect x="0" y="25.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-2">
+    <rect x="0" y="50.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-3">
+    <rect x="0" y="74.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-4">
+    <rect x="0" y="99.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-5">
+    <rect x="0" y="123.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-6">
+    <rect x="0" y="147.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-7">
+    <rect x="0" y="172.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-8">
+    <rect x="0" y="196.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-9">
+    <rect x="0" y="221.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-10">
+    <rect x="0" y="245.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-11">
+    <rect x="0" y="269.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-12">
+    <rect x="0" y="294.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-13">
+    <rect x="0" y="318.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-14">
+    <rect x="0" y="343.1" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-15">
+    <rect x="0" y="367.5" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-16">
+    <rect x="0" y="391.9" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-17">
+    <rect x="0" y="416.3" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-18">
+    <rect x="0" y="440.7" width="1464" height="24.65"/>
+            </clipPath>
+<clipPath id="breeze-ci-set-milestone-line-19">
+    <rect x="0" y="465.1" 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="560.4" rx="8"/><text 
class="breeze-ci-set-milestone-title" fill="#c5c8c6" text-anchor="middle" 
x="740" y="27">Command:&#160;ci&#160;set-milestone</text>
+            <g transform="translate(26,22)">
+            <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+            <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+            <circle cx="44" cy="0" r="7" fill="#28c840"/>
+            </g>
+        
+    <g transform="translate(9, 41)" 
clip-path="url(#breeze-ci-set-milestone-clip-terminal)">
+    
+    <g class="breeze-ci-set-milestone-matrix">
+    <text class="breeze-ci-set-milestone-r1" x="1464" y="20" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-0)">
+</text><text class="breeze-ci-set-milestone-r2" x="12.2" y="44.4" 
textLength="73.2" 
clip-path="url(#breeze-ci-set-milestone-line-1)">Usage:</text><text 
class="breeze-ci-set-milestone-r3" x="97.6" y="44.4" textLength="280.6" 
clip-path="url(#breeze-ci-set-milestone-line-1)">breeze&#160;ci&#160;set-milestone</text><text
 class="breeze-ci-set-milestone-r1" x="390.4" y="44.4" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-1)">[</text><text 
class="breeze-ci-set-milestone-r4" x=" [...]
+</text><text class="breeze-ci-set-milestone-r1" x="1464" y="68.8" 
textLength="12.2" clip-path="url(#breeze-ci-set-milestone-line-2)">
+</text><text class="breeze-ci-set-milestone-r1" x="12.2" y="93.2" 
textLength="1195.6" 
clip-path="url(#breeze-ci-set-milestone-line-3)">Set&#160;milestone&#160;on&#160;a&#160;merged&#160;PR&#160;if&#160;it&#160;doesn&#x27;t&#160;have&#160;one.&#160;Used&#160;by&#160;the&#160;milestone-tag-assistant&#160;workflow.</text><text
 class="breeze-ci-set-milestone-r1" x="1464" y="93.2" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-3)">
+</text><text class="breeze-ci-set-milestone-r1" x="1464" y="117.6" 
textLength="12.2" clip-path="url(#breeze-ci-set-milestone-line-4)">
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="142" 
textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-5)">╭─</text><text 
class="breeze-ci-set-milestone-r5" x="24.4" y="142" textLength="195.2" 
clip-path="url(#breeze-ci-set-milestone-line-5)">&#160;PR&#160;information&#160;</text><text
 class="breeze-ci-set-milestone-r5" x="219.6" y="142" textLength="1220" 
clip-path="url(#breeze-ci-set-milestone-line-5)">────────────────────────────────────────────────────────────────
 [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="166.4" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-6)">│</text><text 
class="breeze-ci-set-milestone-r6" x="24.4" y="166.4" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-6)">*</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="166.4" textLength="158.6" 
clip-path="url(#breeze-ci-set-milestone-line-6)">--pr-number&#160;&#160;</text><text
 class="breeze-ci-set-milestone-r1" x="244" y="166.4" tex [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="190.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-7)">│</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="190.8" textLength="158.6" 
clip-path="url(#breeze-ci-set-milestone-line-7)">--pr-title&#160;&#160;&#160;</text><text
 class="breeze-ci-set-milestone-r1" x="244" y="190.8" textLength="158.6" 
clip-path="url(#breeze-ci-set-milestone-line-7)">The&#160;PR&#160;title&#160;</text><text
 class="breeze-ci-set-mil [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="215.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-8)">│</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="215.2" textLength="158.6" 
clip-path="url(#breeze-ci-set-milestone-line-8)">--pr-labels&#160;&#160;</text><text
 class="breeze-ci-set-milestone-r1" x="244" y="215.2" textLength="353.8" 
clip-path="url(#breeze-ci-set-milestone-line-8)">JSON&#160;array&#160;of&#160;PR&#160;label&#160;names&#160;</text><tex
 [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-9)">│</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="239.6" textLength="158.6" 
clip-path="url(#breeze-ci-set-milestone-line-9)">--base-branch</text><text 
class="breeze-ci-set-milestone-r1" x="244" y="239.6" textLength="451.4" 
clip-path="url(#breeze-ci-set-milestone-line-9)">The&#160;base&#160;branch&#160;the&#160;PR&#160;was&#160;merged&#160;to&#160;</t
 [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-10)">│</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="264" textLength="158.6" 
clip-path="url(#breeze-ci-set-milestone-line-10)">--merged-by&#160;&#160;</text><text
 class="breeze-ci-set-milestone-r1" x="244" y="264" textLength="585.6" 
clip-path="url(#breeze-ci-set-milestone-line-10)">GitHub&#160;username&#160;of&#160;the&#160;person&#160;who&#160;merged&#1
 [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="288.4" 
textLength="1464" 
clip-path="url(#breeze-ci-set-milestone-line-11)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-set-milestone-r1" x="1464" y="288.4" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-11)">
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="312.8" 
textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-12)">╭─</text><text 
class="breeze-ci-set-milestone-r5" x="24.4" y="312.8" textLength="231.8" 
clip-path="url(#breeze-ci-set-milestone-line-12)">&#160;GitHub&#160;parameters&#160;</text><text
 class="breeze-ci-set-milestone-r5" x="256.2" y="312.8" textLength="1183.4" 
clip-path="url(#breeze-ci-set-milestone-line-12)">──────────────────────────────────────────────────
 [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-13)">│</text><text 
class="breeze-ci-set-milestone-r6" x="24.4" y="337.2" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-13)">*</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="337.2" textLength="231.8" 
clip-path="url(#breeze-ci-set-milestone-line-13)">--github-token&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ci-set-milestone-r [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="361.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-14)">│</text><text 
class="breeze-ci-set-milestone-r4" x="61" y="361.6" textLength="231.8" 
clip-path="url(#breeze-ci-set-milestone-line-14)">--github-repository</text><text
 class="breeze-ci-set-milestone-r9" x="317.2" y="361.6" textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-14)">-g</text><text 
class="breeze-ci-set-milestone-r1" x="366" y="361.6" te [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="386" 
textLength="1464" 
clip-path="url(#breeze-ci-set-milestone-line-15)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-set-milestone-r1" x="1464" y="386" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-15)">
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="410.4" 
textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-16)">╭─</text><text 
class="breeze-ci-set-milestone-r5" x="24.4" y="410.4" textLength="195.2" 
clip-path="url(#breeze-ci-set-milestone-line-16)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ci-set-milestone-r5" x="219.6" y="410.4" textLength="1220" 
clip-path="url(#breeze-ci-set-milestone-line-16)">───────────────────────────────────────────────────────
 [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="434.8" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-17)">│</text><text 
class="breeze-ci-set-milestone-r4" x="24.4" y="434.8" textLength="109.8" 
clip-path="url(#breeze-ci-set-milestone-line-17)">--verbose</text><text 
class="breeze-ci-set-milestone-r9" x="158.6" y="434.8" textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-17)">-v</text><text 
class="breeze-ci-set-milestone-r1" x="207.4" y="434.8" textLeng [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="459.2" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-18)">│</text><text 
class="breeze-ci-set-milestone-r4" x="24.4" y="459.2" textLength="109.8" 
clip-path="url(#breeze-ci-set-milestone-line-18)">--dry-run</text><text 
class="breeze-ci-set-milestone-r9" x="158.6" y="459.2" textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-18)">-D</text><text 
class="breeze-ci-set-milestone-r1" x="207.4" y="459.2" textLeng [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="483.6" 
textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-19)">│</text><text 
class="breeze-ci-set-milestone-r4" x="24.4" y="483.6" textLength="109.8" 
clip-path="url(#breeze-ci-set-milestone-line-19)">--help&#160;&#160;&#160;</text><text
 class="breeze-ci-set-milestone-r9" x="158.6" y="483.6" textLength="24.4" 
clip-path="url(#breeze-ci-set-milestone-line-19)">-h</text><text 
class="breeze-ci-set-milestone-r1" x="207.4" y=" [...]
+</text><text class="breeze-ci-set-milestone-r5" x="0" y="508" 
textLength="1464" 
clip-path="url(#breeze-ci-set-milestone-line-20)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ci-set-milestone-r1" x="1464" y="508" textLength="12.2" 
clip-path="url(#breeze-ci-set-milestone-line-20)">
+</text>
+    </g>
+    </g>
+</svg>
diff --git a/dev/breeze/doc/images/output_ci_set-milestone.txt 
b/dev/breeze/doc/images/output_ci_set-milestone.txt
new file mode 100644
index 00000000000..2333f800620
--- /dev/null
+++ b/dev/breeze/doc/images/output_ci_set-milestone.txt
@@ -0,0 +1 @@
+7e6437d4c374aba8a6c9f459d3237ae0
diff --git a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg 
b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg
index 2802ddf54e2..11f9f680dd3 100644
--- a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg
+++ b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.svg
@@ -195,8 +195,8 @@
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="166.4" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-6)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r4" x="24.4" y="166.4" 
textLength="109.8" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-6)">--command</text><text
 class="breeze-setup-check-all-params-in-groups-r1" x="158.6" y="166.4" 
textLength="805.2" clip-path="url(#breeze-setup-check-all-params-in- [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="190.8" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-7)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="190.8" 
textLength="1134.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-7)">ci-image:build&#160;|&#160;ci-image:export-mount-cache&#160;|&#160;ci-image:import-mount-cache&#160;|&#160;ci-image:load&#160;|&#160;</text><text
 class="breeze [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="215.2" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-8)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="215.2" 
textLength="1281" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-8)">ci-image:pull&#160;|&#160;ci-image:save&#160;|&#160;ci-image:verify&#160;|&#160;ci:fix-ownership&#160;|&#160;ci:free-space&#160;|&#160;ci:get-workflow-info</text
 [...]
-</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="239.6" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-9)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="239.6" 
textLength="1085.8" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-9)">|&#160;ci:resource-check&#160;|&#160;ci:selective-check&#160;|&#160;ci:upgrade&#160;|&#160;cleanup&#160;|&#160;doctor&#160;|&#160;down&#160;|&#160;exec&#160;|&
 [...]
-</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-10)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="264" 
textLength="1207.8" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-10)">generate-migration-file&#160;|&#160;k8s&#160;|&#160;k8s:build-k8s-image&#160;|&#160;k8s:configure-cluster&#160;|&#160;k8s:create-cluster&#160;|&#160;</text><text
 [...]
+</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="239.6" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-9)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="239.6" 
textLength="1281" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-9)">|&#160;ci:resource-check&#160;|&#160;ci:selective-check&#160;|&#160;ci:set-milestone&#160;|&#160;ci:upgrade&#160;|&#160;cleanup&#160;|&#160;doctor&#160;|&#160;do
 [...]
+</text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-10)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="264" 
textLength="1232.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-10)">|&#160;generate-migration-file&#160;|&#160;k8s&#160;|&#160;k8s:build-k8s-image&#160;|&#160;k8s:configure-cluster&#160;|&#160;k8s:create-cluster&#160;|&#160;</tex
 [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="288.4" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-11)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="288.4" 
textLength="1195.6" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-11)">k8s:delete-cluster&#160;|&#160;k8s:deploy-airflow&#160;|&#160;k8s:dev&#160;|&#160;k8s:k9s&#160;|&#160;k8s:logs&#160;|&#160;k8s:run-complete-tests&#160;|&#160
 [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="312.8" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-12)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="312.8" 
textLength="1281" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-12)">k8s:setup-env&#160;|&#160;k8s:shell&#160;|&#160;k8s:status&#160;|&#160;k8s:tests&#160;|&#160;k8s:upload-k8s-image&#160;|&#160;prod-image&#160;|&#160;prod-image
 [...]
 </text><text class="breeze-setup-check-all-params-in-groups-r5" x="0" 
y="337.2" textLength="12.2" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-13)">│</text><text 
class="breeze-setup-check-all-params-in-groups-r6" x="158.6" y="337.2" 
textLength="1183.4" 
clip-path="url(#breeze-setup-check-all-params-in-groups-line-13)">|&#160;prod-image:load&#160;|&#160;prod-image:pull&#160;|&#160;prod-image:save&#160;|&#160;prod-image:verify&#160;|&#160;release-management&#160;|&#160;</tex
 [...]
diff --git a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt 
b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt
index 3b92374606c..d50a5e8b320 100644
--- a/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt
+++ b/dev/breeze/doc/images/output_setup_check-all-params-in-groups.txt
@@ -1 +1 @@
-506ec91a73aad6b8d861d1d5522e8613
+3bd93619bb2ae3508dd86849f3fbe4b1
diff --git a/dev/breeze/doc/images/output_setup_regenerate-command-images.svg 
b/dev/breeze/doc/images/output_setup_regenerate-command-images.svg
index ce1a474c442..91a13c44ed6 100644
--- a/dev/breeze/doc/images/output_setup_regenerate-command-images.svg
+++ b/dev/breeze/doc/images/output_setup_regenerate-command-images.svg
@@ -214,12 +214,12 @@
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="190.8" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-7)">│</text><text 
class="breeze-setup-regenerate-command-images-r4" x="24.4" y="190.8" 
textLength="146.4" 
clip-path="url(#breeze-setup-regenerate-command-images-line-7)">--command&#160;&#160;&#160;</text><text
 class="breeze-setup-regenerate-command-images-r1" x="195.2" y="190.8" 
textLength="805.2" clip-path="url(#breeze-setup-regener [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="215.2" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-8)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="215.2" 
textLength="1134.6" 
clip-path="url(#breeze-setup-regenerate-command-images-line-8)">ci-image:build&#160;|&#160;ci-image:export-mount-cache&#160;|&#160;ci-image:import-mount-cache&#160;|&#160;ci-image:load&#160;|&#160;</text><text
 class="breeze-set [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-9)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="239.6" 
textLength="1037" 
clip-path="url(#breeze-setup-regenerate-command-images-line-9)">ci-image:pull&#160;|&#160;ci-image:save&#160;|&#160;ci-image:verify&#160;|&#160;ci:fix-ownership&#160;|&#160;ci:free-space&#160;|&#160;</text><text
 class="breeze-set [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-10)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="264" 
textLength="1244.4" 
clip-path="url(#breeze-setup-regenerate-command-images-line-10)">ci:get-workflow-info&#160;|&#160;ci:resource-check&#160;|&#160;ci:selective-check&#160;|&#160;ci:upgrade&#160;|&#160;cleanup&#160;|&#160;doctor&#160;|&#160;down&#160
 [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="288.4" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-11)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="288.4" 
textLength="1037" 
clip-path="url(#breeze-setup-regenerate-command-images-line-11)">exec&#160;|&#160;generate-migration-file&#160;|&#160;k8s&#160;|&#160;k8s:build-k8s-image&#160;|&#160;k8s:configure-cluster&#160;|&#160;</text><text
 class="breeze-s [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="312.8" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-12)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="312.8" 
textLength="1146.8" 
clip-path="url(#breeze-setup-regenerate-command-images-line-12)">k8s:create-cluster&#160;|&#160;k8s:delete-cluster&#160;|&#160;k8s:deploy-airflow&#160;|&#160;k8s:dev&#160;|&#160;k8s:k9s&#160;|&#160;k8s:logs&#160;|&#160;</text>
 [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-13)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="337.2" 
textLength="1232.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-13)">k8s:run-complete-tests&#160;|&#160;k8s:setup-env&#160;|&#160;k8s:shell&#160;|&#160;k8s:status&#160;|&#160;k8s:tests&#160;|&#160;k8s:upload-k8s-image&#160;|&#160;
 [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="361.6" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-14)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="361.6" 
textLength="1049.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-14)">prod-image&#160;|&#160;prod-image:build&#160;|&#160;prod-image:load&#160;|&#160;prod-image:pull&#160;|&#160;prod-image:save&#160;|&#160;</text><text
 class="breez [...]
-</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-15)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="386" 
textLength="1000.4" 
clip-path="url(#breeze-setup-regenerate-command-images-line-15)">prod-image:verify&#160;|&#160;release-management&#160;|&#160;release-management:add-back-references&#160;|&#160;</text><text
 class="breeze-setup-regenerate-command-i [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="264" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-10)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="264" 
textLength="1171.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-10)">ci:get-workflow-info&#160;|&#160;ci:resource-check&#160;|&#160;ci:selective-check&#160;|&#160;ci:set-milestone&#160;|&#160;ci:upgrade&#160;|&#160;</text><text
 class= [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="288.4" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-11)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="288.4" 
textLength="1061.4" 
clip-path="url(#breeze-setup-regenerate-command-images-line-11)">cleanup&#160;|&#160;doctor&#160;|&#160;down&#160;|&#160;exec&#160;|&#160;generate-migration-file&#160;|&#160;k8s&#160;|&#160;k8s:build-k8s-image&#160;|&#160;</te
 [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="312.8" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-12)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="312.8" 
textLength="1183.4" 
clip-path="url(#breeze-setup-regenerate-command-images-line-12)">k8s:configure-cluster&#160;|&#160;k8s:create-cluster&#160;|&#160;k8s:delete-cluster&#160;|&#160;k8s:deploy-airflow&#160;|&#160;k8s:dev&#160;|&#160;</text><text
 c [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="337.2" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-13)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="337.2" 
textLength="1207.8" 
clip-path="url(#breeze-setup-regenerate-command-images-line-13)">k8s:k9s&#160;|&#160;k8s:logs&#160;|&#160;k8s:run-complete-tests&#160;|&#160;k8s:setup-env&#160;|&#160;k8s:shell&#160;|&#160;k8s:status&#160;|&#160;k8s:tests&#160
 [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="361.6" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-14)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="361.6" 
textLength="1110.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-14)">k8s:upload-k8s-image&#160;|&#160;prod-image&#160;|&#160;prod-image:build&#160;|&#160;prod-image:load&#160;|&#160;prod-image:pull&#160;|&#160;</text><text
 class=" [...]
+</text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="386" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-15)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="386" 
textLength="1220" 
clip-path="url(#breeze-setup-regenerate-command-images-line-15)">prod-image:save&#160;|&#160;prod-image:verify&#160;|&#160;release-management&#160;|&#160;release-management:add-back-references&#160;|&#160;</text><text
 class="breeze- [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="410.4" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-16)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="410.4" 
textLength="1110.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-16)">release-management:check-release-files&#160;|&#160;release-management:clean-old-provider-artifacts&#160;|&#160;</text><text
 class="breeze-setup-regenerate-comman [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="434.8" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-17)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="434.8" 
textLength="1073.6" 
clip-path="url(#breeze-setup-regenerate-command-images-line-17)">release-management:constraints-version-check&#160;|&#160;release-management:create-minor-branch&#160;|&#160;</text><text
 class="breeze-setup-regenerate-command-i [...]
 </text><text class="breeze-setup-regenerate-command-images-r5" x="0" y="459.2" 
textLength="12.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-18)">│</text><text 
class="breeze-setup-regenerate-command-images-r6" x="195.2" y="459.2" 
textLength="1110.2" 
clip-path="url(#breeze-setup-regenerate-command-images-line-18)">release-management:generate-constraints&#160;|&#160;release-management:generate-issue-content-core&#160;|&#160;</text><text
 class="breeze-setup-regenerate-comman [...]
diff --git a/dev/breeze/doc/images/output_setup_regenerate-command-images.txt 
b/dev/breeze/doc/images/output_setup_regenerate-command-images.txt
index c7a09427163..98088450668 100644
--- a/dev/breeze/doc/images/output_setup_regenerate-command-images.txt
+++ b/dev/breeze/doc/images/output_setup_regenerate-command-images.txt
@@ -1 +1 @@
-0ba77107bd20c94bc1056658ede5dbf0
+6b4f955fd531841d67223b6c86f69c77
diff --git a/dev/breeze/src/airflow_breeze/commands/ci_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
index f9f65965c59..95dfea5b107 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands.py
@@ -28,7 +28,7 @@ import tempfile
 from collections.abc import Iterable
 from io import StringIO
 from pathlib import Path
-from typing import Any, NamedTuple
+from typing import TYPE_CHECKING, Any, NamedTuple
 
 import click
 
@@ -41,6 +41,8 @@ from airflow_breeze.commands.common_options import (
 )
 from airflow_breeze.global_constants import (
     DEFAULT_PYTHON_MAJOR_MINOR_VERSION,
+    MILESTONE_BUG_LABELS,
+    MILESTONE_SKIP_LABELS,
     PUBLIC_AMD_RUNNERS,
     GithubEvents,
     github_events,
@@ -58,6 +60,10 @@ from airflow_breeze.utils.docker_command_utils import (
 from airflow_breeze.utils.path_utils import AIRFLOW_HOME_PATH, 
AIRFLOW_ROOT_PATH
 from airflow_breeze.utils.run_utils import run_command
 
+if TYPE_CHECKING:
+    from github import Github
+    from github.Repository import Issue, Milestone, Repository
+
 
 @click.group(cls=BreezeGroup, name="ci", help="Tools that CI workflows use to 
cleanup/manage CI environment")
 def ci_group():
@@ -749,3 +755,326 @@ def upgrade(target_branch: str, create_pr: bool | None, 
switch_to_base: bool | N
         get_console().print(f"[success]Local branch {branch_name} deleted.[/]")
     else:
         get_console().print("[info]PR creation skipped. Changes are committed 
locally.[/]")
+
+
+VERSION_BRANCH_PATTERN = re.compile(r"^v(\d+)-(\d+)-test$")
+BACKPORT_LABEL_PATTERN = re.compile(r"^backport-to-v(\d+)-(\d+)-test$")
+
+
+def _parse_version_from_branch(branch: str) -> tuple[int, int] | None:
+    """Parse major and minor version from a branch name like 'v3-1-test'."""
+    match = VERSION_BRANCH_PATTERN.match(branch)
+    if match:
+        return int(match.group(1)), int(match.group(2))
+    return None
+
+
+def _parse_version_from_backport_label(label: str) -> tuple[int, int] | None:
+    """Parse major and minor version from a backport label like 
'backport-to-v3-1-test'."""
+    match = BACKPORT_LABEL_PATTERN.match(label)
+    if match:
+        return int(match.group(1)), int(match.group(2))
+    return None
+
+
+def _get_milestone_prefix(major: int, minor: int) -> str:
+    """Get the milestone prefix for a given version like 'Airflow 3.1'."""
+    return f"Airflow {major}.{minor}"
+
+
+def _get_github_client(github_token: str) -> Github:
+    """Create a GitHub client with the given token."""
+    from github import Github
+
+    return Github(github_token)
+
+
+def _find_matching_milestone(repo: Repository, milestone_prefix: str) -> 
Milestone | None:
+    """Find the latest matching milestone that starts with the given prefix."""
+    try:
+        milestones = list(repo.get_milestones(state="open"))
+        matching = [m for m in milestones if 
m.title.startswith(milestone_prefix)]
+        if not matching:
+            return None
+        # Sort by title to get the latest patch version (e.g., Airflow 3.1.8 > 
Airflow 3.1.7)
+        matching.sort(key=lambda m: m.title, reverse=True)
+        return matching[0]
+    except Exception as e:
+        get_console().print(f"[error]Failed to get milestones: {e}[/]")
+        return None
+
+
+def _parse_milestone_version(title: str) -> tuple[int, int, int] | None:
+    """Parse version from milestone title like 'Airflow 3.1.8' or 'Airflow 
3.2'."""
+    if not title.startswith("Airflow "):
+        return None
+    version_part = title.replace("Airflow ", "")
+    parts = version_part.split(".")
+    if len(parts) < 2:
+        return None
+    try:
+        major = int(parts[0])
+        minor = int(parts[1])
+        patch = int(parts[2]) if len(parts) > 2 else 0
+        return major, minor, patch
+    except ValueError:
+        return None
+
+
+def _find_latest_milestone(repo: Repository) -> Milestone | None:
+    """Find the latest (highest version) open milestone."""
+    try:
+        milestones = list(repo.get_milestones(state="open"))
+        # Filter for Airflow milestones and parse versions
+        airflow_milestones: list[tuple[Milestone, tuple[int, int, int]]] = []
+        for m in milestones:
+            version = _parse_milestone_version(m.title)
+            if version:
+                airflow_milestones.append((m, version))
+
+        if not airflow_milestones:
+            return None
+
+        # Sort by version (major, minor, patch) descending to get the latest
+        airflow_milestones.sort(key=lambda x: x[1], reverse=True)
+        return airflow_milestones[0][0]
+    except Exception as e:
+        get_console().print(f"[error]Failed to get milestones: {e}[/]")
+        return None
+
+
+def _get_mention(merged_by_login: str) -> str:
+    """Get the mention string for a user."""
+    return f"@{merged_by_login}" if merged_by_login and merged_by_login != 
"unknown" else "maintainer"
+
+
+def _get_milestone_notification_comment(
+    milestone_title: str, milestone_number: int, merged_by_login: str, reason: 
str, github_repository: str
+) -> str:
+    """Generate the notification comment for auto-set milestone."""
+    mention = _get_mention(merged_by_login)
+
+    return f"""Hi {mention}, this PR was merged without a milestone set.
+We've automatically set the milestone to 
**[{milestone_title}](https://github.com/{github_repository}/milestone/{milestone_number})**
 based on: {reason}
+If this milestone is not correct, please update it to the appropriate 
milestone.
+
+> This comment was generated by [Milestone Tag 
Assistant](https://github.com/{github_repository}/blob/main/.github/workflows/milestone-tag-assistant.yml).
+"""
+
+
+def _get_milestone_not_found_comment(
+    merged_by_login: str, reason: str, github_repository: str, 
search_criteria: str
+) -> str:
+    """Generate the notification comment when no matching milestone is 
found."""
+    mention = _get_mention(merged_by_login)
+
+    return f"""Hi {mention}, this PR was merged without a milestone set.
+We tried to automatically set a milestone based on: {reason}
+However, **no open milestone was found** matching: {search_criteria}
+
+**Action required:** Please manually set the appropriate milestone for this PR.
+
+> This comment was generated by [Milestone Tag 
Assistant](https://github.com/{github_repository}/blob/main/.github/workflows/milestone-tag-assistant.yml).
+"""
+
+
+def _has_bug_fix_indicators(title: str, labels: list[str]) -> bool:
+    """Check if the PR has indicators that it's a bug fix."""
+    title_lower = title.lower()
+    if "fix" in title_lower or "bug" in title_lower:
+        return True
+    if set(labels) & MILESTONE_BUG_LABELS:
+        return True
+    return False
+
+
+def _should_skip_milestone_tagging(labels: list[str]) -> bool:
+    """Check if the PR should be skipped from milestone auto-tagging."""
+    return bool(set(labels) & MILESTONE_SKIP_LABELS)
+
+
+def _get_backport_version_from_labels(labels: list[str]) -> tuple[int, int] | 
None:
+    """Find the first backport label and extract version from it."""
+    for label in labels:
+        if label.startswith("backport-to-"):
+            version = _parse_version_from_backport_label(label)
+            if version:
+                return version
+    return None
+
+
+def _determine_milestone_version(
+    labels: list[str], title: str, base_branch: str
+) -> tuple[tuple[int, int] | None, str]:
+    """Determine which milestone version to use based on PR criteria.
+
+    :returns: Tuple of (version, reason) where version can be:
+        - (major, minor) tuple for specific version milestone (patch releases)
+        - None if no milestone should be set
+    """
+    # Priority 1: Check for backport labels - use specific version milestone
+    backport_version = _get_backport_version_from_labels(labels)
+    if backport_version:
+        return backport_version, f"backport label targeting 
v{backport_version[0]}-{backport_version[1]}-test"
+
+    # Priority 2: Check if merged to a version branch - use that version's 
milestone
+    version = _parse_version_from_branch(base_branch)
+    if version:
+        if _has_bug_fix_indicators(title, labels):
+            return version, "bug fix merged to version branch"
+        # Non-bug fix merged to version branch still gets that version's 
milestone
+        return version, "merged to version branch"
+
+    return None, "no backport label and not merged to a version branch"
+
+
+@ci_group.command(
+    name="set-milestone",
+    help="Set milestone on a merged PR if it doesn't have one. Used by the 
milestone-tag-assistant workflow.",
+)
[email protected](
+    "--pr-number",
+    help="The PR number to set milestone on",
+    envvar="PR_NUMBER",
+    required=True,
+    type=int,
+)
[email protected](
+    "--pr-title",
+    help="The PR title",
+    envvar="PR_TITLE",
+    default="",
+)
[email protected](
+    "--pr-labels",
+    help="JSON array of PR label names",
+    envvar="PR_LABELS",
+    default="[]",
+)
[email protected](
+    "--base-branch",
+    help="The base branch the PR was merged to",
+    envvar="BASE_BRANCH",
+    default="",
+)
[email protected](
+    "--merged-by",
+    help="GitHub username of the person who merged the PR",
+    envvar="MERGED_BY",
+    default="unknown",
+)
[email protected](
+    "--github-token",
+    help="GitHub token for API access",
+    envvar="GH_TOKEN",
+    required=True,
+)
+@option_github_repository
+@option_verbose
+@option_dry_run
+def set_milestone(
+    pr_number: int,
+    pr_title: str,
+    pr_labels: str,
+    base_branch: str,
+    merged_by: str,
+    github_token: str,
+    github_repository: str,
+):
+    """Set milestone on a merged PR based on backport labels or bug fix 
indicators."""
+    from github import UnknownObjectException
+
+    get_console().print(f"[info]Processing PR #{pr_number}[/]")
+    get_console().print(f"[info]Title: {pr_title}[/]")
+    get_console().print(f"[info]Base branch: {base_branch}[/]")
+    get_console().print(f"[info]Merged by: {merged_by}[/]")
+
+    # Parse labels from JSON
+    try:
+        labels = json.loads(pr_labels)
+    except json.JSONDecodeError:
+        get_console().print(f"[warning]Could not parse labels JSON: 
{pr_labels}[/]")
+        labels = []
+
+    get_console().print(f"[info]Labels: {labels}[/]")
+
+    # Check if we should skip
+    if _should_skip_milestone_tagging(labels):
+        get_console().print(
+            f"[info]Skipping milestone tagging - PR has skip label(s): 
{set(labels) & MILESTONE_SKIP_LABELS}[/]"
+        )
+        return
+
+    # Determine which milestone to use
+    version, reason = _determine_milestone_version(labels, pr_title, 
base_branch)
+    if version is None:
+        get_console().print(f"[info]No milestone to set: {reason}[/]")
+        return
+
+    # Initialize GitHub client and get repository
+    try:
+        gh = _get_github_client(github_token)
+        repo: Repository = gh.get_repo(github_repository)
+    except Exception as e:
+        get_console().print(f"[error]Failed to connect to GitHub: {e}[/]")
+        return
+
+    # Double check whether the PR already has a milestone set - if so, we 
don't want to override it
+    try:
+        issue: Issue = repo.get_issue(pr_number)
+        if issue.milestone is not None:
+            get_console().print(
+                f"[info]PR #{pr_number} already has milestone 
'{issue.milestone.title}' set. Skipping.[/]"
+            )
+            return
+    except UnknownObjectException:
+        get_console().print(f"[error]PR #{pr_number} not found when checking 
existing milestone[/]")
+        return
+    except Exception as e:
+        get_console().print(f"[error]Failed to check existing milestone: 
{e}[/]")
+        return
+
+    major, minor = version
+    milestone_prefix = _get_milestone_prefix(major, minor)
+    get_console().print(f"[info]Looking for milestone with prefix: 
{milestone_prefix}[/]")
+    milestone = _find_matching_milestone(repo, milestone_prefix)
+    search_criteria = f"prefix '{milestone_prefix}'"
+
+    if not milestone:
+        get_console().print(f"[warning]No open milestone found matching: 
{search_criteria}[/]")
+        # Add reminder comment for committer
+        try:
+            issue = repo.get_issue(pr_number)
+            comment = _get_milestone_not_found_comment(merged_by, reason, 
github_repository, search_criteria)
+            issue.create_comment(comment)
+            get_console().print(f"[info]Added reminder comment to PR 
#{pr_number}[/]")
+        except Exception as e:
+            get_console().print(f"[warning]Failed to add reminder comment: 
{e}[/]")
+        return
+
+    get_console().print(f"[info]Found milestone: {milestone.title} 
(#{milestone.number})[/]")
+
+    # Get the issue (PRs are issues in GitHub API)
+    try:
+        issue = repo.get_issue(pr_number)
+    except UnknownObjectException:
+        get_console().print(f"[error]PR #{pr_number} not found[/]")
+        return
+
+    # Set milestone on PR
+    try:
+        issue.edit(milestone=milestone)
+        get_console().print(f"[success]Successfully set milestone 
'{milestone.title}' on PR #{pr_number}[/]")
+    except Exception as e:
+        get_console().print(f"[error]Failed to set milestone on PR 
#{pr_number}: {e}[/]")
+        return
+
+    # Add notification comment
+    comment = _get_milestone_notification_comment(
+        milestone.title, milestone.number, merged_by, reason, github_repository
+    )
+    try:
+        issue.create_comment(comment)
+        get_console().print(f"[success]Added notification comment to PR 
#{pr_number}[/]")
+    except Exception as e:
+        get_console().print(f"[warning]Failed to add notification comment to 
PR #{pr_number}: {e}[/]")
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 9f822c4365d..2df31059727 100644
--- a/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/ci_commands_config.py
@@ -25,6 +25,7 @@ CI_COMMANDS: dict[str, str | list[str]] = {
         "selective-check",
         "get-workflow-info",
         "upgrade",
+        "set-milestone",
     ],
 }
 CI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
@@ -77,4 +78,23 @@ CI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = 
{
             ],
         }
     ],
+    "breeze ci set-milestone": [
+        {
+            "name": "PR information",
+            "options": [
+                "--pr-number",
+                "--pr-title",
+                "--pr-labels",
+                "--base-branch",
+                "--merged-by",
+            ],
+        },
+        {
+            "name": "GitHub parameters",
+            "options": [
+                "--github-token",
+                "--github-repository",
+            ],
+        },
+    ],
 }
diff --git a/dev/breeze/src/airflow_breeze/global_constants.py 
b/dev/breeze/src/airflow_breeze/global_constants.py
index d7c49348e5c..b969e7d2efc 100644
--- a/dev/breeze/src/airflow_breeze/global_constants.py
+++ b/dev/breeze/src/airflow_breeze/global_constants.py
@@ -770,6 +770,12 @@ ALL_PYTHON_VERSION_TO_PATCHLEVEL_VERSION: dict[str, str] = 
{
 # Number of slices for low dep tests
 NUMBER_OF_LOW_DEP_SLICES = 5
 
+# Milestone Tag Assistant configuration
+# Labels indicating a bug fix PR that should have a milestone
+MILESTONE_BUG_LABELS: frozenset[str] = frozenset({"kind:bug", "type:bug-fix"})
+# Labels that indicate the PR should be skipped from milestone auto-tagging
+MILESTONE_SKIP_LABELS: frozenset[str] = frozenset({"area:dev-tools", 
"area:dev-env", "area:CI"})
+
 
 class GithubEvents(Enum):
     PULL_REQUEST = "pull_request"
diff --git a/dev/breeze/tests/test_set_milestone.py 
b/dev/breeze/tests/test_set_milestone.py
new file mode 100644
index 00000000000..e7c1c291534
--- /dev/null
+++ b/dev/breeze/tests/test_set_milestone.py
@@ -0,0 +1,569 @@
+# 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 json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from airflow_breeze.commands.ci_commands import (
+    _determine_milestone_version,
+    _find_latest_milestone,
+    _find_matching_milestone,
+    _get_backport_version_from_labels,
+    _get_mention,
+    _get_milestone_not_found_comment,
+    _get_milestone_notification_comment,
+    _get_milestone_prefix,
+    _has_bug_fix_indicators,
+    _parse_milestone_version,
+    _parse_version_from_backport_label,
+    _parse_version_from_branch,
+    _should_skip_milestone_tagging,
+)
+
+
+class TestParseVersionFromBranch:
+    """Test cases for _parse_version_from_branch."""
+
+    @pytest.mark.parametrize(
+        ("branch", "expected"),
+        [
+            ("v3-1-test", (3, 1)),
+            ("v2-10-test", (2, 10)),
+            ("v10-0-test", (10, 0)),
+        ],
+    )
+    def test_valid_version_branch(self, branch, expected):
+        assert _parse_version_from_branch(branch) == expected
+
+    @pytest.mark.parametrize(
+        "branch",
+        ["main", "v3-test", "v3-1", "feature-branch"],
+    )
+    def test_invalid_version_branch(self, branch):
+        assert _parse_version_from_branch(branch) is None
+
+
+class TestParseVersionFromBackportLabel:
+    """Test cases for _parse_version_from_backport_label."""
+
+    @pytest.mark.parametrize(
+        ("label", "expected"),
+        [
+            ("backport-to-v3-1-test", (3, 1)),
+            ("backport-to-v2-10-test", (2, 10)),
+        ],
+    )
+    def test_valid_backport_label(self, label, expected):
+        assert _parse_version_from_backport_label(label) == expected
+
+    @pytest.mark.parametrize(
+        "label",
+        ["backport-v3-1-test", "backport-to-main", "some-label"],
+    )
+    def test_invalid_backport_label(self, label):
+        assert _parse_version_from_backport_label(label) is None
+
+
+class TestGetMilestonePrefix:
+    """Test cases for _get_milestone_prefix."""
+
+    @pytest.mark.parametrize(
+        ("major", "minor", "expected"),
+        [
+            (3, 1, "Airflow 3.1"),
+            (2, 10, "Airflow 2.10"),
+        ],
+    )
+    def test_milestone_prefix(self, major, minor, expected):
+        assert _get_milestone_prefix(major, minor) == expected
+
+
+class TestParseMilestoneVersion:
+    """Test cases for _parse_milestone_version."""
+
+    @pytest.mark.parametrize(
+        ("title", "expected"),
+        [
+            ("Airflow 3.1.8", (3, 1, 8)),
+            ("Airflow 3.2", (3, 2, 0)),
+            ("Airflow 2.10.5", (2, 10, 5)),
+        ],
+    )
+    def test_valid_milestone_version(self, title, expected):
+        assert _parse_milestone_version(title) == expected
+
+    @pytest.mark.parametrize(
+        "title",
+        ["Something else", "Airflow", "Airflow 3"],
+    )
+    def test_invalid_milestone_version(self, title):
+        assert _parse_milestone_version(title) is None
+
+
+class TestHasBugFixIndicators:
+    """Test cases for _has_bug_fix_indicators."""
+
+    @pytest.mark.parametrize(
+        ("title", "labels"),
+        [
+            ("Fix: something broken", []),
+            ("fix issue with scheduler", []),
+            ("Bug in executor", []),
+            ("BUG: critical issue", []),
+            ("Normal title", ["kind:bug"]),
+            ("Normal title", ["type:bug-fix"]),
+        ],
+    )
+    def test_has_bug_indicators(self, title, labels):
+        assert _has_bug_fix_indicators(title, labels)
+
+    def test_no_bug_indicators(self):
+        assert not _has_bug_fix_indicators("Add new feature", ["kind:feature"])
+
+
+class TestShouldSkipMilestoneTagging:
+    """Test cases for _should_skip_milestone_tagging."""
+
+    @pytest.mark.parametrize(
+        "labels",
+        [
+            ["area:dev-tools"],
+            ["area:dev-env"],
+            ["area:CI"],
+        ],
+    )
+    def test_skip_with_skip_labels(self, labels):
+        assert _should_skip_milestone_tagging(labels)
+
+    def test_no_skip_without_skip_labels(self):
+        assert not _should_skip_milestone_tagging(["kind:feature", 
"area:scheduler"])
+
+
+class TestGetBackportVersionFromLabels:
+    """Test cases for _get_backport_version_from_labels."""
+
+    def test_backport_label_found(self):
+        labels = ["kind:feature", "backport-to-v3-1-test", "other-label"]
+        assert _get_backport_version_from_labels(labels) == (3, 1)
+
+    def test_no_backport_label(self):
+        labels = ["kind:feature", "other-label"]
+        assert _get_backport_version_from_labels(labels) is None
+
+
+class TestDetermineMilestoneVersion:
+    """Test cases for _determine_milestone_version."""
+
+    @pytest.mark.parametrize(
+        (
+            "labels",
+            "title",
+            "base_branch",
+            "expected_version",
+            "expected_reason_substring",
+        ),
+        [
+            (["backport-to-v3-1-test"], "Some title", "main", (3, 1), 
"backport label"),
+            ([], "Fix: something", "v3-1-test", (3, 1), "bug fix"),
+            ([], "Add feature", "v3-1-test", (3, 1), "merged to version 
branch"),
+            ([], "Add feature", "main", None, "not merged to a version 
branch"),
+        ],
+    )
+    def test_determine_milestone_version(
+        self, labels, title, base_branch, expected_version, 
expected_reason_substring
+    ):
+        version, reason = _determine_milestone_version(labels, title, 
base_branch)
+        assert version == expected_version
+        assert expected_reason_substring in reason
+
+
+class TestGetMention:
+    """Test cases for _get_mention."""
+
+    @pytest.mark.parametrize(
+        ("merged_by_login", "expected"),
+        [
+            ("testuser", "@testuser"),
+            ("unknown", "maintainer"),
+            ("", "maintainer"),
+            (None, "maintainer"),
+        ],
+    )
+    def test_mention(self, merged_by_login, expected):
+        assert _get_mention(merged_by_login) == expected
+
+
+class TestGetMilestoneNotificationComment:
+    """Test cases for _get_milestone_notification_comment."""
+
+    def test_notification_comment_content(self):
+        comment = _get_milestone_notification_comment(
+            "Airflow 3.1.8", 42, "testuser", "bug fix", "apache/airflow"
+        )
+        assert "@testuser" in comment
+        assert "Airflow 3.1.8" in comment
+        assert "bug fix" in comment
+        assert "milestone/42" in comment
+
+
+class TestGetMilestoneNotFoundComment:
+    """Test cases for _get_milestone_not_found_comment."""
+
+    def test_not_found_comment(self):
+        comment = _get_milestone_not_found_comment(
+            "testuser", "bug fix", "apache/airflow", "prefix 'Airflow 3.1'"
+        )
+        assert "@testuser" in comment
+        assert "no open milestone was found" in comment
+        assert "Action required" in comment
+
+
+class TestFindMatchingMilestone:
+    """Test cases for _find_matching_milestone."""
+
+    def test_find_matching_milestone(self):
+        mock_repo = MagicMock()
+        mock_milestone1 = MagicMock()
+        mock_milestone1.title = "Airflow 3.1.7"
+        mock_milestone2 = MagicMock()
+        mock_milestone2.title = "Airflow 3.1.8"
+        mock_milestone3 = MagicMock()
+        mock_milestone3.title = "Airflow 3.2"
+        mock_repo.get_milestones.return_value = [mock_milestone1, 
mock_milestone2, mock_milestone3]
+
+        result = _find_matching_milestone(mock_repo, "Airflow 3.1")
+        assert result.title == "Airflow 3.1.8"  # Should get the latest patch 
version
+
+    def test_no_matching_milestone(self):
+        mock_repo = MagicMock()
+        mock_milestone = MagicMock()
+        mock_milestone.title = "Airflow 3.2"
+        mock_repo.get_milestones.return_value = [mock_milestone]
+
+        result = _find_matching_milestone(mock_repo, "Airflow 3.1")
+        assert result is None
+
+
+class TestFindLatestMilestone:
+    """Test cases for _find_latest_milestone."""
+
+    def test_find_latest_milestone(self):
+        mock_repo = MagicMock()
+        mock_milestone1 = MagicMock()
+        mock_milestone1.title = "Airflow 3.1.8"
+        mock_milestone2 = MagicMock()
+        mock_milestone2.title = "Airflow 3.2"
+        mock_milestone3 = MagicMock()
+        mock_milestone3.title = "Airflow 2.10.5"
+        mock_repo.get_milestones.return_value = [mock_milestone1, 
mock_milestone2, mock_milestone3]
+
+        result = _find_latest_milestone(mock_repo)
+        assert result.title == "Airflow 3.2"  # Should get the highest version
+
+    def test_no_milestone_found(self):
+        mock_repo = MagicMock()
+        mock_repo.get_milestones.return_value = []
+
+        result = _find_latest_milestone(mock_repo)
+        assert result is None
+
+
+class TestSetMilestoneCommand:
+    """Test cases for set_milestone command."""
+
+    @pytest.fixture
+    def cli_runner(self):
+        """Create a CliRunner for testing CLI commands."""
+        from click.testing import CliRunner
+
+        return CliRunner()
+
+    @pytest.fixture
+    def mock_github_setup(self):
+        """Set up mock GitHub client, repo, and issue."""
+        mock_gh = MagicMock()
+        mock_repo = MagicMock()
+        mock_issue = MagicMock()
+
+        mock_gh.get_repo.return_value = mock_repo
+        mock_repo.get_issue.return_value = mock_issue
+
+        return mock_gh, mock_repo, mock_issue
+
+    @pytest.mark.parametrize(
+        ("base_branch", "skip_label"),
+        [
+            ("main", "area:CI"),
+            ("main", "area:dev-tools"),
+            ("main", "area:dev-env"),
+            ("v3-1-test", "area:CI"),
+            ("v3-1-test", "area:dev-tools"),
+            ("v3-1-test", "area:dev-env"),
+        ],
+    )
+    @patch("airflow_breeze.commands.ci_commands._get_github_client")
+    def test_skip_label_should_skip(self, mock_get_client, base_branch, 
skip_label, cli_runner):
+        """When PR has a skip label, milestone tagging should be skipped."""
+        from airflow_breeze.commands.ci_commands import ci_group
+
+        result = cli_runner.invoke(
+            ci_group,
+            [
+                "set-milestone",
+                "--pr-number",
+                "12345",
+                "--pr-title",
+                "CI: update workflow",
+                "--pr-labels",
+                json.dumps([skip_label]),
+                "--base-branch",
+                base_branch,
+                "--merged-by",
+                "testuser",
+                "--github-token",
+                "fake-token",
+            ],
+        )
+
+        mock_get_client.assert_not_called()
+        assert "Skipping milestone tagging" in result.output
+
+    @patch("airflow_breeze.commands.ci_commands._get_github_client")
+    def test_main_branch_without_backport_label_should_skip(self, 
mock_get_client, cli_runner):
+        """When PR is merged to main without backport label, milestone tagging 
should be skipped."""
+        from airflow_breeze.commands.ci_commands import ci_group
+
+        result = cli_runner.invoke(
+            ci_group,
+            [
+                "set-milestone",
+                "--pr-number",
+                "12345",
+                "--pr-title",
+                "Add new feature",
+                "--pr-labels",
+                json.dumps(["kind:feature"]),
+                "--base-branch",
+                "main",
+                "--merged-by",
+                "testuser",
+                "--github-token",
+                "fake-token",
+            ],
+        )
+
+        mock_get_client.assert_not_called()
+        assert "No milestone to set" in result.output
+
+    @pytest.mark.parametrize(
+        ("base_branch", "pr_title", "pr_labels", "milestone_title", 
"expected_reason"),
+        [
+            # version branch - finds matching milestone (bug fix)
+            (
+                "v3-1-test",
+                "Fix: scheduler issue",
+                ["kind:bug"],
+                "Airflow 3.1.8",
+                "bug fix merged to version branch",
+            ),
+            # version branch - finds matching milestone (non-bug)
+            (
+                # Since we are on v3-1-test branch
+                # so even the PR title and labels doesn't indicate a bug fix, 
we should still find the matching milestone for the version branch.
+                "v3-1-test",
+                "Add missing configuration",
+                ["kind:documentation"],
+                "Airflow 3.1.8",
+                "merged to version branch",
+            ),
+            # backport label - finds version milestone
+            (
+                "main",
+                "Add missing configuration",
+                ["backport-to-v3-1-test", "kind:documentation"],
+                "Airflow 3.1.8",
+                "backport label targeting v3-1-test",
+            ),
+        ],
+    )
+    @patch("airflow_breeze.commands.ci_commands._get_github_client")
+    def test_find_milestone_should_set_and_comment(
+        self,
+        mock_get_client,
+        base_branch,
+        pr_title,
+        pr_labels,
+        milestone_title,
+        expected_reason,
+        cli_runner,
+        mock_github_setup,
+    ):
+        """When milestone is found, should set it and add comment."""
+        from airflow_breeze.commands.ci_commands import ci_group
+
+        mock_gh, mock_repo, mock_issue = mock_github_setup
+        mock_issue.milestone = None
+        mock_milestone = MagicMock()
+        mock_milestone.title = milestone_title
+        mock_milestone.number = 42
+
+        mock_get_client.return_value = mock_gh
+        mock_repo.get_milestones.return_value = [mock_milestone]
+
+        captured_comments: list[str] = []
+        mock_issue.create_comment.side_effect = lambda c: 
captured_comments.append(c)
+
+        result = cli_runner.invoke(
+            ci_group,
+            [
+                "set-milestone",
+                "--pr-number",
+                "12345",
+                "--pr-title",
+                pr_title,
+                "--pr-labels",
+                json.dumps(pr_labels),
+                "--base-branch",
+                base_branch,
+                "--merged-by",
+                "testuser",
+                "--github-token",
+                "fake-token",
+            ],
+        )
+
+        mock_issue.edit.assert_called_once_with(milestone=mock_milestone)
+        mock_issue.create_comment.assert_called_once()
+        assert len(captured_comments) == 1
+
+        expected_comment = f"""Hi @testuser, this PR was merged without a 
milestone set.
+We've automatically set the milestone to 
**[{milestone_title}](https://github.com/apache/airflow/milestone/42)** based 
on: {expected_reason}
+If this milestone is not correct, please update it to the appropriate 
milestone.
+
+> This comment was generated by [Milestone Tag 
Assistant](https://github.com/apache/airflow/blob/main/.github/workflows/milestone-tag-assistant.yml).
+"""
+        assert captured_comments[0] == expected_comment
+        assert "Successfully set milestone" in result.output
+        assert milestone_title in result.output
+
+    @patch("airflow_breeze.commands.ci_commands._get_github_client")
+    def test_milestone_already_set_should_skip(self, mock_get_client, 
cli_runner, mock_github_setup):
+        """When PR already has a milestone, should skip."""
+        from airflow_breeze.commands.ci_commands import ci_group
+
+        mock_gh, mock_repo, mock_issue = mock_github_setup
+        existing_milestone = MagicMock()
+        existing_milestone.title = "Existing Milestone"
+        mock_issue.milestone = existing_milestone
+        mock_get_client.return_value = mock_gh
+
+        result = cli_runner.invoke(
+            ci_group,
+            [
+                "set-milestone",
+                "--pr-number",
+                "12345",
+                "--pr-title",
+                "Some nice feature",
+                "--base-branch",
+                "v3-1-test",
+                "--github-token",
+                "fake-token",
+            ],
+        )
+
+        mock_issue.edit.assert_not_called()
+        mock_issue.create_comment.assert_not_called()
+        # Rich console adds formatting/colors, so checking for parts of the 
string
+        assert "already has milestone" in result.output
+        assert "Existing Milestone" in result.output
+        assert "Skipping" in result.output
+
+    @pytest.mark.parametrize(
+        ("base_branch", "pr_title", "pr_labels", "milestones", 
"expected_reason", "expected_search_criteria"),
+        [
+            # version branch - no matching milestone (only 3.2 exists, need 
3.1)
+            (
+                "v3-1-test",
+                "Fix: scheduler issue",
+                ["kind:bug"],
+                [MagicMock(title="Airflow 3.2")],
+                "bug fix merged to version branch",
+                "prefix 'Airflow 3.1'",
+            ),
+        ],
+    )
+    @patch("airflow_breeze.commands.ci_commands._get_github_client")
+    def test_not_find_milestone_should_comment_warning(
+        self,
+        mock_get_client,
+        base_branch,
+        pr_title,
+        pr_labels,
+        milestones,
+        expected_reason,
+        expected_search_criteria,
+        cli_runner,
+        mock_github_setup,
+    ):
+        """When no milestone is found, should add warning comment."""
+        from airflow_breeze.commands.ci_commands import ci_group
+
+        mock_gh, mock_repo, mock_issue = mock_github_setup
+        mock_issue.milestone = None
+        captured_comments: list[str] = []
+        mock_issue.create_comment.side_effect = lambda c: 
captured_comments.append(c)
+
+        mock_get_client.return_value = mock_gh
+        mock_repo.get_milestones.return_value = milestones
+
+        result = cli_runner.invoke(
+            ci_group,
+            [
+                "set-milestone",
+                "--pr-number",
+                "12345",
+                "--pr-title",
+                pr_title,
+                "--pr-labels",
+                json.dumps(pr_labels),
+                "--base-branch",
+                base_branch,
+                "--merged-by",
+                "testuser",
+                "--github-token",
+                "fake-token",
+            ],
+        )
+
+        mock_issue.edit.assert_not_called()
+        mock_issue.create_comment.assert_called_once()
+        assert len(captured_comments) == 1
+
+        expected_comment = f"""Hi @testuser, this PR was merged without a 
milestone set.
+We tried to automatically set a milestone based on: {expected_reason}
+However, **no open milestone was found** matching: {expected_search_criteria}
+
+**Action required:** Please manually set the appropriate milestone for this PR.
+
+> This comment was generated by [Milestone Tag 
Assistant](https://github.com/apache/airflow/blob/main/.github/workflows/milestone-tag-assistant.yml).
+"""
+        assert captured_comments[0] == expected_comment
+        assert "No open milestone found" in result.output

Reply via email to