| package git_tm |
| |
| import ( |
| "context" |
| "fmt" |
| "log/slog" |
| "os" |
| "path/filepath" |
| "sort" |
| "strings" |
| "time" |
| |
| "github.com/google/uuid" |
| "github.com/iomodo/staff/git" |
| "github.com/iomodo/staff/tm" |
| "gopkg.in/yaml.v3" |
| ) |
| |
| const ( |
| // File system constants |
| DefaultFileMode = 0755 |
| TaskFileMode = 0644 |
| TaskFileExt = ".md" |
| |
| // Frontmatter constants |
| FrontmatterSeparator = "---\n" |
| |
| // Task ID format |
| TaskIDPrefix = "task-" |
| ) |
| |
| // UserService defines interface for user-related operations |
| type UserService interface { |
| GetUserName(userID string) (string, error) |
| } |
| |
| // DefaultUserService provides a simple implementation that uses userID as name |
| type DefaultUserService struct{} |
| |
| func (dus *DefaultUserService) GetUserName(userID string) (string, error) { |
| // For now, just return the userID as the name |
| // This can be enhanced to lookup from a proper user service |
| return userID, nil |
| } |
| |
| // GitTaskManager implements TaskManager interface using git as the source of truth |
| type GitTaskManager struct { |
| git git.GitInterface |
| repoPath string |
| tasksDir string |
| logger *slog.Logger |
| userService UserService |
| } |
| |
| // NewGitTaskManager creates a new GitTaskManager instance |
| func NewGitTaskManager(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager { |
| return &GitTaskManager{ |
| git: git, |
| repoPath: repoPath, |
| tasksDir: filepath.Join(repoPath, "tasks"), |
| logger: logger, |
| userService: &DefaultUserService{}, |
| } |
| } |
| |
| // NewGitTaskManagerWithLogger creates a new GitTaskManager instance with a custom logger |
| func NewGitTaskManagerWithLogger(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager { |
| if logger == nil { |
| logger = slog.Default() |
| } |
| return &GitTaskManager{ |
| git: git, |
| repoPath: repoPath, |
| tasksDir: filepath.Join(repoPath, "tasks"), |
| logger: logger, |
| userService: &DefaultUserService{}, |
| } |
| } |
| |
| // NewGitTaskManagerWithUserService creates a new GitTaskManager with custom user service |
| func NewGitTaskManagerWithUserService(git git.GitInterface, repoPath string, logger *slog.Logger, userService UserService) *GitTaskManager { |
| if logger == nil { |
| logger = slog.Default() |
| } |
| if userService == nil { |
| userService = &DefaultUserService{} |
| } |
| return &GitTaskManager{ |
| git: git, |
| repoPath: repoPath, |
| tasksDir: filepath.Join(repoPath, "tasks"), |
| logger: logger, |
| userService: userService, |
| } |
| } |
| |
| // ensureTasksDir ensures the tasks directory exists |
| func (gtm *GitTaskManager) ensureTasksDir() error { |
| if err := os.MkdirAll(gtm.tasksDir, DefaultFileMode); 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("%s%d-%s", TaskIDPrefix, 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(FrontmatterSeparator) |
| content.Write(yamlData) |
| content.WriteString(FrontmatterSeparator) |
| content.WriteString("\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, FrontmatterSeparator, 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+TaskFileExt) |
| |
| 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+TaskFileExt) |
| if err := os.WriteFile(filePath, []byte(content), TaskFileMode); 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(), TaskFileExt) { |
| taskID := strings.TrimSuffix(entry.Name(), TaskFileExt) |
| taskFiles = append(taskFiles, taskID) |
| } |
| } |
| |
| return taskFiles, nil |
| } |
| |
| // commitTaskChange commits a task change to git |
| func (gtm *GitTaskManager) commitTaskChange(taskID, operation string, owner tm.Owner) error { |
| ctx := context.Background() |
| |
| // Add the task file |
| if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, taskID+TaskFileExt)}); 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{ |
| Author: &git.Author{ |
| Name: owner.Name, |
| }, |
| }); 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() |
| |
| // Get owner name from user service |
| ownerName, err := gtm.userService.GetUserName(req.OwnerID) |
| if err != nil { |
| gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", req.OwnerID), slog.String("error", err.Error())) |
| ownerName = req.OwnerID |
| } |
| |
| // Create task |
| task := &tm.Task{ |
| ID: taskID, |
| Title: req.Title, |
| Description: req.Description, |
| Owner: tm.Owner{ |
| ID: req.OwnerID, |
| Name: ownerName, |
| }, |
| 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", task.Owner); 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 |
| // Get owner name from user service |
| if ownerName, err := gtm.userService.GetUserName(*req.OwnerID); err == nil { |
| task.Owner.Name = ownerName |
| } else { |
| gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", *req.OwnerID), slog.String("error", err.Error())) |
| task.Owner.Name = *req.OwnerID |
| } |
| 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", task.Owner); 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) |