From d23aa87f75e80cac58cffa5dd8bbd39fed05b35e Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Sat, 14 Feb 2026 12:59:32 -0800 Subject: [PATCH] More forgejo features --- go.mod | 7 + go.sum | 17 + internal/forgejo/client.go | 330 ++++++++++++++++-- internal/handlers/admin/tickets.go | 77 +++- internal/handlers/public/routes.go | 1 + internal/handlers/public/tickets.go | 242 ++++++++++++- internal/markdown/markdown.go | 45 +++ internal/templates/funcs.go | 31 ++ package-lock.json | 47 ++- package.json | 3 + web/static/css/input.css | 1 + web/templates/pages/admin/tickets/detail.html | 89 ++++- web/templates/pages/admin/tickets/list.html | 9 +- web/templates/pages/tickets/detail.html | 108 +++++- web/templates/pages/tickets/list.html | 9 +- web/templates/pages/tickets/new.html | 10 +- 16 files changed, 957 insertions(+), 69 deletions(-) create mode 100644 internal/markdown/markdown.go diff --git a/go.mod b/go.mod index 4242549..02183d1 100644 --- a/go.mod +++ b/go.mod @@ -19,9 +19,12 @@ require ( require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/alecthomas/chroma/v2 v2.2.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dlclark/regexp2 v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -29,6 +32,7 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -40,6 +44,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -47,6 +52,8 @@ require ( github.com/quic-go/quic-go v0.54.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + github.com/yuin/goldmark v1.7.16 // indirect + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/go.sum b/go.sum index 69e847b..73c2431 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,10 @@ cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY= +github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= +github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= @@ -10,6 +15,9 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo= +github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -40,6 +48,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/csrf v1.7.2 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI= github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -68,6 +78,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= @@ -100,6 +112,11 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= +github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c= diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index e4586c8..013151d 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -7,8 +7,12 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "regexp" + "sort" + "strconv" "strings" "time" ) @@ -30,6 +34,16 @@ func NewClient(baseURL, apiToken string) *Client { } } +// BaseURL returns the Forgejo base URL for proxy download purposes. +func (c *Client) BaseURL() string { + return c.baseURL +} + +// APIToken returns the API token for authenticated proxy requests. +func (c *Client) APIToken() string { + return c.apiToken +} + type CreateIssueRequest struct { Title string `json:"title"` Body string `json:"body"` @@ -41,17 +55,30 @@ type EditIssueRequest struct { } type Label struct { - ID int64 `json:"id"` - Name string `json:"name"` + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +type Attachment struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + DownloadURL string `json:"browser_download_url"` + Created time.Time `json:"created_at"` } type Issue struct { - Number int64 `json:"number"` - Title string `json:"title"` - Body string `json:"body"` - State string `json:"state"` - Labels []Label `json:"labels"` - CreatedAt time.Time `json:"created_at"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Labels []Label `json:"labels"` + Assignees []APIUser `json:"assignees"` + DueDate *time.Time `json:"due_date"` + PinOrder int `json:"pin_order"` + Assets []Attachment `json:"assets"` + CreatedAt time.Time `json:"created_at"` } type CreateCommentRequest struct { @@ -59,10 +86,11 @@ type CreateCommentRequest struct { } type Comment struct { - ID int64 `json:"id"` - Body string `json:"body"` - User APIUser `json:"user"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + Body string `json:"body"` + User APIUser `json:"user"` + Assets []Attachment `json:"assets"` + CreatedAt time.Time `json:"created_at"` } type APIUser struct { @@ -84,10 +112,44 @@ func (u APIUser) DisplayName() string { } type CommentView struct { - Body string - AuthorName string - IsTeam bool - CreatedAt time.Time + Body string + AuthorName string + IsTeam bool + CreatedAt time.Time + Attachments []Attachment +} + +// TimelineEvent represents a single event in the issue timeline from Forgejo. +type TimelineEvent struct { + ID int64 `json:"id"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + User APIUser `json:"user"` + Body string `json:"body"` + Label *Label `json:"label"` + Assignee *APIUser `json:"assignee"` + OldRef string `json:"old_ref"` + Assets []Attachment `json:"assets"` +} + +// TimelineView is the template-friendly representation of a timeline event. +type TimelineView struct { + Type string // "comment", "status_change", "assignment", "label" + Body string + AuthorName string + IsTeam bool + EventText string + CreatedAt time.Time + Attachments []Attachment +} + +// RelatedIssue represents a cross-referenced issue with visibility info. +type RelatedIssue struct { + Number int64 + Title string + IsVisible bool + DisplayText string + TicketID string // customer-facing UUID if visible } // DeriveStatus maps Forgejo issue state + labels to app status. @@ -103,6 +165,21 @@ func DeriveStatus(issue *Issue) string { return "open" } +// DerivePriority returns "high", "medium", "low", or "" from issue labels. +func DerivePriority(issue *Issue) string { + for _, l := range issue.Labels { + switch l.Name { + case "priority/high": + return "high" + case "priority/medium": + return "medium" + case "priority/low": + return "low" + } + } + return "" +} + // StripCommentFooter removes the "---\n*...*" footer from bot-posted comments // and returns the clean body and the attribution (email). func StripCommentFooter(body string) (string, string) { @@ -124,9 +201,7 @@ func StripCommentFooter(body string) (string, string) { } // BuildCommentViews transforms Forgejo comments into view models, -// identifying customer vs team comments. A comment is considered a -// customer comment if it has a recognizable footer (primary check) OR -// if its author matches the bot login (fallback). +// identifying customer vs team comments. func BuildCommentViews(comments []Comment, botLogin string) []CommentView { var views []CommentView for _, c := range comments { @@ -139,23 +214,137 @@ func BuildCommentViews(comments []Comment, botLogin string) []CommentView { authorName = "Customer" } views = append(views, CommentView{ - Body: body, - AuthorName: authorName, - IsTeam: false, - CreatedAt: c.CreatedAt, + Body: body, + AuthorName: authorName, + IsTeam: false, + CreatedAt: c.CreatedAt, + Attachments: c.Assets, }) } else { views = append(views, CommentView{ - Body: c.Body, - AuthorName: c.User.DisplayName(), - IsTeam: true, - CreatedAt: c.CreatedAt, + Body: c.Body, + AuthorName: c.User.DisplayName(), + IsTeam: true, + CreatedAt: c.CreatedAt, + Attachments: c.Assets, }) } } return views } +// BuildTimelineViews converts raw timeline events into template-friendly views. +func BuildTimelineViews(events []TimelineEvent, botLogin string, isAdmin bool) []TimelineView { + var views []TimelineView + for _, e := range events { + switch e.Type { + case "comment": + body, email := StripCommentFooter(e.Body) + isCustomer := email != "" || (botLogin != "" && e.User.Login == botLogin) + authorName := e.User.DisplayName() + if isCustomer { + if email != "" { + authorName = email + } else { + authorName = "Customer" + } + } + views = append(views, TimelineView{ + Type: "comment", + Body: body, + AuthorName: authorName, + IsTeam: !isCustomer, + CreatedAt: e.CreatedAt, + Attachments: e.Assets, + }) + case "close": + views = append(views, TimelineView{ + Type: "status_change", + AuthorName: e.User.DisplayName(), + IsTeam: true, + EventText: "closed this ticket", + CreatedAt: e.CreatedAt, + }) + case "reopen": + views = append(views, TimelineView{ + Type: "status_change", + AuthorName: e.User.DisplayName(), + IsTeam: true, + EventText: "reopened this ticket", + CreatedAt: e.CreatedAt, + }) + case "label": + if !isAdmin { + continue + } + action := "added label" + labelName := "" + if e.Label != nil { + labelName = e.Label.Name + } + views = append(views, TimelineView{ + Type: "label", + AuthorName: e.User.DisplayName(), + IsTeam: true, + EventText: action + " " + labelName, + CreatedAt: e.CreatedAt, + }) + case "assignees": + if !isAdmin { + continue + } + assigneeName := "" + if e.Assignee != nil { + assigneeName = e.Assignee.DisplayName() + } + views = append(views, TimelineView{ + Type: "assignment", + AuthorName: e.User.DisplayName(), + IsTeam: true, + EventText: "assigned " + assigneeName, + CreatedAt: e.CreatedAt, + }) + } + } + return views +} + +var issueRefRegex = regexp.MustCompile(`(?:^|[^\w])#(\d+)\b`) + +// ExtractIssueReferences finds all #N references in text. +func ExtractIssueReferences(text string) []int64 { + matches := issueRefRegex.FindAllStringSubmatch(text, -1) + seen := map[int64]bool{} + var refs []int64 + for _, m := range matches { + n, err := strconv.ParseInt(m[1], 10, 64) + if err != nil || n == 0 { + continue + } + if !seen[n] { + seen[n] = true + refs = append(refs, n) + } + } + sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] }) + return refs +} + +// SortIssuesPinnedFirst sorts issues with pinned (PinOrder > 0) first, then by CreatedAt desc. +func SortIssuesPinnedFirst(issues []Issue) { + sort.SliceStable(issues, func(i, j int) bool { + iPinned := issues[i].PinOrder > 0 + jPinned := issues[j].PinOrder > 0 + if iPinned != jPinned { + return iPinned + } + if iPinned && jPinned { + return issues[i].PinOrder < issues[j].PinOrder + } + return issues[i].CreatedAt.After(issues[j].CreatedAt) + }) +} + func GenerateWebhookSecret() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { @@ -470,3 +659,90 @@ func (c *Client) GetAuthenticatedUser() (*APIUser, error) { } 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) + + 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 events []TimelineEvent + if err := json.NewDecoder(resp.Body).Decode(&events); err != nil { + return nil, err + } + return events, nil +} + +// CreateIssueAttachment uploads a file to a Forgejo issue. +func (c *Client) CreateIssueAttachment(owner, repo string, number int64, filename string, reader io.Reader) (*Attachment, error) { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/assets", c.baseURL, owner, repo, number) + return c.uploadAttachment(reqURL, filename, reader) +} + +// CreateCommentAttachment uploads a file to a Forgejo comment. +func (c *Client) CreateCommentAttachment(owner, repo string, commentID int64, filename string, reader io.Reader) (*Attachment, error) { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d/assets", c.baseURL, owner, repo, commentID) + return c.uploadAttachment(reqURL, filename, reader) +} + +func (c *Client) uploadAttachment(reqURL, filename string, reader io.Reader) (*Attachment, error) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("attachment", filename) + if err != nil { + return nil, fmt.Errorf("create form file: %w", err) + } + if _, err := io.Copy(part, reader); err != nil { + return nil, fmt.Errorf("copy file data: %w", err) + } + writer.Close() + + httpReq, err := http.NewRequest("POST", reqURL, &buf) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", writer.FormDataContentType()) + 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.StatusCreated { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var attachment Attachment + if err := json.NewDecoder(resp.Body).Decode(&attachment); err != nil { + return nil, err + } + return &attachment, nil +} + +// ProxyDownload fetches a file from the given Forgejo URL with authentication and streams it back. +func (c *Client) ProxyDownload(downloadURL string) (*http.Response, error) { + httpReq, err := http.NewRequest("GET", downloadURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + return c.httpClient.Do(httpReq) +} diff --git a/internal/handlers/admin/tickets.go b/internal/handlers/admin/tickets.go index 10cb4b0..884200a 100644 --- a/internal/handlers/admin/tickets.go +++ b/internal/handlers/admin/tickets.go @@ -1,7 +1,11 @@ package admin import ( + "fmt" "net/http" + "sort" + "strings" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -18,10 +22,13 @@ type ticketListRow struct { ID uuid.UUID Title string Status string + Priority string + Pinned bool RepoName string RepoSlug string UserEmail string UserName string + DueDate *time.Time CreatedAt interface{} } @@ -117,15 +124,26 @@ func (h *TicketHandler) List(c *gin.Context) { ID: m.ID, Title: issue.Title, Status: status, + Priority: forgejo.DerivePriority(issue), + Pinned: issue.PinOrder > 0, RepoName: m.RepoName, RepoSlug: m.RepoSlug, UserEmail: m.UserEmail, UserName: m.UserName, + DueDate: issue.DueDate, CreatedAt: m.CreatedAt, }) } } + // Sort: pinned first, then by created date + sort.SliceStable(tickets, func(i, j int) bool { + if tickets[i].Pinned != tickets[j].Pinned { + return tickets[i].Pinned + } + return false + }) + h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{ "Tickets": tickets, "StatusFilter": statusFilter, @@ -151,7 +169,7 @@ func (h *TicketHandler) Detail(c *gin.Context) { var repo models.Repo h.deps.DB.First(&repo, "id = ?", ticket.RepoID) - // Fetch issue and comments from Forgejo + // Fetch issue from Forgejo issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo get issue error") @@ -159,28 +177,71 @@ func (h *TicketHandler) Detail(c *gin.Context) { return } - rawComments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) + // Fetch timeline for full event view + timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { - log.Error().Err(err).Msg("forgejo list comments error") - rawComments = nil + log.Error().Err(err).Msg("forgejo list timeline error") + timelineEvents = nil } cleanBody, _ := forgejo.StripCommentFooter(issue.Body) - comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin) + timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true) status := forgejo.DeriveStatus(issue) + // Build assignee names + var assigneeNames []string + for _, a := range issue.Assignees { + assigneeNames = append(assigneeNames, a.DisplayName()) + } + + // Extract related issue references (admin sees all as links) + allText := cleanBody + for _, tv := range timeline { + if tv.Type == "comment" { + allText += "\n" + tv.Body + } + } + refNumbers := forgejo.ExtractIssueReferences(allText) + + var relatedIssues []forgejo.RelatedIssue + for _, refNum := range refNumbers { + if refNum == ticket.ForgejoIssueNumber { + continue + } + ri := forgejo.RelatedIssue{Number: refNum, IsVisible: true} + refIssue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, refNum) + if err == nil { + ri.Title = refIssue.Title + ri.DisplayText = refIssue.Title + } else { + ri.DisplayText = fmt.Sprintf("#%d", refNum) + } + // Check if there's a ticket mapping for admin link + var refTicket models.Ticket + if h.deps.DB.Where("repo_id = ? AND forgejo_issue_number = ?", ticket.RepoID, refNum).First(&refTicket).Error == nil { + ri.TicketID = refTicket.ID.String() + } + relatedIssues = append(relatedIssues, ri) + } + h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/detail", map[string]interface{}{ "Ticket": map[string]interface{}{ "ID": ticket.ID, "Title": issue.Title, "Description": cleanBody, "Status": status, + "Priority": forgejo.DerivePriority(issue), + "Pinned": issue.PinOrder > 0, + "Assignees": strings.Join(assigneeNames, ", "), + "DueDate": issue.DueDate, + "Attachments": issue.Assets, "ForgejoIssueNumber": ticket.ForgejoIssueNumber, "CreatedAt": ticket.CreatedAt, }, - "User": user, - "Repo": repo, - "Comments": comments, + "User": user, + "Repo": repo, + "Timeline": timeline, + "RelatedIssues": relatedIssues, }) } diff --git a/internal/handlers/public/routes.go b/internal/handlers/public/routes.go index eca70f6..f560013 100644 --- a/internal/handlers/public/routes.go +++ b/internal/handlers/public/routes.go @@ -77,6 +77,7 @@ func NewRouter(deps Dependencies) *gin.Engine { authenticated.POST("/tickets", ticketHandler.Create) authenticated.GET("/tickets/:id", ticketHandler.Detail) authenticated.POST("/tickets/:id/comments", ticketHandler.AddComment) + authenticated.GET("/tickets/:id/attachments/:attachmentId/*filename", ticketHandler.DownloadAttachment) } } diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index cfa297b..1da358e 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -1,7 +1,12 @@ package public import ( + "io" "net/http" + "sort" + "strconv" + "strings" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -50,7 +55,10 @@ func (h *TicketHandler) List(c *gin.Context) { ID uuid.UUID Title string Status string + Priority string + Pinned bool RepoName string + DueDate *time.Time CreatedAt interface{} } var views []ticketView @@ -83,13 +91,24 @@ func (h *TicketHandler) List(c *gin.Context) { ID: t.ID, Title: issue.Title, Status: forgejo.DeriveStatus(issue), + Priority: forgejo.DerivePriority(issue), + Pinned: issue.PinOrder > 0, RepoName: group.repo.Name, + DueDate: issue.DueDate, CreatedAt: t.CreatedAt, }) } } } + // Sort: pinned first, then by created date + sort.SliceStable(views, func(i, j int) bool { + if views[i].Pinned != views[j].Pinned { + return views[i].Pinned + } + return false // preserve existing order for non-pinned + }) + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ "Tickets": views, }) @@ -177,6 +196,23 @@ func (h *TicketHandler) Create(c *gin.Context) { return } + // Upload attachments if any + form, _ := c.MultipartForm() + if form != nil && form.File["attachments"] != nil { + for _, fh := range form.File["attachments"] { + f, err := fh.Open() + if err != nil { + log.Error().Err(err).Str("file", fh.Filename).Msg("open uploaded file error") + continue + } + _, err = h.deps.ForgejoClient.CreateIssueAttachment(repo.ForgejoOwner, repo.ForgejoRepo, issue.Number, fh.Filename, f) + f.Close() + if err != nil { + log.Error().Err(err).Str("file", fh.Filename).Msg("upload attachment error") + } + } + } + // Create local ticket mapping ticket := models.Ticket{ UserID: user.ID, @@ -216,7 +252,7 @@ func (h *TicketHandler) Detail(c *gin.Context) { var repo models.Repo h.deps.DB.First(&repo, "id = ?", ticket.RepoID) - // Fetch issue and comments from Forgejo + // Fetch issue from Forgejo issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo get issue error") @@ -224,16 +260,66 @@ func (h *TicketHandler) Detail(c *gin.Context) { return } - rawComments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) + // Fetch timeline instead of plain comments + timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { - log.Error().Err(err).Msg("forgejo list comments error") - rawComments = nil // Show ticket without comments on error + log.Error().Err(err).Msg("forgejo list timeline error") + timelineEvents = nil } // Strip the "Submitted by" footer from the issue body cleanBody, _ := forgejo.StripCommentFooter(issue.Body) - comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin) + timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, false) + + // Build assignee names + var assigneeNames []string + for _, a := range issue.Assignees { + assigneeNames = append(assigneeNames, a.DisplayName()) + } + + // Extract related issue references + allText := cleanBody + for _, tv := range timeline { + if tv.Type == "comment" { + allText += "\n" + tv.Body + } + } + refNumbers := forgejo.ExtractIssueReferences(allText) + + // Check visibility of referenced issues + var relatedIssues []forgejo.RelatedIssue + if len(refNumbers) > 0 { + // Build a set of issue numbers this customer has tickets for + var userTickets []models.Ticket + h.deps.DB.Where("user_id = ? AND repo_id = ?", user.ID, ticket.RepoID).Find(&userTickets) + ticketByIssue := map[int64]models.Ticket{} + for _, ut := range userTickets { + ticketByIssue[ut.ForgejoIssueNumber] = ut + } + + for _, refNum := range refNumbers { + if refNum == ticket.ForgejoIssueNumber { + continue // skip self-reference + } + ri := forgejo.RelatedIssue{Number: refNum} + if ut, ok := ticketByIssue[refNum]; ok { + // Customer has access to this issue + refIssue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, refNum) + if err == nil { + ri.Title = refIssue.Title + ri.IsVisible = true + ri.DisplayText = refIssue.Title + ri.TicketID = ut.ID.String() + } else { + ri.DisplayText = "[Internal Ticket]" + } + } else { + ri.DisplayText = "[Internal Ticket]" + } + relatedIssues = append(relatedIssues, ri) + } + } h.deps.Renderer.Render(c.Writer, c.Request, "tickets/detail", map[string]interface{}{ "Ticket": map[string]interface{}{ @@ -241,10 +327,16 @@ func (h *TicketHandler) Detail(c *gin.Context) { "Title": issue.Title, "Description": cleanBody, "Status": forgejo.DeriveStatus(issue), + "Priority": forgejo.DerivePriority(issue), + "Pinned": issue.PinOrder > 0, + "Assignees": strings.Join(assigneeNames, ", "), + "DueDate": issue.DueDate, + "Attachments": issue.Assets, "CreatedAt": ticket.CreatedAt, }, - "Repo": repo, - "Comments": comments, + "Repo": repo, + "Timeline": timeline, + "RelatedIssues": relatedIssues, }) } @@ -281,7 +373,7 @@ func (h *TicketHandler) AddComment(c *gin.Context) { return } - _, 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 + "*", }) if err != nil { @@ -290,5 +382,139 @@ func (h *TicketHandler) AddComment(c *gin.Context) { return } + // Upload attachments to comment if any + form, _ := c.MultipartForm() + if form != nil && form.File["attachments"] != nil { + for _, fh := range form.File["attachments"] { + f, err := fh.Open() + if err != nil { + log.Error().Err(err).Str("file", fh.Filename).Msg("open uploaded file error") + continue + } + _, err = h.deps.ForgejoClient.CreateCommentAttachment(repo.ForgejoOwner, repo.ForgejoRepo, comment.ID, fh.Filename, f) + f.Close() + if err != nil { + log.Error().Err(err).Str("file", fh.Filename).Msg("upload comment attachment error") + } + } + } + c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) } + +// DownloadAttachment proxies an attachment download from Forgejo. +func (h *TicketHandler) DownloadAttachment(c *gin.Context) { + user := auth.CurrentUser(c) + + ticketID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") + return + } + + var ticket models.Ticket + if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") + return + } + + if ticket.UserID != user.ID { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") + return + } + + attachmentID := c.Param("attachmentId") + filename := c.Param("filename") + + var repo models.Repo + h.deps.DB.First(&repo, "id = ?", ticket.RepoID) + + // Build the Forgejo download URL + downloadURL := h.deps.ForgejoClient.BaseURL() + "/attachments/" + attachmentID + + resp, err := h.deps.ForgejoClient.ProxyDownload(downloadURL) + if err != nil { + log.Error().Err(err).Msg("proxy download error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadGateway, "Failed to download file") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + h.deps.Renderer.RenderError(c.Writer, c.Request, resp.StatusCode, "Failed to download file") + return + } + + // Forward content type and set download headers + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Content-Type", contentType) + c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") + if cl := resp.Header.Get("Content-Length"); cl != "" { + c.Header("Content-Length", cl) + } + c.Status(http.StatusOK) + io.Copy(c.Writer, resp.Body) +} + +// GetIssueAttachment proxies an issue-level attachment download using the Forgejo asset API. +func (h *TicketHandler) GetIssueAttachment(c *gin.Context) { + user := auth.CurrentUser(c) + + ticketID, err := uuid.Parse(c.Param("id")) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID") + return + } + + var ticket models.Ticket + if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found") + return + } + + if ticket.UserID != user.ID { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied") + return + } + + attachmentID, err := strconv.ParseInt(c.Param("attachmentId"), 10, 64) + if err != nil { + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid attachment ID") + return + } + filename := c.Param("filename") + + var repo models.Repo + h.deps.DB.First(&repo, "id = ?", ticket.RepoID) + + // Use the Forgejo API to get the asset + assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/" + strconv.FormatInt(ticket.ForgejoIssueNumber, 10) + "/assets/" + strconv.FormatInt(attachmentID, 10) + + resp, err := h.deps.ForgejoClient.ProxyDownload(assetURL) + if err != nil { + log.Error().Err(err).Msg("proxy attachment download error") + h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadGateway, "Failed to download file") + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + h.deps.Renderer.RenderError(c.Writer, c.Request, resp.StatusCode, "Failed to download file") + return + } + + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + c.Header("Content-Type", contentType) + c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"") + if cl := resp.Header.Get("Content-Length"); cl != "" { + c.Header("Content-Length", cl) + } + c.Status(http.StatusOK) + io.Copy(c.Writer, resp.Body) +} diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go new file mode 100644 index 0000000..58b2372 --- /dev/null +++ b/internal/markdown/markdown.go @@ -0,0 +1,45 @@ +package markdown + +import ( + "bytes" + "html/template" + + "github.com/microcosm-cc/bluemonday" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" +) + +var ( + md goldmark.Markdown + policy *bluemonday.Policy +) + +func init() { + md = goldmark.New( + goldmark.WithExtensions( + extension.GFM, + highlighting.NewHighlighting( + highlighting.WithStyle("github"), + ), + ), + goldmark.WithRendererOptions( + html.WithHardWraps(), + ), + ) + + policy = bluemonday.UGCPolicy() + policy.AllowAttrs("class").OnElements("code", "pre", "span", "div") + policy.AllowAttrs("style").OnElements("span", "pre", "code") +} + +// RenderMarkdown converts markdown text to sanitized HTML. +func RenderMarkdown(input 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()) + return template.HTML(sanitized) +} diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index b23e9ed..5be95b5 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -5,6 +5,8 @@ import ( "html/template" "strings" "time" + + "github.com/mattnite/forgejo-tickets/internal/markdown" ) func templateFuncs() template.FuncMap { @@ -30,6 +32,35 @@ 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) + }, + "isOverdue": func(t *time.Time) bool { + if t == nil { + return false + } + return t.Before(time.Now()) + }, + "priorityBadge": func(priority string) template.HTML { + var class string + switch priority { + case "high": + class = "bg-red-100 text-red-800" + case "medium": + class = "bg-yellow-100 text-yellow-800" + case "low": + class = "bg-gray-100 text-gray-800" + default: + return "" + } + return template.HTML(fmt.Sprintf(`%s priority`, class, priority)) + }, + "formatDatePtr": func(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("Jan 2, 2006") + }, "truncate": func(s string, n int) string { if len(s) <= n { return s diff --git a/package-lock.json b/package-lock.json index 485b7bf..2a633db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,15 +4,60 @@ "requires": true, "packages": { "": { + "dependencies": { + "@tailwindcss/typography": "^0.5.19" + }, "devDependencies": { "tailwindcss": "^4.1.18" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" } } diff --git a/package.json b/package.json index 8601870..3d7383e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,8 @@ { "devDependencies": { "tailwindcss": "^4.1.18" + }, + "dependencies": { + "@tailwindcss/typography": "^0.5.19" } } diff --git a/web/static/css/input.css b/web/static/css/input.css index f1d8c73..3571db7 100644 --- a/web/static/css/input.css +++ b/web/static/css/input.css @@ -1 +1,2 @@ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; diff --git a/web/templates/pages/admin/tickets/detail.html b/web/templates/pages/admin/tickets/detail.html index 1998972..e5480a2 100644 --- a/web/templates/pages/admin/tickets/detail.html +++ b/web/templates/pages/admin/tickets/detail.html @@ -9,7 +9,10 @@
-

{{.Ticket.Title}}

+
+ {{if .Ticket.Pinned}}📌{{end}} +

{{.Ticket.Title}}

+

{{if .Repo}}{{.Repo.Name}} · {{end}} {{if .User}}by {{.User.Email}} · {{end}} @@ -17,13 +20,43 @@ {{if .Ticket.ForgejoIssueNumber}} · Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}}

