Merge lp:~mwhudson/lava-dashboard/compare-testrun-view into lp:lava-dashboard

Proposed by Michael Hudson-Doyle
Status: Merged
Merged at revision: 385
Proposed branch: lp:~mwhudson/lava-dashboard/compare-testrun-view
Merge into: lp:lava-dashboard
Diff against target: 569 lines (+439/-3)
9 files modified
dashboard_app/filters.py (+16/-0)
dashboard_app/static/css/filter-detail.css (+52/-0)
dashboard_app/static/js/filter-detail.js (+135/-0)
dashboard_app/templates/dashboard_app/filter_compare_matches.html (+28/-0)
dashboard_app/templates/dashboard_app/filter_detail.html (+19/-0)
dashboard_app/urls.py (+2/-1)
dashboard_app/views/__init__.py (+1/-1)
dashboard_app/views/filters/tables.py (+35/-0)
dashboard_app/views/filters/views.py (+151/-1)
To merge this branch: bzr merge lp:~mwhudson/lava-dashboard/compare-testrun-view
Reviewer Review Type Date Requested Status
Andy Doan (community) Approve
Review via email: mp+142421@code.launchpad.net

Description of the change

This branch adds a way to compare matches of a filter. You can play with it at http://staging.validation.linaro.org/dashboard/filters/~mwhudson/pmqa-vexpress -- click the compare builds button at the bottom and go from there.

The js stuff to access the page is slightly hackish, but it seems to work for me...

To post a comment you must log in.
402. By Michael Hudson-Doyle

merge trunk

403. By Michael Hudson-Doyle

start to make compare builds selection a bit more ordinary

404. By Michael Hudson-Doyle

finish that

405. By Michael Hudson-Doyle

one more

406. By Michael Hudson-Doyle

fixes++

Revision history for this message
Michael Hudson-Doyle (mwhudson) wrote :

I've tried to make the way of getting to the comparison page a little less odd. Let me know what you think.

407. By Michael Hudson-Doyle

move js and css to separate files

408. By Michael Hudson-Doyle

css tweak

Revision history for this message
Andy Doan (doanac) wrote :

