├── .gitignore
├── MANIFEST.in
├── Makefile
├── conftest.py
├── src
└── sentry_github
│ ├── models.py
│ ├── __init__.py
│ ├── utils.py
│ ├── templates
│ └── sentry_github
│ │ └── create_github_issue.html
│ └── plugin.py
├── setup.cfg
├── .travis.yml
├── setup.py
├── README.rst
└── tests
└── sentry_github
└── test_plugin.py
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.egg-info/
3 | /dist
4 | /build
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------