- {{statusBadge (print .Ticket.Status)}} +
+ {{if .Ticket.Priority}}{{priorityBadge (print .Ticket.Priority)}}{{end}} + {{statusBadge (print .Ticket.Status)}} +
+
+ + +
+ {{if .Ticket.Assignees}} +
Assigned to: {{.Ticket.Assignees}}
+ {{end}} + {{if .Ticket.DueDate}} +
+ Due: {{formatDatePtr .Ticket.DueDate}} + {{if isOverdue .Ticket.DueDate}}(overdue){{end}} +
+ {{end}}
-

{{.Ticket.Description}}

+ {{renderMarkdown .Ticket.Description}}
+ + {{if .Ticket.Attachments}} +
+

Attachments

+
+ {{range .Ticket.Attachments}} + + {{.Name}} ({{.Size}} bytes) + + {{end}} +
+
+ {{end}} +
@@ -38,12 +71,33 @@
- + +{{if .RelatedIssues}} +
+

Related Issues

+ +
+{{end}} + +
-

Comments

- {{if .Comments}} +

Timeline

+ {{if .Timeline}}
- {{range .Comments}} + {{range .Timeline}} + {{if eq .Type "comment"}}
@@ -52,12 +106,29 @@
{{formatDateTime .CreatedAt}}
-

