diff --git a/eng/tools/azure-sdk-tools/gh_tools/vnext_issue_creator.py b/eng/tools/azure-sdk-tools/gh_tools/vnext_issue_creator.py index 66f0f1e08779..6396e1cadca2 100644 --- a/eng/tools/azure-sdk-tools/gh_tools/vnext_issue_creator.py +++ b/eng/tools/azure-sdk-tools/gh_tools/vnext_issue_creator.py @@ -5,7 +5,7 @@ # This script is used to create issues for client libraries failing the vnext of mypy, pyright, and pylint. from __future__ import annotations -from typing import Optional +from typing import Optional, TYPE_CHECKING import sys import os @@ -20,11 +20,46 @@ from github import Github, Auth, GithubException from ci_tools.variables import discover_repo_root +from ci_tools.parsing import ParsedSetup +from ci_tools.functions import is_package_active + +if TYPE_CHECKING: + from github.Repository import Repository logging.getLogger().setLevel(logging.INFO) CHECK_TYPE = Literal["mypy", "pylint", "pyright", "sphinx"] +# The automation that files vnext issues has run under different identities over time +# (the `azure-sdk` user account and the `azure-sdk-automation[bot]` GitHub App). We match +# issues from any known automation creator so existing issues are correctly found and +# de-duplicated regardless of which identity created them. Filtering by creator (rather +# than title alone) avoids ever editing/closing an issue a human happened to open. +VNEXT_ISSUE_CREATORS = ["azure-sdk", "azure-sdk-automation[bot]"] + + +def find_vnext_issues(repo: "Repository", check_type: CHECK_TYPE, package_name: str) -> list: + """Return all open vnext issues for the given package and check_type, across every + known automation creator identity, sorted oldest-first (by issue number).""" + matches = [ + issue + for issue in repo.get_issues(state="open", labels=[check_type]) + if issue.title.split("needs")[0].strip() == package_name and issue.user.login in VNEXT_ISSUE_CREATORS + ] + return sorted(matches, key=lambda issue: issue.number) + + +def is_package_deprecated(package_dir: str) -> bool: + """Returns True if the package is deprecated/inactive (e.g. carries the + 'Development Status :: 7 - Inactive' classifier). Deprecated packages should + not have vnext issues created against them.""" + try: + parsed = ParsedSetup.from_path(package_dir) + except Exception as e: # pragma: no cover - defensive + logging.warning(f"Unable to parse metadata for {package_dir} to determine deprecation status: {e}") + return False + return not is_package_active(parsed) + def get_version_running(check_type: CHECK_TYPE) -> str: commands = [sys.executable, "-m", check_type, "--version"] @@ -148,14 +183,20 @@ def create_vnext_issue(package_dir: str, check_type: CHECK_TYPE, check_version: package_path = pathlib.Path(package_dir) package_name = package_path.name service_directory = package_path.parent.name + + # Deprecated/inactive packages should never have vnext issues created against them. + if is_package_deprecated(package_dir): + logging.info(f"Package {package_name} is deprecated/inactive. Skipping vnext issue creation for {check_type}.") + close_vnext_issue(package_name, check_type) + return + auth = Auth.Token(os.environ["GH_TOKEN"]) g = Github(auth=auth) today = datetime.date.today() repo = g.get_repo("Azure/azure-sdk-for-python") - issues = repo.get_issues(state="open", labels=[check_type], creator="azure-sdk") - vnext_issue = [issue for issue in issues if issue.title.split("needs")[0].strip() == package_name] + vnext_issue = find_vnext_issues(repo, check_type, package_name) version = check_version or get_version_running(check_type) build_link = get_build_link(check_type) @@ -219,7 +260,13 @@ def create_vnext_issue(package_dir: str, check_type: CHECK_TYPE, check_version: labels = [] assignees = [] - vnext_issue[0].edit( + # Update the most recent issue and close any older duplicates so we converge on a single issue. + primary_issue = vnext_issue[-1] + for duplicate in vnext_issue[:-1]: + logging.info(f"Closing duplicate vnext issue #{duplicate.number} for {package_name} ({check_type}).") + duplicate.edit(state="closed") + + primary_issue.edit( title=title, body=template, ) @@ -227,7 +274,7 @@ def create_vnext_issue(package_dir: str, check_type: CHECK_TYPE, check_version: # Assign codeowners individually with error handling for assignee in assignees: try: - vnext_issue[0].add_to_assignees(assignee) + primary_issue.add_to_assignees(assignee) logging.info(f"Assigned {assignee} to issue for {package_name}") except GithubException as e: logging.warning(f"Failed to assign {assignee} to issue for {package_name}: {e}") @@ -241,8 +288,7 @@ def close_vnext_issue(package_name: str, check_type: CHECK_TYPE) -> None: repo = g.get_repo("Azure/azure-sdk-for-python") - issues = repo.get_issues(state="open", labels=[check_type], creator="azure-sdk") - vnext_issue = [issue for issue in issues if issue.title.split("needs")[0].strip() == package_name] - if vnext_issue: - logging.info(f"{package_name} passes {check_type}. Closing existing GH issue #{vnext_issue[0].number}...") - vnext_issue[0].edit(state="closed") + vnext_issues = find_vnext_issues(repo, check_type, package_name) + for issue in vnext_issues: + logging.info(f"{package_name} passes {check_type}. Closing existing GH issue #{issue.number}...") + issue.edit(state="closed") diff --git a/eng/tools/azure-sdk-tools/tests/test_vnext_issue_creator.py b/eng/tools/azure-sdk-tools/tests/test_vnext_issue_creator.py new file mode 100644 index 000000000000..1e8ed3eb504d --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_vnext_issue_creator.py @@ -0,0 +1,143 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from unittest import mock + +import pytest + +from gh_tools import vnext_issue_creator + + +class _FakeParsedSetup: + def __init__(self, name, classifiers): + self.name = name + self.classifiers = classifiers + + +class _FakeIssue: + def __init__(self, number, title, login): + self.number = number + self.title = title + self.user = mock.MagicMock() + self.user.login = login + self.edit = mock.MagicMock() + self.add_to_assignees = mock.MagicMock() + + +@pytest.mark.parametrize( + "classifiers, expected_deprecated", + [ + (["Development Status :: 5 - Production/Stable"], False), + (["Development Status :: 4 - Beta"], False), + (["Development Status :: 7 - Inactive"], True), + (["Development Status :: 5 - Production/Stable", "Development Status :: 7 - Inactive"], True), + ], +) +def test_is_package_deprecated(classifiers, expected_deprecated): + parsed = _FakeParsedSetup("azure-mgmt-fake", classifiers) + with mock.patch.object(vnext_issue_creator.ParsedSetup, "from_path", return_value=parsed): + assert vnext_issue_creator.is_package_deprecated("some/path") is expected_deprecated + + +def test_is_package_deprecated_parse_failure_returns_false(): + with mock.patch.object(vnext_issue_creator.ParsedSetup, "from_path", side_effect=RuntimeError("boom")): + assert vnext_issue_creator.is_package_deprecated("some/path") is False + + +def test_create_vnext_issue_skips_and_closes_for_deprecated_package(): + with mock.patch.object( + vnext_issue_creator, "is_package_deprecated", return_value=True + ) as mock_deprecated, mock.patch.object(vnext_issue_creator, "close_vnext_issue") as mock_close, mock.patch.object( + vnext_issue_creator, "Github" + ) as mock_github: + vnext_issue_creator.create_vnext_issue("sdk/mixedreality/azure-mgmt-mixedreality", "mypy") + + mock_deprecated.assert_called_once() + mock_close.assert_called_once_with("azure-mgmt-mixedreality", "mypy") + # No GitHub client should be constructed when short-circuiting on a deprecated package. + mock_github.assert_not_called() + + +def test_create_vnext_issue_proceeds_for_active_package(): + with mock.patch.object(vnext_issue_creator, "is_package_deprecated", return_value=False), mock.patch.object( + vnext_issue_creator, "close_vnext_issue" + ) as mock_close, mock.patch.dict("os.environ", {"GH_TOKEN": "fake-token"}), mock.patch.object( + vnext_issue_creator, "Github" + ) as mock_github: + repo = mock.MagicMock() + repo.get_issues.return_value = [] + mock_github.return_value.get_repo.return_value = repo + with mock.patch.object(vnext_issue_creator, "get_labels", return_value=([], [])), mock.patch.object( + vnext_issue_creator, "get_build_link", return_value="http://build" + ), mock.patch.object(vnext_issue_creator, "get_date_for_version_bump", return_value="2026-04-13"): + vnext_issue_creator.create_vnext_issue("sdk/fake/azure-mgmt-fake", "mypy", check_version="1.0.0") + + # Active package: we do not close an issue, we create one. + mock_close.assert_not_called() + repo.create_issue.assert_called_once() + + +def test_find_vnext_issues_matches_all_automation_creators_and_ignores_others(): + repo = mock.MagicMock() + repo.get_issues.return_value = [ + _FakeIssue(100, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "azure-sdk"), + _FakeIssue( + 200, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "azure-sdk-automation[bot]" + ), + # Same title but opened by a human -> must be ignored. + _FakeIssue(300, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "some-human"), + # Different package -> must be ignored. + _FakeIssue(400, "azure-core needs typing updates for mypy version 1.19.1", "azure-sdk"), + ] + + result = vnext_issue_creator.find_vnext_issues(repo, "mypy", "azure-ai-textanalytics") + + assert [issue.number for issue in result] == [100, 200] + + +def test_close_vnext_issue_closes_all_duplicates(): + dup_a = _FakeIssue(100, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "azure-sdk") + dup_b = _FakeIssue( + 200, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "azure-sdk-automation[bot]" + ) + repo = mock.MagicMock() + with mock.patch.dict("os.environ", {"GH_TOKEN": "fake-token"}), mock.patch.object( + vnext_issue_creator, "Github" + ) as mock_github, mock.patch.object(vnext_issue_creator, "find_vnext_issues", return_value=[dup_a, dup_b]): + mock_github.return_value.get_repo.return_value = repo + vnext_issue_creator.close_vnext_issue("azure-ai-textanalytics", "mypy") + + dup_a.edit.assert_called_once_with(state="closed") + dup_b.edit.assert_called_once_with(state="closed") + + +def test_create_vnext_issue_updates_newest_and_closes_older_duplicates(): + dup_a = _FakeIssue(100, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "azure-sdk") + dup_b = _FakeIssue( + 200, "azure-ai-textanalytics needs typing updates for mypy version 1.19.1", "azure-sdk-automation[bot]" + ) + repo = mock.MagicMock() + with mock.patch.object(vnext_issue_creator, "is_package_deprecated", return_value=False), mock.patch.dict( + "os.environ", {"GH_TOKEN": "fake-token"} + ), mock.patch.object(vnext_issue_creator, "Github") as mock_github, mock.patch.object( + vnext_issue_creator, "find_vnext_issues", return_value=[dup_a, dup_b] + ), mock.patch.object( + vnext_issue_creator, "get_labels", return_value=([], []) + ), mock.patch.object( + vnext_issue_creator, "get_build_link", return_value="http://build" + ), mock.patch.object( + vnext_issue_creator, "get_date_for_version_bump", return_value="2026-04-13" + ): + mock_github.return_value.get_repo.return_value = repo + vnext_issue_creator.create_vnext_issue( + "sdk/textanalytics/azure-ai-textanalytics", "mypy", check_version="1.19.1" + ) + + # No new issue created when duplicates already exist. + repo.create_issue.assert_not_called() + # Older duplicate is closed; newest is updated (edited but not closed). + dup_a.edit.assert_called_once_with(state="closed") + dup_b.edit.assert_called_once() + assert dup_b.edit.call_args.kwargs.get("state") != "closed"