Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
37f436f
feat(tasks): add artifact comments and mention activity
puemos Aug 4, 2026
716634e
fix(tasks): complete and secure artifact comments
puemos Aug 4, 2026
eccd8ba
fix(tasks): satisfy Python CI checks
puemos Aug 4, 2026
d820b7b
fix(tasks): type comment test payload
puemos Aug 4, 2026
68633d0
fix(comments): use safe object lookup hook
puemos Aug 4, 2026
0404d5b
chore: update OpenAPI generated types
puemos Aug 4, 2026
548e0a6
chore(mcp): update comments list schema snapshot
puemos Aug 4, 2026
a808c22
fix(comments): support relational canvases
puemos Aug 4, 2026
383c579
feat(canvas): add selection comments and comments tab
puemos Aug 4, 2026
c3cdf48
fix(canvas): scope comments pane to current canvas
puemos Aug 4, 2026
20f06be
fix(canvas): make versioned comments navigable
puemos Aug 4, 2026
e43bf41
fix(canvas): narrow facade import boundary
puemos Aug 4, 2026
31e083d
fix(canvas): surface comment count in header
puemos Aug 4, 2026
5d3c429
fix(canvas): open comments from view mode
puemos Aug 4, 2026
3989d56
fix(canvas): simplify comment references
puemos Aug 4, 2026
90f7cc5
test(comments): cover mention activity deep links
puemos Aug 4, 2026
8720b8d
fix(comments): grant mentioned users task read access
puemos Aug 4, 2026
b7f9b0c
fix(comments): keep personal-space mentions private
puemos Aug 4, 2026
00a4eb0
fix(comments): preserve distinct mention activity
puemos Aug 4, 2026
f69a578
fix(comments): order replies by sent time
puemos Aug 4, 2026
3798266
fix(tasks): shorten comment mention index name
puemos Aug 4, 2026
09b9cbe
feat(tasks): notify comment owners and participants
puemos Aug 4, 2026
bec26d1
refactor(tasks): simplify comment activity notifications
puemos Aug 4, 2026
4c705bc
test(desktop): cover markdown selection comments
puemos Aug 4, 2026
559d79d
fix(canvas): persist text comment highlights
puemos Aug 4, 2026
c95e053
fix(desktop): refresh markdown comment anchors
puemos Aug 4, 2026
d5a31a7
fix(desktop): polish markdown comment selection
puemos Aug 4, 2026
66934c4
fix(comments): unify text highlight styling
puemos Aug 4, 2026
c4dd280
fix(comments): remove focused highlight outline
puemos Aug 4, 2026
a709216
refactor(comments): tighten annotation and activity paths
puemos Aug 4, 2026
e647c5d
feat(tasks): expose scoped task comments to agents
puemos Aug 4, 2026
e329f35
refactor(comments): tighten boundaries and runtime behavior
puemos Aug 4, 2026
085b736
fix(comments): hide GitHub bot comments from task pane
puemos Aug 4, 2026
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
174 changes: 170 additions & 4 deletions posthog/api/comments.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import logging
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID

from django.core import exceptions as django_exceptions
from django.db import transaction
Expand All @@ -7,6 +9,7 @@

from drf_spectacular.utils import extend_schema
from rest_framework import exceptions, pagination, serializers, viewsets
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request
from rest_framework.response import Response

Expand All @@ -25,6 +28,8 @@
if TYPE_CHECKING:
from posthog.rbac.user_access_control import UserAccessControl

logger = logging.getLogger(__name__)


