]> git.ipfire.org Git - pbs.git/commitdiff
builds: Add controls to easily report bugs to Bugzilla
authorMichael Tremer <michael.tremer@ipfire.org>
Mon, 15 May 2023 10:36:48 +0000 (10:36 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Mon, 15 May 2023 10:36:48 +0000 (10:36 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
Makefile.am
src/buildservice/bugtracker.py
src/buildservice/builds.py
src/templates/builds/show.html
src/web/__init__.py
src/web/builds.py

index 46d534bcca1550bdfb91ab3afe6dd5593a6cddf3..0570d17e7568f544090bc700046ea59fefb91f29 100644 (file)
@@ -179,6 +179,8 @@ dist_templates_builders_modules_DATA = \
 templates_builders_modulesdir = $(templates_buildersdir)/modules
 
 dist_templates_builds_DATA = \
+       src/templates/builds/bug.html \
+       src/templates/builds/bug-created.html \
        src/templates/builds/delete.html \
        src/templates/builds/index.html \
        src/templates/builds/show.html
index c26025d3fabd88ff1fca6d637a17b3798cea354a..27897915cddc1181ce73c201b6ef788ed33a9913 100644 (file)
 ###############################################################################
 
 import asyncio
+import base64
 import datetime
+import io
 import json
 import logging
+import mimetypes
 import tornado.httpclient
 import urllib.parse
 
@@ -34,8 +37,17 @@ log = logging.getLogger("pbs.bugzilla")
 
 TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
 
+class BadRequestError(Exception):
+       pass
+
 class Bugzilla(base.Object):
-       def init(self):
+       def init(self, api_key=None):
+               if api_key is None:
+                       api_key = self.settings.get("bugzilla-api-key")
+
+               # Store the API key
+               self.api_key = api_key
+
                # Set up HTTP Client
                self.client = tornado.httpclient.AsyncHTTPClient()
 
@@ -46,6 +58,15 @@ class Bugzilla(base.Object):
                """
                return self.settings.get("bugzilla-url")
 
+       async def whoami(self):
+               """
+                       Returns the email address of the logged in user
+               """
+               response = await self._request("GET", "/rest/whoami")
+
+               # Return the email address
+               return response.get("name")
+
        async def get_products(self):
                """
                        Returns a dictionary with all products and versions
@@ -68,6 +89,13 @@ class Bugzilla(base.Object):
 
                return products
 
+       @property
+       async def fields(self):
+               """
+                       Fetch all fields
+               """
+               return await self._request("GET", "/rest/field/bug")
+
        def make_url(self, *args, **kwargs):
                """
                        Composes a URL based on the base URL
@@ -80,18 +108,25 @@ class Bugzilla(base.Object):
 
                return url
 
-       async def _request(self, method, url, **kwargs):
+       async def _request(self, method, url, data=None):
                # Headers
                headers = {
                        # Authenticate all requests
-                       "X-BUGZILLA-API-KEY" : self.settings.get("bugzilla-api-key"),
+                       "X-BUGZILLA-API-KEY" : self.api_key,
                }
 
                # Make the URL
                url = self.make_url(url)
 
+               if data is None:
+                       data = {}
+
+               # Fallback authentication because some API endpoints
+               # do not accept the API key in the header
+               data |= { "api_key" : self.api_key }
+
                # Encode body
-               body = urllib.parse.urlencode(kwargs)
+               body = urllib.parse.urlencode(data)
 
                # For GET requests, append query arguments
                if method == "GET":
@@ -108,7 +143,22 @@ class Bugzilla(base.Object):
                )
 
                # Send the request and wait for a response
-               res = await self.client.fetch(req)
+               try:
+                       res = await self.client.fetch(req)
+
+               # Catch any HTTP Errors
+               except tornado.httpclient.HTTPClientError as e:
+                       try:
+                               error = json.loads(e.response.body)
+                       except json.DecodeError:
+                               error = None
+
+                       # Catch bad requests
+                       if e.code == 400:
+                               raise BadRequestError(error) from e
+
+                       # Raise any other exceptions
+                       raise e
 
                log.debug("Response received in %.2fms" % (res.request_time * 1000))
 
@@ -123,7 +173,7 @@ class Bugzilla(base.Object):
                """
                        Creates a URL to a bug report
                """
-               return self.make_url("/show_bug.cgi", id=id)
+               return self.make_url("/show_bug.cgi", data={ "id" : id })
 
        def enter_url(self, component, product=None, version=None):
                """
@@ -155,10 +205,10 @@ class Bugzilla(base.Object):
                        }
 
                # Send request
-               response = await self._request("GET", "/rest/bug", **kwargs)
+               response = await self._request("GET", "/rest/bug", data=kwargs)
 
                # Parse the response
-               bugs = [Bug(self.backend, data) for data in response.get("bugs")]
+               bugs = [Bug(self.backend, self, data) for data in response.get("bugs")]
 
                # Sort and return in reverse order
                return sorted(bugs, reverse=True)
@@ -180,11 +230,32 @@ class Bugzilla(base.Object):
                response = await self._request("GET", "/rest/bug/%s" % bug)
 
                for data in response.get("bugs"):
-                       return Bug(self.backend, data)
+                       return Bug(self.backend, self, data)
+
+       async def create_bug(self, product, component, version, summary, **kwargs):
+               data = {
+                       # Mandatory fields
+                       "product"         : product,
+                       "component"       : component,
+                       "version"         : version,
+                       "summary"         : summary,
+               } | kwargs
+
+               # Create the bug
+               response = await self._request("POST", "/rest/bug", data=data)
+
+               # Fetch the bug ID
+               id = response.get("id")
+               if not id:
+                       raise RuntimeError("No bug ID received")
+
+               # Return the Bug object
+               return await self.get_bug(id)
 
 
 class Bug(base.Object):
-       def init(self, data):
+       def init(self, bugzilla, data):
+               self.bugzilla = bugzilla
                self.data = data
 
        def __repr__(self):
@@ -211,7 +282,7 @@ class Bug(base.Object):
 
        @property
        def url(self):
-               return self.backend.bugzilla.make_url("/show_bug.cgi", id=self.id)
+               return self.bugzilla.make_url("/show_bug.cgi", id=self.id)
 
        @property
        def created_at(self):
@@ -257,3 +328,48 @@ class Bug(base.Object):
        @property
        def keywords(self):
                return self.data.get("keywords")
+
+       async def attach(self, filename, data, summary, content_type=None, is_patch=False):
+               """
+                       Attaches something to an existing bug
+               """
+               # Guess the mimetype if none was provided
+               if content_type is None:
+                       content_type, encoding = mimetypes.guess_type(filename)
+
+               # Assemble the request
+               data = {
+                       # Mandatory fields
+                       "ids"          : self.id,
+                       "summary"      : summary,
+                       "file_name"    : filename,
+                       "data"         : self._encode_to_base64(data),
+                       "content_type" : content_type,
+               }
+
+               # Is this a patch?
+               if is_patch:
+                       data |= { "is_patch" : is_patch }
+
+               # Send request
+               await self.bugzilla._request("POST", "/rest/bug/%s/attachment" % self.id, data=data)
+
+       @staticmethod
+       def _encode_to_base64(data):
+               """
+                       This function takes a file-like object, a bytes-like object or string
+                       and returns it encoded as base64.
+               """
+               # Read all data from file handles
+               if isinstance(data, io.IOBase):
+                       data = data.read()
+
+               # Convert to bytes()
+               if not isinstance(data, bytes):
+                       data = data.encode()
+
+               # Encode to base64
+               data = base64.b64encode(data)
+
+               # Convert to string
+               return data.decode()
index dabf150c4afdab6e69df3b00a35bc5880b15876a..93da461e33de8a862c0eec6a3fb334a5c03fe5f0 100644 (file)
@@ -268,6 +268,25 @@ class Build(base.DataObject):
 
                return NotImplemented
 
+       @property
+       def url(self):
+               return "/builds/%s" % self.uuid
+
+       @property
+       def bugzilla_fields(self):
+               fields = {
+                       # Component
+                       "component" : self.pkg.name,
+
+                       # Send URL to build
+                       "url"       : self.backend.url_to(self.url),
+               }
+
+               # Add the distribution fields
+               fields |= self.distro.bugzilla_fields
+
+               return fields
+
        def can_be_deleted(self, user=None):
                """
                        Returns True if this build can be deleted
index 16b15601aac5c1d4319728d9988da14d474f6134..cbb2a86b740498b00ecf370a25490083e888ddff 100644 (file)
                                <h5 class="title is-5">{{ _("Jobs")}}</h5>
 
                                {% module JobsList(build.jobs, show_arch_only=True, show_packages=True) %}
+
+                               {# Bug? #}
+                               {% if build.has_failed() %}
+                                       <a class="button is-danger" href="/builds/{{ build.uuid }}/bug">
+                                               <span class="icon">
+                                                       <i class="fa-solid fa-bug"></i>
+                                               </span>
+                                               <span>{{ _("File A Bug Report") }}</span>
+                                       </a>
+                               {% end %}
                        </div>
                </section>
        {% end %}
index 8c836702cbfa5922586110fdce4265d746ba5fb0..771a109da6ff0ef396c6473bb418306eb5955e16 100644 (file)
@@ -134,6 +134,7 @@ class Application(tornado.web.Application):
                        # Builds
                        (r"/builds", builds.IndexHandler),
                        (r"/builds/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})", builds.ShowHandler),
+                       (r"/builds/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/bug", builds.BugHandler),
                        (r"/builds/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/comment", builds.CommentHandler),
                        (r"/builds/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/delete", builds.DeleteHandler),
                        (r"/builds/([\w]{8}-[\w]{4}-[\w]{4}-[\w]{4}-[\w]{12})/watch", builds.WatchHandler),
index 407608f0022a3018f1312553883dd925f9ea4855..4a1cc4c62f066a3d53e96763c0178334bb8423be 100644 (file)
@@ -171,6 +171,57 @@ class CommentHandler(base.BaseHandler):
                self.redirect("/builds/%s" % build.uuid)
 
 
+class BugHandler(base.BaseHandler):
+       @tornado.web.authenticated
+       async def get(self, uuid):
+               build = self.backend.builds.get_by_uuid(uuid)
+               if not build:
+                       raise tornado.web.HTTPError(404, "Could not find build %s" % uuid)
+
+               # Fetch fields
+               fields = await self.backend.bugzilla.fields
+
+               self.render("builds/bug.html", build=build, fields=fields)
+
+       @tornado.web.authenticated
+       async def post(self, uuid):
+               build = self.backend.builds.get_by_uuid(uuid)
+               if not build:
+                       raise tornado.web.HTTPError(404, "Could not find build %s" % uuid)
+
+               # Is the user connected to Bugzilla?
+               if not self.current_user.bugzilla:
+                       raise tornado.web.HTTPError(400, "%s is not connected to Bugzilla" \
+                               % self.current_user)
+
+               kwargs = {
+                       # Summary & Description
+                       "summary"     : self.get_argument("summary"),
+                       "description" : self.get_argument("description", None),
+               } | build.bugzilla_fields
+
+               # Create the bug
+               bug = await self.current_user.bugzilla.create_bug(**kwargs)
+
+               # Send the attachments
+               for job in build.jobs:
+                       if not self.get_argument_bool("attach_log_%s" % job.uuid):
+                               continue
+
+                       # Open the logfile
+                       try:
+                               log = await job.open_log()
+                       except FileNotFoundError as e:
+                               log.warning("Could not open log file for %s" % job)
+                               continue
+
+                       # Attach it to the bug
+                       await bug.attach(summary="Log file for %s" % job, filename="%s.log" % job,
+                               data=log, content_type="text/plain")
+
+               self.render("builds/bug-created.html", build=build, bug=bug)
+
+
 class GroupShowHandler(base.BaseHandler):
        def get(self, uuid):
                group = self.backend.builds.groups.get_by_uuid(uuid)