Add YouTrack pull/push sync with project settings and time tracking.
Wire manual Y menu and CLI sync for issues and work items, persist per-project credentials, and keep local edits until an explicit push. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user