Add bugwarrior
authorIsmaël Bouya <ismael.bouya@normalesup.org>
Sat, 24 Oct 2020 08:40:25 +0000 (10:40 +0200)
committerIsmaël Bouya <ismael.bouya@normalesup.org>
Sat, 24 Oct 2020 08:40:25 +0000 (10:40 +0200)
environments/immae-eu.nix
overlays/bugwarrior/default.nix [new file with mode: 0644]
overlays/bugwarrior/mantisbt.patch [new file with mode: 0644]
overlays/default.nix

index 04a9e9c07fa64c98f982579072a70c4be6929533..4ceee824516cc86ce9ec2f9d89304af2dd893d15 100644 (file)
@@ -106,6 +106,7 @@ let
 
     # todolist/time management
     taskwarrior vit timewarrior
+    bugwarrior
 
     # video/music
     youtube-dl ncmpc ncmpcpp ffmpeg
diff --git a/overlays/bugwarrior/default.nix b/overlays/bugwarrior/default.nix
new file mode 100644 (file)
index 0000000..2b25985
--- /dev/null
@@ -0,0 +1,5 @@
+self: super: {
+  bugwarrior = super.python3Packages.bugwarrior.overridePythonAttrs(old: rec {
+    patches = old.patches or [] ++ [ ./mantisbt.patch ];
+  });
+}
diff --git a/overlays/bugwarrior/mantisbt.patch b/overlays/bugwarrior/mantisbt.patch
new file mode 100644 (file)
index 0000000..85e5af1
--- /dev/null
@@ -0,0 +1,379 @@
+diff --git a/bugwarrior/services/mantisbt.py b/bugwarrior/services/mantisbt.py
+new file mode 100644
+index 0000000..e54af0d
+--- /dev/null
++++ b/bugwarrior/services/mantisbt.py
+@@ -0,0 +1,361 @@
++from builtins import filter
++import re
++import six
++
++import requests
++from jinja2 import Template
++
++from bugwarrior.config import asbool, aslist, die
++from bugwarrior.services import IssueService, Issue, ServiceClient
++
++import logging
++log = logging.getLogger(__name__)
++
++
++class MantisbtClient(ServiceClient):
++    def __init__(self, host, token):
++        self.host = host
++        self.session = requests.Session()
++        self.session.headers['Authorization'] = token
++
++    def _api_url(self, path, **context):
++        """ Build the full url to the API endpoint """
++        baseurl = "https://{}/api/rest".format(self.host)
++        return baseurl + path.format(**context)
++
++    def get_user(self):
++        return self.json_response(self.session.get(self._api_url("/users/me")))
++
++    def get_projects(self):
++        return self._getter(self._api_url("/projects"), subkey="projects")
++
++    def get_issues(self):
++        url = self._api_url("/issues?page_size=50")
++        return self._getter(url, page_size=50, subkey="issues")
++
++    def get_assigned_issues(self):
++        """ Returns all issues assigned to authenticated user.
++        """
++        url = self._api_url("/issues?page_size=50&filter_id=assigned")
++        return self._getter(url, page_size=50, subkey="issues")
++
++    def get_monitored_issues(self):
++        """ Returns all issues monitored by authenticated user.
++        """
++        url = self._api_url("/issues?page_size=50&filter_id=monitored")
++        return self._getter(url, page_size=50, subkey="issues")
++
++    def get_reported_issues(self):
++        """ Returns all issues reported by authenticated user.
++        """
++        url = self._api_url("/issues?page_size=50&filter_id=reported")
++        return self._getter(url, page_size=50, subkey="issues")
++
++    def _getter(self, url, page_size=None, subkey=None):
++        """ Pagination utility.  Obnoxious. """
++
++        results = []
++        link = dict(next=url)
++        page_number = 1
++
++        while 'next' in link:
++            if page_size is not None:
++                response = self.session.get(link['next'] + "&page=" + str(page_number))
++            else:
++                response = self.session.get(link['next'])
++
++            json_res = self.json_response(response)
++
++            if subkey is not None:
++                json_res = json_res[subkey]
++
++            results += json_res
++
++            if page_size is not None and len(json_res) == page_size:
++                page_number += 1
++            else:
++                break
++
++        return results
++
++class MantisbtIssue(Issue):
++    TITLE = 'mantisbttitle'
++    BODY = 'mantisbtbody'
++    CREATED_AT = 'mantisbtcreatedon'
++    UPDATED_AT = 'mantisbtupdatedat'
++    CLOSED_AT = 'mantisbtclosedon'
++    URL = 'mantisbturl'
++    PROJECT = 'mantisbtproject'
++    NUMBER = 'mantisbtnumber'
++    USER = 'mantisbtuser'
++    CATEGORY = 'mantisbtcategory'
++    STATE = 'mantisbtstate'
++
++    UDAS = {
++        TITLE: {
++            'type': 'string',
++            'label': 'Mantisbt Title',
++        },
++        BODY: {
++            'type': 'string',
++            'label': 'Mantisbt Body',
++        },
++        CREATED_AT: {
++            'type': 'date',
++            'label': 'Mantisbt Created',
++        },
++        UPDATED_AT: {
++            'type': 'date',
++            'label': 'Mantisbt Updated',
++        },
++        CLOSED_AT: {
++            'type': 'date',
++            'label': 'Mantisbt Closed',
++        },
++        PROJECT: {
++            'type': 'string',
++            'label': 'Mantisbt Project',
++        },
++        URL: {
++            'type': 'string',
++            'label': 'Mantisbt URL',
++        },
++        NUMBER: {
++            'type': 'numeric',
++            'label': 'Mantisbt Issue #',
++        },
++        USER: {
++            'type': 'string',
++            'label': 'Mantisbt User',
++        },
++        CATEGORY: {
++            'type': 'string',
++            'label': 'Mantisbt Category',
++        },
++        STATE: {
++            'type': 'string',
++            'label': 'Mantisbt State',
++        }
++    }
++    UNIQUE_KEY = (URL, NUMBER, )
++
++    def _normalize_tag(self, label):
++        return re.sub(r'[^a-zA-Z0-9]', '_', label)
++
++    def to_taskwarrior(self):
++        body = self.record.get('description')
++        if body:
++            body = body.replace('\r\n', '\n')
++
++        created = self.parse_date(self.record.get('created_at'))
++        updated = self.parse_date(self.record.get('updated_at'))
++        closed_date = None
++        if self.record["status"]["name"] in ["closed", "resolved"]:
++            for history in self.record.get("history", []):
++                if history.get("field", {}).get("name", "") == "status"\
++                        and history.get("new_value", {}).get("name", "") in ["closed", "resolved"]:
++                    closed_date = history["created_at"]
++        closed = self.parse_date(closed_date)
++
++        return {
++            'project': self.record['project']['name'],
++            'priority': self.origin['default_priority'],
++            'annotations': self.get_annotations(),
++            'tags': self.get_tags(),
++            'entry': created,
++            'end': closed,
++
++            self.TITLE: self.record.get('summary'),
++            self.BODY: body,
++            self.CREATED_AT: created,
++            self.UPDATED_AT: updated,
++            self.CLOSED_AT: closed,
++            self.URL: self.get_url(),
++            self.PROJECT: self.record['project'].get('name'),
++            self.NUMBER: self.record['id'],
++            self.USER: self.record['reporter'].get('name'),
++            self.CATEGORY: self.record['category'].get('name'),
++            self.STATE: self.record['status'].get('label'),
++        }
++
++    def get_url(self):
++        return "https://{}view.php?id={}".format(self.extra['host'], self.record["id"])
++
++    def get_annotations(self):
++        annotations = []
++
++        context = self.record.copy()
++        annotation_template = Template(self.origin['annotation_template'])
++
++        for annotation_dict in self.record.get('notes', []):
++            context.update({
++                'text': annotation_dict['text'],
++                'date': annotation_dict['created_at'],
++                'author': annotation_dict['reporter'].get('name', 'unknown'),
++                'view': annotation_dict['view_state']['label'],
++                })
++            annotations.append(
++                    annotation_template.render(context)
++                    )
++        return annotations
++
++    def get_tags(self):
++        tags = []
++
++        context = self.record.copy()
++        tag_template = Template(self.origin['tag_template'])
++
++        for tag_dict in self.record.get('tags', []):
++            context.update({
++                'tag': self._normalize_tag(tag_dict['name'])
++            })
++            tags.append(
++                tag_template.render(context)
++            )
++
++        return tags
++
++    def get_default_description(self):
++        return self.build_default_description(
++            title=self.record['summary'],
++            url=self.get_processed_url(self.get_url()),
++            number=self.record['id'],
++        )
++
++
++class MantisbtService(IssueService):
++    ISSUE_CLASS = MantisbtIssue
++    CONFIG_PREFIX = 'mantisbt'
++
++    def __init__(self, *args, **kw):
++        super(MantisbtService, self).__init__(*args, **kw)
++
++        self.host = self.config.get('host', 'www.mantisbt.org/bugs/')
++
++        token = self.get_password('token')
++
++        self.client = MantisbtClient(self.host, token)
++        self.user = None
++
++        self.exclude_projects = self.config.get('exclude_projects', [], aslist)
++        self.include_projects = self.config.get('include_projects', [], aslist)
++
++        self.involved_issues = self.config.get(
++            'involved_issues', default=True, to_type=asbool
++        )
++        self.assigned_issues = self.config.get(
++            'assigned_issues', default=False, to_type=asbool
++        )
++        self.monitored_issues = self.config.get(
++            'monitored_issues', default=False, to_type=asbool
++        )
++        self.reported_issues = self.config.get(
++            'reported_issues', default=False, to_type=asbool
++        )
++        self.tag_template = self.config.get(
++            'tag_template', default='{{tag}}', to_type=six.text_type
++        )
++        self.annotation_template = self.config.get(
++            'annotation_template', default='{{date}} {{author}} ({{view}}): {{text}}', to_type=six.text_type
++        )
++
++    def get_service_metadata(self):
++        return {
++            'tag_template': self.tag_template,
++            'annotation_template': self.annotation_template,
++        }
++
++    def filter_involved_issues(self, issue):
++        _, issue = issue
++        user = self.client.get_user()
++        uid = user["id"]
++        if issue["reporter"]["id"] != uid and \
++                issue.get("handler", {}).get("id") != uid and \
++                all([ x.get("user", {}).get("id") != uid for x in issue.get("history", [])]) and \
++                all([ x.get("user", {}).get("id") != uid for x in issue.get("monitors", [])]):
++            return False
++        return self.filter_project_name(issue["project"]["name"])
++
++    def filter_issues(self, issue):
++        _, issue = issue
++        return self.filter_project_name(issue["project"]["name"])
++
++    def filter_project_name(self, name):
++        if self.exclude_projects:
++            if name in self.exclude_projects:
++                return False
++
++        if self.include_projects:
++            if name in self.include_projects:
++                return True
++            else:
++                return False
++
++        return True
++
++    @staticmethod
++    def get_keyring_service(service_config):
++        host = service_config.get('host', 'www.mantisbt.org/bugs/')
++        username = service_config.get('username', default='nousername')
++        return "mantisbt://{username}@{host}".format(username=username,
++                host=host)
++
++    @staticmethod
++    def to_issue_dict(issues):
++        return { i['id']: i for i in issues }
++
++    def get_owner(self, issue):
++        return issue.get("handler", {}).get("name")
++
++    def get_author(self, issue):
++        return issue.get("reporter", {}).get("name")
++
++    def issues(self):
++        issues = {}
++        is_limited = self.assigned_issues or self.monitored_issues or self.reported_issues
++
++        if self.assigned_issues:
++            issues.update(
++                filter(self.filter_issues, self.to_issue_dict(self.client.get_assigned_issues()).items())
++            )
++        if self.monitored_issues:
++            issues.update(
++                filter(self.filter_issues, self.to_issue_dict(self.client.get_monitored_issues()).items())
++            )
++        if self.reported_issues:
++            issues.update(
++                filter(self.filter_issues, self.to_issue_dict(self.client.get_reported_issues()).items())
++            )
++
++        if not is_limited:
++            all_issues = self.to_issue_dict(self.client.get_issues())
++            if self.involved_issues:
++                issues.update(
++                    filter(self.filter_involved_issues, all_issues.items())
++                )
++            else:
++                issues.update(
++                    filter(self.filter_issues, all_issues.items())
++                )
++
++        log.debug(" Found %i issues.", len(issues))
++        if not is_limited:
++            issues = list(filter(self.include, issues.values()))
++        else:
++            issues = list(issues.values())
++        log.debug(" Pruned down to %i issues.", len(issues))
++
++        for issue in issues:
++            issue_obj = self.get_issue_for_record(issue)
++            extra = {
++                'host': self.host
++            }
++            issue_obj.update_extra(extra)
++            yield issue_obj
++
++    @classmethod
++    def validate_config(cls, service_config, target):
++        if 'token' not in service_config:
++            die("[%s] has no 'mantisbt.token'" % target)
++
++        super(MantisbtService, cls).validate_config(service_config, target)
+diff --git a/setup.py b/setup.py
+index d6d957a..665e36e 100644
+--- a/setup.py
++++ b/setup.py
+@@ -80,6 +80,7 @@ setup(name='bugwarrior',
+       activecollab2=bugwarrior.services.activecollab2:ActiveCollab2Service
+       activecollab=bugwarrior.services.activecollab:ActiveCollabService
+       jira=bugwarrior.services.jira:JiraService
++      mantisbt=bugwarrior.services.mantisbt:MantisbtService
+       megaplan=bugwarrior.services.megaplan:MegaplanService
+       phabricator=bugwarrior.services.phab:PhabricatorService
+       versionone=bugwarrior.services.versionone:VersionOneService
index 7444e15139fde6599293032f720df77796c9e98d..1db1819c5be584f0ea1f395e05a492824a6e98fa 100644 (file)
@@ -6,6 +6,7 @@
   bitlbee-discord = import ./bitlbee-discord;
   bonfire = import ./bonfire;
   bundix = import ./bundix;
+  bugwarrior = import ./bugwarrior;
   dwm = import ./dwm;
   elinks = import ./elinks;
   gitweb = import ./gitweb;