Assign readable ticket keys from project code and sequence.

Replace UUID-looking sync labels in TUI and web with keys like TAS-1,
while keeping the remote UUID for task-master matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-28 00:09:14 +01:00
co-authored by Cursor
parent 26a3723a06
commit efeb4a74a8
14 changed files with 535 additions and 28 deletions
+100 -14
View File
@@ -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
}