diff --git a/core/commands/repository/interactors/tests/test_update_bundle_cache_config.py b/core/commands/repository/interactors/tests/test_update_bundle_cache_config.py new file mode 100644 index 0000000000..986a99bcf6 --- /dev/null +++ b/core/commands/repository/interactors/tests/test_update_bundle_cache_config.py @@ -0,0 +1,91 @@ +import pytest +from asgiref.sync import async_to_sync +from django.test import TransactionTestCase +from shared.django_apps.bundle_analysis.models import CacheConfig +from shared.django_apps.core.tests.factories import ( + OwnerFactory, + RepositoryFactory, +) + +from codecov.commands.exceptions import ValidationError + +from ..update_bundle_cache_config import UpdateBundleCacheConfigInteractor + + +class UpdateBundleCacheConfigInteractorTest(TransactionTestCase): + databases = {"default"} + + def setUp(self): + self.org = OwnerFactory(username="test-org") + self.repo = RepositoryFactory(author=self.org, name="test-repo", active=True) + self.user = OwnerFactory(permission=[self.repo.pk]) + + @async_to_sync + def execute(self, owner, repo_name=None, cache_config=[]): + return UpdateBundleCacheConfigInteractor(owner, "github").execute( + repo_name=repo_name, + owner_username="test-org", + cache_config=cache_config, + ) + + def test_repo_not_found(self): + with pytest.raises(ValidationError): + self.execute(owner=self.user, repo_name="wrong") + + def test_bundle_not_found(self): + with pytest.raises( + ValidationError, match="The following bundle names do not exist: wrong" + ): + self.execute( + owner=self.user, + repo_name="test-repo", + cache_config=[{"bundle_name": "wrong", "toggle_caching": True}], + ) + + def test_some_bundles_not_found(self): + CacheConfig.objects.create( + repo_id=self.repo.pk, bundle_name="bundle1", is_caching=True + ) + with pytest.raises( + ValidationError, match="The following bundle names do not exist: bundle2" + ): + self.execute( + owner=self.user, + repo_name="test-repo", + cache_config=[ + {"bundle_name": "bundle1", "toggle_caching": False}, + {"bundle_name": "bundle2", "toggle_caching": True}, + ], + ) + + def test_update_bundles_successfully(self): + CacheConfig.objects.create( + repo_id=self.repo.pk, bundle_name="bundle1", is_caching=True + ) + CacheConfig.objects.create( + repo_id=self.repo.pk, bundle_name="bundle2", is_caching=True + ) + + res = self.execute( + owner=self.user, + repo_name="test-repo", + cache_config=[ + {"bundle_name": "bundle1", "toggle_caching": False}, + {"bundle_name": "bundle2", "toggle_caching": True}, + ], + ) + + assert res == [ + {"bundle_name": "bundle1", "is_cached": False}, + {"bundle_name": "bundle2", "is_cached": True}, + ] + + assert len(CacheConfig.objects.all()) == 2 + + query = CacheConfig.objects.filter(repo_id=self.repo.pk, bundle_name="bundle1") + assert len(query) == 1 + assert query[0].is_caching == False + + query = CacheConfig.objects.filter(repo_id=self.repo.pk, bundle_name="bundle2") + assert len(query) == 1 + assert query[0].is_caching == True diff --git a/core/commands/repository/interactors/update_bundle_cache_config.py b/core/commands/repository/interactors/update_bundle_cache_config.py new file mode 100644 index 0000000000..e2bd25973d --- /dev/null +++ b/core/commands/repository/interactors/update_bundle_cache_config.py @@ -0,0 +1,71 @@ +from typing import Dict, List + +from shared.django_apps.bundle_analysis.models import CacheConfig +from shared.django_apps.bundle_analysis.service.bundle_analysis import ( + BundleAnalysisCacheConfigService, +) + +from codecov.commands.base import BaseInteractor +from codecov.commands.exceptions import ValidationError +from codecov.db import sync_to_async +from codecov_auth.models import Owner +from core.models import Repository + + +class UpdateBundleCacheConfigInteractor(BaseInteractor): + def validate( + self, repo: Repository, cache_config: List[Dict[str, str | bool]] + ) -> None: + if not repo: + raise ValidationError("Repo not found") + + # Find any missing bundle names + bundle_names = [ + bundle["bundle_name"] + for bundle in cache_config + # the value of bundle_name is always a string, just do this check to appease mypy + if isinstance(bundle["bundle_name"], str) + ] + existing_bundle_names = set( + CacheConfig.objects.filter( + repo_id=repo.pk, bundle_name__in=bundle_names + ).values_list("bundle_name", flat=True) + ) + missing_bundles = set(bundle_names) - existing_bundle_names + if missing_bundles: + raise ValidationError( + f"The following bundle names do not exist: {', '.join(missing_bundles)}" + ) + + @sync_to_async + def execute( + self, + owner_username: str, + repo_name: str, + cache_config: List[Dict[str, str | bool]], + ) -> List[Dict[str, str | bool]]: + author = Owner.objects.filter( + username=owner_username, service=self.service + ).first() + repo = ( + Repository.objects.viewable_repos(self.current_owner) + .filter(author=author, name=repo_name) + .first() + ) + + self.validate(repo, cache_config) + + results = [] + for bundle in cache_config: + bundle_name = bundle["bundle_name"] + is_caching = bundle["toggle_caching"] + BundleAnalysisCacheConfigService.update_cache_option( + repo.pk, bundle_name, is_caching + ) + results.append( + { + "bundle_name": bundle_name, + "is_cached": is_caching, + } + ) + return results diff --git a/core/commands/repository/repository.py b/core/commands/repository/repository.py index 49bf521e7f..e3d4a2e90e 100644 --- a/core/commands/repository/repository.py +++ b/core/commands/repository/repository.py @@ -1,5 +1,5 @@ import uuid -from typing import Awaitable, Optional +from typing import Awaitable, Dict, List, Optional from codecov.commands.base import BaseCommand from codecov_auth.models import Owner, RepositoryToken @@ -16,6 +16,7 @@ from .interactors.regenerate_repository_upload_token import ( RegenerateRepositoryUploadTokenInteractor, ) +from .interactors.update_bundle_cache_config import UpdateBundleCacheConfigInteractor from .interactors.update_repository import UpdateRepositoryInteractor @@ -85,3 +86,13 @@ def encode_secret_string(self, owner: Owner, repo_name: str, value: str) -> str: return self.get_interactor(EncodeSecretStringInteractor).execute( owner, repo_name, value ) + + def update_bundle_cache_config( + self, + owner_username: str, + repo_name: str, + cache_config: List[Dict[str, str | bool]], + ) -> Awaitable[List[Dict[str, str | bool]]]: + return self.get_interactor(UpdateBundleCacheConfigInteractor).execute( + owner_username, repo_name, cache_config + ) diff --git a/graphql_api/tests/mutation/test_update_bundle_cache_config.py b/graphql_api/tests/mutation/test_update_bundle_cache_config.py new file mode 100644 index 0000000000..55f8a210ff --- /dev/null +++ b/graphql_api/tests/mutation/test_update_bundle_cache_config.py @@ -0,0 +1,79 @@ +from unittest.mock import patch + +from django.test import TransactionTestCase +from shared.django_apps.core.tests.factories import OwnerFactory + +from graphql_api.tests.helper import GraphQLTestHelper + +query = """ +mutation($input: ActivateMeasurementsInput!) { + activateMeasurements(input: $input) { + error { + __typename + } + } +} +""" + + +query = """ +mutation UpdateBundleCacheConfig( + $owner: String! + $repoName: String! + $bundles: [BundleCacheConfigInput!]! +) { + updateBundleCacheConfig(input: { + owner: $owner, + repoName: $repoName, + bundles: $bundles + }) { + results { + bundleName + isCached + } + error { + __typename + ... on UnauthenticatedError { + message + } + ... on ValidationError { + message + } + } + } +} +""" + + +class UpdateBundleCacheConfigTestCase(GraphQLTestHelper, TransactionTestCase): + def setUp(self): + self.owner = OwnerFactory() + + def test_when_unauthenticated(self): + data = self.gql_request( + query, + variables={ + "owner": "codecov", + "repoName": "test-repo", + "bundles": [{"bundleName": "pr_bundle1", "toggleCaching": True}], + }, + ) + assert ( + data["updateBundleCacheConfig"]["error"]["__typename"] + == "UnauthenticatedError" + ) + + @patch( + "core.commands.repository.interactors.update_bundle_cache_config.UpdateBundleCacheConfigInteractor.execute" + ) + def test_when_authenticated(self, execute): + data = self.gql_request( + query, + owner=self.owner, + variables={ + "owner": "codecov", + "repoName": "test-repo", + "bundles": [{"bundleName": "pr_bundle1", "toggleCaching": True}], + }, + ) + assert data == {"updateBundleCacheConfig": {"results": [], "error": None}} diff --git a/graphql_api/types/inputs/bundle_analysis_cache_config.graphql b/graphql_api/types/inputs/bundle_analysis_cache_config.graphql new file mode 100644 index 0000000000..5500da6b1a --- /dev/null +++ b/graphql_api/types/inputs/bundle_analysis_cache_config.graphql @@ -0,0 +1,10 @@ +input BundleCacheConfigInput { + bundleName: String! + toggleCaching: Boolean! +} + +input UpdateBundleCacheConfigInput { + owner: String! + repoName: String! + bundles: [BundleCacheConfigInput!]! +} \ No newline at end of file diff --git a/graphql_api/types/mutation/__init__.py b/graphql_api/types/mutation/__init__.py index 244e529cc9..adc9f3501f 100644 --- a/graphql_api/types/mutation/__init__.py +++ b/graphql_api/types/mutation/__init__.py @@ -23,6 +23,7 @@ from .start_trial import gql_start_trial from .store_event_metrics import gql_store_event_metrics from .sync_with_git_provider import gql_sync_with_git_provider +from .update_bundle_cache_config import gql_update_bundle_cache_config from .update_default_organization import gql_update_default_organization from .update_profile import gql_update_profile from .update_repository import gql_update_repository @@ -55,3 +56,4 @@ mutation = mutation + gql_store_event_metrics mutation = mutation + gql_save_okta_config mutation = mutation + gql_set_upload_token_required +mutation = mutation + gql_update_bundle_cache_config diff --git a/graphql_api/types/mutation/mutation.graphql b/graphql_api/types/mutation/mutation.graphql index e8ab068efd..bad930d06f 100644 --- a/graphql_api/types/mutation/mutation.graphql +++ b/graphql_api/types/mutation/mutation.graphql @@ -39,4 +39,5 @@ type Mutation { storeEventMetric(input: StoreEventMetricsInput!): StoreEventMetricsPayload saveOktaConfig(input: SaveOktaConfigInput!): SaveOktaConfigPayload setUploadTokenRequired(input: SetUploadTokenRequiredInput!): SetUploadTokenRequiredPayload + updateBundleCacheConfig(input: UpdateBundleCacheConfigInput!): UpdateBundleCacheConfigPayload } diff --git a/graphql_api/types/mutation/mutation.py b/graphql_api/types/mutation/mutation.py index 1b487432f1..d1fd6ea7dd 100644 --- a/graphql_api/types/mutation/mutation.py +++ b/graphql_api/types/mutation/mutation.py @@ -49,6 +49,10 @@ error_sync_with_git_provider, resolve_sync_with_git_provider, ) +from .update_bundle_cache_config import ( + error_update_bundle_cache_config, + resolve_update_bundle_cache_config, +) from .update_default_organization import ( error_update_default_organization, resolve_update_default_organization, @@ -99,6 +103,7 @@ mutation_bindable.field("saveOktaConfig")(resolve_save_okta_config) mutation_bindable.field("setUploadTokenRequired")(resolve_set_upload_token_required) +mutation_bindable.field("updateBundleCacheConfig")(resolve_update_bundle_cache_config) mutation_resolvers = [ mutation_bindable, @@ -128,4 +133,5 @@ error_store_event_metrics, error_save_okta_config, error_set_upload_token_required, + error_update_bundle_cache_config, ] diff --git a/graphql_api/types/mutation/update_bundle_cache_config/__init__.py b/graphql_api/types/mutation/update_bundle_cache_config/__init__.py new file mode 100644 index 0000000000..c434620641 --- /dev/null +++ b/graphql_api/types/mutation/update_bundle_cache_config/__init__.py @@ -0,0 +1,15 @@ +from graphql_api.helpers.ariadne import ariadne_load_local_graphql + +from .update_bundle_cache_config import ( + error_update_bundle_cache_config, + resolve_update_bundle_cache_config, +) + +gql_update_bundle_cache_config = ariadne_load_local_graphql( + __file__, "update_bundle_cache_config.graphql" +) + +__all__ = [ + "error_update_bundle_cache_config", + "resolve_update_bundle_cache_config", +] diff --git a/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.graphql b/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.graphql new file mode 100644 index 0000000000..34e7e02fde --- /dev/null +++ b/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.graphql @@ -0,0 +1,11 @@ +union UpdateBundleCacheConfigError = UnauthenticatedError | ValidationError + +type UpdateBundleCacheConfigResult { + bundleName: String + isCached: Boolean +} + +type UpdateBundleCacheConfigPayload { + results: [UpdateBundleCacheConfigResult!] + error: UpdateBundleCacheConfigError +} diff --git a/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.py b/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.py new file mode 100644 index 0000000000..ae13c64a25 --- /dev/null +++ b/graphql_api/types/mutation/update_bundle_cache_config/update_bundle_cache_config.py @@ -0,0 +1,30 @@ +from typing import Any, Dict, List + +from ariadne import UnionType +from graphql import GraphQLResolveInfo + +from core.commands.repository.repository import RepositoryCommands +from graphql_api.helpers.mutation import ( + require_authenticated, + resolve_union_error_type, + wrap_error_handling_mutation, +) + + +@wrap_error_handling_mutation +@require_authenticated +async def resolve_update_bundle_cache_config( + _: Any, info: GraphQLResolveInfo, input: Dict[str, Any] +) -> Dict[str, List[Dict[str, str | bool]]]: + command: RepositoryCommands = info.context["executor"].get_command("repository") + + results = await command.update_bundle_cache_config( + repo_name=input.get("repo_name", ""), + owner_username=input.get("owner", ""), + cache_config=input.get("bundles", []), + ) + return {"results": results} + + +error_update_bundle_cache_config = UnionType("UpdateBundleCacheConfigError") +error_update_bundle_cache_config.type_resolver(resolve_union_error_type)