├── .gitignore ├── .travis.yml ├── MANIFEST.in ├── Makefile ├── README.rst ├── conftest.py ├── setup.cfg ├── setup.py ├── src └── sentry_github │ ├── __init__.py │ ├── models.py │ ├── plugin.py │ ├── templates │ └── sentry_github │ │ └── create_github_issue.html │ └── utils.py └── tests └── sentry_github └── test_plugin.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info/ 3 | /dist 4 | /build -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | services: 4 | - memcached 5 | - postgresql 6 | - redis-server 7 | python: 8 | - '2.7' 9 | cache: pip 10 | deploy: 11 | provider: pypi 12 | user: getsentry 13 | password: 14 | secure: Cckg3cvzfGBAKuSZ++eDLrezxxZCMBs97ZnDHhfLxpwbMhZXquXmkQHoOmd/7qUq5Yijdzz7XQb17qh3rlE+WcB+htmd+m3QEu2YIk8BwFFVE5CeRus7ALlNsYyn0dWv8wpbqGVihpF1pbKlIY+NUDD4MTICTTQrMU4CfpPEE6E= 15 | on: 16 | tags: true 17 | distributions: sdist bdist_wheel 18 | install: 19 | - make install-tests 20 | script: 21 | - flake8 22 | - py.test 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py README.rst MANIFEST.in LICENSE 2 | recursive-include src/sentry_github/templates * 3 | global-exclude *~ 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | develop: 2 | pip install "pip>=7" 3 | pip install -e . 4 | 5 | install-tests: develop 6 | pip install .[tests] 7 | 8 | .PHONY: develop install-tests 9 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | sentry-github 2 | ============= 3 | 4 | **DEPRECATED:** This project now lives in `sentry-plugins `_ 5 | 6 | An extension for Sentry which integrates with GitHub. Specifically, it allows you to easily create 7 | issues from events within Sentry. 8 | 9 | 10 | Install 11 | ------- 12 | 13 | Install the package via ``pip``:: 14 | 15 | pip install sentry-github 16 | 17 | You'll have to create an application in GitHub to get the app ID and API secret. Use the following for the Authentication redirect URL:: 18 | 19 | /account/settings/social/associate/complete/github/ 20 | 21 | Ensure you've configured GitHub auth in Sentry:: 22 | 23 | GITHUB_APP_ID = 'GitHub Application Client ID' 24 | GITHUB_API_SECRET = 'GitHub Application Client Secret' 25 | GITHUB_EXTENDED_PERMISSIONS = ['repo'] 26 | 27 | If the callback URL you've registered with Github uses HTTPS, you'll need this in your config:: 28 | 29 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = True 30 | 31 | If your server is behind a reverse proxy, you'll need to enable the X-Forwarded-Proto 32 | and X-Forwarded-Host headers, and use this config:: 33 | 34 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 35 | USE_X_FORWARDED_HOST = True 36 | 37 | 38 | Associate your account with GitHub (if you haven't already) via Account -> Identities. If you had 39 | already associated your account, and you hadn't configured extended permissions, you'll need to 40 | disconnect and reconnect the account. 41 | 42 | You'll now see a new action on groups which allows quick creation of GitHub issues. 43 | 44 | Caveats 45 | ------- 46 | 47 | If you have multiple GitHub identities associated in Sentry, the plugin will just select 48 | one to use. 49 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | # Run tests against sqlite for simplicity 4 | import os 5 | os.environ.setdefault('DB', 'sqlite') 6 | 7 | pytest_plugins = ['sentry.utils.pytest'] 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [pytest] 5 | python_files = test*.py 6 | addopts = --tb=native -p no:doctest 7 | norecursedirs = bin dist docs htmlcov script hooks node_modules .* {args} 8 | 9 | [flake8] 10 | ignore = F999,E501,E128,E124,E402,W503,E731,C901 11 | max-line-length = 100 12 | exclude = .tox,.git,*/migrations/*,node_modules/*,docs/* 13 | 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | sentry-github 4 | ============= 5 | 6 | An extension for Sentry which integrates with GitHub. Specifically, it allows you to easily create 7 | issues from events within Sentry. 8 | 9 | :copyright: (c) 2012 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 | tests_require = [ 16 | 'exam', 17 | 'flake8>=2.0,<2.1', 18 | 'responses', 19 | 'sentry<8.7', 20 | ] 21 | 22 | install_requires = [] 23 | 24 | setup( 25 | name='sentry-github', 26 | version='0.1.2', 27 | author='David Cramer', 28 | author_email='dcramer@gmail.com', 29 | url='http://github.com/getsentry/sentry-github', 30 | description='A Sentry extension which integrates with GitHub.', 31 | long_description=__doc__, 32 | license='BSD', 33 | package_dir={'': 'src'}, 34 | packages=find_packages('src'), 35 | zip_safe=False, 36 | install_requires=install_requires, 37 | extras_require={'tests': tests_require}, 38 | include_package_data=True, 39 | entry_points={ 40 | 'sentry.apps': [ 41 | 'github = sentry_github', 42 | ], 43 | 'sentry.plugins': [ 44 | 'github = sentry_github.plugin:GitHubPlugin' 45 | ], 46 | }, 47 | classifiers=[ 48 | 'Framework :: Django', 49 | 'Intended Audience :: Developers', 50 | 'Intended Audience :: System Administrators', 51 | 'Operating System :: OS Independent', 52 | 'Topic :: Software Development' 53 | ], 54 | ) 55 | -------------------------------------------------------------------------------- /src/sentry_github/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | sentry_github 3 | ~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2012 by the Sentry Team, see AUTHORS for more details. 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | 9 | try: 10 | VERSION = __import__('pkg_resources') \ 11 | .get_distribution('sentry-github').version 12 | except Exception, e: 13 | VERSION = 'unknown' 14 | -------------------------------------------------------------------------------- /src/sentry_github/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | sentry_github.models 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2012 by the Sentry Team, see AUTHORS for more details. 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | -------------------------------------------------------------------------------- /src/sentry_github/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | sentry_github.plugin 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2012 by the Sentry Team, see AUTHORS for more details. 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | import requests 9 | from urllib import urlencode 10 | from django import forms 11 | from django.contrib import messages 12 | from django.utils.safestring import mark_safe 13 | from django.utils.translation import ugettext_lazy as _ 14 | 15 | from sentry.plugins.bases.issue import IssuePlugin, NewIssueForm 16 | from sentry.http import safe_urlopen, safe_urlread 17 | from sentry.utils import json 18 | from sentry.utils.http import absolute_uri 19 | 20 | import sentry_github 21 | from .utils import JSONResponse 22 | 23 | 24 | class GitHubOptionsForm(forms.Form): 25 | # TODO: validate repo? 26 | repo = forms.CharField(label=_('Repository Name'), 27 | widget=forms.TextInput(attrs={'placeholder': 'e.g. getsentry/sentry'}), 28 | help_text=_('Enter your repository name, including the owner.')) 29 | 30 | 31 | class GitHubNewIssueForm(NewIssueForm): 32 | assignee = forms.ChoiceField(choices=tuple(), required=False) 33 | 34 | def __init__(self, assignee_choices, *args, **kwargs): 35 | super(GitHubNewIssueForm, self).__init__(*args, **kwargs) 36 | self.fields['assignee'].choices = assignee_choices 37 | 38 | 39 | class GitHubExistingIssueForm(forms.Form): 40 | issue_id = forms.CharField( 41 | label=_('Issue'), 42 | widget=forms.TextInput(attrs={'class': 'issue-selector'}), 43 | help_text=mark_safe(_('You can use any syntax supported by GitHub\'s ' 44 | 'issue search.'))) 46 | comment = forms.CharField( 47 | label=_('GitHub Comment'), 48 | widget=forms.Textarea, 49 | required=False, 50 | help_text=_('Leave blank if you don\'t want to add a comment to the GitHub issue')) 51 | 52 | 53 | class GitHubPlugin(IssuePlugin): 54 | author = 'Sentry Team' 55 | author_url = 'https://github.com/getsentry/sentry-github' 56 | version = sentry_github.VERSION 57 | new_issue_form = GitHubNewIssueForm 58 | link_issue_form = GitHubExistingIssueForm 59 | description = "Integrate GitHub issues by linking a repository to a project." 60 | resource_links = [ 61 | ('Bug Tracker', 'https://github.com/getsentry/sentry-github/issues'), 62 | ('Source', 'https://github.com/getsentry/sentry-github'), 63 | ] 64 | 65 | slug = 'github' 66 | title = _('GitHub') 67 | conf_title = title 68 | conf_key = 'github' 69 | project_conf_form = GitHubOptionsForm 70 | auth_provider = 'github' 71 | create_issue_template = 'sentry_github/create_github_issue.html' 72 | can_unlink_issues = True 73 | can_link_existing_issues = True 74 | 75 | def is_configured(self, request, project, **kwargs): 76 | return bool(self.get_option('repo', project)) 77 | 78 | def get_new_issue_title(self, **kwargs): 79 | return 'Link GitHub Issue' 80 | 81 | def get_unlink_issue_title(self, **kwargs): 82 | return 'Unlink GitHub Issue' 83 | 84 | def get_new_issue_read_only_fields(self, **kwargs): 85 | group = kwargs.get('group') 86 | if group: 87 | return [{'label': 'Github Repository', 'value': self.get_option('repo', group.project)}] 88 | return [] 89 | 90 | def handle_api_error(self, request, error): 91 | msg = _('Error communicating with GitHub: %s') % error 92 | messages.add_message(request, messages.ERROR, msg) 93 | 94 | def get_allowed_assignees(self, request, group): 95 | try: 96 | url = self.build_api_url(group, 'assignees') 97 | req = self.make_api_request(request.user, url) 98 | body = safe_urlread(req) 99 | except requests.RequestException as e: 100 | msg = unicode(e) 101 | self.handle_api_error(request, msg) 102 | return tuple() 103 | 104 | try: 105 | json_resp = json.loads(body) 106 | except ValueError as e: 107 | msg = unicode(e) 108 | self.handle_api_error(request, msg) 109 | return tuple() 110 | 111 | if req.status_code > 399: 112 | self.handle_api_error(request, json_resp.get('message', '')) 113 | return tuple() 114 | 115 | users = tuple((u['login'], u['login']) for u in json_resp) 116 | 117 | return (('', 'Unassigned'),) + users 118 | 119 | def get_initial_link_form_data(self, request, group, event, **kwargs): 120 | return {'comment': absolute_uri(group.get_absolute_url())} 121 | 122 | def get_new_issue_form(self, request, group, event, **kwargs): 123 | """ 124 | Return a Form for the "Create new issue" page. 125 | """ 126 | return self.new_issue_form(self.get_allowed_assignees(request, group), 127 | request.POST or None, 128 | initial=self.get_initial_form_data(request, group, event)) 129 | 130 | def build_api_url(self, group, github_api, query_params=None): 131 | repo = self.get_option('repo', group.project) 132 | 133 | url = 'https://api.github.com/repos/%s/%s' % (repo, github_api) 134 | 135 | if query_params: 136 | url = '%s?%s' % (url, urlencode(query_params)) 137 | 138 | return url 139 | 140 | def make_api_request(self, user, url, json_data=None): 141 | auth = self.get_auth_for_user(user=user) 142 | if auth is None: 143 | raise forms.ValidationError(_('You have not yet associated GitHub with your account.')) 144 | 145 | req_headers = { 146 | 'Authorization': 'token %s' % auth.tokens['access_token'], 147 | } 148 | return safe_urlopen(url, json=json_data, headers=req_headers, allow_redirects=True) 149 | 150 | def create_issue(self, request, group, form_data, **kwargs): 151 | # TODO: support multiple identities via a selection input in the form? 152 | json_data = { 153 | "title": form_data['title'], 154 | "body": form_data['description'], 155 | "assignee": form_data.get('assignee'), 156 | } 157 | 158 | try: 159 | url = self.build_api_url(group, 'issues') 160 | req = self.make_api_request(request.user, url, json_data=json_data) 161 | body = safe_urlread(req) 162 | except requests.RequestException as e: 163 | msg = unicode(e) 164 | raise forms.ValidationError(_('Error communicating with GitHub: %s') % (msg,)) 165 | 166 | try: 167 | json_resp = json.loads(body) 168 | except ValueError as e: 169 | msg = unicode(e) 170 | raise forms.ValidationError(_('Error communicating with GitHub: %s') % (msg,)) 171 | 172 | if req.status_code > 399: 173 | raise forms.ValidationError(json_resp['message']) 174 | 175 | return json_resp['number'] 176 | 177 | def link_issue(self, request, group, form_data, **kwargs): 178 | comment = form_data.get('comment') 179 | if not comment: 180 | return 181 | url = '%s/%s/comments' % (self.build_api_url(group, 'issues'), form_data['issue_id']) 182 | try: 183 | req = self.make_api_request(request.user, url, json_data={'body': comment}) 184 | body = safe_urlread(req) 185 | except requests.RequestException as e: 186 | msg = unicode(e) 187 | raise forms.ValidationError(_('Error communicating with GitHub: %s') % (msg,)) 188 | 189 | try: 190 | json_resp = json.loads(body) 191 | except ValueError as e: 192 | msg = unicode(e) 193 | raise forms.ValidationError(_('Error communicating with GitHub: %s') % (msg,)) 194 | 195 | if req.status_code > 399: 196 | raise forms.ValidationError(json_resp['message']) 197 | 198 | def get_issue_label(self, group, issue_id, **kwargs): 199 | return 'GH-%s' % issue_id 200 | 201 | def get_issue_url(self, group, issue_id, **kwargs): 202 | # XXX: get_option may need tweaked in Sentry so that it can be pre-fetched in bulk 203 | repo = self.get_option('repo', group.project) 204 | 205 | return 'https://github.com/%s/issues/%s' % (repo, issue_id) 206 | 207 | def get_issue_title_by_id(self, request, group, issue_id): 208 | url = '%s/%s' % (self.build_api_url(group, 'issues'), issue_id) 209 | req = self.make_api_request(request.user, url) 210 | 211 | body = safe_urlread(req) 212 | json_resp = json.loads(body) 213 | return json_resp['title'] 214 | 215 | def view(self, request, group, **kwargs): 216 | if request.GET.get('autocomplete_query'): 217 | query = request.GET.get('q') 218 | if not query: 219 | return JSONResponse({'issues': []}) 220 | repo = self.get_option('repo', group.project) 221 | query = 'repo:%s %s' % (repo, query) 222 | url = 'https://api.github.com/search/issues?%s' % (urlencode({'q': query}),) 223 | 224 | try: 225 | req = self.make_api_request(request.user, url) 226 | body = safe_urlread(req) 227 | except requests.RequestException as e: 228 | msg = unicode(e) 229 | self.handle_api_error(request, msg) 230 | return JSONResponse({}, status=502) 231 | 232 | try: 233 | json_resp = json.loads(body) 234 | except ValueError as e: 235 | msg = unicode(e) 236 | self.handle_api_error(request, msg) 237 | return JSONResponse({}, status=502) 238 | 239 | issues = [{ 240 | 'text': '(#%s) %s' % (i['number'], i['title']), 241 | 'id': i['number'] 242 | } for i in json_resp.get('items', [])] 243 | return JSONResponse({'issues': issues}) 244 | 245 | return super(GitHubPlugin, self).view(request, group, **kwargs) 246 | -------------------------------------------------------------------------------- /src/sentry_github/templates/sentry_github/create_github_issue.html: -------------------------------------------------------------------------------- 1 | {% extends "sentry/plugins/bases/issue/create_issue.html" %} 2 | 3 | {% block meta %} 4 | {{ block.super }} 5 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /src/sentry_github/utils.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from sentry.plugins.base import Response 4 | from sentry.utils import json 5 | 6 | 7 | # This is just a fixed version of sentry.plugins.base.JSONResponse 8 | # that accepts a status kwarg 9 | class JSONResponse(Response): 10 | def __init__(self, context, status=200): 11 | self.context = context 12 | self.status = status 13 | 14 | def respond(self, request, context=None): 15 | return HttpResponse(json.dumps(self.context), 16 | content_type='application/json', status=self.status) 17 | -------------------------------------------------------------------------------- /tests/sentry_github/test_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import responses 4 | from exam import fixture 5 | from django.contrib.auth.models import AnonymousUser 6 | from django.forms import ValidationError 7 | from django.test import RequestFactory 8 | from django.test.utils import override_settings 9 | from sentry.testutils import TestCase 10 | from sentry.utils import json 11 | from social_auth.models import UserSocialAuth 12 | 13 | from sentry_github.plugin import GitHubPlugin 14 | 15 | 16 | class GitHubPluginTest(TestCase): 17 | @fixture 18 | def plugin(self): 19 | return GitHubPlugin() 20 | 21 | @fixture 22 | def request(self): 23 | return RequestFactory() 24 | 25 | def test_get_issue_label(self): 26 | group = self.create_group(message='Hello world', culprit='foo.bar') 27 | assert self.plugin.get_issue_label(group, 1) == 'GH-1' 28 | 29 | def test_get_issue_url(self): 30 | self.plugin.set_option('repo', 'getsentry/sentry', self.project) 31 | group = self.create_group(message='Hello world', culprit='foo.bar') 32 | assert self.plugin.get_issue_url(group, 1) == 'https://github.com/getsentry/sentry/issues/1' 33 | 34 | def test_is_configured(self): 35 | assert self.plugin.is_configured(None, self.project) is False 36 | self.plugin.set_option('repo', 'getsentry/sentry', self.project) 37 | assert self.plugin.is_configured(None, self.project) is True 38 | 39 | @responses.activate 40 | @override_settings(GITHUB_APP_ID='abc', GITHUB_API_SECRET='123') 41 | def test_create_issue(self): 42 | self.plugin.set_option('repo', 'getsentry/sentry', self.project) 43 | group = self.create_group(message='Hello world', culprit='foo.bar') 44 | 45 | request = self.request.get('/') 46 | request.user = AnonymousUser() 47 | form_data = { 48 | 'title': 'Hello', 49 | 'description': 'Fix this.', 50 | } 51 | with self.assertRaises(ValidationError): 52 | self.plugin.create_issue(request, group, form_data) 53 | 54 | request.user = self.user 55 | self.login_as(self.user) 56 | UserSocialAuth.objects.create(user=self.user, provider=self.plugin.auth_provider, extra_data={'access_token': 'foo'}) 57 | 58 | responses.add(responses.POST, 'https://api.github.com/repos/getsentry/sentry/issues', 59 | body='{"number": 1}') 60 | assert self.plugin.create_issue(request, group, form_data) == 1 61 | request = responses.calls[0].request 62 | payload = json.loads(request.body) 63 | assert payload == { 64 | 'title': 'Hello', 65 | 'body': 'Fix this.', 66 | 'assignee': None 67 | } 68 | --------------------------------------------------------------------------------