bazel_roller: Detect WORKSPACE / MODULE.bazel

Bug: b/359948624
Change-Id: Ibc6d8ed31e163a43744a70289bd7692c92508f9d
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/229894
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
Reviewed-by: Taylor Cramer <cramertj@google.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/recipes/bazel_roller.proto b/recipes/bazel_roller.proto
index f5717ca..5b16e08 100644
--- a/recipes/bazel_roller.proto
+++ b/recipes/bazel_roller.proto
@@ -26,7 +26,9 @@
   // Auto roller module options.
   recipe_modules.fuchsia.auto_roller.Options auto_roller_options = 2;
 
-  // The path of the WORKSPACE file to update. Default: WORKSPACE.
+  // The path of the WORKSPACE file to update. If not provided the recipe will
+  // look for a top-level WORKSPACE or MODULE.bazel file. If provided and is a
+  // directory, looks for those files in that directory.
   string workspace_path = 3;
 
   // Name for project to be rolled. By default will extract from WORKSPACE.
diff --git a/recipes/bazel_roller.py b/recipes/bazel_roller.py
index 5dacd47..96d05c1 100644
--- a/recipes/bazel_roller.py
+++ b/recipes/bazel_roller.py
@@ -63,11 +63,69 @@
 PROPERTIES = InputProperties
 
 
+def _workspace_path(
+    api: recipe_api.RecipeScriptApi,
+    root: config_types.Path,
+    value: str,
+) -> config_types.Path:
+    """Figure out the location of the WORKSPACE or MODULE.bazel file.
+
+    If value is '', look for root / 'WORKSPACE' and root / 'MODULE.bazel'. If
+    exactly one exists, return it. If not, error out.
+
+    If root / value is a file, return it.
+
+    If root / value is a directory, set root = root / value and apply the above
+    logic. This enables applying this logic to subdirectories.
+
+    Args:
+        api: Recipe API object.
+        root: Checkout root.
+        value: Relative path specified in properties.
+
+    Returns:
+        Path to the WORKSPACE or MODULE.bazel file.
+    """
+    if value:
+        value_path = root / value
+        api.path.mock_add_file(value_path)
+        if api.path.isfile(value_path):
+            return value_path
+        elif api.path.isdir(value_path):  # pragma: no cover
+            root = value_path
+        else:
+            api.step.empty(  # pragma: no cover
+                f'{value_path} does not exist',
+                status='FAILURE',
+            )
+
+    workspace = root / 'WORKSPACE'
+    module_bazel = root / 'MODULE.bazel'
+
+    api.path.mock_add_file(workspace)
+
+    if api.path.isfile(module_bazel) and api.path.isfile(workspace):
+        api.step.empty(  # pragma: no cover
+            f'{module_bazel} and {workspace} both exist',
+            status='FAILURE',
+        )
+
+    if api.path.isfile(module_bazel):
+        return module_bazel  # pragma: no cover
+
+    if api.path.isfile(workspace):
+        return workspace
+
+    api.step.empty(  # pragma: no cover
+        'no WORKSPACE or MODULE.bazel file found',
+        status='FAILURE',
+    )
+
+
 def RunSteps(  # pylint: disable=invalid-name
     api: recipe_api.RecipeScriptApi,
     props: InputProperties,
 ):
-    workspace_path: str = props.workspace_path or 'WORKSPACE'
     project_branch: str = props.project_branch or 'main'
 
     # The checkout module will try to use trigger data to pull in a specific
@@ -78,6 +136,8 @@
         props.checkout_options
     )
 
+    workspace_path = _workspace_path(api, checkout.root, props.workspace_path)
+
     new_revision: Optional[str] = None
 
     # First, try to get new_revision from the trigger.
@@ -240,7 +300,7 @@
 
     yield api.test(
         'bad-trigger',
-        properties(project_remote=_url('foo')),
+        properties(project_remote=_url('foo'), workspace_path='bar/WORKSPACE'),
         trigger('bar'),
         status='FAILURE',
     )