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