forgejo-tickets/internal/handlers/public/tickets.go

295 lines
8.9 KiB
Go

package public
import (
"net/http"
"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
RepoName string
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),
RepoName: group.repo.Name,
CreatedAt: t.CreatedAt,
})
}
}
}
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
}
// 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 and comments 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
}
rawComments, err := h.deps.ForgejoClient.ListIssueComments(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
}
// Strip the "Submitted by" footer from the issue body
cleanBody, _ := forgejo.StripCommentFooter(issue.Body)
comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin)
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),
"CreatedAt": ticket.CreatedAt,
},
"Repo": repo,
"Comments": comments,
})
}
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
}
_, 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
}
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
}