├── sentry_redmine ├── models.py ├── __init__.py ├── client.py ├── forms.py └── plugin.py ├── MANIFEST.in ├── .gitignore ├── README.rst └── setup.py /sentry_redmine/models.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py README.rst MANIFEST.in LICENSE 2 | recursive-include sentry_redmine/templates * 3 | global-exclude *~ 4 | -------------------------------------------------------------------------------- /sentry_redmine/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | sentry_redmine 3 | ~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2013 by Aaditya Sood, Idea Device 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | 9 | try: 10 | VERSION = __import__('pkg_resources') \ 11 | .get_distribution('sentry-redmine').version 12 | except Exception, e: 13 | VERSION = 'unknown' 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | sentry-redmine 2 | ================== 3 | 4 | DEPRECATED: This project now lives in `sentry `_ 5 | 6 | An extension for Sentry which integrates with Redmine. Specifically, it allows you to easily create 7 | Redmine issues from events within Sentry. 8 | 9 | 10 | Install 11 | ------- 12 | 13 | Install the package via ``pip``:: 14 | 15 | pip install git+git://github.com/getsentry/sentry-redmine@master 16 | 17 | Configuration 18 | ------------- 19 | 20 | Create a user within your Redmine install (a system agent). This user will 21 | be creating tickets on your behalf via Sentry. 22 | 23 | Go to your project's configuration page (Projects -> [Project]) and select the 24 | Redmine tab. Enter the required credentials and click save changes. 25 | 26 | You'll now see a new action on groups which allows quick creation of issues. 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | sentry-redmine 4 | ================== 5 | 6 | An extension for Sentry which integrates with Redmine. Specifically, it allows 7 | you to easily create Redmine tickets from events within Sentry. 8 | 9 | :copyright: (c) 2015 by the Sentry Team, see AUTHORS for more details. 10 | :license: BSD, see LICENSE for more details. 11 | """ 12 | from setuptools import setup, find_packages 13 | 14 | 15 | install_requires = [ 16 | 'sentry>=7.3.0', 17 | ] 18 | 19 | setup( 20 | name='sentry-redmine', 21 | version='0.1.0', 22 | author='Sentry Team', 23 | author_email='support@getsentry.com', 24 | url='http://github.com/getsentry/sentry-redmine', 25 | description='A Sentry extension which integrates with Redmine.', 26 | long_description=__doc__, 27 | license='BSD', 28 | packages=find_packages(exclude=['tests']), 29 | zip_safe=False, 30 | install_requires=install_requires, 31 | test_suite='runtests.runtests', 32 | include_package_data=True, 33 | entry_points={ 34 | 'sentry.apps': [ 35 | 'redmine = sentry_redmine', 36 | ], 37 | 'sentry.plugins': [ 38 | 'redmine = sentry_redmine.plugin:RedminePlugin' 39 | ], 40 | }, 41 | classifiers=[ 42 | 'Framework :: Django', 43 | 'Intended Audience :: Developers', 44 | 'Intended Audience :: System Administrators', 45 | 'Operating System :: OS Independent', 46 | 'Topic :: Software Development' 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /sentry_redmine/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from sentry import http 4 | from sentry.utils import json 5 | 6 | 7 | class RedmineClient(object): 8 | def __init__(self, host, key): 9 | self.host = host.rstrip('/') 10 | self.key = key 11 | 12 | def request(self, method, path, data=None): 13 | headers = { 14 | 'X-Redmine-API-Key': self.key, 15 | 'Content-Type': "application/json", 16 | } 17 | url = '{}{}'.format(self.host, path) 18 | session = http.build_session() 19 | req = getattr(session, method.lower())(url, json=data, headers=headers) 20 | return json.loads(req.text) 21 | 22 | def get_projects(self): 23 | limit = 100 24 | projects = [] 25 | 26 | def get_response(limit, offset): 27 | return self.request('GET', '/projects.json?limit=%s&offset=%s' % (limit, offset)) 28 | 29 | response = get_response(limit, 0) 30 | 31 | while len(response['projects']): 32 | projects.extend(response['projects']) 33 | response = get_response(limit, response['offset'] + response['limit']) 34 | 35 | return {'projects': projects} 36 | 37 | def get_trackers(self): 38 | response = self.request('GET', '/trackers.json') 39 | return response 40 | 41 | def get_priorities(self): 42 | response = self.request('GET', '/enumerations/issue_priorities.json') 43 | return response 44 | 45 | def create_issue(self, data): 46 | response = self.request('POST', '/issues.json', data={ 47 | 'issue': data, 48 | }) 49 | 50 | if 'issue' not in response or 'id' not in response['issue']: 51 | raise Exception('Unable to create redmine ticket') 52 | 53 | return response 54 | -------------------------------------------------------------------------------- /sentry_redmine/forms.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import json 3 | 4 | from django import forms 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | from .client import RedmineClient 8 | 9 | 10 | class RedmineOptionsForm(forms.Form): 11 | host = forms.URLField(help_text=_("e.g. http://bugs.redmine.org")) 12 | key = forms.CharField( 13 | widget=forms.TextInput(attrs={'class': 'span9'}), 14 | help_text='Your API key is available on your account page after enabling the Rest API (Administration -> Settings -> Authentication)') 15 | project_id = forms.TypedChoiceField( 16 | label='Project', coerce=int) 17 | tracker_id = forms.TypedChoiceField( 18 | label='Tracker', coerce=int) 19 | default_priority = forms.TypedChoiceField( 20 | label='Default Priority', coerce=int) 21 | extra_fields = forms.CharField( 22 | widget=forms.Textarea(attrs={'rows': 5, 'class': 'span9'}), 23 | help_text='Extra attributes (custom fields, status id, etc.) in JSON format', 24 | label='Extra Fields', 25 | required=False) 26 | 27 | def __init__(self, data=None, *args, **kwargs): 28 | super(RedmineOptionsForm, self).__init__(data=data, *args, **kwargs) 29 | 30 | initial = kwargs.get('initial') or {} 31 | for key, value in self.data.items(): 32 | initial[key.lstrip(self.prefix or '')] = value 33 | 34 | has_credentials = all(initial.get(k) for k in ('host', 'key')) 35 | if has_credentials: 36 | client = RedmineClient(initial['host'], initial['key']) 37 | try: 38 | projects = client.get_projects() 39 | except Exception: 40 | has_credentials = False 41 | else: 42 | project_choices = [ 43 | (p['id'], '%s (%s)' % (p['name'], p['identifier'])) 44 | for p in projects['projects'] 45 | ] 46 | self.fields['project_id'].choices = project_choices 47 | 48 | if has_credentials: 49 | try: 50 | trackers = client.get_trackers() 51 | except Exception: 52 | del self.fields['tracker_id'] 53 | else: 54 | tracker_choices = [ 55 | (p['id'], p['name']) 56 | for p in trackers['trackers'] 57 | ] 58 | self.fields['tracker_id'].choices = tracker_choices 59 | 60 | try: 61 | priorities = client.get_priorities() 62 | except Exception: 63 | del self.fields['default_priority'] 64 | else: 65 | tracker_choices = [ 66 | (p['id'], p['name']) 67 | for p in priorities['issue_priorities'] 68 | ] 69 | self.fields['default_priority'].choices = tracker_choices 70 | 71 | if not has_credentials: 72 | del self.fields['project_id'] 73 | del self.fields['tracker_id'] 74 | del self.fields['default_priority'] 75 | 76 | def clean(self): 77 | cd = self.cleaned_data 78 | if cd.get('host') and cd.get('key'): 79 | client = RedmineClient(cd['host'], cd['key']) 80 | try: 81 | client.get_projects() 82 | except Exception: 83 | raise forms.ValidationError('There was an issue authenticating with Redmine') 84 | return cd 85 | 86 | def clean_host(self): 87 | """ 88 | Strip forward slashes off any url passed through the form. 89 | """ 90 | url = self.cleaned_data.get('host') 91 | if url: 92 | return url.rstrip('/') 93 | return url 94 | 95 | def clean_extra_fields(self): 96 | """ 97 | Ensure that the value provided is either a valid JSON dictionary, 98 | or the empty string. 99 | """ 100 | extra_fields_json = self.cleaned_data.get('extra_fields').strip() 101 | if not extra_fields_json: 102 | return '' 103 | 104 | try: 105 | extra_fields_dict = json.loads(extra_fields_json) 106 | except ValueError: 107 | raise forms.ValidationError('Invalid JSON specified') 108 | 109 | if not isinstance(extra_fields_dict, dict): 110 | raise forms.ValidationError('JSON dictionary must be specified') 111 | return json.dumps(extra_fields_dict, indent=4) 112 | 113 | 114 | class RedmineNewIssueForm(forms.Form): 115 | title = forms.CharField(max_length=200, widget=forms.TextInput(attrs={'class': 'span9'})) 116 | description = forms.CharField(widget=forms.Textarea(attrs={'class': 'span9'})) 117 | -------------------------------------------------------------------------------- /sentry_redmine/plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import json 3 | import six 4 | 5 | from django.utils.translation import ugettext_lazy as _ 6 | 7 | from sentry.exceptions import PluginError 8 | from sentry.plugins.bases.issue import IssuePlugin 9 | from sentry.utils.http import absolute_uri 10 | 11 | from .client import RedmineClient 12 | from .forms import RedmineNewIssueForm 13 | 14 | class RedminePlugin(IssuePlugin): 15 | author = 'Sentry' 16 | author_url = 'https://github.com/getsentry/sentry-redmine' 17 | version = '0.1.0' 18 | description = "Integrate Redmine issue tracking by linking a user account to a project." 19 | resource_links = [ 20 | ('Bug Tracker', 'https://github.com/getsentry/sentry-redmine/issues'), 21 | ('Source', 'https://github.com/getsentry/sentry-redmine'), 22 | ] 23 | 24 | slug = 'redmine' 25 | title = _('Redmine') 26 | conf_title = 'Redmine' 27 | conf_key = 'redmine' 28 | 29 | new_issue_form = RedmineNewIssueForm 30 | 31 | 32 | def __init__(self): 33 | super(RedminePlugin, self).__init__() 34 | self.client_errors = [] 35 | self.fields = [] 36 | 37 | def has_project_conf(self): 38 | return True 39 | 40 | def is_configured(self, project, **kwargs): 41 | return all((self.get_option(k, project) for k in ('host', 'key', 'project_id'))) 42 | 43 | def get_new_issue_title(self, **kwargs): 44 | return 'Create Redmine Task' 45 | 46 | def get_initial_form_data(self, request, group, event, **kwargs): 47 | return { 48 | 'description': self._get_group_description(request, group, event), 49 | 'title': self._get_group_title(request, group, event), 50 | } 51 | 52 | def _get_group_description(self, request, group, event): 53 | output = [ 54 | absolute_uri(group.get_absolute_url()), 55 | ] 56 | body = self._get_group_body(request, group, event) 57 | if body: 58 | output.extend([ 59 | '', 60 | '
',
 61 |                 body,
 62 |                 '
