diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index 013151d..8ebd565 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -660,6 +660,34 @@ func (c *Client) GetAuthenticatedUser() (*APIUser, error) { return &user, nil } +// GetUser looks up a Forgejo user by username. +func (c *Client) GetUser(username string) (*APIUser, error) { + reqURL := fmt.Sprintf("%s/api/v1/users/%s", c.baseURL, username) + + httpReq, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var user APIUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, err + } + return &user, nil +} + // ListIssueTimeline fetches the timeline events for an issue. func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) { reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", c.baseURL, owner, repo, number) diff --git a/internal/handlers/admin/tickets.go b/internal/handlers/admin/tickets.go index 884200a..88f818a 100644 --- a/internal/handlers/admin/tickets.go +++ b/internal/handlers/admin/tickets.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/forgejo" + "github.com/mattnite/forgejo-tickets/internal/markdown" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) @@ -177,13 +178,33 @@ func (h *TicketHandler) Detail(c *gin.Context) { return } - // Fetch timeline for full event view + // Fetch comments (includes assets) and timeline (includes events) + comments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) + if err != nil { + log.Error().Err(err).Msg("forgejo list comments error") + } + timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo list timeline error") timelineEvents = nil } + // Build comment asset lookup (timeline may not include assets) + commentAssets := map[int64][]forgejo.Attachment{} + for _, cm := range comments { + if len(cm.Assets) > 0 { + commentAssets[cm.ID] = cm.Assets + } + } + for i := range timelineEvents { + if timelineEvents[i].Type == "comment" && len(timelineEvents[i].Assets) == 0 { + if assets, ok := commentAssets[timelineEvents[i].ID]; ok { + timelineEvents[i].Assets = assets + } + } + } + cleanBody, _ := forgejo.StripCommentFooter(issue.Body) timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true) status := forgejo.DeriveStatus(issue) @@ -194,13 +215,27 @@ func (h *TicketHandler) Detail(c *gin.Context) { assigneeNames = append(assigneeNames, a.DisplayName()) } - // Extract related issue references (admin sees all as links) - allText := cleanBody + // Build mention map + var allTexts []string + allTexts = append(allTexts, cleanBody) for _, tv := range timeline { if tv.Type == "comment" { - allText += "\n" + tv.Body + allTexts = append(allTexts, tv.Body) } } + usernames := markdown.ExtractMentions(allTexts...) + mentions := map[string]string{} + for _, username := range usernames { + u, err := h.deps.ForgejoClient.GetUser(username) + if err != nil { + mentions[username] = username + } else { + mentions[username] = u.DisplayName() + } + } + + // Extract related issue references (admin sees all as links) + allText := strings.Join(allTexts, "\n") refNumbers := forgejo.ExtractIssueReferences(allText) var relatedIssues []forgejo.RelatedIssue @@ -242,6 +277,7 @@ func (h *TicketHandler) Detail(c *gin.Context) { "Repo": repo, "Timeline": timeline, "RelatedIssues": relatedIssues, + "Mentions": mentions, }) } diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index 1da358e..82f52d3 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/forgejo" + "github.com/mattnite/forgejo-tickets/internal/markdown" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) @@ -260,13 +261,35 @@ func (h *TicketHandler) Detail(c *gin.Context) { return } - // Fetch timeline instead of plain comments + // Fetch comments (includes assets) and timeline (includes events) + comments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) + if err != nil { + log.Error().Err(err).Msg("forgejo list comments error") + } + timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo list timeline error") timelineEvents = nil } + // Build comment asset lookup (timeline may not include assets) + commentAssets := map[int64][]forgejo.Attachment{} + for _, cm := range comments { + if len(cm.Assets) > 0 { + commentAssets[cm.ID] = cm.Assets + } + } + + // Merge assets into timeline events + for i := range timelineEvents { + if timelineEvents[i].Type == "comment" && len(timelineEvents[i].Assets) == 0 { + if assets, ok := commentAssets[timelineEvents[i].ID]; ok { + timelineEvents[i].Assets = assets + } + } + } + // Strip the "Submitted by" footer from the issue body cleanBody, _ := forgejo.StripCommentFooter(issue.Body) @@ -278,13 +301,27 @@ func (h *TicketHandler) Detail(c *gin.Context) { assigneeNames = append(assigneeNames, a.DisplayName()) } - // Extract related issue references - allText := cleanBody + // Build mention map: extract @usernames from body + comments, look up display names + var allTexts []string + allTexts = append(allTexts, cleanBody) for _, tv := range timeline { if tv.Type == "comment" { - allText += "\n" + tv.Body + allTexts = append(allTexts, tv.Body) } } + usernames := markdown.ExtractMentions(allTexts...) + mentions := map[string]string{} + for _, username := range usernames { + u, err := h.deps.ForgejoClient.GetUser(username) + if err != nil { + mentions[username] = username + } else { + mentions[username] = u.DisplayName() + } + } + + // Extract related issue references + allText := strings.Join(allTexts, "\n") refNumbers := forgejo.ExtractIssueReferences(allText) // Check visibility of referenced issues @@ -337,6 +374,7 @@ func (h *TicketHandler) Detail(c *gin.Context) { "Repo": repo, "Timeline": timeline, "RelatedIssues": relatedIssues, + "Mentions": mentions, }) } @@ -361,7 +399,12 @@ func (h *TicketHandler) AddComment(c *gin.Context) { } body := c.PostForm("body") - if body == "" { + + // Check if there are attachments + form, _ := c.MultipartForm() + hasAttachments := form != nil && len(form.File["attachments"]) > 0 + + if body == "" && !hasAttachments { c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) return } @@ -373,8 +416,14 @@ func (h *TicketHandler) AddComment(c *gin.Context) { return } + // Build comment body — use placeholder if only attachments + commentBody := body + if commentBody == "" { + commentBody = "(attached files)" + } + comment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{ - Body: body + "\n\n---\n*Customer comment by: " + user.Email + "*", + Body: commentBody + "\n\n---\n*Customer comment by: " + user.Email + "*", }) if err != nil { log.Error().Err(err).Msg("forgejo create comment error") @@ -382,9 +431,8 @@ func (h *TicketHandler) AddComment(c *gin.Context) { return } - // Upload attachments to comment if any - form, _ := c.MultipartForm() - if form != nil && form.File["attachments"] != nil { + // Upload attachments to comment + if hasAttachments { for _, fh := range form.File["attachments"] { f, err := fh.Open() if err != nil { diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go index 58b2372..bd72a18 100644 --- a/internal/markdown/markdown.go +++ b/internal/markdown/markdown.go @@ -2,7 +2,10 @@ package markdown import ( "bytes" + "fmt" "html/template" + "regexp" + "strings" "github.com/microcosm-cc/bluemonday" "github.com/yuin/goldmark" @@ -14,6 +17,12 @@ import ( var ( md goldmark.Markdown policy *bluemonday.Policy + + // Matches @username in rendered HTML text (not inside tags) + mentionRegex = regexp.MustCompile(`(?:^|[\s(>])(@(\w+))`) + + // Matches @username in raw markdown for extraction + RawMentionRegex = regexp.MustCompile(`(?:^|[\s(])@(\w+)`) ) func init() { @@ -30,16 +39,103 @@ func init() { ) policy = bluemonday.UGCPolicy() - policy.AllowAttrs("class").OnElements("code", "pre", "span", "div") + policy.AllowAttrs("class").OnElements("code", "pre", "span", "div", "ul", "li") policy.AllowAttrs("style").OnElements("span", "pre", "code") + // Allow task list checkboxes generated by goldmark GFM + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") + policy.AllowAttrs("checked", "disabled").OnElements("input") +} + +// ExtractMentions returns unique @usernames found in the raw markdown text. +func ExtractMentions(texts ...string) []string { + seen := map[string]bool{} + var result []string + for _, text := range texts { + for _, m := range RawMentionRegex.FindAllStringSubmatch(text, -1) { + username := m[1] + if !seen[username] { + seen[username] = true + result = append(result, username) + } + } + } + return result } // RenderMarkdown converts markdown text to sanitized HTML. -func RenderMarkdown(input string) template.HTML { +// An optional mentions map (username -> display name) can be passed to style @mentions. +func RenderMarkdown(input string, mentions map[string]string) template.HTML { var buf bytes.Buffer if err := md.Convert([]byte(input), &buf); err != nil { return template.HTML(template.HTMLEscapeString(input)) } - sanitized := policy.SanitizeBytes(buf.Bytes()) + sanitized := string(policy.SanitizeBytes(buf.Bytes())) + + if len(mentions) > 0 { + sanitized = processMentions(sanitized, mentions) + } + return template.HTML(sanitized) } + +// processMentions replaces @username in HTML text with styled spans. +// It avoids replacing inside ,
, and  tags.
+func processMentions(html string, mentions map[string]string) string {
+	// Simple approach: split on code/pre blocks, only process outside them
+	// For robustness, just do a string replacement for known usernames
+	for username, displayName := range mentions {
+		old := "@" + username
+		title := template.HTMLEscapeString(displayName)
+		replacement := fmt.Sprintf(`@%s`, title, template.HTMLEscapeString(username))
+		html = replaceOutsideCode(html, old, replacement)
+	}
+	return html
+}
+
+// replaceOutsideCode replaces old with new in html, but skips content inside  and 
 tags.