def _require_ticket_editor_access(
*, team_id: int, item_id: str | None, user_access_control: "UserAccessControl"
Expand All @@ -48,6 +53,86 @@ def _require_ticket_editor_access(
raise exceptions.PermissionDenied("You do not have access to this ticket")


def _record_task_comment_activity(
comment: Comment,
mentions: list[int],
*,
activity_at=None,
include_relationship_recipients: bool = True,
) -> None:
try:
from products.tasks.backend.facade.api import ( # noqa: PLC0415 — keeps the generic comments API decoupled from the tasks product
record_comment_activity,
)

owner_id = None
if comment.scope == "desktop_canvas" and comment.item_id:
from products.canvas.backend.comment_access import canvas_owner_id # noqa: PLC0415

owner_id = canvas_owner_id(team_id=comment.team_id, canvas_id=comment.item_id)

record_comment_activity(
team_id=comment.team_id,
comment_id=comment.id,
mentioned_user_ids=mentions,
include_relationship_recipients=include_relationship_recipients,
target_owner_id=owner_id,
activity_at=activity_at,
)
except Exception:
logger.exception("Failed to project task comment activity", extra={"comment_id": str(comment.id)})


def _mentions_allowed_for_comment_target(
*, team_id: int, scope: str, item_id: str | None, item_context: dict | None
) -> bool:
if scope not in {"task", "task_artifact", "desktop_canvas"}:
return True
task_id = item_id if scope == "task" else (item_context or {}).get("taskId")
if not task_id:
return False
from products.tasks.backend.facade.api import task_comment_mentions_allowed # noqa: PLC0415

return task_comment_mentions_allowed(team_id=team_id, task_id=task_id)


def _task_comment_target_is_accessible(
*, team_id: int, user_id: int | None, task_id: str, scope: str, item_id: str | None
) -> bool:
from products.tasks.backend.facade.api import task_comment_target_is_accessible # noqa: PLC0415

if scope != "desktop_canvas":
return task_comment_target_is_accessible(
team_id=team_id,
user_id=user_id,
task_id=task_id,
scope=scope,
item_id=item_id,
)
if not task_comment_target_is_accessible(
team_id=team_id,
user_id=user_id,
task_id=task_id,
scope="task",
item_id=task_id,
):
return False

from products.canvas.backend.comment_access import canvas_belongs_to_task # noqa: PLC0415

try:
parsed_task_id = UUID(task_id)
except ValueError:
return False
if not item_id:
return False
return canvas_belongs_to_task(
team_id=team_id,
canvas_id=item_id,
task_id=parsed_task_id,
)


class CommentSerializer(serializers.ModelSerializer):
def _extract_mentions_from_rich_content(self, rich_content: dict | None) -> list[int]:
if not rich_content:
Expand All @@ -71,7 +156,8 @@ def find_mentions(node: Any) -> None:
find_mentions(rich_content)
return mentions

created_by = UserBasicSerializer(read_only=True)
created_by = UserBasicSerializer(read_only=True, allow_null=True)
item_context = serializers.JSONField(required=False, allow_null=True)
deleted = ClassicBehaviorBooleanFieldSerializer()
mentions = serializers.ListField(child=serializers.IntegerField(), write_only=True, required=False)
slug = serializers.CharField(write_only=True, required=False)
Expand Down Expand Up @@ -128,6 +214,19 @@ def validate(self, data):
if "is_task" in data and bool(data["is_task"]) != bool(instance.is_task):
raise exceptions.ValidationError({"is_task": "Cannot change task state after creation."})

if not instance and (parent := data.get("source_comment")):
root = parent.source_comment or parent
if root.team_id != self.context["get_team"]().id:
raise exceptions.ValidationError({"source_comment": "Comment not found."})
data["source_comment"] = root
data["scope"] = root.scope
data["item_id"] = root.item_id
reply_context = data.get("item_context") or {}
data["item_context"] = {
**(root.item_context or {}),
**({"is_emoji": reply_context["is_emoji"]} if "is_emoji" in reply_context else {}),
}

# Check both the comment's persisted (scope, item_id) and the submitted target — so losing
# ticket editor access after creation, and re-scoping a comment into or out of a ticket,
# are all caught, not just fresh ticket-message creation.
Expand All @@ -147,6 +246,20 @@ def validate(self, data):
user_access_control=self.context["get_user_access_control"](),
)

target_scope = data.get("scope", instance.scope if instance else None)
target_item_id = data.get("item_id", instance.item_id if instance else None)
target_context = data.get("item_context", instance.item_context if instance else None) or {}
if target_scope in {"task", "task_artifact", "desktop_canvas"}:
task_id = target_item_id if target_scope == "task" else target_context.get("taskId")
if not _task_comment_target_is_accessible(
team_id=self.context["get_team"]().id,
user_id=request.user.id,
task_id=task_id or "",
scope=target_scope,
item_id=target_item_id,
):
raise exceptions.PermissionDenied("You do not have access to this task comment target")

# Skip content validation when soft-deleting a comment
is_deleting = data.get("deleted") is True
if not is_deleting:
Expand Down Expand Up @@ -188,13 +301,21 @@ def create(self, validated_data: Any) -> Any:
validated_data["team_id"] = self.context["team_id"]

mentions = self._filter_mentions_to_organization(mentions, self.context["get_organization"]().id)
if not _mentions_allowed_for_comment_target(
team_id=self.context["team_id"],
scope=validated_data["scope"],
item_id=validated_data.get("item_id"),
item_context=validated_data.get("item_context"),
):
mentions = []

comment = super().create(validated_data)

if mentions:
send_discussions_mentioned.delay(comment.id, mentions, slug)
produce_discussion_mention_events(comment, mentions, slug)
send_mention_notifications(comment, mentions, slug)
_record_task_comment_activity(comment, mentions)

return comment

Expand All @@ -208,6 +329,13 @@ def update(self, instance: Comment, validated_data: dict, **kwargs: Any) -> Comm
request = self.context["request"]

mentions = self._filter_mentions_to_organization(mentions, self.context["get_organization"]().id)
if not _mentions_allowed_for_comment_target(
team_id=instance.team_id,
scope=validated_data.get("scope", instance.scope),
item_id=validated_data.get("item_id", instance.item_id),
item_context=validated_data.get("item_context", instance.item_context),
):
mentions = []

with transaction.atomic():
locked_instance = Comment.objects.select_for_update().get(pk=instance.pk)
Expand All @@ -225,6 +353,12 @@ def update(self, instance: Comment, validated_data: dict, **kwargs: Any) -> Comm
send_discussions_mentioned.delay(updated_instance.id, mentions, slug)
produce_discussion_mention_events(updated_instance, mentions, slug)
send_mention_notifications(updated_instance, mentions, slug)
_record_task_comment_activity(
updated_instance,
mentions,
activity_at=timezone.now(),
include_relationship_recipients=False,
)

return updated_instance

Expand All @@ -240,6 +374,9 @@ class CommentListQueryParamsSerializer(serializers.Serializer):
help_text="Filter by resource type (e.g. Dashboard, FeatureFlag, Insight, Replay).",
)
item_id = serializers.CharField(required=False, help_text="Filter by the ID of the resource being commented on.")
task_id = serializers.UUIDField(
required=False, help_text="Owning task for task, task_artifact, and desktop_canvas comment scopes."
)
search = serializers.CharField(required=False, help_text="Full-text search within comment content.")
source_comment = serializers.CharField(required=False, help_text="Filter replies to a specific parent comment.")
kind = serializers.ChoiceField(
Expand Down Expand Up @@ -277,6 +414,22 @@ def get_serializer_context(self) -> dict[str, Any]:
context["get_user_access_control"] = lambda: self.user_access_control
return context

def safely_get_object(self, queryset: QuerySet) -> Comment:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
comment = get_object_or_404(queryset, **{self.lookup_field: lookup_value})
if comment.scope in {"task", "task_artifact", "desktop_canvas"}:
task_id = comment.item_id if comment.scope == "task" else (comment.item_context or {}).get("taskId")
if not _task_comment_target_is_accessible(
team_id=self.team_id,
user_id=self.request.user.id,
task_id=task_id or "",
scope=comment.scope,
item_id=comment.item_id,
):
raise exceptions.NotFound()
return comment

def _filter_ticket_scoped_queryset(self, queryset: QuerySet, item_id: str | None) -> QuerySet:
"""conversations_ticket comments are ticket messages — restrict them to tickets the
caller has viewer access to, mirroring TicketViewSet's own object-level filtering."""
Expand Down Expand Up @@ -320,10 +473,23 @@ def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:
queryset = queryset.filter(scope=scope)
if scope == "conversations_ticket":
queryset = self._filter_ticket_scoped_queryset(queryset, params.get("item_id"))
elif scope in {"task", "task_artifact", "desktop_canvas"}:
task_id = params.get("task_id")
item_id = params.get("item_id")
if not _task_comment_target_is_accessible(
team_id=self.team_id,
user_id=self.request.user.id,
task_id=task_id or "",
scope=scope,
item_id=item_id,
):
return queryset.none()
if scope != "task":
queryset = queryset.filter(item_context__taskId=str(task_id))
else:
# Exclude conversations_ticket comments by default - they use rich content
# from SupportEditor and should only be viewed in the conversations product
queryset = queryset.exclude(scope="conversations_ticket")
# Product-owned scopes require their own object-level access checks and must
# never leak through an unscoped generic comments query.
queryset = queryset.exclude(scope__in=["conversations_ticket", "task", "task_artifact", "desktop_canvas"])

if params.get("item_id"):
queryset = queryset.filter(item_id=params.get("item_id"))
Expand Down
Loading
Loading