blob: 5c43828a445b1b9201144507e1fd6a70b8b053e4 [file] [log] [blame]
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"
)
// GitTaskManager implements TaskManager interface using git as the source of truth
type GitTaskManager struct {
git git.GitInterface
repoPath string
tasksDir string
logger *slog.Logger
}
// NewGitTaskManager creates a new GitTaskManager instance
func NewGitTaskManager(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
return &GitTaskManager{
git: git,
repoPath: repoPath,
tasksDir: repoPath,
logger: logger,
}
}
// 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: repoPath,
logger: logger,
}
}
// 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, owner tm.Owner) error {
ctx := context.Background()
// Add the task file
if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, 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{
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()
// 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", 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
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", 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)