Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/api/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -59,6 +60,8 @@
ModuleIssue,
Project,
ProjectMember,
State,
StateGroup,
UserRecentVisit,
)
from plane.utils.filters import ComplexFilterBackend, IssueFilterSet
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Expand Down
133 changes: 133 additions & 0 deletions apps/api/plane/bgtasks/issue_cascade_task.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

[
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions apps/api/plane/db/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
164 changes: 164 additions & 0 deletions apps/api/plane/tests/contract/app/test_issue_cascade_app.py
Original file line number Diff line number Diff line change
@@ -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
Loading