307 lines
9.5 KiB
Go
307 lines
9.5 KiB
Go
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/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 timeline for full event view
|
|
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
|
|
}
|
|
|
|
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())
|
|
}
|
|
|
|
// Extract related issue references (admin sees all as links)
|
|
allText := cleanBody
|
|
for _, tv := range timeline {
|
|
if tv.Type == "comment" {
|
|
allText += "\n" + tv.Body
|
|
}
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
|
|
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())
|
|
}
|