diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index bc6372593a..f281f740bd 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -126,6 +126,11 @@ EE_JOBS = { "task": "plane.event_stream.bgtasks.outbox_cleaner.delete_outbox_records", "schedule": crontab(hour=0, minute=30), # UTC 00:30 }, + # Cycles maintenance + "maintain-future-cycles": { + "task": "plane.ee.bgtasks.cycle_automation_task.maintain_future_cycles", + "schedule": crontab(minute="*/2"), # Every 2 minutes + }, } diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index e8cbf3d9c6..7e4150dee9 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -47,6 +47,7 @@ class BotTypeEnum(models.TextChoices): INTAKE_BOT = "INTAKE_BOT", "Intake Bot" APP_BOT = "APP_BOT", "App Bot" AUTOMATION_BOT = "AUTOMATION_BOT", "Automation Bot" + CYCLE_AUTOMATION_BOT = "CYCLE_AUTOMATION_BOT", "Cycle Automation Bot" class User(AbstractBaseUser, PermissionsMixin): diff --git a/apps/api/plane/ee/bgtasks/cycle_automation_task.py b/apps/api/plane/ee/bgtasks/cycle_automation_task.py new file mode 100644 index 0000000000..9f86e29766 --- /dev/null +++ b/apps/api/plane/ee/bgtasks/cycle_automation_task.py @@ -0,0 +1,372 @@ +from __future__ import annotations +import json +import pytz + +# Python imports +from datetime import timedelta + +# Django imports +from django.utils import timezone +from django.db.models import Subquery + +# Third Party imports +from celery import shared_task +from django.db.models import Count, Q, When, Case, Value, FloatField, Sum, F +from django.db.models.functions import Cast, Concat +from django.db import models, transaction + +# Module imports +from plane.ee.models import CycleSettings, ProjectFeature, AutomatedCycleLog +from plane.db.models import Cycle, Project, Issue, CycleIssue, Workspace +from plane.utils.analytics_plot import burndown_plot +from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.webhook_task import model_activity +from plane.ee.bgtasks.entity_issue_state_progress_task import ( + entity_issue_state_activity_task, +) +from plane.utils.host import base_host +from plane.db.models import BotTypeEnum, ProjectMember +from plane.utils.exception_logger import log_exception +from plane.utils.cycle_transfer_issues import transfer_cycle_issues +from plane.utils.timezone_converter import convert_to_utc + + +@shared_task +def schedule_cycle(automated_cycle_id: str, project_id: str, bot_id: str): + try: + """ + Schedule a cycle for a project based on the CycleSettings configuration. + + - Creates future cycles using the duration and cooldown period. + - Skips cycles that overlap with existing ones. + - Logs success or failure for each attempted cycle creation. + """ + + automated_cycle = CycleSettings.objects.get(id=automated_cycle_id, project_id=project_id) + + number_of_cycles = automated_cycle.number_of_cycles + cycle_duration = automated_cycle.cycle_duration + cooldown_period = automated_cycle.cooldown_period + start_date = automated_cycle.start_date + workspace_id = automated_cycle.workspace_id + + desired_windows = [ + ( + start_date + timedelta(days=i * (cycle_duration + cooldown_period)), + start_date + timedelta(days=i * (cycle_duration + cooldown_period) + cycle_duration), + ) + for i in range(number_of_cycles) + ] + + # Prefetch all existing cycles in the same project/workspace that could possibly overlap + existing_cycles = Cycle.objects.filter( + project_id=project_id, + workspace_id=workspace_id, + deleted_at__isnull=True, + ).values("start_date", "end_date") + + logs = [] + cycles_to_create = [] + + for start_dt, end_dt in desired_windows: + # Check if there's any overlap + is_clashing = any((ec["start_date"] <= end_dt and ec["end_date"] >= start_dt) for ec in existing_cycles) + + if is_clashing: + logs.append( + AutomatedCycleLog( + automated_cycle=automated_cycle, + workspace_id=workspace_id, + project_id=project_id, + cycle_id=None, + action="cycle_creation_failed", + status="failed", + message=f"Cycle window {start_dt.date()} - {end_dt.date()} overlaps with existing cycle.", + scheduled_at=timezone.now(), + ) + ) + continue + + start_dt = convert_to_utc( + date=str(start_dt.date()), + project_id=project_id, + is_start_date=True, + ) + end_dt = convert_to_utc( + date=str(end_dt.date()), + project_id=project_id, + ) + + # Create the cycle object + cycle = Cycle( + project_id=project_id, + workspace_id=workspace_id, + owned_by_id=bot_id, + created_by_id=bot_id, + updated_by_id=bot_id, + name=automated_cycle.title, + start_date=start_dt, + end_date=end_dt, + ) + cycles_to_create.append(cycle) + + # Bulk create non-conflicting cycles + created_cycles = Cycle.objects.bulk_create(cycles_to_create, batch_size=50, ignore_conflicts=True) + + # Add logs for successful creations + for cycle in created_cycles: + logs.append( + AutomatedCycleLog( + automated_cycle=automated_cycle, + workspace_id=workspace_id, + project_id=project_id, + cycle_id=cycle.id, + action="cycle_created", + status="success", + message=f"Cycle created successfully for {cycle.start_date.date()} - {cycle.end_date.date()}", + scheduled_at=timezone.now(), + ) + ) + + # Bulk create logs + if logs: + AutomatedCycleLog.objects.bulk_create(logs, batch_size=50) + + return True + except Exception as e: + log_exception(e) + return False + + +@shared_task +def maintain_future_cycles(): + try: + """ + Ensure each active CycleSettings has exactly `number_of_cycles` future cycles. + - If a cycle started within the last 2 minutes, or a cycle will start within the + next 2 minutes, backfill to keep the number of future cycles constant. + - This runs idempotently and safely in concurrent environments. + """ + + now = timezone.now() + buffer = timedelta(minutes=2) + + # from the project features table get the cycle enabled projects + cycle_enabled_projects = ProjectFeature.objects.filter( + is_automated_cycle_enabled=True, project__cycle_view=True + ).values_list("project_id", flat=True) + + # check if the cycle is disabled for the projects + scheduled_cycles = CycleSettings.objects.filter(project_id__in=cycle_enabled_projects) + + scheduled_cycles_projects = scheduled_cycles.values("project_id") + + # get the recently started cycles to create new future cycles + # get the project's timezone time to check if the cycle has started is accurately respected . + recently_started_cycles = Cycle.objects.filter( + project_id__in=Subquery(scheduled_cycles_projects), + deleted_at__isnull=True, + start_date__gte=now - buffer, # started within last 2 mins (inclusive) + start_date__lte=now, # up to now + ).values("project_id", "id") + + # get the recently ended cycles to transfer work items + recently_ended_cycles = Cycle.objects.filter( + project_id__in=Subquery(scheduled_cycles_projects), + deleted_at__isnull=True, + end_date__gte=now - buffer, # ended within last 2 mins (inclusive) + end_date__lte=now, # up to now + ).values("project_id", "id") + + # check for auto-rollover enabled (placeholder for future use) + scheduled_cycles.filter(is_auto_rollover_enabled=True).values_list("project_id", flat=True) + + # create the new cycles + for cycle in recently_ended_cycles: + project_id = cycle["project_id"] + cycle_id = cycle["id"] + + project = Project.objects.get(id=project_id) + + # get the bot id + bot_id = ( + ProjectMember.objects.filter( + project_id=project_id, + member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT, + ) + .first() + .member_id + ) + + # Fetch project for the specific record or pass project_id dynamically + project_timezone = project.timezone + + # Convert the current time (timezone.now()) to the project's timezone + local_tz = pytz.timezone(project_timezone) + current_time_in_project_tz = timezone.now().astimezone(local_tz) + + # Convert project local time back to UTC for comparison (start_date is stored in UTC) + current_time_in_utc = current_time_in_project_tz.astimezone(pytz.utc) + + scheduled_cycle = scheduled_cycles.filter(project_id=project_id).first() + + # transfer the work-items to the next cycle (if the next cycle exists) + if scheduled_cycle.is_auto_rollover_enabled: + workspace_slug = Workspace.objects.get(id=scheduled_cycle.workspace_id).slug + + # first check if the current active cycle is not the same as the old cycle + current_cycle = ( + Cycle.objects.filter( + Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc), + workspace__slug=workspace_slug, + project_id=project_id, + deleted_at__isnull=True, + ) + .order_by("start_date") + .first() + ) + if current_cycle and current_cycle.id != cycle_id: + transfer_cycle_issues( + slug=workspace_slug, + project_id=project_id, + cycle_id=cycle_id, + new_cycle_id=current_cycle.id, + request=None, + user_id=str(bot_id), + ) + else: + next_cycle = ( + Cycle.objects.filter( + workspace__slug=workspace_slug, + project_id=project_id, + start_date__gt=current_time_in_utc, + deleted_at__isnull=True, + ) + .order_by("start_date") + .first() + ) + if next_cycle: + transfer_cycle_issues( + slug=workspace_slug, + project_id=project_id, + cycle_id=cycle_id, + new_cycle_id=next_cycle.id, + request=None, + user_id=str(bot_id), + ) + + for cycle in recently_started_cycles: + project_id = cycle["project_id"] + cycle_id = cycle["id"] + + project = Project.objects.get(id=project_id) + + # get the bot id + bot_id = ( + ProjectMember.objects.filter( + project_id=project_id, + member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT, + ) + .first() + .member_id + ) + scheduled_cycle = scheduled_cycles.filter(project_id=project_id).first() + + # get the number of cycles to create + number_of_cycles = scheduled_cycle.number_of_cycles + cycle_duration = scheduled_cycle.cycle_duration + cooldown_period = scheduled_cycle.cooldown_period + project_id = scheduled_cycle.project_id + workspace_id = scheduled_cycle.workspace_id + + # get the upcoming cycles which are scheduled for the project + upcoming_cycles = Cycle.objects.filter(project_id=project_id, start_date__gt=now, deleted_at__isnull=True) + + # get then end date of the last created cycle and then add the cooldown period and create the new cycle. + last_cycle = upcoming_cycles.order_by("-end_date").first() + if last_cycle: + last_cycle_end_date = last_cycle.end_date + timedelta(days=cooldown_period) + else: + # if there are no upcoming cycles then check for the start date and start the cycles from the next day + last_upcoming = upcoming_cycles.order_by("-start_date").first() + last_cycle_end_date = ( + last_upcoming.start_date + timedelta(days=cooldown_period) if last_upcoming else now + ) + + # if the last cycles is not found then check for which every is the upcoming cycle as the end date (if none of them have end date then skip the project) + upcoming_cycles = Cycle.objects.filter( + project_id=project_id, start_date__gt=now, deleted_at__isnull=True + ) + if not upcoming_cycles.exists(): + # create the new and the future cycles + schedule_cycle( + automated_cycle_id=scheduled_cycle.id, + project_id=project_id, + bot_id=bot_id, + ) + continue + + # check the difference between the number of cycles and the upcoming cycles + number_of_cycles_diff = number_of_cycles - upcoming_cycles.count() + + # if its greater than create the new cycles from the last cycle end date and add the cooldown period bulk create the cycle + + # first we need to check if any of the cycles exists with the same dates in the database + if number_of_cycles_diff > 0: + # Build cycles list with proper UTC conversion for each cycle + cycles_to_create = [] + for i in range(number_of_cycles_diff): + start_dt = last_cycle_end_date + timedelta(days=i * (cycle_duration + cooldown_period)) + end_dt = last_cycle_end_date + timedelta( + days=(i * (cycle_duration + cooldown_period) + cycle_duration) + ) + + # Convert dates to UTC using the project's timezone + start_date = convert_to_utc( + date=str(start_dt.date()), + project_id=project_id, + is_start_date=True, + ) + end_date = convert_to_utc( + date=str(end_dt.date()), + project_id=project_id, + ) + + cycles_to_create.append( + Cycle( + project_id=project_id, + workspace_id=workspace_id, + owned_by_id=bot_id, + created_by_id=bot_id, + updated_by_id=bot_id, + name=scheduled_cycle.title, + start_date=start_date, + end_date=end_date, + ) + ) + + # bulk create the cycles + cycles = Cycle.objects.bulk_create( + cycles_to_create, + batch_size=50, + ignore_conflicts=True, + ) + + AutomatedCycleLog.objects.bulk_create( + [ + AutomatedCycleLog( + automated_cycle=scheduled_cycle, + cycle=cycle, + workspace_id=workspace_id, + project_id=project_id, + action="cycle_created", + status="success", + message=f"Cycle created successfully for {cycle.start_date.date()} - {cycle.end_date.date()}", + scheduled_at=timezone.now(), + ) + for cycle in cycles + ] + ) + except Exception as e: + log_exception(e) + return False diff --git a/apps/api/plane/ee/serializers/__init__.py b/apps/api/plane/ee/serializers/__init__.py index 56c9d205ad..93e8758cd8 100644 --- a/apps/api/plane/ee/serializers/__init__.py +++ b/apps/api/plane/ee/serializers/__init__.py @@ -139,6 +139,7 @@ from .app.job import ImportReportSerializer, ImportJobSerializer from .app.workspace.credential import WorkspaceCredentialSerializer from .app.workspace.connection import WorkspaceConnectionSerializer from .app.workspace.entity_connection import WorkspaceEntityConnectionSerializer +from .app.cycle_schedule import AutomatedCycleSerializer # api from .api.job import ImportReportAPISerializer, ImportJobAPISerializer diff --git a/apps/api/plane/ee/serializers/app/cycle_schedule.py b/apps/api/plane/ee/serializers/app/cycle_schedule.py new file mode 100644 index 0000000000..c6f2945419 --- /dev/null +++ b/apps/api/plane/ee/serializers/app/cycle_schedule.py @@ -0,0 +1,47 @@ +from plane.ee.models import CycleSettings +from plane.ee.serializers import BaseSerializer +from django.utils import timezone +from rest_framework import serializers + + +class AutomatedCycleSerializer(BaseSerializer): + + class Meta: + model = CycleSettings + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + ] + + def validate(self, data): + # start date cannot be in the past + if ( + data.get("start_date", None) is not None + and data.get("start_date", None).date() < timezone.now().date() + ): + raise serializers.ValidationError("Start date cannot be in the past") + + # cycle duration will be in multiple of 7 days + if ( + data.get("cycle_duration", None) is not None + and data.get("cycle_duration", None) % 7 != 0 + ): + raise serializers.ValidationError( + "Cycle duration must be in multiple of 7 days" + ) + + # the number of cycles to be scheduled in the future cannot be greater than 3 + if ( + data.get("number_of_cycles", None) is not None + and data.get("number_of_cycles", None) > 3 + ): + raise serializers.ValidationError( + "Number of cycles to be scheduled in the future cannot be greater than 3" + ) + + return data diff --git a/apps/api/plane/ee/serializers/app/project.py b/apps/api/plane/ee/serializers/app/project.py index a2cf6ecec8..8b87516b31 100644 --- a/apps/api/plane/ee/serializers/app/project.py +++ b/apps/api/plane/ee/serializers/app/project.py @@ -46,6 +46,7 @@ class ProjectFeatureSerializer(BaseSerializer): "is_issue_type_enabled", "is_time_tracking_enabled", "is_workflow_enabled", + "is_automated_cycle_enabled", "project_id", ] read_only_fields = [ diff --git a/apps/api/plane/ee/urls/app/cycle.py b/apps/api/plane/ee/urls/app/cycle.py index 22dd94dbe0..92d1a7508d 100644 --- a/apps/api/plane/ee/urls/app/cycle.py +++ b/apps/api/plane/ee/urls/app/cycle.py @@ -5,6 +5,7 @@ from plane.ee.views.app.cycle import ( CycleUpdatesViewSet, CycleStartStopEndpoint, CycleIssueStateAnalyticsEndpoint, + AutomatedCycleViewSet, ) from plane.ee.views.app.update import UpdatesReactionViewSet @@ -57,4 +58,10 @@ urlpatterns = [ CycleIssueStateAnalyticsEndpoint.as_view(), name="project-cycle-progress", ), + # Scheduled Cycles + path( + "workspaces//projects//automated-cycles/", + AutomatedCycleViewSet.as_view({"get": "list", "post": "create", "patch": "partial_update"}), + name="automated-cycles", + ), ] diff --git a/apps/api/plane/ee/views/app/cycle/__init__.py b/apps/api/plane/ee/views/app/cycle/__init__.py index a9123a43fa..e3e477af78 100644 --- a/apps/api/plane/ee/views/app/cycle/__init__.py +++ b/apps/api/plane/ee/views/app/cycle/__init__.py @@ -2,3 +2,4 @@ from .active_cycle import WorkspaceActiveCycleEndpoint from .update import CycleUpdatesViewSet from .start_stop import CycleStartStopEndpoint from .base import CycleIssueStateAnalyticsEndpoint +from .schedule import AutomatedCycleViewSet diff --git a/apps/api/plane/ee/views/app/cycle/schedule.py b/apps/api/plane/ee/views/app/cycle/schedule.py new file mode 100644 index 0000000000..a2d53b0d61 --- /dev/null +++ b/apps/api/plane/ee/views/app/cycle/schedule.py @@ -0,0 +1,111 @@ +import uuid +from django.contrib.auth.hashers import make_password + +# Third Party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.ee.models import CycleSettings +from plane.db.models import BotTypeEnum, User, Workspace, WorkspaceMember, ProjectMember +from plane.ee.views.base import BaseViewSet +from plane.ee.serializers import AutomatedCycleSerializer +from plane.ee.bgtasks.cycle_automation_task import schedule_cycle +from plane.app.permissions import allow_permission, ROLE +from plane.payment.flags.flag import FeatureFlag +from plane.payment.flags.flag_decorator import check_feature_flag + + +class AutomatedCycleViewSet(BaseViewSet): + serializer_class = AutomatedCycleSerializer + model = CycleSettings + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + ) + + @allow_permission([ROLE.ADMIN]) + @check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES) + def list(self, request, slug, project_id): + automated_cycle = self.get_queryset().first() + if automated_cycle is None: + return Response({}, status=status.HTTP_200_OK) + serializer = AutomatedCycleSerializer(automated_cycle) + return Response(serializer.data, status=status.HTTP_200_OK) + + @allow_permission([ROLE.ADMIN]) + @check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES) + def create(self, request, slug, project_id): + # check if the automation already exists + if CycleSettings.objects.filter(project_id=project_id, workspace__slug=slug).exists(): + return Response( + {"error": "Automated cycle already exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cycle_automation_bot = ProjectMember.objects.filter( + project_id=project_id, member__bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT + ).first() + if not cycle_automation_bot: + # Create a automation bot + cycle_automation_bot = User.objects.create( + username=f"cycle-automation-bot-{uuid.uuid4().hex}", + display_name="Cycle Automation Bot", + first_name="Cycle Automation", + last_name="Bot", + is_bot=True, + bot_type=BotTypeEnum.CYCLE_AUTOMATION_BOT, + email=f"cycle-automation-bot-{uuid.uuid4().hex}@plane.so", + password=make_password(uuid.uuid4().hex), + is_password_autoset=True, + ) + + workspace_id = Workspace.objects.get(slug=slug).id + # Add user to the workspace + WorkspaceMember.objects.create( + member_id=cycle_automation_bot.id, + workspace_id=workspace_id, + role=ROLE.ADMIN.value, + is_active=True, + ) + + # add the user to the project + ProjectMember.objects.create( + project_id=project_id, + workspace_id=workspace_id, + member_id=cycle_automation_bot.id, + role=ROLE.ADMIN.value, + is_active=True, + ) + + serializer = AutomatedCycleSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + # when ever some one tries to schedule a cycle, we will schedule the first cycle + schedule_cycle.delay( + project_id=project_id, + bot_id=cycle_automation_bot.id, + automated_cycle_id=serializer.instance.id, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @allow_permission([ROLE.ADMIN]) + @check_feature_flag(FeatureFlag.AUTO_SCHEDULE_CYCLES) + def partial_update(self, request, slug, project_id): + automated_cycle = CycleSettings.objects.filter(project_id=project_id, workspace__slug=slug).first() + if not automated_cycle: + return Response( + {"error": "Automated cycle not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = AutomatedCycleSerializer(automated_cycle, 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) diff --git a/apps/api/plane/payment/flags/flag.py b/apps/api/plane/payment/flags/flag.py index 927ead2c5c..a68a313a91 100644 --- a/apps/api/plane/payment/flags/flag.py +++ b/apps/api/plane/payment/flags/flag.py @@ -88,6 +88,8 @@ class FeatureFlag(Enum): PROJECT_AUTOMATIONS = "PROJECT_AUTOMATIONS" # Recurring Work Items RECURRING_WORKITEMS = "RECURRING_WORKITEMS" + # Automated Cycles + AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES" # Exports ADVANCED_EXPORTS = "ADVANCED_EXPORTS" diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index b777ed8aa2..92fd5c8a65 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -312,6 +312,7 @@ CELERY_IMPORTS = ( "plane.authentication.bgtasks.send_app_uninstall_webhook", "plane.ee.bgtasks.batched_search_update_task", "plane.ee.bgtasks.recurring_work_item_task", + "plane.ee.bgtasks.cycle_automation_task", # silo tasks "plane.silo.bgtasks.integration_apps_task", # event stream tasks diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py index 0e125e7e8a..87a0b4d606 100644 --- a/apps/api/plane/utils/cycle_transfer_issues.py +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -30,6 +30,7 @@ from plane.ee.bgtasks.entity_issue_state_progress_task import ( entity_issue_state_activity_task, ) + def transfer_cycle_issues( slug, project_id, @@ -53,9 +54,7 @@ def transfer_cycle_issues( dict: Response data with success or error message """ # Get the new cycle - new_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ).first() + new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first() # Check if new cycle is already completed if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): @@ -218,9 +217,7 @@ def transfer_cycle_issues( assignee_estimate_distribution = [ { "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), "avatar_url": item.get("avatar_url"), "total_estimates": item["total_estimates"], "completed_estimates": item["completed_estimates"], @@ -312,9 +309,7 @@ def transfer_cycle_issues( ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -362,9 +357,7 @@ def transfer_cycle_issues( .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -411,9 +404,7 @@ def transfer_cycle_issues( ) # Get the current cycle and save progress snapshot - current_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ).first() + current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first() current_cycle.progress_snapshot = { "total_issues": old_cycle.total_issues, @@ -463,9 +454,7 @@ def transfer_cycle_issues( ) # Bulk update cycle issues - cycle_issues = CycleIssue.objects.bulk_update( - updated_cycles, ["cycle_id"], batch_size=100 - ) + cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( @@ -477,7 +466,7 @@ def transfer_cycle_issues( current_instance=json.dumps( { "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": [], + "created_cycle_issues": "[]", } ), epoch=int(timezone.now().timestamp()), @@ -485,6 +474,8 @@ def transfer_cycle_issues( origin=base_host(request=request, is_app=True), ) + # EE Code start here + # Trigger the entity issue state activity task for removal of issues entity_issue_state_activity_task.delay( issue_cycle_data=[ diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx new file mode 100644 index 0000000000..7578834c56 --- /dev/null +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/features/cycles/page.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { ChevronLeft } from "lucide-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { setPromiseToast } from "@plane/propel/toast"; +import { ToggleSwitch } from "@plane/ui"; +// components +import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; +import { PageHead } from "@/components/core/page-title"; +import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; +import { SettingsHeading } from "@/components/settings/heading"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; +import { AutoScheduleCycles } from "@/plane-web/components/cycles/settings"; +// plane web imports +import { PROJECT_BASE_FEATURES_LIST } from "@/plane-web/constants/project/settings"; + +const CyclesFeatureSettingsPage = observer(() => { + const { workspaceSlug, projectId } = useParams(); + // permissions + const { workspaceUserInfo, allowPermissions } = useUserPermissions(); + const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + // project store + const { getProjectById, updateProject } = useProject(); + const currentProjectDetails = projectId ? getProjectById(projectId.toString()) : undefined; + + if (!workspaceSlug || !projectId) return null; + + if (workspaceUserInfo && !canPerformProjectAdminActions) { + return ; + } + + const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Cycle Configuration` : undefined; + + const handleToggle = async () => { + if (!currentProjectDetails) return; + const payload = { cycle_view: !Boolean(currentProjectDetails.cycle_view) }; + const promise = updateProject(workspaceSlug.toString(), projectId.toString(), payload); + setPromiseToast(promise, { + loading: "Updating project feature...", + success: { title: "Success!", message: () => "Project feature updated successfully." }, + error: { title: "Error!", message: () => "Something went wrong. Please try again." }, + }); + }; + + const cyclesIcon = PROJECT_BASE_FEATURES_LIST.cycles.icon; + + return ( + + +
+ +
+ + Back to features +
+ +
+ + + +
+
+
+
{cyclesIcon}
+
+

Enable cycles

+

Plan work in focused timeframes.

+
+
+ + +
+
+ + {/* Auto-schedule cycles configuration */} + {currentProjectDetails?.cycle_view && } +
+ ); +}); + +export default CyclesFeatureSettingsPage; diff --git a/apps/web/core/components/project/settings/features-list.tsx b/apps/web/core/components/project/settings/features-list.tsx index 31ebd2765c..8024def86a 100644 --- a/apps/web/core/components/project/settings/features-list.tsx +++ b/apps/web/core/components/project/settings/features-list.tsx @@ -16,11 +16,8 @@ import { captureSuccess } from "@/helpers/event-tracker.helper"; import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; // plane web imports -import { ProjectFeatureChildren } from "@/plane-web/components/projects/settings/feature-children"; import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge"; import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings"; -import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects"; -import { useFlag } from "@/plane-web/hooks/store/use-flag"; import { ProjectFeatureToggle } from "./helper"; type Props = { @@ -35,8 +32,6 @@ export const ProjectFeaturesList: FC = observer((props) => { const { t } = useTranslation(); const { data: currentUser } = useUser(); const { getProjectById, updateProject } = useProject(); - const { toggleProjectFeatures } = useProjectAdvanced(); - const isWorklogEnabled = useFlag(workspaceSlug, "ISSUE_WORKLOG"); // derived values const currentProjectDetails = getProjectById(projectId); @@ -48,9 +43,7 @@ export const ProjectFeaturesList: FC = observer((props) => { [featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject], }; const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload); - if (featureProperty === "is_time_tracking_enabled") { - toggleProjectFeatures(workspaceSlug, projectId, settingsPayload, false); - } + setPromiseToast(updateProjectPromise, { loading: "Updating project feature...", success: { @@ -62,7 +55,6 @@ export const ProjectFeaturesList: FC = observer((props) => { message: () => "Something went wrong while updating project feature. Please try again.", }, }); - updateProjectPromise.then(() => { captureSuccess({ eventName: PROJECT_TRACKER_EVENTS.feature_toggled, @@ -95,10 +87,7 @@ export const ProjectFeaturesList: FC = observer((props) => {

