More forgejo features

This commit is contained in:
Matthew Knight 2026-02-14 12:59:32 -08:00
parent 387810676b
commit d23aa87f75
No known key found for this signature in database
16 changed files with 957 additions and 69 deletions

7
go.mod
View File

@ -19,9 +19,12 @@ require (
require ( require (
cloud.google.com/go/compute/metadata v0.3.0 // indirect 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 v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // 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/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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/quic-go/quic-go v0.54.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // 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 go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect golang.org/x/mod v0.25.0 // indirect

17
go.sum
View File

@ -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 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= 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 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= 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/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 h1:oTUjx0vyf2T+wkrx09Trsev1TE+/EbDAeHtSTbtC2eI=
github.com/gorilla/csrf v1.7.2/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 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 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= 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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c= goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c=

View File

@ -7,8 +7,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"regexp"
"sort"
"strconv"
"strings" "strings"
"time" "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 { type CreateIssueRequest struct {
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
@ -41,17 +55,30 @@ type EditIssueRequest struct {
} }
type Label struct { type Label struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` 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 { type Issue struct {
Number int64 `json:"number"` Number int64 `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"` Body string `json:"body"`
State string `json:"state"` State string `json:"state"`
Labels []Label `json:"labels"` Labels []Label `json:"labels"`
CreatedAt time.Time `json:"created_at"` 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 { type CreateCommentRequest struct {
@ -59,10 +86,11 @@ type CreateCommentRequest struct {
} }
type Comment struct { type Comment struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Body string `json:"body"` Body string `json:"body"`
User APIUser `json:"user"` User APIUser `json:"user"`
CreatedAt time.Time `json:"created_at"` Assets []Attachment `json:"assets"`
CreatedAt time.Time `json:"created_at"`
} }
type APIUser struct { type APIUser struct {
@ -84,10 +112,44 @@ func (u APIUser) DisplayName() string {
} }
type CommentView struct { type CommentView struct {
Body string Body string
AuthorName string AuthorName string
IsTeam bool IsTeam bool
CreatedAt time.Time 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. // DeriveStatus maps Forgejo issue state + labels to app status.
@ -103,6 +165,21 @@ func DeriveStatus(issue *Issue) string {
return "open" 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 // StripCommentFooter removes the "---\n*...*" footer from bot-posted comments
// and returns the clean body and the attribution (email). // and returns the clean body and the attribution (email).
func StripCommentFooter(body string) (string, string) { func StripCommentFooter(body string) (string, string) {
@ -124,9 +201,7 @@ func StripCommentFooter(body string) (string, string) {
} }
// BuildCommentViews transforms Forgejo comments into view models, // BuildCommentViews transforms Forgejo comments into view models,
// identifying customer vs team comments. A comment is considered a // identifying customer vs team comments.
// customer comment if it has a recognizable footer (primary check) OR
// if its author matches the bot login (fallback).
func BuildCommentViews(comments []Comment, botLogin string) []CommentView { func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
var views []CommentView var views []CommentView
for _, c := range comments { for _, c := range comments {
@ -139,23 +214,137 @@ func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
authorName = "Customer" authorName = "Customer"
} }
views = append(views, CommentView{ views = append(views, CommentView{
Body: body, Body: body,
AuthorName: authorName, AuthorName: authorName,
IsTeam: false, IsTeam: false,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
Attachments: c.Assets,
}) })
} else { } else {
views = append(views, CommentView{ views = append(views, CommentView{
Body: c.Body, Body: c.Body,
AuthorName: c.User.DisplayName(), AuthorName: c.User.DisplayName(),
IsTeam: true, IsTeam: true,
CreatedAt: c.CreatedAt, CreatedAt: c.CreatedAt,
Attachments: c.Assets,
}) })
} }
} }
return views 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) { func GenerateWebhookSecret() (string, error) {
b := make([]byte, 32) b := make([]byte, 32)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
@ -470,3 +659,90 @@ func (c *Client) GetAuthenticatedUser() (*APIUser, error) {
} }
return &user, nil 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)
}

View File

@ -1,7 +1,11 @@
package admin package admin
import ( import (
"fmt"
"net/http" "net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@ -18,10 +22,13 @@ type ticketListRow struct {
ID uuid.UUID ID uuid.UUID
Title string Title string
Status string Status string
Priority string
Pinned bool
RepoName string RepoName string
RepoSlug string RepoSlug string
UserEmail string UserEmail string
UserName string UserName string
DueDate *time.Time
CreatedAt interface{} CreatedAt interface{}
} }
@ -117,15 +124,26 @@ func (h *TicketHandler) List(c *gin.Context) {
ID: m.ID, ID: m.ID,
Title: issue.Title, Title: issue.Title,
Status: status, Status: status,
Priority: forgejo.DerivePriority(issue),
Pinned: issue.PinOrder > 0,
RepoName: m.RepoName, RepoName: m.RepoName,
RepoSlug: m.RepoSlug, RepoSlug: m.RepoSlug,
UserEmail: m.UserEmail, UserEmail: m.UserEmail,
UserName: m.UserName, UserName: m.UserName,
DueDate: issue.DueDate,
CreatedAt: m.CreatedAt, 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{
"Tickets": tickets, "Tickets": tickets,
"StatusFilter": statusFilter, "StatusFilter": statusFilter,
@ -151,7 +169,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
var repo models.Repo var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID) 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) issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil { if err != nil {
log.Error().Err(err).Msg("forgejo get issue error") log.Error().Err(err).Msg("forgejo get issue error")
@ -159,28 +177,71 @@ func (h *TicketHandler) Detail(c *gin.Context) {
return 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 { if err != nil {
log.Error().Err(err).Msg("forgejo list comments error") log.Error().Err(err).Msg("forgejo list timeline error")
rawComments = nil timelineEvents = nil
} }
cleanBody, _ := forgejo.StripCommentFooter(issue.Body) 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) 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/detail", map[string]interface{}{
"Ticket": map[string]interface{}{ "Ticket": map[string]interface{}{
"ID": ticket.ID, "ID": ticket.ID,
"Title": issue.Title, "Title": issue.Title,
"Description": cleanBody, "Description": cleanBody,
"Status": status, "Status": status,
"Priority": forgejo.DerivePriority(issue),
"Pinned": issue.PinOrder > 0,
"Assignees": strings.Join(assigneeNames, ", "),
"DueDate": issue.DueDate,
"Attachments": issue.Assets,
"ForgejoIssueNumber": ticket.ForgejoIssueNumber, "ForgejoIssueNumber": ticket.ForgejoIssueNumber,
"CreatedAt": ticket.CreatedAt, "CreatedAt": ticket.CreatedAt,
}, },
"User": user, "User": user,
"Repo": repo, "Repo": repo,
"Comments": comments, "Timeline": timeline,
"RelatedIssues": relatedIssues,
}) })
} }

