Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 56 additions & 10 deletions eng/tools/azure-sdk-tools/gh_tools/vnext_issue_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -219,15 +260,21 @@ 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,
)

# 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}")
Expand All @@ -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")
143 changes: 143 additions & 0 deletions eng/tools/azure-sdk-tools/tests/test_vnext_issue_creator.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +1 to +10


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"
Loading