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 (
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
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/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=

View File

@ -7,8 +7,12 @@ import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
@ -30,6 +34,16 @@ func NewClient(baseURL, apiToken string) *Client {
}
}
// BaseURL returns the Forgejo base URL for proxy download purposes.
func (c *Client) BaseURL() string {
return c.baseURL
}
// APIToken returns the API token for authenticated proxy requests.
func (c *Client) APIToken() string {
return c.apiToken
}
type CreateIssueRequest struct {
Title string `json:"title"`
Body string `json:"body"`
@ -41,17 +55,30 @@ type EditIssueRequest struct {
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
type Attachment struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
DownloadURL string `json:"browser_download_url"`
Created time.Time `json:"created_at"`
}
type Issue struct {
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Labels []Label `json:"labels"`
CreatedAt time.Time `json:"created_at"`
Number int64 `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Labels []Label `json:"labels"`
Assignees []APIUser `json:"assignees"`
DueDate *time.Time `json:"due_date"`
PinOrder int `json:"pin_order"`
Assets []Attachment `json:"assets"`
CreatedAt time.Time `json:"created_at"`
}
type CreateCommentRequest struct {
@ -59,10 +86,11 @@ type CreateCommentRequest struct {
}
type Comment struct {
ID int64 `json:"id"`
Body string `json:"body"`
User APIUser `json:"user"`
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
Body string `json:"body"`
User APIUser `json:"user"`
Assets []Attachment `json:"assets"`
CreatedAt time.Time `json:"created_at"`
}
type APIUser struct {
@ -84,10 +112,44 @@ func (u APIUser) DisplayName() string {
}
type CommentView struct {
Body string
AuthorName string
IsTeam bool
CreatedAt time.Time
Body string
AuthorName string
IsTeam bool
CreatedAt time.Time
Attachments []Attachment
}
// TimelineEvent represents a single event in the issue timeline from Forgejo.
type TimelineEvent struct {
ID int64 `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
User APIUser `json:"user"`
Body string `json:"body"`
Label *Label `json:"label"`
Assignee *APIUser `json:"assignee"`
OldRef string `json:"old_ref"`
Assets []Attachment `json:"assets"`
}
// TimelineView is the template-friendly representation of a timeline event.
type TimelineView struct {
Type string // "comment", "status_change", "assignment", "label"
Body string
AuthorName string
IsTeam bool
EventText string
CreatedAt time.Time
Attachments []Attachment
}
// RelatedIssue represents a cross-referenced issue with visibility info.
type RelatedIssue struct {
Number int64
Title string
IsVisible bool
DisplayText string
TicketID string // customer-facing UUID if visible
}
// DeriveStatus maps Forgejo issue state + labels to app status.
@ -103,6 +165,21 @@ func DeriveStatus(issue *Issue) string {
return "open"
}
// DerivePriority returns "high", "medium", "low", or "" from issue labels.
func DerivePriority(issue *Issue) string {
for _, l := range issue.Labels {
switch l.Name {
case "priority/high":
return "high"
case "priority/medium":
return "medium"
case "priority/low":
return "low"
}
}
return ""
}
// StripCommentFooter removes the "---\n*...*" footer from bot-posted comments
// and returns the clean body and the attribution (email).
func StripCommentFooter(body string) (string, string) {
@ -124,9 +201,7 @@ func StripCommentFooter(body string) (string, string) {
}
// BuildCommentViews transforms Forgejo comments into view models,
// identifying customer vs team comments. A comment is considered a
// customer comment if it has a recognizable footer (primary check) OR
// if its author matches the bot login (fallback).
// identifying customer vs team comments.
func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
var views []CommentView
for _, c := range comments {
@ -139,23 +214,137 @@ func BuildCommentViews(comments []Comment, botLogin string) []CommentView {
authorName = "Customer"
}
views = append(views, CommentView{
Body: body,
AuthorName: authorName,
IsTeam: false,
CreatedAt: c.CreatedAt,
Body: body,
AuthorName: authorName,
IsTeam: false,
CreatedAt: c.CreatedAt,
Attachments: c.Assets,
})
} else {
views = append(views, CommentView{
Body: c.Body,
AuthorName: c.User.DisplayName(),
IsTeam: true,
CreatedAt: c.CreatedAt,
Body: c.Body,
AuthorName: c.User.DisplayName(),
IsTeam: true,
CreatedAt: c.CreatedAt,
Attachments: c.Assets,
})
}
}
return views
}
// BuildTimelineViews converts raw timeline events into template-friendly views.
func BuildTimelineViews(events []TimelineEvent, botLogin string, isAdmin bool) []TimelineView {
var views []TimelineView
for _, e := range events {
switch e.Type {
case "comment":
body, email := StripCommentFooter(e.Body)
isCustomer := email != "" || (botLogin != "" && e.User.Login == botLogin)
authorName := e.User.DisplayName()
if isCustomer {
if email != "" {
authorName = email
} else {
authorName = "Customer"
}
}
views = append(views, TimelineView{
Type: "comment",
Body: body,
AuthorName: authorName,
IsTeam: !isCustomer,
CreatedAt: e.CreatedAt,
Attachments: e.Assets,
})
case "close":
views = append(views, TimelineView{
Type: "status_change",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: "closed this ticket",
CreatedAt: e.CreatedAt,
})
case "reopen":
views = append(views, TimelineView{
Type: "status_change",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: "reopened this ticket",
CreatedAt: e.CreatedAt,
})
case "label":
if !isAdmin {
continue
}
action := "added label"
labelName := ""
if e.Label != nil {
labelName = e.Label.Name
}
views = append(views, TimelineView{
Type: "label",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: action + " " + labelName,
CreatedAt: e.CreatedAt,
})
case "assignees":
if !isAdmin {
continue
}
assigneeName := ""
if e.Assignee != nil {
assigneeName = e.Assignee.DisplayName()
}
views = append(views, TimelineView{
Type: "assignment",
AuthorName: e.User.DisplayName(),
IsTeam: true,
EventText: "assigned " + assigneeName,
CreatedAt: e.CreatedAt,
})
}
}
return views
}
var issueRefRegex = regexp.MustCompile(`(?:^|[^\w])#(\d+)\b`)
// ExtractIssueReferences finds all #N references in text.
func ExtractIssueReferences(text string) []int64 {
matches := issueRefRegex.FindAllStringSubmatch(text, -1)
seen := map[int64]bool{}
var refs []int64
for _, m := range matches {
n, err := strconv.ParseInt(m[1], 10, 64)
if err != nil || n == 0 {
continue
}
if !seen[n] {
seen[n] = true
refs = append(refs, n)
}
}
sort.Slice(refs, func(i, j int) bool { return refs[i] < refs[j] })
return refs
}
// SortIssuesPinnedFirst sorts issues with pinned (PinOrder > 0) first, then by CreatedAt desc.
func SortIssuesPinnedFirst(issues []Issue) {
sort.SliceStable(issues, func(i, j int) bool {
iPinned := issues[i].PinOrder > 0
jPinned := issues[j].PinOrder > 0
if iPinned != jPinned {
return iPinned
}
if iPinned && jPinned {
return issues[i].PinOrder < issues[j].PinOrder
}
return issues[i].CreatedAt.After(issues[j].CreatedAt)
})
}
func GenerateWebhookSecret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
@ -470,3 +659,90 @@ func (c *Client) GetAuthenticatedUser() (*APIUser, error) {
}
return &user, nil
}
// ListIssueTimeline fetches the timeline events for an issue.
func (c *Client) ListIssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/timeline", c.baseURL, owner, repo, number)
httpReq, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var events []TimelineEvent
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
return nil, err
}
return events, nil
}
// CreateIssueAttachment uploads a file to a Forgejo issue.
func (c *Client) CreateIssueAttachment(owner, repo string, number int64, filename string, reader io.Reader) (*Attachment, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/assets", c.baseURL, owner, repo, number)
return c.uploadAttachment(reqURL, filename, reader)
}
// CreateCommentAttachment uploads a file to a Forgejo comment.
func (c *Client) CreateCommentAttachment(owner, repo string, commentID int64, filename string, reader io.Reader) (*Attachment, error) {
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/comments/%d/assets", c.baseURL, owner, repo, commentID)
return c.uploadAttachment(reqURL, filename, reader)
}
func (c *Client) uploadAttachment(reqURL, filename string, reader io.Reader) (*Attachment, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile("attachment", filename)
if err != nil {
return nil, fmt.Errorf("create form file: %w", err)
}
if _, err := io.Copy(part, reader); err != nil {
return nil, fmt.Errorf("copy file data: %w", err)
}
writer.Close()
httpReq, err := http.NewRequest("POST", reqURL, &buf)
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
httpReq.Header.Set("Authorization", "token "+c.apiToken)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("forgejo API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody))
}
var attachment Attachment
if err := json.NewDecoder(resp.Body).Decode(&attachment); err != nil {
return nil, err
}
return &attachment, nil
}
// ProxyDownload fetches a file from the given Forgejo URL with authentication and streams it back.
func (c *Client) ProxyDownload(downloadURL string) (*http.Response, error) {
httpReq, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "token "+c.apiToken)
return c.httpClient.Do(httpReq)
}

View File

@ -1,7 +1,11 @@
package admin
import (
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@ -18,10 +22,13 @@ type ticketListRow struct {
ID uuid.UUID
Title string
Status string
Priority string
Pinned bool
RepoName string
RepoSlug string
UserEmail string
UserName string
DueDate *time.Time
CreatedAt interface{}
}
@ -117,15 +124,26 @@ func (h *TicketHandler) List(c *gin.Context) {
ID: m.ID,
Title: issue.Title,
Status: status,
Priority: forgejo.DerivePriority(issue),
Pinned: issue.PinOrder > 0,
RepoName: m.RepoName,
RepoSlug: m.RepoSlug,
UserEmail: m.UserEmail,
UserName: m.UserName,
DueDate: issue.DueDate,
CreatedAt: m.CreatedAt,
})
}
}
// Sort: pinned first, then by created date
sort.SliceStable(tickets, func(i, j int) bool {
if tickets[i].Pinned != tickets[j].Pinned {
return tickets[i].Pinned
}
return false
})
h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{
"Tickets": tickets,
"StatusFilter": statusFilter,
@ -151,7 +169,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
// Fetch issue and comments from Forgejo
// Fetch issue from Forgejo
issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil {
log.Error().Err(err).Msg("forgejo get issue error")
@ -159,28 +177,71 @@ func (h *TicketHandler) Detail(c *gin.Context) {
return
}
rawComments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
// Fetch timeline for full event view
timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil {
log.Error().Err(err).Msg("forgejo list comments error")
rawComments = nil
log.Error().Err(err).Msg("forgejo list timeline error")
timelineEvents = nil
}
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin)
timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true)
status := forgejo.DeriveStatus(issue)
// Build assignee names
var assigneeNames []string
for _, a := range issue.Assignees {
assigneeNames = append(assigneeNames, a.DisplayName())
}
// Extract related issue references (admin sees all as links)
allText := cleanBody
for _, tv := range timeline {
if tv.Type == "comment" {
allText += "\n" + tv.Body
}
}
refNumbers := forgejo.ExtractIssueReferences(allText)
var relatedIssues []forgejo.RelatedIssue
for _, refNum := range refNumbers {
if refNum == ticket.ForgejoIssueNumber {
continue
}
ri := forgejo.RelatedIssue{Number: refNum, IsVisible: true}
refIssue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, refNum)
if err == nil {
ri.Title = refIssue.Title
ri.DisplayText = refIssue.Title
} else {
ri.DisplayText = fmt.Sprintf("#%d", refNum)
}
// Check if there's a ticket mapping for admin link
var refTicket models.Ticket
if h.deps.DB.Where("repo_id = ? AND forgejo_issue_number = ?", ticket.RepoID, refNum).First(&refTicket).Error == nil {
ri.TicketID = refTicket.ID.String()
}
relatedIssues = append(relatedIssues, ri)
}
h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/detail", map[string]interface{}{
"Ticket": map[string]interface{}{
"ID": ticket.ID,
"Title": issue.Title,
"Description": cleanBody,
"Status": status,
"Priority": forgejo.DerivePriority(issue),
"Pinned": issue.PinOrder > 0,
"Assignees": strings.Join(assigneeNames, ", "),
"DueDate": issue.DueDate,
"Attachments": issue.Assets,
"ForgejoIssueNumber": ticket.ForgejoIssueNumber,
"CreatedAt": ticket.CreatedAt,
},
"User": user,
"Repo": repo,
"Comments": comments,
"User": user,
"Repo": repo,
"Timeline": timeline,
"RelatedIssues": relatedIssues,
})
}

