1 |
from __future__ import unicode_literals |
2 |
|
3 |
import base64 |
4 |
import json |
5 |
import logging |
6 |
import mimetools |
7 |
|
8 |
from django.utils.translation import ugettext_lazy as _ |
9 |
from djblets.util.compat import six |
10 |
from djblets.util.compat.six.moves.urllib.parse import urlparse |
11 |
from djblets.util.compat.six.moves.urllib.request import ( |
12 |
Request as URLRequest, |
13 |
HTTPBasicAuthHandler, |
14 |
urlopen) |
15 |
from pkg_resources import iter_entry_points |
16 |
|
17 |
|
18 |
class HostingService(object): |
19 |
"""An interface to a hosting service for repositories and bug trackers. |
20 |
|
21 |
HostingService subclasses are used to more easily configure repositories |
22 |
and to make use of third party APIs to perform special operations not |
23 |
otherwise usable by generic repositories. |
24 |
|
25 |
A HostingService can specify forms for repository and bug tracker |
26 |
configuration. |
27 |
|
28 |
It can also provide a list of repository "plans" (such as public |
29 |
repositories, private repositories, or other types available to the hosting |
30 |
service), along with configuration specific to the plan. These plans will |
31 |
be available when configuring the repository. |
32 |
""" |
33 |
name = None |
34 |
plans = None |
35 |
supports_bug_trackers = False |
36 |
supports_post_commit = False |
37 |
supports_repositories = False |
38 |
supports_ssh_key_association = False |
39 |
supports_two_factor_auth = False |
40 |
self_hosted = False |
41 |
|
42 |
# These values are defaults that can be overridden in repository_plans |
43 |
# above. |
44 |
needs_authorization = False |
45 |
supported_scmtools = [] |
46 |
form = None |
47 |
fields = [] |
48 |
repository_fields = {} |
49 |
bug_tracker_field = None |
50 |
|
51 |
def __init__(self, account): |
52 |
assert account |
53 |
self.account = account |
54 |
|
55 |
def is_authorized(self): |
56 |
"""Returns whether or not the account is currently authorized. |
57 |
|
58 |
An account may no longer be authorized if the hosting service |
59 |
switches to a new API that doesn't match the current authorization |
60 |
records. This function will determine whether the account is still |
61 |
considered authorized. |
62 |
""" |
63 |
return False |
64 |
|
65 |
def get_password(self): |
66 |
"""Returns the raw password for this hosting service. |
67 |
|
68 |
Not all hosting services provide this, and not all would need it. |
69 |
It's primarily used when building a Subversion client, or other |
70 |
SCMTools that still need direct access to the repository itself. |
71 |
""" |
72 |
return None |
73 |
|
74 |
def is_ssh_key_associated(self, repository, key): |
75 |
"""Returns whether or not the key is associated with the repository. |
76 |
|
77 |
If the ``key`` (an instance of :py:mod:`paramiko.PKey`) is present |
78 |
among the hosting service's deploy keys for a given ``repository`` or |
79 |
account, then it is considered associated. If there is a problem |
80 |
checking with the hosting service, an :py:exc:`SSHKeyAssociationError` |
81 |
will be raised. |
82 |
""" |
83 |
raise NotImplementedError |
84 |
|
85 |
def associate_ssh_key(self, repository, key): |
86 |
"""Associates an SSH key with a given repository |
87 |
|
88 |
The ``key`` (an instance of :py:mod:`paramiko.PKey`) will be added to |
89 |
the hosting service's list of deploy keys (if possible). If there |
90 |
is a problem uploading the key to the hosting service, a |
91 |
:py:exc:`SSHKeyAssociationError` will be raised. |
92 |
""" |
93 |
raise NotImplementedError |
94 |
|
95 |
def authorize(self, username, password, hosting_url, local_site_name=None, |
96 |
*args, **kwargs): |
97 |
raise NotImplementedError |
98 |
|
99 |
def check_repository(self, path, username, password, scmtool_class, |
100 |
local_site_name, *args, **kwargs): |
101 |
"""Checks the validity of a repository configuration. |
102 |
|
103 |
This performs a check against the hosting service or repository |
104 |
to ensure that the information provided by the user represents |
105 |
a valid repository. |
106 |
|
107 |
This is passed in the repository details, such as the path and |
108 |
raw credentials, as well as the SCMTool class being used, the |
109 |
LocalSite's name (if any), and all field data from the |
110 |
HostingServiceForm as keyword arguments. |
111 |
""" |
112 |
return scmtool_class.check_repository(path, username, password, |
113 |
local_site_name) |
114 |
|
115 |
def get_file(self, repository, path, revision, *args, **kwargs): |
116 |
if not self.supports_repositories: |
117 |
raise NotImplementedError |
118 |
|
119 |
return repository.get_scmtool().get_file(path, revision) |
120 |
|
121 |
def get_file_exists(self, repository, path, revision, *args, **kwargs): |
122 |
if not self.supports_repositories: |
123 |
raise NotImplementedError |
124 |
|
125 |
return repository.get_scmtool().file_exists(path, revision) |
126 |
|
127 |
def get_branches(self, repository): |
128 |
"""Get a list of all branches in the repositories. |
129 |
|
130 |
This should be implemented by subclasses, and is expected to return a |
131 |
list of Branch objects. One (and only one) of those objects should have |
132 |
the "default" field set to True. |
133 |
""" |
134 |
raise NotImplementedError |
135 |
|
136 |
def get_commits(self, repository, start=None): |
137 |
"""Get a list of commits backward in history from a given starting point. |
138 |
|
139 |
This should be implemented by subclasses, and is expected to return a |
140 |
list of Commit objects (usually 30, but this is flexible depending on |
141 |
the limitations of the APIs provided. |
142 |
|
143 |
This can be called multiple times in succession using the "parent" |
144 |
field of the last entry as the start parameter in order to paginate |
145 |
through the history of commits in the repository. |
146 |
""" |
147 |
raise NotImplementedError |
148 |
|
149 |
def get_change(self, repository, revision): |
150 |
"""Get an individual change. |
151 |
|
152 |
This should be implemented by subclasses, and is expected to return a |
153 |
tuple of (commit message, diff), both strings. |
154 |
""" |
155 |
raise NotImplementedError |
156 |
|
157 |
@classmethod |
158 |
def get_repository_fields(cls, username, hosting_url, plan, tool_name, |
159 |
field_vars): |
160 |
if not cls.supports_repositories: |
161 |
raise NotImplementedError |
162 |
|
163 |
# Grab the list of fields for population below. We have to do this |
164 |
# differently depending on whether or not this hosting service has |
165 |
# different repository plans. |
166 |
fields = cls._get_field(plan, 'repository_fields') |
167 |
|
168 |
new_vars = field_vars.copy() |
169 |
new_vars['hosting_account_username'] = username |
170 |
|
171 |
if cls.self_hosted: |
172 |
new_vars['hosting_url'] = hosting_url |
173 |
new_vars['hosting_domain'] = urlparse(hosting_url)[1] |
174 |
|
175 |
results = {} |
176 |
|
177 |
assert tool_name in fields |
178 |
|
179 |
for field, value in six.iteritems(fields[tool_name]): |
180 |
try: |
181 |
results[field] = value % new_vars |
182 |
except KeyError as e: |
183 |
logging.error('Failed to generate %s field for hosting ' |
184 |
'service %s using %s and %r: Missing key %s' |
185 |
% (field, six.text_type(cls.name), value, |
186 |
new_vars, e), |
187 |
exc_info=1) |
188 |
raise KeyError( |
189 |
_('Internal error when generating %(field)s field ' |
190 |
'(Missing key "%(key)s"). Please report this.') % { |
191 |
'field': field, |
192 |
'key': e, |
193 |
}) |
194 |
|
195 |
return results |
196 |
|
197 |
@classmethod |
198 |
def get_bug_tracker_requires_username(cls, plan=None): |
199 |
if not cls.supports_bug_trackers: |
200 |
raise NotImplementedError |
201 |
|
202 |
return ('%(hosting_account_username)s' in |
203 |
cls._get_field(plan, 'bug_tracker_field', '')) |
204 |
|
205 |
@classmethod |
206 |
def get_bug_tracker_field(cls, plan, field_vars): |
207 |
if not cls.supports_bug_trackers: |
208 |
raise NotImplementedError |
209 |
|
210 |
bug_tracker_field = cls._get_field(plan, 'bug_tracker_field') |
211 |
|
212 |
if not bug_tracker_field: |
213 |
return '' |
214 |
|
215 |
try: |
216 |
return bug_tracker_field % field_vars |
217 |
except KeyError as e: |
218 |
logging.error('Failed to generate %s field for hosting ' |
219 |
'service %s using %r: Missing key %s' |
220 |
% (bug_tracker_field, six.text_type(cls.name), |
221 |
field_vars, e), |
222 |
exc_info=1) |
223 |
raise KeyError( |
224 |
_('Internal error when generating %(field)s field ' |
225 |
'(Missing key "%(key)s"). Please report this.') % { |
226 |
'field': bug_tracker_field, |
227 |
'key': e, |
228 |
}) |
229 |
|
230 |
@classmethod |
231 |
def _get_field(cls, plan, name, default=None): |
232 |
if cls.plans: |
233 |
assert plan |
234 |
|
235 |
for plan_name, info in cls.plans: |
236 |
if plan_name == plan and name in info: |
237 |
return info[name] |
238 |
|
239 |
return getattr(cls, name, default) |
240 |
|
241 |
# |
242 |
# HTTP utility methods |
243 |
# |
244 |
|
245 |
def _json_get(self, *args, **kwargs): |
246 |
data, headers = self._http_get(*args, **kwargs) |
247 |
return json.loads(data), headers |
248 |
|
249 |
def _json_post(self, *args, **kwargs): |
250 |
data, headers = self._http_post(*args, **kwargs) |
251 |
return json.loads(data), headers |
252 |
|
253 |
def _http_get(self, url, *args, **kwargs): |
254 |
return self._http_request(url, **kwargs) |
255 |
|
256 |
def _http_post(self, url, body=None, fields={}, files={}, |
257 |
content_type=None, headers={}, *args, **kwargs): |
258 |
headers = headers.copy() |
259 |
|
260 |
if body is None: |
261 |
if fields is not None: |
262 |
body, content_type = self._build_form_data(fields, files) |
263 |
else: |
264 |
body = '' |
265 |
|
266 |
if content_type: |
267 |
headers['Content-Type'] = content_type |
268 |
|
269 |
headers['Content-Length'] = '%d' % len(body) |
270 |
|
271 |
return self._http_request(url, body, headers, **kwargs) |
272 |
|
273 |
def _build_request(self, url, body=None, headers={}, username=None, |
274 |
password=None): |
275 |
r = URLRequest(url, body, headers) |
276 |
|
277 |
if username is not None and password is not None: |
278 |
auth_key = username + ':' + password |
279 |
r.add_header(HTTPBasicAuthHandler.auth_header, |
280 |
'Basic %s' % |
281 |
base64.b64encode(auth_key.encode('utf-8'))) |
282 |
|
283 |
return r |
284 |
|
285 |
def _http_request(self, url, body=None, headers={}, **kwargs): |
286 |
r = self._build_request(url, body, headers, **kwargs) |
287 |
u = urlopen(r) |
288 |
|
289 |
return u.read(), u.headers |
290 |
|
291 |
def _build_form_data(self, fields, files): |
292 |
"""Encodes data for use in an HTTP POST.""" |
293 |
BOUNDARY = mimetools.choose_boundary() |
294 |
content = "" |
295 |
|
296 |
for key in fields: |
297 |
content += "--" + BOUNDARY + "\r\n" |
298 |
content += "Content-Disposition: form-data; name=\"%s\"\r\n" % key |
299 |
content += "\r\n" |
300 |
content += six.text_type(fields[key]) + "\r\n" |
301 |
|
302 |
for key in files: |
303 |
filename = files[key]['filename'] |
304 |
value = files[key]['content'] |
305 |
content += "--" + BOUNDARY + "\r\n" |
306 |
content += "Content-Disposition: form-data; name=\"%s\"; " % key |
307 |
content += "filename=\"%s\"\r\n" % filename |
308 |
content += "\r\n" |
309 |
content += value + "\r\n" |
310 |
|
311 |
content += "--" + BOUNDARY + "--\r\n" |
312 |
content += "\r\n" |
313 |
|
314 |
content_type = "multipart/form-data; boundary=%s" % BOUNDARY |
315 |
|
316 |
return content, content_type |
317 |
|
318 |
|
319 |
_hosting_services = {} |
320 |
|
321 |
|
322 |
def _populate_hosting_services(): |
323 |
"""Populates a list of known hosting services from Python entrypoints. |
324 |
|
325 |
This is called any time we need to access or modify the list of hosting |
326 |
services, to ensure that we have loaded the initial list once. |
327 |
""" |
328 |
if not _hosting_services: |
329 |
for entry in iter_entry_points('reviewboard.hosting_services'): |
330 |
try: |
331 |
_hosting_services[entry.name] = entry.load() |
332 |
except Exception as e: |
333 |
logging.error( |
334 |
'Unable to load repository hosting service %s: %s' |
335 |
% (entry, e)) |
336 |
|
337 |
|
338 |
def get_hosting_services(): |
339 |
"""Gets the list of hosting services. |
340 |
|
341 |
This will return an iterator for iterating over each hosting service. |
342 |
""" |
343 |
_populate_hosting_services() |
344 |
|
345 |
for name, cls in six.iteritems(_hosting_services): |
346 |
yield name, cls |
347 |
|
348 |
|
349 |
def get_hosting_service(name): |
350 |
"""Retrieves the hosting service with the given name. |
351 |
|
352 |
If the hosting service is not found, None will be returned. |
353 |
""" |
354 |
_populate_hosting_services() |
355 |
|
356 |
return _hosting_services.get(name, None) |
357 |
|
358 |
|
359 |
def register_hosting_service(name, cls): |
360 |
"""Registers a custom hosting service class. |
361 |
|
362 |
A name can only be registered once. A KeyError will be thrown if attempting |
363 |
to register a second time. |
364 |
""" |
365 |
_populate_hosting_services() |
366 |
|
367 |
if name in _hosting_services: |
368 |
raise KeyError('"%s" is already a registered hosting service' % name) |
369 |
|
370 |
_hosting_services[name] = cls |
371 |
|
372 |
|
373 |
def unregister_hosting_service(name): |
374 |
"""Unregisters a previously registered hosting service.""" |
375 |
_populate_hosting_services() |
376 |
|
377 |
try: |
378 |
del _hosting_services[name] |
379 |
except KeyError: |
380 |
logging.error('Failed to unregister unknown hosting service "%s"' % |
381 |
name) |
382 |
raise KeyError('"%s" is not a registered hosting service' % name) |