Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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
|
||||
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,
|
||||
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.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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user