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()}") # =================================================================