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
+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
}