forgejo-tickets/internal/sync/sync.go

111 lines
2.9 KiB
Go

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
)
// 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) {
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
}
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")
return
}
if !sleep(ctx, backoff) {
return
}
backoff = nextBackoff(backoff)
}
log.Warn().Msg("sync: gave up after max retries, remaining unsynced tickets 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
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 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
}