diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 8d43d90ff..183129939 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -40,4 +40,13 @@ from .issue import ( from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer -from .api_token import APITokenSerializer \ No newline at end of file +from .api_token import APITokenSerializer + +from .integration import ( + IntegrationSerializer, + WorkspaceIntegrationSerializer, + GithubIssueSyncSerializer, + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, +) diff --git a/apiserver/plane/api/serializers/integration/__init__.py b/apiserver/plane/api/serializers/integration/__init__.py new file mode 100644 index 000000000..8aea68bd6 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/__init__.py @@ -0,0 +1,7 @@ +from .base import IntegrationSerializer, WorkspaceIntegrationSerializer +from .github import ( + GithubRepositorySerializer, + GithubRepositorySyncSerializer, + GithubIssueSyncSerializer, + GithubCommentSyncSerializer, +) diff --git a/apiserver/plane/api/serializers/integration/base.py b/apiserver/plane/api/serializers/integration/base.py new file mode 100644 index 000000000..10ebd4620 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/base.py @@ -0,0 +1,20 @@ +# Module imports +from plane.api.serializers import BaseSerializer +from plane.db.models import Integration, WorkspaceIntegration + + +class IntegrationSerializer(BaseSerializer): + class Meta: + model = Integration + fields = "__all__" + read_only_fields = [ + "verified", + ] + + +class WorkspaceIntegrationSerializer(BaseSerializer): + integration_detail = IntegrationSerializer(read_only=True, source="integration") + + class Meta: + model = WorkspaceIntegration + fields = "__all__" diff --git a/apiserver/plane/api/serializers/integration/github.py b/apiserver/plane/api/serializers/integration/github.py new file mode 100644 index 000000000..8352dcee1 --- /dev/null +++ b/apiserver/plane/api/serializers/integration/github.py @@ -0,0 +1,45 @@ +# Module imports +from plane.api.serializers import BaseSerializer +from plane.db.models import ( + GithubIssueSync, + GithubRepository, + GithubRepositorySync, + GithubCommentSync, +) + + +class GithubRepositorySerializer(BaseSerializer): + class Meta: + model = GithubRepository + fields = "__all__" + + +class GithubRepositorySyncSerializer(BaseSerializer): + repo_detail = GithubRepositorySerializer(source="repository") + + class Meta: + model = GithubRepositorySync + fields = "__all__" + + +class GithubIssueSyncSerializer(BaseSerializer): + class Meta: + model = GithubIssueSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "repository_sync", + ] + + +class GithubCommentSyncSerializer(BaseSerializer): + class Meta: + model = GithubCommentSync + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + "repository_sync", + "issue_sync", + ] diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index 808991ddc..14a33d9c3 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -21,6 +21,7 @@ class UserSerializer(BaseSerializer): "last_login_uagent", "token_updated_at", "is_onboarded", + "is_bot", ] extra_kwargs = {"password": {"write_only": True}} @@ -34,7 +35,9 @@ class UserLiteSerializer(BaseSerializer): "last_name", "email", "avatar", + "is_bot", ] read_only_fields = [ "id", + "is_bot", ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index 4af139bf5..e44579cb7 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -86,6 +86,14 @@ from plane.api.views import ( # Api Tokens ApiTokenEndpoint, ## End Api Tokens + # Integrations + IntegrationViewSet, + WorkspaceIntegrationViewSet, + GithubRepositoriesEndpoint, + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + ## End Integrations ) @@ -681,7 +689,118 @@ urlpatterns = [ ), ## End Modules # API Tokens - path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-token"), - path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-token"), + path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), + path("api-tokens//", ApiTokenEndpoint.as_view(), name="api-tokens"), ## End API Tokens + # Integrations + path( + "integrations/", + IntegrationViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="integrations", + ), + path( + "integrations//", + IntegrationViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="integrations", + ), + path( + "workspaces//workspace-integrations/", + WorkspaceIntegrationViewSet.as_view( + { + "get": "list", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "post": "create", + } + ), + name="workspace-integrations", + ), + path( + "workspaces//workspace-integrations//", + WorkspaceIntegrationViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + name="workspace-integrations", + ), + # Github Integrations + path( + "workspaces//workspace-integrations//github-repositories/", + GithubRepositoriesEndpoint.as_view(), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync/", + GithubRepositorySyncViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + ), + path( + "workspaces//projects//workspace-integrations//github-repository-sync//", + GithubRepositorySyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync/", + GithubIssueSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//", + GithubIssueSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync/", + GithubCommentSyncViewSet.as_view( + { + "post": "create", + "get": "list", + } + ), + ), + path( + "workspaces//projects//github-repository-sync//github-issue-sync//github-comment-sync//", + GithubCommentSyncViewSet.as_view( + { + "get": "retrieve", + "delete": "destroy", + } + ), + ), + ## End Github Integrations + ## End Integrations ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 4fb565e8d..275642c50 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -72,4 +72,13 @@ from .authentication import ( from .module import ModuleViewSet, ModuleIssueViewSet -from .api_token import ApiTokenEndpoint \ No newline at end of file +from .api_token import ApiTokenEndpoint + +from .integration import ( + WorkspaceIntegrationViewSet, + IntegrationViewSet, + GithubIssueSyncViewSet, + GithubRepositorySyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) diff --git a/apiserver/plane/api/views/integration/__init__.py b/apiserver/plane/api/views/integration/__init__.py new file mode 100644 index 000000000..693202573 --- /dev/null +++ b/apiserver/plane/api/views/integration/__init__.py @@ -0,0 +1,7 @@ +from .base import IntegrationViewSet, WorkspaceIntegrationViewSet +from .github import ( + GithubRepositorySyncViewSet, + GithubIssueSyncViewSet, + GithubCommentSyncViewSet, + GithubRepositoriesEndpoint, +) diff --git a/apiserver/plane/api/views/integration/base.py b/apiserver/plane/api/views/integration/base.py new file mode 100644 index 000000000..bded732ec --- /dev/null +++ b/apiserver/plane/api/views/integration/base.py @@ -0,0 +1,159 @@ +# Python improts +import uuid + +# Django imports +from django.db import IntegrityError +from django.contrib.auth.hashers import make_password + +# Third party imports +from rest_framework.response import Response +from rest_framework import status +from sentry_sdk import capture_exception + +# Module imports +from plane.api.views import BaseViewSet +from plane.db.models import ( + Integration, + WorkspaceIntegration, + Workspace, + User, + WorkspaceMember, + APIToken, +) +from plane.api.serializers import IntegrationSerializer, WorkspaceIntegrationSerializer +from plane.utils.integrations.github import get_github_metadata + + +class IntegrationViewSet(BaseViewSet): + serializer_class = IntegrationSerializer + model = Integration + + def create(self, request): + try: + serializer = IntegrationSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def partial_update(self, request, pk): + try: + integration = Integration.objects.get(pk=pk) + if integration.verified: + return Response( + {"error": "Verified integrations cannot be updated"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = IntegrationSerializer( + integration, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except Integration.DoesNotExist: + return Response( + {"error": "Integration Does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class WorkspaceIntegrationViewSet(BaseViewSet): + serializer_class = WorkspaceIntegrationSerializer + model = WorkspaceIntegration + + def create(self, request, slug, provider): + try: + installation_id = request.data.get("installation_id", None) + + if not installation_id: + return Response( + {"error": "Installation ID is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + workspace = Workspace.objects.get(slug=slug) + integration = Integration.objects.get(provider=provider) + config = {} + if provider == "github": + metadata = get_github_metadata(installation_id) + config = {"installation_id": installation_id} + + # Create a bot user + bot_user = User.objects.create( + email=f"{uuid.uuid4().hex}@plane.so", + username=uuid.uuid4().hex, + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + is_bot=True, + first_name=integration.title, + avatar=integration.avatar_url + if integration.avatar_url is not None + else "", + ) + + # Create an API Token for the bot user + api_token = APIToken.objects.create( + user=bot_user, + user_type=1, # bot user + workspace=workspace, + ) + + workspace_integration = WorkspaceIntegration.objects.create( + workspace=workspace, + integration=integration, + actor=bot_user, + api_token=api_token, + metadata=metadata, + config=config, + ) + + # Add bot user as a member of workspace + _ = WorkspaceMember.objects.create( + workspace=workspace_integration.workspace, + member=bot_user, + role=20, + ) + return Response( + WorkspaceIntegrationSerializer(workspace_integration).data, + status=status.HTTP_201_CREATED, + ) + except IntegrityError as e: + if "already exists" in str(e): + return Response( + {"error": "Integration is already active in the workspace"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (Workspace.DoesNotExist, Integration.DoesNotExist) as e: + capture_exception(e) + return Response( + {"error": "Workspace or Integration not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/integration/github.py b/apiserver/plane/api/views/integration/github.py new file mode 100644 index 000000000..7486ce7b9 --- /dev/null +++ b/apiserver/plane/api/views/integration/github.py @@ -0,0 +1,145 @@ +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from plane.api.views import BaseViewSet, BaseAPIView +from plane.db.models import ( + GithubIssueSync, + GithubRepositorySync, + GithubRepository, + WorkspaceIntegration, + ProjectMember, + Label, + GithubCommentSync, +) +from plane.api.serializers import ( + GithubIssueSyncSerializer, + GithubRepositorySyncSerializer, + GithubCommentSyncSerializer, +) +from plane.utils.integrations.github import get_github_repos + + +class GithubRepositoriesEndpoint(BaseAPIView): + def get(self, request, slug, workspace_integration_id): + try: + workspace_integration = WorkspaceIntegration.objects.get( + workspace__slug=slug, pk=workspace_integration_id + ) + access_tokens_url = workspace_integration.metadata["access_tokens_url"] + repositories_url = workspace_integration.metadata["repositories_url"] + repositories = get_github_repos(access_tokens_url, repositories_url) + return Response(repositories, status=status.HTTP_200_OK) + except WorkspaceIntegration.DoesNotExist: + return Response( + {"error": "Workspace Integration Does not exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class GithubRepositorySyncViewSet(BaseViewSet): + serializer_class = GithubRepositorySyncSerializer + model = GithubRepositorySync + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def create(self, request, slug, project_id, workspace_integration_id): + try: + name = request.data.get("name", False) + url = request.data.get("url", False) + config = request.data.get("config", {}) + repository_id = request.data.get("repository_id", False) + owner = request.data.get("owner", False) + + if not name or not url or not repository_id or not owner: + return Response( + {"error": "Name, url, repository_id and owner are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Create repository + repo = GithubRepository.objects.create( + name=name, + url=url, + config=config, + repository_id=repository_id, + owner=owner, + project_id=project_id, + ) + + # Get the workspace integration + workspace_integration = WorkspaceIntegration.objects.get( + pk=workspace_integration_id + ) + + # Create a Label for github + label = Label.objects.filter( + name="GitHub", + project_id=project_id, + ).first() + + if label is None: + label = Label.objects.create( + name="GitHub", + project_id=project_id, + description="Label to sync Plane issues with GitHub issues", + color="#003773", + ) + + # Create repo sync + repo_sync = GithubRepositorySync.objects.create( + repository=repo, + workspace_integration=workspace_integration, + actor=workspace_integration.actor, + credentials=request.data.get("credentials", {}), + project_id=project_id, + label=label, + ) + + # Add bot as a member in the project + _ = ProjectMember.objects.create( + member=workspace_integration.actor, role=20, project_id=project_id + ) + + # Return Response + return Response( + GithubRepositorySyncSerializer(repo_sync).data, + status=status.HTTP_201_CREATED, + ) + + except WorkspaceIntegration.DoesNotExist: + return Response( + {"error": "Workspace Integration does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class GithubIssueSyncViewSet(BaseViewSet): + serializer_class = GithubIssueSyncSerializer + model = GithubIssueSync + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + repository_sync_id=self.kwargs.get("repo_sync_id"), + ) + + +class GithubCommentSyncViewSet(BaseViewSet): + serializer_class = GithubCommentSyncSerializer + model = GithubCommentSync + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_sync_id=self.kwargs.get("issue_sync_id"), + ) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 3bc585348..68797c296 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -3,7 +3,7 @@ import json from itertools import groupby, chain # Django imports -from django.db.models import Prefetch, OuterRef, Func, F +from django.db.models import Prefetch, OuterRef, Func, F, Q from django.core.serializers.json import DjangoJSONEncoder # Third Party imports @@ -80,7 +80,7 @@ class IssueViewSet(BaseViewSet): if current_instance is not None: issue_activity.delay( { - "type": "issue.activity", + "type": "issue.activity.updated", "requested_data": requested_data, "actor_id": str(self.request.user.id), "issue_id": str(self.kwargs.get("pk", None)), @@ -93,6 +93,27 @@ class IssueViewSet(BaseViewSet): return super().perform_update(serializer) + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + { + "type": "issue.activity.deleted", + "requested_data": json.dumps( + {"issue_id": str(self.kwargs.get("pk", None))} + ), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("pk", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + IssueSerializer(current_instance).data, cls=DjangoJSONEncoder + ), + }, + ) + return super().perform_destroy(instance) + def get_queryset(self): return ( super() @@ -193,15 +214,18 @@ class IssueViewSet(BaseViewSet): serializer.save() # Track the issue - IssueActivity.objects.create( - issue_id=serializer.data["id"], - project_id=project_id, - workspace_id=serializer["workspace"], - comment=f"{request.user.email} created the issue", - verb="created", - actor=request.user, + issue_activity.delay( + { + "type": "issue.activity.created", + "requested_data": json.dumps( + self.request.data, cls=DjangoJSONEncoder + ), + "actor_id": str(request.user.id), + "issue_id": str(serializer.data.get("id", None)), + "project_id": str(project_id), + "current_instance": None, + }, ) - return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -304,7 +328,10 @@ class IssueActivityEndpoint(BaseAPIView): try: issue_activities = ( IssueActivity.objects.filter(issue_id=issue_id) - .filter(project__project_projectmember__member=self.request.user) + .filter( + ~Q(field="comment"), + project__project_projectmember__member=self.request.user, + ) .select_related("actor") ).order_by("created_by") issue_comments = ( @@ -347,6 +374,60 @@ class IssueCommentViewSet(BaseViewSet): issue_id=self.kwargs.get("issue_id"), actor=self.request.user if self.request.user is not None else None, ) + issue_activity.delay( + { + "type": "comment.activity.created", + "requested_data": json.dumps(serializer.data, cls=DjangoJSONEncoder), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("issue_id")), + "project_id": str(self.kwargs.get("project_id")), + "current_instance": None, + }, + ) + + def perform_update(self, serializer): + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + { + "type": "comment.activity.updated", + "requested_data": requested_data, + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("issue_id", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + IssueCommentSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + }, + ) + + return super().perform_update(serializer) + + def perform_destroy(self, instance): + current_instance = ( + self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() + ) + if current_instance is not None: + issue_activity.delay( + { + "type": "comment.activity.deleted", + "requested_data": json.dumps( + {"comment_id": str(self.kwargs.get("pk", None))} + ), + "actor_id": str(self.request.user.id), + "issue_id": str(self.kwargs.get("issue_id", None)), + "project_id": str(self.kwargs.get("project_id", None)), + "current_instance": json.dumps( + IssueCommentSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + }, + ) + return super().perform_destroy(instance) def get_queryset(self): return self.filter_queryset( diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 7e0e3f6ff..a9bf30712 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -1,5 +1,10 @@ # Python imports import json +import requests + +# Django imports +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from django_rq import job @@ -16,6 +21,7 @@ from plane.db.models import ( Cycle, Module, ) +from plane.api.serializers import IssueActivitySerializer # Track Chnages in name @@ -612,14 +618,136 @@ def track_modules( ) +def create_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created the issue", + verb="created", + actor=actor, + ) + ) + + +def update_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + ISSUE_ACTIVITY_MAPPER = { + "name": track_name, + "parent": track_parent, + "priority": track_priority, + "state": track_state, + "description": track_description, + "target_date": track_target_date, + "start_date": track_start_date, + "labels_list": track_labels, + "assignees_list": track_assignees, + "blocks_list": track_blocks, + "blockers_list": track_blockings, + "cycles_list": track_cycles, + "modules_list": track_modules, + } + for key in requested_data: + func = ISSUE_ACTIVITY_MAPPER.get(key, None) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) + + +def create_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} created a comment", + verb="created", + actor=actor, + field="comment", + new_value=requested_data.get("comment_html"), + new_identifier=requested_data.get("id"), + issue_comment_id=requested_data.get("id", None), + ) + ) + + +def update_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + if current_instance.get("comment_html") != requested_data.get("comment_html"): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} updated a comment", + verb="updated", + actor=actor, + field="comment", + old_value=current_instance.get("comment_html"), + old_identifier=current_instance.get("id"), + new_value=requested_data.get("comment_html"), + new_identifier=current_instance.get("id"), + issue_comment_id=current_instance.get("id"), + ) + ) + + +def delete_issue_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the issue", + verb="deleted", + actor=actor, + field="issue", + ) + ) + + +def delete_comment_activity( + requested_data, current_instance, issue_id, project, actor, issue_activities +): + issue_activities.append( + IssueActivity( + issue_id=issue_id, + project=project, + workspace=project.workspace, + comment=f"{actor.email} deleted the comment", + verb="deleted", + actor=actor, + field="comment", + ) + ) + + # Receive message from room group @job("default") def issue_activity(event): try: issue_activities = [] - + type = event.get("type") requested_data = json.loads(event.get("requested_data")) - current_instance = json.loads(event.get("current_instance")) + current_instance = ( + json.loads(event.get("current_instance")) + if event.get("current_instance") is not None + else None + ) issue_id = event.get("issue_id", None) actor_id = event.get("actor_id") project_id = event.get("project_id") @@ -628,37 +756,43 @@ def issue_activity(event): project = Project.objects.get(pk=project_id) - ISSUE_ACTIVITY_MAPPER = { - "name": track_name, - "parent": track_parent, - "priority": track_priority, - "state": track_state, - "description": track_description, - "target_date": track_target_date, - "start_date": track_start_date, - "labels_list": track_labels, - "assignees_list": track_assignees, - "blocks_list": track_blocks, - "blockers_list": track_blockings, - "cycles_list": track_cycles, - "modules_list": track_modules, + ACTIVITY_MAPPER = { + "issue.activity.created": create_issue_activity, + "issue.activity.updated": update_issue_activity, + "issue.activity.deleted": delete_issue_activity, + "comment.activity.created": create_comment_activity, + "comment.activity.updated": update_comment_activity, + "comment.activity.deleted": delete_comment_activity, } - for key in requested_data: - func = ISSUE_ACTIVITY_MAPPER.get(key, None) - if func is not None: - func( - requested_data, - current_instance, - issue_id, - project, - actor, - issue_activities, - ) + func = ACTIVITY_MAPPER.get(type) + if func is not None: + func( + requested_data, + current_instance, + issue_id, + project, + actor, + issue_activities, + ) # Save all the values to database - _ = IssueActivity.objects.bulk_create(issue_activities) - + issue_activities_created = IssueActivity.objects.bulk_create(issue_activities) + # Post the updates to segway for integrations and webhooks + if len(issue_activities_created): + # Don't send activities if the actor is a bot + if settings.PROXY_BASE_URL: + for issue_activity in issue_activities_created: + headers = {"Content-Type": "application/json"} + issue_activity_json = json.dumps( + IssueActivitySerializer(issue_activity).data, + cls=DjangoJSONEncoder, + ) + _ = requests.post( + f"{settings.PROXY_BASE_URL}/hooks/workspaces/{str(issue_activity.workspace_id)}/projects/{str(issue_activity.project_id)}/issues/{str(issue_activity.issue_id)}/issue-activity-hooks/", + json=issue_activity_json, + headers=headers, + ) return except Exception as e: capture_exception(e) diff --git a/apiserver/plane/db/mixins.py b/apiserver/plane/db/mixins.py index b48e5c965..728cb9933 100644 --- a/apiserver/plane/db/mixins.py +++ b/apiserver/plane/db/mixins.py @@ -1,3 +1,7 @@ +# Python imports +import uuid + +# Django imports from django.db import models diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index d12578fa1..ce8cf950b 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -10,7 +10,13 @@ from .workspace import ( TeamMember, ) -from .project import Project, ProjectMember, ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier +from .project import ( + Project, + ProjectMember, + ProjectBaseModel, + ProjectMemberInvite, + ProjectIdentifier, +) from .issue import ( Issue, @@ -38,6 +44,15 @@ from .shortcut import Shortcut from .view import View -from .module import Module, ModuleMember, ModuleIssue, ModuleLink +from .module import Module, ModuleMember, ModuleIssue, ModuleLink -from .api_token import APIToken \ No newline at end of file +from .api_token import APIToken + +from .integration import ( + WorkspaceIntegration, + Integration, + GithubRepository, + GithubRepositorySync, + GithubIssueSync, + GithubCommentSync, +) diff --git a/apiserver/plane/db/models/integration/__init__.py b/apiserver/plane/db/models/integration/__init__.py new file mode 100644 index 000000000..4742a2529 --- /dev/null +++ b/apiserver/plane/db/models/integration/__init__.py @@ -0,0 +1,2 @@ +from .base import Integration, WorkspaceIntegration +from .github import GithubRepository, GithubRepositorySync, GithubIssueSync, GithubCommentSync diff --git a/apiserver/plane/db/models/integration/base.py b/apiserver/plane/db/models/integration/base.py new file mode 100644 index 000000000..47db0483c --- /dev/null +++ b/apiserver/plane/db/models/integration/base.py @@ -0,0 +1,68 @@ +# Python imports +import uuid + +# Django imports +from django.db import models + +# Module imports +from plane.db.models import BaseModel +from plane.db.mixins import AuditModel + + +class Integration(AuditModel): + id = models.UUIDField( + default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True + ) + title = models.CharField(max_length=400) + provider = models.CharField(max_length=400, unique=True) + network = models.PositiveIntegerField( + default=1, choices=((1, "Private"), (2, "Public")) + ) + description = models.JSONField(default=dict) + author = models.CharField(max_length=400, blank=True) + webhook_url = models.TextField(blank=True) + webhook_secret = models.TextField(blank=True) + redirect_url = models.TextField(blank=True) + metadata = models.JSONField(default=dict) + verified = models.BooleanField(default=False) + avatar_url = models.URLField(blank=True, null=True) + + def __str__(self): + """Return provider of the integration""" + return f"{self.provider}" + + class Meta: + verbose_name = "Integration" + verbose_name_plural = "Integrations" + db_table = "integrations" + ordering = ("-created_at",) + + +class WorkspaceIntegration(BaseModel): + workspace = models.ForeignKey( + "db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE + ) + # Bot user + actor = models.ForeignKey( + "db.User", related_name="integrations", on_delete=models.CASCADE + ) + integration = models.ForeignKey( + "db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE + ) + api_token = models.ForeignKey( + "db.APIToken", related_name="integrations", on_delete=models.CASCADE + ) + metadata = models.JSONField(default=dict) + + config = models.JSONField(default=dict) + + def __str__(self): + """Return name of the integration and workspace""" + return f"{self.workspace.name} <{self.integration.provider}>" + + class Meta: + unique_together = ["workspace", "integration"] + verbose_name = "Workspace Integration" + verbose_name_plural = "Workspace Integrations" + db_table = "workspace_integrations" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/integration/github.py b/apiserver/plane/db/models/integration/github.py new file mode 100644 index 000000000..130925c21 --- /dev/null +++ b/apiserver/plane/db/models/integration/github.py @@ -0,0 +1,99 @@ +# Python imports +import uuid + +# Django imports +from django.db import models + +# Module imports +from plane.db.models import ProjectBaseModel +from plane.db.mixins import AuditModel + + +class GithubRepository(ProjectBaseModel): + name = models.CharField(max_length=500) + url = models.URLField(null=True) + config = models.JSONField(default=dict) + repository_id = models.BigIntegerField() + owner = models.CharField(max_length=500) + + def __str__(self): + """Return the repo name""" + return f"{self.name}" + + class Meta: + verbose_name = "Repository" + verbose_name_plural = "Repositories" + db_table = "github_repositories" + ordering = ("-created_at",) + + +class GithubRepositorySync(ProjectBaseModel): + repository = models.OneToOneField( + "db.GithubRepository", on_delete=models.CASCADE, related_name="syncs" + ) + credentials = models.JSONField(default=dict) + # Bot user + actor = models.ForeignKey( + "db.User", related_name="user_syncs", on_delete=models.CASCADE + ) + workspace_integration = models.ForeignKey( + "db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE + ) + label = models.ForeignKey( + "db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs" + ) + + def __str__(self): + """Return the repo sync""" + return f"{self.repository.name} <{self.project.name}>" + + class Meta: + unique_together = ["project", "repository"] + verbose_name = "Github Repository Sync" + verbose_name_plural = "Github Repository Syncs" + db_table = "github_repository_syncs" + ordering = ("-created_at",) + + +class GithubIssueSync(ProjectBaseModel): + repo_issue_id = models.BigIntegerField() + github_issue_id = models.BigIntegerField() + issue_url = models.URLField(blank=False) + issue = models.ForeignKey( + "db.Issue", related_name="github_syncs", on_delete=models.CASCADE + ) + repository_sync = models.ForeignKey( + "db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE + ) + + def __str__(self): + """Return the github issue sync""" + return f"{self.repository.name}-{self.project.name}-{self.issue.name}" + + class Meta: + unique_together = ["repository_sync", "issue"] + verbose_name = "Github Issue Sync" + verbose_name_plural = "Github Issue Syncs" + db_table = "github_issue_syncs" + ordering = ("-created_at",) + + +class GithubCommentSync(ProjectBaseModel): + repo_comment_id = models.BigIntegerField() + comment = models.ForeignKey( + "db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE + ) + issue_sync = models.ForeignKey( + "db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE + ) + + def __str__(self): + """Return the github issue sync""" + return f"{self.comment.id}" + + class Meta: + unique_together = ["issue_sync", "comment"] + verbose_name = "Github Comment Sync" + verbose_name_plural = "Github Comment Syncs" + db_table = "github_comment_syncs" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index 2979362dc..aea41677e 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -187,7 +187,7 @@ class IssueLink(ProjectBaseModel): class IssueActivity(ProjectBaseModel): issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_activity" + Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" ) verb = models.CharField(max_length=255, verbose_name="Action", default="created") field = models.CharField( diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 3fa0fae5c..ccb388012 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -77,3 +77,4 @@ if DOCKERIZED: REDIS_URL = os.environ.get("REDIS_URL") WEB_URL = os.environ.get("WEB_URL", "localhost:3000") +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 0401a0f0e..1b6ac2cf7 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -209,3 +209,5 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") + +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 725f2cd85..0e58ab224 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -185,3 +185,5 @@ RQ_QUEUES = { WEB_URL = os.environ.get("WEB_URL") + +PROXY_BASE_URL = os.environ.get("PROXY_BASE_URL", False) diff --git a/apiserver/plane/utils/integrations/__init__.py b/apiserver/plane/utils/integrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/utils/integrations/github.py b/apiserver/plane/utils/integrations/github.py new file mode 100644 index 000000000..ba9cb0ae0 --- /dev/null +++ b/apiserver/plane/utils/integrations/github.py @@ -0,0 +1,62 @@ +import os +import jwt +import requests +from datetime import datetime, timedelta +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from cryptography.hazmat.backends import default_backend + + +def get_jwt_token(): + app_id = os.environ.get("GITHUB_APP_ID", "") + secret = bytes(os.environ.get("GITHUB_APP_PRIVATE_KEY", ""), encoding="utf8") + current_timestamp = int(datetime.now().timestamp()) + due_date = datetime.now() + timedelta(minutes=10) + expiry = int(due_date.timestamp()) + payload = { + "iss": app_id, + "sub": app_id, + "exp": expiry, + "iat": current_timestamp, + "aud": "https://github.com/login/oauth/access_token", + } + + priv_rsakey = load_pem_private_key(secret, None, default_backend()) + token = jwt.encode(payload, priv_rsakey, algorithm="RS256") + return token + + +def get_github_metadata(installation_id): + token = get_jwt_token() + + url = f"https://api.github.com/app/installations/{installation_id}" + headers = { + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + } + response = requests.get(url, headers=headers).json() + return response + + +def get_github_repos(access_tokens_url, repositories_url): + token = get_jwt_token() + + headers = { + "Authorization": "Bearer " + token, + "Accept": "application/vnd.github+json", + } + + oauth_response = requests.post( + access_tokens_url, + headers=headers, + ).json() + + oauth_token = oauth_response.get("token") + headers = { + "Authorization": "Bearer " + oauth_token, + "Accept": "application/vnd.github+json", + } + response = requests.get( + repositories_url, + headers=headers, + ).json() + return response diff --git a/app.json b/app.json index 017911920..7f6b27427 100644 --- a/app.json +++ b/app.json @@ -6,8 +6,16 @@ "website": "https://plane.so/", "success_url": "/", "stack": "heroku-22", - "keywords": ["plane", "project management", "django", "next"], - "addons": ["heroku-postgresql:mini", "heroku-redis:mini"], + "keywords": [ + "plane", + "project management", + "django", + "next" + ], + "addons": [ + "heroku-postgresql:mini", + "heroku-redis:mini" + ], "buildpacks": [ { "url": "https://github.com/heroku/heroku-buildpack-python.git" @@ -74,4 +82,4 @@ "value": "" } } -} +} \ No newline at end of file diff --git a/apps/app/components/popup/index.tsx b/apps/app/components/popup/index.tsx new file mode 100644 index 000000000..e97d39493 --- /dev/null +++ b/apps/app/components/popup/index.tsx @@ -0,0 +1,41 @@ +import { useRouter } from "next/router"; +import React, { useRef } from "react"; + +const OAuthPopUp = ({ workspaceSlug, integration }: any) => { + const popup = useRef(); + + const router = useRouter(); + + const checkPopup = () => { + const check = setInterval(() => { + if (!popup || popup.current.closed || popup.current.closed === undefined) { + clearInterval(check); + } + }, 1000); + }; + + const openPopup = () => { + const width = 600, + height = 600; + const left = window.innerWidth / 2 - width / 2; + const top = window.innerHeight / 2 - height / 2; + const url = `https://github.com/apps/${process.env.NEXT_PUBLIC_GITHUB_APP_NAME}/installations/new?state=${workspaceSlug}`; + + return window.open(url, "", `width=${width}, height=${height}, top=${top}, left=${left}`); + }; + + const startAuth = () => { + popup.current = openPopup(); + checkPopup(); + }; + + return ( + <> +
+ +
+ + ); +}; + +export default OAuthPopUp; diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 77df1bc97..e7360461d 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,8 +1,11 @@ export const CURRENT_USER = "CURRENT_USER"; export const USER_WORKSPACE_INVITATIONS = "USER_WORKSPACE_INVITATIONS"; export const USER_WORKSPACES = "USER_WORKSPACES"; +export const APP_INTEGRATIONS = "APP_INTEGRATIONS"; export const WORKSPACE_DETAILS = (workspaceSlug: string) => `WORKSPACE_DETAILS_${workspaceSlug}`; +export const WORKSPACE_INTEGRATIONS = (workspaceSlug: string) => + `WORKSPACE_INTEGRATIONS_${workspaceSlug}`; export const WORKSPACE_MEMBERS = (workspaceSlug: string) => `WORKSPACE_MEMBERS_${workspaceSlug}`; export const WORKSPACE_MEMBERS_ME = (workspaceSlug: string) => diff --git a/apps/app/layouts/app-layout/index.tsx b/apps/app/layouts/app-layout/index.tsx index fddc5f2f5..db76abf07 100644 --- a/apps/app/layouts/app-layout/index.tsx +++ b/apps/app/layouts/app-layout/index.tsx @@ -61,6 +61,10 @@ const workspaceLinks: (wSlug: string) => Array<{ label: "Billing & Plans", href: `/${workspaceSlug}/settings/billing`, }, + { + label: "Integrations", + href: `/${workspaceSlug}/settings/integrations`, + }, ]; const sidebarLinks: ( @@ -94,6 +98,10 @@ const sidebarLinks: ( label: "Labels", href: `/${workspaceSlug}/projects/${projectId}/settings/labels`, }, + { + label: "Integrations", + href: `/${workspaceSlug}/projects/${projectId}/settings/integrations`, + }, ]; const AppLayout: FC = ({ diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx new file mode 100644 index 000000000..4e472d7e5 --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; +import Image from "next/image"; + +import useSWR, { mutate } from "swr"; + +// lib +import { requiredAdmin } from "lib/auth"; +// layouts +import AppLayout from "layouts/app-layout"; +// services +import workspaceService from "services/workspace.service"; +import projectService from "services/project.service"; + +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// types +import { IProject, IWorkspace } from "types"; +import type { NextPageContext, NextPage } from "next"; +// fetch-keys +import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; + +type TProjectIntegrationsProps = { + isMember: boolean; + isOwner: boolean; + isViewer: boolean; + isGuest: boolean; +}; + +const defaultValues: Partial = { + project_lead: null, + default_assignee: null, +}; + +const ProjectIntegrations: NextPage = (props) => { + const { isMember, isOwner, isViewer, isGuest } = props; + const [userRepos, setUserRepos] = useState([]); + const [activeIntegrationId, setActiveIntegrationId] = useState(); + + const { + query: { workspaceSlug, projectId }, + } = useRouter(); + + const { data: projectDetails } = useSWR( + workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.getProject(workspaceSlug as string, projectId as string) + : null + ); + + const { data: integrations } = useSWR( + workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, + () => + workspaceSlug ? workspaceService.getWorkspaceIntegrations(workspaceSlug as string) : null + ); + const handleChange = (repo: any) => { + const { + html_url, + owner: { login }, + id, + name, + } = repo; + + projectService + .syncGiuthubRepository( + workspaceSlug as string, + projectId as string, + activeIntegrationId as any, + { name, owner: login, repository_id: id, url: html_url } + ) + .then((res) => { + console.log(res); + }) + .catch((err) => { + console.log(err); + }); + }; + console.log(userRepos); + return ( + + + + + } + > +
+ {integrations?.map((integration: any) => ( +
{ + setActiveIntegrationId(integration.id); + projectService + .getGithubRepositories(workspaceSlug as any, integration.id) + .then((response) => { + setUserRepos(response.repositories); + }) + .catch((err) => { + console.log(err); + }); + }} + > + {integration.integration_detail.provider} +
+ ))} + {userRepos.length > 0 && ( + + )} +
+
+ ); +}; + +export const getServerSideProps = async (ctx: NextPageContext) => { + const projectId = ctx.query.projectId as string; + const workspaceSlug = ctx.query.workspaceSlug as string; + + const memberDetail = await requiredAdmin(workspaceSlug, projectId, ctx.req?.headers.cookie); + + return { + props: { + isOwner: memberDetail?.role === 20, + isMember: memberDetail?.role === 15, + isViewer: memberDetail?.role === 10, + isGuest: memberDetail?.role === 5, + }, + }; +}; + +export default ProjectIntegrations; diff --git a/apps/app/pages/[workspaceSlug]/settings/integrations.tsx b/apps/app/pages/[workspaceSlug]/settings/integrations.tsx new file mode 100644 index 000000000..0757dfd52 --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/settings/integrations.tsx @@ -0,0 +1,93 @@ +import React from "react"; + +import { useRouter } from "next/router"; +import useSWR from "swr"; + +// lib +import type { NextPage, GetServerSideProps } from "next"; +import { requiredWorkspaceAdmin } from "lib/auth"; +// constants +// services +import workspaceService from "services/workspace.service"; +// layouts +import AppLayout from "layouts/app-layout"; +// ui +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +import { WORKSPACE_DETAILS, APP_INTEGRATIONS } from "constants/fetch-keys"; +import OAuthPopUp from "components/popup"; + +type TWorkspaceIntegrationsProps = { + isOwner: boolean; + isMember: boolean; + isViewer: boolean; + isGuest: boolean; +}; + +const WorkspaceIntegrations: NextPage = (props) => { + const { + query: { workspaceSlug }, + } = useRouter(); + + const { data: activeWorkspace } = useSWR( + workspaceSlug ? WORKSPACE_DETAILS(workspaceSlug as string) : null, + () => (workspaceSlug ? workspaceService.getWorkspace(workspaceSlug as string) : null) + ); + + const { data: integrations } = useSWR(workspaceSlug ? APP_INTEGRATIONS : null, () => + workspaceSlug ? workspaceService.getIntegrations() : null + ); + + return ( + <> + + + + + } + > +
+ {integrations?.map((integration: any) => ( + + ))} +
+
+ + ); +}; + +export const getServerSideProps: GetServerSideProps = async (ctx) => { + const workspaceSlug = ctx.params?.workspaceSlug as string; + + const memberDetail = await requiredWorkspaceAdmin(workspaceSlug, ctx.req.headers.cookie); + + if (memberDetail === null) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + return { + props: { + isOwner: memberDetail?.role === 20, + isMember: memberDetail?.role === 15, + isViewer: memberDetail?.role === 10, + isGuest: memberDetail?.role === 5, + }, + }; +}; + +export default WorkspaceIntegrations; diff --git a/apps/app/pages/installations/[provider]/index.tsx b/apps/app/pages/installations/[provider]/index.tsx new file mode 100644 index 000000000..85effe46b --- /dev/null +++ b/apps/app/pages/installations/[provider]/index.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from "react"; +import appinstallationsService from "services/appinstallations.service"; + +interface IGithuPostInstallationProps { + installation_id: string; + setup_action: string; + state: string; + provider: string; +} + +const AppPostInstallation = ({ + installation_id, + setup_action, + state, + provider, +}: IGithuPostInstallationProps) => { + useEffect(() => { + if (state && installation_id) { + appinstallationsService + .addGithubApp(state, provider, { installation_id }) + .then((res) => { + window.opener = null; + window.open("", "_self"); + window.close(); + }) + .catch((err) => { + console.log(err); + }); + } + }, [state, installation_id, provider]); + return <>Loading...; +}; + +export async function getServerSideProps(context: any) { + console.log(context.query); + return { + props: context.query, + }; +} + +export default AppPostInstallation; diff --git a/apps/app/services/appinstallations.service.ts b/apps/app/services/appinstallations.service.ts new file mode 100644 index 000000000..3ceae3b1a --- /dev/null +++ b/apps/app/services/appinstallations.service.ts @@ -0,0 +1,20 @@ +// services +import APIService from "services/api.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +class AppInstallationsService extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async addGithubApp(workspaceSlug: string, provider: string, data: any): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/workspace-integrations/${provider}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } +} + +export default new AppInstallationsService(); diff --git a/apps/app/services/project.service.ts b/apps/app/services/project.service.ts index c67f8144a..d2f3aa193 100644 --- a/apps/app/services/project.service.ts +++ b/apps/app/services/project.service.ts @@ -201,6 +201,37 @@ class ProjectServices extends APIService { throw error?.response?.data; }); } + + async getGithubRepositories(slug: string, workspaceIntegrationId: string): Promise { + return this.get( + `/api/workspaces/${slug}/workspace-integrations/${workspaceIntegrationId}/github-repositories/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async syncGiuthubRepository( + slug: string, + projectId: string, + workspaceIntegrationId: string, + data: { + name: string; + owner: string; + repository_id: string; + url: string; + } + ): Promise { + return this.post( + `/api/workspaces/${slug}/projects/${projectId}/workspace-integrations/${workspaceIntegrationId}/github-repository-sync/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new ProjectServices(); diff --git a/apps/app/services/workspace.service.ts b/apps/app/services/workspace.service.ts index 034104242..cf3f6d3e9 100644 --- a/apps/app/services/workspace.service.ts +++ b/apps/app/services/workspace.service.ts @@ -169,6 +169,20 @@ class WorkspaceService extends APIService { throw error?.response?.data; }); } + async getIntegrations(): Promise { + return this.get(`/api/integrations/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getWorkspaceIntegrations(slug: string): Promise { + return this.get(`/api/workspaces/${slug}/workspace-integrations/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new WorkspaceService(); diff --git a/turbo.json b/turbo.json index 56b0d8219..98de1bca0 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,7 @@ "NEXT_PUBLIC_SENTRY_DSN", "SENTRY_AUTH_TOKEN", "NEXT_PUBLIC_SENTRY_ENVIRONMENT", + "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH" ],