webapp: add concept of "build architecture" to frontend
[~helmut/crossqa.git] / webapp.py
index 6d1ad76..cc9de37 100644 (file)
--- a/webapp.py
+++ b/webapp.py
@@ -35,9 +35,11 @@ index_template = """<!DOCTYPE html>
      <tr>
       <th>source</th>
       <th>version</th>
      <tr>
       <th>source</th>
       <th>version</th>
-      <th>architecture</th>
+      <th>build architecture</th>
+      <th>host architecture</th>
       <th>started</th>
       <th>result log</th>
       <th>started</th>
       <th>result log</th>
+      <th>bugs</th>
      </tr>
     </thead>
     <tbody>
      </tr>
     </thead>
     <tbody>
@@ -49,7 +51,8 @@ index_template = """<!DOCTYPE html>
         </a>
        </td>
        <td>{{ build.version|e }}</td>
         </a>
        </td>
        <td>{{ build.version|e }}</td>
-       <td>{{ build.architecture|e }}</td>
+       <td>{{ build.buildarch|e }}</td>
+       <td>{{ build.hostarch|e }}</td>
        <td>
         {{- build.starttime|sqltimestamp|formatts -}}
        </td>
        <td>
         {{- build.starttime|sqltimestamp|formatts -}}
        </td>
@@ -57,6 +60,13 @@ index_template = """<!DOCTYPE html>
         <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">log</a>
         <a href="{{ url_for("show_log", filename=build.filename) }}">xz</a>
        </td>
         <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">log</a>
         <a href="{{ url_for("show_log", filename=build.filename) }}">xz</a>
        </td>
+       <td>
+        {%- if build.buglvl == 2 -%}
+         patch reported
+        {%- elif build.buglvl == 1 -%}
+         bug reported
+        {%- endif -%}
+       </td>
       </tr>
      {%- endfor -%}
     </tbody>
       </tr>
      {%- endfor -%}
     </tbody>
@@ -73,7 +83,19 @@ index_template = """<!DOCTYPE html>
 </html>
 """
 
 </html>
 """
 
-src_template = """<!DOCTYPE html>
+src_template = """
+{%- macro render_bug(bugobj) -%}
+ <a href="https://bugs.debian.org/{{ bugobj.bugnum }}">#{{ bugobj.bugnum }}</a>
+ {%- if bugobj.patched %}
+  [<abbr title="patch available">+</abbr>]
+ {%- endif -%}:
+ {% if bugobj.package != "src:" + bugobj.affects and
+       not bugobj.title.startswith(bugobj.affects + ":") -%}
+  {{- bugobj.package|e }}:
+ {% endif -%}
+ {{- bugobj.title|e -}}
+{%- endmacro -%}
+<!DOCTYPE html>
 <html>
  <head>
   <title>{{ sourcepackage|e }} - Debian cross build</title>
 <html>
  <head>
   <title>{{ sourcepackage|e }} - Debian cross build</title>
