package tmsync import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" "task/internal/project" "task/internal/ticket" "task/internal/ytsync" ) // Result reuses ytsync.Result for consistent CLI/UI messaging. type Result = ytsync.Result // Syncer syncs a local project with a remote tm_server project. type Syncer struct { Store ticket.Store TimeStore ticket.TimeStore HTTP *http.Client } func (s *Syncer) http() *http.Client { if s.HTTP != nil { return s.HTTP } return http.DefaultClient } // NeedsPush reports whether a local ticket should be pushed to task-master. func NeedsPush(t ticket.Ticket) bool { if t.ExternalID == "" { return true } if t.Source != ticket.SourceTaskMaster { return false } if t.SyncedAt.IsZero() { return true } return t.UpdatedAt.After(t.SyncedAt) } // Pull imports remote tickets/time into localProjectID. func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) { if !tm.Configured() { return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID) } if s.Store == nil { return Result{}, fmt.Errorf("ticket store required") } remote, err := s.listTickets(ctx, tm) if err != nil { return Result{}, err } existing, err := s.Store.List(localProjectID) if err != nil { return Result{}, err } byExt := map[string]ticket.Ticket{} for _, t := range existing { if t.ExternalID != "" { byExt[t.ExternalID] = t } } var res Result res.Total = len(remote) for _, rt := range remote { if rt.ID == "" { res.Skipped++ continue } extURL := strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + rt.ID if prev, ok := byExt[rt.ID]; ok { if NeedsPush(prev) { res.Skipped++ continue } next := prev next.Title = rt.Title next.Description = rt.Description next.Status = rt.Status next.Source = ticket.SourceTaskMaster next.ExternalURL = extURL next.Number = rt.Number next.Key = rt.Key if sameFields(prev, next) { _ = s.Store.MarkSynced(prev.ID) res.Skipped++ continue } if err := s.Store.Update(next); err != nil { return res, err } _ = s.Store.MarkSynced(next.ID) res.Updated++ continue } now := time.Now().UTC() _, err := s.Store.Create(ticket.Ticket{ Title: rt.Title, Description: rt.Description, Status: rt.Status, ProjectID: localProjectID, Source: ticket.SourceTaskMaster, ExternalID: rt.ID, ExternalURL: extURL, Number: rt.Number, Key: rt.Key, CreatedAt: now, UpdatedAt: now, SyncedAt: now, }) if err != nil { return res, err } res.Created++ } if err := s.pullTime(ctx, tm, localProjectID, &res); err != nil { return res, err } return res, nil } // Push sends dirty local tickets/time to the remote project. func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.TaskMaster) (Result, error) { if !tm.Configured() { return Result{}, fmt.Errorf("taskmaster not configured for project %s", localProjectID) } if s.Store == nil { return Result{}, fmt.Errorf("ticket store required") } existing, err := s.Store.List(localProjectID) if err != nil { return Result{}, err } var res Result for _, t := range existing { if !NeedsPush(t) { res.Skipped++ continue } res.Total++ if t.ExternalID == "" { created, err := s.createRemote(ctx, tm, t) if err != nil { return res, err } t.ExternalID = created.ID t.Number = created.Number t.Key = created.Key t.Source = ticket.SourceTaskMaster t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID if err := s.Store.Update(t); err != nil { return res, err } _ = s.Store.MarkSynced(t.ID) res.Created++ res.Pushed++ continue } if err := s.patchRemote(ctx, tm, t); err != nil { return res, err } _ = s.Store.MarkSynced(t.ID) res.Updated++ res.Pushed++ } if err := s.pushTime(ctx, tm, localProjectID, &res); err != nil { return res, err } return res, nil } func sameFields(a, b ticket.Ticket) bool { return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status && a.Key == b.Key && a.Number == b.Number } func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) { var out []ticket.Ticket err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/tickets", nil, &out) return out, err } func (s *Syncer) createRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) (ticket.Ticket, error) { body := map[string]any{ "title": t.Title, "description": t.Description, "status": t.Status, } var created ticket.Ticket err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/tickets", body, &created) return created, err } func (s *Syncer) patchRemote(ctx context.Context, tm project.TaskMaster, t ticket.Ticket) error { body := map[string]any{ "title": t.Title, "description": t.Description, "status": t.Status, } return s.doJSON(ctx, tm, http.MethodPatch, "/projects/"+tm.ProjectID+"/tickets/"+t.ExternalID, body, nil) } func (s *Syncer) pullTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error { if s.TimeStore == nil { return nil } var remote []ticket.TimeEntry if err := s.doJSON(ctx, tm, http.MethodGet, "/projects/"+tm.ProjectID+"/time", nil, &remote); err != nil { return err } localTickets, err := s.Store.List(localProjectID) if err != nil { return err } localByExt := map[string]ticket.Ticket{} for _, t := range localTickets { if t.ExternalID != "" { localByExt[t.ExternalID] = t } } existing, err := s.TimeStore.List() if err != nil { return err } have := map[string]bool{} for _, e := range existing { if e.ExternalID != "" { have[e.ExternalID] = true } } for _, e := range remote { if e.ID == "" || have[e.ID] { continue } lt, ok := localByExt[e.TicketID] if !ok { continue } _, err := s.TimeStore.Create(ticket.TimeEntry{ TicketID: lt.ID, ProjectID: localProjectID, Start: e.Start, End: e.End, Duration: e.Duration, Source: ticket.SourceTaskMaster, Note: e.Note, ExternalID: e.ID, }) if err != nil { return err } res.TimePulled++ } return nil } func (s *Syncer) pushTime(ctx context.Context, tm project.TaskMaster, localProjectID string, res *Result) error { if s.TimeStore == nil { return nil } tickets, err := s.Store.List(localProjectID) if err != nil { return err } byID := map[string]ticket.Ticket{} 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 != localProjectID || e.ExternalID != "" { continue } t, ok := byID[e.TicketID] if !ok || t.ExternalID == "" { continue } body := map[string]any{ "ticketId": t.ExternalID, "durationNs": int64(e.Duration), "note": e.Note, "source": e.Source, } var created ticket.TimeEntry if err := s.doJSON(ctx, tm, http.MethodPost, "/projects/"+tm.ProjectID+"/time", body, &created); err != nil { return err } e.ExternalID = created.ID if err := s.TimeStore.Update(e); err != nil { return err } res.TimePushed++ } return nil } func (s *Syncer) doJSON(ctx context.Context, tm project.TaskMaster, method, path string, body any, dest any) error { var rdr io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return err } rdr = bytes.NewReader(data) } url := strings.TrimRight(strings.TrimSpace(tm.BaseURL), "/") + "/api/v1" + path req, err := http.NewRequestWithContext(ctx, method, url, rdr) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+tm.Token) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := s.http().Do(req) if err != nil { return fmt.Errorf("taskmaster: %w", err) } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { var eresp struct { Error string `json:"error"` } _ = json.Unmarshal(data, &eresp) msg := eresp.Error if msg == "" { msg = string(data) } return fmt.Errorf("taskmaster: %s: %s", resp.Status, msg) } if dest == nil || len(data) == 0 { return nil } return json.Unmarshal(data, dest) }