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

Wire manual Y menu and CLI sync for issues and work items, persist
per-project credentials, and keep local edits until an explicit push.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-24 00:17:51 +02:00
co-authored by Cursor
parent 19ae1f1b62
commit 98d2253f44
23 changed files with 2874 additions and 44 deletions
+18
View File
@@ -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/<id>/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
+79 -4
View File
@@ -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 <pull|push> [--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 <pull|push> [--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)
}
+107
View File
@@ -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: <ProjectKey>" 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/<id>/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/<id>/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
}
+70
View File
@@ -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())
}
}
+2
View File
@@ -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.
+38
View File
@@ -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()
+38
View File
@@ -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")
+3
View File
@@ -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
}
+11
View File
@@ -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"`
}
+35 -24
View File
@@ -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
}
+256 -6
View File
@@ -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")
+16
View File
@@ -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"},
+16 -1
View File
@@ -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.
+7
View File
@@ -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)
+199
View File
@@ -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()
}
+40
View File
@@ -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")
}
}
+26 -7
View File
@@ -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
}
+94
View File
@@ -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)
}
+6 -2
View File
@@ -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 {
+719
View File
@@ -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] + "…"
}
+266
View File
@@ -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)
}
}
+484
View File
@@ -0,0 +1,484 @@
package ytsync
import (
"context"
"fmt"
"strings"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/youtrack"
)
// Result summarizes a pull or push sync.
type Result struct {
Created int
Updated int
Skipped int
Total int
Pushed int // creates + updates sent to YouTrack
TimePushed int // work items created on YouTrack
TimePulled int // work items imported locally
}
// SyncResult is the outcome of a full bidirectional sync.
type SyncResult struct {
Push Result
Pull Result
}
// YouTrackSyncer syncs issues with the local ticket store.
type YouTrackSyncer struct {
Store ticket.Store
TimeStore ticket.TimeStore
Client *youtrack.Client
}
func (s *YouTrackSyncer) clientFor(yt project.YouTrack) *youtrack.Client {
client := s.Client
if client == nil {
return &youtrack.Client{BaseURL: yt.BaseURL, Token: yt.Token}
}
client.BaseURL = yt.BaseURL
client.Token = yt.Token
return client
}
// MapStatus converts a YouTrack State name to a local Status.
func MapStatus(state string) ticket.Status {
s := strings.ToLower(strings.TrimSpace(state))
switch {
case s == "":
return ticket.StatusTodo
case strings.Contains(s, "progress"), strings.Contains(s, "wip"),
s == "ongoing", s == "doing", s == "in progress",
strings.Contains(s, "bearbeitung"), strings.Contains(s, "arbeit"):
return ticket.StatusInProgress
case strings.Contains(s, "done"), strings.Contains(s, "fixed"),
strings.Contains(s, "complete"), strings.Contains(s, "resolved"),
strings.Contains(s, "obsolete"), strings.Contains(s, "closed"),
strings.Contains(s, "erledigt"), strings.Contains(s, "fertig"),
s == "verified", s == "duplicate", s == "won't fix", s == "cant reproduce":
return ticket.StatusDone
default:
return ticket.StatusTodo
}
}
// MapState converts a local Status to a YouTrack State name.
func MapState(st ticket.Status) string {
switch st {
case ticket.StatusInProgress:
return "In Progress"
case ticket.StatusDone:
return "Fixed"
default:
return "Open"
}
}
// NeedsPush reports whether a local ticket should be sent to YouTrack.
func NeedsPush(t ticket.Ticket) bool {
if t.ExternalID == "" {
// Local-only ticket in a YT-configured project → create remotely.
return true
}
if t.Source != ticket.SourceYouTrack {
return false
}
if t.SyncedAt.IsZero() {
return true
}
return t.UpdatedAt.After(t.SyncedAt)
}
// Pull syncs YouTrack issues into projectID using yt settings.
// Local tickets that still need a push (dirty) are not overwritten.
func (s *YouTrackSyncer) Pull(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) {
if !yt.Configured() {
return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
client := s.clientFor(yt)
issues, err := client.ListIssues(ctx, yt.IssueQuery())
if err != nil {
return Result{}, err
}
existing, err := s.Store.List(projectID)
if err != nil {
return Result{}, err
}
byExt := make(map[string]ticket.Ticket, len(existing))
for _, t := range existing {
if t.ExternalID != "" {
byExt[t.ExternalID] = t
}
}
var res Result
res.Total = len(issues)
for _, iss := range issues {
if iss.IDReadable == "" {
res.Skipped++
continue
}
status := MapStatus(iss.State)
issueURL := client.IssueURL(iss.IDReadable)
title := iss.Summary
if title == "" {
title = iss.IDReadable
}
if prev, ok := byExt[iss.IDReadable]; ok {
if NeedsPush(prev) {
res.Skipped++
continue
}
next := prev
next.Title = title
next.Description = iss.Description
// Only apply remote status when we actually parsed a state field.
// Empty state used to map to OPEN and wiped local WIP/DONE on every pull.
if iss.State != "" {
next.Status = status
}
next.Source = ticket.SourceYouTrack
next.ExternalURL = issueURL
if sameSyncedFields(prev, next) {
if err := s.Store.MarkSynced(prev.ID); err != nil {
return res, fmt.Errorf("touch %s: %w", iss.IDReadable, err)
}
res.Skipped++
continue
}
if err := s.Store.Update(next); err != nil {
return res, fmt.Errorf("update %s: %w", iss.IDReadable, err)
}
if err := s.Store.MarkSynced(next.ID); err != nil {
return res, fmt.Errorf("mark synced %s: %w", iss.IDReadable, err)
}
res.Updated++
continue
}
now := time.Now().UTC()
created, err := s.Store.Create(ticket.Ticket{
Title: title,
Description: iss.Description,
Status: status,
ProjectID: projectID,
Source: ticket.SourceYouTrack,
ExternalID: iss.IDReadable,
ExternalURL: issueURL,
CreatedAt: now,
UpdatedAt: now,
SyncedAt: now,
})
if err != nil {
return res, fmt.Errorf("create %s: %w", iss.IDReadable, err)
}
_ = created
res.Created++
}
if err := s.pullTimeEntries(ctx, client, projectID, &res); err != nil {
return res, err
}
return res, nil
}
// Push sends local tickets to YouTrack: creates for tickets without ExternalID,
// updates for dirty synced tickets, then pushes unsynced time entries.
func (s *YouTrackSyncer) Push(ctx context.Context, projectID string, yt project.YouTrack) (Result, error) {
if !yt.Configured() {
return Result{}, fmt.Errorf("youtrack not configured for project %s", projectID)
}
if s.Store == nil {
return Result{}, fmt.Errorf("ticket store required")
}
client := s.clientFor(yt)
existing, err := s.Store.List(projectID)
if err != nil {
return Result{}, err
}
var res Result
for _, t := range existing {
if !NeedsPush(t) {
res.Skipped++
continue
}
res.Total++
if t.ExternalID == "" {
if err := s.pushCreate(ctx, client, yt, t); err != nil {
return res, err
}
res.Created++
res.Pushed++
continue
}
if err := s.pushUpdate(ctx, client, t); err != nil {
return res, err
}
res.Updated++
res.Pushed++
}
if err := s.pushTimeEntries(ctx, client, projectID, &res); err != nil {
return res, err
}
return res, nil
}
func (s *YouTrackSyncer) pushTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(projectID)
if err != nil {
return err
}
byID := make(map[string]ticket.Ticket, len(tickets))
for _, t := range tickets {
byID[t.ID] = t
}
entries, err := s.TimeStore.List()
if err != nil {
return err
}
for _, e := range entries {
if e.ProjectID != projectID || e.ExternalID != "" {
continue
}
t, ok := byID[e.TicketID]
if !ok || t.ExternalID == "" {
continue // ticket not linked to YouTrack yet
}
minutes := durationMinutes(e.Duration)
text := e.Note
if text == "" && e.Source != "" {
text = "logged via task-master (" + e.Source + ")"
}
wi, err := client.CreateWorkItem(ctx, t.ExternalID, minutes, e.Start, text)
if err != nil {
return fmt.Errorf("push time for %s: %w", t.ExternalID, err)
}
e.ExternalID = wi.ID
if err := s.TimeStore.Update(e); err != nil {
return fmt.Errorf("link time entry %s: %w", e.ID, err)
}
res.TimePushed++
}
return nil
}
func (s *YouTrackSyncer) pullTimeEntries(ctx context.Context, client *youtrack.Client, projectID string, res *Result) error {
if s.TimeStore == nil {
return nil
}
tickets, err := s.Store.List(projectID)
if err != nil {
return err
}
local, err := s.TimeStore.List()
if err != nil {
return err
}
haveExt := make(map[string]bool)
for _, e := range local {
if e.ExternalID != "" {
haveExt[e.ExternalID] = true
}
}
for _, t := range tickets {
if t.ExternalID == "" {
continue
}
items, err := client.ListWorkItems(ctx, t.ExternalID)
if err != nil {
return fmt.Errorf("list work items %s: %w", t.ExternalID, err)
}
for _, wi := range items {
if wi.ID == "" || haveExt[wi.ID] {
continue
}
dur := time.Duration(wi.Minutes) * time.Minute
if dur <= 0 {
continue
}
start := wi.Date
if start.IsZero() {
start = time.Now().UTC()
}
_, err := s.TimeStore.Create(ticket.TimeEntry{
TicketID: t.ID,
ProjectID: projectID,
Start: start,
End: start.Add(dur),
Duration: dur,
Source: ticket.SourceYouTrackTime,
Note: wi.Text,
ExternalID: wi.ID,
})
if err != nil {
return fmt.Errorf("import work item %s: %w", wi.ID, err)
}
haveExt[wi.ID] = true
res.TimePulled++
}
}
return nil
}
func durationMinutes(d time.Duration) int {
if d <= 0 {
return 1
}
m := int((d + time.Minute - 1) / time.Minute) // round up
if m < 1 {
return 1
}
return m
}
// PushTicket creates or updates a single ticket on YouTrack.
func (s *YouTrackSyncer) PushTicket(ctx context.Context, yt project.YouTrack, t ticket.Ticket) (ticket.Ticket, error) {
if !yt.Configured() {
return t, fmt.Errorf("youtrack not configured")
}
if s.Store == nil {
return t, fmt.Errorf("ticket store required")
}
if !NeedsPush(t) {
return t, nil
}
client := s.clientFor(yt)
if t.ExternalID == "" {
if err := s.pushCreate(ctx, client, yt, t); err != nil {
return t, err
}
} else {
if err := s.pushUpdate(ctx, client, t); err != nil {
return t, err
}
}
updated, err := s.Store.Get(t.ID)
if err != nil {
return t, err
}
return updated, nil
}
func (s *YouTrackSyncer) pushCreate(ctx context.Context, client *youtrack.Client, yt project.YouTrack, t ticket.Ticket) error {
iss, err := client.CreateIssue(ctx, yt.ProjectKey, t.Title, t.Description, MapState(t.Status))
if err != nil {
return fmt.Errorf("create remote for %s: %w", t.ID, err)
}
t.Source = ticket.SourceYouTrack
t.ExternalID = iss.IDReadable
t.ExternalURL = client.IssueURL(iss.IDReadable)
if err := s.Store.Update(t); err != nil {
return fmt.Errorf("link %s → %s: %w", t.ID, iss.IDReadable, err)
}
if err := s.Store.MarkSynced(t.ID); err != nil {
return fmt.Errorf("mark synced %s: %w", iss.IDReadable, err)
}
return nil
}
func (s *YouTrackSyncer) pushUpdate(ctx context.Context, client *youtrack.Client, t ticket.Ticket) error {
// Title/description only — state is applied separately when we can read the remote field.
_, err := client.UpdateIssue(ctx, t.ExternalID, t.Title, t.Description, "")
if err != nil {
return fmt.Errorf("update remote %s: %w", t.ExternalID, err)
}
if remote, err := client.GetIssue(ctx, t.ExternalID); err == nil && remote.State != "" {
if MapStatus(remote.State) != t.Status {
_ = client.SetState(ctx, t.ExternalID, MapState(t.Status))
}
}
t.Source = ticket.SourceYouTrack
t.ExternalURL = client.IssueURL(t.ExternalID)
if err := s.Store.Update(t); err != nil {
return fmt.Errorf("update local %s: %w", t.ExternalID, err)
}
if err := s.Store.MarkSynced(t.ID); err != nil {
return fmt.Errorf("mark synced %s: %w", t.ExternalID, err)
}
return nil
}
// Sync runs Push then Pull for a project.
func (s *YouTrackSyncer) Sync(ctx context.Context, projectID string, yt project.YouTrack) (SyncResult, error) {
var out SyncResult
var err error
out.Push, err = s.Push(ctx, projectID, yt)
if err != nil {
return out, err
}
out.Pull, err = s.Pull(ctx, projectID, yt)
return out, err
}
func sameSyncedFields(a, b ticket.Ticket) bool {
return a.Title == b.Title &&
a.Description == b.Description &&
a.Status == b.Status &&
a.ExternalURL == b.ExternalURL &&
a.Source == b.Source
}
// MetaLoader loads per-project YouTrack settings.
type MetaLoader interface {
LoadMeta(id string) (project.Meta, error)
}
func loadYT(projects MetaLoader, projectID string) (project.YouTrack, error) {
meta, err := projects.LoadMeta(projectID)
if err != nil {
return project.YouTrack{}, err
}
if meta.YouTrack == nil || !meta.YouTrack.Configured() {
return project.YouTrack{}, fmt.Errorf("project %s has no YouTrack config (edit project: URL, token, key)", projectID)
}
return *meta.YouTrack, nil
}
// SyncProject pushes local changes then pulls remote issues.
func SyncProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (SyncResult, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return SyncResult{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Sync(ctx, projectID, yt)
}
// PullProject loads meta for projectID and runs Pull (no push).
func PullProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return Result{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Pull(ctx, projectID, yt)
}
// PushProject loads meta for projectID and runs Push (no pull).
func PushProject(ctx context.Context, projects MetaLoader, store ticket.Store, timeStore ticket.TimeStore, projectID string) (Result, error) {
yt, err := loadYT(projects, projectID)
if err != nil {
return Result{}, err
}
s := &YouTrackSyncer{Store: store, TimeStore: timeStore}
return s.Push(ctx, projectID, yt)
}
+344
View File
@@ -0,0 +1,344 @@
package ytsync_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"task/internal/project"
"task/internal/ticket"
"task/internal/youtrack"
"task/internal/ytsync"
)
func TestMapStatus(t *testing.T) {
cases := map[string]ticket.Status{
"": ticket.StatusTodo,
"Open": ticket.StatusTodo,
"In Progress": ticket.StatusInProgress,
"Fixed": ticket.StatusDone,
"Done": ticket.StatusDone,
"Obsolete": ticket.StatusDone,
}
for in, want := range cases {
if got := ytsync.MapStatus(in); got != want {
t.Fatalf("MapStatus(%q) = %s, want %s", in, got, want)
}
}
}
func TestMapState(t *testing.T) {
if ytsync.MapState(ticket.StatusTodo) != "Open" {
t.Fatal("todo")
}
if ytsync.MapState(ticket.StatusInProgress) != "In Progress" {
t.Fatal("wip")
}
if ytsync.MapState(ticket.StatusDone) != "Fixed" {
t.Fatal("done")
}
}
func TestNeedsPush(t *testing.T) {
now := time.Now().UTC()
local := ticket.Ticket{Title: "x"}
if !ytsync.NeedsPush(local) {
t.Fatal("local without external id should push")
}
synced := ticket.Ticket{
Source: ticket.SourceYouTrack, ExternalID: "P-1",
UpdatedAt: now, SyncedAt: now,
}
if ytsync.NeedsPush(synced) {
t.Fatal("freshly synced should not push")
}
dirty := synced
dirty.UpdatedAt = now.Add(time.Minute)
if !ytsync.NeedsPush(dirty) {
t.Fatal("dirty should push")
}
}
func TestPullCreateAndUpdate(t *testing.T) {
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Content-Type", "application/json")
if calls == 1 {
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"One","description":"d1","updated":1,
"customFields":[{"name":"State","value":{"name":"Open"}}]}
]`))
return
}
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"One updated","description":"d2","updated":2,
"customFields":[{"name":"State","value":{"name":"In Progress"}}]},
{"idReadable":"P-2","summary":"Two","description":"","updated":2,
"customFields":[{"name":"State","value":{"name":"Fixed"}}]}
]`))
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
r1, err := syncer.Pull(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("pull1: %v", err)
}
if r1.Created != 1 || r1.Updated != 0 {
t.Fatalf("r1 = %+v", r1)
}
r2, err := syncer.Pull(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("pull2: %v", err)
}
if r2.Created != 1 || r2.Updated != 1 {
t.Fatalf("r2 = %+v", r2)
}
list, err := store.List("inbox")
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("len = %d", len(list))
}
byExt := map[string]ticket.Ticket{}
for _, tk := range list {
byExt[tk.ExternalID] = tk
}
if byExt["P-1"].Title != "One updated" || byExt["P-1"].Status != ticket.StatusInProgress {
t.Fatalf("P-1 = %+v", byExt["P-1"])
}
if byExt["P-2"].Status != ticket.StatusDone || byExt["P-2"].Source != ticket.SourceYouTrack {
t.Fatalf("P-2 = %+v", byExt["P-2"])
}
}
func TestPushCreateAndUpdate(t *testing.T) {
var createdBody, updatedBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/admin/projects/P":
_, _ = w.Write([]byte(`{"id":"0-1","shortName":"P"}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/issues":
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &createdBody)
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local","description":"d"}`))
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/issues/P-10"):
_, _ = w.Write([]byte(`{
"idReadable":"P-10","summary":"Local","description":"d",
"customFields":[{"id":"1","name":"State","$type":"StateIssueCustomField","value":{"name":"Open"}}]
}`))
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/api/issues/P-10"):
b, _ := io.ReadAll(r.Body)
var payload map[string]any
_ = json.Unmarshal(b, &payload)
if _, ok := payload["customFields"]; ok {
// state set
w.WriteHeader(http.StatusOK)
return
}
_ = json.Unmarshal(b, &updatedBody)
_, _ = w.Write([]byte(`{"idReadable":"P-10","summary":"Local2","description":"d2"}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/issues":
_, _ = w.Write([]byte(`[]`))
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
local, err := store.Create(ticket.Ticket{
Title: "Local", Description: "d", ProjectID: "inbox", Status: ticket.StatusTodo,
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
push, err := syncer.Push(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("push: %v", err)
}
if push.Created != 1 {
t.Fatalf("push = %+v", push)
}
if createdBody["summary"] != "Local" {
t.Fatalf("created body = %+v", createdBody)
}
got, err := store.Get(local.ID)
if err != nil {
t.Fatal(err)
}
if got.ExternalID != "P-10" || got.Source != ticket.SourceYouTrack {
t.Fatalf("linked = %+v", got)
}
if ytsync.NeedsPush(got) {
t.Fatalf("should not need push after create: updated=%v synced=%v", got.UpdatedAt, got.SyncedAt)
}
got.Title = "Local2"
got.Status = ticket.StatusInProgress
got.UpdatedAt = time.Now().UTC().Add(time.Minute)
if err := store.Update(got); err != nil {
t.Fatal(err)
}
got, _ = store.Get(local.ID)
if !ytsync.NeedsPush(got) {
t.Fatal("expected dirty after local edit")
}
push2, err := syncer.Push(context.Background(), "inbox", yt)
if err != nil {
t.Fatalf("push2: %v", err)
}
if push2.Updated != 1 {
t.Fatalf("push2 = %+v", push2)
}
if updatedBody["summary"] != "Local2" {
t.Fatalf("updated body = %+v", updatedBody)
}
}
func TestPushTimeEntries(t *testing.T) {
var workPosts int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/timeTracking/workItems"):
workPosts++
_, _ = w.Write([]byte(`{"id":"wi-9","date":1,"duration":{"minutes":15},"text":"n"}`))
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer srv.Close()
dir := t.TempDir()
store := ticket.NewJSONStore(dir)
timeStore := ticket.NewJSONTimeStore(dir)
tk, err := store.Create(ticket.Ticket{
Title: "T", ProjectID: "inbox", Status: ticket.StatusTodo,
Source: ticket.SourceYouTrack, ExternalID: "TAS-1",
})
if err != nil {
t.Fatal(err)
}
if err := store.MarkSynced(tk.ID); err != nil {
t.Fatal(err)
}
_, err = timeStore.Create(ticket.TimeEntry{
TicketID: tk.ID, ProjectID: "inbox",
Duration: 15 * time.Minute, Source: ticket.SourceManual, Note: "n",
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, TimeStore: timeStore, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "TAS"}
res, err := syncer.Push(context.Background(), "inbox", yt)
if err != nil {
t.Fatal(err)
}
if res.TimePushed != 1 || workPosts != 1 {
t.Fatalf("res=%+v posts=%d", res, workPosts)
}
entries, _ := timeStore.ListByTicket(tk.ID)
if entries[0].ExternalID != "wi-9" {
t.Fatalf("entry = %+v", entries[0])
}
}
func TestPullKeepsStatusWhenRemoteStateEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if strings.Contains(r.URL.Path, "/timeTracking/") {
_, _ = w.Write([]byte(`[]`))
return
}
// No State custom field → empty remote state must not wipe local WIP.
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"One","description":"d","updated":2,"customFields":[]}
]`))
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
now := time.Now().UTC()
_, err := store.Create(ticket.Ticket{
Title: "One", ProjectID: "inbox", Status: ticket.StatusInProgress,
Source: ticket.SourceYouTrack, ExternalID: "P-1",
UpdatedAt: now, SyncedAt: now,
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
if _, err := syncer.Pull(context.Background(), "inbox", yt); err != nil {
t.Fatal(err)
}
list, _ := store.List("inbox")
if list[0].Status != ticket.StatusInProgress {
t.Fatalf("status = %s, want in_progress", list[0].Status)
}
}
func TestPullSkipsDirtyLocal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"idReadable":"P-1","summary":"Remote","description":"","updated":2,
"customFields":[{"name":"State","value":{"name":"Open"}}]}
]`))
}))
defer srv.Close()
store := ticket.NewJSONStore(t.TempDir())
now := time.Now().UTC()
_, err := store.Create(ticket.Ticket{
Title: "Local edit", ProjectID: "inbox", Status: ticket.StatusTodo,
Source: ticket.SourceYouTrack, ExternalID: "P-1",
UpdatedAt: now.Add(time.Hour), SyncedAt: now,
})
if err != nil {
t.Fatal(err)
}
client := &youtrack.Client{BaseURL: srv.URL, Token: "t", HTTPClient: srv.Client()}
syncer := &ytsync.YouTrackSyncer{Store: store, Client: client}
yt := project.YouTrack{BaseURL: srv.URL, Token: "t", ProjectKey: "P"}
res, err := syncer.Pull(context.Background(), "inbox", yt)
if err != nil {
t.Fatal(err)
}
if res.Skipped != 1 || res.Updated != 0 {
t.Fatalf("res = %+v", res)
}
list, _ := store.List("inbox")
if list[0].Title != "Local edit" {
t.Fatalf("should keep local title, got %q", list[0].Title)
}
}