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"
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{

View File

@ -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")

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))
}
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 {
return emailWrapper(fmt.Sprintf(`
<h2 style="color: #111;">Your account has been approved</h2>

View File

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

View File

@ -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 == "" {

View File

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

View File

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

View File

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

View File

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

View File

@ -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())

View File

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

View File

@ -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")

View File

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

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}}
<div class="space-y-4">
{{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">
<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>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>

View File

@ -67,7 +67,7 @@
<td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</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 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr>

View File

@ -30,9 +30,12 @@
{{if .Comments}}
<div class="space-y-4">
{{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">
<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>
</div>
<p class="text-sm text-gray-700 whitespace-pre-wrap">{{.Body}}</p>

View File

@ -24,7 +24,7 @@
<td class="px-4 py-3">
<a href="/tickets/{{.ID}}" class="text-sm font-medium text-blue-600 hover:text-blue-500">{{.Title}}</a>
</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 text-sm text-gray-500">{{formatDate .CreatedAt}}</td>
</tr>