I think this works. You've now passed your job interview question with this algorithm

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'dashboard_app/filters.py'
2--- dashboard_app/filters.py 2012-12-18 00:47:08 +0000
3+++ dashboard_app/filters.py 2013-01-10 20:39:21 +0000
4@@ -300,6 +300,22 @@
5 q = self.queryset.filter(bundle__uploaded_on__gt=since)
6 return self._wrap(q)
7
8+ def with_tags(self, tag1, tag2):
9+ if self.key == 'build_number':
10+ q = self.queryset.extra(
11+ where=['convert_to_integer("dashboard_app_namedattribute"."value") in (%s, %s)' % (tag1, tag2)]
12+ )
13+ else:
14+ tag1 = datetime.datetime.strptime(tag1, "%Y-%m-%d %H:%M:%S.%f")
15+ tag2 = datetime.datetime.strptime(tag2, "%Y-%m-%d %H:%M:%S.%f")
16+ q = self.queryset.filter(bundle__uploaded_on__in=(tag1, tag2))
17+ matches = list(self._wrap(q))
18+ if matches[0].tag == tag1:
19+ return matches
20+ else:
21+ matches.reverse()
22+ return matches
23+
24 def count(self):
25 return self.queryset.count()
26
27
28=== added file 'dashboard_app/static/css/filter-detail.css'
29--- dashboard_app/static/css/filter-detail.css 1970-01-01 00:00:00 +0000
30+++ dashboard_app/static/css/filter-detail.css 2013-01-10 20:39:21 +0000
31@@ -0,0 +1,52 @@
32+table.select-compare1 td { cursor: pointer; }
33+table.select-compare1 tr.even td {
34+ background-color: #ccf;
35+}
36+table.select-compare1 tr.even.hover td {
37+ background-color: #77f;
38+}
39+table.select-compare1 tr.odd td {
40+ background-color: #aaf;
41+}
42+table.select-compare1 tr.odd.hover td {
43+ background-color: #77f;
44+}
45+
46+table.select-compare2 td { cursor: pointer; }
47+table.select-compare2 tr.even td {
48+ background-color: #fcc;
49+}
50+table.select-compare2 tr.odd td {
51+ background-color: #faa;
52+}
53+table.select-compare2 tr.selected-1 td {
54+ background-color: #77f;
55+}
56+table.select-compare2 tr.selected-1.hover td {
57+ background-color: #77f;
58+}
59+table.select-compare2 tr.hover td {
60+ background-color: #f77;
61+}
62+table.select-compare3 tr.selected-1 td {
63+ background-color: #77f;
64+}
65+table.select-compare3 tr.selected-1.hover td {
66+ background-color: #77f;
67+}
68+table.select-compare3 tr.selected-2 td {
69+ background-color: #f77;
70+}
71+table.select-compare3 tr.selected-2.hover td {
72+ background-color: #f77;
73+}
74+table.select-compare3 tr.selected-1 {
75+ cursor: pointer;
76+}
77+table.select-compare3 tr.selected-2 {
78+ cursor: pointer;
79+}
80+#filter-table input {
81+ margin-top: 0;
82+ margin-bottom: 0;
83+}
84\ No newline at end of file
85
86=== added file 'dashboard_app/static/js/filter-detail.js'
87--- dashboard_app/static/js/filter-detail.js 1970-01-01 00:00:00 +0000
88+++ dashboard_app/static/js/filter-detail.js 2013-01-10 20:39:21 +0000
89@@ -0,0 +1,135 @@
90+var compareState = 0;
91+var compare1 = null, compare2 = null;
92+function cancelCompare () {
93+ $("#filter-table").removeClass("select-compare1");
94+ $("#filter-table").removeClass("select-compare2");
95+ $("#filter-table").removeClass("select-compare3");
96+ $("#filter-table tr").removeClass("selected-1");
97+ $("#filter-table tr").removeClass("selected-2");
98+ $("#filter-table tr").unbind("click");
99+ $("#filter-table tr").unbind("hover");
100+ $("#filter-table tr").each(removeCheckbox);
101+ $("#first-prompt").hide();
102+ $("#second-prompt").hide();
103+ $("#third-prompt").hide();
104+ $("#compare-button").button({label:"Compare builds"});
105+ compareState = 0;
106+}
107+function startCompare () {
108+ $("#compare-button").button({label:"Cancel"});
109+ $("#filter-table").addClass("select-compare1");
110+ $("#filter-table tr").click(rowClickHandler);
111+ $("#filter-table tr").each(insertCheckbox);
112+ $("#filter-table tr").hover(rowHoverHandlerIn, rowHoverHandlerOut);
113+ $("#first-prompt").show();
114+ compareState = 1;
115+}
116+function tagFromRow(tr) {
117+ var firstCell = $(tr).find("td:eq(0)");
118+ return {
119+ machinetag: firstCell.find("span").data("machinetag"),
120+ usertag: firstCell.text()
121+ };
122+}
123+function rowClickHandler() {
124+ if (compareState == 1) {
125+ compare1 = tagFromRow($(this));
126+ $(this).addClass("selected-1");
127+ $(this).find("input").attr("checked", true);
128+ $("#p2-build").text(compare1.usertag);
129+ $("#first-prompt").hide();
130+ $("#second-prompt").show();
131+ $("#filter-table").removeClass("select-compare1");
132+ $("#filter-table").addClass("select-compare2");
133+ compareState = 2;
134+ } else if (compareState == 2) {
135+ var thistag = tagFromRow($(this));
136+ if (compare1.machinetag == thistag.machinetag) {
137+ cancelCompare();
138+ startCompare();
139+ } else {
140+ compare2 = thistag;
141+ $(this).find("input").attr("checked", true);
142+ $(this).addClass("selected-2");
143+ $("#second-prompt").hide();
144+ $("#third-prompt").show();
145+ $("#filter-table").removeClass("select-compare2");
146+ $("#filter-table").addClass("select-compare3");
147+ $("#filter-table input").attr("disabled", true);
148+ $("#filter-table .selected-1 input").attr("disabled", false);
149+ $("#filter-table .selected-2 input").attr("disabled", false);
150+ $("#p3-build-1").text(compare1.usertag);
151+ $("#p3-build-2").text(compare2.usertag);
152+ $("#third-prompt a").attr("href", window.location + '/+compare/' + compare1.machinetag + '/' + compare2.machinetag);
153+ compareState = 3;
154+ }
155+ } else if (compareState == 3) {
156+ var thistag = tagFromRow($(this));
157+ if (thistag.machinetag == compare1.machinetag || thistag.machinetag == compare2.machinetag) {
158+ $("#second-prompt").show();
159+ $("#third-prompt").hide();
160+ $("#filter-table").addClass("select-compare2");
161+ $("#filter-table").removeClass("select-compare3");
162+ $("#filter-table input").attr("disabled", false);
163+ compareState = 2;
164+ $(this).find("input").attr("checked", false);
165+ if (thistag.machinetag == compare1.machinetag) {
166+ compare1 = compare2;
167+ $("#filter-table .selected-1").removeClass("selected-1");
168+ $("#filter-table .selected-2").addClass("selected-1");
169+ $("#p2-build").text(compare1.usertag);
170+ }
171+ $("#filter-table .selected-2").removeClass("selected-2");
172+ }
173+ }
174+ tagFromRow(this);
175+}
176+function rowHoverHandlerIn() {
177+ $(this).addClass("hover");
178+}
179+function rowHoverHandlerOut() {
180+ $(this).removeClass("hover");
181+}
182+function insertCheckbox() {
183+ var row = $(this);
184+ var checkbox = $('<input type="checkbox">');
185+ row.find("td:first").prepend(checkbox);
186+}
187+function removeCheckbox() {
188+ var row = $(this);
189+ row.find('input').remove();
190+}
191+$(window).load(
192+ function () {
193+ $("#filter-table").dataTable().fnSettings().fnRowCallback = function(tr, data, index) {
194+ if (compareState) {
195+ insertCheckbox.call(tr);
196+ $(tr).click(rowClickHandler);
197+ $("#filter-table tr").hover(rowHoverHandlerIn, rowHoverHandlerOut);
198+ if (compareState >= 2 && tagFromRow(tr).machinetag == compare1.machinetag) {
199+ $(tr).addClass("selected-1");
200+ $(tr).find("input").attr("checked", true);
201+ }
202+ if (compareState >= 3) {
203+ if (tagFromRow(tr).machinetag == compare2.machinetag) {
204+ $(tr).addClass("selected-2");
205+ $(tr).find("input").attr("checked", true);
206+ } else if (tagFromRow(tr).machinetag != compare1.machinetag) {
207+ $(tr).find("input").attr("disabled", true);
208+ }
209+ }
210+ }
211+ return tr;
212+ };
213+ $("#compare-button").button();
214+ $("#compare-button").click(
215+ function (e) {
216+ if (compareState == 0) {
217+ startCompare();
218+ } else {
219+ cancelCompare();
220+ }
221+ }
222+ );
223+ }
224+);
225
226=== added file 'dashboard_app/templates/dashboard_app/filter_compare_matches.html'
227--- dashboard_app/templates/dashboard_app/filter_compare_matches.html 1970-01-01 00:00:00 +0000
228+++ dashboard_app/templates/dashboard_app/filter_compare_matches.html 2013-01-10 20:39:21 +0000
229@@ -0,0 +1,28 @@
230+{% extends "dashboard_app/_content.html" %}
231+
232+{% load django_tables2 %}
233+
234+{% block extrahead %}
235+{{ block.super }}
236+<style type="text/css">
237+ th.orderable.sortable a {
238+ color: rgb(0, 136, 204);
239+ text-decoration: underline;
240+ }
241+</style>
242+{% endblock %}
243+
244+{% block content %}
245+{% for trinfo in test_run_info %}
246+<h3>{{ trinfo.key }} results</h3>
247+{% if trinfo.only %}
248+<p style="text-align: {{ trinfo.only }}">
249+ Results were only present in <a href="{{ trinfo.tr.get_absolute_url }}">build {{ trinfo.tag }}</a>.
250+</p>
251+{% elif trinfo.table %}
252+{% render_table trinfo.table %}
253+{% else %}
254+<p>No difference in {{ trinfo.key }} results{% if trinfo.cases %} for {{ trinfo.cases }}{% endif %}.</p>
255+{% endif %}
256+{% endfor %}
257+{% endblock %}
258
259=== modified file 'dashboard_app/templates/dashboard_app/filter_detail.html'
260--- dashboard_app/templates/dashboard_app/filter_detail.html 2013-01-09 00:05:14 +0000
261+++ dashboard_app/templates/dashboard_app/filter_detail.html 2013-01-10 20:39:21 +0000
262@@ -2,6 +2,12 @@
263 {% load i18n %}
264 {% load django_tables2 %}
265
266+{% block extrahead %}
267+{{ block.super }}
268+<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}dashboard_app/css/filter-detail.css"/>
269+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/filter-detail.js"></script>
270+{% endblock %}
271+
272 {% block content %}
273
274 <h1>Filter {{ filter.name }}</h1>
275@@ -27,4 +33,17 @@
276
277 {% render_table filter_table %}
278
279+<p>
280+ <button id="compare-button">Compare builds</button>
281+ <span id="first-prompt" style="display:none">
282+ Click a build to compare.
283+ </span>
284+ <span id="second-prompt" style="display:none">
285+ Click build to compare with build <span id="p2-build">XXX</span>.
286+ </span>
287+ <span id="third-prompt" style="display:none">
288+ Click <a href="#">here</a> to compare with build <span id="p3-build-1">XXX</span> with build <span id="p3-build-2">XXX</span>.
289+ </span>
290+</p>
291+
292 {% endblock %}
293
294=== modified file 'dashboard_app/urls.py'
295--- dashboard_app/urls.py 2012-12-11 02:01:37 +0000
296+++ dashboard_app/urls.py 2013-01-10 20:39:21 +0000
297@@ -45,7 +45,8 @@
298 url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+edit$', 'filters.views.filter_edit'),
299 url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+subscribe$', 'filters.views.filter_subscribe'),
300 url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+delete$', 'filters.views.filter_delete'),
301- url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler,
302+ url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+compare/(?P<tag1>[a-zA-Z0-9-_: .]+)/(?P<tag2>[a-zA-Z0-9-_: .]+)$', 'filters.views.compare_matches'),
303+ url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler,
304 name='dashboard_app.views.dashboard_xml_rpc_handler',
305 kwargs={
306 'mapper': legacy_mapper,
307
308=== modified file 'dashboard_app/views/__init__.py'
309--- dashboard_app/views/__init__.py 2012-12-17 00:10:53 +0000
310+++ dashboard_app/views/__init__.py 2013-01-10 20:39:21 +0000
311@@ -38,7 +38,6 @@
312 )
313 from django.shortcuts import render_to_response, redirect, get_object_or_404
314 from django.template import RequestContext, loader
315-from django.utils.html import escape
316 from django.utils.safestring import mark_safe
317 from django.views.generic.list_detail import object_list, object_detail
318
319@@ -825,3 +824,4 @@
320 pk=effort.pk)
321 })
322 return HttpResponse(t.render(c))
323+
324
325=== modified file 'dashboard_app/views/filters/tables.py'
326--- dashboard_app/views/filters/tables.py 2012-12-13 00:51:07 +0000
327+++ dashboard_app/views/filters/tables.py 2013-01-10 20:39:21 +0000
328@@ -16,8 +16,11 @@
329 # You should have received a copy of the GNU Affero General Public License
330 # along with Launch Control. If not, see <http://www.gnu.org/licenses/>.
331
332+import datetime
333 import operator
334
335+from django.conf import settings
336+from django.template import defaultfilters
337 from django.utils.html import escape
338 from django.utils.safestring import mark_safe
339
340@@ -184,6 +187,12 @@
341 self.base_columns.insert(0, 'bundle_stream', bundle_stream_col)
342 self.base_columns.insert(0, 'tag', tag_col)
343
344+ def render_tag(self, value):
345+ if isinstance(value, datetime.datetime):
346+ strvalue = defaultfilters.date(value, settings.DATETIME_FORMAT)
347+ else:
348+ strvalue = value
349+ return mark_safe('<span data-machinetag="%s">%s</span>' % (escape(str(value)), strvalue))
350 tag = Column()
351
352 def render_bundle_stream(self, record):
353@@ -225,3 +234,29 @@
354 datatable_opts.update({
355 "iDisplayLength": 10,
356 })
357+
358+
359+class TestResultDifferenceTable(DataTablesTable):
360+ test_case_id = Column(verbose_name=mark_safe('test_case_id'))
361+ first_result = TemplateColumn('''
362+ {% if record.first_result %}
363+ <img src="{{ STATIC_URL }}dashboard_app/images/icon-{{ record.first_result }}.png"
364+ alt="{{ record.first_result }}" width="16" height="16" border="0"/>{{ record.first_result }}
365+ {% else %}
366+ <i>missing</i>
367+ {% endif %}
368+ ''')
369+ second_result = TemplateColumn('''
370+ {% if record.second_result %}
371+ <img src="{{ STATIC_URL }}dashboard_app/images/icon-{{ record.second_result }}.png"
372+ alt="{{ record.second_result }}" width="16" height="16" border="0"/>{{ record.second_result }}
373+ {% else %}
374+ <i>missing</i>
375+ {% endif %}
376+ ''')
377+
378+ datatable_opts = {
379+ 'iDisplayLength': 25,
380+ 'sPaginationType': "full_numbers",
381+ }
382+
383
384=== modified file 'dashboard_app/views/filters/views.py'
385--- dashboard_app/views/filters/views.py 2012-11-27 05:07:00 +0000
386+++ dashboard_app/views/filters/views.py 2013-01-10 20:39:21 +0000
387@@ -25,12 +25,17 @@
388 from django.http import HttpResponse, HttpResponseRedirect
389 from django.shortcuts import render_to_response
390 from django.template import RequestContext
391+from django.utils.html import escape
392+from django.utils.safestring import mark_safe
393
394 from lava_server.bread_crumbs import (
395 BreadCrumb,
396 BreadCrumbTrail,
397 )
398
399+from dashboard_app.filters import (
400+ evaluate_filter,
401+ )
402 from dashboard_app.models import (
403 NamedAttribute,
404 Test,
405@@ -39,7 +44,9 @@
406 TestRunFilter,
407 TestRunFilterSubscription,
408 )
409-from dashboard_app.views import index
410+from dashboard_app.views import (
411+ index,
412+ )
413 from dashboard_app.views.filters.forms import (
414 TestRunFilterForm,
415 TestRunFilterSubscriptionForm,
416@@ -48,6 +55,7 @@
417 FilterTable,
418 FilterPreviewTable,
419 PublicFiltersTable,
420+ TestResultDifferenceTable,
421 UserFiltersTable,
422 )
423
424@@ -248,3 +256,145 @@
425 json.dumps(list(result)),
426 mimetype='application/json')
427
428+
429+def _iter_matching(seq1, seq2, key):
430+ """Iterate over sequences in the order given by the key function, matching
431+ elements with matching key values.
432+
433+ For example:
434+
435+ >>> seq1 = [(1, 2), (2, 3)]
436+ >>> seq2 = [(1, 3), (3, 4)]
437+ >>> def key(pair): return pair[0]
438+ >>> list(_iter_matching(seq1, seq2, key))
439+ [(1, (1, 2), (1, 3)), (2, (2, 3), None), (3, None, (3, 4))]
440+ """
441+ seq1.sort(key=key)
442+ seq2.sort(key=key)
443+ sentinel = object()
444+ def next(it):
445+ try:
446+ o = it.next()
447+ return (key(o), o)
448+ except StopIteration:
449+ return (sentinel, None)
450+ iter1 = iter(seq1)
451+ iter2 = iter(seq2)
452+ k1, o1 = next(iter1)
453+ k2, o2 = next(iter2)
454+ while k1 is not sentinel or k2 is not sentinel:
455+ if k1 is sentinel:
456+ yield (k2, None, o2)
457+ k2, o2 = next(iter2)
458+ elif k2 is sentinel:
459+ yield (k1, o1, None)
460+ k1, o1 = next(iter1)
461+ elif k1 == k2:
462+ yield (k1, o1, o2)
463+ k1, o1 = next(iter1)
464+ k2, o2 = next(iter2)
465+ elif k1 < k2:
466+ yield (k1, o1, None)
467+ k1, o1 = next(iter1)
468+ else: # so k1 > k2...
469+ yield (k2, None, o2)
470+ k2, o2 = next(iter2)
471+
472+
473+def _test_run_difference(test_run1, test_run2, cases=None):
474+ test_results1 = list(test_run1.test_results.all().select_related('test_case'))
475+ test_results2 = list(test_run2.test_results.all().select_related('test_case'))
476+ def key(tr):
477+ return tr.test_case.test_case_id
478+ differences = []
479+ for tc_id, tc1, tc2 in _iter_matching(test_results1, test_results2, key):
480+ if cases is not None and tc_id not in cases:
481+ continue
482+ if tc1:
483+ tc1 = tc1.result_code
484+ if tc2:
485+ tc2 = tc2.result_code
486+ if tc1 != tc2:
487+ differences.append({
488+ 'test_case_id': tc_id,
489+ 'first_result': tc1,
490+ 'second_result': tc2,
491+ })
492+ return differences
493+
494+
495+@BreadCrumb(
496+ "Comparing builds {tag1} and {tag2}",
497+ parent=filter_detail,
498+ needs=['username', 'name', 'tag1', 'tag2'])
499+def compare_matches(request, username, name, tag1, tag2):
500+ filter = TestRunFilter.objects.get(owner__username=username, name=name)
501+ if not filter.public and filter.owner != request.user:
502+ raise PermissionDenied()
503+ filter_data = filter.as_data()
504+ matches = evaluate_filter(request.user, filter_data)
505+ match1, match2 = matches.with_tags(tag1, tag2)
506+ test_cases_for_test_id = {}
507+ for test in filter_data['tests']:
508+ test_cases = test['test_cases']
509+ if test_cases:
510+ test_cases = set([tc.test_case_id for tc in test_cases])
511+ else:
512+ test_cases = None
513+ test_cases_for_test_id[test['test'].test_id] = test_cases
514+ test_run_info = []
515+ def key(tr):
516+ return tr.test.test_id
517+ for key, tr1, tr2 in _iter_matching(match1.test_runs, match2.test_runs, key):
518+ if tr1 is None:
519+ table = None
520+ only = 'right'
521+ tr = tr2
522+ tag = tag2
523+ cases = None
524+ elif tr2 is None:
525+ table = None
526+ only = 'left'
527+ tr = tr1
528+ tag = tag1
529+ cases = None
530+ else:
531+ only = None
532+ tr = None
533+ tag = None
534+ cases = test_cases_for_test_id.get(key)
535+ test_result_differences = _test_run_difference(tr1, tr2, cases)
536+ if test_result_differences:
537+ table = TestResultDifferenceTable(
538+ "test-result-difference-" + escape(key), data=test_result_differences)
539+ table.base_columns['first_result'].verbose_name = mark_safe(
540+ '<a href="%s">build %s: %s</a>' % (
541+ tr1.get_absolute_url(), escape(tag1), escape(key)))
542+ table.base_columns['second_result'].verbose_name = mark_safe(
543+ '<a href="%s">build %s: %s</a>' % (
544+ tr2.get_absolute_url(), escape(tag2), escape(key)))
545+ else:
546+ table = None
547+ if cases:
548+ cases = sorted(cases)
549+ if len(cases) > 1:
550+ cases = ', '.join(cases[:-1]) + ' or ' + cases[-1]
551+ else:
552+ cases = cases[0]
553+ test_run_info.append(dict(
554+ only=only,
555+ key=key,
556+ table=table,
557+ tr=tr,
558+ tag=tag,
559+ cases=cases))
560+ return render_to_response(
561+ "dashboard_app/filter_compare_matches.html", {
562+ 'test_run_info': test_run_info,
563+ 'bread_crumb_trail': BreadCrumbTrail.leading_to(
564+ compare_matches,
565+ name=name,
566+ username=username,
567+ tag1=tag1,
568+ tag2=tag2),
569+ }, RequestContext(request))

Subscribers

People subscribed via source and target branches

to all changes: