package ytsync import ( "context" "fmt" "strings" "time" "task/internal/project" "task/internal/ticket" "task/internal/youtrack" ) // Result summarizes a pull or push sync. type Result struct { Created int Updated int Skipped int Total int Pushed int // creates + updates sent to YouTrack TimePushed int // work items created on YouTrack TimePulled int // work items imported locally } // SyncResult is the outcome of a full bidirectional sync. type SyncResult struct { Push Result Pull Result } // YouTrackSyncer syncs issues with the local ticket store. type YouTrackSyncer struct { Store ticket.Store TimeStore ticket.TimeStore Client *youtrack.Client } func (s *YouTrackSyncer) clientFor(yt project.YouTrack) *youtrack.Client { client := s.Client if client == nil { return &youtrack.Client{BaseURL: yt.BaseURL, Token: yt.Token} } client.BaseURL = yt.BaseURL client.Token = yt.Token return client } // MapStatus converts a YouTrack State name to a local Status. func MapStatus(state string) ticket.Status { s := strings.ToLower(strings.TrimSpace(state)) switch { case s == "": return ticket.StatusTodo case strings.Contains(s, "progress"), strings.Contains(s, "wip"), s == "ongoing", s == "doing", s == "in progress", strings.Contains(s, "bearbeitung"), strings.Contains(s, "arbeit"): return ticket.StatusInProgress case strings.Contains(s, "done"), strings.Contains(s, "fixed"), strings.Contains(s, "complete"), strings.Contains(s, "resolved"), strings.Contains(s, "obsolete"), strings.Contains(s, "closed"), strings.Contains(s, "erledigt"), strings.Contains(s, "fertig"), s == "verified", s == "duplicate", s == "won't fix", s == "cant reproduce": return ticket.StatusDone default: return ticket.StatusTodo } } // MapState converts a local Status to a YouTrack State name. func MapState(st ticket.Status) string { switch st { case ticket.StatusInProgress: return "In Progress" case ticket.StatusDone: return "Fixed" default: return "Open" } } // NeedsPush reports whether a local ticket should be sent to YouTrack. func NeedsPush(t ticket.Ticket) bool { if t.ExternalID == "" { // Local-only ticket in a YT-configured project → create remotely. return true } if t.Source != ticket.SourceYouTrack { return false } if t.SyncedAt.IsZero() { return true } return t.UpdatedAt.After(t.SyncedAt) } // Pull syncs YouTrack issues into projectID using yt settings. // Local tickets that still need a push (dirty) are not overwritten. func (s *YouTrackSyncer) Pull(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) { if !yt.Configured() { return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID) } if s.Store == nil { return Result{}, fmt.Errorf("ticket store required") } client := s.clientFor(yt) issues, err := client.ListIssues(ctx, yt.IssueQuery()) if err != nil { return Result{}, err } existing, err := s.Store.List(projectID) if err != nil { return Result{}, err } byExt := make(map[string]ticket.Ticket, len(existing)) for _, t := range existing { if t.ExternalID != "" { byExt[t.ExternalID] = t } } var res Result res.Total = len(issues) for _, iss := range issues { if iss.IDReadable == "" { res.Skipped++ continue } status := MapStatus(iss.State) issueURL := client.IssueURL(iss.IDReadable) title := iss.Summary if title == "" { title = iss.IDReadable } if prev, ok := byExt[iss.IDReadable]; ok { if NeedsPush(prev) { res.Skipped++ continue } next := prev next.Title = title next.Description = iss.Description // Only apply remote status when we actually parsed a state field. // Empty state used to map to OPEN and wiped local WIP/DONE on every pull. if iss.State != "" { next.Status = status } next.Source = ticket.SourceYouTrack next.ExternalURL = issueURL if sameSyncedFields(prev, next) { if err := s.Store.MarkSynced(prev.ID); err != nil { return res, fmt.Errorf("touch %s: %w", iss.IDReadable, err) } res.Skipped++ continue } if err := s.Store.Update(next); err != nil { return res, fmt.Errorf("update %s: %w", iss.IDReadable, err) } if err := s.Store.MarkSynced(next.ID); err != nil { return res, fmt.Errorf("mark synced %s: %w", iss.IDReadable, err) } res.Updated++ continue } now := time.Now().UTC() created, err := s.Store.Create(ticket.Ticket{ Title: title, Description: iss.Description, Status: status, ProjectID: projectID, Source: ticket.SourceYouTrack, ExternalID: iss.IDReadable, ExternalURL: issueURL, CreatedAt: now, UpdatedAt: now, SyncedAt: now, }) if err != nil { return res, fmt.Errorf("create %s: %w", iss.IDReadable, err) } _ = created res.Created++ } if err := s.pullTimeEntries(ctx, client, projectID, &res); err != nil { return res, err } return res, nil } // Push sends local tickets to YouTrack: creates for tickets without ExternalID, // updates for dirty synced tickets, then pushes unsynced time entries. func (s *YouTrackSyncer) Push(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) { if !yt.Configured() { return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID) } if s.Store == nil { return Result{}, fmt.Errorf("ticket store required") } client := s.clientFor(yt) existing, err := s.Store.List(projectID) if err != nil { return Result{}, err } var res Result for _, t := range existing { if !NeedsPush(t) { res.Skipped++ continue } res.Total++ if t.ExternalID == "" { if err := s.pushCreate(ctx, client, yt, t); err != nil { return res, err } res.Created++ res.Pushed++ continue } if err := s.pushUpdate(ctx, client, t); err != nil { return res, err } res.Updated++ res.Pushed++ } if err := s.pushTimeEntries(ctx, client, projectID, &res); err != nil { return res, err } return res, nil } func (s *YouTrackSyncer) pushTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error { if s.TimeStore == nil { return nil } tickets, err := s.Store.List(projectID) if err != nil { return err } byID := make(map[string]ticket.Ticket, len(tickets)) for _, t := range tickets { byID[t.ID] = t } entries, err := s.TimeStore.List() if err != nil { return err } for _, e := range entries { if e.ProjectID != projectID || e.ExternalID != "" { continue } t, ok := byID[e.TicketID] if !ok || t.ExternalID == "" { continue // ticket not linked to YouTrack yet } minutes := durationMinutes(e.Duration) text := e.Note if text == "" && e.Source != "" { text = "logged via task-master (" + e.Source + ")" } wi, err := client.CreateWorkItem(ctx, t.ExternalID, minutes, e.Start, text) if err != nil { return fmt.Errorf("push time for %s: %w", t.ExternalID, err) } e.ExternalID = wi.ID if err := s.TimeStore.Update(e); err != nil { return fmt.Errorf("link time entry %s: %w", e.ID, err) } res.TimePushed++ } return nil } func (s *YouTrackSyncer) pullTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error { if s.TimeStore == nil { return nil } tickets, err := s.Store.List(projectID) if err != nil { return err } local, err := s.TimeStore.List() if err != nil { return err } haveExt := make(map[string]bool) for _, e := range local { if e.ExternalID != "" { haveExt[e.ExternalID] = true } } for _, t := range tickets { if t.ExternalID == "" { continue } items, err := client.ListWorkItems(ctx, t.ExternalID) if err != nil { return fmt.Errorf("list work items %s: %w", t.ExternalID, err) } for _, wi := range items { if wi.ID == "" || haveExt[wi.ID] { continue } dur := time.Duration(wi.Minutes) * time.Minute if dur <= 0 { continue } start := wi.Date if start.IsZero() { start = time.Now().UTC() } _, err := s.TimeStore.Create(ticket.TimeEntry{ TicketID: t.ID, ProjectID: projectID, Start: start, End: start.Add(dur), Duration: dur, Source: ticket.SourceYouTrackTime, Note: wi.Text, ExternalID: wi.ID, }) if err != nil { return fmt.Errorf("import work item %s: %w", wi.ID, err) } haveExt[wi.ID] = true res.TimePulled++ } } return nil } func durationMinutes(d time.Duration) int { if d <= 0 { return 1 } m := int((d + time.Minute - 1) / time.Minute) // round up if m < 1 { return 1 } return m } // PushTicket creates or updates a single ticket on YouTrack. func (s *YouTrackSyncer) PushTicket(ctx context.Context, yt project.YouTrack, t ticket.Ticket) (ticket.Ticket, error) { if !yt.Configured() { return t, fmt.Errorf("youtrack not configured") } if s.Store == nil { return t, fmt.Errorf("ticket store required") } if !NeedsPush(t) { return t, nil } client := s.clientFor(yt) if t.ExternalID == "" { if err := s.pushCreate(ctx, client, yt, t); err != nil { return t, err } } else { if err := s.pushUpdate(ctx, client, t); err != nil { return t, err } } updated, err := s.Store.Get(t.ID) if err != nil { return t, err } return updated, nil } func (s *YouTrackSyncer) pushCreate(ctx context.Context, client *youtrack.Client, yt project.YouTrack, t ticket.Ticket) error { iss, err := client.CreateIssue(ctx, yt.ProjectKey, t.Title, t.Description, MapState(t.Status)) if err != nil { return fmt.Errorf("create remote for %s: %w", t.ID, err) } t.Source = ticket.SourceYouTrack t.ExternalID = iss.IDReadable t.ExternalURL = client.IssueURL(iss.IDReadable) if err := s.Store.Update(t); err != nil { return fmt.Errorf("link %s → %s: %w", t.ID, iss.IDReadable, err) } if err := s.Store.MarkSynced(t.ID); err != nil { return fmt.Errorf("mark synced %s: %w", iss.IDReadable, err) } return nil } func (s *YouTrackSyncer) pushUpdate(ctx context.Context, client *youtrack.Client, t ticket.Ticket) error { // Title/description only — state is applied separately when we can read the remote field. _, err := client.UpdateIssue(ctx, t.ExternalID, t.Title, t.Description, "") if err != nil { return fmt.Errorf("update remote %s: %w", t.ExternalID, err) } if remote, err := client.GetIssue(ctx, t.ExternalID); err == nil && remote.State != "" { if MapStatus(remote.State) != t.Status { _ = client.SetState(ctx, t.ExternalID, MapState(t.Status)) } } t.Source = ticket.SourceYouTrack t.ExternalURL = client.IssueURL(t.ExternalID) if err := s.Store.Update(t); err != nil { return fmt.Errorf("update local %s: %w", t.ExternalID, err) } if err := s.Store.MarkSynced(t.ID); err != nil { return fmt.Errorf("mark synced %s: %w", t.ExternalID, err) } return nil } // Sync runs Push then Pull for a project. func (s *YouTrackSyncer) Sync(ctx context.Context, projectID string, yt project.YouTrack) (SyncResult, error) { var out SyncResult var err error out.Push, err = s.Push(ctx, projectID, yt) if err != nil { return out, err } out.Pull, err = s.Pull(ctx, projectID, yt) return out, err } func sameSyncedFields(a, b ticket.Ticket) bool { return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status && a.ExternalURL == b.ExternalURL && a.Source == b.Source } // MetaLoader loads per-project YouTrack settings. type MetaLoader interface { LoadMeta(id string) (project.Meta, error) } func loadYT(projects MetaLoader, projectID string) (project.YouTrack, error) { meta, err := projects.LoadMeta(projectID) if err != nil { return project.YouTrack{}, err } if meta.YouTrack == nil || !meta.YouTrack.Configured() { return project.YouTrack{}, fmt.Errorf("project %s has no YouTrack config (edit project: URL, token, key)", projectID) } return *meta.YouTrack, nil } // SyncProject pushes local changes then pulls remote issues. func SyncProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (SyncResult, error) { yt, err := loadYT(projects, projectID) if err != nil { return SyncResult{}, err } s := &YouTrackSyncer{Store: store, TimeStore: timeStore} return s.Sync(ctx, projectID, yt) } // PullProject loads meta for projectID and runs Pull (no push). func PullProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) { yt, err := loadYT(projects, projectID) if err != nil { return Result{}, err } s := &YouTrackSyncer{Store: store, TimeStore: timeStore} return s.Pull(ctx, projectID, yt) } // PushProject loads meta for projectID and runs Push (no pull). func PushProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) { yt, err := loadYT(projects, projectID) if err != nil { return Result{}, err } s := &YouTrackSyncer{Store: store, TimeStore: timeStore} return s.Push(ctx, projectID, yt) }