=== modified file 'dashboard_app/filters.py'
--- dashboard_app/filters.py	2012-12-18 00:47:08 +0000
+++ dashboard_app/filters.py	2013-01-08 01:27:34 +0000
@@ -300,6 +300,22 @@
             q = self.queryset.filter(bundle__uploaded_on__gt=since)
         return self._wrap(q)
 
+    def with_tags(self, tag1, tag2):
+        if self.key == 'build_number':
+            q = self.queryset.extra(
+                where=['convert_to_integer("dashboard_app_namedattribute"."value") in (%s, %s)' % (tag1, tag2)]
+                )
+        else:
+            tag1 = datetime.datetime.strptime(tag1, "%Y-%m-%d %H:%M:%S.%f")
+            tag2 = datetime.datetime.strptime(tag2, "%Y-%m-%d %H:%M:%S.%f")
+            q = self.queryset.filter(bundle__uploaded_on__in=(tag1, tag2))
+        matches = list(self._wrap(q))
+        if matches[0].tag == tag1:
+            return matches
+        else:
+            matches.reverse()
+            return matches
+
     def count(self):
         return self.queryset.count()
 

=== added file 'dashboard_app/static/css/filter-detail.css'
--- dashboard_app/static/css/filter-detail.css	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/css/filter-detail.css	2013-01-10 20:37:00 +0000
@@ -0,0 +1,52 @@
+table.select-compare1 td { cursor: pointer; }
+table.select-compare1 tr.even td {
+    background-color: #ccf;
+}
+table.select-compare1 tr.even.hover td {
+    background-color: #77f;
+}
+table.select-compare1 tr.odd td {
+    background-color: #aaf;
+}
+table.select-compare1 tr.odd.hover td {
+    background-color: #77f;
+}
+
+table.select-compare2 td { cursor: pointer; }
+table.select-compare2 tr.even td {
+    background-color: #fcc;
+}
+table.select-compare2 tr.odd td {
+    background-color: #faa;
+}
+table.select-compare2 tr.selected-1 td {
+    background-color: #77f;
+}
+table.select-compare2 tr.selected-1.hover td {
+    background-color: #77f;
+}
+table.select-compare2 tr.hover td {
+    background-color: #f77;
+}
+table.select-compare3 tr.selected-1 td {
+    background-color: #77f;
+}
+table.select-compare3 tr.selected-1.hover td {
+    background-color: #77f;
+}
+table.select-compare3 tr.selected-2 td {
+    background-color: #f77;
+}
+table.select-compare3 tr.selected-2.hover td {
+    background-color: #f77;
+}
+table.select-compare3 tr.selected-1 {
+    cursor: pointer;
+}
+table.select-compare3 tr.selected-2 {
+    cursor: pointer;
+}
+#filter-table input {
+    margin-top: 0;
+    margin-bottom: 0;
+}
\ No newline at end of file

