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