/
models.py
1385 lines (1105 loc) · 50.1 KB
/
models.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import logging
import uuid
from functools import wraps
from importlib import import_module
from time import time
from urllib.parse import quote
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import IntegrityError, models
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import gettext, gettext_lazy as _
from djblets.cache.backend import cache_memoize, make_cache_key
from djblets.db.fields import JSONField
from djblets.log import log_timed
from djblets.util.decorators import cached_property
from reviewboard.deprecation import RemovedInReviewBoard60Warning
from reviewboard.hostingsvcs.errors import MissingHostingServiceError
from reviewboard.hostingsvcs.models import HostingServiceAccount
from reviewboard.hostingsvcs.service import get_hosting_service
from reviewboard.scmtools import scmtools_registry
from reviewboard.scmtools.core import FileLookupContext
from reviewboard.scmtools.crypto_utils import (decrypt_password,
encrypt_password)
from reviewboard.scmtools.managers import RepositoryManager, ToolManager
from reviewboard.scmtools.signals import (checked_file_exists,
checking_file_exists,
fetched_file, fetching_file)
from reviewboard.site.models import LocalSite
logger = logging.getLogger(__name__)
def _deprecated_proxy_property(property_name):
"""Implement a proxy property for tools.
The Tool class includes a number of properties that are proxied from the
scmtool class, and we'd like to transition away from them in favor of
having users get that information directly from the scmtool instead.
Version Added:
5.0
Args:
property_name (str):
The name of the property to proxy.
Returns:
property:
A property that will proxy the value and raise a warning.
"""
@wraps(property_name)
def _wrapped(tool):
RemovedInReviewBoard60Warning.warn(
'The Tool.%s property is deprecated and will be removed in '
'Review Board 6.0. Use the property on the repository or '
'SCMTool class instead.'
% property_name)
return getattr(tool.scmtool_class, property_name)
return property(_wrapped)
class Tool(models.Model):
"""A configured source code management tool.
Each :py:class:`~reviewboard.scmtools.core.SCMTool` used by repositories
must have a corresponding :py:class:`Tool` entry. These provide information
on the capabilities of the tool, and accessors to construct a tool for
a repository.
Deprecated:
5.0:
This model is now obsolete. Any usage of this should be updated to use
equivalent methods on the Repository or SCMTool instead.
"""
name = models.CharField(max_length=32, unique=True)
class_name = models.CharField(max_length=128, unique=True)
objects = ToolManager()
# Templates can't access variables on a class properly. It'll attempt to
# instantiate the class, which will fail without the necessary parameters.
# So, we use these as convenient wrappers to do what the template can't do.
#: Whether or not the SCMTool supports review requests with history.
#:
#: See :py:attr:`SCMTool.supports_history
#: <reviewboard.scmtools.core.SCMTool.supports_history>` for details.
supports_history = _deprecated_proxy_property('supports_history')
#: Whether custom URL masks can be defined to fetching file contents.
#:
#: See :py:attr:`SCMTool.supports_raw_file_urls
#: <reviewboard.scmtools.core.SCMTool.supports_raw_file_urls>` for details.
supports_raw_file_urls = _deprecated_proxy_property(
'supports_raw_file_urls')
#: Whether ticket-based authentication is supported.
#:
#: See :py:attr:`SCMTool.supports_ticket_auth
#: <reviewboard.scmtools.core.SCMTool.supports_ticket_auth>` for details.
supports_ticket_auth = _deprecated_proxy_property('supports_ticket_auth')
#: Whether server-side pending changesets are supported.
#:
#: See :py:attr:`SCMTool.supports_pending_changesets
#: <reviewboard.scmtools.core.SCMTool.supports_pending_changesets>` for
#: details.
supports_pending_changesets = _deprecated_proxy_property(
'supports_pending_changesets')
#: Overridden help text for the configuration form fields.
#:
#: See :py:attr:`SCMTool.field_help_text
#: <reviewboard.scmtools.core.SCMTool.field_help_text>` for details.
field_help_text = property(
lambda x: x.scmtool_class.field_help_text)
@property
def scmtool_id(self):
"""The unique ID for the SCMTool.
Type:
unicode
"""
return self.scmtool_class.scmtool_id
def get_scmtool_class(self):
"""Return the configured SCMTool class.
Returns:
type:
The subclass of :py:class:`~reviewboard.scmtools.core.SCMTool`
backed by this Tool entry.
Raises:
django.core.exceptions.ImproperlyConfigured:
The SCMTool could not be found.
"""
if not hasattr(self, '_scmtool_class'):
path = self.class_name
i = path.rfind('.')
module, attr = path[:i], path[i + 1:]
try:
mod = import_module(module)
except ImportError as e:
raise ImproperlyConfigured(
'Error importing SCM Tool %s: "%s"' % (module, e))
try:
self._scmtool_class = getattr(mod, attr)
except AttributeError:
raise ImproperlyConfigured(
'Module "%s" does not define a "%s" SCM Tool'
% (module, attr))
return self._scmtool_class
scmtool_class = property(get_scmtool_class)
def __str__(self):
"""Return the name of the tool.
Returns:
unicode:
The name of the tool.
"""
return self.name
class Meta:
db_table = 'scmtools_tool'
ordering = ('name',)
verbose_name = _('Tool')
verbose_name_plural = _('Tools')
class Repository(models.Model):
"""A configured external source code repository.
Each configured Repository entry represents a source code repository that
Review Board can communicate with as part of the diff uploading and
viewing process.
Repositories are backed by a
:py:class:`~reviewboard.scmtools.core.SCMTool`, which functions as a client
for the type of repository and can fetch files, load lists of commits and
branches, and more.
Access control is managed by a combination of the :py:attr:`public`,
:py:attr:`users`, and :py:attr:`groups` fields. :py:attr:`public` controls
whether a repository is publicly-accessible by all users on the server.
When ``False``, only users explicitly listed in :py:attr:`users` or users
who are members of the groups listed in :py:attr:`groups` will be able
to access the repository or view review requests posted against it.
"""
#: The amount of time branches are cached, in seconds.
#:
#: Branches are cached for 5 minutes.
BRANCHES_CACHE_PERIOD = 60 * 5
#: The short period of time to cache commit information, in seconds.
#:
#: Some commit information (such as retrieving the latest commits in a
#: repository) should result in information cached only for a short
#: period of time. This is set to cache for 5 minutes.
COMMITS_CACHE_PERIOD_SHORT = 60 * 5
#: The long period of time to cache commit information, in seconds.
#:
#: Commit information that is unlikely to change should be kept around
#: for a longer period of time. This is set to cache for 1 day.
COMMITS_CACHE_PERIOD_LONG = 60 * 60 * 24 # 1 day
#: The fallback encoding for text-based files in repositories.
#:
#: This is used if the file isn't valid UTF-8, and if the repository
#: doesn't specify a list of encodings.
FALLBACK_ENCODING = 'iso-8859-15'
#: The error message used to indicate that a repository name conflicts.
NAME_CONFLICT_ERROR = _('A repository with this name already exists')
#: The error message used to indicate that a repository path conflicts.
PATH_CONFLICT_ERROR = _('A repository with this path already exists')
#: The prefix used to indicate an encrypted password.
#:
#: This is used to indicate whether a stored password is in encrypted
#: form or plain text form.
ENCRYPTED_PASSWORD_PREFIX = '\t'
name = models.CharField(_('Name'), max_length=255)
path = models.CharField(_('Path'), max_length=255)
mirror_path = models.CharField(max_length=255, blank=True)
raw_file_url = models.CharField(
_('Raw file URL mask'),
max_length=255,
blank=True)
username = models.CharField(max_length=32, blank=True)
encrypted_password = models.CharField(max_length=128, blank=True,
db_column='password')
extra_data = JSONField(null=True)
tool = models.ForeignKey(Tool, on_delete=models.CASCADE,
related_name='repositories')
scmtool_id = models.CharField(max_length=255, null=True, blank=True)
hosting_account = models.ForeignKey(
HostingServiceAccount,
on_delete=models.CASCADE,
related_name='repositories',
verbose_name=_('Hosting service account'),
blank=True,
null=True)
bug_tracker = models.CharField(
_('Bug tracker URL'),
max_length=256,
blank=True,
help_text=_("This should be the full path to a bug in the bug tracker "
"for this repository, using '%s' in place of the bug ID."))
encoding = models.CharField(
max_length=32,
blank=True,
help_text=_("The encoding used for files in this repository. This is "
"an advanced setting and should only be used if you're "
"sure you need it."))
visible = models.BooleanField(
_('Show this repository'),
default=True,
help_text=_('Use this to control whether or not a repository is '
'shown when creating new review requests. Existing '
'review requests are unaffected.'))
archived = models.BooleanField(
_('Archived'),
default=False,
help_text=_("Archived repositories do not show up in lists of "
"repositories, and aren't open to new review requests."))
archived_timestamp = models.DateTimeField(null=True, blank=True)
# Access control
local_site = models.ForeignKey(LocalSite,
on_delete=models.CASCADE,
verbose_name=_('Local site'),
blank=True,
null=True)
public = models.BooleanField(
_('publicly accessible'),
default=True,
help_text=_('Review requests and files on public repositories are '
'visible to anyone. Private repositories must explicitly '
'list the users and groups that can access them.'))
users = models.ManyToManyField(
User,
limit_choices_to={'is_active': True},
blank=True,
related_name='repositories',
verbose_name=_('Users with access'),
help_text=_('A list of users with explicit access to the repository.'))
review_groups = models.ManyToManyField(
'reviews.Group',
limit_choices_to={'invite_only': True},
blank=True,
related_name='repositories',
verbose_name=_('Review groups with access'),
help_text=_('A list of invite-only review groups whose members have '
'explicit access to the repository.'))
hooks_uuid = models.CharField(
_('Hooks UUID'),
max_length=32,
null=True,
blank=True,
help_text=_('Unique identifier used for validating incoming '
'webhooks.'))
objects = RepositoryManager()
@property
def password(self):
"""The password for the repository.
If a password is stored and encrypted, it will be decrypted and
returned.
If the stored password is in plain-text, then it will be encrypted,
stored in the database, and returned.
"""
password = self.encrypted_password
# NOTE: Due to a bug in 2.0.9, it was possible to get a string of
# "\tNone", indicating no password. We have to check for this.
if not password or password == '\tNone':
password = None
elif password.startswith(self.ENCRYPTED_PASSWORD_PREFIX):
password = password[len(self.ENCRYPTED_PASSWORD_PREFIX):]
if password:
password = decrypt_password(password)
else:
password = None
else:
# This is a plain-text password. Convert it.
self.password = password
self.save(update_fields=['encrypted_password'])
return password
@password.setter
def password(self, value):
"""Set the password for the repository.
The password will be stored as an encrypted value, prefixed with a
tab character in order to differentiate between legacy plain-text
passwords.
"""
if value:
value = '%s%s' % (self.ENCRYPTED_PASSWORD_PREFIX,
encrypt_password(value))
else:
value = ''
self.encrypted_password = value
@property
def scmtool_class(self):
"""The SCMTool subclass used for this repository.
Type:
type:
A subclass of :py:class:`~reviewboard.scmtools.core.SCMTool`.
Raises:
django.core.exceptions.ImproperlyConfigured:
The SCMTool could not be found, due to missing packages or
extensions. Details are in the message, and the failure is
logged.
"""
# We'll optimistically cache this, mirroring behavior in
# Tool.get_scmtool_class(). Note that we only cache below if we get
# a non-None value, as None can occur while an instance is being set
# up.
if hasattr(self, '_scmtool_class'):
return self._scmtool_class
scmtool_id = self.scmtool_id
if scmtool_id:
tool = scmtools_registry.get_by_id(scmtool_id)
if tool is not None:
self._scmtool_class = tool
return tool
logger.error('Error finding registered SCMTool "%s" in '
'repository ID %s.',
scmtool_id, self.pk)
elif not self.tool_id:
# For backwards-compatibility reasons, we return None when there's
# no Tool object associated.
return None
# We use ImproperlyConfigured here for compatibility with the
# the call to Tool.get_scmtool_class() in Review Board < 5.0.
raise ImproperlyConfigured(
gettext(
'There was an error loading the SCMTool "%s" needed by this '
'repository. The administrator should ensure all necessary '
'packages and extensions are installed.'
)
% (scmtool_id or self.tool.name))
@cached_property
def hosting_service(self):
"""The hosting service providing this repository.
This will be ``None`` if this is a standalone repository.
Type:
reviewboard.hostingsvcs.service.HostingService
Raises:
reviewboard.hostingsvcs.errors.MissingHostingServiceError:
The hosting service for this repository could not be loaded.
"""
if self.hosting_account:
try:
return self.hosting_account.service
except MissingHostingServiceError as e:
raise MissingHostingServiceError(e.hosting_service_id,
self.name)
return None
@cached_property
def bug_tracker_service(self):
"""The selected bug tracker service for the repository.
This will be ``None`` if this repository is not associated with a bug
tracker.
Type:
reviewboard.hostingsvcs.service.HostingService
Raises:
reviewboard.hostingsvcs.errors.MissingHostingServiceError:
The hosting service for this repository could not be loaded.
"""
if self.extra_data.get('bug_tracker_use_hosting'):
return self.hosting_service
bug_tracker_type = self.extra_data.get('bug_tracker_type')
if bug_tracker_type:
bug_tracker_cls = get_hosting_service(bug_tracker_type)
# TODO: we need to figure out some way of storing a second
# hosting service account for bug trackers.
return bug_tracker_cls(HostingServiceAccount())
return None
@property
def supports_post_commit(self):
"""Whether or not this repository supports post-commit creation.
If this is ``True``, the :py:meth:`get_branches` and
:py:meth:`get_commits` methods will be implemented to fetch information
about the committed revisions, and get_change will be implemented to
fetch the actual diff. This is used by
:py:meth:`ReviewRequestDraft.update_from_commit_id
<reviewboard.reviews.models.ReviewRequestDraft.update_from_commit_id>`.
Type:
bool
Raises:
reviewboard.hostingsvcs.errors.MissingHostingServiceError:
The hosting service for this repository could not be loaded.
"""
hosting_service = self.hosting_service
if hosting_service:
return hosting_service.supports_post_commit
else:
return self.scmtool_class.supports_post_commit
@property
def supports_pending_changesets(self):
"""Whether this repository supports server-aware pending changesets.
Type:
bool
"""
return self.scmtool_class.supports_pending_changesets
@cached_property
def diffs_use_absolute_paths(self):
"""Whether or not diffs for this repository contain absolute paths.
Some types of source code management systems generate diffs that
contain paths relative to the directory where the diff was generated.
Most contain absolute paths. This flag indicates which path format
this repository can expect.
Type:
bool
"""
# Ideally, we won't have to instantiate the class, as that can end up
# performing some expensive calls or HTTP requests. If the SCMTool is
# modern (doesn't define a get_diffs_use_absolute_paths), it will have
# all the information we need on the class. If not, we might have to
# instantiate it, but do this as a last resort.
scmtool_cls = self.scmtool_class
if isinstance(scmtool_cls.diffs_use_absolute_paths, bool):
return scmtool_cls.diffs_use_absolute_paths
elif hasattr(scmtool_cls, 'get_diffs_use_absolute_paths'):
# This will trigger a deprecation warning.
return self.get_scmtool().diffs_use_absolute_paths
else:
return False
def get_scmtool(self):
"""Return an instance of the SCMTool for this repository.
Each call will construct a brand new instance. The returned value
should be stored and used for multiple operations in a single session.
Returns:
reviewboard.scmtools.core.SCMTool:
A new instance of the SCMTool for this repository.
"""
return self.scmtool_class(self)
def get_credentials(self):
"""Return the credentials for this repository.
This returns a dictionary with ``username`` and ``password`` keys.
By default, these will be the values stored for the repository,
but if a hosting service is used and the repository doesn't have
values for one or both of these, the hosting service's credentials
(if available) will be used instead.
Returns:
dict:
A dictionary with credentials information.
"""
username = self.username
password = self.password
if self.hosting_account and self.hosting_account.service:
username = username or self.hosting_account.username
password = password or self.hosting_account.service.get_password()
return {
'username': username,
'password': password,
}
def get_or_create_hooks_uuid(self, max_attempts=20):
"""Return a hooks UUID, creating one if necessary.
A hooks UUID is used for creating unique incoming webhook URLs,
allowing services to communicate information to Review Board.
If a hooks UUID isn't already saved, then this will try to generate one
that doesn't conflict with any other registered hooks UUID. It will try
up to ``max_attempts`` times, and if it fails, ``None`` will be
returned.
Args:
max_attempts (int, optional):
The maximum number of UUID generation attempts to try before
giving up.
Returns:
unicode:
The resulting UUID.
Raises:
Exception:
The maximum number of attempts has been reached.
"""
if not self.hooks_uuid:
for attempt in range(max_attempts):
self.hooks_uuid = uuid.uuid4().hex
try:
self.save(update_fields=['hooks_uuid'])
break
except IntegrityError:
# We hit a collision with the token value. Try again.
self.hooks_uuid = None
if not self.hooks_uuid:
s = ('Unable to generate a unique hooks UUID for '
'repository %s after %d attempts'
% (self.pk, max_attempts))
logger.error(s)
raise Exception(s)
return self.hooks_uuid
def get_encoding_list(self):
"""Return a list of candidate text encodings for files.
This will return a list based on a comma-separated list of encodings
in :py:attr:`encoding`. If no encodings are configured, the default
of ``iso-8859-15`` will be used.
Returns:
list of unicode:
The list of text encodings to try for files in the repository.
"""
encodings = []
for e in self.encoding.split(','):
e = e.strip()
if e:
encodings.append(e)
return encodings or [self.FALLBACK_ENCODING]
def get_file(self, path, revision, base_commit_id=None, request=None,
context=None):
"""Return a file from the repository.
This will attempt to retrieve the file from the repository. If the
repository is backed by a hosting service, it will go through that.
Otherwise, it will attempt to directly access the repository.
This will send the
:py:data:`~reviewboard.scmtools.signals.fetching_file` signal before
beginning a file fetch from the repository (if not cached), and the
:py:data:`~reviewboard.scmtools.signals.fetched_file` signal after.
Args:
path (unicode):
The path to the file in the repository.
revision (unicode):
The revision of the file to retrieve.
base_commit_id (unicode, optional):
The ID of the commit containing the revision of the file
to retrieve. This is required for some types of repositories
where the revision of a file and the ID of a commit differ.
Deprecated:
4.0.5:
Callers should provide this in ``context`` instead.
request (django.http.HttpRequest, optional):
The current HTTP request from the client. This is used for
logging purposes.
Deprecated:
4.0.5:
Callers should provide this in ``context`` instead.
context (reviewboard.scmtools.core.FileLookupContext, optional):
Extra context used to help look up this file.
This contains information about the HTTP request, requesting
user, and parsed diff information, which may be useful as
part of the repository lookup process.
Version Added:
4.0.5
Returns:
bytes:
The resulting file contents.
Raises:
TypeError:
One or more of the provided arguments is an invalid type.
Details are contained in the error message.
"""
# We wrap the result of get_file in a list and then return the first
# element after getting the result from the cache. This prevents the
# cache backend from converting to unicode, since we're no longer
# passing in a string and the cache backend doesn't recursively look
# through the list in order to convert the elements inside.
#
# Basically, this fixes the massive regressions introduced by the
# Django unicode changes.
if not isinstance(path, str):
raise TypeError('"path" must be a Unicode string, not %s'
% type(path))
if not isinstance(revision, str):
raise TypeError('"revision" must be a Unicode string, not %s'
% type(revision))
if context is None:
# If an explicit context isn't provided, create one. In a future
# version, this will be required.
context = FileLookupContext(request=request,
base_commit_id=base_commit_id)
return cache_memoize(
self._make_file_cache_key(path=path,
revision=revision,
base_commit_id=context.base_commit_id),
lambda: [
self._get_file_uncached(path=path,
revision=revision,
context=context),
],
large_data=True)[0]
def get_file_exists(self, path, revision, base_commit_id=None,
request=None, context=None):
"""Return whether or not a file exists in the repository.
If the repository is backed by a hosting service, this will go
through that. Otherwise, it will attempt to directly access the
repository.
The result of this call will be cached, making future lookups
of this path and revision on this repository faster.
This will send the
:py:data:`~reviewboard.scmtools.signals.checking_file_exists` signal
before beginning a file fetch from the repository (if not cached), and
the :py:data:`~reviewboard.scmtools.signals.checked_file_exists` signal
after.
Args:
path (unicode):
The path to the file in the repository.
revision (unicode);
The revision of the file to check.
base_commit_id (unicode, optional):
The ID of the commit containing the revision of the file
to check. This is required for some types of repositories
where the revision of a file and the ID of a commit differ.
Deprecated:
4.0.5:
Callers should provide this in ``context`` instead.
request (django.http.HttpRequest, optional):
The current HTTP request from the client. This is used for
logging purposes.
Deprecated:
4.0.5:
Callers should provide this in ``context`` instead.
context (reviewboard.scmtools.core.FileLookupContext, optional):
Extra context used to help look up this file.
This contains information about the HTTP request, requesting
user, and parsed diff information, which may be useful as
part of the repository lookup process.
Version Added:
4.0.5
Returns:
bool:
``True`` if the file exists in the repository. ``False`` if it
does not.
Raises:
TypeError:
One or more of the provided arguments is an invalid type.
Details are contained in the error message.
"""
if not isinstance(path, str):
raise TypeError('"path" must be a Unicode string, not %s'
% type(path))
if not isinstance(revision, str):
raise TypeError('"revision" must be a Unicode string, not %s'
% type(revision))
if context is None:
# If an explicit context isn't provided, create one. In a future
# version, this will be required.
context = FileLookupContext(request=request,
base_commit_id=base_commit_id)
key = self._make_file_exists_cache_key(
path=path,
revision=revision,
base_commit_id=context.base_commit_id)
if cache.get(make_cache_key(key)) == '1':
return True
exists = self._get_file_exists_uncached(path=path,
revision=revision,
context=context)
if exists:
cache_memoize(key, lambda: '1')
return exists
def get_branches(self):
"""Return a list of all branches on the repository.
This will fetch a list of all known branches for use in the API and
New Review Request page.
Returns:
list of reviewboard.scmtools.core.Branch:
The list of branches in the repository. One (and only one) will
be marked as the default branch.
Raises:
reviewboard.hostingsvcs.errors.HostingServiceError:
The hosting service backing the repository encountered an
error.
reviewboard.scmtools.errors.SCMError:
The repository tool encountered an error.
NotImplementedError:
Branch retrieval is not available for this type of repository.
"""
hosting_service = self.hosting_service
cache_key = make_cache_key('repository-branches:%s' % self.pk)
if hosting_service:
branches_callable = lambda: hosting_service.get_branches(self)
else:
branches_callable = self.get_scmtool().get_branches
return cache_memoize(cache_key, branches_callable,
self.BRANCHES_CACHE_PERIOD)
def get_commit_cache_key(self, commit_id):
"""Return the cache key used for a commit ID.
The resulting cache key is used to cache information about a commit
retrieved from the repository that matches the provided ID. This can
be used to delete information already in cache.
Args:
commit_id (unicode):
The ID of the commit to generate a cache key for.
Returns:
unicode:
The resulting cache key.
"""
return 'repository-commit:%s:%s' % (self.pk, commit_id)
def get_commits(self, branch=None, start=None):
"""Return a list of commits.
This will fetch a batch of commits from the repository for use in the
API and New Review Request page.
The resulting commits will be in order from newest to oldest, and
should return upwards of a fixed number of commits (usually 30, but
this depends on the type of repository and its limitations). It may
also be limited to commits that exist on a given branch (if supported
by the repository).
This can be called multiple times in succession using the
:py:attr:`Commit.parent` of the last entry as the ``start`` parameter
in order to paginate through the history of commits in the repository.
Args:
branch (unicode, optional):
The branch to limit commits to. This may not be supported by
all repositories.
start (unicode, optional):
The commit to start at. If not provided, this will fetch the
first commit in the repository.
Returns:
list of reviewboard.scmtools.core.Commit:
The retrieved commits.
Raises:
reviewboard.hostingsvcs.errors.HostingServiceError:
The hosting service backing the repository encountered an
error.
reviewboard.scmtools.errors.SCMError:
The repository tool encountered an error.
NotImplementedError:
Commits retrieval is not available for this type of repository.
"""
hosting_service = self.hosting_service
commits_kwargs = {
'branch': branch,
'start': start,
}
if hosting_service:
commits_callable = \
lambda: hosting_service.get_commits(self, **commits_kwargs)
else:
commits_callable = \
lambda: self.get_scmtool().get_commits(**commits_kwargs)
# We cache both the entire list for 'start', as well as each individual
# commit. This allows us to reduce API load when people are looking at
# the "new review request" page more frequently than they're pushing
# code, and will usually save 1 API request when they go to actually
# create a new review request.
if branch and start:
cache_period = self.COMMITS_CACHE_PERIOD_LONG
else:
cache_period = self.COMMITS_CACHE_PERIOD_SHORT
cache_key = make_cache_key('repository-commits:%s:%s:%s'
% (self.pk, branch, start))
commits = cache_memoize(cache_key, commits_callable,
cache_period)
for commit in commits:
cache.set(self.get_commit_cache_key(commit.id),
commit, self.COMMITS_CACHE_PERIOD_LONG)
return commits
def get_change(self, revision):
"""Return an individual change/commit in the repository.
Args:
revision (unicode):
The commit ID or revision to retrieve.
Returns:
reviewboard.scmtools.core.Commit:
The commit from the repository.
Raises:
reviewboard.hostingsvcs.errors.HostingServiceError:
The hosting service backing the repository encountered an
error.
reviewboard.scmtools.errors.SCMError:
The repository tool encountered an error.
NotImplementedError:
Commits retrieval is not available for this type of repository.
"""
hosting_service = self.hosting_service
if hosting_service:
return hosting_service.get_change(self, revision)
else:
return self.get_scmtool().get_change(revision)
def normalize_patch(self, patch, filename, revision):
"""Normalize a diff/patch file before it's applied.
This can be used to take an uploaded diff file and modify it so that
it can be properly applied. This may, for instance, uncollapse
keywords or remove metadata that would confuse :command:`patch`.
This passes the request on to the hosting service or repository
tool backend.
Args:
patch (bytes):
The diff/patch file to normalize.
filename (unicode):
The name of the file being changed in the diff.
revision (unicode):
The revision of the file being changed in the diff.
Returns:
bytes:
The resulting diff/patch file.
Raises:
reviewboard.hostingsvcs.errors.MissingHostingServiceError:
The hosting service for this repository could not be loaded.
"""
hosting_service = self.hosting_service