package admin import ( "fmt" "net/http" "sort" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/markdown" "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 Priority string Pinned bool RepoName string RepoSlug string UserEmail string UserName string DueDate *time.Time 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, Priority: forgejo.DerivePriority(issue), Pinned: issue.PinOrder > 0, RepoName: m.RepoName, RepoSlug: m.RepoSlug, UserEmail: m.UserEmail, UserName: m.UserName, DueDate: issue.DueDate, CreatedAt: m.CreatedAt, }) } } // Sort: pinned first, then by created date sort.SliceStable(tickets, func(i, j int) bool { if tickets[i].Pinned != tickets[j].Pinned { return tickets[i].Pinned } return false }) 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 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 } // Fetch comments (includes assets) and timeline (includes events) comments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber) if err != nil { log.Error().Err(err).Msg("forgejo list comments error") } 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 } // Build comment asset lookup (timeline may not include assets) commentAssets := map[int64][]forgejo.Attachment{} for _, cm := range comments { if len(cm.Assets) > 0 { commentAssets[cm.ID] = cm.Assets } } for i := range timelineEvents { if timelineEvents[i].Type == "comment" && len(timelineEvents[i].Assets) == 0 { if assets, ok := commentAssets[timelineEvents[i].ID]; ok { timelineEvents[i].Assets = assets } } } cleanBody, _ := forgejo.StripCommentFooter(issue.Body) timeline := forgejo.BuildTimelineViews(timelineEvents, h.deps.ForgejoClient.BotLogin, true) status := forgejo.DeriveStatus(issue) // Build assignee names var assigneeNames []string for _, a := range issue.Assignees { assigneeNames = append(assigneeNames, a.DisplayName()) } // Build mention map var allTexts []string allTexts = append(allTexts, cleanBody) for _, tv := range timeline { if tv.Type == "comment" { allTexts = append(allTexts, tv.Body) } } usernames := markdown.ExtractMentions(allTexts...) mentions := map[string]string{} for _, username := range usernames { u, err := h.deps.ForgejoClient.GetUser(username) if err != nil { mentions[username] = username } else { mentions[username] = u.DisplayName() } } // Extract related issue references (admin sees all as links) allText := strings.Join(allTexts, "\n") refNumbers := forgejo.ExtractIssueReferences(allText) var relatedIssues []forgejo.RelatedIssue for _, refNum := range refNumbers { if refNum == ticket.ForgejoIssueNumber { continue } ri := forgejo.RelatedIssue{Number: refNum, IsVisible: true} refIssue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, refNum) if err == nil { ri.Title = refIssue.Title ri.DisplayText = refIssue.Title } else { ri.DisplayText = fmt.Sprintf("#%d", refNum) } // Check if there's a ticket mapping for admin link var refTicket models.Ticket if h.deps.DB.Where("repo_id = ? AND forgejo_issue_number = ?", ticket.RepoID, refNum).First(&refTicket).Error == nil { ri.TicketID = refTicket.ID.String() } relatedIssues = append(relatedIssues, ri) } 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, "Priority": forgejo.DerivePriority(issue), "Pinned": issue.PinOrder > 0, "Assignees": strings.Join(assigneeNames, ", "), "DueDate": issue.DueDate, "Attachments": issue.Assets, "ForgejoIssueNumber": ticket.ForgejoIssueNumber, "CreatedAt": ticket.CreatedAt, }, "User": user, "Repo": repo, "Timeline": timeline, "RelatedIssues": relatedIssues, "Mentions": mentions, }) } 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()) }