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)