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

Commit ef7e55c

Browse files
briglebclaude
andcommitted
fix: improve @mention regex, index safety, and test coverage
- Require word boundary before @ to avoid matching inside emails/URLs - Treat empty identifiers as not-found in ResolvePeople for safe indexing - Add ResolvePeople tests covering happy path, errors, and edge cases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7f9dc04 commit ef7e55c

3 files changed

Lines changed: 99 additions & 12 deletions

File tree

cmd/comment/create.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
"github.com/spf13/cobra"
2121
)
2222

23-
var mentionRe = regexp.MustCompile(`@[\w]+(?:\.[\w]+)*`)
23+
var mentionRe = regexp.MustCompile(`(?:^|[>\s])(@[\w]+(?:\.[\w]+)*)`)
2424

2525
func newCreateCmd(f *factory.Factory) *cobra.Command {
2626
var content string
@@ -127,19 +127,21 @@ You can provide comment content in several ways:
127127

128128
// Replace inline @Name mentions with bc-attachment tags
129129
// Supports @FirstName and @First.Last for disambiguation
130-
inlineMatches := mentionRe.FindAllString(richContent, -1)
131-
if len(inlineMatches) > 0 {
130+
submatches := mentionRe.FindAllStringSubmatch(richContent, -1)
131+
if len(submatches) > 0 {
132132
resolver := utils.NewUserResolver(client.Client, projectID)
133-
// Convert @First.Last to "First Last" for resolution
134-
identifiers := make([]string, len(inlineMatches))
135-
for i, m := range inlineMatches {
136-
identifiers[i] = strings.ReplaceAll(strings.TrimPrefix(m, "@"), ".", " ")
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], "@"), ".", " ")
137139
}
138140
people, err := resolver.ResolvePeople(f.Context(), identifiers)
139141
if err != nil {
140142
return fmt.Errorf("failed to resolve mentions: %w", err)
141143
}
142-
for i, match := range inlineMatches {
144+
for i, match := range mentions {
143145
tag := attachments.BuildTag(people[i].AttachableSGID)
144146
richContent = strings.Replace(richContent, match, tag, 1)
145147
}

internal/utils/users.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,14 @@ func (ur *UserResolver) ResolvePeople(ctx context.Context, identifiers []string)
8888

8989
for _, identifier := range identifiers {
9090
identifier = strings.TrimSpace(identifier)
91-
if identifier == "" {
92-
continue
93-
}
9491

9592
personID, found := ur.resolveIdentifier(identifier)
9693
if !found {
97-
notFound = append(notFound, identifier)
94+
if identifier == "" {
95+
notFound = append(notFound, "(empty)")
96+
} else {
97+
notFound = append(notFound, identifier)
98+
}
9899
continue
99100
}
100101

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)