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:
parent
cd1b1155c1
commit
f353e8db59
8 changed files with 350 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
196
server/internal/mention/expand.go
Normal file
196
server/internal/mention/expand.go
Normal 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])
|
||||
}
|
||||
119
server/internal/mention/expand_test.go
Normal file
119
server/internal/mention/expand_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue