Replace UUID-looking sync labels in TUI and web with keys like TAS-1, while keeping the remote UUID for task-master matching. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
8.3 KiB
Go
337 lines
8.3 KiB
Go
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)
|
|
}
|