521 lines
16 KiB
Go
521 lines
16 KiB
Go
package public
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/mattnite/forgejo-tickets/internal/auth"
|
|
"github.com/mattnite/forgejo-tickets/internal/forgejo"
|
|
"github.com/mattnite/forgejo-tickets/internal/models"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
type TicketHandler struct {
|
|
deps Dependencies
|
|
}
|
|
|
|
func (h *TicketHandler) List(c *gin.Context) {
|
|
user := auth.CurrentUser(c)
|
|
|
|
var tickets []models.Ticket
|
|
if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets).Error; err != nil {
|
|
log.Error().Err(err).Msg("list tickets error")
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets")
|
|
return
|
|
}
|
|
|
|
if len(tickets) == 0 {
|
|
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
|
|
"Tickets": []map[string]interface{}{},
|
|
})
|
|
return
|
|
}
|
|
|
|
// Group tickets by repo
|
|
type repoGroup struct {
|
|
repo models.Repo
|
|
tickets []models.Ticket
|
|
}
|
|
repoMap := map[uuid.UUID]*repoGroup{}
|
|
for _, t := range tickets {
|
|
if _, ok := repoMap[t.RepoID]; !ok {
|
|
repoMap[t.RepoID] = &repoGroup{repo: t.Repo}
|
|
}
|
|
repoMap[t.RepoID].tickets = append(repoMap[t.RepoID].tickets, t)
|
|
}
|
|
|
|
// Fetch issues from Forgejo per repo and build view models
|
|
type ticketView struct {
|
|
ID uuid.UUID
|
|
Title string
|
|
Status string
|
|
Priority string
|
|
Pinned bool
|
|
RepoName string
|
|
DueDate *time.Time
|
|
CreatedAt interface{}
|
|
}
|
|
var views []ticketView
|
|
|
|
for _, group := range repoMap {
|
|
issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "")
|
|
if err != nil {
|
|
log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error")
|
|
// Show tickets with unknown status on API failure
|
|
for _, t := range group.tickets {
|
|
views = append(views, ticketView{
|
|
ID: t.ID,
|
|
Title: "Unable to load",
|
|
Status: "open",
|
|
RepoName: group.repo.Name,
|
|
CreatedAt: t.CreatedAt,
|
|
})
|
|
}
|
|
continue
|
|
}
|
|
|
|
issueByNumber := map[int64]*forgejo.Issue{}
|
|
for i := range issues {
|
|
issueByNumber[issues[i].Number] = &issues[i]
|
|
}
|
|
|
|
for _, t := range group.tickets {
|
|
if issue, ok := issueByNumber[t.ForgejoIssueNumber]; ok {
|
|
views = append(views, ticketView{
|
|
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,
|
|
})
|
|
}
|
|
|
|
func (h *TicketHandler) NewForm(c *gin.Context) {
|
|
user := auth.CurrentUser(c)
|
|
|
|
var repos []models.Repo
|
|
if err := h.deps.DB.
|
|
Joins("JOIN user_repos ON user_repos.repo_id = repos.id").
|
|
Where("user_repos.user_id = ? AND repos.active = ?", user.ID, true).
|
|
Order("repos.name ASC").
|
|
Find(&repos).Error; err != nil {
|
|
log.Error().Err(err).Msg("list repos error")
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load products")
|
|
return
|
|
}
|
|
|
|
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{
|
|
"Repos": repos,
|
|
})
|
|
}
|
|
|
|
func (h *TicketHandler) Create(c *gin.Context) {
|
|
user := auth.CurrentUser(c)
|
|
|
|
repoID, err := uuid.Parse(c.PostForm("repo_id"))
|
|
if err != nil {
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusBadRequest, "Invalid product selection")
|
|
return
|
|
}
|
|
|
|
// Validate the user has access to this repo
|
|
var userRepo models.UserRepo
|
|
if err := h.deps.DB.Where("user_id = ? AND repo_id = ?", user.ID, repoID).First(&userRepo).Error; err != nil {
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusForbidden, "You do not have access to this project")
|
|
return
|
|
}
|
|
|
|
title := c.PostForm("title")
|
|
description := c.PostForm("description")
|
|
|
|
if title == "" || description == "" {
|
|
var repos []models.Repo
|
|
h.deps.DB.
|
|
Joins("JOIN user_repos ON user_repos.repo_id = repos.id").
|
|
Where("user_repos.user_id = ? AND repos.active = ?", user.ID, true).
|
|
Order("repos.name ASC").
|
|
Find(&repos)
|
|
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/new", map[string]interface{}{
|
|
"Repos": repos,
|
|
"Error": "Title and description are required",
|
|
"Title": title,
|
|
"Description": description,
|
|
"RepoID": repoID.String(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Look up the repo
|
|
var repo models.Repo
|
|
if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err != nil {
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to find product")
|
|
return
|
|
}
|
|
|
|
// Synchronous Forgejo issue creation
|
|
var labelIDs []int64
|
|
label, err := h.deps.ForgejoClient.GetLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer")
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("forgejo get/create label error")
|
|
} else {
|
|
labelIDs = append(labelIDs, label.ID)
|
|
}
|
|
|
|
issue, err := h.deps.ForgejoClient.CreateIssue(repo.ForgejoOwner, repo.ForgejoRepo, forgejo.CreateIssueRequest{
|
|
Title: title,
|
|
Body: description + "\n\n---\n*Submitted by: " + user.Email + "*",
|
|
Labels: labelIDs,
|
|
})
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("forgejo create issue error")
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Service temporarily unavailable. Please try again later.")
|
|
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,
|
|
RepoID: repoID,
|
|
ForgejoIssueNumber: issue.Number,
|
|
}
|
|
|
|
if err := h.deps.DB.Create(&ticket).Error; err != nil {
|
|
log.Error().Err(err).Msg("create ticket error")
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to create ticket")
|
|
return
|
|
}
|
|
|
|
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
|
|
}
|
|
|
|
func (h *TicketHandler) Detail(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
|
|
}
|
|
|
|
var repo models.Repo
|
|
h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
|
|
|
|
// 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")
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Unable to load ticket details. Please try again later.")
|
|
return
|
|
}
|
|
|
|
// 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 timeline error")
|
|
timelineEvents = nil
|
|
}
|
|
|
|
// Strip the "Submitted by" footer from the issue body
|
|
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
|
|
|
|
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{}{
|
|
"ID": ticket.ID,
|
|
"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,
|
|
"Timeline": timeline,
|
|
"RelatedIssues": relatedIssues,
|
|
})
|
|
}
|
|
|
|
func (h *TicketHandler) AddComment(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
|
|
}
|
|
|
|
body := c.PostForm("body")
|
|
if body == "" {
|
|
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
|
|
return
|
|
}
|
|
|
|
// Post comment directly to Forgejo
|
|
var repo models.Repo
|
|
if err := h.deps.DB.First(&repo, "id = ?", ticket.RepoID).Error; err != nil {
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to find product")
|
|
return
|
|
}
|
|
|
|
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 {
|
|
log.Error().Err(err).Msg("forgejo create comment error")
|
|
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Unable to add comment. Please try again later.")
|
|
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)
|
|
}
|