dc609c19cedefea37e57795682d5ff34332fabc8
jnavarr5
  Wed Aug 5 11:27:25 2026 -0700
Fix mlqAutomate 403 insufficientPermissions by forcing a token refresh when the shared gmail token records fewer scopes than required. No RM.

diff --git src/utils/qa/mlqAutomate.py src/utils/qa/mlqAutomate.py
index d8cdf471928..a25f52b5524 100755
--- src/utils/qa/mlqAutomate.py
+++ src/utils/qa/mlqAutomate.py
@@ -1,29 +1,30 @@
 #!/usr/bin/env python3
 """
 MLQ Automation Script
 Monitors Gmail for mailing list emails, moderates pending messages,
 and creates/updates Redmine tickets.
 """
 
 import os
 import sys
 import argparse
 import base64
 import re
 import logging
 import html as html_module
+import json
 import time
 from datetime import datetime, timedelta
 from difflib import SequenceMatcher
 import email
 from email import policy
 from email.utils import parseaddr
 from email.mime.text import MIMEText
 from functools import wraps
 import pytz
 import requests
 from google.oauth2.credentials import Credentials
 from google_auth_oauthlib.flow import InstalledAppFlow
 from google.auth.transport.requests import Request
 from googleapiclient.discovery import build
 from googleapiclient.errors import HttpError
@@ -191,35 +192,48 @@
                         logger.error(f"{func.__name__} failed after {max_attempts} attempts: {e}")
                         raise
                     logger.warning(f"{func.__name__} attempt {attempts} failed: {e}. Retrying in {current_delay}s...")
                     time.sleep(current_delay)
                     current_delay *= backoff
         return wrapper
     return decorator
 
 
 def get_google_credentials():
     """Get or refresh Google API credentials."""
     creds = None
     token_path = os.path.expanduser('~/.gmail_token.json')
     creds_path = os.path.expanduser('~/.gmail_credentials.json')
 
+    downscoped = False
+
     if os.path.exists(token_path):
         creds = Credentials.from_authorized_user_file(token_path, SCOPES)
-
-    if not creds or not creds.valid:
-        if creds and creds.expired and creds.refresh_token:
+        # The token file is shared with other gbauto automation, which can leave behind an
+        # access token minted with a narrower scope set. creds.valid only reports expiry, and
+        # from_authorized_user_file stamps SCOPES onto the object regardless of what the token
+        # really carries, so check the scopes recorded in the file instead. Otherwise every API
+        # call returns 403 insufficientPermissions until that token expires on its own.
+        with open(token_path) as f:
+            stored_scopes = json.load(f).get('scopes')
+        if stored_scopes is not None and not set(SCOPES).issubset(stored_scopes):
+            logger.warning(f"Token in {token_path} is missing scope(s) "
+                           f"{sorted(set(SCOPES) - set(stored_scopes))}, forcing a refresh")
+            downscoped = True
+
+    if not creds or not creds.valid or downscoped:
+        if creds and creds.refresh_token:
             creds.refresh(Request())
         else:
             flow = InstalledAppFlow.from_client_secrets_file(creds_path, SCOPES)
             creds = flow.run_local_server(port=8080)
         with open(token_path, 'w') as f:
             f.write(creds.to_json())
 
     return creds
 
 
 def get_current_mlm():
     """Get the current MLM based on PST time rules."""
     creds = get_google_credentials()
     service = build('calendar', 'v3', credentials=creds, cache_discovery=False)