3a5f01ff264521fd46312b8ada68f3b9c7708d64
lrnassar
  Tue Jul 14 07:57:07 2026 -0700
Add --since/--until window options to codeReviewAi.py --daily mode. refs #36890

Let --daily review an explicit date window instead of only the last N hours, so
a missed period can be backfilled in digestible chunks (e.g. one week at a time)
rather than one large bundle. --since (with optional --until) is passed straight
to git log; --hours behavior is unchanged when --since is absent, so the cron is
unaffected. The window is reflected in the review prompt, progress output, and
the failure-alert email, and is included in saved review filenames so same-day
backfill runs do not overwrite each other.

diff --git src/utils/codeReviewAi.py src/utils/codeReviewAi.py
index 8399e62cdf7..e8acc71abad 100755
--- src/utils/codeReviewAi.py
+++ src/utils/codeReviewAi.py
@@ -1,1777 +1,1805 @@
 #!/usr/bin/env python3
 """
 Automated Code Review Script for UCSC Genome Browser
 Per-ticket review using Claude Code CLI with full tool access for thorough investigation.
 
 Modes:
   Ticket mode (default): Reviews all commits for a coder together (per-ticket)
   Commit mode: Use --commit to review a single specific commit (with or without ticket)
   Daily mode: Use --daily to review all commits from the last N hours, bundled by author,
               and email each author with the review (designed to run as a daily cron)
 
 Usage:
     python3 codeReviewAi.py [--dry-run] [--ticket TICKET_ID]
     python3 codeReviewAi.py --ticket TICKET_ID --commit COMMIT_HASH [--dry-run]
     python3 codeReviewAi.py --commit COMMIT_HASH [--dry-run]
     python3 codeReviewAi.py --daily [--hours 24] [--cc list@example.com] [--dry-run]
 """
 
 import os
 import sys
 import re
 import json
 import base64
 import getpass
 import subprocess
 import argparse
 import requests
 from datetime import datetime, timedelta
 from email.mime.text import MIMEText
 from collections import defaultdict
 
 # Configuration
 REDMINE_URL = "https://redmine.gi.ucsc.edu"
 GIT_REPORTS_PATH = "/hive/groups/qa/git-reports-history"
 GIT_REPO_PATH = "/data/git/kent.git"
 OUTPUT_DIR = f"/hive/users/{getpass.getuser()}/codeReview"
 MLQ_CONF_PATH = os.path.expanduser("~/.hg.conf")
 DEFAULT_CC = "browser-code-reviews-group@ucsc.edu"
 DEFAULT_ALERT_EMAIL = "browserqa-group@ucsc.edu"
 GMAIL_TOKEN_PATH = os.path.expanduser("~/.gmail_token.json")
 GMAIL_CREDS_PATH = os.path.expanduser("~/.gmail_credentials.json")
 GMAIL_SCOPES = [
     'https://www.googleapis.com/auth/gmail.send',
 ]
 CLAUDE_CLI = os.path.expanduser('~/.local/bin/claude')
 
 def load_config():
     """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'
     }
     resp = requests.put(url, headers=headers, json=data)
     return resp.status_code in (200, 204)
 
 def parse_git_reports_url(url):
     """Convert git-reports URL to local path components"""
     match = re.search(r'/git-reports-history/([^/]+)/([^/]+)/user', url)
     if match:
         return match.group(1), match.group(2)
     return None, None
 
 def get_open_cr_tickets(api_key):
     """Get all open code review tickets"""
     data = redmine_get('/issues.json', api_key, {
         'project_id': 'codereview',
         'status_id': 'open',
         'limit': 100
     })
     return data.get('issues', [])
 
 def get_ticket_details(ticket_id, api_key):
     """Get full ticket details including custom fields"""
     data = redmine_get(f'/issues/{ticket_id}.json', api_key, {
         'include': 'custom_fields,journals'
     })
     return data.get('issue', {})
 
 def get_coder_from_ticket(ticket):
     """Extract coder name from ticket custom fields"""
     for cf in ticket.get('custom_fields', []):
         if cf.get('name') == 'Coder' and cf.get('value'):
             return cf['value']
     return None
 
 def get_git_reports_url(ticket):
     """Extract git-reports URL from ticket description"""
     desc = ticket.get('description', '')
     match = re.search(r'https://genecats\.gi\.ucsc\.edu/git-reports-history/[^\s]+', desc)
     return match.group(0) if match else None
 
 def read_coder_commits(version, period, coder):
     """Read commits from local git-reports index.html"""
     index_path = f"{GIT_REPORTS_PATH}/{version}/{period}/user/{coder}/index.html"
 
     if not os.path.exists(index_path):
         return [], f"Git reports not found: {index_path}"
 
     with open(index_path, 'r') as f:
         html = f.read()
 
     commits = []
 
     # Match hash in <span class='details'> followed by the commit message <li>
     # This ensures we pair each hash with its actual commit message, not nested file <li>s
     # HTML structure: <span class='details'>HASH DATE <br>\n</span>\n<li>COMMIT MESSAGE
     # Note: span contains <br> tag, so we use .*? instead of [^<]*
     commit_pattern = r"<span class='details' >([a-f0-9]{40}).*?</span>\s*<li>([^<\n]+)"
 
     matches = re.findall(commit_pattern, html, re.DOTALL)
 
     for commit_hash, message in matches:
         refs = re.findall(r'#(\d+)', message)
         commits.append({
             'hash': commit_hash,
             'short_hash': commit_hash[:10],
             'message': message.strip(),
             'referenced_issues': refs
         })
 
     return commits, None
 
 def get_referenced_issue(issue_id, api_key):
     """Fetch a referenced Redmine issue for context"""
     try:
         data = redmine_get(f'/issues/{issue_id}.json', api_key)
         issue = data.get('issue', {})
         return {
             'id': issue_id,
             'subject': issue.get('subject', ''),
             'description': issue.get('description', ''),
             'status': issue.get('status', {}).get('name', '')
         }
     except Exception:
         return {'id': issue_id, 'subject': 'Could not fetch', 'description': '', 'status': ''}
 
 def get_commit_from_git(commit_hash):
     """Get commit info directly from git (for standalone commit review)"""
     try:
         result = subprocess.run(
             ['git', f'--git-dir={GIT_REPO_PATH}', 'log', '-1',
              '--format=%H%n%an%n%ae%n%ad%n%s', commit_hash],
             capture_output=True, text=True, timeout=30
         )
         if result.returncode != 0:
             return None, f"Commit not found: {commit_hash}"
 
         lines = result.stdout.strip().split('\n')
         if len(lines) < 5:
             return None, f"Could not parse commit: {commit_hash}"
 
         full_hash = lines[0]
         author = lines[1]
         message = lines[4]
 
         # Extract referenced issues from commit message
         refs = re.findall(r'#(\d+)', message)
 
         return {
             'hash': full_hash,
             'short_hash': full_hash[:10],
             'author': author,
             'message': message,
             'referenced_issues': refs
         }, None
     except Exception as e:
         return None, f"Error getting commit: {e}"
 
 def gather_ticket_data(ticket_id, api_key):
     """Gather all data for a single ticket"""
     print(f"\n--- Gathering data for ticket #{ticket_id} ---")
 
     ticket = get_ticket_details(ticket_id, api_key)
     coder = get_coder_from_ticket(ticket)
     git_url = get_git_reports_url(ticket)
 
     if not coder:
         print(f"  WARNING: No coder found")
         return None
 
     if not git_url:
         print(f"  WARNING: No git-reports URL found")
         return None
 
     print(f"  Coder: {coder}")
     print(f"  Git reports: {git_url}")
 
     version, period = parse_git_reports_url(git_url)
     if not version:
         print(f"  WARNING: Could not parse git-reports URL")
         return None
 
     commits, error = read_coder_commits(version, period, coder)
     if error:
         print(f"  WARNING: {error}")
         return None
 
     print(f"  Found {len(commits)} commit(s)")
 
     # Collect all referenced issues
     all_refs = set()
     for c in commits:
         all_refs.update(c['referenced_issues'])
 
     # Fetch referenced issues
     referenced_issues = {}
     for ref_id in all_refs:
         print(f"  Fetching referenced issue #{ref_id}")
         referenced_issues[ref_id] = get_referenced_issue(ref_id, api_key)
 
     return {
         'ticket_id': ticket_id,
         'subject': ticket.get('subject', ''),
         'coder': coder,
         'version': version,
         'period': period,
         'commits': commits,
         'referenced_issues': referenced_issues
     }
 
 # =============================================================================
 # PER-TICKET REVIEW (Default Mode)
 # =============================================================================
 
 def build_per_ticket_prompt(ticket_data):
     """Build a prompt for reviewing all commits for one coder together"""
 
     # Build commits list
     commits_list = []
     for i, c in enumerate(ticket_data['commits'], 1):
         refs = ', '.join('#' + r for r in c['referenced_issues']) or 'None'
         commits_list.append(f"  {i}. @{c['short_hash']}@ - {c['message'][:70]}")
         commits_list.append(f"     Referenced issues: {refs}")
 
     commits_section = "\n".join(commits_list)
 
     # Build referenced issues context
     ref_issues_section = ""
     if ticket_data['referenced_issues']:
         ref_issues_section = "## REFERENCED ISSUES (for context)\n\n"
         for ref_id, ref in ticket_data['referenced_issues'].items():
             ref_issues_section += f"### Issue #{ref_id}: {ref['subject']}\n"
             ref_issues_section += f"Status: {ref['status']}\n"
             ref_issues_section += f"Description:\n{ref['description'][:1500]}\n\n"
 
     # Build the commit hashes for git commands
     commit_hashes = " ".join([c['hash'] for c in ticket_data['commits']])
 
     prompt = f"""You are performing a code review for UCSC Genome Browser ticket #{ticket_data['ticket_id']}.
 
 ## TICKET INFORMATION
 
 **Ticket:** #{ticket_data['ticket_id']} - {ticket_data['subject']}
 **Coder:** {ticket_data['coder']}
 **Version:** {ticket_data['version']}
 **Number of commits:** {len(ticket_data['commits'])}
 
 ## COMMITS TO REVIEW
 
 {commits_section}
 
 {ref_issues_section}
 
 ## YOUR TASK
 
 Review ALL commits for this coder thoroughly. You have full tool access - USE IT.
 
 ### Step 1: Get the diffs for all commits
 
 For each commit, get the full diff:
 ```
 git --git-dir=/data/git/kent.git show <commit_hash>
 ```
 
 The commit hashes are:
 {commit_hashes}
 
 ### Step 2: For EACH commit, check for:
 
 - Does the change correctly address the referenced issue(s)?
 - Security issues (buffer overflows, SQL injection, XSS, command injection)
 - Kent codebase patterns (freez vs freeMem, safef vs sprintf, sqlSafef)
 - Logic errors, off-by-one errors, null pointer risks
 - **IMPORTANT: For documentation/HTML/text changes, read the content word-by-word and check for:**
   - Typos (doubled words like "the the", wrong words like "of" vs "or")
   - Grammar errors
   - Unclosed HTML tags
   - Missing or incomplete sentences
 
 ### Step 3: Read files for context when needed
 
 ```
 git --git-dir=/data/git/kent.git show HEAD:src/path/to/file.c
 ```
 
 ### Step 4: Check if issues still exist in HEAD
 
 If you find any issues in a commit, verify whether they still exist in HEAD:
 ```
 git --git-dir=/data/git/kent.git show HEAD:src/path/to/file
 ```
 - If FIXED in HEAD (by a later commit in this review), note: "Issue found but FIXED in later commit" → APPROVED
 - If STILL EXISTS in HEAD → FEEDBACK required
 
 ### Step 5: Look for cross-commit patterns
 
 Since you're reviewing all commits together, note:
 - Consistent good practices (or bad practices) across commits
 - How commits relate to each other
 - Whether earlier commits' issues are fixed in later commits
 
 ## OUTPUT FORMAT
 
 Provide your review in Redmine Textile format. IMPORTANT Textile syntax rules:
 - Inline code uses @code@ - ALWAYS close with a second @, never leave @ unclosed
 - Don't start lines with spaces (creates unwanted code blocks)
 - Use @short_hash@ for commit hashes, not backticks
 - Headers: h1. h2. h3. (with period and space)
 - Bold: *text* | Italic: _text_
 - Tables: |_. header |_. header | then | cell | cell |
 
 Format:
 
 ```
 h1. Code Review: Ticket #{ticket_data['ticket_id']} - {ticket_data['subject']}
 
 *Coder:* {ticket_data['coder']}
 *Review Date:* {datetime.now().strftime('%Y-%m-%d')}
 *Redmine Ticket:* #{ticket_data['ticket_id']}
 
 ---
 
 h2. Summary
 
 [Number] commits reviewed: [brief description of what these commits do overall]
 
 |_. # |_. Commit |_. Issue |_. Description |
 [Table rows for each commit]
 
 ---
 
 h2. Commit 1: [short_hash] - [Brief Title]
 
 *Message:* [commit message]
 *Referenced Issues:* [issues]
 
 *Files Changed:*
 [List files]
 
 *Analysis:*
 [Your detailed analysis]
 
 *Issues Found:*
 [List issues or "None". Indicate if issues still exist in HEAD or were fixed in later commits.]
 
 *Verified:* [Yes/No/Partial] - Does change address referenced issue(s)?
 
 h3. Verdict: APPROVED / FEEDBACK
 
 ---
 
 [Repeat for each commit]
 
 ---
 
 h2. Cross-Commit Observations
 
 [Note any patterns, relationships between commits, or overall code quality observations]
 
 ---
 
 h2. Risk Assessment
 
 |_. Area |_. Risk |_. Notes |
 | Security | Low/Med/High | [explanation] |
 | Regression | Low/Med/High | [explanation] |
 
 ---
 
 h2. Final Recommendation
 
 h3. Status: APPROVED / FEEDBACK
 
 [Summary. If FEEDBACK, list all items that need to be addressed before approval.]
 
 ---
 
 _Review: {datetime.now().strftime('%Y-%m-%d')} | Commits: {len(ticket_data['commits'])} | Per-ticket review with full tool access_
 ```
 
 IMPORTANT:
 - Give FEEDBACK only if issues STILL EXIST in HEAD
 - Give APPROVED if all issues were fixed in later commits (note this in your review)
 - Be thorough - check every commit, read every diff
 
 OUTPUT REQUIREMENTS:
 - You MUST output the COMPLETE review in Textile format as shown above
 - Start your output with "h1. Code Review:" - no preamble text before this
 - Include ALL sections: Summary, each Commit analysis, Cross-Commit Observations, Risk Assessment, Final Recommendation
 - Do NOT output just a summary sentence - output the FULL FORMATTED REVIEW
 - Do NOT say "The review is complete" - instead output the actual review content
 
 BEGIN YOUR REVIEW NOW. Use your tools to investigate thoroughly, then output the COMPLETE formatted review starting with "h1. Code Review:"
 """
     return prompt
 
 def validate_review_output(response):
     """Check if the response contains a valid Textile-formatted review"""
     if not response:
         return False, "Empty response"
 
     # Must contain h1. header
     if 'h1.' not in response:
         return False, "Missing h1. header - Claude may have returned a summary instead of full review"
 
     # Must contain a verdict
     if 'Verdict:' not in response and 'APPROVED' not in response and 'FEEDBACK' not in response:
         return False, "Missing verdict section"
 
     # Should be reasonably long (at least 500 chars for a minimal review)
     if len(response) < 500:
         return False, f"Response too short ({len(response)} chars) - may be incomplete"
 
     return True, "OK"
 
 def validate_daily_review_output(response):
     """Check if the response contains a valid daily review in plain text format"""
     if not response:
         return False, "Empty response"
 
     if 'DAILY CODE REVIEW' not in response:
         return False, "Missing DAILY CODE REVIEW header"
 
     if 'APPROVED' not in response and 'FEEDBACK' not in response:
         return False, "Missing verdict section"
 
     if len(response) < 500:
         return False, f"Response too short ({len(response)} chars) - may be incomplete"
 
     return True, "OK"
 
 # Substrings that indicate the Claude CLI could not authenticate or otherwise
 # failed to produce a usable review. Used to alert instead of failing silently.
 CLI_AUTH_ERROR_MARKERS = (
     'Failed to authenticate',
     'authentication_error',
     'Invalid authentication credentials',
     'API Error: 401',
 )
 
 def detect_cli_failure(response, validator):
     """Return an error description if the CLI response indicates a hard failure
     (no output, an authentication error, or output that fails validation),
     otherwise return None. Lets the caller alert rather than silently save a
     broken review."""
     if not response:
         return "No response from Claude CLI (timeout, crash, or empty output)"
     for marker in CLI_AUTH_ERROR_MARKERS:
         if marker in response:
             first_line = next((l for l in response.strip().splitlines() if l.strip()), response)
             return f"Claude CLI authentication failure: {first_line.strip()[:300]}"
     is_valid, msg = validator(response)
     if not is_valid:
         return f"Invalid or incomplete review output: {msg}"
     return None
 
 def call_claude_cli(prompt, timeout=600, retries=1, validator=None):
     """Call Claude Code CLI with a prompt and return the response"""
     if validator is None:
         validator = validate_review_output
     for attempt in range(retries + 1):
         try:
             result = subprocess.run(
                 [CLAUDE_CLI, '-p', prompt, '--output-format', 'text',
                  '--allowedTools', 'Bash,Read,Glob,Grep,Agent'],
                 capture_output=True,
                 text=True,
                 timeout=timeout
             )
 
             if result.returncode != 0:
                 print(f"  WARNING: Claude CLI returned non-zero: {result.returncode}")
                 if result.stderr:
                     print(f"  stderr: {result.stderr[:500]}")
 
             response = result.stdout
 
             # Check if response is valid (not empty/trivial)
             is_valid, msg = validator(response)
             if not is_valid and attempt < retries:
                 print(f"  WARNING: Invalid response ({msg}) - retrying...")
                 continue
 
             return response
 
         except subprocess.TimeoutExpired:
             print(f"  WARNING: Claude CLI timed out after {timeout}s")
             if attempt < retries:
                 print(f"  Retrying...")
                 continue
             return None
         except Exception as e:
             print(f"  ERROR calling Claude CLI: {e}")
             if attempt < retries:
                 print(f"  Retrying...")
                 continue
             return None
 
     return None
 
 def clean_review_output(response):
     """Strip any preamble before the actual Textile review content"""
     if not response:
         return response
 
     # First validate we have a proper review
     is_valid, msg = validate_review_output(response)
     if not is_valid:
         print(f"  WARNING: Invalid review output - {msg}")
         # Return as-is with a warning header so it's obvious something went wrong
         return f"h1. Code Review - ERROR\n\n*Warning: Review generation may have failed.*\n\nClaude's response:\n{response}\n"
 
     # Find where the actual review starts (h1. header)
     # Look for patterns like "h1. Code Review" or just "h1."
     patterns = [
         r'^h1\. Code Review',
         r'^h1\.',
         r'^\s*h1\. Code Review',
         r'^\s*h1\.',
     ]
 
     for pattern in patterns:
         match = re.search(pattern, response, re.MULTILINE)
         if match:
             response = response[match.start():]
             break
     else:
         # If no h1. found, try to find the start of Textile markup
         lines = response.split('\n')
         for i, line in enumerate(lines):
             if line.strip().startswith('h1.') or line.strip().startswith('h2.'):
                 response = '\n'.join(lines[i:])
                 break
 
     # Fix common Textile formatting issues
     response = fix_textile_formatting(response)
 
     return response
 
 def fix_textile_formatting(text):
     """Fix common Textile formatting issues that break Redmine rendering"""
     if not text:
         return text
 
     # Fix 1: Ensure @ symbols for inline code are properly paired
     # Count @ symbols per line and fix unclosed ones
     lines = text.split('\n')
     fixed_lines = []
 
     for line in lines:
         # Count @ symbols (excluding @@)
         # Replace @@ temporarily to not count them
         temp_line = line.replace('@@', '\x00\x00')
         at_count = temp_line.count('@')
 
         # If odd number of @, there's an unclosed one
         if at_count % 2 == 1:
             # Try to find the unclosed @ and close it or remove it
             # Common case: "@something" at end of line without closing
             # Add closing @ at end of the word
             fixed_line = re.sub(r'@(\w+)(?!\w*@)', r'@\1@', line)
             # If that didn't fix it, try to escape just the lone @ at end
             temp_fixed = fixed_line.replace('@@', '\x00\x00')
             if temp_fixed.count('@') % 2 == 1:
                 # Still odd - find and escape only the lone @ (likely at end of line)
                 # Look for @ not followed by a word char, or @ at end of line
                 fixed_line = re.sub(r'@(?=\s|$|[^\w])', '&#64;', fixed_line)
                 # If STILL odd (edge case), escape just the last @
                 temp_fixed2 = fixed_line.replace('@@', '\x00\x00')
                 if temp_fixed2.count('@') % 2 == 1:
                     # Find last @ and escape it
                     last_at = fixed_line.rfind('@')
                     if last_at >= 0:
                         fixed_line = fixed_line[:last_at] + '&#64;' + fixed_line[last_at+1:]
             line = fixed_line
 
         fixed_lines.append(line)
 
     text = '\n'.join(fixed_lines)
 
     # Fix 2: Remove leading spaces from lines that shouldn't be code blocks
     # (lines starting with h1., h2., h3., |, *, -, etc. shouldn't have leading spaces)
     lines = text.split('\n')
     fixed_lines = []
     for line in lines:
         stripped = line.lstrip()
         # If it's a Textile formatting line, remove leading whitespace
         if re.match(r'^(h[1-6]\.|[|*#-]|\*\*|_)', stripped):
             line = stripped
         fixed_lines.append(line)
 
     text = '\n'.join(fixed_lines)
 
     return text
 
 def review_ticket_per_ticket(ticket_data):
     """Review all commits for a ticket together (default mode)"""
     print(f"\n{'='*60}")
     print(f"REVIEWING TICKET #{ticket_data['ticket_id']}: {ticket_data['coder']}")
     print(f"Commits: {len(ticket_data['commits'])}")
     print(f"Mode: Per-ticket (all commits together)")
     print(f"{'='*60}")
 
     prompt = build_per_ticket_prompt(ticket_data)
 
     # Save prompt for debugging
     prompt_file = os.path.join(OUTPUT_DIR, f".last_ticket_prompt_{ticket_data['ticket_id']}.txt")
     with open(prompt_file, 'w') as f:
         f.write(prompt)
     print(f"  Prompt saved to: {prompt_file}")
     print(f"  Calling Claude CLI (this may take a few minutes)...")
 
     response = call_claude_cli(prompt, timeout=600)
 
     if response:
         # Save raw response for debugging
         response_file = os.path.join(OUTPUT_DIR, f".last_ticket_response_{ticket_data['ticket_id']}.txt")
         with open(response_file, 'w') as f:
             f.write(response)
         # Clean up any preamble
         response = clean_review_output(response)
         print(f"  Review complete")
     else:
         print(f"  WARNING: No response received")
         response = f"h1. Code Review: Ticket #{ticket_data['ticket_id']}\n\n*Error: Review failed - no response from Claude CLI*\n"
 
     return response
 
 # =============================================================================
 # PER-COMMIT REVIEW (when --commit is specified)
 # =============================================================================
 
 def build_single_commit_prompt(commit, ticket_data):
     """Build a prompt for reviewing a single specific commit"""
 
     # Get referenced issue context for this commit
     ref_context = ""
     for ref_id in commit['referenced_issues']:
         if ref_id in ticket_data['referenced_issues']:
             ref = ticket_data['referenced_issues'][ref_id]
             ref_context += f"""
 ### Referenced Issue #{ref_id}: {ref['subject']}
 Status: {ref['status']}
 Description:
 {ref['description'][:2000]}
 """
 
     prompt = f"""You are reviewing a SINGLE commit for UCSC Genome Browser code review ticket #{ticket_data['ticket_id']}.
 
 ## COMMIT INFORMATION
 
 **Commit:** {commit['hash']}
 **Coder:** {ticket_data['coder']}
 **Message:** {commit['message']}
 **Referenced Issues:** {', '.join('#' + r for r in commit['referenced_issues']) or 'None'}
 
 {ref_context}
 
 ## YOUR TASK
 
 Review this ONE commit thoroughly. You have full tool access - USE IT.
 
 1. **Get the full diff:**
    ```
    git --git-dir=/data/git/kent.git show {commit['hash']}
    ```
 
 2. **Read any modified files in full** if needed for context:
    ```
    git --git-dir=/data/git/kent.git show HEAD:src/path/to/file.c
    ```
 
 3. **Check for:**
    - Does the change correctly address the referenced issue(s)?
    - Security issues (buffer overflows, SQL injection, XSS, command injection)
    - Kent codebase patterns (freez vs freeMem, safef vs sprintf, sqlSafef)
    - Logic errors, off-by-one errors, null pointer risks
    - **IMPORTANT: For documentation/HTML/text changes, read the content word-by-word and check for:**
      - Typos (doubled words like "the the", wrong words like "of" vs "or")
      - Grammar errors
      - Unclosed HTML tags
      - Missing or incomplete sentences
 
 4. **Investigate** any uncertainties using git grep, git blame, or reading related files.
 
 5. **Check if issues still exist in HEAD:**
    If you find any issues, check if they still exist in the current HEAD version:
    ```
    git --git-dir=/data/git/kent.git show HEAD:src/path/to/file
    ```
    - If FIXED in HEAD, note: "Issue found but FIXED in later commit" → APPROVED
    - If STILL EXISTS in HEAD → FEEDBACK required
 
 ## OUTPUT FORMAT
 
 Provide your review in Redmine Textile format. IMPORTANT Textile syntax rules:
 - Inline code uses @code@ - ALWAYS close with a second @, never leave @ unclosed
 - Don't start lines with spaces (creates unwanted code blocks)
 - Use @short_hash@ for commit hashes, not backticks
 - Headers: h1. h2. h3. (with period and space)
 - Bold: *text* | Italic: _text_
 
 Format:
 
 ```
 h1. Code Review: Commit {commit['short_hash']}
 
 *Coder:* {ticket_data['coder']}
 *Review Date:* {datetime.now().strftime('%Y-%m-%d')}
 *Ticket:* #{ticket_data['ticket_id']}
 
 ---
 
 h2. Commit: {commit['short_hash']} - Brief Title
 
 *Message:* {commit['message']}
 *Referenced Issues:* {', '.join('#' + r for r in commit['referenced_issues']) or 'None'}
 
 *Files Changed:*
 [List the files modified]
 
 *Analysis:*
 [Your detailed analysis of the changes. Be specific about what you found.]
 
 *Issues Found:*
 [List any issues, or "None". Indicate if issues still exist in HEAD or were fixed.]
 
 *Verified:* [Yes/No/Partial] - Does change correctly address referenced issue(s)?
 
 h3. Verdict: APPROVED / FEEDBACK
 
 [If FEEDBACK, explain exactly what needs to be fixed]
 
 ---
 
 _Review: {datetime.now().strftime('%Y-%m-%d')} | Single commit review_
 ```
 
 OUTPUT REQUIREMENTS:
 - You MUST output the COMPLETE review in Textile format as shown above
 - Start your output with "h1. Code Review:" - no preamble text before this
 - Include ALL sections: Commit analysis, Issues Found, Verdict
 - Do NOT output just a summary sentence - output the FULL FORMATTED REVIEW
 - Do NOT say "The review is complete" - instead output the actual review content
 
 BEGIN YOUR REVIEW NOW. Use your tools to investigate thoroughly, then output the COMPLETE formatted review starting with "h1. Code Review:"
 """
     return prompt
 
 def review_single_commit(commit, ticket_data):
     """Review a single specific commit"""
     print(f"\n{'='*60}")
     print(f"REVIEWING SINGLE COMMIT: {commit['short_hash']}")
     print(f"Ticket: #{ticket_data['ticket_id']} | Coder: {ticket_data['coder']}")
     print(f"Mode: Single commit")
     print(f"{'='*60}")
 
     prompt = build_single_commit_prompt(commit, ticket_data)
 
     # Save prompt for debugging
     prompt_file = os.path.join(OUTPUT_DIR, f".last_commit_prompt_{commit['short_hash']}.txt")
     with open(prompt_file, 'w') as f:
         f.write(prompt)
     print(f"  Prompt saved to: {prompt_file}")
     print(f"  Calling Claude CLI...")
 
     response = call_claude_cli(prompt, timeout=300)
 
     if response:
         response_file = os.path.join(OUTPUT_DIR, f".last_commit_response_{commit['short_hash']}.txt")
         with open(response_file, 'w') as f:
             f.write(response)
         # Clean up any preamble
         response = clean_review_output(response)
         print(f"  Review complete")
     else:
         print(f"  WARNING: No response received")
         response = f"h1. Code Review: Commit {commit['short_hash']}\n\n*Error: Review failed - no response from Claude CLI*\n"
 
     return response
 
 # =============================================================================
 # STANDALONE COMMIT REVIEW (when only --commit is specified, no ticket)
 # =============================================================================
 
 def build_standalone_commit_prompt(commit, referenced_issues):
     """Build a prompt for reviewing a commit without ticket context"""
 
     # Get referenced issue context
     ref_context = ""
     for ref_id in commit['referenced_issues']:
         if ref_id in referenced_issues:
             ref = referenced_issues[ref_id]
             ref_context += f"""
 ### Referenced Issue #{ref_id}: {ref['subject']}
 Status: {ref['status']}
 Description:
 {ref['description'][:2000]}
 """
 
     prompt = f"""You are reviewing a commit for the UCSC Genome Browser project.
 
 ## COMMIT INFORMATION
 
 **Commit:** {commit['hash']}
 **Author:** {commit['author']}
 **Message:** {commit['message']}
 **Referenced Issues:** {', '.join('#' + r for r in commit['referenced_issues']) or 'None'}
 
 {ref_context}
 
 ## RESOURCES AVAILABLE
 
 You have full tool access. USE THESE RESOURCES:
 
 ### Central Git Repository
 - Path: `/data/git/kent.git`
 - Usage: `git --git-dir=/data/git/kent.git <command>`
 - Examples:
   - `git --git-dir=/data/git/kent.git show {commit['hash']}` - full commit diff
   - `git --git-dir=/data/git/kent.git show HEAD:src/path/file.c` - read current file
   - `git --git-dir=/data/git/kent.git log --oneline -20 -- src/path/file.c` - file history
   - `git --git-dir=/data/git/kent.git grep "pattern"` - search entire codebase
   - `git --git-dir=/data/git/kent.git blame src/path/file.c -L 100,120` - who wrote each line
 
 ### Kent Codebase Conventions
 - Reference: `~/kent/src/README` - contains code conventions, indentation standards, source tree organization
 
 ## YOUR TASK
 
 Review this commit thoroughly. **NEVER speculate about code you haven't read.**
 
 ### Step 1: Get the full diff
 ```
 git --git-dir=/data/git/kent.git show {commit['hash']}
 ```
 
 ### Step 2: Read modified files for full context
 ```
 git --git-dir=/data/git/kent.git show HEAD:src/path/to/file.c
 ```
 
 ### Step 3: Check for security issues (C code)
 
 **Buffer overflow risks:**
 - `gets()` → ALWAYS vulnerable, must use `fgets()`
 - `strcpy()`, `strcat()` → use `safecpy()`, kent safe equivalents
 - `sprintf()`, `vsprintf()` → use `safef()` or `snprintf()`
 - Check all array indexing for bounds validation
 - Watch for off-by-one errors (using `<=` instead of `<`)
 
 **Format string vulnerabilities:**
 - `printf(userInput)` → NEVER pass user input as format string
 - Always use `printf("%s", userInput)` pattern
 
 **Memory safety:**
 - Use-after-free: check freed pointers aren't used later
 - Double-free: ensure memory isn't freed twice
 - Memory leaks: allocated memory should be freed on all paths
 
 **Command/SQL injection:**
 - `system()`, `popen()`, `exec*()` → command injection risk
 - SQL queries → must use `sqlSafef()`, never string concatenation
 - User input in file paths → path traversal risk (check for `..`)
 
 **Web output:**
 - HTML output → XSS risk, ensure proper escaping
 - URL parameters → validate and sanitize
 
 ### Step 4: Check kent codebase patterns
 
 | Unsafe | Safe Kent Equivalent |
 |--------|---------------------|
 | sprintf | safef |
 | strcpy | safecpy |
 | strcat | safecat |
 | malloc/free | needMem/freez |
 | freeMem | freez (sets pointer to NULL) |
 | SQL string concat | sqlSafef |
 
 ### Step 5: Check for bugs and logic errors
 - Logic errors, off-by-one errors
 - Null pointer risks (check return values)
 - Unclosed tags/brackets
 - Typos in strings or variable names
 
 ### Step 6: For documentation/HTML/text changes
 **Read the content word-by-word and check for:**
 - Typos (doubled words like "the the", wrong words like "of" vs "or")
 - Grammar errors
 - Unclosed HTML tags
 - Missing or incomplete sentences
 - Documentation changes are NOT low-effort reviews - text quality matters
 
 ### Step 7: Investigate when needed
 
 - **Unfamiliar function** → Read its implementation
 - **Wondering if pattern exists elsewhere** → `git grep "functionName"`
 - **Need to see how something is used** → `git grep "functionCall("`
 - **Understanding existing code** → `git blame` to see who wrote it and when
 
 ### Step 8: Check if issues still exist in HEAD
 If you find any issues, verify whether they still exist:
 ```
 git --git-dir=/data/git/kent.git show HEAD:src/path/to/file
 ```
 - If FIXED in HEAD → note "Issue found but FIXED in later commit" → APPROVED
 - If STILL EXISTS in HEAD → FEEDBACK required
 
 ## OUTPUT FORMAT
 
 Provide your review in Redmine Textile format. IMPORTANT Textile syntax rules:
 - Inline code uses @code@ - ALWAYS close with a second @, never leave @ unclosed
 - Don't start lines with spaces (creates unwanted code blocks)
 - Use @short_hash@ for commit hashes, not backticks
 - Headers: h1. h2. h3. (with period and space)
 - Bold: *text* | Italic: _text_
 
 Format:
 
 ```
 h1. Code Review: Commit {commit['short_hash']}
 
 *Author:* {commit['author']}
 *Review Date:* {datetime.now().strftime('%Y-%m-%d')}
 
 ---
 
 h2. Commit: {commit['short_hash']} - Brief Title
 
 *Message:* {commit['message']}
 *Referenced Issues:* {', '.join('#' + r for r in commit['referenced_issues']) or 'None'}
 
 *Files Changed:*
 [List the files modified]
 
 *Analysis:*
 [Your detailed analysis of the changes. Be specific about what you found.]
 
 *Issues Found:*
 [List any issues, or "None". Indicate if issues still exist in HEAD or were fixed.]
 
 *Verified:* [Yes/No/Partial] - Does change correctly address referenced issue(s)?
 
 h3. Verdict: APPROVED / FEEDBACK
 
 [If FEEDBACK, explain exactly what needs to be fixed]
 
 ---
 
 _Review: {datetime.now().strftime('%Y-%m-%d')} | Standalone commit review_
 ```
 
 ## VERDICT GUIDELINES
 
 **Give APPROVED when:**
 - Change correctly addresses its stated purpose
 - No security vulnerabilities found
 - No bugs that would affect users
 - Code follows kent patterns (minor deviations okay with note)
 
 **Give FEEDBACK when:**
 - Security vulnerability present
 - Bug that would cause incorrect behavior or crash
 - Typos or grammar errors in documentation/user-facing text
 - Missing required error handling
 
 OUTPUT REQUIREMENTS:
 - You MUST output the COMPLETE review in Textile format as shown above
 - Start your output with "h1. Code Review:" - no preamble text before this
 - Include ALL sections: Commit analysis, Issues Found, Verdict
 - Do NOT output just a summary sentence - output the FULL FORMATTED REVIEW
 - Do NOT say "The review is complete" - instead output the actual review content
 
 BEGIN YOUR REVIEW NOW. Use your tools to investigate thoroughly, then output the COMPLETE formatted review starting with "h1. Code Review:"
 """
     return prompt
 
 def review_standalone_commit(commit, referenced_issues):
     """Review a commit without ticket context"""
     print(f"\n{'='*60}")
     print(f"REVIEWING COMMIT: {commit['short_hash']}")
     print(f"Author: {commit['author']}")
     print(f"Mode: Standalone commit (no ticket)")
     print(f"{'='*60}")
 
     prompt = build_standalone_commit_prompt(commit, referenced_issues)
 
     # Save prompt for debugging
     prompt_file = os.path.join(OUTPUT_DIR, f".last_commit_prompt_{commit['short_hash']}.txt")
     with open(prompt_file, 'w') as f:
         f.write(prompt)
     print(f"  Prompt saved to: {prompt_file}")
     print(f"  Calling Claude CLI...")
 
     response = call_claude_cli(prompt, timeout=300)
 
     if response:
         response_file = os.path.join(OUTPUT_DIR, f".last_commit_response_{commit['short_hash']}.txt")
         with open(response_file, 'w') as f:
             f.write(response)
         # Clean up any preamble
         response = clean_review_output(response)
         print(f"  Review complete")
     else:
         print(f"  WARNING: No response received")
         response = f"h1. Code Review: Commit {commit['short_hash']}\n\n*Error: Review failed - no response from Claude CLI*\n"
 
     return response
 
 # =============================================================================
 # DAILY REVIEW MODE (when --daily is specified)
 # =============================================================================
 
-def get_commits_since(hours):
-    """Get all commits from the last N hours, grouped by author"""
-    since = datetime.now() - timedelta(hours=hours)
-    since_str = since.strftime('%Y-%m-%d %H:%M:%S')
+def get_commits_since(hours, since=None, until=None):
+    """Get commits grouped by author. By default covers the last N hours; if
+    'since' (and optionally 'until') are given, covers that explicit window
+    instead. 'since'/'until' are passed straight to git log, so any date git
+    understands works (e.g. '2026-06-20' or '2026-06-20 00:00:00')."""
+    since_str = since or (datetime.now() - timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S')
 
     # Get commits with author name, email, hash, and subject
-    result = subprocess.run(
-        ['git', f'--git-dir={GIT_REPO_PATH}', 'log',
-         f'--since={since_str}', '--format=%H%n%an%n%ae%n%s', '--no-merges'],
-        capture_output=True, text=True, timeout=60
-    )
+    cmd = ['git', f'--git-dir={GIT_REPO_PATH}', 'log',
+           f'--since={since_str}', '--format=%H%n%an%n%ae%n%s', '--no-merges']
+    if until:
+        cmd.append(f'--until={until}')
+    result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
     if result.returncode != 0:
         print(f"ERROR: git log failed: {result.stderr}")
         return {}
 
     lines = result.stdout.strip().split('\n')
     if not lines or lines == ['']:
         return {}
 
     # Parse into commit records, grouped by author email
     authors = defaultdict(lambda: {'name': '', 'email': '', 'commits': []})
     i = 0
     while i + 3 < len(lines):
         commit_hash = lines[i]
         author_name = lines[i + 1]
         author_email = lines[i + 2]
         message = lines[i + 3]
         i += 4
 
         # Skip blank separator lines between records
         while i < len(lines) and lines[i] == '':
             i += 1
 
         refs = re.findall(r'#(\d+)', message)
         authors[author_email]['name'] = author_name
         authors[author_email]['email'] = author_email
         authors[author_email]['commits'].append({
             'hash': commit_hash,
             'short_hash': commit_hash[:10],
             'message': message.strip(),
             'referenced_issues': refs
         })
 
     return dict(authors)
 
 
-def build_daily_review_prompt(author_name, commits):
-    """Build a prompt for reviewing all of one author's daily commits"""
+def build_daily_review_prompt(author_name, commits, window_label="the last 24 hours"):
+    """Build a prompt for reviewing all of one author's commits in a window"""
 
     commits_list = []
     commit_hashes = []
     for i, c in enumerate(commits, 1):
         refs = ', '.join('#' + r for r in c['referenced_issues']) or 'None'
         commits_list.append(f"  {i}. @{c['short_hash']}@ - {c['message'][:80]}")
         commits_list.append(f"     Referenced issues: {refs}")
         commit_hashes.append(c['hash'])
 
     commits_section = "\n".join(commits_list)
     hashes_section = " ".join(commit_hashes)
 
