Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
606267f08a MUL-3310: disable bare issue key expansion in comments
Co-authored-by: multica-agent <github@multica.ai>
2026-06-16 16:11:12 +08:00
10 changed files with 104 additions and 336 deletions

View File

@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
## イシューを参照する
別のイシューをリンクするには、`MUL-123` のようにそのイシューキーを入力してください。Multica はコメント内で実在するイシューキーを解決し、内部的に `mention://issue/<uuid>` リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
別のイシューをリンクするには、コメントの mention ピッカーからそのイシューを選択してください。Multica はイシューリンクを明示的な `[MUL-123](mention://issue/<uuid>)` mention リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
通常は `[MUL-123](mention://issue/<uuid>)` を手で書く必要はありません。その形式は、Multica がキーを解決した後に使う標準的な内部表現です
`MUL-123` のような裸のイシューキーを入力しても、通常のテキストのまま残ります。そのため、`feature/MUL-123` のようなコメント内のブランチ名やパスも書き換えられません
<Callout type="info">
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。

View File

@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
## 이슈 참조하기
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
다른 이슈를 링크하려면 댓글 mention 선택기에서 해당 이슈를 선택하세요. Multica는 이슈 링크를 명시적인 `[MUL-123](mention://issue/<uuid>)` mention 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
`MUL-123` 같은 bare 이슈 키를 입력하면 일반 텍스트로 유지됩니다. 따라서 `feature/MUL-123` 같은 댓글 안의 브랜치 이름과 경로도 다시 작성되지 않습니다.
<Callout type="info">
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.

View File

@@ -39,9 +39,9 @@ Mentioning the same person multiple times in one comment still produces **only o
## Referencing issues
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
To link another issue, choose it from the comment mention picker. Multica stores issue links as an explicit `[MUL-123](mention://issue/<uuid>)` mention link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
Typing a bare issue key, such as `MUL-123`, keeps it as plain text. This also keeps branch names and paths, such as `feature/MUL-123`, from being rewritten inside comments.
<Callout type="info">
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.

View File

@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
## 引用 issue
要链接另一个 issue直接输入它的 issue key例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
要链接另一个 issue请在评论的 mention 选择器里选择它。Multica 会把 issue 链接存成显式的 `[MUL-123](mention://issue/<uuid>)` mention 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式
直接输入裸 issue key例如 `MUL-123`,会保持为普通文本。这样评论里的分支名和路径,例如 `feature/MUL-123`,也不会被改写
<Callout type="info">
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。

View File

@@ -13,7 +13,6 @@ 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/service"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
@@ -896,7 +895,7 @@ func (h *Handler) PreviewCommentTriggers(w http.ResponseWriter, r *http.Request)
parentComment = &parent
}
content := mention.ExpandIssueIdentifiers(r.Context(), h.Queries, issue.WorkspaceID, req.Content)
content := req.Content
if content == "" {
writeJSON(w, http.StatusOK, CommentTriggerPreviewResponse{Agents: []CommentTriggerAgentResponse{}})
return
@@ -1005,9 +1004,6 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
}
}
// 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
@@ -1538,9 +1534,6 @@ func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
oldContent := existing.Content
// Expand bare issue identifiers (same pipeline as CreateComment).
req.Content = mention.ExpandIssueIdentifiers(r.Context(), h.Queries, wsUUID, req.Content)
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
ID: commentUUID,
Content: req.Content,

View File

