Update --flaky_test_attempts to also take a repeatable argument of the form
regex@attempts, similarly to --runs_per_test.

Also re-arrange some option converters to be closer to their options.

RELNOTES: flaky_test_attempts supports the regex@attempts syntax, like runs_per_test.
PiperOrigin-RevId: 186358437
diff --git a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
index 3be1d4e..c0bae8d 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
@@ -13,18 +13,22 @@
 // limitations under the License.
 package com.google.devtools.build.lib.exec;
 
+import com.google.common.collect.Iterables;
 import com.google.devtools.build.lib.actions.ResourceSet;
-import com.google.devtools.build.lib.exec.TestStrategy.TestOutputFormat;
-import com.google.devtools.build.lib.exec.TestStrategy.TestSummaryFormat;
+import com.google.devtools.build.lib.analysis.config.PerLabelOptions;
 import com.google.devtools.build.lib.packages.TestTimeout;
 import com.google.devtools.build.lib.util.OptionsUtils;
+import com.google.devtools.build.lib.util.RegexFilter;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.common.options.Option;
 import com.google.devtools.common.options.OptionDocumentationCategory;
 import com.google.devtools.common.options.OptionEffectTag;
 import com.google.devtools.common.options.Options;
 import com.google.devtools.common.options.OptionsBase;
+import com.google.devtools.common.options.OptionsParsingException;
 import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 
 /**
@@ -132,9 +136,10 @@
 
   @Option(
     name = "flaky_test_attempts",
+    allowMultiple = true,
     defaultValue = "default",
     category = "testing",
-    converter = TestStrategy.TestAttemptsConverter.class,
+    converter = TestAttemptsConverter.class,
     documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
     effectTags = {OptionEffectTag.UNKNOWN},
     help =
@@ -146,7 +151,7 @@
             + "will be made for regular tests and three for tests marked explicitly as flaky by "
             + "their rule (flaky=1 attribute)."
   )
-  public int testAttempts;
+  public List<PerLabelOptions> testAttempts;
 
   @Option(
     name = "test_tmpdir",
@@ -173,7 +178,7 @@
             + "(this will force tests to be executed locally one at a time regardless of "
             + "--test_strategy value)."
   )
-  public TestOutputFormat testOutput;
+  public TestStrategy.TestOutputFormat testOutput;
 
   @Option(
     name = "test_summary",
@@ -188,7 +193,7 @@
             + "unsuccessful tests that were run, 'detailed' to print detailed information about "
             + "failed test cases, and 'none' to omit the summary."
   )
-  public TestSummaryFormat testSummary;
+  public TestStrategy.TestSummaryFormat testSummary;
 
   @Option(
     name = "test_timeout",
@@ -303,4 +308,62 @@
             + " aggressive RAM optimizations in some cases."
   )
   public boolean enableCriticalPathProfiling;
+
+  /** Converter for the --flaky_test_attempts option. */
+  public static class TestAttemptsConverter extends PerLabelOptions.PerLabelOptionsConverter {
+    private static final int MIN_VALUE = 1;
+    private static final int MAX_VALUE = 10;
+
+    private void validateInput(String input) throws OptionsParsingException {
+      if ("default".equals(input)) {
+        return;
+      } else {
+        Integer value = Integer.parseInt(input);
+        if (value < MIN_VALUE) {
+          throw new OptionsParsingException("'" + input + "' should be >= " + MIN_VALUE);
+        } else if (value < MIN_VALUE || value > MAX_VALUE) {
+          throw new OptionsParsingException("'" + input + "' should be <= " + MAX_VALUE);
+        }
+        return;
+      }
+    }
+
+    @Override
+    public PerLabelOptions convert(String input) throws OptionsParsingException {
+      try {
+        return parseAsInteger(input);
+      } catch (NumberFormatException ignored) {
+        return parseAsRegex(input);
+      }
+    }
+
+    private PerLabelOptions parseAsInteger(String input)
+        throws NumberFormatException, OptionsParsingException {
+      validateInput(input);
+      RegexFilter catchAll =
+          new RegexFilter(Collections.singletonList(".*"), Collections.<String>emptyList());
+      return new PerLabelOptions(catchAll, Collections.singletonList(input));
+    }
+
+    private PerLabelOptions parseAsRegex(String input) throws OptionsParsingException {
+      PerLabelOptions testRegexps = super.convert(input);
+      if (testRegexps.getOptions().size() != 1) {
+        throw new OptionsParsingException("'" + input + "' has multiple runs for a single pattern");
+      }
+      String runsPerTest = Iterables.getOnlyElement(testRegexps.getOptions());
+      try {
+        // Run this in order to catch errors.
+        validateInput(runsPerTest);
+      } catch (NumberFormatException e) {
+        throw new OptionsParsingException("'" + input + "' has a non-numeric value", e);
+      }
+      return testRegexps;
+    }
+
+    @Override
+    public String getTypeDescription() {
+      return "a positive integer, the string \"default\", or test_regex@attempts. "
+          + "This flag may be passed more than once";
+    }
+  }
 }