diff --git a/docs/user/integrations.rst b/docs/user/integrations.rst index e8fa05d8a2f..e560ea8a42b 100644 --- a/docs/user/integrations.rst +++ b/docs/user/integrations.rst @@ -82,7 +82,8 @@ GitLab * Go to the :guilabel:`Settings` > :guilabel:`Webhooks` page for your project * For **URL**, use the URL of the integration on Read the Docs, found on the :guilabel:`Admin` > :guilabel:`Integrations` page -* Leave the default **Push events** selected and mark **Tag push events** also +* Leave the default **Push events** selected, + additionally mark **Tag push events** and **Merge request events**. * Finish by clicking **Add Webhook** Gitea diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py index c0d06053370..fe32eabd53c 100644 --- a/readthedocs/projects/forms.py +++ b/readthedocs/projects/forms.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.auth.models import User from django.template.loader import render_to_string +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from readthedocs.builds.constants import INTERNAL @@ -272,6 +273,77 @@ def __init__(self, *args, **kwargs): else: self.fields['default_version'].widget.attrs['readonly'] = True + self.setup_external_builds_option() + + def setup_external_builds_option(self): + """Disable the external builds option if the project doesn't meet the requirements.""" + integrations = list(self.instance.integrations.all()) + has_supported_integration = self.has_supported_integration(integrations) + can_build_external_versions = self.can_build_external_versions(integrations) + + # External builds are supported for this project, + # don't disable the option. + if has_supported_integration and can_build_external_versions: + return + + msg = None + url = reverse("projects_integrations", args=[self.instance.slug]) + if not has_supported_integration: + msg = _( + "To build from pull requests you need a " + f'GitHub or GitLab integration.' + ) + if has_supported_integration and not can_build_external_versions: + # If there is only one integration, link directly to it. + if len(integrations) == 1: + url = reverse( + "projects_integrations_detail", + args=[self.instance.slug, integrations[0].pk], + ) + msg = _( + "To build from pull requests your repository's webhook " + "needs to send pull request events. " + f'Try to resync your integration.' + ) + + if msg: + field = self.fields["external_builds_enabled"] + field.disabled = True + field.help_text = f"{msg} {field.help_text}" + + def has_supported_integration(self, integrations): + supported_types = {Integration.GITHUB_WEBHOOK, Integration.GITLAB_WEBHOOK} + for integration in integrations: + if integration.integration_type in supported_types: + return True + return False + + def can_build_external_versions(self, integrations): + """ + Check if external versions can be enabled for this project. + + A project can build external versions if: + + - They are using GitHub or GitLab. + - The repository's webhook is setup to send pull request events. + + If the integration's provider data isn't set, + it could mean that the user created the integration manually, + and doesn't have an account connected. + So we don't know for sure if the webhook is sending pull request events. + """ + for integration in integrations: + provider_data = integration.provider_data + if integration.integration_type == Integration.GITHUB_WEBHOOK and ( + not provider_data or "pull_request" in provider_data.get("events", []) + ): + return True + if integration.integration_type == Integration.GITLAB_WEBHOOK and ( + not provider_data or provider_data.get("merge_requests_events") + ): + return True + return False + def clean_conf_py_file(self): filename = self.cleaned_data.get('conf_py_file', '').strip() if filename and 'conf.py' not in filename: diff --git a/readthedocs/projects/migrations/0089_update_help_text.py b/readthedocs/projects/migrations/0089_update_help_text.py new file mode 100644 index 00000000000..7f74e543950 --- /dev/null +++ b/readthedocs/projects/migrations/0089_update_help_text.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.13 on 2022-06-02 17:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projects", "0088_domain_field_edits"), + ] + + operations = [ + migrations.AlterField( + model_name="historicalproject", + name="external_builds_enabled", + field=models.BooleanField( + default=False, + help_text='More information in our docs.', + verbose_name="Build pull requests for this project", + ), + ), + migrations.AlterField( + model_name="project", + name="external_builds_enabled", + field=models.BooleanField( + default=False, + help_text='More information in our docs.', + verbose_name="Build pull requests for this project", + ), + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index cc10054da93..d80d8817943 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -218,7 +218,9 @@ class Project(models.Model): external_builds_enabled = models.BooleanField( _('Build pull requests for this project'), default=False, - help_text=_('More information in our docs') # noqa + help_text=_( + 'More information in our docs.' # noqa + ), ) external_builds_privacy_level = models.CharField( _('Privacy level of Pull Requests'), diff --git a/readthedocs/projects/tests/test_views.py b/readthedocs/projects/tests/test_views.py new file mode 100644 index 00000000000..7942d9718f7 --- /dev/null +++ b/readthedocs/projects/tests/test_views.py @@ -0,0 +1,100 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from django_dynamic_fixture import get + +from readthedocs.integrations.models import Integration +from readthedocs.projects.models import Project + + +class TestExternalBuildOption(TestCase): + def setUp(self): + self.user = get(User) + self.project = get(Project, users=[self.user]) + self.integration = get( + Integration, + integration_type=Integration.GITHUB_WEBHOOK, + project=self.project, + ) + self.url = reverse("projects_advanced", args=[self.project.slug]) + self.client.force_login(self.user) + + def test_unsuported_integration(self): + self.integration.delete() + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertTrue(field.disabled) + self.assertTrue( + field.help_text.startswith( + "To build from pull requests you need a GitHub or GitLab" + ) + ) + + get( + Integration, + project=self.project, + integration_type=Integration.BITBUCKET_WEBHOOK, + ) + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertTrue(field.disabled) + self.assertTrue( + field.help_text.startswith( + "To build from pull requests you need a GitHub or GitLab" + ) + ) + + def test_github_integration(self): + self.integration.provider_data = {} + self.integration.save() + + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertFalse(field.disabled) + self.assertTrue(field.help_text.startswith("More information in")) + + self.integration.provider_data = {"events": ["pull_request"]} + self.integration.save() + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertFalse(field.disabled) + self.assertTrue(field.help_text.startswith("More information in")) + + self.integration.provider_data = {"events": []} + self.integration.save() + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertTrue(field.disabled) + self.assertTrue( + field.help_text.startswith( + "To build from pull requests your repository's webhook needs to send pull request events." + ) + ) + + def test_gitlab_integration(self): + self.integration.integration_type = Integration.GITLAB_WEBHOOK + self.integration.provider_data = {} + self.integration.save() + + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertFalse(field.disabled) + self.assertTrue(field.help_text.startswith("More information in")) + + self.integration.provider_data = {"merge_requests_events": True} + self.integration.save() + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertFalse(field.disabled) + self.assertTrue(field.help_text.startswith("More information in")) + + self.integration.provider_data = {"merge_requests_events": False} + self.integration.save() + resp = self.client.get(self.url) + field = resp.context["form"].fields["external_builds_enabled"] + self.assertTrue(field.disabled) + self.assertTrue( + field.help_text.startswith( + "To build from pull requests your repository's webhook needs to send pull request events." + ) + )