diff --git a/.github/scripts/stale_issue_pr_ping.py b/.github/scripts/stale_issue_pr_ping.py deleted file mode 100644 index 9c865213ade..00000000000 --- a/.github/scripts/stale_issue_pr_ping.py +++ /dev/null @@ -1,216 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Scan open issues and PRs labeled 'waiting-for-author' for stale follow-ups. - -Team members manually add the 'waiting-for-author' label when they need a -response from the external author. If the author hasn't replied within -DAYS_THRESHOLD days of the last team comment, post a reminder and add the -'requested-info' label to prevent duplicate pings. -""" - -from __future__ import annotations - -import os -import sys -import time -from datetime import datetime, timezone - -from github import Auth, Github, GithubException -from github.Issue import Issue -from github.IssueComment import IssueComment - - -PING_COMMENT = ( - "@{author}, friendly reminder — this issue is waiting on your response. " - "Please share any updates when you get a chance. (This is an automated message.)" -) -TRIGGER_LABEL = "waiting-for-author" -PINGED_LABEL = "requested-info" - - -def get_team_members(g: Github, org: str, team_slug: str) -> set[str]: - """Fetch active team member usernames.""" - try: - org_obj = g.get_organization(org) - team = org_obj.get_team_by_slug(team_slug) - return {m.login for m in team.get_members()} - except GithubException as exc: - if exc.status in (403, 404): - print( - f"ERROR: Failed to fetch team members for {org}/{team_slug} " - f"(HTTP {exc.status}). Check that the token has the 'read:org' " - f"scope and that the team slug '{team_slug}' is correct." - ) - else: - print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") - sys.exit(1) - except Exception as exc: - print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") - sys.exit(1) - - -def find_last_team_comment( - comments: list[IssueComment], team_members: set[str] -) -> IssueComment | None: - """Return the most recent comment from a team member, or None.""" - for comment in reversed(comments): - if comment.user and comment.user.login in team_members: - return comment - return None - - -def author_replied_after( - comments: list[IssueComment], author: str, after: datetime -) -> bool: - """Check if the issue author commented after the given timestamp.""" - for comment in comments: - if ( - comment.user - and comment.user.login == author - and comment.created_at > after - ): - return True - return False - - -def should_ping( - issue: Issue, - team_members: set[str], - days_threshold: int, - now: datetime, -) -> bool: - """Determine whether this issue/PR should be pinged. - - Only issues/PRs carrying the 'waiting-for-author' label are candidates. - """ - author = issue.user.login - - # Skip if the trigger label is not present - if not any(label.name == TRIGGER_LABEL for label in issue.labels): - return False - # Skip if author is a team member - if author in team_members: - return False - - # Skip if already pinged - if any(label.name == PINGED_LABEL for label in issue.labels): - return False - - # Skip if no comments at all - if issue.comments == 0: - return False - - # Fetch comments once for both lookups - comments = list(issue.get_comments()) - - # Find last team member comment - last_team_comment = find_last_team_comment(comments, team_members) - if last_team_comment is None: - return False - - # Skip if author replied after the last team comment - if author_replied_after(comments, author, last_team_comment.created_at): - return False - - # Check if enough days have passed - days_since = (now - last_team_comment.created_at.astimezone(timezone.utc)).days - if days_since < days_threshold: - return False - - return True - - -def ping(issue: Issue, dry_run: bool) -> bool: - """Post a reminder comment and add the 'requested-info' label. Returns True on success.""" - author = issue.user.login - kind = "PR" if issue.pull_request else "Issue" - - if dry_run: - print(f" [DRY RUN] Would ping {kind} #{issue.number} (@{author})") - return True - - max_retries = 3 - commented = False - labeled = False - for attempt in range(1, max_retries + 1): - try: - if not commented: - issue.create_comment(PING_COMMENT.format(author=author)) - commented = True - if not labeled: - issue.add_to_labels(PINGED_LABEL) - labeled = True - print(f" Pinged {kind} #{issue.number} (@{author})") - return True - except Exception as exc: - if attempt < max_retries: - wait = 2 ** attempt # 2s, 4s - print(f" WARN: Attempt {attempt}/{max_retries} failed for {kind} #{issue.number}: {exc}. Retrying in {wait}s...") - time.sleep(wait) - else: - print(f" ERROR: Failed to ping {kind} #{issue.number} after {max_retries} attempts: {exc}") - return False - - -def main() -> None: - token = os.environ.get("GITHUB_TOKEN") - if not token: - print("ERROR: GITHUB_TOKEN environment variable is required") - sys.exit(1) - - repository = os.environ.get("GITHUB_REPOSITORY") - if not repository: - print("ERROR: GITHUB_REPOSITORY environment variable is required") - sys.exit(1) - - team_slug = os.environ.get("TEAM_SLUG") - if not team_slug: - print("ERROR: TEAM_SLUG environment variable is required") - sys.exit(1) - - days_threshold_raw = os.environ.get("DAYS_THRESHOLD", "4") - try: - days_threshold = int(days_threshold_raw) - except ValueError: - print(f"ERROR: DAYS_THRESHOLD must be a numeric value, got '{days_threshold_raw}'") - sys.exit(1) - dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" - - org = repository.split("/")[0] - - if dry_run: - print("Running in DRY RUN mode — no comments or labels will be applied.\n") - - g = Github(auth=Auth.Token(token)) - repo = g.get_repo(repository) - - print(f"Fetching team members for {org}/{team_slug}...") - team_members = get_team_members(g, org, team_slug) - print(f"Found {len(team_members)} team members.\n") - - now = datetime.now(timezone.utc) - pinged = [] - failed = [] - scanned = 0 - - print(f"Scanning open issues and PRs labeled '{TRIGGER_LABEL}' (threshold: {days_threshold} days)...\n") - - for issue in repo.get_issues(state="open", labels=[TRIGGER_LABEL]): - scanned += 1 - - if should_ping(issue, team_members, days_threshold, now): - if ping(issue, dry_run): - pinged.append(issue.number) - else: - failed.append(issue.number) - - print(f"\nDone. Scanned {scanned} items, pinged {len(pinged)}, failed {len(failed)}.") - if pinged: - print(f"Pinged: {', '.join(f'#{n}' for n in pinged)}") - if failed: - print(f"Failed: {', '.join(f'#{n}' for n in failed)}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/stale_pr_ping.py b/.github/scripts/stale_pr_ping.py new file mode 100644 index 00000000000..032a332d033 --- /dev/null +++ b/.github/scripts/stale_pr_ping.py @@ -0,0 +1,400 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Warn and close stale external-authored pull requests. + +After a repository or organization member's latest conversation comment, warn +and apply 'requested-info' after the warning threshold, then close after the +closing threshold without activity. Member-authored and 'dont-close'-labeled +pull requests are exempt. Membership uses GitHub's exact MEMBER association. +""" + +from __future__ import annotations + +import os +import sys +import time +from datetime import datetime, timezone + +from github import Auth, Github, GithubException +from github.IssueComment import IssueComment +from github.PullRequest import PullRequest + + +MEMBER_ASSOCIATION = "MEMBER" +REQUESTED_INFO_LABEL = "requested-info" +DONT_CLOSE_LABEL = "dont-close" +WARNING_MARKER = "" +WARNING_THRESHOLD_DAYS = 4 +CLOSING_THRESHOLD_DAYS = 4 +WARNING_COMMENT = ( + "@{author}, this pull request is waiting on your response. " + "If there is no further activity within {closing_days} days, " + "it will be closed automatically. (This is an automated message.)" + f"\n\n{WARNING_MARKER}" +) +CLOSING_COMMENT = ( + "Closing this pull request because there was no further activity " + "after the automated warning. (This is an automated message.)" +) + + +def get_latest_comment(pull_request: PullRequest) -> IssueComment | None: + """Fetch only the newest page of regular PR conversation comments.""" + return next(iter(pull_request.get_issue_comments().reversed), None) + + +def get_latest_comment_with_retry( + pull_request: PullRequest, +) -> tuple[bool, IssueComment | None]: + """Fetch the latest comment, retrying transient GitHub API failures.""" + max_retries = 3 + for attempt in range(1, max_retries + 1): + try: + return True, get_latest_comment(pull_request) + except GithubException as exc: + if attempt < max_retries: + wait = 2 ** attempt # 2s, 4s + print( + f" WARN: Latest-comment lookup attempt " + f"{attempt}/{max_retries} failed for " + f"PR #{pull_request.number}: {exc}. " + f"Retrying in {wait}s..." + ) + time.sleep(wait) + else: + print( + f" ERROR: Failed to fetch the latest comment for " + f"PR #{pull_request.number} after {max_retries} " + f"attempts: {exc}" + ) + return False, None + + +def has_warning_marker(comment: IssueComment | None) -> bool: + """Return whether a comment is the automated warning.""" + return bool(comment and WARNING_MARKER in (comment.body or "")) + + +def is_exempt(pull_request: PullRequest) -> bool: + """Return whether a pull request is exempt from the lifecycle.""" + return ( + pull_request.author_association == MEMBER_ASSOCIATION + or any(label.name == DONT_CLOSE_LABEL for label in pull_request.labels) + ) + + +def should_warn( + pull_request: PullRequest, + latest_comment: IssueComment | None, + now: datetime, + warning_threshold_days: int, +) -> bool: + """Return whether an external pull request is ready for its warning.""" + if is_exempt(pull_request): + return False + if any(label.name == REQUESTED_INFO_LABEL for label in pull_request.labels): + return False + days_inactive = ( + now - pull_request.updated_at.astimezone(timezone.utc) + ).days + if days_inactive < warning_threshold_days: + return False + + return bool( + latest_comment + and latest_comment.author_association == MEMBER_ASSOCIATION + ) + + +def should_close( + pull_request: PullRequest, + latest_comment: IssueComment | None, + now: datetime, + closing_threshold_days: int, +) -> bool: + """Return whether a warned external pull request is ready to close.""" + if is_exempt(pull_request): + return False + if not any( + label.name == REQUESTED_INFO_LABEL + for label in pull_request.labels + ): + return False + if not has_warning_marker(latest_comment): + return False + + days_inactive = ( + now - pull_request.updated_at.astimezone(timezone.utc) + ).days + return days_inactive >= closing_threshold_days + + +def clear_requested_info(pull_request: PullRequest, dry_run: bool) -> bool: + """Remove stale lifecycle state after newer conversation activity.""" + if dry_run: + print( + f" [DRY RUN] Would remove '{REQUESTED_INFO_LABEL}' " + f"from PR #{pull_request.number}" + ) + return True + + try: + pull_request.remove_from_labels(REQUESTED_INFO_LABEL) + except Exception as exc: + print( + f" ERROR: Failed to remove '{REQUESTED_INFO_LABEL}' " + f"from PR #{pull_request.number}: {exc}" + ) + return False + + print( + f" Removed '{REQUESTED_INFO_LABEL}' from PR #{pull_request.number} " + "after newer conversation activity" + ) + return True + + +def restore_requested_info(pull_request: PullRequest, dry_run: bool) -> bool: + """Restore lifecycle state when the warning comment already exists.""" + if dry_run: + print( + f" [DRY RUN] Would restore '{REQUESTED_INFO_LABEL}' " + f"on PR #{pull_request.number}" + ) + return True + + try: + pull_request.add_to_labels(REQUESTED_INFO_LABEL) + except Exception as exc: + print( + f" ERROR: Failed to restore '{REQUESTED_INFO_LABEL}' " + f"on PR #{pull_request.number}: {exc}" + ) + return False + + print( + f" Restored '{REQUESTED_INFO_LABEL}' on PR #{pull_request.number} " + "from the warning marker" + ) + return True + + +def warn( + pull_request: PullRequest, + dry_run: bool, + closing_threshold_days: int, +) -> bool: + """Post the warning and add 'requested-info'. Returns True on success.""" + author = pull_request.user.login + + if dry_run: + print( + f" [DRY RUN] Would warn PR #{pull_request.number} (@{author}) " + f"and add '{REQUESTED_INFO_LABEL}'" + ) + return True + + max_retries = 3 + commented = False + labeled = False + for attempt in range(1, max_retries + 1): + try: + if not commented: + pull_request.create_issue_comment( + WARNING_COMMENT.format( + author=author, + closing_days=closing_threshold_days, + ) + ) + commented = True + if not labeled: + pull_request.add_to_labels(REQUESTED_INFO_LABEL) + labeled = True + print(f" Warned PR #{pull_request.number} (@{author})") + return True + except Exception as exc: + if attempt < max_retries: + wait = 2 ** attempt # 2s, 4s + print( + f" WARN: Warning attempt {attempt}/{max_retries} failed " + f"for PR #{pull_request.number}: {exc}. " + f"Retrying in {wait}s..." + ) + time.sleep(wait) + else: + print( + f" ERROR: Failed to warn PR #{pull_request.number} after " + f"{max_retries} attempts: {exc}" + ) + return False + + +def close(pull_request: PullRequest, dry_run: bool) -> bool: + """Post the closing message and close the PR. Returns True on success.""" + author = pull_request.user.login + + if dry_run: + print( + f" [DRY RUN] Would comment on and close " + f"PR #{pull_request.number} (@{author})" + ) + return True + + max_retries = 3 + commented = False + for attempt in range(1, max_retries + 1): + try: + if not commented: + pull_request.create_issue_comment(CLOSING_COMMENT) + commented = True + pull_request.edit(state="closed") + print(f" Closed PR #{pull_request.number} (@{author})") + return True + except Exception as exc: + if attempt < max_retries: + wait = 2 ** attempt # 2s, 4s + print( + f" WARN: Closing attempt {attempt}/{max_retries} failed " + f"for PR #{pull_request.number}: {exc}. " + f"Retrying in {wait}s..." + ) + time.sleep(wait) + else: + print( + f" ERROR: Failed to close PR #{pull_request.number} after " + f"{max_retries} attempts: {exc}" + ) + return False + + +def get_runtime_thresholds() -> tuple[int, int]: + """Return warning and closing thresholds, applying DAYS_THRESHOLD to both.""" + override = os.environ.get("DAYS_THRESHOLD") + if override is None: + return WARNING_THRESHOLD_DAYS, CLOSING_THRESHOLD_DAYS + + try: + threshold = int(override) + except ValueError: + print( + "ERROR: DAYS_THRESHOLD must be a non-negative integer, " + f"got '{override}'" + ) + sys.exit(1) + + if threshold < 0: + print( + "ERROR: DAYS_THRESHOLD must be a non-negative integer, " + f"got '{override}'" + ) + sys.exit(1) + + return threshold, threshold + + +def main() -> None: + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("ERROR: GITHUB_TOKEN environment variable is required") + sys.exit(1) + + repository = os.environ.get("GITHUB_REPOSITORY") + if not repository: + print("ERROR: GITHUB_REPOSITORY environment variable is required") + sys.exit(1) + + warning_threshold_days, closing_threshold_days = get_runtime_thresholds() + dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" + + if dry_run: + print("Running in DRY RUN mode — no comments, labels, or closures will be applied.\n") + + g = Github(auth=Auth.Token(token)) + repo = g.get_repo(repository) + + now = datetime.now(timezone.utc) + warned = [] + closed = [] + failed = [] + scanned = 0 + + print( + "Scanning open pull requests " + f"(warning: {warning_threshold_days} days, " + f"closing: {closing_threshold_days} days)...\n" + ) + + pull_requests = list(repo.get_pulls(state="open")) + + for pull_request in pull_requests: + scanned += 1 + + if is_exempt(pull_request): + continue + + lookup_succeeded, latest_comment = get_latest_comment_with_retry( + pull_request + ) + if not lookup_succeeded: + failed.append(pull_request.number) + continue + + has_requested_info = any( + label.name == REQUESTED_INFO_LABEL + for label in pull_request.labels + ) + has_marker = has_warning_marker(latest_comment) + + if has_requested_info and not has_marker: + if not clear_requested_info(pull_request, dry_run): + failed.append(pull_request.number) + continue + + if has_marker and not has_requested_info: + if not restore_requested_info(pull_request, dry_run): + failed.append(pull_request.number) + continue + + if should_close( + pull_request, + latest_comment, + now, + closing_threshold_days, + ): + if close(pull_request, dry_run): + closed.append(pull_request.number) + else: + failed.append(pull_request.number) + continue + + if should_warn( + pull_request, + latest_comment, + now, + warning_threshold_days, + ): + if warn( + pull_request, + dry_run, + closing_threshold_days, + ): + warned.append(pull_request.number) + else: + failed.append(pull_request.number) + continue + + print( + f"\nDone. Scanned {scanned} PRs, warned {len(warned)}, " + f"closed {len(closed)}, failed {len(failed)}." + ) + if warned: + print(f"Warned: {', '.join(f'#{n}' for n in warned)}") + if closed: + print(f"Closed: {', '.join(f'#{n}' for n in closed)}") + if failed: + print(f"Failed: {', '.join(f'#{n}' for n in failed)}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/test_stale_issue_pr_ping.py b/.github/tests/test_stale_issue_pr_ping.py deleted file mode 100644 index b9a7ad5d434..00000000000 --- a/.github/tests/test_stale_issue_pr_ping.py +++ /dev/null @@ -1,297 +0,0 @@ -# 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 diff --git a/.github/tests/test_stale_pr_ping.py b/.github/tests/test_stale_pr_ping.py new file mode 100644 index 00000000000..ef65c9b92c1 --- /dev/null +++ b/.github/tests/test_stale_pr_ping.py @@ -0,0 +1,724 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for stale_pr_ping.py.""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import MagicMock, call, patch + +import pytest +from github import GithubException + +# Ensure the script directory is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +import stale_pr_ping +from stale_pr_ping import ( + CLOSING_COMMENT, + CLOSING_THRESHOLD_DAYS, + DONT_CLOSE_LABEL, + MEMBER_ASSOCIATION, + REQUESTED_INFO_LABEL, + WARNING_COMMENT, + WARNING_MARKER, + WARNING_THRESHOLD_DAYS, + clear_requested_info, + close, + get_latest_comment, + get_latest_comment_with_retry, + get_runtime_thresholds, + has_warning_marker, + is_exempt, + main, + restore_requested_info, + should_close, + should_warn, + warn, +) + +NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc) + + +def _make_comment( + login: str | None = "external", + body: str = "", + author_association: str = "CONTRIBUTOR", +) -> MagicMock: + comment = MagicMock() + comment.user = None if login is None else MagicMock(login=login) + comment.body = body + comment.author_association = author_association + return comment + + +def _member_comment(login: str = "member") -> MagicMock: + return _make_comment(login, author_association=MEMBER_ASSOCIATION) + + +def _warning_comment() -> MagicMock: + return _make_comment("github-actions[bot]", WARNING_MARKER) + + +def _make_pull_request( + author: str = "external", + author_association: str = "CONTRIBUTOR", + labels: list[str] | None = None, + comments: list[MagicMock] | None = None, + number: int = 42, + updated_at: datetime = NOW, +) -> MagicMock: + pull_request = MagicMock() + pull_request.user = MagicMock(login=author) + pull_request.author_association = author_association + pull_request.number = number + pull_request.labels = [ + SimpleNamespace(name=name) + for name in (labels or []) + ] + pull_request.updated_at = updated_at + issue_comments = MagicMock() + issue_comments.reversed = iter(reversed(comments or [])) + pull_request.get_issue_comments.return_value = issue_comments + return pull_request + + +@pytest.fixture +def main_context(monkeypatch): + github = MagicMock() + repo = github.return_value.get_repo.return_value + mock_datetime = MagicMock() + mock_datetime.now.return_value = NOW + monkeypatch.setattr(stale_pr_ping, "Github", github) + monkeypatch.setattr(stale_pr_ping, "datetime", mock_datetime) + monkeypatch.setenv("GITHUB_TOKEN", "tok") + monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo") + monkeypatch.delenv("DAYS_THRESHOLD", raising=False) + monkeypatch.delenv("DRY_RUN", raising=False) + return SimpleNamespace( + github=github, + repo=repo, + datetime=mock_datetime, + ) + + +class TestLatestComment: + def test_fetches_only_newest_reversed_comment_page(self): + latest_comment = _member_comment() + pull_request = _make_pull_request( + comments=[_make_comment(), latest_comment], + ) + issue_comments = pull_request.get_issue_comments.return_value + issue_comments.__iter__.side_effect = AssertionError( + "forward history should not be fetched" + ) + + assert get_latest_comment(pull_request) is latest_comment + pull_request.get_issue_comments.assert_called_once_with() + + def test_returns_none_without_comments(self): + assert get_latest_comment(_make_pull_request()) is None + + @patch("stale_pr_ping.time.sleep") + def test_transient_lookup_failure_succeeds_on_retry( + self, + mock_sleep, + capsys, + ): + latest_comment = _member_comment() + pull_request = _make_pull_request(comments=[latest_comment]) + issue_comments = pull_request.get_issue_comments.return_value + pull_request.get_issue_comments.side_effect = [ + GithubException(502, {"message": "Bad Gateway"}, None), + issue_comments, + ] + + assert get_latest_comment_with_retry(pull_request) == ( + True, + latest_comment, + ) + assert pull_request.get_issue_comments.call_count == 2 + mock_sleep.assert_called_once_with(2) + assert "lookup attempt 1/3 failed for PR #42" in capsys.readouterr().out + + @patch("stale_pr_ping.time.sleep") + def test_final_lookup_failure_is_reported( + self, + mock_sleep, + capsys, + ): + pull_request = _make_pull_request() + pull_request.get_issue_comments.side_effect = GithubException( + 502, + {"message": "Bad Gateway"}, + None, + ) + + assert get_latest_comment_with_retry(pull_request) == (False, None) + assert pull_request.get_issue_comments.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + assert ( + "Failed to fetch the latest comment for PR #42 after 3 attempts" + in capsys.readouterr().out + ) + + +class TestPolicy: + @pytest.mark.parametrize( + ("association", "labels", "expected"), + [ + (MEMBER_ASSOCIATION, [], True), + ("OWNER", [], False), + ("COLLABORATOR", [], False), + ("CONTRIBUTOR", [], False), + ("CONTRIBUTOR", [DONT_CLOSE_LABEL], True), + ], + ) + def test_exemptions(self, association, labels, expected): + pull_request = _make_pull_request( + author_association=association, + labels=labels, + ) + + assert is_exempt(pull_request) is expected + + @pytest.mark.parametrize( + ("comment", "expected"), + [ + (_warning_comment(), True), + (_make_comment(body="regular comment"), False), + (None, False), + ], + ) + def test_warning_marker(self, comment, expected): + assert has_warning_marker(comment) is expected + + def test_warns_at_threshold_for_latest_member_comment(self): + pull_request = _make_pull_request( + updated_at=NOW - timedelta(days=WARNING_THRESHOLD_DAYS), + ) + + assert should_warn( + pull_request, + _member_comment(), + NOW, + WARNING_THRESHOLD_DAYS, + ) is True + + def test_recent_activity_resets_warning_clock(self): + pull_request = _make_pull_request( + updated_at=NOW - timedelta(days=WARNING_THRESHOLD_DAYS - 1), + ) + + assert should_warn( + pull_request, + _member_comment(), + NOW, + WARNING_THRESHOLD_DAYS, + ) is False + + def test_requested_info_pr_is_not_warned_again(self): + pull_request = _make_pull_request( + labels=[REQUESTED_INFO_LABEL], + updated_at=NOW - timedelta(days=WARNING_THRESHOLD_DAYS), + ) + + assert should_warn( + pull_request, + _member_comment(), + NOW, + WARNING_THRESHOLD_DAYS, + ) is False + + @pytest.mark.parametrize( + "latest_comment", + [ + None, + _make_comment(author_association="OWNER"), + _make_comment(author_association="COLLABORATOR"), + _make_comment(author_association="CONTRIBUTOR"), + _make_comment(None, author_association="NONE"), + ], + ) + def test_non_member_or_missing_latest_comment_is_skipped( + self, + latest_comment, + ): + pull_request = _make_pull_request( + updated_at=NOW - timedelta(days=WARNING_THRESHOLD_DAYS), + ) + + assert should_warn( + pull_request, + latest_comment, + NOW, + WARNING_THRESHOLD_DAYS, + ) is False + + def test_closes_requested_info_pr_at_threshold(self): + pull_request = _make_pull_request( + labels=[REQUESTED_INFO_LABEL], + updated_at=NOW - timedelta(days=CLOSING_THRESHOLD_DAYS), + ) + + assert should_close( + pull_request, + _warning_comment(), + NOW, + CLOSING_THRESHOLD_DAYS, + ) is True + + def test_recent_activity_resets_closing_clock(self): + pull_request = _make_pull_request( + labels=[REQUESTED_INFO_LABEL], + updated_at=NOW - timedelta(days=CLOSING_THRESHOLD_DAYS - 1), + ) + + assert should_close( + pull_request, + _warning_comment(), + NOW, + CLOSING_THRESHOLD_DAYS, + ) is False + + @pytest.mark.parametrize( + ("labels", "comment"), + [ + ([], _warning_comment()), + ([REQUESTED_INFO_LABEL], _make_comment(body="manual comment")), + ], + ) + def test_closing_requires_label_and_warning_marker(self, labels, comment): + pull_request = _make_pull_request( + labels=labels, + updated_at=NOW - timedelta(days=CLOSING_THRESHOLD_DAYS), + ) + + assert should_close( + pull_request, + comment, + NOW, + CLOSING_THRESHOLD_DAYS, + ) is False + + +class TestThresholds: + def test_default_constants(self): + assert WARNING_THRESHOLD_DAYS == 4 + assert CLOSING_THRESHOLD_DAYS == 4 + + @pytest.mark.parametrize( + ("environment", "expected"), + [ + ({}, (4, 4)), + ({"DAYS_THRESHOLD": "7"}, (7, 7)), + ({"DAYS_THRESHOLD": "0"}, (0, 0)), + ], + ) + def test_runtime_thresholds(self, environment, expected): + with patch.dict(os.environ, environment, clear=True): + assert get_runtime_thresholds() == expected + + @pytest.mark.parametrize("value", ["invalid", "-1"]) + def test_invalid_runtime_override_is_rejected(self, value, capsys): + with patch.dict( + os.environ, + {"DAYS_THRESHOLD": value}, + clear=True, + ), pytest.raises(SystemExit): + get_runtime_thresholds() + + assert "non-negative integer" in capsys.readouterr().out + + +class TestWarn: + def test_dry_run_mutates_nothing(self, capsys): + pull_request = _make_pull_request() + + assert warn(pull_request, dry_run=True, closing_threshold_days=6) is True + pull_request.create_issue_comment.assert_not_called() + pull_request.add_to_labels.assert_not_called() + assert REQUESTED_INFO_LABEL in capsys.readouterr().out + + def test_posts_warning_before_applying_label(self): + pull_request = _make_pull_request() + + assert warn(pull_request, dry_run=False, closing_threshold_days=6) is True + assert pull_request.method_calls == [ + call.create_issue_comment( + WARNING_COMMENT.format( + author="external", + closing_days=6, + ) + ), + call.add_to_labels(REQUESTED_INFO_LABEL), + ] + + @patch("stale_pr_ping.time.sleep") + def test_comment_retry_succeeds(self, mock_sleep): + pull_request = _make_pull_request() + pull_request.create_issue_comment.side_effect = [ + Exception("network error"), + None, + ] + + assert warn(pull_request, dry_run=False, closing_threshold_days=4) is True + assert pull_request.create_issue_comment.call_count == 2 + pull_request.add_to_labels.assert_called_once_with(REQUESTED_INFO_LABEL) + mock_sleep.assert_called_once_with(2) + + @patch("stale_pr_ping.time.sleep") + def test_label_retry_does_not_repeat_warning(self, mock_sleep): + pull_request = _make_pull_request() + pull_request.add_to_labels.side_effect = [ + Exception("label error"), + None, + ] + + assert warn(pull_request, dry_run=False, closing_threshold_days=4) is True + pull_request.create_issue_comment.assert_called_once() + assert pull_request.add_to_labels.call_count == 2 + mock_sleep.assert_called_once_with(2) + + @pytest.mark.parametrize("failure_stage", ["comment", "label"]) + @patch("stale_pr_ping.time.sleep") + def test_final_failure_preserves_partial_state( + self, + mock_sleep, + failure_stage, + ): + pull_request = _make_pull_request() + if failure_stage == "comment": + pull_request.create_issue_comment.side_effect = Exception("comment") + else: + pull_request.add_to_labels.side_effect = Exception("label") + + assert warn(pull_request, dry_run=False, closing_threshold_days=4) is False + assert mock_sleep.call_args_list == [call(2), call(4)] + if failure_stage == "comment": + assert pull_request.create_issue_comment.call_count == 3 + pull_request.add_to_labels.assert_not_called() + else: + pull_request.create_issue_comment.assert_called_once() + assert pull_request.add_to_labels.call_count == 3 + + +class TestRequestedInfoRecovery: + @pytest.mark.parametrize( + ("function", "method_name", "label"), + [ + (clear_requested_info, "remove_from_labels", REQUESTED_INFO_LABEL), + (restore_requested_info, "add_to_labels", REQUESTED_INFO_LABEL), + ], + ) + def test_updates_requested_info(self, function, method_name, label): + pull_request = _make_pull_request(labels=[REQUESTED_INFO_LABEL]) + + assert function(pull_request, dry_run=False) is True + getattr(pull_request, method_name).assert_called_once_with(label) + + @pytest.mark.parametrize( + ("function", "method_name"), + [ + (clear_requested_info, "remove_from_labels"), + (restore_requested_info, "add_to_labels"), + ], + ) + def test_dry_run_mutates_nothing( + self, + function, + method_name, + capsys, + ): + pull_request = _make_pull_request(labels=[REQUESTED_INFO_LABEL]) + + assert function(pull_request, dry_run=True) is True + getattr(pull_request, method_name).assert_not_called() + assert "DRY RUN" in capsys.readouterr().out + + @pytest.mark.parametrize( + ("function", "method_name"), + [ + (clear_requested_info, "remove_from_labels"), + (restore_requested_info, "add_to_labels"), + ], + ) + def test_failure_is_explicit( + self, + function, + method_name, + capsys, + ): + pull_request = _make_pull_request(labels=[REQUESTED_INFO_LABEL]) + getattr(pull_request, method_name).side_effect = Exception("failure") + + assert function(pull_request, dry_run=False) is False + assert "ERROR" in capsys.readouterr().out + + def test_restore_does_not_repeat_warning(self): + pull_request = _make_pull_request() + + assert restore_requested_info(pull_request, dry_run=False) is True + pull_request.create_issue_comment.assert_not_called() + + +class TestClose: + def test_dry_run_mutates_nothing(self, capsys): + pull_request = _make_pull_request() + + assert close(pull_request, dry_run=True) is True + pull_request.create_issue_comment.assert_not_called() + pull_request.edit.assert_not_called() + assert "DRY RUN" in capsys.readouterr().out + + def test_posts_comment_before_closing(self): + pull_request = _make_pull_request() + + assert close(pull_request, dry_run=False) is True + assert pull_request.method_calls == [ + call.create_issue_comment(CLOSING_COMMENT), + call.edit(state="closed"), + ] + + @patch("stale_pr_ping.time.sleep") + def test_close_retry_does_not_repeat_comment(self, mock_sleep): + pull_request = _make_pull_request() + pull_request.edit.side_effect = [Exception("close error"), None] + + assert close(pull_request, dry_run=False) is True + pull_request.create_issue_comment.assert_called_once_with(CLOSING_COMMENT) + assert pull_request.edit.call_count == 2 + mock_sleep.assert_called_once_with(2) + + @pytest.mark.parametrize("failure_stage", ["comment", "close"]) + @patch("stale_pr_ping.time.sleep") + def test_final_failure_preserves_partial_state( + self, + mock_sleep, + failure_stage, + ): + pull_request = _make_pull_request() + if failure_stage == "comment": + pull_request.create_issue_comment.side_effect = Exception("comment") + else: + pull_request.edit.side_effect = Exception("close") + + assert close(pull_request, dry_run=False) is False + assert mock_sleep.call_args_list == [call(2), call(4)] + if failure_stage == "comment": + assert pull_request.create_issue_comment.call_count == 3 + pull_request.edit.assert_not_called() + else: + pull_request.create_issue_comment.assert_called_once() + assert pull_request.edit.call_count == 3 + + +class TestMain: + @pytest.mark.parametrize( + ("environment", "message"), + [ + ({}, "GITHUB_TOKEN"), + ({"GITHUB_TOKEN": "tok"}, "GITHUB_REPOSITORY"), + ], + ) + def test_required_environment(self, environment, message, capsys): + with patch.dict(os.environ, environment, clear=True), pytest.raises( + SystemExit + ): + main() + + assert message in capsys.readouterr().out + + def test_invalid_threshold_stops_before_github( + self, + main_context, + monkeypatch, + capsys, + ): + monkeypatch.setenv("DAYS_THRESHOLD", "invalid") + + with pytest.raises(SystemExit): + main() + + main_context.github.assert_not_called() + assert "non-negative integer" in capsys.readouterr().out + + def test_scans_only_open_prs_and_warns( + self, + main_context, + monkeypatch, + capsys, + ): + monkeypatch.setenv("DAYS_THRESHOLD", "7") + pull_request = _make_pull_request( + comments=[_member_comment()], + updated_at=NOW - timedelta(days=7), + ) + main_context.repo.get_pulls.return_value = [pull_request] + + main() + + main_context.repo.get_pulls.assert_called_once_with(state="open") + main_context.repo.get_issues.assert_not_called() + pull_request.create_issue_comment.assert_called_once_with( + WARNING_COMMENT.format(author="external", closing_days=7) + ) + pull_request.add_to_labels.assert_called_once_with(REQUESTED_INFO_LABEL) + pull_request.edit.assert_not_called() + assert "Scanned 1 PRs, warned 1, closed 0" in capsys.readouterr().out + + def test_materializes_prs_before_closing( + self, + main_context, + monkeypatch, + capsys, + ): + monkeypatch.setenv("DAYS_THRESHOLD", "7") + pull_request = _make_pull_request( + labels=[REQUESTED_INFO_LABEL], + comments=[_warning_comment()], + updated_at=NOW - timedelta(days=7), + ) + materialized = {"complete": False} + + def pull_requests(): + yield pull_request + materialized["complete"] = True + + def assert_materialized(**kwargs): + assert materialized["complete"] is True + assert kwargs == {"state": "closed"} + + main_context.repo.get_pulls.return_value = pull_requests() + pull_request.edit.side_effect = assert_materialized + + main() + + pull_request.create_issue_comment.assert_called_once_with(CLOSING_COMMENT) + pull_request.edit.assert_called_once_with(state="closed") + output = capsys.readouterr().out + assert "Scanned 1 PRs, warned 0, closed 1, failed 0" in output + assert "Closed: #42" in output + + def test_clears_manual_or_post_warning_state(self, main_context): + pull_request = _make_pull_request( + labels=[REQUESTED_INFO_LABEL], + comments=[_make_comment(body="author update")], + updated_at=NOW - timedelta(days=CLOSING_THRESHOLD_DAYS), + ) + main_context.repo.get_pulls.return_value = [pull_request] + + main() + + pull_request.remove_from_labels.assert_called_once_with( + REQUESTED_INFO_LABEL + ) + pull_request.create_issue_comment.assert_not_called() + pull_request.edit.assert_not_called() + + def test_restores_missing_label_without_reposting(self, main_context): + pull_request = _make_pull_request( + comments=[_warning_comment()], + updated_at=NOW - timedelta(days=CLOSING_THRESHOLD_DAYS), + ) + main_context.repo.get_pulls.return_value = [pull_request] + + main() + + pull_request.add_to_labels.assert_called_once_with(REQUESTED_INFO_LABEL) + pull_request.create_issue_comment.assert_not_called() + pull_request.edit.assert_not_called() + + def test_recovery_dry_run_mutates_nothing( + self, + main_context, + monkeypatch, + ): + monkeypatch.setenv("DRY_RUN", "true") + manual_label = _make_pull_request( + labels=[REQUESTED_INFO_LABEL], + comments=[_make_comment(body="author update")], + number=41, + ) + missing_label = _make_pull_request( + comments=[_warning_comment()], + number=42, + ) + main_context.repo.get_pulls.return_value = [ + manual_label, + missing_label, + ] + + main() + + manual_label.remove_from_labels.assert_not_called() + missing_label.add_to_labels.assert_not_called() + manual_label.edit.assert_not_called() + missing_label.edit.assert_not_called() + + def test_failed_warning_is_reported_and_exits( + self, + main_context, + monkeypatch, + capsys, + ): + pull_request = _make_pull_request( + comments=[_member_comment()], + updated_at=NOW - timedelta(days=WARNING_THRESHOLD_DAYS), + ) + main_context.repo.get_pulls.return_value = [pull_request] + mock_warn = MagicMock(return_value=False) + monkeypatch.setattr(stale_pr_ping, "warn", mock_warn) + + with pytest.raises(SystemExit): + main() + + mock_warn.assert_called_once_with( + pull_request, + False, + CLOSING_THRESHOLD_DAYS, + ) + output = capsys.readouterr().out + assert "Scanned 1 PRs, warned 0, closed 0, failed 1" in output + assert "Failed: #42" in output + + def test_lookup_failure_does_not_block_later_pr( + self, + main_context, + monkeypatch, + capsys, + ): + failed_pull_request = _make_pull_request(number=41) + failed_pull_request.get_issue_comments.side_effect = GithubException( + 502, + {"message": "Bad Gateway"}, + None, + ) + later_pull_request = _make_pull_request( + comments=[_member_comment()], + number=42, + updated_at=NOW - timedelta(days=WARNING_THRESHOLD_DAYS), + ) + main_context.repo.get_pulls.return_value = [ + failed_pull_request, + later_pull_request, + ] + mock_sleep = MagicMock() + monkeypatch.setattr(stale_pr_ping.time, "sleep", mock_sleep) + + with pytest.raises(SystemExit): + main() + + assert failed_pull_request.get_issue_comments.call_count == 3 + assert mock_sleep.call_args_list == [call(2), call(4)] + later_pull_request.create_issue_comment.assert_called_once() + later_pull_request.add_to_labels.assert_called_once_with( + REQUESTED_INFO_LABEL + ) + output = capsys.readouterr().out + assert "Scanned 2 PRs, warned 1, closed 0, failed 1" in output + assert "Warned: #42" in output + assert "Failed: #41" in output diff --git a/.github/workflows/stale-issue-pr-ping.yml b/.github/workflows/stale-pr-ping.yml similarity index 83% rename from .github/workflows/stale-issue-pr-ping.yml rename to .github/workflows/stale-pr-ping.yml index 4b395282bdd..d4b6eda1e42 100644 --- a/.github/workflows/stale-issue-pr-ping.yml +++ b/.github/workflows/stale-pr-ping.yml @@ -1,4 +1,4 @@ -name: Stale issue and PR ping +name: Stale pull request lifecycle on: schedule: @@ -6,11 +6,11 @@ on: workflow_dispatch: inputs: days_threshold: - description: 'Days of silence before pinging the author' + description: 'Days of inactivity for both warning and closing periods' required: false default: '4' dry_run: - description: 'Log what would be pinged without taking action' + description: 'Log pull request warnings and closures without taking action' required: false default: 'false' type: choice @@ -23,8 +23,8 @@ concurrency: cancel-in-progress: true jobs: - ping_stale: - name: "Ping stale issues and PRs" + manage_stale: + name: "Manage stale pull requests" runs-on: ubuntu-latest environment: github-app-auth permissions: @@ -57,10 +57,9 @@ jobs: - name: Install dependencies run: pip install PyGithub==2.6.0 - - name: Run stale issue/PR ping - run: python .github/scripts/stale_issue_pr_ping.py + - name: Run stale pull request lifecycle + run: python .github/scripts/stale_pr_ping.py env: GITHUB_TOKEN: ${{ steps.github-auth.outputs.token }} - TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }} DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }} DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}