feat(mentions): support @mentioning issues + server-side auto-expansion (#242)

* 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/<uuid>) 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)
This commit is contained in:
Bohan Jiang 2026-04-02 13:48:53 +08:00 committed by GitHub
parent cd1b1155c1
commit f353e8db59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 350 additions and 15 deletions

View file

@ -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})`;
},
});

View file

@ -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",

View file

@ -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/<issue-id>)` — renders as a clickable link to the issue\n")
b.WriteString("- **Member**: `[@Name](mention://member/<user-id>)` — renders as a styled mention and sends a notification\n")
b.WriteString("- **Agent**: `[@Name](mention://agent/<agent-id>)` — 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")

View file

@ -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,

View file

@ -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/<uuid>)
//
// 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])
}

View file

@ -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)
}
})
}
}

View file

@ -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),

View file

@ -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 {