mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 10:02:36 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/walt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606267f08a |
@@ -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 のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。
|
||||
|
||||
@@ -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 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user