{{.Body}}

+
{{renderMarkdown .Body}}
+ {{if .Attachments}} +
+ {{range .Attachments}} + + {{.Name}} + + {{end}} +
+ {{end}}
+ {{else}} +
+ + {{.AuthorName}} + {{.EventText}} + · {{formatDateTime .CreatedAt}} +
+ {{end}} {{end}}
{{else}} -

No comments.

+

No activity.

{{end}}
{{end}} diff --git a/web/templates/pages/admin/tickets/list.html b/web/templates/pages/admin/tickets/list.html index 3daad12..e01f157 100644 --- a/web/templates/pages/admin/tickets/list.html +++ b/web/templates/pages/admin/tickets/list.html @@ -19,7 +19,9 @@ Title User Product + Priority Status + Due Created @@ -27,11 +29,16 @@ {{range .Tickets}} - {{.Title}} +
+ {{if .Pinned}}📌{{end}} + {{.Title}} +
{{.UserEmail}} {{.RepoName}} + {{if .Priority}}{{priorityBadge .Priority}}{{end}} {{statusBadge (print .Status)}} + {{if .DueDate}}{{formatDatePtr .DueDate}}{{end}} {{formatDate .CreatedAt}} {{end}} diff --git a/web/templates/pages/tickets/detail.html b/web/templates/pages/tickets/detail.html index c2c9cdd..55e704f 100644 --- a/web/templates/pages/tickets/detail.html +++ b/web/templates/pages/tickets/detail.html @@ -9,27 +9,79 @@
-

{{.Ticket.Title}}

+
+ {{if .Ticket.Pinned}}📌{{end}} +

{{.Ticket.Title}}

+

{{if .Repo}}{{.Repo.Name}} · {{end}} Created {{formatDate .Ticket.CreatedAt}}

- {{statusBadge (print .Ticket.Status)}} +
+ {{if .Ticket.Priority}}{{priorityBadge (print .Ticket.Priority)}}{{end}} + {{statusBadge (print .Ticket.Status)}} +
+
+ + +
+ {{if .Ticket.Assignees}} +
Assigned to: {{.Ticket.Assignees}}
+ {{end}} + {{if .Ticket.DueDate}} +
+ Due: {{formatDatePtr .Ticket.DueDate}} + {{if isOverdue .Ticket.DueDate}}(overdue){{end}} +
+ {{end}}
-

{{.Ticket.Description}}

+ {{renderMarkdown .Ticket.Description}}
+ + + {{if .Ticket.Attachments}} +
+

Attachments

+
+ {{range .Ticket.Attachments}} + + {{.Name}} ({{.Size}} bytes) + + {{end}} +
+
+ {{end}}
- -
-

Comments

+ +{{if .RelatedIssues}} +
+

Related Tickets

+
    + {{range .RelatedIssues}} +
  • + {{if .IsVisible}} + {{.DisplayText}} + {{else}} + {{.DisplayText}} + {{end}} +
  • + {{end}} +
