view libervia/backend/plugins/plugin_misc_merge_requests.py @ 4334:111dce64dcb5

plugins XEP-0300, XEP-0446, XEP-0447, XEP0448 and others: Refactoring to use Pydantic: Pydantic models are used more and more in Libervia, for the bridge API, and also to convert `domish.Element` to internal representation. Type hints have also been added in many places. rel 453
author Goffi <goffi@goffi.org>
date Tue, 03 Dec 2024 00:12:38 +0100
parents 0d7bb4df2343
children
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 libervia.backend.core.i18n import _
from libervia.backend.core.constants import Const as C
from libervia.backend.core import exceptions
from libervia.backend.tools.common import data_format
from libervia.backend.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.get_submitted_ns(APP_NS_MERGE_REQUESTS)
        host.register_namespace("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.add_method(
            "merge_requests_get",
            ".plugin",
            in_sign="ssiassss",
            out_sign="s",
            method=self._get,
            async_=True,
        )
        host.bridge.add_method(
            "merge_request_set",
            ".plugin",
            in_sign="ssssa{sas}ssss",
            out_sign="s",
            method=self._set,
            async_=True,
        )
        host.bridge.add_method(
            "merge_requests_schema_get",
            ".plugin",
            in_sign="sss",
            out_sign="s",
            method=lambda service, nodeIdentifier, profile_key: self._s._get_ui_schema(
                service,
                nodeIdentifier,
                default_node=self.namespace,
                profile_key=profile_key,
            ),
            async_=True,
        )
        host.bridge.add_method(
            "merge_request_parse_data",
            ".plugin",
            in_sign="ss",
            out_sign="aa{ss}",
            method=self._parse_data,
            async_=True,
        )
        host.bridge.add_method(
            "merge_requests_import",
            ".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.trans_items_data((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.prepare_bridge_get(
            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.textbox_2_list_filter}
        else:
            filters = {}
        tickets_xmlui, metadata = yield defer.ensureDeferred(
            self._s.get_data_form_items(
                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.parse_data(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.prepare_bridge_set(
            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 = 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 _parse_data(self, data_type, data):
        d = self.parse_data(data_type, data)
        d.addCallback(
            lambda parsed_patches: {
                key: str(value) for key, value in parsed_patches.items()
            }
        )
        return d

    def parse_data(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.get_client(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.get_data_form_items(
                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
        )