-    prompt = f"""You are performing a daily code review of all commits by {author_name} in the UCSC Genome Browser kent repository from the last 24 hours.
+    prompt = f"""You are performing a daily code review of all commits by {author_name} in the UCSC Genome Browser kent repository from {window_label}.
 
 ## AUTHOR: {author_name}
 ## COMMITS TO REVIEW ({len(commits)} total)
 
 {commits_section}
 
 ## YOUR TASK
 
 Review ALL commits by this author. You have full tool access - USE IT.
 
 ### Step 1: Get the diffs for all commits
 
 For each commit, get the full diff:
 ```
 git --git-dir=/data/git/kent.git show <commit_hash>
 ```
 
 The commit hashes are:
 {hashes_section}
 
 ### Step 2: For EACH commit, check for:
 
 - Does the change correctly address the referenced issue(s)?
 - Security issues (buffer overflows, SQL injection, XSS, command injection)
 - Kent codebase patterns (freez vs freeMem, safef vs sprintf, sqlSafef)
 - Logic errors, off-by-one errors, null pointer risks
 - **IMPORTANT: For documentation/HTML/text changes, read the content word-by-word and check for:**
   - Typos (doubled words like "the the", wrong words like "of" vs "or")
   - Grammar errors
   - Unclosed HTML tags
   - Missing or incomplete sentences
 
 ### Step 3: Read files for context when needed
 
 ```
 git --git-dir=/data/git/kent.git show HEAD:src/path/to/file.c
 ```
 
 ### Step 4: Look for cross-commit patterns
 
 Since you're reviewing all commits by the same author, note:
 - Consistent good practices (or bad practices) across commits
 - How commits relate to each other
 - Whether earlier commits' issues are fixed in later commits
 
 ## OUTPUT FORMAT
 
 Provide your review as a **plain text email body** (NOT Textile, NOT Markdown). Use simple formatting that reads well in email:
 - Use CAPS or dashes for section headers
 - Use simple indentation for lists
 - No special markup syntax
 
 Format:
 
 ```
 DAILY CODE REVIEW - {author_name}
 Review Date: {datetime.now().strftime('%Y-%m-%d')}
 Commits Reviewed: {len(commits)}
 
 ========================================
 SUMMARY
 ========================================
 
 [Brief overview of what these commits do overall]
 
 Commit  | Description
 --------|--------------------------------------------
 [short_hash] | [brief description for each commit]
 
 ========================================
 DETAILED REVIEW
 ========================================
 
 COMMIT 1: [short_hash] - [Brief Title]
 Message: [commit message]
 Referenced Issues: [issues]
 Files Changed: [list files]
 
 Analysis:
 [Your detailed analysis]
 
 Issues Found:
 [List issues or "None"]
 
 Verdict: APPROVED / FEEDBACK
 [If FEEDBACK, explain what needs fixing]
 
 ----------------------------------------
 
 [Repeat for each commit]
 
 ========================================
 CROSS-COMMIT OBSERVATIONS (omit if only one commit or nothing notable)
 ========================================
 
 [Note any patterns, relationships, or overall code quality observations.
  Omit this entire section if there is only one commit or nothing substantive to say.]
 
 ========================================
 RISK ASSESSMENT (omit if all Low with no concerns)
 ========================================
 
 Security:   Low/Med/High - [explanation]
 Regression: Low/Med/High - [explanation]
 [Omit this entire section if everything is Low risk with no notable concerns.]
 
 ========================================
 OVERALL STATUS: APPROVED / FEEDBACK
 ========================================
 
 [Summary. If FEEDBACK, list all items that need attention.]
 
 ---
 Automated daily code review | {datetime.now().strftime('%Y-%m-%d')} | {len(commits)} commits
 ```
 
 IMPORTANT:
 - Be thorough - check every commit, read every diff
 - Give FEEDBACK only for real issues that need attention
 - Be constructive - this email goes directly to the author
 
 OUTPUT REQUIREMENTS:
 - Output the COMPLETE review in the plain text email format shown above
 - Start with "DAILY CODE REVIEW" - no preamble
 - Include ALL sections, but omit CROSS-COMMIT OBSERVATIONS and RISK ASSESSMENT if they would only contain boilerplate (e.g., "only one commit", "all low risk with no concerns")
 
 BEGIN YOUR REVIEW NOW. Use your tools to investigate thoroughly.
 """
     return prompt
 
 
 def get_gmail_service():
     """Get authenticated Gmail API service"""
     from google.oauth2.credentials import Credentials
     from google.auth.transport.requests import Request
     from googleapiclient.discovery import build as google_build
 
     creds = None
     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"""
     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, hours, log_dir):
