blob: 6a8735e3a49ecc89918d05c9349f2154c6bd3879 [file] [log] [blame]
iomodo3b89bdf2025-07-25 15:19:22 +04001package git_tm
2
3import (
4 "context"
5 "fmt"
iomodo0c203b12025-07-26 19:44:57 +04006 "log/slog"
iomodo3b89bdf2025-07-25 15:19:22 +04007 "os"
8 "path/filepath"
9 "sort"
10 "strings"
11 "time"
12
13 "github.com/google/uuid"
14 "github.com/iomodo/staff/git"
15 "github.com/iomodo/staff/tm"
16 "gopkg.in/yaml.v3"
17)
18
19// GitTaskManager implements TaskManager interface using git as the source of truth
20type GitTaskManager struct {
21 git git.GitInterface
22 repoPath string
23 tasksDir string
iomodo0c203b12025-07-26 19:44:57 +040024 logger *slog.Logger
iomodo3b89bdf2025-07-25 15:19:22 +040025}
26
27// NewGitTaskManager creates a new GitTaskManager instance
iomodo0c203b12025-07-26 19:44:57 +040028func NewGitTaskManager(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
iomodo3b89bdf2025-07-25 15:19:22 +040029 return &GitTaskManager{
30 git: git,
31 repoPath: repoPath,
32 tasksDir: filepath.Join(repoPath, "tasks"),
iomodo0c203b12025-07-26 19:44:57 +040033 logger: logger,
34 }
35}
36
37// NewGitTaskManagerWithLogger creates a new GitTaskManager instance with a custom logger
38func NewGitTaskManagerWithLogger(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
39 if logger == nil {
40 logger = slog.Default()
41 }
42 return &GitTaskManager{
43 git: git,
44 repoPath: repoPath,
45 tasksDir: filepath.Join(repoPath, "tasks"),
46 logger: logger,
iomodo3b89bdf2025-07-25 15:19:22 +040047 }
48}
49
50// ensureTasksDir ensures the tasks directory exists
51func (gtm *GitTaskManager) ensureTasksDir() error {
52 if err := os.MkdirAll(gtm.tasksDir, 0755); err != nil {
53 return fmt.Errorf("failed to create tasks directory: %w", err)
54 }
55 return nil
56}
57
58// generateTaskID generates a unique task ID
59func (gtm *GitTaskManager) generateTaskID() string {
60 timestamp := time.Now().Unix()
61 random := uuid.New().String()[:8]
62 return fmt.Sprintf("task-%d-%s", timestamp, random)
63}
64
65// taskToMarkdown converts a Task to markdown format
66func (gtm *GitTaskManager) taskToMarkdown(task *tm.Task) (string, error) {
67 // Create frontmatter data
68 frontmatter := map[string]interface{}{
69 "id": task.ID,
70 "title": task.Title,
71 "description": task.Description,
72 "owner_id": task.Owner.ID,
73 "owner_name": task.Owner.Name,
74 "status": task.Status,
75 "priority": task.Priority,
76 "created_at": task.CreatedAt.Format(time.RFC3339),
77 "updated_at": task.UpdatedAt.Format(time.RFC3339),
78 }
79
80 if task.DueDate != nil {
81 frontmatter["due_date"] = task.DueDate.Format(time.RFC3339)
82 }
83 if task.CompletedAt != nil {
84 frontmatter["completed_at"] = task.CompletedAt.Format(time.RFC3339)
85 }
86 if task.ArchivedAt != nil {
87 frontmatter["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
88 }
89
90 // Marshal frontmatter to YAML
91 yamlData, err := yaml.Marshal(frontmatter)
92 if err != nil {
93 return "", fmt.Errorf("failed to marshal frontmatter: %w", err)
94 }
95
96 // Build markdown content
97 var content strings.Builder
98 content.WriteString("---\n")
99 content.Write(yamlData)
100 content.WriteString("---\n\n")
101
102 if task.Description != "" {
103 content.WriteString("# Task Description\n\n")
104 content.WriteString(task.Description)
105 content.WriteString("\n\n")
106 }
107
108 return content.String(), nil
109}
110
111// parseTaskFromMarkdown parses a Task from markdown format
112func (gtm *GitTaskManager) parseTaskFromMarkdown(content string) (*tm.Task, error) {
113 // Split content into frontmatter and body
114 parts := strings.SplitN(content, "---\n", 3)
115 if len(parts) < 3 {
116 return nil, fmt.Errorf("invalid markdown format: missing frontmatter")
117 }
118
119 // Parse YAML frontmatter
120 var frontmatter map[string]interface{}
121 if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil {
122 return nil, fmt.Errorf("failed to parse frontmatter: %w", err)
123 }
124
125 // Extract task data
126 task := &tm.Task{}
127
128 if id, ok := frontmatter["id"].(string); ok {
129 task.ID = id
130 }
131 if title, ok := frontmatter["title"].(string); ok {
132 task.Title = title
133 }
134 if description, ok := frontmatter["description"].(string); ok {
135 task.Description = description
136 }
137 if ownerID, ok := frontmatter["owner_id"].(string); ok {
138 task.Owner.ID = ownerID
139 }
140 if ownerName, ok := frontmatter["owner_name"].(string); ok {
141 task.Owner.Name = ownerName
142 }
143 if status, ok := frontmatter["status"].(string); ok {
144 task.Status = tm.TaskStatus(status)
145 }
146 if priority, ok := frontmatter["priority"].(string); ok {
147 task.Priority = tm.TaskPriority(priority)
148 }
149
150 // Parse timestamps
151 if createdAt, ok := frontmatter["created_at"].(string); ok {
152 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
153 task.CreatedAt = t
154 }
155 }
156 if updatedAt, ok := frontmatter["updated_at"].(string); ok {
157 if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
158 task.UpdatedAt = t
159 }
160 }
161 if dueDate, ok := frontmatter["due_date"].(string); ok {
162 if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
163 task.DueDate = &t
164 }
165 }
166 if completedAt, ok := frontmatter["completed_at"].(string); ok {
167 if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
168 task.CompletedAt = &t
169 }
170 }
171 if archivedAt, ok := frontmatter["archived_at"].(string); ok {
172 if t, err := time.Parse(time.RFC3339, archivedAt); err == nil {
173 task.ArchivedAt = &t
174 }
175 }
176
177 return task, nil
178}
179
180// readTaskFile reads a task from a file
181func (gtm *GitTaskManager) readTaskFile(taskID string) (*tm.Task, error) {
182 filePath := filepath.Join(gtm.tasksDir, taskID+".md")
183
184 content, err := os.ReadFile(filePath)
185 if err != nil {
186 if os.IsNotExist(err) {
187 return nil, tm.ErrTaskNotFound
188 }
189 return nil, fmt.Errorf("failed to read task file: %w", err)
190 }
191
192 return gtm.parseTaskFromMarkdown(string(content))
193}
194
195// writeTaskFile writes a task to a file
196func (gtm *GitTaskManager) writeTaskFile(task *tm.Task) error {
197 content, err := gtm.taskToMarkdown(task)
198 if err != nil {
199 return fmt.Errorf("failed to convert task to markdown: %w", err)
200 }
201
202 filePath := filepath.Join(gtm.tasksDir, task.ID+".md")
203 if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
204 return fmt.Errorf("failed to write task file: %w", err)
205 }
206
207 return nil
208}
209
210// listTaskFiles returns all task file paths
211func (gtm *GitTaskManager) listTaskFiles() ([]string, error) {
212 entries, err := os.ReadDir(gtm.tasksDir)
213 if err != nil {
214 if os.IsNotExist(err) {
215 return []string{}, nil
216 }
217 return nil, fmt.Errorf("failed to read tasks directory: %w", err)
218 }
219
220 var taskFiles []string
221 for _, entry := range entries {
222 if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") {
223 taskID := strings.TrimSuffix(entry.Name(), ".md")
224 taskFiles = append(taskFiles, taskID)
225 }
226 }
227
228 return taskFiles, nil
229}
230
231// commitTaskChange commits a task change to git
232func (gtm *GitTaskManager) commitTaskChange(taskID, operation string) error {
233 ctx := context.Background()
234
235 // Add the task file
236 if err := gtm.git.Add(ctx, []string{filepath.Join("tasks", taskID+".md")}); err != nil {
237 return fmt.Errorf("failed to add task file: %w", err)
238 }
239
240 // Commit the change
241 message := fmt.Sprintf("task: %s - %s", taskID, operation)
242 if err := gtm.git.Commit(ctx, message, git.CommitOptions{}); err != nil {
243 return fmt.Errorf("failed to commit task change: %w", err)
244 }
245
246 return nil
247}
248
249// CreateTask creates a new task
250func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
251 if err := gtm.ensureTasksDir(); err != nil {
252 return nil, err
253 }
254
255 // Validate request
256 if req.Title == "" {
257 return nil, tm.ErrInvalidTaskData
258 }
259 if req.OwnerID == "" {
260 return nil, tm.ErrInvalidOwner
261 }
262
263 // Generate task ID
264 taskID := gtm.generateTaskID()
265 now := time.Now()
266
267 // Create task
268 task := &tm.Task{
269 ID: taskID,
270 Title: req.Title,
271 Description: req.Description,
272 Owner: tm.Owner{
273 ID: req.OwnerID,
274 Name: req.OwnerID, // TODO: Look up owner name from a user service
275 },
276 Status: tm.StatusToDo,
277 Priority: req.Priority,
278 CreatedAt: now,
279 UpdatedAt: now,
280 DueDate: req.DueDate,
281 }
282
283 // Write task file
284 if err := gtm.writeTaskFile(task); err != nil {
285 return nil, err
286 }
287
288 // Commit to git
289 if err := gtm.commitTaskChange(taskID, "created"); err != nil {
290 return nil, err
291 }
292
293 return task, nil
294}
295
296// GetTask retrieves a task by ID
297func (gtm *GitTaskManager) GetTask(ctx context.Context, id string) (*tm.Task, error) {
298 return gtm.readTaskFile(id)
299}
300
301// UpdateTask updates an existing task
302func (gtm *GitTaskManager) UpdateTask(ctx context.Context, id string, req *tm.TaskUpdateRequest) (*tm.Task, error) {
303 // Read existing task
304 task, err := gtm.readTaskFile(id)
305 if err != nil {
306 return nil, err
307 }
308
309 // Update fields
310 updated := false
311 if req.Title != nil {
312 task.Title = *req.Title
313 updated = true
314 }
315 if req.Description != nil {
316 task.Description = *req.Description
317 updated = true
318 }
319 if req.OwnerID != nil {
320 task.Owner.ID = *req.OwnerID
321 task.Owner.Name = *req.OwnerID // TODO: Look up owner name from a user service
322 updated = true
323 }
324 if req.Status != nil {
325 task.Status = *req.Status
326 updated = true
327 }
328 if req.Priority != nil {
329 task.Priority = *req.Priority
330 updated = true
331 }
332 if req.DueDate != nil {
333 task.DueDate = req.DueDate
334 updated = true
335 }
336
337 if !updated {
338 return task, nil
339 }
340
341 // Update timestamps
342 task.UpdatedAt = time.Now()
343
344 // Handle status-specific timestamps
345 if req.Status != nil {
346 switch *req.Status {
347 case tm.StatusCompleted:
348 if task.CompletedAt == nil {
349 now := time.Now()
350 task.CompletedAt = &now
351 }
352 case tm.StatusArchived:
353 if task.ArchivedAt == nil {
354 now := time.Now()
355 task.ArchivedAt = &now
356 }
357 }
358 }
359
360 // Write updated task
361 if err := gtm.writeTaskFile(task); err != nil {
362 return nil, err
363 }
364
365 // Commit to git
366 if err := gtm.commitTaskChange(id, "updated"); err != nil {
367 return nil, err
368 }
369
370 return task, nil
371}
372
373// ArchiveTask archives a task
374func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
375 status := tm.StatusArchived
376 req := &tm.TaskUpdateRequest{
377 Status: &status,
378 }
379
380 _, err := gtm.UpdateTask(ctx, id, req)
381 return err
382}
383
384// ListTasks lists tasks with filtering and pagination
385func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
386 // Get all task files
387 taskFiles, err := gtm.listTaskFiles()
388 if err != nil {
389 return nil, err
390 }
391
392 // Read all tasks
393 var tasks []*tm.Task
394 for _, taskID := range taskFiles {
395 task, err := gtm.readTaskFile(taskID)
396 if err != nil {
397 continue // Skip corrupted files
398 }
399 tasks = append(tasks, task)
400 }
401
402 // Apply filters
403 if filter != nil {
404 tasks = gtm.filterTasks(tasks, filter)
405 }
406
407 // Sort by creation date (newest first)
408 sort.Slice(tasks, func(i, j int) bool {
409 return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
410 })
411
412 // Apply pagination
413 totalCount := len(tasks)
414 start := page * pageSize
415 end := start + pageSize
416
417 if start >= totalCount {
418 return &tm.TaskList{
419 Tasks: []*tm.Task{},
420 TotalCount: totalCount,
421 Page: page,
422 PageSize: pageSize,
423 HasMore: false,
424 }, nil
425 }
426
427 if end > totalCount {
428 end = totalCount
429 }
430
431 return &tm.TaskList{
432 Tasks: tasks[start:end],
433 TotalCount: totalCount,
434 Page: page,
435 PageSize: pageSize,
436 HasMore: end < totalCount,
437 }, nil
438}
439
440// filterTasks applies filters to a list of tasks
441func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
442 var filtered []*tm.Task
443
444 for _, task := range tasks {
445 if gtm.taskMatchesFilter(task, filter) {
446 filtered = append(filtered, task)
447 }
448 }
449
450 return filtered
451}
452
453// taskMatchesFilter checks if a task matches the given filter
454func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
455 if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
456 return false
457 }
458 if filter.Status != nil && task.Status != *filter.Status {
459 return false
460 }
461 if filter.Priority != nil && task.Priority != *filter.Priority {
462 return false
463 }
464 if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
465 return false
466 }
467 if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
468 return false
469 }
470 if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
471 return false
472 }
473 if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
474 return false
475 }
476
477 return true
478}
479
480// StartTask starts a task (changes status to in_progress)
481func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
482 status := tm.StatusInProgress
483 req := &tm.TaskUpdateRequest{
484 Status: &status,
485 }
486
487 return gtm.UpdateTask(ctx, id, req)
488}
489
490// CompleteTask completes a task (changes status to completed)
491func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
492 status := tm.StatusCompleted
493 req := &tm.TaskUpdateRequest{
494 Status: &status,
495 }
496
497 return gtm.UpdateTask(ctx, id, req)
498}
499
500// GetTasksByOwner gets tasks for a specific owner
501func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
502 filter := &tm.TaskFilter{
503 OwnerID: &ownerID,
504 }
505 return gtm.ListTasks(ctx, filter, page, pageSize)
506}
507
508// GetTasksByStatus gets tasks with a specific status
509func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
510 filter := &tm.TaskFilter{
511 Status: &status,
512 }
513 return gtm.ListTasks(ctx, filter, page, pageSize)
514}
515
516// GetTasksByPriority gets tasks with a specific priority
517func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
518 filter := &tm.TaskFilter{
519 Priority: &priority,
520 }
521 return gtm.ListTasks(ctx, filter, page, pageSize)
522}
523
524// Ensure GitTaskManager implements TaskManager interface
525var _ tm.TaskManager = (*GitTaskManager)(nil)