package forgejo import ( "context" "fmt" "strings" cairnapi "github.com/mattnite/cairn/internal/api" "github.com/rs/zerolog/log" "gorm.io/gorm" "github.com/mattnite/cairn/internal/models" ) // Sync handles bidirectional state synchronization between Cairn and Forgejo. type Sync struct { Client *Client DB *gorm.DB CairnURL string } // CreateIssueForCrashGroup creates a Forgejo issue for a new crash group. func (s *Sync) CreateIssueForCrashGroup(ctx context.Context, group *cairnapi.CrashGroup, sampleTrace string) error { if s.Client == nil { return nil } repo, err := models.GetRepositoryByID(ctx, s.DB, group.RepositoryID) if err != nil { return fmt.Errorf("getting repository: %w", err) } crashGroupLink := fmt.Sprintf("%d", group.ID) if s.CairnURL != "" { crashGroupLink = fmt.Sprintf("[%d](%s/crashgroups/%d)", group.ID, strings.TrimRight(s.CairnURL, "/"), group.ID) } body := fmt.Sprintf(`## Crash Group **Crash Group:** %s **Fingerprint:** `+"`%s`"+` **First seen:** %s **Type:** %s ### Sample Stack Trace `+"```"+` %s `+"```"+` --- *Auto-created by [Cairn](/) — crash artifact aggregator* `, crashGroupLink, group.Fingerprint, group.FirstSeenAt.Format("2006-01-02 15:04:05"), group.Title, sampleTrace) issue, err := s.Client.CreateIssue(ctx, repo.Owner, repo.Name, CreateIssueRequest{ Title: "[Cairn] " + group.Title, Body: body, }) if err != nil { return fmt.Errorf("creating issue: %w", err) } if err := s.DB.WithContext(ctx). Model(&models.CrashGroup{}). Where("id = ?", group.ID). Update("forgejo_issue_id", issue.Number).Error; err != nil { return err } issueURL := fmt.Sprintf("%s/%s/%s/issues/%d", strings.TrimRight(s.Client.BaseURL(), "/"), repo.Owner, repo.Name, issue.Number) group.ForgejoIssueURL = &issueURL return nil } // HandleIssueEvent processes a Forgejo issue webhook event for state sync. func (s *Sync) HandleIssueEvent(ctx context.Context, event *WebhookEvent) error { if event.Issue == nil { return nil } // Only handle issues that start with [Cairn] prefix. if !strings.HasPrefix(event.Issue.Title, "[Cairn] ") { return nil } switch event.Action { case "closed": return s.DB.WithContext(ctx). Model(&models.CrashGroup{}). Where("forgejo_issue_id = ?", event.Issue.Number). Update("status", "resolved").Error case "reopened": return s.DB.WithContext(ctx). Model(&models.CrashGroup{}). Where("forgejo_issue_id = ?", event.Issue.Number). Update("status", "open").Error } return nil } // HandlePushEvent processes a push webhook event for commit enrichment. func (s *Sync) HandlePushEvent(ctx context.Context, event *WebhookEvent) { if event.Repo == nil || event.After == "" { return } log.Info().Str("repo", event.Repo.FullName).Str("sha", event.After[:8]).Msg("Push event") }