Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
7c27a83
feat(tasks): add artifact comments and mention activity
puemos Aug 5, 2026
a2936f4
fix(tasks): complete and secure artifact comments
puemos Aug 5, 2026
13be27e
fix(tasks): satisfy Python CI checks
puemos Aug 5, 2026
2a36f61
fix(tasks): type comment test payload
puemos Aug 5, 2026
95cf8b3
fix(comments): use safe object lookup hook
puemos Aug 5, 2026
2f063a2
chore: update OpenAPI generated types
puemos Aug 5, 2026
233c4a1
chore(mcp): update comments list schema snapshot
puemos Aug 5, 2026
f108b8d
fix(comments): support relational canvases
puemos Aug 5, 2026
a592f46
feat(canvas): add selection comments and comments tab
puemos Aug 5, 2026
f82df9e
fix(canvas): scope comments pane to current canvas
puemos Aug 5, 2026
c98ca42
fix(canvas): make versioned comments navigable
puemos Aug 5, 2026
74bd623
fix(canvas): narrow facade import boundary
puemos Aug 5, 2026
9d83a17
fix(canvas): surface comment count in header
puemos Aug 5, 2026
074cd1c
fix(canvas): open comments from view mode
puemos Aug 5, 2026
22c87cb
fix(canvas): simplify comment references
puemos Aug 5, 2026
63df27e
test(comments): cover mention activity deep links
puemos Aug 5, 2026
ac26a22
fix(comments): grant mentioned users task read access
puemos Aug 5, 2026
d124575
fix(comments): keep personal-space mentions private
puemos Aug 5, 2026
e5b6fca
fix(comments): preserve distinct mention activity
puemos Aug 5, 2026
1edd1fb
fix(comments): order replies by sent time
puemos Aug 5, 2026
9cc3b96
fix(tasks): shorten comment mention index name
puemos Aug 5, 2026
e88be25
feat(tasks): notify comment owners and participants
puemos Aug 5, 2026
7f91199
refactor(tasks): simplify comment activity notifications
puemos Aug 5, 2026
125174d
test(desktop): cover markdown selection comments
puemos Aug 5, 2026
15b660e
fix(canvas): persist text comment highlights
puemos Aug 5, 2026
72663bc
fix(desktop): refresh markdown comment anchors
puemos Aug 5, 2026
c4cb2d9
fix(desktop): polish markdown comment selection
puemos Aug 5, 2026
2f060e3
fix(comments): unify text highlight styling
puemos Aug 5, 2026
4651003
fix(comments): remove focused highlight outline
puemos Aug 5, 2026
b1c9215
refactor(comments): tighten annotation and activity paths
puemos Aug 5, 2026
9b34bf5
feat(tasks): expose scoped task comments to agents
puemos Aug 5, 2026
698144c
refactor(comments): tighten boundaries and runtime behavior
puemos Aug 5, 2026
d51961c
fix(comments): hide GitHub bot comments from task pane
puemos Aug 5, 2026
51ec807
feat(tasks): add task comments agent skill
puemos Aug 5, 2026
e10dcc2
fix(canvas): clear stale comment selections
puemos Aug 5, 2026
6981644
fix(tasks): route comment skill through MCP exec
puemos Aug 5, 2026
469b202
fix(desktop): stop Markdown artifact annotations wiping the app selec…
puemos Aug 5, 2026
aaf0b47
fix(comments): let a reply's threadState survive the root-context merge
puemos Aug 5, 2026
9fbb547
fix(tasks): enforce IDOR checks for comment activity
puemos Aug 5, 2026
aeff66e
fix(tasks): mark task comments as untrusted feedback
puemos Aug 5, 2026
063dea0
fix(desktop): open comment activity at its target
puemos Aug 5, 2026
fedb757
fix(desktop): show empty comment sources accurately
puemos Aug 5, 2026
420afff
fix(desktop): prevent duplicate selection comments
puemos Aug 5, 2026
4350cd1
chore: update OpenAPI generated types
puemos Aug 5, 2026
6ddb196
fix(canvas): render historical multi-file comments
puemos Aug 5, 2026
102b9f4
test(mcp): update unit test snapshots
puemos Aug 5, 2026
6d58daa
fix(tasks): retry comment activity projection
puemos Aug 5, 2026
6625f5f
fix(tasks): use canonical MCP tool naming
puemos Aug 5, 2026
38f0039
fix(desktop): clean up HTML preview object URLs
puemos Aug 5, 2026
24e0f7e
fix(tasks): bound agent comment responses
puemos Aug 5, 2026
12fde31
chore(tasks): format task comments skill
puemos Aug 5, 2026
738e881
fix(desktop): synchronize HTML annotation refs in effects
puemos Aug 5, 2026
2e3e093
fix(desktop): synchronize text annotation refs in effects
puemos Aug 5, 2026
8dbbae9
test(mcp): update unit test snapshots
puemos Aug 5, 2026
49fd8e0
fix(tasks): address artifact comment review feedback
puemos Aug 5, 2026
be9a590
fix(tasks): route comment retries through facade
puemos Aug 5, 2026
396b650
fix(desktop): use supported button variant
puemos Aug 5, 2026
c0d8ce2
fix(tasks): validate comment anchor kinds
puemos Aug 5, 2026
b1e6c84
fix(desktop): update comment list test mock
puemos Aug 5, 2026
eb245c2
chore(ci): allow selective jest tests to finish
puemos Aug 5, 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
8 changes: 4 additions & 4 deletions .github/workflows/ci-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -700,10 +700,10 @@ jobs:
jest:
runs-on: ubuntu-latest
# Draft PRs run the selective mode as a single unsharded job over every
# test reachable from the changed files. A PR touching a scene component
# can pull in ~700 suites, which does not fit in 15 minutes. 20 covers it;
# the ready-for-review fanout shards well below either limit.
timeout-minutes: 20
# test reachable from the changed files. A PR touching shared components
# can pull in enough suites to exceed 20 minutes. The ready-for-review
# fanout shards the same tests well below this limit.
timeout-minutes: 30
needs: [changes, select-jest-tests]
# A status function is required because select-jest-tests is skipped on ready
# PRs and master pushes, and a skipped need would otherwise cascade a skip here.
Expand Down
2 changes: 2 additions & 0 deletions .semgrep/rules/security/idor-team-scoped-models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ rules:
|Task
|TaskActivity
|TaskArtifact
|TaskCommentActivity
|TaskAutomation
|TaskPresence
|TaskRun
Expand Down Expand Up @@ -648,6 +649,7 @@ rules:
|Task
|TaskActivity
|TaskArtifact
|TaskCommentActivity
|TaskAutomation
|TaskPresence
|TaskRun
Expand Down
208 changes: 201 additions & 7 deletions posthog/api/comments.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import logging
from datetime import datetime
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 +10,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 +29,8 @@
if TYPE_CHECKING:
from posthog.rbac.user_access_control import UserAccessControl

