b6040e23fd8490ae4ada8bceb147aa28be30313f
jnavarr5
  Wed Aug 12 15:33:02 2026 -0700
Quote the saved review file's absolute path in daily code review emails so authors can point a tool at it, refs #36890

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git src/utils/codeReviewAi.py src/utils/codeReviewAi.py
index 85116b44d60..c6fd9879cdf 100755
--- src/utils/codeReviewAi.py
+++ src/utils/codeReviewAi.py
@@ -1964,32 +1964,40 @@
     if os.path.exists(GMAIL_TOKEN_PATH):
         creds = Credentials.from_authorized_user_file(GMAIL_TOKEN_PATH, GMAIL_SCOPES)
 
     if not creds or not creds.valid:
         if creds and creds.expired and creds.refresh_token:
             creds.refresh(Request())
             with open(GMAIL_TOKEN_PATH, 'w') as f:
                 f.write(creds.to_json())
         else:
             print("ERROR: Gmail token not found or invalid. Run the MLQ automation script first to authenticate.")
             sys.exit(1)
 
     return google_build('gmail', 'v1', credentials=creds, cache_discovery=False)
 
 
-def send_review_email(gmail_service, to_email, author_name, review_text, cc=None):
-    """Send a code review email to the author"""
+def send_review_email(gmail_service, to_email, author_name, review_text, cc=None,
+                      review_file=None):
+    """Send a code review email to the author.
+    review_file is the path to the saved text copy of this same review; it is quoted
+    at the bottom so the author can point a tool at the file instead of copying the
+    body out of the email."""
+    if review_file:
+        review_text = (review_text.rstrip('\n') + "\n\n" + "-" * 70 + "\n" +
+                       "Text version of this review, readable on hgwdev:\n" +
+                       f"  {review_file}\n")
     message = MIMEText(review_text)
     message['To'] = to_email
     message['From'] = 'gbauto@ucsc.edu'
     message['Subject'] = f'Daily Code Review - {author_name} - {datetime.now().strftime("%Y-%m-%d")}'
     if cc:
         message['Cc'] = cc
 
     encoded = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
     gmail_service.users().messages().send(
         userId='me',
         body={'raw': encoded}
     ).execute()
 
 
 def send_alert_email(gmail_service, to_email, failures, window_label, log_dir,
@@ -2340,31 +2348,34 @@
 
         review, temp_files, error = review_daily_author(
             data['name'], data['commits'], log_dir,
             window_label=window_label, file_suffix=file_suffix)
         all_temp_files.extend(temp_files)
         reviews[author_email] = {
             'name': data['name'],
             'email': author_email,
             'review': review,
             'num_commits': len(data['commits']),
             'error': error,
         }
 
         # Save review to log_dir
         safe_name = re.sub(r'[^a-zA-Z0-9]', '_', data['name'])
-        filepath = os.path.join(log_dir, f"daily_review_{safe_name}_{file_suffix}.txt")
+        # Absolute, because this path is quoted in the author's email and has to be
+        # usable from wherever they happen to be sitting.
+        filepath = os.path.abspath(
+            os.path.join(log_dir, f"daily_review_{safe_name}_{file_suffix}.txt"))
         with open(filepath, 'w') as f:
             f.write(review)
         reviews[author_email]['file'] = filepath
         print(f"  Saved: {filepath}")
 
     # Collect any failures (broken/auth-failed reviews). A failed review is
     # never emailed to an author; instead we alert the maintainer below.
     failures = [(d['name'], d['error']) for d in reviews.values()
                 if d['error'] and not d.get('skipped')]
     # Authors the run never got to. Their own category: nothing is broken and no token has
     # expired, the night simply ran out, so they need different words in the alert.
     skipped = [(d['name'], d['error']) for d in reviews.values() if d.get('skipped')]
 
     # A split that only got through some of an author's commits is not an outright
     # failure - the author still gets the reviews that worked, and the digest is
@@ -2396,31 +2407,33 @@
             print(f"  {data['name']} <{author_email}>: {verdict_of(data)} - {data['file']}")
     else:
         gmail_service = get_gmail_service()
         for author_email, data in reviews.items():
             if data['error']:
                 print(f"  {data['name']}: FAILED - skipping author email (will alert maintainer)")
                 continue
             if not digest_wants_email(data['review']):
                 print(f"  {data['name']}: APPROVED - skipping email")
                 continue
             if digest_overall_status(data['review']) is None:
                 print(f"  {data['name']}: WARNING - could not read OVERALL STATUS, "
                       f"emailing anyway rather than dropping a possible FEEDBACK")
             print(f"  Emailing {data['name']} <{author_email}> (FEEDBACK)...")
             try:
-                send_review_email(gmail_service, author_email, data['name'], data['review'], cc=cc_address)
+                send_review_email(gmail_service, author_email, data['name'],
+                                  data['review'], cc=cc_address,
+                                  review_file=data.get('file'))
                 print(f"    SENT")
             except Exception as e:
                 # This email is the only channel that tells an author a commit of
                 # theirs went unreviewed, so a send failure has to reach the
                 # maintainer rather than being swallowed into the log.
                 print(f"    FAILED: {e}")
                 failures.append((data['name'], f"Review completed but the email could "
                                                f"not be sent: {e}"))
 
     # Phase 3b: Alert the maintainer if anything failed, so it does not fail silently
     if failures or incomplete or skipped:
         print(f"\nPhase 3b: {len(failures)} review(s) FAILED, "
               f"{len(incomplete)} incomplete, {len(skipped)} not started "
               f"- alerting maintainer...")
         for name, err in failures + incomplete + skipped: