c17beac2e24b5e0cd8b6e4e828265d5a5df2bbd5 lrnassar Mon Mar 16 08:54:14 2026 -0700 Adding new script to perform CRs. It is used in -daily mode to run overnight CRs, or can also be pointed towards specific commits, or towrads all build tickets. Refs #36890 diff --git src/utils/codeReviewAi.py src/utils/codeReviewAi.py new file mode 100755 index 00000000000..19fba5ade69 --- /dev/null +++ src/utils/codeReviewAi.py @@ -0,0 +1,1637 @@ +#!/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 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 = "/hive/users/lrnassar/codeReview" +MLQ_CONF_PATH = os.path.expanduser("~/.hg.conf") +DEFAULT_CC = "browser-code-reviews-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 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" + +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])', '@', 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] + '@' + 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') + + # 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 + ) + 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""" + + 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. + +## 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 review_daily_author(author_name, commits, log_dir): + """Review all commits by one author for the daily digest. + Temp files (prompts/responses) are written to log_dir and returned for cleanup.""" + 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) + + # 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') + temp_files = [] + + prompt_file = os.path.join(log_dir, f".tmp_daily_prompt_{safe_name}_{date_str}.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)...") + + response = call_claude_cli(prompt, timeout=600, validator=validate_daily_review_output) + + if response: + response_file = os.path.join(log_dir, f".tmp_daily_response_{safe_name}_{date_str}.txt") + with open(response_file, 'w') as f: + f.write(response) + temp_files.append(response_file) + # 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") + else: + print(f" WARNING: No response received") + response = f"DAILY CODE REVIEW - {author_name}\n\nError: Review generation failed - no response from Claude CLI.\n" + + return response, temp_files + + +def run_daily_mode(hours, cc_address, dry_run, log_dir): + """Run daily review mode: get recent commits, review per author, email results""" + os.makedirs(log_dir, exist_ok=True) + + print("=" * 60) + print(f"DAILY CODE REVIEW MODE") + print(f"Looking back: {hours} hours") + print(f"CC: {cc_address or 'None'}") + print(f"Log dir: {log_dir}") + print(f"Dry run: {dry_run}") + print("=" * 60) + + # Phase 1: Gather commits + print(f"\nPhase 1: Gathering commits from the last {hours} hours...") + authors = get_commits_since(hours) + + if not authors: + print("No commits found in the specified time window.") + return + + total_commits = sum(len(a['commits']) for a in authors.values()) + print(f"Found {total_commits} commit(s) from {len(authors)} author(s):") + for email, data in authors.items(): + 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 = review_daily_author(data['name'], data['commits'], log_dir) + all_temp_files.extend(temp_files) + reviews[author_email] = { + 'name': data['name'], + 'email': author_email, + 'review': review, + 'num_commits': len(data['commits']), + } + + # 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") + with open(filepath, 'w') as f: + f.write(review) + reviews[author_email]['file'] = filepath + print(f" Saved: {filepath}") + + # Phase 3: Send emails (only for reviews with FEEDBACK) + 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(): + verdict = 'FEEDBACK' if 'OVERALL STATUS: FEEDBACK' in data['review'] else 'APPROVED' + print(f" {data['name']} <{author_email}>: {verdict} - {data['file']}") + else: + gmail_service = get_gmail_service() + for author_email, data in reviews.items(): + has_feedback = 'OVERALL STATUS: FEEDBACK' in data['review'] + if not has_feedback: + 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}") + + # 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(): + verdict = 'FEEDBACK' if 'OVERALL STATUS: FEEDBACK' in data['review'] else 'APPROVED' + print(f" {data['name']}: {data['num_commits']} commits - {verdict}") + + +# ============================================================================= +# 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 + """ + ) + 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('--cc', type=str, default=DEFAULT_CC, + help=f'CC address for --daily emails (default: {DEFAULT_CC})') + 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: + run_daily_mode(args.hours, args.cc, args.dry_run, args.log_dir) + return + + # 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) + + # ================================================================= + # 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()