Files
multica/server/internal/handler/comment_invocation_notice.go
1eb4b5d1f8 MUL-8203: record permission-blocked mentions as system replies (#9624)
* fix(comments): leave system replies for denied mentions (MUL-8203)

Co-authored-by: multica-agent <[email protected]>

* fix(comments): address refusal notice review nits (MUL-8203)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Yushen <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-10-28 15:30:58 +08:00

104 lines
3.8 KiB
Go

package handler
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"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/dbid"
"github.com/multica-ai/multica/server/pkg/protocol"
)
func (h *Handler) noteCommentInvocationDenied(ctx context.Context, issue db.Issue, comment db.Comment, outcomes []CommentTriggerOutcome) {
var denied []CommentTriggerOutcome
for _, outcome := range outcomes {
if outcome.Status == DispatchBlocked && outcome.ReasonCode == ReasonInvocationNotAllowed {
denied = append(denied, outcome)
}
}
if len(denied) == 0 {
return
}
// Labels come only from the submitted text. Resolving a denied target's
// name or runtime here would reveal information the caller cannot access.
labels := make(map[string]string)
for _, match := range util.MentionRe.FindAllStringSubmatch(comment.Content, -1) {
key := match[2] + ":" + match[3]
if _, exists := labels[key]; !exists {
labels[key] = match[1]
}
}
var content strings.Builder
fmt.Fprintf(&content, "These mentions did not start when the parent comment was submitted on %s:\n", comment.UpdatedAt.Time.UTC().Format("Jan 2, 2006 at 15:04:05 MST"))
for _, outcome := range denied {
label := labels[outcome.TargetType+":"+outcome.TargetID]
if label == "" {
label = outcome.TargetType + " " + outcome.TargetID
}
fmt.Fprintf(&content, "\n- **%s**: Target not found or permission not granted.", escapeMarkdownInline(label))
}
h.noteTriggerFailure(ctx, issue, content.String(), comment.ID)
}
// noteTriggerFailure extends the existing runtime refusal comments. Notices
// are best-effort and bypass the comment-trigger pipeline to avoid new runs.
func (h *Handler) noteTriggerFailure(ctx context.Context, issue db.Issue, content string, parentID pgtype.UUID, logAttrs ...slog.Attr) {
params := db.CreateCommentParams{
ID: dbid.NewV7(), IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
AuthorType: "system", AuthorID: pgtype.UUID{Valid: true},
Content: content, Type: "system", ParentID: parentID,
}
created, err := h.createTriggerFailureComment(ctx, params)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
logAttrs = append(logAttrs, slog.Any("error", err), slog.String("issue_id", uuidToString(issue.ID)))
slog.LogAttrs(ctx, slog.LevelWarn, "trigger failure notice: create system comment failed", logAttrs...)
}
return
}
comment := created.Comment()
h.publish(protocol.EventCommentCreated, uuidToString(issue.WorkspaceID), "system", "", map[string]any{
"comment": commentToResponse(comment, nil, nil),
"issue_title": issue.Title,
"issue_assignee_type": textToPtr(issue.AssigneeType),
"issue_assignee_id": uuidToPtr(issue.AssigneeID),
"issue_status": issue.Status,
"issue_revision": created.IssueRevision,
})
}
func (h *Handler) createTriggerFailureComment(ctx context.Context, params db.CreateCommentParams) (db.CreateCommentRow, error) {
if !params.ParentID.Valid {
return h.Queries.CreateComment(ctx, params)
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return db.CreateCommentRow{}, err
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
// Use the existing issue -> comment lock order so a deletion cannot leave
// an orphan reply or attach a new notice to a tombstone.
parent, err := qtx.LockLiveComment(ctx, db.LockLiveCommentParams{ID: params.ParentID, WorkspaceID: params.WorkspaceID})
if err != nil {
return db.CreateCommentRow{}, err
}
if parent.IssueID != params.IssueID {
return db.CreateCommentRow{}, pgx.ErrNoRows
}
created, err := qtx.CreateComment(ctx, params)
if err != nil {
return db.CreateCommentRow{}, err
}
if err := tx.Commit(ctx); err != nil {
return db.CreateCommentRow{}, err
}
return created, nil
}