Files
skrueckenandCursor efeb4a74a8 Assign readable ticket keys from project code and sequence.
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>
2026-10-28 00:09:14 +01:00

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)
}