webapp: add concept of "build architecture" to frontend
[~helmut/crossqa.git] / webapp.py
index eece841..cc9de37 100644 (file)
--- a/webapp.py
+++ b/webapp.py
@@ -19,7 +19,83 @@ app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite:///db'
 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
 db = flask_sqlalchemy.SQLAlchemy(app)
 
-src_template = """<!DOCTYPE html>
+index_template = """<!DOCTYPE html>
+<html>
+ <head>
+  <title>Debian cross build quality assurance</title>
+ </head>
+ <body>
+  <header>
+   <h1>Debian cross build quality assurance</h1>
+  </header>
+  <section>
+   <h3>Recently failed builds</h3>
+   <table>
+    <thead>
+     <tr>
+      <th>source</th>
+      <th>version</th>
+      <th>build architecture</th>
+      <th>host architecture</th>
+      <th>started</th>
+      <th>result log</th>
+      <th>bugs</th>
+     </tr>
+    </thead>
+    <tbody>
+     {%- for build in builds|sort(attribute='starttime', reverse=true) -%}
+      <tr>
+       <td>
+        <a href="{{ url_for("show_source", source=build.source) }}">
+         {{- build.source|e -}}
+        </a>
+       </td>
+       <td>{{ build.version|e }}</td>
+       <td>{{ build.buildarch|e }}</td>
+       <td>{{ build.hostarch|e }}</td>
+       <td>
+        {{- build.starttime|sqltimestamp|formatts -}}
+       </td>
+       <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>
+   </table>
+  </section>
+  <footer>
+   <h3>Details about this service</h3>
+   <ul>
+    <li>Maintainer: Helmut Grohne &lt;helmut@subdivi.de&gt;</li>
+    <li>Source: git://git.subdivi.de/~helmut/crossqa.git</li>
+   </ul>
+  </footer>
+ </body>
+</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>
@@ -33,13 +109,13 @@ tr.dep.tempbad td:nth-child(1) {
 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;
 }
-tr.build.tempbad td:nth-child(4) {
+tr.build.tempbad td:nth-child(5) {
     background-color: #ffa;
 }
-tr.build.good td:nth-child(4) {
+tr.build.good td:nth-child(5) {
     background-color: #afa;
 }
 th {
@@ -67,8 +143,30 @@ footer {
     </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>
+   {%- 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>
@@ -81,38 +179,51 @@ footer {
      {%- 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>
-       <td>{{ okarchs|arch_format }}</td>
+       <td>{{ okarchs|archpairs_format }}</td>
       </tr>
      {%- endif -%}
     </tbody>
    </table>
-   <h5>See also</h5>
-   <ul>
-    {%- if show_bootstrapdn -%}
+   {%- if show_debcheck -%}
+    <h5>See also</h5>
+    <ul>
+     {%- if show_bootstrapdn -%}
+      <li>
+       <a href="https://bootstrap.debian.net/cross_all/{{ sourcepackage|e }}.html">bootstrap.debian.net</a>
+      </li>
+     {%- endif -%}
      <li>
-      <a href="https://bootstrap.debian.net/cross_all/{{ sourcepackage|e }}.html">bootstrap.debian.net</a>
+      <a href="https://qa.debian.org/dose/debcheck/cross_unstable_main_amd64/latest/packages/{{ sourcepackage|e }}.html">debcheck</a>
      </li>
-    {%- endif -%}
-    <li>
-     <a href="https://qa.debian.org/dose/debcheck/cross_unstable_main_amd64/">debcheck</a>
-    </li>
-   </ul>
+    </ul>
+   {%- endif -%}
   </section>
-  {%- if builds -%}
-   <section>
-    <h3>Cross builds</h3>
+  <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>
-       <th>architecture</th>
+       <th>build architecture</th>
+       <th>host architecture</th>
        <th>result log</th>
       </tr>
      </thead>
@@ -123,7 +234,8 @@ footer {
          {{- 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" -}}
@@ -134,16 +246,43 @@ footer {
       {%- endfor -%}
      </tbody>
     </table>
-   </section>
-  {%- endif -%}
+   {%- else -%}
+    <p>No build performed yet.</p>
+   {%- endif -%}
+   <form method="POST" action="{{ url_for("request_schedule")|e }}">
+    <input type="submit" name="schedule" value="cross build" />
+    {{ sourcepackage|e }} for
+    <input type="hidden" name="source" value="{{ sourcepackage|e }}" />
+    <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>
+  </section>
+  <footer>
+   <h3>Details about this service</h3>
+   <ul>
+    <li>Maintainer: Helmut Grohne &lt;helmut@subdivi.de&gt;</li>
+    <li>Source: git://git.subdivi.de/~helmut/crossqa.git</li>
+   </ul>
+  </footer>
+ </body>
+</html>
+"""
+
+schedule_template = """<!DOCTYPE html>
+<html>
+ <body>
+  <p>Scheduled a build of {{ request.form["source"]|e }}
+   {%- if request.form["architecture"] != "any" %}
+    for {{ request.form["architecture"]|e -}}
+   {%- endif %}.
+  <p>
  </body>
- <footer>
-  <h3>Details about this service</h3>
-  <ul>
-   <li>Maintainer: Helmut Grohne &lt;helmut@subdivi.de&gt;</li>
-   <li>Source: git://git.subdivi.de/~helmut/crossqa.git</li>
-  </ul>
- </footer>
 </html>
 """
 
@@ -175,46 +314,99 @@ def formatts_filter(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 ", ".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("""
-        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 = {}
-        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 = {}
-    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("""
+            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
+                DESC LIMIT 10;"""))
+    return flask.render_template_string(index_template, builds=builds)
+
 @app.route("/src/<source>")
 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("""
-            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))
+        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_debcheck"] = \
+                any(context["depresult"].keys())
     return flask.render_template_string(src_template, **context)
 
 @app.route("/build/<path:filename>")
@@ -228,3 +420,28 @@ def show_log(filename):
                                mimetype="text/plain")
     except FileNotFoundError:
         raise werkzeug.exceptions.NotFound()
+
+
+@app.route("/schedule", methods=["POST"])
+def request_schedule():
+    source = flask.request.form["source"]
+    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()
+        if hostarch == "any":
+            hostarch = None
+        else:
+            query = sqlalchemy.text("""
+                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)
+                VALUES (:source, :hostarch, datetime('now'));""")
+        conn.execute(query, source=source, hostarch=hostarch)
+    return flask.render_template_string(schedule_template)