040099e4e758f5186ef775e0c5e749519018c38d
lrnassar
  Fri Jul 10 10:32:50 2026 -0700
Make codeReviewAi.py Claude CLI auth resilient for cron. refs #36890

Add ensure_claude_auth(), which sources a long-lived setup-token from the
CLAUDE_CODE_OAUTH_TOKEN env var or, failing that, from claude.oauthToken in
~/.hg.conf, exporting it so the CLI can authenticate in unattended cron runs
that do not source a login shell. If no token is configured it falls back to
the local ~/.claude/.credentials.json login. The active method is logged, and
any residual auth failure is still caught and alerted. This fixes the daily
reviews silently failing after the local subscription login expired.

diff --git src/utils/codeReviewAi.py src/utils/codeReviewAi.py
index 30467301805..17e68cb904d 100755
--- src/utils/codeReviewAi.py
+++ src/utils/codeReviewAi.py
@@ -48,30 +48,59 @@
     """Load API keys from ~/.hg.conf"""
     config = {}
     if not os.path.exists(MLQ_CONF_PATH):
         print(f"ERROR: Config file not found: {MLQ_CONF_PATH}")
         sys.exit(1)
 
     with open(MLQ_CONF_PATH, 'r') as f:
         for line in f:
             line = line.strip()
             if '=' in line and not line.startswith('#'):
                 key, value = line.split('=', 1)
                 config[key.strip()] = value.strip()
 
     return config
 
+def ensure_claude_auth():
+    """Make the Claude CLI auth resilient for unattended/cron runs, where no
+    login shell is sourced. Returns a short label naming the method in effect.
+
+    Preference order (mirrors the CLI's own precedence, high to low):
+      1. CLAUDE_CODE_OAUTH_TOKEN already in the environment - respected as-is.
+      2. A long-lived token stored as claude.oauthToken in ~/.hg.conf - exported
+         so the CLI can authenticate in cron. A setup-token value outranks the
+         interactive login, so this keeps working after the local login lapses.
+      3. Fall back to the local ~/.claude/.credentials.json login.
+
+    Any residual auth failure is still caught and alerted downstream, so a
+    lapsed credential surfaces loudly rather than silently."""
+    if os.environ.get('CLAUDE_CODE_OAUTH_TOKEN'):
+        return "CLAUDE_CODE_OAUTH_TOKEN (environment)"
+    try:
+        with open(MLQ_CONF_PATH, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if line.startswith('#') or '=' not in line:
+                    continue
+                key, value = line.split('=', 1)
+                if key.strip() == 'claude.oauthToken' and value.strip():
+                    os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = value.strip()
+                    return "CLAUDE_CODE_OAUTH_TOKEN (from ~/.hg.conf)"
+    except OSError:
+        pass
+    return "local login (~/.claude/.credentials.json)"
+
 def redmine_get(endpoint, api_key, params=None):
     """Make a GET request to Redmine API"""
     url = f"{REDMINE_URL}{endpoint}"
     headers = {'X-Redmine-API-Key': api_key}
     resp = requests.get(url, headers=headers, params=params)
     resp.raise_for_status()
     return resp.json()
 
 def redmine_put(endpoint, api_key, data):
     """Make a PUT request to Redmine API"""
     url = f"{REDMINE_URL}{endpoint}"
     headers = {
         'X-Redmine-API-Key': api_key,
         'Content-Type': 'application/json'
     }
@@ -1355,36 +1384,38 @@
         response = raw_response
         # Strip any preamble before "DAILY CODE REVIEW"
         match = re.search(r'^DAILY CODE REVIEW', response, re.MULTILINE)
         if match:
             response = response[match.start():]
         print(f"  Review complete")
 
     return response, temp_files, error
 
 
 def run_daily_mode(hours, cc_address, dry_run, log_dir, alert_email=DEFAULT_ALERT_EMAIL):
     """Run daily review mode: get recent commits, review per author, email results.
     Returns True on full success, False if any author's review failed (so the
     caller can exit non-zero)."""
     os.makedirs(log_dir, exist_ok=True)
+    auth_method = ensure_claude_auth()
 
     print("=" * 60)
     print(f"DAILY CODE REVIEW MODE")
     print(f"Looking back: {hours} hours")
     print(f"CC: {cc_address or 'None'}")
     print(f"Alert: {alert_email or 'None'}")
+    print(f"Auth: {auth_method}")
     print(f"Log dir: {log_dir}")
     print(f"Dry run: {dry_run}")
     print("=" * 60)
 
     # Phase 1: Gather commits
     print(f"\nPhase 1: Gathering commits from the last {hours} hours...")
     authors = get_commits_since(hours)
 
     if not authors:
         print("No commits found in the specified time window.")
         return
 
     total_commits = sum(len(a['commits']) for a in authors.values())
     print(f"Found {total_commits} commit(s) from {len(authors)} author(s):")
     for email, data in authors.items():
@@ -1564,30 +1595,31 @@
     if args.daily:
         ok = run_daily_mode(args.hours, args.cc, args.dry_run, args.log_dir,
                             alert_email=args.alert_email)
         sys.exit(0 if ok else 1)
 
     # Load configuration
     config = load_config()
     redmine_key = config.get('redmine.apiKey')
 
     if not redmine_key:
         print("ERROR: redmine.apiKey not found in config")
         sys.exit(1)
 
     # Ticket/commit modes save reviews and debug files under OUTPUT_DIR
     os.makedirs(OUTPUT_DIR, exist_ok=True)
+    print(f"Auth: {ensure_claude_auth()}")
 
     # =================================================================
     # STANDALONE COMMIT MODE (--commit without --ticket)
     # =================================================================
     if args.commit and not args.ticket:
         print("=" * 60)
         print("STANDALONE COMMIT REVIEW")
         print("=" * 60)
 
         # Get commit info from git
         commit, error = get_commit_from_git(args.commit)
         if error:
             print(f"ERROR: {error}")
             sys.exit(1)