Add YouTrack pull/push sync with project settings and time tracking.
Wire manual Y menu and CLI sync for issues and work items, persist per-project credentials, and keep local edits until an explicit push. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
package ytsync_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
"task/internal/youtrack"
|
||||
"task/internal/ytsync"
|
||||
)
|
||||
|
||||
func TestMapStatus(t *testing.T) {
|
||||
cases := map[string]ticket.Status{
|
||||
"": ticket.StatusTodo,
|
||||
"Open": ticket.StatusTodo,
|
||||
"In Progress": ticket.StatusInProgress,
|
||||
"Fixed": ticket.StatusDone,
|
||||
"Done": ticket.StatusDone,
|
||||
"Obsolete": ticket.StatusDone,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := ytsync.MapStatus(in); got != want {
|
||||
t.Fatalf("MapStatus(%q) = %s, want %s", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapState(t *testing.T) {
|
||||
if ytsync.MapState(ticket.StatusTodo) != "Open" {
|
||||
t.Fatal("todo")
|
||||
}
|
||||
if ytsync.MapState(ticket.StatusInProgress) != "In Progress" {
|
||||
t.Fatal("wip")
|
||||
}
|
||||
if ytsync.MapState(ticket.StatusDone) != "Fixed" {
|
||||
t.Fatal("done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsPush(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
local := ticket.Ticket{Title: "x"}
|
||||
if !ytsync.NeedsPush(local) {
|
||||
t.Fatal("local without external id should push")
|
||||
}
|
||||
synced := ticket.Ticket{
|
||||
Source: ticket.SourceYouTrack, ExternalID: "P-1",
|
||||
UpdatedAt: now, SyncedAt: now,
|
||||
}
|
||||
if ytsync.NeedsPush(synced) {
|
||||
t.Fatal("freshly synced should not push")
|
||||
}
|
||||
dirty := synced
|
||||
dirty.UpdatedAt = now.Add(time.Minute)
|
||||
if !ytsync.NeedsPush(dirty) {
|
||||
t.Fatal("dirty should push")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullCreateAndUpdate(t *testing.T) {
|
||||
var calls int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if calls == 1 {
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"One","description":"d1","updated":1,
|
||||
"customFields":[{"name":"State","value":{"name":"Open"}}]}
|
||||
]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"One updated","description":"d2","updated":2,
|
||||
"customFields":[{"name":"State","value":{"name":"In Progress"}}]},
|
||||
{"idReadable":"P-2","summary":"Two","description":"","updated":2,
|
||||
"customFields":[{"name":"State","value":{"name":"Fixed"}}]}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
|
||||
r1, err := syncer.Pull(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("pull1: %v", err)
|
||||
}
|
||||
if r1.Created != 1 || r1.Updated != 0 {
|
||||
t.Fatalf("r1 = %+v", r1)
|
||||
}
|
||||
|
||||
r2, err := syncer.Pull(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("pull2: %v", err)
|
||||
}
|
||||
if r2.Created != 1 || r2.Updated != 1 {
|
||||
t.Fatalf("r2 = %+v", r2)
|
||||
}
|
||||
|
||||
list, err := store.List("inbox")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("len = %d", len(list))
|
||||
}
|
||||
byExt := map[string]ticket.Ticket{}
|
||||
for _, tk := range list {
|
||||
byExt[tk.ExternalID] = tk
|
||||
}
|
||||
if byExt["P-1"].Title != "One updated" || byExt["P-1"].Status != ticket.StatusInProgress {
|
||||
t.Fatalf("P-1 = %+v", byExt["P-1"])
|
||||
}
|
||||
if byExt["P-2"].Status != ticket.StatusDone || byExt["P-2"].Source != ticket.SourceYouTrack {
|
||||
t.Fatalf("P-2 = %+v", byExt["P-2"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushCreateAndUpdate(t *testing.T) {
|
||||
var createdBody, updatedBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/admin/projects/P":
|
||||
_, _ = w.Write([]byte(`{"id":"0-1","shortName":"P"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/issues":
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(b, &createdBody)
|
||||
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local","description":"d"}`))
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/issues/P-10"):
|
||||
_, _ = w.Write([]byte(`{
|
||||
"idReadable":"P-10","summary":"Local","description":"d",
|
||||
"customFields":[{"id":"1","name":"State","$type":"StateIssueCustomField","value":{"name":"Open"}}]
|
||||
}`))
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/api/issues/P-10"):
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
if _, ok := payload["customFields"]; ok {
|
||||
// state set
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
_ = json.Unmarshal(b, &updatedBody)
|
||||
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local2","description":"d2"}`))
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/issues":
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
local, err := store.Create(ticket.Ticket{
|
||||
Title: "Local", Description: "d", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
|
||||
push, err := syncer.Push(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("push: %v", err)
|
||||
}
|
||||
if push.Created != 1 {
|
||||
t.Fatalf("push = %+v", push)
|
||||
}
|
||||
if createdBody["summary"] != "Local" {
|
||||
t.Fatalf("created body = %+v", createdBody)
|
||||
}
|
||||
|
||||
got, err := store.Get(local.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ExternalID != "P-10" || got.Source != ticket.SourceYouTrack {
|
||||
t.Fatalf("linked = %+v", got)
|
||||
}
|
||||
if ytsync.NeedsPush(got) {
|
||||
t.Fatalf("should not need push after create: updated=%v synced=%v", got.UpdatedAt, got.SyncedAt)
|
||||
}
|
||||
|
||||
got.Title = "Local2"
|
||||
got.Status = ticket.StatusInProgress
|
||||
got.UpdatedAt = time.Now().UTC().Add(time.Minute)
|
||||
if err := store.Update(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = store.Get(local.ID)
|
||||
if !ytsync.NeedsPush(got) {
|
||||
t.Fatal("expected dirty after local edit")
|
||||
}
|
||||
|
||||
push2, err := syncer.Push(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatalf("push2: %v", err)
|
||||
}
|
||||
if push2.Updated != 1 {
|
||||
t.Fatalf("push2 = %+v", push2)
|
||||
}
|
||||
if updatedBody["summary"] != "Local2" {
|
||||
t.Fatalf("updated body = %+v", updatedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushTimeEntries(t *testing.T) {
|
||||
var workPosts int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
|
||||
workPosts++
|
||||
_, _ = w.Write([]byte(`{"id":"wi-9","date":1,"duration":{"minutes":15},"text":"n"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
timeStore := ticket.NewJSONTimeStore(dir)
|
||||
tk, err := store.Create(ticket.Ticket{
|
||||
Title: "T", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.MarkSynced(tk.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = timeStore.Create(ticket.TimeEntry{
|
||||
TicketID: tk.ID, ProjectID: "inbox",
|
||||
Duration: 15 * time.Minute, Source: ticket.SourceManual, Note: "n",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "TAS"}
|
||||
res, err := syncer.Push(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.TimePushed != 1 || workPosts != 1 {
|
||||
t.Fatalf("res=%+v posts=%d", res, workPosts)
|
||||
}
|
||||
entries, _ := timeStore.ListByTicket(tk.ID)
|
||||
if entries[0].ExternalID != "wi-9" {
|
||||
t.Fatalf("entry = %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullKeepsStatusWhenRemoteStateEmpty(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.Contains(r.URL.Path, "/timeTracking/") {
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
return
|
||||
}
|
||||
// No State custom field → empty remote state must not wipe local WIP.
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"One","description":"d","updated":2,"customFields":[]}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
_, err := store.Create(ticket.Ticket{
|
||||
Title: "One", ProjectID: "inbox", Status: ticket.StatusInProgress,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "P-1",
|
||||
UpdatedAt: now, SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
if _, err := syncer.Pull(context.Background(), "inbox", yt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, _ := store.List("inbox")
|
||||
if list[0].Status != ticket.StatusInProgress {
|
||||
t.Fatalf("status = %s, want in_progress", list[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullSkipsDirtyLocal(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"idReadable":"P-1","summary":"Remote","description":"","updated":2,
|
||||
"customFields":[{"name":"State","value":{"name":"Open"}}]}
|
||||
]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
store := ticket.NewJSONStore(t.TempDir())
|
||||
now := time.Now().UTC()
|
||||
_, err := store.Create(ticket.Ticket{
|
||||
Title: "Local edit", ProjectID: "inbox", Status: ticket.StatusTodo,
|
||||
Source: ticket.SourceYouTrack, ExternalID: "P-1",
|
||||
UpdatedAt: now.Add(time.Hour), SyncedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
|
||||
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
|
||||
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
|
||||
|
||||
res, err := syncer.Pull(context.Background(), "inbox", yt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Skipped != 1 || res.Updated != 0 {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
list, _ := store.List("inbox")
|
||||
if list[0].Title != "Local edit" {
|
||||
t.Fatalf("should keep local title, got %q", list[0].Title)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user