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
@@ -1064,41 +1064,43 @@
         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]
@@ -1110,45 +1112,45 @@
             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>
@@ -1294,164 +1296,179 @@
     """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)
@@ -1474,31 +1491,31 @@
                 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}")
@@ -1555,58 +1572,69 @@
   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()}")
 
     # =================================================================