From 8a19a6ba852573db6a7abe849325ae2779b6d29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 09:33:46 +0000 Subject: [PATCH 01/16] ci: Auto-reminder github MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../python_scripts/auto_reminder_github.py | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 tests/test_utils/python_scripts/auto_reminder_github.py diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py new file mode 100644 index 00000000000..6100882d918 --- /dev/null +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +GitHub PR Review Reminder Automation +Requirements: pip install PyGithub +Usage: GITHUB_TOKEN=ghp_... REPO=NVIDIA/Megatron-LM python github_pr_reminder.py +""" + +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import List, Set +from github import Github + + +@dataclass +class Reminder: + id: int + pr: str + milestone: str + author: str + priority: str + review_stage: str + total_review_time: int + current_stage_time: int + reviewers: List[str] + + +class PRReviewTracker: + EXPERT_REVIEW = "Expert Review" + FINAL_REVIEW = "Final Review" + EXCLUDED_TEAMS = {"core-adlr", "core-nemo"} + + def __init__(self, token: str, repo_name: str): + self.repo = Github(token).get_repo(repo_name) + self.github = Github(token) + + def get_label_date(self, pr, label: str): + """Get most recent date when label was attached.""" + dates = [e.created_at for e in pr.as_issue().get_events() + if e.event == "labeled" and e.label and e.label.name == label] + return max(dates) if dates else None + + def days_since(self, date): + """Calculate days since given date.""" + if not date: + return 0 + now = datetime.now(timezone.utc) + if date.tzinfo is None: + date = date.replace(tzinfo=timezone.utc) + return (now - date).days + + def get_stage(self, pr): + """Get current review stage.""" + labels = {l.name for l in pr.labels} + return self.FINAL_REVIEW if self.FINAL_REVIEW in labels else self.EXPERT_REVIEW + + def get_reviewers(self, pr): + """Get filtered reviewer usernames.""" + stage = self.get_stage(pr) + teams = {t.slug for t in pr.get_review_requests()[1]} + + # Filter teams based on stage + if stage == self.EXPERT_REVIEW: + teams -= self.EXCLUDED_TEAMS + else: # FINAL_REVIEW + teams &= self.EXCLUDED_TEAMS + + # Get team members + reviewers = set() + org = self.github.get_organization(self.repo.organization.login) + for slug in teams: + try: + team = org.get_team_by_slug(slug) + reviewers.update(m.login for m in team.get_members()) + except: + pass + + # Add individual reviewers + reviewers.update(r.login for r in pr.get_review_requests()[0]) + return sorted(reviewers) + + def create_reminder(self, pr): + """Create reminder for PR.""" + stage = self.get_stage(pr) + expert_days = self.days_since(self.get_label_date(pr, self.EXPERT_REVIEW)) + stage_days = self.days_since(self.get_label_date(pr, stage)) + priority = "P0" if stage_days > 3 else "P1" if stage_days >= 1 else "P2" + + return Reminder( + id=pr.number, + pr=f"<{pr.html_url}|#{pr.number} - {pr.title}>", + milestone=pr.milestone.title if pr.milestone else "No Milestone", + author=f"@{pr.user.login}", + priority=priority, + review_stage=stage, + total_review_time=expert_days, + current_stage_time=stage_days, + reviewers=self.get_reviewers(pr) + ) + + def generate_reminders(self): + """Generate all reminders.""" + # Get top 2 milestones + milestones = list(self.repo.get_milestones(state="open", sort="due_on", direction="desc"))[:2] + print(f"📋 Milestones: {', '.join(m.title for m in milestones)}") + + # Get PRs with required labels + reminders = [] + for milestone in milestones: + for issue in self.repo.get_issues(state="open", milestone=milestone): + if not issue.pull_request: + continue + labels = {l.name for l in issue.labels} + if self.EXPERT_REVIEW in labels or self.FINAL_REVIEW in labels: + try: + pr = self.repo.get_pull(issue.number) + reminders.append(self.create_reminder(pr)) + print(f"✅ PR #{pr.number}") + except Exception as e: + print(f"❌ PR #{issue.number}: {e}") + + return sorted(reminders, key=lambda r: (r.priority, -r.current_stage_time)) + + +def main(): + token = os.environ.get("GITHUB_TOKEN") + repo = os.environ.get("REPO", "NVIDIA/Megatron-LM") + + if not token: + print("❌ GITHUB_TOKEN required") + sys.exit(1) + + tracker = PRReviewTracker(token, repo) + reminders = tracker.generate_reminders() + + print(f"\n📊 {len(reminders)} reminders\n" + "=" * 80) + for r in reminders: + print(f"\n{r.priority} | PR #{r.id} | {r.milestone}") + print(f" Author: {r.author} | Stage: {r.review_stage}") + print(f" Stage time: {r.current_stage_time}d | Total: {r.total_review_time}d") + print(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") + + return reminders + + +if __name__ == "__main__": + main() \ No newline at end of file From 91ef8f778695ff1804aa7f3642932ef99879df89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 09:46:22 +0000 Subject: [PATCH 02/16] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../python_scripts/auto_reminder_github.py | 87 ++++++++++++------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index 6100882d918..d1f4b49f101 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -2,16 +2,20 @@ """ GitHub PR Review Reminder Automation Requirements: pip install PyGithub -Usage: GITHUB_TOKEN=ghp_... REPO=NVIDIA/Megatron-LM python github_pr_reminder.py +Usage: GH_TOKEN=ghp_... REPO=NVIDIA/Megatron-LM python github_pr_reminder.py """ import os import sys +import logging from dataclasses import dataclass from datetime import datetime, timezone -from typing import List, Set +from typing import List from github import Github +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + @dataclass class Reminder: @@ -32,8 +36,40 @@ class PRReviewTracker: EXCLUDED_TEAMS = {"core-adlr", "core-nemo"} def __init__(self, token: str, repo_name: str): - self.repo = Github(token).get_repo(repo_name) self.github = Github(token) + self.repo = self.github.get_repo(repo_name) + self.email_cache = {} + + def get_user_email(self, username: str): + """Get user's email address from GitHub API.""" + if username in self.email_cache: + return self.email_cache[username] + + try: + user = self.github.get_user(username) + email = user.email + + if not email: + events = list(user.get_events()[:10]) + for event in events: + if event.type == "PushEvent" and event.payload.get("commits"): + for commit in event.payload["commits"]: + if commit.get("author", {}).get("email"): + email = commit["author"]["email"] + break + if email: + break + + if not email: + email = f"{username}@users.noreply.github.com" + + self.email_cache[username] = email + return email + except Exception as e: + logger.warning(f"Could not get email for {username}: {e}") + email = f"{username}@users.noreply.github.com" + self.email_cache[username] = email + return email def get_label_date(self, pr, label: str): """Get most recent date when label was attached.""" @@ -45,10 +81,9 @@ def days_since(self, date): """Calculate days since given date.""" if not date: return 0 - now = datetime.now(timezone.utc) if date.tzinfo is None: date = date.replace(tzinfo=timezone.utc) - return (now - date).days + return (datetime.now(timezone.utc) - date).days def get_stage(self, pr): """Get current review stage.""" @@ -56,56 +91,48 @@ def get_stage(self, pr): return self.FINAL_REVIEW if self.FINAL_REVIEW in labels else self.EXPERT_REVIEW def get_reviewers(self, pr): - """Get filtered reviewer usernames.""" + """Get filtered reviewer emails.""" stage = self.get_stage(pr) teams = {t.slug for t in pr.get_review_requests()[1]} - # Filter teams based on stage if stage == self.EXPERT_REVIEW: teams -= self.EXCLUDED_TEAMS - else: # FINAL_REVIEW + else: teams &= self.EXCLUDED_TEAMS - # Get team members reviewers = set() org = self.github.get_organization(self.repo.organization.login) for slug in teams: try: - team = org.get_team_by_slug(slug) - reviewers.update(m.login for m in team.get_members()) + reviewers.update(m.login for m in org.get_team_by_slug(slug).get_members()) except: pass - # Add individual reviewers reviewers.update(r.login for r in pr.get_review_requests()[0]) - return sorted(reviewers) + return sorted([self.get_user_email(u) for u in reviewers]) def create_reminder(self, pr): """Create reminder for PR.""" stage = self.get_stage(pr) - expert_days = self.days_since(self.get_label_date(pr, self.EXPERT_REVIEW)) stage_days = self.days_since(self.get_label_date(pr, stage)) - priority = "P0" if stage_days > 3 else "P1" if stage_days >= 1 else "P2" return Reminder( id=pr.number, pr=f"<{pr.html_url}|#{pr.number} - {pr.title}>", milestone=pr.milestone.title if pr.milestone else "No Milestone", - author=f"@{pr.user.login}", - priority=priority, + author=self.get_user_email(pr.user.login), + priority="P0" if stage_days > 3 else "P1" if stage_days >= 1 else "P2", review_stage=stage, - total_review_time=expert_days, + total_review_time=self.days_since(self.get_label_date(pr, self.EXPERT_REVIEW)), current_stage_time=stage_days, reviewers=self.get_reviewers(pr) ) def generate_reminders(self): """Generate all reminders.""" - # Get top 2 milestones milestones = list(self.repo.get_milestones(state="open", sort="due_on", direction="desc"))[:2] - print(f"📋 Milestones: {', '.join(m.title for m in milestones)}") + logger.info(f"Found milestones: {', '.join(m.title for m in milestones)}") - # Get PRs with required labels reminders = [] for milestone in milestones: for issue in self.repo.get_issues(state="open", milestone=milestone): @@ -114,27 +141,27 @@ def generate_reminders(self): labels = {l.name for l in issue.labels} if self.EXPERT_REVIEW in labels or self.FINAL_REVIEW in labels: try: - pr = self.repo.get_pull(issue.number) - reminders.append(self.create_reminder(pr)) - print(f"✅ PR #{pr.number}") + reminders.append(self.create_reminder(self.repo.get_pull(issue.number))) + logger.info(f"Processed PR #{issue.number}") except Exception as e: - print(f"❌ PR #{issue.number}: {e}") + logger.error(f"Failed to process PR #{issue.number}: {e}") return sorted(reminders, key=lambda r: (r.priority, -r.current_stage_time)) def main(): - token = os.environ.get("GITHUB_TOKEN") + token = os.environ.get("GH_TOKEN") repo = os.environ.get("REPO", "NVIDIA/Megatron-LM") if not token: - print("❌ GITHUB_TOKEN required") + logger.error("GH_TOKEN environment variable is required") sys.exit(1) - tracker = PRReviewTracker(token, repo) - reminders = tracker.generate_reminders() + logger.info(f"Starting PR review reminder for {repo}") + reminders = PRReviewTracker(token, repo).generate_reminders() + logger.info(f"Generated {len(reminders)} reminders") - print(f"\n📊 {len(reminders)} reminders\n" + "=" * 80) + print("\n" + "=" * 80) for r in reminders: print(f"\n{r.priority} | PR #{r.id} | {r.milestone}") print(f" Author: {r.author} | Stage: {r.review_stage}") From a0f469cfd6d79281cdaaf12112fb3476e60c43a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 09:56:19 +0000 Subject: [PATCH 03/16] cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../python_scripts/auto_reminder_github.py | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index d1f4b49f101..c3e65747801 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -41,30 +41,45 @@ def __init__(self, token: str, repo_name: str): self.email_cache = {} def get_user_email(self, username: str): - """Get user's email address from GitHub API.""" + """Get user's email from their recent commits in fork or main repo.""" if username in self.email_cache: return self.email_cache[username] try: user = self.github.get_user(username) - email = user.email + repos = [] - if not email: - events = list(user.get_events()[:10]) - for event in events: - if event.type == "PushEvent" and event.payload.get("commits"): - for commit in event.payload["commits"]: - if commit.get("author", {}).get("email"): - email = commit["author"]["email"] - break - if email: - break + # Try user's fork first + try: + repos.append(user.get_repo(self.repo.name)) + except: + pass + repos.append(self.repo) - if not email: - email = f"{username}@users.noreply.github.com" + # Search commits in fork then main repo + for repo in repos: + try: + commits = self.github.search_commits( + f"author:{username} repo:{repo.full_name}", + sort="author-date", + order="desc" + ) + for commit in commits[:5]: + try: + email = repo.get_commit(commit.sha).commit.author.email + if email and not email.endswith("@users.noreply.github.com"): + self.email_cache[username] = email + return email + except: + continue + except: + continue + # Fallback to public email or noreply + email = user.email or f"{username}@users.noreply.github.com" self.email_cache[username] = email return email + except Exception as e: logger.warning(f"Could not get email for {username}: {e}") email = f"{username}@users.noreply.github.com" @@ -95,10 +110,7 @@ def get_reviewers(self, pr): stage = self.get_stage(pr) teams = {t.slug for t in pr.get_review_requests()[1]} - if stage == self.EXPERT_REVIEW: - teams -= self.EXCLUDED_TEAMS - else: - teams &= self.EXCLUDED_TEAMS + teams = teams - self.EXCLUDED_TEAMS if stage == self.EXPERT_REVIEW else teams & self.EXCLUDED_TEAMS reviewers = set() org = self.github.get_organization(self.repo.organization.login) @@ -159,14 +171,13 @@ def main(): logger.info(f"Starting PR review reminder for {repo}") reminders = PRReviewTracker(token, repo).generate_reminders() - logger.info(f"Generated {len(reminders)} reminders") + logger.info(f"Generated {len(reminders)} reminders\n{'=' * 80}") - print("\n" + "=" * 80) for r in reminders: - print(f"\n{r.priority} | PR #{r.id} | {r.milestone}") - print(f" Author: {r.author} | Stage: {r.review_stage}") - print(f" Stage time: {r.current_stage_time}d | Total: {r.total_review_time}d") - print(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") + logger.info(f"{r.priority} | PR #{r.id} | {r.milestone}") + logger.info(f" Author: {r.author} | Stage: {r.review_stage}") + logger.info(f" Stage time: {r.current_stage_time}d | Total: {r.total_review_time}d") + logger.info(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") return reminders From 6206b6516334dcffe7b6f9fa98efc637159029a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 10:50:09 +0000 Subject: [PATCH 04/16] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../python_scripts/auto_reminder_github.py | 149 +++++++++++++----- 1 file changed, 111 insertions(+), 38 deletions(-) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index c3e65747801..deba0886b67 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -1,17 +1,21 @@ #!/usr/bin/env python3 """ GitHub PR Review Reminder Automation -Requirements: pip install PyGithub -Usage: GH_TOKEN=ghp_... REPO=NVIDIA/Megatron-LM python github_pr_reminder.py +Requirements: pip install PyGithub slack-sdk requests +Usage: GH_TOKEN=ghp_... SLACK_TOKEN=xoxb-... SLACK_WEBHOOK_URL=https://... REPO=NVIDIA/Megatron-LM python github_pr_reminder.py """ +import logging import os import sys -import logging from dataclasses import dataclass from datetime import datetime, timezone from typing import List + +import requests from github import Github +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -34,35 +38,38 @@ class PRReviewTracker: EXPERT_REVIEW = "Expert Review" FINAL_REVIEW = "Final Review" EXCLUDED_TEAMS = {"core-adlr", "core-nemo"} - - def __init__(self, token: str, repo_name: str): + + def __init__( + self, token: str, repo_name: str, slack_token: str = None, webhook_url: str = None + ): self.github = Github(token) self.repo = self.github.get_repo(repo_name) self.email_cache = {} - + self.slack_id_cache = {} + self.slack_client = WebClient(token=slack_token) if slack_token else None + self.webhook_url = webhook_url + def get_user_email(self, username: str): """Get user's email from their recent commits in fork or main repo.""" if username in self.email_cache: return self.email_cache[username] - + try: user = self.github.get_user(username) repos = [] - + # Try user's fork first try: repos.append(user.get_repo(self.repo.name)) except: pass repos.append(self.repo) - + # Search commits in fork then main repo for repo in repos: try: commits = self.github.search_commits( - f"author:{username} repo:{repo.full_name}", - sort="author-date", - order="desc" + f"author:{username} repo:{repo.full_name}", sort="author-date", order="desc" ) for commit in commits[:5]: try: @@ -74,24 +81,45 @@ def get_user_email(self, username: str): continue except: continue - + # Fallback to public email or noreply email = user.email or f"{username}@users.noreply.github.com" self.email_cache[username] = email return email - + except Exception as e: logger.warning(f"Could not get email for {username}: {e}") email = f"{username}@users.noreply.github.com" self.email_cache[username] = email return email - + + def get_slack_user_id(self, email: str): + """Get Slack user ID from email.""" + if not self.slack_client: + return email + + if email in self.slack_id_cache: + return self.slack_id_cache[email] + + try: + response = self.slack_client.users_lookupByEmail(email=email) + user_id = response["user"]["id"] + self.slack_id_cache[email] = f"<@{user_id}>" + return self.slack_id_cache[email] + except SlackApiError as e: + logger.warning(f"Could not find Slack user for {email}: {e.response['error']}") + self.slack_id_cache[email] = email + return email + def get_label_date(self, pr, label: str): """Get most recent date when label was attached.""" - dates = [e.created_at for e in pr.as_issue().get_events() - if e.event == "labeled" and e.label and e.label.name == label] + dates = [ + e.created_at + for e in pr.as_issue().get_events() + if e.event == "labeled" and e.label and e.label.name == label + ] return max(dates) if dates else None - + def days_since(self, date): """Calculate days since given date.""" if not date: @@ -99,19 +127,23 @@ def days_since(self, date): if date.tzinfo is None: date = date.replace(tzinfo=timezone.utc) return (datetime.now(timezone.utc) - date).days - + def get_stage(self, pr): """Get current review stage.""" labels = {l.name for l in pr.labels} return self.FINAL_REVIEW if self.FINAL_REVIEW in labels else self.EXPERT_REVIEW - + def get_reviewers(self, pr): """Get filtered reviewer emails.""" stage = self.get_stage(pr) teams = {t.slug for t in pr.get_review_requests()[1]} - - teams = teams - self.EXCLUDED_TEAMS if stage == self.EXPERT_REVIEW else teams & self.EXCLUDED_TEAMS - + + teams = ( + teams - self.EXCLUDED_TEAMS + if stage == self.EXPERT_REVIEW + else teams & self.EXCLUDED_TEAMS + ) + reviewers = set() org = self.github.get_organization(self.repo.organization.login) for slug in teams: @@ -119,32 +151,36 @@ def get_reviewers(self, pr): reviewers.update(m.login for m in org.get_team_by_slug(slug).get_members()) except: pass - + reviewers.update(r.login for r in pr.get_review_requests()[0]) return sorted([self.get_user_email(u) for u in reviewers]) - + def create_reminder(self, pr): """Create reminder for PR.""" stage = self.get_stage(pr) stage_days = self.days_since(self.get_label_date(pr, stage)) - + author_email = self.get_user_email(pr.user.login) + reviewer_emails = self.get_reviewers(pr) + return Reminder( id=pr.number, pr=f"<{pr.html_url}|#{pr.number} - {pr.title}>", milestone=pr.milestone.title if pr.milestone else "No Milestone", - author=self.get_user_email(pr.user.login), + author=self.get_slack_user_id(author_email), priority="P0" if stage_days > 3 else "P1" if stage_days >= 1 else "P2", review_stage=stage, total_review_time=self.days_since(self.get_label_date(pr, self.EXPERT_REVIEW)), current_stage_time=stage_days, - reviewers=self.get_reviewers(pr) + reviewers=[self.get_slack_user_id(email) for email in reviewer_emails], ) - + def generate_reminders(self): """Generate all reminders.""" - milestones = list(self.repo.get_milestones(state="open", sort="due_on", direction="desc"))[:2] + milestones = list(self.repo.get_milestones(state="open", sort="due_on", direction="desc"))[ + :2 + ] logger.info(f"Found milestones: {', '.join(m.title for m in milestones)}") - + reminders = [] for milestone in milestones: for issue in self.repo.get_issues(state="open", milestone=milestone): @@ -157,30 +193,67 @@ def generate_reminders(self): logger.info(f"Processed PR #{issue.number}") except Exception as e: logger.error(f"Failed to process PR #{issue.number}: {e}") - + return sorted(reminders, key=lambda r: (r.priority, -r.current_stage_time)) + def send_slack_notification(self, reminder: Reminder): + """Send Slack notification via webhook.""" + if not self.webhook_url: + logger.warning("Slack webhook URL not configured, skipping notification") + return + + message = [] + message.append(f"*PR*: {reminder.pr}") + message.append(f"*Milestone*: {reminder.milestone}") + message.append(f"*Author*: {reminder.author}") + message.append(f"*Priority*: {reminder.priority}") + message.append(f"*Review stage*: {reminder.review_stage}") + message.append(f"*Days in review*: {reminder.total_review_time}") + message.append(f"*Days in {reminder.review_stage}*: {reminder.current_stage_time}") + message.append( + f"*Reviewers*: {', '.join(reminder.reviewers) if reminder.reviewers else 'None'}" + ) + + payload = { + "text": f"PR Review Reminder: {reminder.priority} - PR #{reminder.id}", + "blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(message)}}], + } + + try: + response = requests.post(self.webhook_url, json=payload) + response.raise_for_status() + logger.info(f"Sent Slack notification for PR #{reminder.id}") + except requests.exceptions.RequestException as e: + logger.error(f"Failed to send Slack notification for PR #{reminder.id}: {e}") + def main(): token = os.environ.get("GH_TOKEN") + slack_token = os.environ.get("SLACK_TOKEN") + webhook_url = os.environ.get("SLACK_WEBHOOK_URL") repo = os.environ.get("REPO", "NVIDIA/Megatron-LM") - + if not token: logger.error("GH_TOKEN environment variable is required") sys.exit(1) - + logger.info(f"Starting PR review reminder for {repo}") - reminders = PRReviewTracker(token, repo).generate_reminders() + tracker = PRReviewTracker(token, repo, slack_token, webhook_url) + reminders = tracker.generate_reminders() logger.info(f"Generated {len(reminders)} reminders\n{'=' * 80}") - + for r in reminders: logger.info(f"{r.priority} | PR #{r.id} | {r.milestone}") logger.info(f" Author: {r.author} | Stage: {r.review_stage}") logger.info(f" Stage time: {r.current_stage_time}d | Total: {r.total_review_time}d") logger.info(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") - + + # Send Slack notification via webhook + if webhook_url: + tracker.send_slack_notification(r) + return reminders if __name__ == "__main__": - main() \ No newline at end of file + main() From 53140c01552d1e3c0b252c5f3a43be0501683708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 11:03:21 +0000 Subject: [PATCH 05/16] exclude self from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- tests/test_utils/python_scripts/auto_reminder_github.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index deba0886b67..c0ba2ed98e8 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -153,7 +153,7 @@ def get_reviewers(self, pr): pass reviewers.update(r.login for r in pr.get_review_requests()[0]) - return sorted([self.get_user_email(u) for u in reviewers]) + return sorted([self.get_user_email(u) for u in reviewers if u != pr.user.login]) def create_reminder(self, pr): """Create reminder for PR.""" @@ -249,8 +249,8 @@ def main(): logger.info(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") # Send Slack notification via webhook - if webhook_url: - tracker.send_slack_notification(r) + # if webhook_url: + # tracker.send_slack_notification(r) return reminders From f87d1346f71793c2cf293e5a41e500c3073f8b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 11:30:48 +0000 Subject: [PATCH 06/16] commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../python_scripts/auto_reminder_github.py | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index c0ba2ed98e8..3c58c661ffc 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -153,7 +153,32 @@ def get_reviewers(self, pr): pass reviewers.update(r.login for r in pr.get_review_requests()[0]) - return sorted([self.get_user_email(u) for u in reviewers if u != pr.user.login]) + reviewer_emails = sorted([self.get_user_email(u) for u in reviewers]) + + # Edge case: Expert Review with no reviewers - assign to PR author + if len(reviewer_emails) == 0 and stage == self.EXPERT_REVIEW: + pr_author_email = self.get_user_email(pr.user.login) + reviewer_emails = [pr_author_email] + + # Edge case: Final Review with no reviewers - get approvers from mcore-reviewers team + if len(reviewer_emails) == 0 and stage == self.FINAL_REVIEW: + try: + # Get all approvers (users who approved the PR) + approvers = { + review.user.login for review in pr.get_reviews() if review.state == "APPROVED" + } + + # Get mcore-reviewers team members + mcore_team = org.get_team_by_slug("mcore-reviewers") + mcore_members = {m.login for m in mcore_team.get_members()} + + # Intersection: approvers who are in mcore-reviewers + valid_approvers = approvers & mcore_members + reviewer_emails = sorted([self.get_user_email(u) for u in valid_approvers]) + except Exception as e: + logger.warning(f"Could not get mcore-reviewers approvers for PR #{pr.number}: {e}") + + return reviewer_emails def create_reminder(self, pr): """Create reminder for PR.""" @@ -249,8 +274,8 @@ def main(): logger.info(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") # Send Slack notification via webhook - # if webhook_url: - # tracker.send_slack_notification(r) + if webhook_url: + tracker.send_slack_notification(r) return reminders From 9556dcb6eb4c00c50c26e22c8b26d4b11ffd0006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 12:04:00 +0000 Subject: [PATCH 07/16] ci: Auto reminder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .../python_scripts/auto_reminder_github.py | 217 +++++++++++------- 1 file changed, 129 insertions(+), 88 deletions(-) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index 3c58c661ffc..bdc1defaa98 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -32,6 +32,7 @@ class Reminder: total_review_time: int current_stage_time: int reviewers: List[str] + action_message: str class PRReviewTracker: @@ -50,45 +51,36 @@ def __init__( self.webhook_url = webhook_url def get_user_email(self, username: str): - """Get user's email from their recent commits in fork or main repo.""" + """Get user's email, prioritizing public profile, then recent commits.""" if username in self.email_cache: return self.email_cache[username] try: user = self.github.get_user(username) - repos = [] - # Try user's fork first - try: - repos.append(user.get_repo(self.repo.name)) - except: - pass - repos.append(self.repo) + # 1. Try public profile email first + if user.email and not user.email.endswith("@users.noreply.github.com"): + self.email_cache[username] = user.email + return user.email - # Search commits in fork then main repo - for repo in repos: - try: - commits = self.github.search_commits( - f"author:{username} repo:{repo.full_name}", sort="author-date", order="desc" - ) - for commit in commits[:5]: - try: - email = repo.get_commit(commit.sha).commit.author.email - if email and not email.endswith("@users.noreply.github.com"): - self.email_cache[username] = email - return email - except: - continue - except: - continue + # 2. If no public email, check recent commits on the main repo + try: + # Use get_commits(author=...) which is more direct than search_commits + for commit in self.repo.get_commits(author=user)[:10]: + email = commit.commit.author.email + if email and not email.endswith("@users.noreply.github.com"): + self.email_cache[username] = email + return email + except Exception as e: + logger.debug(f"Could not check commits for {username}: {e}") - # Fallback to public email or noreply + # 3. Fallback to public email (even if noreply) or a constructed noreply email = user.email or f"{username}@users.noreply.github.com" self.email_cache[username] = email return email except Exception as e: - logger.warning(f"Could not get email for {username}: {e}") + logger.warning(f"Could not get user object for {username}: {e}") email = f"{username}@users.noreply.github.com" self.email_cache[username] = email return email @@ -97,10 +89,8 @@ def get_slack_user_id(self, email: str): """Get Slack user ID from email.""" if not self.slack_client: return email - if email in self.slack_id_cache: return self.slack_id_cache[email] - try: response = self.slack_client.users_lookupByEmail(email=email) user_id = response["user"]["id"] @@ -134,58 +124,97 @@ def get_stage(self, pr): return self.FINAL_REVIEW if self.FINAL_REVIEW in labels else self.EXPERT_REVIEW def get_reviewers(self, pr): - """Get filtered reviewer emails.""" + """Get filtered reviewer emails who haven't approved yet.""" stage = self.get_stage(pr) - teams = {t.slug for t in pr.get_review_requests()[1]} + org = self.github.get_organization(self.repo.organization.login) - teams = ( - teams - self.EXCLUDED_TEAMS - if stage == self.EXPERT_REVIEW - else teams & self.EXCLUDED_TEAMS - ) + # 1. Get the latest review state for everyone who has submitted a review + latest_reviews = {} + try: + for review in pr.get_reviews(): + if not review.user: # Handle rare cases of deleted users + continue + # Only track 'APPROVED' or 'CHANGES_REQUESTED' as definitive states + if review.state in ("APPROVED", "CHANGES_REQUESTED"): + if ( + review.user.login not in latest_reviews + or review.submitted_at > latest_reviews[review.user.login].submitted_at + ): + latest_reviews[review.user.login] = review + except Exception as e: + logger.warning(f"Could not get reviews for PR #{pr.number}: {e}") - reviewers = set() - org = self.github.get_organization(self.repo.organization.login) - for slug in teams: - try: - reviewers.update(m.login for m in org.get_team_by_slug(slug).get_members()) - except: - pass + # 2. Separate reviewers into approvers (List B) and non-approvers + approvers = {user for user, review in latest_reviews.items() if review.state == "APPROVED"} + non_approving_reviewers = { + user for user, review in latest_reviews.items() if review.state == "CHANGES_REQUESTED" + } - reviewers.update(r.login for r in pr.get_review_requests()[0]) - reviewer_emails = sorted([self.get_user_email(u) for u in reviewers]) + # 3. Get all *currently pending* review requests + try: + pending_users_req, pending_teams_req = pr.get_review_requests() + pending_individuals = {r.login for r in pending_users_req} + pending_teams_slugs = {t.slug for t in pending_teams_req} + except Exception as e: + logger.warning(f"Could not get review requests for PR #{pr.number}: {e}") + pending_individuals = set() + pending_teams_slugs = set() - # Edge case: Expert Review with no reviewers - assign to PR author - if len(reviewer_emails) == 0 and stage == self.EXPERT_REVIEW: - pr_author_email = self.get_user_email(pr.user.login) - reviewer_emails = [pr_author_email] + # 4. Filter pending teams based on the current stage + teams_to_query = ( + pending_teams_slugs - self.EXCLUDED_TEAMS + if stage == self.EXPERT_REVIEW + else pending_teams_slugs & self.EXCLUDED_TEAMS + ) - # Edge case: Final Review with no reviewers - get approvers from mcore-reviewers team - if len(reviewer_emails) == 0 and stage == self.FINAL_REVIEW: + # 5. Get members from the required pending teams + pending_team_members = set() + for slug in teams_to_query: try: - # Get all approvers (users who approved the PR) - approvers = { - review.user.login for review in pr.get_reviews() if review.state == "APPROVED" - } - - # Get mcore-reviewers team members - mcore_team = org.get_team_by_slug("mcore-reviewers") - mcore_members = {m.login for m in mcore_team.get_members()} - - # Intersection: approvers who are in mcore-reviewers - valid_approvers = approvers & mcore_members - reviewer_emails = sorted([self.get_user_email(u) for u in valid_approvers]) + pending_team_members.update( + m.login for m in org.get_team_by_slug(slug).get_members() + ) except Exception as e: - logger.warning(f"Could not get mcore-reviewers approvers for PR #{pr.number}: {e}") + logger.warning(f"Could not get members for team {slug} on PR #{pr.number}: {e}") + + # 6. "List A": Combine all users who *still need to review* + all_required_reviewers = ( + pending_individuals | pending_team_members | non_approving_reviewers + ) + + # 7. Final list (List A - List B): + pending_reviewers = all_required_reviewers - approvers + reviewer_emails = sorted([self.get_user_email(u) for u in pending_reviewers]) + action_message = "Please review the PR." + + # 8. Handle the original edge cases + if len(reviewer_emails) == 0: + if stage == self.EXPERT_REVIEW: + # Assign to PR author + reviewer_emails = [self.get_user_email(pr.user.login)] + action_message = "All Expert Reviewers approved the PR. Please attach the Final Review label to proceed with the review." + elif stage == self.FINAL_REVIEW: + # Assign to mcore-reviewers who approved + try: + mcore_team = org.get_team_by_slug("mcore-reviewers") + mcore_members = {m.login for m in mcore_team.get_members()} + valid_approvers = approvers & mcore_members + reviewer_emails = sorted([self.get_user_email(u) for u in valid_approvers]) + action_message = "All Final Reviewers approved the PR. Please ping an Expert or Final Reviewer to merge the PR." + + except Exception as e: + logger.warning( + f"Could not get mcore-reviewers approvers for PR #{pr.number}: {e}" + ) - return reviewer_emails + return reviewer_emails, action_message def create_reminder(self, pr): """Create reminder for PR.""" stage = self.get_stage(pr) stage_days = self.days_since(self.get_label_date(pr, stage)) author_email = self.get_user_email(pr.user.login) - reviewer_emails = self.get_reviewers(pr) + reviewer_emails, action_message = self.get_reviewers(pr) return Reminder( id=pr.number, @@ -197,6 +226,7 @@ def create_reminder(self, pr): total_review_time=self.days_since(self.get_label_date(pr, self.EXPERT_REVIEW)), current_stage_time=stage_days, reviewers=[self.get_slack_user_id(email) for email in reviewer_emails], + action_message=action_message, ) def generate_reminders(self): @@ -208,36 +238,43 @@ def generate_reminders(self): reminders = [] for milestone in milestones: - for issue in self.repo.get_issues(state="open", milestone=milestone): - if not issue.pull_request: - continue - labels = {l.name for l in issue.labels} - if self.EXPERT_REVIEW in labels or self.FINAL_REVIEW in labels: + # Find issues with the 'Expert Review' or 'Final Review' label + query = ( + f'repo:"{self.repo.full_name}" ' + f'milestone:"{milestone.title}" ' + f'is:open is:pr ' + f'label:"{self.EXPERT_REVIEW}","{self.FINAL_REVIEW}"' + ) + try: + # Use search_issues for a more direct query instead of get_issues + filtering + issues = self.github.search_issues(query) + for issue in issues: try: - reminders.append(self.create_reminder(self.repo.get_pull(issue.number))) + reminders.append(self.create_reminder(issue.as_pull_request())) logger.info(f"Processed PR #{issue.number}") except Exception as e: logger.error(f"Failed to process PR #{issue.number}: {e}") + except Exception as e: + logger.error(f"Failed to search issues for milestone {milestone.title}: {e}") return sorted(reminders, key=lambda r: (r.priority, -r.current_stage_time)) def send_slack_notification(self, reminder: Reminder): """Send Slack notification via webhook.""" if not self.webhook_url: - logger.warning("Slack webhook URL not configured, skipping notification") return - message = [] - message.append(f"*PR*: {reminder.pr}") - message.append(f"*Milestone*: {reminder.milestone}") - message.append(f"*Author*: {reminder.author}") - message.append(f"*Priority*: {reminder.priority}") - message.append(f"*Review stage*: {reminder.review_stage}") - message.append(f"*Days in review*: {reminder.total_review_time}") - message.append(f"*Days in {reminder.review_stage}*: {reminder.current_stage_time}") - message.append( - f"*Reviewers*: {', '.join(reminder.reviewers) if reminder.reviewers else 'None'}" - ) + reviewers_str = ', '.join(reminder.reviewers) if reminder.reviewers else 'None' + message = [ + f"*PR*: {reminder.pr}", + f"*Milestone*: {reminder.milestone}", + f"*Author*: {reminder.author}", + f"*Priority*: {reminder.priority}", + f"*Review stage*: {reminder.review_stage}", + f"*Days in review*: {reminder.total_review_time}", + f"*Days in {reminder.review_stage}*: {reminder.current_stage_time}", + f"*Reviewers*: {reviewers_str}", + ] payload = { "text": f"PR Review Reminder: {reminder.priority} - PR #{reminder.id}", @@ -245,7 +282,7 @@ def send_slack_notification(self, reminder: Reminder): } try: - response = requests.post(self.webhook_url, json=payload) + response = requests.post(self.webhook_url, json=payload, timeout=10) response.raise_for_status() logger.info(f"Sent Slack notification for PR #{reminder.id}") except requests.exceptions.RequestException as e: @@ -267,17 +304,21 @@ def main(): reminders = tracker.generate_reminders() logger.info(f"Generated {len(reminders)} reminders\n{'=' * 80}") + if not reminders: + logger.info("No reminders to send.") + return + for r in reminders: logger.info(f"{r.priority} | PR #{r.id} | {r.milestone}") logger.info(f" Author: {r.author} | Stage: {r.review_stage}") - logger.info(f" Stage time: {r.current_stage_time}d | Total: {r.total_review_time}d") + logger.info(f" Stage time: {r.current_stage_time}d | Total: {r.total_review_time}") logger.info(f" Reviewers: {', '.join(r.reviewers) if r.reviewers else 'None'}") - - # Send Slack notification via webhook + logger.info(f" Action message: {r.action_message}") + logger.info("-" * 80) if webhook_url: tracker.send_slack_notification(r) - return reminders + logger.info("All reminders processed.") if __name__ == "__main__": From c10c66eba28419f99c17f6e06e600461f5491526 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 17:47:01 +0000 Subject: [PATCH 08/16] add copyright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- tests/test_utils/python_scripts/auto_reminder_github.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_utils/python_scripts/auto_reminder_github.py b/tests/test_utils/python_scripts/auto_reminder_github.py index bdc1defaa98..df75ec0542c 100644 --- a/tests/test_utils/python_scripts/auto_reminder_github.py +++ b/tests/test_utils/python_scripts/auto_reminder_github.py @@ -1,3 +1,4 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. #!/usr/bin/env python3 """ GitHub PR Review Reminder Automation From 7ceba933988dc5e8772ae109b28585c6a75cb7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 18:17:32 +0000 Subject: [PATCH 09/16] copyright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- tests/test_utils/python_scripts/check_status_of_main.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_utils/python_scripts/check_status_of_main.py b/tests/test_utils/python_scripts/check_status_of_main.py index a1cae393bfb..ce777814b91 100644 --- a/tests/test_utils/python_scripts/check_status_of_main.py +++ b/tests/test_utils/python_scripts/check_status_of_main.py @@ -1,3 +1,5 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + from __future__ import annotations import logging From a9052055033a432e1af15d0fc0fd6ba4ccb51813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 18:44:03 +0000 Subject: [PATCH 10/16] add scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/auto-reminder-bot.yml | 33 ++++ .../python_scripts/swap_pr_labels.py | 149 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 .github/workflows/auto-reminder-bot.yml create mode 100644 tests/test_utils/python_scripts/swap_pr_labels.py diff --git a/.github/workflows/auto-reminder-bot.yml b/.github/workflows/auto-reminder-bot.yml new file mode 100644 index 00000000000..d6be927e2be --- /dev/null +++ b/.github/workflows/auto-reminder-bot.yml @@ -0,0 +1,33 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +name: Auto Reminder Bot + +on: + workflow_dispatch: + schedule: + - cron: "0 12 * * *" + +jobs: + run-script: + environment: main + name: Run Auto Reminder Bot + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + pip install --no-cache-dir PyGithub slack-sdk + + - name: Run Auto Reminder Bot + run: | + export SLACK_TOKEN=${{ secrets.SLACK_API_TOKEN }} + export SLACK_WEBHOOK_URL=${{ secrets.SLACK_WEBHOOK_URL }} + export GH_TOKEN=${{ secrets.PAT }} + python tests/test_utils/python_scripts/auto_reminder_github.py diff --git a/tests/test_utils/python_scripts/swap_pr_labels.py b/tests/test_utils/python_scripts/swap_pr_labels.py new file mode 100644 index 00000000000..7533a01a5ff --- /dev/null +++ b/tests/test_utils/python_scripts/swap_pr_labels.py @@ -0,0 +1,149 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +#!/usr/bin/env python3 +""" +GitHub PR Review Reminder Automation +Requirements: pip install PyGithub slack-sdk requests +Usage: GH_TOKEN=ghp_... SLACK_TOKEN=xoxb-... SLACK_WEBHOOK_URL=https://... REPO=NVIDIA/Megatron-LM python github_pr_reminder.py +""" + +import logging +import os +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import List + +import requests +from github import Github +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + + +@dataclass +class Reminder: + id: int + pr: str + milestone: str + author: str + priority: str + review_stage: str + total_review_time: int + current_stage_time: int + reviewers: List[str] + action_message: str + + +class PRReviewTracker: + EXPERT_REVIEW = "Expert Review" + FINAL_REVIEW = "Final Review" + EXCLUDED_TEAMS = {"core-adlr", "core-nemo"} + + def __init__( + self, token: str, repo_name: str, pr_number: str + ): + self.github = Github(token) + self.repo = self.github.get_repo(repo_name) + self.pr = self.repo.get_pull(pr_number) + self.stage = self.get_stage(self.pr) + self.org = self.github.get_organization(self.repo.organization.login) + + + def get_stage(self, pr): + """Get current review stage.""" + labels = {l.name for l in pr.labels} + return self.FINAL_REVIEW if self.FINAL_REVIEW in labels else self.EXPERT_REVIEW + + def swap_labels(self, pr): + """Get filtered reviewer emails who haven't approved yet.""" + if self.stage == self.FINAL_REVIEW: + logger.info(f"PR #{self.pr.number} is in the {self.stage} stage. No reviewers needed.") + return + + # 1. Get the latest review state for everyone who has submitted a review + latest_reviews = {} + try: + for review in pr.get_reviews(): + if not review.user: # Handle rare cases of deleted users + continue + # Only track 'APPROVED' or 'CHANGES_REQUESTED' as definitive states + if review.state in ("APPROVED", "CHANGES_REQUESTED"): + if ( + review.user.login not in latest_reviews + or review.submitted_at > latest_reviews[review.user.login].submitted_at + ): + latest_reviews[review.user.login] = review + except Exception as e: + logger.warning(f"Could not get reviews for PR #{pr.number}: {e}") + + # 2. Separate reviewers into approvers (List B) and non-approvers + approvers = {user for user, review in latest_reviews.items() if review.state == "APPROVED"} + non_approving_reviewers = { + user for user, review in latest_reviews.items() if review.state == "CHANGES_REQUESTED" + } + + # 3. Get all *currently pending* review requests + try: + pending_users_req, pending_teams_req = pr.get_review_requests() + pending_individuals = {r.login for r in pending_users_req} + pending_teams_slugs = {t.slug for t in pending_teams_req} + except Exception as e: + logger.warning(f"Could not get review requests for PR #{pr.number}: {e}") + pending_individuals = set() + pending_teams_slugs = set() + + # 4. Filter pending teams based on the current stage + teams_to_query = ( + pending_teams_slugs - self.EXCLUDED_TEAMS + if stage == self.EXPERT_REVIEW + else pending_teams_slugs & self.EXCLUDED_TEAMS + ) + + # 5. Get members from the required pending teams + pending_team_members = set() + for slug in teams_to_query: + try: + pending_team_members.update( + m.login for m in org.get_team_by_slug(slug).get_members() + ) + except Exception as e: + logger.warning(f"Could not get members for team {slug} on PR #{pr.number}: {e}") + + # 6. "List A": Combine all users who *still need to review* + all_required_reviewers = ( + pending_individuals | pending_team_members | non_approving_reviewers + ) + + # 7. Final list (List A - List B): + pending_reviewers = all_required_reviewers - approvers + + if len(pending_reviewers) == 0: + try: + pr.remove_from_labels(self.EXPERT_REVIEW) + logger.info(f'Removed "{self.EXPERT_REVIEW}" label from PR #{pr.number}') + except Exception as e: + logger.warning(f'Failed to remove "{self.EXPERT_REVIEW}" label from PR #{pr.number}: {e}') + + try: + pr.add_to_labels(self.FINAL_REVIEW) + logger.info(f'Added "{self.FINAL_REVIEW}" label to PR #{pr.number}') + except Exception as e: + logger.warning(f'Failed to add "{self.FINAL_REVIEW}" label to PR #{pr.number}: {e}') + +def main(): + token = os.environ.get("GH_TOKEN") + repo = os.environ.get("REPO", "NVIDIA/Megatron-LM") + pr_number = os.environ.get("PR_NUMBER") + + if not token: + logger.error("GH_TOKEN environment variable is required") + sys.exit(1) + + logger.info(f"Starting PR review reminder for {repo}") + tracker = PRReviewTracker(token, repo, pr_number) + tracker.swap_labels() + +if __name__ == "__main__": + main() From 84c3ec2ec0facff605b82c053d96fd75d3717c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 19:02:58 +0000 Subject: [PATCH 11/16] format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- tests/test_utils/python_scripts/swap_pr_labels.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_utils/python_scripts/swap_pr_labels.py b/tests/test_utils/python_scripts/swap_pr_labels.py index 7533a01a5ff..e613ab9e0b5 100644 --- a/tests/test_utils/python_scripts/swap_pr_labels.py +++ b/tests/test_utils/python_scripts/swap_pr_labels.py @@ -41,16 +41,13 @@ class PRReviewTracker: FINAL_REVIEW = "Final Review" EXCLUDED_TEAMS = {"core-adlr", "core-nemo"} - def __init__( - self, token: str, repo_name: str, pr_number: str - ): + def __init__(self, token: str, repo_name: str, pr_number: str): self.github = Github(token) self.repo = self.github.get_repo(repo_name) self.pr = self.repo.get_pull(pr_number) self.stage = self.get_stage(self.pr) self.org = self.github.get_organization(self.repo.organization.login) - def get_stage(self, pr): """Get current review stage.""" labels = {l.name for l in pr.labels} @@ -118,13 +115,15 @@ def swap_labels(self, pr): # 7. Final list (List A - List B): pending_reviewers = all_required_reviewers - approvers - + if len(pending_reviewers) == 0: try: pr.remove_from_labels(self.EXPERT_REVIEW) logger.info(f'Removed "{self.EXPERT_REVIEW}" label from PR #{pr.number}') except Exception as e: - logger.warning(f'Failed to remove "{self.EXPERT_REVIEW}" label from PR #{pr.number}: {e}') + logger.warning( + f'Failed to remove "{self.EXPERT_REVIEW}" label from PR #{pr.number}: {e}' + ) try: pr.add_to_labels(self.FINAL_REVIEW) @@ -132,6 +131,7 @@ def swap_labels(self, pr): except Exception as e: logger.warning(f'Failed to add "{self.FINAL_REVIEW}" label to PR #{pr.number}: {e}') + def main(): token = os.environ.get("GH_TOKEN") repo = os.environ.get("REPO", "NVIDIA/Megatron-LM") @@ -145,5 +145,6 @@ def main(): tracker = PRReviewTracker(token, repo, pr_number) tracker.swap_labels() + if __name__ == "__main__": main() From 128b7edc0b49cd214c914a9f09391f5352eabd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 19:31:05 +0000 Subject: [PATCH 12/16] add workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/auto-swap-labels.yml | 26 +++++++++++++++++++ .../python_scripts/swap_pr_labels.py | 15 +++++------ 2 files changed, 32 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/auto-swap-labels.yml diff --git a/.github/workflows/auto-swap-labels.yml b/.github/workflows/auto-swap-labels.yml new file mode 100644 index 00000000000..540859d19ae --- /dev/null +++ b/.github/workflows/auto-swap-labels.yml @@ -0,0 +1,26 @@ +on: + pull_request_review: + types: [submitted] + +jobs: + check-approval: + runs-on: ubuntu-latest + if: github.event.review.state == 'approved' + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install dependencies + run: | + pip install --no-cache-dir PyGithub slack-sdk + + - name: Run Auto Reminder Bot + run: | + export GH_TOKEN=${{ secrets.PAT }} + export PR_NUMBER=${{ github.event.pull_request.number }} + python tests/test_utils/python_scripts/swap_pr_labels.py diff --git a/tests/test_utils/python_scripts/swap_pr_labels.py b/tests/test_utils/python_scripts/swap_pr_labels.py index e613ab9e0b5..aa5875443f8 100644 --- a/tests/test_utils/python_scripts/swap_pr_labels.py +++ b/tests/test_utils/python_scripts/swap_pr_labels.py @@ -10,13 +10,9 @@ import os import sys from dataclasses import dataclass -from datetime import datetime, timezone from typing import List -import requests from github import Github -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -53,8 +49,9 @@ def get_stage(self, pr): labels = {l.name for l in pr.labels} return self.FINAL_REVIEW if self.FINAL_REVIEW in labels else self.EXPERT_REVIEW - def swap_labels(self, pr): + def swap_labels(self): """Get filtered reviewer emails who haven't approved yet.""" + pr = self.pr if self.stage == self.FINAL_REVIEW: logger.info(f"PR #{self.pr.number} is in the {self.stage} stage. No reviewers needed.") return @@ -94,7 +91,7 @@ def swap_labels(self, pr): # 4. Filter pending teams based on the current stage teams_to_query = ( pending_teams_slugs - self.EXCLUDED_TEAMS - if stage == self.EXPERT_REVIEW + if self.stage == self.EXPERT_REVIEW else pending_teams_slugs & self.EXCLUDED_TEAMS ) @@ -103,7 +100,7 @@ def swap_labels(self, pr): for slug in teams_to_query: try: pending_team_members.update( - m.login for m in org.get_team_by_slug(slug).get_members() + m.login for m in self.org.get_team_by_slug(slug).get_members() ) except Exception as e: logger.warning(f"Could not get members for team {slug} on PR #{pr.number}: {e}") @@ -115,7 +112,7 @@ def swap_labels(self, pr): # 7. Final list (List A - List B): pending_reviewers = all_required_reviewers - approvers - + logger.info(f"Pending reviewers: {pending_reviewers}") if len(pending_reviewers) == 0: try: pr.remove_from_labels(self.EXPERT_REVIEW) @@ -135,7 +132,7 @@ def swap_labels(self, pr): def main(): token = os.environ.get("GH_TOKEN") repo = os.environ.get("REPO", "NVIDIA/Megatron-LM") - pr_number = os.environ.get("PR_NUMBER") + pr_number = int(os.environ.get("PR_NUMBER")) if not token: logger.error("GH_TOKEN environment variable is required") From 564e6fdb758f51a94e23a1858920312939fd642e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 22:09:15 +0000 Subject: [PATCH 13/16] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/auto-swap-labels.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/auto-swap-labels.yml b/.github/workflows/auto-swap-labels.yml index 540859d19ae..6d067739b22 100644 --- a/.github/workflows/auto-swap-labels.yml +++ b/.github/workflows/auto-swap-labels.yml @@ -6,6 +6,7 @@ jobs: check-approval: runs-on: ubuntu-latest if: github.event.review.state == 'approved' + environment: main steps: - name: Check out repository code uses: actions/checkout@v4 From 126d787668f747ab189e37676d42e3a2aea074c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 22:13:16 +0000 Subject: [PATCH 14/16] fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/auto-swap-labels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-swap-labels.yml b/.github/workflows/auto-swap-labels.yml index 6d067739b22..5d4f2cdd4e1 100644 --- a/.github/workflows/auto-swap-labels.yml +++ b/.github/workflows/auto-swap-labels.yml @@ -6,7 +6,7 @@ jobs: check-approval: runs-on: ubuntu-latest if: github.event.review.state == 'approved' - environment: main + environment: nemo-ci steps: - name: Check out repository code uses: actions/checkout@v4 From d92bf39ff16549684ad9b851efe780ebda5d7bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Sun, 26 Oct 2025 22:13:43 +0000 Subject: [PATCH 15/16] final MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/auto-swap-labels.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/auto-swap-labels.yml b/.github/workflows/auto-swap-labels.yml index 5d4f2cdd4e1..8d85a09c9e7 100644 --- a/.github/workflows/auto-swap-labels.yml +++ b/.github/workflows/auto-swap-labels.yml @@ -1,3 +1,6 @@ +# Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + +name: Auto Swap Labels on: pull_request_review: types: [submitted] From 1055e77ee3c11afc80b9bd46bb093150b9713a53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?oliver=20k=C3=B6nig?= Date: Mon, 27 Oct 2025 15:27:09 +0000 Subject: [PATCH 16/16] add to merge queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: oliver könig --- .github/workflows/copyright-check.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml index 0463e1dd962..472388c7e1a 100644 --- a/.github/workflows/copyright-check.yml +++ b/.github/workflows/copyright-check.yml @@ -30,6 +30,7 @@ jobs: needs: [pre-flight] if: | !(needs.pre-flight.outputs.docs_only == 'true' + || needs.pre-flight.outputs.is_merge_group == 'true' || needs.pre-flight.outputs.is_deployment_workflow == 'true') uses: NVIDIA-NeMo/FW-CI-templates/.github/workflows/_copyright_check.yml@v0.65.9