Add offline-first task-master TUI with tickets, board lists, and undo.
Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package ticket
|
||||
|
||||
// BoardList is a project-scoped view/filter over tickets by status.
|
||||
type BoardList string
|
||||
|
||||
const (
|
||||
BoardAll BoardList = "all"
|
||||
BoardBacklog BoardList = "backlog"
|
||||
BoardInProgress BoardList = "in_progress"
|
||||
BoardDone BoardList = "done"
|
||||
)
|
||||
|
||||
// AllBoardLists is the ordered picker list.
|
||||
var AllBoardLists = []BoardList{BoardAll, BoardBacklog, BoardInProgress, BoardDone}
|
||||
|
||||
// Label returns a display name for the board list.
|
||||
func (b BoardList) Label() string {
|
||||
switch b {
|
||||
case BoardAll:
|
||||
return "All"
|
||||
case BoardBacklog:
|
||||
return "Backlog"
|
||||
case BoardInProgress:
|
||||
return "In Progress"
|
||||
case BoardDone:
|
||||
return "Done"
|
||||
default:
|
||||
return string(b)
|
||||
}
|
||||
}
|
||||
|
||||
// Index returns the position in AllBoardLists.
|
||||
func (b BoardList) Index() int {
|
||||
for i, bl := range AllBoardLists {
|
||||
if bl == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ParseBoardList validates a board list id; invalid values become BoardAll.
|
||||
func ParseBoardList(s string) BoardList {
|
||||
b := BoardList(s)
|
||||
switch b {
|
||||
case BoardAll, BoardBacklog, BoardInProgress, BoardDone:
|
||||
return b
|
||||
default:
|
||||
return BoardAll
|
||||
}
|
||||
}
|
||||
|
||||
// Matches reports whether a ticket belongs on this board list.
|
||||
func (b BoardList) Matches(status Status) bool {
|
||||
switch b {
|
||||
case BoardAll:
|
||||
return true
|
||||
case BoardBacklog:
|
||||
return status == StatusTodo
|
||||
case BoardInProgress:
|
||||
return status == StatusInProgress
|
||||
case BoardDone:
|
||||
return status == StatusDone
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultStatus is the status used when creating a ticket on this list.
|
||||
func (b BoardList) DefaultStatus() Status {
|
||||
switch b {
|
||||
case BoardInProgress:
|
||||
return StatusInProgress
|
||||
case BoardDone:
|
||||
return StatusDone
|
||||
default:
|
||||
return StatusTodo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestBoardListMatches(t *testing.T) {
|
||||
cases := []struct {
|
||||
list ticket.BoardList
|
||||
status ticket.Status
|
||||
want bool
|
||||
}{
|
||||
{ticket.BoardAll, ticket.StatusTodo, true},
|
||||
{ticket.BoardAll, ticket.StatusDone, true},
|
||||
{ticket.BoardBacklog, ticket.StatusTodo, true},
|
||||
{ticket.BoardBacklog, ticket.StatusInProgress, false},
|
||||
{ticket.BoardInProgress, ticket.StatusInProgress, true},
|
||||
{ticket.BoardDone, ticket.StatusDone, true},
|
||||
{ticket.BoardDone, ticket.StatusTodo, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.list.Matches(tc.status); got != tc.want {
|
||||
t.Errorf("%s.Matches(%s) = %v, want %v", tc.list, tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoardListDefaultStatus(t *testing.T) {
|
||||
if ticket.BoardBacklog.DefaultStatus() != ticket.StatusTodo {
|
||||
t.Fatal("backlog default")
|
||||
}
|
||||
if ticket.BoardInProgress.DefaultStatus() != ticket.StatusInProgress {
|
||||
t.Fatal("in progress default")
|
||||
}
|
||||
if ticket.BoardDone.DefaultStatus() != ticket.StatusDone {
|
||||
t.Fatal("done default")
|
||||
}
|
||||
if ticket.BoardAll.DefaultStatus() != ticket.StatusTodo {
|
||||
t.Fatal("all default")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FormatEditDocument is the markdown representation used for external editing.
|
||||
// Format:
|
||||
//
|
||||
// # Title
|
||||
//
|
||||
// optional description body…
|
||||
func FormatEditDocument(t Ticket) string {
|
||||
title := strings.TrimSpace(t.Title)
|
||||
title = strings.TrimLeft(title, "#")
|
||||
title = strings.TrimSpace(title)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# ")
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n")
|
||||
if desc := strings.TrimSpace(t.Description); desc != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(desc)
|
||||
if !strings.HasSuffix(desc, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ParseEditDocument extracts title and description from an edited markdown file.
|
||||
func ParseEditDocument(content string) (title, description string, err error) {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", "", fmt.Errorf("ticket content is empty")
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
first := strings.TrimSpace(lines[0])
|
||||
if strings.HasPrefix(first, "#") {
|
||||
title = strings.TrimSpace(strings.TrimLeft(first, "#"))
|
||||
rest := strings.Join(lines[1:], "\n")
|
||||
description = strings.TrimSpace(rest)
|
||||
} else {
|
||||
title = first
|
||||
rest := strings.Join(lines[1:], "\n")
|
||||
description = strings.TrimSpace(rest)
|
||||
}
|
||||
if title == "" {
|
||||
return "", "", fmt.Errorf("ticket title is required")
|
||||
}
|
||||
return title, description, nil
|
||||
}
|
||||
|
||||
// WriteEditFile writes the ticket to a temp .md file and returns its path.
|
||||
func WriteEditFile(t Ticket) (string, error) {
|
||||
f, err := os.CreateTemp("", "task-master-ticket-*.md")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
path := f.Name()
|
||||
if _, err := f.WriteString(FormatEditDocument(t)); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return "", fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ResolveEditor returns the user's preferred editor command string.
|
||||
// Order: $EDITOR → $VISUAL → nvim → vim → vi.
|
||||
func ResolveEditor() string {
|
||||
if ed := strings.TrimSpace(os.Getenv("EDITOR")); ed != "" {
|
||||
return ed
|
||||
}
|
||||
if ed := strings.TrimSpace(os.Getenv("VISUAL")); ed != "" {
|
||||
return ed
|
||||
}
|
||||
for _, cand := range []string{"nvim", "vim", "vi"} {
|
||||
if _, err := exec.LookPath(cand); err == nil {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return "vi"
|
||||
}
|
||||
|
||||
// EditorCmd builds a command that opens path in the default terminal editor.
|
||||
// Supports multi-word EDITOR values like "code --wait".
|
||||
func EditorCmd(path string) *exec.Cmd {
|
||||
ed := ResolveEditor()
|
||||
cmd := exec.Command("sh", "-c", ed+` "$1"`, "--", path)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestResolveEditorPrefersEnv(t *testing.T) {
|
||||
t.Setenv("EDITOR", "custom-ed")
|
||||
t.Setenv("VISUAL", "visual-ed")
|
||||
if got := ticket.ResolveEditor(); got != "custom-ed" {
|
||||
t.Fatalf("got %q, want custom-ed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEditorFallsBackToNvim(t *testing.T) {
|
||||
t.Setenv("EDITOR", "")
|
||||
t.Setenv("VISUAL", "")
|
||||
got := ticket.ResolveEditor()
|
||||
if _, err := exec.LookPath("nvim"); err == nil {
|
||||
if got != "nvim" {
|
||||
t.Fatalf("got %q, want nvim when available", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Without nvim, accept vim/vi.
|
||||
if got != "vim" && got != "vi" {
|
||||
t.Fatalf("got unexpected fallback %q", got)
|
||||
}
|
||||
_ = os.Environ()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestFormatAndParseEditDocument(t *testing.T) {
|
||||
doc := ticket.FormatEditDocument(ticket.Ticket{
|
||||
Title: "Fix login",
|
||||
Description: "Steps:\n\n1. repro\n2. patch",
|
||||
})
|
||||
if !strings.HasPrefix(doc, "# Fix login\n") {
|
||||
t.Fatalf("format = %q", doc)
|
||||
}
|
||||
|
||||
title, desc, err := ticket.ParseEditDocument(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title != "Fix login" {
|
||||
t.Fatalf("title = %q", title)
|
||||
}
|
||||
if desc != "Steps:\n\n1. repro\n2. patch" {
|
||||
t.Fatalf("desc = %q", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEditDocumentPlainTitle(t *testing.T) {
|
||||
title, desc, err := ticket.ParseEditDocument("Plain title\n\nBody text")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title != "Plain title" || desc != "Body text" {
|
||||
t.Fatalf("got %q / %q", title, desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEditDocumentEmpty(t *testing.T) {
|
||||
if _, _, err := ticket.ParseEditDocument(" "); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package ticket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
ticketsFileName = "tickets.json"
|
||||
legacyFileName = "tasks.json"
|
||||
)
|
||||
|
||||
// JSONStore persists tickets as JSON arrays under DataDir/projects/<id>/tickets.json.
|
||||
type JSONStore struct {
|
||||
dataDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewJSONStore creates a store rooted at dataDir.
|
||||
func NewJSONStore(dataDir string) *JSONStore {
|
||||
return &JSONStore{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (s *JSONStore) projectPath(projectID string) string {
|
||||
return filepath.Join(s.dataDir, "projects", projectID)
|
||||
}
|
||||
|
||||
func (s *JSONStore) ticketsPath(projectID string) string {
|
||||
return filepath.Join(s.projectPath(projectID), ticketsFileName)
|
||||
}
|
||||
|
||||
func (s *JSONStore) legacyPath(projectID string) string {
|
||||
return filepath.Join(s.projectPath(projectID), legacyFileName)
|
||||
}
|
||||
|
||||
// List returns all tickets for a project.
|
||||
func (s *JSONStore) List(projectID string) ([]Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.readUnlocked(projectID)
|
||||
}
|
||||
|
||||
// Get finds a ticket by ID across all project directories.
|
||||
func (s *JSONStore) Get(id string) (Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return Ticket{}, fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
for _, t := range tickets {
|
||||
if t.ID == id {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ticket{}, fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
// Create adds a new ticket, assigning ID and timestamps if missing.
|
||||
func (s *JSONStore) Create(t Ticket) (Ticket, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if t.ProjectID == "" {
|
||||
return Ticket{}, fmt.Errorf("projectId is required")
|
||||
}
|
||||
if t.Title == "" {
|
||||
return Ticket{}, fmt.Errorf("title is required")
|
||||
}
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.NewString()
|
||||
}
|
||||
if t.Status == "" {
|
||||
t.Status = StatusTodo
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
if t.UpdatedAt.IsZero() {
|
||||
t.UpdatedAt = now
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(s.projectPath(t.ProjectID), 0o755); err != nil {
|
||||
return Ticket{}, fmt.Errorf("create project dir: %w", err)
|
||||
}
|
||||
|
||||
tickets, err := s.readUnlocked(t.ProjectID)
|
||||
if err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
tickets = append(tickets, t)
|
||||
if err := s.writeUnlocked(t.ProjectID, tickets); err != nil {
|
||||
return Ticket{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Update replaces an existing ticket (matched by ID within its project).
|
||||
func (s *JSONStore) Update(t Ticket) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if t.ID == "" || t.ProjectID == "" {
|
||||
return fmt.Errorf("id and projectId are required")
|
||||
}
|
||||
|
||||
tickets, err := s.readUnlocked(t.ProjectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i := range tickets {
|
||||
if tickets[i].ID == t.ID {
|
||||
t.CreatedAt = tickets[i].CreatedAt
|
||||
t.UpdatedAt = time.Now().UTC()
|
||||
tickets[i] = t
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("ticket %s: not found", t.ID)
|
||||
}
|
||||
return s.writeUnlocked(t.ProjectID, tickets)
|
||||
}
|
||||
|
||||
// Delete removes a ticket by ID from its project's file.
|
||||
func (s *JSONStore) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
projects, err := os.ReadDir(filepath.Join(s.dataDir, "projects"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
return fmt.Errorf("list projects: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range projects {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
tickets, err := s.readUnlocked(p.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, t := range tickets {
|
||||
if t.ID == id {
|
||||
tickets = append(tickets[:i], tickets[i+1:]...)
|
||||
return s.writeUnlocked(p.Name(), tickets)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("ticket %s: not found", id)
|
||||
}
|
||||
|
||||
func (s *JSONStore) readUnlocked(projectID string) ([]Ticket, error) {
|
||||
path := s.ticketsPath(projectID)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Migrate from legacy tasks.json if present.
|
||||
legacy := s.legacyPath(projectID)
|
||||
data, err = os.ReadFile(legacy)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Ticket{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read %s: %w", legacy, err)
|
||||
}
|
||||
path = legacy
|
||||
} else {
|
||||
return nil, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []Ticket{}, nil
|
||||
}
|
||||
var tickets []Ticket
|
||||
if err := json.Unmarshal(data, &tickets); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if tickets == nil {
|
||||
tickets = []Ticket{}
|
||||
}
|
||||
return tickets, nil
|
||||
}
|
||||
|
||||
func (s *JSONStore) writeUnlocked(projectID string, tickets []Ticket) error {
|
||||
path := s.ticketsPath(projectID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create project dir: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(tickets, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal tickets: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), "tickets-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("rename temp to %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Clean up legacy file after successful write to tickets.json.
|
||||
_ = os.Remove(s.legacyPath(projectID))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package ticket_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestJSONStoreCreateListUpdate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := ticket.NewJSONStore(dir)
|
||||
|
||||
created, err := store.Create(ticket.Ticket{
|
||||
Title: "Write tests",
|
||||
ProjectID: "inbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Fatal("expected ID to be set")
|
||||
}
|
||||
if created.Status != ticket.StatusTodo {
|
||||
t.Fatalf("status = %s, want todo", created.Status)
|
||||
}
|
||||
|
||||
list, err := store.List("inbox")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("len(list) = %d, want 1", len(list))
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "projects", "inbox", "tickets.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("tickets.json missing: %v", err)
|
||||
}
|
||||
|
||||
created.Status = ticket.StatusDone
|
||||
created.Title = "Write better tests"
|
||||
if err := store.Update(created); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.Status != ticket.StatusDone || got.Title != "Write better tests" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
|
||||
if err := store.Delete(created.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
list, err = store.List("inbox")
|
||||
if err != nil {
|
||||
t.Fatalf("List after delete: %v", err)
|
||||
}
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("len(list) = %d, want 0", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONStoreMigratesLegacyTasksJSON(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
projectDir := filepath.Join(dir, "projects", "inbox")
|
||||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := `[{"id":"abc","title":"Legacy","status":"todo","projectId":"inbox","createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z"}]`
|
||||
if err := os.WriteFile(filepath.Join(projectDir, "tasks.json"), []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := ticket.NewJSONStore(dir)
|
||||
list, err := store.List("inbox")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Title != "Legacy" {
|
||||
t.Fatalf("got %+v", list)
|
||||
}
|
||||
|
||||
// Write should create tickets.json and remove legacy.
|
||||
list[0].Title = "Migrated"
|
||||
if err := store.Update(list[0]); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectDir, "tickets.json")); err != nil {
|
||||
t.Fatalf("tickets.json missing after write: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectDir, "tasks.json")); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy tasks.json should be removed, err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ticket
|
||||
|
||||
// Store is the persistence interface for tickets.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Store interface {
|
||||
List(projectID string) ([]Ticket, error)
|
||||
Get(id string) (Ticket, error)
|
||||
Create(t Ticket) (Ticket, error)
|
||||
Update(t Ticket) error
|
||||
Delete(id string) error
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ticket
|
||||
|
||||
import "time"
|
||||
|
||||
// Status represents the workflow state of a ticket.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusTodo Status = "todo"
|
||||
StatusInProgress Status = "in_progress"
|
||||
StatusDone Status = "done"
|
||||
)
|
||||
|
||||
// AllStatuses is the ordered list shown in the status picker.
|
||||
var AllStatuses = []Status{StatusTodo, StatusInProgress, StatusDone}
|
||||
|
||||
// Label returns a short display label for the status.
|
||||
func (s Status) Label() string {
|
||||
switch s {
|
||||
case StatusTodo:
|
||||
return "OPEN"
|
||||
case StatusInProgress:
|
||||
return "WIP"
|
||||
case StatusDone:
|
||||
return "DONE"
|
||||
default:
|
||||
return string(s)
|
||||
}
|
||||
}
|
||||
|
||||
// Index returns the position in AllStatuses, or 0 if unknown.
|
||||
func (s Status) Index() int {
|
||||
for i, st := range AllStatuses {
|
||||
if st == s {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Ticket is the domain model for a work item.
|
||||
// Title (headline) and Description are Markdown.
|
||||
type Ticket struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status Status `json:"status"`
|
||||
ProjectID string `json:"projectId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
Reference in New Issue
Block a user