Forgejo as the source of truth
This commit is contained in:
parent
2a21f6ba50
commit
cb21e0f6a2
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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{}{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue