view sat/plugins/plugin_misc_merge_requests.py @ 3671:9c50d2f812c1

docker (e2e): add `pytest-twisted` to image
author Goffi <goffi@goffi.org>
date Wed, 08 Sep 2021 17:58:48 +0200
parents 5f65f4e9f8cb
children a0666f17f300
line wrap: on
line source

#!/usr/bin/env python3


# SAT plugin for Pubsub Schemas
# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from collections import namedtuple
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core import exceptions
from sat.tools.common import data_format
from sat.core.log import getLogger


log = getLogger(__name__)

APP_NS_MERGE_REQUESTS = 'org.salut-a-toi.merge_requests:0'

PLUGIN_INFO = {
    C.PI_NAME: _("Merge requests management"),
    C.PI_IMPORT_NAME: "MERGE_REQUESTS",
    C.PI_TYPE: "EXP",
    C.PI_PROTOCOLS: [],
    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "LISTS", "TEXT_SYNTAXES"],
    C.PI_MAIN: "MergeRequests",
    C.PI_HANDLER: "no",
    C.PI_DESCRIPTION: _("""Merge requests management plugin""")
}

FIELD_DATA_TYPE = 'type'
FIELD_DATA = 'request_data'


MergeRequestHandler = namedtuple("MergeRequestHandler", ['name',
                                                         'handler',
                                                         'data_types',
                                                         'short_desc',
                                                         'priority'])


