From 3f951b96dff103dbb666af9ed1905b302dba127c Mon Sep 17 00:00:00 2001 From: koreanjoker Date: Sat, 18 Jul 2026 14:32:21 +0900 Subject: [PATCH 1/2] feat: cascade parent issue status to sub-issues on close Add an opt-in, per-project automation that mirrors a parent issue's completed/cancelled state onto its non-terminal sub-issues, recursively. - Project.cascade_state_on_close flag (+ migration 0122) - cascade_state_to_sub_issues Celery task: BFS over the sub-issue tree with a visited set and depth cap, bulk_update, one activity per child (modeled on the existing close_old_issues automation) - Hook in IssueViewSet.partial_update: fires only when the parent enters a completed/cancelled group and the project has opted in; never on reopen - Project Automations settings toggle (web) + IProject type + en i18n - Unit and contract tests Ref #9432 --- apps/api/plane/app/views/issue/base.py | 27 +++ apps/api/plane/bgtasks/issue_cascade_task.py | 104 ++++++++++++ .../0122_project_cascade_state_on_close.py | 18 ++ apps/api/plane/db/models/project.py | 1 + .../contract/app/test_issue_cascade_app.py | 110 ++++++++++++ .../unit/bg_tasks/test_issue_cascade_task.py | 158 ++++++++++++++++++ .../projects/[projectId]/automations/page.tsx | 3 +- .../automation/auto-close-automation.tsx | 2 +- .../automation/cascade-state-automation.tsx | 65 +++++++ apps/web/core/components/automation/index.ts | 1 + .../i18n/src/locales/en/project-settings.json | 4 + packages/types/src/project/projects.ts | 1 + 12 files changed, 492 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/bgtasks/issue_cascade_task.py create mode 100644 apps/api/plane/db/migrations/0122_project_cascade_state_on_close.py create mode 100644 apps/api/plane/tests/contract/app/test_issue_cascade_app.py create mode 100644 apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py create mode 100644 apps/web/core/components/automation/cascade-state-automation.tsx diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 3b8b05a6a2f..353ffaa5c4d 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -41,6 +41,7 @@ ProjectUserPropertySerializer, ) from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.issue_cascade_task import cascade_state_to_sub_issues from plane.bgtasks.issue_description_version_task import issue_description_version_task from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.webhook_task import model_activity @@ -59,6 +60,8 @@ ModuleIssue, Project, ProjectMember, + State, + StateGroup, UserRecentVisit, ) from plane.utils.filters import ComplexFilterBackend, IssueFilterSet @@ -679,6 +682,8 @@ def partial_update(self, request, slug, project_id, pk=None): requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id}) if serializer.is_valid(): + # Snapshot the state before saving to detect a closing transition. + previous_state_id = issue.state_id serializer.save() # Check if the update is a migration description update is_migration_description_update = skip_activity and is_description_update @@ -710,6 +715,28 @@ def partial_update(self, request, slug, project_id, pk=None): issue_id=str(serializer.data.get("id", None)), user_id=request.user.id, ) + # Cascade the closing state to non-terminal sub-issues when the parent + # enters a completed/cancelled group and the project has opted in. + new_state_id = issue.state_id + if new_state_id and new_state_id != previous_state_id: + state_groups = dict( + State.objects.filter(id__in=[previous_state_id, new_state_id]).values_list("id", "group") + ) + new_group = state_groups.get(new_state_id) + previous_group = state_groups.get(previous_state_id) + terminal_groups = [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value] + if ( + new_group in terminal_groups + and previous_group not in terminal_groups + and Project.objects.filter(id=project_id, cascade_state_on_close=True).exists() + ): + cascade_state_to_sub_issues.delay( + parent_issue_id=str(pk), + new_state_id=str(new_state_id), + actor_id=str(request.user.id), + project_id=str(project_id), + epoch=int(timezone.now().timestamp()), + ) return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/bgtasks/issue_cascade_task.py b/apps/api/plane/bgtasks/issue_cascade_task.py new file mode 100644 index 00000000000..1154f9b90b8 --- /dev/null +++ b/apps/api/plane/bgtasks/issue_cascade_task.py @@ -0,0 +1,104 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Python imports +import json + +# Third party imports +from celery import shared_task + +# Django imports +from django.utils import timezone + +# Module imports +from plane.bgtasks.issue_activities_task import issue_activity +from plane.db.models import Issue, State, StateGroup +from plane.utils.exception_logger import log_exception + +# State groups that are considered terminal (closed) for the cascade. +TERMINAL_GROUPS = [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value] + +# Safety cap on how deep the sub-issue tree is walked. Issue.parent is a plain +# self referencing FK with no cycle guard, so this bounds pathological data. +MAX_DEPTH = 20 + + +@shared_task +def cascade_state_to_sub_issues(parent_issue_id, new_state_id, actor_id, project_id, epoch): + """Mirror a parent's closing state onto its non-terminal descendants. + + When a parent issue enters a completed/cancelled group, every descendant + (recursively, via Issue.parent) that is not already in a terminal group is + moved to the parent's new state. The operation is idempotent and close-only: + descendants already in a completed/cancelled group are left untouched, but + the walk still continues through them to reach deeper open descendants. + """ + try: + # Resolve the target state and confirm it is a terminal (closing) state. + new_state = State.all_state_objects.filter(id=new_state_id).first() + if new_state is None or new_state.group not in TERMINAL_GROUPS: + return + + completed_at = timezone.now() if new_state.group == StateGroup.COMPLETED.value else None + + # Breadth first walk over descendants with a visited set + depth cap. + visited = {str(parent_issue_id)} + frontier = [str(parent_issue_id)] + issues_to_update = [] + depth = 0 + + while frontier and depth < MAX_DEPTH: + children = list( + Issue.issue_objects.filter( + parent_id__in=frontier, + project_id=project_id, + deleted_at__isnull=True, + ).select_related("state") + ) + + next_frontier = [] + for child in children: + child_id = str(child.id) + if child_id in visited: + continue + visited.add(child_id) + # Continue traversing through every child, even terminal ones, + # so open grandchildren under a closed child are still reached. + next_frontier.append(child_id) + + # Skip descendants already in a terminal group (close-only). + if child.state and child.state.group in TERMINAL_GROUPS: + continue + + child.state_id = new_state_id + child.completed_at = completed_at + child.updated_at = timezone.now() + issues_to_update.append(child) + + frontier = next_frontier + depth += 1 + + if not issues_to_update: + return + + Issue.objects.bulk_update(issues_to_update, ["state", "completed_at", "updated_at"], batch_size=100) + + [ + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"closed_to": str(new_state_id)}), + actor_id=str(actor_id), + issue_id=issue.id, + project_id=project_id, + current_instance=None, + subscriber=False, + epoch=epoch, + notification=True, + ) + for issue in issues_to_update + ] + return + except Exception as e: + log_exception(e) + return diff --git a/apps/api/plane/db/migrations/0122_project_cascade_state_on_close.py b/apps/api/plane/db/migrations/0122_project_cascade_state_on_close.py new file mode 100644 index 00000000000..9ebfc29a420 --- /dev/null +++ b/apps/api/plane/db/migrations/0122_project_cascade_state_on_close.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.30 on 2026-07-17 00:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0121_alter_estimate_type'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='cascade_state_on_close', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index 4039b1d2903..4b90f89c1ec 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -109,6 +109,7 @@ class Project(BaseModel): estimate = models.ForeignKey("db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True) archive_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) close_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) + cascade_state_on_close = models.BooleanField(default=False) logo_props = models.JSONField(default=dict) default_state = models.ForeignKey("db.State", on_delete=models.SET_NULL, null=True, related_name="default_state") archived_at = models.DateTimeField(null=True) diff --git a/apps/api/plane/tests/contract/app/test_issue_cascade_app.py b/apps/api/plane/tests/contract/app/test_issue_cascade_app.py new file mode 100644 index 00000000000..6a4d317f1b7 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_issue_cascade_app.py @@ -0,0 +1,110 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Opt-in gate for the parent->child close cascade. + +IssueViewSet.partial_update should only enqueue cascade_state_to_sub_issues when +the parent enters a completed/cancelled group AND the project has +``cascade_state_on_close`` enabled. +""" + +from unittest.mock import patch + +import pytest +from rest_framework import status + +from plane.db.models import Issue, Project, ProjectMember, State + + +def _project(workspace, user, *, cascade): + project = Project.objects.create( + name=f"Cascade {'On' if cascade else 'Off'}", + identifier="CON" if cascade else "COF", + workspace=workspace, + created_by=user, + cascade_state_on_close=cascade, + ) + ProjectMember.objects.create(project=project, member=user, role=20, is_active=True) + return project + + +def _states(project, workspace): + started = State.objects.create(name="In Progress", project=project, workspace=workspace, group="started") + completed = State.objects.create(name="Done", project=project, workspace=workspace, group="completed") + return started, completed + + +def _issue(name, workspace, project, state, user, parent=None): + return Issue.objects.create( + name=name, workspace=workspace, project=project, state=state, parent=parent, created_by=user + ) + + +def _url(slug, project_id, issue_id): + return f"/api/workspaces/{slug}/projects/{project_id}/issues/{issue_id}/" + + +@pytest.mark.contract +class TestCascadeOptInGate: + @pytest.mark.django_db + @patch("plane.app.views.issue.base.cascade_state_to_sub_issues") + @patch("plane.app.views.issue.base.issue_description_version_task") + @patch("plane.app.views.issue.base.model_activity") + @patch("plane.app.views.issue.base.issue_activity") + def test_enqueues_when_option_enabled( + self, + mock_issue_activity, + mock_model_activity, + mock_desc_version, + mock_cascade, + session_client, + workspace, + create_user, + ): + project = _project(workspace, create_user, cascade=True) + started, completed = _states(project, workspace) + parent = _issue("Parent", workspace, project, started, create_user) + _issue("Child", workspace, project, started, create_user, parent=parent) + + response = session_client.patch( + _url(workspace.slug, project.id, parent.id), + {"state_id": str(completed.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}" + mock_cascade.delay.assert_called_once() + kwargs = mock_cascade.delay.call_args.kwargs + assert kwargs["parent_issue_id"] == str(parent.id) + assert kwargs["new_state_id"] == str(completed.id) + assert kwargs["project_id"] == str(project.id) + + @pytest.mark.django_db + @patch("plane.app.views.issue.base.cascade_state_to_sub_issues") + @patch("plane.app.views.issue.base.issue_description_version_task") + @patch("plane.app.views.issue.base.model_activity") + @patch("plane.app.views.issue.base.issue_activity") + def test_no_enqueue_when_option_disabled( + self, + mock_issue_activity, + mock_model_activity, + mock_desc_version, + mock_cascade, + session_client, + workspace, + create_user, + ): + project = _project(workspace, create_user, cascade=False) + started, completed = _states(project, workspace) + parent = _issue("Parent", workspace, project, started, create_user) + _issue("Child", workspace, project, started, create_user, parent=parent) + + response = session_client.patch( + _url(workspace.slug, project.id, parent.id), + {"state_id": str(completed.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}" + mock_cascade.delay.assert_not_called() diff --git a/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py b/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py new file mode 100644 index 00000000000..85e392a1f4a --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py @@ -0,0 +1,158 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from unittest.mock import patch + +import pytest +from django.utils import timezone + +from plane.bgtasks.issue_cascade_task import cascade_state_to_sub_issues +from plane.db.models import Issue, Project, ProjectMember, State + + +@pytest.fixture +def project(db, workspace, create_user): + """A project owned by the workspace owner.""" + project = Project.objects.create( + name="Cascade Project", + identifier="CAS", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + return project + + +@pytest.fixture +def states(project, workspace): + """One state per relevant group (plus a second completed state).""" + return { + "backlog": State.objects.create( + name="Backlog", project=project, workspace=workspace, group="backlog", default=True + ), + "started": State.objects.create(name="In Progress", project=project, workspace=workspace, group="started"), + "completed": State.objects.create(name="Done", project=project, workspace=workspace, group="completed"), + "completed_alt": State.objects.create(name="Shipped", project=project, workspace=workspace, group="completed"), + "cancelled": State.objects.create(name="Cancelled", project=project, workspace=workspace, group="cancelled"), + } + + +def _make_issue(name, workspace, project, state, user, parent=None): + return Issue.objects.create( + name=name, + workspace=workspace, + project=project, + state=state, + parent=parent, + created_by=user, + ) + + +def _run(parent, new_state, project, user): + cascade_state_to_sub_issues( + parent_issue_id=str(parent.id), + new_state_id=str(new_state.id), + actor_id=str(user.id), + project_id=str(project.id), + epoch=int(timezone.now().timestamp()), + ) + + +@pytest.mark.unit +@patch("plane.bgtasks.issue_cascade_task.issue_activity.delay") +class TestCascadeStateToSubIssues: + """Unit tests for the recursive close-cascade background task.""" + + @pytest.mark.django_db + def test_mirror_to_completed(self, mock_activity, workspace, project, states, create_user): + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + child = _make_issue("Child", workspace, project, states["started"], create_user, parent=parent) + + _run(parent, states["completed"], project, create_user) + + child.refresh_from_db() + assert child.state_id == states["completed"].id + assert child.completed_at is not None + mock_activity.assert_called_once() + + @pytest.mark.django_db + def test_mirror_to_cancelled_clears_completed_at(self, mock_activity, workspace, project, states, create_user): + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + child = _make_issue("Child", workspace, project, states["started"], create_user, parent=parent) + + _run(parent, states["cancelled"], project, create_user) + + child.refresh_from_db() + assert child.state_id == states["cancelled"].id + assert child.completed_at is None + mock_activity.assert_called_once() + + @pytest.mark.django_db + def test_already_terminal_child_is_skipped(self, mock_activity, workspace, project, states, create_user): + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + # Child is already in a (different) completed state; it must not be touched. + child = _make_issue("Child", workspace, project, states["completed_alt"], create_user, parent=parent) + + _run(parent, states["completed"], project, create_user) + + child.refresh_from_db() + assert child.state_id == states["completed_alt"].id + mock_activity.assert_not_called() + + @pytest.mark.django_db + def test_recurses_into_grandchildren(self, mock_activity, workspace, project, states, create_user): + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + child = _make_issue("Child", workspace, project, states["started"], create_user, parent=parent) + grandchild = _make_issue("Grandchild", workspace, project, states["started"], create_user, parent=child) + + _run(parent, states["completed"], project, create_user) + + child.refresh_from_db() + grandchild.refresh_from_db() + assert child.state_id == states["completed"].id + assert grandchild.state_id == states["completed"].id + assert mock_activity.call_count == 2 + + @pytest.mark.django_db + def test_open_grandchild_under_closed_child_is_reached( + self, mock_activity, workspace, project, states, create_user + ): + # Middle child already closed -> skipped, but its open grandchild must still be mirrored. + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + child = _make_issue("Child", workspace, project, states["completed_alt"], create_user, parent=parent) + grandchild = _make_issue("Grandchild", workspace, project, states["started"], create_user, parent=child) + + _run(parent, states["completed"], project, create_user) + + child.refresh_from_db() + grandchild.refresh_from_db() + assert child.state_id == states["completed_alt"].id # unchanged (already terminal) + assert grandchild.state_id == states["completed"].id # reached through the closed child + mock_activity.assert_called_once() + + @pytest.mark.django_db + def test_cycle_is_safe(self, mock_activity, workspace, project, states, create_user): + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + child = _make_issue("Child", workspace, project, states["started"], create_user, parent=parent) + # Introduce a cycle: parent.parent = child (bypass save side effects). + Issue.objects.filter(id=parent.id).update(parent=child) + + # Must terminate (visited-set) and mirror the child exactly once. + _run(parent, states["completed"], project, create_user) + + child.refresh_from_db() + assert child.state_id == states["completed"].id + mock_activity.assert_called_once() + + @pytest.mark.django_db + def test_non_terminal_target_is_noop(self, mock_activity, workspace, project, states, create_user): + # Defensive: the task only fires for terminal target states. + parent = _make_issue("Parent", workspace, project, states["backlog"], create_user) + child = _make_issue("Child", workspace, project, states["started"], create_user, parent=parent) + + _run(parent, states["started"], project, create_user) + + child.refresh_from_db() + assert child.state_id == states["started"].id + mock_activity.assert_not_called() diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index 3a8b98631c8..4a02176e479 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -11,7 +11,7 @@ import { useTranslation } from "@plane/i18n"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IProject } from "@plane/types"; import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; -import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; +import { AutoArchiveAutomation, AutoCloseAutomation, CascadeStateAutomation } from "@/components/automation"; import { PageHead } from "@/components/core/page-title"; import { SettingsContentWrapper } from "@/components/settings/content-wrapper"; import { SettingsHeading } from "@/components/settings/heading"; @@ -66,6 +66,7 @@ function AutomationSettingsPage({ params }: Route.ComponentProps) {
+
diff --git a/apps/web/core/components/automation/auto-close-automation.tsx b/apps/web/core/components/automation/auto-close-automation.tsx index 48c7203abde..8bd0cfe9853 100644 --- a/apps/web/core/components/automation/auto-close-automation.tsx +++ b/apps/web/core/components/automation/auto-close-automation.tsx @@ -85,7 +85,7 @@ export const AutoCloseAutomation = observer(function AutoCloseAutomation(props: handleClose={() => setmonthModal(false)} handleChange={handleChange} /> -
+
diff --git a/apps/web/core/components/automation/cascade-state-automation.tsx b/apps/web/core/components/automation/cascade-state-automation.tsx new file mode 100644 index 00000000000..be0badd9494 --- /dev/null +++ b/apps/web/core/components/automation/cascade-state-automation.tsx @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { ListTree } from "lucide-react"; +// plane imports +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { IProject } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; +// component +import { SettingsControlItem } from "@/components/settings/control-item"; +// hooks +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; + +type Props = { + handleChange: (formData: Partial) => Promise; +}; + +export const CascadeStateAutomation = observer(function CascadeStateAutomation(props: Props) { + const { handleChange } = props; + // router + const { workspaceSlug } = useParams(); + // store hooks + const { allowPermissions } = useUserPermissions(); + const { currentProjectDetails } = useProject(); + const { t } = useTranslation(); + + const isAdmin = allowPermissions( + [EUserPermissions.ADMIN], + EUserPermissionsLevel.PROJECT, + workspaceSlug?.toString(), + currentProjectDetails?.id + ); + + const cascadeStatus = useMemo(() => currentProjectDetails?.cascade_state_on_close ?? false, [currentProjectDetails]); + + return ( +
+
+
+ +
+ void handleChange({ cascade_state_on_close: val })} + size="sm" + disabled={!isAdmin} + /> + } + /> +
+
+ ); +}); diff --git a/apps/web/core/components/automation/index.ts b/apps/web/core/components/automation/index.ts index 9f126cd0d06..41cad35c4a9 100644 --- a/apps/web/core/components/automation/index.ts +++ b/apps/web/core/components/automation/index.ts @@ -6,4 +6,5 @@ export * from "./auto-close-automation"; export * from "./auto-archive-automation"; +export * from "./cascade-state-automation"; export * from "./select-month-modal"; diff --git a/packages/i18n/src/locales/en/project-settings.json b/packages/i18n/src/locales/en/project-settings.json index b6ccb9c962b..7f6e3fc2e3c 100644 --- a/packages/i18n/src/locales/en/project-settings.json +++ b/packages/i18n/src/locales/en/project-settings.json @@ -191,6 +191,10 @@ "duration": "Auto-close work items that are inactive for", "auto_close_status": "Auto-close status" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Auto-reminders", "description": "Plane will automatically send reminders via email and in-app notifications to keep your team on track with deadlines.", diff --git a/packages/types/src/project/projects.ts b/packages/types/src/project/projects.ts index 3cdbed1deb4..34bb6c06dec 100644 --- a/packages/types/src/project/projects.ts +++ b/packages/types/src/project/projects.ts @@ -45,6 +45,7 @@ export interface IPartialProject { export interface IProject extends IPartialProject { archive_in?: number; close_in?: number; + cascade_state_on_close?: boolean; // only for uploading the cover image cover_image_asset?: null; cover_image?: string; From d1975b63523c0a907f9c7842f82440323d72df62 Mon Sep 17 00:00:00 2001 From: koreanjoker Date: Sat, 18 Jul 2026 17:04:27 +0900 Subject: [PATCH 2/2] fix: address review feedback on sub-issue status cascade - i18n: add the two cascade-state keys to the remaining 18 locales so the sync check passes. Values are the English source as a placeholder; they still need real translations (this repo translates every locale). - Scope the cascade's target state lookup to the project and drop the all_state_objects manager, so a soft-deleted or cross-project state can no longer drive the cascade. - Cascade on terminal -> terminal moves too (e.g. completed -> cancelled). The previous_group guard suppressed the cascade whenever the parent was already closed, stranding still-open descendants. Re-entry is not a concern: the task writes via bulk_update and bypasses save(). - Bound total traversal with MAX_DESCENDANTS. MAX_DEPTH only capped the tree's height, so a wide tree could fan out one activity task per descendant. Hitting the cap logs a warning instead of failing silently. Adds regression coverage for the terminal -> terminal cascade, the project scoping of the target state, and the new traversal cap. --- apps/api/plane/app/views/issue/base.py | 10 ++-- apps/api/plane/bgtasks/issue_cascade_task.py | 33 +++++++++++- .../contract/app/test_issue_cascade_app.py | 54 +++++++++++++++++++ .../unit/bg_tasks/test_issue_cascade_task.py | 52 ++++++++++++++++++ .../i18n/src/locales/cs/project-settings.json | 4 ++ .../i18n/src/locales/de/project-settings.json | 4 ++ .../i18n/src/locales/es/project-settings.json | 4 ++ .../i18n/src/locales/fr/project-settings.json | 4 ++ .../i18n/src/locales/id/project-settings.json | 4 ++ .../i18n/src/locales/it/project-settings.json | 4 ++ .../i18n/src/locales/ja/project-settings.json | 4 ++ .../i18n/src/locales/ko/project-settings.json | 4 ++ .../i18n/src/locales/pl/project-settings.json | 4 ++ .../src/locales/pt-BR/project-settings.json | 4 ++ .../i18n/src/locales/ro/project-settings.json | 4 ++ .../i18n/src/locales/ru/project-settings.json | 4 ++ .../i18n/src/locales/sk/project-settings.json | 4 ++ .../src/locales/tr-TR/project-settings.json | 4 ++ .../i18n/src/locales/ua/project-settings.json | 4 ++ .../src/locales/vi-VN/project-settings.json | 4 ++ .../src/locales/zh-CN/project-settings.json | 4 ++ .../src/locales/zh-TW/project-settings.json | 4 ++ 22 files changed, 213 insertions(+), 8 deletions(-) diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 353ffaa5c4d..7efb28be48d 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -719,15 +719,13 @@ def partial_update(self, request, slug, project_id, pk=None): # enters a completed/cancelled group and the project has opted in. new_state_id = issue.state_id if new_state_id and new_state_id != previous_state_id: - state_groups = dict( - State.objects.filter(id__in=[previous_state_id, new_state_id]).values_list("id", "group") - ) - new_group = state_groups.get(new_state_id) - previous_group = state_groups.get(previous_state_id) + new_group = State.objects.filter(id=new_state_id).values_list("group", flat=True).first() terminal_groups = [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value] + # Note: a terminal -> terminal move (e.g. completed -> cancelled) + # must cascade too, so the previous group is deliberately not + # gated on here. if ( new_group in terminal_groups - and previous_group not in terminal_groups and Project.objects.filter(id=project_id, cascade_state_on_close=True).exists() ): cascade_state_to_sub_issues.delay( diff --git a/apps/api/plane/bgtasks/issue_cascade_task.py b/apps/api/plane/bgtasks/issue_cascade_task.py index 1154f9b90b8..d16dfe594ca 100644 --- a/apps/api/plane/bgtasks/issue_cascade_task.py +++ b/apps/api/plane/bgtasks/issue_cascade_task.py @@ -4,6 +4,7 @@ # Python imports import json +import logging # Third party imports from celery import shared_task @@ -16,6 +17,8 @@ from plane.db.models import Issue, State, StateGroup from plane.utils.exception_logger import log_exception +logger = logging.getLogger("plane.worker") + # State groups that are considered terminal (closed) for the cascade. TERMINAL_GROUPS = [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value] @@ -23,6 +26,12 @@ # self referencing FK with no cycle guard, so this bounds pathological data. MAX_DEPTH = 20 +# Safety cap on the total number of descendants visited in one cascade. MAX_DEPTH +# alone bounds the tree's height, not its width: a shallow but very wide tree can +# still fan out into one Celery activity task per issue. This bounds the total +# work a single close can schedule. +MAX_DESCENDANTS = 1000 + @shared_task def cascade_state_to_sub_issues(parent_issue_id, new_state_id, actor_id, project_id, epoch): @@ -36,7 +45,7 @@ def cascade_state_to_sub_issues(parent_issue_id, new_state_id, actor_id, project """ try: # Resolve the target state and confirm it is a terminal (closing) state. - new_state = State.all_state_objects.filter(id=new_state_id).first() + new_state = State.objects.filter(id=new_state_id, project_id=project_id).first() if new_state is None or new_state.group not in TERMINAL_GROUPS: return @@ -48,7 +57,10 @@ def cascade_state_to_sub_issues(parent_issue_id, new_state_id, actor_id, project issues_to_update = [] depth = 0 - while frontier and depth < MAX_DEPTH: + descendants_seen = 0 + truncated = False + + while frontier and depth < MAX_DEPTH and not truncated: children = list( Issue.issue_objects.filter( parent_id__in=frontier, @@ -62,7 +74,14 @@ def cascade_state_to_sub_issues(parent_issue_id, new_state_id, actor_id, project child_id = str(child.id) if child_id in visited: continue + if descendants_seen >= MAX_DESCENDANTS: + # Stop walking rather than fan out an unbounded number of + # per issue activity tasks. Already collected updates below + # are still applied. + truncated = True + break visited.add(child_id) + descendants_seen += 1 # Continue traversing through every child, even terminal ones, # so open grandchildren under a closed child are still reached. next_frontier.append(child_id) @@ -79,6 +98,16 @@ def cascade_state_to_sub_issues(parent_issue_id, new_state_id, actor_id, project frontier = next_frontier depth += 1 + if truncated: + logger.warning( + "cascade_state_to_sub_issues truncated: parent_issue_id=%s project_id=%s " + "reached MAX_DESCENDANTS=%s at depth=%s; deeper descendants were not updated", + parent_issue_id, + project_id, + MAX_DESCENDANTS, + depth, + ) + if not issues_to_update: return diff --git a/apps/api/plane/tests/contract/app/test_issue_cascade_app.py b/apps/api/plane/tests/contract/app/test_issue_cascade_app.py index 6a4d317f1b7..704163fa452 100644 --- a/apps/api/plane/tests/contract/app/test_issue_cascade_app.py +++ b/apps/api/plane/tests/contract/app/test_issue_cascade_app.py @@ -14,6 +14,7 @@ import pytest from rest_framework import status +from plane.bgtasks.issue_cascade_task import cascade_state_to_sub_issues from plane.db.models import Issue, Project, ProjectMember, State @@ -108,3 +109,56 @@ def test_no_enqueue_when_option_disabled( assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}" mock_cascade.delay.assert_not_called() + + @pytest.mark.django_db + @patch("plane.bgtasks.issue_cascade_task.issue_activity.delay") + @patch("plane.app.views.issue.base.cascade_state_to_sub_issues") + @patch("plane.app.views.issue.base.issue_description_version_task") + @patch("plane.app.views.issue.base.model_activity") + @patch("plane.app.views.issue.base.issue_activity") + def test_terminal_to_terminal_transition_still_cascades( + self, + mock_issue_activity, + mock_model_activity, + mock_desc_version, + mock_cascade, + mock_task_activity, + session_client, + workspace, + create_user, + ): + """Regression: completed -> cancelled must cascade to still-open descendants. + + The parent is already terminal, so an earlier ``previous_group not in + terminal_groups`` guard suppressed the cascade entirely and left open + sub-issues stranded in their old state. + """ + project = _project(workspace, create_user, cascade=True) + started, completed = _states(project, workspace) + cancelled = State.objects.create(name="Cancelled", project=project, workspace=workspace, group="cancelled") + + # Parent is already in a terminal (completed) group. + parent = _issue("Parent", workspace, project, completed, create_user) + # ...but a descendant is still open at the moment of the re-close. + child = _issue("Child", workspace, project, started, create_user, parent=parent) + grandchild = _issue("Grandchild", workspace, project, started, create_user, parent=child) + + response = session_client.patch( + _url(workspace.slug, project.id, parent.id), + {"state_id": str(cancelled.id)}, + format="json", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}" + mock_cascade.delay.assert_called_once() + kwargs = mock_cascade.delay.call_args.kwargs + assert kwargs["new_state_id"] == str(cancelled.id) + + # Run the task the view enqueued and confirm the open descendants moved. + cascade_state_to_sub_issues(**kwargs) + + child.refresh_from_db() + grandchild.refresh_from_db() + assert child.state_id == cancelled.id + assert grandchild.state_id == cancelled.id + assert child.completed_at is None diff --git a/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py b/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py index 85e392a1f4a..573a06eeec3 100644 --- a/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py +++ b/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py @@ -7,6 +7,7 @@ import pytest from django.utils import timezone +import plane.bgtasks.issue_cascade_task as cascade_module from plane.bgtasks.issue_cascade_task import cascade_state_to_sub_issues from plane.db.models import Issue, Project, ProjectMember, State @@ -156,3 +157,54 @@ def test_non_terminal_target_is_noop(self, mock_activity, workspace, project, st child.refresh_from_db() assert child.state_id == states["started"].id mock_activity.assert_not_called() + + @pytest.mark.django_db + def test_state_from_another_project_is_ignored(self, mock_activity, workspace, project, states, create_user): + # The target state must belong to the same project as the cascade. + other_project = Project.objects.create( + name="Other", identifier="OTH", workspace=workspace, created_by=create_user + ) + foreign_state = State.objects.create(name="Done", project=other_project, workspace=workspace, group="completed") + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + child = _make_issue("Child", workspace, project, states["started"], create_user, parent=parent) + + _run(parent, foreign_state, project, create_user) + + child.refresh_from_db() + assert child.state_id == states["started"].id + mock_activity.assert_not_called() + + @pytest.mark.django_db + def test_total_descendants_are_capped(self, mock_activity, workspace, project, states, create_user, monkeypatch): + """A wide tree must stop at MAX_DESCENDANTS instead of fanning out unbounded. + + MAX_DEPTH only bounds the tree's height; without a total cap a single + close can schedule one activity task per descendant. + """ + monkeypatch.setattr(cascade_module, "MAX_DESCENDANTS", 3) + + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + children = [ + _make_issue(f"Child {i}", workspace, project, states["started"], create_user, parent=parent) + for i in range(6) + ] + + _run(parent, states["completed"], project, create_user) + + moved = [c for c in children if Issue.objects.get(id=c.id).state_id == states["completed"].id] + assert len(moved) == 3, f"expected the cap to stop at 3, got {len(moved)}" + assert mock_activity.call_count == 3 + + @pytest.mark.django_db + def test_cap_is_logged_when_reached(self, mock_activity, workspace, project, states, create_user, monkeypatch): + # Hitting the cap must not fail silently. + monkeypatch.setattr(cascade_module, "MAX_DESCENDANTS", 1) + parent = _make_issue("Parent", workspace, project, states["started"], create_user) + for i in range(3): + _make_issue(f"Child {i}", workspace, project, states["started"], create_user, parent=parent) + + with patch.object(cascade_module.logger, "warning") as mock_warning: + _run(parent, states["completed"], project, create_user) + + mock_warning.assert_called_once() + assert "MAX_DESCENDANTS" in mock_warning.call_args.args[0] diff --git a/packages/i18n/src/locales/cs/project-settings.json b/packages/i18n/src/locales/cs/project-settings.json index 967e998d006..18de07d507e 100644 --- a/packages/i18n/src/locales/cs/project-settings.json +++ b/packages/i18n/src/locales/cs/project-settings.json @@ -191,6 +191,10 @@ "duration": "Automaticky uzavřít pracovní položky, které jsou neaktivní po dobu", "auto_close_status": "Stav automatického uzavření" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Automatické upozornění", "description": "Plane automaticky pošle upozornění přes e-mail a v aplikaci, aby vaše tým zůstal na cestě s termíny.", diff --git a/packages/i18n/src/locales/de/project-settings.json b/packages/i18n/src/locales/de/project-settings.json index 74afb0c94fe..67c8d097316 100644 --- a/packages/i18n/src/locales/de/project-settings.json +++ b/packages/i18n/src/locales/de/project-settings.json @@ -191,6 +191,10 @@ "duration": "Arbeitselemente automatisch schließen, die inaktiv sind seit", "auto_close_status": "Auto-Schließ-Status" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Automatische Erinnerungen", "description": "Plane wird automatische Erinnerungen via E-Mail und in-App-Benachrichtigungen senden, um Ihr Team auf den Lauf der Dinge zu halten.", diff --git a/packages/i18n/src/locales/es/project-settings.json b/packages/i18n/src/locales/es/project-settings.json index 6ec66f538ee..820cc7aea96 100644 --- a/packages/i18n/src/locales/es/project-settings.json +++ b/packages/i18n/src/locales/es/project-settings.json @@ -191,6 +191,10 @@ "duration": "Cerrar automáticamente elementos de trabajo inactivos durante", "auto_close_status": "Estado de cierre automático" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Recordatorios automáticos", "description": "Plane enviará recordatorios automáticos via correo electrónico y notificaciones en la aplicación para mantener a tu equipo en el camino de los plazos.", diff --git a/packages/i18n/src/locales/fr/project-settings.json b/packages/i18n/src/locales/fr/project-settings.json index 533a0adf97b..8c4daea1b6f 100644 --- a/packages/i18n/src/locales/fr/project-settings.json +++ b/packages/i18n/src/locales/fr/project-settings.json @@ -191,6 +191,10 @@ "duration": "Fermer automatiquement les éléments de travail inactifs depuis", "auto_close_status": "Statut de fermeture automatique" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Rappels automatiques", "description": "Plane enverra automatiquement des rappels via e-mail et notifications dans l'application pour garder votre équipe sur le bon chemin des délais.", diff --git a/packages/i18n/src/locales/id/project-settings.json b/packages/i18n/src/locales/id/project-settings.json index b5ac935bf7c..db1fd236893 100644 --- a/packages/i18n/src/locales/id/project-settings.json +++ b/packages/i18n/src/locales/id/project-settings.json @@ -191,6 +191,10 @@ "duration": "Tutup otomatis item kerja yang tidak aktif selama", "auto_close_status": "Status penutupan otomatis" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Pengingat otomatis", "description": "Plane akan mengirim pengingat otomatis via email dan notifikasi dalam aplikasi untuk memantau kemajuan proyek Anda.", diff --git a/packages/i18n/src/locales/it/project-settings.json b/packages/i18n/src/locales/it/project-settings.json index 5fc17c535a9..04f11ac87ce 100644 --- a/packages/i18n/src/locales/it/project-settings.json +++ b/packages/i18n/src/locales/it/project-settings.json @@ -191,6 +191,10 @@ "duration": "Chiudi automaticamente gli elementi di lavoro inattivi per", "auto_close_status": "Stato di chiusura automatica" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Promemoria automatici", "description": "Plane invierà automaticamente promemoria via email e notifiche in-app per mantenere il tuo team in linea con le scadenze.", diff --git a/packages/i18n/src/locales/ja/project-settings.json b/packages/i18n/src/locales/ja/project-settings.json index c6e8521a7be..2bd60a3072d 100644 --- a/packages/i18n/src/locales/ja/project-settings.json +++ b/packages/i18n/src/locales/ja/project-settings.json @@ -191,6 +191,10 @@ "duration": "非アクティブな作業項目を自動的に閉じる", "auto_close_status": "自動クローズステータス" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "自動リマインダー", "description": "Planeはメールとアプリ内通知を通じて、チームが期限に沿って進めるように自動的にリマインダーを送信します。", diff --git a/packages/i18n/src/locales/ko/project-settings.json b/packages/i18n/src/locales/ko/project-settings.json index cf0716946e1..8ed9ea68508 100644 --- a/packages/i18n/src/locales/ko/project-settings.json +++ b/packages/i18n/src/locales/ko/project-settings.json @@ -191,6 +191,10 @@ "duration": "다음 기간 동안 비활성 작업 항목 자동 닫기", "auto_close_status": "자동 닫기 상태" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "자동 알림", "description": "Plane은 이메일과 앱 알림을 통해 팀이 마감일에 따라 추진하도록 자동으로 알림을 보냅니다.", diff --git a/packages/i18n/src/locales/pl/project-settings.json b/packages/i18n/src/locales/pl/project-settings.json index 302ec78979b..39b974649f1 100644 --- a/packages/i18n/src/locales/pl/project-settings.json +++ b/packages/i18n/src/locales/pl/project-settings.json @@ -191,6 +191,10 @@ "duration": "Zamknij elementy nieaktywne dłużej niż", "auto_close_status": "Status automatycznego zamknięcia" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Automatyczne przypomnienia", "description": "Plane automatycznie wysyła przypomnienia przez e-mail i powiadomienia w aplikacji, aby Twoja ekipa utrzymała się na ścieżce do terminów.", diff --git a/packages/i18n/src/locales/pt-BR/project-settings.json b/packages/i18n/src/locales/pt-BR/project-settings.json index 401a26f8d02..c210b20d153 100644 --- a/packages/i18n/src/locales/pt-BR/project-settings.json +++ b/packages/i18n/src/locales/pt-BR/project-settings.json @@ -191,6 +191,10 @@ "duration": "Fechar automaticamente itens de trabalho que estão inativos por", "auto_close_status": "Status de fechamento automático" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Avisos automáticos", "description": "O Plane enviará avisos automáticos via e-mail e notificações no aplicativo para manter sua equipe no caminho dos prazos.", diff --git a/packages/i18n/src/locales/ro/project-settings.json b/packages/i18n/src/locales/ro/project-settings.json index 8472198dc2b..d804e6d683c 100644 --- a/packages/i18n/src/locales/ro/project-settings.json +++ b/packages/i18n/src/locales/ro/project-settings.json @@ -191,6 +191,10 @@ "duration": "Închide automat activitățile inactive de", "auto_close_status": "Stare închidere automată" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Avertismente automate", "description": "Plane va trimite avertizări automate prin e-mail și notificări în aplicație pentru a menține echipa pe calea termenelor.", diff --git a/packages/i18n/src/locales/ru/project-settings.json b/packages/i18n/src/locales/ru/project-settings.json index 6ecde27b9df..a0006e54a07 100644 --- a/packages/i18n/src/locales/ru/project-settings.json +++ b/packages/i18n/src/locales/ru/project-settings.json @@ -191,6 +191,10 @@ "duration": "Автоматическое закрытие рабочих элементов, которые неактивны в течение", "auto_close_status": "Статус автоматического закрытия" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Автоматические напоминания", "description": "Plane автоматически будет отправлять напоминания через email и в приложении, чтобы ваша команда оставалась на пути к срокам.", diff --git a/packages/i18n/src/locales/sk/project-settings.json b/packages/i18n/src/locales/sk/project-settings.json index 18ce0154ae5..c616b69fc1a 100644 --- a/packages/i18n/src/locales/sk/project-settings.json +++ b/packages/i18n/src/locales/sk/project-settings.json @@ -191,6 +191,10 @@ "duration": "Uzatvoriť položky neaktívne dlhšie ako", "auto_close_status": "Stav pre automatické uzatvorenie" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Automatické upozornenia", "description": "Plane automaticky bude poslať upozornenia cez email a notifikácie v aplikácii, aby vaša ekipa zostala na ceste s termínmi.", diff --git a/packages/i18n/src/locales/tr-TR/project-settings.json b/packages/i18n/src/locales/tr-TR/project-settings.json index 3eb32620d39..350fdf4dbc7 100644 --- a/packages/i18n/src/locales/tr-TR/project-settings.json +++ b/packages/i18n/src/locales/tr-TR/project-settings.json @@ -191,6 +191,10 @@ "duration": "Şu süre etkin olmayan iş öğelerini otomatik kapat", "auto_close_status": "Otomatik kapatma durumu" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Otomatik hatırlatmalar", "description": "Plane, e-posta ve uygulama bildirimleri aracılığıyla otomatik olarak takımınızı zamanlama yolunda tutacaktır.", diff --git a/packages/i18n/src/locales/ua/project-settings.json b/packages/i18n/src/locales/ua/project-settings.json index 987be6fa7f7..f6e5f21b9ff 100644 --- a/packages/i18n/src/locales/ua/project-settings.json +++ b/packages/i18n/src/locales/ua/project-settings.json @@ -191,6 +191,10 @@ "duration": "Закривати одиниці, що неактивні понад", "auto_close_status": "Стан для автоматичного закриття" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Автоматичні сповіщення", "description": "Plane автоматично буде надсилати сповіщення через email та в додатку, щоб ваша команда залишалася на шляху до термінів.", diff --git a/packages/i18n/src/locales/vi-VN/project-settings.json b/packages/i18n/src/locales/vi-VN/project-settings.json index 33f3ab63af1..90cdd586c17 100644 --- a/packages/i18n/src/locales/vi-VN/project-settings.json +++ b/packages/i18n/src/locales/vi-VN/project-settings.json @@ -191,6 +191,10 @@ "duration": "Tự động đóng không hoạt động", "auto_close_status": "Trạng thái tự động đóng" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "Nhắc nhở tự động", "description": "Plane sẽ tự động gửi nhắc nhở qua email và thông báo trong ứng dụng để giữ cho nhóm của bạn trên đường đến các hạn chót.", diff --git a/packages/i18n/src/locales/zh-CN/project-settings.json b/packages/i18n/src/locales/zh-CN/project-settings.json index 594502dfa19..e55441584c9 100644 --- a/packages/i18n/src/locales/zh-CN/project-settings.json +++ b/packages/i18n/src/locales/zh-CN/project-settings.json @@ -191,6 +191,10 @@ "duration": "自动关闭不活跃", "auto_close_status": "自动关闭状态" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "自动提醒", "description": "Plane 将自动通过电子邮件和应用程序通知来提醒您的团队保持进度。", diff --git a/packages/i18n/src/locales/zh-TW/project-settings.json b/packages/i18n/src/locales/zh-TW/project-settings.json index a7c3a476f2e..837d6794300 100644 --- a/packages/i18n/src/locales/zh-TW/project-settings.json +++ b/packages/i18n/src/locales/zh-TW/project-settings.json @@ -191,6 +191,10 @@ "duration": "自動關閉非活動工作項目", "auto_close_status": "自動關閉狀態" }, + "cascade-state": { + "title": "Auto-close sub-issues", + "description": "When a parent work item is completed or cancelled, its sub-issues are moved to the same state." + }, "auto-remind": { "title": "自動提醒", "description": "Plane 將自動通過電子郵件和應用程式通知來提醒您的團隊保持進度。",