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