Allow moving tickets between projects with P.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -146,6 +146,73 @@ func (s *JSONStore) Update(t Ticket) error {
|
||||
return s.writeUnlocked(t.ProjectID, tickets)
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -97,3 +97,33 @@ func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
||||
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStoreMove(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
|
||||
created, err := store.Create(ticket.Ticket{
|
||||
Title: "Misfiled",
|
||||
ProjectID: "inbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
moved, err := store.Move(created.ID, "client")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.ProjectID != "client" {
|
||||
t.Fatalf("ProjectID = %q", moved.ProjectID)
|
||||
}
|
||||
|
||||
from, err := store.List("inbox")
|
||||
if err != nil || len(from) != 0 {
|
||||
t.Fatalf("inbox after move: %v len=%d", err, len(from))
|
||||
}
|
||||
to, err := store.List("client")
|
||||
if err != nil || len(to) != 1 || to[0].ID != created.ID {
|
||||
t.Fatalf("client after move: %v %+v", err, to)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +153,34 @@ func (s *JSONTimeStore) Delete(id string) error {
|
||||
return s.writeUnlocked(out)
|
||||
}
|
||||
|
||||
// RetargetTicket sets ProjectID on all entries for a ticket.
|
||||
func (s *JSONTimeStore) RetargetTicket(ticketID, projectID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if ticketID == "" {
|
||||
return fmt.Errorf("ticketId is required")
|
||||
}
|
||||
if projectID == "" {
|
||||
return fmt.Errorf("projectId is required")
|
||||
}
|
||||
entries, err := s.readUnlocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
for i := range entries {
|
||||
if entries[i].TicketID == ticketID {
|
||||
entries[i].ProjectID = projectID
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return s.writeUnlocked(entries)
|
||||
}
|
||||
|
||||
// DeleteByProject removes all time entries for a project.
|
||||
func (s *JSONTimeStore) DeleteByProject(projectID string) error {
|
||||
s.mu.Lock()
|
||||
|
||||
@@ -91,3 +91,38 @@ func TestJSONTimeStoreDeleteByProject(t *testing.T) {
|
||||
t.Fatalf("after DeleteByProject: %v %+v", err, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONTimeStoreRetargetTicket(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONTimeStore(dir)
|
||||
|
||||
if _, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t1", ProjectID: "old", Duration: time.Hour, Source: ticket.SourceManual,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.Create(ticket.TimeEntry{
|
||||
TicketID: "t2", ProjectID: "old", Duration: time.Minute, Source: ticket.SourceManual,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.RetargetTicket("t1", "new"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range list {
|
||||
switch e.TicketID {
|
||||
case "t1":
|
||||
if e.ProjectID != "new" {
|
||||
t.Fatalf("t1 project=%q", e.ProjectID)
|
||||
}
|
||||
case "t2":
|
||||
if e.ProjectID != "old" {
|
||||
t.Fatalf("t2 project=%q", e.ProjectID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ type Store interface {
|
||||
Get(id string) (Ticket, error)
|
||||
Create(t Ticket) (Ticket, error)
|
||||
Update(t Ticket) error
|
||||
Move(id, toProjectID string) (Ticket, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ type TimeStore interface {
|
||||
Update(e TimeEntry) error
|
||||
Delete(id string) error
|
||||
DeleteByProject(projectID string) error
|
||||
RetargetTicket(ticketID, projectID string) error
|
||||
SumByTicket(ticketID string) (time.Duration, error)
|
||||
|
||||
LoadActiveTimer() (*ActiveTimer, error)
|
||||
|
||||
Reference in New Issue
Block a user