Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,50 @@ 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)
Comment on lines +173 to +176
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetHTML() is a new request path with important behavior differences from Get() (different Accept header and no JSON parsing). Since internal/client has extensive request/response tests already, it would be good to add a focused unit test for GetHTML() to prevent regressions (e.g., assert the server sees Accept: text/html, the raw body is returned, and non-2xx responses are surfaced via errorFromResponse).

Copilot uses AI. Check for mistakes.
req, err := http.NewRequestWithContext(context.Background(), "GET", requestURL, nil)
if err != nil {
return nil, errors.NewNetworkError(fmt.Sprintf("Failed to create request: %v", err))
}

req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "text/html")
req.Header.Set("User-Agent", "fizzy-cli/1.0")
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetHTML duplicates header-setting logic rather than reusing c.setHeaders(req). This risks divergence if common headers change (e.g., User-Agent/auth handling) and makes the client harder to maintain. Consider calling c.setHeaders(req) and then overriding only the Accept header to text/html.

Suggested change
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "text/html")
req.Header.Set("User-Agent", "fizzy-cli/1.0")
c.setHeaders(req)
req.Header.Set("Accept", "text/html")

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0b727e. GetHTML() now calls c.setHeaders(req) and only overrides the Accept header to 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)

Expand Down
1 change: 1 addition & 0 deletions internal/client/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions internal/commands/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,9 @@ var cardCreateCmd = &cobra.Command{
if descErr != nil {
return descErr
}
description = markdownToHTML(string(descContent))
description = markdownToHTML(resolveMentions(string(descContent), getClient()))
} else if cardCreateDescription != "" {
description = markdownToHTML(cardCreateDescription)
description = markdownToHTML(resolveMentions(cardCreateDescription, getClient()))
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClient() creates a new API client each time it’s called; here it’s invoked separately for the file vs flag branches. Consider creating the client once in the command (before the conditional) and reusing it for resolveMentions(...) to avoid redundant client construction.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f84f9d7 — same change applied here.


ac := getSDK()
Expand Down Expand Up @@ -384,9 +384,9 @@ var cardUpdateCmd = &cobra.Command{
if err != nil {
return err
}
description = markdownToHTML(string(content))
description = markdownToHTML(resolveMentions(string(content), getClient()))
} else if cardUpdateDescription != "" {
description = markdownToHTML(cardUpdateDescription)
description = markdownToHTML(resolveMentions(cardUpdateDescription, getClient()))
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as create: this code calls getClient() separately in each branch, which can create multiple identical clients per invocation. Prefer allocating once and reusing when calling resolveMentions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f84f9d7 — same change applied here.


// Build breadcrumbs
Expand Down
8 changes: 4 additions & 4 deletions internal/commands/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ var commentCreateCmd = &cobra.Command{
if err != nil {
return err
}
body = markdownToHTML(string(content))
body = markdownToHTML(resolveMentions(string(content), getClient()))
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Mention resolution is applied to raw markdown text using regex, so @... inside markdown contexts (code/links/URLs) can be transformed unintentionally before parsing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At internal/commands/comment.go, line 152:

<comment>Mention resolution is applied to raw markdown text using regex, so `@...` inside markdown contexts (code/links/URLs) can be transformed unintentionally before parsing.</comment>

<file context>
@@ -149,9 +149,9 @@ var commentCreateCmd = &cobra.Command{
 				return err
 			}
-			body = markdownToHTML(string(content))
+			body = markdownToHTML(resolveMentions(string(content), getClient()))
 		} else if commentCreateBody != "" {
-			body = markdownToHTML(commentCreateBody)
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0b727e. resolveMentions() now protects markdown code spans (`@name`) and fenced code blocks (```...```) by replacing them with placeholders before scanning for mentions, then restoring them after. Added three test cases: inline code, fenced block, and mixed (mention outside + code inside).

} else if commentCreateBody != "" {
body = markdownToHTML(commentCreateBody)
body = markdownToHTML(resolveMentions(commentCreateBody, getClient()))
} else {
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getClient() constructs a new API client each call. In this command it’s invoked separately for each resolveMentions(...) path, which can create multiple identical clients during a single execution. Consider creating the client once (e.g., apiClient := getClient()) and reuse it for mention resolution in both branches.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f84f9d7. Hoisted getClient() before the if/else branches in all 4 call sites (comment create/update, card create/update).

return newRequiredFlagError("body or body_file")
}
Expand Down Expand Up @@ -209,9 +209,9 @@ var commentUpdateCmd = &cobra.Command{
if err != nil {
return err
}
body = markdownToHTML(string(content))
body = markdownToHTML(resolveMentions(string(content), getClient()))
} else if commentUpdateBody != "" {
body = markdownToHTML(commentUpdateBody)
body = markdownToHTML(resolveMentions(commentUpdateBody, getClient()))
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as comment create: getClient() is called separately in each branch here, which can instantiate multiple identical clients in a single run. Prefer allocating the client once and reusing it when calling resolveMentions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f84f9d7 — same change applied here.


commentID := args[0]
Expand Down
207 changes: 207 additions & 0 deletions internal/commands/mentions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package commands

import (
"fmt"
"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).
var mentionRegex = regexp.MustCompile(`(?:^|[^a-zA-Z0-9_.])@([a-zA-Z][a-zA-Z0-9_]*)`)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

// promptItemRegex parses <lexxy-prompt-item> tags from the /prompts/users HTML.
var promptItemRegex = regexp.MustCompile(`<lexxy-prompt-item\s+search="([^"]+)"\s+sgid="([^"]+)"[^>]*>`)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

promptItemRegex assumes a very specific attribute order/spacing (search then sgid) and will fail to parse users if the HTML emitter changes attribute order or inserts additional attributes (which is allowed by HTML). That would silently disable mention resolution. Prefer parsing the HTML with an HTML tokenizer/parser and extracting search/sgid attributes from each regardless of order (and similarly find the avatar within that element).

Suggested change
var promptItemRegex = regexp.MustCompile(`<lexxy-prompt-item\s+search="([^"]+)"\s+sgid="([^"]+)"[^>]*>`)
// It is robust to attribute order and extra attributes, while capturing search and sgid.
var promptItemRegex = regexp.MustCompile(`<lexxy-prompt-item(?=[^>]*\ssearch="([^"]+)")(?=[^>]*\ssgid="([^"]+)")[^>]*>`)

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0b727e. Note: the suggested lookahead regex ((?=...)) is not supported by Go's RE2 engine — it panics at compile time. Instead, replaced the single regex with separate searchAttrRegex and sgidAttrRegex patterns applied independently to each matched tag. This handles any attribute order without requiring PCRE features.


// avatarRegex extracts the src attribute from the first <img> in editor template.
var avatarRegex = regexp.MustCompile(`<img[^>]+src="([^"]+)"`)

// 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.
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
}

// 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(text, -1)
var matches []mentionMatch
Comment on lines +71 to +109
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveMentions runs on the raw markdown input before markdownToHTML(), so it will also rewrite @Name occurrences inside markdown code spans / fenced code blocks (e.g., @Sarah), changing what the user intended to show as literal code into an escaped <action-text-attachment ...> snippet. Add logic to skip mention resolution inside markdown code (or move mention resolution to operate on the post-markdown HTML while ignoring /

 content), and add a unit test for the backtick/code-fence case.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0b727e. Code spans and fenced code blocks are now replaced with null-byte placeholders before mention resolution runs, then restored afterward. This prevents @name inside backticks or code fences from being transformed. Three new test cases cover this.

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 := text[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:
html := buildMentionHTML(found[0])
text = text[:m.start] + html + text[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, ", "))
}
}

return text
}

