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 }