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 {
|
type Project struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
GroupID string `json:"groupId,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()
|
_ = sqlDB.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := db.backfillTicketKeys(); err != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return db, nil
|
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) {
|
func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project, error) {
|
||||||
var p project.Project
|
var p project.Project
|
||||||
var groupID sql.NullString
|
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
|
return project.Project{}, err
|
||||||
}
|
}
|
||||||
if groupID.Valid {
|
if groupID.Valid {
|
||||||
@@ -34,9 +34,11 @@ func scanProject(scanner interface{ Scan(dest ...any) error }) (project.Project,
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectCols = `id, name, code, group_id`
|
||||||
|
|
||||||
// List returns all projects sorted by name.
|
// List returns all projects sorted by name.
|
||||||
func (s *ProjectStore) List() ([]project.Project, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list projects: %w", err)
|
return nil, fmt.Errorf("list projects: %w", err)
|
||||||
}
|
}
|
||||||
@@ -55,19 +57,46 @@ func (s *ProjectStore) List() ([]project.Project, error) {
|
|||||||
return out, rows.Err()
|
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.
|
// Ensure creates the project if missing.
|
||||||
func (s *ProjectStore) Ensure(id string) error {
|
func (s *ProjectStore) Ensure(id string) error {
|
||||||
id = strings.TrimSpace(id)
|
id = strings.TrimSpace(id)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return fmt.Errorf("project id is required")
|
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())
|
now := formatTime(time.Now().UTC())
|
||||||
_, err := s.db.SQL.Exec(`
|
_, err = s.db.SQL.Exec(`
|
||||||
INSERT INTO projects(id, name, created_at, updated_at) VALUES(?, ?, ?, ?)
|
INSERT INTO projects(id, name, code, created_at, updated_at) VALUES(?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO NOTHING`, id, id, now, now)
|
ON CONFLICT(id) DO NOTHING`, id, id, code, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("ensure project %s: %w", id, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +110,18 @@ func (s *ProjectStore) Create(id, name string) (project.Project, error) {
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
id = uuid.NewString()
|
id = uuid.NewString()
|
||||||
}
|
}
|
||||||
|
used, err := s.usedCodes()
|
||||||
|
if err != nil {
|
||||||
|
return project.Project{}, err
|
||||||
|
}
|
||||||
|
code := nextUniqueCode(name, used)
|
||||||
now := formatTime(time.Now().UTC())
|
now := formatTime(time.Now().UTC())
|
||||||
_, err := s.db.SQL.Exec(`INSERT INTO projects(id, name, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, NULL)`,
|
_, err = s.db.SQL.Exec(`INSERT INTO projects(id, name, code, created_at, updated_at, group_id) VALUES(?, ?, ?, ?, ?, NULL)`,
|
||||||
id, name, now, now)
|
id, name, code, now, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return project.Project{}, fmt.Errorf("create project: %w", err)
|
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.
|
// 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.
|
// Get returns one project.
|
||||||
func (s *ProjectStore) Get(id string) (project.Project, error) {
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return project.Project{}, fmt.Errorf("project %s: not found", id)
|
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()
|
return s.List()
|
||||||
}
|
}
|
||||||
rows, err := s.db.SQL.Query(`
|
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
|
INNER JOIN project_members m ON m.project_id = p.id
|
||||||
WHERE m.user_id = ?
|
WHERE m.user_id = ?
|
||||||
ORDER BY p.name COLLATE NOCASE`, userID)
|
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 t ticket.Ticket
|
||||||
var created, updated, synced string
|
var created, updated, synced string
|
||||||
var status string
|
var status string
|
||||||
|
var number sql.NullInt64
|
||||||
err := scanner.Scan(
|
err := scanner.Scan(
|
||||||
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
|
&t.ID, &t.Title, &t.Description, &status, &t.ProjectID,
|
||||||
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
|
&created, &updated, &t.Source, &t.ExternalID, &t.ExternalURL, &synced,
|
||||||
|
&number, &t.Key,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ticket.Ticket{}, err
|
return ticket.Ticket{}, err
|
||||||
@@ -35,10 +37,13 @@ func scanTicket(scanner interface{ Scan(dest ...any) error }) (ticket.Ticket, er
|
|||||||
t.CreatedAt = parseTime(created)
|
t.CreatedAt = parseTime(created)
|
||||||
t.UpdatedAt = parseTime(updated)
|
t.UpdatedAt = parseTime(updated)
|
||||||
t.SyncedAt = parseTime(synced)
|
t.SyncedAt = parseTime(synced)
|
||||||
|
if number.Valid {
|
||||||
|
t.Number = int(number.Int64)
|
||||||
|
}
|
||||||
return t, nil
|
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.
|
// List returns tickets for a project.
|
||||||
func (s *TicketStore) List(projectID string) ([]ticket.Ticket, error) {
|
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
|
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) {
|
func (s *TicketStore) Create(t ticket.Ticket) (ticket.Ticket, error) {
|
||||||
if t.ProjectID == "" {
|
if t.ProjectID == "" {
|
||||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
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() {
|
if t.UpdatedAt.IsZero() {
|
||||||
t.UpdatedAt = now
|
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,
|
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),
|
formatTime(t.CreatedAt), formatTime(t.UpdatedAt), t.Source, t.ExternalID, t.ExternalURL, formatTime(t.SyncedAt),
|
||||||
|
number, t.Key,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
|
return ticket.Ticket{}, fmt.Errorf("create ticket: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return ticket.Ticket{}, err
|
||||||
|
}
|
||||||
return t, nil
|
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 {
|
func (s *TicketStore) Update(t ticket.Ticket) error {
|
||||||
if t.ID == "" || t.ProjectID == "" {
|
if t.ID == "" || t.ProjectID == "" {
|
||||||
return fmt.Errorf("id and projectId are required")
|
return fmt.Errorf("id and projectId are required")
|
||||||
}
|
}
|
||||||
var created string
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
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.CreatedAt = parseTime(created)
|
||||||
t.UpdatedAt = time.Now().UTC()
|
t.UpdatedAt = time.Now().UTC()
|
||||||
|
if number.Valid {
|
||||||
|
t.Number = int(number.Int64)
|
||||||
|
}
|
||||||
|
t.Key = key
|
||||||
res, err := s.db.SQL.Exec(`
|
res, err := s.db.SQL.Exec(`
|
||||||
UPDATE tickets SET title=?, description=?, status=?, project_id=?, created_at=?, updated_at=?,
|
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=?`,
|
WHERE id=?`,
|
||||||
t.Title, t.Description, string(t.Status), t.ProjectID, formatTime(t.CreatedAt), formatTime(t.UpdatedAt),
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -138,6 +186,13 @@ func (s *TicketStore) Update(t ticket.Ticket) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullInt(n int) any {
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
|
// MarkSynced sets SyncedAt = UpdatedAt without bumping UpdatedAt.
|
||||||
func (s *TicketStore) MarkSynced(id string) error {
|
func (s *TicketStore) MarkSynced(id string) error {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -154,7 +209,7 @@ func (s *TicketStore) MarkSynced(id string) error {
|
|||||||
return nil
|
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) {
|
func (s *TicketStore) Move(id, toProjectID string) (ticket.Ticket, error) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return ticket.Ticket{}, fmt.Errorf("ticket id is required")
|
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 == "" {
|
if toProjectID == "" {
|
||||||
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
return ticket.Ticket{}, fmt.Errorf("projectId is required")
|
||||||
}
|
}
|
||||||
t, err := s.Get(id)
|
|
||||||
|
tx, err := s.db.SQL.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ticket.Ticket{}, err
|
return ticket.Ticket{}, err
|
||||||
}
|
}
|
||||||
t.ProjectID = toProjectID
|
defer func() { _ = tx.Rollback() }()
|
||||||
t.UpdatedAt = time.Now().UTC()
|
|
||||||
_, err = s.db.SQL.Exec(`UPDATE tickets SET project_id=?, updated_at=? WHERE id=?`,
|
row := tx.QueryRow(`SELECT `+ticketCols+` FROM tickets WHERE id = ?`, id)
|
||||||
toProjectID, formatTime(t.UpdatedAt), id)
|
t, err := scanTicket(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return ticket.Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ticket.Ticket{}, err
|
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
|
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"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
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.
|
// External sync (optional). Local UUID stays in ID.
|
||||||
Source string `json:"source,omitempty"`
|
Source string `json:"source,omitempty"`
|
||||||
ExternalID string `json:"externalId,omitempty"`
|
ExternalID string `json:"externalId,omitempty"`
|
||||||
ExternalURL string `json:"externalUrl,omitempty"`
|
ExternalURL string `json:"externalUrl,omitempty"`
|
||||||
SyncedAt time.Time `json:"syncedAt,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.Status = rt.Status
|
||||||
next.Source = ticket.SourceTaskMaster
|
next.Source = ticket.SourceTaskMaster
|
||||||
next.ExternalURL = extURL
|
next.ExternalURL = extURL
|
||||||
|
next.Number = rt.Number
|
||||||
|
next.Key = rt.Key
|
||||||
if sameFields(prev, next) {
|
if sameFields(prev, next) {
|
||||||
_ = s.Store.MarkSynced(prev.ID)
|
_ = s.Store.MarkSynced(prev.ID)
|
||||||
res.Skipped++
|
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,
|
Title: rt.Title, Description: rt.Description, Status: rt.Status,
|
||||||
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
|
ProjectID: localProjectID, Source: ticket.SourceTaskMaster,
|
||||||
ExternalID: rt.ID, ExternalURL: extURL,
|
ExternalID: rt.ID, ExternalURL: extURL,
|
||||||
|
Number: rt.Number, Key: rt.Key,
|
||||||
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
|
CreatedAt: now, UpdatedAt: now, SyncedAt: now,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -142,6 +145,8 @@ func (s *Syncer) Push(ctx context.Context, localProjectID string, tm project.Tas
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
t.ExternalID = created.ID
|
t.ExternalID = created.ID
|
||||||
|
t.Number = created.Number
|
||||||
|
t.Key = created.Key
|
||||||
t.Source = ticket.SourceTaskMaster
|
t.Source = ticket.SourceTaskMaster
|
||||||
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
|
t.ExternalURL = strings.TrimRight(tm.BaseURL, "/") + "/#/ticket/" + created.ID
|
||||||
if err := s.Store.Update(t); err != nil {
|
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 {
|
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) {
|
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)
|
badge := l.statusBadge(t.Status)
|
||||||
ext := ""
|
ext := ""
|
||||||
if t.ExternalID != "" {
|
if id := t.DisplayID(); id != "" {
|
||||||
ext = lipgloss.NewStyle().Faint(true).Render(t.ExternalID) + " "
|
ext = lipgloss.NewStyle().Faint(true).Render(id) + " "
|
||||||
}
|
}
|
||||||
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-lipgloss.Width(ext)-2))
|
||||||
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
headerLine := badge + " " + ext + l.Styles.Headline.Render(headline)
|
||||||
|
|||||||
+4
-1
@@ -907,7 +907,7 @@
|
|||||||
>
|
>
|
||||||
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
|
<button type="button" class="ticket-open" onclick={() => onBoardTicketClick(t)}>
|
||||||
<div>{t.title}</div>
|
<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>
|
</button>
|
||||||
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
|
<span class="drag-handle" title="Ziehen" aria-hidden="true">⠿</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -924,6 +924,9 @@
|
|||||||
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
<button onclick={() => openProject(projectId, projectName)}>← Board</button>
|
||||||
<button class="danger" onclick={removeTicket}>Delete</button>
|
<button class="danger" onclick={removeTicket}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if selected.key || selected.externalId}
|
||||||
|
<div class="muted">{selected.key || selected.externalId}</div>
|
||||||
|
{/if}
|
||||||
<label>Title<input bind:value={editTitle} /></label>
|
<label>Title<input bind:value={editTitle} /></label>
|
||||||
<label>Status
|
<label>Status
|
||||||
<select bind:value={editStatus}>
|
<select bind:value={editStatus}>
|
||||||
|
|||||||
Reference in New Issue
Block a user