Render fixes

This commit is contained in:
Matthew Knight 2026-02-14 13:33:19 -08:00
parent d23aa87f75
commit 0e52d7ef98
No known key found for this signature in database
8 changed files with 257 additions and 23 deletions

View File

@ -660,6 +660,34 @@ func (c *Client) GetAuthenticatedUser() (*APIUser, error) {
return &user, nil 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. // ListIssueTimeline fetches the timeline events for an issue.
func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) { 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) reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", c.baseURL, owner, repo, number)

View File

@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/markdown"
"github.com/mattnite/forgejo-tickets/internal/models" "github.com/mattnite/forgejo-tickets/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -177,13 +178,33 @@ func (h *TicketHandler) Detail(c *gin.Context) {
return 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) timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil { if err != nil {
log.Error().Err(err).Msg("forgejo list timeline error") log.Error().Err(err).Msg("forgejo list timeline error")
timelineEvents = nil 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) cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true) timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true)
status := forgejo.DeriveStatus(issue) status := forgejo.DeriveStatus(issue)
@ -194,13 +215,27 @@ func (h *TicketHandler) Detail(c *gin.Context) {
assigneeNames = append(assigneeNames, a.DisplayName()) assigneeNames = append(assigneeNames, a.DisplayName())
} }
// Extract related issue references (admin sees all as links) // Build mention map
allText := cleanBody var allTexts []string
allTexts = append(allTexts, cleanBody)
for _, tv := range timeline { for _, tv := range timeline {
if tv.Type == "comment" { 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) refNumbers := forgejo.ExtractIssueReferences(allText)
var relatedIssues []forgejo.RelatedIssue var relatedIssues []forgejo.RelatedIssue
@ -242,6 +277,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
"Repo": repo, "Repo": repo,
"Timeline": timeline, "Timeline": timeline,
"RelatedIssues": relatedIssues, "RelatedIssues": relatedIssues,
"Mentions": mentions,
}) })
} }

View File

