Forgejo as the source of truth

This commit is contained in:
Matthew Knight 2026-02-14 02:04:27 -08:00
parent 2a21f6ba50
commit cb21e0f6a2
No known key found for this signature in database
18 changed files with 879 additions and 370 deletions

View File

@ -16,7 +16,6 @@ import (
"github.com/mattnite/forgejo-tickets/internal/forgejo" "github.com/mattnite/forgejo-tickets/internal/forgejo"
adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin" adminhandlers "github.com/mattnite/forgejo-tickets/internal/handlers/admin"
publichandlers "github.com/mattnite/forgejo-tickets/internal/handlers/public" 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/mattnite/forgejo-tickets/internal/templates"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -47,6 +46,14 @@ func main() {
emailClient := email.NewClient(cfg.PostmarkServerToken, cfg.PostmarkFromEmail, cfg.BaseURL) emailClient := email.NewClient(cfg.PostmarkServerToken, cfg.PostmarkFromEmail, cfg.BaseURL)
forgejoClient := forgejo.NewClient(cfg.ForgejoURL, cfg.ForgejoAPIToken) 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)) sessionStore := auth.NewPGStore(db, []byte(cfg.SessionSecret))
authService := auth.NewService(db, sessionStore, emailClient) authService := auth.NewService(db, sessionStore, emailClient)
@ -54,7 +61,6 @@ func main() {
defer cancel() defer cancel()
go sessionStore.Cleanup(ctx, 30*time.Minute) go sessionStore.Cleanup(ctx, 30*time.Minute)
go forgejosync.SyncUnsynced(ctx, db, forgejoClient)
publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{
DB: db, DB: db,
@ -71,6 +77,7 @@ func main() {
Renderer: renderer, Renderer: renderer,
Auth: authService, Auth: authService,
EmailClient: emailClient, EmailClient: emailClient,
ForgejoClient: forgejoClient,
Config: cfg, Config: cfg,
}) })

View File