+def send_alert_email(gmail_service, to_email, failures, window_label, log_dir):
     """Send a maintainer alert when one or more author reviews failed (e.g. an
     expired Claude CLI token). This is what keeps the daily cron from failing
     silently: a broken review is never sent to the author, so without this
     nobody would notice the tool had stopped working."""
     lines = [
         "The automated daily code review (codeReviewAi.py --daily) hit errors and",
         "did not produce valid reviews for one or more authors.",
         "",
         f"Review date:      {datetime.now().strftime('%Y-%m-%d %H:%M')}",
-        f"Look-back window: {hours} hours",
+        f"Review window:    {window_label}",
         f"Failed reviews:   {len(failures)}",
         "",
         "Details:",
     ]
     for name, err in failures:
         lines.append(f"  - {name}: {err}")
     lines += [
         "",
         "Most common cause: the 'claude' CLI OAuth token for the cron user has",
         "expired. Re-run 'claude' interactively as that user and /login, or mint a",
         "long-lived token with 'claude setup-token', then confirm with:",
         "  claude -p 'say ok'",
         "",
         f"Logs: {log_dir}/",
         "",
         "-- codeReviewAi.py automated alert",
     ]
     message = MIMEText("\n".join(lines))
     message['To'] = to_email
     message['From'] = 'gbauto@ucsc.edu'
     message['Subject'] = f"[ALERT] Daily Code Review FAILED - {datetime.now().strftime('%Y-%m-%d')}"
     encoded = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8')
     gmail_service.users().messages().send(
         userId='me',
         body={'raw': encoded}
     ).execute()
 
 
