import click import re import requests import subprocess import os from shutil import rmtree from bs4 import BeautifulSoup worktree_root = "/home/hubert/src/18.0-proj" bundles = { "17.0": "17-0-192736", "18.0": "18-0-320432", } def _get_bundle_last_page(version: str) -> int: bundle = bundles[version] url = f"https://runbot.odoo.com/runbot/bundle/{bundle}/page/1000" res = requests.get(url) assert res.status_code == 200 soup = BeautifulSoup(res.text, "html.parser") text = soup.select_one(".page-item.active").get_text(strip=True) return int(text) def _commits_from_batch(id: int) -> dict: url = f"https://runbot.odoo.com/runbot/batch/{id}" res = requests.get(url) assert res.status_code == 200 soup = BeautifulSoup(res.text, "html.parser") commit_nodes = soup.select("a[title='View Commit on Github']") commits = dict() assert len(commit_nodes) > 0 gh_re = re.compile(r"https://github.com/odoo/([\w-]+)/commit/(\w+)") for commit in commit_nodes: match = gh_re.search(commit["href"]) repo = match.group(1) commit = match.group(2) commits[repo] = commit assert "odoo" in commits assert "enterprise" in commits return commits def _checkout_commits(commits: dict): subprocess.check_call( ["git", "-C", f"{worktree_root}/odoo", "checkout", commits["odoo"]] ) subprocess.check_call( ["git", "-C", f"{worktree_root}/enterprise", "checkout", commits["enterprise"]] ) def _run_odoo(test_tags: str) -> bool: venv_path = f"{worktree_root}/venv-bisect" rmtree(venv_path, ignore_errors=True) subprocess.check_call(["uv", "venv", "-p", "3.12", venv_path]) env = os.environ.copy() env["VIRTUAL_ENV"] = venv_path subprocess.check_call( ["uv", "pip", "install", "-r", f"{worktree_root}/odoo/requirements.txt"], env=env, ) res = subprocess.run( ["odoo", "--no-patch", "--drop", "--test-tags", test_tags], cwd=worktree_root, env=env, ) return res.returncode == 0 def _get_batches(bundle, page=1) -> list[int]: limit = 3000 url = f"https://runbot.odoo.com/runbot/bundle/{bundle}?limit={limit}" res = requests.get(url) assert res.status_code == 200 soup = BeautifulSoup(res.text, "html.parser") batch_nodes = soup.select("div.batch_row") batch_url_re = re.compile(r"^/runbot/batch/(\d+)$") ids = [] for node in batch_nodes: link = node.select_one("a[title='View Batch']")["href"] match = batch_url_re.search(link) ids.append(match.group(1)) return ids def _find_first_failing_commit(batches: list[int], test_failed): low = 0 high = len(batches) while low < high: mid = (low + high) // 2 if test_failed(batches[mid]): high = mid else: low = mid + 1 if low < len(batches): return batches[low] raise ValueError("404") def _bisect(bundle: str, test_tags: str): batches = list(reversed(_get_batches(bundle))) def test_failed(batch: int): commits = _commits_from_batch(batch) _checkout_commits(commits) return not _run_odoo(test_tags) suspect = _find_first_failing_commit(batches, test_failed) if suspect: commits = _commits_from_batch(suspect) return commits @click.command() @click.option("--version", default="18.0", type=click.Choice(bundles.keys())) @click.option("--test-tags") def main(version, test_tags): print(_bisect(bundles["18.0"], test_tags)) if __name__ == "__main__": main()