@@ -87,13 +109,13 @@ tr.dep.tempbad td:nth-child(1) {
 tr.dep.good td:nth-child(1) {
     background-color: #afa;
 }
 tr.dep.good td:nth-child(1) {
     background-color: #afa;
 }
-tr.build.bad td:nth-child(4) {
+tr.build.bad td:nth-child(5) {
     background-color: #faa;
 }
     background-color: #faa;
 }
-tr.build.tempbad td:nth-child(4) {
+tr.build.tempbad td:nth-child(5) {
     background-color: #ffa;
 }
     background-color: #ffa;
 }
-tr.build.good td:nth-child(4) {
+tr.build.good td:nth-child(5) {
     background-color: #afa;
 }
 th {
     background-color: #afa;
 }
 th {
@@ -121,8 +143,30 @@ footer {
     </a>
    </h1>
   </header>
     </a>
    </h1>
   </header>
+  {%- if bugs.ftbfs -%}
+   <section>
+    <h3>Reported <abbr title="fails to build from source">FTBFS</abbr> bugs</h3>
+    <ul>
+     {%- for bug in bugs.ftbfs|sort(attribute="bugnum") -%}
+      <li>
+       {{- render_bug(bug) -}}
+      </li>
+     {%- endfor -%}
+    </ul>
+   </section>
+  {%- endif -%}
   <section>
    <h3>Cross build dependency satisfiability</h3>
   <section>
    <h3>Cross build dependency satisfiability</h3>
+   {%- if bugs.bdsat -%}
+    <h5>Reported satisfiability problems</h5>
+    <ul>
+     {%- for bug in bugs.bdsat|sort(attribute="bugnum") -%}
+      <li>
+       {{- render_bug(bug) -}}
+      </li>
+     {%- endfor -%}
+    </ul>
+   {%- endif -%}
    <table>
     <thead>
      <tr>
    <table>
     <thead>
      <tr>
@@ -135,13 +179,13 @@ footer {
      {%- for reason, archs in depresult.items()|sort -%}
       <tr class="dep {{ "tempbad" if reason.startswith("skew") else "bad" }}">
        <td>{{ reason|e }}</td>
      {%- for reason, archs in depresult.items()|sort -%}
       <tr class="dep {{ "tempbad" if reason.startswith("skew") else "bad" }}">
        <td>{{ reason|e }}</td>
-       <td>{{ archs|arch_format }}</td>
+       <td>{{ archs|archpairs_format }}</td>
       </tr>
      {%- endfor -%}
      {%- if okarchs -%}
       <tr class="dep good">
        <td>ok</td>
       </tr>
      {%- endfor -%}
      {%- if okarchs -%}
       <tr class="dep good">
        <td>ok</td>
-       <td>{{ okarchs|arch_format }}</td>
+       <td>{{ okarchs|archpairs_format }}</td>
       </tr>
      {%- endif -%}
     </tbody>
       </tr>
      {%- endif -%}
     </tbody>
@@ -162,13 +206,24 @@ footer {
   </section>
   <section>
    <h3>Cross builds</h3>
   </section>
   <section>
    <h3>Cross builds</h3>
+   {%- if bugs.ftcbfs -%}
+    <h5>Reported cross build failures</h5>
+    <ul>
+     {%- for bug in bugs.ftcbfs|sort(attribute="bugnum") -%}
+      <li>
+       {{- render_bug(bug) -}}
+      </li>
+     {%- endfor -%}
+    </ul>
+   {%- endif -%}
    {%- if builds -%}
     <table>
      <thead>
       <tr>
        <th>started</th>
        <th>version</th>
    {%- if builds -%}
     <table>
      <thead>
       <tr>
        <th>started</th>
        <th>version</th>
-       <th>architecture</th>
+       <th>build architecture</th>
+       <th>host architecture</th>
        <th>result log</th>
       </tr>
      </thead>
        <th>result log</th>
       </tr>
      </thead>
@@ -179,7 +234,8 @@ footer {
          {{- build.starttime|sqltimestamp|formatts -}}
         </td>
         <td>{{ build.version|e }}</td>
          {{- build.starttime|sqltimestamp|formatts -}}
         </td>
         <td>{{ build.version|e }}</td>
-        <td>{{ build.architecture|e }}</td>
+        <td>{{ build.buildarch|e }}</td>
+        <td>{{ build.hostarch|e }}</td>
         <td>
          <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">
           {{- "ok" if build.success else "failed" -}}
         <td>
          <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">
           {{- "ok" if build.success else "failed" -}}
@@ -197,10 +253,12 @@ footer {
     <input type="submit" name="schedule" value="cross build" />
     {{ sourcepackage|e }} for
     <input type="hidden" name="source" value="{{ sourcepackage|e }}" />
     <input type="submit" name="schedule" value="cross build" />
     {{ sourcepackage|e }} for
     <input type="hidden" name="source" value="{{ sourcepackage|e }}" />
-    <select name="architecture">
-     <option value="any">any</option>
-     {%- for architecture in architectures|sort -%}
-      <option value="{{ architecture|e }}">{{ architecture|e }}</option>
+    <select name="archpair">
+     <option value="any_any">{{ ("any", "any")|archpair_format }}</option>
+     {%- for buildarch, hostarch in architectures|sort -%}
+      <option value="{{ buildarch|e }}_{{ hostarch|e }}">
+       {{- (buildarch, hostarch)|archpair_format -}}
+      </option>
      {%- endfor -%}
     </select>
    </form>
      {%- endfor -%}
     </select>
    </form>
@@ -256,36 +314,67 @@ def formatts_filter(ts):
     return jinja2.Markup('<time title="%s" datetime="%s">%s</time>' %
                          (ts, ts, formatts(ts)))
 
     return jinja2.Markup('<time title="%s" datetime="%s">%s</time>' %
                          (ts, ts, formatts(ts)))
 
-@app.template_filter('arch_format')
-@jinja2.contextfilter
-def arch_format_filter(context, some_archs):
-    if context["architectures"] == some_archs:
+@app.template_filter("archpair_format")
+def archpair_format_filter(archpair):
+    return jinja2.Markup("%s &rarr; %s" % tuple(map(jinja2.escape, archpair)))
+
+def group_pairs(pairs):
+    result = {}
+    for v, w in pairs:
+        result.setdefault(v, set()).add(w)
+    return result
+
+def render_archset(subset, all_archs):
+    if len(subset) == 1:
+        return next(iter(subset))
+    if subset == all_archs:
         return "any"
         return "any"
-    return ", ".join(sorted(some_archs))
+    return "{%s}" % ", ".join(map(jinja2.escape, sorted(subset)))
+
+@app.template_filter('archpairs_format')
+@jinja2.contextfilter
+def archpairs_format_filter(context, some_archs):
+    architectures = group_pairs(context["architectures"])
+    fwdmap = {}  # build architecture -> host architecture set representation
+    for buildarch, hostarchs in group_pairs(some_archs).items():
+        fwdmap[buildarch] = render_archset(hostarchs, architectures[buildarch])
+    allbuildarchs = set(architectures.keys())
+    # host architecture set representation -> build architecture set
+    flippedit = group_pairs((v, k) for (k, v) in fwdmap.items()).items()
+    maps = ("%s &rarr; %s" % (render_archset(buildarchs, allbuildarchs),
+                              hostarchrep)
+            for hostarchrep, buildarchs in flippedit)
+    return jinja2.Markup("; ".join(sorted(maps)))
 
 def collect_depstate(conn, source):
     version = None
     depstate = None
     query = sqlalchemy.text("""
 
 def collect_depstate(conn, source):
     version = None
     depstate = None
     query = sqlalchemy.text("""
-        SELECT version, architecture, satisfiable, reason
+        SELECT version, 'amd64' AS buildarch, architecture AS hostarch,
+               satisfiable, reason
             FROM depstate WHERE source = :source;""")
     for row in conn.execute(query, source=source):
         if version is None or version_compare(version, row.version) > 0:
             version = row.version
             depstate = {}
             FROM depstate WHERE source = :source;""")
     for row in conn.execute(query, source=source):
         if version is None or version_compare(version, row.version) > 0:
             version = row.version
             depstate = {}
-        depstate[row.architecture] = None if row.satisfiable else row.reason
+        depstate[row.buildarch, row.hostarch] = \
+                None if row.satisfiable else row.reason
     if version is None:
         raise werkzeug.exceptions.NotFound()
     depresult = {}
     if version is None:
         raise werkzeug.exceptions.NotFound()
     depresult = {}
-    for arch, reason in depstate.items():
-        depresult.setdefault(reason, set()).add(arch)
+    for archpair, reason in depstate.items():
+        depresult.setdefault(reason, set()).add(archpair)
     return version, depresult
 
 @app.route("/")
 def show_index():
     with db.engine.connect() as conn:
         builds = list(conn.execute("""
     return version, depresult
 
 @app.route("/")
 def show_index():
     with db.engine.connect() as conn:
         builds = list(conn.execute("""
-            SELECT source, version, architecture, starttime, filename
+            SELECT source, version, 'amd64' AS buildarch,
+                architecture AS hostarch, starttime, filename,
+                ifnull((SELECT max(patched + 1) FROM bugs
+                                                WHERE affects = source),
+                       0) AS buglvl
                 FROM builds
                 WHERE success = 0
                 ORDER BY starttime
                 FROM builds
                 WHERE success = 0
                 ORDER BY starttime
@@ -296,14 +385,23 @@ def show_index():
 def show_source(source):
     context = dict(sourcepackage=source)
     with db.engine.connect() as conn:
 def show_source(source):
     context = dict(sourcepackage=source)
     with db.engine.connect() as conn:
-        query = sqlalchemy.text("SELECT architecture FROM depcheck;")
-        context["architectures"] = set(row[0] for row in conn.execute(query))
+        query = sqlalchemy.text("""SELECT 'amd64' AS buildarch,
+                                          architecture AS hostarch
+                                       FROM depcheck;""")
+        context["architectures"] = set(map(tuple, conn.execute(query)))
         context["version"], context["depresult"] = collect_depstate(conn,
                                                                     source)
         query = sqlalchemy.text("""
         context["version"], context["depresult"] = collect_depstate(conn,
                                                                     source)
         query = sqlalchemy.text("""
-            SELECT version, architecture, success, starttime, filename
+            SELECT version, 'amd64' AS buildarch, architecture AS hostarch,
+                   success, starttime, filename
                 FROM builds WHERE source = :source;""")
         context["builds"] = list(conn.execute(query, source=source))
                 FROM builds WHERE source = :source;""")
         context["builds"] = list(conn.execute(query, source=source))
+        query = sqlalchemy.text("""
+            SELECT bugnum, kind, title, package, patched, affects
+                FROM bugs WHERE affects = :affects;""")
+        context["bugs"] = {}
+        for bug in conn.execute(query, affects=source):
+            context["bugs"].setdefault(bug.kind, []).append(bug)
         context["show_bootstrapdn"] = \
                 any(reason and not reason.startswith("skew ")
                     for reason in context["depresult"].keys())
         context["show_bootstrapdn"] = \
                 any(reason and not reason.startswith("skew ")
                     for reason in context["depresult"].keys())
@@ -327,21 +425,23 @@ def show_log(filename):
 @app.route("/schedule", methods=["POST"])
 def request_schedule():
     source = flask.request.form["source"]
 @app.route("/schedule", methods=["POST"])
 def request_schedule():
     source = flask.request.form["source"]
-    architecture = flask.request.form["architecture"]
+    buildarch, hostarch = flask.request.form["archpair"].split("_")
+    if buildarch not in ("any", "amd64"):
+        raise werkzeug.exceptions.BadRequest()
     with db.engine.connect() as conn:
         query = sqlalchemy.text("""
             SELECT 1 FROM depstate WHERE source = :source;""")
         if not conn.execute(query, source=source).first():
             raise werkzeug.exceptions.BadRequest()
     with db.engine.connect() as conn:
         query = sqlalchemy.text("""
             SELECT 1 FROM depstate WHERE source = :source;""")
         if not conn.execute(query, source=source).first():
             raise werkzeug.exceptions.BadRequest()
-        if architecture == "any":
-            architecture = None
+        if hostarch == "any":
+            hostarch = None
         else:
             query = sqlalchemy.text("""
         else:
             query = sqlalchemy.text("""
-                SELECT 1 FROM depcheck WHERE architecture = :architecture;""")
-            if not conn.execute(query, architecture=architecture).first():
+                SELECT 1 FROM depcheck WHERE architecture = :hostarch;""")
+            if not conn.execute(query, hostarch=hostarch).first():
                 raise werkzeug.exceptions.BadRequest()
         query = sqlalchemy.text("""
             INSERT INTO buildrequests (source, architecture, requesttime)
                 raise werkzeug.exceptions.BadRequest()
         query = sqlalchemy.text("""
             INSERT INTO buildrequests (source, architecture, requesttime)
-                VALUES (:source, :architecture, datetime('now'));""")
-        conn.execute(query, source=source, architecture=architecture)
+                VALUES (:source, :hostarch, datetime('now'));""")
+        conn.execute(query, source=source, hostarch=hostarch)
     return flask.render_template_string(schedule_template)
     return flask.render_template_string(schedule_template)