repo: add a script for rolling bazel dependencies

It appears that `dependabot` cannot roll dependencies that don't have
semantic-versioning version numbers.

The `roller.py` script looks for dependencies in MODULE.bazel with the
magic comment `# roll:<dep>`.  We query the origin of such deps and
update the git hash with the hash retrieved from the origin.

Signed-off-by: Chris Frantz <cfrantz@google.com>
diff --git a/presubmit/roller.py b/presubmit/roller.py
new file mode 100644
index 0000000..7aeeab6
--- /dev/null
+++ b/presubmit/roller.py
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+import argparse
+import re
+import subprocess
+import logging
+import sys
+
+
+def get_roll_directives(module):
+    """Parses MODULE.bazel content to find roll directives.
+
+    Roll directives are comments in the format `# roll:dependency_name`
+    following a commit hash in quotes.
+
+    Args:
+        module: The content of the MODULE.bazel file as a string.
+
+    Returns:
+        A dictionary mapping dependency names to their current commit hashes.
+    """
+    roll = {}
+    for match in re.finditer(r'"([0-9A-Fa-f]+)",?\s+#\s+roll:(\w+)', module):
+        roll[match.group(2)] = match.group(1)
+    return roll
+
+
+def git_ls_remote(url):
+    """Retrieves the latest commit hash of a remote Git repository.
+
+    Supports specifying a ref in the URL using the format 'url{ref}'.
+    If no ref is specified, it defaults to 'HEAD'.
+
+    Args:
+        url: The URL of the remote Git repository, optionally followed by
+          '{ref}'.
+
+    Returns:
+        The latest commit hash for the specified ref as a string.
+
+    Raises:
+        subprocess.CalledProcessError: If the git ls-remote command fails.
+    """
+    ref = "HEAD"
+    if url.endswith("}"):
+        url, ref = url.split("{")
+        ref = ref[:-1]
+
+    result = subprocess.run(
+        [
+            "git",
+            "ls-remote",
+            url,
+            ref,
+        ],
+        capture_output=True,
+        check=True,
+    )
+    data = result.stdout.decode("utf-8")
+    hash, *_ = data.split()
+    return hash
+
+
+def roll(module, roll, replacements, dry_run=False):
+    """Performs the rolling of dependencies in the module content.
+
+    Iterates through the identified roll directives and replaces the old
+    version (hash) with the new one from replacements.
+
+    Args:
+        module: The content of the MODULE.bazel file as a string.
+        roll: A dictionary mapping dependency names to their current hashes.
+        replacements: A dictionary mapping dependency names to their new
+          hashes. This dictionary is modified in-place.
+        dry_run: If True, only log what would be updated without modifying the
+          module content.
+
+    Returns:
+        A tuple containing:
+        - module (str): The updated module content.
+        - err (bool): True if there were errors (missing or unused
+        replacements), False otherwise.
+    """
+    err = False
+    for name, oldver in roll.items():
+        if newver := replacements.pop(name, None):
+            if dry_run:
+                logging.info("DRY-RUN: updating %s from %s to %s", name, oldver, newver)
+            else:
+                logging.info("Updating %s from %s to %s", name, oldver, newver)
+                module = module.replace(oldver, newver)
+        else:
+            logging.error("No replacement for %s", name)
+            err = True
+
+    for name in replacements:
+        logging.error("No roll directive for %s", name)
+        err = True
+    return (module, err)
+
+
+def main():
+    """Main entry point for the dependency roller script.
+
+    Parses command-line arguments, reads the module file, fetches remote
+    hashes, performs the roll, and writes the updated content back to the
+    file (unless dry-run is enabled).
+
+    Returns:
+        An integer: 0 if successful, 1 if there were errors during the roll.
+    """
+    parser = argparse.ArgumentParser(description="Visualize ELF memory layout.")
+    parser.add_argument(
+        "--logging",
+        default="info",
+        choices=["error", "warning", "info", "debug"],
+        help="Logging level",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action=argparse.BooleanOptionalAction,
+        help="Dry-run: don't update dependencies",
+    )
+    parser.add_argument(
+        "--remote",
+        action="append",
+        default=[],
+        help="Remote repositorie URLs to check as name=url or name=url{ref}",
+    )
+    parser.add_argument("module", help="Path to MODULE.bazel")
+
+    args = parser.parse_args()
+    logging.basicConfig(level=args.logging.upper())
+
+    replacements = {}
+    for r in args.remote:
+        name, url = r.split("=", 1)
+        replacements[name] = git_ls_remote(url)
+        logging.info("Remote %s = %s", name, replacements[name])
+
+    with open(args.module, "rt") as f:
+        module = f.read()
+
+    rolls = get_roll_directives(module)
+    module, err = roll(module, rolls, replacements, args.dry_run)
+
+    if args.dry_run:
+        logging.info("DRY-RUN: would rewrite %s", args.module)
+    else:
+        with open(args.module, "wt") as f:
+            f.write(module)
+
+    return int(err)
+
+
+if __name__ == "__main__":
+    sys.exit(main())