diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 3b8b05a6a2f..7efb28be48d 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,26 @@ 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: + 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 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..d16dfe594ca --- /dev/null +++ b/apps/api/plane/bgtasks/issue_cascade_task.py @@ -0,0 +1,133 @@ +# 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 +import logging + +# 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 + +logger = logging.getLogger("plane.worker") + +# 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 + +# 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): + """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.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 + + 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 + + descendants_seen = 0 + truncated = False + + while frontier and depth < MAX_DEPTH and not truncated: + 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 + 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) + + # 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 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 + + 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..704163fa452 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_issue_cascade_app.py @@ -0,0 +1,164 @@ +# 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.bgtasks.issue_cascade_task import cascade_state_to_sub_issues +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() + + @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 new file mode 100644 index 00000000000..573a06eeec3 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_issue_cascade_task.py @@ -0,0 +1,210 @@ +# 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 + +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 + + +@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() + + @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/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) {