mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
A thread could hold multiple resolved comments at once: ResolveComment was a plain per-row setter that never cleared the prior resolution, and "replacing" one was a display-only illusion (deriveThreadResolution picks the max resolved_at). The stale rows stayed resolved in the DB and the optimistic update flashed the new resolution, then reverted. Make single-resolution-per-thread a write invariant: - ClearOtherThreadResolutions: thread-scoped clear via a RECURSIVE CTE (root + descendants of the target, id <> target), returns each cleared row. - ResolveComment handler runs the clear + set in one tx so the replace is atomic. It emits comment:unresolved per cleared sibling (granular realtime consumers patch a single comment in place and would otherwise keep showing the stale resolution). Target keeps its COALESCE idempotency and the re-resolve event suppression. - Frontend optimistic update mirrors the invariant: resolving clears every other resolution in the same thread, so the cache never shows two at once. Unresolve still only clears its own row. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
1541 lines
60 KiB
Go
1541 lines
60 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/logger"
|
|
"github.com/multica-ai/multica/server/internal/mention"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
"github.com/multica-ai/multica/server/internal/util"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
type CommentResponse struct {
|
|
ID string `json:"id"`
|
|
IssueID string `json:"issue_id"`
|
|
AuthorType string `json:"author_type"`
|
|
AuthorID string `json:"author_id"`
|
|
Content string `json:"content"`
|
|
Type string `json:"type"`
|
|
ParentID *string `json:"parent_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
ResolvedAt *string `json:"resolved_at"`
|
|
ResolvedByType *string `json:"resolved_by_type"`
|
|
ResolvedByID *string `json:"resolved_by_id"`
|
|
Reactions []ReactionResponse `json:"reactions"`
|
|
Attachments []AttachmentResponse `json:"attachments"`
|
|
// Orientation stats — populated only on the roots_only path and omitted in
|
|
// every other mode, so the default response shape stays byte-identical for
|
|
// existing callers. ReplyCount is the number of descendants in the thread;
|
|
// LastActivityAt is the MAX(created_at) across the whole subtree. Together
|
|
// they let an agent triage which thread to drill into without fetching any
|
|
// replies.
|
|
ReplyCount *int `json:"reply_count,omitempty"`
|
|
LastActivityAt *string `json:"last_activity_at,omitempty"`
|
|
// ContentTruncated is set only under summary=true: true when Content was
|
|
// clipped to the summary budget, false when it fit. nil (omitted) means the
|
|
// caller did not request a summary projection, so Content is verbatim.
|
|
ContentTruncated *bool `json:"content_truncated,omitempty"`
|
|
}
|
|
|
|
func commentToResponse(c db.Comment, reactions []ReactionResponse, attachments []AttachmentResponse) CommentResponse {
|
|
if reactions == nil {
|
|
reactions = []ReactionResponse{}
|
|
}
|
|
if attachments == nil {
|
|
attachments = []AttachmentResponse{}
|
|
}
|
|
return CommentResponse{
|
|
ID: uuidToString(c.ID),
|
|
IssueID: uuidToString(c.IssueID),
|
|
AuthorType: c.AuthorType,
|
|
AuthorID: uuidToString(c.AuthorID),
|
|
Content: c.Content,
|
|
Type: c.Type,
|
|
ParentID: uuidToPtr(c.ParentID),
|
|
CreatedAt: timestampToString(c.CreatedAt),
|
|
UpdatedAt: timestampToString(c.UpdatedAt),
|
|
ResolvedAt: timestampToPtr(c.ResolvedAt),
|
|
ResolvedByType: textToPtr(c.ResolvedByType),
|
|
ResolvedByID: uuidToPtr(c.ResolvedByID),
|
|
Reactions: reactions,
|
|
Attachments: attachments,
|
|
}
|
|
}
|
|
|
|
// summaryContentRunes bounds comment content under summary=true. 200 runes is
|
|
// enough to tell what a comment is about (its opening) while cutting the bulk
|
|
// of a long body out of an agent's context budget. Counted in runes, not bytes,
|
|
// so multi-byte (e.g. CJK) content is clipped on a character boundary.
|
|
const summaryContentRunes = 200
|
|
|
|
// summarizeContent clips content to summaryContentRunes for the summary
|
|
// projection. Returns the (possibly clipped) content and whether it was
|
|
// truncated. An ellipsis marks a clip so the reader knows more text exists.
|
|
//
|
|
// It scans by rune and stops at the (budget+1)th rune rather than allocating a
|
|
// full []rune for the whole body — so a pathologically long comment costs only
|
|
// the budget, not its full length, under summary mode.
|
|
func summarizeContent(content string) (string, bool) {
|
|
count := 0
|
|
for byteOffset := range content { // range over a string yields rune start offsets
|
|
if count == summaryContentRunes {
|
|
return content[:byteOffset] + "…", true
|
|
}
|
|
count++
|
|
}
|
|
return content, false
|
|
}
|
|
|
|
// commentHardCap bounds the comments returned per issue. Sized as a defensive
|
|
// safety net rather than a UX paging window: prod p99 is ~30 comments and
|
|
// the all-time max observed is ~1.1k, so 2000 leaves ~2x headroom while still
|
|
// preventing a runaway response if some user manages to accumulate a wild
|
|
// number of rows on a single issue.
|
|
const commentHardCap = 2000
|
|
|
|
// ListComments returns comments for an issue. The default behaviour is
|
|
// unchanged — full chronological dump capped at commentHardCap — so existing
|
|
// callers and the desktop UI keep working as-is. Optional query params give
|
|
// agent-style readers bounded views that scale to long issues without dragging
|
|
// every prior reply into context:
|
|
//
|
|
// - roots_only=true — return only top-level comments (parent_id IS NULL),
|
|
// each annotated with reply_count + last_activity_at so the caller can
|
|
// triage which thread to drill into. May combine with since for incremental
|
|
// polling of newly created roots, but is exclusive with thread/recent/tail/
|
|
// cursor modes because those have their own grouping or pagination semantics.
|
|
//
|
|
// - summary=true — orthogonal content projection. Clips each returned
|
|
// comment's content to a fixed budget and sets content_truncated, so an
|
|
// agent can scan a list cheaply before pulling a full body. Composes with
|
|
// every mode (default, since, thread, recent, roots_only).
|
|
//
|
|
// - thread=<comment-uuid> — return the root of the thread containing this
|
|
// comment plus every descendant. The anchor may be a root or any reply;
|
|
// the server walks up to the root via a recursive CTE, so callers do not
|
|
// need to know whether the id they have is a root.
|
|
//
|
|
// - tail=<N> — only valid with thread. Cap the reply count at the N most
|
|
// recent replies (per (created_at, id)). The thread root is always
|
|
// returned, even when N=0, so the reader keeps the "what is this thread
|
|
// about" context. Without tail, thread returns the entire thread (the
|
|
// pre-MUL-2421 behavior).
|
|
//
|
|
// - recent=<N> — return the N most recently active threads (root + every
|
|
// descendant per thread). A thread's recency is MAX(created_at) across
|
|
// the whole subtree, so a stale-but-recently-replied thread ranks ahead
|
|
// of an active-but-quiet one. Row-based "newest N comments" is
|
|
// deliberately NOT exposed — it surfaces unrelated thread tails and
|
|
// hides relevant history (#2340).
|
|
//
|
|
// - before=<RFC3339> + before-id=<uuid> — cursor. The pair's meaning is
|
|
// context-dependent so the flag surface stays small:
|
|
//
|
|
// - with recent: a *thread* cursor — (last_activity_at, root_id) — and
|
|
// the next page returns threads strictly less recent.
|
|
//
|
|
// - with thread + tail: a *reply* cursor — (created_at, id) — and the
|
|
// next page returns replies in the same thread strictly older than
|
|
// that reply.
|
|
//
|
|
// Both values must be set together so the cursor can tie-break entries
|
|
// landing in the same microsecond. The cursor for the next page is
|
|
// emitted via the X-Multica-Next-Before / X-Multica-Next-Before-Id
|
|
// response headers.
|
|
//
|
|
// Combination rules (kept narrow on purpose — Elon flagged the matrix risk):
|
|
//
|
|
// - roots_only is exclusive with thread, recent, tail, and before/before-id.
|
|
// It may combine with since. This keeps "list issue roots" separate from
|
|
// "read a specific thread" and "read recently active threads".
|
|
// - thread is exclusive with recent. Asking for "the most recent N within
|
|
// thread X" mixes two different navigation models and is rejected.
|
|
// - thread + before/before-id requires tail. Without tail, thread returns
|
|
// the entire thread and a cursor would be ignored — reject loudly so
|
|
// the documented "cursor scrolls within a tailed window" rule holds.
|
|
// - tail requires thread (it is a thread-scoped limit; outside of thread
|
|
// it has no defined behavior).
|
|
// - thread may combine with since (incremental polling of one thread),
|
|
// and the since filter is applied after the tail/cursor cut so the
|
|
// thread root is still emitted but stale rows drop out.
|
|
// - recent may combine with before/before-id (scroll older threads) and
|
|
// with since (recent activity in a window).
|
|
//
|
|
// The response body is always chronological (oldest → newest); under recent
|
|
// that means threads are listed oldest-active first and the freshest thread
|
|
// sits at the tail, closest to "now" in an agent prompt.
|
|
func (h *Handler) ListComments(w http.ResponseWriter, r *http.Request) {
|
|
issueID := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
|
|
var sinceTime pgtype.Timestamptz
|
|
if v := q.Get("since"); v != "" {
|
|
t, err := time.Parse(time.RFC3339Nano, v)
|
|
if err != nil {
|
|
// Fall back to RFC3339 for backwards-compat with the original CLI.
|
|
t, err = time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid since parameter; expected RFC3339 format")
|
|
return
|
|
}
|
|
}
|
|
sinceTime = pgtype.Timestamptz{Time: t, Valid: true}
|
|
}
|
|
|
|
threadStr := q.Get("thread")
|
|
recentStr := q.Get("recent")
|
|
tailStr := q.Get("tail")
|
|
beforeTimeStr := q.Get("before")
|
|
beforeIDStr := q.Get("before_id")
|
|
if beforeIDStr == "" {
|
|
// Accept hyphenated alias to match CLI flag convention.
|
|
beforeIDStr = q.Get("before-id")
|
|
}
|
|
|
|
rootsOnlyStr := q.Get("roots_only")
|
|
if rootsOnlyStr == "" {
|
|
// Accept hyphenated alias to match CLI flag convention.
|
|
rootsOnlyStr = q.Get("roots-only")
|
|
}
|
|
|
|
rootsOnly := false
|
|
if rootsOnlyStr != "" {
|
|
switch rootsOnlyStr {
|
|
case "true":
|
|
rootsOnly = true
|
|
case "false":
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid roots_only parameter; expected boolean")
|
|
return
|
|
}
|
|
}
|
|
|
|
// summary=true is an orthogonal content projection: it clips each comment's
|
|
// content to a fixed budget so an agent can scan a list without pulling full
|
|
// bodies into context. It is intentionally NOT mutually exclusive with any
|
|
// mode — it composes with the default list, since, thread, recent, and
|
|
// roots_only alike.
|
|
summary := false
|
|
if summaryStr := q.Get("summary"); summaryStr != "" {
|
|
switch summaryStr {
|
|
case "true":
|
|
summary = true
|
|
case "false":
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "invalid summary parameter; expected boolean")
|
|
return
|
|
}
|
|
}
|
|
|
|
// --- combination validation ----------------------------------------
|
|
if rootsOnly && threadStr != "" {
|
|
writeError(w, http.StatusBadRequest, "roots_only and thread are mutually exclusive")
|
|
return
|
|
}
|
|
if rootsOnly && recentStr != "" {
|
|
writeError(w, http.StatusBadRequest, "roots_only and recent are mutually exclusive")
|
|
return
|
|
}
|
|
if rootsOnly && tailStr != "" {
|
|
writeError(w, http.StatusBadRequest, "roots_only and tail are mutually exclusive")
|
|
return
|
|
}
|
|
if rootsOnly && (beforeTimeStr != "" || beforeIDStr != "") {
|
|
writeError(w, http.StatusBadRequest, "roots_only does not support before / before_id")
|
|
return
|
|
}
|
|
if threadStr != "" && recentStr != "" {
|
|
writeError(w, http.StatusBadRequest, "thread and recent are mutually exclusive")
|
|
return
|
|
}
|
|
if tailStr != "" && threadStr == "" {
|
|
writeError(w, http.StatusBadRequest, "tail requires thread (it is a thread-scoped limit)")
|
|
return
|
|
}
|
|
if (beforeTimeStr == "") != (beforeIDStr == "") {
|
|
writeError(w, http.StatusBadRequest, "before and before_id must be set together (composite cursor)")
|
|
return
|
|
}
|
|
// Cursor needs either a recent window (thread cursor) or a tailed thread
|
|
// (reply cursor). A bare cursor would otherwise fall through to the
|
|
// default / since path — returning a full timeline that the caller did
|
|
// not ask for. Reject loudly so the API surface matches the documented
|
|
// semantics.
|
|
if beforeTimeStr != "" && recentStr == "" && (threadStr == "" || tailStr == "") {
|
|
writeError(w, http.StatusBadRequest, "before / before_id require recent (thread cursor) or thread + tail (reply cursor)")
|
|
return
|
|
}
|
|
|
|
// --- parse cursor / recent ----------------------------------------
|
|
var beforeCursor pgtype.Timestamptz
|
|
var beforeUUID pgtype.UUID
|
|
hasCursor := false
|
|
if beforeTimeStr != "" {
|
|
t, err := time.Parse(time.RFC3339Nano, beforeTimeStr)
|
|
if err != nil {
|
|
t, err = time.Parse(time.RFC3339, beforeTimeStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid before parameter; expected RFC3339 format")
|
|
return
|
|
}
|
|
}
|
|
beforeCursor = pgtype.Timestamptz{Time: t, Valid: true}
|
|
uuid, perr := util.ParseUUID(beforeIDStr)
|
|
if perr != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid before_id parameter; expected UUID")
|
|
return
|
|
}
|
|
beforeUUID = uuid
|
|
hasCursor = true
|
|
}
|
|
|
|
recentN := 0
|
|
if recentStr != "" {
|
|
n, err := strconv.Atoi(recentStr)
|
|
if err != nil || n <= 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid recent parameter; expected positive integer")
|
|
return
|
|
}
|
|
if n > commentHardCap {
|
|
n = commentHardCap
|
|
}
|
|
recentN = n
|
|
}
|
|
|
|
// tail=0 is allowed (returns root only — useful for "what is this thread
|
|
// about" lookups without dragging any replies into context). Negative
|
|
// values are rejected because they'd round-trip to LIMIT -N which
|
|
// PostgreSQL flags as a syntax error.
|
|
threadTail := -1
|
|
threadTailSet := false
|
|
if tailStr != "" {
|
|
n, err := strconv.Atoi(tailStr)
|
|
if err != nil || n < 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid tail parameter; expected non-negative integer")
|
|
return
|
|
}
|
|
if n > commentHardCap {
|
|
n = commentHardCap
|
|
}
|
|
threadTail = n
|
|
threadTailSet = true
|
|
}
|
|
|
|
result, err := h.fetchCommentsForList(r.Context(), fetchCommentsArgs{
|
|
Issue: issue,
|
|
Since: sinceTime,
|
|
ThreadAnchor: threadStr,
|
|
ThreadTail: threadTail,
|
|
ThreadTailSet: threadTailSet,
|
|
RecentN: recentN,
|
|
HasCursor: hasCursor,
|
|
BeforeAt: beforeCursor,
|
|
BeforeID: beforeUUID,
|
|
RootsOnly: rootsOnly,
|
|
})
|
|
if err != nil {
|
|
switch err {
|
|
case errCommentThreadNotFound:
|
|
writeError(w, http.StatusNotFound, "thread anchor not found in this issue")
|
|
return
|
|
case errCommentThreadBadID:
|
|
writeError(w, http.StatusBadRequest, "invalid thread parameter; expected UUID")
|
|
return
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, "failed to list comments")
|
|
return
|
|
}
|
|
}
|
|
|
|
commentIDs := make([]pgtype.UUID, len(result.Comments))
|
|
for i, c := range result.Comments {
|
|
commentIDs[i] = c.ID
|
|
}
|
|
grouped := h.groupReactions(r, commentIDs)
|
|
groupedAtt := h.groupAttachments(r, commentIDs)
|
|
|
|
resp := make([]CommentResponse, len(result.Comments))
|
|
for i, c := range result.Comments {
|
|
cid := uuidToString(c.ID)
|
|
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
|
|
// Attach roots_only orientation stats when present (nil map elsewhere).
|
|
if st, ok := result.RootStats[cid]; ok {
|
|
rc := st.ReplyCount
|
|
resp[i].ReplyCount = &rc
|
|
if st.LastActivityAt.Valid {
|
|
la := timestampToString(st.LastActivityAt)
|
|
resp[i].LastActivityAt = &la
|
|
}
|
|
}
|
|
// Apply the summary projection last so it clips whatever content the
|
|
// chosen read mode produced, uniformly across every mode.
|
|
if summary {
|
|
clipped, truncated := summarizeContent(resp[i].Content)
|
|
resp[i].Content = clipped
|
|
resp[i].ContentTruncated = &truncated
|
|
}
|
|
}
|
|
|
|
// Emit the next cursor as response headers when the page is likely not
|
|
// the last one. The cursor's meaning is context-dependent: under recent
|
|
// it points at the oldest thread in the page (next page = older threads);
|
|
// under thread + tail it points at the oldest reply in the page (next
|
|
// page = older replies in the same thread). Headers stay out of the JSON
|
|
// body so the default flat-array response shape — which the desktop UI
|
|
// and existing callers depend on — is unchanged.
|
|
if result.NextBefore != "" && result.NextBeforeID != "" {
|
|
w.Header().Set("X-Multica-Next-Before", result.NextBefore)
|
|
w.Header().Set("X-Multica-Next-Before-Id", result.NextBeforeID)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// fetchCommentsArgs bundles the parsed query params so fetchCommentsForList
|
|
// stays readable. Sentinel errors below let the caller turn DB-layer outcomes
|
|
// into the right HTTP status without leaking SQL details.
|
|
//
|
|
// ThreadTail is split into a value + a "set" flag because tail=0 is a
|
|
// meaningful caller intent (return just the root). A bare int would collapse
|
|
// "user did not pass --tail" and "user passed --tail 0" into the same state,
|
|
// which would silently downgrade the latter to the full-thread path.
|
|
type fetchCommentsArgs struct {
|
|
Issue db.Issue
|
|
Since pgtype.Timestamptz
|
|
RootsOnly bool
|
|
ThreadAnchor string
|
|
ThreadTail int
|
|
ThreadTailSet bool
|
|
RecentN int
|
|
HasCursor bool
|
|
BeforeAt pgtype.Timestamptz
|
|
BeforeID pgtype.UUID
|
|
}
|
|
|
|
// fetchCommentsResult carries both the materialised comments and (for the
|
|
// recent/thread-grouped path) the cursor to use for the next page. Cursor
|
|
// fields are empty strings when there is no next page or the path does not
|
|
// support cursors.
|
|
type fetchCommentsResult struct {
|
|
Comments []db.Comment
|
|
NextBefore string
|
|
NextBeforeID string
|
|
// RootStats carries per-root orientation stats keyed by comment id string.
|
|
// Populated only on the roots_only path; nil for every other mode.
|
|
RootStats map[string]rootStat
|
|
}
|
|
|
|
// rootStat is the per-thread orientation metadata attached to each root comment
|
|
// on the roots_only path. See CommentResponse.ReplyCount / LastActivityAt.
|
|
type rootStat struct {
|
|
ReplyCount int
|
|
LastActivityAt pgtype.Timestamptz
|
|
}
|
|
|
|
var (
|
|
errCommentThreadNotFound = &commentFetchError{"thread anchor not found"}
|
|
errCommentThreadBadID = &commentFetchError{"invalid thread anchor id"}
|
|
)
|
|
|
|
type commentFetchError struct{ msg string }
|
|
|
|
func (e *commentFetchError) Error() string { return e.msg }
|
|
|
|
func (h *Handler) fetchCommentsForList(ctx context.Context, args fetchCommentsArgs) (fetchCommentsResult, error) {
|
|
issue := args.Issue
|
|
|
|
// Thread-scoped read. Server resolves the anchor → root via recursive
|
|
// CTE, so we don't have to assume two-layer flat threads here.
|
|
if args.ThreadAnchor != "" {
|
|
anchor, err := util.ParseUUID(args.ThreadAnchor)
|
|
if err != nil {
|
|
return fetchCommentsResult{}, errCommentThreadBadID
|
|
}
|
|
// Tailed path: paged query that returns root + the @reply_limit
|
|
// most recent replies (per (created_at, id)). The thread root is
|
|
// always returned, so a reader can land on a long thread without
|
|
// dragging hundreds of replies into context. The reply-internal
|
|
// cursor (--before / --before-id under --thread + --tail) scrolls
|
|
// to older replies inside the same thread.
|
|
if args.ThreadTailSet {
|
|
// Probe for has-more by asking the SQL for one extra reply
|
|
// beyond what the caller wants. If we get back >tail replies
|
|
// there is at least one older reply still on disk; if we get
|
|
// back ≤tail the page is the tail of the thread and there is
|
|
// nothing older to scroll to (so we must NOT emit a cursor —
|
|
// otherwise the next page is wasted round-trip that returns
|
|
// just the root). This is the exact-boundary fix called out
|
|
// in the MUL-2421 review.
|
|
rows, err := h.Queries.ListThreadCommentsForIssuePaged(ctx, db.ListThreadCommentsForIssuePagedParams{
|
|
AnchorID: anchor,
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
HasCursor: args.HasCursor,
|
|
BeforeAt: args.BeforeAt,
|
|
BeforeID: args.BeforeID,
|
|
ReplyLimit: int32(args.ThreadTail) + 1,
|
|
})
|
|
if err != nil {
|
|
return fetchCommentsResult{}, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return fetchCommentsResult{}, errCommentThreadNotFound
|
|
}
|
|
// Split the result into root + replies (ASC order preserved).
|
|
// Root is identified by parent_id IS NULL and is always
|
|
// present in the SQL output; we keep it out of the cursor /
|
|
// tail-trim logic so the user always sees thread context.
|
|
var rootComment *db.Comment
|
|
replies := make([]db.Comment, 0, len(rows))
|
|
for _, r := range rows {
|
|
c := db.Comment{
|
|
ID: r.ID,
|
|
IssueID: r.IssueID,
|
|
AuthorType: r.AuthorType,
|
|
AuthorID: r.AuthorID,
|
|
Content: r.Content,
|
|
Type: r.Type,
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
ParentID: r.ParentID,
|
|
WorkspaceID: r.WorkspaceID,
|
|
ResolvedAt: r.ResolvedAt,
|
|
ResolvedByType: r.ResolvedByType,
|
|
ResolvedByID: r.ResolvedByID,
|
|
}
|
|
if !r.ParentID.Valid {
|
|
root := c
|
|
rootComment = &root
|
|
continue
|
|
}
|
|
replies = append(replies, c)
|
|
}
|
|
// Trim the probe overflow back to the caller's tail. The SQL
|
|
// emits ASC, so the extra row is the oldest reply — dropping
|
|
// it from the head is what aligns "newest N" with the user's
|
|
// request.
|
|
hasMore := len(replies) > args.ThreadTail
|
|
if hasMore {
|
|
replies = replies[1:]
|
|
}
|
|
out := make([]db.Comment, 0, len(replies)+1)
|
|
if rootComment != nil {
|
|
out = append(out, *rootComment)
|
|
}
|
|
for _, r := range replies {
|
|
// since drops stale rows AFTER the tail / cursor cut.
|
|
// The root is exempt (already appended above): a reader
|
|
// who set --since to skip already-seen replies still
|
|
// needs the root context if the page only contained
|
|
// the root.
|
|
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
// Emit a reply cursor only when we proved an older reply
|
|
// exists (hasMore). On an exact-boundary page (replyCount
|
|
// == tail with no overflow) hasMore is false and the cursor
|
|
// stays empty.
|
|
//
|
|
// Additionally suppress the cursor when `since` is set and
|
|
// the oldest retained reply on this page is already <= since.
|
|
// The next page walks replies strictly older than that one,
|
|
// so every older reply has created_at strictly less — if the
|
|
// cursor target itself can't satisfy `> since`, no older
|
|
// reply can either, and continuing to paginate would only
|
|
// return root-only pages until the agent walks the entire
|
|
// pre-`since` history. This mirrors the head-thread guard on
|
|
// the recent + since path. Flagged by Elon's second review on
|
|
// MUL-2421.
|
|
res := fetchCommentsResult{Comments: out}
|
|
emitCursor := hasMore && len(replies) > 0
|
|
if emitCursor && args.Since.Valid && !replies[0].CreatedAt.Time.After(args.Since.Time) {
|
|
emitCursor = false
|
|
}
|
|
if emitCursor {
|
|
oldest := replies[0]
|
|
res.NextBefore = oldest.CreatedAt.Time.UTC().Format(time.RFC3339Nano)
|
|
res.NextBeforeID = uuidToString(oldest.ID)
|
|
}
|
|
return res, nil
|
|
}
|
|
rows, err := h.Queries.ListThreadCommentsForIssue(ctx, db.ListThreadCommentsForIssueParams{
|
|
AnchorID: anchor,
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
RowLimit: commentHardCap,
|
|
})
|
|
if err != nil {
|
|
return fetchCommentsResult{}, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return fetchCommentsResult{}, errCommentThreadNotFound
|
|
}
|
|
out := make([]db.Comment, 0, len(rows))
|
|
for _, r := range rows {
|
|
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
|
|
continue
|
|
}
|
|
out = append(out, db.Comment{
|
|
ID: r.ID,
|
|
IssueID: r.IssueID,
|
|
AuthorType: r.AuthorType,
|
|
AuthorID: r.AuthorID,
|
|
Content: r.Content,
|
|
Type: r.Type,
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
ParentID: r.ParentID,
|
|
WorkspaceID: r.WorkspaceID,
|
|
ResolvedAt: r.ResolvedAt,
|
|
ResolvedByType: r.ResolvedByType,
|
|
ResolvedByID: r.ResolvedByID,
|
|
})
|
|
}
|
|
return fetchCommentsResult{Comments: out}, nil
|
|
}
|
|
|
|
// Thread-grouped recent read: N most recently active threads.
|
|
if args.RecentN > 0 {
|
|
rows, err := h.Queries.ListRecentThreadCommentsForIssue(ctx, db.ListRecentThreadCommentsForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
HasCursor: args.HasCursor,
|
|
BeforeAt: args.BeforeAt,
|
|
BeforeID: args.BeforeID,
|
|
ThreadLimit: int32(args.RecentN),
|
|
})
|
|
if err != nil {
|
|
return fetchCommentsResult{}, err
|
|
}
|
|
|
|
// The SQL already orders rows by (last_activity_at ASC, root_id ASC,
|
|
// created_at ASC, id ASC), so the OLDEST-active thread sits at the
|
|
// head and the FRESHEST thread at the tail. Walk the rows once to:
|
|
// 1. Strip the thread-metadata columns down to db.Comment for the
|
|
// caller (uniform shape across paths).
|
|
// 2. Count distinct threads in the page so we know whether a "next
|
|
// older page" is likely to exist.
|
|
// 3. Capture the head thread's (last_activity_at, root_id) — that
|
|
// is the cursor for the next page (next page = threads strictly
|
|
// less recent than this one).
|
|
comments := make([]db.Comment, 0, len(rows))
|
|
var headRoot pgtype.UUID
|
|
var headLast pgtype.Timestamptz
|
|
seenRoot := map[string]struct{}{}
|
|
for _, r := range rows {
|
|
if !headRoot.Valid {
|
|
headRoot = r.ThreadRootID
|
|
headLast = r.ThreadLastActivityAt
|
|
}
|
|
seenRoot[uuidToString(r.ThreadRootID)] = struct{}{}
|
|
// Since filter on the recent path: drop comments older than
|
|
// `since`. Done in-memory so we keep the thread-grouped
|
|
// semantics from the query (don't pre-filter rows before the
|
|
// MAX(created_at) ranking — that would silently downgrade a
|
|
// thread whose most recent activity falls inside the window).
|
|
if args.Since.Valid && !r.CreatedAt.Time.After(args.Since.Time) {
|
|
continue
|
|
}
|
|
comments = append(comments, db.Comment{
|
|
ID: r.ID,
|
|
IssueID: r.IssueID,
|
|
AuthorType: r.AuthorType,
|
|
AuthorID: r.AuthorID,
|
|
Content: r.Content,
|
|
Type: r.Type,
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
ParentID: r.ParentID,
|
|
WorkspaceID: r.WorkspaceID,
|
|
ResolvedAt: r.ResolvedAt,
|
|
ResolvedByType: r.ResolvedByType,
|
|
ResolvedByID: r.ResolvedByID,
|
|
})
|
|
}
|
|
|
|
// Only emit a cursor when the page is full. Fewer threads than
|
|
// requested ⇒ the SELECT exhausted matching threads, so there is
|
|
// no older page to scroll to.
|
|
//
|
|
// Additionally suppress the cursor when `since` is set and the head
|
|
// thread's last_activity_at is already <= since. The pagination
|
|
// walks threads in strictly decreasing last_activity_at, so every
|
|
// older page has last_activity_at strictly less than the head's —
|
|
// if the head itself can't satisfy `> since`, no older thread can
|
|
// either. Predicating on the head (not on whether `comments` is
|
|
// empty) also catches the mixed case where this page keeps rows
|
|
// from fresher threads but the head thread is already past `since`.
|
|
// Flagged by Elon in #2787's second review (MUL-2340 nit).
|
|
out := fetchCommentsResult{Comments: comments}
|
|
emitCursor := len(seenRoot) >= args.RecentN && headRoot.Valid && headLast.Valid
|
|
if emitCursor && args.Since.Valid && !headLast.Time.After(args.Since.Time) {
|
|
emitCursor = false
|
|
}
|
|
if emitCursor {
|
|
out.NextBefore = headLast.Time.UTC().Format(time.RFC3339Nano)
|
|
out.NextBeforeID = uuidToString(headRoot)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
if args.RootsOnly {
|
|
// Root-only read for issue-level orientation. This intentionally
|
|
// stays separate from thread/recent modes: callers get the global
|
|
// top-level discussion first, then fetch a specific thread only when
|
|
// they need reply context. Each root carries reply_count +
|
|
// last_activity_at so the reader can triage which thread to drill into.
|
|
stats := map[string]rootStat{}
|
|
if args.Since.Valid {
|
|
rows, err := h.Queries.ListRootCommentsSinceForIssue(ctx, db.ListRootCommentsSinceForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
Since: args.Since,
|
|
RowLimit: commentHardCap,
|
|
})
|
|
if err != nil {
|
|
return fetchCommentsResult{}, err
|
|
}
|
|
comments := make([]db.Comment, len(rows))
|
|
for i, r := range rows {
|
|
comments[i] = db.Comment{
|
|
ID: r.ID, IssueID: r.IssueID, AuthorType: r.AuthorType, AuthorID: r.AuthorID,
|
|
Content: r.Content, Type: r.Type, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
ParentID: r.ParentID, WorkspaceID: r.WorkspaceID, ResolvedAt: r.ResolvedAt,
|
|
ResolvedByType: r.ResolvedByType, ResolvedByID: r.ResolvedByID,
|
|
}
|
|
stats[uuidToString(r.ID)] = rootStat{ReplyCount: int(r.ReplyCount), LastActivityAt: r.LastActivityAt}
|
|
}
|
|
return fetchCommentsResult{Comments: comments, RootStats: stats}, nil
|
|
}
|
|
|
|
rows, err := h.Queries.ListRootCommentsForIssue(ctx, db.ListRootCommentsForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
RowLimit: commentHardCap,
|
|
})
|
|
if err != nil {
|
|
return fetchCommentsResult{}, err
|
|
}
|
|
comments := make([]db.Comment, len(rows))
|
|
for i, r := range rows {
|
|
comments[i] = db.Comment{
|
|
ID: r.ID, IssueID: r.IssueID, AuthorType: r.AuthorType, AuthorID: r.AuthorID,
|
|
Content: r.Content, Type: r.Type, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
|
|
ParentID: r.ParentID, WorkspaceID: r.WorkspaceID, ResolvedAt: r.ResolvedAt,
|
|
ResolvedByType: r.ResolvedByType, ResolvedByID: r.ResolvedByID,
|
|
}
|
|
stats[uuidToString(r.ID)] = rootStat{ReplyCount: int(r.ReplyCount), LastActivityAt: r.LastActivityAt}
|
|
}
|
|
return fetchCommentsResult{Comments: comments, RootStats: stats}, nil
|
|
}
|
|
|
|
// Default + since paths preserved verbatim (no behavioural change for
|
|
// existing callers).
|
|
if args.Since.Valid {
|
|
comments, err := h.Queries.ListCommentsSinceForIssue(ctx, db.ListCommentsSinceForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
CreatedAt: args.Since,
|
|
Limit: commentHardCap,
|
|
})
|
|
return fetchCommentsResult{Comments: comments}, err
|
|
}
|
|
comments, err := h.Queries.ListCommentsForIssue(ctx, db.ListCommentsForIssueParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
Limit: commentHardCap,
|
|
})
|
|
return fetchCommentsResult{Comments: comments}, err
|
|
}
|
|
|
|
type CreateCommentRequest struct {
|
|
Content string `json:"content"`
|
|
Type string `json:"type"`
|
|
ParentID *string `json:"parent_id"`
|
|
AttachmentIDs []string `json:"attachment_ids"`
|
|
}
|
|
|
|
func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|
issueID := chi.URLParam(r, "id")
|
|
issue, ok := h.loadIssueForUser(w, r, issueID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateCommentRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
if req.Type == "" {
|
|
req.Type = "comment"
|
|
}
|
|
|
|
var parentID pgtype.UUID
|
|
var parentComment *db.Comment
|
|
if req.ParentID != nil {
|
|
var parsed pgtype.UUID
|
|
parsed, ok = parseUUIDOrBadRequest(w, *req.ParentID, "parent_id")
|
|
if !ok {
|
|
return
|
|
}
|
|
parentID = parsed
|
|
parent, err := h.Queries.GetComment(r.Context(), parentID)
|
|
if err != nil || uuidToString(parent.IssueID) != uuidToString(issue.ID) {
|
|
writeError(w, http.StatusBadRequest, "invalid parent comment")
|
|
return
|
|
}
|
|
parentComment = &parent
|
|
}
|
|
|
|
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Determine author identity: agent (via X-Agent-ID header) or member.
|
|
authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID))
|
|
|
|
// Defense against resumed-session drift: when an agent posts from inside a
|
|
// comment-triggered task AND the comment is being posted on that same
|
|
// issue, the parent_id must exactly match the task's trigger comment.
|
|
// Resumed Claude sessions otherwise carry forward a previous turn's
|
|
// --parent UUID and silently misplace the reply.
|
|
//
|
|
// The task.IssueID scope is important: the CLI stamps X-Task-ID on every
|
|
// request, so an agent legitimately commenting on a different issue must
|
|
// not be blocked by its current task's trigger. Assignment-triggered
|
|
// tasks (no TriggerCommentID) are also unaffected.
|
|
if authorType == "agent" {
|
|
if taskIDHeader := r.Header.Get("X-Task-ID"); taskIDHeader != "" {
|
|
taskUUID, parseErr := util.ParseUUID(taskIDHeader)
|
|
if parseErr == nil {
|
|
task, err := h.Queries.GetAgentTask(r.Context(), taskUUID)
|
|
if err == nil && task.IssueID.Valid && uuidToString(task.IssueID) == uuidToString(issue.ID) {
|
|
if task.TriggerCommentID.Valid {
|
|
if uuidToString(parentID) != uuidToString(task.TriggerCommentID) {
|
|
writeError(w, http.StatusConflict,
|
|
"parent_id must equal this task's trigger comment id ("+uuidToString(task.TriggerCommentID)+")")
|
|
return
|
|
}
|
|
}
|
|
noAction, checkErr := service.HasSquadLeaderNoActionEvaluationForTask(r.Context(), h.Queries, task)
|
|
if checkErr != nil {
|
|
slog.Warn("checking squad leader no_action evaluation failed", append(logger.RequestAttrs(r),
|
|
"error", checkErr,
|
|
"task_id", taskIDHeader,
|
|
"issue_id", issueID,
|
|
)...)
|
|
} else if noAction {
|
|
writeError(w, http.StatusConflict, "squad leader recorded no_action; comments are not allowed for this task")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expand bare issue identifiers (e.g. MUL-117) into mention links.
|
|
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content)
|
|
|
|
// NOTE: Comment content is stored as Markdown source. XSS is handled at the
|
|
// rendering layer (rehype-sanitize) and at the editor layer
|
|
// (@tiptap/markdown with html:false). Running an HTML sanitizer here would
|
|
// entity-encode Markdown syntax characters (>, ", &, <) and corrupt the
|
|
// source. See issue #1303 / discussion in MUL-1119, MUL-1125.
|
|
|
|
// parent_id stores the exact comment being replied to. Thread-level behavior
|
|
// (for example auto-unresolving a resolved thread) resolves the root
|
|
// separately so storing a reply-to-reply does not destroy the direct-parent
|
|
// signal used by trigger decisions.
|
|
var rootComment *db.Comment
|
|
if parentID.Valid {
|
|
if root, err := h.Queries.GetThreadRoot(r.Context(), db.GetThreadRootParams{
|
|
CommentID: parentID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
}); err == nil {
|
|
rootComment = &root
|
|
}
|
|
}
|
|
|
|
comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{
|
|
IssueID: issue.ID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
AuthorType: authorType,
|
|
AuthorID: parseUUID(authorID),
|
|
Content: req.Content,
|
|
Type: req.Type,
|
|
ParentID: parentID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("create comment failed", append(logger.RequestAttrs(r), "error", err, "issue_id", issueID)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to create comment: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Link uploaded attachments to this comment.
|
|
if len(attachmentIDs) > 0 {
|
|
h.linkAttachmentsByIDs(r.Context(), comment.ID, issue.ID, attachmentIDs)
|
|
}
|
|
|
|
// Fetch linked attachments so the response includes them.
|
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
|
resp := commentToResponse(comment, nil, groupedAtt[uuidToString(comment.ID)])
|
|
slog.Info("comment created", append(logger.RequestAttrs(r), "comment_id", uuidToString(comment.ID), "issue_id", issueID)...)
|
|
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), authorType, authorID, map[string]any{
|
|
"comment": resp,
|
|
"issue_title": issue.Title,
|
|
"issue_assignee_type": textToPtr(issue.AssigneeType),
|
|
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
|
|
"issue_status": issue.Status,
|
|
})
|
|
|
|
// A reply in a resolved thread re-opens it. Done after CreateComment commits
|
|
// so the reply is visible regardless of the unresolve outcome. Shared with
|
|
// the agent task path (TaskService.createAgentComment) — both reply paths
|
|
// must keep the resolved root in sync.
|
|
h.TaskService.AutoUnresolveThreadOnReply(r.Context(), rootComment, uuidToString(issue.WorkspaceID), authorType, authorID)
|
|
|
|
h.triggerTasksForComment(r.Context(), issue, comment, parentComment, authorType, authorID)
|
|
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
// noteCommentPrefix marks a comment as a human-only note. A comment whose first
|
|
// whitespace-delimited token is this prefix (case-insensitive) is stored like
|
|
// any other comment but never triggers an agent — see triggerTasksForComment.
|
|
const noteCommentPrefix = "/note"
|
|
|
|
// isNoteComment reports whether content opts out of agent triggering via the
|
|
// reserved /note prefix. The prefix must be the comment's first token, so
|
|
// "/note check expiry", " /NOTE", and "/note" all match, while "/notes",
|
|
// "/ note", and "see foo/note" do not.
|
|
func isNoteComment(content string) bool {
|
|
trimmed := strings.TrimLeft(content, " \t\r\n")
|
|
firstToken := trimmed
|
|
if i := strings.IndexFunc(trimmed, unicode.IsSpace); i >= 0 {
|
|
firstToken = trimmed[:i]
|
|
}
|
|
return strings.EqualFold(firstToken, noteCommentPrefix)
|
|
}
|
|
|
|
func (h *Handler) triggerTasksForComment(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, actorType, actorID string) {
|
|
// A comment opening with the reserved /note prefix is a human-only note: it
|
|
// is recorded like any other comment but must not wake ANY agent. This guard
|
|
// lives at the single chokepoint so it covers all three trigger paths below
|
|
// (assignee, squad leader, and @mentioned agents). Gating only
|
|
// shouldEnqueueOnComment would still let "/note @agent ..." reach an agent
|
|
// through the mention path.
|
|
if isNoteComment(comment.Content) {
|
|
return
|
|
}
|
|
|
|
if actorType == "member" && h.shouldEnqueueOnComment(ctx, issue, actorType, actorID) &&
|
|
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
|
|
!h.isReplyToMemberThread(ctx, parentComment, comment.Content, issue) {
|
|
if _, err := h.TaskService.EnqueueTaskForIssue(ctx, issue, comment.ID); err != nil {
|
|
slog.Warn("enqueue agent task on comment failed", "issue_id", uuidToString(issue.ID), "error", err)
|
|
}
|
|
}
|
|
|
|
if h.shouldEnqueueSquadLeaderOnComment(ctx, issue, comment.Content, actorType, actorID) {
|
|
h.enqueueSquadLeaderTask(ctx, issue, comment.ID, actorType, actorID)
|
|
}
|
|
|
|
h.enqueueMentionedAgentTasks(ctx, issue, comment, parentComment, actorType, actorID)
|
|
}
|
|
|
|
// commentMentionsOthersButNotAssignee returns true if the comment @mentions
|
|
// anyone but does NOT @mention the issue's assignee agent. This is used to
|
|
// suppress the on_comment trigger when the user is directing their comment at
|
|
// someone else (e.g. sharing results with a colleague, asking another agent).
|
|
// @all is treated as a broadcast — it suppresses the trigger because the user
|
|
// is announcing to everyone, not specifically requesting work from the agent.
|
|
func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.Issue) bool {
|
|
mentions := util.ParseMentions(content)
|
|
// Filter out issue mentions — they are cross-references, not @people.
|
|
filtered := mentions[:0]
|
|
for _, m := range mentions {
|
|
if m.Type != "issue" {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
mentions = filtered
|
|
if len(mentions) == 0 {
|
|
return false // No mentions (or only issue refs) — normal on_comment behavior
|
|
}
|
|
// @all is a broadcast to all members — suppress agent trigger.
|
|
if util.HasMentionAll(mentions) {
|
|
return true
|
|
}
|
|
if !issue.AssigneeID.Valid {
|
|
return true // No assignee — mentions target others
|
|
}
|
|
assigneeID := uuidToString(issue.AssigneeID)
|
|
for _, m := range mentions {
|
|
if m.ID == assigneeID {
|
|
return false // Assignee is mentioned — allow trigger
|
|
}
|
|
}
|
|
return true // Others mentioned but not assignee — suppress trigger
|
|
}
|
|
|
|
// isReplyToMemberThread returns true if the comment is a reply in a thread
|
|
// started by a member and does NOT @mention the issue's assignee agent.
|
|
// When a member replies in a member-started thread, they are most likely
|
|
// continuing a human conversation — not requesting work from the assigned agent.
|
|
// Replying to an agent-started thread, or explicitly @mentioning the assignee
|
|
// in the reply, still triggers on_comment as expected.
|
|
// If the parent (thread root) itself @mentions the assignee, the thread is
|
|
// considered a conversation with the agent, so replies are allowed to trigger.
|
|
// If the assigned agent has already replied in the thread, the member is
|
|
// conversing with the agent, so replies are allowed to trigger.
|
|
func (h *Handler) isReplyToMemberThread(ctx context.Context, parent *db.Comment, content string, issue db.Issue) bool {
|
|
if parent == nil {
|
|
return false // Not a reply — normal top-level comment
|
|
}
|
|
if parent.AuthorType != "member" {
|
|
return false // Thread started by an agent — allow trigger
|
|
}
|
|
// Thread was started by a member. Suppress on_comment unless the reply
|
|
// or the parent explicitly @mentions the assignee agent, or the agent
|
|
// has already participated in this thread.
|
|
if !issue.AssigneeID.Valid {
|
|
return true // No assignee to mention
|
|
}
|
|
assigneeID := uuidToString(issue.AssigneeID)
|
|
// Check current comment mentions.
|
|
for _, m := range util.ParseMentions(content) {
|
|
if m.ID == assigneeID {
|
|
return false // Assignee explicitly mentioned in reply — allow trigger
|
|
}
|
|
}
|
|
// Check parent (thread root) mentions — if the thread was started by
|
|
// mentioning the assignee, replies continue that conversation.
|
|
for _, m := range util.ParseMentions(parent.Content) {
|
|
if m.ID == assigneeID {
|
|
return false // Assignee mentioned in thread root — allow trigger
|
|
}
|
|
}
|
|
// Check if the assigned agent has already replied in this thread —
|
|
// if so, the member is continuing a conversation with the agent.
|
|
if h.Queries != nil {
|
|
hasReplied, err := h.Queries.HasAgentRepliedInThread(ctx, db.HasAgentRepliedInThreadParams{
|
|
ParentID: parent.ID,
|
|
AgentID: issue.AssigneeID,
|
|
})
|
|
if err == nil && hasReplied {
|
|
return false // Agent participated in thread — allow trigger
|
|
}
|
|
}
|
|
return true // Reply to member thread without agent participation — suppress
|
|
}
|
|
|
|
// shouldInheritParentMentions decides whether a reply with no explicit
|
|
// mentions should inherit the parent (thread root) comment's mentions.
|
|
//
|
|
// Inheritance lets a member who started a thread by @mentioning an agent
|
|
// continue the conversation with that agent without re-typing the mention
|
|
// on every follow-up reply.
|
|
//
|
|
// It is intentionally narrow:
|
|
//
|
|
// - Only when the reply contains zero mentions of its own. Any explicit
|
|
// mention in the reply is a deliberate choice about who to involve.
|
|
// - Only when the reply author is a member. Agent-authored replies must
|
|
// never inherit, otherwise an agent posting in a thread whose root
|
|
// mentioned another agent would re-trigger that agent and create a loop.
|
|
// - Only when the parent author is a member. When an agent authors a
|
|
// comment that @mentions another agent, it is typically a one-shot
|
|
// delegation (e.g. an agent posting a PR completion that @mentions a
|
|
// reviewer agent). Subsequent member follow-ups in the same thread are
|
|
// directed at the assignee, not at the delegated agent — inheriting
|
|
// would re-trigger the delegated agent on every plain reply.
|
|
func shouldInheritParentMentions(parentComment *db.Comment, replyMentions []util.Mention, replyAuthorType string) bool {
|
|
if parentComment == nil {
|
|
return false
|
|
}
|
|
if len(replyMentions) > 0 {
|
|
return false
|
|
}
|
|
if replyAuthorType == "agent" {
|
|
return false
|
|
}
|
|
return parentComment.AuthorType == "member"
|
|
}
|
|
|
|
// enqueueMentionedAgentTasks parses @agent mentions from comment content and
|
|
// enqueues a task for each mentioned agent. When parentComment is non-nil
|
|
// (i.e. the comment is a reply), mentions from the parent (thread root) are
|
|
// also included so that agents mentioned in the top-level comment are
|
|
// re-triggered by subsequent replies in the same thread — unless the reply
|
|
// explicitly @mentions only non-agent entities (members, issues), which
|
|
// signals the user is talking to other people and not the agent.
|
|
// Skips agents with on_mention trigger disabled, and private agents mentioned
|
|
// by non-owner members (only the agent owner or workspace admin/owner can
|
|
// mention a private agent). Self-mentions are intentionally allowed so an
|
|
// agent running in one issue can explicitly enqueue itself on another (e.g.
|
|
// a child-issue run notifying the parent issue whose assignee is the same
|
|
// agent); runaway loops are prevented by HasPendingTaskForIssueAndAgent
|
|
// dedupe and the natural queued/dispatched coalescing of the task queue.
|
|
// Note: no status gate here — @mention is an explicit action and should work
|
|
// even on done/cancelled issues (the agent can reopen the issue if needed).
|
|
func (h *Handler) enqueueMentionedAgentTasks(ctx context.Context, issue db.Issue, comment db.Comment, parentComment *db.Comment, authorType, authorID string) {
|
|
wsID := uuidToString(issue.WorkspaceID)
|
|
mentions := util.ParseMentions(comment.Content)
|
|
if shouldInheritParentMentions(parentComment, mentions, authorType) {
|
|
mentions = util.ParseMentions(parentComment.Content)
|
|
}
|
|
for _, m := range mentions {
|
|
if m.Type == "squad" {
|
|
// @squad mention → trigger the squad's leader agent.
|
|
squadUUID := parseUUID(m.ID)
|
|
squad, err := h.Queries.GetSquadInWorkspace(ctx, db.GetSquadInWorkspaceParams{
|
|
ID: squadUUID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
leaderID := squad.LeaderID
|
|
// Prevent self-trigger only when the agent's last activity on this
|
|
// issue was itself a leader task. An agent that holds both the
|
|
// leader and a worker role in the squad must still wake its
|
|
// leader role after posting a comment from its worker task.
|
|
if authorType == "agent" && authorID == uuidToString(leaderID) &&
|
|
h.lastTaskWasLeader(ctx, issue.ID, leaderID) {
|
|
continue
|
|
}
|
|
// Verify leader agent is ready (has runtime, not archived).
|
|
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
|
ID: leaderID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
continue
|
|
}
|
|
// Private-agent gate: prevent triggering a private leader via squad mention.
|
|
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
|
|
continue
|
|
}
|
|
// Dedup: skip if leader already has a pending task for this issue.
|
|
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issue.ID,
|
|
AgentID: leaderID,
|
|
})
|
|
if err != nil || hasPending {
|
|
continue
|
|
}
|
|
if _, err := h.TaskService.EnqueueTaskForSquadLeader(ctx, issue, leaderID, comment.ID); err != nil {
|
|
slog.Warn("enqueue squad leader mention task failed", "issue_id", uuidToString(issue.ID), "squad_id", m.ID, "error", err)
|
|
}
|
|
continue
|
|
}
|
|
if m.Type != "agent" {
|
|
continue
|
|
}
|
|
agentUUID := parseUUID(m.ID)
|
|
// Load the agent scoped to the current issue's workspace. Using the
|
|
// bare GetAgent here would let a mention resolve to an agent in a
|
|
// different workspace, and the visibility check below would then be
|
|
// applied against the wrong workspace's roles (a workspace owner in
|
|
// THIS workspace would pass the gate for a private agent that lives
|
|
// in someone else's workspace).
|
|
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
|
ID: agentUUID,
|
|
WorkspaceID: issue.WorkspaceID,
|
|
})
|
|
if err != nil || !agent.RuntimeID.Valid || agent.ArchivedAt.Valid {
|
|
continue
|
|
}
|
|
// Private-agent gate (member→private requires allowed_principals;
|
|
// agent→agent always passes).
|
|
if !h.canAccessPrivateAgent(ctx, agent, authorType, authorID, wsID) {
|
|
continue
|
|
}
|
|
// Dedup: skip if this agent already has a pending task for this issue.
|
|
hasPending, err := h.Queries.HasPendingTaskForIssueAndAgent(ctx, db.HasPendingTaskForIssueAndAgentParams{
|
|
IssueID: issue.ID,
|
|
AgentID: agentUUID,
|
|
})
|
|
if err != nil || hasPending {
|
|
continue
|
|
}
|
|
// Always use the current comment as the trigger so the agent reads the
|
|
// actual reply that mentioned it, not the thread root.
|
|
if _, err := h.TaskService.EnqueueTaskForMention(ctx, issue, agentUUID, comment.ID); err != nil {
|
|
slog.Warn("enqueue mention agent task failed", "issue_id", uuidToString(issue.ID), "agent_id", m.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
|
commentId := chi.URLParam(r, "commentId")
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Load comment scoped to current workspace.
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
existing, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
|
ID: commentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "comment not found")
|
|
return
|
|
}
|
|
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
isAuthor := existing.AuthorType == actorType && uuidToString(existing.AuthorID) == actorID
|
|
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
|
if !isAuthor && !isAdmin {
|
|
writeError(w, http.StatusForbidden, "only comment author or admin can edit")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Content string `json:"content"`
|
|
AttachmentIDs *[]string `json:"attachment_ids"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Content == "" {
|
|
writeError(w, http.StatusBadRequest, "content is required")
|
|
return
|
|
}
|
|
|
|
var attachmentIDs []pgtype.UUID
|
|
replaceAttachments := req.AttachmentIDs != nil
|
|
if replaceAttachments {
|
|
var ok bool
|
|
attachmentIDs, ok = parseUUIDSliceOrBadRequest(w, *req.AttachmentIDs, "attachment_ids")
|
|
if !ok {
|
|
return
|
|
}
|
|
}
|
|
|
|
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
|
|
|
|
oldContent := existing.Content
|
|
|
|
// Expand bare issue identifiers (same pipeline as CreateComment).
|
|
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, wsUUID, req.Content)
|
|
|
|
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
|
|
ID: commentUUID,
|
|
Content: req.Content,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("update comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to update comment")
|
|
return
|
|
}
|
|
|
|
// Replace the comment attachment set when a modern client sends
|
|
// attachment_ids. Older clients omit the field; in that case preserve the
|
|
// existing attachment links rather than unlinking everything.
|
|
if replaceAttachments {
|
|
if err := h.Queries.ReplaceCommentAttachments(r.Context(), db.ReplaceCommentAttachmentsParams{
|
|
CommentID: comment.ID,
|
|
IssueID: existing.IssueID,
|
|
AttachmentIds: attachmentIDs,
|
|
}); err != nil {
|
|
slog.Error("failed to replace comment attachments", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "failed to update attachments")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fetch reactions and attachments for the updated comment.
|
|
grouped := h.groupReactions(r, []pgtype.UUID{comment.ID})
|
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{comment.ID})
|
|
cid := uuidToString(comment.ID)
|
|
resp := commentToResponse(comment, grouped[cid], groupedAtt[cid])
|
|
slog.Info("comment updated", append(logger.RequestAttrs(r), "comment_id", commentId)...)
|
|
h.publish(protocol.EventCommentUpdated, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
|
|
|
if oldContent != comment.Content {
|
|
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), existing.ID); err != nil {
|
|
slog.Warn("cancel tasks for edited comment failed", "comment_id", uuidToString(existing.ID), "error", err)
|
|
}
|
|
|
|
issue, err := h.Queries.GetIssue(r.Context(), existing.IssueID)
|
|
if err != nil {
|
|
slog.Warn("load issue for edit post-processing failed", "issue_id", uuidToString(existing.IssueID), "error", err)
|
|
} else {
|
|
var parentComment *db.Comment
|
|
if existing.ParentID.Valid {
|
|
parent, err := h.Queries.GetComment(r.Context(), existing.ParentID)
|
|
if err == nil {
|
|
parentComment = &parent
|
|
}
|
|
}
|
|
|
|
h.triggerTasksForComment(r.Context(), issue, comment, parentComment, actorType, actorID)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
|
commentId := chi.URLParam(r, "commentId")
|
|
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Load comment scoped to current workspace.
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
|
ID: commentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "comment not found")
|
|
return
|
|
}
|
|
|
|
member, ok := h.workspaceMember(w, r, workspaceID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
isAuthor := comment.AuthorType == actorType && uuidToString(comment.AuthorID) == actorID
|
|
isAdmin := roleAllowed(member.Role, "owner", "admin")
|
|
if !isAuthor && !isAdmin {
|
|
writeError(w, http.StatusForbidden, "only comment author or admin can delete")
|
|
return
|
|
}
|
|
|
|
// Collect attachment URLs before CASCADE delete removes them.
|
|
attachmentURLs, _ := h.Queries.ListAttachmentURLsByCommentID(r.Context(), comment.ID)
|
|
|
|
// Cancel any active tasks triggered by this comment so the agent does not
|
|
// run with the now-deleted content already embedded in its prompt. Must
|
|
// run before DeleteComment because the FK ON DELETE SET NULL would
|
|
// otherwise nullify trigger_comment_id and orphan those tasks in queued.
|
|
if err := h.TaskService.CancelTasksByTriggerComment(r.Context(), comment.ID); err != nil {
|
|
slog.Warn("cancel tasks for deleted trigger comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
|
}
|
|
|
|
if err := h.Queries.DeleteComment(r.Context(), db.DeleteCommentParams{
|
|
ID: comment.ID,
|
|
WorkspaceID: comment.WorkspaceID,
|
|
}); err != nil {
|
|
slog.Warn("delete comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", commentId)...)
|
|
writeError(w, http.StatusInternalServerError, "failed to delete comment")
|
|
return
|
|
}
|
|
|
|
h.deleteS3Objects(r.Context(), attachmentURLs)
|
|
slog.Info("comment deleted", append(logger.RequestAttrs(r), "comment_id", commentId, "issue_id", uuidToString(comment.IssueID))...)
|
|
h.publish(protocol.EventCommentDeleted, workspaceID, actorType, actorID, map[string]any{
|
|
"comment_id": uuidToString(comment.ID),
|
|
"issue_id": uuidToString(comment.IssueID),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// loadCommentForActor resolves a {commentId} URL param to a comment in the
|
|
// caller's workspace. Returns the comment, the workspace UUID, the actor
|
|
// identity, and ok. Resolve / unresolve handlers share this scaffolding so the
|
|
// workspace membership + tenant guard stay identical. Any comment (root or
|
|
// reply) may be resolved: resolving a root collapses the whole thread; resolving
|
|
// a reply marks it as the thread's resolution. Which one is the thread's
|
|
// resolution is a pure frontend derivation, so the backend stays a plain setter.
|
|
func (h *Handler) loadCommentForActor(w http.ResponseWriter, r *http.Request) (db.Comment, string, string, string, bool) {
|
|
commentId := chi.URLParam(r, "commentId")
|
|
userID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return db.Comment{}, "", "", "", false
|
|
}
|
|
commentUUID, ok := parseUUIDOrBadRequest(w, commentId, "comment id")
|
|
if !ok {
|
|
return db.Comment{}, "", "", "", false
|
|
}
|
|
workspaceID := h.resolveWorkspaceID(r)
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return db.Comment{}, "", "", "", false
|
|
}
|
|
if _, ok := h.workspaceMember(w, r, workspaceID); !ok {
|
|
return db.Comment{}, "", "", "", false
|
|
}
|
|
comment, err := h.Queries.GetCommentInWorkspace(r.Context(), db.GetCommentInWorkspaceParams{
|
|
ID: commentUUID,
|
|
WorkspaceID: wsUUID,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "comment not found")
|
|
return db.Comment{}, "", "", "", false
|
|
}
|
|
actorType, actorID := h.resolveActor(r, userID, workspaceID)
|
|
return comment, workspaceID, actorType, actorID, true
|
|
}
|
|
|
|
func (h *Handler) ResolveComment(w http.ResponseWriter, r *http.Request) {
|
|
comment, workspaceID, actorType, actorID, ok := h.loadCommentForActor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wasResolved := comment.ResolvedAt.Valid
|
|
|
|
actorUUID, err := util.ParseUUID(actorID)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid actor id")
|
|
return
|
|
}
|
|
|
|
// Single-resolution invariant: a thread has at most one resolved comment, so
|
|
// resolving this one must clear any other resolution in the same thread. Both
|
|
// writes run in one tx — clearing the old resolution and setting the new one
|
|
// is atomic, so a crash can never leave two resolutions (or none) visible.
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
cleared, err := qtx.ClearOtherThreadResolutions(r.Context(), db.ClearOtherThreadResolutionsParams{
|
|
TargetID: comment.ID,
|
|
IssueID: comment.IssueID,
|
|
WorkspaceID: comment.WorkspaceID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("clear other thread resolutions failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
|
|
return
|
|
}
|
|
|
|
updated, err := qtx.ResolveComment(r.Context(), db.ResolveCommentParams{
|
|
ID: comment.ID,
|
|
ResolvedByType: pgtype.Text{String: actorType, Valid: true},
|
|
ResolvedByID: actorUUID,
|
|
})
|
|
if err != nil {
|
|
slog.Warn("resolve comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
|
|
return
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
slog.Warn("resolve comment commit failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to resolve comment")
|
|
return
|
|
}
|
|
|
|
// Emit a comment:unresolved per cleared sibling so granular realtime
|
|
// consumers (which patch a single comment in place) drop the stale
|
|
// resolution instead of showing two. Published after commit so no event ever
|
|
// describes an uncommitted state.
|
|
for _, c := range cleared {
|
|
clearedID := uuidToString(c.ID)
|
|
clearedReactions := h.groupReactions(r, []pgtype.UUID{c.ID})
|
|
clearedAtt := h.groupAttachments(r, []pgtype.UUID{c.ID})
|
|
clearedResp := commentToResponse(c, clearedReactions[clearedID], clearedAtt[clearedID])
|
|
slog.Info("comment unresolved (replaced)", append(logger.RequestAttrs(r), "comment_id", clearedID)...)
|
|
h.publish(protocol.EventCommentUnresolved, workspaceID, actorType, actorID, map[string]any{"comment": clearedResp})
|
|
}
|
|
|
|
grouped := h.groupReactions(r, []pgtype.UUID{updated.ID})
|
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{updated.ID})
|
|
cid := uuidToString(updated.ID)
|
|
resp := commentToResponse(updated, grouped[cid], groupedAtt[cid])
|
|
|
|
// Suppress the target event on a re-resolve no-op so consumers do not
|
|
// re-process an unchanged thread (notifications, log spam). Cleared siblings
|
|
// still get their own events above — those rows did change.
|
|
if !wasResolved {
|
|
slog.Info("comment resolved", append(logger.RequestAttrs(r), "comment_id", cid)...)
|
|
h.publish(protocol.EventCommentResolved, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) UnresolveComment(w http.ResponseWriter, r *http.Request) {
|
|
comment, workspaceID, actorType, actorID, ok := h.loadCommentForActor(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
wasResolved := comment.ResolvedAt.Valid
|
|
|
|
updated, err := h.Queries.UnresolveComment(r.Context(), comment.ID)
|
|
if err != nil {
|
|
slog.Warn("unresolve comment failed", append(logger.RequestAttrs(r), "error", err, "comment_id", uuidToString(comment.ID))...)
|
|
writeError(w, http.StatusInternalServerError, "failed to unresolve comment")
|
|
return
|
|
}
|
|
|
|
grouped := h.groupReactions(r, []pgtype.UUID{updated.ID})
|
|
groupedAtt := h.groupAttachments(r, []pgtype.UUID{updated.ID})
|
|
cid := uuidToString(updated.ID)
|
|
resp := commentToResponse(updated, grouped[cid], groupedAtt[cid])
|
|
|
|
if wasResolved {
|
|
slog.Info("comment unresolved", append(logger.RequestAttrs(r), "comment_id", cid)...)
|
|
h.publish(protocol.EventCommentUnresolved, workspaceID, actorType, actorID, map[string]any{"comment": resp})
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|