diff --git a/cmd/server/main.go b/cmd/server/main.go index 8fa56f5..0596ffb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -54,7 +54,7 @@ func main() { defer cancel() go sessionStore.Cleanup(ctx, 30*time.Minute) - go forgejosync.SyncUnsyncedTickets(ctx, db, forgejoClient) + go forgejosync.SyncUnsynced(ctx, db, forgejoClient) publicRouter := publichandlers.NewRouter(publichandlers.Dependencies{ DB: db, diff --git a/internal/sync/sync.go b/internal/sync/sync.go index e8aad89..cf79d63 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -17,42 +17,18 @@ const ( maxRetries = 10 ) -// SyncUnsyncedTickets runs a background loop that finds tickets without a -// Forgejo issue number and creates issues for them, retrying with exponential -// backoff. It stops when ctx is cancelled or all tickets are synced. -func SyncUnsyncedTickets(ctx context.Context, db *gorm.DB, client *forgejo.Client) { +// 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++ { - 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") - if !sleep(ctx, backoff) { - return - } - backoff = nextBackoff(backoff) - continue - } + ticketsFailed := syncUnsyncedTickets(db, client) + commentsFailed := syncUnsyncedComments(db, client) - if len(tickets) == 0 { - log.Info().Msg("sync: all tickets synced") - return - } - - log.Info().Int("count", len(tickets)).Msg("sync: found unsynced tickets") - - allSynced := true - for _, ticket := range tickets { - if err := syncTicket(ctx, db, client, ticket); err != nil { - log.Error().Err(err).Str("ticket_id", ticket.ID.String()).Msg("sync: failed to sync ticket") - allSynced = false - } - } - - if allSynced { - log.Info().Msg("sync: all tickets synced successfully") + if !ticketsFailed && !commentsFailed { + log.Info().Msg("sync: everything synced successfully") return } @@ -62,11 +38,70 @@ func SyncUnsyncedTickets(ctx context.Context, db *gorm.DB, client *forgejo.Clien backoff = nextBackoff(backoff) } - log.Warn().Msg("sync: gave up after max retries, remaining unsynced tickets will be retried on next restart") + log.Warn().Msg("sync: gave up after max retries, remaining items will be retried on next restart") } -func syncTicket(_ context.Context, db *gorm.DB, client *forgejo.Client, ticket models.Ticket) error { - // Look up or create the "customer" label +// 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 { @@ -92,6 +127,23 @@ func syncTicket(_ context.Context, db *gorm.DB, client *forgejo.Client, ticket m 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():