{t(featureItem.key)}

{featureItem.isPro && ( - + )} @@ -117,13 +106,8 @@ export const ProjectFeaturesList: FC = observer((props) => { />
- {currentProjectDetails && currentProjectDetails?.[featureItem.property as keyof IProject] && ( - - )} + {currentProjectDetails?.[featureItem.property as keyof IProject] && + featureItem.renderChildren?.(currentProjectDetails, workspaceSlug)}
))} diff --git a/apps/web/ee/components/cycles/settings/auto-schedule-cycles.tsx b/apps/web/ee/components/cycles/settings/auto-schedule-cycles.tsx new file mode 100644 index 0000000000..adf5af3fdc --- /dev/null +++ b/apps/web/ee/components/cycles/settings/auto-schedule-cycles.tsx @@ -0,0 +1,376 @@ +"use client"; + +import React, { useState } from "react"; +import { isEmpty } from "lodash-es"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Controller, useForm } from "react-hook-form"; +// plane imports +import useSWR from "swr"; +import { InfoIcon } from "@plane/propel/icons"; +import { setPromiseToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import { TCycleConfig } from "@plane/types"; +import { Input, ToggleSwitch, CustomSelect, Button } from "@plane/ui"; +import { renderFormattedPayloadDate, getDate } from "@plane/utils"; +// components +import { DateDropdown } from "@/components/dropdowns/date"; +//services +import { useFlag } from "@/plane-web/hooks/store"; +import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects"; +import { cycleService } from "@/plane-web/services/cycle.service"; + +const defaultValues: Partial = { + title: "", + cycle_duration: 0, + cooldown_period: 0, + start_date: null, + number_of_cycles: 0, + is_auto_rollover_enabled: false, +}; + +const cycleCountOptions = [ + { value: 1, label: "1 cycle" }, + { value: 2, label: "2 cycles" }, + { value: 3, label: "3 cycles" }, +]; + +export const AutoScheduleCycles = observer(() => { + const { workspaceSlug, projectId } = useParams(); + + const { control, handleSubmit, reset } = useForm({ + defaultValues, + }); + + // states + const [isEdit, setIsEdit] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + //hooks + const { isProjectFeatureEnabled, toggleProjectFeatures } = useProjectAdvanced(); + + // derived values + const isFeatureFlagEnabled = useFlag(workspaceSlug.toString(), "AUTO_SCHEDULE_CYCLES"); + const isAutoScheduleEnabled = isProjectFeatureEnabled(projectId.toString(), "is_automated_cycle_enabled"); + + const fetchCycleConfig = async () => { + const cycleConfig = await cycleService.getCycleConfig(workspaceSlug.toString(), projectId.toString()); + if (isEmpty(cycleConfig)) { + reset(defaultValues); + return; + } else { + handleSetFormData(cycleConfig); + } + }; + + useSWR( + workspaceSlug && projectId && isFeatureFlagEnabled && isAutoScheduleEnabled + ? `cycle-config-${workspaceSlug}-${projectId}` + : null, + workspaceSlug && projectId && isFeatureFlagEnabled && isAutoScheduleEnabled ? fetchCycleConfig : null, + { + revalidateOnFocus: false, + } + ); + + if (!isFeatureFlagEnabled) return null; + + const handleReset = () => { + setIsEdit(false); + fetchCycleConfig(); + }; + + const handleSetFormData = (data: Partial) => { + reset({ + ...data, + cycle_duration: data.cycle_duration ? data.cycle_duration / 7 : 0, + }); + }; + + const onSubmit = async (data: Partial) => { + setIsSubmitting(true); + const payload: Partial = { + ...data, + cycle_duration: data.cycle_duration ? data.cycle_duration * 7 : 0, + }; + const promise = data?.id + ? cycleService.updateCycleConfig(workspaceSlug.toString(), projectId.toString(), payload) + : cycleService.scheduleCycle(workspaceSlug.toString(), projectId.toString(), payload); + + const toastPromise = promise + .then((response) => { + handleSetFormData(response); + setIsEdit(false); + }) + .catch((error) => { + throw error; + }) + .finally(() => { + setIsSubmitting(false); + }); + + setPromiseToast(toastPromise, { + loading: "Saving auto-schedule cycles configuration", + success: { + title: "Success!", + message: () => + data.id + ? "Auto-schedule cycles configuration updated successfully." + : "Auto-schedule cycles configuration saved successfully.", + }, + error: { + title: "Error!", + message: () => + data.id + ? "Failed to update auto-schedule cycles configuration." + : "Failed to save auto-schedule cycles configuration.", + }, + }); + }; + + const toggleScheduleCycle = async (enabled: boolean) => { + const promise = toggleProjectFeatures( + workspaceSlug.toString(), + projectId.toString(), + { + is_automated_cycle_enabled: enabled, + }, + enabled + ); + + setPromiseToast(promise, { + loading: enabled ? "Enabling auto-schedule cycles" : "Disabling auto-schedule cycles", + success: { title: "Success!", message: () => "Auto-schedule cycles toggled successfully." }, + error: { title: "Error!", message: () => "Failed to toggle auto-schedule cycles." }, + }); + + if (enabled) { + setIsEdit(true); + } + }; + + return ( +
+ {/* Main toggle */} +
+
+
+

+ Auto-schedule cycles + +
+ +
+
+

+

+ Keep cycles moving without manual setup. +

+
+
+ {isAutoScheduleEnabled && !isEdit && ( + + )} + +
+
+
+ + {/* Configuration form - only show when enabled */} + {isAutoScheduleEnabled && ( +
+
+ {/* Cycle Title */} +
+
+
+ Cycle Title + +
+ +
+
+
+
+
+ ( + + )} + /> +
+
+ {/* Cycle Duration */} +
+
+
+ Cycle Duration +
+
+
+
+ ( + + )} + /> + Weeks +
+
+
+ {/* Cool down Period*/} +
+
+
+ Cooldown Period + +
+ +
+
+
+
+
+
+ ( + + )} + /> + days +
+
+
+ {/* Cycle Start Date */} +
+
+
+ Cycle starts day +
+
+
+
+ ( + field.onChange(renderFormattedPayloadDate(date) || "")} + minDate={new Date()} + buttonVariant="border-with-text" + className="w-full" + buttonClassName="bg-custom-background-100 px-3 py-2 rounded-md border-[0.5px] border-custom-border-200 text-left justify-start w-full text-sm" + showTooltip + disabled={!isEdit} + /> + )} + /> +
+
+
+ + {/* Number of future cycles */} +
+
+
+ Number of future cycles +
+
+
+ ( + option.value === field.value)?.label || "1 cycle"} + > + {cycleCountOptions.map((option) => ( + + {option.label} + + ))} + + )} + /> +
+
+ + {/* Auto-rollover work items */} +
+
+
+ Auto-rollover work items + +
+ +
+
+
+
+
+ ( + + )} + /> +
+
+ + {/* Action buttons */} + {isEdit && ( +
+ + +
+ )} +
+
+ )} +
+ ); +}); diff --git a/apps/web/ee/components/cycles/settings/disable-modal.tsx b/apps/web/ee/components/cycles/settings/disable-modal.tsx new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/ee/components/cycles/settings/index.ts b/apps/web/ee/components/cycles/settings/index.ts new file mode 100644 index 0000000000..7095d055c1 --- /dev/null +++ b/apps/web/ee/components/cycles/settings/index.ts @@ -0,0 +1 @@ +export * from "./auto-schedule-cycles"; diff --git a/apps/web/ee/components/projects/settings/features-list.tsx b/apps/web/ee/components/projects/settings/features-list.tsx index 26fc591fdb..b2050867aa 100644 --- a/apps/web/ee/components/projects/settings/features-list.tsx +++ b/apps/web/ee/components/projects/settings/features-list.tsx @@ -1 +1,149 @@ -export { ProjectFeaturesList } from "@/components/project/settings/features-list"; +"use client"; + +import type { FC } from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { E_FEATURE_FLAGS } from "@plane/constants"; +import { PROJECT_TRACKER_EVENTS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { setPromiseToast } from "@plane/propel/toast"; +import { Tooltip } from "@plane/propel/tooltip"; +import type { IProject, TProjectFeaturesList } from "@plane/types"; +// components +import type { TProperties } from "@/ce/constants/project"; +import { ProjectFeatureToggle } from "@/components/project/settings/helper"; +import { SettingsHeading } from "@/components/settings/heading"; +// helpers +import { captureSuccess } from "@/helpers/event-tracker.helper"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUser } from "@/hooks/store/user"; +// plane web imports +import { ProjectFeatureChildren } from "@/plane-web/components/projects/settings/feature-children"; +import { UpgradeBadge } from "@/plane-web/components/workspace/upgrade-badge"; +import { PROJECT_FEATURES_LIST } from "@/plane-web/constants/project/settings"; +import { useProjectAdvanced } from "@/plane-web/hooks/store/projects/use-projects"; +import { useFlag } from "@/plane-web/hooks/store/use-flag"; + +type Props = { + workspaceSlug: string; + projectId: string; + isAdmin: boolean; +}; + +export const ProjectFeaturesList: FC = observer((props) => { + const { workspaceSlug, projectId, isAdmin } = props; + // store hooks + const { t } = useTranslation(); + const { data: currentUser } = useUser(); + const { getProjectById, updateProject } = useProject(); + const { toggleProjectFeatures, isProjectFeatureEnabled } = useProjectAdvanced(); + // Feature flag mapping per project feature property + const FEATURE_FLAG_BY_PROPERTY: Record = { + is_time_tracking_enabled: "ISSUE_WORKLOG", + // Add more property-to-flag mappings here as needed + }; + // Precompute known flags once + const flagsByKey: Partial> = { + ISSUE_WORKLOG: useFlag(workspaceSlug, "ISSUE_WORKLOG"), + }; + // derived values + const currentProjectDetails = getProjectById(projectId); + + const handleSubmit = async (featureKey: string, featureProperty: string) => { + if (!workspaceSlug || !projectId || !currentProjectDetails) return; + + // making the request to update the project feature + const settingsPayload = { + [featureProperty]: !currentProjectDetails?.[featureProperty as keyof IProject], + }; + const updateProjectPromise = updateProject(workspaceSlug, projectId, settingsPayload); + // Optimistically update features in store for any feature toggle + toggleProjectFeatures(workspaceSlug, projectId, settingsPayload, false); + setPromiseToast(updateProjectPromise, { + loading: "Updating project feature...", + success: { + title: "Success!", + message: () => "Project feature updated successfully.", + }, + error: { + title: "Error!", + message: () => "Something went wrong while updating project feature. Please try again.", + }, + }); + + updateProjectPromise.then(() => { + captureSuccess({ + eventName: PROJECT_TRACKER_EVENTS.feature_toggled, + payload: { + feature_key: featureKey, + }, + }); + }); + }; + + const isFeatureEnabled = (featureItem: TProperties): boolean => + Boolean(currentProjectDetails?.[featureItem.property as keyof IProject]) || + isProjectFeatureEnabled(projectId, featureItem.property as keyof TProjectFeaturesList); + + if (!currentUser) return <>; + + return ( +
+ {Object.entries(PROJECT_FEATURES_LIST).map(([featureSectionKey, feature]) => ( +
+ + {Object.entries(feature.featureList).map(([featureItemKey, featureItem]) => { + const flagKey = FEATURE_FLAG_BY_PROPERTY[featureItem.property]; + const isFeatureFlagEnabled = flagKey ? Boolean(flagsByKey[flagKey]) : true; + return ( +
+
+
+
+ {featureItem.icon} +
+
+
+

{t(featureItem.key)}

+ {featureItem.isPro && ( + + + + )} +
+

+ {t(`${featureItem.key}_description`)} +

+
+
+ + +
+
+ {currentProjectDetails && currentProjectDetails?.[featureItem.property as keyof IProject] && ( + + )} +
+
+ ); + })} +
+ ))} +
+ ); +}); diff --git a/apps/web/ee/constants/project/settings/features.tsx b/apps/web/ee/constants/project/settings/features.tsx index 564095faaf..3d90ef634a 100644 --- a/apps/web/ee/constants/project/settings/features.tsx +++ b/apps/web/ee/constants/project/settings/features.tsx @@ -58,6 +58,10 @@ export const INTAKE_FEATURES_LIST: TIntakeFeatureList = { export const PROJECT_BASE_FEATURES_LIST = { ...CE_PROJECT_BASE_FEATURES_LIST, + cycles: { + ...CE_PROJECT_BASE_FEATURES_LIST.cycles, + href: "/cycles", + }, }; export const PROJECT_OTHER_FEATURES_LIST = { diff --git a/apps/web/ee/services/cycle.service.ts b/apps/web/ee/services/cycle.service.ts index 3f90df3d1a..107ffb5f1e 100644 --- a/apps/web/ee/services/cycle.service.ts +++ b/apps/web/ee/services/cycle.service.ts @@ -1,11 +1,11 @@ // helpers import { API_BASE_URL } from "@plane/constants"; // services +import type { TCycleConfig } from "@plane/types"; import type { CYCLE_ACTION } from "@/plane-web/constants/cycle"; import { APIService } from "@/services/api.service"; import { CycleService as CycleServiceCore } from "@/services/cycle.service"; import type { TCycleUpdateReaction, TCycleUpdates } from "../types"; -import { TCycleUpdateStatus } from "../types"; export class CycleUpdateService extends APIService { constructor() { @@ -112,4 +112,38 @@ export class CycleService extends CycleServiceCore { throw err?.response?.data; }); } + + async getCycleConfig(workspaceSlug: string, projectId: string): Promise> { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + async scheduleCycle( + workspaceSlug: string, + projectId: string, + data: Partial + ): Promise> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`, data) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } + + async updateCycleConfig( + workspaceSlug: string, + projectId: string, + data: Partial + ): Promise> { + return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/automated-cycles/`, data) + .then((res) => res?.data) + .catch((err) => { + throw err?.response?.data; + }); + } } + +export const cycleService = new CycleService(); diff --git a/apps/web/ee/store/projects/projects.ts b/apps/web/ee/store/projects/projects.ts index 45d83a360a..53de9fc7d2 100644 --- a/apps/web/ee/store/projects/projects.ts +++ b/apps/web/ee/store/projects/projects.ts @@ -100,6 +100,7 @@ export class ProjectStore implements IProjectStore { is_time_tracking_enabled: false, is_workflow_enabled: false, project_id: projectId, + is_automated_cycle_enabled: false, }; } } diff --git a/packages/constants/src/feature-flag.ts b/packages/constants/src/feature-flag.ts index 9dc41a0160..f6805566a0 100644 --- a/packages/constants/src/feature-flag.ts +++ b/packages/constants/src/feature-flag.ts @@ -33,6 +33,7 @@ export enum E_FEATURE_FLAGS { GLOBAL_VIEWS_TIMELINE = "GLOBAL_VIEWS_TIMELINE", COPY_WORK_ITEM = "COPY_WORK_ITEM", LINK_PAGES = "LINK_PAGES", + AUTO_SCHEDULE_CYCLES = "AUTO_SCHEDULE_CYCLES", // ====== silo importers ====== SILO_IMPORTERS = "SILO_IMPORTERS", diff --git a/packages/types/src/cycle/cycle-extended.ts b/packages/types/src/cycle/cycle-extended.ts new file mode 100644 index 0000000000..a3f6392d0f --- /dev/null +++ b/packages/types/src/cycle/cycle-extended.ts @@ -0,0 +1,14 @@ +export type TCycleConfig = { + id: string; + title: string; + cycle_duration: number; + cooldown_period: number; + start_date: string | null; + number_of_cycles: number; + is_auto_rollover_enabled: boolean; + is_active: boolean; + created_by: string; + updated_by: string | null; + created_at: string; + updated_at: string | null; +}; diff --git a/packages/types/src/cycle/index.ts b/packages/types/src/cycle/index.ts index d5f4ce5b05..537f26ccb7 100644 --- a/packages/types/src/cycle/index.ts +++ b/packages/types/src/cycle/index.ts @@ -1,2 +1,3 @@ export * from "./cycle_filters"; export * from "./cycle"; +export * from "./cycle-extended"; diff --git a/packages/types/src/project/projects-extended.ts b/packages/types/src/project/projects-extended.ts index 25c629a3fb..a0fa8bc874 100644 --- a/packages/types/src/project/projects-extended.ts +++ b/packages/types/src/project/projects-extended.ts @@ -91,6 +91,7 @@ export type TProjectFeaturesList = { is_issue_type_enabled: boolean; is_time_tracking_enabled: boolean; is_workflow_enabled: boolean; + is_automated_cycle_enabled: boolean; }; export type TProjectFeatures = {