package admin import ( "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) type TicketHandler struct { deps Dependencies } type ticketListRow struct { ID uuid.UUID Title string Status string RepoName string RepoSlug string UserEmail string UserName string CreatedAt interface{} } func (h *TicketHandler) List(c *gin.Context) { statusFilter := c.Query("status") // Load all ticket mappings with User and Repo joins type ticketMapping struct { models.Ticket RepoName string RepoSlug string ForgejoOwner string ForgejoRepo string UserEmail string UserName string } var mappings []ticketMapping if err := h.deps.DB.Table("tickets"). Select("tickets.*, repos.name as repo_name, repos.slug as repo_slug, repos.forgejo_owner, repos.forgejo_repo, users.email as user_email, users.name as user_name"). Joins("JOIN repos ON repos.id = tickets.repo_id"). Joins("JOIN users ON users.id = tickets.user_id"). Order("tickets.created_at DESC"). Limit(100). Scan(&mappings).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 } // Group by repo for Forgejo API calls type repoGroup struct { owner string repo string mappings []ticketMapping } repoGroups := map[string]*repoGroup{} for _, m := range mappings { key := m.ForgejoOwner + "/" + m.ForgejoRepo if _, ok := repoGroups[key]; !ok { repoGroups[key] = &repoGroup{owner: m.ForgejoOwner, repo: m.ForgejoRepo} } repoGroups[key].mappings = append(repoGroups[key].mappings, m) } // Determine Forgejo API state filter apiState := "all" if statusFilter == "closed" { apiState = "closed" } else if statusFilter == "open" || statusFilter == "in_progress" { apiState = "open" } // Fetch issues from Forgejo per repo and match var tickets []ticketListRow for _, group := range repoGroups { issues, err := h.deps.ForgejoClient.ListIssues(group.owner, group.repo, apiState, "") if err != nil { log.Error().Err(err).Str("repo", group.owner+"/"+group.repo).Msg("forgejo list issues error") for _, m := range group.mappings { tickets = append(tickets, ticketListRow{ ID: m.ID, Title: "Unable to load", Status: "open", RepoName: m.RepoName, RepoSlug: m.RepoSlug, UserEmail: m.UserEmail, UserName: m.UserName, CreatedAt: m.CreatedAt, }) } continue } issueByNumber := map[int64]*forgejo.Issue{} for i := range issues { issueByNumber[issues[i].Number] = &issues[i] } for _, m := range group.mappings { issue, ok := issueByNumber[m.ForgejoIssueNumber] if !ok { continue } status := forgejo.DeriveStatus(issue) // Apply client-side status filter if statusFilter != "" && status != statusFilter { continue } tickets = append(tickets, ticketListRow{ ID: m.ID, Title: issue.Title, Status: status, RepoName: m.RepoName, RepoSlug: m.RepoSlug, UserEmail: m.UserEmail, UserName: m.UserName, CreatedAt: m.CreatedAt, }) } } h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{ "Tickets": tickets, "StatusFilter": statusFilter, }) } func (h *TicketHandler) Detail(c *gin.Context) { 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 } var user models.User h.deps.DB.First(&user, "id = ?", ticket.UserID) 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") 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 } cleanBody, _ := forgejo.StripCommentFooter(issue.Body) comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin) status := forgejo.DeriveStatus(issue) 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, "ForgejoIssueNumber": ticket.ForgejoIssueNumber, "CreatedAt": ticket.CreatedAt, }, "User": user, "Repo": repo, "Comments": comments, }) } func (h *TicketHandler) UpdateStatus(c *gin.Context) { 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 } 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 } newStatus := c.PostForm("status") // Get or create the in_progress label inProgressLabel, err := h.deps.ForgejoClient.GetLabel(repo.ForgejoOwner, repo.ForgejoRepo, "in_progress") if err != nil { log.Error().Err(err).Msg("forgejo get/create in_progress label error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Service temporarily unavailable") return } switch newStatus { case "open": if err := h.deps.ForgejoClient.EditIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.EditIssueRequest{State: "open"}); err != nil { log.Error().Err(err).Msg("forgejo edit issue error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Failed to update status") return } h.deps.ForgejoClient.RemoveLabel(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, inProgressLabel.ID) case "in_progress": if err := h.deps.ForgejoClient.EditIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.EditIssueRequest{State: "open"}); err != nil { log.Error().Err(err).Msg("forgejo edit issue error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Failed to update status") return } if err := h.deps.ForgejoClient.AddLabels(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, []int64{inProgressLabel.ID}); err != nil { log.Error().Err(err).Msg("forgejo add label error") } case "closed": if err := h.deps.ForgejoClient.EditIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.EditIssueRequest{State: "closed"}); err != nil { log.Error().Err(err).Msg("forgejo edit issue error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusServiceUnavailable, "Failed to update status") return } h.deps.ForgejoClient.RemoveLabel(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, inProgressLabel.ID) } c.Redirect(http.StatusSeeOther, "/tickets/"+ticketID.String()) }