| # 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 |
| import re |
| from datetime import datetime, timezone |
| 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, |
| _get_removed_backport_labels_from_events, |
| _has_bug_fix_indicators, |
| _parse_milestone_version, |
| _parse_version_from_backport_label, |
| _parse_version_from_branch, |
| _should_skip_milestone_tagging, |
| ) |
| |
| _ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") |
| |
| |
| def _plain_output(output: str) -> str: |
| """Strip ANSI color codes and collapse whitespace so wrap-tolerant substring |
| asserts don't trip over Rich's color escapes or soft line wraps.""" |
| return " ".join(_ANSI_ESCAPE_RE.sub("", output).split()) |
| |
| |
| def _label(name: str) -> MagicMock: |
| """Build a mock that quacks like a PyGithub ``Label`` for ``issue.labels``.""" |
| m = MagicMock() |
| m.name = name |
| return m |
| |
| |
| def _unlabel_event(label_name: str, actor_login: str, when: datetime) -> MagicMock: |
| """Build a mock that quacks like a PyGithub IssueEvent for an ``unlabeled`` event.""" |
| event = MagicMock() |
| event.event = "unlabeled" |
| event.label = _label(label_name) |
| event.actor = MagicMock() |
| event.actor.login = actor_login |
| event.created_at = when |
| return event |
| |
| |
| def _issue_event( |
| event_name: str, |
| actor_login: str, |
| when: str, |
| label_name: str | None = None, |
| ) -> MagicMock: |
| """Build a mock shaped like a PyGithub ``IssueEvent`` for any event kind.""" |
| event = MagicMock() |
| event.event = event_name |
| event.label = _label(label_name) if label_name else None |
| event.actor = MagicMock() |
| event.actor.login = actor_login |
| event.created_at = when |
| return event |
| |
| |
| 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_static_skip_labels(self, labels): |
| assert _should_skip_milestone_tagging(labels, events=[]) |
| |
| def test_no_skip_without_static_skip_labels(self): |
| assert not _should_skip_milestone_tagging(["kind:feature", "area:scheduler"], events=[]) |
| |
| def test_skip_when_backport_unlabeled_with_no_replacement(self): |
| # Events show backport removal, no backport on PR now → skip (check 2). |
| events = [ |
| _unlabel_event( |
| "backport-to-v3-1-test", "alice", datetime(2026, 5, 23, 12, 0, tzinfo=timezone.utc) |
| ) |
| ] |
| assert _should_skip_milestone_tagging(["kind:bug"], events=events) |
| |
| def test_no_skip_when_backport_replaced(self): |
| # Events show backport-to-v3-1 removal but v3-2 remains on the PR → no skip. |
| # The caller's regular evaluation will pick up the new label. |
| events = [ |
| _unlabel_event( |
| "backport-to-v3-1-test", "alice", datetime(2026, 5, 23, 12, 0, tzinfo=timezone.utc) |
| ) |
| ] |
| assert not _should_skip_milestone_tagging(["backport-to-v3-2-test", "kind:bug"], events=events) |
| |
| def test_no_skip_when_no_removal_event(self): |
| # No unlabeled events at all — current backport label drives the decision elsewhere. |
| assert not _should_skip_milestone_tagging(["backport-to-v3-1-test", "kind:bug"], events=[]) |
| |
| def test_no_skip_when_unrelated_label_removed(self): |
| events = [ |
| _unlabel_event("kind:documentation", "alice", datetime(2026, 5, 23, 12, 0, tzinfo=timezone.utc)) |
| ] |
| assert not _should_skip_milestone_tagging(["kind:bug"], events=events) |
| |
| def test_events_check_takes_precedence_over_static_labels(self): |
| # Both check 2 (events) and check 3 (static label) would fire here; |
| # the function returns True on the first one — events — and never logs |
| # the static-label reason. |
| events = [ |
| _unlabel_event( |
| "backport-to-v3-1-test", "alice", datetime(2026, 5, 23, 12, 0, tzinfo=timezone.utc) |
| ) |
| ] |
| assert _should_skip_milestone_tagging(["area:CI"], events=events) |
| |
| def test_events_none_disables_events_check(self): |
| # When events is None (fetch failed), only the static label check runs. |
| assert not _should_skip_milestone_tagging(["kind:bug"], events=None) |
| assert _should_skip_milestone_tagging(["area:CI"], events=None) |
| |
| |
| class TestGetRemovedBackportLabelsFromEvents: |
| """Test cases for _get_removed_backport_labels_from_events.""" |
| |
| def test_returns_all_unlabel_events_for_backports(self): |
| events = [ |
| _unlabel_event( |
| "backport-to-v3-1-test", "alice", datetime(2026, 5, 23, 12, 0, tzinfo=timezone.utc) |
| ), |
| _unlabel_event("backport-to-v3-2-test", "bob", datetime(2026, 5, 23, 14, 0, tzinfo=timezone.utc)), |
| ] |
| assert _get_removed_backport_labels_from_events(events) == { |
| "backport-to-v3-1-test", |
| "backport-to-v3-2-test", |
| } |
| |
| def test_ignores_unrelated_label_unlabel_events(self): |
| events = [ |
| _unlabel_event("kind:documentation", "alice", datetime(2026, 5, 23, 12, 0, tzinfo=timezone.utc)) |
| ] |
| assert _get_removed_backport_labels_from_events(events) == set() |
| |
| def test_ignores_non_unlabeled_events(self): |
| labeled = MagicMock() |
| labeled.event = "labeled" |
| labeled.label = _label("backport-to-v3-1-test") |
| assert _get_removed_backport_labels_from_events([labeled]) == set() |
| |
| def test_empty_events(self): |
| assert _get_removed_backport_labels_from_events([]) == set() |
| |
| |
| 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, mock_github_setup |
| ): |
| """When PR has a skip label, milestone tagging should be skipped.""" |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label(skip_label)] |
| mock_issue.get_events.return_value = [] |
| mock_get_client.return_value = mock_gh |
| |
| 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_issue.edit.assert_not_called() |
| plain = _plain_output(result.output) |
| assert "Skipping milestone tagging" in plain |
| assert skip_label in plain |
| |
| @patch("airflow_breeze.commands.ci_commands._get_github_client") |
| def test_main_branch_without_backport_label_should_skip( |
| self, mock_get_client, cli_runner, mock_github_setup |
| ): |
| """When PR is merged to main without backport label, milestone tagging should be skipped.""" |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label("kind:feature")] |
| mock_issue.get_events.return_value = [] |
| mock_get_client.return_value = mock_gh |
| |
| 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_issue.edit.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 |
| # Fresh-issue labels match the workflow snapshot — no race, no re-evaluation. |
| mock_issue.labels = [_label(name) for name in pr_labels] |
| 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", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| 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 |
| # Fresh-issue labels match the workflow snapshot — no race, no re-evaluation. |
| mock_issue.labels = [_label(name) for name in pr_labels] |
| 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", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| 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 |
| |
| @patch("airflow_breeze.commands.ci_commands._get_github_client") |
| def test_backport_unlabeled_with_no_replacement_should_skip( |
| self, mock_get_client, cli_runner, mock_github_setup |
| ): |
| """If an ``unlabeled`` event for a ``backport-to-*`` label exists on the |
| PR and no ``backport-to-*`` label remains, the action must skip the |
| milestone-set. Regression test for PR #67301 race; the events stream |
| is now the single source of truth for the unbackport signal. |
| """ |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label("kind:documentation")] |
| mock_issue.get_events.return_value = [ |
| _unlabel_event( |
| "backport-to-v3-2-test", |
| "shahar1", |
| datetime(2026, 5, 23, 20, 32, 17, tzinfo=timezone.utc), |
| ), |
| ] |
| mock_get_client.return_value = mock_gh |
| |
| result = cli_runner.invoke( |
| ci_group, |
| [ |
| "set-milestone", |
| "--pr-number", |
| "67301", |
| "--pr-title", |
| "fix: typo", |
| "--pr-labels", |
| json.dumps(["backport-to-v3-2-test", "kind:documentation"]), |
| "--base-branch", |
| "main", |
| "--merged-by", |
| "shahar1", |
| "--github-token", |
| "fake-token", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| mock_issue.edit.assert_not_called() |
| mock_issue.create_comment.assert_not_called() |
| plain = _plain_output(result.output) |
| assert "Skipping milestone tagging" in plain |
| assert "backport labels were removed during the PR lifecycle" in plain |
| assert "backport-to-v3-2-test" in plain |
| assert result.exit_code == 0 |
| |
| @patch("airflow_breeze.commands.ci_commands._get_github_client") |
| def test_backport_unlabeled_on_version_branch_should_skip( |
| self, mock_get_client, cli_runner, mock_github_setup |
| ): |
| """A backport-label removal recorded in the issue events must take |
| precedence over the merge-to-version-branch heuristic. Without this, a |
| PR merged to a version branch would still get that branch's milestone |
| even after a maintainer/triager explicitly removed the backport label. |
| """ |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label("kind:bug")] |
| mock_issue.get_events.return_value = [ |
| _unlabel_event( |
| "backport-to-v3-2-test", |
| "testuser", |
| datetime(2026, 5, 23, 20, 32, 17, tzinfo=timezone.utc), |
| ), |
| ] |
| mock_get_client.return_value = mock_gh |
| |
| result = cli_runner.invoke( |
| ci_group, |
| [ |
| "set-milestone", |
| "--pr-number", |
| "12345", |
| "--pr-title", |
| "Fix: scheduler issue", |
| "--pr-labels", |
| json.dumps(["backport-to-v3-2-test", "kind:bug"]), |
| "--base-branch", |
| "v3-1-test", |
| "--merged-by", |
| "testuser", |
| "--github-token", |
| "fake-token", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| mock_issue.edit.assert_not_called() |
| mock_issue.create_comment.assert_not_called() |
| plain = _plain_output(result.output) |
| assert "Skipping milestone tagging" in plain |
| assert "backport labels were removed during the PR lifecycle" in plain |
| assert "backport-to-v3-2-test" in plain |
| assert result.exit_code == 0 |
| |
| @patch("airflow_breeze.commands.ci_commands._get_github_client") |
| def test_backport_label_replaced_should_use_current(self, mock_get_client, cli_runner, mock_github_setup): |
| """When the events show one backport label removed but another |
| ``backport-to-*`` remains on the PR (e.g. someone swapped the version |
| target), the action must use the current label, not skip. |
| """ |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label("backport-to-v3-2-test"), _label("kind:bug")] |
| mock_issue.get_events.return_value = [ |
| _unlabel_event( |
| "backport-to-v3-1-test", |
| "testuser", |
| datetime(2026, 5, 23, 20, 30, 0, tzinfo=timezone.utc), |
| ), |
| ] |
| mock_milestone = MagicMock() |
| mock_milestone.title = "Airflow 3.2.3" |
| mock_milestone.number = 140 |
| 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", |
| "Fix: scheduler issue", |
| "--pr-labels", |
| json.dumps(["backport-to-v3-1-test", "kind:bug"]), |
| "--base-branch", |
| "main", |
| "--merged-by", |
| "testuser", |
| "--github-token", |
| "fake-token", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| mock_issue.edit.assert_called_once_with(milestone=mock_milestone) |
| assert "Airflow 3.2.3" in captured_comments[0] |
| assert "backport label targeting v3-2-test" in captured_comments[0] |
| assert result.exit_code == 0 |
| |
| @patch("airflow_breeze.commands.ci_commands._get_github_client") |
| def test_skip_label_present_on_live_labels_should_skip( |
| self, mock_get_client, cli_runner, mock_github_setup |
| ): |
| """A skip label present on the live labels (regardless of what the |
| workflow snapshot had) must halt the action via check 3 of the |
| pipeline. |
| """ |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label("backport-to-v3-1-test"), _label("area:CI")] |
| mock_issue.get_events.return_value = [] |
| mock_get_client.return_value = mock_gh |
| |
| result = cli_runner.invoke( |
| ci_group, |
| [ |
| "set-milestone", |
| "--pr-number", |
| "12345", |
| "--pr-title", |
| "CI tweak", |
| "--pr-labels", |
| json.dumps(["backport-to-v3-1-test"]), |
| "--base-branch", |
| "main", |
| "--merged-by", |
| "testuser", |
| "--github-token", |
| "fake-token", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| mock_issue.edit.assert_not_called() |
| mock_issue.create_comment.assert_not_called() |
| assert "Skipping milestone tagging" in result.output |
| assert "area:CI" in result.output |
| assert result.exit_code == 0 |
| |
| @patch("airflow_breeze.commands.ci_commands._get_github_client") |
| def test_pr_67301_real_events_should_skip(self, mock_get_client, cli_runner, mock_github_setup): |
| """End-to-end regression test against the real ``issue.get_events()`` |
| stream from PR #67301 (the incident that motivated this change). |
| |
| The events below are the actual events captured from |
| ``GET /repos/apache/airflow/issues/67301/events``, trimmed to those |
| that existed BEFORE the offending ``github-actions[bot] milestoned`` |
| event — that ``milestoned`` event is exactly what the new |
| live-labels + events pipeline must prevent, so it is intentionally |
| omitted from this fixture. With the fix in place, ``set-milestone`` |
| must notice shahar1's ``unlabeled backport-to-v3-2-test`` 92 seconds |
| earlier and skip. |
| """ |
| from airflow_breeze.commands.ci_commands import ci_group |
| |
| pr_67301_events = [ |
| _issue_event("labeled", "boring-cyborg[bot]", "2026-05-21T19:42:28Z", "area:providers"), |
| _issue_event("labeled", "boring-cyborg[bot]", "2026-05-21T19:42:28Z", "kind:documentation"), |
| _issue_event("labeled", "boring-cyborg[bot]", "2026-05-21T19:42:28Z", "provider:standard"), |
| _issue_event("merged", "shahar1", "2026-05-21T20:31:18Z"), |
| _issue_event("closed", "shahar1", "2026-05-21T20:31:18Z"), |
| _issue_event("labeled", "shahar1", "2026-05-21T20:31:28Z", "backport-to-v3-2-test"), |
| _issue_event("unlabeled", "shahar1", "2026-05-21T20:32:17Z", "backport-to-v3-2-test"), |
| ] |
| # Live ``issue.labels`` at the moment set-milestone would have run: |
| # backport-to-v3-2-test had just been removed, leaving these three. |
| live_labels = ["area:providers", "kind:documentation", "provider:standard"] |
| |
| mock_gh, mock_repo, mock_issue = mock_github_setup |
| mock_issue.milestone = None |
| mock_issue.labels = [_label(name) for name in live_labels] |
| mock_issue.get_events.return_value = pr_67301_events |
| mock_get_client.return_value = mock_gh |
| |
| result = cli_runner.invoke( |
| ci_group, |
| [ |
| "set-milestone", |
| "--pr-number", |
| "67301", |
| "--pr-title", |
| 'fix: typo "@tash.bash" -> "@task.bash', |
| "--pr-labels", |
| # The workflow's stale snapshot from get-pr-info still saw the |
| # backport label; the new pipeline ignores this in favour of |
| # live state. |
| json.dumps( |
| [ |
| "area:providers", |
| "kind:documentation", |
| "provider:standard", |
| "backport-to-v3-2-test", |
| ] |
| ), |
| "--base-branch", |
| "main", |
| "--merged-by", |
| "shahar1", |
| "--github-token", |
| "fake-token", |
| "--github-repository", |
| "apache/airflow", |
| ], |
| ) |
| |
| mock_issue.edit.assert_not_called() |
| mock_issue.create_comment.assert_not_called() |
| plain = _plain_output(result.output) |
| assert "Skipping milestone tagging" in plain |
| assert "backport labels were removed during the PR lifecycle" in plain |
| assert "backport-to-v3-2-test" in plain |
| assert result.exit_code == 0 |