View File

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

View File

@ -1,7 +1,12 @@
package public
import (
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@ -50,7 +55,10 @@ func (h *TicketHandler) List(c *gin.Context) {
ID uuid.UUID
Title string
Status string
Priority string
Pinned bool
RepoName string
DueDate *time.Time
CreatedAt interface{}
}
var views []ticketView
@ -83,13 +91,24 @@ func (h *TicketHandler) List(c *gin.Context) {
ID: t.ID,
Title: issue.Title,
Status: forgejo.DeriveStatus(issue),
Priority: forgejo.DerivePriority(issue),
Pinned: issue.PinOrder > 0,
RepoName: group.repo.Name,
DueDate: issue.DueDate,
CreatedAt: t.CreatedAt,
})
}
}
}
// Sort: pinned first, then by created date
sort.SliceStable(views, func(i, j int) bool {
if views[i].Pinned != views[j].Pinned {
return views[i].Pinned
}
return false // preserve existing order for non-pinned
})
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
"Tickets": views,
})
@ -177,6 +196,23 @@ func (h *TicketHandler) Create(c *gin.Context) {
return
}
// Upload attachments if any
form, _ := c.MultipartForm()
if form != nil && form.File["attachments"] != nil {
for _, fh := range form.File["attachments"] {
f, err := fh.Open()
if err != nil {
log.Error().Err(err).Str("file", fh.Filename).Msg("open uploaded file error")
continue
}
_, err = h.deps.ForgejoClient.CreateIssueAttachment(repo.ForgejoOwner, repo.ForgejoRepo, issue.Number, fh.Filename, f)
f.Close()
if err != nil {
log.Error().Err(err).Str("file", fh.Filename).Msg("upload attachment error")
}
}
}
// Create local ticket mapping
ticket := models.Ticket{
UserID: user.ID,
@ -216,7 +252,7 @@ func (h *TicketHandler) Detail(c *gin.Context) {
var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
// Fetch issue and comments from Forgejo
// Fetch issue from Forgejo
issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil {
log.Error().Err(err).Msg("forgejo get issue error")
@ -224,16 +260,66 @@ func (h *TicketHandler) Detail(c *gin.Context) {
return
}
rawComments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
// Fetch timeline instead of plain comments
timelineEvents, err := h.deps.ForgejoClient.ListIssueTimeline(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
if err != nil {
log.Error().Err(err).Msg("forgejo list comments error")
rawComments = nil // Show ticket without comments on error
log.Error().Err(err).Msg("forgejo list timeline error")
timelineEvents = nil
}
// Strip the "Submitted by" footer from the issue body
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin)
timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, false)
// Build assignee names
var assigneeNames []string
for _, a := range issue.Assignees {
assigneeNames = append(assigneeNames, a.DisplayName())
}
// Extract related issue references
allText := cleanBody
for _, tv := range timeline {
if tv.Type == "comment" {
allText += "\n" + tv.Body
}
}
refNumbers := forgejo.ExtractIssueReferences(allText)
// Check visibility of referenced issues
var relatedIssues []forgejo.RelatedIssue
if len(refNumbers) > 0 {
// Build a set of issue numbers this customer has tickets for
var userTickets []models.Ticket
h.deps.DB.Where("user_id = ? AND repo_id = ?", user.ID, ticket.RepoID).Find(&userTickets)
ticketByIssue := map[int64]models.Ticket{}
for _, ut := range userTickets {
ticketByIssue[ut.ForgejoIssueNumber] = ut
}
for _, refNum := range refNumbers {
if refNum == ticket.ForgejoIssueNumber {
continue // skip self-reference
}
ri := forgejo.RelatedIssue{Number: refNum}
if ut, ok := ticketByIssue[refNum]; ok {
// Customer has access to this issue
refIssue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, refNum)
if err == nil {
ri.Title = refIssue.Title
ri.IsVisible = true
ri.DisplayText = refIssue.Title
ri.TicketID = ut.ID.String()
} else {
ri.DisplayText = "[Internal Ticket]"
}
} else {
ri.DisplayText = "[Internal Ticket]"
}
relatedIssues = append(relatedIssues, ri)
}
}
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/detail", map[string]interface{}{
"Ticket": map[string]interface{}{
@ -241,10 +327,16 @@ func (h *TicketHandler) Detail(c *gin.Context) {
"Title": issue.Title,
"Description": cleanBody,
"Status": forgejo.DeriveStatus(issue),
"Priority": forgejo.DerivePriority(issue),
"Pinned": issue.PinOrder > 0,
"Assignees": strings.Join(assigneeNames, ", "),
"DueDate": issue.DueDate,
"Attachments": issue.Assets,
"CreatedAt": ticket.CreatedAt,
},
"Repo": repo,
"Comments": comments,
"Repo": repo,
"Timeline": timeline,
"RelatedIssues": relatedIssues,
})
}
@ -281,7 +373,7 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return
}
_, err = h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{
comment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{
Body: body + "\n\n---\n*Customer comment by: " + user.Email + "*",
})
if err != nil {
@ -290,5 +382,139 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return
}
// Upload attachments to comment if any
form, _ := c.MultipartForm()
if form != nil && form.File["attachments"] != nil {
for _, fh := range form.File["attachments"] {
f, err := fh.Open()
if err != nil {
log.Error().Err(err).Str("file", fh.Filename).Msg("open uploaded file error")
continue
}
_, err = h.deps.ForgejoClient.CreateCommentAttachment(repo.ForgejoOwner, repo.ForgejoRepo, comment.ID, fh.Filename, f)
f.Close()
if err != nil {
log.Error().Err(err).Str("file", fh.Filename).Msg("upload comment attachment error")
}
}
}
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
}
// DownloadAttachment proxies an attachment download from Forgejo.
func (h *TicketHandler) DownloadAttachment(c *gin.Context) {
user := auth.CurrentUser(c)
ticketID, err := uuid.Parse(c.Param("id"))
if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID")
return
}
var ticket models.Ticket
if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found")
return
}
if ticket.UserID != user.ID {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied")
return
}
attachmentID := c.Param("attachmentId")
filename := c.Param("filename")
var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
// Build the Forgejo download URL
downloadURL := h.deps.ForgejoClient.BaseURL() + "/attachments/" + attachmentID
resp, err := h.deps.ForgejoClient.ProxyDownload(downloadURL)
if err != nil {
log.Error().Err(err).Msg("proxy download error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadGateway, "Failed to download file")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
h.deps.Renderer.RenderError(c.Writer, c.Request, resp.StatusCode, "Failed to download file")
return
}
// Forward content type and set download headers
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Content-Type", contentType)
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
if cl := resp.Header.Get("Content-Length"); cl != "" {
c.Header("Content-Length", cl)
}
c.Status(http.StatusOK)
io.Copy(c.Writer, resp.Body)
}
// GetIssueAttachment proxies an issue-level attachment download using the Forgejo asset API.
func (h *TicketHandler) GetIssueAttachment(c *gin.Context) {
user := auth.CurrentUser(c)
ticketID, err := uuid.Parse(c.Param("id"))
if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid ticket ID")
return
}
var ticket models.Ticket
if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found")
return
}
if ticket.UserID != user.ID {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "Access denied")
return
}
attachmentID, err := strconv.ParseInt(c.Param("attachmentId"), 10, 64)
if err != nil {
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid attachment ID")
return
}
filename := c.Param("filename")
var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
// Use the Forgejo API to get the asset
assetURL := h.deps.ForgejoClient.BaseURL() + "/api/v1/repos/" + repo.ForgejoOwner + "/" + repo.ForgejoRepo + "/issues/" + strconv.FormatInt(ticket.ForgejoIssueNumber, 10) + "/assets/" + strconv.FormatInt(attachmentID, 10)
resp, err := h.deps.ForgejoClient.ProxyDownload(assetURL)
if err != nil {
log.Error().Err(err).Msg("proxy attachment download error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadGateway, "Failed to download file")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
h.deps.Renderer.RenderError(c.Writer, c.Request, resp.StatusCode, "Failed to download file")
return
}
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Content-Type", contentType)
c.Header("Content-Disposition", "attachment; filename=\""+filename+"\"")
if cl := resp.Header.Get("Content-Length"); cl != "" {
c.Header("Content-Length", cl)
}
c.Status(http.StatusOK)
io.Copy(c.Writer, resp.Body)
}

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"
"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

47
package-lock.json generated
View File

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

View File

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

View File

@ -1 +1,2 @@
@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="flex items-start justify-between">
<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">
{{if .Repo}}{{.Repo.Name}} &middot; {{end}}
{{if .User}}by {{.User.Email}} &middot; {{end}}
@ -17,13 +20,43 @@
{{if .Ticket.ForgejoIssueNumber}} &middot; Forgejo #{{.Ticket.ForgejoIssueNumber}}{{end}}
</p>
</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 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}}
<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}}
</div>
{{else}}
<p class="text-sm text-gray-500">No comments.</p>
<p class="text-sm text-gray-500">No activity.</p>
{{end}}
</div>
{{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">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">
<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 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}}

View File

@ -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>
<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">
{{if .Repo}}{{.Repo.Name}} &middot; {{end}}
Created {{formatDate .Ticket.CreatedAt}}
</p>
</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 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="/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>
<!-- Comments -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Comments</h2>
<!-- 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}}
{{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">
{{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}}
<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}}
</div>
{{else}}
<p class="text-sm text-gray-500">No comments yet.</p>
<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}}

View File

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

View File

@ -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">