From f353e8db596b413f9762d893c4138b4e4b03b4fc Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:48:53 +0800 Subject: [PATCH] feat(mentions): support @mentioning issues + server-side auto-expansion (#242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mentions): support @mentioning issues in comments - Extend MentionItem type to include "issue" alongside "member"/"agent" - Add issue search (by identifier and title) to mention suggestion dropdown - Render issue mentions with CircleDot icon in autocomplete popup - Issue mentions serialize as [MUL-117 Title](mention://issue/id) (no @ prefix) - Markdown renderer shows issue mentions as clickable links to /issues/:id - Backend mentionRe regex updated to match issue mention type * feat(mentions): auto-expand issue identifiers and add mention format to agent instructions 1. Path A — CLAUDE.md template (runtime_config.go): Add a "## Mentions" section teaching agents the mention serialization format for issues, members, and agents. All agents automatically receive this via the auto-generated CLAUDE.md. 2. Approach 2 — Server-side auto-conversion (internal/mention/): New ExpandIssueIdentifiers() utility that scans comment content for bare issue identifiers (e.g. MUL-117) and replaces them with [MUL-117](mention://issue/) mention links. Skips code blocks, inline code, and existing markdown links. Integrated into both: - handler.CreateComment (HTTP API path) - service.createAgentComment (agent task output path) --- .../components/common/rich-text-editor.tsx | 12 +- server/cmd/server/notification_listeners.go | 5 +- .../internal/daemon/execenv/runtime_config.go | 7 + server/internal/handler/comment.go | 4 + server/internal/mention/expand.go | 196 ++++++++++++++++++ server/internal/mention/expand_test.go | 119 +++++++++++ server/internal/service/task.go | 13 +- server/internal/util/mention.go | 9 +- 8 files changed, 350 insertions(+), 15 deletions(-) create mode 100644 server/internal/mention/expand.go create mode 100644 server/internal/mention/expand_test.go diff --git a/apps/web/components/common/rich-text-editor.tsx b/apps/web/components/common/rich-text-editor.tsx index a07adeed..67f597cc 100644 --- a/apps/web/components/common/rich-text-editor.tsx +++ b/apps/web/components/common/rich-text-editor.tsx @@ -63,6 +63,8 @@ const MentionExtension = Mention.configure({ suggestion: createMentionSuggestion(), }).extend({ renderHTML({ node, HTMLAttributes }) { + const type = node.attrs.type ?? "member"; + const prefix = type === "issue" ? "" : "@"; return [ "span", mergeAttributes( @@ -74,7 +76,7 @@ const MentionExtension = Mention.configure({ "data-mention-id": node.attrs.id, }, ), - `@${node.attrs.label ?? node.attrs.id}`, + `${prefix}${node.attrs.label ?? node.attrs.id}`, ]; }, addAttributes() { @@ -89,15 +91,16 @@ const MentionExtension = Mention.configure({ }; }, // @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id) + // and [Label](mention://issue/id) (issue mentions have no @ prefix) markdownTokenizer: { name: "mention", level: "inline" as const, start(src: string) { - return src.search(/\[@[^\]]+\]\(mention:\/\//); + return src.search(/\[@?[^\]]+\]\(mention:\/\//); }, tokenize(src: string) { const match = src.match( - /^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, + /^\[@?([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/, ); if (!match) return undefined; return { @@ -114,7 +117,8 @@ const MentionExtension = Mention.configure({ // eslint-disable-next-line @typescript-eslint/no-explicit-any renderMarkdown: (node: any) => { const { id, label, type = "member" } = node.attrs || {}; - return `[@${label ?? id}](mention://${type}/${id})`; + const prefix = type === "issue" ? "" : "@"; + return `[${prefix}${label ?? id}](mention://${type}/${id})`; }, }); diff --git a/server/cmd/server/notification_listeners.go b/server/cmd/server/notification_listeners.go index 5e5b78f3..9c39c853 100644 --- a/server/cmd/server/notification_listeners.go +++ b/server/cmd/server/notification_listeners.go @@ -14,10 +14,11 @@ import ( // mention represents a parsed @mention from markdown content (local alias). type mention struct { - Type string // "member", "agent", or "all" - ID string // user_id, agent_id, or "all" + Type string // "member", "agent", "issue", or "all" + ID string // user_id, agent_id, issue_id, or "all" } + // statusLabels maps DB status values to human-readable labels for notifications. var statusLabels = map[string]string{ "backlog": "Backlog", diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go index e334a9c0..4d4e604b 100644 --- a/server/internal/daemon/execenv/runtime_config.go +++ b/server/internal/daemon/execenv/runtime_config.go @@ -126,6 +126,13 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string { b.WriteString("\n") } + b.WriteString("## Mentions\n\n") + b.WriteString("When referencing issues or people in comments, use the mention format so they render as interactive links:\n\n") + b.WriteString("- **Issue**: `[MUL-123](mention://issue/)` — renders as a clickable link to the issue\n") + b.WriteString("- **Member**: `[@Name](mention://member/)` — renders as a styled mention and sends a notification\n") + b.WriteString("- **Agent**: `[@Name](mention://agent/)` — renders as a styled mention\n\n") + b.WriteString("Use `multica issue list --output json` to look up issue IDs, and `multica workspace members --output json` for member IDs.\n\n") + b.WriteString("## Output\n\n") b.WriteString("Keep comments concise and natural — state the outcome, not the process.\n") b.WriteString("Good: \"Fixed the login redirect. PR: https://...\"\n") diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index 9bd353e7..5e0e4c59 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -9,6 +9,7 @@ import ( "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/util" db "github.com/multica-ai/multica/server/pkg/db/generated" "github.com/multica-ai/multica/server/pkg/protocol" @@ -130,6 +131,9 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) { // Determine author identity: agent (via X-Agent-ID header) or member. authorType, authorID := h.resolveActor(r, userID, uuidToString(issue.WorkspaceID)) + // Expand bare issue identifiers (e.g. MUL-117) into mention links. + req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content) + comment, err := h.Queries.CreateComment(r.Context(), db.CreateCommentParams{ IssueID: issue.ID, WorkspaceID: issue.WorkspaceID, diff --git a/server/internal/mention/expand.go b/server/internal/mention/expand.go new file mode 100644 index 00000000..1c9d9329 --- /dev/null +++ b/server/internal/mention/expand.go @@ -0,0 +1,196 @@ +// Package mention provides utilities for expanding issue identifier references +// (e.g. MUL-117) into clickable mention links in markdown content. +package mention + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// IssueResolver looks up an issue by workspace and number. +// Implemented by db.Queries. +type IssueResolver interface { + GetIssueByNumber(ctx context.Context, arg db.GetIssueByNumberParams) (db.Issue, error) +} + +// PrefixResolver looks up a workspace to get its issue prefix. +type PrefixResolver interface { + GetWorkspace(ctx context.Context, id pgtype.UUID) (db.Workspace, error) +} + +// Resolver combines both interfaces needed for mention expansion. +type Resolver interface { + IssueResolver + PrefixResolver +} + +// ExpandIssueIdentifiers scans markdown content for bare issue identifier +// patterns (e.g. MUL-117) and replaces them with mention links: +// [MUL-117](mention://issue/) +// +// It skips identifiers that are: +// - Already inside a markdown link: [MUL-117](...) +// - Inside inline code: `MUL-117` +// - Inside fenced code blocks: ```...``` +func ExpandIssueIdentifiers(ctx context.Context, resolver Resolver, workspaceID pgtype.UUID, content string) string { + // Get the workspace prefix. + ws, err := resolver.GetWorkspace(ctx, workspaceID) + if err != nil || ws.IssuePrefix == "" { + return content + } + prefix := ws.IssuePrefix + + // Build a regex that matches the workspace prefix followed by a hyphen and number. + // Use word boundaries to avoid matching inside longer strings. + // The prefix is escaped in case it contains regex-special characters. + pattern := regexp.MustCompile(`(?:^|(?:\W))` + `(` + regexp.QuoteMeta(prefix) + `-(\d+))` + `(?:\W|$)`) + + // First, identify regions to skip: fenced code blocks and inline code. + skipRegions := findSkipRegions(content) + + // Find all matches and process from right to left (to preserve offsets). + allMatches := pattern.FindAllStringSubmatchIndex(content, -1) + if len(allMatches) == 0 { + return content + } + + // Build a set of replacements (offset → replacement string). + type replacement struct { + start, end int + text string + } + var replacements []replacement + + for _, match := range allMatches { + // match[2:4] is the full identifier (e.g. "MUL-117") + // match[4:6] is the number part (e.g. "117") + identStart, identEnd := match[2], match[3] + numStr := content[match[4]:match[5]] + + // Skip if inside a code region. + if inSkipRegion(identStart, skipRegions) { + continue + } + + // Skip if already inside a markdown link: check if preceded by [ + // or followed by ](...). + if isInsideMarkdownLink(content, identStart, identEnd) { + continue + } + + num, err := strconv.Atoi(numStr) + if err != nil || num <= 0 { + continue + } + + // Look up the issue. + issue, err := resolver.GetIssueByNumber(ctx, db.GetIssueByNumberParams{ + WorkspaceID: workspaceID, + Number: int32(num), + }) + if err != nil { + continue // Issue doesn't exist — leave as-is. + } + + identifier := content[identStart:identEnd] + issueID := uuidToString(issue.ID) + mentionLink := fmt.Sprintf("[%s](mention://issue/%s)", identifier, issueID) + + replacements = append(replacements, replacement{ + start: identStart, + end: identEnd, + text: mentionLink, + }) + } + + if len(replacements) == 0 { + return content + } + + // Apply replacements from right to left to preserve offsets. + result := content + for i := len(replacements) - 1; i >= 0; i-- { + r := replacements[i] + result = result[:r.start] + r.text + result[r.end:] + } + + return result +} + +// skipRegion represents a region of text that should not be modified. +type skipRegion struct { + start, end int +} + +// findSkipRegions identifies fenced code blocks (```) and inline code (`) +// regions in the content. +func findSkipRegions(content string) []skipRegion { + var regions []skipRegion + + // Fenced code blocks: ```...``` + fenceRe := regexp.MustCompile("(?m)^```[^`]*\n[\\s\\S]*?\n```") + for _, loc := range fenceRe.FindAllStringIndex(content, -1) { + regions = append(regions, skipRegion{loc[0], loc[1]}) + } + + // Inline code: `...` (but not inside fenced blocks — already handled). + inlineRe := regexp.MustCompile("`[^`\n]+`") + for _, loc := range inlineRe.FindAllStringIndex(content, -1) { + regions = append(regions, skipRegion{loc[0], loc[1]}) + } + + return regions +} + +// inSkipRegion checks if a position falls within any skip region. +func inSkipRegion(pos int, regions []skipRegion) bool { + for _, r := range regions { + if pos >= r.start && pos < r.end { + return true + } + } + return false +} + +// isInsideMarkdownLink checks if the text at [start:end] is already part of +// a markdown link like [MUL-117](mention://...) or [text](url). +func isInsideMarkdownLink(content string, start, end int) bool { + // Check if preceded by '[' (part of link text). + if start > 0 { + before := strings.TrimRight(content[:start], " ") + if len(before) > 0 && before[len(before)-1] == '[' { + return true + } + } + // Check if followed by '](', indicating it's the link text of a markdown link. + after := content[end:] + if strings.HasPrefix(after, "](") { + return true + } + // Check if we're inside the URL part of a link: ...](mention://issue/...). + // Look backwards for ]( pattern. + idx := strings.LastIndex(content[:start], "](") + if idx >= 0 { + // Check that we haven't passed a closing ) yet. + between := content[idx:start] + if !strings.Contains(between, ")") { + return true + } + } + return false +} + +func uuidToString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + b := u.Bytes + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/server/internal/mention/expand_test.go b/server/internal/mention/expand_test.go new file mode 100644 index 00000000..0fa3d1b7 --- /dev/null +++ b/server/internal/mention/expand_test.go @@ -0,0 +1,119 @@ +package mention + +import ( + "context" + "fmt" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +// mockResolver implements Resolver for testing. +type mockResolver struct { + prefix string + issues map[int32]db.Issue +} + +func (m *mockResolver) GetWorkspace(_ context.Context, _ pgtype.UUID) (db.Workspace, error) { + return db.Workspace{IssuePrefix: m.prefix}, nil +} + +func (m *mockResolver) GetIssueByNumber(_ context.Context, arg db.GetIssueByNumberParams) (db.Issue, error) { + if issue, ok := m.issues[arg.Number]; ok { + return issue, nil + } + return db.Issue{}, fmt.Errorf("not found") +} + +func makeUUID(id string) pgtype.UUID { + var u pgtype.UUID + u.Valid = true + // Simple deterministic UUID from a short string for testing. + copy(u.Bytes[:], []byte(fmt.Sprintf("%-16s", id))) + return u +} + +func TestExpandIssueIdentifiers(t *testing.T) { + ctx := context.Background() + wsID := makeUUID("ws1") + issueID := makeUUID("issue117") + + resolver := &mockResolver{ + prefix: "MUL", + issues: map[int32]db.Issue{ + 117: {ID: issueID, Number: 117}, + }, + } + + tests := []struct { + name string + input string + want string + }{ + { + name: "basic replacement", + input: "See MUL-117 for details", + want: "See [MUL-117](mention://issue/" + uuidToString(issueID) + ") for details", + }, + { + name: "at start of line", + input: "MUL-117 is important", + want: "[MUL-117](mention://issue/" + uuidToString(issueID) + ") is important", + }, + { + name: "at end of line", + input: "Check out MUL-117", + want: "Check out [MUL-117](mention://issue/" + uuidToString(issueID) + ")", + }, + { + name: "already a mention link", + input: "[MUL-117](mention://issue/some-id)", + want: "[MUL-117](mention://issue/some-id)", + }, + { + name: "inside inline code", + input: "Run `MUL-117` to test", + want: "Run `MUL-117` to test", + }, + { + name: "inside fenced code block", + input: "```\nMUL-117\n```", + want: "```\nMUL-117\n```", + }, + { + name: "non-existent issue unchanged", + input: "See MUL-999 for details", + want: "See MUL-999 for details", + }, + { + name: "no match", + input: "No issues here", + want: "No issues here", + }, + { + name: "already a markdown link text", + input: "[MUL-117](https://example.com)", + want: "[MUL-117](https://example.com)", + }, + { + name: "multiple references", + input: "MUL-117 and also MUL-117 again", + want: "[MUL-117](mention://issue/" + uuidToString(issueID) + ") and also [MUL-117](mention://issue/" + uuidToString(issueID) + ") again", + }, + { + name: "with parentheses", + input: "(MUL-117)", + want: "([MUL-117](mention://issue/" + uuidToString(issueID) + "))", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExpandIssueIdentifiers(ctx, resolver, wsID, tt.input) + if got != tt.want { + t.Errorf("ExpandIssueIdentifiers() =\n %q\nwant:\n %q", got, tt.want) + } + }) + } +} diff --git a/server/internal/service/task.go b/server/internal/service/task.go index fb44e613..d4026ff6 100644 --- a/server/internal/service/task.go +++ b/server/internal/service/task.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/events" + "github.com/multica-ai/multica/server/internal/mention" "github.com/multica-ai/multica/server/internal/realtime" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -454,6 +455,13 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p if content == "" { return } + // Look up issue to get workspace ID for mention expansion and broadcasting. + issue, err := s.Queries.GetIssue(ctx, issueID) + if err != nil { + return + } + // Expand bare issue identifiers (e.g. MUL-117) into mention links. + content = mention.ExpandIssueIdentifiers(ctx, s.Queries, issue.WorkspaceID, content) comment, err := s.Queries.CreateComment(ctx, db.CreateCommentParams{ IssueID: issueID, AuthorType: "agent", @@ -465,11 +473,6 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p if err != nil { return } - // Look up issue to get workspace ID for broadcasting - issue, err := s.Queries.GetIssue(ctx, issueID) - if err != nil { - return - } s.Bus.Publish(events.Event{ Type: protocol.EventCommentCreated, WorkspaceID: util.UUIDToString(issue.WorkspaceID), diff --git a/server/internal/util/mention.go b/server/internal/util/mention.go index d2557bdd..b15e0781 100644 --- a/server/internal/util/mention.go +++ b/server/internal/util/mention.go @@ -4,12 +4,13 @@ import "regexp" // Mention represents a parsed @mention from markdown content. type Mention struct { - Type string // "member", "agent", or "all" - ID string // user_id, agent_id, or "all" + Type string // "member", "agent", "issue", or "all" + ID string // user_id, agent_id, issue_id, or "all" } -// MentionRe matches [@Label](mention://type/id) in markdown. -var MentionRe = regexp.MustCompile(`\[@[^\]]*\]\(mention://(member|agent|all)/([0-9a-fA-F-]+|all)\)`) +// MentionRe matches [@Label](mention://type/id) or [Label](mention://issue/id) in markdown. +// The @ prefix is optional to support issue mentions which use [MUL-123](mention://issue/...). +var MentionRe = regexp.MustCompile(`\[@?[^\]]*\]\(mention://(member|agent|issue|all)/([0-9a-fA-F-]+|all)\)`) // IsMentionAll returns true if the mention is an @all mention. func (m Mention) IsMentionAll() bool {