From 71b99088fd6f553c6f2b2095b5d7a556caa7e29c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 18:25:29 +0000 Subject: [PATCH 1/2] feat: extend @mention support to campfire, cards, todos, and messages Extract inline @mention resolution from cmd/comment/create.go into a shared internal/mentions package, then integrate it into all commands that produce rich text content: - campfire post - card add/edit (both interactive and non-interactive) - comment create/edit - message post/edit - todo add/edit (title and description) The mentions.Resolve function finds @Name and @First.Last patterns in rich HTML, resolves them to project people via the existing UserResolver, and replaces them with Basecamp bc-attachment tags. https://claude.ai/code/session_014k1wdNHRsaAmfNL5TFMF3c --- cmd/campfire/post.go | 7 ++ cmd/card/add.go | 9 ++ cmd/card/edit.go | 14 +++ cmd/comment/create.go | 27 +----- cmd/comment/edit.go | 7 ++ cmd/message/edit.go | 8 ++ cmd/message/post.go | 7 ++ cmd/todo/add.go | 13 +++ cmd/todo/edit.go | 9 ++ internal/mentions/mentions.go | 51 ++++++++++ internal/mentions/mentions_test.go | 150 +++++++++++++++++++++++++++++ 11 files changed, 279 insertions(+), 23 deletions(-) create mode 100644 internal/mentions/mentions.go create mode 100644 internal/mentions/mentions_test.go diff --git a/cmd/campfire/post.go b/cmd/campfire/post.go index 8600f88..b3a732c 100644 --- a/cmd/campfire/post.go +++ b/cmd/campfire/post.go @@ -9,6 +9,7 @@ import ( "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/spf13/cobra" ) @@ -112,6 +113,12 @@ func newPostCmd(f *factory.Factory) *cobra.Command { return fmt.Errorf("failed to convert message: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Post the message line, err := campfireOps.PostCampfireLine(f.Context(), projectID, campfireID, richContent, "text/html") if err != nil { diff --git a/cmd/card/add.go b/cmd/card/add.go index 6305de9..d7d6555 100644 --- a/cmd/card/add.go +++ b/cmd/card/add.go @@ -11,6 +11,7 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" @@ -174,6 +175,14 @@ can be attached by using the flag multiple times.`, richContent = rc } + // Replace inline @Name mentions with bc-attachment tags + if richContent != "" { + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + } + // Handle attachments if len(attach) > 0 { for _, attachPath := range attach { diff --git a/cmd/card/edit.go b/cmd/card/edit.go index ab3183d..fd29710 100644 --- a/cmd/card/edit.go +++ b/cmd/card/edit.go @@ -17,6 +17,7 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/spf13/cobra" ) @@ -101,6 +102,12 @@ func (m editModel) updateCard() tea.Cmd { return cardUpdatedMsg{err: err} } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(m.factory.Context(), richContent, m.client, m.projectID) + if err != nil { + return cardUpdatedMsg{err: err} + } + req := api.CardUpdateRequest{ Title: m.cardTitle, Content: richContent, @@ -381,6 +388,13 @@ func updateCardNonInteractive(f *factory.Factory, client *api.Client, projectID if err != nil { return fmt.Errorf("failed to convert content: %w", err) } + + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + req.Content = richContent } else { req.Content = card.Content diff --git a/cmd/comment/create.go b/cmd/comment/create.go index 7c757a9..9572b47 100644 --- a/cmd/comment/create.go +++ b/cmd/comment/create.go @@ -5,7 +5,6 @@ import ( "io" "os" "path/filepath" - "regexp" "strconv" "strings" @@ -14,14 +13,12 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" - "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" ) -var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`) - func newCreateCmd(f *factory.Factory) *cobra.Command { var content string var attachmentPath string @@ -126,25 +123,9 @@ You can provide comment content in several ways: } // Replace inline @Name mentions with bc-attachment tags - // Supports @FirstName and @First.Last for disambiguation - submatches := mentionRe.FindAllStringSubmatch(richContent, -1) - if len(submatches) > 0 { - resolver := utils.NewUserResolver(client.Client, projectID) - // Extract capture group (the @mention) and convert @First.Last to "First Last" - mentions := make([]string, len(submatches)) - identifiers := make([]string, len(submatches)) - for i, sm := range submatches { - mentions[i] = sm[1] - identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(sm[1], "@"), ".", " ") - } - people, err := resolver.ResolvePeople(f.Context(), identifiers) - if err != nil { - return fmt.Errorf("failed to resolve mentions: %w", err) - } - for i, match := range mentions { - tag := attachments.BuildTag(people[i].AttachableSGID) - richContent = strings.Replace(richContent, match, tag, 1) - } + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) } // Attach file if provided diff --git a/cmd/comment/edit.go b/cmd/comment/edit.go index 304a436..dd3443a 100644 --- a/cmd/comment/edit.go +++ b/cmd/comment/edit.go @@ -11,6 +11,7 @@ import ( "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" @@ -124,6 +125,12 @@ You can provide updated content in several ways: return fmt.Errorf("failed to convert markdown: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Update the comment req := api.CommentUpdateRequest{ Content: richContent, diff --git a/cmd/message/edit.go b/cmd/message/edit.go index 48bcedc..96537d8 100644 --- a/cmd/message/edit.go +++ b/cmd/message/edit.go @@ -12,6 +12,7 @@ import ( "github.com/needmore/bc4/internal/cmdutil" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" @@ -137,6 +138,13 @@ cat updated.md | bc4 message edit 12345`, if err != nil { return fmt.Errorf("failed to convert markdown: %w", err) } + + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + req.Content = richContent hasUpdate = true } diff --git a/cmd/message/post.go b/cmd/message/post.go index 4257b05..8a3ce38 100644 --- a/cmd/message/post.go +++ b/cmd/message/post.go @@ -10,6 +10,7 @@ import ( "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" ) @@ -107,6 +108,12 @@ You can provide message content in several ways: return fmt.Errorf("failed to convert markdown: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Create the message req := api.MessageCreateRequest{ Subject: title, diff --git a/cmd/todo/add.go b/cmd/todo/add.go index 82e7bbe..a8c2a2b 100644 --- a/cmd/todo/add.go +++ b/cmd/todo/add.go @@ -11,6 +11,7 @@ import ( "github.com/needmore/bc4/internal/attachments" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" @@ -232,6 +233,18 @@ func runAdd(f *factory.Factory, opts *addOptions, args []string) error { } } + // Replace inline @Name mentions with bc-attachment tags + richTitle, err = mentions.Resolve(f.Context(), richTitle, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + if richDescription != "" { + richDescription, err = mentions.Resolve(f.Context(), richDescription, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + } + // Handle attachments if len(opts.attach) > 0 { for _, attachPath := range opts.attach { diff --git a/cmd/todo/edit.go b/cmd/todo/edit.go index 6c87128..2f976a7 100644 --- a/cmd/todo/edit.go +++ b/cmd/todo/edit.go @@ -13,6 +13,7 @@ import ( "github.com/needmore/bc4/internal/cmdutil" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/utils" "github.com/spf13/cobra" @@ -187,6 +188,10 @@ func runEdit(f *factory.Factory, opts *editOptions, args []string) error { if err != nil { return fmt.Errorf("failed to convert title: %w", err) } + richTitle, err = mentions.Resolve(f.Context(), richTitle, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } req.Content = richTitle } @@ -196,6 +201,10 @@ func runEdit(f *factory.Factory, opts *editOptions, args []string) error { if err != nil { return fmt.Errorf("failed to convert description: %w", err) } + richDescription, err = mentions.Resolve(f.Context(), richDescription, client.Client, resolvedProjectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } req.Description = richDescription } diff --git a/internal/mentions/mentions.go b/internal/mentions/mentions.go new file mode 100644 index 0000000..09d490c --- /dev/null +++ b/internal/mentions/mentions.go @@ -0,0 +1,51 @@ +package mentions + +import ( + "context" + "regexp" + "strings" + + "github.com/needmore/bc4/internal/api" + "github.com/needmore/bc4/internal/attachments" + "github.com/needmore/bc4/internal/utils" +) + +// mentionRe matches @Name and @First.Last mentions in rich text content. +// Mentions must appear at the start of the string, after > (blockquotes), +// or after whitespace. +var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`) + +// Resolve finds @mentions in rich text content and replaces them with +// Basecamp bc-attachment tags. It uses the project's people list to +// resolve mention identifiers to their AttachableSGID values. +// +// Supports @FirstName and @First.Last syntax. Returns the content +// unchanged if no mentions are found. +func Resolve(ctx context.Context, richContent string, client api.APIClient, projectID string) (string, error) { + submatches := mentionRe.FindAllStringSubmatch(richContent, -1) + if len(submatches) == 0 { + return richContent, nil + } + + resolver := utils.NewUserResolver(client, projectID) + + // Extract capture group (the @mention) and convert @First.Last to "First Last" + mentions := make([]string, len(submatches)) + identifiers := make([]string, len(submatches)) + for i, sm := range submatches { + mentions[i] = sm[1] + identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(sm[1], "@"), ".", " ") + } + + people, err := resolver.ResolvePeople(ctx, identifiers) + if err != nil { + return "", err + } + + for i, match := range mentions { + tag := attachments.BuildTag(people[i].AttachableSGID) + richContent = strings.Replace(richContent, match, tag, 1) + } + + return richContent, nil +} diff --git a/internal/mentions/mentions_test.go b/internal/mentions/mentions_test.go new file mode 100644 index 0000000..829a51d --- /dev/null +++ b/internal/mentions/mentions_test.go @@ -0,0 +1,150 @@ +package mentions + +import ( + "context" + "errors" + "testing" + + "github.com/needmore/bc4/internal/api" + "github.com/needmore/bc4/internal/api/mock" +) + +func TestResolve(t *testing.T) { + people := []api.Person{ + {ID: 1, Name: "John Doe", EmailAddress: "john@example.com", AttachableSGID: "sgid-john"}, + {ID: 2, Name: "Jane Smith", EmailAddress: "jane@example.com", AttachableSGID: "sgid-jane"}, + {ID: 3, Name: "Bob Johnson", EmailAddress: "bob@company.com", AttachableSGID: "sgid-bob"}, + } + + tests := []struct { + name string + content string + mockPeople []api.Person + mockError error + expected string + expectError bool + }{ + { + name: "no mentions returns content unchanged", + content: "

