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

409 lines
12 KiB
Go

package admin
import (
"fmt"
"net/http"
"sort"
"strconv"
"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")
priorityFilter := c.Query("priority")
productFilter := c.Query("product")
reporterFilter := c.Query("reporter")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
const pageSize = 20
// 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").
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 build all rows
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
}
tickets = append(tickets, ticketListRow{
ID: m.ID,
Title: issue.Title,
Status: forgejo.DeriveStatus(issue),
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,
})
}
}
// Collect unique product names and reporter names before filtering
productSet := map[string]bool{}
reporterSet := map[string]bool{}
for _, t := range tickets {
if t.RepoName != "" {
productSet[t.RepoName] = true
}
if t.UserName != "" {
reporterSet[t.UserName] = true
}
}
var products []string
for p := range productSet {
products = append(products, p)
}
sort.Strings(products)
var reporters []string
for r := range reporterSet {
reporters = append(reporters, r)
}
sort.Strings(reporters)
// Apply in-memory filters
filtered := tickets[:0]
for _, t := range tickets {
if statusFilter != "" && t.Status != statusFilter {
continue
}
if priorityFilter != "" && t.Priority != priorityFilter {
continue
}
if productFilter != "" && t.RepoName != productFilter {
continue
}
if reporterFilter != "" && t.UserName != reporterFilter {
continue
}
filtered = append(filtered, t)
}
tickets = filtered
// 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
})
// Paginate
totalPages := (len(tickets) + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
start := (page - 1) * pageSize
end := start + pageSize
if end > len(tickets) {
end = len(tickets)
}
tickets = tickets[start:end]
h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{
"Tickets": tickets,
"StatusFilter": statusFilter,
"PriorityFilter": priorityFilter,
"ProductFilter": productFilter,
"ReporterFilter": reporterFilter,
"Products": products,
"Reporters": reporters,
"Page": page,
"TotalPages": totalPages,
})
}
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.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "in_progress", "#e4e669")
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, "/admin/tickets/"+ticketID.String())
}