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