# Copyright (c) Microsoft. All rights reserved. """Tests for stale_issue_pr_ping.py.""" from __future__ import annotations import os import sys from datetime import datetime, timezone, timedelta from unittest.mock import MagicMock, patch import pytest # Ensure the script directory is importable sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) from stale_issue_pr_ping import ( PINGED_LABEL, PING_COMMENT, TRIGGER_LABEL, author_replied_after, find_last_team_comment, get_team_members, main, ping, should_ping, ) TEAM = {"alice", "bob"} NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_comment(login: str | None, created_at: datetime) -> MagicMock: """Create a mock IssueComment.""" c = MagicMock() if login is None: c.user = None else: c.user = MagicMock() c.user.login = login c.created_at = created_at return c def _make_label(name: str) -> MagicMock: lbl = MagicMock() lbl.name = name return lbl def _make_issue( author: str = "external", labels: list[str] | None = None, comment_count: int = 1, comments: list[MagicMock] | None = None, pull_request: bool = False, number: int = 42, ) -> MagicMock: issue = MagicMock() issue.user = MagicMock() issue.user.login = author issue.number = number # Default to having the trigger label, since the API query pre-filters. if labels is None: labels = [TRIGGER_LABEL] issue.labels = [_make_label(n) for n in labels] issue.comments = comment_count issue.pull_request = MagicMock() if pull_request else None if comments is not None: issue.get_comments.return_value = comments return issue # --------------------------------------------------------------------------- # find_last_team_comment # --------------------------------------------------------------------------- class TestFindLastTeamComment: def test_returns_last_team_comment(self): c1 = _make_comment("alice", datetime(2026, 3, 1, tzinfo=timezone.utc)) c2 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) c3 = _make_comment("bob", datetime(2026, 3, 3, tzinfo=timezone.utc)) assert find_last_team_comment([c1, c2, c3], TEAM) is c3 def test_returns_none_when_no_team_comments(self): c1 = _make_comment("external", datetime(2026, 3, 1, tzinfo=timezone.utc)) assert find_last_team_comment([c1], TEAM) is None def test_returns_none_for_empty_list(self): assert find_last_team_comment([], TEAM) is None def test_skips_deleted_user(self): c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc)) c2 = _make_comment("alice", datetime(2026, 3, 2, tzinfo=timezone.utc)) assert find_last_team_comment([c1, c2], TEAM) is c2 def test_only_deleted_users(self): c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc)) assert find_last_team_comment([c1], TEAM) is None # --------------------------------------------------------------------------- # author_replied_after # --------------------------------------------------------------------------- class TestAuthorRepliedAfter: def test_author_replied(self): after = datetime(2026, 3, 1, tzinfo=timezone.utc) c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) assert author_replied_after([c1], "external", after) is True def test_author_not_replied(self): after = datetime(2026, 3, 5, tzinfo=timezone.utc) c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) assert author_replied_after([c1], "external", after) is False def test_different_user_replied(self): after = datetime(2026, 3, 1, tzinfo=timezone.utc) c1 = _make_comment("someone_else", datetime(2026, 3, 2, tzinfo=timezone.utc)) assert author_replied_after([c1], "external", after) is False def test_deleted_user_comment(self): after = datetime(2026, 3, 1, tzinfo=timezone.utc) c1 = _make_comment(None, datetime(2026, 3, 2, tzinfo=timezone.utc)) assert author_replied_after([c1], "external", after) is False # --------------------------------------------------------------------------- # should_ping # --------------------------------------------------------------------------- class TestShouldPing: def test_should_ping_stale_issue(self): team_comment = _make_comment("alice", NOW - timedelta(days=5)) issue = _make_issue(comments=[team_comment], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is True def test_skip_team_member_author(self): issue = _make_issue(author="alice", labels=[TRIGGER_LABEL], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is False def test_skip_already_pinged(self): issue = _make_issue(labels=[TRIGGER_LABEL, PINGED_LABEL], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is False def test_skip_no_comments(self): issue = _make_issue(comment_count=0) assert should_ping(issue, TEAM, 4, NOW) is False def test_skip_no_team_comment(self): c = _make_comment("external", NOW - timedelta(days=5)) issue = _make_issue(comments=[c], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is False def test_skip_author_replied(self): team_c = _make_comment("alice", NOW - timedelta(days=5)) author_c = _make_comment("external", NOW - timedelta(days=3)) issue = _make_issue(comments=[team_c, author_c], comment_count=2) assert should_ping(issue, TEAM, 4, NOW) is False def test_skip_not_enough_days(self): team_comment = _make_comment("alice", NOW - timedelta(days=2)) issue = _make_issue(comments=[team_comment], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is False def test_aware_datetime_handled(self): """Timezone-aware datetimes should not be mangled by astimezone.""" aware_dt = (NOW - timedelta(days=5)).replace(tzinfo=timezone.utc) team_comment = _make_comment("alice", aware_dt) issue = _make_issue(comments=[team_comment], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is True def test_naive_datetime_handled(self): """Naive datetimes (pre-PyGithub 2.x) should be handled by astimezone.""" naive_dt = (NOW - timedelta(days=5)).replace(tzinfo=None) team_comment = _make_comment("alice", naive_dt) issue = _make_issue(comments=[team_comment], comment_count=1) # astimezone on naive datetime treats it as local time; just verify no crash should_ping(issue, TEAM, 4, NOW) # --------------------------------------------------------------------------- # ping # --------------------------------------------------------------------------- class TestPing: def test_dry_run(self, capsys): issue = _make_issue() assert ping(issue, dry_run=True) is True issue.create_comment.assert_not_called() assert "DRY RUN" in capsys.readouterr().out def test_success(self, capsys): issue = _make_issue() assert ping(issue, dry_run=False) is True issue.create_comment.assert_called_once() issue.add_to_labels.assert_called_once_with(PINGED_LABEL) @patch("stale_issue_pr_ping.time.sleep") def test_retry_on_failure(self, mock_sleep): issue = _make_issue() issue.create_comment.side_effect = [Exception("net error"), None] assert ping(issue, dry_run=False) is True assert issue.create_comment.call_count == 2 mock_sleep.assert_called_once() @patch("stale_issue_pr_ping.time.sleep") def test_idempotent_retry_skips_comment_on_label_failure(self, mock_sleep): """If create_comment succeeds but add_to_labels fails, retry should not re-comment.""" issue = _make_issue() issue.add_to_labels.side_effect = [Exception("label error"), None] assert ping(issue, dry_run=False) is True # Comment should only be created once even though there were 2 attempts assert issue.create_comment.call_count == 1 assert issue.add_to_labels.call_count == 2 @patch("stale_issue_pr_ping.time.sleep") def test_all_retries_fail(self, mock_sleep): issue = _make_issue() issue.create_comment.side_effect = Exception("permanent error") assert ping(issue, dry_run=False) is False assert issue.create_comment.call_count == 3 # --------------------------------------------------------------------------- # get_team_members # --------------------------------------------------------------------------- class TestGetTeamMembers: def test_success(self): g = MagicMock() member = MagicMock() member.login = "alice" g.get_organization.return_value.get_team_by_slug.return_value.get_members.return_value = [member] assert get_team_members(g, "org", "my-team") == {"alice"} def test_403_error_message(self, capsys): from github import GithubException g = MagicMock() g.get_organization.return_value.get_team_by_slug.side_effect = GithubException( 403, {"message": "Forbidden"}, None ) with pytest.raises(SystemExit): get_team_members(g, "org", "my-team") out = capsys.readouterr().out assert "read:org" in out assert "403" in out def test_404_error_message(self, capsys): from github import GithubException g = MagicMock() g.get_organization.return_value.get_team_by_slug.side_effect = GithubException( 404, {"message": "Not Found"}, None ) with pytest.raises(SystemExit): get_team_members(g, "org", "bad-slug") out = capsys.readouterr().out assert "read:org" in out assert "bad-slug" in out def test_generic_error(self, capsys): g = MagicMock() g.get_organization.side_effect = RuntimeError("boom") with pytest.raises(SystemExit): get_team_members(g, "org", "team") # --------------------------------------------------------------------------- # main – env var validation # --------------------------------------------------------------------------- class TestMain: @patch.dict(os.environ, { "GITHUB_TOKEN": "tok", "GITHUB_REPOSITORY": "org/repo", "TEAM_SLUG": "my-team", "DAYS_THRESHOLD": "abc", }, clear=True) def test_invalid_days_threshold(self, capsys): with pytest.raises(SystemExit): main() assert "numeric" in capsys.readouterr().out @patch.dict(os.environ, { "GITHUB_TOKEN": "tok", "GITHUB_REPOSITORY": "org/repo", }, clear=True) def test_missing_team_slug(self, capsys): with pytest.raises(SystemExit): main() assert "TEAM_SLUG" in capsys.readouterr().out