diff --git a/internal/project/project.go b/internal/project/project.go index e6b0f89..ebb4114 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -4,5 +4,6 @@ package project type Project struct { ID string `json:"id"` Name string `json:"name"` + Code string `json:"code,omitempty"` GroupID string `json:"groupId,omitempty"` } diff --git a/internal/sqlite/backfill_keys.go b/internal/sqlite/backfill_keys.go new file mode 100644 index 0000000..156e4b5 --- /dev/null +++ b/internal/sqlite/backfill_keys.go @@ -0,0 +1,131 @@ +package sqlite + +import ( + "database/sql" + "fmt" +) + +// backfillTicketKeys assigns project codes and sequential ticket keys for rows +// that predate migration 005. Safe to run repeatedly (no-ops when already set). +func (db *DB) backfillTicketKeys() error { + if err := db.backfillProjectCodes(); err != nil { + return err + } + return db.backfillTicketNumbers() +} + +func (db *DB) backfillProjectCodes() error { + rows, err := db.SQL.Query(`SELECT id, name, code FROM projects`) + if err != nil { + return fmt.Errorf("backfill codes: list: %w", err) + } + defer rows.Close() + + type row struct { + id, name, code string + } + var all []row + used := map[string]bool{} + for rows.Next() { + var r row + if err := rows.Scan(&r.id, &r.name, &r.code); err != nil { + return err + } + all = append(all, r) + if r.code != "" { + used[r.code] = true + } + } + if err := rows.Err(); err != nil { + return err + } + + for _, r := range all { + if r.code != "" { + continue + } + code := nextUniqueCode(r.name, used) + used[code] = true + if _, err := db.SQL.Exec(`UPDATE projects SET code = ? WHERE id = ?`, code, r.id); err != nil { + return fmt.Errorf("backfill code %s: %w", r.id, err) + } + } + return nil +} + +func (db *DB) backfillTicketNumbers() error { + type proj struct { + id, code string + seq int + } + var projects []proj + { + projRows, err := db.SQL.Query(`SELECT id, code, ticket_seq FROM projects`) + if err != nil { + return fmt.Errorf("backfill tickets: projects: %w", err) + } + for projRows.Next() { + var p proj + if err := projRows.Scan(&p.id, &p.code, &p.seq); err != nil { + projRows.Close() + return err + } + projects = append(projects, p) + } + err = projRows.Err() + projRows.Close() + if err != nil { + return err + } + } + + for _, p := range projects { + type trow struct { + id, key string + number sql.NullInt64 + } + var tickets []trow + { + tRows, err := db.SQL.Query(` + SELECT id, number, key FROM tickets + WHERE project_id = ? + ORDER BY created_at ASC, id ASC`, p.id) + if err != nil { + return err + } + for tRows.Next() { + var tr trow + if err := tRows.Scan(&tr.id, &tr.number, &tr.key); err != nil { + tRows.Close() + return err + } + tickets = append(tickets, tr) + } + err = tRows.Err() + tRows.Close() + if err != nil { + return err + } + } + seq := p.seq + for _, tr := range tickets { + if tr.number.Valid && tr.key != "" { + if int(tr.number.Int64) > seq { + seq = int(tr.number.Int64) + } + continue + } + seq++ + newKey := fmt.Sprintf("%s-%d", p.code, seq) + if _, err := db.SQL.Exec(`UPDATE tickets SET number = ?, key = ? WHERE id = ?`, seq, newKey, tr.id); err != nil { + return fmt.Errorf("backfill ticket %s: %w", tr.id, err) + } + } + if seq != p.seq { + if _, err := db.SQL.Exec(`UPDATE projects SET ticket_seq = ? WHERE id = ?`, seq, p.id); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/sqlite/db.go b/internal/sqlite/db.go index da3f87c..b3e3cb5 100644 --- a/internal/sqlite/db.go +++ b/internal/sqlite/db.go @@ -44,6 +44,10 @@ func Open(path string, secret []byte) (*DB, error) { _ = sqlDB.Close() return nil, err } + if err := db.backfillTicketKeys(); err != nil { + _ = sqlDB.Close() + return nil, err + } return db, nil } diff --git a/internal/sqlite/migrations/005_ticket_keys.sql b/internal/sqlite/migrations/005_ticket_keys.sql new file mode 100644 index 0000000..147e7fe --- /dev/null +++ b/internal/sqlite/migrations/005_ticket_keys.sql @@ -0,0 +1,9 @@ +ALTER TABLE projects ADD COLUMN code TEXT NOT NULL DEFAULT ''; +ALTER TABLE projects ADD COLUMN ticket_seq INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE tickets ADD COLUMN number INTEGER; +ALTER TABLE tickets ADD COLUMN key TEXT NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code ON projects(code) WHERE code != ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_key ON tickets(key) WHERE key != ''; +CREATE UNIQUE INDEX IF NOT EXISTS idx_tickets_project_number ON tickets(project_id, number) WHERE number IS NOT NULL; diff --git a/internal/sqlite/projectcode.go b/internal/sqlite/projectcode.go new file mode 100644 index 0000000..53af576 --- /dev/null +++ b/internal/sqlite/projectcode.go @@ -0,0 +1,49 @@ +package sqlite + +import ( + "fmt" + "strings" + "unicode" +) + +// DeriveProjectCode builds a 3-letter uppercase code from a project name. +// Non-letters are skipped; short results are padded with X. +func DeriveProjectCode(name string) string { + var b strings.Builder + for _, r := range strings.ToUpper(name) { + if unicode.IsLetter(r) && r < unicode.MaxASCII { + b.WriteRune(r) + if b.Len() == 3 { + break + } + } + } + code := b.String() + for len(code) < 3 { + code += "X" + } + return code +} + +// nextUniqueCode returns base or a nearby 3-char variant until unused. +// used maps existing codes (uppercase) → true. +func nextUniqueCode(base string, used map[string]bool) string { + base = DeriveProjectCode(base) + if !used[base] { + return base + } + prefix := base[:2] + for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" { + cand := prefix + string(c) + if !used[cand] { + return cand + } + } + for i := 0; i < 1000; i++ { + cand := fmt.Sprintf("%c%02d", base[0], i) + if !used[cand] { + return cand + } + } + return fmt.Sprintf("X%02d", len(used)%100) +} diff --git a/internal/sqlite/projectcode_test.go b/internal/sqlite/projectcode_test.go new file mode 100644 index 0000000..c2bf392 --- /dev/null +++ b/internal/sqlite/projectcode_test.go @@ -0,0 +1,32 @@ +package sqlite + +import "testing" + +func TestDeriveProjectCode(t *testing.T) { + cases := []struct { + name, want string + }{ + {"Alpha", "ALP"}, + {"task-master", "TAS"}, + {"AB", "ABX"}, + {"12", "XXX"}, + {"Büro Projekt", "BRO"}, // non-ASCII letters skipped + {"my project", "MYP"}, + } + for _, tc := range cases { + if got := DeriveProjectCode(tc.name); got != tc.want { + t.Errorf("DeriveProjectCode(%q)=%q want %q", tc.name, got, tc.want) + } + } +} + +func TestNextUniqueCode(t *testing.T) { + used := map[string]bool{"ALP": true, "ALA": true} + got := nextUniqueCode("Alpha", used) + if got == "ALP" || got == "ALA" { + t.Fatalf("expected unique code, got %q", got) + } + if len(got) != 3 { + t.Fatalf("len=%d code=%q", len(got), got) + } +} diff --git a/internal/sqlite/projects.go b/internal/sqlite/projects.go index dd75e00..ad5b322 100644 --- a/internal/sqlite/projects.go +++ b/internal/sqlite/projects.go @@ -25,7 +25,7 @@ func NewProjectStore(db *DB) *ProjectStore { func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) { var p project.Project var groupID sql.NullString - if err := scanner.Scan(&p.ID, &p.Name, &groupID); err != nil { + if err := scanner.Scan(&p.ID, &p.Name, &p.Code, &groupID); err != nil { return project.Project{}, err } if groupID.Valid { @@ -34,9 +34,11 @@ func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, return p, nil } +const projectCols = `id, name, code, group_id` + // List returns all projects sorted by name. func (s *ProjectStore) List() ([]project.Project, error) { - rows, err := s.db.SQL.Query(`SELECT id, name, group_id FROM projects ORDER BY name COLLATE NOCASE`) + rows, err := s.db.SQL.Query(`SELECT ` + projectCols + ` FROM projects ORDER BY name COLLATE NOCASE`) if err != nil { return nil, fmt.Errorf("list projects: %w", err) } @@ -55,19 +57,46 @@ func (s *ProjectStore) List() ([]project.Project, error) { return out, rows.Err() } +func (s *ProjectStore) usedCodes() (map[string]bool, error) { + rows, err := s.db.SQL.Query(`SELECT code FROM projects WHERE code != ''`) + if err != nil { + return nil, err + } + defer rows.Close() + used := map[string]bool{} + for rows.Next() { + var c string + if err := rows.Scan(&c); err != nil { + return nil, err + } + used[c] = true + } + return used, rows.Err() +} + // Ensure creates the project if missing. func (s *ProjectStore) Ensure(id string) error { id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("project id is required") } + used, err := s.usedCodes() + if err != nil { + return err + } + code := nextUniqueCode(id, used) now := formatTime(time.Now().UTC()) - _, err := s.db.SQL.Exec(` - INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?) - ON CONFLICT(id) DO NOTHING`, id, id, now, now) + _, err = s.db.SQL.Exec(` + INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING`, id, id, code, now, now) if err != nil { return fmt.Errorf("ensure project %s: %w", id, err) } + var existing string + _ = s.db.SQL.QueryRow(`SELECT code FROM projects WHERE id = ?`, id).Scan(&existing) + if existing == "" { + return s.db.backfillProjectCodes() + } return nil } @@ -81,13 +110,18 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) { if id == "" { id = uuid.NewString() } + used, err := s.usedCodes() + if err != nil { + return project.Project{}, err + } + code := nextUniqueCode(name, used) now := formatTime(time.Now().UTC()) - _, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`, - id, name, now, now) + _, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`, + id, name, code, now, now) if err != nil { return project.Project{}, fmt.Errorf("create project: %w", err) } - return project.Project{ID: id, Name: name}, nil + return project.Project{ID: id, Name: name, Code: code}, nil } // SetGroup assigns a project to a group. Empty groupID clears the assignment. @@ -151,7 +185,7 @@ func (s *ProjectStore) Delete(id string) error { // Get returns one project. func (s *ProjectStore) Get(id string) (project.Project, error) { - p, err := scanProject(s.db.SQL.QueryRow(`SELECT id, name, group_id FROM projects WHERE id = ?`, id)) + p, err := scanProject(s.db.SQL.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)) if err == sql.ErrNoRows { return project.Project{}, fmt.Errorf("project %s: not found", id) } @@ -291,7 +325,7 @@ func (s *ProjectStore) ListIDsForUser(userID string, isAdmin bool) ([]project.Pr return s.List() } rows, err := s.db.SQL.Query(` - SELECT p.id, p.name, p.group_id FROM projects p + SELECT p.id, p.name, p.code, p.group_id FROM projects p INNER JOIN project_members m ON m.project_id = p.id WHERE m.user_id = ? ORDER BY p.name COLLATE NOCASE`, userID) diff --git a/internal/sqlite/ticketkeys_test.go b/internal/sqlite/ticketkeys_test.go new file mode 100644 index 0000000..f8b92d0 --- /dev/null +++ b/internal/sqlite/ticketkeys_test.go @@ -0,0 +1,124 @@ +package sqlite + +import ( + "path/filepath" + "testing" + + "task/internal/ticket" +) + +func TestTicketKeysSequential(t *testing.T) { + db, err := Open(filepath.Join(t.TempDir(), "keys.db"), []byte("test-secret")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + ps := NewProjectStore(db) + p, err := ps.Create("", "Task Master") + if err != nil { + t.Fatal(err) + } + if p.Code != "TAS" { + t.Fatalf("code=%q want TAS", p.Code) + } + + ts := NewTicketStore(db) + a, err := ts.Create(ticket.Ticket{Title: "One", ProjectID: p.ID}) + if err != nil { + t.Fatal(err) + } + b, err := ts.Create(ticket.Ticket{Title: "Two", ProjectID: p.ID}) + if err != nil { + t.Fatal(err) + } + if a.Key != "TAS-1" || a.Number != 1 { + t.Fatalf("first=%+v", a) + } + if b.Key != "TAS-2" || b.Number != 2 { + t.Fatalf("second=%+v", b) + } + + got, err := ts.Get(a.ID) + if err != nil || got.Key != "TAS-1" { + t.Fatalf("get=%+v err=%v", got, err) + } + + // Update must preserve key. + got.Title = "One*" + if err := ts.Update(got); err != nil { + t.Fatal(err) + } + got, _ = ts.Get(a.ID) + if got.Key != "TAS-1" || got.Title != "One*" { + t.Fatalf("after update=%+v", got) + } +} + +func TestTicketKeyBackfill(t *testing.T) { + path := filepath.Join(t.TempDir(), "backfill.db") + db, err := Open(path, []byte("test-secret")) + if err != nil { + t.Fatal(err) + } + // Insert legacy-shaped rows as if pre-migration (columns exist after Open). + _, err = db.SQL.Exec(`INSERT INTO projects(id, name, code, ticket_seq, created_at, updated_at) + VALUES('p1', 'Inbox', '', 0, '2020-01-01T00:00:00Z', '2020-01-01T00:00:00Z')`) + if err != nil { + t.Fatal(err) + } + _, err = db.SQL.Exec(`INSERT INTO tickets(id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key) + VALUES('t1', 'Old', '', 'todo', 'p1', '2020-01-02T00:00:00Z', '2020-01-02T00:00:00Z', '', '', '', '', NULL, '')`) + if err != nil { + t.Fatal(err) + } + if err := db.backfillTicketKeys(); err != nil { + t.Fatal(err) + } + var code string + var seq int + if err := db.SQL.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id='p1'`).Scan(&code, &seq); err != nil { + t.Fatal(err) + } + if code != "INB" || seq != 1 { + t.Fatalf("project code=%q seq=%d", code, seq) + } + var key string + var num int + if err := db.SQL.QueryRow(`SELECT key, number FROM tickets WHERE id='t1'`).Scan(&key, &num); err != nil { + t.Fatal(err) + } + if key != "INB-1" || num != 1 { + t.Fatalf("ticket key=%q num=%d", key, num) + } + _ = db.Close() +} + +func TestMoveReassignsKey(t *testing.T) { + db, err := Open(filepath.Join(t.TempDir(), "move.db"), []byte("test-secret")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + ps := NewProjectStore(db) + a, err := ps.Create("", "Alpha") + if err != nil { + t.Fatal(err) + } + b, err := ps.Create("", "Beta") + if err != nil { + t.Fatal(err) + } + ts := NewTicketStore(db) + tk, err := ts.Create(ticket.Ticket{Title: "Move me", ProjectID: a.ID}) + if err != nil { + t.Fatal(err) + } + moved, err := ts.Move(tk.ID, b.ID) + if err != nil { + t.Fatal(err) + } + if moved.ProjectID != b.ID || moved.Key != b.Code+"-1" { + t.Fatalf("moved=%+v betaCode=%s", moved, b.Code) + } +} diff --git a/internal/sqlite/tickets.go b/internal/sqlite/tickets.go index 6abd3c3..c8b5206 100644 --- a/internal/sqlite/tickets.go +++ b/internal/sqlite/tickets.go @@ -24,9 +24,11 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er var t ticket.Ticket var created, updated, synced string var status string + var number sql.NullInt64 err := scanner.Scan( &t.ID, &t.Title, &t.Description, &status, &t.ProjectID, &created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced, + &number, &t.Key, ) if err != nil { return ticket.Ticket{}, err @@ -35,10 +37,13 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er t.CreatedAt = parseTime(created) t.UpdatedAt = parseTime(updated) t.SyncedAt = parseTime(synced) + if number.Valid { + t.Number = int(number.Int64) + } return t, nil } -const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at` +const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at, number, key` // List returns tickets for a project. func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) { @@ -74,7 +79,7 @@ func (s *TicketStore) Get(id string) (ticket.Ticket, error) { return t, nil } -// Create inserts a ticket. +// Create inserts a ticket and assigns the next project key atomically. func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) { if t.ProjectID == "" { return ticket.Ticket{}, fmt.Errorf("projectId is required") @@ -95,24 +100,63 @@ func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) { if t.UpdatedAt.IsZero() { t.UpdatedAt = now } - _, err := s.db.SQL.Exec(` - INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + + tx, err := s.db.SQL.Begin() + if err != nil { + return ticket.Ticket{}, err + } + defer func() { _ = tx.Rollback() }() + + if t.Number == 0 || t.Key == "" { + res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, t.ProjectID) + if err != nil { + return ticket.Ticket{}, fmt.Errorf("create ticket: bump seq: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ticket.Ticket{}, fmt.Errorf("create ticket: project %s not found", t.ProjectID) + } + var code string + var seq int + if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, t.ProjectID).Scan(&code, &seq); err != nil { + return ticket.Ticket{}, fmt.Errorf("create ticket: read seq: %w", err) + } + if code == "" { + return ticket.Ticket{}, fmt.Errorf("create ticket: project %s has no code", t.ProjectID) + } + t.Number = seq + t.Key = fmt.Sprintf("%s-%d", code, seq) + } + + var number any + if t.Number > 0 { + number = t.Number + } + _, err = tx.Exec(` + INSERT INTO tickets(`+ticketCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, t.ID, t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), + number, t.Key, ) if err != nil { return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err) } + if err := tx.Commit(); err != nil { + return ticket.Ticket{}, err + } return t, nil } -// Update replaces a ticket and bumps UpdatedAt. +// Update replaces a ticket and bumps UpdatedAt. Number and Key are preserved from DB. func (s *TicketStore) Update(t ticket.Ticket) error { if t.ID == "" || t.ProjectID == "" { return fmt.Errorf("id and projectId are required") } var created string - err := s.db.SQL.QueryRow(`SELECT created_at FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID).Scan(&created) + var number sql.NullInt64 + var key string + err := s.db.SQL.QueryRow(`SELECT created_at, number, key FROM tickets WHERE id = ? AND project_id = ?`, t.ID, t.ProjectID). + Scan(&created, &number, &key) if err == sql.ErrNoRows { return fmt.Errorf("ticket %s: not found", t.ID) } @@ -121,12 +165,16 @@ func (s *TicketStore) Update(t ticket.Ticket) error { } t.CreatedAt = parseTime(created) t.UpdatedAt = time.Now().UTC() + if number.Valid { + t.Number = int(number.Int64) + } + t.Key = key res, err := s.db.SQL.Exec(` UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?, - source=?, external_id=?, external_url=?, synced_at=? + source=?, external_id=?, external_url=?, synced_at=?, number=?, key=? WHERE id=?`, t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt), - t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), t.ID, + t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt), nullInt(t.Number), t.Key, t.ID, ) if err != nil { return err @@ -138,6 +186,13 @@ func (s *TicketStore) Update(t ticket.Ticket) error { return nil } +func nullInt(n int) any { + if n == 0 { + return nil + } + return n +} + // MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt. func (s *TicketStore) MarkSynced(id string) error { if id == "" { @@ -154,7 +209,7 @@ func (s *TicketStore) MarkSynced(id string) error { return nil } -// Move relocates a ticket to another project. +// Move relocates a ticket to another project and assigns a new key there. func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) { if id == "" { return ticket.Ticket{}, fmt.Errorf("ticket id is required") @@ -162,17 +217,48 @@ func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) { if toProjectID == "" { return ticket.Ticket{}, fmt.Errorf("projectId is required") } - t, err := s.Get(id) + + tx, err := s.db.SQL.Begin() if err != nil { return ticket.Ticket{}, err } - t.ProjectID = toProjectID - t.UpdatedAt = time.Now().UTC() - _, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`, - toProjectID, formatTime(t.UpdatedAt), id) + defer func() { _ = tx.Rollback() }() + + row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id) + t, err := scanTicket(row) + if err == sql.ErrNoRows { + return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id) + } if err != nil { return ticket.Ticket{}, err } + + res, err := tx.Exec(`UPDATE projects SET ticket_seq = ticket_seq + 1 WHERE id = ?`, toProjectID) + if err != nil { + return ticket.Ticket{}, err + } + n, _ := res.RowsAffected() + if n == 0 { + return ticket.Ticket{}, fmt.Errorf("project %s: not found", toProjectID) + } + var code string + var seq int + if err := tx.QueryRow(`SELECT code, ticket_seq FROM projects WHERE id = ?`, toProjectID).Scan(&code, &seq); err != nil { + return ticket.Ticket{}, err + } + t.ProjectID = toProjectID + t.Number = seq + t.Key = fmt.Sprintf("%s-%d", code, seq) + t.UpdatedAt = time.Now().UTC() + + _, err = tx.Exec(`UPDATE tickets SET project_id=?, updated_at=?, number=?, key=? WHERE id=?`, + toProjectID, formatTime(t.UpdatedAt), t.Number, t.Key, id) + if err != nil { + return ticket.Ticket{}, err + } + if err := tx.Commit(); err != nil { + return ticket.Ticket{}, err + } return t, nil } diff --git a/internal/ticket/displayid_test.go b/internal/ticket/displayid_test.go new file mode 100644 index 0000000..712f2c4 --- /dev/null +++ b/internal/ticket/displayid_test.go @@ -0,0 +1,15 @@ +package ticket + +import "testing" + +func TestDisplayID(t *testing.T) { + if got := (Ticket{Key: "TAS-1", ExternalID: "uuid"}).DisplayID(); got != "TAS-1" { + t.Fatalf("got %q", got) + } + if got := (Ticket{ExternalID: "YT-9"}).DisplayID(); got != "YT-9" { + t.Fatalf("got %q", got) + } + if got := (Ticket{}).DisplayID(); got != "" { + t.Fatalf("got %q", got) + } +} diff --git a/internal/ticket/ticket.go b/internal/ticket/ticket.go index b047963..328cdea 100644 --- a/internal/ticket/ticket.go +++ b/internal/ticket/ticket.go @@ -68,9 +68,22 @@ type Ticket struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + // Readable key assigned by the server (e.g. "ABC-42"). Optional on local-only tickets. + Number int `json:"number,omitempty"` + Key string `json:"key,omitempty"` + // 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"` } + +// DisplayID returns the human-readable ticket label for UI. +// Prefers the server key; falls back to ExternalID (e.g. YouTrack idReadable). +func (t Ticket) DisplayID() string { + if t.Key != "" { + return t.Key + } + return t.ExternalID +} diff --git a/internal/tmsync/sync.go b/internal/tmsync/sync.go index 81cb73b..8d60edc 100644 --- a/internal/tmsync/sync.go +++ b/internal/tmsync/sync.go @@ -87,6 +87,8 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas next.Status = rt.Status next.Source = ticket.SourceTaskMaster next.ExternalURL = extURL + next.Number = rt.Number + next.Key = rt.Key if sameFields(prev, next) { _ = s.Store.MarkSynced(prev.ID) res.Skipped++ @@ -104,6 +106,7 @@ func (s *Syncer) Pull(ctx context.Context, localProjectID string, tm project.Tas Title: rt.Title, Description: rt.Description, Status: rt.Status, ProjectID: localProjectID, Source: ticket.SourceTaskMaster, ExternalID: rt.ID, ExternalURL: extURL, + Number: rt.Number, Key: rt.Key, CreatedAt: now, UpdatedAt: now, SyncedAt: now, }) if err != nil { @@ -142,6 +145,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas return res, err } t.ExternalID = created.ID + t.Number = created.Number + t.Key = created.Key t.Source = ticket.SourceTaskMaster t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID if err := s.Store.Update(t); err != nil { @@ -166,7 +171,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas } func sameFields(a, b ticket.Ticket) bool { - return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status + return a.Title == b.Title && a.Description == b.Description && a.Status == b.Status && + a.Key == b.Key && a.Number == b.Number } func (s *Syncer) listTickets(ctx context.Context, tm project.TaskMaster) ([]ticket.Ticket, error) { diff --git a/internal/ui/views/tickets.go b/internal/ui/views/tickets.go index 3138cbf..b752a8b 100644 --- a/internal/ui/views/tickets.go +++ b/internal/ui/views/tickets.go @@ -177,8 +177,8 @@ func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string { badge := l.statusBadge(t.Status) ext := "" - if t.ExternalID != "" { - ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " " + if id := t.DisplayID(); id != "" { + ext = lipgloss.NewStyle().Faint(true).Render(id) + " " } headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2)) headerLine := badge + " " + ext + l.Styles.Headline.Render(headline) diff --git a/web/src/App.svelte b/web/src/App.svelte index 9229ebc..c98e250 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -907,7 +907,7 @@ > @@ -924,6 +924,9 @@ + {#if selected.key || selected.externalId} +
{selected.key || selected.externalId}
+ {/if}