diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py
index 5a771f2ad5c..b094a8cfef3 100644
--- a/apps/api/plane/api/serializers/issue.py
+++ b/apps/api/plane/api/serializers/issue.py
@@ -31,6 +31,7 @@
validate_html_content,
validate_binary_data,
)
+from plane.utils.entity_mention_parser import apply_entity_mention_transformation
from .base import BaseSerializer
from .cycle import CycleLiteSerializer, CycleSerializer
@@ -89,6 +90,10 @@ def validate(self, data):
except Exception:
raise serializers.ValidationError("Invalid HTML passed")
+ # Transform @issue/PROJ-123 style mentions before sanitization
+ if data.get("description_html"):
+ apply_entity_mention_transformation(data, self.context, "description_html")
+
# Validate description content for security
if data.get("description_html"):
is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"])
@@ -719,6 +724,18 @@ class Meta:
"edited_at",
]
+ def validate(self, data):
+ if "comment_html" in data and data["comment_html"]:
+ apply_entity_mention_transformation(data, self.context, "comment_html")
+
+ if "comment_html" in data and data["comment_html"]:
+ is_valid, error_msg, sanitized_html = validate_html_content(data["comment_html"])
+ if not is_valid:
+ raise serializers.ValidationError({"comment_html": "HTML content is not valid"})
+ if sanitized_html is not None:
+ data["comment_html"] = sanitized_html
+ return data
+
class IssueCommentSerializer(BaseSerializer):
"""
@@ -745,6 +762,9 @@ class Meta:
exclude = ["comment_stripped", "comment_json"]
def validate(self, data):
+ if "comment_html" in data and data["comment_html"]:
+ apply_entity_mention_transformation(data, self.context, "comment_html")
+
if "comment_html" in data and data["comment_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(data["comment_html"])
if not is_valid:
diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py
index da9edc66d66..99308bf4e7a 100644
--- a/apps/api/plane/api/views/issue.py
+++ b/apps/api/plane/api/views/issue.py
@@ -1477,7 +1477,10 @@ def post(self, request, slug, project_id, issue_id):
status=status.HTTP_409_CONFLICT,
)
- serializer = IssueCommentCreateSerializer(data=request.data)
+ serializer = IssueCommentCreateSerializer(
+ data=request.data,
+ context={"project_id": project_id},
+ )
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
issue_comment = IssueComment.objects.get(pk=serializer.instance.id)
@@ -1624,7 +1627,12 @@ def patch(self, request, slug, project_id, issue_id, pk):
status=status.HTTP_409_CONFLICT,
)
- serializer = IssueCommentCreateSerializer(issue_comment, data=request.data, partial=True)
+ serializer = IssueCommentCreateSerializer(
+ issue_comment,
+ data=request.data,
+ partial=True,
+ context={"project_id": project_id},
+ )
if serializer.is_valid():
serializer.save()
issue_activity.delay(
diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py
index 2e116cd6613..bed9256c862 100644
--- a/apps/api/plane/app/serializers/issue.py
+++ b/apps/api/plane/app/serializers/issue.py
@@ -47,6 +47,7 @@
validate_html_content,
validate_binary_data,
)
+from plane.utils.entity_mention_parser import apply_entity_mention_transformation
class IssueFlatSerializer(BaseSerializer):
@@ -132,6 +133,10 @@ def validate(self, attrs):
):
raise serializers.ValidationError("Start date cannot exceed target date")
+ # Transform @issue/PROJ-123 style mentions before sanitization
+ if "description_html" in attrs and attrs["description_html"]:
+ apply_entity_mention_transformation(attrs, self.context, "description_html")
+
# Validate description content for security
if "description_html" in attrs and attrs["description_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"])
@@ -716,6 +721,9 @@ class Meta:
]
def validate(self, attrs):
+ if "comment_html" in attrs and attrs["comment_html"]:
+ apply_entity_mention_transformation(attrs, self.context, "comment_html")
+
if "comment_html" in attrs and attrs["comment_html"]:
is_valid, error_msg, sanitized_html = validate_html_content(attrs["comment_html"])
if not is_valid:
diff --git a/apps/api/plane/app/views/issue/comment.py b/apps/api/plane/app/views/issue/comment.py
index 34fe0f9e4b9..4e45c841d00 100644
--- a/apps/api/plane/app/views/issue/comment.py
+++ b/apps/api/plane/app/views/issue/comment.py
@@ -79,7 +79,10 @@ def create(self, request, slug, project_id, issue_id):
{"error": "You are not allowed to comment on the issue"},
status=status.HTTP_400_BAD_REQUEST,
)
- serializer = IssueCommentSerializer(data=request.data)
+ serializer = IssueCommentSerializer(
+ data=request.data,
+ context={"project_id": project_id},
+ )
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user)
issue_activity.delay(
@@ -111,7 +114,12 @@ def partial_update(self, request, slug, project_id, issue_id, pk):
issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk)
requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder)
current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder)
- serializer = IssueCommentSerializer(issue_comment, data=request.data, partial=True)
+ serializer = IssueCommentSerializer(
+ issue_comment,
+ data=request.data,
+ partial=True,
+ context={"project_id": project_id},
+ )
if serializer.is_valid():
if "comment_html" in request.data and request.data["comment_html"] != issue_comment.comment_html:
serializer.save(edited_at=timezone.now())
diff --git a/apps/api/plane/tests/unit/utils/test_entity_mention_parser.py b/apps/api/plane/tests/unit/utils/test_entity_mention_parser.py
new file mode 100644
index 00000000000..bf348f38646
--- /dev/null
+++ b/apps/api/plane/tests/unit/utils/test_entity_mention_parser.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.
+
+import pytest
+
+from plane.db.models import Issue, Project, State, Workspace
+from plane.utils.entity_mention_parser import (
+ build_mention_component,
+ transform_entity_mentions_in_html,
+ transform_entity_mentions_in_text,
+)
+
+
+@pytest.fixture
+def workspace(create_user):
+ return Workspace.objects.create(
+ name="Test Workspace",
+ slug="test-workspace",
+ owner=create_user,
+ )
+
+
+@pytest.fixture
+def project(workspace, create_user):
+ return Project.objects.create(
+ name="Test Project",
+ identifier="ENG",
+ workspace=workspace,
+ created_by=create_user,
+ )
+
+
+@pytest.fixture
+def state(project):
+ return State.objects.create(
+ name="Todo",
+ project=project,
+ group="unstarted",
+ color="#60646C",
+ )
+
+
+@pytest.fixture
+def issue(workspace, project, state, create_user):
+ return Issue.objects.create(
+ name="Referenced Issue",
+ workspace=workspace,
+ project=project,
+ state=state,
+ created_by=create_user,
+ )
+
+
+@pytest.mark.unit
+class TestEntityMentionParser:
+ @pytest.mark.django_db
+ def test_transform_issue_mention_with_type_prefix(self, workspace, project, issue):
+ identifier = f"{project.identifier}-{issue.sequence_id}"
+ html = f"
Blocked by @issue/{identifier}
"
+ result = transform_entity_mentions_in_html(html, workspace_slug=workspace.slug)
+
+ assert "mention-component" in result
+ assert f'entity_identifier="{issue.id}"' in result
+ assert 'entity_name="issue"' in result
+ assert f'entity_display_name="{identifier}"' in result
+
+ @pytest.mark.django_db
+ def test_transform_issue_mention_without_type_prefix(self, workspace, project, issue):
+ identifier = f"{project.identifier}-{issue.sequence_id}"
+ text = f"See @{identifier} for details"
+ result = transform_entity_mentions_in_text(text, workspace_slug=workspace.slug)
+
+ assert "mention-component" in result
+ assert f'entity_identifier="{issue.id}"' in result
+
+ @pytest.mark.django_db
+ def test_transform_project_mention(self, workspace, project):
+ text = "Track in @project/ENG"
+ result = transform_entity_mentions_in_text(text, workspace_slug=workspace.slug)
+
+ assert "mention-component" in result
+ assert f'entity_identifier="{project.id}"' in result
+ assert 'entity_name="project"' in result
+ assert 'entity_display_name="ENG"' in result
+
+ @pytest.mark.django_db
+ def test_unknown_mention_is_left_unchanged(self, workspace):
+ text = "Unknown @issue/ZZZ-999 reference"
+ result = transform_entity_mentions_in_text(text, workspace_slug=workspace.slug)
+ assert result == text
+
+ @pytest.mark.django_db
+ def test_existing_mention_component_is_not_replaced(self, workspace, project, issue):
+ existing = build_mention_component(
+ entity_name="issue",
+ entity_identifier=str(issue.id),
+ entity_display_name=f"{project.identifier}-{issue.sequence_id}",
+ )
+ html = f"