714 lines
23 KiB
Python
714 lines
23 KiB
Python
# This Source Code Form is subject to the terms of the Mozilla Public
|
|
# License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections import defaultdict
|
|
|
|
import mozpack.path as mozpath
|
|
from moztest.resolve import TestResolver
|
|
|
|
from ..cli import BaseTryParser
|
|
from ..push import build, push_to_try
|
|
|
|
here = os.path.abspath(os.path.dirname(__file__))
|
|
|
|
|
|
class SyntaxParser(BaseTryParser):
|
|
name = "syntax"
|
|
arguments = [
|
|
[
|
|
["paths"],
|
|
{
|
|
"nargs": "*",
|
|
"default": [],
|
|
"help": "Paths to search for tests to run on try.",
|
|
},
|
|
],
|
|
[
|
|
["-b", "--build"],
|
|
{
|
|
"dest": "builds",
|
|
"default": "do",
|
|
"help": "Build types to run (d for debug, o for optimized).",
|
|
},
|
|
],
|
|
[
|
|
["-p", "--platform"],
|
|
{
|
|
"dest": "platforms",
|
|
"action": "append",
|
|
"help": "Platforms to run (required if not found in the environment as "
|
|
"AUTOTRY_PLATFORM_HINT).",
|
|
},
|
|
],
|
|
[
|
|
["-u", "--unittests"],
|
|
{
|
|
"dest": "tests",
|
|
"action": "append",
|
|
"help": "Test suites to run in their entirety.",
|
|
},
|
|
],
|
|
[
|
|
["-t", "--talos"],
|
|
{
|
|
"action": "append",
|
|
"help": "Talos suites to run.",
|
|
},
|
|
],
|
|
[
|
|
["-j", "--jobs"],
|
|
{
|
|
"action": "append",
|
|
"help": "Job tasks to run.",
|
|
},
|
|
],
|
|
[
|
|
["--tag"],
|
|
{
|
|
"dest": "tags",
|
|
"action": "append",
|
|
"help": "Restrict tests to the given tag (may be specified multiple times).",
|
|
},
|
|
],
|
|
[
|
|
["--and"],
|
|
{
|
|
"action": "store_true",
|
|
"dest": "intersection",
|
|
"help": "When -u and paths are supplied run only the intersection of the "
|
|
"tests specified by the two arguments.",
|
|
},
|
|
],
|
|
[
|
|
["--no-artifact"],
|
|
{
|
|
"action": "store_true",
|
|
"help": "Disable artifact builds even if --enable-artifact-builds is set "
|
|
"in the mozconfig.",
|
|
},
|
|
],
|
|
[
|
|
["-v", "--verbose"],
|
|
{
|
|
"dest": "verbose",
|
|
"action": "store_true",
|
|
"default": False,
|
|
"help": "Print detailed information about the resulting test selection "
|
|
"and commands performed.",
|
|
},
|
|
],
|
|
]
|
|
|
|
# Arguments we will accept on the command line and pass through to try
|
|
# syntax with no further intervention. The set is taken from
|
|
# http://trychooser.pub.build.mozilla.org with a few additions.
|
|
#
|
|
# Note that the meaning of store_false and store_true arguments is
|
|
# not preserved here, as we're only using these to echo the literal
|
|
# arguments to another consumer. Specifying either store_false or
|
|
# store_true here will have an equivalent effect.
|
|
pass_through_arguments = {
|
|
"--rebuild": {
|
|
"action": "store",
|
|
"dest": "rebuild",
|
|
"help": "Re-trigger all test jobs (up to 20 times)",
|
|
},
|
|
"--rebuild-talos": {
|
|
"action": "store",
|
|
"dest": "rebuild_talos",
|
|
"help": "Re-trigger all talos jobs",
|
|
},
|
|
"--interactive": {
|
|
"action": "store_true",
|
|
"dest": "interactive",
|
|
"help": "Allow ssh-like access to running test containers",
|
|
},
|
|
"--no-retry": {
|
|
"action": "store_true",
|
|
"dest": "no_retry",
|
|
"help": "Do not retrigger failed tests",
|
|
},
|
|
"--setenv": {
|
|
"action": "append",
|
|
"dest": "setenv",
|
|
"help": "Set the corresponding variable in the test environment for "
|
|
"applicable harnesses.",
|
|
},
|
|
"-f": {
|
|
"action": "store_true",
|
|
"dest": "failure_emails",
|
|
"help": "Request failure emails only",
|
|
},
|
|
"--failure-emails": {
|
|
"action": "store_true",
|
|
"dest": "failure_emails",
|
|
"help": "Request failure emails only",
|
|
},
|
|
"-e": {
|
|
"action": "store_true",
|
|
"dest": "all_emails",
|
|
"help": "Request all emails",
|
|
},
|
|
"--all-emails": {
|
|
"action": "store_true",
|
|
"dest": "all_emails",
|
|
"help": "Request all emails",
|
|
},
|
|
"--artifact": {
|
|
"action": "store_true",
|
|
"dest": "artifact",
|
|
"help": "Force artifact builds where possible.",
|
|
},
|
|
"--upload-xdbs": {
|
|
"action": "store_true",
|
|
"dest": "upload_xdbs",
|
|
"help": "Upload XDB compilation db files generated by hazard build",
|
|
},
|
|
}
|
|
task_configs = []
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
BaseTryParser.__init__(self, *args, **kwargs)
|
|
|
|
group = self.add_argument_group("pass-through arguments")
|
|
for arg, opts in self.pass_through_arguments.items():
|
|
group.add_argument(arg, **opts)
|
|
|
|
|
|
class TryArgumentTokenizer:
|
|
symbols = [
|
|
("separator", ","),
|
|
("list_start", r"\["),
|
|
("list_end", r"\]"),
|
|
("item", r"([^,\[\]\s][^,\[\]]+)"),
|
|
("space", r"\s+"),
|
|
]
|
|
token_re = re.compile("|".join("(?P<%s>%s)" % item for item in symbols))
|
|
|
|
def tokenize(self, data):
|
|
for match in self.token_re.finditer(data):
|
|
symbol = match.lastgroup
|
|
data = match.group(symbol)
|
|
if symbol == "space":
|
|
pass
|
|
else:
|
|
yield symbol, data
|
|
|
|
|
|
class TryArgumentParser:
|
|
"""Simple three-state parser for handling expressions
|
|
of the from "foo[sub item, another], bar,baz". This takes
|
|
input from the TryArgumentTokenizer and runs through a small
|
|
state machine, returning a dictionary of {top-level-item:[sub_items]}
|
|
i.e. the above would result in
|
|
{"foo":["sub item", "another"], "bar": [], "baz": []}
|
|
In the case of invalid input a ValueError is raised."""
|
|
|
|
EOF = object()
|
|
|
|
def __init__(self):
|
|
self.reset()
|
|
|
|
def reset(self):
|
|
self.tokens = None
|
|
self.current_item = None
|
|
self.data = {}
|
|
self.token = None
|
|
self.state = None
|
|
|
|
def parse(self, tokens):
|
|
self.reset()
|
|
self.tokens = tokens
|
|
self.consume()
|
|
self.state = self.item_state
|
|
while self.token[0] != self.EOF:
|
|
self.state()
|
|
return self.data
|
|
|
|
def consume(self):
|
|
try:
|
|
self.token = next(self.tokens)
|
|
except StopIteration:
|
|
self.token = (self.EOF, None)
|
|
|
|
def expect(self, *types):
|
|
if self.token[0] not in types:
|
|
raise ValueError(
|
|
"Error parsing try string, unexpected %s" % (self.token[0])
|
|
)
|
|
|
|
def item_state(self):
|
|
self.expect("item")
|
|
value = self.token[1].strip()
|
|
if value not in self.data:
|
|
self.data[value] = []
|
|
self.current_item = value
|
|
self.consume()
|
|
if self.token[0] == "separator":
|
|
self.consume()
|
|
elif self.token[0] == "list_start":
|
|
self.consume()
|
|
self.state = self.subitem_state
|
|
elif self.token[0] == self.EOF:
|
|
pass
|
|
else:
|
|
raise ValueError
|
|
|
|
def subitem_state(self):
|
|
self.expect("item")
|
|
value = self.token[1].strip()
|
|
self.data[self.current_item].append(value)
|
|
self.consume()
|
|
if self.token[0] == "separator":
|
|
self.consume()
|
|
elif self.token[0] == "list_end":
|
|
self.consume()
|
|
self.state = self.after_list_end_state
|
|
else:
|
|
raise ValueError
|
|
|
|
def after_list_end_state(self):
|
|
self.expect("separator")
|
|
self.consume()
|
|
self.state = self.item_state
|
|
|
|
|
|
def parse_arg(arg):
|
|
tokenizer = TryArgumentTokenizer()
|
|
parser = TryArgumentParser()
|
|
return parser.parse(tokenizer.tokenize(arg))
|
|
|
|
|
|
class AutoTry:
|
|
# Maps from flavors to the job names needed to run that flavour
|
|
flavor_jobs = {
|
|
"mochitest": ["mochitest-1", "mochitest-e10s-1"],
|
|
"xpcshell": ["xpcshell"],
|
|
"chrome": ["mochitest-o"],
|
|
"browser-a11y": ["mochitest-ba"],
|
|
"browser-media": ["mochitest-bmda"],
|
|
"browser-translations": ["mochitest-btr8ns"],
|
|
"browser-chrome": [
|
|
"mochitest-browser-chrome-1",
|
|
"mochitest-e10s-browser-chrome-1",
|
|
"mochitest-browser-chrome-e10s-1",
|
|
],
|
|
"devtools-chrome": [
|
|
"mochitest-devtools-chrome-1",
|
|
"mochitest-e10s-devtools-chrome-1",
|
|
"mochitest-devtools-chrome-e10s-1",
|
|
],
|
|
"crashtest": ["crashtest", "crashtest-e10s"],
|
|
"reftest": ["reftest", "reftest-e10s"],
|
|
"remote": ["mochitest-remote"],
|
|
"web-platform-tests": ["web-platform-tests-1"],
|
|
}
|
|
|
|
flavor_suites = {
|
|
"mochitest": "mochitests",
|
|
"xpcshell": "xpcshell",
|
|
"chrome": "mochitest-o",
|
|
"browser-chrome": "mochitest-bc",
|
|
"browser-a11y": "mochitest-ba",
|
|
"browser-media": "mochitest-bmda",
|
|
"browser-translations": "mochitest-btr8ns",
|
|
"devtools-chrome": "mochitest-dt",
|
|
"crashtest": "crashtest",
|
|
"reftest": "reftest",
|
|
"web-platform-tests": "web-platform-tests",
|
|
}
|
|
|
|
compiled_suites = [
|
|
"cppunit",
|
|
"gtest",
|
|
"jittest",
|
|
]
|
|
|
|
common_suites = [
|
|
"cppunit",
|
|
"crashtest",
|
|
"firefox-ui-functional",
|
|
"geckoview",
|
|
"geckoview-junit",
|
|
"gtest",
|
|
"jittest",
|
|
"jsreftest",
|
|
"marionette",
|
|
"marionette-e10s",
|
|
"mochitests",
|
|
"reftest",
|
|
"robocop",
|
|
"web-platform-tests",
|
|
"xpcshell",
|
|
]
|
|
|
|
def __init__(self):
|
|
self.topsrcdir = build.topsrcdir
|
|
self._resolver = None
|
|
|
|
@property
|
|
def resolver(self):
|
|
if self._resolver is None:
|
|
self._resolver = TestResolver.from_environment(cwd=here)
|
|
return self._resolver
|
|
|
|
@classmethod
|
|
def split_try_string(cls, data):
|
|
return re.findall(r"(?:\[.*?\]|\S)+", data)
|
|
|
|
def paths_by_flavor(self, paths=None, tags=None):
|
|
paths_by_flavor = defaultdict(set)
|
|
|
|
if not (paths or tags):
|
|
return dict(paths_by_flavor)
|
|
|
|
tests = list(self.resolver.resolve_tests(paths=paths, tags=tags))
|
|
|
|
for t in tests:
|
|
if t["flavor"] in self.flavor_suites:
|
|
flavor = t["flavor"]
|
|
if "subsuite" in t and t["subsuite"] == "devtools":
|
|
flavor = "devtools-chrome"
|
|
|
|
if "subsuite" in t and t["subsuite"] == "a11y":
|
|
flavor = "browser-a11y"
|
|
|
|
if "subsuite" in t and t["subsuite"] == "media-bc":
|
|
flavor = "browser-media"
|
|
|
|
if "subsuite" in t and t["subsuite"] == "translations":
|
|
flavor = "browser-translations"
|
|
|
|
if flavor in ["crashtest", "reftest"]:
|
|
manifest_relpath = os.path.relpath(t["manifest"], self.topsrcdir)
|
|
paths_by_flavor[flavor].add(os.path.dirname(manifest_relpath))
|
|
elif "dir_relpath" in t:
|
|
paths_by_flavor[flavor].add(t["dir_relpath"])
|
|
else:
|
|
file_relpath = os.path.relpath(t["path"], self.topsrcdir)
|
|
dir_relpath = os.path.dirname(file_relpath)
|
|
paths_by_flavor[flavor].add(dir_relpath)
|
|
|
|
for flavor, path_set in paths_by_flavor.items():
|
|
paths_by_flavor[flavor] = self.deduplicate_prefixes(path_set, paths)
|
|
|
|
return dict(paths_by_flavor)
|
|
|
|
def deduplicate_prefixes(self, path_set, input_paths):
|
|
# Removes paths redundant to test selection in the given path set.
|
|
# If a path was passed on the commandline that is the prefix of a
|
|
# path in our set, we only need to include the specified prefix to
|
|
# run the intended tests (every test in "layout/base" will run if
|
|
# "layout" is passed to the reftest harness).
|
|
removals = set()
|
|
additions = set()
|
|
|
|
for path in path_set:
|
|
full_path = path
|
|
while path:
|
|
path, _ = os.path.split(path)
|
|
if path in input_paths:
|
|
removals.add(full_path)
|
|
additions.add(path)
|
|
|
|
return additions | (path_set - removals)
|
|
|
|
def remove_duplicates(self, paths_by_flavor, tests):
|
|
rv = {}
|
|
for item in paths_by_flavor:
|
|
if self.flavor_suites[item] not in tests:
|
|
rv[item] = paths_by_flavor[item].copy()
|
|
return rv
|
|
|
|
def calc_try_syntax(
|
|
self,
|
|
platforms,
|
|
tests,
|
|
talos,
|
|
jobs,
|
|
builds,
|
|
paths_by_flavor,
|
|
tags,
|
|
extras,
|
|
intersection,
|
|
):
|
|
parts = ["try:"]
|
|
|
|
if platforms:
|
|
parts.extend(["-b", builds, "-p", ",".join(platforms)])
|
|
|
|
suites = tests if not intersection else {}
|
|
paths = set()
|
|
for flavor, flavor_tests in paths_by_flavor.items():
|
|
suite = self.flavor_suites[flavor]
|
|
if suite not in suites and (not intersection or suite in tests):
|
|
for job_name in self.flavor_jobs[flavor]:
|
|
for test in flavor_tests:
|
|
paths.add(f"{flavor}:{test}")
|
|
suites[job_name] = tests.get(suite, [])
|
|
|
|
# intersection implies tests are expected
|
|
if intersection and not suites:
|
|
raise ValueError("No tests found matching filters")
|
|
|
|
if extras.get("artifact") and any([p.endswith("-nightly") for p in platforms]):
|
|
print(
|
|
'You asked for |--artifact| but "-nightly" platforms don\'t have artifacts. '
|
|
"Running without |--artifact| instead."
|
|
)
|
|
del extras["artifact"]
|
|
|
|
if extras.get("artifact"):
|
|
rejected = []
|
|
for suite in suites.keys():
|
|
if any([suite.startswith(c) for c in self.compiled_suites]):
|
|
rejected.append(suite)
|
|
if rejected:
|
|
raise ValueError(
|
|
"You can't run {} with "
|
|
"--artifact option.".format(", ".join(rejected))
|
|
)
|
|
|
|
if extras.get("artifact") and "all" in suites.keys():
|
|
non_compiled_suites = set(self.common_suites) - set(self.compiled_suites)
|
|
message = (
|
|
"You asked for |-u all| with |--artifact| but compiled-code tests ({tests})"
|
|
" can't run against an artifact build. Running (-u {non_compiled_suites}) "
|
|
"instead."
|
|
)
|
|
string_format = {
|
|
"tests": ",".join(self.compiled_suites),
|
|
"non_compiled_suites": ",".join(non_compiled_suites),
|
|
}
|
|
print(message.format(**string_format))
|
|
del suites["all"]
|
|
suites.update({suite_name: None for suite_name in non_compiled_suites})
|
|
|
|
if suites:
|
|
parts.append("-u")
|
|
parts.append(
|
|
",".join(
|
|
"{}{}".format(k, "[%s]" % ",".join(v) if v else "")
|
|
for k, v in sorted(suites.items())
|
|
)
|
|
)
|
|
|
|
if talos:
|
|
parts.append("-t")
|
|
parts.append(
|
|
",".join(
|
|
"{}{}".format(k, "[%s]" % ",".join(v) if v else "")
|
|
for k, v in sorted(talos.items())
|
|
)
|
|
)
|
|
|
|
if jobs:
|
|
parts.append("-j")
|
|
parts.append(",".join(jobs))
|
|
|
|
if tags:
|
|
parts.append(" ".join("--tag %s" % t for t in tags))
|
|
|
|
if paths:
|
|
parts.append("--try-test-paths %s" % " ".join(sorted(paths)))
|
|
|
|
args_by_dest = {
|
|
v["dest"]: k for k, v in SyntaxParser.pass_through_arguments.items()
|
|
}
|
|
for dest, value in extras.items():
|
|
assert dest in args_by_dest
|
|
arg = args_by_dest[dest]
|
|
action = SyntaxParser.pass_through_arguments[arg]["action"]
|
|
if action == "store":
|
|
parts.append(arg)
|
|
parts.append(value)
|
|
if action == "append":
|
|
for e in value:
|
|
parts.append(arg)
|
|
parts.append(e)
|
|
if action in ("store_true", "store_false"):
|
|
parts.append(arg)
|
|
|
|
return " ".join(parts)
|
|
|
|
def normalise_list(self, items, allow_subitems=False):
|
|
rv = defaultdict(list)
|
|
for item in items:
|
|
parsed = parse_arg(item)
|
|
for key, values in parsed.items():
|
|
rv[key].extend(values)
|
|
|
|
if not allow_subitems:
|
|
if not all(item == [] for item in rv.values()):
|
|
raise ValueError("Unexpected subitems in argument")
|
|
return rv.keys()
|
|
else:
|
|
return rv
|
|
|
|
def validate_args(self, **kwargs):
|
|
tests_selected = kwargs["tests"] or kwargs["paths"] or kwargs["tags"]
|
|
if kwargs["platforms"] is None and (kwargs["jobs"] is None or tests_selected):
|
|
if "AUTOTRY_PLATFORM_HINT" in os.environ:
|
|
kwargs["platforms"] = [os.environ["AUTOTRY_PLATFORM_HINT"]]
|
|
elif tests_selected:
|
|
print("Must specify platform when selecting tests.")
|
|
sys.exit(1)
|
|
else:
|
|
print(
|
|
"Either platforms or jobs must be specified as an argument to autotry."
|
|
)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
platforms = (
|
|
self.normalise_list(kwargs["platforms"]) if kwargs["platforms"] else {}
|
|
)
|
|
except ValueError as e:
|
|
print("Error parsing -p argument:\n%s" % e)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
tests = (
|
|
self.normalise_list(kwargs["tests"], allow_subitems=True)
|
|
if kwargs["tests"]
|
|
else {}
|
|
)
|
|
except ValueError as e:
|
|
print("Error parsing -u argument ({}):\n{}".format(kwargs["tests"], e))
|
|
sys.exit(1)
|
|
|
|
try:
|
|
talos = (
|
|
self.normalise_list(kwargs["talos"], allow_subitems=True)
|
|
if kwargs["talos"]
|
|
else []
|
|
)
|
|
except ValueError as e:
|
|
print("Error parsing -t argument:\n%s" % e)
|
|
sys.exit(1)
|
|
|
|
try:
|
|
jobs = self.normalise_list(kwargs["jobs"]) if kwargs["jobs"] else {}
|
|
except ValueError as e:
|
|
print("Error parsing -j argument:\n%s" % e)
|
|
sys.exit(1)
|
|
|
|
paths = []
|
|
for p in kwargs["paths"]:
|
|
p = mozpath.normpath(os.path.abspath(p))
|
|
if not (os.path.isdir(p) and p.startswith(self.topsrcdir)):
|
|
print(
|
|
'Specified path "%s" is not a directory under the srcdir,'
|
|
" unable to specify tests outside of the srcdir" % p
|
|
)
|
|
sys.exit(1)
|
|
if len(p) <= len(self.topsrcdir):
|
|
print(
|
|
'Specified path "%s" is at the top of the srcdir and would'
|
|
" select all tests." % p
|
|
)
|
|
sys.exit(1)
|
|
paths.append(os.path.relpath(p, self.topsrcdir))
|
|
|
|
try:
|
|
tags = self.normalise_list(kwargs["tags"]) if kwargs["tags"] else []
|
|
except ValueError as e:
|
|
print("Error parsing --tags argument:\n%s" % e)
|
|
sys.exit(1)
|
|
|
|
extra_values = {k["dest"] for k in SyntaxParser.pass_through_arguments.values()}
|
|
extra_args = {k: v for k, v in kwargs.items() if k in extra_values and v}
|
|
|
|
return kwargs["builds"], platforms, tests, talos, jobs, paths, tags, extra_args
|
|
|
|
def run(self, **kwargs):
|
|
if not any(kwargs[item] for item in ("paths", "tests", "tags")):
|
|
kwargs["paths"] = set()
|
|
kwargs["tags"] = set()
|
|
|
|
builds, platforms, tests, talos, jobs, paths, tags, extra = self.validate_args(
|
|
**kwargs
|
|
)
|
|
|
|
if paths or tags:
|
|
paths = [
|
|
os.path.relpath(os.path.normpath(os.path.abspath(item)), self.topsrcdir)
|
|
for item in paths
|
|
]
|
|
paths_by_flavor = self.paths_by_flavor(paths=paths, tags=tags)
|
|
|
|
if not paths_by_flavor and not tests:
|
|
print(
|
|
"No tests were found when attempting to resolve paths:\n\n\t%s"
|
|
% paths
|
|
)
|
|
sys.exit(1)
|
|
|
|
if not kwargs["intersection"]:
|
|
paths_by_flavor = self.remove_duplicates(paths_by_flavor, tests)
|
|
else:
|
|
paths_by_flavor = {}
|
|
|
|
# No point in dealing with artifacts if we aren't running any builds
|
|
local_artifact_build = False
|
|
if platforms:
|
|
local_artifact_build = kwargs.get("local_artifact_build", False)
|
|
|
|
# Add --artifact if --enable-artifact-builds is set ...
|
|
if local_artifact_build:
|
|
extra["artifact"] = True
|
|
# ... unless --no-artifact is explicitly given.
|
|
if kwargs["no_artifact"]:
|
|
if "artifact" in extra:
|
|
del extra["artifact"]
|
|
|
|
try:
|
|
msg = self.calc_try_syntax(
|
|
platforms,
|
|
tests,
|
|
talos,
|
|
jobs,
|
|
builds,
|
|
paths_by_flavor,
|
|
tags,
|
|
extra,
|
|
kwargs["intersection"],
|
|
)
|
|
except ValueError as e:
|
|
print(e)
|
|
sys.exit(1)
|
|
|
|
if local_artifact_build and not kwargs["no_artifact"]:
|
|
print(
|
|
"mozconfig has --enable-artifact-builds; including "
|
|
"--artifact flag in try syntax (use --no-artifact "
|
|
"to override)"
|
|
)
|
|
|
|
if kwargs["verbose"] and paths_by_flavor:
|
|
print("The following tests will be selected: ")
|
|
for flavor, paths in paths_by_flavor.items():
|
|
print("{}: {}".format(flavor, ",".join(paths)))
|
|
|
|
if kwargs["verbose"]:
|
|
print("The following try syntax was calculated:\n%s" % msg)
|
|
|
|
push_to_try(
|
|
"syntax",
|
|
kwargs["message"].format(msg=msg),
|
|
stage_changes=kwargs["stage_changes"],
|
|
dry_run=kwargs["dry_run"],
|
|
closed_tree=kwargs["closed_tree"],
|
|
push_to_lando=kwargs["push_to_lando"],
|
|
push_to_vcs=kwargs["push_to_vcs"],
|
|
)
|
|
|
|
|
|
def run(**kwargs):
|
|
at = AutoTry()
|
|
return at.run(**kwargs)
|