Add YouTrack pull/push sync with project settings and time tracking.
CI / test-and-build (push) Failing after 1m19s
CI / demo (push) Skipped

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:
2026-10-24 00:17:51 +02:00
co-authored by Cursor
parent 19ae1f1b62
commit 98d2253f44
23 changed files with 2874 additions and 44 deletions
+484
View File
@@ -0,0 +1,484 @@
package ytsync
import (
"context"
"fmt"
"strings"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/youtrack"
)
// Result summarizes a pull or push sync.
type Result struct {
Created int
Updated int
Skipped int
Total int
Pushed int // creates + updates sent to YouTrack
TimePushed int // work items created on YouTrack
TimePulled int // work items imported locally
}
// SyncResult is the outcome of a full bidirectional sync.
type SyncResult struct {
Push Result
Pull Result
}
// YouTrackSyncer syncs issues with the local ticket store.
type YouTrackSyncer struct {
Store ticket.Store
TimeStore ticket.TimeStore
Client *youtrack.Client
}
func (s *YouTrackSyncer) clientFor(yt project.YouTrack) *youtrack.Client {
client := s.Client
if client == nil {
return &youtrack.Client{BaseURL: yt.BaseURL, Token: yt.Token}
}
client.BaseURL = yt.BaseURL
client.Token = yt.Token
return client
}
// MapStatus converts a YouTrack State name to a local Status.
func MapStatus(state string) ticket.Status {
s := strings.ToLower(strings.TrimSpace(state))
switch {
case s == "":
return ticket.StatusTodo
case strings.Contains(s, "progress"), strings.Contains(s, "wip"),
s == "ongoing", s == "doing", s == "in progress",
strings.Contains(s, "bearbeitung"), strings.Contains(s, "arbeit"):
return ticket.StatusInProgress
case strings.Contains(s, "done"), strings.Contains(s, "fixed"),
strings.Contains(s, "complete"), strings.Contains(s, "resolved"),
strings.Contains(s, "obsolete"), strings.Contains(s, "closed"),
strings.Contains(s, "erledigt"), strings.Contains(s, "fertig"),
s == "verified", s == "duplicate", s == "won't fix", s == "cant reproduce":
return ticket.StatusDone
default:
return ticket.StatusTodo
}
}
// MapState converts a local Status to a YouTrack State name.
func MapState(st ticket.Status) string {
switch st {
case ticket.StatusInProgress:
return "In Progress"
case ticket.StatusDone:
return "Fixed"
default:
return "Open"
}
}
// NeedsPush reports whether a local ticket should be sent to YouTrack.
func NeedsPush(t ticket.Ticket) bool {
if t.ExternalID == "" {
// Local-only ticket in a YT-configured project → create remotely.
return true
}
if t.Source != ticket.SourceYouTrack {
return false
}
if t.SyncedAt.IsZero() {
return true
}
return t.UpdatedAt.After(t.SyncedAt)
}
// Pull syncs YouTrack issues into projectID using yt settings.
// Local tickets that still need a push (dirty) are not overwritten.
func (s *YouTrackSyncer) Pull(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) {
if !yt.Configured() {
return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
client := s.clientFor(yt)
issues, err := client.ListIssues(ctx, yt.IssueQuery())
if err != nil {
return Result{}, err
}
existing, err := s.Store.List(projectID)
if err != nil {
return Result{}, err
}
byExt := make(map[string]ticket.Ticket, len(existing))
for _, t := range existing {
if t.ExternalID != "" {
byExt[t.ExternalID] = t
}
}
var res Result
res.Total = len(issues)
for _, iss := range issues {
if iss.IDReadable == "" {
res.Skipped++
continue
}
status := MapStatus(iss.State)
issueURL := client.IssueURL(iss.IDReadable)
title := iss.Summary
if title == "" {
title = iss.IDReadable
}
if prev, ok := byExt[iss.IDReadable]; ok {
if NeedsPush(prev) {
res.Skipped++
continue
}
next := prev
next.Title = title
next.Description = iss.Description
// Only apply remote status when we actually parsed a state field.
// Empty state used to map to OPEN and wiped local WIP/DONE on every pull.
if iss.State != "" {
next.Status = status
}
next.Source = ticket.SourceYouTrack
next.ExternalURL = issueURL
if sameSyncedFields(prev, next) {
if err := s.Store.MarkSynced(prev.ID); err != nil {
return res, fmt.Errorf("touch %s: %w", iss.IDReadable, err)
}
res.Skipped++
continue
}
if err := s.Store.Update(next); err != nil {
return res, fmt.Errorf("update %s: %w", iss.IDReadable, err)
}
if err := s.Store.MarkSynced(next.ID); err != nil {
return res, fmt.Errorf("mark synced %s: %w", iss.IDReadable, err)
}
res.Updated++
continue
}
now := time.Now().UTC()
created, err := s.Store.Create(ticket.Ticket{
Title: title,
Description: iss.Description,
Status: status,
ProjectID: projectID,
Source: ticket.SourceYouTrack,
ExternalID: iss.IDReadable,
ExternalURL: issueURL,
CreatedAt: now,
UpdatedAt: now,
SyncedAt: now,
})
if err != nil {
return res, fmt.Errorf("create %s: %w", iss.IDReadable, err)
}
_ = created
res.Created++
}
if err := s.pullTimeEntries(ctx, client, projectID, &res); err != nil {
return res, err
}
return res, nil
}
// Push sends local tickets to YouTrack: creates for tickets without ExternalID,
// updates for dirty synced tickets, then pushes unsynced time entries.
func (s *YouTrackSyncer) Push(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) {
if !yt.Configured() {
return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
client := s.clientFor(yt)
existing, err := s.Store.List(projectID)
if err != nil {
return Result{}, err
}
var res Result
for _, t := range existing {
if !NeedsPush(t) {
res.Skipped++
continue
}
res.Total++
if t.ExternalID == "" {
if err := s.pushCreate(ctx, client, yt, t); err != nil {
return res, err
}
res.Created++
res.Pushed++
continue
}
if err := s.pushUpdate(ctx, client, t); err != nil {
return res, err
}
res.Updated++
res.Pushed++
}
if err := s.pushTimeEntries(ctx, client, projectID, &res); err != nil {
return res, err
}
return res, nil
}
func (s *YouTrackSyncer) pushTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(projectID)
if err != nil {
return err
}
byID := make(map[string]ticket.Ticket, len(tickets))
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 != projectID || e.ExternalID != "" {
continue
}
t, ok := byID[e.TicketID]
if !ok || t.ExternalID == "" {
continue // ticket not linked to YouTrack yet
}
minutes := durationMinutes(e.Duration)
text := e.Note
if text == "" && e.Source != "" {
text = "logged via task-master (" + e.Source + ")"
}
wi, err := client.CreateWorkItem(ctx, t.ExternalID, minutes, e.Start, text)
if err != nil {
return fmt.Errorf("push time for %s: %w", t.ExternalID, err)
}
e.ExternalID = wi.ID
if err := s.TimeStore.Update(e); err != nil {
return fmt.Errorf("link time entry %s: %w", e.ID, err)
}
res.TimePushed++
}
return nil
}
func (s *YouTrackSyncer) pullTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(projectID)
if err != nil {
return err
}
local, err := s.TimeStore.List()
if err != nil {
return err
}
haveExt := make(map[string]bool)
for _, e := range local {
if e.ExternalID != "" {
haveExt[e.ExternalID] = true
}
}
for _, t := range tickets {
if t.ExternalID == "" {
continue
}
items, err := client.ListWorkItems(ctx, t.ExternalID)
if err != nil {
return fmt.Errorf("list work items %s: %w", t.ExternalID, err)
}
for _, wi := range items {
if wi.ID == "" || haveExt[wi.ID] {
continue
}
dur := time.Duration(wi.Minutes) * time.Minute
if dur <= 0 {
continue
}
start := wi.Date
if start.IsZero() {
start = time.Now().UTC()
}
_, err := s.TimeStore.Create(ticket.TimeEntry{
TicketID: t.ID,
ProjectID: projectID,
Start: start,
End: start.Add(dur),
Duration: dur,
Source: ticket.SourceYouTrackTime,
Note: wi.Text,
ExternalID: wi.ID,
})
if err != nil {
return fmt.Errorf("import work item %s: %w", wi.ID, err)
}
haveExt[wi.ID] = true
res.TimePulled++
}
}
return nil
}
func durationMinutes(d time.Duration) int {
if d <= 0 {
return 1
}
m := int((d + time.Minute - 1) / time.Minute) // round up
if m < 1 {
return 1
}
return m
}
// PushTicket creates or updates a single ticket on YouTrack.
func (s *YouTrackSyncer) PushTicket(ctx context.Context, yt project.YouTrack, t ticket.Ticket) (ticket.Ticket, error) {
if !yt.Configured() {
return t, fmt.Errorf("youtrack not configured")
}
if s.Store == nil {
return t, fmt.Errorf("ticket store required")
}
if !NeedsPush(t) {
return t, nil
}
client := s.clientFor(yt)
if t.ExternalID == "" {
if err := s.pushCreate(ctx, client, yt, t); err != nil {
return t, err
}
} else {
if err := s.pushUpdate(ctx, client, t); err != nil {
return t, err
}
}
updated, err := s.Store.Get(t.ID)
if err != nil {
return t, err
}
return updated, nil
}
func (s *YouTrackSyncer) pushCreate(ctx context.Context, client *youtrack.Client, yt project.YouTrack, t ticket.Ticket) error {
iss, err := client.CreateIssue(ctx, yt.ProjectKey, t.Title, t.Description, MapState(t.Status))
if err != nil {
return fmt.Errorf("create remote for %s: %w", t.ID, err)
}
t.Source = ticket.SourceYouTrack
t.ExternalID = iss.IDReadable
t.ExternalURL = client.IssueURL(iss.IDReadable)
if err := s.Store.Update(t); err != nil {
return fmt.Errorf("link %s → %s: %w", t.ID, iss.IDReadable, err)
}
if err := s.Store.MarkSynced(t.ID); err != nil {
return fmt.Errorf("mark synced %s: %w", iss.IDReadable, err)
}
return nil
}
func (s *YouTrackSyncer) pushUpdate(ctx context.Context, client *youtrack.Client, t ticket.Ticket) error {
// Title/description only — state is applied separately when we can read the remote field.
_, err := client.UpdateIssue(ctx, t.ExternalID, t.Title, t.Description, "")
if err != nil {
return fmt.Errorf("update remote %s: %w", t.ExternalID, err)
}
if remote, err := client.GetIssue(ctx, t.ExternalID); err == nil && remote.State != "" {
if MapStatus(remote.State) != t.Status {
_ = client.SetState(ctx, t.ExternalID, MapState(t.Status))
}
}
t.Source = ticket.SourceYouTrack
t.ExternalURL = client.IssueURL(t.ExternalID)
if err := s.Store.Update(t); err != nil {
return fmt.Errorf("update local %s: %w", t.ExternalID, err)
}
if err := s.Store.MarkSynced(t.ID); err != nil {
return fmt.Errorf("mark synced %s: %w", t.ExternalID, err)
}
return nil
}
// Sync runs Push then Pull for a project.
func (s *YouTrackSyncer) Sync(ctx context.Context, projectID string, yt project.YouTrack) (SyncResult, error) {
var out SyncResult
var err error
out.Push, err = s.Push(ctx, projectID, yt)
if err != nil {
return out, err
}
out.Pull, err = s.Pull(ctx, projectID, yt)
return out, err
}
func sameSyncedFields(a, b ticket.Ticket) bool {
return a.Title == b.Title &&
a.Description == b.Description &&
a.Status == b.Status &&
a.ExternalURL == b.ExternalURL &&
a.Source == b.Source
}
// MetaLoader loads per-project YouTrack settings.
type MetaLoader interface {
LoadMeta(id string) (project.Meta, error)
}
func loadYT(projects MetaLoader, projectID string) (project.YouTrack, error) {
meta, err := projects.LoadMeta(projectID)
if err != nil {
return project.YouTrack{}, err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() {
return project.YouTrack{}, fmt.Errorf("project %s has no YouTrack config (edit project: URL, token, key)", projectID)
}
return *meta.YouTrack, nil
}
// SyncProject pushes local changes then pulls remote issues.
func SyncProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (SyncResult, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return SyncResult{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Sync(ctx, projectID, yt)
}
// PullProject loads meta for projectID and runs Pull (no push).
func PullProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return Result{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Pull(ctx, projectID, yt)
}
// PushProject loads meta for projectID and runs Push (no pull).
func PushProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return Result{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Push(ctx, projectID, yt)
}
+344
View File
@@ -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)
}
}