From cb21e0f6a2d85e668ad0e5dfb953a09ee79440fd Mon Sep 17 00:00:00 2001 From: Matthew Knight Date: Sat, 14 Feb 2026 02:04:27 -0800 Subject: [PATCH] Forgejo as the source of truth --- cmd/server/main.go | 21 +- internal/email/email.go | 20 ++ internal/email/templates.go | 10 + internal/forgejo/client.go | 314 +++++++++++++++++- internal/forgejo/webhook.go | 16 +- internal/handlers/admin/dashboard.go | 65 +++- internal/handlers/admin/routes.go | 12 +- internal/handlers/admin/tickets.go | 193 +++++++++-- internal/handlers/admin/users.go | 60 +++- internal/handlers/public/tickets.go | 198 +++++++---- internal/handlers/public/webhook.go | 64 +++- internal/models/models.go | 63 ++-- internal/models/models_test.go | 33 -- internal/sync/sync.go | 162 --------- web/templates/pages/admin/tickets/detail.html | 7 +- web/templates/pages/admin/users/detail.html | 2 +- web/templates/pages/tickets/detail.html | 7 +- web/templates/pages/tickets/list.html | 2 +- 18 files changed, 879 insertions(+), 370 deletions(-) delete mode 100644 internal/sync/sync.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 0596ffb..1f264ed 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,7 +16,6 @@ import ( "github.com/mattnite/forgejo-tickets/internal/forgejo" adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin" publichandlers "github.com/mattnite/forgejo-tickets/internal/handlers/public" - forgejosync "github.com/mattnite/forgejo-tickets/internal/sync" "github.com/mattnite/forgejo-tickets/internal/templates" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -47,6 +46,14 @@ func main() { emailClient := email.NewClient(cfg.PostmarkServerToken, cfg.PostmarkFromEmail, cfg.BaseURL) forgejoClient := forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoAPIToken) + + // Discover the bot's username for comment attribution + if err := forgejoClient.InitBotLogin(); err != nil { + log.Warn().Err(err).Msg("failed to initialize bot login (comment attribution may not work)") + } else { + log.Info().Str("bot_login", forgejoClient.BotLogin).Msg("forgejo bot login initialized") + } + sessionStore := auth.NewPGStore(db, []byte(cfg.SessionSecret)) authService := auth.NewService(db, sessionStore, emailClient) @@ -54,7 +61,6 @@ func main() { defer cancel() go sessionStore.Cleanup(ctx, 30*time.Minute) - go forgejosync.SyncUnsynced(ctx, db, forgejoClient) publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ DB: db, @@ -67,11 +73,12 @@ func main() { }) adminRouter := adminhandlers.NewRouter(adminhandlers.Dependencies{ - DB: db, - Renderer: renderer, - Auth: authService, - EmailClient: emailClient, - Config: cfg, + DB: db, + Renderer: renderer, + Auth: authService, + EmailClient: emailClient, + ForgejoClient: forgejoClient, + Config: cfg, }) publicServer := &http.Server{ diff --git a/internal/email/email.go b/internal/email/email.go index 9864b60..048c58d 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -105,6 +105,26 @@ func (c *Client) SendAccountApprovedEmail(to, name string) error { return err } +func (c *Client) SendTicketReplyNotification(to, name, ticketTitle, ticketID string) error { + if c.server == nil { + return fmt.Errorf("email client not configured") + } + + ticketURL := fmt.Sprintf("%s/tickets/%s", c.baseURL, ticketID) + htmlBody := renderTicketReplyEmail(name, ticketTitle, ticketURL) + textBody := fmt.Sprintf("Hi %s,\n\nThere is a new reply on your ticket \"%s\".\n\nView it at: %s", name, ticketTitle, ticketURL) + + _, err := c.server.SendEmail(context.Background(), postmark.Email{ + From: c.fromEmail, + To: to, + Subject: fmt.Sprintf("New reply on your ticket \"%s\"", ticketTitle), + HTMLBody: htmlBody, + TextBody: textBody, + Tag: "ticket-reply", + }) + return err +} + func (c *Client) SendWelcomeEmail(to, name, tempPassword string) error { if c.server == nil { return fmt.Errorf("email client not configured") diff --git a/internal/email/templates.go b/internal/email/templates.go index decb0d0..4d36510 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -51,6 +51,16 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {

If you believe the issue is not fully resolved, you can add a comment on the ticket page.

`, name, ticketTitle, ticketURL)) } +func renderTicketReplyEmail(name, ticketTitle, ticketURL string) string { + return emailWrapper(fmt.Sprintf(` +

New reply on your ticket

+

Hi %s,

+

There is a new reply on your ticket "%s".

+

+ View Ticket +

`, name, ticketTitle, ticketURL)) +} + func renderAccountApprovedEmail(name, loginURL string) string { return emailWrapper(fmt.Sprintf(`

Your account has been approved

diff --git a/internal/forgejo/client.go b/internal/forgejo/client.go index a7fa626..40ecd4c 100644 --- a/internal/forgejo/client.go +++ b/internal/forgejo/client.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "net/http" + "net/url" + "strings" "time" ) @@ -15,6 +17,7 @@ type Client struct { baseURL string apiToken string httpClient *http.Client + BotLogin string } func NewClient(baseURL, apiToken string) *Client { @@ -33,15 +36,22 @@ type CreateIssueRequest struct { Labels []int64 `json:"labels,omitempty"` } +type EditIssueRequest struct { + State string `json:"state,omitempty"` +} + type Label struct { ID int64 `json:"id"` Name string `json:"name"` } type Issue struct { - Number int64 `json:"number"` - Title string `json:"title"` - State string `json:"state"` + Number int64 `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Labels []Label `json:"labels"` + CreatedAt time.Time `json:"created_at"` } type CreateCommentRequest struct { @@ -49,8 +59,88 @@ type CreateCommentRequest struct { } type Comment struct { - ID int64 `json:"id"` - Body string `json:"body"` + ID int64 `json:"id"` + Body string `json:"body"` + User APIUser `json:"user"` + CreatedAt time.Time `json:"created_at"` +} + +type APIUser struct { + Login string `json:"login"` + FullName string `json:"full_name"` +} + +type CommentView struct { + Body string + AuthorName string + IsTeam bool + CreatedAt time.Time +} + +// DeriveStatus maps Forgejo issue state + labels to app status. +func DeriveStatus(issue *Issue) string { + if issue.State == "closed" { + return "closed" + } + for _, l := range issue.Labels { + if l.Name == "in_progress" { + return "in_progress" + } + } + return "open" +} + +// StripCommentFooter removes the "---\n*...*" footer from bot-posted comments +// and returns the clean body and the attribution (email). +func StripCommentFooter(body string) (string, string) { + sep := "\n\n---\n*" + idx := strings.LastIndex(body, sep) + if idx == -1 { + return body, "" + } + footer := body[idx+len(sep)-1:] // starts at "*..." + cleanBody := body[:idx] + if len(footer) > 1 && footer[len(footer)-1] == '*' { + inner := footer[1 : len(footer)-1] + parts := strings.SplitN(inner, ": ", 2) + if len(parts) == 2 { + return cleanBody, parts[1] + } + } + return cleanBody, "" +} + +// BuildCommentViews transforms Forgejo comments into view models, +// identifying customer vs team comments based on the bot login. +func BuildCommentViews(comments []Comment, botLogin string) []CommentView { + var views []CommentView + for _, c := range comments { + if c.User.Login == botLogin { + body, email := StripCommentFooter(c.Body) + authorName := email + if authorName == "" { + authorName = "Customer" + } + views = append(views, CommentView{ + Body: body, + AuthorName: authorName, + IsTeam: false, + CreatedAt: c.CreatedAt, + }) + } else { + name := c.User.FullName + if name == "" { + name = c.User.Login + } + views = append(views, CommentView{ + Body: c.Body, + AuthorName: name, + IsTeam: true, + CreatedAt: c.CreatedAt, + }) + } + } + return views } func GenerateWebhookSecret() (string, error) { @@ -61,6 +151,16 @@ func GenerateWebhookSecret() (string, error) { return hex.EncodeToString(b), nil } +// InitBotLogin calls GetAuthenticatedUser and stores the login on the client. +func (c *Client) InitBotLogin() error { + user, err := c.GetAuthenticatedUser() + if err != nil { + return err + } + c.BotLogin = user.Login + return nil +} + func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, error) { // Try to find existing label listURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", c.baseURL, owner, repo) @@ -118,14 +218,14 @@ func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, } func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) body, err := json.Marshal(req) if err != nil { return nil, err } - httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) if err != nil { return nil, err } @@ -153,14 +253,14 @@ func (c *Client) CreateIssue(owner, repo string, req CreateIssueRequest) (*Issue } func (c *Client) CreateComment(owner, repo string, issueNumber int64, req CreateCommentRequest) (*Comment, error) { - url := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber) + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, issueNumber) body, err := json.Marshal(req) if err != nil { return nil, err } - httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) if err != nil { return nil, err } @@ -186,3 +286,199 @@ func (c *Client) CreateComment(owner, repo string, issueNumber int64, req Create return &comment, nil } + +func (c *Client) GetIssue(owner, repo string, number int64) (*Issue, error) { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, owner, repo, number) + + httpReq, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var issue Issue + if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil { + return nil, err + } + return &issue, nil +} + +func (c *Client) ListIssueComments(owner, repo string, number int64) ([]Comment, error) { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/comments", c.baseURL, owner, repo, number) + + httpReq, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var comments []Comment + if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { + return nil, err + } + return comments, nil +} + +func (c *Client) ListIssues(owner, repo, state, labels string) ([]Issue, error) { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues", c.baseURL, owner, repo) + params := url.Values{} + params.Set("type", "issues") + params.Set("limit", "50") + if state != "" { + params.Set("state", state) + } + if labels != "" { + params.Set("labels", labels) + } + reqURL += "?" + params.Encode() + + httpReq, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var issues []Issue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, err + } + return issues, nil +} + +func (c *Client) EditIssue(owner, repo string, number int64, req EditIssueRequest) error { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d", c.baseURL, owner, repo, number) + + body, err := json.Marshal(req) + if err != nil { + return err + } + + httpReq, err := http.NewRequest("PATCH", reqURL, bytes.NewReader(body)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (c *Client) AddLabels(owner, repo string, number int64, labelIDs []int64) error { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels", c.baseURL, owner, repo, number) + + body, err := json.Marshal(map[string][]int64{"labels": labelIDs}) + if err != nil { + return err + } + + httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body)) + if err != nil { + return err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (c *Client) RemoveLabel(owner, repo string, number int64, labelID int64) error { + reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/issues/%d/labels/%d", c.baseURL, owner, repo, number, labelID) + + httpReq, err := http.NewRequest("DELETE", reqURL, nil) + if err != nil { + return err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (c *Client) GetAuthenticatedUser() (*APIUser, error) { + reqURL := fmt.Sprintf("%s/api/v1/user", c.baseURL) + + httpReq, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + httpReq.Header.Set("Authorization", "token "+c.apiToken) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("forgejo API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("forgejo API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var user APIUser + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/forgejo/webhook.go b/internal/forgejo/webhook.go index 12635a7..75fd0a1 100644 --- a/internal/forgejo/webhook.go +++ b/internal/forgejo/webhook.go @@ -11,8 +11,9 @@ import ( ) type WebhookPayload struct { - Action string `json:"action"` - Issue WebhookIssue `json:"issue"` + Action string `json:"action"` + Issue WebhookIssue `json:"issue"` + Comment WebhookComment `json:"comment"` } type WebhookIssue struct { @@ -21,6 +22,17 @@ type WebhookIssue struct { State string `json:"state"` } +type WebhookComment struct { + ID int64 `json:"id"` + Body string `json:"body"` + User WebhookUser `json:"user"` +} + +type WebhookUser struct { + Login string `json:"login"` + FullName string `json:"full_name"` +} + func VerifyWebhookSignature(r *http.Request, secret string) ([]byte, error) { signature := r.Header.Get("X-Forgejo-Signature") if signature == "" { diff --git a/internal/handlers/admin/dashboard.go b/internal/handlers/admin/dashboard.go index 643ddd6..f818986 100644 --- a/internal/handlers/admin/dashboard.go +++ b/internal/handlers/admin/dashboard.go @@ -2,6 +2,7 @@ package admin import ( "github.com/gin-gonic/gin" + "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/models" "github.com/rs/zerolog/log" ) @@ -21,19 +22,63 @@ func (h *DashboardHandler) Index(c *gin.Context) { log.Error().Err(err).Msg("count tickets error") } - var openTickets int64 - if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusOpen).Count(&openTickets).Error; err != nil { - log.Error().Err(err).Msg("count open tickets error") + // Get distinct repos that have tickets + type repoInfo struct { + ForgejoOwner string + ForgejoRepo string + } + var repos []repoInfo + h.deps.DB.Table("tickets"). + Select("DISTINCT repos.forgejo_owner, repos.forgejo_repo"). + Joins("JOIN repos ON repos.id = tickets.repo_id"). + Scan(&repos) + + // Get all ticket issue numbers grouped by repo for matching + type ticketIssue struct { + ForgejoOwner string + ForgejoRepo string + ForgejoIssueNumber int64 + } + var ticketIssues []ticketIssue + h.deps.DB.Table("tickets"). + Select("repos.forgejo_owner, repos.forgejo_repo, tickets.forgejo_issue_number"). + Joins("JOIN repos ON repos.id = tickets.repo_id"). + Scan(&ticketIssues) + + // Build a set of known issue numbers per repo + knownIssues := map[string]map[int64]bool{} + for _, ti := range ticketIssues { + key := ti.ForgejoOwner + "/" + ti.ForgejoRepo + if knownIssues[key] == nil { + knownIssues[key] = map[int64]bool{} + } + knownIssues[key][ti.ForgejoIssueNumber] = true } - var inProgressTickets int64 - if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusInProgress).Count(&inProgressTickets).Error; err != nil { - log.Error().Err(err).Msg("count in_progress tickets error") - } + var openTickets, inProgressTickets, closedTickets int64 + for _, repo := range repos { + issues, err := h.deps.ForgejoClient.ListIssues(repo.ForgejoOwner, repo.ForgejoRepo, "all", "customer") + if err != nil { + log.Error().Err(err).Str("repo", repo.ForgejoOwner+"/"+repo.ForgejoRepo).Msg("dashboard: forgejo list issues error") + continue + } - var closedTickets int64 - if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusClosed).Count(&closedTickets).Error; err != nil { - log.Error().Err(err).Msg("count closed tickets error") + key := repo.ForgejoOwner + "/" + repo.ForgejoRepo + known := knownIssues[key] + + for i := range issues { + if !known[issues[i].Number] { + continue + } + switch forgejo.DeriveStatus(&issues[i]) { + case "open": + openTickets++ + case "in_progress": + inProgressTickets++ + case "closed": + closedTickets++ + } + } } h.deps.Renderer.Render(c.Writer, c.Request, "admin/dashboard", map[string]interface{}{ diff --git a/internal/handlers/admin/routes.go b/internal/handlers/admin/routes.go index 8daf838..1785be9 100644 --- a/internal/handlers/admin/routes.go +++ b/internal/handlers/admin/routes.go @@ -5,17 +5,19 @@ import ( "github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/email" + "github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/middleware" "github.com/mattnite/forgejo-tickets/internal/templates" "gorm.io/gorm" ) type Dependencies struct { - DB *gorm.DB - Renderer *templates.Renderer - Auth *auth.Service - EmailClient *email.Client - Config *config.Config + DB *gorm.DB + Renderer *templates.Renderer + Auth *auth.Service + EmailClient *email.Client + ForgejoClient *forgejo.Client + Config *config.Config } func NewRouter(deps Dependencies) *gin.Engine { diff --git a/internal/handlers/admin/tickets.go b/internal/handlers/admin/tickets.go index c920cca..322eba6 100644 --- a/internal/handlers/admin/tickets.go +++ b/internal/handlers/admin/tickets.go @@ -5,6 +5,7 @@ import ( "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" ) @@ -14,32 +15,117 @@ type TicketHandler struct { } type ticketListRow struct { - models.Ticket + 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") - var tickets []ticketListRow - query := h.deps.DB.Table("tickets"). - Select("tickets.*, repos.name as repo_name, repos.slug as repo_slug, 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") - - if statusFilter != "" { - query = query.Where("tickets.status = ?", statusFilter) + // 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 } - if err := query.Order("tickets.created_at DESC").Limit(100).Scan(&tickets).Error; err != nil { + 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, "customer") + 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, @@ -65,20 +151,33 @@ func (h *TicketHandler) Detail(c *gin.Context) { var repo models.Repo h.deps.DB.First(&repo, "id = ?", ticket.RepoID) - var comments []struct { - models.TicketComment - UserName string - UserEmail string + // 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 } - h.deps.DB.Table("ticket_comments"). - Select("ticket_comments.*, users.name as user_name, users.email as user_email"). - Joins("JOIN users ON users.id = ticket_comments.user_id"). - Where("ticket_comments.ticket_id = ?", ticket.ID). - Order("ticket_comments.created_at ASC"). - Scan(&comments) + + 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": ticket, + "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, @@ -92,13 +191,55 @@ func (h *TicketHandler) UpdateStatus(c *gin.Context) { return } - status := models.TicketStatus(c.PostForm("status")) - - if err := h.deps.DB.Model(&models.Ticket{}).Where("id = ?", ticketID).Update("status", status).Error; err != nil { - log.Error().Err(err).Msg("update ticket status error") - h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update status") + 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", "#fbca04") + 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()) } diff --git a/internal/handlers/admin/users.go b/internal/handlers/admin/users.go index 067fdd5..ac55f23 100644 --- a/internal/handlers/admin/users.go +++ b/internal/handlers/admin/users.go @@ -9,6 +9,7 @@ import ( "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" ) @@ -43,9 +44,66 @@ func (h *UserHandler) Detail(c *gin.Context) { return } + // Load user's ticket mappings with repo info var tickets []models.Ticket h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets) + // Group by repo and fetch issue details from Forgejo + type ticketView struct { + ID uuid.UUID + Title string + Status string + RepoName string + CreatedAt interface{} + } + var ticketViews []ticketView + + 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) + } + + for _, group := range repoMap { + issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "customer") + if err != nil { + log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error") + for _, t := range group.tickets { + ticketViews = append(ticketViews, 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 { + ticketViews = append(ticketViews, ticketView{ + ID: t.ID, + Title: issue.Title, + Status: forgejo.DeriveStatus(issue), + RepoName: group.repo.Name, + CreatedAt: t.CreatedAt, + }) + } + } + } + // Load all repos and user's assigned repo IDs var allRepos []models.Repo h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&allRepos) @@ -60,7 +118,7 @@ func (h *UserHandler) Detail(c *gin.Context) { h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/detail", map[string]interface{}{ "User": user, - "Tickets": tickets, + "Tickets": ticketViews, "AllRepos": allRepos, "AssignedRepoIDs": assignedRepoIDs, }) diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index a2fe8d9..7ef8d88 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -25,8 +25,73 @@ func (h *TicketHandler) List(c *gin.Context) { 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 + RepoName string + CreatedAt interface{} + } + var views []ticketView + + for _, group := range repoMap { + issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "customer") + 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), + RepoName: group.repo.Name, + CreatedAt: t.CreatedAt, + }) + } + } + } + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ - "Tickets": tickets, + "Tickets": views, }) } @@ -85,11 +150,38 @@ func (h *TicketHandler) Create(c *gin.Context) { 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.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer", "#0075ca") + 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 + } + + // Create local ticket mapping ticket := models.Ticket{ - UserID: user.ID, - RepoID: repoID, - Title: title, - Description: description, + UserID: user.ID, + RepoID: repoID, + ForgejoIssueNumber: issue.Number, } if err := h.deps.DB.Create(&ticket).Error; err != nil { @@ -98,32 +190,6 @@ func (h *TicketHandler) Create(c *gin.Context) { return } - // Async Forgejo issue creation - var repo models.Repo - if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err == nil { - go func() { - // Look up or create the "customer" label - var labelIDs []int64 - label, err := h.deps.ForgejoClient.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer", "#0075ca") - 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).Msgf("forgejo create issue error for ticket %s", ticket.ID) - return - } - h.deps.DB.Model(&ticket).Update("forgejo_issue_number", issue.Number) - }() - } - c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) } @@ -150,20 +216,33 @@ func (h *TicketHandler) Detail(c *gin.Context) { var repo models.Repo h.deps.DB.First(&repo, "id = ?", ticket.RepoID) - var comments []struct { - models.TicketComment - UserName string - UserEmail string + // 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. Please try again later.") + return } - h.deps.DB.Table("ticket_comments"). - Select("ticket_comments.*, users.name as user_name, users.email as user_email"). - Joins("JOIN users ON users.id = ticket_comments.user_id"). - Where("ticket_comments.ticket_id = ?", ticket.ID). - Order("ticket_comments.created_at ASC"). - Scan(&comments) + + 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 // Show ticket without comments on error + } + + // Strip the "Submitted by" footer from the issue body + cleanBody, _ := forgejo.StripCommentFooter(issue.Body) + + comments := forgejo.BuildCommentViews(rawComments, h.deps.ForgejoClient.BotLogin) h.deps.Renderer.Render(c.Writer, c.Request, "tickets/detail", map[string]interface{}{ - "Ticket": ticket, + "Ticket": map[string]interface{}{ + "ID": ticket.ID, + "Title": issue.Title, + "Description": cleanBody, + "Status": forgejo.DeriveStatus(issue), + "CreatedAt": ticket.CreatedAt, + }, "Repo": repo, "Comments": comments, }) @@ -195,33 +274,20 @@ func (h *TicketHandler) AddComment(c *gin.Context) { return } - comment := models.TicketComment{ - TicketID: ticket.ID, - UserID: user.ID, - Body: body, - } - - if err := h.deps.DB.Create(&comment).Error; err != nil { - log.Error().Err(err).Msg("create comment error") - h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to add comment") + // 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 } - // Async sync to Forgejo - if ticket.ForgejoIssueNumber != nil { - var repo models.Repo - if err := h.deps.DB.First(&repo, "id = ?", ticket.RepoID).Error; err == nil { - go func() { - forgejoComment, err := h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, *ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{ - Body: body + "\n\n---\n*Comment by: " + user.Email + "*", - }) - if err != nil { - log.Error().Err(err).Msg("forgejo create comment error") - return - } - h.deps.DB.Model(&comment).Update("forgejo_comment_id", forgejoComment.ID) - }() - } + _, 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 } c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) diff --git a/internal/handlers/public/webhook.go b/internal/handlers/public/webhook.go index b92cf8d..296e639 100644 --- a/internal/handlers/public/webhook.go +++ b/internal/handlers/public/webhook.go @@ -47,6 +47,25 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) { return } + // Determine event type from header + eventType := c.GetHeader("X-Forgejo-Event") + + switch eventType { + case "issues": + h.handleIssueEvent(c, &repo, payload) + case "issue_comment": + h.handleCommentEvent(c, &repo, payload) + default: + // For unrecognized events or legacy (no header), try issue events + if payload.Action == "closed" || payload.Action == "reopened" { + h.handleIssueEvent(c, &repo, payload) + } else { + c.Status(http.StatusOK) + } + } +} + +func (h *WebhookHandler) handleIssueEvent(c *gin.Context, repo *models.Repo, payload *forgejo.WebhookPayload) { if payload.Action != "closed" { c.Status(http.StatusOK) return @@ -54,22 +73,17 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) { var ticket models.Ticket if err := h.deps.DB.Where("repo_id = ? AND forgejo_issue_number = ?", repo.ID, payload.Issue.Number).First(&ticket).Error; err != nil { - log.Info().Msgf("webhook: no ticket found for repo %s issue #%d", repoSlug, payload.Issue.Number) + log.Info().Msgf("webhook: no ticket found for repo %s issue #%d", repo.Slug, payload.Issue.Number) c.Status(http.StatusOK) return } - if err := h.deps.DB.Model(&ticket).Update("status", models.TicketStatusClosed).Error; err != nil { - log.Error().Err(err).Msg("webhook: update ticket status error") - c.String(http.StatusInternalServerError, "Internal error") - return - } - + // Send email notification to ticket owner var user models.User if err := h.deps.DB.First(&user, "id = ?", ticket.UserID).Error; err == nil { go func() { if err := h.deps.EmailClient.SendTicketClosedNotification( - user.Email, user.Name, ticket.Title, ticket.ID.String(), + user.Email, user.Name, payload.Issue.Title, ticket.ID.String(), ); err != nil { log.Error().Err(err).Msg("webhook: send notification error") } @@ -78,3 +92,37 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) { c.Status(http.StatusOK) } + +func (h *WebhookHandler) handleCommentEvent(c *gin.Context, repo *models.Repo, payload *forgejo.WebhookPayload) { + if payload.Action != "created" { + c.Status(http.StatusOK) + return + } + + // Skip comments posted by the bot (customer comments) + if payload.Comment.User.Login == h.deps.ForgejoClient.BotLogin { + c.Status(http.StatusOK) + return + } + + // This is a team reply — find the ticket and notify the customer + var ticket models.Ticket + if err := h.deps.DB.Where("repo_id = ? AND forgejo_issue_number = ?", repo.ID, payload.Issue.Number).First(&ticket).Error; err != nil { + log.Info().Msgf("webhook: no ticket found for repo %s issue #%d", repo.Slug, payload.Issue.Number) + c.Status(http.StatusOK) + return + } + + var user models.User + if err := h.deps.DB.First(&user, "id = ?", ticket.UserID).Error; err == nil { + go func() { + if err := h.deps.EmailClient.SendTicketReplyNotification( + user.Email, user.Name, payload.Issue.Title, ticket.ID.String(), + ); err != nil { + log.Error().Err(err).Msg("webhook: send reply notification error") + } + }() + } + + c.Status(http.StatusOK) +} diff --git a/internal/models/models.go b/internal/models/models.go index 98a2788..6d4185e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -7,14 +7,6 @@ import ( "gorm.io/gorm" ) -type TicketStatus string - -const ( - TicketStatusOpen TicketStatus = "open" - TicketStatusInProgress TicketStatus = "in_progress" - TicketStatusClosed TicketStatus = "closed" -) - type TokenType string const ( @@ -74,28 +66,13 @@ type Repo struct { } type Ticket struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` - UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` - User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` - RepoID uuid.UUID `gorm:"type:uuid;not null;index" json:"repo_id"` - Repo Repo `gorm:"foreignKey:RepoID;constraint:OnDelete:CASCADE" json:"-"` - Title string `gorm:"not null" json:"title"` - Description string `gorm:"not null" json:"description"` - Status TicketStatus `gorm:"type:ticket_status;not null;default:'open';index" json:"status"` - ForgejoIssueNumber *int64 `json:"forgejo_issue_number"` - CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"not null;default:now()" json:"updated_at"` -} - -type TicketComment struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` - TicketID uuid.UUID `gorm:"type:uuid;not null;index" json:"ticket_id"` - Ticket Ticket `gorm:"foreignKey:TicketID;constraint:OnDelete:CASCADE" json:"-"` - UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"` - User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` - Body string `gorm:"not null" json:"body"` - ForgejoCommentID *int64 `json:"forgejo_comment_id"` - CreatedAt time.Time `gorm:"not null;default:now()" json:"created_at"` + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + UserID uuid.UUID `gorm:"type:uuid;not null;index"` + User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"` + RepoID uuid.UUID `gorm:"type:uuid;not null;index"` + Repo Repo `gorm:"foreignKey:RepoID;constraint:OnDelete:CASCADE"` + ForgejoIssueNumber int64 `gorm:"not null"` + CreatedAt time.Time `gorm:"not null;default:now()"` } type EmailToken struct { @@ -109,19 +86,35 @@ type EmailToken struct { } // AutoMigrate runs GORM auto-migration for all models. -// Note: enum types and partial indexes must be created via SQL migrations. func AutoMigrate(db *gorm.DB) error { // Create enum types if they don't exist - db.Exec("DO $$ BEGIN CREATE TYPE ticket_status AS ENUM ('open', 'in_progress', 'closed'); EXCEPTION WHEN duplicate_object THEN null; END $$;") db.Exec("DO $$ BEGIN CREATE TYPE token_type AS ENUM ('verify_email', 'reset_password'); EXCEPTION WHEN duplicate_object THEN null; END $$;") + // Migration: Drop ticket_comments table (no longer used) + db.Exec("DROP TABLE IF EXISTS ticket_comments") + + // Migration: Drop removed columns from tickets + db.Exec("ALTER TABLE tickets DROP COLUMN IF EXISTS title") + db.Exec("ALTER TABLE tickets DROP COLUMN IF EXISTS description") + db.Exec("ALTER TABLE tickets DROP COLUMN IF EXISTS status") + db.Exec("ALTER TABLE tickets DROP COLUMN IF EXISTS updated_at") + + // Migration: Delete any tickets without a Forgejo issue number, then make it NOT NULL + db.Exec("DELETE FROM tickets WHERE forgejo_issue_number IS NULL") + db.Exec("ALTER TABLE tickets ALTER COLUMN forgejo_issue_number SET NOT NULL") + + // Drop the old partial unique index + db.Exec("DROP INDEX IF EXISTS idx_tickets_repo_forgejo_issue") + + // Drop the old ticket_status enum type (no longer used) + db.Exec("DROP TYPE IF EXISTS ticket_status") + if err := db.AutoMigrate( &User{}, &OAuthAccount{}, &Session{}, &Repo{}, &Ticket{}, - &TicketComment{}, &EmailToken{}, &UserRepo{}, ); err != nil { @@ -131,8 +124,8 @@ func AutoMigrate(db *gorm.DB) error { // Create unique composite index for oauth_accounts db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_provider_user ON oauth_accounts(provider, provider_user_id)") - // Create partial unique index for ticket forgejo issue lookup - db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_forgejo_issue ON tickets(repo_id, forgejo_issue_number) WHERE forgejo_issue_number IS NOT NULL") + // Create unique index for ticket forgejo issue lookup + db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_repo_forgejo_issue ON tickets(repo_id, forgejo_issue_number)") // Approve all existing verified users so they aren't locked out db.Exec("UPDATE users SET approved = true WHERE approved = false AND email_verified = true") diff --git a/internal/models/models_test.go b/internal/models/models_test.go index a6e6840..576dbfe 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -4,23 +4,6 @@ import ( "testing" ) -func TestTicketStatusConstants(t *testing.T) { - tests := []struct { - status TicketStatus - expected string - }{ - {TicketStatusOpen, "open"}, - {TicketStatusInProgress, "in_progress"}, - {TicketStatusClosed, "closed"}, - } - - for _, tt := range tests { - if string(tt.status) != tt.expected { - t.Errorf("expected TicketStatus %q, got %q", tt.expected, string(tt.status)) - } - } -} - func TestTokenTypeConstants(t *testing.T) { tests := []struct { tokenType TokenType @@ -37,22 +20,6 @@ func TestTokenTypeConstants(t *testing.T) { } } -func TestTicketStatusValues_AreDistinct(t *testing.T) { - statuses := []TicketStatus{ - TicketStatusOpen, - TicketStatusInProgress, - TicketStatusClosed, - } - - seen := make(map[TicketStatus]bool) - for _, s := range statuses { - if seen[s] { - t.Errorf("duplicate TicketStatus value: %q", s) - } - seen[s] = true - } -} - func TestTokenTypeValues_AreDistinct(t *testing.T) { types := []TokenType{ TokenTypeVerifyEmail, diff --git a/internal/sync/sync.go b/internal/sync/sync.go deleted file mode 100644 index cf79d63..0000000 --- a/internal/sync/sync.go +++ /dev/null @@ -1,162 +0,0 @@ -package sync - -import ( - "context" - "math" - "time" - - "github.com/mattnite/forgejo-tickets/internal/forgejo" - "github.com/mattnite/forgejo-tickets/internal/models" - "github.com/rs/zerolog/log" - "gorm.io/gorm" -) - -const ( - initialBackoff = 5 * time.Second - maxBackoff = 5 * time.Minute - maxRetries = 10 -) - -// SyncUnsynced runs a background loop that syncs tickets and comments -// that are missing their Forgejo counterparts, retrying with exponential -// backoff. It stops when ctx is cancelled or everything is synced. -func SyncUnsynced(ctx context.Context, db *gorm.DB, client *forgejo.Client) { - backoff := initialBackoff - - for attempt := 0; attempt < maxRetries; attempt++ { - ticketsFailed := syncUnsyncedTickets(db, client) - commentsFailed := syncUnsyncedComments(db, client) - - if !ticketsFailed && !commentsFailed { - log.Info().Msg("sync: everything synced successfully") - return - } - - if !sleep(ctx, backoff) { - return - } - backoff = nextBackoff(backoff) - } - - log.Warn().Msg("sync: gave up after max retries, remaining items will be retried on next restart") -} - -// syncUnsyncedTickets syncs tickets without a Forgejo issue number. -// Returns true if any tickets failed to sync. -func syncUnsyncedTickets(db *gorm.DB, client *forgejo.Client) bool { - var tickets []models.Ticket - if err := db.Preload("Repo").Preload("User"). - Where("forgejo_issue_number IS NULL"). - Find(&tickets).Error; err != nil { - log.Error().Err(err).Msg("sync: failed to query unsynced tickets") - return true - } - - if len(tickets) == 0 { - return false - } - - log.Info().Int("count", len(tickets)).Msg("sync: found unsynced tickets") - - anyFailed := false - for _, ticket := range tickets { - if err := syncTicket(db, client, ticket); err != nil { - log.Error().Err(err).Str("ticket_id", ticket.ID.String()).Msg("sync: failed to sync ticket") - anyFailed = true - } - } - return anyFailed -} - -// syncUnsyncedComments syncs comments that belong to tickets with a Forgejo -// issue but are missing their own Forgejo comment ID. -// Returns true if any comments failed to sync. -func syncUnsyncedComments(db *gorm.DB, client *forgejo.Client) bool { - var comments []models.TicketComment - if err := db.Preload("Ticket").Preload("Ticket.Repo").Preload("User"). - Where("forgejo_comment_id IS NULL"). - Find(&comments).Error; err != nil { - log.Error().Err(err).Msg("sync: failed to query unsynced comments") - return true - } - - if len(comments) == 0 { - return false - } - - log.Info().Int("count", len(comments)).Msg("sync: found unsynced comments") - - anyFailed := false - for _, comment := range comments { - if comment.Ticket.ForgejoIssueNumber == nil { - // Parent ticket hasn't been synced yet; skip until next round - anyFailed = true - continue - } - if err := syncComment(db, client, comment); err != nil { - log.Error().Err(err).Str("comment_id", comment.ID.String()).Msg("sync: failed to sync comment") - anyFailed = true - } - } - return anyFailed -} - -func syncTicket(db *gorm.DB, client *forgejo.Client, ticket models.Ticket) error { - var labelIDs []int64 - label, err := client.GetOrCreateLabel(ticket.Repo.ForgejoOwner, ticket.Repo.ForgejoRepo, "customer", "#0075ca") - if err != nil { - log.Error().Err(err).Str("ticket_id", ticket.ID.String()).Msg("sync: failed to get/create label") - } else { - labelIDs = append(labelIDs, label.ID) - } - - issue, err := client.CreateIssue(ticket.Repo.ForgejoOwner, ticket.Repo.ForgejoRepo, forgejo.CreateIssueRequest{ - Title: ticket.Title, - Body: ticket.Description + "\n\n---\n*Submitted by: " + ticket.User.Email + "*", - Labels: labelIDs, - }) - if err != nil { - return err - } - - if err := db.Model(&ticket).Update("forgejo_issue_number", issue.Number).Error; err != nil { - return err - } - - log.Info().Str("ticket_id", ticket.ID.String()).Int64("issue_number", issue.Number).Msg("sync: ticket synced") - return nil -} - -func syncComment(db *gorm.DB, client *forgejo.Client, comment models.TicketComment) error { - repo := comment.Ticket.Repo - forgejoComment, err := client.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, *comment.Ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{ - Body: comment.Body + "\n\n---\n*Comment by: " + comment.User.Email + "*", - }) - if err != nil { - return err - } - - if err := db.Model(&comment).Update("forgejo_comment_id", forgejoComment.ID).Error; err != nil { - return err - } - - log.Info().Str("comment_id", comment.ID.String()).Int64("forgejo_comment_id", forgejoComment.ID).Msg("sync: comment synced") - return nil -} - -func sleep(ctx context.Context, d time.Duration) bool { - select { - case <-ctx.Done(): - return false - case <-time.After(d): - return true - } -} - -func nextBackoff(current time.Duration) time.Duration { - next := time.Duration(float64(current) * math.Phi) - if next > maxBackoff { - return maxBackoff - } - return next -} diff --git a/web/templates/pages/admin/tickets/detail.html b/web/templates/pages/admin/tickets/detail.html index a4a7e2b..1998972 100644 --- a/web/templates/pages/admin/tickets/detail.html +++ b/web/templates/pages/admin/tickets/detail.html @@ -44,9 +44,12 @@ {{if .Comments}}
{{range .Comments}} -
+
- {{.UserName}} ({{.UserEmail}}) +
+ {{.AuthorName}} + {{if .IsTeam}}Team{{end}} +
{{formatDateTime .CreatedAt}}

{{.Body}}

diff --git a/web/templates/pages/admin/users/detail.html b/web/templates/pages/admin/users/detail.html index 7071dc5..e2d3139 100644 --- a/web/templates/pages/admin/users/detail.html +++ b/web/templates/pages/admin/users/detail.html @@ -67,7 +67,7 @@ {{.Title}} - {{.Repo.Name}} + {{.RepoName}} {{statusBadge (print .Status)}} {{formatDate .CreatedAt}} diff --git a/web/templates/pages/tickets/detail.html b/web/templates/pages/tickets/detail.html index 0cd6b00..c2c9cdd 100644 --- a/web/templates/pages/tickets/detail.html +++ b/web/templates/pages/tickets/detail.html @@ -30,9 +30,12 @@ {{if .Comments}}
{{range .Comments}} -
+
- {{.UserName}} +
+ {{.AuthorName}} + {{if .IsTeam}}Team{{end}} +
{{formatDateTime .CreatedAt}}

{{.Body}}

diff --git a/web/templates/pages/tickets/list.html b/web/templates/pages/tickets/list.html index 7f33936..d326260 100644 --- a/web/templates/pages/tickets/list.html +++ b/web/templates/pages/tickets/list.html @@ -24,7 +24,7 @@ {{.Title}} - {{.Repo.Name}} + {{.RepoName}} {{statusBadge (print .Status)}} {{formatDate .CreatedAt}}