class MergeRequests(object):
    META_AUTHOR = 'author'
    META_EMAIL = 'email'
    META_TIMESTAMP = 'timestamp'
    META_HASH = 'hash'
    META_PARENT_HASH = 'parent_hash'
    META_COMMIT_MSG = 'commit_msg'
    META_DIFF = 'diff'
    # index of the diff in the whole data
    # needed to retrieve comments location
    META_DIFF_IDX = 'diff_idx'

    def __init__(self, host):
        log.info(_("Merge requests plugin initialization"))
        self.host = host
        self._s = self.host.plugins["XEP-0346"]
        self.namespace = self._s.getSubmittedNS(APP_NS_MERGE_REQUESTS)
        host.registerNamespace('merge_requests', self.namespace)
        self._p = self.host.plugins["XEP-0060"]
        self._t = self.host.plugins["LISTS"]
        self._handlers = {}
        self._handlers_list = []  # handlers sorted by priority
        self._type_handlers = {}  # data type => handler map
        host.bridge.addMethod("mergeRequestsGet", ".plugin",
                              in_sign='ssiassss', out_sign='s',
                              method=self._get,
                              async_=True
                              )
        host.bridge.addMethod("mergeRequestSet", ".plugin",
                              in_sign='ssssa{sas}ssss', out_sign='s',
                              method=self._set,
                              async_=True)
        host.bridge.addMethod("mergeRequestsSchemaGet", ".plugin",
                              in_sign='sss', out_sign='s',
                              method=lambda service, nodeIdentifier, profile_key:
                                self._s._getUISchema(service,
                                                     nodeIdentifier,
                                                     default_node=self.namespace,
                                                     profile_key=profile_key),
                              async_=True)
        host.bridge.addMethod("mergeRequestParseData", ".plugin",
                              in_sign='ss', out_sign='aa{ss}',
                              method=self._parseData,
                              async_=True)
        host.bridge.addMethod("mergeRequestsImport", ".plugin",
                              in_sign='ssssa{ss}s', out_sign='',
                              method=self._import,
                              async_=True
                              )

    def register(self, name, handler, data_types, short_desc, priority=0):
        """register an merge request handler

        @param name(unicode): name of the handler
        @param handler(object): instance of the handler.
            It must have the following methods, which may all return a Deferred:
                - check(repository)->bool: True if repository can be handled
                - export(repository)->str: return export data, i.e. the patches
                - parse(export_data): parse report data and return a list of dict
                                      (1 per patch) with:
                    - title: title of the commit message (first line)
                    - body: body of the commit message
        @aram data_types(list[unicode]): data types that his handler can generate or parse
        """
        if name in self._handlers:
            raise exceptions.ConflictError(_("a handler with name {name} already "
                                             "exists!").format(name = name))
        self._handlers[name] = MergeRequestHandler(name,
                                                   handler,
                                                   data_types,
                                                   short_desc,
                                                   priority)
        self._handlers_list.append(name)
        self._handlers_list.sort(key=lambda name: self._handlers[name].priority)
        if isinstance(data_types, str):
            data_types = [data_types]
        for data_type in data_types:
            if data_type in self._type_handlers:
                log.warning(_('merge requests of type {type} are already handled by '
                              '{old_handler}, ignoring {new_handler}').format(
                                type = data_type,
                old_handler = self._type_handlers[data_type].name,
                new_handler = name))
                continue
            self._type_handlers[data_type] = self._handlers[name]

    def serialise(self, get_data):
        tickets_xmlui, metadata, items_patches = get_data
        tickets_xmlui_s, metadata = self._p.transItemsData((tickets_xmlui, metadata))
        return data_format.serialise({
            "items": tickets_xmlui_s,
            "metadata": metadata,
            "items_patches": items_patches,
        })

    def _get(self, service='', node='', max_items=10, item_ids=None, sub_id=None,
             extra="", profile_key=C.PROF_KEY_NONE):
        extra = data_format.deserialise(extra)
        client, service, node, max_items, extra, sub_id = self._s.prepareBridgeGet(
            service, node, max_items, sub_id, extra, profile_key)
        d = self.get(client, service, node or None, max_items, item_ids, sub_id or None,
                     extra.rsm_request, extra.extra)
        d.addCallback(self.serialise)
        return d

    @defer.inlineCallbacks
    def get(self, client, service=None, node=None, max_items=None, item_ids=None,
            sub_id=None, rsm_request=None, extra=None):
        """Retrieve merge requests and convert them to XMLUI

        @param extra(XEP-0060.parse, None): can have following keys:
            - update(bool): if True, will return list of parsed request data
        other params are the same as for [TICKETS._get]
        @return (tuple[list[unicode], list[dict[unicode, unicode]])): tuple with
            - XMLUI of the tickets, like [TICKETS._get]
            - node metadata
            - list of parsed request data (if extra['parse'] is set, else empty list)
        """
        if not node:
            node = self.namespace
        if extra is None:
            extra = {}
        # XXX: Q&D way to get list for labels when displaying them, but text when we
        #      have to modify them
        if C.bool(extra.get('labels_as_list', C.BOOL_FALSE)):
            filters = {'labels': self._s.textbox2ListFilter}
        else:
            filters = {}
        tickets_xmlui, metadata = yield defer.ensureDeferred(
            self._s.getDataFormItems(
                client,
                service,
                node,
                max_items=max_items,
                item_ids=item_ids,
                sub_id=sub_id,
                rsm_request=rsm_request,
                extra=extra,
                form_ns=APP_NS_MERGE_REQUESTS,
                filters = filters)
        )
        parsed_patches = []
        if extra.get('parse', False):
            for ticket in tickets_xmlui:
                request_type = ticket.named_widgets[FIELD_DATA_TYPE].value
                request_data = ticket.named_widgets[FIELD_DATA].value
                parsed_data = yield self.parseData(request_type, request_data)
                parsed_patches.append(parsed_data)
        defer.returnValue((tickets_xmlui, metadata, parsed_patches))

    def _set(self, service, node, repository, method, values, schema=None, item_id=None,
             extra="", profile_key=C.PROF_KEY_NONE):
        client, service, node, schema, item_id, extra = self._s.prepareBridgeSet(
            service, node, schema, item_id, extra, profile_key)
        d = defer.ensureDeferred(
            self.set(
                client, service, node, repository, method, values, schema,
                item_id or None, extra, deserialise=True
            )
        )
        d.addCallback(lambda ret: ret or '')
        return d

    async def set(self, client, service, node, repository, method='auto', values=None,
            schema=None, item_id=None, extra=None, deserialise=False):
        """Publish a tickets

        @param service(None, jid.JID): Pubsub service to use
        @param node(unicode, None): Pubsub node to use
            None to use default tickets node
        @param repository(unicode): path to the repository where the code stands
        @param method(unicode): name of one of the registered handler,
                                or "auto" to try autodetection.
        other arguments are same as for [TICKETS.set]
        @return (unicode): id of the created item
        """
        if not node:
            node = self.namespace
        if values is None:
            values = {}
        update = extra.get('update', False)
        if not repository and not update:
            # in case of update, we may re-user former patches data
            # so repository is not mandatory
            raise exceptions.DataError(_("repository must be specified"))

        if FIELD_DATA in values:
            raise exceptions.DataError(_("{field} is set by backend, you must not set "
                                         "it in frontend").format(field = FIELD_DATA))

        if repository:
            if method == 'auto':
                for name in self._handlers_list:
                    handler = self._handlers[name].handler
                    can_handle = await handler.check(repository)
                    if can_handle:
                        log.info(_("{name} handler will be used").format(name=name))
                        break
                else:
                    log.warning(_("repository {path} can't be handled by any installed "
                                  "handler").format(
                        path = repository))
                    raise exceptions.NotFound(_("no handler for this repository has "
                                                "been found"))
            else:
                try:
                    handler = self._handlers[name].handler
                except KeyError:
                    raise exceptions.NotFound(_("No handler of this name found"))

            data = await handler.export(repository)
            if not data.strip():
                raise exceptions.DataError(_('export data is empty, do you have any '
                                             'change to send?'))

            if not values.get('title') or not values.get('body'):
                patches = await handler.parse(data, values.get(FIELD_DATA_TYPE))
                commits_msg = patches[-1][self.META_COMMIT_MSG]
                msg_lines = commits_msg.splitlines()
                if not values.get('title'):
                    values['title'] = msg_lines[0]
                if not values.get('body'):
                    ts = self.host.plugins['TEXT_SYNTAXES']
                    xhtml = await ts.convert(
                        '\n'.join(msg_lines[1:]),
                        syntax_from = ts.SYNTAX_TEXT,
                        syntax_to = ts.SYNTAX_XHTML,
                        profile = client.profile)
                    values['body'] = '<div xmlns="{ns}">{xhtml}</div>'.format(
                        ns=C.NS_XHTML, xhtml=xhtml)

            values[FIELD_DATA] = data

        item_id = await self._t.set(client, service, node, values, schema, item_id, extra,
                                    deserialise, form_ns=APP_NS_MERGE_REQUESTS)
        return item_id

    def _parseData(self, data_type, data):
        d = self.parseData(data_type, data)
        d.addCallback(lambda parsed_patches:
            {key: str(value) for key, value in parsed_patches.items()})
        return d

    def parseData(self, data_type, data):
        """Parse a merge request data according to type

        @param data_type(unicode): type of the data to parse
        @param data(unicode): data to parse
        @return(list[dict[unicode, unicode]]): parsed data
            key of dictionary are self.META_* or keys specifics to handler
        @raise NotFound: no handler can parse this data_type
        """
        try:
            handler = self._type_handlers[data_type]
        except KeyError:
            raise exceptions.NotFound(_('No handler can handle data type "{type}"')
                                      .format(type=data_type))
        return defer.maybeDeferred(handler.handler.parse, data, data_type)

    def _import(self, repository, item_id, service=None, node=None, extra=None,
                profile_key=C.PROF_KEY_NONE):
        client = self.host.getClient(profile_key)
        service = jid.JID(service) if service else None
        d = self.import_request(client, repository, item_id, service, node or None,
                                extra=extra or None)
        return d

    @defer.inlineCallbacks
    def import_request(self, client, repository, item, service=None, node=None,
                       extra=None):
        """Import a merge request in specified directory

        @param repository(unicode): path to the repository where the code stands
        """
        if not node:
            node = self.namespace
        tickets_xmlui, metadata = yield defer.ensureDeferred(
            self._s.getDataFormItems(
                client,
                service,
                node,
                max_items=1,
                item_ids=[item],
                form_ns=APP_NS_MERGE_REQUESTS)
        )
        ticket_xmlui = tickets_xmlui[0]
        data = ticket_xmlui.named_widgets[FIELD_DATA].value
        data_type = ticket_xmlui.named_widgets[FIELD_DATA_TYPE].value
        try:
            handler = self._type_handlers[data_type]
        except KeyError:
            raise exceptions.NotFound(_('No handler found to import {data_type}')
                                      .format(data_type=data_type))
        log.info(_("Importing patch [{item_id}] using {name} handler").format(
            item_id = item,
            name = handler.name))
        yield handler.handler.import_(repository, data, data_type, item, service, node,
                                      extra)