logger = logging.getLogger(__name__)


def _normalize_scope(scope: Any) -> Any:
"""Match how the serializer will store a submitted scope.
Expand Down Expand Up @@ -58,6 +64,104 @@ 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: datetime | None = None,
include_relationship_recipients: bool = True,
) -> None:
if comment.scope not in {"task", "task_artifact", "desktop_canvas"}:
return

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)})
from products.tasks.backend.facade.api import ( # noqa: PLC0415 — keeps the generic comments API decoupled from the tasks product
enqueue_comment_activity_retry,
)

activity_at_value = activity_at.isoformat() if activity_at else None
transaction.on_commit(
lambda: enqueue_comment_activity_retry(
team_id=comment.team_id,
comment_id=str(comment.id),
mentioned_user_ids=mentions,
include_relationship_recipients=include_relationship_recipients,
target_owner_id=owner_id,
activity_at=activity_at_value,
)
)


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 @@ -81,7 +185,12 @@ 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,
help_text="Metadata for the comment target, anchor, thread state, and owning task.",
)
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 @@ -164,6 +273,27 @@ def validate(self, data):
# parent — so losing ticket editor access after creation, re-scoping a comment into or out
# of a ticket, and replying into a thread on another ticket are all caught, not just fresh
# ticket-message creation.
if not instance and source_comment is not None:
root = source_comment.source_comment or source_comment
data["source_comment"] = root
data["scope"] = root.scope
data["item_id"] = root.item_id
reply_context = data.get("item_context") or {}
# Replies inherit the root's context (anchor, taskId) so filters keep
# working, but a reply's own signal keys must survive the merge.
data["item_context"] = {
**(root.item_context or {}),
**({"is_emoji": reply_context["is_emoji"]} if "is_emoji" in reply_context else {}),
**(
{"threadState": reply_context["threadState"]}
if reply_context.get("threadState") in ("resolved", "open")
else {}
),
}
source_comment = root
scope = root.scope
item_id = root.item_id

scopes_and_items = {(scope, item_id)}
if instance:
scopes_and_items.add((instance.scope, instance.item_id))
Expand All @@ -177,6 +307,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 @@ -218,13 +362,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)

Comment thread
puemos marked this conversation as resolved.
return comment

Expand All @@ -238,6 +390,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 @@ -256,6 +415,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 @@ -274,6 +439,9 @@ class CommentListQueryParamsSerializer(serializers.Serializer):
),
)
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 @@ -388,6 +556,22 @@ def _require_ticket_viewer_access_for_pk(self) -> None:
# Match the list path, where a denied ticket's comments are simply absent.
raise exceptions.NotFound()

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:
"""Ticket-carrying comments are ticket content — restrict them to tickets the caller has
viewer access to, mirroring TicketViewSet's own object-level filtering."""
Expand Down Expand Up @@ -431,14 +615,24 @@ def safely_get_queryset(self, queryset: QuerySet) -> QuerySet:
queryset = queryset.filter(scope=scope)
if scope in TICKET_COMMENT_SCOPES:
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))
elif self.action in ("list", "count"):
# Ticket-carrying comments (customer messages and internal ticket discussions) never
# appear in unscoped enumeration — only when explicitly requested by scope.
queryset = queryset.exclude(scope__in=TICKET_COMMENT_SCOPES)
# 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=[*TICKET_COMMENT_SCOPES, "task", "task_artifact", "desktop_canvas"])
else:
# Detail actions (retrieve, thread, send_to_slack, ...) carry no scope param, so the
# branch above never gates them — and API scope access doesn't cover session callers
# denied the ticket. Check the pk target's own ticket instead.
self._require_ticket_viewer_access_for_pk()

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