diff --git a/aocsync.py b/aocsync.py index 8477f5f..85c8fc2 100755 --- a/aocsync.py +++ b/aocsync.py @@ -37,6 +37,8 @@ class PerformanceResult: part: int time_ns: int # Runner time in nanoseconds generator_time_ns: int = 0 # Generator time in nanoseconds (optional) + git_rev: str = "" # Git revision (short hash) + repo_url: str = "" # Repository URL timestamp: str = "" @@ -107,17 +109,24 @@ class Database: part INTEGER NOT NULL, time_ns INTEGER NOT NULL, generator_time_ns INTEGER NOT NULL DEFAULT 0, + git_rev TEXT NOT NULL DEFAULT '', + repo_url TEXT NOT NULL DEFAULT '', timestamp TEXT NOT NULL, - UNIQUE(user, year, day, part, timestamp) + UNIQUE(user, year, day, part, timestamp, git_rev) ) ''') - # Add generator_time_ns column if it doesn't exist (for existing databases) - try: - cursor.execute('ALTER TABLE results ADD COLUMN generator_time_ns INTEGER NOT NULL DEFAULT 0') - except sqlite3.OperationalError: - # Column already exists - pass + # Add new columns if they don't exist (for existing databases) + for column, col_type in [ + ('generator_time_ns', 'INTEGER NOT NULL DEFAULT 0'), + ('git_rev', 'TEXT NOT NULL DEFAULT \'\''), + ('repo_url', 'TEXT NOT NULL DEFAULT \'\'') + ]: + try: + cursor.execute(f'ALTER TABLE results ADD COLUMN {column} {col_type}') + except sqlite3.OperationalError: + # Column already exists + pass cursor.execute(''' CREATE INDEX IF NOT EXISTS idx_user_year_day_part @@ -135,10 +144,11 @@ class Database: try: cursor.execute(''' INSERT OR REPLACE INTO results - (user, year, day, part, time_ns, generator_time_ns, timestamp) - VALUES (?, ?, ?, ?, ?, ?, ?) + (user, year, day, part, time_ns, generator_time_ns, git_rev, repo_url, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', (result.user, result.year, result.day, result.part, - result.time_ns, result.generator_time_ns, result.timestamp)) + result.time_ns, result.generator_time_ns, result.git_rev, + result.repo_url, result.timestamp)) conn.commit() except sqlite3.IntegrityError: # Already exists, skip @@ -156,7 +166,7 @@ class Database: cursor = conn.cursor() query = ''' - SELECT user, year, day, part, time_ns, generator_time_ns, timestamp + SELECT user, year, day, part, time_ns, generator_time_ns, git_rev, repo_url, timestamp FROM results r1 WHERE timestamp = ( SELECT MAX(timestamp) @@ -198,7 +208,35 @@ class Database: 'part': row[3], 'time_ns': row[4], 'generator_time_ns': row[5] if len(row) > 5 else 0, - 'timestamp': row[6] if len(row) > 6 else row[5] + 'git_rev': row[6] if len(row) > 6 else '', + 'repo_url': row[7] if len(row) > 7 else '', + 'timestamp': row[8] if len(row) > 8 else (row[6] if len(row) > 6 else '') + } + for row in rows + ] + + def get_historical_results(self, user: str, year: int, day: int, part: int) -> List[Dict]: + """Get historical results for a specific user/day/part""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + + cursor.execute(''' + SELECT time_ns, generator_time_ns, git_rev, repo_url, timestamp + FROM results + WHERE user = ? AND year = ? AND day = ? AND part = ? + ORDER BY timestamp DESC + ''', (user, year, day, part)) + + rows = cursor.fetchall() + conn.close() + + return [ + { + 'time_ns': row[0], + 'generator_time_ns': row[1], + 'git_rev': row[2], + 'repo_url': row[3], + 'timestamp': row[4] } for row in rows ] @@ -388,20 +426,41 @@ class CargoAOCRunner: return sorted(years) if years else [] + @staticmethod + def get_git_rev(repo_path: Path) -> str: + """Get short git revision hash""" + try: + result = subprocess.run( + ['git', 'rev-parse', '--short', 'HEAD'], + cwd=repo_path, + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception as e: + logger.warning(f"Could not get git rev for {repo_path}: {e}") + return "" + @staticmethod def run_benchmarks(repo_path: Path, year: int, user: str = "unknown", - is_multi_year: bool = False) -> List[PerformanceResult]: + repo_url: str = "", is_multi_year: bool = False) -> List[PerformanceResult]: """Run cargo aoc benchmarks and parse results Args: repo_path: Path to the repository root (for single repos) or year directory (for multi-year repos) year: The year to benchmark user: User name for the results + repo_url: Repository URL for linking is_multi_year: True if this is a multi-year repo (repo_path is already the year directory) """ results = [] repo_path = Path(repo_path) + # Get git revision + git_rev = CargoAOCRunner.get_git_rev(repo_path) + # Determine the working directory if is_multi_year: # For multi-year repos, repo_path is already the year directory @@ -413,6 +472,8 @@ class CargoAOCRunner: if year_dir.exists() and year_dir.is_dir(): work_dir = year_dir logger.info(f"Using year directory: {work_dir}") + # Get git rev from repo root, not year directory + git_rev = CargoAOCRunner.get_git_rev(repo_path) if not (work_dir / 'Cargo.toml').exists(): logger.warning(f"No Cargo.toml found in {work_dir}") @@ -445,7 +506,7 @@ class CargoAOCRunner: # Parse output for runtime information day_results = CargoAOCRunner._parse_runtime_output( - result.stdout, result.stderr, day, year, user + result.stdout, result.stderr, day, year, user, git_rev, repo_url ) if day_results: logger.info(f"Parsed {len(day_results)} runtime result(s) for {user} year {year} day {day}") @@ -464,7 +525,7 @@ class CargoAOCRunner: @staticmethod def _parse_runtime_output(stdout: str, stderr: str, day: int, year: int, - user: str) -> List[PerformanceResult]: + user: str, git_rev: str = "", repo_url: str = "") -> List[PerformanceResult]: """Parse cargo-aoc runtime output cargo aoc typically outputs timing information like: @@ -537,6 +598,8 @@ class CargoAOCRunner: part=current_part, time_ns=runner_time_ns, generator_time_ns=generator_time_ns, + git_rev=git_rev, + repo_url=repo_url, timestamp=timestamp )) @@ -579,6 +642,8 @@ class CargoAOCRunner: part=current_part, time_ns=runner_time_ns, generator_time_ns=generator_time_ns, + git_rev=git_rev, + repo_url=repo_url, timestamp=timestamp )) @@ -744,6 +809,8 @@ class HTMLGenerator: user = result['user'] runner_time_ns = result['time_ns'] generator_time_ns = result.get('generator_time_ns', 0) + git_rev = result.get('git_rev', '') + repo_url = result.get('repo_url', '') # Only store if runner_time_ns > 0 (valid result) # Store total time (generator + runner) for comparison @@ -752,10 +819,12 @@ class HTMLGenerator: data[year][day][part][user] = { 'total': total_time_ns, 'runner': runner_time_ns, - 'generator': generator_time_ns + 'generator': generator_time_ns, + 'git_rev': git_rev, + 'repo_url': repo_url } - html = self._generate_html(data, years, users) + html = self._generate_html(data, years, users, db) output_file = self.output_dir / 'index.html' with open(output_file, 'w') as f: @@ -763,7 +832,7 @@ class HTMLGenerator: logger.info(f"Generated HTML report: {output_file}") - def _generate_html(self, data: dict, years: List[int], users: List[str]) -> str: + def _generate_html(self, data: dict, years: List[int], users: List[str], db: Database) -> str: """Generate HTML content""" # Sort years descending (most recent first) sorted_years = sorted(years, reverse=True) @@ -932,6 +1001,15 @@ class HTMLGenerator: font-size: 0.9em; }} + a {{ + color: #667eea; + text-decoration: none; + }} + + a:hover {{ + text-decoration: underline; + }} + .fastest {{ background: #d4edda !important; color: #155724; @@ -947,6 +1025,40 @@ class HTMLGenerator: font-style: italic; }} + .history-link {{ + font-size: 0.75em; + color: #666; + margin-left: 5px; + cursor: pointer; + }} + + .history-link:hover {{ + color: #667eea; + text-decoration: underline; + }} + + .history-data {{ + display: none; + margin-top: 5px; + padding: 5px; + background: #f0f0f0; + border-radius: 4px; + font-size: 0.8em; + }} + + .history-data.show {{ + display: block; + }} + + .history-item {{ + padding: 2px 0; + border-bottom: 1px solid #ddd; + }} + + .history-item:last-child {{ + border-bottom: none; + }} + .summary {{ margin-top: 30px; padding: 15px; @@ -987,6 +1099,15 @@ class HTMLGenerator: color: #333; }} +