Files
multica/server/internal/handler/comment.go
Jiayuan Zhang 4d6b5ad06f fix(squad): wake leader when dual-role agent posts as worker (MUL-2218) (#2626)
* fix(squad): wake leader when dual-role agent posts as worker (MUL-2218)

The squad-leader self-trigger guard skipped a comment whenever the
author equalled the squad's leader id, regardless of the role the agent
was acting in. For an agent that holds both leader and worker roles in
the same squad, this meant the leader role never reacted to its own
worker output and the issue stalled.

Tag each enqueued task with is_leader_task and consult the agent's
most recent task on the issue from both self-trigger guards (comment
path + @squad mention path) — skip only when that task was itself a
leader task.

Co-authored-by: multica-agent <github@multica.ai>

* fix(squad): inherit is_leader_task on retry task clone (MUL-2218)

CreateRetryTask cloned a parent task into a fresh queued attempt but
omitted is_leader_task from the column list, so the child silently fell
back to the column default (false). For a leader task that hit auto-retry
through MaybeRetryFailedTask, the retried task posed as a worker task —
the self-trigger guard then no longer recognised the leader's own
comments, re-opening the very loop MUL-2218 closes.

Inherit p.is_leader_task in the clone and add a query-level test that
covers both leader and worker retries.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 15:23:36 +02:00

784 lines
30 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"time"
"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"`
}
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,
}
}
// 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
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
}
// Only `since` is honoured — used by the CLI's `--since` agent-polling
// flow to fetch incremental comments. The previous limit/offset cursor
// was ripped out (#1929): time-based pagination breaks reply threads,
// and at the actual data sizes there is no win from paging.
var sinceTime pgtype.Timestamptz
if v := r.URL.Query().Get("since"); v != "" {
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}
}
var comments []db.Comment
var err error
if sinceTime.Valid {
comments, err = h.Queries.ListCommentsSinceForIssue(r.Context(), db.ListCommentsSinceForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
CreatedAt: sinceTime,
Limit: commentHardCap,
})
} else {
comments, err = h.Queries.ListCommentsForIssue(r.Context(), db.ListCommentsForIssueParams{
IssueID: issue.ID,
WorkspaceID: issue.WorkspaceID,
Limit: commentHardCap,
})
}
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
commentIDs := make([]pgtype.UUID, len(comments))
for i, c := range comments {
commentIDs[i] = c.ID
}
grouped := h.groupReactions(r, commentIDs)
groupedAtt := h.groupAttachments(r, commentIDs)
resp := make([]CommentResponse, len(comments))
for i, c := range comments {
cid := uuidToString(c.ID)
resp[i] = commentToResponse(c, grouped[cid], groupedAtt[cid])
}
writeJSON(w, http.StatusOK, resp)
}
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.
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(), parentComment, uuidToString(issue.WorkspaceID), authorType, authorID)
// If the issue is assigned to an agent with on_comment trigger, enqueue a new task.
// Skip when the comment comes from the assigned agent itself to avoid loops.
// Also skip when the comment @mentions others but not the assignee agent —
// the user is talking to someone else, not requesting work from the assignee.
// Also skip when replying in a member-started thread without mentioning the
// assignee — the user is continuing a member-to-member conversation.
if authorType == "member" && h.shouldEnqueueOnComment(r.Context(), issue) &&
!h.commentMentionsOthersButNotAssignee(comment.Content, issue) &&
!h.isReplyToMemberThread(r.Context(), parentComment, comment.Content, issue) {
// Always use the current comment as the trigger so the agent reads
// the actual new reply, not the thread root. Reply placement (flat
// thread grouping) is handled downstream by createAgentComment,
// which resolves parent_id to the thread root before posting. This
// mirrors the mention path's behavior (see enqueueMentionedAgentTasks).
if _, err := h.TaskService.EnqueueTaskForIssue(r.Context(), issue, comment.ID); err != nil {
slog.Warn("enqueue agent task on comment failed", "issue_id", issueID, "error", err)
}
}
// Squad trigger: if the issue is assigned to a squad, trigger the squad leader.
// Skip when the comment author is the leader (prevent internal loops), or
// when a member explicitly @mentions anyone (agent/member/squad/all) — that
// counts as deliberate routing and the leader stays out.
if h.shouldEnqueueSquadLeaderOnComment(r.Context(), issue, comment.Content, authorType, authorID) {
h.enqueueSquadLeaderTask(r.Context(), issue, comment.ID, authorType, authorID)
}
// Trigger @mentioned agents: parse agent mentions and enqueue tasks for each.
// Pass parentComment so that replies inherit mentions from the thread root.
h.enqueueMentionedAgentTasks(r.Context(), issue, comment, parentComment, authorType, authorID)
writeJSON(w, http.StatusCreated, resp)
}
// 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 self-mentions, 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).
// 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
}
// Prevent self-trigger: skip if the comment author is this agent.
if authorType == "agent" && authorID == m.ID {
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
}
attachmentIDs, ok := parseUUIDSliceOrBadRequest(w, req.AttachmentIDs, "attachment_ids")
if !ok {
return
}
// NOTE: See CreateComment — Markdown is sanitized at render/edit time, not here.
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
}
// Bind any newly uploaded attachments referenced in the edited content so
// they appear in the timeline's comment.attachments after refresh. Existing
// attachments already point at this comment via the upload flow; passing
// them again is a no-op at the SQL level.
if len(attachmentIDs) > 0 {
h.linkAttachmentsByIDs(r.Context(), comment.ID, existing.IssueID, attachmentIDs)
}
// 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})
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(), comment.ID); 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)
}
// loadRootCommentForActor resolves a {commentId} URL param to a root 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
// "must be a root comment" rule lives in one place.
func (h *Handler) loadRootCommentForActor(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
}
if comment.ParentID.Valid {
writeError(w, http.StatusBadRequest, "only root comments can be resolved")
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.loadRootCommentForActor(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
}
updated, err := h.Queries.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
}
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 event on a re-resolve no-op so consumers do not re-process
// an unchanged thread (notifications, log spam).
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.loadRootCommentForActor(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)
}