Files
task-master/internal/sqlite/tickets.go
T
skrueckenandCursor 4adfcdb95a Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions),
YouTrack-or-taskmaster backend choice in the TUI, and a first-pass
embedded Svelte UI. The frontend is functional but needs a major rework.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-27 22:23:11 +01:00

191 lines
4.9 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
err := scanner.Scan(
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
)
if err != nil {
return ticket.Ticket{}, err
}
t.Status = ticket.Status(status)
t.CreatedAt = parseTime(created)
t.UpdatedAt = parseTime(updated)
t.SyncedAt = parseTime(synced)
return t, nil
}
const ticketCols = `id, title, description, status, project_id, created_at, updated_at, source, external_id, external_url, synced_at`
// 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.
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
}
_, err := s.db.SQL.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),
)
if err != nil {
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
}
return t, nil
}
// Update replaces a ticket and bumps UpdatedAt.
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)
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()
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=?
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,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("ticket %s: not found", t.ID)
}
return nil
}
// 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.
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")
}
t, err := s.Get(id)
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)
if 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
}