diff --git a/bugwarrior/docs/services/pagure.rst b/bugwarrior/docs/services/pagure.rst new file mode 100644 index 000000000..7ed6d1541 --- /dev/null +++ b/bugwarrior/docs/services/pagure.rst @@ -0,0 +1,104 @@ +Pagure +====== + +You can import tasks from your private or public `pagure `_ +instance using the ``pagure`` service name. + +Example Service +--------------- + +Here's an example of a Pagure target:: + + [my_issue_tracker] + service = pagure + pagure.tag = releng + pagure.base_url = https://pagure.io + +The above example is the minimum required to import issues from +Pagure. You can also feel free to use any of the +configuration options described in :ref:`common_configuration_options` +or described in `Service Features`_ below. + +Note that **either** ``pagure.tag`` or ``pagure.repo`` is required. + +- ``pagure.tag`` offers a flexible way to import issues from many pagure repos. + It will include issues from *every* repo on the pagure instance that is + *tagged* with the specified tag. It is similar in usage to a github + "organization". In the example above, the entry will pull issues from all + "releng" pagure repos. +- ``pagure.repo`` offers a simple way to import issues from a single pagure repo. + +Note -- no authentication tokens are needed to pull issues from pagure. + +Service Features +---------------- + +Include and Exclude Certain Repositories +++++++++++++++++++++++++++++++++++++++++ + +If you happen to be working with a large number of projects, you +may want to pull issues from only a subset of your repositories. To +do that, you can use the ``pagure.include_repos`` option. + +For example, if you would like to only pull-in issues from +your ``project_foo`` and ``project_fox`` repositories, you could add +this line to your service configuration:: + + pagure.tag = fedora-infra + pagure.include_repos = project_foo,project_fox + +Alternatively, if you have a particularly noisy repository, you can +instead choose to import all issues excepting it using the +``pagure.exclude_repos`` configuration option. + +In this example, ``noisy_repository`` is the repository you would +*not* like issues created for:: + + pagure.tag = fedora-infra + pagure.exclude_repos = noisy_repository + +Import Labels as Tags ++++++++++++++++++++++ + +The Pagure issue tracker allows you to attach tags to issues; to +use those pagure tags as taskwarrior tags, you can use the +``pagure.import_tags`` option:: + + github.import_tags = True + +Also, if you would like to control how these taskwarrior tags are created, you +can specify a template used for converting the Pagure tag into a Taskwarrior +tag. + +For example, to prefix all incoming labels with the string 'pagure_' (perhaps +to differentiate them from any existing tags you might have), you could +add the following configuration option:: + + pagure.label_template = pagure_{{label}} + +In addition to the context variable ``{{label}}``, you also have access +to all fields on the Taskwarrior task if needed. + +.. note:: + + See :ref:`field_templates` for more details regarding how templates + are processed. + +Provided UDA Fields +------------------- + ++-----------------------+---------------------+---------------------+ +| Field Name | Description | Type | ++=======================+=====================+=====================+ +| ``paguredatecreated`` | Created | Date & Time | ++-----------------------+---------------------+---------------------+ +| ``pagurenumber`` | Issue/PR # | Numeric | ++-----------------------+---------------------+---------------------+ +| ``paguretitle`` | Title | Text (string) | ++-----------------------+---------------------+---------------------+ +| ``paguretype`` | Type | Text (string) | ++-----------------------+---------------------+---------------------+ +| ``pagureurl`` | URL | Text (string) | ++-----------------------+---------------------+---------------------+ +| ``pagurerepo`` | username/reponame | Text (string) | ++-----------------------+---------------------+---------------------+ diff --git a/bugwarrior/services/__init__.py b/bugwarrior/services/__init__.py index bb01f61fe..c870b82d4 100644 --- a/bugwarrior/services/__init__.py +++ b/bugwarrior/services/__init__.py @@ -42,6 +42,7 @@ 'megaplan': 'bugwarrior.services.megaplan:MegaplanService', 'phabricator': 'bugwarrior.services.phab:PhabricatorService', 'versionone': 'bugwarrior.services.versionone:VersionOneService', + 'pagure': 'bugwarrior.services.pagure:PagureService', }) diff --git a/bugwarrior/services/pagure.py b/bugwarrior/services/pagure.py new file mode 100644 index 000000000..ea8b0ed4e --- /dev/null +++ b/bugwarrior/services/pagure.py @@ -0,0 +1,229 @@ +import re +import six +import datetime +import pytz + +import requests + +from jinja2 import Template +from twiggy import log + +from bugwarrior.config import asbool, die +from bugwarrior.services import IssueService, Issue + +class PagureIssue(Issue): + TITLE = 'paguretitle' + DATE_CREATED = 'paguredatecreated' + URL = 'pagureurl' + REPO = 'pagurerepo' + TYPE = 'paguretype' + ID = 'pagureid' + + UDAS = { + TITLE: { + 'type': 'string', + 'label': 'Pagure Title', + }, + DATE_CREATED: { + 'type': 'date', + 'label': 'Pagure Created', + }, + REPO: { + 'type': 'string', + 'label': 'Pagure Repo Slug', + }, + URL: { + 'type': 'string', + 'label': 'Pagure URL', + }, + TYPE: { + 'type': 'string', + 'label': 'Pagure Type', + }, + ID: { + 'type': 'numeric', + 'label': 'Pagure Issue/PR #', + }, + } + UNIQUE_KEY = (URL, TYPE,) + + def _normalize_label_to_tag(self, label): + return re.sub(r'[^a-zA-Z0-9]', '_', label) + + def to_taskwarrior(self): + if self.extra['type'] == 'pull_request': + priority = 'H' + else: + priority = self.origin['default_priority'] + + return { + 'project': self.extra['project'], + 'priority': priority, + 'annotations': self.extra.get('annotations', []), + 'tags': self.get_tags(), + + self.URL: self.record['html_url'], + self.REPO: self.record['repo'], + self.TYPE: self.extra['type'], + self.TITLE: self.record['title'], + self.ID: self.record['id'], + self.DATE_CREATED: datetime.datetime.fromtimestamp( + int(self.record['date_created']), pytz.UTC), + } + + def get_tags(self): + tags = [] + + if not self.origin['import_tags']: + return tags + + context = self.record.copy() + tag_template = Template(self.origin['tag_template']) + + for tagname in self.record.get('tags', []): + context.update({'label': self._normalize_label_to_tag(tagname) }) + tags.append(tag_template.render(context)) + + return tags + + def get_default_description(self): + return self.build_default_description( + title=self.record['title'], + url=self.get_processed_url(self.record['html_url']), + number=self.record['id'], + cls=self.extra['type'], + ) + + +class PagureService(IssueService): + ISSUE_CLASS = PagureIssue + CONFIG_PREFIX = 'pagure' + + def __init__(self, *args, **kw): + super(PagureService, self).__init__(*args, **kw) + + self.auth = {} + + self.tag = self.config_get_default('tag') + self.repo = self.config_get_default('repo') + self.base_url = self.config_get_default('base_url') + + self.exclude_repos = [] + if self.config_get_default('exclude_repos', None): + self.exclude_repos = [ + item.strip() for item in + self.config_get('exclude_repos').strip().split(',') + ] + + self.include_repos = [] + if self.config_get_default('include_repos', None): + self.include_repos = [ + item.strip() for item in + self.config_get('include_repos').strip().split(',') + ] + + self.import_tags = self.config_get_default( + 'import_tags', default=False, to_type=asbool + ) + self.tag_template = self.config_get_default( + 'tag_template', default='{{label}}', to_type=six.text_type + ) + + def get_service_metadata(self): + return { + 'import_tags': self.import_tags, + 'tag_template': self.tag_template, + } + + def get_issues(self, repo, keys): + """ Grab all the issues """ + key1, key2 = keys + key3 = key1[:-1] # Just the singular form of key1 + + url = self.base_url + "/api/0/" + repo + "/" + key1 + response = requests.get(url) + + if not bool(response): + raise IOError('Failed to talk to %r %r' % (url, response)) + + issues = [] + for result in response.json()[key2]: + idx = six.text_type(result['id']) + result['html_url'] = "/".join([self.base_url, repo, key3, idx]) + issues.append((repo, result)) + + return issues + + def annotations(self, issue, issue_obj): + url = issue['html_url'] + return self.build_annotations( + (( + c['user']['name'], + c['comment'], + ) for c in issue['comments']), + issue_obj.get_processed_url(url) + ) + + def get_owner(self, issue): + if issue[1]['assignee']: + return issue[1]['assignee']['name'] + + def filter_repos(self, repo): + if self.exclude_repos: + if repo in self.exclude_repos: + return False + + if self.include_repos: + if repo in self.include_repos: + return True + else: + return False + + return True + + def issues(self): + if self.tag: + url = self.base_url + "/api/0/projects?tags=" + self.tag + response = requests.get(url) + if not bool(response): + raise IOError('Failed to talk to %r %r' % (url, response)) + + all_repos = [r['name'] for r in response.json()['projects']] + else: + all_repos = [self.repo] + + repos = filter(self.filter_repos, all_repos) + + issues = [] + for repo in repos: + issues.extend(self.get_issues(repo, ('issues', 'issues'))) + issues.extend(self.get_issues(repo, ('pull-requests', 'requests'))) + + log.name(self.target).debug(" Found {0} issues.", len(issues)) + issues = filter(self.include, issues) + log.name(self.target).debug(" Pruned down to {0} issues.", len(issues)) + + for repo, issue in issues: + # Stuff this value into the upstream dict for: + # https://pagure.com/ralphbean/bugwarrior/issues/159 + issue['repo'] = repo + + issue_obj = self.get_issue_for_record(issue) + extra = { + 'project': repo, + 'type': 'pull_request' if 'branch' in issue else 'issue', + 'annotations': self.annotations(issue, issue_obj) + } + issue_obj.update_extra(extra) + yield issue_obj + + @classmethod + def validate_config(cls, config, target): + if not config.has_option(target, 'pagure.tag') and \ + not config.has_option(target, 'pagure.repo'): + die("[%s] has no 'pagure.tag' or 'pagure.repo'" % target) + + if not config.has_option(target, 'pagure.base_url'): + die("[%s] has no 'pagure.base_url'" % target) + + super(PagureService, cls).validate_config(config, target)