service
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)
Loading...