Hello world

", + mockPeople: people, + expected: "

Hello world

", + }, + { + name: "single @FirstName mention", + content: `

Hey @John check this out

`, + mockPeople: people, + expected: `

Hey check this out

`, + }, + { + name: "single @First.Last mention", + content: `

Hey @John.Doe check this out

`, + mockPeople: people, + expected: `

Hey check this out

`, + }, + { + name: "multiple mentions", + content: `

Hey @John and @Jane

`, + mockPeople: people, + expected: `

Hey and

`, + }, + { + name: "mention at start of content", + content: `@Bob please review`, + mockPeople: people, + expected: ` please review`, + }, + { + name: "mention after blockquote marker", + content: `
@John said something
`, + mockPeople: people, + expected: `
said something
`, + }, + { + name: "unknown mention returns error", + content: `

Hey @Unknown

`, + mockPeople: people, + expectError: true, + }, + { + name: "API error propagated", + content: `

Hey @John

`, + mockError: errors.New("API error"), + expectError: true, + }, + { + name: "empty content returns empty", + content: "", + mockPeople: people, + expected: "", + }, + { + name: "content with email address not treated as mention", + content: `

Email me at john@example.com

`, + mockPeople: people, + expected: `

Email me at john@example.com

`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mock.NewMockClient() + mockClient.People = tt.mockPeople + mockClient.PeopleError = tt.mockError + + result, err := Resolve(context.Background(), tt.content, mockClient, "12345") + + if tt.expectError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result != tt.expected { + t.Errorf("Expected:\n %s\nGot:\n %s", tt.expected, result) + } + }) + } +} + +func TestMentionRegex(t *testing.T) { + tests := []struct { + name string + input string + matches []string + }{ + {"simple mention", "Hello @John", []string{"@John"}}, + {"dot mention", "Hello @John.Doe", []string{"@John.Doe"}}, + {"start of string", "@John hello", []string{"@John"}}, + {"after blockquote", ">@John said", []string{"@John"}}, + {"multiple mentions", "@John and @Jane", []string{"@John", "@Jane"}}, + {"no mentions", "Hello world", nil}, + {"email not matched", "user@example.com", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + submatches := mentionRe.FindAllStringSubmatch(tt.input, -1) + var got []string + for _, sm := range submatches { + got = append(got, sm[1]) + } + + if len(got) != len(tt.matches) { + t.Errorf("Expected %d matches, got %d: %v", len(tt.matches), len(got), got) + return + } + + for i, m := range got { + if m != tt.matches[i] { + t.Errorf("Match[%d]: expected %q, got %q", i, tt.matches[i], m) + } + } + }) + } +} From 171809fe5e12ea2d0d217b5076b33a807d830969 Mon Sep 17 00:00:00 2001 From: Raymond Brigleb Date: Sun, 1 Mar 2026 08:10:47 -0800 Subject: [PATCH 2/2] fix: use index-based replacement for mentions and add document support Fix positional replacement bug where strings.Replace could replace the wrong @mention occurrence. Now uses FindAllStringSubmatchIndex and replaces by exact position from end-to-start. Also adds mention resolution to document create and edit commands. Co-Authored-By: Claude Opus 4.6 --- cmd/document/create.go | 7 +++++++ cmd/document/edit.go | 8 ++++++++ internal/mentions/mentions.go | 19 ++++++++++--------- internal/mentions/mentions_test.go | 6 ++++++ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/cmd/document/create.go b/cmd/document/create.go index 3db4dd3..dd2b7f1 100644 --- a/cmd/document/create.go +++ b/cmd/document/create.go @@ -10,6 +10,7 @@ import ( "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" ) @@ -107,6 +108,12 @@ You can provide document content in several ways: return fmt.Errorf("failed to convert markdown: %w", err) } + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + // Create the document req := api.DocumentCreateRequest{ Title: title, diff --git a/cmd/document/edit.go b/cmd/document/edit.go index 6dc0f3c..51716ee 100644 --- a/cmd/document/edit.go +++ b/cmd/document/edit.go @@ -11,6 +11,7 @@ import ( "github.com/needmore/bc4/internal/api" "github.com/needmore/bc4/internal/factory" "github.com/needmore/bc4/internal/markdown" + "github.com/needmore/bc4/internal/mentions" "github.com/needmore/bc4/internal/parser" "github.com/needmore/bc4/internal/ui" "github.com/spf13/cobra" @@ -132,6 +133,13 @@ You can provide updated content in several ways: if err != nil { return fmt.Errorf("failed to convert markdown: %w", err) } + + // Replace inline @Name mentions with bc-attachment tags + richContent, err = mentions.Resolve(f.Context(), richContent, client.Client, projectID) + if err != nil { + return fmt.Errorf("failed to resolve mentions: %w", err) + } + req.Content = richContent hasUpdate = true } diff --git a/internal/mentions/mentions.go b/internal/mentions/mentions.go index 09d490c..752b315 100644 --- a/internal/mentions/mentions.go +++ b/internal/mentions/mentions.go @@ -22,19 +22,19 @@ var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`) // Supports @FirstName and @First.Last syntax. Returns the content // unchanged if no mentions are found. func Resolve(ctx context.Context, richContent string, client api.APIClient, projectID string) (string, error) { - submatches := mentionRe.FindAllStringSubmatch(richContent, -1) - if len(submatches) == 0 { + indexMatches := mentionRe.FindAllStringSubmatchIndex(richContent, -1) + if len(indexMatches) == 0 { return richContent, nil } resolver := utils.NewUserResolver(client, projectID) // Extract capture group (the @mention) and convert @First.Last to "First Last" - mentions := make([]string, len(submatches)) - identifiers := make([]string, len(submatches)) - for i, sm := range submatches { - mentions[i] = sm[1] - identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(sm[1], "@"), ".", " ") + identifiers := make([]string, len(indexMatches)) + for i, loc := range indexMatches { + // loc[2]:loc[3] is the capture group (the @mention) + mention := richContent[loc[2]:loc[3]] + identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(mention, "@"), ".", " ") } people, err := resolver.ResolvePeople(ctx, identifiers) @@ -42,9 +42,10 @@ func Resolve(ctx context.Context, richContent string, client api.APIClient, proj return "", err } - for i, match := range mentions { + // Replace from end to start so earlier indices remain valid + for i := len(indexMatches) - 1; i >= 0; i-- { tag := attachments.BuildTag(people[i].AttachableSGID) - richContent = strings.Replace(richContent, match, tag, 1) + richContent = richContent[:indexMatches[i][2]] + tag + richContent[indexMatches[i][3]:] } return richContent, nil diff --git a/internal/mentions/mentions_test.go b/internal/mentions/mentions_test.go index 829a51d..837eddb 100644 --- a/internal/mentions/mentions_test.go +++ b/internal/mentions/mentions_test.go @@ -84,6 +84,12 @@ func TestResolve(t *testing.T) { mockPeople: people, expected: `

Email me at john@example.com

`, }, + { + name: "replaces correct occurrence by position", + content: `

path/@John then @John

`, + mockPeople: people, + expected: `

path/@John then

`, + }, } for _, tt := range tests {