View File

@ -77,6 +77,7 @@ func NewRouter(deps Dependencies) *gin.Engine {
authenticated.POST("/tickets", ticketHandler.Create) authenticated.POST("/tickets", ticketHandler.Create)
authenticated.GET("/tickets/:id", ticketHandler.Detail) authenticated.GET("/tickets/:id", ticketHandler.Detail)
authenticated.POST("/tickets/:id/comments", ticketHandler.AddComment) authenticated.POST("/tickets/:id/comments", ticketHandler.AddComment)
authenticated.GET("/tickets/:id/attachments/:attachmentId/*filename", ticketHandler.DownloadAttachment)
} }
} }

View File

@ -1,7 +1,12 @@
package public package public
import ( import (
"io"
"net/http" "net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
@ -50,7 +55,10 @@ func (h *TicketHandler) List(c *gin.Context) {
ID uuid.UUID ID uuid.UUID
Title string Title string
Status string Status string
Priority string
Pinned bool
RepoName string RepoName string
DueDate *time.Time
CreatedAt interface{} CreatedAt interface{}
} }
var views []ticketView var views []ticketView
@ -83,13 +91,24 @@ func (h *TicketHandler) List(c *gin.Context) {
ID: t.ID, ID: t.ID,
Title: issue.Title, Title: issue.Title,
Status: forgejo.DeriveStatus(issue), Status: forgejo.DeriveStatus(issue),
Priority: forgejo.DerivePriority(issue),
Pinned: issue.PinOrder > 0,
RepoName: group.repo.Name, RepoName: group.repo.Name,
DueDate: issue.DueDate,
CreatedAt: t.CreatedAt, 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
"Tickets": views, "Tickets": views,
}) })
@ -177,6 +196,23 @@ func (h *TicketHandler) Create(c *gin.Context) {
return 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 // Create local ticket mapping
ticket := models.Ticket{ ticket := models.Ticket{
UserID: user.ID, UserID: user.ID,
@ -216,7 +252,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
var repo models.Repo var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID) 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) issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil { if err != nil {
log.Error().Err(err).Msg("forgejo get issue error") log.Error().Err(err).Msg("forgejo get issue error")
@ -224,16 +260,66 @@ func (h *TicketHandler) Detail(c *gin.Context) {
return 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 { if err != nil {
log.Error().Err(err).Msg("forgejo list comments error") log.Error().Err(err).Msg("forgejo list timeline error")
rawComments = nil // Show ticket without comments on error timelineEvents = nil
} }
// Strip the "Submitted by" footer from the issue body // Strip the "Submitted by" footer from the issue body
cleanBody, _ := forgejo.StripCommentFooter(issue.Body) cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "tickets/detail", map[string]interface{}{
"Ticket": map[string]interface{}{ "Ticket": map[string]interface{}{
@ -241,10 +327,16 @@ func (h *TicketHandler) Detail(c *gin.Context) {
"Title": issue.Title, "Title": issue.Title,
"Description": cleanBody, "Description": cleanBody,
"Status": forgejo.DeriveStatus(issue), "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, "CreatedAt": ticket.CreatedAt,
}, },
"Repo": repo, "Repo": repo,
"Comments": comments, "Timeline": timeline,
"RelatedIssues": relatedIssues,
}) })
} }
@ -281,7 +373,7 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return 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 + "*", Body: body + "\n\n---\n*Customer comment by: " + user.Email + "*",
}) })
if err != nil { if err != nil {
@ -290,5 +382,139 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return 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()) 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)
}

