diff --git a/.gitignore b/.gitignore index dbb0fa606cb..dc1401badb6 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ logs/* media/dash media/epub media/export +media/external media/html media/htmlzip media/json diff --git a/docs/guides/feature-flags.rst b/docs/guides/feature-flags.rst index fd73f90a178..884b16f5b8c 100644 --- a/docs/guides/feature-flags.rst +++ b/docs/guides/feature-flags.rst @@ -29,3 +29,5 @@ e.g. python-reno release notes manager is known to do that (error message line would probably include one of old Git commit id's). ``USE_TESTING_BUILD_IMAGE``: :featureflags:`USE_TESTING_BUILD_IMAGE` + +``EXTERNAL_VERSION_BUILD``: :featureflags:`EXTERNAL_VERSION_BUILD` diff --git a/readthedocs/api/v2/serializers.py b/readthedocs/api/v2/serializers.py index 6d9a1cd48a6..d153166e612 100644 --- a/readthedocs/api/v2/serializers.py +++ b/readthedocs/api/v2/serializers.py @@ -118,6 +118,7 @@ class BuildSerializer(serializers.ModelSerializer): version_slug = serializers.ReadOnlyField(source='version.slug') docs_url = serializers.ReadOnlyField(source='version.get_absolute_url') state_display = serializers.ReadOnlyField(source='get_state_display') + commit_url = serializers.ReadOnlyField(source='get_commit_url') # Jsonfield needs an explicit serializer # https://github.com/dmkoch/django-jsonfield/issues/188#issuecomment-300439829 config = serializers.JSONField(required=False) diff --git a/readthedocs/api/v2/views/footer_views.py b/readthedocs/api/v2/views/footer_views.py index 04d4fe8210b..1036ea961b1 100644 --- a/readthedocs/api/v2/views/footer_views.py +++ b/readthedocs/api/v2/views/footer_views.py @@ -25,7 +25,7 @@ def get_version_compare_data(project, base_version=None): :param base_version: We assert whether or not the base_version is also the highest version in the resulting "is_highest" value. """ - versions_qs = Version.objects.public(project=project) + versions_qs = Version.internal.public(project=project) # Take preferences over tags only if the project has at least one tag if versions_qs.filter(type=TAG).exists(): diff --git a/readthedocs/api/v2/views/integrations.py b/readthedocs/api/v2/views/integrations.py index d11ca50f01f..d0d5f5cc18f 100644 --- a/readthedocs/api/v2/views/integrations.py +++ b/readthedocs/api/v2/views/integrations.py @@ -19,9 +19,15 @@ webhook_github, webhook_gitlab, ) -from readthedocs.core.views.hooks import build_branches, sync_versions +from readthedocs.core.views.hooks import ( + build_branches, + sync_versions, + get_or_create_external_version, + delete_external_version, + build_external_version, +) from readthedocs.integrations.models import HttpExchange, Integration -from readthedocs.projects.models import Project +from readthedocs.projects.models import Project, Feature log = logging.getLogger(__name__) @@ -29,6 +35,11 @@ GITHUB_EVENT_HEADER = 'HTTP_X_GITHUB_EVENT' GITHUB_SIGNATURE_HEADER = 'HTTP_X_HUB_SIGNATURE' GITHUB_PUSH = 'push' +GITHUB_PULL_REQUEST = 'pull_request' +GITHUB_PULL_REQUEST_OPENED = 'opened' +GITHUB_PULL_REQUEST_CLOSED = 'closed' +GITHUB_PULL_REQUEST_REOPENED = 'reopened' +GITHUB_PULL_REQUEST_SYNC = 'synchronize' GITHUB_CREATE = 'create' GITHUB_DELETE = 'delete' GITLAB_TOKEN_HEADER = 'HTTP_X_GITLAB_TOKEN' @@ -110,6 +121,10 @@ def handle_webhook(self): """Handle webhook payload.""" raise NotImplementedError + def get_external_version_data(self): + """Get External Version data from payload.""" + raise NotImplementedError + def is_payload_valid(self): """Validates the webhook's payload using the integration's secret.""" return False @@ -183,28 +198,96 @@ def sync_versions(self, project): 'versions': [version], } + def get_external_version_response(self, project): + """ + Trigger builds for External versions on pull/merge request events and return API response. + + Return a JSON response with the following:: + + { + "build_triggered": true, + "project": "project_name", + "versions": [verbose_name] + } + + :param project: Project instance + :type project: readthedocs.projects.models.Project + """ + identifier, verbose_name = self.get_external_version_data() + # create or get external version object using `verbose_name`. + external_version = get_or_create_external_version( + project, identifier, verbose_name + ) + # returns external version verbose_name (pull/merge request number) + to_build = build_external_version(project, external_version) + + return { + 'build_triggered': True, + 'project': project.slug, + 'versions': [to_build], + } + + def get_delete_external_version_response(self, project): + """ + Delete External version on pull/merge request `closed` events and return API response. + + Return a JSON response with the following:: + + { + "version_deleted": true, + "project": "project_name", + "versions": [verbose_name] + } + + :param project: Project instance + :type project: Project + """ + identifier, verbose_name = self.get_external_version_data() + # Delete external version + deleted_version = delete_external_version( + project, identifier, verbose_name + ) + return { + 'version_deleted': deleted_version is not None, + 'project': project.slug, + 'versions': [deleted_version], + } + class GitHubWebhookView(WebhookMixin, APIView): """ Webhook consumer for GitHub. - Accepts webhook events from GitHub, 'push' events trigger builds. Expects the - webhook event type will be included in HTTP header ``X-GitHub-Event``, and - we will have a JSON payload. + Accepts webhook events from GitHub, 'push' and 'pull_request' events trigger builds. + Expects the webhook event type will be included in HTTP header ``X-GitHub-Event``, + and we will have a JSON payload. Expects the following JSON:: - { - "ref": "branch-name", - ... - } + For push, create, delete Events: + { + "ref": "branch-name", + ... + } + + For pull_request Events: + { + "action": "opened", + "number": 2, + "pull_request": { + "head": { + "sha": "ec26de721c3235aad62de7213c562f8c821" + } + } + } See full payload here: - https://developer.github.com/v3/activity/events/types/#pushevent - https://developer.github.com/v3/activity/events/types/#createevent - https://developer.github.com/v3/activity/events/types/#deleteevent + - https://developer.github.com/v3/activity/events/types/#pullrequestevent """ integration_type = Integration.GITHUB_WEBHOOK @@ -218,6 +301,17 @@ def get_data(self): pass return super().get_data() + def get_external_version_data(self): + """Get Commit Sha and pull request number from payload.""" + try: + identifier = self.data['pull_request']['head']['sha'] + verbose_name = str(self.data['number']) + + return identifier, verbose_name + + except KeyError: + raise ParseError('Parameters "sha" and "number" are required') + def is_payload_valid(self): """ GitHub use a HMAC hexdigest hash to sign the payload. @@ -256,6 +350,7 @@ def get_digest(secret, msg): def handle_webhook(self): # Get event and trigger other webhook events + action = self.data.get('action', None) event = self.request.META.get(GITHUB_EVENT_HEADER, GITHUB_PUSH) webhook_github.send( Project, @@ -272,6 +367,26 @@ def handle_webhook(self): raise ParseError('Parameter "ref" is required') if event in (GITHUB_CREATE, GITHUB_DELETE): return self.sync_versions(self.project) + + if ( + self.project.has_feature(Feature.EXTERNAL_VERSION_BUILD) and + event == GITHUB_PULL_REQUEST and action + ): + if ( + action in + [ + GITHUB_PULL_REQUEST_OPENED, + GITHUB_PULL_REQUEST_REOPENED, + GITHUB_PULL_REQUEST_SYNC + ] + ): + # Handle opened, synchronize, reopened pull_request event. + return self.get_external_version_response(self.project) + + if action == GITHUB_PULL_REQUEST_CLOSED: + # Handle closed pull_request event. + return self.get_delete_external_version_response(self.project) + return None def _normalize_ref(self, ref): diff --git a/readthedocs/api/v2/views/model_views.py b/readthedocs/api/v2/views/model_views.py index af41f28ab84..fca91b32626 100644 --- a/readthedocs/api/v2/views/model_views.py +++ b/readthedocs/api/v2/views/model_views.py @@ -10,7 +10,7 @@ from rest_framework.renderers import BaseRenderer, JSONRenderer from rest_framework.response import Response -from readthedocs.builds.constants import BRANCH, TAG +from readthedocs.builds.constants import BRANCH, TAG, INTERNAL from readthedocs.builds.models import Build, BuildCommandResult, Version from readthedocs.core.utils import trigger_build from readthedocs.core.utils.extend import SettingsOverrideObject @@ -130,7 +130,7 @@ def active_versions(self, request, **kwargs): Project.objects.api(request.user), pk=kwargs['pk'], ) - versions = project.versions.filter(active=True) + versions = project.versions(manager=INTERNAL).filter(active=True) return Response({ 'versions': VersionSerializer(versions, many=True).data, }) diff --git a/readthedocs/api/v3/views.py b/readthedocs/api/v3/views.py index ad738a16b9a..be1b898b258 100644 --- a/readthedocs/api/v3/views.py +++ b/readthedocs/api/v3/views.py @@ -281,7 +281,7 @@ class VersionsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, lookup_value_regex = r'[^/]+' filterset_class = VersionFilter - queryset = Version.objects.all() + queryset = Version.internal.all() permit_list_expands = [ 'last_build', 'last_build.config', @@ -320,7 +320,7 @@ class BuildsViewSet(APIv3Settings, NestedViewSetMixin, ProjectQuerySetMixin, lookup_url_kwarg = 'build_pk' serializer_class = BuildSerializer filterset_class = BuildFilter - queryset = Build.objects.all() + queryset = Build.internal.all() permit_list_expands = [ 'config', ] diff --git a/readthedocs/builds/constants.py b/readthedocs/builds/constants.py index b0dc6cfba94..d122c9065de 100644 --- a/readthedocs/builds/constants.py +++ b/readthedocs/builds/constants.py @@ -31,6 +31,13 @@ ('dash', _('Dash')), ) +# Manager name for Internal Versions or Builds. +# ie: Versions and Builds Excluding pull request/merge request Versions and Builds. +INTERNAL = 'internal' +# Manager name for External Versions or Builds. +# ie: Only pull request/merge request Versions and Builds. +EXTERNAL = 'external' + BRANCH = 'branch' TAG = 'tag' UNKNOWN = 'unknown' @@ -38,6 +45,7 @@ VERSION_TYPES = ( (BRANCH, _('Branch')), (TAG, _('Tag')), + (EXTERNAL, _('External')), (UNKNOWN, _('Unknown')), ) @@ -53,3 +61,34 @@ LATEST, STABLE, ) + +# General Build Statuses +BUILD_STATUS_FAILURE = 'failed' +BUILD_STATUS_PENDING = 'pending' +BUILD_STATUS_SUCCESS = 'success' + +# GitHub Build Statuses +GITHUB_BUILD_STATUS_FAILURE = 'failure' +GITHUB_BUILD_STATUS_PENDING = 'pending' +GITHUB_BUILD_STATUS_SUCCESS = 'success' + +# Used to select correct Build status and description to be sent to each service API +SELECT_BUILD_STATUS = { + BUILD_STATUS_FAILURE: { + 'github': GITHUB_BUILD_STATUS_FAILURE, + 'description': 'Read the Docs build failed!', + }, + BUILD_STATUS_PENDING: { + 'github': GITHUB_BUILD_STATUS_PENDING, + 'description': 'Read the Docs build is in progress!', + }, + BUILD_STATUS_SUCCESS: { + 'github': GITHUB_BUILD_STATUS_SUCCESS, + 'description': 'Read the Docs build succeeded!', + }, +} + +RTD_BUILD_STATUS_API_NAME = 'continuous-documentation/read-the-docs' + +GITHUB_EXTERNAL_VERSION_NAME = 'Pull Request' +GENERIC_EXTERNAL_VERSION_NAME = 'External Version' diff --git a/readthedocs/builds/managers.py b/readthedocs/builds/managers.py index 68cdec6efe9..cbf0d96bc27 100644 --- a/readthedocs/builds/managers.py +++ b/readthedocs/builds/managers.py @@ -19,8 +19,9 @@ STABLE, STABLE_VERBOSE_NAME, TAG, + EXTERNAL, ) -from .querysets import VersionQuerySet +from .querysets import VersionQuerySet, BuildQuerySet log = logging.getLogger(__name__) @@ -85,6 +86,96 @@ def get_object_or_log(self, **kwargs): log.warning('Version not found for given kwargs. %s' % kwargs) +class InternalVersionManagerBase(VersionManagerBase): + + """ + Version manager that only includes internal version. + + It will exclude pull request/merge request versions from the queries + and only include BRANCH, TAG, UNKONWN type Versions. + """ + + def get_queryset(self): + return super().get_queryset().exclude(type=EXTERNAL) + + +class ExternalVersionManagerBase(VersionManagerBase): + + """ + Version manager that only includes external version. + + It will only include pull request/merge request Versions in the queries. + """ + + def get_queryset(self): + return super().get_queryset().filter(type=EXTERNAL) + + class VersionManager(SettingsOverrideObject): _default_class = VersionManagerBase _override_setting = 'VERSION_MANAGER' + + +class InternalVersionManager(SettingsOverrideObject): + _default_class = InternalVersionManagerBase + + +class ExternalVersionManager(SettingsOverrideObject): + _default_class = ExternalVersionManagerBase + + +class BuildManagerBase(models.Manager): + + """ + Build manager for manager only queries. + + For creating different Managers. + """ + + @classmethod + def from_queryset(cls, queryset_class, class_name=None): + # This is overridden because :py:meth:`models.Manager.from_queryset` + # uses `inspect` to retrieve the class methods, and the proxy class has + # no direct members. + queryset_class = get_override_class( + BuildQuerySet, + BuildQuerySet._default_class, # pylint: disable=protected-access + ) + return super().from_queryset(queryset_class, class_name) + + +class InternalBuildManagerBase(BuildManagerBase): + + """ + Build manager that only includes internal version builds. + + It will exclude pull request/merge request version builds from the queries + and only include BRANCH, TAG, UNKONWN type Version builds. + """ + + def get_queryset(self): + return super().get_queryset().exclude(version__type=EXTERNAL) + + +class ExternalBuildManagerBase(BuildManagerBase): + + """ + Build manager that only includes external version builds. + + It will only include pull request/merge request version builds in the queries. + """ + + def get_queryset(self): + return super().get_queryset().filter(version__type=EXTERNAL) + + +class BuildManager(SettingsOverrideObject): + _default_class = BuildManagerBase + + +class InternalBuildManager(SettingsOverrideObject): + _default_class = InternalBuildManagerBase + + +class ExternalBuildManager(SettingsOverrideObject): + _default_class = ExternalBuildManagerBase diff --git a/readthedocs/builds/migrations/0009_added_external_version_type.py b/readthedocs/builds/migrations/0009_added_external_version_type.py new file mode 100644 index 00000000000..ece2f2fb25c --- /dev/null +++ b/readthedocs/builds/migrations/0009_added_external_version_type.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.21 on 2019-07-04 13:10 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('builds', '0008_remove-version-tags'), + ] + + operations = [ + migrations.AlterField( + model_name='version', + name='type', + field=models.CharField(choices=[('branch', 'Branch'), ('tag', 'Tag'), ('external', 'External'), ('unknown', 'Unknown')], default='unknown', max_length=20, verbose_name='Type'), + ), + migrations.AlterField( + model_name='versionautomationrule', + name='version_type', + field=models.CharField(choices=[('branch', 'Branch'), ('tag', 'Tag'), ('external', 'External'), ('unknown', 'Unknown')], max_length=32, verbose_name='Version type'), + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index 93389050dfc..54dd6848e03 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -20,8 +20,13 @@ from readthedocs.config import LATEST_CONFIGURATION_VERSION from readthedocs.core.utils import broadcast from readthedocs.projects.constants import ( + BITBUCKET_COMMIT_URL, BITBUCKET_URL, + GITHUB_COMMIT_URL, GITHUB_URL, + GITHUB_PULL_REQUEST_URL, + GITHUB_PULL_REQUEST_COMMIT_URL, + GITLAB_COMMIT_URL, GITLAB_URL, PRIVACY_CHOICES, PRIVATE, @@ -30,26 +35,42 @@ from readthedocs.projects.models import APIProject, Project from readthedocs.projects.version_handling import determine_stable_version -from .constants import ( +from readthedocs.builds.constants import ( BRANCH, BUILD_STATE, BUILD_STATE_FINISHED, BUILD_STATE_TRIGGERED, BUILD_TYPES, + GENERIC_EXTERNAL_VERSION_NAME, + GITHUB_EXTERNAL_VERSION_NAME, + INTERNAL, LATEST, NON_REPOSITORY_VERSIONS, + EXTERNAL, STABLE, TAG, VERSION_TYPES, ) -from .managers import VersionManager -from .querysets import BuildQuerySet, RelatedBuildQuerySet, VersionQuerySet -from .utils import ( +from readthedocs.builds.managers import ( + VersionManager, + InternalVersionManager, + ExternalVersionManager, + BuildManager, + InternalBuildManager, + ExternalBuildManager, +) +from readthedocs.builds.querysets import ( + BuildQuerySet, + RelatedBuildQuerySet, + VersionQuerySet, +) +from readthedocs.builds.utils import ( get_bitbucket_username_repo, get_github_username_repo, get_gitlab_username_repo, ) -from .version_slug import VersionSlugField +from readthedocs.builds.version_slug import VersionSlugField +from readthedocs.oauth.models import RemoteRepository log = logging.getLogger(__name__) @@ -108,6 +129,10 @@ class Version(models.Model): machine = models.BooleanField(_('Machine Created'), default=False) objects = VersionManager.from_queryset(VersionQuerySet)() + # Only include BRANCH, TAG, UNKONWN type Versions. + internal = InternalVersionManager.from_queryset(VersionQuerySet)() + # Only include EXTERNAL type Versions. + external = ExternalVersionManager.from_queryset(VersionQuerySet)() class Meta: unique_together = [('project', 'slug')] @@ -130,7 +155,9 @@ def __str__(self): @property def ref(self): if self.slug == STABLE: - stable = determine_stable_version(self.project.versions.all()) + stable = determine_stable_version( + self.project.versions(manager=INTERNAL).all() + ) if stable: return stable.slug @@ -140,7 +167,19 @@ def vcs_url(self): Generate VCS (github, gitlab, bitbucket) URL for this version. Example: https://github.com/rtfd/readthedocs.org/tree/3.4.2/. + External Version Example: https://github.com/rtfd/readthedocs.org/pull/99/. """ + if self.type == EXTERNAL: + if 'github' in self.project.repo: + user, repo = get_github_username_repo(self.project.repo) + return GITHUB_PULL_REQUEST_URL.format( + user=user, + repo=repo, + number=self.verbose_name, + ) + # TODO: Add VCS URL for other Git Providers + return '' + url = '' if self.slug == STABLE: slug_url = self.ref @@ -172,9 +211,8 @@ def config(self): :rtype: dict """ last_build = ( - self.builds - .filter( - state='finished', + self.builds(manager=INTERNAL).filter( + state=BUILD_STATE_FINISHED, success=True, ).order_by('-date') .only('_config') @@ -220,7 +258,14 @@ def commit_name(self): # the actual tag name. return self.verbose_name - # If we came that far it's not a special version nor a branch or tag. + if self.type == EXTERNAL: + # If this version is a EXTERNAL version, the identifier will + # contain the actual commit hash. which we can use to + # generate url for a given file name + return self.identifier + + # If we came that far it's not a special version + # nor a branch, tag or EXTERNAL version. # Therefore just return the identifier to make a safe guess. log.debug( 'TODO: Raise an exception here. Testing what cases it happens', @@ -300,17 +345,17 @@ def get_downloads(self, pretty=False): def prettify(k): return k if pretty else k.lower() - if project.has_pdf(self.slug): + if project.has_pdf(self.slug, version_type=self.type): data[prettify('PDF')] = project.get_production_media_url( 'pdf', self.slug, ) - if project.has_htmlzip(self.slug): + if project.has_htmlzip(self.slug, version_type=self.type): data[prettify('HTML')] = project.get_production_media_url( 'htmlzip', self.slug, ) - if project.has_epub(self.slug): + if project.has_epub(self.slug, version_type=self.type): data[prettify('Epub')] = project.get_production_media_url( 'epub', self.slug, @@ -361,6 +406,7 @@ def get_storage_paths(self): type_=type_, version_slug=self.slug, include_file=False, + version_type=self.type, ) ) @@ -593,9 +639,12 @@ class Build(models.Model): help_text='Build steps stored outside the database.', ) - # Manager - - objects = BuildQuerySet.as_manager() + # Managers + objects = BuildManager.from_queryset(BuildQuerySet)() + # Only include BRANCH, TAG, UNKONWN type Version builds. + internal = InternalBuildManager.from_queryset(BuildQuerySet)() + # Only include EXTERNAL type Version builds. + external = ExternalBuildManager.from_queryset(BuildQuerySet)() CONFIG_KEY = '__config' @@ -692,6 +741,74 @@ def __str__(self): def get_absolute_url(self): return reverse('builds_detail', args=[self.project.slug, self.pk]) + def get_full_url(self): + """ + Get full url of the build including domain. + + Example: https://readthedocs.org/projects/pip/builds/99999999/ + """ + scheme = 'http' if settings.DEBUG else 'https' + full_url = '{scheme}://{domain}{absolute_url}'.format( + scheme=scheme, + domain=settings.PRODUCTION_DOMAIN, + absolute_url=self.get_absolute_url() + ) + return full_url + + def get_commit_url(self): + """Return the commit URL.""" + repo_url = self.project.repo + if self.is_external: + if 'github' in repo_url: + user, repo = get_github_username_repo(repo_url) + if not user and not repo: + return '' + + repo = repo.rstrip('/') + return GITHUB_PULL_REQUEST_COMMIT_URL.format( + user=user, + repo=repo, + number=self.version.verbose_name, + commit=self.commit + ) + # TODO: Add External Version Commit URL for other Git Providers + else: + if 'github' in repo_url: + user, repo = get_github_username_repo(repo_url) + if not user and not repo: + return '' + + repo = repo.rstrip('/') + return GITHUB_COMMIT_URL.format( + user=user, + repo=repo, + commit=self.commit + ) + if 'gitlab' in repo_url: + user, repo = get_gitlab_username_repo(repo_url) + if not user and not repo: + return '' + + repo = repo.rstrip('/') + return GITLAB_COMMIT_URL.format( + user=user, + repo=repo, + commit=self.commit + ) + if 'bitbucket' in repo_url: + user, repo = get_bitbucket_username_repo(repo_url) + if not user and not repo: + return '' + + repo = repo.rstrip('/') + return BITBUCKET_COMMIT_URL.format( + user=user, + repo=repo, + commit=self.commit + ) + + return None + @property def finished(self): """Return if build has a finished state.""" @@ -703,6 +820,28 @@ def is_stale(self): mins_ago = timezone.now() - datetime.timedelta(minutes=5) return self.state == BUILD_STATE_TRIGGERED and self.date < mins_ago + @property + def is_external(self): + return self.version.type == EXTERNAL + + @property + def external_version_name(self): + if self.is_external: + try: + if self.project.remote_repository.account.provider == 'github': + return GITHUB_EXTERNAL_VERSION_NAME + # TODO: Add External Version Name for other Git Providers + except RemoteRepository.DoesNotExist: + log.info('Remote repository does not exist for %s', self.project) + return GENERIC_EXTERNAL_VERSION_NAME + except Exception: + log.exception( + 'Unhandled exception raised for %s while getting external_version_name', + self.project + ) + return GENERIC_EXTERNAL_VERSION_NAME + return None + def using_latest_config(self): return int(self.config.get('version', '1')) == LATEST_CONFIGURATION_VERSION diff --git a/readthedocs/builds/querysets.py b/readthedocs/builds/querysets.py index ced29a2faa6..fd14608af86 100644 --- a/readthedocs/builds/querysets.py +++ b/readthedocs/builds/querysets.py @@ -116,7 +116,6 @@ def api(self, user=None, detail=True): class BuildQuerySet(SettingsOverrideObject): _default_class = BuildQuerySetBase - _override_setting = 'BUILD_MANAGER' class RelatedBuildQuerySetBase(models.QuerySet): diff --git a/readthedocs/builds/static-src/builds/js/detail.js b/readthedocs/builds/static-src/builds/js/detail.js index 58637d6b1ee..047c1ecc713 100644 --- a/readthedocs/builds/static-src/builds/js/detail.js +++ b/readthedocs/builds/static-src/builds/js/detail.js @@ -52,6 +52,7 @@ function BuildDetailView(instance) { }); self.commit = ko.observable(instance.commit); self.docs_url = ko.observable(instance.docs_url); + self.commit_url = ko.observable(instance.commit_url); /* Others */ self.legacy_output = ko.observable(false); @@ -72,6 +73,7 @@ function BuildDetailView(instance) { self.length(data.length); self.commit(data.commit); self.docs_url(data.docs_url); + self.commit_url(data.commit_url); var n; for (n in data.commands) { var command = data.commands[n]; diff --git a/readthedocs/builds/static/builds/js/detail.js b/readthedocs/builds/static/builds/js/detail.js index 2b1cf9490fb..e541a417d4c 100644 --- a/readthedocs/builds/static/builds/js/detail.js +++ b/readthedocs/builds/static/builds/js/detail.js @@ -1 +1 @@ -require=function n(i,u,a){function c(t,e){if(!u[t]){if(!i[t]){var o="function"==typeof require&&require;if(!e&&o)return o(t,!0);if(d)return d(t,!0);var r=new Error("Cannot find module '"+t+"'");throw r.code="MODULE_NOT_FOUND",r}var s=u[t]={exports:{}};i[t][0].call(s.exports,function(e){return c(i[t][1][e]||e)},s,s.exports,n,i,u,a)}return u[t].exports}for(var d="function"==typeof require&&require,e=0;e - {% if request.user|is_admin:project %} + {% if request.user|is_admin:project and not build.is_external %} {{ build.version.slug }} - {% else %} + {% elif build.is_external %} + {% blocktrans with build.external_version_name as external_version_name %} + {{ external_version_name }} + {% endblocktrans %} + #{{ build.version.verbose_name }} + {% else %} {{ build.version.slug }} {% endif %} - - ({{ build.commit }}) + + {% if build.get_commit_url %} + ({{ build.commit }}) + {% else %} + ({{ build.commit }}) + {% endif %} diff --git a/readthedocs/templates/core/build_list_detailed.html b/readthedocs/templates/core/build_list_detailed.html index 4f574b55b16..f8b0ef9e2b5 100644 --- a/readthedocs/templates/core/build_list_detailed.html +++ b/readthedocs/templates/core/build_list_detailed.html @@ -6,7 +6,7 @@ diff --git a/readthedocs/vcs_support/backends/git.py b/readthedocs/vcs_support/backends/git.py index 52bd210d7ee..e7ed5d38b52 100644 --- a/readthedocs/vcs_support/backends/git.py +++ b/readthedocs/vcs_support/backends/git.py @@ -10,7 +10,9 @@ from django.core.exceptions import ValidationError from git.exc import BadName, InvalidGitRepositoryError +from readthedocs.builds.constants import EXTERNAL from readthedocs.config import ALL +from readthedocs.projects.constants import GITHUB_PR_PULL_PATTERN from readthedocs.projects.exceptions import RepositoryError from readthedocs.projects.validators import validate_submodule_url from readthedocs.vcs_support.base import BaseVCS, VCSVersion @@ -57,6 +59,10 @@ def update(self): self.set_remote_url(self.repo_url) return self.fetch() self.make_clean_working_dir() + # A fetch is always required to get external versions properly + if self.version_type == EXTERNAL: + self.clone() + return self.fetch() return self.clone() def repo_exists(self): @@ -142,11 +148,21 @@ def use_shallow_clone(self): return not self.project.has_feature(Feature.DONT_SHALLOW_CLONE) def fetch(self): - cmd = ['git', 'fetch', '--tags', '--prune', '--prune-tags'] + cmd = ['git', 'fetch', 'origin', + '--tags', '--prune', '--prune-tags'] if self.use_shallow_clone(): cmd.extend(['--depth', str(self.repo_depth)]) + if ( + self.verbose_name and + self.version_type == EXTERNAL and + 'github.com' in self.repo_url + ): + cmd.append( + GITHUB_PR_PULL_PATTERN.format(id=self.verbose_name) + ) + code, stdout, stderr = self.run(*cmd) if code != 0: raise RepositoryError diff --git a/readthedocs/vcs_support/base.py b/readthedocs/vcs_support/base.py index 653629110fc..1b7da2017d0 100644 --- a/readthedocs/vcs_support/base.py +++ b/readthedocs/vcs_support/base.py @@ -50,12 +50,18 @@ class BaseVCS: # Defining a base API, so we'll have unused args # pylint: disable=unused-argument - def __init__(self, project, version_slug, environment=None, **kwargs): + def __init__( + self, project, version_slug, environment=None, + verbose_name=None, version_type=None, **kwargs + ): self.default_branch = project.default_branch self.project = project self.name = project.name self.repo_url = project.clean_repo self.working_dir = project.checkout_path(version_slug) + # required for External versions + self.verbose_name = verbose_name + self.version_type = version_type from readthedocs.doc_builder.environments import LocalEnvironment self.environment = environment or LocalEnvironment(project)