Skip to content
This repository was archived by the owner on Mar 12, 2026. It is now read-only.

Commit df4b5de

Browse files
authored
Merge pull request #146 from nnemirovsky/feat/comment-mentions
feat: add inline @mention support to comment create
2 parents 7c31ece + ef7e55c commit df4b5de

4 files changed

Lines changed: 157 additions & 9 deletions

File tree

cmd/comment/create.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io"
66
"os"
77
"path/filepath"
8+
"regexp"
89
"strconv"
910
"strings"
1011

@@ -15,9 +16,12 @@ import (
1516
"github.com/needmore/bc4/internal/markdown"
1617
"github.com/needmore/bc4/internal/parser"
1718
"github.com/needmore/bc4/internal/ui"
19+
"github.com/needmore/bc4/internal/utils"
1820
"github.com/spf13/cobra"
1921
)
2022

23+
var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`)
24+
2125
func newCreateCmd(f *factory.Factory) *cobra.Command {
2226
var content string
2327
var attachmentPath string
@@ -121,6 +125,28 @@ You can provide comment content in several ways:
121125
richContent = rc
122126
}
123127

128+
// Replace inline @Name mentions with bc-attachment tags
129+
// Supports @FirstName and @First.Last for disambiguation
130+
submatches := mentionRe.FindAllStringSubmatch(richContent, -1)
131+
if len(submatches) > 0 {
132+
resolver := utils.NewUserResolver(client.Client, projectID)
133+
// Extract capture group (the @mention) and convert @First.Last to "First Last"
134+
mentions := make([]string, len(submatches))
135+
identifiers := make([]string, len(submatches))
136+
for i, sm := range submatches {
137+
mentions[i] = sm[1]
138+
identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(sm[1], "@"), ".", " ")
139+
}
140+
people, err := resolver.ResolvePeople(f.Context(), identifiers)
141+
if err != nil {
142+
return fmt.Errorf("failed to resolve mentions: %w", err)
143+
}
144+
for i, match := range mentions {
145+
tag := attachments.BuildTag(people[i].AttachableSGID)
146+
richContent = strings.Replace(richContent, match, tag, 1)
147+
}
148+
}
149+
124150
// Attach file if provided
125151
if attachmentPath != "" {
126152
fileData, err := os.ReadFile(attachmentPath)

internal/api/client.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -355,15 +355,16 @@ type Company struct {
355355

356356
// Person represents a Basecamp user
357357
type Person struct {
358-
ID int64 `json:"id"`
359-
Name string `json:"name"`
360-
EmailAddress string `json:"email_address"`
361-
Title string `json:"title"`
362-
AvatarURL string `json:"avatar_url"`
363-
Company *Company `json:"company,omitempty"`
364-
CreatedAt string `json:"created_at,omitempty"`
365-
Admin bool `json:"admin,omitempty"`
366-
Owner bool `json:"owner,omitempty"`
358+
ID int64 `json:"id"`
359+
AttachableSGID string `json:"attachable_sgid"`
360+
Name string `json:"name"`
361+
EmailAddress string `json:"email_address"`
362+
Title string `json:"title"`
363+
AvatarURL string `json:"avatar_url"`
364+
Company *Company `json:"company,omitempty"`
365+
CreatedAt string `json:"created_at,omitempty"`
366+
Admin bool `json:"admin,omitempty"`
367+
Owner bool `json:"owner,omitempty"`
367368
}
368369

369370
// Todo represents a Basecamp todo item

internal/utils/users.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,43 @@ func (ur *UserResolver) GetPeople(ctx context.Context) ([]api.Person, error) {
7777
return ur.people, nil
7878
}
7979

80+
// ResolvePeople resolves a list of user identifiers to full Person objects
81+
func (ur *UserResolver) ResolvePeople(ctx context.Context, identifiers []string) ([]api.Person, error) {
82+
if err := ur.ensurePeopleCached(ctx); err != nil {
83+
return nil, err
84+
}
85+
86+
var people []api.Person
87+
var notFound []string
88+
89+
for _, identifier := range identifiers {
90+
identifier = strings.TrimSpace(identifier)
91+
92+
personID, found := ur.resolveIdentifier(identifier)
93+
if !found {
94+
if identifier == "" {
95+
notFound = append(notFound, "(empty)")
96+
} else {
97+
notFound = append(notFound, identifier)
98+
}
99+
continue
100+
}
101+
102+
for _, p := range ur.people {
103+
if p.ID == personID {
104+
people = append(people, p)
105+
break
106+
}
107+
}
108+
}
109+
110+
if len(notFound) > 0 {
111+
return nil, fmt.Errorf("could not find users: %s", strings.Join(notFound, ", "))
112+
}
113+
114+
return people, nil
115+
}
116+
80117
// ensurePeopleCached loads the project people if not already cached
81118
func (ur *UserResolver) ensurePeopleCached(ctx context.Context) error {
82119
if ur.cached {

internal/utils/users_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,90 @@ func TestUserResolver_ResolveUsers(t *testing.T) {
199199
}
200200
}
201201

202+
func TestUserResolver_ResolvePeople(t *testing.T) {
203+
people := []api.Person{
204+
{ID: 1, Name: "John Doe", EmailAddress: "john@example.com", AttachableSGID: "sgid-john"},
205+
{ID: 2, Name: "Jane Smith", EmailAddress: "jane@example.com", AttachableSGID: "sgid-jane"},
206+
{ID: 3, Name: "Bob Johnson", EmailAddress: "bob@company.com", AttachableSGID: "sgid-bob"},
207+
}
208+
209+
tests := []struct {
210+
name string
211+
identifiers []string
212+
mockPeople []api.Person
213+
mockError error
214+
expectPeople []api.Person
215+
expectError bool
216+
}{
217+
{
218+
name: "Resolve single person by name",
219+
identifiers: []string{"John"},
220+
mockPeople: people,
221+
expectPeople: []api.Person{people[0]},
222+
expectError: false,
223+
},
224+
{
225+
name: "Resolve multiple people",
226+
identifiers: []string{"John", "Jane"},
227+
mockPeople: people,
228+
expectPeople: []api.Person{people[0], people[1]},
229+
expectError: false,
230+
},
231+
{
232+
name: "Not found error",
233+
identifiers: []string{"Unknown"},
234+
mockPeople: people,
235+
expectError: true,
236+
},
237+
{
238+
name: "API error",
239+
identifiers: []string{"John"},
240+
mockError: errors.New("API error"),
241+
expectError: true,
242+
},
243+
{
244+
name: "Empty identifier treated as not found",
245+
identifiers: []string{""},
246+
mockPeople: people,
247+
expectError: true,
248+
},
249+
}
250+
251+
for _, tt := range tests {
252+
t.Run(tt.name, func(t *testing.T) {
253+
mockClient := mock.NewMockClient()
254+
mockClient.People = tt.mockPeople
255+
mockClient.PeopleError = tt.mockError
256+
257+
resolver := NewUserResolver(mockClient, "12345")
258+
ctx := context.Background()
259+
260+
result, err := resolver.ResolvePeople(ctx, tt.identifiers)
261+
262+
if tt.expectError && err == nil {
263+
t.Error("Expected error but got none")
264+
}
265+
if !tt.expectError && err != nil {
266+
t.Errorf("Unexpected error: %v", err)
267+
}
268+
269+
if !tt.expectError {
270+
if len(result) != len(tt.expectPeople) {
271+
t.Fatalf("Expected %d people, got %d", len(tt.expectPeople), len(result))
272+
}
273+
for i, p := range result {
274+
if p.ID != tt.expectPeople[i].ID {
275+
t.Errorf("Expected person[%d].ID = %d, got %d", i, tt.expectPeople[i].ID, p.ID)
276+
}
277+
if p.AttachableSGID != tt.expectPeople[i].AttachableSGID {
278+
t.Errorf("Expected person[%d].AttachableSGID = %q, got %q", i, tt.expectPeople[i].AttachableSGID, p.AttachableSGID)
279+
}
280+
}
281+
}
282+
})
283+
}
284+
}
285+
202286
func TestUserResolver_resolveIdentifier(t *testing.T) {
203287
// Create test people
204288
ur := &UserResolver{

0 commit comments

Comments
 (0)