View File

@ -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)
}

View File

@ -5,6 +5,8 @@ import (
"html/template" "html/template"
"strings" "strings"
"time" "time"
"github.com/mattnite/forgejo-tickets/internal/markdown"
) )
func templateFuncs() template.FuncMap { func templateFuncs() template.FuncMap {
@ -30,6 +32,35 @@ func templateFuncs() template.FuncMap {
label := strings.ReplaceAll(status, "_", " ") label := strings.ReplaceAll(status, "_", " ")
return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, class, label)) return template.HTML(fmt.Sprintf(`<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium %s">%s</span>`, class, label))
}, },
"renderMarkdown": func(input string) template.HTML {
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 { "truncate": func(s string, n int) string {
if len(s) <= n { if len(s) <= n {
return s return s

47
package-lock.json generated
View File

@ -4,15 +4,60 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": {
"@tailwindcss/typography": "^0.5.19"
},
"devDependencies": { "devDependencies": {
"tailwindcss": "^4.1.18" "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": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", "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" "license": "MIT"
} }
} }

View File

@ -1,5 +1,8 @@
{ {
"devDependencies": { "devDependencies": {
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.19"
} }
} }

View File

@ -1 +1,2 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography";

View File

@ -9,7 +9,10 @@
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1> <div class="flex items-center gap-2">
{{if .Ticket.Pinned}}<span class="text-gray-400" title="Pinned">&#128204;</span>{{end}}
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1>
</div>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{{if .Repo}}{{.Repo.Name}} &middot; {{end}} {{if .Repo}}{{.Repo.Name}} &middot; {{end}}
{{if .User}}by {{.User.Email}} &middot; {{end}} {{if .User}}by {{.User.Email}} &middot; {{end}}
@ -17,13 +20,43 @@
{{if .Ticket.ForgejoIssueNumber}} &middot; Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}} {{if .Ticket.ForgejoIssueNumber}} &middot; Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}}
</p> </p>
</div> </div>
{{statusBadge (print .Ticket.Status)}} <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>
<div class="mt-6 prose prose-sm max-w-none text-gray-700"> <div class="mt-6 prose prose-sm max-w-none text-gray-700">
<p class="whitespace-pre-wrap">{{.Ticket.Description}}</p> {{renderMarkdown .Ticket.Description}}
</div> </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 --> <!-- Status Update -->
<div class="mt-6 pt-4 border-t border-gray-200"> <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"> <form method="POST" action="/tickets/{{.Ticket.ID}}/status" class="flex items-center gap-3">
@ -38,12 +71,33 @@
</div> </div>
</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"> <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">Timeline</h2>
{{if .Comments}} {{if .Timeline}}
<div class="space-y-4"> <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="{{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 justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -52,12 +106,29 @@
</div> </div>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span> <span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div> </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> </div>
{{else}}
<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">&middot; {{formatDateTime .CreatedAt}}</span>
</div>
{{end}}
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<p class="text-sm text-gray-500">No comments.</p> <p class="text-sm text-gray-500">No activity.</p>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}

