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:
2026-10-28 00:09:14 +01:00
co-authored by Cursor
parent 26a3723a06
commit efeb4a74a8
14 changed files with 535 additions and 28 deletions
+1
View File
@@ -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"`
}
+131
View File
@@ -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
}
+4
View File
@@ -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;
+49
View File
@@ -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)
}
+32
View File
@@ -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
View File
@@ -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)
+124
View File
@@ -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
View File
@@ -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
}
+15
View File
@@ -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)
}
}
+13
View File
@@ -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
}
+7 -1
View File
@@ -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) {
+2 -2
View File
@@ -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
View File
@@ -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}>