', 63 | ]) 64 | return '\n'.join(output) 65 | 66 | def get_client(self, project): 67 | return RedmineClient( 68 | host=self.get_option('host', project), 69 | key=self.get_option('key', project), 70 | ) 71 | 72 | def create_issue(self, group, form_data, **kwargs): 73 | """ 74 | Create a Redmine issue 75 | """ 76 | client = self.get_client(group.project) 77 | default_priority = self.get_option('default_priority', group.project) 78 | if default_priority is None: 79 | default_priority = 4 80 | 81 | issue_dict = { 82 | 'project_id': self.get_option('project_id', group.project), 83 | 'tracker_id': self.get_option('tracker_id', group.project), 84 | 'priority_id': default_priority, 85 | 'subject': form_data['title'].encode('utf-8'), 86 | 'description': form_data['description'].encode('utf-8'), 87 | } 88 | 89 | extra_fields_str = self.get_option('extra_fields', group.project) 90 | if extra_fields_str: 91 | extra_fields = json.loads(extra_fields_str) 92 | else: 93 | extra_fields = {} 94 | issue_dict.update(extra_fields) 95 | 96 | response = client.create_issue(issue_dict) 97 | return response['issue']['id'] 98 | 99 | def get_issue_url(self, group, issue_id, **kwargs): 100 | host = self.get_option('host', group.project) 101 | return '{}/issues/{}'.format(host.rstrip('/'), issue_id) 102 | 103 | 104 | def build_config(self): 105 | host = {'name':'host', 106 | 'label':'Host', 107 | 'type':'text', 108 | 'help':'e.g. http://bugs.redmine.org', 109 | 'required':True,} 110 | key = {'name':'key', 111 | 'label':'Key', 112 | 'type':'text', 113 | 'help':'Your API key is available on your account page after enabling the Rest API (Administration -> Settings -> Authentication)', 114 | 'required':True,} 115 | project_id = {'name':'project_id', 116 | 'label':'Project*', 117 | 'type':'select', 118 | 'choices':[], 119 | 'required':False,} 120 | tracker_id = {'name':'tracker_id', 121 | 'label':'Tracker*', 122 | 'type':'select', 123 | 'choices':[], 124 | 'required':False,} 125 | default_priority = {'name':'default_priority', 126 | 'label':'Default Priority*', 127 | 'type':'select', 128 | 'choices':[], 129 | 'required':False,} 130 | extra_fields = {'name':'extra_fields', 131 | 'label':'Extra Fields', 132 | 'type':'text', 133 | 'help':'Extra attributes (custom fields, status id, etc.) in JSON format', 134 | 'required':False,} 135 | return [host, key, project_id, tracker_id, default_priority, extra_fields] 136 | 137 | def add_choices(self, field_name, choices, default): 138 | for field in self.fields: 139 | if field_name == field['name']: 140 | field['choices'] = choices 141 | field['default'] = default 142 | return 143 | 144 | def remove_field(self, field_name): 145 | for field in self.fields: 146 | if field['name'] == field_name: 147 | self.fields.remove(field) 148 | return 149 | 150 | def build_initial(self, inital_args, project): 151 | initial = {} 152 | fields = ['host', 'key', 'project_id', 'tracker_id', 'default_priority', 'extra_fields'] 153 | for field in fields: 154 | value = inital_args.get(field) or self.get_option(field, project) 155 | if value is not None: 156 | initial[field] = value 157 | return initial 158 | 159 | def get_config(self, project, **kwargs): 160 | self.client_errors = [] 161 | self.fields = self.build_config() 162 | initial_args = kwargs.get('initial') or {} 163 | initial = self.build_initial(initial_args, project) 164 | 165 | has_credentials = all(initial.get(k) for k in ('host', 'key')) 166 | if has_credentials: 167 | client = RedmineClient(initial['host'], initial['key']) 168 | try: 169 | projects = client.get_projects() 170 | except Exception: 171 | has_credentials = False 172 | self.client_errors.append('There was an issue authenticating with Redmine') 173 | else: 174 | choices_value = self.get_option('project_id', project) 175 | project_choices = [('', '--') ] if not choices_value else [] 176 | project_choices += [ 177 | (p['id'], '%s (%s)' % (p['name'], p['identifier'])) 178 | for p in projects['projects'] 179 | ] 180 | self.add_choices('project_id', project_choices, choices_value) 181 | 182 | if has_credentials: 183 | try: 184 | trackers = client.get_trackers() 185 | except Exception: 186 | self.remove_field('tracker_id') 187 | else: 188 | choices_value = self.get_option('tracker_id', project) 189 | tracker_choices = [('', '--') ] if not choices_value else [] 190 | tracker_choices += [ 191 | (p['id'], p['name']) 192 | for p in trackers['trackers'] 193 | ] 194 | self.add_choices('tracker_id', tracker_choices, choices_value) 195 | 196 | 197 | try: 198 | priorities = client.get_priorities() 199 | except Exception: 200 | self.remove_field('default_priority') 201 | else: 202 | choices_value = self.get_option('default_priority', project) 203 | tracker_choices = [('', '--') ] if not choices_value else [] 204 | tracker_choices += [ 205 | (p['id'], p['name']) 206 | for p in priorities['issue_priorities'] 207 | ] 208 | self.add_choices('default_priority', tracker_choices, choices_value) 209 | 210 | if not has_credentials: 211 | for field_name in ['project_id', 'tracker_id', 'default_priority', 'extra_fields']: 212 | self.remove_field(field_name) 213 | 214 | return self.fields 215 | 216 | 217 | def validate_config(self, project, config, actor): 218 | super(RedminePlugin, self).validate_config(project, config, actor) 219 | self.client_errors = [] 220 | 221 | for field in self.fields: 222 | if field['name'] in ['project_id', 'tracker_id', 'default_priority']: 223 | if not config[field['name']]: 224 | self.logger.exception(six.text_type('{} required.'.format(field['name']))) 225 | self.client_errors.append(field['name']) 226 | 227 | if self.client_errors: 228 | raise PluginError(', '.join(self.client_errors) + ' required.') 229 | return config 230 | 231 | --------------------------------------------------------------------------------