Add git implementation of task manager
Change-Id: I1e0925e54fa167af9459eceea7a2cae082bc4504
diff --git a/server/go.mod b/server/go.mod
index 621072a..d2079f1 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -3,11 +3,16 @@
go 1.24.4
require (
+ github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.9.1
+ github.com/stretchr/testify v1.10.0
+ gopkg.in/yaml.v3 v3.0.1
)
require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
)
diff --git a/server/go.sum b/server/go.sum
index d2f5463..12bafed 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -1,12 +1,22 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/server/tm/git_tm/example.go b/server/tm/git_tm/example.go
new file mode 100644
index 0000000..39541fa
--- /dev/null
+++ b/server/tm/git_tm/example.go
@@ -0,0 +1,114 @@
+package git_tm
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/iomodo/staff/git"
+ "github.com/iomodo/staff/tm"
+)
+
+// Example demonstrates how to use the GitTaskManager
+func Example() {
+ // Initialize git interface
+ gitInterface := git.DefaultGit("./tasks-repo")
+
+ // Create task manager
+ taskManager := NewGitTaskManager(gitInterface, "./tasks-repo")
+
+ // Create a new task
+ ctx := context.Background()
+ dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
+
+ createReq := &tm.TaskCreateRequest{
+ Title: "Implement user authentication",
+ Description: "Add login/logout functionality with JWT tokens",
+ OwnerID: "john.doe",
+ Priority: tm.PriorityHigh,
+ DueDate: &dueDate,
+ }
+
+ task, err := taskManager.CreateTask(ctx, createReq)
+ if err != nil {
+ log.Fatalf("Failed to create task: %v", err)
+ }
+
+ fmt.Printf("Created task: %s\n", task.ID)
+
+ // Get the task
+ retrievedTask, err := taskManager.GetTask(ctx, task.ID)
+ if err != nil {
+ log.Fatalf("Failed to get task: %v", err)
+ }
+
+ fmt.Printf("Retrieved task: %s - %s\n", retrievedTask.ID, retrievedTask.Title)
+
+ // Start the task
+ startedTask, err := taskManager.StartTask(ctx, task.ID)
+ if err != nil {
+ log.Fatalf("Failed to start task: %v", err)
+ }
+
+ fmt.Printf("Started task: %s (status: %s)\n", startedTask.ID, startedTask.Status)
+
+ // List all tasks
+ taskList, err := taskManager.ListTasks(ctx, nil, 0, 10)
+ if err != nil {
+ log.Fatalf("Failed to list tasks: %v", err)
+ }
+
+ fmt.Printf("Total tasks: %d\n", taskList.TotalCount)
+ for _, t := range taskList.Tasks {
+ fmt.Printf("- %s: %s (%s)\n", t.ID, t.Title, t.Status)
+ }
+
+ // Complete the task
+ completedTask, err := taskManager.CompleteTask(ctx, task.ID)
+ if err != nil {
+ log.Fatalf("Failed to complete task: %v", err)
+ }
+
+ fmt.Printf("Completed task: %s (completed at: %s)\n",
+ completedTask.ID, completedTask.CompletedAt.Format(time.RFC3339))
+}
+
+// ExampleTaskFile shows the format of a task file
+func ExampleTaskFile() {
+ fmt.Print(`Example task file (tasks/task-1704067200-abc123.md):
+
+---
+id: task-1704067200-abc123
+title: Implement user authentication
+description: Add login/logout functionality with JWT tokens
+owner_id: john.doe
+owner_name: John Doe
+status: in_progress
+priority: high
+created_at: 2024-01-01T10:00:00Z
+updated_at: 2024-01-01T15:30:00Z
+due_date: 2024-01-08T17:00:00Z
+completed_at: null
+archived_at: null
+---
+
+# Task Description
+
+Add login/logout functionality with JWT tokens for the web application.
+
+## Requirements
+
+- User registration and login forms
+- JWT token generation and validation
+- Password hashing with bcrypt
+- Session management
+- Logout functionality
+
+## Notes
+
+- Consider using bcrypt for password hashing
+- Implement refresh token mechanism
+- Add rate limiting for login attempts
+`)
+}
diff --git a/server/tm/git_tm/git_task_manager.go b/server/tm/git_tm/git_task_manager.go
new file mode 100644
index 0000000..02d5197
--- /dev/null
+++ b/server/tm/git_tm/git_task_manager.go
@@ -0,0 +1,509 @@
+package git_tm
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/iomodo/staff/git"
+ "github.com/iomodo/staff/tm"
+ "gopkg.in/yaml.v3"
+)
+
+// GitTaskManager implements TaskManager interface using git as the source of truth
+type GitTaskManager struct {
+ git git.GitInterface
+ repoPath string
+ tasksDir string
+}
+
+// NewGitTaskManager creates a new GitTaskManager instance
+func NewGitTaskManager(git git.GitInterface, repoPath string) *GitTaskManager {
+ return &GitTaskManager{
+ git: git,
+ repoPath: repoPath,
+ tasksDir: filepath.Join(repoPath, "tasks"),
+ }
+}
+
+// ensureTasksDir ensures the tasks directory exists
+func (gtm *GitTaskManager) ensureTasksDir() error {
+ if err := os.MkdirAll(gtm.tasksDir, 0755); err != nil {
+ return fmt.Errorf("failed to create tasks directory: %w", err)
+ }
+ return nil
+}
+
+// generateTaskID generates a unique task ID
+func (gtm *GitTaskManager) generateTaskID() string {
+ timestamp := time.Now().Unix()
+ random := uuid.New().String()[:8]
+ return fmt.Sprintf("task-%d-%s", timestamp, random)
+}
+
+// taskToMarkdown converts a Task to markdown format
+func (gtm *GitTaskManager) taskToMarkdown(task *tm.Task) (string, error) {
+ // Create frontmatter data
+ frontmatter := map[string]interface{}{
+ "id": task.ID,
+ "title": task.Title,
+ "description": task.Description,
+ "owner_id": task.Owner.ID,
+ "owner_name": task.Owner.Name,
+ "status": task.Status,
+ "priority": task.Priority,
+ "created_at": task.CreatedAt.Format(time.RFC3339),
+ "updated_at": task.UpdatedAt.Format(time.RFC3339),
+ }
+
+ if task.DueDate != nil {
+ frontmatter["due_date"] = task.DueDate.Format(time.RFC3339)
+ }
+ if task.CompletedAt != nil {
+ frontmatter["completed_at"] = task.CompletedAt.Format(time.RFC3339)
+ }
+ if task.ArchivedAt != nil {
+ frontmatter["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
+ }
+
+ // Marshal frontmatter to YAML
+ yamlData, err := yaml.Marshal(frontmatter)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal frontmatter: %w", err)
+ }
+
+ // Build markdown content
+ var content strings.Builder
+ content.WriteString("---\n")
+ content.Write(yamlData)
+ content.WriteString("---\n\n")
+
+ if task.Description != "" {
+ content.WriteString("# Task Description\n\n")
+ content.WriteString(task.Description)
+ content.WriteString("\n\n")
+ }
+
+ return content.String(), nil
+}
+
+// parseTaskFromMarkdown parses a Task from markdown format
+func (gtm *GitTaskManager) parseTaskFromMarkdown(content string) (*tm.Task, error) {
+ // Split content into frontmatter and body
+ parts := strings.SplitN(content, "---\n", 3)
+ if len(parts) < 3 {
+ return nil, fmt.Errorf("invalid markdown format: missing frontmatter")
+ }
+
+ // Parse YAML frontmatter
+ var frontmatter map[string]interface{}
+ if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil {
+ return nil, fmt.Errorf("failed to parse frontmatter: %w", err)
+ }
+
+ // Extract task data
+ task := &tm.Task{}
+
+ if id, ok := frontmatter["id"].(string); ok {
+ task.ID = id
+ }
+ if title, ok := frontmatter["title"].(string); ok {
+ task.Title = title
+ }
+ if description, ok := frontmatter["description"].(string); ok {
+ task.Description = description
+ }
+ if ownerID, ok := frontmatter["owner_id"].(string); ok {
+ task.Owner.ID = ownerID
+ }
+ if ownerName, ok := frontmatter["owner_name"].(string); ok {
+ task.Owner.Name = ownerName
+ }
+ if status, ok := frontmatter["status"].(string); ok {
+ task.Status = tm.TaskStatus(status)
+ }
+ if priority, ok := frontmatter["priority"].(string); ok {
+ task.Priority = tm.TaskPriority(priority)
+ }
+
+ // Parse timestamps
+ if createdAt, ok := frontmatter["created_at"].(string); ok {
+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
+ task.CreatedAt = t
+ }
+ }
+ if updatedAt, ok := frontmatter["updated_at"].(string); ok {
+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
+ task.UpdatedAt = t
+ }
+ }
+ if dueDate, ok := frontmatter["due_date"].(string); ok {
+ if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
+ task.DueDate = &t
+ }
+ }
+ if completedAt, ok := frontmatter["completed_at"].(string); ok {
+ if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
+ task.CompletedAt = &t
+ }
+ }
+ if archivedAt, ok := frontmatter["archived_at"].(string); ok {
+ if t, err := time.Parse(time.RFC3339, archivedAt); err == nil {
+ task.ArchivedAt = &t
+ }
+ }
+
+ return task, nil
+}
+
+// readTaskFile reads a task from a file
+func (gtm *GitTaskManager) readTaskFile(taskID string) (*tm.Task, error) {
+ filePath := filepath.Join(gtm.tasksDir, taskID+".md")
+
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, tm.ErrTaskNotFound
+ }
+ return nil, fmt.Errorf("failed to read task file: %w", err)
+ }
+
+ return gtm.parseTaskFromMarkdown(string(content))
+}
+
+// writeTaskFile writes a task to a file
+func (gtm *GitTaskManager) writeTaskFile(task *tm.Task) error {
+ content, err := gtm.taskToMarkdown(task)
+ if err != nil {
+ return fmt.Errorf("failed to convert task to markdown: %w", err)
+ }
+
+ filePath := filepath.Join(gtm.tasksDir, task.ID+".md")
+ if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
+ return fmt.Errorf("failed to write task file: %w", err)
+ }
+
+ return nil
+}
+
+// listTaskFiles returns all task file paths
+func (gtm *GitTaskManager) listTaskFiles() ([]string, error) {
+ entries, err := os.ReadDir(gtm.tasksDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []string{}, nil
+ }
+ return nil, fmt.Errorf("failed to read tasks directory: %w", err)
+ }
+
+ var taskFiles []string
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
+ taskID := strings.TrimSuffix(entry.Name(), ".md")
+ taskFiles = append(taskFiles, taskID)
+ }
+ }
+
+ return taskFiles, nil
+}
+
+// commitTaskChange commits a task change to git
+func (gtm *GitTaskManager) commitTaskChange(taskID, operation string) error {
+ ctx := context.Background()
+
+ // Add the task file
+ if err := gtm.git.Add(ctx, []string{filepath.Join("tasks", taskID+".md")}); err != nil {
+ return fmt.Errorf("failed to add task file: %w", err)
+ }
+
+ // Commit the change
+ message := fmt.Sprintf("task: %s - %s", taskID, operation)
+ if err := gtm.git.Commit(ctx, message, git.CommitOptions{}); err != nil {
+ return fmt.Errorf("failed to commit task change: %w", err)
+ }
+
+ return nil
+}
+
+// CreateTask creates a new task
+func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
+ if err := gtm.ensureTasksDir(); err != nil {
+ return nil, err
+ }
+
+ // Validate request
+ if req.Title == "" {
+ return nil, tm.ErrInvalidTaskData
+ }
+ if req.OwnerID == "" {
+ return nil, tm.ErrInvalidOwner
+ }
+
+ // Generate task ID
+ taskID := gtm.generateTaskID()
+ now := time.Now()
+
+ // Create task
+ task := &tm.Task{
+ ID: taskID,
+ Title: req.Title,
+ Description: req.Description,
+ Owner: tm.Owner{
+ ID: req.OwnerID,
+ Name: req.OwnerID, // TODO: Look up owner name from a user service
+ },
+ Status: tm.StatusToDo,
+ Priority: req.Priority,
+ CreatedAt: now,
+ UpdatedAt: now,
+ DueDate: req.DueDate,
+ }
+
+ // Write task file
+ if err := gtm.writeTaskFile(task); err != nil {
+ return nil, err
+ }
+
+ // Commit to git
+ if err := gtm.commitTaskChange(taskID, "created"); err != nil {
+ return nil, err
+ }
+
+ return task, nil
+}
+
+// GetTask retrieves a task by ID
+func (gtm *GitTaskManager) GetTask(ctx context.Context, id string) (*tm.Task, error) {
+ return gtm.readTaskFile(id)
+}
+
+// UpdateTask updates an existing task
+func (gtm *GitTaskManager) UpdateTask(ctx context.Context, id string, req *tm.TaskUpdateRequest) (*tm.Task, error) {
+ // Read existing task
+ task, err := gtm.readTaskFile(id)
+ if err != nil {
+ return nil, err
+ }
+
+ // Update fields
+ updated := false
+ if req.Title != nil {
+ task.Title = *req.Title
+ updated = true
+ }
+ if req.Description != nil {
+ task.Description = *req.Description
+ updated = true
+ }
+ if req.OwnerID != nil {
+ task.Owner.ID = *req.OwnerID
+ task.Owner.Name = *req.OwnerID // TODO: Look up owner name from a user service
+ updated = true
+ }
+ if req.Status != nil {
+ task.Status = *req.Status
+ updated = true
+ }
+ if req.Priority != nil {
+ task.Priority = *req.Priority
+ updated = true
+ }
+ if req.DueDate != nil {
+ task.DueDate = req.DueDate
+ updated = true
+ }
+
+ if !updated {
+ return task, nil
+ }
+
+ // Update timestamps
+ task.UpdatedAt = time.Now()
+
+ // Handle status-specific timestamps
+ if req.Status != nil {
+ switch *req.Status {
+ case tm.StatusCompleted:
+ if task.CompletedAt == nil {
+ now := time.Now()
+ task.CompletedAt = &now
+ }
+ case tm.StatusArchived:
+ if task.ArchivedAt == nil {
+ now := time.Now()
+ task.ArchivedAt = &now
+ }
+ }
+ }
+
+ // Write updated task
+ if err := gtm.writeTaskFile(task); err != nil {
+ return nil, err
+ }
+
+ // Commit to git
+ if err := gtm.commitTaskChange(id, "updated"); err != nil {
+ return nil, err
+ }
+
+ return task, nil
+}
+
+// ArchiveTask archives a task
+func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
+ status := tm.StatusArchived
+ req := &tm.TaskUpdateRequest{
+ Status: &status,
+ }
+
+ _, err := gtm.UpdateTask(ctx, id, req)
+ return err
+}
+
+// ListTasks lists tasks with filtering and pagination
+func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
+ // Get all task files
+ taskFiles, err := gtm.listTaskFiles()
+ if err != nil {
+ return nil, err
+ }
+
+ // Read all tasks
+ var tasks []*tm.Task
+ for _, taskID := range taskFiles {
+ task, err := gtm.readTaskFile(taskID)
+ if err != nil {
+ continue // Skip corrupted files
+ }
+ tasks = append(tasks, task)
+ }
+
+ // Apply filters
+ if filter != nil {
+ tasks = gtm.filterTasks(tasks, filter)
+ }
+
+ // Sort by creation date (newest first)
+ sort.Slice(tasks, func(i, j int) bool {
+ return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
+ })
+
+ // Apply pagination
+ totalCount := len(tasks)
+ start := page * pageSize
+ end := start + pageSize
+
+ if start >= totalCount {
+ return &tm.TaskList{
+ Tasks: []*tm.Task{},
+ TotalCount: totalCount,
+ Page: page,
+ PageSize: pageSize,
+ HasMore: false,
+ }, nil
+ }
+
+ if end > totalCount {
+ end = totalCount
+ }
+
+ return &tm.TaskList{
+ Tasks: tasks[start:end],
+ TotalCount: totalCount,
+ Page: page,
+ PageSize: pageSize,
+ HasMore: end < totalCount,
+ }, nil
+}
+
+// filterTasks applies filters to a list of tasks
+func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
+ var filtered []*tm.Task
+
+ for _, task := range tasks {
+ if gtm.taskMatchesFilter(task, filter) {
+ filtered = append(filtered, task)
+ }
+ }
+
+ return filtered
+}
+
+// taskMatchesFilter checks if a task matches the given filter
+func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
+ if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
+ return false
+ }
+ if filter.Status != nil && task.Status != *filter.Status {
+ return false
+ }
+ if filter.Priority != nil && task.Priority != *filter.Priority {
+ return false
+ }
+ if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
+ return false
+ }
+ if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
+ return false
+ }
+ if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
+ return false
+ }
+ if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
+ return false
+ }
+
+ return true
+}
+
+// StartTask starts a task (changes status to in_progress)
+func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
+ status := tm.StatusInProgress
+ req := &tm.TaskUpdateRequest{
+ Status: &status,
+ }
+
+ return gtm.UpdateTask(ctx, id, req)
+}
+
+// CompleteTask completes a task (changes status to completed)
+func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
+ status := tm.StatusCompleted
+ req := &tm.TaskUpdateRequest{
+ Status: &status,
+ }
+
+ return gtm.UpdateTask(ctx, id, req)
+}
+
+// GetTasksByOwner gets tasks for a specific owner
+func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
+ filter := &tm.TaskFilter{
+ OwnerID: &ownerID,
+ }
+ return gtm.ListTasks(ctx, filter, page, pageSize)
+}
+
+// GetTasksByStatus gets tasks with a specific status
+func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
+ filter := &tm.TaskFilter{
+ Status: &status,
+ }
+ return gtm.ListTasks(ctx, filter, page, pageSize)
+}
+
+// GetTasksByPriority gets tasks with a specific priority
+func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
+ filter := &tm.TaskFilter{
+ Priority: &priority,
+ }
+ return gtm.ListTasks(ctx, filter, page, pageSize)
+}
+
+// Ensure GitTaskManager implements TaskManager interface
+var _ tm.TaskManager = (*GitTaskManager)(nil)
diff --git a/server/tm/git_tm/git_task_manager_test.go b/server/tm/git_tm/git_task_manager_test.go
new file mode 100644
index 0000000..cd37838
--- /dev/null
+++ b/server/tm/git_tm/git_task_manager_test.go
@@ -0,0 +1,1021 @@
+package git_tm
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/iomodo/staff/git"
+ "github.com/iomodo/staff/tm"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Test helper functions
+func setupTestDir(t *testing.T) (string, func()) {
+ tempDir, err := os.MkdirTemp("", "git-task-manager-test")
+ require.NoError(t, err)
+
+ cleanup := func() {
+ os.RemoveAll(tempDir)
+ }
+
+ return tempDir, cleanup
+}
+
+func createTestTaskManager(t *testing.T, repoPath string) (*GitTaskManager, git.GitInterface) {
+ // Initialize git repository
+ gitImpl := git.DefaultGit(repoPath)
+ ctx := context.Background()
+
+ err := gitImpl.Init(ctx, repoPath)
+ require.NoError(t, err)
+
+ // Set up git user config for commits
+ userConfig := git.UserConfig{
+ Name: "Test User",
+ Email: "test@example.com",
+ }
+ err = gitImpl.SetUserConfig(ctx, userConfig)
+ require.NoError(t, err)
+
+ gtm := NewGitTaskManager(gitImpl, repoPath)
+ return gtm, gitImpl
+}
+
+// Test cases
+func TestNewGitTaskManager(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gitImpl := git.DefaultGit(tempDir)
+ gtm := NewGitTaskManager(gitImpl, tempDir)
+
+ assert.NotNil(t, gtm)
+ assert.Equal(t, gitImpl, gtm.git)
+ assert.Equal(t, tempDir, gtm.repoPath)
+ assert.Equal(t, filepath.Join(tempDir, "tasks"), gtm.tasksDir)
+}
+
+func TestEnsureTasksDir(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Test creating tasks directory
+ err := gtm.ensureTasksDir()
+ assert.NoError(t, err)
+
+ // Verify directory exists
+ _, err = os.Stat(gtm.tasksDir)
+ assert.NoError(t, err)
+
+ // Test creating again (should not error)
+ err = gtm.ensureTasksDir()
+ assert.NoError(t, err)
+}
+
+func TestGenerateTaskID(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ id1 := gtm.generateTaskID()
+ id2 := gtm.generateTaskID()
+
+ assert.NotEmpty(t, id1)
+ assert.NotEmpty(t, id2)
+ assert.NotEqual(t, id1, id2)
+ assert.Contains(t, id1, "task-")
+}
+
+func TestTaskToMarkdown(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ now := time.Now()
+ dueDate := now.Add(24 * time.Hour)
+ completedAt := now.Add(12 * time.Hour)
+
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "This is a test task",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityHigh,
+ CreatedAt: now,
+ UpdatedAt: now,
+ DueDate: &dueDate,
+ CompletedAt: &completedAt,
+ }
+
+ markdown, err := gtm.taskToMarkdown(task)
+ assert.NoError(t, err)
+ assert.NotEmpty(t, markdown)
+ assert.Contains(t, markdown, "---")
+ assert.Contains(t, markdown, "id: test-task-123")
+ assert.Contains(t, markdown, "title: Test Task")
+ assert.Contains(t, markdown, "description: This is a test task")
+ assert.Contains(t, markdown, "owner_id: user123")
+ assert.Contains(t, markdown, "owner_name: Test User")
+ assert.Contains(t, markdown, "status: todo")
+ assert.Contains(t, markdown, "priority: high")
+ assert.Contains(t, markdown, "# Task Description")
+ assert.Contains(t, markdown, "This is a test task")
+}
+
+func TestParseTaskFromMarkdown(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ markdown := `---
+id: test-task-123
+title: Test Task
+description: This is a test task
+owner_id: user123
+owner_name: Test User
+status: todo
+priority: high
+created_at: 2023-01-01T00:00:00Z
+updated_at: 2023-01-01T00:00:00Z
+due_date: 2023-01-02T00:00:00Z
+completed_at: 2023-01-01T12:00:00Z
+---
+
+# Task Description
+
+This is a test task
+`
+
+ task, err := gtm.parseTaskFromMarkdown(markdown)
+ assert.NoError(t, err)
+ assert.NotNil(t, task)
+ assert.Equal(t, "test-task-123", task.ID)
+ assert.Equal(t, "Test Task", task.Title)
+ assert.Equal(t, "This is a test task", task.Description)
+ assert.Equal(t, "user123", task.Owner.ID)
+ assert.Equal(t, "Test User", task.Owner.Name)
+ assert.Equal(t, tm.StatusToDo, task.Status)
+ assert.Equal(t, tm.PriorityHigh, task.Priority)
+}
+
+func TestParseTaskFromMarkdownInvalid(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Test invalid markdown format
+ invalidMarkdown := "This is not valid markdown"
+
+ task, err := gtm.parseTaskFromMarkdown(invalidMarkdown)
+ assert.Error(t, err)
+ assert.Nil(t, task)
+ assert.Contains(t, err.Error(), "invalid markdown format")
+}
+
+func TestWriteAndReadTaskFile(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Ensure tasks directory exists
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+
+ // Create test task
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "This is a test task",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityHigh,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ // Write task file
+ err = gtm.writeTaskFile(task)
+ assert.NoError(t, err)
+
+ // Verify file exists
+ filePath := filepath.Join(gtm.tasksDir, task.ID+".md")
+ _, err = os.Stat(filePath)
+ assert.NoError(t, err)
+
+ // Read task file
+ readTask, err := gtm.readTaskFile(task.ID)
+ assert.NoError(t, err)
+ assert.NotNil(t, readTask)
+ assert.Equal(t, task.ID, readTask.ID)
+ assert.Equal(t, task.Title, readTask.Title)
+ assert.Equal(t, task.Description, readTask.Description)
+ assert.Equal(t, task.Owner.ID, readTask.Owner.ID)
+ assert.Equal(t, task.Owner.Name, readTask.Owner.Name)
+ assert.Equal(t, task.Status, readTask.Status)
+ assert.Equal(t, task.Priority, readTask.Priority)
+}
+
+func TestReadTaskFileNotFound(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Try to read non-existent task
+ task, err := gtm.readTaskFile("non-existent-task")
+ assert.Error(t, err)
+ assert.Nil(t, task)
+ assert.Equal(t, tm.ErrTaskNotFound, err)
+}
+
+func TestListTaskFiles(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Ensure tasks directory exists
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+
+ // Create some test task files
+ taskIDs := []string{"task-1", "task-2", "task-3"}
+ for _, id := range taskIDs {
+ task := &tm.Task{
+ ID: id,
+ Title: "Test Task " + id,
+ Description: "Test task description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+ }
+
+ // Create a non-task file
+ nonTaskFile := filepath.Join(gtm.tasksDir, "readme.txt")
+ err = os.WriteFile(nonTaskFile, []byte("This is not a task"), 0644)
+ require.NoError(t, err)
+
+ // List task files
+ taskFiles, err := gtm.listTaskFiles()
+ assert.NoError(t, err)
+ assert.Len(t, taskFiles, 3)
+
+ // Verify all task IDs are present
+ for _, id := range taskIDs {
+ assert.Contains(t, taskFiles, id)
+ }
+}
+
+func TestListTaskFilesEmpty(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // List task files in non-existent directory
+ taskFiles, err := gtm.listTaskFiles()
+ assert.NoError(t, err)
+ assert.Empty(t, taskFiles)
+}
+
+func TestCommitTaskChange(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, gitImpl := createTestTaskManager(t, tempDir)
+
+ // Create a test task file first
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+
+ // Test successful commit
+ err = gtm.commitTaskChange("test-task-123", "created")
+ assert.NoError(t, err)
+
+ // Verify commit was created
+ ctx := context.Background()
+ commits, err := gitImpl.Log(ctx, git.LogOptions{MaxCount: 1})
+ assert.NoError(t, err)
+ if len(commits) > 0 {
+ assert.Contains(t, commits[0].Message, "test-task-123")
+ assert.Contains(t, commits[0].Message, "created")
+ }
+}
+
+func TestCreateTask(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, gitImpl := createTestTaskManager(t, tempDir)
+
+ ctx := context.Background()
+ req := &tm.TaskCreateRequest{
+ Title: "New Test Task",
+ Description: "This is a new test task",
+ OwnerID: "user123",
+ Priority: tm.PriorityHigh,
+ }
+
+ task, err := gtm.CreateTask(ctx, req)
+ assert.NoError(t, err)
+ assert.NotNil(t, task)
+
+ // Verify task properties
+ assert.NotEmpty(t, task.ID)
+ assert.Contains(t, task.ID, "task-")
+ assert.Equal(t, req.Title, task.Title)
+ assert.Equal(t, req.Description, task.Description)
+ assert.Equal(t, req.OwnerID, task.Owner.ID)
+ assert.Equal(t, req.OwnerID, task.Owner.Name) // TODO: Should look up actual name
+ assert.Equal(t, tm.StatusToDo, task.Status)
+ assert.Equal(t, req.Priority, task.Priority)
+ assert.False(t, task.CreatedAt.IsZero())
+ assert.False(t, task.UpdatedAt.IsZero())
+
+ // Verify git commit was created
+ commits, err := gitImpl.Log(ctx, git.LogOptions{MaxCount: 1})
+ assert.NoError(t, err)
+ if len(commits) > 0 {
+ assert.Contains(t, commits[0].Message, task.ID)
+ assert.Contains(t, commits[0].Message, "created")
+ }
+}
+
+func TestCreateTaskInvalidData(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ ctx := context.Background()
+
+ // Test empty title
+ req := &tm.TaskCreateRequest{
+ Title: "",
+ OwnerID: "user123",
+ }
+
+ task, err := gtm.CreateTask(ctx, req)
+ assert.Error(t, err)
+ assert.Nil(t, task)
+ assert.Equal(t, tm.ErrInvalidTaskData, err)
+
+ // Test empty owner ID
+ req = &tm.TaskCreateRequest{
+ Title: "Valid Title",
+ OwnerID: "",
+ }
+
+ task, err = gtm.CreateTask(ctx, req)
+ assert.Error(t, err)
+ assert.Nil(t, task)
+ assert.Equal(t, tm.ErrInvalidOwner, err)
+}
+
+func TestGetTask(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test task description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+
+ // Get the task
+ ctx := context.Background()
+ retrievedTask, err := gtm.GetTask(ctx, task.ID)
+ assert.NoError(t, err)
+ assert.NotNil(t, retrievedTask)
+ assert.Equal(t, task.ID, retrievedTask.ID)
+ assert.Equal(t, task.Title, retrievedTask.Title)
+}
+
+func TestGetTaskNotFound(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ ctx := context.Background()
+ task, err := gtm.GetTask(ctx, "non-existent-task")
+ assert.Error(t, err)
+ assert.Nil(t, task)
+ assert.Equal(t, tm.ErrTaskNotFound, err)
+}
+
+func TestUpdateTask(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, gitImpl := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ originalTask := &tm.Task{
+ ID: "test-task-123",
+ Title: "Original Title",
+ Description: "Original description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Original User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityLow,
+ CreatedAt: time.Now().Add(-time.Hour),
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(originalTask)
+ require.NoError(t, err)
+
+ // Commit the initial task
+ err = gtm.commitTaskChange(originalTask.ID, "created")
+ require.NoError(t, err)
+
+ // Update the task
+ ctx := context.Background()
+ newTitle := "Updated Title"
+ newDescription := "Updated description"
+ newStatus := tm.StatusInProgress
+ newPriority := tm.PriorityHigh
+ newOwnerID := "user456"
+
+ req := &tm.TaskUpdateRequest{
+ Title: &newTitle,
+ Description: &newDescription,
+ Status: &newStatus,
+ Priority: &newPriority,
+ OwnerID: &newOwnerID,
+ }
+
+ updatedTask, err := gtm.UpdateTask(ctx, originalTask.ID, req)
+ assert.NoError(t, err)
+ assert.NotNil(t, updatedTask)
+
+ // Verify updated properties
+ assert.Equal(t, newTitle, updatedTask.Title)
+ assert.Equal(t, newDescription, updatedTask.Description)
+ assert.Equal(t, newStatus, updatedTask.Status)
+ assert.Equal(t, newPriority, updatedTask.Priority)
+ assert.Equal(t, newOwnerID, updatedTask.Owner.ID)
+ assert.Equal(t, newOwnerID, updatedTask.Owner.Name)
+
+ // Verify timestamps were updated
+ assert.True(t, updatedTask.UpdatedAt.After(originalTask.UpdatedAt))
+
+ // Verify git commit was created
+ commits, err := gitImpl.Log(ctx, git.LogOptions{MaxCount: 2})
+ assert.NoError(t, err)
+ if len(commits) > 0 {
+ assert.Contains(t, commits[0].Message, "updated")
+ }
+}
+
+func TestUpdateTaskNotFound(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ ctx := context.Background()
+ newTitle := "Updated Title"
+ req := &tm.TaskUpdateRequest{
+ Title: &newTitle,
+ }
+
+ task, err := gtm.UpdateTask(ctx, "non-existent-task", req)
+ assert.Error(t, err)
+ assert.Nil(t, task)
+ assert.Equal(t, tm.ErrTaskNotFound, err)
+}
+
+func TestUpdateTaskNoChanges(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ originalTask := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-time.Hour),
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(originalTask)
+ require.NoError(t, err)
+
+ // Update with no changes
+ ctx := context.Background()
+ req := &tm.TaskUpdateRequest{}
+
+ updatedTask, err := gtm.UpdateTask(ctx, originalTask.ID, req)
+ assert.NoError(t, err)
+ assert.NotNil(t, updatedTask)
+
+ // Verify no changes were made
+ assert.Equal(t, originalTask.Title, updatedTask.Title)
+ assert.Equal(t, originalTask.Description, updatedTask.Description)
+ assert.Equal(t, originalTask.Status, updatedTask.Status)
+ assert.Equal(t, originalTask.Priority, updatedTask.Priority)
+ assert.Equal(t, originalTask.Owner.ID, updatedTask.Owner.ID)
+}
+
+func TestUpdateTaskStatusTimestamps(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-time.Hour),
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+
+ ctx := context.Background()
+
+ // Test completing a task
+ completedStatus := tm.StatusCompleted
+ req := &tm.TaskUpdateRequest{
+ Status: &completedStatus,
+ }
+
+ updatedTask, err := gtm.UpdateTask(ctx, task.ID, req)
+ assert.NoError(t, err)
+ assert.NotNil(t, updatedTask)
+ assert.Equal(t, tm.StatusCompleted, updatedTask.Status)
+ assert.NotNil(t, updatedTask.CompletedAt)
+
+ // Test archiving a task
+ archivedStatus := tm.StatusArchived
+ req = &tm.TaskUpdateRequest{
+ Status: &archivedStatus,
+ }
+
+ updatedTask, err = gtm.UpdateTask(ctx, task.ID, req)
+ assert.NoError(t, err)
+ assert.NotNil(t, updatedTask)
+ assert.Equal(t, tm.StatusArchived, updatedTask.Status)
+ assert.NotNil(t, updatedTask.ArchivedAt)
+}
+
+func TestArchiveTask(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-time.Hour),
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+
+ // Archive the task
+ ctx := context.Background()
+ err = gtm.ArchiveTask(ctx, task.ID)
+ assert.NoError(t, err)
+
+ // Verify task was archived
+ archivedTask, err := gtm.GetTask(ctx, task.ID)
+ assert.NoError(t, err)
+ assert.Equal(t, tm.StatusArchived, archivedTask.Status)
+ assert.NotNil(t, archivedTask.ArchivedAt)
+}
+
+func TestStartTask(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-time.Hour),
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+
+ // Start the task
+ ctx := context.Background()
+ startedTask, err := gtm.StartTask(ctx, task.ID)
+ assert.NoError(t, err)
+ assert.NotNil(t, startedTask)
+ assert.Equal(t, tm.StatusInProgress, startedTask.Status)
+}
+
+func TestCompleteTask(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create a test task
+ task := &tm.Task{
+ ID: "test-task-123",
+ Title: "Test Task",
+ Description: "Test description",
+ Owner: tm.Owner{
+ ID: "user123",
+ Name: "Test User",
+ },
+ Status: tm.StatusInProgress,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-time.Hour),
+ UpdatedAt: time.Now().Add(-time.Hour),
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+
+ // Complete the task
+ ctx := context.Background()
+ completedTask, err := gtm.CompleteTask(ctx, task.ID)
+ assert.NoError(t, err)
+ assert.NotNil(t, completedTask)
+ assert.Equal(t, tm.StatusCompleted, completedTask.Status)
+ assert.NotNil(t, completedTask.CompletedAt)
+}
+
+func TestListTasks(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create test tasks
+ tasks := []*tm.Task{
+ {
+ ID: "task-1",
+ Title: "Task 1",
+ Description: "First task",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityHigh,
+ CreatedAt: time.Now().Add(-2 * time.Hour),
+ UpdatedAt: time.Now().Add(-2 * time.Hour),
+ },
+ {
+ ID: "task-2",
+ Title: "Task 2",
+ Description: "Second task",
+ Owner: tm.Owner{ID: "user2", Name: "User 2"},
+ Status: tm.StatusInProgress,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-1 * time.Hour),
+ UpdatedAt: time.Now().Add(-1 * time.Hour),
+ },
+ {
+ ID: "task-3",
+ Title: "Task 3",
+ Description: "Third task",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusCompleted,
+ Priority: tm.PriorityLow,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+
+ for _, task := range tasks {
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+ }
+
+ ctx := context.Background()
+
+ // Test listing all tasks
+ taskList, err := gtm.ListTasks(ctx, nil, 0, 10)
+ assert.NoError(t, err)
+ assert.NotNil(t, taskList)
+ assert.Len(t, taskList.Tasks, 3)
+ assert.Equal(t, 3, taskList.TotalCount)
+ assert.Equal(t, 0, taskList.Page)
+ assert.Equal(t, 10, taskList.PageSize)
+ assert.False(t, taskList.HasMore)
+
+ // Test pagination
+ taskList, err = gtm.ListTasks(ctx, nil, 0, 2)
+ assert.NoError(t, err)
+ assert.Len(t, taskList.Tasks, 2)
+ assert.Equal(t, 3, taskList.TotalCount)
+ assert.True(t, taskList.HasMore)
+
+ // Test filtering by owner
+ ownerFilter := &tm.TaskFilter{OwnerID: stringPtr("user1")}
+ taskList, err = gtm.ListTasks(ctx, ownerFilter, 0, 10)
+ assert.NoError(t, err)
+ assert.Len(t, taskList.Tasks, 2)
+
+ // Test filtering by status
+ statusFilter := &tm.TaskFilter{Status: taskStatusPtr(tm.StatusToDo)}
+ taskList, err = gtm.ListTasks(ctx, statusFilter, 0, 10)
+ assert.NoError(t, err)
+ assert.Len(t, taskList.Tasks, 1)
+ assert.Equal(t, "task-1", taskList.Tasks[0].ID)
+
+ // Test filtering by priority
+ priorityFilter := &tm.TaskFilter{Priority: taskPriorityPtr(tm.PriorityHigh)}
+ taskList, err = gtm.ListTasks(ctx, priorityFilter, 0, 10)
+ assert.NoError(t, err)
+ assert.Len(t, taskList.Tasks, 1)
+ assert.Equal(t, "task-1", taskList.Tasks[0].ID)
+}
+
+func TestGetTasksByOwner(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create test tasks
+ tasks := []*tm.Task{
+ {
+ ID: "task-1",
+ Title: "Task 1",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityHigh,
+ CreatedAt: time.Now().Add(-2 * time.Hour),
+ UpdatedAt: time.Now().Add(-2 * time.Hour),
+ },
+ {
+ ID: "task-2",
+ Title: "Task 2",
+ Owner: tm.Owner{ID: "user2", Name: "User 2"},
+ Status: tm.StatusInProgress,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-1 * time.Hour),
+ UpdatedAt: time.Now().Add(-1 * time.Hour),
+ },
+ {
+ ID: "task-3",
+ Title: "Task 3",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusCompleted,
+ Priority: tm.PriorityLow,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+
+ for _, task := range tasks {
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+ }
+
+ ctx := context.Background()
+
+ // Get tasks by owner
+ taskList, err := gtm.GetTasksByOwner(ctx, "user1", 0, 10)
+ assert.NoError(t, err)
+ assert.NotNil(t, taskList)
+ assert.Len(t, taskList.Tasks, 2)
+
+ for _, task := range taskList.Tasks {
+ assert.Equal(t, "user1", task.Owner.ID)
+ }
+}
+
+func TestGetTasksByStatus(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create test tasks
+ tasks := []*tm.Task{
+ {
+ ID: "task-1",
+ Title: "Task 1",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityHigh,
+ CreatedAt: time.Now().Add(-2 * time.Hour),
+ UpdatedAt: time.Now().Add(-2 * time.Hour),
+ },
+ {
+ ID: "task-2",
+ Title: "Task 2",
+ Owner: tm.Owner{ID: "user2", Name: "User 2"},
+ Status: tm.StatusInProgress,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-1 * time.Hour),
+ UpdatedAt: time.Now().Add(-1 * time.Hour),
+ },
+ {
+ ID: "task-3",
+ Title: "Task 3",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusCompleted,
+ Priority: tm.PriorityLow,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+
+ for _, task := range tasks {
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+ }
+
+ ctx := context.Background()
+
+ // Get tasks by status
+ taskList, err := gtm.GetTasksByStatus(ctx, tm.StatusToDo, 0, 10)
+ assert.NoError(t, err)
+ assert.NotNil(t, taskList)
+ assert.Len(t, taskList.Tasks, 1)
+ assert.Equal(t, tm.StatusToDo, taskList.Tasks[0].Status)
+}
+
+func TestGetTasksByPriority(t *testing.T) {
+ tempDir, cleanup := setupTestDir(t)
+ defer cleanup()
+
+ gtm, _ := createTestTaskManager(t, tempDir)
+
+ // Create test tasks
+ tasks := []*tm.Task{
+ {
+ ID: "task-1",
+ Title: "Task 1",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusToDo,
+ Priority: tm.PriorityHigh,
+ CreatedAt: time.Now().Add(-2 * time.Hour),
+ UpdatedAt: time.Now().Add(-2 * time.Hour),
+ },
+ {
+ ID: "task-2",
+ Title: "Task 2",
+ Owner: tm.Owner{ID: "user2", Name: "User 2"},
+ Status: tm.StatusInProgress,
+ Priority: tm.PriorityMedium,
+ CreatedAt: time.Now().Add(-1 * time.Hour),
+ UpdatedAt: time.Now().Add(-1 * time.Hour),
+ },
+ {
+ ID: "task-3",
+ Title: "Task 3",
+ Owner: tm.Owner{ID: "user1", Name: "User 1"},
+ Status: tm.StatusCompleted,
+ Priority: tm.PriorityLow,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ },
+ }
+
+ err := gtm.ensureTasksDir()
+ require.NoError(t, err)
+
+ for _, task := range tasks {
+ err = gtm.writeTaskFile(task)
+ require.NoError(t, err)
+ }
+
+ ctx := context.Background()
+
+ // Get tasks by priority
+ taskList, err := gtm.GetTasksByPriority(ctx, tm.PriorityHigh, 0, 10)
+ assert.NoError(t, err)
+ assert.NotNil(t, taskList)
+ assert.Len(t, taskList.Tasks, 1)
+ assert.Equal(t, tm.PriorityHigh, taskList.Tasks[0].Priority)
+}
+
+// Helper functions for creating pointers to string, TaskStatus, and TaskPriority
+func stringPtr(s string) *string {
+ return &s
+}
+
+func taskStatusPtr(status tm.TaskStatus) *tm.TaskStatus {
+ return &status
+}
+
+func taskPriorityPtr(priority tm.TaskPriority) *tm.TaskPriority {
+ return &priority
+}