-def review_daily_author(author_name, commits, log_dir):
+def review_daily_author(author_name, commits, log_dir,
+                        window_label="the last 24 hours", file_suffix=None):
     """Review all commits by one author for the daily digest.
-    Temp files (prompts/responses) are written to log_dir and returned for cleanup."""
+    Temp files (prompts/responses) are written to log_dir and returned for cleanup.
+    file_suffix keys the temp filenames (defaults to today's date); pass an
+    explicit window so same-day backfill runs do not clobber each other."""
     print(f"\n{'='*60}")
     print(f"REVIEWING DAILY COMMITS: {author_name}")
     print(f"Commits: {len(commits)}")
     print(f"{'='*60}")
 
-    prompt = build_daily_review_prompt(author_name, commits)
+    prompt = build_daily_review_prompt(author_name, commits, window_label)
 
     # Save prompt to log_dir for debugging (cleaned up on success)
     safe_name = re.sub(r'[^a-zA-Z0-9]', '_', author_name)
-    date_str = datetime.now().strftime('%Y%m%d')
+    suffix = file_suffix or datetime.now().strftime('%Y%m%d')
     temp_files = []
 
-    prompt_file = os.path.join(log_dir, f".tmp_daily_prompt_{safe_name}_{date_str}.txt")
+    prompt_file = os.path.join(log_dir, f".tmp_daily_prompt_{safe_name}_{suffix}.txt")
     with open(prompt_file, 'w') as f:
         f.write(prompt)
     temp_files.append(prompt_file)
     print(f"  Prompt saved to: {prompt_file}")
     print(f"  Calling Claude CLI (this may take a few minutes)...")
 
     raw_response = call_claude_cli(prompt, timeout=600, validator=validate_daily_review_output)
     error = detect_cli_failure(raw_response, validate_daily_review_output)
 
     # Save whatever we got back for debugging, even on failure.
     if raw_response:
-        response_file = os.path.join(log_dir, f".tmp_daily_response_{safe_name}_{date_str}.txt")
+        response_file = os.path.join(log_dir, f".tmp_daily_response_{safe_name}_{suffix}.txt")
         with open(response_file, 'w') as f:
             f.write(raw_response)
         temp_files.append(response_file)
 
     if error:
         print(f"  WARNING: review failed - {error}")
         response = f"DAILY CODE REVIEW - {author_name}\n\nError: {error}\n"
     else:
         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):
+def run_daily_mode(hours, cc_address, dry_run, log_dir, alert_email=DEFAULT_ALERT_EMAIL,
+                   since=None, until=None):
     """Run daily review mode: get recent commits, review per author, email results.
+    By default covers the last N hours; pass since/until to review an explicit
+    window instead (e.g. to backfill a period the cron missed).
     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()
 
+    # Human- and filename-friendly descriptions of the review window.
+    if since:
+        window_label = f"the window {since} to {until}" if until else f"the window since {since}"
+        file_suffix = re.sub(r'[^0-9]', '', since) + ("-" + re.sub(r'[^0-9]', '', until) if until else "")
+    else:
+        window_label = f"the last {hours} hours"
+        file_suffix = datetime.now().strftime('%Y%m%d')
+
     print("=" * 60)
     print(f"DAILY CODE REVIEW MODE")
-    print(f"Looking back: {hours} hours")
+    print(f"Window: {window_label}")
     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)
