More forgejo features
This commit is contained in:
parent
387810676b
commit
d23aa87f75
7
go.mod
7
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
|
||||
|
|
|
|||
17
go.sum
17
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=
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
@ -43,6 +57,15 @@ type EditIssueRequest struct {
|
|||
type Label struct {
|
||||
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 {
|
||||
|
|
@ -51,6 +74,10 @@ type Issue struct {
|
|||
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"`
|
||||
}
|
||||
|
||||
|
|
@ -62,6 +89,7 @@ type Comment struct {
|
|||
ID int64 `json:"id"`
|
||||
Body string `json:"body"`
|
||||
User APIUser `json:"user"`
|
||||
Assets []Attachment `json:"assets"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
|
|
@ -88,6 +116,40 @@ type CommentView struct {
|
|||
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 {
|
||||
|
|
@ -143,6 +218,7 @@ func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
|
|||
AuthorName: authorName,
|
||||
IsTeam: false,
|
||||
CreatedAt: c.CreatedAt,
|
||||
Attachments: c.Assets,
|
||||
})
|
||||
} else {
|
||||
views = append(views, CommentView{
|
||||
|
|
@ -150,12 +226,125 @@ func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"Timeline": timeline,
|
||||
"RelatedIssues": relatedIssues,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(`<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)
|
||||
},
|
||||
"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(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s priority</span>`, 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^4.1.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@
|
|||
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Ticket.Pinned}}<span class="text-gray-400" title="Pinned">📌</span>{{end}}
|
||||
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{if .Repo}}{{.Repo.Name}} · {{end}}
|
||||
{{if .User}}by {{.User.Email}} · {{end}}
|
||||
|
|
@ -17,13 +20,43 @@
|
|||
{{if .Ticket.ForgejoIssueNumber}} · Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Ticket.Priority}}{{priorityBadge (print .Ticket.Priority)}}{{end}}
|
||||
{{statusBadge (print .Ticket.Status)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="mt-4 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600">
|
||||
{{if .Ticket.Assignees}}
|
||||
<div>Assigned to: <span class="font-medium text-gray-900">{{.Ticket.Assignees}}</span></div>
|
||||
{{end}}
|
||||
{{if .Ticket.DueDate}}
|
||||
<div>
|
||||
Due: <span class="font-medium {{if isOverdue .Ticket.DueDate}}text-red-600{{else}}text-gray-900{{end}}">{{formatDatePtr .Ticket.DueDate}}</span>
|
||||
{{if isOverdue .Ticket.DueDate}}<span class="text-xs text-red-600 font-medium">(overdue)</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
||||
<p class="whitespace-pre-wrap">{{.Ticket.Description}}</p>
|
||||
{{renderMarkdown .Ticket.Description}}
|
||||
</div>
|
||||
|
||||
<!-- Issue Attachments -->
|
||||
{{if .Ticket.Attachments}}
|
||||
<div class="mt-4 border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Attachments</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Ticket.Attachments}}
|
||||
<a href="{{.DownloadURL}}" target="_blank" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
||||
{{.Name}} <span class="text-gray-400">({{.Size}} bytes)</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Status Update -->
|
||||
<div class="mt-6 pt-4 border-t border-gray-200">
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3">
|
||||
|
|
@ -38,12 +71,33 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<!-- Related Issues -->
|
||||
{{if .RelatedIssues}}
|
||||
<div class="mt-6 bg-white p-4 rounded-lg shadow ring-1 ring-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-2">Related Issues</h2>
|
||||
<ul class="space-y-1">
|
||||
{{range .RelatedIssues}}
|
||||
<li class="text-sm">
|
||||
{{if .TicketID}}
|
||||
<a href="/tickets/{{.TicketID}}" class="text-blue-600 hover:text-blue-500">{{.DisplayText}}</a>
|
||||
<span class="text-gray-400">(#{{.Number}})</span>
|
||||
{{else}}
|
||||
<span class="text-gray-600">{{.DisplayText}}</span>
|
||||
<span class="text-gray-400">(#{{.Number}} - no ticket mapping)</span>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Comments</h2>
|
||||
{{if .Comments}}
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Timeline</h2>
|
||||
{{if .Timeline}}
|
||||
<div class="space-y-4">
|
||||
{{range .Comments}}
|
||||
{{range .Timeline}}
|
||||
{{if eq .Type "comment"}}
|
||||
<div class="{{if .IsTeam}}bg-blue-50 ring-blue-200{{else}}bg-white ring-gray-200{{end}} p-4 rounded-lg shadow ring-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -52,12 +106,29 @@
|
|||
</div>
|
||||
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>
|
||||
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body}}</div>
|
||||
{{if .Attachments}}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{{range .Attachments}}
|
||||
<a href="{{.DownloadURL}}" target="_blank" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
||||
{{.Name}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No comments.</p>
|
||||
<div class="flex items-center gap-2 py-2 px-4 text-sm text-gray-500">
|
||||
<span class="inline-block w-2 h-2 rounded-full {{if eq .Type "status_change"}}bg-yellow-400{{else if eq .Type "label"}}bg-purple-400{{else if eq .Type "assignment"}}bg-green-400{{else}}bg-gray-300{{end}}"></span>
|
||||
<span class="font-medium text-gray-700">{{.AuthorName}}</span>
|
||||
<span>{{.EventText}}</span>
|
||||
<span class="text-xs">· {{formatDateTime .CreatedAt}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No activity.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@
|
|||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">User</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Product</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Priority</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Due</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -27,11 +29,16 @@
|
|||
{{range .Tickets}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1">
|
||||
{{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">📌</span>{{end}}
|
||||
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.UserEmail}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
|
||||
<td class="px-4 py-3">{{if .Priority}}{{priorityBadge .Priority}}{{end}}</td>
|
||||
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
|
||||
<td class="px-4 py-3 text-sm {{if isOverdue .DueDate}}text-red-600 font-medium{{else}}text-gray-500{{end}}">{{if .DueDate}}{{formatDatePtr .DueDate}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -9,27 +9,79 @@
|
|||
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Ticket.Pinned}}<span class="text-gray-400" title="Pinned">📌</span>{{end}}
|
||||
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{if .Repo}}{{.Repo.Name}} · {{end}}
|
||||
Created {{formatDate .Ticket.CreatedAt}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{{if .Ticket.Priority}}{{priorityBadge (print .Ticket.Priority)}}{{end}}
|
||||
{{statusBadge (print .Ticket.Status)}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="mt-4 flex flex-wrap gap-x-6 gap-y-1 text-sm text-gray-600">
|
||||
{{if .Ticket.Assignees}}
|
||||
<div>Assigned to: <span class="font-medium text-gray-900">{{.Ticket.Assignees}}</span></div>
|
||||
{{end}}
|
||||
{{if .Ticket.DueDate}}
|
||||
<div>
|
||||
Due: <span class="font-medium {{if isOverdue .Ticket.DueDate}}text-red-600{{else}}text-gray-900{{end}}">{{formatDatePtr .Ticket.DueDate}}</span>
|
||||
{{if isOverdue .Ticket.DueDate}}<span class="text-xs text-red-600 font-medium">(overdue)</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 prose prose-sm max-w-none text-gray-700">
|
||||
<p class="whitespace-pre-wrap">{{.Ticket.Description}}</p>
|
||||
</div>
|
||||
{{renderMarkdown .Ticket.Description}}
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<!-- Issue Attachments -->
|
||||
{{if .Ticket.Attachments}}
|
||||
<div class="mt-4 border-t border-gray-200 pt-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Attachments</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{{range .Ticket.Attachments}}
|
||||
<a href="/tickets/{{$.Data.Ticket.ID}}/attachments/{{.ID}}/{{.Name}}" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
||||
{{.Name}} <span class="text-gray-400">({{.Size}} bytes)</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Related Tickets -->
|
||||
{{if .RelatedIssues}}
|
||||
<div class="mt-6 bg-white p-4 rounded-lg shadow ring-1 ring-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-900 mb-2">Related Tickets</h2>
|
||||
<ul class="space-y-1">
|
||||
{{range .RelatedIssues}}
|
||||
<li class="text-sm">
|
||||
{{if .IsVisible}}
|
||||
<a href="/tickets/{{.TicketID}}" class="text-blue-600 hover:text-blue-500">{{.DisplayText}}</a>
|
||||
{{else}}
|
||||
<span class="text-gray-400">{{.DisplayText}}</span>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Comments</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Activity</h2>
|
||||
|
||||
{{if .Comments}}
|
||||
{{if .Timeline}}
|
||||
<div class="space-y-4">
|
||||
{{range .Comments}}
|
||||
{{range .Timeline}}
|
||||
{{if eq .Type "comment"}}
|
||||
<div class="{{if .IsTeam}}bg-blue-50 ring-blue-200{{else}}bg-white ring-gray-200{{end}} p-4 rounded-lg shadow ring-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -38,27 +90,57 @@
|
|||
</div>
|
||||
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>
|
||||
<div class="text-sm text-gray-700 prose prose-sm max-w-none">{{renderMarkdown .Body}}</div>
|
||||
{{if .Attachments}}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{{range .Attachments}}
|
||||
<a href="/tickets/{{$.Data.Ticket.ID}}/attachments/{{.ID}}/{{.Name}}" class="inline-flex items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
||||
{{.Name}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No comments yet.</p>
|
||||
<div class="flex items-center gap-2 py-2 px-4 text-sm text-gray-500">
|
||||
<span class="inline-block w-2 h-2 rounded-full {{if eq .Type "status_change"}}bg-yellow-400{{else}}bg-gray-300{{end}}"></span>
|
||||
<span class="font-medium text-gray-700">{{.AuthorName}}</span>
|
||||
<span>{{.EventText}}</span>
|
||||
<span class="text-xs">· {{formatDateTime .CreatedAt}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-sm text-gray-500">No activity yet.</p>
|
||||
{{end}}
|
||||
|
||||
<!-- Add Comment -->
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/comments" class="mt-6">
|
||||
<form method="POST" action="/tickets/{{.Ticket.ID}}/comments" enctype="multipart/form-data" class="mt-6">
|
||||
<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
|
||||
placeholder="Add a comment..."
|
||||
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>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<div class="mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
<label for="attachments" class="text-sm text-gray-500 cursor-pointer hover:text-gray-700">
|
||||
Attach files
|
||||
<input type="file" name="attachments" id="attachments" multiple class="hidden">
|
||||
</label>
|
||||
<span id="file-count" class="text-xs text-gray-400 ml-2"></span>
|
||||
</div>
|
||||
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-500">Add Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('attachments').addEventListener('change', function() {
|
||||
var count = this.files.length;
|
||||
document.getElementById('file-count').textContent = count > 0 ? count + ' file(s) selected' : '';
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Title</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Product</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Priority</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Due</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -22,10 +24,15 @@
|
|||
{{range .Tickets}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1">
|
||||
{{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">📌</span>{{end}}
|
||||
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
|
||||
<td class="px-4 py-3">{{if .Priority}}{{priorityBadge .Priority}}{{end}}</td>
|
||||
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
|
||||
<td class="px-4 py-3 text-sm {{if isOverdue .DueDate}}text-red-600 font-medium{{else}}text-gray-500{{end}}">{{if .DueDate}}{{formatDatePtr .DueDate}}{{end}}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/tickets" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
|
||||
<form method="POST" action="/tickets" enctype="multipart/form-data" class="space-y-6 bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
|
||||
<input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
|
||||
|
||||
<div>
|
||||
|
|
@ -41,6 +41,14 @@
|
|||
<textarea name="description" id="description" rows="6" required
|
||||
placeholder="Describe the issue in detail. Include steps to reproduce, expected behavior, and actual behavior."
|
||||
class="mt-1 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">{{with .Data}}{{.Description}}{{end}}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">Markdown supported</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="attachments" class="block text-sm font-medium text-gray-700">Attachments</label>
|
||||
<input type="file" name="attachments" id="attachments" multiple
|
||||
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
<p class="mt-1 text-xs text-gray-500">Optional. You can attach multiple files.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
|
|
|
|||
Loading…
Reference in New Issue