-
Notifications
You must be signed in to change notification settings - Fork 5k
feat: cascade parent issue status to sub-issues on close #9433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kimnamwook1
wants to merge
2
commits into
makeplane:preview
Choose a base branch
from
kimnamwook1:feat/status-cascade
base: preview
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
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) | ||
|
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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
18 changes: 18 additions & 0 deletions
18
apps/api/plane/db/migrations/0122_project_cascade_state_on_close.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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), | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
164 changes: 164 additions & 0 deletions
164
apps/api/plane/tests/contract/app/test_issue_cascade_app.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.