// 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 <lexxy-prompt-item> element with search and sgid
// attributes, containing <img> tags with avatar URLs.
func parseMentionUsers(html []byte) []mentionUser {
htmlStr := string(html)
items := promptItemRegex.FindAllStringSubmatch(htmlStr, -1)
if len(items) == 0 {
return nil
}

var users []mentionUser
for _, item := range items {
search := strings.TrimSpace(item[1])
sgid := item[2]

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: find the <img> after this sgid in the HTML.
avatarSrc := ""
sgidIdx := strings.Index(htmlStr, `sgid="`+sgid+`"`)
if sgidIdx >= 0 {
// Search for the first <img> after the sgid
remainder := htmlStr[sgidIdx:]
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if m := avatarRegex.FindStringSubmatch(remainder); 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.
func buildMentionHTML(u mentionUser) string {
return fmt.Sprintf(
`<action-text-attachment sgid="%s" content-type="application/vnd.actiontext.mention">`+
`<img title="%s" src="%s" width="48" height="48">%s`+
`</action-text-attachment>`,
u.SGID, u.FullName, u.AvatarSrc, u.FirstName,
)
Comment on lines +239 to +248
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildMentionHTML interpolates SGID, FullName, AvatarSrc, and FirstName directly into HTML attributes / text without escaping. User names can legally contain characters like quotes or '<', which can break the generated markup or introduce HTML injection in the rich-text body. Use proper HTML escaping for attribute values and inner text (e.g., html.EscapeString) before formatting the attachment HTML, and add a test covering a name containing quotes or '<' to prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c0b727e. All user-controlled values (SGID, FullName, AvatarSrc, FirstName) are now escaped with html.EscapeString() before interpolation. Added TestBuildMentionHTMLEscaping covering names with quotes and <script> tags.

}

// 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
}
Loading
Loading