View File

@ -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">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">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">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">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> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr> </tr>
</thead> </thead>
@ -27,11 +29,16 @@
{{range .Tickets}} {{range .Tickets}}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a> <div class="flex items-center gap-1">
{{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">&#128204;</span>{{end}}
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</div>
</td> </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">{{.UserEmail}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</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">{{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> <td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@ -9,27 +9,79 @@
<div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200"> <div class="bg-white p-6 rounded-lg shadow ring-1 ring-gray-200">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1> <div class="flex items-center gap-2">
{{if .Ticket.Pinned}}<span class="text-gray-400" title="Pinned">&#128204;</span>{{end}}
<h1 class="text-xl font-bold text-gray-900">{{.Ticket.Title}}</h1>
</div>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{{if .Repo}}{{.Repo.Name}} &middot; {{end}} {{if .Repo}}{{.Repo.Name}} &middot; {{end}}
Created {{formatDate .Ticket.CreatedAt}} Created {{formatDate .Ticket.CreatedAt}}
</p> </p>
</div> </div>
{{statusBadge (print .Ticket.Status)}} <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>
<div class="mt-6 prose prose-sm max-w-none text-gray-700"> <div class="mt-6 prose prose-sm max-w-none text-gray-700">
<p class="whitespace-pre-wrap">{{.Ticket.Description}}</p> {{renderMarkdown .Ticket.Description}}
</div> </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="/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> </div>
<!-- Comments --> <!-- Related Tickets -->
<div class="mt-8"> {{if .RelatedIssues}}
<h2 class="text-lg font-semibold text-gray-900 mb-4">Comments</h2> <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}}
{{if .Comments}} <!-- Timeline -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Activity</h2>
{{if .Timeline}}
<div class="space-y-4"> <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="{{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 justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -38,27 +90,57 @@
</div> </div>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span> <span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div> </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> </div>
{{else}}
<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">&middot; {{formatDateTime .CreatedAt}}</span>
</div>
{{end}}
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<p class="text-sm text-gray-500">No comments yet.</p> <p class="text-sm text-gray-500">No activity yet.</p>
{{end}} {{end}}
<!-- Add Comment --> <!-- 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}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{$.CSRFToken}}">
<div> <div>
<label for="body" class="sr-only">Add a comment</label> <label for="body" class="sr-only">Add a comment</label>
<textarea name="body" id="body" rows="3" required <textarea name="body" id="body" rows="3" 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> 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>
<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> <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> </div>
</form> </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> </div>
{{end}} {{end}}
{{end}} {{end}}

View File

@ -14,7 +14,9 @@
<tr> <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">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">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">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> <th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
</tr> </tr>
</thead> </thead>
@ -22,10 +24,15 @@
{{range .Tickets}} {{range .Tickets}}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a> <div class="flex items-center gap-1">
{{if .Pinned}}<span class="text-gray-400 text-xs" title="Pinned">&#128204;</span>{{end}}
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</div>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</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">{{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> <td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr> </tr>
{{end}} {{end}}

View File

@ -12,7 +12,7 @@
{{end}} {{end}}
{{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}}"> <input type="hidden" name="gorilla.csrf.Token" value="{{.CSRFToken}}">
<div> <div>
@ -41,6 +41,14 @@
<textarea name="description" id="description" rows="6" required <textarea name="description" id="description" rows="6" required
placeholder="Describe the issue in detail. Include steps to reproduce, expected behavior, and actual behavior." 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> 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>
<div class="flex justify-end gap-3"> <div class="flex justify-end gap-3">