+    print(f"\nPhase 1: Gathering commits from {window_label}...")
+    authors = get_commits_since(hours, since=since, until=until)
 
     if not authors:
         print("No commits found in the specified time window.")
         return True
 
     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():
         print(f"  {data['name']} <{email}>: {len(data['commits'])} commit(s)")
 
     # Phase 2: Review each author's commits
     print(f"\nPhase 2: Reviewing commits...")
     reviews = {}
     all_temp_files = []
     for author_email, data in authors.items():
-        review, temp_files, error = review_daily_author(data['name'], data['commits'], log_dir)
+        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'])
-        date_str = datetime.now().strftime('%Y%m%d')
-        filepath = os.path.join(log_dir, f"daily_review_{safe_name}_{date_str}.txt")
+        filepath = 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']]
 
     def verdict_of(data):
         if data['error']:
             return 'FAILED'
         return 'FEEDBACK' if 'OVERALL STATUS: FEEDBACK' in data['review'] else 'APPROVED'
 
     # Phase 3: Send emails (only for reviews with FEEDBACK; never for failures)
     print(f"\nPhase 3: Sending emails (FEEDBACK only)...")
     if dry_run:
         print("[DRY RUN] Emails not sent. Reviews saved locally:")
         for author_email, data in reviews.items():
             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 'OVERALL STATUS: FEEDBACK' not in data['review']:
                 print(f"  {data['name']}: APPROVED - skipping email")
                 continue
             print(f"  Emailing {data['name']} <{author_email}> (FEEDBACK)...")
             try:
                 send_review_email(gmail_service, author_email, data['name'], data['review'], cc=cc_address)
                 print(f"    SENT")
             except Exception as e:
                 print(f"    FAILED: {e}")
 
     # Phase 3b: Alert the maintainer if anything failed, so it does not fail silently
     if failures:
         print(f"\nPhase 3b: {len(failures)} review(s) FAILED - alerting maintainer...")
         for name, err in failures:
             print(f"  {name}: {err}")
         if dry_run:
             print(f"[DRY RUN] Alert email not sent (would go to {alert_email}).")
         elif alert_email:
             try:
                 gmail_service = get_gmail_service()
