Files
task-master/internal/ticket/jsonstore.go
T
skrueckenandCursor 98d2253f44
CI / test-and-build (push) Failing after 1m19s
CI / demo (push) Skipped
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>
2026-10-24 00:17:51 +02:00

351 lines
8.0 KiB
Go

package ticket
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
)
const (
ticketsFileName = "tickets.json"
legacyFileName = "tasks.json"
)
// JSONStore persists tickets as JSON arrays under DataDir/projects/<id>/tickets.json.
type JSONStore struct {
dataDir string
mu sync.Mutex
}
// NewJSONStore creates a store rooted at dataDir.
func NewJSONStore(dataDir string) *JSONStore {
return &JSONStore{dataDir: dataDir}
}
func (s *JSONStore) projectPath(projectID string) string {
return filepath.Join(s.dataDir, "projects", projectID)
}
func (s *JSONStore) ticketsPath(projectID string) string {
return filepath.Join(s.projectPath(projectID), ticketsFileName)
}
func (s *JSONStore) legacyPath(projectID string) string {
return filepath.Join(s.projectPath(projectID), legacyFileName)
}
// List returns all tickets for a project.
func (s *JSONStore) List(projectID string) ([]Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.readUnlocked(projectID)
}
// Get finds a ticket by ID across all project directories.
func (s *JSONStore) Get(id string) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
return Ticket{}, fmt.Errorf("list projects: %w", err)
}
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return Ticket{}, err
}
for _, t := range tickets {
if t.ID == id {
return t, nil
}
}
}
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
// Create adds a new ticket, assigning ID and timestamps if missing.
func (s *JSONStore) Create(t Ticket) (Ticket, error) {
s.mu.Lock()
defer s.mu.Unlock()
if t.ProjectID == "" {
return Ticket{}, fmt.Errorf("projectId is required")
}
if t.Title == "" {
return Ticket{}, fmt.Errorf("title is required")
}
if t.ID == "" {
t.ID = uuid.NewString()
}
if t.Status == "" {
t.Status = StatusTodo
}
now := time.Now().UTC()
if t.CreatedAt.IsZero() {
t.CreatedAt = now
}
if t.UpdatedAt.IsZero() {
t.UpdatedAt = now
}
if err := os.MkdirAll(s.projectPath(t.ProjectID), 0o755); err != nil {
return Ticket{}, fmt.Errorf("create project dir: %w", err)
}
tickets, err := s.readUnlocked(t.ProjectID)
if err != nil {
return Ticket{}, err
}
tickets = append(tickets, t)
if err := s.writeUnlocked(t.ProjectID, tickets); err != nil {
return Ticket{}, err
}
return t, nil
}
// Update replaces an existing ticket (matched by ID within its project).
func (s *JSONStore) Update(t Ticket) error {
s.mu.Lock()
defer s.mu.Unlock()
if t.ID == "" || t.ProjectID == "" {
return fmt.Errorf("id and projectId are required")
}
tickets, err := s.readUnlocked(t.ProjectID)
if err != nil {
return err
}
found := false
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
break
}
}
if !found {
return fmt.Errorf("ticket %s: not found", t.ID)
}
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()
defer s.mu.Unlock()
if id == "" {
return Ticket{}, fmt.Errorf("ticket id is required")
}
if toProjectID == "" {
return Ticket{}, fmt.Errorf("projectId is required")
}
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
if err != nil {
if os.IsNotExist(err) {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
return Ticket{}, fmt.Errorf("list projects: %w", err)
}
var found Ticket
fromProject := ""
for _, p := range projects {
if !p.IsDir() {
continue
}
tickets, err := s.readUnlocked(p.Name())
if err != nil {
return Ticket{}, err
}
for i, t := range tickets {
if t.ID != id {
continue
}
found = t
fromProject = p.Name()
tickets = append(tickets[:i], tickets[i+1:]...)
if err := s.writeUnlocked(fromProject, tickets); err != nil {
return Ticket{}, err
}
break
}
if fromProject != "" {
break
}
}
if fromProject == "" {
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
}
found.ProjectID = toProjectID
found.UpdatedAt = time.Now().UTC()
if err := os.MkdirAll(s.projectPath(toProjectID), 0o755); err != nil {
return Ticket{}, fmt.Errorf("create project dir: %w", err)
}
dest, err := s.readUnlocked(toProjectID)
if err != nil {
return Ticket{}, err
}
dest = append(dest, found)
if err := s.writeUnlocked(toProjectID, dest); err != nil {
return Ticket{}, err
}
return found, nil
}
// Delete removes a ticket by ID from its project's file.
func (s *JSONStore) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
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, t := range tickets {
if t.ID == id {
tickets = append(tickets[:i], tickets[i+1:]...)
return s.writeUnlocked(p.Name(), tickets)
}
}
}
return fmt.Errorf("ticket %s: not found", id)
}
func (s *JSONStore) readUnlocked(projectID string) ([]Ticket, error) {
path := s.ticketsPath(projectID)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
// Migrate from legacy tasks.json if present.
legacy := s.legacyPath(projectID)
data, err = os.ReadFile(legacy)
if err != nil {
if os.IsNotExist(err) {
return []Ticket{}, nil
}
return nil, fmt.Errorf("read %s: %w", legacy, err)
}
path = legacy
} else {
return nil, fmt.Errorf("read %s: %w", path, err)
}
}
if len(data) == 0 {
return []Ticket{}, nil
}
var tickets []Ticket
if err := json.Unmarshal(data, &tickets); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
if tickets == nil {
tickets = []Ticket{}
}
return tickets, nil
}
func (s *JSONStore) writeUnlocked(projectID string, tickets []Ticket) error {
path := s.ticketsPath(projectID)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create project dir: %w", err)
}
data, err := json.MarshalIndent(tickets, "", " ")
if err != nil {
return fmt.Errorf("marshal tickets: %w", err)
}
data = append(data, '\n')
tmp, err := os.CreateTemp(filepath.Dir(path), "tickets-*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename temp to %s: %w", path, err)
}
// Clean up legacy file after successful write to tickets.json.
_ = os.Remove(s.legacyPath(projectID))
return nil
}