blob: 5c43828a445b1b9201144507e1fd6a70b8b053e4 [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,
iomodo97555d02025-07-27 15:07:14 +040032 tasksDir: repoPath,
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,
iomodo97555d02025-07-27 15:07:14 +040045 tasksDir: repoPath,
iomodo0c203b12025-07-26 19:44:57 +040046 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
iomodo97555d02025-07-27 15:07:14 +0400232func (gtm *GitTaskManager) commitTaskChange(taskID, operation string, owner tm.Owner) error {
iomodo3b89bdf2025-07-25 15:19:22 +0400233 ctx := context.Background()
234
235 // Add the task file
iomodo97555d02025-07-27 15:07:14 +0400236 if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, taskID+".md")}); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400237 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)
iomodo97555d02025-07-27 15:07:14 +0400242 if err := gtm.git.Commit(ctx, message, git.CommitOptions{
243 Author: &git.Author{
244 Name: owner.Name,
245 },
246 }); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400247 return fmt.Errorf("failed to commit task change: %w", err)
248 }
249
250 return nil
251}
252
253// CreateTask creates a new task
254func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
255 if err := gtm.ensureTasksDir(); err != nil {
256 return nil, err
257 }
258
259 // Validate request
260 if req.Title == "" {
261 return nil, tm.ErrInvalidTaskData
262 }
263 if req.OwnerID == "" {
264 return nil, tm.ErrInvalidOwner
265 }
266
267 // Generate task ID
268 taskID := gtm.generateTaskID()
269 now := time.Now()
270
271 // Create task
272 task := &tm.Task{
273 ID: taskID,
274 Title: req.Title,
275 Description: req.Description,
276 Owner: tm.Owner{
277 ID: req.OwnerID,
278 Name: req.OwnerID, // TODO: Look up owner name from a user service
279 },
280 Status: tm.StatusToDo,
281 Priority: req.Priority,
282 CreatedAt: now,
283 UpdatedAt: now,
284 DueDate: req.DueDate,
285 }
286
287 // Write task file
288 if err := gtm.writeTaskFile(task); err != nil {
289 return nil, err
290 }
291
292 // Commit to git
iomodo97555d02025-07-27 15:07:14 +0400293 if err := gtm.commitTaskChange(taskID, "created", task.Owner); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400294 return nil, err
295 }
296
297 return task, nil
298}
299
300// GetTask retrieves a task by ID
301func (gtm *GitTaskManager) GetTask(ctx context.Context, id string) (*tm.Task, error) {
302 return gtm.readTaskFile(id)
303}
304
305// UpdateTask updates an existing task
306func (gtm *GitTaskManager) UpdateTask(ctx context.Context, id string, req *tm.TaskUpdateRequest) (*tm.Task, error) {
307 // Read existing task
308 task, err := gtm.readTaskFile(id)
309 if err != nil {
310 return nil, err
311 }
312
313 // Update fields
314 updated := false
315 if req.Title != nil {
316 task.Title = *req.Title
317 updated = true
318 }
319 if req.Description != nil {
320 task.Description = *req.Description
321 updated = true
322 }
323 if req.OwnerID != nil {
324 task.Owner.ID = *req.OwnerID
325 task.Owner.Name = *req.OwnerID // TODO: Look up owner name from a user service
326 updated = true
327 }
328 if req.Status != nil {
329 task.Status = *req.Status
330 updated = true
331 }
332 if req.Priority != nil {
333 task.Priority = *req.Priority
334 updated = true
335 }
336 if req.DueDate != nil {
337 task.DueDate = req.DueDate
338 updated = true
339 }
340
341 if !updated {
342 return task, nil
343 }
344
345 // Update timestamps
346 task.UpdatedAt = time.Now()
347
348 // Handle status-specific timestamps
349 if req.Status != nil {
350 switch *req.Status {
351 case tm.StatusCompleted:
352 if task.CompletedAt == nil {
353 now := time.Now()
354 task.CompletedAt = &now
355 }
356 case tm.StatusArchived:
357 if task.ArchivedAt == nil {
358 now := time.Now()
359 task.ArchivedAt = &now
360 }
361 }
362 }
363
364 // Write updated task
365 if err := gtm.writeTaskFile(task); err != nil {
366 return nil, err
367 }
368
369 // Commit to git
iomodo97555d02025-07-27 15:07:14 +0400370 if err := gtm.commitTaskChange(id, "updated", task.Owner); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400371 return nil, err
372 }
373
374 return task, nil
375}
376
377// ArchiveTask archives a task
378func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
379 status := tm.StatusArchived
380 req := &tm.TaskUpdateRequest{
381 Status: &status,
382 }
383
384 _, err := gtm.UpdateTask(ctx, id, req)
385 return err
386}
387
388// ListTasks lists tasks with filtering and pagination
389func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
390 // Get all task files
391 taskFiles, err := gtm.listTaskFiles()
392 if err != nil {
393 return nil, err
394 }
395
396 // Read all tasks
397 var tasks []*tm.Task
398 for _, taskID := range taskFiles {
399 task, err := gtm.readTaskFile(taskID)
400 if err != nil {
401 continue // Skip corrupted files
402 }
403 tasks = append(tasks, task)
404 }
405
406 // Apply filters
407 if filter != nil {
408 tasks = gtm.filterTasks(tasks, filter)
409 }
410
411 // Sort by creation date (newest first)
412 sort.Slice(tasks, func(i, j int) bool {
413 return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
414 })
415
416 // Apply pagination
417 totalCount := len(tasks)
418 start := page * pageSize
419 end := start + pageSize
420
421 if start >= totalCount {
422 return &tm.TaskList{
423 Tasks: []*tm.Task{},
424 TotalCount: totalCount,
425 Page: page,
426 PageSize: pageSize,
427 HasMore: false,
428 }, nil
429 }
430
431 if end > totalCount {
432 end = totalCount
433 }
434
435 return &tm.TaskList{
436 Tasks: tasks[start:end],
437 TotalCount: totalCount,
438 Page: page,
439 PageSize: pageSize,
440 HasMore: end < totalCount,
441 }, nil
442}
443
444// filterTasks applies filters to a list of tasks
445func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
446 var filtered []*tm.Task
447
448 for _, task := range tasks {
449 if gtm.taskMatchesFilter(task, filter) {
450 filtered = append(filtered, task)
451 }
452 }
453
454 return filtered
455}
456
457// taskMatchesFilter checks if a task matches the given filter
458func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
459 if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
460 return false
461 }
462 if filter.Status != nil && task.Status != *filter.Status {
463 return false
464 }
465 if filter.Priority != nil && task.Priority != *filter.Priority {
466 return false
467 }
468 if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
469 return false
470 }
471 if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
472 return false
473 }
474 if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
475 return false
476 }
477 if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
478 return false
479 }
480
481 return true
482}
483
484// StartTask starts a task (changes status to in_progress)
485func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
486 status := tm.StatusInProgress
487 req := &tm.TaskUpdateRequest{
488 Status: &status,
489 }
490
491 return gtm.UpdateTask(ctx, id, req)
492}
493
494// CompleteTask completes a task (changes status to completed)
495func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
496 status := tm.StatusCompleted
497 req := &tm.TaskUpdateRequest{
498 Status: &status,
499 }
500
501 return gtm.UpdateTask(ctx, id, req)
502}
503
504// GetTasksByOwner gets tasks for a specific owner
505func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
506 filter := &tm.TaskFilter{
507 OwnerID: &ownerID,
508 }
509 return gtm.ListTasks(ctx, filter, page, pageSize)
510}
511
512// GetTasksByStatus gets tasks with a specific status
513func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
514 filter := &tm.TaskFilter{
515 Status: &status,
516 }
517 return gtm.ListTasks(ctx, filter, page, pageSize)
518}
519
520// GetTasksByPriority gets tasks with a specific priority
521func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
522 filter := &tm.TaskFilter{
523 Priority: &priority,
524 }
525 return gtm.ListTasks(ctx, filter, page, pageSize)
526}
527
528// Ensure GitTaskManager implements TaskManager interface
529var _ tm.TaskManager = (*GitTaskManager)(nil)