diff --git a/README.md b/README.md index 177c136..40bcff8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Offline-first Terminal-Taskmanager (`tm`) — Kanban-Board, Markdown-Tickets, Ch - Report mit Group-by (Ticket / Tag) - Undo / Redo über eine Command-History - Vim-Style Keybindings +- YouTrack Pull/Push (manuell; Remote-URL, Token, Project-Key pro Projekt) ## Install @@ -32,6 +33,9 @@ make build # → ./bin/tm ```bash tm # TUI starten +tm sync pull # Issues von YouTrack holen +tm sync push # lokale Änderungen nach YouTrack +tm sync pull -p inbox # anderes Projekt tm version # Build-Version (Git-Hash) tm help ``` @@ -62,6 +66,8 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~ | `l` | Board-Filter | | `/` | Suche | | `p`/`P` | Projekt / Ticket verschieben | +| `e` | Ticket bzw. Projekt editieren (in Projektliste: YouTrack) | +| `Y` | YouTrack-Menü: Pull / Push | | `t`/`T` | Timer / Zeit buchen | | `m` | Zeiteinträge | | `r` | Report | @@ -71,6 +77,18 @@ Daten landen standardmäßig unter `~/.local/share/task-master`, Config unter `~ Checklisten im Preview: `J`/`K` + `Space`. +### YouTrack + +1. `p` → Projekt wählen → `e` +2. Remote-URL, Permanent Token und Project-Key setzen (gespeichert in `projects//meta.json`, Mode `0600`) +3. Lokal arbeiten (Anlegen/Edit/Status bleiben lokal) +4. `Y` → **Pull** (Issues + Work Items) oder **Push** (Tickets + Zeiteinträge) + CLI: `tm sync pull` / `tm sync push` + +Zeit: lokale Einträge ohne `externalId` werden als YouTrack-Work-Items gepusht; remote Work Items landen lokal beim Pull. + +Status-Mapping Richtung YouTrack: OPEN→`Open`, WIP→`In Progress`, DONE→`Fixed` (Workflow-Namen können projektspezifisch abweichen). + ## Entwicklung ```bash diff --git a/cmd/task-master/main.go b/cmd/task-master/main.go index e28394d..f3d8ffa 100644 --- a/cmd/task-master/main.go +++ b/cmd/task-master/main.go @@ -1,8 +1,10 @@ package main import ( + "context" "fmt" "os" + "strings" tea "github.com/charmbracelet/bubbletea" @@ -12,6 +14,7 @@ import ( "task/internal/ticket" "task/internal/ui" "task/internal/version" + "task/internal/ytsync" ) func main() { @@ -23,6 +26,12 @@ func main() { case "help", "--help", "-h": printHelp() return + case "sync": + if err := runSync(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return default: fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) printHelp() @@ -61,17 +70,83 @@ func run() error { return nil } +func runSync(args []string) error { + cfg, err := config.LoadOrCreate() + if err != nil { + return fmt.Errorf("config: %w", err) + } + projectID := cfg.ActiveProject + action := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "pull", "push": + if action != "" { + return fmt.Errorf("specify only one of pull/push") + } + action = args[i] + case "--project", "-p": + if i+1 >= len(args) { + return fmt.Errorf("%s requires a project id", args[i]) + } + i++ + projectID = strings.TrimSpace(args[i]) + case "--help", "-h": + fmt.Printf("Usage: %s sync [--project ID]\n", config.CliName) + return nil + default: + return fmt.Errorf("unknown sync argument: %s (use pull or push)", args[i]) + } + } + if action == "" { + return fmt.Errorf("usage: %s sync [--project ID]", config.CliName) + } + if projectID == "" { + return fmt.Errorf("project id required") + } + + store := ticket.NewJSONStore(cfg.DataDir) + timeStore := ticket.NewJSONTimeStore(cfg.DataDir) + projects := project.NewDirStore(cfg.DataDir) + if err := projects.Ensure(projectID); err != nil { + return err + } + + switch action { + case "push": + res, err := ytsync.PushProject(context.Background(), projects, store, timeStore, projectID) + if err != nil { + return err + } + fmt.Printf("pushed %s: tickets +%d ~%d, time %d\n", + projectID, res.Created, res.Updated, res.TimePushed) + default: + res, err := ytsync.PullProject(context.Background(), projects, store, timeStore, projectID) + if err != nil { + return err + } + fmt.Printf("pulled %s: tickets +%d ~%d, time %d (%d issues)\n", + projectID, res.Created, res.Updated, res.TimePulled, res.Total) + } + return nil +} + func printHelp() { fmt.Printf(`%s — offline-first terminal task manager Usage: - %s Start the TUI - %s version Print build version - %s help Show this help + %s Start the TUI + %s sync pull [-p ID] Pull issues from YouTrack + %s sync push [-p ID] Push local changes to YouTrack + %s version Print build version + %s help Show this help Env: TASK_MASTER_HOME Override home for config/data (dev/demo sandbox) TASK_MASTER_DATA_DIR Override data directory only -`, config.CliName, config.CliName, config.CliName, config.CliName) +YouTrack: + In the TUI: p → project → e to set remote URL, token, project key. + Then Y → Pull or Push (manual only; edits stay local until you push). + +`, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName, config.CliName) } diff --git a/internal/project/meta.go b/internal/project/meta.go new file mode 100644 index 0000000..271301c --- /dev/null +++ b/internal/project/meta.go @@ -0,0 +1,107 @@ +package project + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +const metaFileName = "meta.json" + +// YouTrack holds remote sync settings for a project. +type YouTrack struct { + BaseURL string `json:"baseUrl"` + Token string `json:"token"` + ProjectKey string `json:"projectKey"` + // Query overrides the default "project: " issue filter. + Query string `json:"query,omitempty"` +} + +// Configured reports whether enough fields are set to sync. +func (y YouTrack) Configured() bool { + return strings.TrimSpace(y.BaseURL) != "" && + strings.TrimSpace(y.Token) != "" && + strings.TrimSpace(y.ProjectKey) != "" +} + +// IssueQuery returns the YouTrack search query used for sync. +func (y YouTrack) IssueQuery() string { + if q := strings.TrimSpace(y.Query); q != "" { + return q + } + return "project: " + strings.TrimSpace(y.ProjectKey) +} + +// Meta is per-project metadata stored beside tickets.json. +type Meta struct { + YouTrack *YouTrack `json:"youtrack,omitempty"` +} + +func (s *DirStore) metaPath(id string) string { + return filepath.Join(s.projectsRoot(), id, metaFileName) +} + +// LoadMeta reads projects//meta.json. Missing file yields empty Meta. +func (s *DirStore) LoadMeta(id string) (Meta, error) { + if id == "" { + return Meta{}, fmt.Errorf("project id is required") + } + path := s.metaPath(id) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Meta{}, nil + } + return Meta{}, fmt.Errorf("read meta %s: %w", id, err) + } + if len(data) == 0 { + return Meta{}, nil + } + var meta Meta + if err := json.Unmarshal(data, &meta); err != nil { + return Meta{}, fmt.Errorf("parse meta %s: %w", id, err) + } + return meta, nil +} + +// SaveMeta writes projects//meta.json with mode 0600 (may contain tokens). +func (s *DirStore) SaveMeta(id string, meta Meta) error { + if id == "" { + return fmt.Errorf("project id is required") + } + if err := s.Ensure(id); err != nil { + return err + } + path := s.metaPath(id) + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("marshal meta: %w", err) + } + data = append(data, '\n') + + tmp, err := os.CreateTemp(filepath.Dir(path), "meta-*.tmp") + if err != nil { + return fmt.Errorf("create temp meta: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("chmod temp meta: %w", err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write temp meta: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp meta: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("rename meta: %w", err) + } + _ = os.Chmod(path, 0o600) + return nil +} diff --git a/internal/project/meta_test.go b/internal/project/meta_test.go new file mode 100644 index 0000000..a3522ab --- /dev/null +++ b/internal/project/meta_test.go @@ -0,0 +1,70 @@ +package project_test + +import ( + "os" + "path/filepath" + "testing" + + "task/internal/project" +) + +func TestMetaSaveLoad(t *testing.T) { + dir := t.TempDir() + store := project.NewDirStore(dir) + if err := store.Ensure("inbox"); err != nil { + t.Fatal(err) + } + + meta := project.Meta{ + YouTrack: &project.YouTrack{ + BaseURL: "https://yt.example.com", + Token: "perm:secret", + ProjectKey: "PROJ", + }, + } + if err := store.SaveMeta("inbox", meta); err != nil { + t.Fatalf("SaveMeta: %v", err) + } + + path := filepath.Join(dir, "projects", "inbox", "meta.json") + st, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if st.Mode().Perm()&0o077 != 0 { + t.Fatalf("meta permissions too open: %o", st.Mode().Perm()) + } + + got, err := store.LoadMeta("inbox") + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if got.YouTrack == nil || got.YouTrack.Token != "perm:secret" || got.YouTrack.ProjectKey != "PROJ" { + t.Fatalf("got %+v", got.YouTrack) + } +} + +func TestLoadMetaMissing(t *testing.T) { + store := project.NewDirStore(t.TempDir()) + got, err := store.LoadMeta("missing") + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if got.YouTrack != nil { + t.Fatalf("expected empty meta, got %+v", got) + } +} + +func TestYouTrackConfiguredAndQuery(t *testing.T) { + y := project.YouTrack{BaseURL: "https://x", Token: "t", ProjectKey: "ABC"} + if !y.Configured() { + t.Fatal("expected configured") + } + if y.IssueQuery() != "project: ABC" { + t.Fatalf("query = %q", y.IssueQuery()) + } + y.Query = "Assignee: me" + if y.IssueQuery() != "Assignee: me" { + t.Fatalf("query = %q", y.IssueQuery()) + } +} diff --git a/internal/project/store.go b/internal/project/store.go index 79f075f..b43459c 100644 --- a/internal/project/store.go +++ b/internal/project/store.go @@ -12,6 +12,8 @@ type Store interface { List() ([]Project, error) Ensure(id string) error Delete(id string) error + LoadMeta(id string) (Meta, error) + SaveMeta(id string, meta Meta) error } // DirStore discovers projects as subdirectories of dataDir/projects. diff --git a/internal/ticket/jsonstore.go b/internal/ticket/jsonstore.go index 1a4b091..1d0eed8 100644 --- a/internal/ticket/jsonstore.go +++ b/internal/ticket/jsonstore.go @@ -134,6 +134,8 @@ func (s *JSONStore) Update(t Ticket) error { for i := range tickets { if tickets[i].ID == t.ID { t.CreatedAt = tickets[i].CreatedAt + // Always bump UpdatedAt on local edits so NeedsPush sees dirty tickets. + // Use MarkSynced after a successful remote sync to clear the dirty flag. t.UpdatedAt = time.Now().UTC() tickets[i] = t found = true @@ -146,6 +148,42 @@ func (s *JSONStore) Update(t Ticket) error { return s.writeUnlocked(t.ProjectID, tickets) } +// MarkSynced sets SyncedAt = UpdatedAt without changing UpdatedAt. +func (s *JSONStore) MarkSynced(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if id == "" { + return fmt.Errorf("ticket id is required") + } + + projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects")) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("ticket %s: not found", id) + } + return fmt.Errorf("list projects: %w", err) + } + + for _, p := range projects { + if !p.IsDir() { + continue + } + tickets, err := s.readUnlocked(p.Name()) + if err != nil { + return err + } + for i := range tickets { + if tickets[i].ID != id { + continue + } + tickets[i].SyncedAt = tickets[i].UpdatedAt + return s.writeUnlocked(p.Name(), tickets) + } + } + return fmt.Errorf("ticket %s: not found", id) +} + // Move relocates a ticket to another project directory. func (s *JSONStore) Move(id, toProjectID string) (Ticket, error) { s.mu.Lock() diff --git a/internal/ticket/jsonstore_test.go b/internal/ticket/jsonstore_test.go index c2c7874..bc6374c 100644 --- a/internal/ticket/jsonstore_test.go +++ b/internal/ticket/jsonstore_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "testing" + "time" "task/internal/ticket" ) @@ -65,6 +66,43 @@ func TestJSONStoreCreateListUpdate(t *testing.T) { } } +func TestUpdateAfterMarkSyncedIsDirty(t *testing.T) { + store := ticket.NewJSONStore(t.TempDir()) + created, err := store.Create(ticket.Ticket{ + Title: "Synced", ProjectID: "inbox", Status: ticket.StatusTodo, + Source: ticket.SourceYouTrack, ExternalID: "TAS-1", + }) + if err != nil { + t.Fatal(err) + } + if err := store.MarkSynced(created.ID); err != nil { + t.Fatalf("MarkSynced: %v", err) + } + synced, err := store.Get(created.ID) + if err != nil { + t.Fatal(err) + } + if synced.UpdatedAt.After(synced.SyncedAt) { + t.Fatalf("freshly marked synced should not be dirty: updated=%v syncedAt=%v", + synced.UpdatedAt, synced.SyncedAt) + } + + // Simulate UI edit: copy ticket (SyncedAt == UpdatedAt) and change title. + edited := synced + edited.Title = "Edited locally" + time.Sleep(time.Millisecond) // ensure clock can move + if err := store.Update(edited); err != nil { + t.Fatal(err) + } + got, err := store.Get(created.ID) + if err != nil { + t.Fatal(err) + } + if !got.UpdatedAt.After(got.SyncedAt) { + t.Fatalf("edit must bump UpdatedAt past SyncedAt: %v vs %v", got.UpdatedAt, got.SyncedAt) + } +} + func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) { dir := t.TempDir() projectDir := filepath.Join(dir, "projects", "inbox") diff --git a/internal/ticket/store.go b/internal/ticket/store.go index bf02fe1..d672030 100644 --- a/internal/ticket/store.go +++ b/internal/ticket/store.go @@ -7,6 +7,9 @@ type Store interface { Get(id string) (Ticket, error) Create(t Ticket) (Ticket, error) Update(t Ticket) error + // MarkSynced sets SyncedAt to the ticket's current UpdatedAt without + // bumping UpdatedAt (so NeedsPush becomes false after a remote sync). + MarkSynced(id string) error Move(id, toProjectID string) (Ticket, error) Delete(id string) error } diff --git a/internal/ticket/ticket.go b/internal/ticket/ticket.go index 6ea9839..b1fe043 100644 --- a/internal/ticket/ticket.go +++ b/internal/ticket/ticket.go @@ -50,6 +50,11 @@ func (s Status) Index() int { return 0 } +const ( + // SourceYouTrack marks tickets imported from YouTrack. + SourceYouTrack = "youtrack" +) + // Ticket is the domain model for a work item. // Title (headline) and Description are Markdown. type Ticket struct { @@ -60,4 +65,10 @@ type Ticket struct { ProjectID string `json:"projectId"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + + // External sync (optional). Local UUID stays in ID. + Source string `json:"source,omitempty"` + ExternalID string `json:"externalId,omitempty"` + ExternalURL string `json:"externalUrl,omitempty"` + SyncedAt time.Time `json:"syncedAt,omitempty"` } diff --git a/internal/ticket/time.go b/internal/ticket/time.go index 1d52d3d..f02daf5 100644 --- a/internal/ticket/time.go +++ b/internal/ticket/time.go @@ -11,6 +11,11 @@ const ( SourceManual = "manual" ) +const ( + // SourceYouTrackTime marks time entries imported from YouTrack work items. + SourceYouTrackTime = "youtrack" +) + // TimeEntry is a recorded block of work on a ticket. type TimeEntry struct { ID string `json:"id"` @@ -21,43 +26,48 @@ type TimeEntry struct { Duration time.Duration `json:"duration"` Source string `json:"source"` Note string `json:"note,omitempty"` + // ExternalID is the YouTrack work-item id when synced. + ExternalID string `json:"externalId,omitempty"` } // MarshalJSON encodes Duration as nanoseconds for stable JSON. func (e TimeEntry) MarshalJSON() ([]byte, error) { type alias struct { - ID string `json:"id"` - TicketID string `json:"ticketId"` - ProjectID string `json:"projectId"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - Duration int64 `json:"duration"` - Source string `json:"source"` - Note string `json:"note,omitempty"` + ID string `json:"id"` + TicketID string `json:"ticketId"` + ProjectID string `json:"projectId"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Duration int64 `json:"duration"` + Source string `json:"source"` + Note string `json:"note,omitempty"` + ExternalID string `json:"externalId,omitempty"` } return json.Marshal(alias{ - ID: e.ID, - TicketID: e.TicketID, - ProjectID: e.ProjectID, - Start: e.Start, - End: e.End, - Duration: int64(e.Duration), - Source: e.Source, - Note: e.Note, + ID: e.ID, + TicketID: e.TicketID, + ProjectID: e.ProjectID, + Start: e.Start, + End: e.End, + Duration: int64(e.Duration), + Source: e.Source, + Note: e.Note, + ExternalID: e.ExternalID, }) } // UnmarshalJSON decodes Duration from nanoseconds. func (e *TimeEntry) UnmarshalJSON(data []byte) error { type alias struct { - ID string `json:"id"` - TicketID string `json:"ticketId"` - ProjectID string `json:"projectId"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - Duration int64 `json:"duration"` - Source string `json:"source"` - Note string `json:"note,omitempty"` + ID string `json:"id"` + TicketID string `json:"ticketId"` + ProjectID string `json:"projectId"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Duration int64 `json:"duration"` + Source string `json:"source"` + Note string `json:"note,omitempty"` + ExternalID string `json:"externalId,omitempty"` } var a alias if err := json.Unmarshal(data, &a); err != nil { @@ -71,6 +81,7 @@ func (e *TimeEntry) UnmarshalJSON(data []byte) error { e.Duration = time.Duration(a.Duration) e.Source = a.Source e.Note = a.Note + e.ExternalID = a.ExternalID return nil } diff --git a/internal/ui/app.go b/internal/ui/app.go index 0fe1d75..e634da0 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "os" "strings" @@ -18,6 +19,8 @@ import ( "task/internal/ticket" "task/internal/ui/views" "task/internal/version" + "task/internal/youtrack" + "task/internal/ytsync" ) // timerTickMsg refreshes the header while a timer is running. @@ -31,6 +34,15 @@ type editorFinishedMsg struct { create bool // true: create ticket from edited file } +// syncFinishedMsg is sent after a manual YouTrack pull or push completes. +type syncFinishedMsg struct { + projectID string + action views.SyncAction + pull ytsync.Result + push ytsync.Result + err error +} + // Mode is the active UI mode. type Mode int @@ -48,6 +60,8 @@ const ( ModeTimeEdit ModeReport ModeReportGroup + ModeEditProject + ModeSync ) // Model is the root Bubble Tea model. @@ -62,6 +76,8 @@ type Model struct { tickets views.TicketList projectList list.Model statusMenu views.StatusMenu + syncMenu views.SyncMenu + syncProjectID string // project targeted by ModeSync boardMenu views.BoardMenu boardFilter ticket.BoardFilter input textinput.Model @@ -85,6 +101,9 @@ type Model struct { creatingProject bool pendingDeleteTitle string pendingDeleteProject string // non-empty → confirm deletes a project, not a ticket + projectEdit views.ProjectEditForm + projectEditMeta project.Meta // snapshot when opening the form + syncing bool } // New creates the root UI model. @@ -161,16 +180,44 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case editorFinishedMsg: return m.handleEditorFinished(msg) + case syncFinishedMsg: + m.syncing = false + if msg.err != nil { + m.setError(msg.err) + return m, nil + } + if err := m.reloadTickets(); err != nil { + m.setError(err) + return m, nil + } + switch msg.action { + case views.SyncActionPush: + m.setStatus(fmt.Sprintf( + "pushed %s: +%d ~%d · time %d", + msg.projectID, msg.push.Created, msg.push.Updated, msg.push.TimePushed, + )) + default: + m.setStatus(fmt.Sprintf( + "pulled %s: +%d ~%d · time %d", + msg.projectID, msg.pull.Created, msg.pull.Updated, msg.pull.TimePulled, + )) + } + return m, nil + case tea.KeyMsg: switch m.mode { case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit: return m.updateInput(msg) + case ModeEditProject: + return m.updateEditProject(msg) case ModeProjects: return m.updateProjects(msg) case ModeMoveTicket: return m.updateMoveTicket(msg) case ModeStatus: return m.updateStatus(msg) + case ModeSync: + return m.updateSync(msg) case ModeBoard: return m.updateBoard(msg) case ModeConfirmDelete: @@ -192,6 +239,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.projectList, cmd = m.projectList.Update(msg) case ModeCreate, ModeSearch, ModeTimeManual, ModeTimeEdit: m.input, cmd = m.input.Update(msg) + case ModeEditProject: + cmd = m.projectEdit.Update(msg) } return m, cmd } @@ -373,6 +422,11 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = ModeReport return m, nil + case key.Matches(msg, m.keys.Sync): + m.clearStatus() + m.openSyncMenu(m.cfg.ActiveProject) + return m, nil + case key.Matches(msg, m.keys.TimeEntries): m.clearStatus() if err := m.openTimeEntries(); err != nil { @@ -594,7 +648,7 @@ func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.setStatus(fmt.Sprintf("created project %s", id)) return m, nil } - if err := m.createTicket(value, ""); err != nil { + if _, err := m.createTicket(value, ""); err != nil { m.setError(err) return m, nil } @@ -749,6 +803,138 @@ func (m Model) updateReportGroup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) updateEditProject(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Escape): + m.mode = ModeProjects + m.clearStatus() + return m, nil + + case key.Matches(msg, m.keys.Tab): + m.projectEdit.NextField() + return m, textinput.Blink + + case key.Matches(msg, m.keys.ShiftTab): + m.projectEdit.PrevField() + return m, textinput.Blink + + case msg.Type == tea.KeyDown: + m.projectEdit.NextField() + return m, textinput.Blink + + case msg.Type == tea.KeyUp: + m.projectEdit.PrevField() + return m, textinput.Blink + + case key.Matches(msg, m.keys.Enter): + if err := m.projectEdit.Validate(m.projectEditMeta); err != nil { + m.setError(err) + return m, nil + } + meta := m.projectEdit.Meta(m.projectEditMeta) + id := m.projectEdit.ProjectID + if err := m.projects.SaveMeta(id, meta); err != nil { + m.setError(err) + return m, nil + } + if err := m.openProjects(); err != nil { + m.setError(err) + return m, nil + } + m.mode = ModeProjects + if meta.YouTrack != nil && meta.YouTrack.Configured() { + m.setStatus(fmt.Sprintf("saved YouTrack settings for %s", id)) + } else { + m.setStatus(fmt.Sprintf("cleared YouTrack settings for %s", id)) + } + return m, nil + } + + cmd := m.projectEdit.Update(msg) + return m, cmd +} + +func (m *Model) openProjectEdit(projectID string) error { + meta, err := m.projects.LoadMeta(projectID) + if err != nil { + return err + } + m.projectEditMeta = meta + m.projectEdit = views.NewProjectEditForm(projectID, meta, m.width) + return nil +} + +func (m *Model) openSyncMenu(projectID string) { + m.syncProjectID = projectID + m.syncMenu = views.NewSyncMenu(m.width, views.StatusMenuStyles{ + Box: m.styles.StatusMenuBox, + Title: m.styles.StatusMenuTitle, + Selected: m.styles.StatusMenuSelected, + Normal: m.styles.StatusMenuNormal, + }) + m.mode = ModeSync +} + +func (m Model) updateSync(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Escape): + m.mode = ModeList + m.clearStatus() + return m, nil + + case key.Matches(msg, m.keys.Enter): + action := m.syncMenu.Selected() + m.mode = ModeList + return m.startSyncAction(m.syncProjectID, action) + + case key.Matches(msg, m.keys.Down): + m.syncMenu.MoveDown() + return m, nil + + case key.Matches(msg, m.keys.Up): + m.syncMenu.MoveUp() + return m, nil + } + + if msg.Type == tea.KeyRunes && len(msg.Runes) == 1 { + if m.syncMenu.JumpToShortcut(msg.Runes[0]) { + action := m.syncMenu.Selected() + m.mode = ModeList + return m.startSyncAction(m.syncProjectID, action) + } + } + return m, nil +} + +func (m Model) startSyncAction(projectID string, action views.SyncAction) (tea.Model, tea.Cmd) { + if m.syncing { + m.setStatus("sync already running…") + return m, nil + } + m.clearStatus() + m.syncing = true + switch action { + case views.SyncActionPush: + m.setStatus(fmt.Sprintf("pushing %s…", projectID)) + default: + m.setStatus(fmt.Sprintf("pulling %s…", projectID)) + } + store := m.store + timeStore := m.timeStore + projects := m.projects + return m, func() tea.Msg { + var pull, push ytsync.Result + var err error + switch action { + case views.SyncActionPush: + push, err = ytsync.PushProject(context.Background(), projects, store, timeStore, projectID) + default: + pull, err = ytsync.PullProject(context.Background(), projects, store, timeStore, projectID) + } + return syncFinishedMsg{projectID: projectID, action: action, pull: pull, push: push, err: err} + } +} + func (m Model) updateMoveTicket(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Escape): @@ -796,6 +982,30 @@ func (m Model) updateProjects(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.input.Focus() return m, textinput.Blink + case key.Matches(msg, m.keys.Edit): + m.clearStatus() + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + m.setError(fmt.Errorf("no project selected")) + return m, nil + } + if err := m.openProjectEdit(item.Project.ID); err != nil { + m.setError(err) + return m, nil + } + m.mode = ModeEditProject + return m, textinput.Blink + + case key.Matches(msg, m.keys.Sync): + item, ok := m.projectList.SelectedItem().(views.ProjectItem) + if !ok { + m.setError(fmt.Errorf("no project selected")) + return m, nil + } + m.clearStatus() + m.openSyncMenu(item.Project.ID) + return m, nil + case key.Matches(msg, m.keys.DeleteForce): m.clearStatus() item, ok := m.projectList.SelectedItem().(views.ProjectItem) @@ -904,7 +1114,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) } if msg.create { - if err := m.createTicket(title, description); err != nil { + if _, err := m.createTicket(title, description); err != nil { m.setError(err) return m, nil } @@ -941,7 +1151,7 @@ func (m Model) handleEditorFinished(msg editorFinishedMsg) (tea.Model, tea.Cmd) return m, nil } -func (m *Model) createTicket(title, description string) error { +func (m *Model) createTicket(title, description string) (ticket.Ticket, error) { cmd := &command.CreateTicket{ Store: m.store, Ticket: ticket.Ticket{ @@ -952,9 +1162,12 @@ func (m *Model) createTicket(title, description string) error { }, } if err := m.exec.Do(cmd); err != nil { - return err + return ticket.Ticket{}, err } - return m.reloadTickets() + if err := m.reloadTickets(); err != nil { + return ticket.Ticket{}, err + } + return cmd.Ticket, nil } func (m *Model) deleteSelected() error { @@ -1232,7 +1445,15 @@ func (m *Model) openProjects() error { if bodyH < 1 { bodyH = 1 } - m.projectList = views.NewProjectList(projects, m.cfg.ActiveProject, m.width, bodyH) + yt := make(map[string]bool, len(projects)) + for _, p := range projects { + meta, err := m.projects.LoadMeta(p.ID) + if err != nil { + return err + } + yt[p.ID] = meta.YouTrack != nil && meta.YouTrack.Configured() + } + m.projectList = views.NewProjectListWithYT(projects, m.cfg.ActiveProject, yt, m.width, bodyH) return nil } @@ -1424,6 +1645,13 @@ func (m *Model) deleteSelectedTimeEntry() error { if !ok { return fmt.Errorf("no entry selected") } + // If synced to YouTrack, remove the remote work item first so the next + // pull does not resurrect it. + if e.ExternalID != "" { + if err := m.deleteRemoteWorkItem(e); err != nil { + return err + } + } if err := m.exec.Do(&command.RemoveTimeEntry{Store: m.timeStore, Snapshot: e}); err != nil { return err } @@ -1433,6 +1661,28 @@ func (m *Model) deleteSelectedTimeEntry() error { return m.openTimeEntriesFor(m.timeEntriesTicket) } +func (m *Model) deleteRemoteWorkItem(e ticket.TimeEntry) error { + tk, err := m.store.Get(e.TicketID) + if err != nil { + return fmt.Errorf("resolve ticket for work item: %w", err) + } + if tk.ExternalID == "" { + return nil + } + meta, err := m.projects.LoadMeta(tk.ProjectID) + if err != nil { + return err + } + if meta.YouTrack == nil || !meta.YouTrack.Configured() { + return nil + } + client := &youtrack.Client{BaseURL: meta.YouTrack.BaseURL, Token: meta.YouTrack.Token} + if err := client.DeleteWorkItem(context.Background(), tk.ExternalID, e.ExternalID); err != nil { + return fmt.Errorf("delete YouTrack work item: %w", err) + } + return nil +} + func (m *Model) saveEditedTime(raw string) error { if m.timeStore == nil { return fmt.Errorf("time store not configured") diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index 7f1a91a..dc48078 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -33,10 +33,18 @@ func (m Model) helpEntries() []helpEntry { {"a-z", "jump"}, {"enter", "select"}, {"n", "new project"}, + {"e", "edit"}, + {"Y", "YouTrack"}, {"x", "delete"}, {"X", "force delete"}, {"esc", "cancel"}, } + case ModeEditProject: + return []helpEntry{ + {"tab/↑↓", "fields"}, + {"enter", "save"}, + {"esc", "cancel"}, + } case ModeMoveTicket: return []helpEntry{ {"j/k", "navigate"}, @@ -51,6 +59,13 @@ func (m Model) helpEntries() []helpEntry { {"enter", "apply"}, {"esc", "cancel"}, } + case ModeSync: + return []helpEntry{ + {"j/k", "select"}, + {"p/u", "jump"}, + {"enter", "run"}, + {"esc", "cancel"}, + } case ModeBoard: return []helpEntry{ {"j/k", "navigate"}, @@ -106,6 +121,7 @@ func (m Model) helpEntries() []helpEntry { {"t/T", "timer/log"}, {"m", "entries"}, {"r", "report"}, + {"Y", "YouTrack"}, {"u/U", "undo/redo"}, {"/", "search"}, {"p", "project"}, diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 52f063b..407dc8b 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -24,11 +24,14 @@ type KeyMap struct { TimeManual key.Binding // T — manual duration TimeEntries key.Binding // m — manage time entries Report key.Binding // r — time report + Sync key.Binding // Y — pull YouTrack issues Undo key.Binding Redo key.Binding Quit key.Binding Enter key.Binding Escape key.Binding + Tab key.Binding // tab — next form field + ShiftTab key.Binding // shift+tab — previous form field } // DefaultKeyMap returns the default key bindings. @@ -114,6 +117,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("r"), key.WithHelp("r", "report"), ), + Sync: key.NewBinding( + key.WithKeys("Y"), + key.WithHelp("Y", "YouTrack pull/push"), + ), Undo: key.NewBinding( key.WithKeys("u", "ctrl+z"), key.WithHelp("u", "undo"), @@ -134,12 +141,20 @@ func DefaultKeyMap() KeyMap { key.WithKeys("esc"), key.WithHelp("esc", "cancel"), ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next field"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev field"), + ), } } // ShortHelp implements help.KeyMap. func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit} + return []key.Binding{k.Up, k.New, k.Search, k.Project, k.MoveTicket, k.Board, k.Status, k.Edit, k.Timer, k.TimeManual, k.TimeEntries, k.Report, k.Sync, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit} } // FullHelp implements help.KeyMap. diff --git a/internal/ui/layout.go b/internal/ui/layout.go index d0e8946..c61d5ff 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -80,10 +80,17 @@ func (m Model) renderBody(height int) string { m.projectList.SetSize(m.width, height) return m.projectList.View() + case ModeEditProject: + return lipgloss.NewStyle().Height(height).Width(m.width).Render(m.projectEdit.View()) + case ModeStatus: menu := m.statusMenu.View() return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu) + case ModeSync: + menu := m.syncMenu.View() + return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu) + case ModeBoard: menu := m.boardMenu.View() return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu) diff --git a/internal/ui/views/projectedit.go b/internal/ui/views/projectedit.go new file mode 100644 index 0000000..180ed68 --- /dev/null +++ b/internal/ui/views/projectedit.go @@ -0,0 +1,199 @@ +package views + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "task/internal/project" +) + +// ProjectEdit field indices. +const ( + FieldRemoteURL = iota + FieldToken + FieldProjectKey + ProjectEditFieldCount +) + +// ProjectEditStyles styles the project edit form. +type ProjectEditStyles struct { + Label lipgloss.Style + Focused lipgloss.Style + Blurred lipgloss.Style + Hint lipgloss.Style + Title lipgloss.Style +} + +// DefaultProjectEditStyles returns sensible defaults. +func DefaultProjectEditStyles() ProjectEditStyles { + return ProjectEditStyles{ + Label: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + Focused: lipgloss.NewStyle().Foreground(lipgloss.Color("205")), + Blurred: lipgloss.NewStyle().Foreground(lipgloss.Color("240")), + Hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + Title: lipgloss.NewStyle().Bold(true), + } +} + +// ProjectEditForm is a multi-field editor for YouTrack project settings. +type ProjectEditForm struct { + ProjectID string + Inputs []textinput.Model + Focus int + Styles ProjectEditStyles + Width int + hadToken bool + tokenChanged bool +} + +// NewProjectEditForm builds a form prefilled from meta. +func NewProjectEditForm(projectID string, meta project.Meta, width int) ProjectEditForm { + styles := DefaultProjectEditStyles() + inputs := make([]textinput.Model, ProjectEditFieldCount) + + mk := func(placeholder string, charLimit int, password bool) textinput.Model { + ti := textinput.New() + ti.Placeholder = placeholder + ti.CharLimit = charLimit + ti.Width = max(20, width-4) + if password { + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + } + return ti + } + + inputs[FieldRemoteURL] = mk("https://youtrack.example.com", 256, false) + inputs[FieldToken] = mk("permanent token", 512, true) + inputs[FieldProjectKey] = mk("PROJ", 64, false) + + hadToken := false + if meta.YouTrack != nil { + yt := meta.YouTrack + inputs[FieldRemoteURL].SetValue(yt.BaseURL) + inputs[FieldProjectKey].SetValue(yt.ProjectKey) + if yt.Token != "" { + hadToken = true + inputs[FieldToken].Placeholder = "(unchanged — type to replace)" + } + } + + f := ProjectEditForm{ + ProjectID: projectID, + Inputs: inputs, + Focus: FieldRemoteURL, + Styles: styles, + Width: width, + hadToken: hadToken, + } + f.focusIndex(FieldRemoteURL) + return f +} + +func (f *ProjectEditForm) focusIndex(i int) { + f.Focus = i + for idx := range f.Inputs { + if idx == i { + f.Inputs[idx].Focus() + f.Inputs[idx].PromptStyle = f.Styles.Focused + f.Inputs[idx].TextStyle = f.Styles.Focused + } else { + f.Inputs[idx].Blur() + f.Inputs[idx].PromptStyle = f.Styles.Blurred + f.Inputs[idx].TextStyle = f.Styles.Blurred + } + } +} + +// NextField moves focus to the next field. +func (f *ProjectEditForm) NextField() { + f.focusIndex((f.Focus + 1) % ProjectEditFieldCount) +} + +// PrevField moves focus to the previous field. +func (f *ProjectEditForm) PrevField() { + f.focusIndex((f.Focus - 1 + ProjectEditFieldCount) % ProjectEditFieldCount) +} + +// Update forwards a message to the focused text input. +func (f *ProjectEditForm) Update(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + f.Inputs[f.Focus], cmd = f.Inputs[f.Focus].Update(msg) + if f.Focus == FieldToken && f.Inputs[FieldToken].Value() != "" { + f.tokenChanged = true + } + return cmd +} + +// Meta builds the Meta to persist. Empty token keeps the previous token. +func (f ProjectEditForm) Meta(previous project.Meta) project.Meta { + baseURL := strings.TrimSpace(f.Inputs[FieldRemoteURL].Value()) + token := strings.TrimSpace(f.Inputs[FieldToken].Value()) + key := strings.TrimSpace(f.Inputs[FieldProjectKey].Value()) + + if baseURL == "" && token == "" && key == "" { + return project.Meta{YouTrack: nil} + } + + yt := &project.YouTrack{ + BaseURL: baseURL, + ProjectKey: key, + Token: token, + } + if token == "" && previous.YouTrack != nil { + yt.Token = previous.YouTrack.Token + } + if previous.YouTrack != nil && previous.YouTrack.Query != "" { + yt.Query = previous.YouTrack.Query + } + return project.Meta{YouTrack: yt} +} + +// Validate ensures partial YouTrack configs are rejected. +func (f ProjectEditForm) Validate(previous project.Meta) error { + meta := f.Meta(previous) + if meta.YouTrack == nil { + return nil + } + yt := meta.YouTrack + any := yt.BaseURL != "" || yt.Token != "" || yt.ProjectKey != "" + if any && !yt.Configured() { + missing := []string{} + if yt.BaseURL == "" { + missing = append(missing, "remote URL") + } + if yt.Token == "" { + missing = append(missing, "token") + } + if yt.ProjectKey == "" { + missing = append(missing, "project key") + } + return fmt.Errorf("incomplete YouTrack config: missing %s", strings.Join(missing, ", ")) + } + return nil +} + +// View renders the form. +func (f ProjectEditForm) View() string { + labels := []string{"Remote URL", "Token", "Project key"} + var b strings.Builder + b.WriteString(f.Styles.Title.Render(fmt.Sprintf("Edit project %s", f.ProjectID))) + b.WriteString("\n") + b.WriteString(f.Styles.Hint.Render("YouTrack sync settings · tab/↑↓ fields · enter save · esc cancel")) + b.WriteString("\n\n") + for i, label := range labels { + b.WriteString(f.Styles.Label.Render(label + ":")) + b.WriteString("\n") + b.WriteString(f.Inputs[i].View()) + b.WriteString("\n\n") + } + if f.hadToken && !f.tokenChanged { + b.WriteString(f.Styles.Hint.Render("Token is set; leave blank to keep.")) + b.WriteString("\n") + } + return b.String() +} diff --git a/internal/ui/views/projectedit_test.go b/internal/ui/views/projectedit_test.go new file mode 100644 index 0000000..98d2aa4 --- /dev/null +++ b/internal/ui/views/projectedit_test.go @@ -0,0 +1,40 @@ +package views_test + +import ( + "testing" + + "task/internal/project" + "task/internal/ui/views" +) + +func TestProjectEditMetaKeepsToken(t *testing.T) { + prev := project.Meta{ + YouTrack: &project.YouTrack{ + BaseURL: "https://yt.example", + Token: "secret", + ProjectKey: "ABC", + }, + } + f := views.NewProjectEditForm("inbox", prev, 80) + // Leave token blank → keep previous. + f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example") + f.Inputs[views.FieldProjectKey].SetValue("ABC") + f.Inputs[views.FieldToken].SetValue("") + + meta := f.Meta(prev) + if meta.YouTrack == nil || meta.YouTrack.Token != "secret" { + t.Fatalf("expected kept token, got %+v", meta.YouTrack) + } + if err := f.Validate(prev); err != nil { + t.Fatalf("Validate: %v", err) + } +} + +func TestProjectEditValidateIncomplete(t *testing.T) { + f := views.NewProjectEditForm("inbox", project.Meta{}, 80) + f.Inputs[views.FieldRemoteURL].SetValue("https://yt.example") + // missing token + key + if err := f.Validate(project.Meta{}); err == nil { + t.Fatal("expected incomplete error") + } +} diff --git a/internal/ui/views/projects.go b/internal/ui/views/projects.go index 84871f5..81a6e7e 100644 --- a/internal/ui/views/projects.go +++ b/internal/ui/views/projects.go @@ -10,25 +10,39 @@ import ( // ProjectItem wraps a project for bubbles/list. type ProjectItem struct { - Project project.Project - Active bool + Project project.Project + Active bool + YouTrack bool // true when YouTrack sync is configured } func (i ProjectItem) Title() string { - if i.Active { - return i.Project.Name + " *" + name := i.Project.Name + if i.YouTrack { + name += " [YT]" } - return i.Project.Name + if i.Active { + return name + " *" + } + return name } func (i ProjectItem) Description() string { return i.Project.ID } func (i ProjectItem) FilterValue() string { return i.Project.Name } // NewProjectList builds a list of projects. func NewProjectList(projects []project.Project, activeID string, width, height int) list.Model { + return NewProjectListWithYT(projects, activeID, nil, width, height) +} + +// NewProjectListWithYT builds a list, marking projects with YouTrack config. +func NewProjectListWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool, width, height int) list.Model { items := make([]list.Item, len(projects)) selected := 0 for i, p := range projects { - items[i] = ProjectItem{Project: p, Active: p.ID == activeID} + items[i] = ProjectItem{ + Project: p, + Active: p.ID == activeID, + YouTrack: ytConfigured[p.ID], + } if p.ID == activeID { selected = i } @@ -77,9 +91,14 @@ func JumpProjectByLetter(l *list.Model, r rune) bool { // ProjectsToItems converts projects to list items. func ProjectsToItems(projects []project.Project, activeID string) []list.Item { + return ProjectsToItemsWithYT(projects, activeID, nil) +} + +// ProjectsToItemsWithYT converts projects to list items with YouTrack flags. +func ProjectsToItemsWithYT(projects []project.Project, activeID string, ytConfigured map[string]bool) []list.Item { items := make([]list.Item, len(projects)) for i, p := range projects { - items[i] = ProjectItem{Project: p, Active: p.ID == activeID} + items[i] = ProjectItem{Project: p, Active: p.ID == activeID, YouTrack: ytConfigured[p.ID]} } return items } diff --git a/internal/ui/views/syncmenu.go b/internal/ui/views/syncmenu.go new file mode 100644 index 0000000..34d975d --- /dev/null +++ b/internal/ui/views/syncmenu.go @@ -0,0 +1,94 @@ +package views + +import ( + "fmt" + "strings" + "unicode" +) + +// SyncAction is a YouTrack sync menu choice. +type SyncAction string + +const ( + SyncActionPull SyncAction = "pull" + SyncActionPush SyncAction = "push" +) + +// SyncOption is one row in the sync menu. +type SyncOption struct { + Action SyncAction + Label string + Shortcut rune +} + +// AllSyncOptions is the ordered sync menu. +var AllSyncOptions = []SyncOption{ + {Action: SyncActionPull, Label: "Pull from YouTrack", Shortcut: 'p'}, + {Action: SyncActionPush, Label: "Push to YouTrack", Shortcut: 'u'}, +} + +// SyncMenu renders a pull/push picker overlay. +type SyncMenu struct { + Cursor int + Width int + Styles StatusMenuStyles +} + +// NewSyncMenu creates a sync action menu. +func NewSyncMenu(width int, styles StatusMenuStyles) SyncMenu { + return SyncMenu{Cursor: 0, Width: width, Styles: styles} +} + +// MoveDown moves the selection down. +func (m *SyncMenu) MoveDown() { + if m.Cursor < len(AllSyncOptions)-1 { + m.Cursor++ + } +} + +// MoveUp moves the selection up. +func (m *SyncMenu) MoveUp() { + if m.Cursor > 0 { + m.Cursor-- + } +} + +// JumpToShortcut selects the option whose shortcut matches r. +func (m *SyncMenu) JumpToShortcut(r rune) bool { + r = unicode.ToLower(r) + for i, opt := range AllSyncOptions { + if opt.Shortcut == r { + m.Cursor = i + return true + } + } + return false +} + +// Selected returns the highlighted action. +func (m SyncMenu) Selected() SyncAction { + if m.Cursor < 0 || m.Cursor >= len(AllSyncOptions) { + return SyncActionPull + } + return AllSyncOptions[m.Cursor].Action +} + +// View renders the sync menu. +func (m SyncMenu) View() string { + var lines []string + lines = append(lines, m.Styles.Title.Render("YouTrack")) + for i, opt := range AllSyncOptions { + entry := fmt.Sprintf("%c %s", opt.Shortcut, opt.Label) + if i == m.Cursor { + lines = append(lines, m.Styles.Selected.Render("▸ "+entry)) + } else { + lines = append(lines, m.Styles.Normal.Render(" "+entry)) + } + } + body := strings.Join(lines, "\n") + w := m.Width + if w < 20 { + w = 20 + } + return m.Styles.Box.Width(min(44, w-2)).Render(body) +} diff --git a/internal/ui/views/tickets.go b/internal/ui/views/tickets.go index aaa2afe..3138cbf 100644 --- a/internal/ui/views/tickets.go +++ b/internal/ui/views/tickets.go @@ -176,8 +176,12 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string { innerWidth := max(10, l.Width-4) badge := l.statusBadge(t.Status) - headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2)) - headerLine := badge + " " + l.Styles.Headline.Render(headline) + ext := "" + if t.ExternalID != "" { + ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " " + } + headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2)) + headerLine := badge + " " + ext + l.Styles.Headline.Render(headline) var content string if selected { diff --git a/internal/youtrack/client.go b/internal/youtrack/client.go new file mode 100644 index 0000000..a2e6d6a --- /dev/null +++ b/internal/youtrack/client.go @@ -0,0 +1,719 @@ +package youtrack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +const ( + defaultPageSize = 100 + maxPages = 50 + issueFields = "idReadable,summary,description,updated,customFields(id,name,$type,value(name,presentation,$type),possibleEvents(id,presentation,$type))" +) + +// Issue is a minimal YouTrack issue used for sync. +type Issue struct { + IDReadable string + Summary string + Description string + State string + Updated time.Time +} + +// WorkItem is a YouTrack time-tracking work item. +type WorkItem struct { + ID string + Date time.Time + Minutes int + Text string +} + +// Client talks to the YouTrack REST API. +type Client struct { + BaseURL string + Token string + HTTPClient *http.Client + + mu sync.Mutex + projectIDByKey map[string]string +} + +func (c *Client) http() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +func (c *Client) base() string { + return strings.TrimRight(strings.TrimSpace(c.BaseURL), "/") +} + +func (c *Client) ensureAuth() error { + if c.base() == "" { + return fmt.Errorf("youtrack: base URL required") + } + if strings.TrimSpace(c.Token) == "" { + return fmt.Errorf("youtrack: token required") + } + return nil +} + +// ListIssues fetches issues matching query (paginated). +func (c *Client) ListIssues(ctx context.Context, query string) ([]Issue, error) { + if err := c.ensureAuth(); err != nil { + return nil, err + } + + var all []Issue + for page := 0; page < maxPages; page++ { + skip := page * defaultPageSize + u, err := url.Parse(c.base() + "/api/issues") + if err != nil { + return nil, fmt.Errorf("youtrack: parse url: %w", err) + } + q := u.Query() + q.Set("query", query) + q.Set("fields", issueFields) + q.Set("$top", fmt.Sprintf("%d", defaultPageSize)) + q.Set("$skip", fmt.Sprintf("%d", skip)) + u.RawQuery = q.Encode() + + var raw []rawIssue + if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &raw); err != nil { + return nil, err + } + for _, r := range raw { + all = append(all, r.toIssue()) + } + if len(raw) < defaultPageSize { + break + } + } + return all, nil +} + +// CreateIssue creates an issue in the project identified by shortName (e.g. PROJ). +func (c *Client) CreateIssue(ctx context.Context, projectKey, summary, description, state string) (Issue, error) { + if err := c.ensureAuth(); err != nil { + return Issue{}, err + } + projectKey = strings.TrimSpace(projectKey) + if projectKey == "" { + return Issue{}, fmt.Errorf("youtrack: project key required") + } + if strings.TrimSpace(summary) == "" { + return Issue{}, fmt.Errorf("youtrack: summary required") + } + + projectID, err := c.resolveProjectID(ctx, projectKey) + if err != nil { + return Issue{}, err + } + + body := map[string]any{ + "project": map[string]string{"id": projectID}, + "summary": summary, + } + if description != "" { + body["description"] = description + } + // Do not set State via customFields — project field types differ + // (StateIssueCustomField vs StateMachineIssueCustomField). Use ApplyCommand. + + u := c.base() + "/api/issues?fields=" + url.QueryEscape(issueFields) + var raw rawIssue + if err := c.doJSON(ctx, http.MethodPost, u, body, &raw); err != nil { + return Issue{}, err + } + iss := raw.toIssue() + if state != "" && iss.IDReadable != "" { + if err := c.SetState(ctx, iss.IDReadable, state); err != nil { + // Title/description already created — don't fail the whole create on state. + iss.State = iss.State // keep whatever default YouTrack assigned + _ = err + } else { + iss.State = state + } + } + return iss, nil +} + +// UpdateIssue updates summary, description and optional State on an existing issue. +func (c *Client) UpdateIssue(ctx context.Context, idReadable, summary, description, state string) (Issue, error) { + if err := c.ensureAuth(); err != nil { + return Issue{}, err + } + idReadable = strings.TrimSpace(idReadable) + if idReadable == "" { + return Issue{}, fmt.Errorf("youtrack: issue id required") + } + + body := map[string]any{ + "summary": summary, + "description": description, + } + + u := fmt.Sprintf("%s/api/issues/%s?fields=%s", + c.base(), url.PathEscape(idReadable), url.QueryEscape(issueFields)) + var raw rawIssue + if err := c.doJSON(ctx, http.MethodPost, u, body, &raw); err != nil { + return Issue{}, err + } + iss := raw.toIssue() + if iss.IDReadable == "" { + iss.IDReadable = idReadable + } + + if state != "" { + if err := c.SetState(ctx, idReadable, state); err != nil { + // Summary/description already saved — state mapping is best-effort + // (field names / state-machine transitions vary per project). + _ = err + } else { + iss.State = state + } + } + return iss, nil +} + +// GetIssue loads a single issue by readable id. +func (c *Client) GetIssue(ctx context.Context, idReadable string) (Issue, error) { + raw, err := c.getIssueRaw(ctx, idReadable) + if err != nil { + return Issue{}, err + } + return raw.toIssue(), nil +} + +func (c *Client) getIssueRaw(ctx context.Context, idReadable string) (rawIssue, error) { + if err := c.ensureAuth(); err != nil { + return rawIssue{}, err + } + idReadable = strings.TrimSpace(idReadable) + if idReadable == "" { + return rawIssue{}, fmt.Errorf("youtrack: issue id required") + } + u := fmt.Sprintf("%s/api/issues/%s?fields=%s", + c.base(), url.PathEscape(idReadable), url.QueryEscape(issueFields)) + var raw rawIssue + if err := c.doJSON(ctx, http.MethodGet, u, nil, &raw); err != nil { + return rawIssue{}, err + } + return raw, nil +} + +// SetState sets the issue's state-like custom field to desiredState. +// Discovers the real field name/$type from the issue (works with renamed fields +// and StateMachineIssueCustomField). +func (c *Client) SetState(ctx context.Context, idReadable, desiredState string) error { + desiredState = strings.TrimSpace(desiredState) + if desiredState == "" { + return nil + } + raw, err := c.getIssueRaw(ctx, idReadable) + if err != nil { + return err + } + sf := findStateField(raw.CustomFields) + if sf == nil { + return nil // project has no state-like field + } + current := parseStateValue(sf.Value) + if strings.EqualFold(current, desiredState) { + return nil + } + + // State-machine fields must be updated via transition events. + if strings.Contains(sf.Type, "StateMachine") { + ev := matchStateEvent(sf.PossibleEvents, desiredState) + if ev == nil { + return fmt.Errorf("no transition toward %q on %s (available: %s)", + desiredState, sf.Name, joinEventPresentations(sf.PossibleEvents)) + } + body := map[string]any{ + "customFields": []map[string]any{{ + "id": sf.ID, + "name": sf.Name, + "$type": sf.Type, + "event": map[string]any{ + "id": ev.ID, + "presentation": ev.Presentation, + "$type": typeOr(ev.Type, "Event"), + }, + }}, + } + u := fmt.Sprintf("%s/api/issues/%s", c.base(), url.PathEscape(idReadable)) + return c.doJSON(ctx, http.MethodPost, u, body, nil) + } + + // Plain state / enum-like field: use the field's actual name and $type. + body := map[string]any{ + "customFields": []map[string]any{{ + "id": sf.ID, + "name": sf.Name, + "$type": typeOr(sf.Type, "StateIssueCustomField"), + "value": map[string]string{"name": desiredState}, + }}, + } + u := fmt.Sprintf("%s/api/issues/%s", c.base(), url.PathEscape(idReadable)) + if err := c.doJSON(ctx, http.MethodPost, u, body, nil); err != nil { + // Fallback: command with discovered field name (quoted for multi-word values). + cmd := formatFieldCommand(sf.Name, desiredState) + if cmdErr := c.ApplyCommand(ctx, idReadable, cmd); cmdErr != nil { + return fmt.Errorf("%v (command %q also failed: %w)", err, cmd, cmdErr) + } + } + return nil +} + +// ApplyCommand runs a YouTrack command against one issue (e.g. `Status Fixed`). +func (c *Client) ApplyCommand(ctx context.Context, idReadable, command string) error { + if err := c.ensureAuth(); err != nil { + return err + } + idReadable = strings.TrimSpace(idReadable) + command = strings.TrimSpace(command) + if idReadable == "" || command == "" { + return fmt.Errorf("youtrack: issue id and command required") + } + body := map[string]any{ + "query": command, + "issues": []map[string]string{ + {"idReadable": idReadable}, + }, + } + u := c.base() + "/api/commands" + return c.doJSON(ctx, http.MethodPost, u, body, nil) +} + +func formatFieldCommand(fieldName, value string) string { + fieldName = strings.TrimSpace(fieldName) + value = strings.TrimSpace(value) + if strings.ContainsAny(value, " \t") { + return fmt.Sprintf("%s %s", fieldName, quoteCommandValue(value)) + } + return fieldName + " " + value +} + +func quoteCommandValue(v string) string { + return "{" + v + "}" +} + +type stateEvent struct { + ID string + Presentation string + Type string +} + +func findStateField(fields []rawCustomField) *rawCustomField { + var named *rawCustomField + for i := range fields { + cf := &fields[i] + if strings.Contains(cf.Type, "State") { + return cf + } + if strings.EqualFold(cf.Name, "State") || strings.EqualFold(cf.Name, "Status") { + named = cf + } + } + return named +} + +func matchStateEvent(events []rawEvent, desired string) *rawEvent { + if len(events) == 0 { + return nil + } + desiredLower := strings.ToLower(strings.TrimSpace(desired)) + for i := range events { + p := strings.ToLower(events[i].Presentation) + id := strings.ToLower(events[i].ID) + if p == desiredLower || id == desiredLower { + return &events[i] + } + } + // Fuzzy: match by status family keywords. + keywords := stateKeywords(desiredLower) + for i := range events { + p := strings.ToLower(events[i].Presentation) + id := strings.ToLower(events[i].ID) + for _, kw := range keywords { + if strings.Contains(p, kw) || strings.Contains(id, kw) { + return &events[i] + } + } + } + return nil +} + +func stateKeywords(desired string) []string { + switch { + case strings.Contains(desired, "progress"), desired == "wip": + return []string{"progress", "start", "wip", "ongoing", "bearbeitung"} + case strings.Contains(desired, "fix"), strings.Contains(desired, "done"), + strings.Contains(desired, "complete"), strings.Contains(desired, "resolve"): + return []string{"fix", "done", "complete", "resolve", "close", "erledigt", "fertig"} + default: + return []string{"open", "reopen", "submit", "offen", "neu"} + } +} + +func joinEventPresentations(events []rawEvent) string { + parts := make([]string, 0, len(events)) + for _, e := range events { + if e.Presentation != "" { + parts = append(parts, e.Presentation) + } else { + parts = append(parts, e.ID) + } + } + return strings.Join(parts, ", ") +} + +func typeOr(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} + +func (c *Client) cacheProjectID(shortName, id string) { + c.mu.Lock() + defer c.mu.Unlock() + if c.projectIDByKey == nil { + c.projectIDByKey = map[string]string{} + } + c.projectIDByKey[shortName] = id +} + +func (c *Client) resolveProjectID(ctx context.Context, shortName string) (string, error) { + shortName = strings.TrimSpace(shortName) + if shortName == "" { + return "", fmt.Errorf("youtrack: project key required") + } + + c.mu.Lock() + if c.projectIDByKey == nil { + c.projectIDByKey = map[string]string{} + } + if id, ok := c.projectIDByKey[shortName]; ok { + c.mu.Unlock() + return id, nil + } + c.mu.Unlock() + + // 1) Direct lookup — YouTrack accepts shortName as {projectID}. + if id, err := c.fetchProjectByID(ctx, shortName); err == nil { + c.cacheProjectID(shortName, id) + return id, nil + } + + // 2) List/filter — query is a free-text match on name/shortName (not "shortName: X"). + if id, err := c.findProjectInList(ctx, shortName); err == nil { + c.cacheProjectID(shortName, id) + return id, nil + } + + // 3) Fallback without admin rights: read project id from an existing issue. + if id, err := c.findProjectViaIssue(ctx, shortName); err == nil { + c.cacheProjectID(shortName, id) + return id, nil + } + + return "", fmt.Errorf("youtrack: project %q not found", shortName) +} + +func (c *Client) fetchProjectByID(ctx context.Context, projectID string) (string, error) { + u := fmt.Sprintf("%s/api/admin/projects/%s?fields=%s", + c.base(), url.PathEscape(projectID), url.QueryEscape("id,shortName")) + var p struct { + ID string `json:"id"` + ShortName string `json:"shortName"` + } + if err := c.doJSON(ctx, http.MethodGet, u, nil, &p); err != nil { + return "", err + } + if p.ID == "" { + return "", fmt.Errorf("empty project id") + } + return p.ID, nil +} + +func (c *Client) findProjectInList(ctx context.Context, shortName string) (string, error) { + u, err := url.Parse(c.base() + "/api/admin/projects") + if err != nil { + return "", err + } + q := u.Query() + q.Set("fields", "id,shortName") + q.Set("query", shortName) + q.Set("$top", "100") + u.RawQuery = q.Encode() + + var projects []struct { + ID string `json:"id"` + ShortName string `json:"shortName"` + } + if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &projects); err != nil { + return "", err + } + for _, p := range projects { + if strings.EqualFold(p.ShortName, shortName) && p.ID != "" { + return p.ID, nil + } + } + // Unfiltered list as last admin attempt (small instances). + u2, err := url.Parse(c.base() + "/api/admin/projects") + if err != nil { + return "", err + } + q2 := u2.Query() + q2.Set("fields", "id,shortName") + q2.Set("$top", "1000") + u2.RawQuery = q2.Encode() + if err := c.doJSON(ctx, http.MethodGet, u2.String(), nil, &projects); err != nil { + return "", err + } + for _, p := range projects { + if strings.EqualFold(p.ShortName, shortName) && p.ID != "" { + return p.ID, nil + } + } + return "", fmt.Errorf("not in project list") +} + +func (c *Client) findProjectViaIssue(ctx context.Context, shortName string) (string, error) { + u, err := url.Parse(c.base() + "/api/issues") + if err != nil { + return "", err + } + q := u.Query() + q.Set("query", "project: "+shortName) + q.Set("fields", "idReadable,project(id,shortName)") + q.Set("$top", "1") + u.RawQuery = q.Encode() + + var issues []struct { + Project *struct { + ID string `json:"id"` + ShortName string `json:"shortName"` + } `json:"project"` + } + if err := c.doJSON(ctx, http.MethodGet, u.String(), nil, &issues); err != nil { + return "", err + } + if len(issues) == 0 || issues[0].Project == nil || issues[0].Project.ID == "" { + return "", fmt.Errorf("no issues in project %s to resolve id", shortName) + } + return issues[0].Project.ID, nil +} + +// IssueURL builds the browser URL for an issue. +func (c *Client) IssueURL(idReadable string) string { + return c.base() + "/issue/" + idReadable +} + +const workItemFields = "id,date,duration(minutes,presentation),text" + +// ListWorkItems returns time-tracking work items for an issue. +func (c *Client) ListWorkItems(ctx context.Context, idReadable string) ([]WorkItem, error) { + if err := c.ensureAuth(); err != nil { + return nil, err + } + idReadable = strings.TrimSpace(idReadable) + if idReadable == "" { + return nil, fmt.Errorf("youtrack: issue id required") + } + u := fmt.Sprintf("%s/api/issues/%s/timeTracking/workItems?fields=%s&$top=%d", + c.base(), url.PathEscape(idReadable), url.QueryEscape(workItemFields), defaultPageSize) + var raw []rawWorkItem + if err := c.doJSON(ctx, http.MethodGet, u, nil, &raw); err != nil { + return nil, err + } + out := make([]WorkItem, 0, len(raw)) + for _, r := range raw { + out = append(out, r.toWorkItem()) + } + return out, nil +} + +// CreateWorkItem adds a work item (duration in minutes) to an issue. +func (c *Client) CreateWorkItem(ctx context.Context, idReadable string, minutes int, date time.Time, text string) (WorkItem, error) { + if err := c.ensureAuth(); err != nil { + return WorkItem{}, err + } + idReadable = strings.TrimSpace(idReadable) + if idReadable == "" { + return WorkItem{}, fmt.Errorf("youtrack: issue id required") + } + if minutes < 1 { + minutes = 1 + } + body := map[string]any{ + "duration": map[string]any{"minutes": minutes}, + } + if !date.IsZero() { + // YouTrack expects epoch millis; time part is typically midnight UTC. + midnight := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC) + body["date"] = midnight.UnixMilli() + } + if strings.TrimSpace(text) != "" { + body["text"] = text + } + u := fmt.Sprintf("%s/api/issues/%s/timeTracking/workItems?fields=%s", + c.base(), url.PathEscape(idReadable), url.QueryEscape(workItemFields)) + var raw rawWorkItem + if err := c.doJSON(ctx, http.MethodPost, u, body, &raw); err != nil { + return WorkItem{}, err + } + return raw.toWorkItem(), nil +} + +// DeleteWorkItem removes a work item from an issue. +func (c *Client) DeleteWorkItem(ctx context.Context, idReadable, workItemID string) error { + if err := c.ensureAuth(); err != nil { + return err + } + idReadable = strings.TrimSpace(idReadable) + workItemID = strings.TrimSpace(workItemID) + if idReadable == "" || workItemID == "" { + return fmt.Errorf("youtrack: issue id and work item id required") + } + u := fmt.Sprintf("%s/api/issues/%s/timeTracking/workItems/%s", + c.base(), url.PathEscape(idReadable), url.PathEscape(workItemID)) + return c.doJSON(ctx, http.MethodDelete, u, nil, nil) +} + +type rawWorkItem struct { + ID string `json:"id"` + Date int64 `json:"date"` + Text string `json:"text"` + Duration *struct { + Minutes int `json:"minutes"` + } `json:"duration"` +} + +func (r rawWorkItem) toWorkItem() WorkItem { + w := WorkItem{ID: r.ID, Text: r.Text} + if r.Date > 0 { + w.Date = time.UnixMilli(r.Date).UTC() + } + if r.Duration != nil { + w.Minutes = r.Duration.Minutes + } + return w +} + +func (c *Client) doJSON(ctx context.Context, method, rawURL string, body any, dest any) error { + var rdr io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("youtrack: marshal: %w", err) + } + rdr = bytes.NewReader(data) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, rdr) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http().Do(req) + if err != nil { + return fmt.Errorf("youtrack: request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("youtrack: read body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("youtrack: %s: %s", resp.Status, truncate(string(respBody), 300)) + } + if dest == nil || len(respBody) == 0 { + return nil + } + if err := json.Unmarshal(respBody, dest); err != nil { + return fmt.Errorf("youtrack: decode: %w", err) + } + return nil +} + +type rawIssue struct { + IDReadable string `json:"idReadable"` + Summary string `json:"summary"` + Description string `json:"description"` + Updated int64 `json:"updated"` + CustomFields []rawCustomField `json:"customFields"` +} + +type rawCustomField struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"$type"` + Value json.RawMessage `json:"value"` + PossibleEvents []rawEvent `json:"possibleEvents"` +} + +type rawEvent struct { + ID string `json:"id"` + Presentation string `json:"presentation"` + Type string `json:"$type"` +} + +func (r rawIssue) toIssue() Issue { + iss := Issue{ + IDReadable: r.IDReadable, + Summary: r.Summary, + Description: r.Description, + } + if r.Updated > 0 { + iss.Updated = time.UnixMilli(r.Updated).UTC() + } + if sf := findStateField(r.CustomFields); sf != nil { + iss.State = parseStateValue(sf.Value) + } + return iss +} + +func parseStateValue(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var obj struct { + Name string `json:"name"` + Presentation string `json:"presentation"` + } + if err := json.Unmarshal(raw, &obj); err == nil { + if obj.Name != "" { + return obj.Name + } + return obj.Presentation + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return "" +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/youtrack/client_test.go b/internal/youtrack/client_test.go new file mode 100644 index 0000000..5d9a9af --- /dev/null +++ b/internal/youtrack/client_test.go @@ -0,0 +1,266 @@ +package youtrack_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "task/internal/youtrack" +) + +func TestListIssues(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/issues" { + t.Fatalf("path = %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Fatalf("auth = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + { + "idReadable": "PROJ-1", + "summary": "Fix bug", + "description": "details", + "updated": 1700000000000, + "customFields": [ + {"name": "State", "$type": "StateIssueCustomField", "value": {"name": "In Progress", "$type": "StateBundleElement"}} + ] + } + ]`)) + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()} + issues, err := c.ListIssues(context.Background(), "project: PROJ") + if err != nil { + t.Fatalf("ListIssues: %v", err) + } + if len(issues) != 1 { + t.Fatalf("len = %d", len(issues)) + } + iss := issues[0] + if iss.IDReadable != "PROJ-1" || iss.Summary != "Fix bug" || iss.State != "In Progress" { + t.Fatalf("issue = %+v", iss) + } + if c.IssueURL("PROJ-1") != srv.URL+"/issue/PROJ-1" { + t.Fatalf("url = %s", c.IssueURL("PROJ-1")) + } +} + +func TestListIssuesHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusUnauthorized) + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "bad", HTTPClient: srv.Client()} + _, err := c.ListIssues(context.Background(), "project: X") + if err == nil { + t.Fatal("expected error") + } +} + +func TestCreateAndUpdateIssueWithRenamedStateField(t *testing.T) { + var statePosts []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/PROJ": + _, _ = w.Write([]byte(`{"id":"0-1","shortName":"PROJ"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/issues": + body, _ := io.ReadAll(r.Body) + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("body: %v", err) + } + if _, hasCF := payload["customFields"]; hasCF { + t.Fatal("create must not send customFields for State") + } + _, _ = w.Write([]byte(`{"idReadable":"PROJ-9","summary":"New","description":"body"}`)) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/issues/"): + // Field is named "Status", not "State". + _, _ = w.Write([]byte(`{ + "idReadable":"PROJ-9","summary":"New","description":"body", + "customFields":[{ + "id":"92-1","name":"Status","$type":"StateIssueCustomField", + "value":{"name":"Submitted"} + }] + }`)) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/issues/"): + body, _ := io.ReadAll(r.Body) + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if cfs, ok := payload["customFields"].([]any); ok { + statePosts = append(statePosts, cfs[0].(map[string]any)) + w.WriteHeader(http.StatusOK) + return + } + if payload["summary"] == "Updated" { + _, _ = w.Write([]byte(`{"idReadable":"PROJ-9","summary":"Updated","description":"new"}`)) + return + } + t.Fatalf("unexpected post body: %s", body) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()} + created, err := c.CreateIssue(context.Background(), "PROJ", "New", "body", "Open") + if err != nil { + t.Fatalf("CreateIssue: %v", err) + } + if created.IDReadable != "PROJ-9" { + t.Fatalf("created = %+v", created) + } + + updated, err := c.UpdateIssue(context.Background(), "PROJ-9", "Updated", "new", "In Progress") + if err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + if updated.Summary != "Updated" { + t.Fatalf("updated = %+v", updated) + } + if len(statePosts) < 1 { + t.Fatal("expected state field posts") + } + for _, p := range statePosts { + if p["name"] != "Status" { + t.Fatalf("expected field name Status, got %v", p["name"]) + } + if p["$type"] != "StateIssueCustomField" { + t.Fatalf("$type = %v", p["$type"]) + } + } +} + +func TestSetStateMachineEvent(t *testing.T) { + var posted 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: + _, _ = w.Write([]byte(`{ + "idReadable":"TAS-1","summary":"x", + "customFields":[{ + "id":"82-11","name":"Stage","$type":"StateMachineIssueCustomField", + "value":{"name":"Open"}, + "possibleEvents":[ + {"id":"start","presentation":"Start progress","$type":"Event"}, + {"id":"fix","presentation":"Fixed","$type":"Event"} + ] + }] + }`)) + case r.Method == http.MethodPost: + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &posted) + w.WriteHeader(http.StatusOK) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()} + if err := c.SetState(context.Background(), "TAS-1", "In Progress"); err != nil { + t.Fatalf("SetState: %v", err) + } + cfs := posted["customFields"].([]any) + cf := cfs[0].(map[string]any) + if cf["name"] != "Stage" || cf["$type"] != "StateMachineIssueCustomField" { + t.Fatalf("cf = %+v", cf) + } + ev := cf["event"].(map[string]any) + if ev["id"] != "start" { + t.Fatalf("event = %+v (want fuzzy match to Start progress)", ev) + } +} + +func TestToIssueReadsStatusField(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":"TAS-1","summary":"x", + "customFields":[{ + "id":"1","name":"Status","$type":"StateIssueCustomField", + "value":{"name":"In Progress"} + }] + }`)) + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()} + iss, err := c.GetIssue(context.Background(), "TAS-1") + if err != nil { + t.Fatal(err) + } + if iss.State != "In Progress" { + t.Fatalf("state = %q", iss.State) + } +} + +func TestCreateAndListWorkItems(t *testing.T) { + var created 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 && strings.Contains(r.URL.Path, "/timeTracking/workItems"): + _, _ = w.Write([]byte(`[{"id":"wi-1","date":1700000000000,"text":"x","duration":{"minutes":30}}]`)) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"): + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &created) + _, _ = w.Write([]byte(`{"id":"wi-2","date":1700000000000,"text":"note","duration":{"minutes":45}}`)) + default: + t.Fatalf("unexpected %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()} + list, err := c.ListWorkItems(context.Background(), "TAS-1") + if err != nil || len(list) != 1 || list[0].Minutes != 30 { + t.Fatalf("list = %+v err=%v", list, err) + } + wi, err := c.CreateWorkItem(context.Background(), "TAS-1", 45, time.UnixMilli(1700000000000), "note") + if err != nil { + t.Fatal(err) + } + if wi.ID != "wi-2" { + t.Fatalf("wi = %+v", wi) + } + dur := created["duration"].(map[string]any) + if int(dur["minutes"].(float64)) != 45 { + t.Fatalf("created = %+v", created) + } +} + +func TestApplyCommand(t *testing.T) { + var got string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/commands" { + t.Fatalf("path %s", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + var payload map[string]any + _ = json.Unmarshal(body, &payload) + got = fmt.Sprint(payload["query"]) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := &youtrack.Client{BaseURL: srv.URL, Token: "tok", HTTPClient: srv.Client()} + if err := c.ApplyCommand(context.Background(), "TAS-1", "Status Open"); err != nil { + t.Fatal(err) + } + if got != "Status Open" { + t.Fatalf("query = %q", got) + } +} diff --git a/internal/ytsync/youtrack.go b/internal/ytsync/youtrack.go new file mode 100644 index 0000000..8ee1311 --- /dev/null +++ b/internal/ytsync/youtrack.go @@ -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) +} diff --git a/internal/ytsync/youtrack_test.go b/internal/ytsync/youtrack_test.go new file mode 100644 index 0000000..4506dce --- /dev/null +++ b/internal/ytsync/youtrack_test.go @@ -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) + } +}