Allow moving tickets between projects with P.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Skruecken
2026-10-19 14:19:10 +02:00
co-authored by Cursor
parent c5920ffd54
commit 7666715f6c
13 changed files with 369 additions and 6 deletions
+67
View File
@@ -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()
+30
View File
@@ -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)
}
}
+28
View File
@@ -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()
+35
View File
@@ -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)
}
}
}
}
+1
View File
@@ -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
}
+1
View File
@@ -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)