Add tool to automate bazel/robolectric.bzl updates (#143)

* Add tool to automate bazel/robolectric.bzl updates

* Formatting

* Add tests

* Forgot

* Formatting
diff --git a/BUILD.bazel b/BUILD.bazel
new file mode 100644
index 0000000..a20e6de
--- /dev/null
+++ b/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@rules_python//python:py_binary.bzl", "py_binary")
+load("@rules_python//python:py_library.bzl", "py_library")
+
+py_library(
+    name = "update_versions_lib",
+    srcs = ["update_versions.py"],
+    visibility = ["//tests:__pkg__"],
+)
+
+py_binary(
+    name = "update-versions",
+    srcs = ["update_versions.py"],
+    main = "update_versions.py",
+)
diff --git a/README.md b/README.md
index 6be7378..15107b2 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,18 @@
 )
 ```
 
+## Updating Android Versions
+
+`update-versions.py` walks `org.robolectric:android-all-instrumented` on Maven
+Central and rewrites `bazel/robolectric.bzl` in place: it bumps the version and
+sha256 of any Android API group that has a newer release, and inserts new
+entries for any numeric API newer than the highest one currently listed.
+
+```console
+./update-versions.py            # apply updates
+./update-versions.py --dry-run  # preview without writing
+```
+
 ## Publishing Releases
 
 A new release can be published by just pushing a tag.
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index d0507af..c543ed0 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -1,4 +1,5 @@
 load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("@rules_python//python:py_test.bzl", "py_test")
 
 build_test(
     name = "test",
@@ -9,3 +10,9 @@
         "//bazel:gen-deps",
     ],
 )
+
+py_test(
+    name = "update_versions_test",
+    srcs = ["update_versions_test.py"],
+    deps = ["//:update_versions_lib"],
+)
diff --git a/tests/update_versions_test.py b/tests/update_versions_test.py
new file mode 100644
index 0000000..eab5588
--- /dev/null
+++ b/tests/update_versions_test.py
@@ -0,0 +1,242 @@
+"""Tests for update-versions.py."""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+import update_versions
+
+
+class ParseTest(unittest.TestCase):
+    def test_modern_build_number(self):
+        self.assertEqual(
+            update_versions.parse("16-robolectric-13921718-i7"),
+            ("16", (7, (1, 13921718), 0)),
+        )
+
+    def test_rev_suffix(self):
+        self.assertEqual(
+            update_versions.parse("9-robolectric-4913185-2-i7"),
+            ("9", (7, (1, 4913185), 2)),
+        )
+
+    def test_legacy_r_build(self):
+        self.assertEqual(
+            update_versions.parse("8.0.0_r4-robolectric-r1-i7"),
+            ("8.0.0_r4", (7, (0, 1), 0)),
+        )
+
+    def test_decimal_api(self):
+        api, _ = update_versions.parse("12.1-robolectric-8229987-i7")
+        self.assertEqual(api, "12.1")
+
+    def test_malformed_returns_none(self):
+        self.assertIsNone(update_versions.parse("not-a-version"))
+        self.assertIsNone(update_versions.parse("16-foo-1-i1"))
+        self.assertIsNone(update_versions.parse("16-robolectric-123"))
+
+
+class NumericApiKeyTest(unittest.TestCase):
+    def test_integer(self):
+        self.assertEqual(update_versions.numeric_api_key("16"), (16,))
+
+    def test_decimal(self):
+        self.assertEqual(update_versions.numeric_api_key("12.1"), (12, 1))
+
+    def test_three_part(self):
+        self.assertEqual(update_versions.numeric_api_key("8.1.0"), (8, 1, 0))
+
+    def test_legacy_r_suffixed(self):
+        self.assertIsNone(update_versions.numeric_api_key("8.0.0_r4"))
+        self.assertIsNone(update_versions.numeric_api_key("5.0.2_r3"))
+        self.assertIsNone(update_versions.numeric_api_key("7.1.0_r7"))
+
+    def test_ordering(self):
+        labels = ["17", "16", "12.1", "12", "8.1.0", "8"]
+        keys = [update_versions.numeric_api_key(a) for a in labels]
+        self.assertEqual(keys, sorted(keys, reverse=True))
+
+
+class LatestPerApiTest(unittest.TestCase):
+    def test_picks_highest_instrumentation(self):
+        versions = [
+            "16-robolectric-13921718-i5",
+            "16-robolectric-13921718-i7",
+            "16-robolectric-13921718-i6",
+        ]
+        self.assertEqual(
+            update_versions.latest_per_api(versions),
+            {"16": "16-robolectric-13921718-i7"},
+        )
+
+    def test_picks_highest_build_within_same_instrumentation(self):
+        versions = [
+            "15-robolectric-12543294-i7",
+            "15-robolectric-13954326-i7",
+            "15-robolectric-12714715-i7",
+        ]
+        self.assertEqual(
+            update_versions.latest_per_api(versions),
+            {"15": "15-robolectric-13954326-i7"},
+        )
+
+    def test_groups_by_api(self):
+        versions = [
+            "16-robolectric-100-i7",
+            "15-robolectric-200-i7",
+            "16-robolectric-300-i7",
+        ]
+        self.assertEqual(
+            update_versions.latest_per_api(versions),
+            {
+                "16": "16-robolectric-300-i7",
+                "15": "15-robolectric-200-i7",
+            },
+        )
+
+    def test_ignores_unparseable(self):
+        self.assertEqual(update_versions.latest_per_api(["nope"]), {})
+
+
+_BZL_FIXTURE = """\
+DEFAULT_AVAILABLE_VERSIONS = [
+    robolectric_version(
+        version = "15-robolectric-12543294-i7",
+        sha256 = "deadbeef00000000000000000000000000000000000000000000000000000000",
+    ),
+    robolectric_version(
+        version = "5.0.2_r3-robolectric-r0-i7",
+        sha256 = "1111111111111111111111111111111111111111111111111111111111111111",
+    ),
+]
+"""
+
+_METADATA_FIXTURE = b"""<?xml version="1.0" encoding="UTF-8"?>
+<metadata>
+  <versioning>
+    <versions>
+      <version>15-robolectric-12543294-i7</version>
+      <version>15-robolectric-13954326-i7</version>
+      <version>16-robolectric-13921718-i7</version>
+      <version>5.0.2_r3-robolectric-r0-i7</version>
+      <version>4.4_r1-robolectric-r2-i7</version>
+    </versions>
+  </versioning>
+</metadata>
+"""
+
+_FAKE_SHAS = {
+    "15-robolectric-13954326-i7": "a" * 64,
+    "16-robolectric-13921718-i7": "b" * 64,
+}
+
+
+def _fake_fetch(url: str) -> bytes:
+    if url == update_versions.METADATA_URL:
+        return _METADATA_FIXTURE
+    for version, sha in _FAKE_SHAS.items():
+        suffix = f"/{version}/android-all-instrumented-{version}.jar.sha256"
+        if url.endswith(suffix):
+            return f"{sha}  android-all-instrumented-{version}.jar\n".encode("ascii")
+    raise AssertionError(f"Unexpected fetch URL: {url}")
+
+
+class EndToEndTest(unittest.TestCase):
+    def _run_main(self, bzl_text: str) -> tuple[int, str]:
+        with tempfile.NamedTemporaryFile("w", suffix=".bzl", delete=False) as f:
+            f.write(bzl_text)
+            bzl_path = Path(f.name)
+        try:
+            with (
+                mock.patch.object(update_versions, "fetch", side_effect=_fake_fetch),
+                mock.patch.object(
+                    sys, "argv", ["update-versions.py", "--bzl", str(bzl_path)]
+                ),
+            ):
+                rc = update_versions.main()
+            return rc, bzl_path.read_text()
+        finally:
+            bzl_path.unlink()
+
+    def test_updates_existing_and_inserts_new(self):
+        rc, result = self._run_main(_BZL_FIXTURE)
+        self.assertEqual(rc, 0)
+
+        # API 16 inserted at the top with the correct sha and indentation.
+        self.assertIn(
+            "    robolectric_version(\n"
+            '        version = "16-robolectric-13921718-i7",\n'
+            f'        sha256 = "{"b" * 64}",\n'
+            "    ),\n",
+            result,
+        )
+        # API 15 rewritten in place with the new build + sha.
+        self.assertIn(
+            "    robolectric_version(\n"
+            '        version = "15-robolectric-13954326-i7",\n'
+            f'        sha256 = "{"a" * 64}",\n'
+            "    ),\n",
+            result,
+        )
+        # Legacy 5.0.2_r3 untouched.
+        self.assertIn(
+            "    robolectric_version(\n"
+            '        version = "5.0.2_r3-robolectric-r0-i7",\n'
+            '        sha256 = "1111111111111111111111111111111111111111111111111111111111111111",\n'
+            "    ),\n",
+            result,
+        )
+        # Stale 15 build is gone.
+        self.assertNotIn("12543294", result)
+        # 4.4_r1 was on Maven but legacy-r-suffixed, so it must NOT be added.
+        self.assertNotIn("4.4_r1", result)
+        # New entry comes before the existing first entry.
+        self.assertLess(result.index("16-robolectric"), result.index("15-robolectric"))
+
+    def test_no_op_when_already_current(self):
+        already_current = (
+            "DEFAULT_AVAILABLE_VERSIONS = [\n"
+            "    robolectric_version(\n"
+            '        version = "16-robolectric-13921718-i7",\n'
+            f'        sha256 = "{"b" * 64}",\n'
+            "    ),\n"
+            "    robolectric_version(\n"
+            '        version = "15-robolectric-13954326-i7",\n'
+            f'        sha256 = "{"a" * 64}",\n'
+            "    ),\n"
+            "    robolectric_version(\n"
+            '        version = "5.0.2_r3-robolectric-r0-i7",\n'
+            '        sha256 = "1111111111111111111111111111111111111111111111111111111111111111",\n'
+            "    ),\n"
+            "]\n"
+        )
+        rc, result = self._run_main(already_current)
+        self.assertEqual(rc, 0)
+        self.assertEqual(result, already_current)
+
+    def test_dry_run_does_not_write(self):
+        with tempfile.NamedTemporaryFile("w", suffix=".bzl", delete=False) as f:
+            f.write(_BZL_FIXTURE)
+            bzl_path = Path(f.name)
+        try:
+            with (
+                mock.patch.object(update_versions, "fetch", side_effect=_fake_fetch),
+                mock.patch.object(
+                    sys,
+                    "argv",
+                    ["update-versions.py", "--bzl", str(bzl_path), "--dry-run"],
+                ),
+            ):
+                rc = update_versions.main()
+            self.assertEqual(rc, 0)
+            self.assertEqual(bzl_path.read_text(), _BZL_FIXTURE)
+        finally:
+            bzl_path.unlink()
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/update_versions.py b/update_versions.py
new file mode 100755
index 0000000..894c598
--- /dev/null
+++ b/update_versions.py
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+"""Update DEFAULT_AVAILABLE_VERSIONS in bazel/robolectric.bzl.
+
+Walks the Maven Central artifact
+    org.robolectric:android-all-instrumented
+and updates bazel/robolectric.bzl with both:
+
+  1. New releases of Android APIs already in the list — the version and sha256
+     are rewritten in place (highest instrumentation version, then highest
+     robolectric build number).
+  2. Entirely new Android APIs (numeric ones newer than the highest currently
+     in the list) — a new robolectric_version(...) entry is inserted at the
+     top with its sha256.
+
+Legacy r-suffixed APIs (e.g. 4.4_r1) that are not already present are never
+auto-added; removal is treated as intentional.
+
+Usage:
+    bazel run //:update-versions -- [--bzl PATH] [--dry-run]
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+import urllib.request
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+MAVEN_BASE = "https://repo1.maven.org/maven2/org/robolectric/android-all-instrumented"
+METADATA_URL = f"{MAVEN_BASE}/maven-metadata.xml"
+
+# Matches a single robolectric_version(...) block, capturing the version and sha256.
+ENTRY_RE = re.compile(
+    r'(?P<head>robolectric_version\(\s*\n\s*version\s*=\s*")'
+    r'(?P<version>[^"]+)'
+    r'(?P<mid>"\s*,\s*\n\s*sha256\s*=\s*")'
+    r"(?P<sha>[0-9a-fA-F]+)"
+    r'(?P<tail>")'
+)
+
+# Parses e.g. "16-robolectric-13921718-i7" or "9-robolectric-4913185-2-i7".
+VERSION_RE = re.compile(
+    r"^(?P<api>.+?)-robolectric-(?P<build>[^-]+)(?:-(?P<rev>\d+))?-i(?P<inst>\d+)$"
+)
+
+
+def fetch(url: str) -> bytes:
+    req = urllib.request.Request(url, headers={"User-Agent": "robolectric-bzl-updater"})
+    with urllib.request.urlopen(req, timeout=30) as resp:
+        return resp.read()
+
+
+def all_versions() -> list[str]:
+    tree = ET.fromstring(fetch(METADATA_URL))
+    return [v.text for v in tree.findall("./versioning/versions/version") if v.text]
+
+
+def parse(version: str):
+    m = VERSION_RE.match(version)
+    if not m:
+        return None
+    api = m.group("api")
+    build = m.group("build")
+    rev = int(m.group("rev")) if m.group("rev") else 0
+    inst = int(m.group("inst"))
+    # Build can be a CI number like "13921718" or an "rN" tag for older releases.
+    if build.isdigit():
+        build_key = (1, int(build))
+    elif build.startswith("r") and build[1:].isdigit():
+        build_key = (0, int(build[1:]))
+    else:
+        build_key = (-1, 0)
+    return api, (inst, build_key, rev)
+
+
+def latest_per_api(versions: list[str]) -> dict[str, str]:
+    groups: dict[str, list[tuple]] = {}
+    for v in versions:
+        p = parse(v)
+        if p is None:
+            continue
+        api, key = p
+        groups.setdefault(api, []).append((key, v))
+    return {api: max(items)[1] for api, items in groups.items()}
+
+
+def numeric_api_key(api: str) -> tuple[int, ...] | None:
+    """Parse a purely-numeric Android API string into a sortable tuple.
+
+    Returns None for legacy r-suffixed APIs like "8.0.0_r4" so they're not
+    auto-added when missing.
+    """
+    if re.fullmatch(r"\d+(?:\.\d+)*", api):
+        return tuple(int(p) for p in api.split("."))
+    return None
+
+
+def sha256_for(version: str) -> str:
+    url = f"{MAVEN_BASE}/{version}/android-all-instrumented-{version}.jar.sha256"
+    return fetch(url).decode("ascii").strip().split()[0].lower()
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(
+        description="Update DEFAULT_AVAILABLE_VERSIONS in robolectric.bzl."
+    )
+    default_bzl = Path(__file__).resolve().parent / "bazel" / "robolectric.bzl"
+    parser.add_argument(
+        "--bzl",
+        type=Path,
+        default=default_bzl,
+        help=f"path to robolectric.bzl (default: {default_bzl})",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="print planned changes without writing the file",
+    )
+    args = parser.parse_args()
+
+    text = args.bzl.read_text()
+    entries = list(ENTRY_RE.finditer(text))
+    if not entries:
+        print(f"No robolectric_version() entries found in {args.bzl}", file=sys.stderr)
+        return 1
+
+    print(f"Fetching {METADATA_URL}", file=sys.stderr)
+    latest = latest_per_api(all_versions())
+
+    # Plan in-place updates for entries already in the bzl. Building against
+    # the original text, then applying in reverse, keeps earlier spans valid.
+    updates = []
+    existing_apis: set[str] = set()
+    for m in entries:
+        old_version = m.group("version")
+        parsed = parse(old_version)
+        if parsed is None:
+            print(f"[skip] cannot parse {old_version!r}", file=sys.stderr)
+            continue
+        api = parsed[0]
+        existing_apis.add(api)
+        new_version = latest.get(api)
+        if new_version is None:
+            print(f"[skip] no remote versions for API {api}", file=sys.stderr)
+            continue
+        if new_version == old_version:
+            print(f"[ ok ] {api}: {old_version}", file=sys.stderr)
+            continue
+        print(f"[ up ] {api}: {old_version} -> {new_version}", file=sys.stderr)
+        updates.append((m, new_version, sha256_for(new_version)))
+
+    # Find numeric APIs on Maven Central that are newer than the highest
+    # numeric API already present, and queue them as new entries.
+    max_existing = max(
+        (k for k in (numeric_api_key(a) for a in existing_apis) if k is not None),
+        default=(),
+    )
+    additions = []
+    for api, version in latest.items():
+        if api in existing_apis:
+            continue
+        key = numeric_api_key(api)
+        if key is None or key <= max_existing:
+            continue
+        additions.append((key, api, version))
+    additions.sort(reverse=True)
+    additions = [(api, version, sha256_for(version)) for _, api, version in additions]
+    for api, version, _ in additions:
+        print(f"[ new] {api}: {version}", file=sys.stderr)
+
+    if not updates and not additions:
+        print("No updates needed.", file=sys.stderr)
+        return 0
+
+    new_text = text
+    for m, new_version, new_sha in reversed(updates):
+        replacement = (
+            f"{m.group('head')}{new_version}{m.group('mid')}{new_sha}{m.group('tail')}"
+        )
+        new_text = new_text[: m.start()] + replacement + new_text[m.end() :]
+
+    if additions:
+        insertion = "".join(
+            f"robolectric_version(\n"
+            f'        version = "{v}",\n'
+            f'        sha256 = "{s}",\n'
+            f"    ),\n"
+            f"    "
+            for _, v, s in additions
+        )
+        insert_at = entries[0].start()
+        new_text = new_text[:insert_at] + insertion + new_text[insert_at:]
+
+    total = len(updates) + len(additions)
+    if args.dry_run:
+        print(f"\n[dry-run] {total} change(s) not written.", file=sys.stderr)
+        return 0
+
+    args.bzl.write_text(new_text)
+    print(f"Wrote {total} change(s) to {args.bzl}", file=sys.stderr)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())