view src/plugins/plugin_misc_merge_requests.py @ 2450:124f5db998f2

jp (merge-request): first draft: Introduce a new "merge-request" command to handle merge request creation/sending.
author Goffi <goffi@goffi.org>
date Thu, 30 Nov 2017 20:52:33 +0100
parents 637ac234424f
children 544c4d2fec45
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# SAT plugin for Pubsub Schemas
# Copyright (C) 2009-2017 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 sat.core.i18n import _
from sat.core.constants import Const as C
from sat.core import exceptions
from twisted.words.protocols.jabber import jid
from twisted.internet import defer
from wokkel import generic
from collections import namedtuple
from sat.core.log import getLogger
log = getLogger(__name__)

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", "PUBSUB_SCHEMA", "TICKETS"],
    C.PI_MAIN: "MergeRequests",
    C.PI_HANDLER: "no",
    C.PI_DESCRIPTION: _("""Merge requests management plugin""")
}

FIELD_DATA_TYPE = u'type'
FIELD_DATA = u'request_data'


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


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

    def __init__(self, host):
        log.info(_(u"Merge requests plugin initialization"))
        self.host = host
        host.registerNamespace('merge_requests', NS_MERGE_REQUESTS)
        self._p = self.host.plugins["XEP-0060"]
        self._s = self.host.plugins["PUBSUB_SCHEMA"]
        self._t = self.host.plugins["TICKETS"]
        self._handlers = {}
        self._handlers_list = []  # handlers sorted by priority
        self._type_handlers = {}  # data type => handler map
        host.bridge.addMethod("mergeRequestsGet", ".plugin",
                              in_sign='ssiassa{ss}s', out_sign='(asa{ss}aaa{ss})',
                              method=self._get,
                              async=True
                              )
        host.bridge.addMethod("mergeRequestSet", ".plugin",
                              in_sign='ssssa{sas}ssa{ss}s', out_sign='s',
                              method=self._set,
                              async=True)
        host.bridge.addMethod("mergeRequestsSchemaGet", ".plugin",
                              in_sign='sss', out_sign='s',
                              method=self._getSchema,
                              async=True)
        host.bridge.addMethod("mergeRequestParseData", ".plugin",
                              in_sign='ss', out_sign='aa{ss}',
                              method=self._parseData,
                              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): True if repository can be handled
                - export(repository): 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(_(u"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, basestring):
            data_types = [data_types]
        for data_type in data_types:
            if data_type in self._type_handlers:
                log.warning(_(u'merge requests of type {type} are already handled by {old_handler}, '
                              u'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 _get(self, service='', node='', max_items=10, item_ids=None, sub_id=None, extra_dict=None, profile_key=C.PROF_KEY_NONE):
        client = self.host.getClient(profile_key)
        service = jid.JID(service) if service else None
        max_items = None if max_items == C.NO_LIMIT else max_items
        if extra_dict and 'parse' in extra_dict:
                extra_dict['parse'] = C.bool(extra_dict['parse'])
        extra = self._p.parseExtra(extra_dict)
        d = self.get(client, service, node or None, max_items, item_ids, sub_id or None, extra.rsm_request, extra.extra)
        d.addCallback(lambda (tickets, metadata, parsed_patches): (
            self._p.serItemsData((tickets, metadata)) +
            ([[{key: unicode(value) for key, value in p.iteritems()}
                for p in patches] for patches in parsed_patches],)))
        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]
            - list of parsed request data (if extra['parse'] is set, else empty list)
        """
        if not node:
            node = NS_MERGE_REQUESTS
        tickets_xmlui, metadata = yield self._t.get(client, service, node, max_items, item_ids, sub_id, rsm_request, extra, form_ns=NS_MERGE_REQUESTS)
        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=None, profile_key=C.PROF_KEY_NONE):
        client = self.host.getClient(profile_key)
        service = None if not service else jid.JID(service)
        if extra and 'update' in extra:
                extra['update'] = C.bool(extra['update'])
        if schema:
            schema = generic.parseXml(schema.encode('utf-8'))
        else:
            schema = None
        d = self.set(client, service, node or None, repository, method, values, schema, item_id or None, extra, deserialise=True)
        d.addCallback(lambda ret: ret or u'')
        return d

    @defer.inlineCallbacks
    def set(self, client, service, node, repository, method=u'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 = NS_MERGE_REQUESTS

        if values is None:
            values = {}

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

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

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

        if not values.get(u'title') or not values.get(u'body'):
            patches = yield 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(u'title'):
                values[u'title'] = msg_lines[0]
            if not values.get(u'body'):
                values[u'body'] = u'\n'.join(msg_lines[1:])

        values[FIELD_DATA] = data

        item_id = yield self._t.set(client, service, node, values, schema, item_id, extra, deserialise, form_ns=NS_MERGE_REQUESTS)
        defer.returnValue(item_id)

    def _getSchema(self, service, node, profile_key=C.PROF_KEY_NONE):
        if not node:
            node = NS_MERGE_REQUESTS
        return self._s._getUISchema(service, node, profile_key)

    def _parseData(self, data_type, data):
        d = self.parseData(data_type, data)
        d.addCallback(lambda parsed_patches:
            {key: unicode(value) for key, value in parsed_patches.iteritems()})
        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(_(u'No handler can handle data type "{type}"').format(type=data_type))
        return defer.maybeDeferred(handler.handler.parse, data, data_type)