Add git implementation of task manager
Change-Id: I1e0925e54fa167af9459eceea7a2cae082bc4504
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)