@ -12,6 +12,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/auth"
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/markdown"
"github.com/mattnite/forgejo-tickets/internal/models" "github.com/mattnite/forgejo-tickets/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -260,13 +261,35 @@ func (h *TicketHandler) Detail(c *gin.Context) {
return 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) timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil { if err != nil {
log.Error().Err(err).Msg("forgejo list timeline error") log.Error().Err(err).Msg("forgejo list timeline error")
timelineEvents = nil 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 // Strip the "Submitted by" footer from the issue body
cleanBody, _ := forgejo.StripCommentFooter(issue.Body) cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
@ -278,13 +301,27 @@ func (h *TicketHandler) Detail(c *gin.Context) {
assigneeNames = append(assigneeNames, a.DisplayName()) assigneeNames = append(assigneeNames, a.DisplayName())
} }
// Extract related issue references // Build mention map: extract @usernames from body + comments, look up display names
allText := cleanBody var allTexts []string
allTexts = append(allTexts, cleanBody)
for _, tv := range timeline { for _, tv := range timeline {
if tv.Type == "comment" { 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) refNumbers := forgejo.ExtractIssueReferences(allText)
// Check visibility of referenced issues // Check visibility of referenced issues
@ -337,6 +374,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
"Repo": repo, "Repo": repo,
"Timeline": timeline, "Timeline": timeline,
"RelatedIssues": relatedIssues, "RelatedIssues": relatedIssues,
"Mentions": mentions,
}) })
} }
@ -361,7 +399,12 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
} }
body := c.PostForm("body") 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()) c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
return return
} }
@ -373,8 +416,14 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return 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{ 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 { if err != nil {
log.Error().Err(err).Msg("forgejo create comment error") log.Error().Err(err).Msg("forgejo create comment error")
@ -382,9 +431,8 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return return
} }
// Upload attachments to comment if any // Upload attachments to comment
form, _ := c.MultipartForm() if hasAttachments {
if form != nil && form.File["attachments"] != nil {
for _, fh := range form.File["attachments"] { for _, fh := range form.File["attachments"] {
f, err := fh.Open() f, err := fh.Open()
if err != nil { if err != nil {

View File

@ -2,7 +2,10 @@ package markdown
import ( import (
"bytes" "bytes"
"fmt"
"html/template" "html/template"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@ -14,6 +17,12 @@ import (
var ( var (
md goldmark.Markdown md goldmark.Markdown
policy *bluemonday.Policy 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() { func init() {
@ -30,16 +39,103 @@ func init() {
) )
policy = bluemonday.UGCPolicy() 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") 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. // 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 var buf bytes.Buffer
if err := md.Convert([]byte(input), &buf); err != nil { if err := md.Convert([]byte(input), &buf); err != nil {
return template.HTML(template.HTMLEscapeString(input)) 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) return template.HTML(sanitized)
} }
// processMentions replaces @username in HTML text with styled spans.
// It avoids replacing inside <code>, <pre>, and <a> 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(`<span class="mention" title="%s">@%s</span>`, title, template.HTMLEscapeString(username))
html = replaceOutsideCode(html, old, replacement)
}
return html
}
// replaceOutsideCode replaces old with new in html, but skips content inside <code> and <pre> 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, "<code") || strings.HasPrefix(lower, "<pre") {
// Find the matching close tag
var closeTag string
if strings.HasPrefix(lower, "<code") {
closeTag = "</code>"
} else {
closeTag = "</pre>"
}
endIdx := strings.Index(strings.ToLower(html[i:]), closeTag)
if endIdx != -1 {
endIdx += i + len(closeTag)
result.WriteString(html[i:endIdx])
i = endIdx
continue
}
}
}
// Try to match old at current position
if i+len(old) <= len(html) && html[i:i+len(old)] == old {
// Make sure it's a word boundary (not part of a longer word)
before := i > 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 == '_'
}

View File

@ -32,8 +32,14 @@ func templateFuncs() template.FuncMap {
label := strings.ReplaceAll(status, "_", " ") label := strings.ReplaceAll(status, "_", " ")
return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, class, label)) return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, class, label))
}, },
"renderMarkdown": func(input string) template.HTML { "renderMarkdown": func(input string, args ...interface{}) template.HTML {
return markdown.RenderMarkdown(input) 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 { "isOverdue": func(t *time.Time) bool {
if t == nil { if t == nil {

View File

@ -1,2 +1,22 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @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);
}

View File

@ -40,7 +40,7 @@
</div> </div>
<div class="mt-6 prose prose-sm max-w-none text-gray-700"> <div class="mt-6 prose prose-sm max-w-none text-gray-700">
{{renderMarkdown .Ticket.Description}} {{renderMarkdown .Ticket.Description .Mentions}}
</div> </div>
<!-- Issue Attachments --> <!-- Issue Attachments -->
@ -106,7 +106,7 @@
</div> </div>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span> <span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div> </div>
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body}}</div> <div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body $.Data.Mentions}}</div>
{{if .Attachments}} {{if .Attachments}}
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{{range .Attachments}} {{range .Attachments}}

View File

@ -38,7 +38,7 @@
</div> </div>
<div class="mt-6 prose prose-sm max-w-none text-gray-700"> <div class="mt-6 prose prose-sm max-w-none text-gray-700">
{{renderMarkdown .Ticket.Description}} {{renderMarkdown .Ticket.Description .Mentions}}
</div> </div>
<!-- Issue Attachments --> <!-- Issue Attachments -->
@ -90,7 +90,7 @@
</div> </div>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span> <span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div> </div>
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body}}</div> <div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body $.Data.Mentions}}</div>
{{if .Attachments}} {{if .Attachments}}
<div class="mt-2 flex flex-wrap gap-2"> <div class="mt-2 flex flex-wrap gap-2">
{{range .Attachments}} {{range .Attachments}}
@ -120,7 +120,7 @@
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div>
<label for="body" class="sr-only">Add a comment</label> <label for="body" class="sr-only">Add a comment</label>
<textarea name="body" id="body" rows="3" required <textarea name="body" id="body" rows="3"
placeholder="Add a comment... (Markdown supported)" placeholder="Add a comment... (Markdown supported)"
class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea> class="block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"></textarea>
</div> </div>