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>
191 lines
4.9 KiB
Go
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
|
|
}
|