=== added file 'dashboard_app/static/js/filter-detail.js'
--- dashboard_app/static/js/filter-detail.js	1970-01-01 00:00:00 +0000
+++ dashboard_app/static/js/filter-detail.js	2013-01-10 01:34:27 +0000
@@ -0,0 +1,135 @@
+var compareState = 0;
+var compare1 = null, compare2 = null;
+function cancelCompare () {
+    $("#filter-table").removeClass("select-compare1");
+    $("#filter-table").removeClass("select-compare2");
+    $("#filter-table").removeClass("select-compare3");
+    $("#filter-table tr").removeClass("selected-1");
+    $("#filter-table tr").removeClass("selected-2");
+    $("#filter-table tr").unbind("click");
+    $("#filter-table tr").unbind("hover");
+    $("#filter-table tr").each(removeCheckbox);
+    $("#first-prompt").hide();
+    $("#second-prompt").hide();
+    $("#third-prompt").hide();
+    $("#compare-button").button({label:"Compare builds"});
+    compareState = 0;
+}
+function startCompare () {
+    $("#compare-button").button({label:"Cancel"});
+    $("#filter-table").addClass("select-compare1");
+    $("#filter-table tr").click(rowClickHandler);
+    $("#filter-table tr").each(insertCheckbox);
+    $("#filter-table tr").hover(rowHoverHandlerIn, rowHoverHandlerOut);
+    $("#first-prompt").show();
+    compareState = 1;
+}
+function tagFromRow(tr) {
+    var firstCell = $(tr).find("td:eq(0)");
+    return {
+        machinetag: firstCell.find("span").data("machinetag"),
+        usertag: firstCell.text()
+    };
+}
+function rowClickHandler() {
+    if (compareState == 1) {
+        compare1 = tagFromRow($(this));
+        $(this).addClass("selected-1");
+        $(this).find("input").attr("checked", true);
+        $("#p2-build").text(compare1.usertag);
+        $("#first-prompt").hide();
+        $("#second-prompt").show();
+        $("#filter-table").removeClass("select-compare1");
+        $("#filter-table").addClass("select-compare2");
+        compareState = 2;
+    } else if (compareState == 2) {
+        var thistag = tagFromRow($(this));
+        if (compare1.machinetag == thistag.machinetag) {
+            cancelCompare();
+            startCompare();
+        } else {
+            compare2 = thistag;
+            $(this).find("input").attr("checked", true);
+            $(this).addClass("selected-2");
+            $("#second-prompt").hide();
+            $("#third-prompt").show();
+            $("#filter-table").removeClass("select-compare2");
+            $("#filter-table").addClass("select-compare3");
+            $("#filter-table input").attr("disabled", true);
+            $("#filter-table .selected-1 input").attr("disabled", false);
+            $("#filter-table .selected-2 input").attr("disabled", false);
+            $("#p3-build-1").text(compare1.usertag);
+            $("#p3-build-2").text(compare2.usertag);
+            $("#third-prompt a").attr("href", window.location + '/+compare/' + compare1.machinetag + '/' + compare2.machinetag);
+            compareState = 3;
+        }
+    } else if (compareState == 3) {
+        var thistag = tagFromRow($(this));
+        if (thistag.machinetag == compare1.machinetag || thistag.machinetag == compare2.machinetag) {
+            $("#second-prompt").show();
+            $("#third-prompt").hide();
+            $("#filter-table").addClass("select-compare2");
+            $("#filter-table").removeClass("select-compare3");
+            $("#filter-table input").attr("disabled", false);
+            compareState = 2;
+            $(this).find("input").attr("checked", false);
+            if (thistag.machinetag == compare1.machinetag) {
+                compare1 = compare2;
+                $("#filter-table .selected-1").removeClass("selected-1");
+                $("#filter-table .selected-2").addClass("selected-1");
+                $("#p2-build").text(compare1.usertag);
+            }
+            $("#filter-table .selected-2").removeClass("selected-2");
+        }
+    }
+    tagFromRow(this);
+}
+function rowHoverHandlerIn() {
+    $(this).addClass("hover");
+}
+function rowHoverHandlerOut() {
+    $(this).removeClass("hover");
+}
+function insertCheckbox() {
+    var row = $(this);
+    var checkbox = $('<input type="checkbox">');
+    row.find("td:first").prepend(checkbox);
+}
+function removeCheckbox() {
+    var row = $(this);
+    row.find('input').remove();
+}
+$(window).load(
+    function () {
+        $("#filter-table").dataTable().fnSettings().fnRowCallback = function(tr, data, index) {
+            if (compareState) {
+                insertCheckbox.call(tr);
+                $(tr).click(rowClickHandler);
+                $("#filter-table tr").hover(rowHoverHandlerIn, rowHoverHandlerOut);
+                if (compareState >= 2 && tagFromRow(tr).machinetag == compare1.machinetag) {
+                    $(tr).addClass("selected-1");
+                    $(tr).find("input").attr("checked", true);
+                }
+                if (compareState >= 3) {
+                    if (tagFromRow(tr).machinetag == compare2.machinetag) {
+                        $(tr).addClass("selected-2");
+                        $(tr).find("input").attr("checked", true);
+                    } else if (tagFromRow(tr).machinetag != compare1.machinetag) {
+                        $(tr).find("input").attr("disabled", true);
+                    }
+                }
+            }
+            return tr;
+        };
+        $("#compare-button").button();
+        $("#compare-button").click(
+            function (e) {
+                if (compareState == 0) {
+                    startCompare();
+                } else {
+                    cancelCompare();
+                }
+            }
+        );
+    }
+);