+func replaceOutsideCode(html, old, replacement string) string {
+	var result strings.Builder
+	i := 0
+	for i < len(html) {
+		// Check if we're entering a code or pre block
+		if i < len(html)-1 && html[i] == '<' {
+			lower := strings.ToLower(html[i:])
+			if strings.HasPrefix(lower, " 0 && isWordChar(html[i-1])
+			after := i+len(old) < len(html) && isWordChar(html[i+len(old)])
+			if !before && !after {
+				result.WriteString(replacement)
+				i += len(old)
+				continue
+			}
+		}
+
+		result.WriteByte(html[i])
+		i++
+	}
+	return result.String()
+}
+
+func isWordChar(b byte) bool {
+	return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
+}
diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go
index 5be95b5..a15f381 100644
--- a/internal/templates/funcs.go
+++ b/internal/templates/funcs.go
@@ -32,8 +32,14 @@ func templateFuncs() template.FuncMap {
 			label := strings.ReplaceAll(status, "_", " ")
 			return template.HTML(fmt.Sprintf(`%s`, class, label))
 		},
-		"renderMarkdown": func(input string) template.HTML {
-			return markdown.RenderMarkdown(input)
+		"renderMarkdown": func(input string, args ...interface{}) template.HTML {
+			var mentions map[string]string
+			if len(args) > 0 {
+				if m, ok := args[0].(map[string]string); ok {
+					mentions = m
+				}
+			}
+			return markdown.RenderMarkdown(input, mentions)
 		},
 		"isOverdue": func(t *time.Time) bool {
 			if t == nil {
diff --git a/web/static/css/input.css b/web/static/css/input.css
index 3571db7..ccc0526 100644
--- a/web/static/css/input.css
+++ b/web/static/css/input.css
@@ -1,2 +1,22 @@
 @import "tailwindcss";
 @plugin "@tailwindcss/typography";
+
+/* @mention styling */
+.mention {
+    color: var(--color-blue-600);
+    background-color: var(--color-blue-50);
+    padding: 0.125rem 0.25rem;
+    border-radius: 0.25rem;
+    font-weight: 500;
+    cursor: default;
+    position: relative;
+}
+.mention:hover {
+    background-color: var(--color-blue-100);
+}
+
+/* Task list checkbox styling */
+.prose input[type="checkbox"] {
+    margin-right: 0.375rem;
+    accent-color: var(--color-blue-600);
+}
diff --git a/web/templates/pages/admin/tickets/detail.html b/web/templates/pages/admin/tickets/detail.html
index e5480a2..adf3ac5 100644
--- a/web/templates/pages/admin/tickets/detail.html
+++ b/web/templates/pages/admin/tickets/detail.html
@@ -40,7 +40,7 @@
     
 
     
- {{renderMarkdown .Ticket.Description}} + {{renderMarkdown .Ticket.Description .Mentions}}
@@ -106,7 +106,7 @@ {{formatDateTime .CreatedAt}} -
{{renderMarkdown .Body}}
+
{{renderMarkdown .Body $.Data.Mentions}}
{{if .Attachments}}
{{range .Attachments}} diff --git a/web/templates/pages/tickets/detail.html b/web/templates/pages/tickets/detail.html index 55e704f..e81cb03 100644 --- a/web/templates/pages/tickets/detail.html +++ b/web/templates/pages/tickets/detail.html @@ -38,7 +38,7 @@
- {{renderMarkdown .Ticket.Description}} + {{renderMarkdown .Ticket.Description .Mentions}}
@@ -90,7 +90,7 @@ {{formatDateTime .CreatedAt}} -
{{renderMarkdown .Body}}
+
{{renderMarkdown .Body $.Data.Mentions}}
{{if .Attachments}}
{{range .Attachments}} @@ -120,7 +120,7 @@
-