Bug 1384593 - Add a fuzzy try selector draft
authorAndrew Halberstadt <ahalberstadt@mozilla.com>
Tue, 25 Jul 2017 10:22:54 -0400
changeset 616128 cdadcb0cf6b0e3207182b780f46858cd0d761c3a
parent 616127 1adaa2908c43d4947514309cceaadf5e26ff4a51
child 639389 a47f479f8694b6ecc63a66fd2ca4cd8ac46ced20
push id70596
push userahalberstadt@mozilla.com
push dateWed, 26 Jul 2017 18:38:03 +0000
bugs1384593
milestone56.0a1
Bug 1384593 - Add a fuzzy try selector MozReview-Commit-ID: 4xHwZ9fATLv
tools/tryselect/mach_commands.py
tools/tryselect/selectors/fuzzy.py
tools/tryselect/selectors/syntax.py
tools/tryselect/tasks.py
tools/tryselect/vcs.py
--- a/tools/tryselect/mach_commands.py
+++ b/tools/tryselect/mach_commands.py
@@ -51,31 +51,79 @@ class TrySelect(MachCommandBase):
              description='Push selected tasks to the try server')
     @CommandArgument('args', nargs=argparse.REMAINDER)
     def try_default(self, args):
         """Push selected tests to the try server.
 
         The |mach try| command is a frontend for scheduling tasks to
         run on try server using selectors. A selector is a subcommand
         that provides its own set of command line arguments and are
-        listed below. Currently there is only single selector called
-        `syntax`, but more selectors will be added in the future.
+        listed below.
 
         If no subcommand is specified, the `syntax` selector is run by
         default. Run |mach try syntax --help| for more information on
         scheduling with the `syntax` selector.
         """
         parser = syntax_parser()
         kwargs = vars(parser.parse_args(args))
         return self._mach_context.commands.dispatch(
             'try', subcommand='syntax', context=self._mach_context, **kwargs)
 
     @SubCommand('try',
+                'fuzzy',
+                description='Select tasks on try using a fuzzy finder')
+    def try_fuzzy(self):
+        """Select which tasks to use with fzf.
+
+        This selector runs all task labels through a fuzzy finding interface.
+        All selected task labels and their dependencies will be scheduled on
+        try.
+
+        Keyboard Shortcuts
+        ------------------
+
+        When in the fuzzy finder interface, start typing to filter down the
+        task list. Then use the following keyboard shortcuts to select tasks:
+
+          accept: <enter>
+          cancel: <ctrl-c> or <esc>
+          cursor-up: <ctrl-k> or <up>
+          cursor-down: <ctrl-j> or <down>
+          toggle-select-down: <tab>
+          toggle-select-up: <shift-tab>
+          select-all: <ctrl-a>
+          deselect-all: <ctrl-d>
+          toggle-all: <ctrl-t>
+          clear-input: <alt-bspace>
+
+        There are many more shortcuts enabled by default, you can also define
+        your own shortcuts by setting `--bind` in the $FZF_DEFAULT_OPTS
+        environment variable. See `man fzf` for more info.
+
+        Extended Search
+        ---------------
+
+        When typing in search terms, the following modifiers can be applied:
+
+          'word: exact match (line must contain the literal string "word")
+          ^word: exact prefix match (line must start with literal "word")
+          word$: exact suffix match (line must end with literal "word")
+          !word: exact negation match (line must not contain literal "word")
+          'a | 'b: OR operator (joins two exact match operators together)
+
+        For example:
+
+          ^start 'exact | !ignore fuzzy end$
+        """
+        from tryselect.selectors.fuzzy import run_fuzzy_try
+        return run_fuzzy_try()
+
+    @SubCommand('try',
                 'syntax',
-                description='Push selected tasks using try syntax',
+                description='Select tasks on try using try syntax',
                 parser=syntax_parser)
     def try_syntax(self, **kwargs):
         """Push the current tree to try, with the specified syntax.
 
         Build options, platforms and regression tests may be selected
         using the usual try options (-b, -p and -u respectively). In
         addition, tests in a given directory may be automatically
         selected by passing that directory as a positional argument to the
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/selectors/fuzzy.py
@@ -0,0 +1,91 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import subprocess
+from distutils.spawn import find_executable
+
+from blessings import Terminal
+
+from ..tasks import generate_target
+from ..vcs import VCSHelper
+
+FZF_NOT_FOUND = """
+Could not find the `fzf` binary.
+
+The `mach try fuzzy` command depends on fzf. Please install it following the
+appropriate instructions for your platform:
+
+    https://github.com/junegunn/fzf#installation
+
+Only the binary is required, if you do not wish to install the shell and
+editor integrations, download the appropriate binary and put it on your $PATH:
+
+    https://github.com/junegunn/fzf-bin/releases
+""".lstrip()
+
+FZF_HEADER = """
+For more shortcuts, see {t.italic_white}mach help try fuzzy{t.normal} and {t.italic_white}man fzf
+{shortcuts}
+""".strip()
+
+fzf_shortcuts = {
+    'ctrl-a': 'select-all',
+    'ctrl-d': 'deselect-all',
+    'ctrl-t': 'toggle-all',
+    'alt-bspace': 'beginning-of-line+kill-line',
+    '?': 'toggle-preview',
+}
+
+fzf_header_shortcuts = {
+    'cursor-up': 'ctrl-k',
+    'cursor-down': 'ctrl-j',
+    'toggle-select': 'tab',
+    'select-all': 'ctrl-a',
+    'accept': 'enter',
+    'cancel': 'ctrl-c',
+}
+
+
+def format_header():
+    t = Terminal()
+    shortcuts = []
+    for action, key in sorted(fzf_header_shortcuts.iteritems()):
+        shortcuts.append('{t.white}{action}{t.normal}: {t.yellow}<{key}>{t.normal}'.format(
+                         t=t, action=action, key=key))
+    return FZF_HEADER.format(shortcuts=', '.join(shortcuts), t=t)
+
+
+def run_fuzzy_try():
+    # TODO bootstrap fzf automatically. This is a bit tricky as there are multiple
+    # ways to install fzf depending on things like whether shell/editor integrations
+    # are desired.
+    if not find_executable('fzf'):
+        print(FZF_NOT_FOUND)
+        return
+
+    vcs = VCSHelper.create()
+    # vcs.check_working_directory()
+
+    all_tasks = generate_target()
+
+    key_shortcuts = [k + ':' + v for k, v in fzf_shortcuts.iteritems()]
+    cmd = [
+        'fzf', '-m',
+        '--bind', ','.join(key_shortcuts),
+        '--header', format_header(),
+        # Using python to split the preview string is a bit convoluted,
+        # but is guaranteed to be available on all platforms.
+        '--preview', 'python -c "print(\\"\\n\\".join(sorted([s.strip(\\"\'\\") for s in \\"{+}\\".split()])))"',  # noqa
+        '--preview-window=right:20%',
+    ]
+    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stdin=subprocess.PIPE)
+    selected = proc.communicate('\n'.join(all_tasks))[0].splitlines()
+
+    if not selected:
+        print("no tasks selected")
+        return
+
+    vcs.push_to_try("Task config generated via 'mach try fuzzy' (see diff)", selected)
--- a/tools/tryselect/selectors/syntax.py
+++ b/tools/tryselect/selectors/syntax.py
@@ -3,22 +3,21 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 import ConfigParser
 import argparse
 import os
 import re
-import subprocess
 import sys
-import which
 from collections import defaultdict
 
 import mozpack.path as mozpath
+from ..vcs import VCSHelper
 
 
 def arg_parser():
     parser = argparse.ArgumentParser()
     parser.add_argument('paths', nargs='*', help='Paths to search for tests to run on try.')
     parser.add_argument('-b', '--build', dest='builds', default='do',
                         help='Build types to run (d for debug, o for optimized).')
     parser.add_argument('-p', '--platform', dest='platforms', action='append',
@@ -271,21 +270,17 @@ class AutoTry(object):
         },
     }
 
     def __init__(self, topsrcdir, resolver_func, mach_context):
         self.topsrcdir = topsrcdir
         self._resolver_func = resolver_func
         self._resolver = None
         self.mach_context = mach_context
-
-        if os.path.exists(os.path.join(self.topsrcdir, '.hg')):
-            self._use_git = False
-        else:
-            self._use_git = True
+        self.vcs = VCSHelper.create()
 
     @property
     def resolver(self):
         if self._resolver is None:
             self._resolver = self._resolver_func()
         return self._resolver
 
     @property
@@ -479,121 +474,19 @@ class AutoTry(object):
                     parts.append(arg)
                     parts.append(e)
             if action in ('store_true', 'store_false'):
                 parts.append(arg)
 
         try_syntax = " ".join(parts)
         return try_syntax
 
-    def _run_git(self, *args):
-        args = ['git'] + list(args)
-        ret = subprocess.call(args)
-        if ret:
-            print('ERROR git command %s returned %s' %
-                  (args, ret))
-            sys.exit(1)
-
-    def _git_push_to_try(self, msg):
-        self._run_git('commit', '--allow-empty', '-m', msg)
-        try:
-            self._run_git('push', 'hg::ssh://hg.mozilla.org/try',
-                          '+HEAD:refs/heads/branches/default/tip')
-        finally:
-            self._run_git('reset', 'HEAD~')
-
-    def _git_find_changed_files(self):
-        # This finds the files changed on the current branch based on the
-        # diff of the current branch its merge-base base with other branches.
-        try:
-            args = ['git', 'rev-parse', 'HEAD']
-            current_branch = subprocess.check_output(args).strip()
-            args = ['git', 'for-each-ref', 'refs/heads', 'refs/remotes',
-                    '--format=%(objectname)']
-            all_branches = subprocess.check_output(args).splitlines()
-            other_branches = set(all_branches) - set([current_branch])
-            args = ['git', 'merge-base', 'HEAD'] + list(other_branches)
-            base_commit = subprocess.check_output(args).strip()
-            args = ['git', 'diff', '--name-only', '-z', 'HEAD', base_commit]
-            return subprocess.check_output(args).strip('\0').split('\0')
-        except subprocess.CalledProcessError as e:
-            print('Failed while determining files changed on this branch')
-            print('Failed whle running: %s' % args)
-            print(e.output)
-            sys.exit(1)
-
-    def _hg_find_changed_files(self):
-        hg_args = [
-            'hg', 'log', '-r',
-            '::. and not public()',
-            '--template',
-            '{join(files, "\n")}\n',
-        ]
-        try:
-            return subprocess.check_output(hg_args).splitlines()
-        except subprocess.CalledProcessError as e:
-            print('Failed while finding files changed since the last '
-                  'public ancestor')
-            print('Failed whle running: %s' % hg_args)
-            print(e.output)
-            sys.exit(1)
-
-    def find_changed_files(self):
-        """Finds files changed in a local source tree.
-
-        For hg, changes since the last public ancestor of '.' are
-        considered. For git, changes in the current branch are considered.
-        """
-        if self._use_git:
-            return self._git_find_changed_files()
-        return self._hg_find_changed_files()
-
-    def push_to_try(self, msg, verbose):
-        if not self._use_git:
-            try:
-                hg_args = ['hg', 'push-to-try', '-m', msg]
-                subprocess.check_call(hg_args, stderr=subprocess.STDOUT)
-            except subprocess.CalledProcessError as e:
-                print('ERROR hg command %s returned %s' % (hg_args, e.returncode))
-                print('\nmach failed to push to try. There may be a problem '
-                      'with your ssh key, or another issue with your mercurial '
-                      'installation.')
-                # Check for the presence of the "push-to-try" extension, and
-                # provide instructions if it can't be found.
-                try:
-                    subprocess.check_output(['hg', 'showconfig',
-                                             'extensions.push-to-try'])
-                except subprocess.CalledProcessError:
-                    print('\nThe "push-to-try" hg extension is required. It '
-                          'can be installed to Mercurial 3.3 or above by '
-                          'running ./mach mercurial-setup')
-                sys.exit(1)
-        else:
-            try:
-                which.which('git-cinnabar')
-                self._git_push_to_try(msg)
-            except which.WhichError:
-                print('ERROR git-cinnabar is required to push from git to try with'
-                      'the autotry command.\n\nMore information can by found at '
-                      'https://github.com/glandium/git-cinnabar')
-                sys.exit(1)
-
-    def find_uncommited_changes(self):
-        if self._use_git:
-            stat = subprocess.check_output(['git', 'status', '-z'])
-            return any(len(entry.strip()) and entry.strip()[0] in ('A', 'M', 'D')
-                       for entry in stat.split('\0'))
-        else:
-            stat = subprocess.check_output(['hg', 'status'])
-            return any(len(entry.strip()) and entry.strip()[0] in ('A', 'M', 'R')
-                       for entry in stat.splitlines())
-
     def find_paths_and_tags(self, verbose):
         paths, tags = set(), set()
-        changed_files = self.find_changed_files()
+        changed_files = self.vcs.files_changed
         if changed_files:
             if verbose:
                 print("Pushing tests based on modifications to the "
                       "following files:\n\t%s" % "\n\t".join(changed_files))
 
             from mozbuild.frontend.reader import (
                 BuildReader,
                 EmptyConfig,
@@ -705,20 +598,16 @@ class AutoTry(object):
             if defaults is None:
                 print("No saved configuration called %s found in autotry.ini" % kwargs["load"],
                       file=sys.stderr)
 
             for key, value in kwargs.iteritems():
                 if value in (None, []) and key in defaults:
                     kwargs[key] = defaults[key]
 
-        if kwargs["push"] and self.find_uncommited_changes():
-            print('ERROR please commit changes before continuing')
-            sys.exit(1)
-
         if not any(kwargs[item] for item in ("paths", "tests", "tags")):
             kwargs["paths"], kwargs["tags"] = self.find_paths_and_tags(kwargs["verbose"])
 
         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]
@@ -768,12 +657,12 @@ class AutoTry(object):
             print('The following tests will be selected: ')
             for flavor, paths in paths_by_flavor.iteritems():
                 print("%s: %s" % (flavor, ",".join(paths)))
 
         if kwargs["verbose"] or not kwargs["push"]:
             print('The following try syntax was calculated:\n%s' % msg)
 
         if kwargs["push"]:
-            self.push_to_try(msg, kwargs["verbose"])
+            self.vcs.push_to_try(msg)
 
         if kwargs["save"] is not None:
             self.save_config(kwargs["save"], msg)
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/tasks.py
@@ -0,0 +1,54 @@
+# 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/.
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+import os
+
+from mozboot.util import get_state_dir
+from mozbuild.base import MozbuildObject
+from mozpack.files import FileFinder
+
+from taskgraph.generator import TaskGraphGenerator
+from taskgraph.parameters import load_parameters_file
+
+here = os.path.abspath(os.path.dirname(__file__))
+build = MozbuildObject.from_environment(cwd=here)
+
+
+def invalidate(cache):
+    if not os.path.isfile(cache):
+        return
+
+    tc_dir = os.path.join(build.topsrcdir, 'taskcluster')
+    tmod = max(os.path.getmtime(os.path.join(tc_dir, p)) for p, _ in FileFinder(tc_dir))
+    cmod = os.path.getmtime(cache)
+
+    if tmod > cmod:
+        os.remove(cache)
+
+
+def generate_target(params='project=mozilla-central'):
+    cache_dir = os.path.join(get_state_dir()[0], 'cache', 'taskgraph')
+    cache = os.path.join(cache_dir, 'target_task_set')
+
+    invalidate(cache)
+    if os.path.isfile(cache):
+        with open(cache, 'r') as fh:
+            return fh.read().splitlines()
+
+    if not os.path.isdir(cache_dir):
+        os.makedirs(cache_dir)
+
+    print("Task configuration changed, generating target tasks")
+    params = load_parameters_file(params)
+    params.check()
+
+    root = os.path.join(build.topsrcdir, 'taskcluster', 'ci')
+    tg = TaskGraphGenerator(root_dir=root, parameters=params).target_task_set
+    labels = [label for label in tg.graph.visit_postorder()]
+
+    with open(cache, 'w') as fh:
+        fh.write('\n'.join(labels))
+    return labels
new file mode 100644
--- /dev/null
+++ b/tools/tryselect/vcs.py
@@ -0,0 +1,180 @@
+# 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 json
+import os
+import subprocess
+import sys
+from abc import ABCMeta, abstractmethod, abstractproperty
+from distutils.spawn import find_executable
+
+GIT_CINNABAR_NOT_FOUND = """
+Could not detect `git-cinnabar`.
+
+The `mach try` command requires git-cinnabar to be installed when
+pushing from git. For more information and installation instruction,
+please see:
+
+    https://github.com/glandium/git-cinnabar
+""".lstrip()
+
+HG_PUSH_TO_TRY_NOT_FOUND = """
+Could not detect `push-to-try`.
+
+The `mach try` command requires the push-to-try extension enabled
+when pushing from hg. Please install it by running:
+
+    $ ./mach mercurial-setup
+""".lstrip()
+
+VCS_NOT_FOUND = """
+Could not detect version control. Only `hg` or `git` are supported.
+""".strip()
+
+UNCOMMITTED_CHANGES = """
+ERROR please commit changes before continuing
+""".strip()
+
+
+class VCSHelper(object):
+    """A abstract base VCS helper that detects hg or git"""
+    __metaclass__ = ABCMeta
+
+    def __init__(self, root):
+        self.root = root
+
+    @classmethod
+    def find_vcs(cls):
+        # First check if we're in an hg repo, if not try git
+        commands = (
+            ['hg', 'root'],
+            ['git', 'rev-parse', '--show-toplevel'],
+        )
+
+        for cmd in commands:
+            proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+            output = proc.communicate()[0].strip()
+
+            if proc.returncode == 0:
+                return cmd[0], output
+        return None, ''
+
+    @classmethod
+    def create(cls):
+        vcs, root = cls.find_vcs()
+        if not vcs:
+            print(VCS_NOT_FOUND)
+            sys.exit(1)
+        return vcs_class[vcs](root)
+
+    def run(self, cmd):
+        try:
+            return subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+        except subprocess.CalledProcessError as e:
+            print("Error running `{}`:".format(' '.join(cmd)))
+            print(e.output)
+            raise
+
+    def write_task_config(self, labels):
+        config = os.path.join(self.root, 'try_task_config.json')
+        with open(config, 'w') as fh:
+            json.dump(labels, fh, indent=2)
+        return config
+
+    def check_working_directory(self):
+        if self.has_uncommitted_changes:
+            print(UNCOMMITTED_CHANGES)
+            sys.exit(1)
+
+    @abstractmethod
+    def push_to_try(self, msg, labels=None):
+        pass
+
+    @abstractproperty
+    def files_changed(self):
+        pass
+
+    @abstractproperty
+    def has_uncommitted_changes(self):
+        pass
+
+
+class HgHelper(VCSHelper):
+
+    def push_to_try(self, msg, labels=None):
+        self.check_working_directory()
+
+        if labels:
+            config = self.write_task_config(labels)
+            self.run(['hg', 'add', config])
+
+        try:
+            subprocess.check_call(['hg', 'push-to-try', '-m', msg])
+        except subprocess.CalledProcessError:
+            try:
+                self.run(['hg', 'showconfig', 'extensions.push-to-try'])
+            except subprocess.CalledProcessError:
+                print(HG_PUSH_TO_TRY_NOT_FOUND)
+                sys.exit(1)
+            raise
+        finally:
+            self.run(['hg', 'revert', '-a'])
+
+            if labels and os.path.isfile(config):
+                os.remove(config)
+
+    @property
+    def files_changed(self):
+        return self.run(['hg', 'log', '-r', '::. and not public()',
+                         '--template', '{join(files, "\n")}\n'])
+
+    @property
+    def has_uncommitted_changes(self):
+        stat = [s for s in self.run(['hg', 'status', '-amrn']).split() if s]
+        return len(stat) > 0
+
+
+class GitHelper(VCSHelper):
+
+    def push_to_try(self, msg, labels=None):
+        self.check_working_directory()
+
+        if not find_executable('git-cinnabar'):
+            print(GIT_CINNABAR_NOT_FOUND)
+            sys.exit(1)
+
+        if labels:
+            config = self.write_task_config(labels)
+            self.run(['git', 'add', config])
+
+        self.run(['git', 'commit', '--allow-empty', '-m', msg])
+        try:
+            self.run(['git', 'push', 'hg::ssh://hg.mozilla.org/try',
+                      '+HEAD:refs/heads/branches/default/tip'])
+        finally:
+            self.run(['git', 'reset', 'HEAD~'])
+
+    @property
+    def files_changed(self):
+        # This finds the files changed on the current branch based on the
+        # diff of the current branch its merge-base base with other branches.
+        current_branch = self.run(['git', 'rev-parse', 'HEAD']).strip()
+        all_branches = self.run(['git', 'for-each-ref', 'refs/heads', 'refs/remotes',
+                                 '--format=%(objectname)']).splitlines()
+        other_branches = set(all_branches) - set([current_branch])
+        base_commit = self.run(['git', 'merge-base', 'HEAD'] + list(other_branches)).strip()
+        return self.run(['git', 'diff', '--name-only', '-z', 'HEAD',
+                         base_commit]).strip('\0').split('\0')
+
+    @property
+    def has_uncommitted_changes(self):
+        stat = [s for s in self.run(['git', 'diff', '--cached', '--name-only',
+                                     '--diff-filter=AMD']).split() if s]
+        return len(stat) > 0
+
+
+vcs_class = {
+    'git': GitHelper,
+    'hg': HgHelper,
+}