=== added file 'dashboard_app/templates/dashboard_app/filter_compare_matches.html'
--- dashboard_app/templates/dashboard_app/filter_compare_matches.html	1970-01-01 00:00:00 +0000
+++ dashboard_app/templates/dashboard_app/filter_compare_matches.html	2013-01-08 21:16:54 +0000
@@ -0,0 +1,28 @@
+{% extends "dashboard_app/_content.html" %}
+
+{% load django_tables2 %}
+
+{% block extrahead %}
+{{ block.super }}
+<style type="text/css">
+  th.orderable.sortable a {
+    color: rgb(0, 136, 204);
+    text-decoration: underline;
+  }
+</style>
+{% endblock %}
+
+{% block content %}
+{% for trinfo in test_run_info %}
+<h3>{{ trinfo.key }} results</h3>
+{% if trinfo.only %}
+<p style="text-align: {{ trinfo.only }}">
+  Results were only present in <a href="{{ trinfo.tr.get_absolute_url }}">build {{ trinfo.tag }}</a>.
+</p>
+{% elif trinfo.table %}
+{% render_table trinfo.table %}
+{% else %}
+<p>No difference in {{ trinfo.key }} results{% if trinfo.cases %} for {{ trinfo.cases }}{% endif %}.</p>
+{% endif %}
+{% endfor %}
+{% endblock %}

=== modified file 'dashboard_app/templates/dashboard_app/filter_detail.html'
--- dashboard_app/templates/dashboard_app/filter_detail.html	2013-01-09 00:05:14 +0000
+++ dashboard_app/templates/dashboard_app/filter_detail.html	2013-01-10 01:34:27 +0000
@@ -2,6 +2,12 @@
 {% load i18n %}
 {% load django_tables2 %}
 
+{% block extrahead %}
+{{ block.super }}
+<link rel="stylesheet" type="text/css" href="{{ STATIC_URL }}dashboard_app/css/filter-detail.css"/>
+<script type="text/javascript" src="{{ STATIC_URL }}dashboard_app/js/filter-detail.js"></script>
+{% endblock %}
+
 {% block content %}
 
 <h1>Filter {{ filter.name }}</h1>
@@ -27,4 +33,17 @@
 
 {% render_table filter_table %}
 
+<p>
+  <button id="compare-button">Compare builds</button>
+  <span id="first-prompt" style="display:none">
+    Click a build to compare.
+  </span>
+  <span id="second-prompt" style="display:none">
+    Click build to compare with build <span id="p2-build">XXX</span>.
+  </span>
+  <span id="third-prompt" style="display:none">
+    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>.
+  </span>
+</p>
+
 {% endblock %}

=== modified file 'dashboard_app/urls.py'
--- dashboard_app/urls.py	2012-12-11 02:01:37 +0000
+++ dashboard_app/urls.py	2013-01-08 23:44:15 +0000
@@ -45,7 +45,8 @@
     url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+edit$', 'filters.views.filter_edit'),
     url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+subscribe$', 'filters.views.filter_subscribe'),
     url(r'^filters/~(?P<username>[a-zA-Z0-9-_]+)/(?P<name>[a-zA-Z0-9-_]+)/\+delete$', 'filters.views.filter_delete'),
-    url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler, 
+    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'),
+    url(r'^xml-rpc/$', linaro_django_xmlrpc.views.handler,
         name='dashboard_app.views.dashboard_xml_rpc_handler',
         kwargs={
             'mapper': legacy_mapper,

=== modified file 'dashboard_app/views/__init__.py'
--- dashboard_app/views/__init__.py	2012-12-17 00:10:53 +0000
+++ dashboard_app/views/__init__.py	2013-01-08 01:27:34 +0000
@@ -38,7 +38,6 @@
     )
 from django.shortcuts import render_to_response, redirect, get_object_or_404
 from django.template import RequestContext, loader
-from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.views.generic.list_detail import object_list, object_detail
 
@@ -825,3 +824,4 @@
             pk=effort.pk)
     })
     return HttpResponse(t.render(c))
+

=== modified file 'dashboard_app/views/filters/tables.py'
--- dashboard_app/views/filters/tables.py	2012-12-13 00:51:07 +0000
+++ dashboard_app/views/filters/tables.py	2013-01-08 23:11:01 +0000
@@ -16,8 +16,11 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with Launch Control.  If not, see <http://www.gnu.org/licenses/>.
 
+import datetime
 import operator
 
+from django.conf import settings
+from django.template import defaultfilters
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 
@@ -184,6 +187,12 @@
             self.base_columns.insert(0, 'bundle_stream', bundle_stream_col)
         self.base_columns.insert(0, 'tag', tag_col)
 
+    def render_tag(self, value):
+        if isinstance(value, datetime.datetime):
+            strvalue = defaultfilters.date(value, settings.DATETIME_FORMAT)
+        else:
+            strvalue = value
+        return mark_safe('<span data-machinetag="%s">%s</span>' % (escape(str(value)), strvalue))
     tag = Column()
 
     def render_bundle_stream(self, record):
@@ -225,3 +234,29 @@
     datatable_opts.update({
         "iDisplayLength": 10,
         })
+
+
+class TestResultDifferenceTable(DataTablesTable):
+    test_case_id = Column(verbose_name=mark_safe('test_case_id'))
+    first_result = TemplateColumn('''
+    {% if record.first_result %}
+    <img src="{{ STATIC_URL }}dashboard_app/images/icon-{{ record.first_result }}.png"
+          alt="{{ record.first_result }}" width="16" height="16" border="0"/>{{ record.first_result }}
+    {% else %}
+    <i>missing</i>
+    {% endif %}
+        ''')
+    second_result = TemplateColumn('''
+    {% if record.second_result %}
+    <img src="{{ STATIC_URL }}dashboard_app/images/icon-{{ record.second_result }}.png"
+          alt="{{ record.second_result }}" width="16" height="16" border="0"/>{{ record.second_result }}
+    {% else %}
+    <i>missing</i>
+    {% endif %}
+        ''')
+
+    datatable_opts = {
+        'iDisplayLength': 25,
+        'sPaginationType': "full_numbers",
+        }
+

=== modified file 'dashboard_app/views/filters/views.py'
--- dashboard_app/views/filters/views.py	2012-11-27 05:07:00 +0000
+++ dashboard_app/views/filters/views.py	2013-01-10 00:34:58 +0000
@@ -25,12 +25,17 @@
 from django.http import HttpResponse, HttpResponseRedirect
 from django.shortcuts import render_to_response
 from django.template import RequestContext
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
 
 from lava_server.bread_crumbs import (
     BreadCrumb,
     BreadCrumbTrail,
 )
 
+from dashboard_app.filters import (
+    evaluate_filter,
+    )
 from dashboard_app.models import (
     NamedAttribute,
     Test,
@@ -39,7 +44,9 @@
     TestRunFilter,
     TestRunFilterSubscription,
     )
-from dashboard_app.views import index
+from dashboard_app.views import (
+    index,
+    )
 from dashboard_app.views.filters.forms import (
     TestRunFilterForm,
     TestRunFilterSubscriptionForm,
@@ -48,6 +55,7 @@
     FilterTable,
     FilterPreviewTable,
     PublicFiltersTable,
+    TestResultDifferenceTable,
     UserFiltersTable,
     )
 
@@ -248,3 +256,145 @@
         json.dumps(list(result)),
         mimetype='application/json')
 
+
+def _iter_matching(seq1, seq2, key):
+    """Iterate over sequences in the order given by the key function, matching
+    elements with matching key values.
+
+    For example:
+
+    >>> seq1 = [(1, 2), (2, 3)]
+    >>> seq2 = [(1, 3), (3, 4)]
+    >>> def key(pair): return pair[0]
+    >>> list(_iter_matching(seq1, seq2, key))
+    [(1, (1, 2), (1, 3)), (2, (2, 3), None), (3, None, (3, 4))]
+    """
+    seq1.sort(key=key)
+    seq2.sort(key=key)
+    sentinel = object()
+    def next(it):
+        try:
+            o = it.next()
+            return (key(o), o)
+        except StopIteration:
+            return (sentinel, None)
+    iter1 = iter(seq1)
+    iter2 = iter(seq2)
+    k1, o1 = next(iter1)
+    k2, o2 = next(iter2)
+    while k1 is not sentinel or k2 is not sentinel:
+        if k1 is sentinel:
+            yield (k2, None, o2)
+            k2, o2 = next(iter2)
+        elif k2 is sentinel:
+            yield (k1, o1, None)
+            k1, o1 = next(iter1)
+        elif k1 == k2:
+            yield (k1, o1, o2)
+            k1, o1 = next(iter1)
+            k2, o2 = next(iter2)
+        elif k1 < k2:
+            yield (k1, o1, None)
+            k1, o1 = next(iter1)
+        else: # so k1 > k2...
+            yield (k2, None, o2)
+            k2, o2 = next(iter2)
+
+
+def _test_run_difference(test_run1, test_run2, cases=None):
+    test_results1 = list(test_run1.test_results.all().select_related('test_case'))
+    test_results2 = list(test_run2.test_results.all().select_related('test_case'))
+    def key(tr):
+        return tr.test_case.test_case_id
+    differences = []
+    for tc_id, tc1, tc2 in _iter_matching(test_results1, test_results2, key):
+        if cases is not None and tc_id not in cases:
+            continue
+        if tc1:
+            tc1 = tc1.result_code
+        if tc2:
+            tc2 = tc2.result_code
+        if tc1 != tc2:
+            differences.append({
+                'test_case_id': tc_id,
+                'first_result': tc1,
+                'second_result': tc2,
+                })
+    return differences
+
+
+@BreadCrumb(
+    "Comparing builds {tag1} and {tag2}",
+    parent=filter_detail,
+    needs=['username', 'name', 'tag1', 'tag2'])
+def compare_matches(request, username, name, tag1, tag2):
+    filter = TestRunFilter.objects.get(owner__username=username, name=name)
+    if not filter.public and filter.owner != request.user:
+        raise PermissionDenied()
+    filter_data = filter.as_data()
+    matches = evaluate_filter(request.user, filter_data)
+    match1, match2 = matches.with_tags(tag1, tag2)
+    test_cases_for_test_id = {}
+    for test in filter_data['tests']:
+        test_cases = test['test_cases']
+        if test_cases:
+            test_cases = set([tc.test_case_id for tc in test_cases])
+        else:
+            test_cases = None
+        test_cases_for_test_id[test['test'].test_id] = test_cases
+    test_run_info = []
+    def key(tr):
+        return tr.test.test_id
+    for key, tr1, tr2 in _iter_matching(match1.test_runs, match2.test_runs, key):
+        if tr1 is None:
+            table = None
+            only = 'right'
+            tr = tr2
+            tag = tag2
+            cases = None
+        elif tr2 is None:
+            table = None
+            only = 'left'
+            tr = tr1
+            tag = tag1
+            cases = None
+        else:
+            only = None
+            tr = None
+            tag = None
+            cases = test_cases_for_test_id.get(key)
+            test_result_differences = _test_run_difference(tr1, tr2, cases)
+            if test_result_differences:
+                table = TestResultDifferenceTable(
+                    "test-result-difference-" + escape(key), data=test_result_differences)
+                table.base_columns['first_result'].verbose_name = mark_safe(
+                    '<a href="%s">build %s: %s</a>' % (
+                        tr1.get_absolute_url(), escape(tag1), escape(key)))
+                table.base_columns['second_result'].verbose_name = mark_safe(
+                    '<a href="%s">build %s: %s</a>' % (
+                        tr2.get_absolute_url(), escape(tag2), escape(key)))
+            else:
+                table = None
+            if cases:
+                cases = sorted(cases)
+                if len(cases) > 1:
+                    cases = ', '.join(cases[:-1]) + ' or ' + cases[-1]
+                else:
+                    cases = cases[0]
+        test_run_info.append(dict(
+            only=only,
+            key=key,
+            table=table,
+            tr=tr,
+            tag=tag,
+            cases=cases))
+    return render_to_response(
+        "dashboard_app/filter_compare_matches.html", {
+            'test_run_info': test_run_info,
+            'bread_crumb_trail': BreadCrumbTrail.leading_to(
+                compare_matches,
+                name=name,
+                username=username,
+                tag1=tag1,
+                tag2=tag2),
+        }, RequestContext(request))

