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)