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}} |