+
+{{end}} - {{if .Comments}} + +
+

Activity

+ + {{if .Timeline}}
- {{range .Comments}} + {{range .Timeline}} + {{if eq .Type "comment"}}
@@ -38,27 +90,57 @@
{{formatDateTime .CreatedAt}}
-

{{.Body}}

+
{{renderMarkdown .Body}}
+ {{if .Attachments}} +
+ {{range .Attachments}} + + {{.Name}} + + {{end}} +
+ {{end}}
+ {{else}} +
+ + {{.AuthorName}} + {{.EventText}} + · {{formatDateTime .CreatedAt}} +
+ {{end}} {{end}}
{{else}} -

No comments yet.

+

No activity yet.

{{end}} - +
-
+
+
+ + +
+
{{end}} {{end}} diff --git a/web/templates/pages/tickets/list.html b/web/templates/pages/tickets/list.html index d326260..de1cf5f 100644 --- a/web/templates/pages/tickets/list.html +++ b/web/templates/pages/tickets/list.html @@ -14,7 +14,9 @@ Title Product + Priority Status + Due Created @@ -22,10 +24,15 @@ {{range .Tickets}} - {{.Title}} +
+ {{if .Pinned}}📌{{end}} + {{.Title}} +
{{.RepoName}} + {{if .Priority}}{{priorityBadge .Priority}}{{end}} {{statusBadge (print .Status)}} + {{if .DueDate}}{{formatDatePtr .DueDate}}{{end}} {{formatDate .CreatedAt}} {{end}} diff --git a/web/templates/pages/tickets/new.html b/web/templates/pages/tickets/new.html index a1c69da..f08944c 100644 --- a/web/templates/pages/tickets/new.html +++ b/web/templates/pages/tickets/new.html @@ -12,7 +12,7 @@ {{end}} {{end}} -
+
@@ -41,6 +41,14 @@ +

Markdown supported

+
+ +
+ + +

Optional. You can attach multiple files.