-                send_alert_email(gmail_service, alert_email, failures, hours, log_dir)
+                send_alert_email(gmail_service, alert_email, failures, window_label, log_dir)
                 print(f"  Alert sent to {alert_email}")
             except Exception as e:
                 print(f"  WARNING: failed to send alert email: {e}")
 
     # Clean up temp files
     for f in all_temp_files:
         try:
             os.remove(f)
         except OSError:
             pass
 
     # Summary
     print(f"\n{'='*60}")
     print("DAILY REVIEW COMPLETE")
     print(f"{'='*60}")
     print(f"Authors reviewed: {len(reviews)}")
     print(f"Total commits: {total_commits}")
     for author_email, data in reviews.items():
         print(f"  {data['name']}: {data['num_commits']} commits - {verdict_of(data)}")
 
     return not failures
 
 
 # =============================================================================
 # MAIN
 # =============================================================================
 
 def save_review(review, ticket_data=None, single_commit=None, standalone_commit=None):
     """Save review to local file"""
     if standalone_commit:
         # Standalone commit review (no ticket)
         filename = f"code_review_commit_{standalone_commit['short_hash']}.md"
     elif single_commit:
         # Single commit within a ticket
         filename = f"code_review_{ticket_data['ticket_id']}_{ticket_data['coder']}_{single_commit['short_hash']}.md"
     else:
         # Per-ticket review
         filename = f"code_review_{ticket_data['ticket_id']}_{ticket_data['coder']}_{ticket_data['version']}.md"
 
     filepath = os.path.join(OUTPUT_DIR, filename)
 
     with open(filepath, 'w') as f:
         f.write(review)
 
     print(f"  Saved: {filename}")
     return filepath
 
 def display_summary(results):
     """Display summary of all reviews"""
     print("\n" + "=" * 60)
     print("REVIEW SUMMARY")
     print("=" * 60)
 
     for ticket_id, data in results.items():
         print(f"\nTicket #{ticket_id}: {data['coder']}")
         print(f"  Commits: {data['num_commits']}")
         print(f"  Verdict: {data['verdict']}")
         print(f"  File: {data['file']}")
 
 def main():
     parser = argparse.ArgumentParser(
         description='Code Review Automation for UCSC Genome Browser',
         formatter_class=argparse.RawDescriptionHelpFormatter,
         epilog="""
 Examples:
   Review all open tickets (per-ticket mode):
     python3 codeReviewAi.py --dry-run
 
   Review a specific ticket:
     python3 codeReviewAi.py --ticket 36933 --dry-run
 
   Review a specific commit within a ticket:
     python3 codeReviewAi.py --ticket 36933 --commit c7c977ef --dry-run
 
   Review any commit directly (no ticket needed):
     python3 codeReviewAi.py --commit c7c977ef --dry-run
 
   Daily review (cron mode) - review last 24h of commits, email authors:
     python3 codeReviewAi.py --daily --dry-run
     python3 codeReviewAi.py --daily --hours 24 --cc browser-code-reviews-group@ucsc.edu
+
+  Backfill an explicit window (e.g. one week the cron missed), email authors:
+    python3 codeReviewAi.py --daily --since 2026-06-20 --until 2026-06-27 --dry-run
         """
     )
     parser.add_argument('--dry-run', action='store_true',
                         help='Generate reviews but do not post to Redmine / send emails')
     parser.add_argument('--ticket', type=int,
                         help='Review only this ticket ID')
     parser.add_argument('--commit', type=str,
                         help='Review a specific commit (can be used with or without --ticket)')
     parser.add_argument('--daily', action='store_true',
                         help='Daily mode: review recent commits by all authors and email results')
     parser.add_argument('--hours', type=int, default=24,
                         help='Hours to look back for --daily mode (default: 24)')
+    parser.add_argument('--since', type=str,
+                        help='Start of an explicit review window for --daily mode, '
+                             'any date git understands (e.g. 2026-06-20). Overrides '
+                             '--hours. Use for backfilling a missed period.')
+    parser.add_argument('--until', type=str,
+                        help='End of the --since window (e.g. 2026-06-27). '
+                             'Optional; defaults to now.')
     parser.add_argument('--cc', type=str, default=DEFAULT_CC,
                         help=f'CC address for --daily emails (default: {DEFAULT_CC})')
     parser.add_argument('--alert-email', type=str, default=DEFAULT_ALERT_EMAIL,
                         help=f'Address to alert if a --daily review fails, e.g. an '
                              f'expired auth token (default: {DEFAULT_ALERT_EMAIL})')
     parser.add_argument('--log-dir', type=str,
                         default=os.path.expanduser('~/codeReviewLogs'),
                         help='Directory for daily review logs and output (default: ~/codeReviewLogs)')
     args = parser.parse_args()
 
     # =================================================================
     # DAILY MODE (--daily)
     # =================================================================
     if args.daily:
         ok = run_daily_mode(args.hours, args.cc, args.dry_run, args.log_dir,
-                            alert_email=args.alert_email)
+                            alert_email=args.alert_email,
+                            since=args.since, until=args.until)
         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)
 
         print(f"Commit: {commit['short_hash']}")
         print(f"Author: {commit['author']}")
         print(f"Message: {commit['message']}")
 
         # Fetch any referenced issues
         referenced_issues = {}
         for ref_id in commit['referenced_issues']:
             print(f"Fetching referenced issue #{ref_id}...")
             referenced_issues[ref_id] = get_referenced_issue(ref_id, redmine_key)
 
         # Review the commit
         review = review_standalone_commit(commit, referenced_issues)
 
         # Save
         filepath = save_review(review, standalone_commit=commit)
 
         # Summary
         verdict = 'FEEDBACK' if 'Verdict: FEEDBACK' in review else 'APPROVED'
         print(f"\n" + "=" * 60)
         print("REVIEW SUMMARY")
         print("=" * 60)
         print(f"\nCommit: {commit['short_hash']}")
         print(f"  Author: {commit['author']}")
         print(f"  Verdict: {verdict}")
         print(f"  File: {filepath}")
 
         if args.dry_run:
             print("\n[DRY RUN] Review saved locally.")
         return
 
     # =================================================================
     # TICKET-BASED MODES (per-ticket or single commit within ticket)
     # =================================================================
 
     # Determine which tickets to process
     if args.ticket:
         ticket_ids = [args.ticket]
         print(f"Processing ticket: #{args.ticket}")
     else:
         print("=" * 60)
         print("PHASE 1: Finding open code review tickets")
         print("=" * 60)
         open_tickets = get_open_cr_tickets(redmine_key)
         ticket_ids = [t['id'] for t in open_tickets]
         print(f"Found {len(ticket_ids)} open ticket(s)")
 
     if not ticket_ids:
         print("\nNo tickets to review.")
         return
 
     # Process each ticket
     results = {}
     reviews = {}
 
     mode = "single-commit" if args.commit else "per-ticket"
     print(f"\n" + "=" * 60)
     print(f"PHASE 2: Code review ({mode} mode)")
     print("=" * 60)
 
     for ticket_id in ticket_ids:
         ticket_data = gather_ticket_data(ticket_id, redmine_key)
 
         if not ticket_data:
             print(f"  Skipping ticket #{ticket_id} - could not gather data")
             continue
 
         # Single commit mode
         if args.commit:
             matching_commits = [
                 c for c in ticket_data['commits']
                 if c['hash'].startswith(args.commit)
             ]
             if not matching_commits:
                 print(f"  Commit {args.commit} not found in ticket #{ticket_id}")
                 continue
 
             commit = matching_commits[0]
             review = review_single_commit(commit, ticket_data)
             reviews[ticket_id] = review
             filepath = save_review(review, ticket_data, single_commit=commit)
             num_commits = 1
 
         # Per-ticket mode (default)
         else:
             review = review_ticket_per_ticket(ticket_data)
             reviews[ticket_id] = review
             filepath = save_review(review, ticket_data)
             num_commits = len(ticket_data['commits'])
 
         # Determine verdict for summary
         verdict = 'FEEDBACK' if 'Status: FEEDBACK' in review else 'APPROVED'
 
         results[ticket_id] = {
             'coder': ticket_data['coder'],
             'num_commits': num_commits,
             'verdict': verdict,
             'file': filepath
         }
 
     if not results:
         print("\nNo reviews generated.")
         return
 
     # Display summary
     display_summary(results)
 
     if args.dry_run:
         print("\n[DRY RUN] Reviews saved locally but not posted to Redmine.")
         return
 
     # Confirm and post
     print("\n" + "=" * 60)
     print("PHASE 3: Confirmation")
     print("=" * 60)
 
     ticket_list = ', '.join(f'#{t}' for t in results.keys())
     print(f"\nReady to post reviews for tickets: {ticket_list}")
 
     response = input("\nPost reviews to Redmine? Enter ticket numbers separated by commas, 'all', or 'none': ").strip().lower()
 
     if response == 'none':
         print("No reviews posted.")
         return
     elif response == 'all':
         to_post = list(results.keys())
     else:
         try:
             to_post = [int(t.strip().replace('#', '')) for t in response.split(',')]
         except ValueError:
             print("Invalid input. No reviews posted.")
             return
 
     # Post reviews
     print("\nPosting reviews...")
     for tid in to_post:
         if tid in reviews:
             print(f"  Posting to ticket #{tid}...")
             success = redmine_put(
                 f'/issues/{tid}.json',
                 redmine_key,
                 {'issue': {'notes': reviews[tid]}}
             )
             print(f"    {'SUCCESS' if success else 'FAILED'}")
         else:
             print(f"  No review found for ticket #{tid}")
 
     print("\n" + "=" * 60)
     print("COMPLETE")
     print("=" * 60)
 
 if __name__ == '__main__':
     main()