ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: source-side принятие и возврат внешнего запроса
This commit is contained in:
parent
3fe3539614
commit
8195c3fc80
|
|
@ -147,11 +147,15 @@
|
||||||
- в карточке отображается блок маршрутизации с ключевой source-target связью
|
- в карточке отображается блок маршрутизации с ключевой source-target связью
|
||||||
- текущий статус берется из фактического state целевой задачи
|
- текущий статус берется из фактического state целевой задачи
|
||||||
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
|
- если у инициатора нет membership в target project, карточка переключается в source-side readonly режим без прямого открытия чужого проекта
|
||||||
|
- для закрытого внешнего запроса доступны source-side действия `Принять` и `Отклонить`
|
||||||
|
- `Принять` фиксирует решение источника в bridge metadata и помечает запрос как принятый во внутренний контур
|
||||||
|
- `Отклонить` возвращает target issue в default-state целевого проекта и переносит source-side карточку обратно в список `Открытые`
|
||||||
|
|
||||||
Что остается:
|
Что остается:
|
||||||
- зеркалирование комментариев
|
- зеркалирование комментариев
|
||||||
- зеркалирование файлов
|
- зеркалирование файлов
|
||||||
- зеркалирование activity stream и обновлений описания
|
- зеркалирование activity stream и обновлений описания
|
||||||
|
- комментарий причины отклонения и ответ обратно во внешний контур
|
||||||
|
|
||||||
## Этап 4. Уведомления
|
## Этап 4. Уведомления
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ from .intake import (
|
||||||
)
|
)
|
||||||
from .external_contours import (
|
from .external_contours import (
|
||||||
ExternalContourRequestCreateSerializer,
|
ExternalContourRequestCreateSerializer,
|
||||||
|
ExternalContourRequestDecisionSerializer,
|
||||||
ExternalContourRequestSerializer,
|
ExternalContourRequestSerializer,
|
||||||
ExternalContourTargetOptionsSerializer,
|
ExternalContourTargetOptionsSerializer,
|
||||||
ExternalContourTargetProjectSerializer,
|
ExternalContourTargetProjectSerializer,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
|
||||||
issue = ExternalContourIssuePayloadSerializer()
|
issue = ExternalContourIssuePayloadSerializer()
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
|
||||||
|
action = serializers.ChoiceField(choices=["accept", "decline"])
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourTargetProjectSerializer(BaseSerializer):
|
class ExternalContourTargetProjectSerializer(BaseSerializer):
|
||||||
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
|
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
|
||||||
|
|
||||||
|
|
@ -95,6 +99,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
issue = ExternalContourIssueSerializer(read_only=True)
|
issue = ExternalContourIssueSerializer(read_only=True)
|
||||||
source_project_id = serializers.SerializerMethodField()
|
source_project_id = serializers.SerializerMethodField()
|
||||||
source_project_name = serializers.SerializerMethodField()
|
source_project_name = serializers.SerializerMethodField()
|
||||||
|
source_decision = serializers.SerializerMethodField()
|
||||||
|
source_decision_at = serializers.SerializerMethodField()
|
||||||
|
source_decision_by_name = serializers.SerializerMethodField()
|
||||||
target_project_id = serializers.SerializerMethodField()
|
target_project_id = serializers.SerializerMethodField()
|
||||||
target_project_name = serializers.SerializerMethodField()
|
target_project_name = serializers.SerializerMethodField()
|
||||||
requested_by_id = serializers.SerializerMethodField()
|
requested_by_id = serializers.SerializerMethodField()
|
||||||
|
|
@ -112,6 +119,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
"issue",
|
"issue",
|
||||||
"source_project_id",
|
"source_project_id",
|
||||||
"source_project_name",
|
"source_project_name",
|
||||||
|
"source_decision",
|
||||||
|
"source_decision_at",
|
||||||
|
"source_decision_by_name",
|
||||||
"target_project_id",
|
"target_project_id",
|
||||||
"target_project_name",
|
"target_project_name",
|
||||||
"requested_by_id",
|
"requested_by_id",
|
||||||
|
|
@ -127,6 +137,15 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
||||||
def get_source_project_name(self, obj):
|
def get_source_project_name(self, obj):
|
||||||
return obj.extra.get("source_project_name")
|
return obj.extra.get("source_project_name")
|
||||||
|
|
||||||
|
def get_source_decision(self, obj):
|
||||||
|
return obj.extra.get("source_decision")
|
||||||
|
|
||||||
|
def get_source_decision_at(self, obj):
|
||||||
|
return obj.extra.get("source_decision_at")
|
||||||
|
|
||||||
|
def get_source_decision_by_name(self, obj):
|
||||||
|
return obj.extra.get("source_decision_by_name")
|
||||||
|
|
||||||
def get_target_project_id(self, obj):
|
def get_target_project_id(self, obj):
|
||||||
target_project_id = obj.extra.get("target_project_id")
|
target_project_id = obj.extra.get("target_project_id")
|
||||||
if target_project_id:
|
if target_project_id:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from django.urls import path
|
||||||
|
|
||||||
from plane.api.views import (
|
from plane.api.views import (
|
||||||
ExternalContourDetailAPIEndpoint,
|
ExternalContourDetailAPIEndpoint,
|
||||||
|
ExternalContourDecisionAPIEndpoint,
|
||||||
ExternalContourListCreateAPIEndpoint,
|
ExternalContourListCreateAPIEndpoint,
|
||||||
ExternalContourTargetOptionsAPIEndpoint,
|
ExternalContourTargetOptionsAPIEndpoint,
|
||||||
ExternalContourTargetProjectListAPIEndpoint,
|
ExternalContourTargetProjectListAPIEndpoint,
|
||||||
|
|
@ -32,4 +33,9 @@ urlpatterns = [
|
||||||
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
|
||||||
name="external-contour-detail",
|
name="external-contour-detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
|
||||||
|
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
|
||||||
|
name="external-contour-decision",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -56,10 +56,11 @@ from .intake import (
|
||||||
IntakeIssueDetailAPIEndpoint,
|
IntakeIssueDetailAPIEndpoint,
|
||||||
)
|
)
|
||||||
from .external_contours import (
|
from .external_contours import (
|
||||||
ExternalContourListCreateAPIEndpoint,
|
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
|
||||||
ExternalContourDetailAPIEndpoint,
|
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
|
||||||
ExternalContourTargetProjectListAPIEndpoint,
|
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
|
||||||
ExternalContourTargetOptionsAPIEndpoint,
|
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
|
||||||
|
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,40 @@
|
||||||
# See the LICENSE file for details.
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.api.serializers import (
|
from plane.api.serializers import (
|
||||||
ExternalContourRequestCreateSerializer,
|
ExternalContourRequestCreateSerializer,
|
||||||
|
ExternalContourRequestDecisionSerializer,
|
||||||
ExternalContourRequestSerializer,
|
ExternalContourRequestSerializer,
|
||||||
ExternalContourTargetOptionsSerializer,
|
ExternalContourTargetOptionsSerializer,
|
||||||
ExternalContourTargetProjectSerializer,
|
ExternalContourTargetProjectSerializer,
|
||||||
)
|
)
|
||||||
from plane.app.permissions import ProjectLitePermission
|
|
||||||
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
|
||||||
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
|
||||||
|
from plane.app.permissions import ProjectLitePermission
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
|
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
|
||||||
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
from plane.db.models.intake import IntakeIssueStatus, SourceType
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourListCreateAPIEndpoint(BaseAPIView):
|
class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
permission_classes = [ProjectLitePermission]
|
permission_classes = [ProjectLitePermission]
|
||||||
serializer_class = ExternalContourRequestSerializer
|
serializer_class = ExternalContourRequestSerializer
|
||||||
|
|
||||||
def get_source_project(self, slug, project_id):
|
def get_source_project(self, slug, project_id):
|
||||||
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
|
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
|
||||||
|
|
||||||
|
def get_default_target_state(self, target_project):
|
||||||
|
return (
|
||||||
|
State.objects.filter(project=target_project, default=True)
|
||||||
|
.exclude(group=StateGroup.TRIAGE.value)
|
||||||
|
.first()
|
||||||
|
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
|
||||||
|
"sequence", "created_at"
|
||||||
|
).first()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
IntakeIssue.objects.filter(
|
IntakeIssue.objects.filter(
|
||||||
|
|
@ -93,11 +104,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_default_state = (
|
target_default_state = self.get_default_target_state(target_project)
|
||||||
State.objects.filter(project=target_project, default=True)
|
|
||||||
.exclude(group=StateGroup.TRIAGE.value)
|
|
||||||
.first()
|
|
||||||
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by("sequence", "created_at").first()
|
|
||||||
|
|
||||||
if not target_default_state:
|
if not target_default_state:
|
||||||
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
@ -166,7 +173,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
|
||||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView):
|
class ExternalContourTargetProjectListEndpoint(BaseAPIView):
|
||||||
permission_classes = [ProjectLitePermission]
|
permission_classes = [ProjectLitePermission]
|
||||||
serializer_class = ExternalContourTargetProjectSerializer
|
serializer_class = ExternalContourTargetProjectSerializer
|
||||||
|
|
||||||
|
|
@ -190,7 +197,7 @@ class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView):
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView):
|
class ExternalContourTargetOptionsEndpoint(BaseAPIView):
|
||||||
permission_classes = [ProjectLitePermission]
|
permission_classes = [ProjectLitePermission]
|
||||||
serializer_class = ExternalContourTargetOptionsSerializer
|
serializer_class = ExternalContourTargetOptionsSerializer
|
||||||
|
|
||||||
|
|
@ -239,7 +246,7 @@ class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView):
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class ExternalContourDetailAPIEndpoint(BaseAPIView):
|
class ExternalContourDetailEndpoint(BaseAPIView):
|
||||||
permission_classes = [ProjectLitePermission]
|
permission_classes = [ProjectLitePermission]
|
||||||
serializer_class = ExternalContourRequestSerializer
|
serializer_class = ExternalContourRequestSerializer
|
||||||
|
|
||||||
|
|
@ -264,3 +271,84 @@ class ExternalContourDetailAPIEndpoint(BaseAPIView):
|
||||||
contour_request = get_object_or_404(self.get_queryset())
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
serializer = ExternalContourRequestSerializer(contour_request)
|
serializer = ExternalContourRequestSerializer(contour_request)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourDecisionEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [ProjectLitePermission]
|
||||||
|
serializer_class = ExternalContourRequestSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return (
|
||||||
|
IntakeIssue.objects.filter(
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
extra__bridge="external-contours",
|
||||||
|
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||||
|
pk=self.kwargs.get("request_id"),
|
||||||
|
)
|
||||||
|
.select_related(
|
||||||
|
"issue",
|
||||||
|
"issue__state",
|
||||||
|
"issue__project",
|
||||||
|
"issue__created_by",
|
||||||
|
)
|
||||||
|
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, request_id):
|
||||||
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
|
serializer = ExternalContourRequestDecisionSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
action = serializer.validated_data["action"]
|
||||||
|
issue = contour_request.issue
|
||||||
|
|
||||||
|
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
|
||||||
|
return Response(
|
||||||
|
{"error": "Source decision is available only after the target contour finishes processing"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "accept":
|
||||||
|
contour_request.extra = {
|
||||||
|
**contour_request.extra,
|
||||||
|
"source_decision": "accepted",
|
||||||
|
"source_decision_at": timezone.now().isoformat(),
|
||||||
|
"source_decision_by_name": request.user.display_name,
|
||||||
|
}
|
||||||
|
contour_request.save(update_fields=["extra", "updated_at"])
|
||||||
|
else:
|
||||||
|
target_default_state = (
|
||||||
|
State.objects.filter(project=issue.project, default=True)
|
||||||
|
.exclude(group=StateGroup.TRIAGE.value)
|
||||||
|
.first()
|
||||||
|
) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
|
||||||
|
"sequence", "created_at"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not target_default_state:
|
||||||
|
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
issue.state = target_default_state
|
||||||
|
issue.save(update_fields=["state", "updated_at"])
|
||||||
|
|
||||||
|
extra = dict(contour_request.extra or {})
|
||||||
|
extra.pop("source_decision", None)
|
||||||
|
extra.pop("source_decision_at", None)
|
||||||
|
extra.pop("source_decision_by_name", None)
|
||||||
|
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
|
||||||
|
extra["last_reopened_by_name"] = request.user.display_name
|
||||||
|
contour_request.extra = extra
|
||||||
|
contour_request.save(update_fields=["extra", "updated_at"])
|
||||||
|
|
||||||
|
contour_request.refresh_from_db()
|
||||||
|
serializer = ExternalContourRequestSerializer(
|
||||||
|
IntakeIssue.objects.select_related(
|
||||||
|
"issue",
|
||||||
|
"issue__state",
|
||||||
|
"issue__project",
|
||||||
|
"issue__created_by",
|
||||||
|
)
|
||||||
|
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||||
|
.get(pk=contour_request.id)
|
||||||
|
)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from django.urls import path
|
||||||
|
|
||||||
from plane.app.views import (
|
from plane.app.views import (
|
||||||
ExternalContourDetailEndpoint,
|
ExternalContourDetailEndpoint,
|
||||||
|
ExternalContourDecisionEndpoint,
|
||||||
ExternalContourListCreateEndpoint,
|
ExternalContourListCreateEndpoint,
|
||||||
ExternalContourTargetOptionsEndpoint,
|
ExternalContourTargetOptionsEndpoint,
|
||||||
ExternalContourTargetProjectListEndpoint,
|
ExternalContourTargetProjectListEndpoint,
|
||||||
|
|
@ -33,4 +34,9 @@ urlpatterns = [
|
||||||
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
|
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
|
||||||
name="external-contour-detail",
|
name="external-contour-detail",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
|
||||||
|
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
|
||||||
|
name="external-contour-decision",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,7 @@ from .exporter.base import ExportIssuesEndpoint
|
||||||
from .external_contours import (
|
from .external_contours import (
|
||||||
ExternalContourListCreateEndpoint,
|
ExternalContourListCreateEndpoint,
|
||||||
ExternalContourDetailEndpoint,
|
ExternalContourDetailEndpoint,
|
||||||
|
ExternalContourDecisionEndpoint,
|
||||||
ExternalContourTargetProjectListEndpoint,
|
ExternalContourTargetProjectListEndpoint,
|
||||||
ExternalContourTargetOptionsEndpoint,
|
ExternalContourTargetOptionsEndpoint,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
# See the LICENSE file for details.
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from plane.api.serializers import (
|
from plane.api.serializers import (
|
||||||
ExternalContourRequestCreateSerializer,
|
ExternalContourRequestCreateSerializer,
|
||||||
|
ExternalContourRequestDecisionSerializer,
|
||||||
ExternalContourRequestSerializer,
|
ExternalContourRequestSerializer,
|
||||||
ExternalContourTargetOptionsSerializer,
|
ExternalContourTargetOptionsSerializer,
|
||||||
ExternalContourTargetProjectSerializer,
|
ExternalContourTargetProjectSerializer,
|
||||||
|
|
@ -26,6 +28,15 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
def get_source_project(self, slug, project_id):
|
def get_source_project(self, slug, project_id):
|
||||||
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
|
return get_object_or_404(Project, workspace__slug=slug, pk=project_id)
|
||||||
|
|
||||||
|
def get_default_target_state(self, target_project):
|
||||||
|
return (
|
||||||
|
State.objects.filter(project=target_project, default=True)
|
||||||
|
.exclude(group=StateGroup.TRIAGE.value)
|
||||||
|
.first()
|
||||||
|
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
|
||||||
|
"sequence", "created_at"
|
||||||
|
).first()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
IntakeIssue.objects.filter(
|
IntakeIssue.objects.filter(
|
||||||
|
|
@ -93,11 +104,7 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
target_default_state = (
|
target_default_state = self.get_default_target_state(target_project)
|
||||||
State.objects.filter(project=target_project, default=True)
|
|
||||||
.exclude(group=StateGroup.TRIAGE.value)
|
|
||||||
.first()
|
|
||||||
) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by("sequence", "created_at").first()
|
|
||||||
|
|
||||||
if not target_default_state:
|
if not target_default_state:
|
||||||
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
@ -264,3 +271,84 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
||||||
contour_request = get_object_or_404(self.get_queryset())
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
serializer = ExternalContourRequestSerializer(contour_request)
|
serializer = ExternalContourRequestSerializer(contour_request)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalContourDecisionEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [ProjectLitePermission]
|
||||||
|
serializer_class = ExternalContourRequestSerializer
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return (
|
||||||
|
IntakeIssue.objects.filter(
|
||||||
|
workspace__slug=self.kwargs.get("slug"),
|
||||||
|
extra__bridge="external-contours",
|
||||||
|
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||||
|
pk=self.kwargs.get("request_id"),
|
||||||
|
)
|
||||||
|
.select_related(
|
||||||
|
"issue",
|
||||||
|
"issue__state",
|
||||||
|
"issue__project",
|
||||||
|
"issue__created_by",
|
||||||
|
)
|
||||||
|
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request, slug, project_id, request_id):
|
||||||
|
contour_request = get_object_or_404(self.get_queryset())
|
||||||
|
serializer = ExternalContourRequestDecisionSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
action = serializer.validated_data["action"]
|
||||||
|
issue = contour_request.issue
|
||||||
|
|
||||||
|
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
|
||||||
|
return Response(
|
||||||
|
{"error": "Source decision is available only after the target contour finishes processing"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if action == "accept":
|
||||||
|
contour_request.extra = {
|
||||||
|
**contour_request.extra,
|
||||||
|
"source_decision": "accepted",
|
||||||
|
"source_decision_at": timezone.now().isoformat(),
|
||||||
|
"source_decision_by_name": request.user.display_name,
|
||||||
|
}
|
||||||
|
contour_request.save(update_fields=["extra", "updated_at"])
|
||||||
|
else:
|
||||||
|
target_default_state = (
|
||||||
|
State.objects.filter(project=issue.project, default=True)
|
||||||
|
.exclude(group=StateGroup.TRIAGE.value)
|
||||||
|
.first()
|
||||||
|
) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
|
||||||
|
"sequence", "created_at"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not target_default_state:
|
||||||
|
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
issue.state = target_default_state
|
||||||
|
issue.save(update_fields=["state", "updated_at"])
|
||||||
|
|
||||||
|
extra = dict(contour_request.extra or {})
|
||||||
|
extra.pop("source_decision", None)
|
||||||
|
extra.pop("source_decision_at", None)
|
||||||
|
extra.pop("source_decision_by_name", None)
|
||||||
|
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
|
||||||
|
extra["last_reopened_by_name"] = request.user.display_name
|
||||||
|
contour_request.extra = extra
|
||||||
|
contour_request.save(update_fields=["extra", "updated_at"])
|
||||||
|
|
||||||
|
contour_request.refresh_from_db()
|
||||||
|
serializer = ExternalContourRequestSerializer(
|
||||||
|
IntakeIssue.objects.select_related(
|
||||||
|
"issue",
|
||||||
|
"issue__state",
|
||||||
|
"issue__project",
|
||||||
|
"issue__created_by",
|
||||||
|
)
|
||||||
|
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||||
|
.get(pk=contour_request.id)
|
||||||
|
)
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ import { useCallback, useEffect } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { PanelLeft } from "lucide-react";
|
import { PanelLeft } from "lucide-react";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
|
import { Badge } from "@plane/propel/badge";
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { IconButton } from "@plane/propel/icon-button";
|
import { IconButton } from "@plane/propel/icon-button";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
|
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
|
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
|
||||||
|
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||||
import { ControlLink, Header, Row } from "@plane/ui";
|
import { ControlLink, Header, Row } from "@plane/ui";
|
||||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||||
|
|
@ -43,11 +45,13 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useAppRouter();
|
const router = useAppRouter();
|
||||||
const { currentTab, filteredRequestIds } = useProjectExternalContours();
|
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab } = useProjectExternalContours();
|
||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
|
|
||||||
const issue = contourRequest.issue;
|
const issue = contourRequest.issue;
|
||||||
const currentRequestId = contourRequest.id;
|
const currentRequestId = contourRequest.id;
|
||||||
|
const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted";
|
||||||
|
const isSourceAccepted = contourRequest.source_decision === "accepted";
|
||||||
|
|
||||||
const redirectToRelativeIssue = useCallback(
|
const redirectToRelativeIssue = useCallback(
|
||||||
(direction: "next" | "prev") => {
|
(direction: "next" | "prev") => {
|
||||||
|
|
@ -92,6 +96,33 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDecision = async (action: "accept" | "decline") => {
|
||||||
|
try {
|
||||||
|
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action);
|
||||||
|
if (action === "decline") {
|
||||||
|
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
|
||||||
|
router.push(
|
||||||
|
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
title: t("success"),
|
||||||
|
message: t(
|
||||||
|
action === "accept"
|
||||||
|
? "external_contours_page.actions.accept_success"
|
||||||
|
: "external_contours_page.actions.decline_success"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
setToast({
|
||||||
|
type: TOAST_TYPE.ERROR,
|
||||||
|
title: t("error"),
|
||||||
|
message: error?.error || t("external_contours_page.actions.decision_error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
|
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 border-b border-subtle bg-surface-1 lg:flex">
|
||||||
|
|
@ -114,6 +145,23 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{canReviewClosedRequest && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="lg" onClick={() => handleDecision("accept")}>
|
||||||
|
<CheckCircleFilledIcon className="size-4 shrink-0 text-success-secondary" />
|
||||||
|
{t("external_contours_page.actions.accept")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="lg" onClick={() => handleDecision("decline")}>
|
||||||
|
<CloseCircleFilledIcon className="size-4 shrink-0 text-danger-secondary" />
|
||||||
|
{t("external_contours_page.actions.decline")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSourceAccepted && (
|
||||||
|
<Badge variant="neutral">{t("external_contours_page.traceability.source_decision_accepted")}</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
|
<Button variant="secondary" size="lg" prependIcon={<LinkIcon className="h-2.5 w-2.5" />} onClick={handleCopyLink}>
|
||||||
{t("external_contours_page.actions.copy")}
|
{t("external_contours_page.actions.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -136,6 +184,19 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
||||||
<div className="flex w-full items-center gap-2">
|
<div className="flex w-full items-center gap-2">
|
||||||
<ExternalContourStatePill request={contourRequest} />
|
<ExternalContourStatePill request={contourRequest} />
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{canReviewClosedRequest && (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleDecision("accept")}>
|
||||||
|
{t("external_contours_page.actions.accept")}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleDecision("decline")}>
|
||||||
|
{t("external_contours_page.actions.decline")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isSourceAccepted && (
|
||||||
|
<Badge variant="neutral">{t("external_contours_page.traceability.source_decision_accepted")}</Badge>
|
||||||
|
)}
|
||||||
{hasDirectTargetAccess && (
|
{hasDirectTargetAccess && (
|
||||||
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
|
||||||
const requestedAt = contourRequest.requested_at || contourRequest.created_at;
|
const requestedAt = contourRequest.requested_at || contourRequest.created_at;
|
||||||
const lastUpdatedAt = issue.updated_at || contourRequest.updated_at;
|
const lastUpdatedAt = issue.updated_at || contourRequest.updated_at;
|
||||||
const targetProjectName = contourRequest.target_project_name || issue.project_detail?.name || t("common.none");
|
const targetProjectName = contourRequest.target_project_name || issue.project_detail?.name || t("common.none");
|
||||||
|
const sourceDecision =
|
||||||
|
contourRequest.source_decision === "accepted"
|
||||||
|
? t("external_contours_page.traceability.source_decision_accepted")
|
||||||
|
: t("external_contours_page.traceability.source_decision_pending");
|
||||||
const targetIssueKey =
|
const targetIssueKey =
|
||||||
issue.project_detail?.identifier && issue.sequence_id
|
issue.project_detail?.identifier && issue.sequence_id
|
||||||
? `${issue.project_detail.identifier}-${issue.sequence_id}`
|
? `${issue.project_detail.identifier}-${issue.sequence_id}`
|
||||||
|
|
@ -61,6 +65,11 @@ export const ExternalContoursRequestTraceability = observer(function ExternalCon
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-subtle bg-surface-1 p-3">
|
||||||
|
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.source_decision")}</div>
|
||||||
|
<div className="mt-1 text-13 font-medium text-secondary">{sourceDecision}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-subtle bg-surface-1 p-3">
|
<div className="rounded-md border border-subtle bg-surface-1 p-3">
|
||||||
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div>
|
<div className="text-11 font-medium text-tertiary">{t("external_contours_page.traceability.requested_by")}</div>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
|
||||||
fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN);
|
fetchRequests(workspaceSlug.toString(), projectId.toString(), navigationTab || EInboxIssueCurrentTab.OPEN);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [workspaceSlug, projectId]);
|
}, [workspaceSlug, projectId, navigationTab]);
|
||||||
|
|
||||||
if (loader === "init-loading") {
|
if (loader === "init-loading") {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -76,4 +76,19 @@ export class ExternalContourService extends APIService {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async decide(
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
requestId: string,
|
||||||
|
action: "accept" | "decline"
|
||||||
|
): Promise<TExternalContourRequest> {
|
||||||
|
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/decision/`, {
|
||||||
|
action,
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ export interface IProjectExternalContoursStore {
|
||||||
projectId: string,
|
projectId: string,
|
||||||
data: Partial<TIssue> & { target_project_id?: string | null }
|
data: Partial<TIssue> & { target_project_id?: string | null }
|
||||||
) => Promise<TExternalContourRequest | undefined>;
|
) => Promise<TExternalContourRequest | undefined>;
|
||||||
|
decideRequest: (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
requestId: string,
|
||||||
|
action: "accept" | "decline"
|
||||||
|
) => Promise<TExternalContourRequest | undefined>;
|
||||||
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
|
fetchTargetProjects: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||||
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
|
fetchTargetOptions: (workspaceSlug: string, projectId: string, targetProjectId: string) => Promise<TExternalContourTargetOptions | undefined>;
|
||||||
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
|
fetchRequestById: (workspaceSlug: string, projectId: string, requestId: string) => Promise<TExternalContourRequest | undefined>;
|
||||||
|
|
@ -82,6 +88,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
||||||
fetchRequests: action,
|
fetchRequests: action,
|
||||||
fetchRequestById: action,
|
fetchRequestById: action,
|
||||||
createRequest: action,
|
createRequest: action,
|
||||||
|
decideRequest: action,
|
||||||
handleCurrentTab: action,
|
handleCurrentTab: action,
|
||||||
upsertRequests: action,
|
upsertRequests: action,
|
||||||
updateRequestIssue: action,
|
updateRequestIssue: action,
|
||||||
|
|
@ -220,6 +227,28 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
decideRequest = async (
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
requestId: string,
|
||||||
|
action: "accept" | "decline"
|
||||||
|
) => {
|
||||||
|
this.loader = "mutation-loading";
|
||||||
|
try {
|
||||||
|
const request = await this.externalContourService.decide(workspaceSlug, projectId, requestId, action);
|
||||||
|
runInAction(() => {
|
||||||
|
this.upsertRequests([request]);
|
||||||
|
this.loader = undefined;
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
} catch (error) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.loader = undefined;
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
updateRequestIssue = (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => {
|
updateRequestIssue = (requestId: string, issueData: Partial<TExternalContourRequest["issue"]>) => {
|
||||||
if (!this.requests[requestId]) return;
|
if (!this.requests[requestId]) return;
|
||||||
const nextStatus =
|
const nextStatus =
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,9 @@ export default {
|
||||||
description:
|
description:
|
||||||
"This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.",
|
"This block shows which contour sent the request, where it was routed, and which linked work item now carries the execution.",
|
||||||
source_contour: "Source internal contour",
|
source_contour: "Source internal contour",
|
||||||
|
source_decision: "Source-side decision",
|
||||||
|
source_decision_pending: "Awaiting decision",
|
||||||
|
source_decision_accepted: "Accepted into the internal contour",
|
||||||
target_contour: "Target external contour",
|
target_contour: "Target external contour",
|
||||||
status: "Current status",
|
status: "Current status",
|
||||||
requested_by: "Requested by",
|
requested_by: "Requested by",
|
||||||
|
|
@ -353,6 +356,9 @@ export default {
|
||||||
send: "Send",
|
send: "Send",
|
||||||
accept: "Accept",
|
accept: "Accept",
|
||||||
decline: "Decline",
|
decline: "Decline",
|
||||||
|
accept_success: "The external contour result has been accepted.",
|
||||||
|
decline_success: "The request has been returned to the external contour for rework.",
|
||||||
|
decision_error: "The external contour action could not be completed.",
|
||||||
copy: "Copy link",
|
copy: "Copy link",
|
||||||
open: "Open request",
|
open: "Open request",
|
||||||
unsupported_title: "This action will be connected in the next stage",
|
unsupported_title: "This action will be connected in the next stage",
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,9 @@ export default {
|
||||||
title: "Маршрутизация",
|
title: "Маршрутизация",
|
||||||
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
|
description: "Здесь отображается, из какого контура ушёл запрос, куда он направлен и с каким связанным элементом он сейчас работает.",
|
||||||
source_contour: "Исходный внутренний контур",
|
source_contour: "Исходный внутренний контур",
|
||||||
|
source_decision: "Решение источника",
|
||||||
|
source_decision_pending: "Ожидает решения",
|
||||||
|
source_decision_accepted: "Принято во внутренний контур",
|
||||||
target_contour: "Целевой внешний контур",
|
target_contour: "Целевой внешний контур",
|
||||||
status: "Текущий статус",
|
status: "Текущий статус",
|
||||||
requested_by: "Отправитель",
|
requested_by: "Отправитель",
|
||||||
|
|
@ -509,6 +512,9 @@ export default {
|
||||||
send: "Отправить",
|
send: "Отправить",
|
||||||
accept: "Принять",
|
accept: "Принять",
|
||||||
decline: "Отклонить",
|
decline: "Отклонить",
|
||||||
|
accept_success: "Результат внешнего контура принят.",
|
||||||
|
decline_success: "Запрос возвращён во внешний контур на доработку.",
|
||||||
|
decision_error: "Не удалось выполнить действие по внешнему запросу.",
|
||||||
copy: "Копировать ссылку",
|
copy: "Копировать ссылку",
|
||||||
open: "Открыть запрос",
|
open: "Открыть запрос",
|
||||||
unsupported_title: "Действие будет подключено следующим этапом",
|
unsupported_title: "Действие будет подключено следующим этапом",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ export type TExternalContourRequest = {
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
issue: TExternalContourIssue;
|
issue: TExternalContourIssue;
|
||||||
|
source_decision?: "accepted" | null;
|
||||||
|
source_decision_at?: string | null;
|
||||||
|
source_decision_by_name?: string | null;
|
||||||
source_project_id: string;
|
source_project_id: string;
|
||||||
source_project_name?: string | null;
|
source_project_name?: string | null;
|
||||||
target_project_id?: string | null;
|
target_project_id?: string | null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue