diff --git a/internal/client/client.go b/internal/client/client.go index 22c3668..660f942 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -170,6 +170,49 @@ func (c *Client) Delete(path string) (*APIResponse, error) { return c.request("DELETE", path, nil) } +// GetHTML performs a GET request expecting an HTML response. +// Unlike Get, it sets Accept: text/html and does not attempt JSON parsing. +func (c *Client) GetHTML(path string) (*APIResponse, error) { + requestURL := c.buildURL(path) + req, err := http.NewRequestWithContext(context.Background(), "GET", requestURL, nil) + if err != nil { + return nil, errors.NewNetworkError(fmt.Sprintf("Failed to create request: %v", err)) + } + + c.setHeaders(req) + req.Header.Set("Accept", "text/html") + + if c.Verbose { + fmt.Fprintf(os.Stderr, "> GET %s (HTML)\n", requestURL) + } + + resp, err := c.doWithRetry(req) + if err != nil { + return nil, errors.NewNetworkError(fmt.Sprintf("Request failed: %v", err)) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.NewNetworkError(fmt.Sprintf("Failed to read response: %v", err)) + } + + if c.Verbose { + fmt.Fprintf(os.Stderr, "< %d %s\n", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + apiResp := &APIResponse{ + StatusCode: resp.StatusCode, + Body: respBody, + } + + if resp.StatusCode >= 400 { + return apiResp, c.errorFromResponse(resp.StatusCode, respBody, resp.Header) + } + + return apiResp, nil +} + func (c *Client) request(method, path string, body any) (*APIResponse, error) { requestURL := c.buildURL(path) diff --git a/internal/client/interface.go b/internal/client/interface.go index 54f2163..f157c4e 100644 --- a/internal/client/interface.go +++ b/internal/client/interface.go @@ -13,6 +13,7 @@ type API interface { FollowLocation(location string) (*APIResponse, error) UploadFile(filePath string) (*APIResponse, error) DownloadFile(urlPath string, destPath string) error + GetHTML(path string) (*APIResponse, error) } // Ensure Client implements API interface diff --git a/internal/commands/card.go b/internal/commands/card.go index b0eff75..18356bc 100644 --- a/internal/commands/card.go +++ b/internal/commands/card.go @@ -287,15 +287,16 @@ var cardCreateCmd = &cobra.Command{ } // Resolve description + apiClient := getClient() var description string if cardCreateDescriptionFile != "" { descContent, descErr := os.ReadFile(cardCreateDescriptionFile) if descErr != nil { return descErr } - description = markdownToHTML(string(descContent)) + description = markdownToHTML(resolveMentions(string(descContent), apiClient)) } else if cardCreateDescription != "" { - description = markdownToHTML(cardCreateDescription) + description = markdownToHTML(resolveMentions(cardCreateDescription, apiClient)) } ac := getSDK() @@ -378,15 +379,16 @@ var cardUpdateCmd = &cobra.Command{ cardNumber := args[0] // Resolve description + apiClient := getClient() var description string if cardUpdateDescriptionFile != "" { content, err := os.ReadFile(cardUpdateDescriptionFile) if err != nil { return err } - description = markdownToHTML(string(content)) + description = markdownToHTML(resolveMentions(string(content), apiClient)) } else if cardUpdateDescription != "" { - description = markdownToHTML(cardUpdateDescription) + description = markdownToHTML(resolveMentions(cardUpdateDescription, apiClient)) } // Build breadcrumbs diff --git a/internal/commands/comment.go b/internal/commands/comment.go index 00b385e..7695423 100644 --- a/internal/commands/comment.go +++ b/internal/commands/comment.go @@ -143,15 +143,16 @@ var commentCreateCmd = &cobra.Command{ } // Determine body content + apiClient := getClient() var body string if commentCreateBodyFile != "" { content, err := os.ReadFile(commentCreateBodyFile) if err != nil { return err } - body = markdownToHTML(string(content)) + body = markdownToHTML(resolveMentions(string(content), apiClient)) } else if commentCreateBody != "" { - body = markdownToHTML(commentCreateBody) + body = markdownToHTML(resolveMentions(commentCreateBody, apiClient)) } else { return newRequiredFlagError("body or body_file") } @@ -203,15 +204,16 @@ var commentUpdateCmd = &cobra.Command{ return newRequiredFlagError("card") } + apiClient := getClient() var body string if commentUpdateBodyFile != "" { content, err := os.ReadFile(commentUpdateBodyFile) if err != nil { return err } - body = markdownToHTML(string(content)) + body = markdownToHTML(resolveMentions(string(content), apiClient)) } else if commentUpdateBody != "" { - body = markdownToHTML(commentUpdateBody) + body = markdownToHTML(resolveMentions(commentUpdateBody, apiClient)) } commentID := args[0] diff --git a/internal/commands/mentions.go b/internal/commands/mentions.go new file mode 100644 index 0000000..2337e01 --- /dev/null +++ b/internal/commands/mentions.go @@ -0,0 +1,262 @@ +package commands + +import ( + "fmt" + "html" + "os" + "regexp" + "strings" + "sync" + "unicode" + + "github.com/basecamp/fizzy-cli/internal/client" +) + +// mentionUser represents a mentionable user parsed from the /prompts/users endpoint. +type mentionUser struct { + FirstName string // e.g. "Wayne" + FullName string // e.g. "Wayne Smith" + SGID string // signed global ID for ActionText + AvatarSrc string // e.g. "/6103476/users/03f5awg7.../avatar" +} + +// Package-level cache: populated once per CLI invocation. +var ( + mentionUsers []mentionUser + mentionOnce sync.Once + mentionErr error +) + +// resetMentionCache resets the cache for testing. +func resetMentionCache() { + mentionOnce = sync.Once{} + mentionUsers = nil + mentionErr = nil +} + +// mentionRegex matches @Name patterns not preceded by word characters or dots +// (to avoid matching emails like user@example.com). +// Supports Unicode letters and hyphens in names (e.g. @José, @Mary-Jane). +var mentionRegex = regexp.MustCompile(`(?:^|[^-\p{L}\p{N}_.])@([\p{L}][\p{L}\p{N}_-]*)`) + +// promptItemRegex matches opening tags. +// Attributes are extracted separately to handle any order. +var promptItemRegex = regexp.MustCompile(`]*>`) + +// searchAttrRegex extracts the search attribute value. +var searchAttrRegex = regexp.MustCompile(`\ssearch="([^"]+)"`) + +// sgidAttrRegex extracts the sgid attribute value. +var sgidAttrRegex = regexp.MustCompile(`\ssgid="([^"]+)"`) + +// promptItemEndRegex matches the closing tag for a prompt item block. +var promptItemEndRegex = regexp.MustCompile(``) + +// avatarRegex extracts the src attribute from the first tag. +var avatarRegex = regexp.MustCompile(`]+src="([^"]+)"`) + +// codeBlockRegex matches fenced code blocks (``` ... ```). +var codeBlockRegex = regexp.MustCompile("(?s)```.*?```") + +// codeSpanRegex matches inline code spans (` ... `). +var codeSpanRegex = regexp.MustCompile("`[^`]+`") + +// resolveMentions scans text for @FirstName patterns and replaces them with +// ActionText mention HTML. If the text contains no @ characters, it is returned +// unchanged. On any error fetching users, the original text is returned with a +// warning printed to stderr. +// +// Mentions inside markdown code spans (`@name`) and fenced code blocks are not +// resolved, preserving the user's intended literal text. +func resolveMentions(text string, c client.API) string { + if !strings.Contains(text, "@") { + return text + } + + mentionOnce.Do(func() { + mentionUsers, mentionErr = fetchMentionUsers(c) + }) + + if mentionErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not fetch mentionable users: %v\n", mentionErr) + return text + } + + if len(mentionUsers) == 0 { + return text + } + + // Protect code blocks and code spans from mention resolution by replacing + // them with placeholders, resolving mentions, then restoring the originals. + var codeChunks []string + placeholder := func(s string) string { + idx := len(codeChunks) + codeChunks = append(codeChunks, s) + return fmt.Sprintf("\x00CODE%d\x00", idx) + } + + protected := codeBlockRegex.ReplaceAllStringFunc(text, placeholder) + protected = codeSpanRegex.ReplaceAllStringFunc(protected, placeholder) + + // Find all @Name matches with positions + type mentionMatch struct { + start int // start of @Name (the @ character) + end int // end of @Name + name string + } + + allMatches := mentionRegex.FindAllStringSubmatchIndex(protected, -1) + var matches []mentionMatch + for _, loc := range allMatches { + // loc[2]:loc[3] is the capture group (the name without @) + nameStart := loc[2] + nameEnd := loc[3] + // The @ is one character before the name + atStart := nameStart - 1 + name := protected[nameStart:nameEnd] + matches = append(matches, mentionMatch{start: atStart, end: nameEnd, name: name}) + } + + // Process from end to start so replacements don't shift indices + for i := len(matches) - 1; i >= 0; i-- { + m := matches[i] + + // Find matching user by first name (case-insensitive) + var found []mentionUser + for _, u := range mentionUsers { + if strings.EqualFold(u.FirstName, m.name) { + found = append(found, u) + } + } + + switch len(found) { + case 1: + mentionHTML := buildMentionHTML(found[0]) + protected = protected[:m.start] + mentionHTML + protected[m.end:] + case 0: + fmt.Fprintf(os.Stderr, "Warning: could not resolve mention @%s\n", m.name) + default: + names := make([]string, len(found)) + for j, u := range found { + names[j] = u.FullName + } + fmt.Fprintf(os.Stderr, "Warning: ambiguous mention @%s — matches: %s\n", m.name, strings.Join(names, ", ")) + } + } + + // Restore code blocks and spans + for i, chunk := range codeChunks { + protected = strings.Replace(protected, fmt.Sprintf("\x00CODE%d\x00", i), chunk, 1) + } + + return protected +} + +// fetchMentionUsers fetches the list of mentionable users from the API. +func fetchMentionUsers(c client.API) ([]mentionUser, error) { + resp, err := c.GetHTML("/prompts/users") + if err != nil { + return nil, err + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status %d from /prompts/users", resp.StatusCode) + } + return parseMentionUsers(resp.Body), nil +} + +// parseMentionUsers extracts mentionable users from the /prompts/users HTML. +// Each user is represented as a element with search and sgid +// attributes, containing tags with avatar URLs. +func parseMentionUsers(htmlBytes []byte) []mentionUser { + htmlStr := string(htmlBytes) + items := promptItemRegex.FindAllStringIndex(htmlStr, -1) + if len(items) == 0 { + return nil + } + + // Find all closing tags for scoping avatar lookups + endIndices := promptItemEndRegex.FindAllStringIndex(htmlStr, -1) + + var users []mentionUser + for itemIdx, loc := range items { + tag := htmlStr[loc[0]:loc[1]] + + // Extract search and sgid attributes (order-independent) + searchMatch := searchAttrRegex.FindStringSubmatch(tag) + sgidMatch := sgidAttrRegex.FindStringSubmatch(tag) + if searchMatch == nil || sgidMatch == nil { + continue + } + + search := strings.TrimSpace(searchMatch[1]) + sgid := sgidMatch[1] + + if search == "" || sgid == "" { + continue + } + + // Parse name from search attribute. + // Format: "Full Name INITIALS [me]" + // Strip trailing "me" and all-uppercase words (initials like "WS", "FMA"). + words := strings.Fields(search) + for len(words) > 1 { + last := words[len(words)-1] + if last == "me" || isAllUpper(last) { + words = words[:len(words)-1] + } else { + break + } + } + + fullName := strings.Join(words, " ") + firstName := words[0] + + // Extract avatar URL scoped to this prompt-item block only. + avatarSrc := "" + blockStart := loc[0] + blockEnd := len(htmlStr) + if itemIdx < len(endIndices) { + blockEnd = endIndices[itemIdx][1] + } + block := htmlStr[blockStart:blockEnd] + if m := avatarRegex.FindStringSubmatch(block); len(m) > 1 { + avatarSrc = m[1] + } + + users = append(users, mentionUser{ + FirstName: firstName, + FullName: fullName, + SGID: sgid, + AvatarSrc: avatarSrc, + }) + } + + return users +} + +// buildMentionHTML creates the ActionText attachment HTML for a mention. +// Values are HTML-escaped to prevent injection from user-controlled names. +func buildMentionHTML(u mentionUser) string { + return fmt.Sprintf( + ``+ + `%s`+ + ``, + html.EscapeString(u.SGID), + html.EscapeString(u.FullName), + html.EscapeString(u.AvatarSrc), + html.EscapeString(u.FirstName), + ) +} + +// isAllUpper returns true if s is non-empty and all uppercase letters. +func isAllUpper(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !unicode.IsUpper(r) { + return false + } + } + return true +} diff --git a/internal/commands/mentions_test.go b/internal/commands/mentions_test.go new file mode 100644 index 0000000..98e092f --- /dev/null +++ b/internal/commands/mentions_test.go @@ -0,0 +1,317 @@ +package commands + +import ( + "fmt" + "strings" + "testing" + + "github.com/basecamp/fizzy-cli/internal/client" +) + +const samplePromptUsersHTML = ` + + + + + + + + + + + + +` + +// ambiguousHTML has two users with the same first name. +const ambiguousHTML = ` + + + + + + +` + +func newMentionMockClient() *MockClient { + m := NewMockClient() + m.GetHTMLResponse = &client.APIResponse{ + StatusCode: 200, + Body: []byte(samplePromptUsersHTML), + } + return m +} + +func TestParseMentionUsers(t *testing.T) { + users := parseMentionUsers([]byte(samplePromptUsersHTML)) + + if len(users) != 3 { + t.Fatalf("expected 3 users, got %d", len(users)) + } + + tests := []struct { + idx int + firstName string + fullName string + sgid string + }{ + {0, "Wayne", "Wayne Smith", "wayne-sgid-123"}, + {1, "Bushra", "Bushra Gul", "bushra-sgid-456"}, + {2, "Kennedy", "Kennedy", "kennedy-sgid-789"}, + } + + for _, tt := range tests { + u := users[tt.idx] + if u.FirstName != tt.firstName { + t.Errorf("user[%d] FirstName = %q, want %q", tt.idx, u.FirstName, tt.firstName) + } + if u.FullName != tt.fullName { + t.Errorf("user[%d] FullName = %q, want %q", tt.idx, u.FullName, tt.fullName) + } + if u.SGID != tt.sgid { + t.Errorf("user[%d] SGID = %q, want %q", tt.idx, u.SGID, tt.sgid) + } + if u.AvatarSrc == "" { + t.Errorf("user[%d] AvatarSrc is empty", tt.idx) + } + } +} + +func TestParseMentionUsersEmpty(t *testing.T) { + users := parseMentionUsers([]byte("")) + if len(users) != 0 { + t.Errorf("expected 0 users from empty HTML, got %d", len(users)) + } +} + +func TestParseMentionUsersAttributeOrder(t *testing.T) { + // sgid before search — should still parse correctly + html := ` + +` + users := parseMentionUsers([]byte(html)) + if len(users) != 1 { + t.Fatalf("expected 1 user from reversed attributes, got %d", len(users)) + } + if users[0].FirstName != "Test" { + t.Errorf("FirstName = %q, want %q", users[0].FirstName, "Test") + } + if users[0].SGID != "reversed-sgid" { + t.Errorf("SGID = %q, want %q", users[0].SGID, "reversed-sgid") + } +} + +func TestParseMentionUsersAvatarScoping(t *testing.T) { + // Each user's avatar should come from their own block, not a later one + users := parseMentionUsers([]byte(samplePromptUsersHTML)) + if len(users) < 2 { + t.Fatal("expected at least 2 users") + } + if !strings.Contains(users[0].AvatarSrc, "u1") { + t.Errorf("user[0] avatar should contain u1, got %q", users[0].AvatarSrc) + } + if !strings.Contains(users[1].AvatarSrc, "u2") { + t.Errorf("user[1] avatar should contain u2, got %q", users[1].AvatarSrc) + } +} + +func TestResolveMentions(t *testing.T) { + tests := []struct { + name string + input string + shouldContain []string + shouldNotMatch []string // substrings that should NOT appear + }{ + { + name: "no @ passthrough", + input: "Hello world", + shouldContain: []string{"Hello world"}, + shouldNotMatch: []string{"action-text-attachment"}, + }, + { + name: "single mention", + input: "Hey @Wayne check this", + shouldContain: []string{`sgid="wayne-sgid-123"`, `content-type="application/vnd.actiontext.mention"`, `title="Wayne Smith"`, ">Wayne<"}, + }, + { + name: "case insensitive", + input: "Hey @wayne check this", + shouldContain: []string{`sgid="wayne-sgid-123"`}, + }, + { + name: "multiple mentions", + input: "@Wayne and @Bushra please review", + shouldContain: []string{`sgid="wayne-sgid-123"`, `sgid="bushra-sgid-456"`}, + }, + { + name: "email not treated as mention", + input: "Contact user@example.com", + shouldContain: []string{"user@example.com"}, + shouldNotMatch: []string{"action-text-attachment"}, + }, + { + name: "unresolved mention stays as text", + input: "Hey @Unknown person", + shouldContain: []string{"@Unknown"}, + shouldNotMatch: []string{"action-text-attachment"}, + }, + { + name: "mention at start of text", + input: "@Kennedy can you look?", + shouldContain: []string{`sgid="kennedy-sgid-789"`}, + }, + { + name: "mention after newline", + input: "First line\n@Wayne second line", + shouldContain: []string{`sgid="wayne-sgid-123"`}, + }, + { + name: "single name user", + input: "Hey @Kennedy", + shouldContain: []string{`sgid="kennedy-sgid-789"`, `title="Kennedy"`}, + }, + { + name: "mention inside inline code not resolved", + input: "Use `@Wayne` to mention someone", + shouldContain: []string{"`@Wayne`"}, + shouldNotMatch: []string{"action-text-attachment"}, + }, + { + name: "mention inside fenced code block not resolved", + input: "Example:\n```\n@Wayne check this\n```", + shouldContain: []string{"@Wayne"}, + shouldNotMatch: []string{"action-text-attachment"}, + }, + { + name: "mention outside code resolved while code preserved", + input: "@Wayne see `@Bushra` example", + shouldContain: []string{`sgid="wayne-sgid-123"`, "`@Bushra`"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetMentionCache() + mock := newMentionMockClient() + result := resolveMentions(tt.input, mock) + + for _, s := range tt.shouldContain { + if !strings.Contains(result, s) { + t.Errorf("result should contain %q\ngot: %s", s, result) + } + } + for _, s := range tt.shouldNotMatch { + if strings.Contains(result, s) { + t.Errorf("result should NOT contain %q\ngot: %s", s, result) + } + } + }) + } +} + +func TestResolveMentionsNoFetchWithoutAt(t *testing.T) { + resetMentionCache() + mock := newMentionMockClient() + + resolveMentions("Hello world, no mentions here", mock) + + if len(mock.GetHTMLCalls) != 0 { + t.Errorf("expected 0 GetHTML calls for text without @, got %d", len(mock.GetHTMLCalls)) + } +} + +func TestResolveMentionsAmbiguous(t *testing.T) { + resetMentionCache() + mock := NewMockClient() + mock.GetHTMLResponse = &client.APIResponse{ + StatusCode: 200, + Body: []byte(ambiguousHTML), + } + + result := resolveMentions("Hey @Alex check this", mock) + + // Should NOT resolve — ambiguous + if strings.Contains(result, "action-text-attachment") { + t.Errorf("ambiguous mention should not resolve, got: %s", result) + } + if !strings.Contains(result, "@Alex") { + t.Errorf("ambiguous mention should stay as @Alex, got: %s", result) + } +} + +func TestResolveMentionsAPIError(t *testing.T) { + resetMentionCache() + mock := NewMockClient() + mock.GetHTMLError = fmt.Errorf("server error") + + // Should return text unchanged on error + input := "Hey @Wayne" + result := resolveMentions(input, mock) + if result != input { + t.Errorf("expected unchanged text on error, got: %s", result) + } +} + +func TestResolveMentionsCaching(t *testing.T) { + resetMentionCache() + mock := newMentionMockClient() + + // First call fetches + resolveMentions("@Wayne", mock) + if len(mock.GetHTMLCalls) != 1 { + t.Errorf("expected 1 GetHTML call, got %d", len(mock.GetHTMLCalls)) + } + + // Second call uses cache + resolveMentions("@Bushra", mock) + if len(mock.GetHTMLCalls) != 1 { + t.Errorf("expected still 1 GetHTML call (cached), got %d", len(mock.GetHTMLCalls)) + } +} + +func TestBuildMentionHTMLEscaping(t *testing.T) { + u := mentionUser{ + FirstName: `O'Brien`, + FullName: `O'Brien