Render fixes
This commit is contained in:
parent
d23aa87f75
commit
0e52d7ef98
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <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 == '_'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,14 @@ func templateFuncs() template.FuncMap {
|
|||
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))
|
||||
},
|
||||
"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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
||||
{{renderMarkdown .Ticket.Description}}
|
||||
{{renderMarkdown .Ticket.Description .Mentions}}
|
||||
</div>
|
||||
|
||||
<!-- Issue Attachments -->
|
||||
|
|
@ -106,7 +106,7 @@
|
|||
</div>
|
||||
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
||||
</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}}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{{range .Attachments}}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
||||
{{renderMarkdown .Ticket.Description}}
|
||||
{{renderMarkdown .Ticket.Description .Mentions}}
|
||||
</div>
|
||||
|
||||
<!-- Issue Attachments -->
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
</div>
|
||||
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
||||
</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}}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{{range .Attachments}}
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
<input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
|
||||
<div>
|
||||
<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)"
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue