mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
Text/code attachments (markdown, JSON, .ts, .log, …) need an attachment id
to render through `/api/attachments/{id}/content`. The composer pipeline
was dropping that id at the upload-hook boundary, so the Eye preview gate
only fired for media (PDF / video / audio via filename fallback).
- `useFileUpload` now returns the full `Attachment` (with `link` kept as a
`url` alias) so editor providers can resolve content-type and id.
- New-comment and reply composers hold a `pendingAttachments` state and
feed it to `ContentEditor`; the active subset (those still referenced in
the markdown) is sent on submit as before.
- Comment edit modes (CommentRow + CommentCardImpl) merge pending uploads
with `entry.attachments` for the editor and pipe `attachment_ids` into
`onEdit` so newly uploaded files actually bind to the comment.
- Issue description editor pushes pending `attachment_ids` on every
debounced save and invalidates `issueKeys.attachments` so the preview
Eye survives a refresh.
- `UpdateComment` and `UpdateIssue` handlers accept `attachment_ids` and
call the existing `linkAttachmentsByIDs` / `linkAttachmentsByIssueIDs`
helpers; the bind is idempotent so re-sending an existing id is safe.
Closes MUL-2153.
Co-authored-by: multica-agent <github@multica.ai>
780 lines
30 KiB
Go
780 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: skip if the comment author is the leader.
|
|
if authorType == "agent" && authorID == uuidToString(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.EnqueueTaskForMention(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)
|
|
}
|