diff --git a/.github/scripts/oncall_manager.py b/.github/scripts/oncall_manager.py index 332fcb1c8cc..facd23c1ca8 100644 --- a/.github/scripts/oncall_manager.py +++ b/.github/scripts/oncall_manager.py @@ -28,6 +28,7 @@ ROTATION_TEAM_SLUG = "mcore-oncall-rotation" ACTIVE_ONCALL_TEAM_SLUG = "mcore-oncall" SLACK_USERGROUP_HANDLE = "mcore-oncall" +COMMUNITY_REQUEST_LABEL = "community-request" TARGET_WEEKS = 12 # Caches for email and Slack lookups @@ -391,14 +392,44 @@ def ensure_schedule_filled(schedule, repo_owner): print(f"Appended: {new_entry}") def assign_reviewer(pr_number): - """Assigns the mcore-oncall team as the reviewer for the PR.""" + """Assigns mcore-oncall if no reviewers are set or community-request is applied.""" owner, repo = get_repo_info() + + pr_url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}" + pr_resp = requests.get(pr_url, headers=get_headers()) + + if pr_resp.status_code != 200: + print(f"Failed to fetch PR: {pr_resp.status_code} {pr_resp.text}") + sys.exit(1) + + pr_data = pr_resp.json() + requested_reviewers = pr_data.get("requested_reviewers", []) + requested_teams = pr_data.get("requested_teams", []) + labels = {label.get("name") for label in pr_data.get("labels", [])} + requested_team_slugs = {team.get("slug") for team in requested_teams} + has_community_request_label = COMMUNITY_REQUEST_LABEL in labels + + if ACTIVE_ONCALL_TEAM_SLUG in requested_team_slugs: + print( + f"Skipping reviewer request: team NVIDIA/{ACTIVE_ONCALL_TEAM_SLUG} " + "is already requested" + ) + return + + if not has_community_request_label and (requested_reviewers or requested_teams): + print( + "Skipping reviewer request: PR already has " + f"{len(requested_reviewers)} user reviewer(s) and " + f"{len(requested_teams)} team reviewer(s)" + ) + return + url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/pulls/{pr_number}/requested_reviewers" - + # Assign the oncall team as reviewer data = {"team_reviewers": [ACTIVE_ONCALL_TEAM_SLUG]} resp = requests.post(url, headers=get_headers(), json=data) - + if resp.status_code in [201, 200]: print(f"Successfully requested review from team NVIDIA/{ACTIVE_ONCALL_TEAM_SLUG}") else: diff --git a/tests/test_utils/python_scripts/test_oncall_manager.py b/tests/test_utils/python_scripts/test_oncall_manager.py new file mode 100644 index 00000000000..a200bee74da --- /dev/null +++ b/tests/test_utils/python_scripts/test_oncall_manager.py @@ -0,0 +1,125 @@ +# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved. +# +# Licensed 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. + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +ONCALL_MANAGER_PATH = REPO_ROOT / ".github" / "scripts" / "oncall_manager.py" + + +class FakeResponse: + def __init__(self, status_code, json_data=None, text=""): + self.status_code = status_code + self._json_data = json_data + self.text = text + + def json(self): + return self._json_data + + +class FakeRequests: + def __init__(self, pr_data): + self.pr_data = pr_data + self.posts = [] + + def get(self, url, headers=None): + return FakeResponse(200, self.pr_data) + + def post(self, url, headers=None, json=None): + self.posts.append({"url": url, "headers": headers, "json": json}) + return FakeResponse(201) + + +@pytest.fixture +def oncall_manager(monkeypatch): + slack_module = types.ModuleType("slack_sdk") + slack_module.WebClient = object + + slack_errors_module = types.ModuleType("slack_sdk.errors") + slack_errors_module.SlackApiError = Exception + requests_module = types.ModuleType("requests") + + monkeypatch.setitem(sys.modules, "requests", requests_module) + monkeypatch.setitem(sys.modules, "slack_sdk", slack_module) + monkeypatch.setitem(sys.modules, "slack_sdk.errors", slack_errors_module) + monkeypatch.setenv("GITHUB_REPOSITORY", "NVIDIA/Megatron-LM") + monkeypatch.setenv("GH_TOKEN", "token") + + spec = importlib.util.spec_from_file_location("oncall_manager", ONCALL_MANAGER_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + "pr_data", + [ + { + "user": {"login": "maintainer"}, + "requested_reviewers": [{"login": "alice"}], + "requested_teams": [], + }, + { + "user": {"login": "maintainer"}, + "requested_reviewers": [], + "requested_teams": [{"slug": "mcore"}], + }, + { + "requested_reviewers": [{"login": "alice"}], + "requested_teams": [{"slug": "mcore-oncall"}], + "labels": [{"name": "community-request"}], + }, + ], +) +def test_assign_reviewer_skips_when_oncall_is_not_needed( + oncall_manager, monkeypatch, capsys, pr_data +): + fake_requests = FakeRequests(pr_data) + monkeypatch.setattr(oncall_manager, "requests", fake_requests) + + oncall_manager.assign_reviewer(123) + + assert fake_requests.posts == [] + assert "Skipping reviewer request" in capsys.readouterr().out + + +@pytest.mark.parametrize( + "pr_data", + [ + {"user": {"login": "maintainer"}, "requested_reviewers": [], "requested_teams": []}, + { + "requested_reviewers": [{"login": "alice"}], + "requested_teams": [{"slug": "mcore"}], + "labels": [{"name": "community-request"}], + }, + ], +) +def test_assign_reviewer_requests_oncall_when_needed(oncall_manager, monkeypatch, pr_data): + fake_requests = FakeRequests(pr_data) + monkeypatch.setattr(oncall_manager, "requests", fake_requests) + + oncall_manager.assign_reviewer(123) + + assert fake_requests.posts == [ + { + "url": "https://api.github.com/repos/NVIDIA/Megatron-LM/pulls/123/requested_reviewers", + "headers": {"Authorization": "token token", "Accept": "application/vnd.github.v3+json"}, + "json": {"team_reviewers": ["mcore-oncall"]}, + } + ]