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>
277 lines
7.3 KiB
Go
277 lines
7.3 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"task/internal/ticket"
|
|
)
|
|
|
|
// TicketStore implements ticket.Store on SQLite.
|
|
type TicketStore struct {
|
|
db *DB
|
|
}
|
|
|
|
// NewTicketStore returns a ticket store.
|
|
func NewTicketStore(db *DB) *TicketStore {
|
|
return &TicketStore{db: db}
|
|
}
|
|
|
|
func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, error) {
|
|
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
|
|
}
|
|
t.Status = ticket.Status(status)
|
|
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, number, key`
|
|
|
|
// List returns tickets for a project.
|
|
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
|
rows, err := s.db.SQL.Query(`SELECT `+ticketCols+` FROM tickets WHERE project_id = ? ORDER BY updated_at DESC`, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ticket.Ticket
|
|
for rows.Next() {
|
|
t, err := scanTicket(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
if out == nil {
|
|
out = []ticket.Ticket{}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Get finds a ticket by ID.
|
|
func (s *TicketStore) Get(id string) (ticket.Ticket, error) {
|
|
row := s.db.SQL.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
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if t.Title == "" {
|
|
return ticket.Ticket{}, fmt.Errorf("title is required")
|
|
}
|
|
if t.ID == "" {
|
|
t.ID = uuid.NewString()
|
|
}
|
|
if t.Status == "" {
|
|
t.Status = ticket.StatusTodo
|
|
}
|
|
now := time.Now().UTC()
|
|
if t.CreatedAt.IsZero() {
|
|
t.CreatedAt = now
|
|
}
|
|
if t.UpdatedAt.IsZero() {
|
|
t.UpdatedAt = now
|
|
}
|
|
|
|
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. 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
|
|
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)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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=?, 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), nullInt(t.Number), t.Key, t.ID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("ticket %s: not found", t.ID)
|
|
}
|
|
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 == "" {
|
|
return fmt.Errorf("ticket id is required")
|
|
}
|
|
res, err := s.db.SQL.Exec(`UPDATE tickets SET synced_at = updated_at WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("ticket %s: not found", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
if toProjectID == "" {
|
|
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
|
}
|
|
|
|
tx, err := s.db.SQL.Begin()
|
|
if err != nil {
|
|
return ticket.Ticket{}, err
|
|
}
|
|
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
|
|
}
|
|
|
|
// Delete removes a ticket.
|
|
func (s *TicketStore) Delete(id string) error {
|
|
res, err := s.db.SQL.Exec(`DELETE FROM tickets WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("ticket %s: not found", id)
|
|
}
|
|
return nil
|
|
}
|