| import os |
| import time |
| from dataclasses import dataclass |
| from datetime import datetime, timezone |
|
|
| import click |
| import gitlab |
| import requests |
| from slack_sdk import WebClient |
| from slack_sdk.errors import SlackApiError |
|
|
| |
| PROJECT_ID = int(os.getenv("CI_PROJECT_ID", 19378)) |
| GITLAB_ENDPOINT = os.getenv('GITLAB_ENDPOINT') |
| RO_API_TOKEN = os.getenv("RO_API_TOKEN") |
| SLACK_WEBHOOK_URL = os.getenv("SLACK_REMINDER_HOOK") |
| SLACK_API_TOKEN = os.getenv("SLACK_API_TOKEN") |
|
|
| |
| if not GITLAB_ENDPOINT or GITLAB_ENDPOINT == 'none': |
| raise ValueError("GITLAB_ENDPOINT environment variable is not set or is invalid") |
| if not RO_API_TOKEN: |
| raise ValueError("RO_API_TOKEN environment variable is not set") |
|
|
| |
| REQUIRED_REVIEWERS = { |
| "final_reviewers": [ |
| "jcasper@nvidia.com", |
| "dnarayanan@nvidia.com", |
| "vkorthikanti@nvidia.com", |
| "eharper@nvidia.com", |
| "shanmugamr@nvidia.com", |
| "yuya@nvidia.com", |
| "ansubramania@nvidia.com", |
| ], |
| "expert_reviewers": [], |
| } |
| CI_MAINTAINER = ["okoenig@nvidia.com"] |
|
|
| |
| slack_client = WebClient(token=SLACK_API_TOKEN) if SLACK_API_TOKEN else None |
|
|
| |
| slack_user_cache = {} |
|
|
|
|
| @dataclass |
| class Reminder: |
| iid: int |
| mr: str |
| milestone: str |
| author: str |
| priority: str |
| review_stage: str |
| total_review_time: str |
| current_stage_time: str |
| reviewers: list[str] |
|
|
|
|
| def retry_with_backoff(func, max_retries=5, initial_delay=1): |
| """Retry a function with exponential backoff.""" |
| delay = initial_delay |
| for attempt in range(max_retries): |
| try: |
| return func() |
| except (requests.exceptions.ConnectionError, requests.exceptions.RequestException) as e: |
| if attempt == max_retries - 1: |
| print(f"Failed after {max_retries} attempts. Last error: {e}") |
| raise |
| print(f"Attempt {attempt + 1} failed with error: {e}. Retrying in {delay} seconds...") |
| time.sleep(delay) |
| delay *= 2 |
|
|
|
|
| def get_gitlab_handle(): |
| """Get GitLab handle with retry logic.""" |
|
|
| def _get_handle(): |
| try: |
| return gitlab.Gitlab( |
| f"https://{GITLAB_ENDPOINT}", private_token=RO_API_TOKEN, timeout=30 |
| ) |
| except Exception as e: |
| print(f"Error creating GitLab handle: {e}") |
| print(f"Using endpoint: https://{GITLAB_ENDPOINT}") |
| raise |
|
|
| return retry_with_backoff(_get_handle) |
|
|
|
|
| def get_recent_milestones(project): |
| """Get the two most recent milestones from the project.""" |
| milestones = project.milestones.list(state='active', sort='due_date_desc') |
| if not milestones: |
| return None, None |
| return milestones[0], milestones[1] if len(milestones) > 1 else None |
|
|
|
|
| def get_current_review_stage(mr): |
| """Get the current review stage of the MR.""" |
| if 'Final Review' in mr.labels: |
| return 'Final Review' |
| elif 'Expert Review' in mr.labels: |
| return 'Expert Review' |
| return None |
|
|
|
|
| def get_mcore_reviewers(): |
| """Get all members of mcore-reviewers group and its subgroups recursively.""" |
| mcore_group = get_gitlab_handle().groups.get('mcore-reviewers') |
| reviewers = set() |
|
|
| def get_group_members(group): |
| |
| for member in group.members.list(get_all=True): |
| reviewers.add(f"{member.username}@nvidia.com") |
|
|
| |
| for subgroup in group.subgroups.list(get_all=True): |
| subgroup_obj = get_gitlab_handle().groups.get(subgroup.id) |
| get_group_members(subgroup_obj) |
|
|
| get_group_members(mcore_group) |
| return list(reviewers) |
|
|
|
|
| def get_days_in_stage(mr, stage): |
| """Get the latest time when each review label was added.""" |
| for event in sorted( |
| mr.resourcelabelevents.list(get_all=True), key=lambda x: x.created_at, reverse=True |
| ): |
| if ( |
| event.label.get('name') == stage |
| and event.action == 'add' |
| and '_bot_' not in event.user.get('username') |
| ): |
| return get_days_since(event.created_at) |
|
|
|
|
| def get_days_since(dt_str): |
| """Calculate number of days since the given datetime string.""" |
| if not dt_str: |
| return 0 |
| now = datetime.now(timezone.utc) |
| dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) |
| delta = now - dt |
| return max(1, delta.days) |
|
|
|
|
| def get_required_reviewers(mr): |
| """Get list of required reviewers who haven't approved yet.""" |
| print(f"MR #{mr.iid} - {mr.title}") |
|
|
| |
| approved_users = [] |
| for approval in mr.approvals.get().approved_by: |
| if username := approval['user'].get('username'): |
| approved_users.append(f"{username}@nvidia.com") |
|
|
| |
| assigned_reviewers = [] |
| for reviewer in mr.reviewers: |
| if username := reviewer.get('username'): |
| assigned_reviewers.append(f"{username}@nvidia.com") |
|
|
| print(f"Assigned reviewers: {assigned_reviewers}") |
| print(f"Approved users: {approved_users}") |
|
|
| |
| if get_current_review_stage(mr) == 'Expert Review': |
| review_group = REQUIRED_REVIEWERS["expert_reviewers"] |
|
|
| elif get_current_review_stage(mr) == 'Final Review': |
| review_group = REQUIRED_REVIEWERS["final_reviewers"] |
| |
| mr_pipelines = mr.pipelines.list(sort='desc', order_by='created_at') |
| pipeline = mr_pipelines[0] if mr_pipelines else None |
| if pipeline and pipeline.status != 'success': |
| review_group = [] |
|
|
| else: |
| review_group = [] |
|
|
| review_group = [ |
| reviewer |
| for reviewer in review_group |
| if (reviewer in assigned_reviewers) and (reviewer not in approved_users) |
| ] |
|
|
| if review_group is None or len(review_group) == 0: |
| review_group = ["S06GU680R3N"] |
|
|
| print(f"Reviewer: {review_group}") |
|
|
| return ", ".join( |
| get_slack_user_id(reviewer) if "@" in reviewer else f"<!subteam^{reviewer}>" |
| for reviewer in review_group |
| ) |
|
|
|
|
| def get_priority(days_in_current_stage): |
| """Get priority based on age category with custom Slack emojis.""" |
|
|
| if days_in_current_stage <= 1: |
| return "P2 :sparkles:" |
| elif days_in_current_stage <= 3: |
| return "P1 :yellow_alert:" |
| else: |
| return "P0 :alert:" |
|
|
|
|
| def get_slack_user_id(email): |
| """Look up Slack user ID by email with caching and retries.""" |
| if not slack_client: |
| return None |
|
|
| def lookup_user(): |
| if email in slack_user_cache: |
| return slack_user_cache[email] |
|
|
| try: |
| response = slack_client.users_lookupByEmail(email=email) |
| if response["ok"]: |
| user_id = response["user"]["id"] |
| |
| slack_user_cache[email] = f"<@{user_id}>" |
| return slack_user_cache[email] |
| return None |
| except SlackApiError as e: |
| if e.response["error"] == "users_not_found": |
| |
| return "" |
| raise |
|
|
| try: |
| return retry_with_backoff(lookup_user) |
| except SlackApiError as e: |
| print(f"Error looking up Slack user after retries: {e}") |
| return "" |
|
|
|
|
| def send_to_slack(message, dry_run=False): |
| """Send message to Slack using webhook.""" |
| if not SLACK_WEBHOOK_URL: |
| print("Warning: SLACK_REMINDER_HOOK not set, skipping Slack notification") |
| return |
|
|
| if dry_run: |
| print("\n=== DRY RUN - Would send to Slack ===") |
| print(message) |
| print("====================================\n") |
| return |
|
|
| payload = {"text": message, "mrkdwn": True} |
|
|
| def _send(): |
| response = requests.post( |
| SLACK_WEBHOOK_URL, |
| json=payload, |
| headers={'Content-Type': 'application/json'}, |
| timeout=10, |
| ) |
| response.raise_for_status() |
| return response |
|
|
| try: |
| retry_with_backoff(_send) |
| except Exception as e: |
| print(f"Error sending to Slack webhook after retries: {e}") |
|
|
|
|
| def process_mrs(project, milestones, labels, dry_run=False): |
| """Process all MRs from given milestones.""" |
| if not any(milestones): |
| print("No milestones found") |
| return "" |
|
|
| reminders = [ |
| Reminder( |
| iid=mr.iid, |
| mr=f"<{mr.web_url}|#{mr.iid} - {mr.title}>", |
| milestone=mr.milestone['title'], |
| author=get_slack_user_id(f"{mr.author['username']}@nvidia.com"), |
| priority=get_priority(get_days_in_stage(mr, stage=get_current_review_stage(mr))), |
| review_stage=get_current_review_stage(mr), |
| total_review_time=get_days_in_stage(mr, stage="Expert Review"), |
| current_stage_time=get_days_in_stage(mr, stage=get_current_review_stage(mr)), |
| reviewers=get_required_reviewers(mr), |
| ) |
| for m in milestones |
| for label in labels |
| for mr in project.mergerequests.list( |
| state='opened', |
| milestone=m.title, |
| labels=[label], |
| order_by='updated_at', |
| sort='desc', |
| ) |
| ] |
|
|
| reminders.sort(key=lambda x: x.current_stage_time) |
|
|
| |
| for reminder in reminders: |
|
|
| |
| message = [] |
| message.append(f"*MR*: {reminder.mr}") |
| 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*: {reminder.reviewers}") |
| |
| print(f"Sending message for MR #{reminder.iid}") |
| send_to_slack("\n".join(message), dry_run) |
|
|
|
|
| @click.command() |
| @click.option('--dry-run', is_flag=True, help='Run in dry-run mode without sending to Slack') |
| def main(dry_run): |
| """Auto reminder script for MR reviews.""" |
| if dry_run: |
| print("Running in DRY-RUN mode - no messages will be sent to Slack") |
|
|
| REQUIRED_REVIEWERS["expert_reviewers"] = list( |
| set(get_mcore_reviewers()) - set(REQUIRED_REVIEWERS["final_reviewers"]) |
| ) |
| print(REQUIRED_REVIEWERS) |
|
|
| try: |
| gl = get_gitlab_handle() |
| project = gl.projects.get(PROJECT_ID) |
|
|
| process_mrs( |
| project, |
| milestones=get_recent_milestones(project), |
| labels=["Expert Review", "Final Review"], |
| dry_run=dry_run, |
| ) |
| except Exception as e: |
| error_message = f"Error in main execution: {str(e)}" |
| print(error_message) |
| send_to_slack(error_message, dry_run) |
| raise |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|