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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+44
-10
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+100
-14
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
-1
@@ -907,7 +907,7 @@
|
||||
>
|
||||
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
|
||||
<div>{t.title}</div>
|
||||
{#if t.externalId}<div class="muted">{t.externalId}</div>{/if}
|
||||
{#if t.key || t.externalId}<div class="muted">{t.key || t.externalId}</div>{/if}
|
||||
</button>
|
||||
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
|
||||
</div>
|
||||
@@ -924,6 +924,9 @@
|
||||
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||
</div>
|
||||
{#if selected.key || selected.externalId}
|
||||
<div class="muted">{selected.key || selected.externalId}</div>
|
||||
{/if}
|
||||
<label>Title<input bind:value={editTitle} /></label>
|
||||
<label>Status
|
||||
<select bind:value={editStatus}>
|
||||
|
||||
Reference in New Issue
Block a user