@ -105,6 +105,26 @@ func (c *Client) SendAccountApprovedEmail(to, name string) error {
return err 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 { func (c *Client) SendWelcomeEmail(to, name, tempPassword string) error {
if c.server == nil { if c.server == nil {
return fmt.Errorf("email client not configured") return fmt.Errorf("email client not configured")

View File

@ -51,6 +51,16 @@ func renderTicketClosedEmail(name, ticketTitle, ticketURL string) string {
<p>If you believe the issue is not fully resolved, you can add a comment on the ticket page.</p>`, name, ticketTitle, ticketURL)) <p>If you believe the issue is not fully resolved, you can add a comment on the ticket page.</p>`, name, ticketTitle, ticketURL))
} }
func renderTicketReplyEmail(name, ticketTitle, ticketURL string) string {
return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">New reply on your ticket</h2>
<p>Hi %s,</p>
<p>There is a new reply on your ticket <strong>"%s"</strong>.</p>
<p style="margin: 30px 0;">
<a href="%s" style="background: #2563eb; color: #fff; padding: 12px 24px; text-decoration: none; border-radius: 6px; font-weight: 500;">View Ticket</a>
</p>`, name, ticketTitle, ticketURL))
}
func renderAccountApprovedEmail(name, loginURL string) string { func renderAccountApprovedEmail(name, loginURL string) string {
return emailWrapper(fmt.Sprintf(` return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">Your account has been approved</h2> <h2 style="color: #111;">Your account has been approved</h2>

View File

@ -8,6 +8,8 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings"
"time" "time"
) )
@ -15,6 +17,7 @@ type Client struct {
baseURL string baseURL string
apiToken string apiToken string
httpClient *http.Client httpClient *http.Client
BotLogin string
} }
func NewClient(baseURL, apiToken string) *Client { func NewClient(baseURL, apiToken string) *Client {
@ -33,6 +36,10 @@ type CreateIssueRequest struct {
Labels []int64 `json:"labels,omitempty"` Labels []int64 `json:"labels,omitempty"`
} }
type EditIssueRequest struct {
State string `json:"state,omitempty"`
}
type Label struct { type Label struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@ -41,7 +48,10 @@ type Label struct {
type Issue struct { type Issue struct {
Number int64 `json:"number"` Number int64 `json:"number"`
Title string `json:"title"` Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"` State string `json:"state"`
Labels []Label `json:"labels"`
CreatedAt time.Time `json:"created_at"`
} }
type CreateCommentRequest struct { type CreateCommentRequest struct {
@ -51,6 +61,86 @@ type CreateCommentRequest struct {
type Comment struct { type Comment struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Body string `json:"body"` 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) { func GenerateWebhookSecret() (string, error) {
@ -61,6 +151,16 @@ func GenerateWebhookSecret() (string, error) {
return hex.EncodeToString(b), nil 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) { func (c *Client) GetOrCreateLabel(owner, repo, labelName, color string) (*Label, error) {
// Try to find existing label // Try to find existing label
listURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/labels", c.baseURL, owner, repo) 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) { 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) body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err 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) { 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) body, err := json.Marshal(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
httpReq, err := http.NewRequest("POST", url, bytes.NewReader(body)) httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -186,3 +286,199 @@ func (c *Client) CreateComment(owner, repo string, issueNumber int64, req Create
return &comment, nil 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
}

View File

@ -13,6 +13,7 @@ import (
type WebhookPayload struct { type WebhookPayload struct {
Action string `json:"action"` Action string `json:"action"`
Issue WebhookIssue `json:"issue"` Issue WebhookIssue `json:"issue"`
Comment WebhookComment `json:"comment"`
} }
type WebhookIssue struct { type WebhookIssue struct {
@ -21,6 +22,17 @@ type WebhookIssue struct {
State string `json:"state"` 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) { func VerifyWebhookSignature(r *http.Request, secret string) ([]byte, error) {
signature := r.Header.Get("X-Forgejo-Signature") signature := r.Header.Get("X-Forgejo-Signature")
if signature == "" { if signature == "" {

View File

@ -2,6 +2,7 @@ package admin
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/models" "github.com/mattnite/forgejo-tickets/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -21,19 +22,63 @@ func (h *DashboardHandler) Index(c *gin.Context) {
log.Error().Err(err).Msg("count tickets error") log.Error().Err(err).Msg("count tickets error")
} }
var openTickets int64 // Get distinct repos that have tickets
if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusOpen).Count(&openTickets).Error; err != nil { type repoInfo struct {
log.Error().Err(err).Msg("count open tickets error") 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 var openTickets, inProgressTickets, closedTickets int64
if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusInProgress).Count(&inProgressTickets).Error; err != nil { for _, repo := range repos {
log.Error().Err(err).Msg("count in_progress tickets error") 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 key := repo.ForgejoOwner + "/" + repo.ForgejoRepo
if err := h.deps.DB.Model(&models.Ticket{}).Where("status = ?", models.TicketStatusClosed).Count(&closedTickets).Error; err != nil { known := knownIssues[key]
log.Error().Err(err).Msg("count closed tickets error")
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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/dashboard", map[string]interface{}{

View File

@ -5,6 +5,7 @@ import (
"github.com/mattnite/forgejo-tickets/internal/auth" "github.com/mattnite/forgejo-tickets/internal/auth"
"github.com/mattnite/forgejo-tickets/internal/config" "github.com/mattnite/forgejo-tickets/internal/config"
"github.com/mattnite/forgejo-tickets/internal/email" "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/middleware"
"github.com/mattnite/forgejo-tickets/internal/templates" "github.com/mattnite/forgejo-tickets/internal/templates"
"gorm.io/gorm" "gorm.io/gorm"
@ -15,6 +16,7 @@ type Dependencies struct {
Renderer *templates.Renderer Renderer *templates.Renderer
Auth *auth.Service Auth *auth.Service
EmailClient *email.Client EmailClient *email.Client
ForgejoClient *forgejo.Client
Config *config.Config Config *config.Config
} }

View File

@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/models" "github.com/mattnite/forgejo-tickets/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -14,32 +15,117 @@ type TicketHandler struct {
} }
type ticketListRow struct { type ticketListRow struct {
models.Ticket ID uuid.UUID
Title string
Status string
RepoName string RepoName string
RepoSlug string RepoSlug string
UserEmail string UserEmail string
UserName string UserName string
CreatedAt interface{}
} }
func (h *TicketHandler) List(c *gin.Context) { func (h *TicketHandler) List(c *gin.Context) {
statusFilter := c.Query("status") statusFilter := c.Query("status")
var tickets []ticketListRow // Load all ticket mappings with User and Repo joins
query := h.deps.DB.Table("tickets"). type ticketMapping struct {
Select("tickets.*, repos.name as repo_name, repos.slug as repo_slug, users.email as user_email, users.name as user_name"). models.Ticket
Joins("JOIN repos ON repos.id = tickets.repo_id"). RepoName string
Joins("JOIN users ON users.id = tickets.user_id") RepoSlug string
ForgejoOwner string
if statusFilter != "" { ForgejoRepo string
query = query.Where("tickets.status = ?", statusFilter) 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") log.Error().Err(err).Msg("list tickets error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets")
return 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{
"Tickets": tickets, "Tickets": tickets,
"StatusFilter": statusFilter, "StatusFilter": statusFilter,
@ -65,20 +151,33 @@ func (h *TicketHandler) Detail(c *gin.Context) {
var repo models.Repo var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID) h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
var comments []struct { // Fetch issue and comments from Forgejo
models.TicketComment issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
UserName string if err != nil {
UserEmail string 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"). rawComments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
Joins("JOIN users ON users.id = ticket_comments.user_id"). if err != nil {
Where("ticket_comments.ticket_id = ?", ticket.ID). log.Error().Err(err).Msg("forgejo list comments error")
Order("ticket_comments.created_at ASC"). rawComments = nil
Scan(&comments) }
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{}{ 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, "User": user,
"Repo": repo, "Repo": repo,
"Comments": comments, "Comments": comments,
@ -92,13 +191,55 @@ func (h *TicketHandler) UpdateStatus(c *gin.Context) {
return return
} }
status := models.TicketStatus(c.PostForm("status")) var ticket models.Ticket
if err := h.deps.DB.First(&ticket, "id = ?", ticketID).Error; err != nil {
if err := h.deps.DB.Model(&models.Ticket{}).Where("id = ?", ticketID).Update("status", status).Error; err != nil { h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusNotFound, "Ticket not found")
log.Error().Err(err).Msg("update ticket status error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to update status")
return 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()) c.Redirect(http.StatusSeeOther, "/tickets/"+ticketID.String())
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mattnite/forgejo-tickets/internal/forgejo"
"github.com/mattnite/forgejo-tickets/internal/models" "github.com/mattnite/forgejo-tickets/internal/models"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@ -43,9 +44,66 @@ func (h *UserHandler) Detail(c *gin.Context) {
return return
} }
// Load user's ticket mappings with repo info
var tickets []models.Ticket var tickets []models.Ticket
h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets) 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 // Load all repos and user's assigned repo IDs
var allRepos []models.Repo var allRepos []models.Repo
h.deps.DB.Where("active = ?", true).Order("name ASC").Find(&allRepos) 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{}{ h.deps.Renderer.Render(c.Writer, c.Request, "admin/users/detail", map[string]interface{}{
"User": user, "User": user,
"Tickets": tickets, "Tickets": ticketViews,
"AllRepos": allRepos, "AllRepos": allRepos,
"AssignedRepoIDs": assignedRepoIDs, "AssignedRepoIDs": assignedRepoIDs,
}) })

View File

@ -25,8 +25,73 @@ func (h *TicketHandler) List(c *gin.Context) {
return return
} }
if len(tickets) == 0 {
h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{
"Tickets": tickets, "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": views,
}) })
} }
@ -85,24 +150,14 @@ func (h *TicketHandler) Create(c *gin.Context) {
return return
} }
ticket := models.Ticket{ // Look up the repo
UserID: user.ID, var repo models.Repo
RepoID: repoID, if err := h.deps.DB.First(&repo, "id = ?", repoID).Error; err != nil {
Title: title, h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to find product")
Description: description,
}
if err := h.deps.DB.Create(&ticket).Error; err != nil {
log.Error().Err(err).Msg("create ticket error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to create ticket")
return return
} }
// Async Forgejo issue creation // Synchronous 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 var labelIDs []int64
label, err := h.deps.ForgejoClient.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer", "#0075ca") label, err := h.deps.ForgejoClient.GetOrCreateLabel(repo.ForgejoOwner, repo.ForgejoRepo, "customer", "#0075ca")
if err != nil { if err != nil {
@ -117,11 +172,22 @@ func (h *TicketHandler) Create(c *gin.Context) {
Labels: labelIDs, Labels: labelIDs,
}) })
if err != nil { if err != nil {
log.Error().Err(err).Msgf("forgejo create issue error for ticket %s", ticket.ID) 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 return
} }
h.deps.DB.Model(&ticket).Update("forgejo_issue_number", issue.Number)
}() // Create local ticket mapping
ticket := models.Ticket{
UserID: user.ID,
RepoID: repoID,
ForgejoIssueNumber: issue.Number,
}
if err := h.deps.DB.Create(&ticket).Error; err != nil {
log.Error().Err(err).Msg("create ticket error")
h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to create ticket")
return
} }
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
@ -150,20 +216,33 @@ func (h *TicketHandler) Detail(c *gin.Context) {
var repo models.Repo var repo models.Repo
h.deps.DB.First(&repo, "id = ?", ticket.RepoID) h.deps.DB.First(&repo, "id = ?", ticket.RepoID)
var comments []struct { // Fetch issue and comments from Forgejo
models.TicketComment issue, err := h.deps.ForgejoClient.GetIssue(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
UserName string if err != nil {
UserEmail string 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"). rawComments, err := h.deps.ForgejoClient.ListIssueComments(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber)
Joins("JOIN users ON users.id = ticket_comments.user_id"). if err != nil {
Where("ticket_comments.ticket_id = ?", ticket.ID). log.Error().Err(err).Msg("forgejo list comments error")
Order("ticket_comments.created_at ASC"). rawComments = nil // Show ticket without comments on error
Scan(&comments) }
// 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{}{ 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, "Repo": repo,
"Comments": comments, "Comments": comments,
}) })
@ -195,34 +274,21 @@ func (h *TicketHandler) AddComment(c *gin.Context) {
return return
} }
comment := models.TicketComment{ // Post comment directly to Forgejo
TicketID: ticket.ID, var repo models.Repo
UserID: user.ID, if err := h.deps.DB.First(&repo, "id = ?", ticket.RepoID).Error; err != nil {
Body: body, h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to find product")
}
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")
return return
} }
// Async sync to Forgejo _, err = h.deps.ForgejoClient.CreateComment(repo.ForgejoOwner, repo.ForgejoRepo, ticket.ForgejoIssueNumber, forgejo.CreateCommentRequest{
if ticket.ForgejoIssueNumber != nil { Body: body + "\n\n---\n*Customer comment by: " + user.Email + "*",
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 { if err != nil {
log.Error().Err(err).Msg("forgejo create comment error") 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 return
} }
h.deps.DB.Model(&comment).Update("forgejo_comment_id", forgejoComment.ID)
}()
}
}
c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String()) c.Redirect(http.StatusSeeOther, "/tickets/"+ticket.ID.String())
} }

View File

@ -47,6 +47,25 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) {
return 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" { if payload.Action != "closed" {
c.Status(http.StatusOK) c.Status(http.StatusOK)
return return
@ -54,22 +73,17 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) {
var ticket models.Ticket 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 { 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) c.Status(http.StatusOK)
return return
} }
if err := h.deps.DB.Model(&ticket).Update("status", models.TicketStatusClosed).Error; err != nil { // Send email notification to ticket owner
log.Error().Err(err).Msg("webhook: update ticket status error")
c.String(http.StatusInternalServerError, "Internal error")
return
}
var user models.User var user models.User
if err := h.deps.DB.First(&user, "id = ?", ticket.UserID).Error; err == nil { if err := h.deps.DB.First(&user, "id = ?", ticket.UserID).Error; err == nil {
go func() { go func() {
if err := h.deps.EmailClient.SendTicketClosedNotification( 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 { ); err != nil {
log.Error().Err(err).Msg("webhook: send notification error") log.Error().Err(err).Msg("webhook: send notification error")
} }
@ -78,3 +92,37 @@ func (h *WebhookHandler) HandleForgejoWebhook(c *gin.Context) {
c.Status(http.StatusOK) 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)
}

View File

@ -7,14 +7,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
type TicketStatus string
const (
TicketStatusOpen TicketStatus = "open"
TicketStatusInProgress TicketStatus = "in_progress"
TicketStatusClosed TicketStatus = "closed"
)
type TokenType string type TokenType string
const ( const (
@ -74,28 +66,13 @@ type Repo struct {
} }
type Ticket struct { type Ticket struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` UserID uuid.UUID `gorm:"type:uuid;not null;index"`
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE" json:"-"` User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
RepoID uuid.UUID `gorm:"type:uuid;not null;index" json:"repo_id"` RepoID uuid.UUID `gorm:"type:uuid;not null;index"`
Repo Repo `gorm:"foreignKey:RepoID;constraint:OnDelete:CASCADE" json:"-"` Repo Repo `gorm:"foreignKey:RepoID;constraint:OnDelete:CASCADE"`
Title string `gorm:"not null" json:"title"` ForgejoIssueNumber int64 `gorm:"not null"`
Description string `gorm:"not null" json:"description"` CreatedAt time.Time `gorm:"not null;default:now()"`
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"`
} }
type EmailToken struct { type EmailToken struct {
@ -109,19 +86,35 @@ type EmailToken struct {
} }
// AutoMigrate runs GORM auto-migration for all models. // 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 { func AutoMigrate(db *gorm.DB) error {
// Create enum types if they don't exist // 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 $$;") 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( if err := db.AutoMigrate(
&User{}, &User{},
&OAuthAccount{}, &OAuthAccount{},
&Session{}, &Session{},
&Repo{}, &Repo{},
&Ticket{}, &Ticket{},
&TicketComment{},
&EmailToken{}, &EmailToken{},
&UserRepo{}, &UserRepo{},
); err != nil { ); err != nil {
@ -131,8 +124,8 @@ func AutoMigrate(db *gorm.DB) error {
// Create unique composite index for oauth_accounts // 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)") 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 // 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) WHERE forgejo_issue_number IS NOT NULL") 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 // 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") db.Exec("UPDATE users SET approved = true WHERE approved = false AND email_verified = true")

View File

@ -4,23 +4,6 @@ import (
"testing" "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) { func TestTokenTypeConstants(t *testing.T) {
tests := []struct { tests := []struct {
tokenType TokenType 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) { func TestTokenTypeValues_AreDistinct(t *testing.T) {
types := []TokenType{ types := []TokenType{
TokenTypeVerifyEmail, TokenTypeVerifyEmail,

View File

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

View File

@ -44,9 +44,12 @@
{{if .Comments}} {{if .Comments}}
<div class="space-y-4"> <div class="space-y-4">
{{range .Comments}} {{range .Comments}}
<div class="bg-white p-4 rounded-lg shadow ring-1 ring-gray-200"> <div class="{{if .IsTeam}}bg-blue-50 ring-blue-200{{else}}bg-white ring-gray-200{{end}} p-4 rounded-lg shadow ring-1">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900">{{.UserName}} ({{.UserEmail}})</span> <div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900">{{.AuthorName}}</span>
{{if .IsTeam}}<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Team</span>{{end}}
</div>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span> <span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div> </div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p> <p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>

View File

@ -67,7 +67,7 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a> <a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-500">{{.Repo.Name}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td> <td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr> </tr>

View File

@ -30,9 +30,12 @@
{{if .Comments}} {{if .Comments}}
<div class="space-y-4"> <div class="space-y-4">
{{range .Comments}} {{range .Comments}}
<div class="bg-white p-4 rounded-lg shadow ring-1 ring-gray-200"> <div class="{{if .IsTeam}}bg-blue-50 ring-blue-200{{else}}bg-white ring-gray-200{{end}} p-4 rounded-lg shadow ring-1">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900">{{.UserName}}</span> <div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900">{{.AuthorName}}</span>
{{if .IsTeam}}<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Team</span>{{end}}
</div>
<span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span> <span class="text-xs text-gray-500">{{formatDateTime .CreatedAt}}</span>
</div> </div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p> <p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>

View File

@ -24,7 +24,7 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a> <a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</td> </td>
<td class="px-4 py-3 text-sm text-gray-500">{{.Repo.Name}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{.RepoName}}</td>
<td class="px-4 py-3">{{statusBadge (print .Status)}}</td> <td class="px-4 py-3">{{statusBadge (print .Status)}}</td>
<td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td> <td class="px-4 py-3 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr> </tr>