@@ -2128,10 +2128,12 @@ func TestCompleteTask_CommentTriggered_SynthesizesCommentWhenAgentSilent(t *test
t.Fatalf("setup: get agent: %v", err)
}
setWorkspaceIssuePrefixForTest(t, "MUL")
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
VALUES ($1, 'mul-1198 fixture', 'in_progress', 'none', $2, 'member', 81198, 0)
VALUES ($1, 'mul-3310 agent output fixture', 'in_progress', 'none', $2, 'member', 3310, 0)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
t.Fatalf("setup: create issue: %v", err)
@@ -2161,7 +2163,10 @@ func TestCompleteTask_CommentTriggered_SynthesizesCommentWhenAgentSilent(t *test
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
const agentFinalOutput = "sure, will look into it shortly"
agentFinalOutput := fmt.Sprintf(
"sure, see MUL-3310, issue/MUL-3310, feature/MUL-3310, and [MUL-3310](mention://issue/%s)",
issueID,
)
w := httptest.NewRecorder()
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/complete",

View File

@@ -171,6 +171,22 @@ func withURLParam(req *http.Request, key, value string) *http.Request {
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
func setWorkspaceIssuePrefixForTest(t *testing.T, prefix string) {
t.Helper()
ctx := context.Background()
var previous string
if err := testPool.QueryRow(ctx, `SELECT issue_prefix FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&previous); err != nil {
t.Fatalf("load workspace prefix: %v", err)
}
if _, err := testPool.Exec(ctx, `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, prefix, testWorkspaceID); err != nil {
t.Fatalf("set workspace prefix: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `UPDATE workspace SET issue_prefix = $1 WHERE id = $2`, previous, testWorkspaceID)
})
}
func handlerTestRuntimeID(t *testing.T) string {
t.Helper()
@@ -1686,6 +1702,78 @@ func TestCommentCRUD(t *testing.T) {
testHandler.DeleteIssue(w, req)
}
func TestCommentWritePathsPreserveIssueIdentifiers(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("requires DB")
}
ctx := context.Background()
setWorkspaceIssuePrefixForTest(t, "MUL")
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title, number)
VALUES ($1, 'member', $2, $3, 3310)
RETURNING id
`, testWorkspaceID, testUserID, "preserve bare issue identifiers").Scan(&issueID); err != nil {
t.Fatalf("create issue fixture: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
explicitMention := fmt.Sprintf("[MUL-3310](mention://issue/%s)", issueID)
createCases := []string{
"MUL-3310",
"issue/MUL-3310",
"feature/MUL-3310",
explicitMention,
}
var firstCommentID string
for _, content := range createCases {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues/"+issueID+"/comments", map[string]any{
"content": content,
})
req = withURLParam(req, "id", issueID)
testHandler.CreateComment(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateComment(%q): expected 201, got %d: %s", content, w.Code, w.Body.String())
}
var created CommentResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode created comment: %v", err)
}
if created.Content != content {
t.Fatalf("CreateComment(%q) stored %q", content, created.Content)
}
if firstCommentID == "" {
firstCommentID = created.ID
}
}
updatedContent := "updated MUL-3310 issue/MUL-3310 feature/MUL-3310 " + explicitMention
w := httptest.NewRecorder()
req := newRequest("PUT", "/api/comments/"+firstCommentID, map[string]any{
"content": updatedContent,
})
req = withURLParam(req, "commentId", firstCommentID)
testHandler.UpdateComment(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateComment: expected 200, got %d: %s", w.Code, w.Body.String())
}
var updated CommentResponse
if err := json.NewDecoder(w.Body).Decode(&updated); err != nil {
t.Fatalf("decode updated comment: %v", err)
}
if updated.Content != updatedContent {
t.Fatalf("UpdateComment stored %q, want %q", updated.Content, updatedContent)
}
}
func TestCreateCommentRejectsMalformedParentID(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{

View File

@@ -1,196 +0,0 @@
// 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

@@ -1,119 +0,0 @@
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

@@ -15,7 +15,6 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/analytics"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/mention"
obsmetrics "github.com/multica-ai/multica/server/internal/metrics"
"github.com/multica-ai/multica/server/internal/realtime"
"github.com/multica-ai/multica/server/internal/util"
@@ -2118,8 +2117,6 @@ func (s *TaskService) createAgentComment(ctx context.Context, issueID, agentID p
rootComment = &root
}
}
// 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,
WorkspaceID: issue.WorkspaceID,