Bug 1499822 - [tryselect] Implement |mach try chooser| r=sclements

Usage:

  $ ./mach try chooser

Will start a local flask server and server a "trychooser-like" page
that is dynamically generated from the taskgraph.

Differential Revision: https://phabricator.services.mozilla.com/D14903

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Andrew Halberstadt 2019-01-09 17:04:39 +00:00
Родитель b578a9b3ce
Коммит 414d48031e
13 изменённых файлов: 670 добавлений и 0 удалений

Просмотреть файл

@ -358,6 +358,7 @@ toolkit/components/reader/Readerable.jsm
toolkit/content/widgets/wizard.xml toolkit/content/widgets/wizard.xml
toolkit/modules/AppConstants.jsm toolkit/modules/AppConstants.jsm
toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js
tools/tryselect/selectors/chooser/templates/chooser.html
# Third party # Third party
toolkit/modules/third_party/** toolkit/modules/third_party/**

Просмотреть файл

@ -0,0 +1,33 @@
Chooser Selector
================
When pushing to try, there are a very large amount of builds and tests to choose from. Often too
many to remember, making it easy to forget a set of tasks which should otherwise have been run.
This selector allows you to select tasks from a web interface that lists all the possible build and
test tasks and allows you to select them from a list. It is similar in concept to the old `try
syntax chooser`_ page, except that the values are dynamically generated using the `taskgraph`_ as an
input. This ensures that it will never be out of date.
To use:
.. code-block:: shell
$ mach try chooser
This will spin up a local web server (using Flask) which serves the chooser app. After making your
selection, simply press ``Push`` and the rest will be handled from there. No need to copy/paste any
syntax strings or the like.
You can run:
.. code-block:: shell
$ mach try chooser --full
To generate the interface using the full taskgraph instead. This will include tasks that don't run
on mozilla-central.
.. _try syntax chooser: https://mozilla-releng.net/trychooser
.. _taskgraph: https://firefox-source-docs.mozilla.org/taskcluster/taskcluster/index.html

Просмотреть файл

@ -3,6 +3,7 @@ Selectors
These are the currently implemented try selectors: These are the currently implemented try selectors:
* :doc:`chooser <chooser>`: Select tasks using a web interface.
* :doc:`fuzzy <fuzzy>`: Select tasks using a fuzzy finding algorithm and * :doc:`fuzzy <fuzzy>`: Select tasks using a fuzzy finding algorithm and
a terminal interface. a terminal interface.
* :doc:`again <again>`: Re-run a previous ``try_task_config.json`` based * :doc:`again <again>`: Re-run a previous ``try_task_config.json`` based
@ -30,6 +31,7 @@ See selector specific options by running:
:maxdepth: 1 :maxdepth: 1
:hidden: :hidden:
Chooser <chooser>
Fuzzy <fuzzy> Fuzzy <fuzzy>
Again <again> Again <again>
Empty <empty> Empty <empty>

Просмотреть файл

@ -149,6 +149,26 @@ class TrySelect(MachCommandBase):
from tryselect.selectors.fuzzy import run_fuzzy_try from tryselect.selectors.fuzzy import run_fuzzy_try
return run_fuzzy_try(**kwargs) return run_fuzzy_try(**kwargs)
@SubCommand('try',
'chooser',
description='Schedule tasks by selecting them from a web '
'interface.',
parser=get_parser('chooser'))
def try_chooser(self, **kwargs):
"""Push tasks selected from a web interface to try.
This selector will build the taskgraph and spin up a dynamically
created 'trychooser-like' web-page on the localhost. After a selection
has been made, pressing the 'Push' button will automatically push the
selection to try.
"""
self._activate_virtualenv()
self.virtualenv_manager.install_pip_package('flask')
self.virtualenv_manager.install_pip_package('flask-wtf')
from tryselect.selectors.chooser import run_try_chooser
return run_try_chooser(**kwargs)
@SubCommand('try', @SubCommand('try',
'again', 'again',
description='Schedule a previously generated (non try syntax) ' description='Schedule a previously generated (non try syntax) '

Просмотреть файл

@ -0,0 +1,12 @@
"use strict";
module.exports = {
env: {
"jquery": true
},
globals: {
"apply": true,
"applyChunks": true,
"tasks": true
}
};

Просмотреть файл

@ -0,0 +1,50 @@
# 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
import webbrowser
from threading import Timer
from tryselect.cli import BaseTryParser
from tryselect.tasks import generate_tasks
from tryselect.push import check_working_directory, push_to_try, vcs
here = os.path.abspath(os.path.dirname(__file__))
class ChooserParser(BaseTryParser):
name = 'chooser'
arguments = []
common_groups = ['push', 'task']
templates = ['artifact', 'env', 'rebuild', 'chemspill-prio', 'gecko-profile']
def run_try_chooser(update=False, query=None, templates=None, full=False, parameters=None,
save=False, preset=None, mod_presets=False, push=True, message='{msg}',
**kwargs):
from .app import create_application
check_working_directory(push)
tg = generate_tasks(parameters, full, root=vcs.path)
app = create_application(tg)
if os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
# we are in the reloader process, don't open the browser or do any try stuff
app.run()
return
# give app a second to start before opening the browser
Timer(1, lambda: webbrowser.open('http://127.0.0.1:5000')).start()
app.run()
selected = app.tasks
if not selected:
print("no tasks selected")
return
msg = "Try Chooser Enhanced ({} tasks selected)".format(len(selected))
return push_to_try('chooser', message.format(msg=msg), selected, templates, push=push,
closed_tree=kwargs["closed_tree"])

Просмотреть файл

@ -0,0 +1,190 @@
# 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
from abc import ABCMeta, abstractproperty
from collections import defaultdict
from flask import (
Flask,
render_template,
request,
)
SECTIONS = []
SUPPORTED_KINDS = set()
def register_section(cls):
assert issubclass(cls, Section)
instance = cls()
SECTIONS.append(instance)
SUPPORTED_KINDS.update(instance.kind.split(','))
class Section(object):
__metaclass__ = ABCMeta
@abstractproperty
def name(self):
pass
@abstractproperty
def kind(self):
pass
@abstractproperty
def title(self):
pass
@abstractproperty
def attrs(self):
pass
def contains(self, task):
return task.kind in self.kind.split(',')
def get_context(self, tasks):
labels = defaultdict(lambda: {'max_chunk': 0, 'attrs': defaultdict(list)})
for task in tasks.values():
if not self.contains(task):
continue
task = task.attributes
label = labels[self.labelfn(task)]
for attr in self.attrs:
if attr in task and task[attr] not in label['attrs'][attr]:
label['attrs'][attr].append(task[attr])
if 'test_chunk' in task:
label['max_chunk'] = max(label['max_chunk'], int(task['test_chunk']))
return {
'name': self.name,
'kind': self.kind,
'title': self.title,
'labels': labels,
}
@register_section
class Platform(Section):
name = 'platform'
kind = 'build'
title = 'Platforms'
attrs = ['build_platform']
def labelfn(self, task):
return task['build_platform']
def contains(self, task):
if not Section.contains(self, task):
return False
# android-stuff tasks aren't actual platforms
return task.task['tags'].get('android-stuff', False) != "true"
@register_section
class Test(Section):
name = 'test'
kind = 'test'
title = 'Test Suites'
attrs = ['unittest_suite', 'unittest_flavor']
def labelfn(self, task):
suite = task['unittest_suite'].replace(' ', '-')
flavor = task['unittest_flavor'].replace(' ', '-')
if flavor.endswith('chunked'):
flavor = flavor[:-len('chunked')]
if flavor.startswith(suite):
flavor = flavor[len(suite):]
flavor = flavor.strip('-')
if flavor in ('crashtest', 'jsreftest'):
return flavor
if flavor:
return '{}-{}'.format(suite, flavor)
return suite
def contains(self, task):
if not Section.contains(self, task):
return False
return task.attributes['unittest_suite'] not in ('raptor', 'talos')
@register_section
class Perf(Section):
name = 'perf'
kind = 'test'
title = 'Performance'
attrs = ['unittest_suite', 'unittest_flavor', 'raptor_try_name', 'talos_try_name']
def labelfn(self, task):
suite = task['unittest_suite']
label = task['{}_try_name'.format(suite)]
if not label.startswith(suite):
label = '{}-{}'.format(suite, label)
if label.endswith('-e10s'):
label = label[:-len('-e10s')]
return label
def contains(self, task):
if not Section.contains(self, task):
return False
return task.attributes['unittest_suite'] in ('raptor', 'talos')
@register_section
class Analysis(Section):
name = 'analysis'
kind = 'build,static-analysis-autotest'
title = 'Analysis'
attrs = ['build_platform']
def labelfn(self, task):
return task['build_platform']
def contains(self, task):
if not Section.contains(self, task):
return False
if task.kind == 'build':
return task.task['tags'].get('android-stuff', False) == "true"
return True
def create_application(tg):
tasks = {l: t for l, t in tg.tasks.items() if t.kind in SUPPORTED_KINDS}
sections = [s.get_context(tasks) for s in SECTIONS]
context = {
'tasks': {l: t.attributes for l, t in tasks.items()},
'sections': sections,
}
app = Flask(__name__)
app.env = 'development'
app.tasks = []
@app.route('/', methods=['GET', 'POST'])
def chooser():
if request.method == 'GET':
return render_template('chooser.html', **context)
if request.form['action'] == 'Push':
labels = request.form['selected-tasks'].splitlines()
app.tasks.extend(labels)
shutdown = request.environ.get('werkzeug.server.shutdown')
shutdown()
return render_template('close.html')
return app

Просмотреть файл

@ -0,0 +1,105 @@
const selection = $("#selection")[0];
const count = $("#selection-count")[0];
const pluralize = (count, noun, suffix = "s") =>
`${count} ${noun}${count !== 1 ? suffix : ""}`;
var selected = [];
var updateLabels = () => {
$(".tab-pane.active > .filter-label").each(function(index) {
let box = $("#" + this.htmlFor)[0];
let method = box.checked ? "add" : "remove";
$(this)[method + "Class"]("is-checked");
});
};
var apply = () => {
let filters = {};
let kinds = [];
$(".filter:checked").each(function(index) {
for (let kind of this.name.split(",")) {
if (!(kinds.includes(kind)))
kinds.push(kind);
}
// Checkbox element values are generated by Section.get_context() in app.py
let attrs = JSON.parse(this.value);
for (let attr in attrs) {
if (!(attr in filters))
filters[attr] = [];
let values = attrs[attr];
filters[attr] = filters[attr].concat(values);
}
});
updateLabels();
if (Object.keys(filters).length == 0 || (Object.keys(filters).length == 1 && "build_type" in filters)) {
selection.value = "";
count.innerHTML = "0 tasks selected";
return;
}
var taskMatches = (label) => {
let task = tasks[label];
// If no box for the given kind has been checked, this task is
// automatically not selected.
if (!(kinds.includes(task.kind)))
return false;
for (let attr in filters) {
let values = filters[attr];
if (!(attr in task) || values.includes(task[attr]))
continue;
return false;
}
return true;
};
selected = Object.keys(tasks).filter(taskMatches);
applyChunks();
};
var applyChunks = () => {
// For tasks that have a chunk filter applied, we handle that here.
let filters = {};
$(".filter:text").each(function(index) {
let value = $(this).val();
if (value === "") {
return;
}
let attrs = JSON.parse(this.name);
let key = `${attrs.unittest_suite}-${attrs.unittest_flavor}`;
if (!(key in filters)) {
filters[key] = [];
}
// Parse the chunk strings. These are formatted like printer page setups, e.g: "1,4-6,9"
for (let item of value.split(",")) {
if (!item.includes("-")) {
filters[key].push(parseInt(item));
continue;
}
let [start, end] = item.split("-");
for (let i = parseInt(start); i <= parseInt(end); ++i) {
filters[key].push(i);
}
}
});
let chunked = selected.filter(function(label) {
let task = tasks[label];
let key = task.unittest_suite + "-" + task.unittest_flavor;
if (key in filters && !filters[key].includes(parseInt(task.test_chunk))) {
return false;
}
return true;
});
selection.value = chunked.join("\n");
count.innerText = pluralize(chunked.length, "task") + " selected";
};

Просмотреть файл

@ -0,0 +1,38 @@
const labels = $("label.multiselect");
const boxes = $("label.multiselect input:checkbox");
var lastChecked = {};
// implements shift+click
labels.click(function(e) {
if (e.target.tagName === "INPUT")
return;
let box = $("#" + this.htmlFor)[0];
let activeSection = $("div.tab-pane.active")[0].id;
if (activeSection in lastChecked) {
// Bug 559506 - In Firefox shift/ctrl/alt+clicking a label doesn't check the box.
let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1;
if (e.shiftKey) {
if (isFirefox)
box.checked = !box.checked;
let start = boxes.index(box);
let end = boxes.index(lastChecked[activeSection]);
boxes.slice(Math.min(start, end), Math.max(start, end) + 1).prop("checked", box.checked);
apply();
}
}
lastChecked[activeSection] = box;
});
function selectAll(btn) {
let checked = !!btn.value;
$("div.active label.filter-label").each(function(index) {
$(this).find("input:checkbox")[0].checked = checked;
});
apply();
}

Просмотреть файл

@ -0,0 +1,104 @@
body {
padding-top: 70px;
}
/* Tabs */
#tabbar .nav-link {
color: #009570;
font-size: 18px;
padding-bottom: 15px;
padding-top: 15px;
}
#tabbar .nav-link.active {
color: #212529;
}
#tabbar .nav-link:hover {
color: #0f5a3a;
}
/* Sections */
.tab-content button {
font-size: 14px;
margin-bottom: 5px;
margin-top: 10px;
}
.filter-label {
display: block;
font-size: 16px;
position: relative;
padding-left: 15px;
padding-right: 15px;
padding-top: 10px;
padding-bottom: 10px;
margin-bottom: 0;
-moz-user-select: none;
user-select: none;
vertical-align: middle;
}
.filter-label span {
display: flex;
min-height: 34px;
align-items: center;
justify-content: space-between;
}
.filter-label input[type="checkbox"] {
position: absolute;
opacity: 0;
height: 0;
width: 0;
}
.filter-label input[type="text"] {
width: 50px;
}
.filter-label:hover {
background-color: #91a0b0;
}
.filter-label.is-checked:hover {
background-color: #91a0b0;
}
.filter-label.is-checked {
background-color: #404c59;
color: white;
}
/* Preview pane */
#preview {
position: fixed;
height: 100vh;
margin-left: 66%;
width: 100%;
}
#submit-tasks {
display: flex;
flex-direction: column;
height: 80%;
}
#buttons {
display: flex;
justify-content: space-between;
}
#push {
background-color: #00e9b7;
margin-left: 5px;
width: 100%;
}
#selection {
height: 100%;
width: 100%;
}

Просмотреть файл

@ -0,0 +1,74 @@
{% extends 'layout.html' %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-8">
<div class="form-group form-inline">
<span class="col-form-label col-md-2 pt-1">Build Type</span>
<div class="form-check form-check-inline">
<input id="both" class="filter form-check-input" type="radio" name="buildtype" value='{}' onchange="apply();" checked>
<label for="both" class="form-check-label">both</label>
</div>
{% for type in ["opt", "debug"] %}
<div class="form-check form-check-inline">
<input id="{{ type }}" class="filter form-check-input" type="radio" name="buildtype" value='{"build_type": "{{ type }}"}' onchange="apply();">
<label for={{ type }} class="form-check-label">{{ type }}</label>
</div>
{% endfor %}
</div>
<ul class="nav nav-tabs" id="tabbar" role="tablist">
{% for section in sections %}
<li class="nav-item">
{% if loop.first %}
<a class="nav-link active" id="{{ section.name }}-tab" data-toggle="tab" href="#{{section.name }}" role="tab" aria-controls="{{ section.name }}" aria-selected="true">{{ section.title }}</a>
{% else %}
<a class="nav-link" id="{{ section.name }}-tab" data-toggle="tab" href="#{{section.name }}" role="tab" aria-controls="{{ section.name }}" aria-selected="false">{{ section.title }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
<div class="tab-content">
<button type="button" class="btn btn-secondary" value="true" onclick="selectAll(this);">Select All</button>
<button type="button" class="btn btn-secondary" onclick="selectAll(this);">Deselect All</button>
{% for section in sections %}
{% if loop.first %}
<div class="tab-pane show active" id="{{ section.name }}" role="tabpanel" aria-labelledby="{{ section.name }}-tab">
{% else %}
<div class="tab-pane" id="{{ section.name }}" role="tabpanel" aria-labelledby="{{ section.name }}-tab">
{% endif %}
{% for label, meta in section.labels|dictsort %}
<label class="multiselect filter-label" for={{ label }}>
<span>
{{ label }}
<input class="filter" type="checkbox" id={{ label }} name="{{ section.kind }}" value='{{ meta.attrs|tojson|safe }}' onchange="console.log('checkbox onchange triggered');apply();">
{% if meta.max_chunk > 1 %}
<input class="filter" type="text" pattern="^[0-9][0-9,-]*$" placeholder="1-{{ meta.max_chunk }}" name='{{ meta.attrs|tojson|safe }}' oninput="applyChunks();">
{% endif %}
</span>
</label>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="col-4" id="preview">
<form id="submit-tasks" action="" method="POST">
<textarea id="selection" name="selected-tasks" wrap="off"></textarea>
<span id="selection-count">0 tasks selected</span><br>
<span id="buttons">
<input id="cancel" class="btn btn-default" type="submit" name="action" value="Cancel">
<input id="push" class="btn btn-default" type="submit" name="action" value="Push">
</span>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const tasks = {{ tasks|tojson|safe }};
</script>
<script src="{{ url_for('static', filename='filter.js') }}"></script>
<script src="{{ url_for('static', filename='select.js') }}"></script>
{% endblock %}

Просмотреть файл

@ -0,0 +1,8 @@
{% extends 'layout.html' %}
{% block content %}
<div class="container-fluid">
<div class="alert alert-primary" role="alert">
You may now close this page.
</div>
</div>
{% endblock %}

Просмотреть файл

@ -0,0 +1,33 @@
<html>
<head>
<meta charset="utf-8">
<title>Try Chooser Enhanced</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav class="navbar navbar-default fixed-top navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">Try Chooser Enhanced</span>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="https://firefox-source-docs.mozilla.org/tools/try/index.html">Documentation</a>
</li>
<li class="nav-item">
<a class="nav-link" href="https://treeherder.mozilla.org/#/jobs?repo=try">Treeherder</a>
</li>
</ul>
</div>
</div>
</nav>
{% block content %}{% endblock %}
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
</body>
</html>