blob: edfb4762c1899e23c46e705ce4a4c4e8888e8b29 [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
iomodo6cf9dda2025-07-27 15:18:07 +040019const (
20 // File system constants
21 DefaultFileMode = 0755
22 TaskFileMode = 0644
23 TaskFileExt = ".md"
24
25 // Frontmatter constants
26 FrontmatterSeparator = "---\n"
27
28 // Task ID format
29 TaskIDPrefix = "task-"
30)
31
32// UserService defines interface for user-related operations
33type UserService interface {
34 GetUserName(userID string) (string, error)
35}
36
37// DefaultUserService provides a simple implementation that uses userID as name
38type DefaultUserService struct{}
39
40func (dus *DefaultUserService) GetUserName(userID string) (string, error) {
41 // For now, just return the userID as the name
42 // This can be enhanced to lookup from a proper user service
43 return userID, nil
44}
45
iomodo3b89bdf2025-07-25 15:19:22 +040046// GitTaskManager implements TaskManager interface using git as the source of truth
47type GitTaskManager struct {
iomodo6cf9dda2025-07-27 15:18:07 +040048 git git.GitInterface
49 repoPath string
50 tasksDir string
51 logger *slog.Logger
52 userService UserService
iomodo3b89bdf2025-07-25 15:19:22 +040053}
54
55// NewGitTaskManager creates a new GitTaskManager instance
iomodo0c203b12025-07-26 19:44:57 +040056func NewGitTaskManager(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
iomodo3b89bdf2025-07-25 15:19:22 +040057 return &GitTaskManager{
iomodo6cf9dda2025-07-27 15:18:07 +040058 git: git,
59 repoPath: repoPath,
60 tasksDir: filepath.Join(repoPath, "tasks"),
61 logger: logger,
62 userService: &DefaultUserService{},
iomodo0c203b12025-07-26 19:44:57 +040063 }
64}
65
66// NewGitTaskManagerWithLogger creates a new GitTaskManager instance with a custom logger
67func NewGitTaskManagerWithLogger(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
68 if logger == nil {
69 logger = slog.Default()
70 }
71 return &GitTaskManager{
iomodo6cf9dda2025-07-27 15:18:07 +040072 git: git,
73 repoPath: repoPath,
74 tasksDir: filepath.Join(repoPath, "tasks"),
75 logger: logger,
76 userService: &DefaultUserService{},
77 }
78}
79
80// NewGitTaskManagerWithUserService creates a new GitTaskManager with custom user service
81func NewGitTaskManagerWithUserService(git git.GitInterface, repoPath string, logger *slog.Logger, userService UserService) *GitTaskManager {
82 if logger == nil {
83 logger = slog.Default()
84 }
85 if userService == nil {
86 userService = &DefaultUserService{}
87 }
88 return &GitTaskManager{
89 git: git,
90 repoPath: repoPath,
91 tasksDir: filepath.Join(repoPath, "tasks"),
92 logger: logger,
93 userService: userService,
iomodo3b89bdf2025-07-25 15:19:22 +040094 }
95}
96
97// ensureTasksDir ensures the tasks directory exists
98func (gtm *GitTaskManager) ensureTasksDir() error {
iomodo6cf9dda2025-07-27 15:18:07 +040099 if err := os.MkdirAll(gtm.tasksDir, DefaultFileMode); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400100 return fmt.Errorf("failed to create tasks directory: %w", err)
101 }
102 return nil
103}
104
105// generateTaskID generates a unique task ID
106func (gtm *GitTaskManager) generateTaskID() string {
107 timestamp := time.Now().Unix()
108 random := uuid.New().String()[:8]
iomodo6cf9dda2025-07-27 15:18:07 +0400109 return fmt.Sprintf("%s%d-%s", TaskIDPrefix, timestamp, random)
iomodo3b89bdf2025-07-25 15:19:22 +0400110}
111
112// taskToMarkdown converts a Task to markdown format
113func (gtm *GitTaskManager) taskToMarkdown(task *tm.Task) (string, error) {
114 // Create frontmatter data
115 frontmatter := map[string]interface{}{
116 "id": task.ID,
117 "title": task.Title,
118 "description": task.Description,
119 "owner_id": task.Owner.ID,
120 "owner_name": task.Owner.Name,
121 "status": task.Status,
122 "priority": task.Priority,
123 "created_at": task.CreatedAt.Format(time.RFC3339),
124 "updated_at": task.UpdatedAt.Format(time.RFC3339),
125 }
126
127 if task.DueDate != nil {
128 frontmatter["due_date"] = task.DueDate.Format(time.RFC3339)
129 }
130 if task.CompletedAt != nil {
131 frontmatter["completed_at"] = task.CompletedAt.Format(time.RFC3339)
132 }
133 if task.ArchivedAt != nil {
134 frontmatter["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
135 }
136
137 // Marshal frontmatter to YAML
138 yamlData, err := yaml.Marshal(frontmatter)
139 if err != nil {
140 return "", fmt.Errorf("failed to marshal frontmatter: %w", err)
141 }
142
143 // Build markdown content
144 var content strings.Builder
iomodo6cf9dda2025-07-27 15:18:07 +0400145 content.WriteString(FrontmatterSeparator)
iomodo3b89bdf2025-07-25 15:19:22 +0400146 content.Write(yamlData)
iomodo6cf9dda2025-07-27 15:18:07 +0400147 content.WriteString(FrontmatterSeparator)
148 content.WriteString("\n")
iomodo3b89bdf2025-07-25 15:19:22 +0400149
150 if task.Description != "" {
151 content.WriteString("# Task Description\n\n")
152 content.WriteString(task.Description)
153 content.WriteString("\n\n")
154 }
155
156 return content.String(), nil
157}
158
159// parseTaskFromMarkdown parses a Task from markdown format
160func (gtm *GitTaskManager) parseTaskFromMarkdown(content string) (*tm.Task, error) {
161 // Split content into frontmatter and body
iomodo6cf9dda2025-07-27 15:18:07 +0400162 parts := strings.SplitN(content, FrontmatterSeparator, 3)
iomodo3b89bdf2025-07-25 15:19:22 +0400163 if len(parts) < 3 {
164 return nil, fmt.Errorf("invalid markdown format: missing frontmatter")
165 }
166
167 // Parse YAML frontmatter
168 var frontmatter map[string]interface{}
169 if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil {
170 return nil, fmt.Errorf("failed to parse frontmatter: %w", err)
171 }
172
173 // Extract task data
174 task := &tm.Task{}
175
176 if id, ok := frontmatter["id"].(string); ok {
177 task.ID = id
178 }
179 if title, ok := frontmatter["title"].(string); ok {
180 task.Title = title
181 }
182 if description, ok := frontmatter["description"].(string); ok {
183 task.Description = description
184 }
185 if ownerID, ok := frontmatter["owner_id"].(string); ok {
186 task.Owner.ID = ownerID
187 }
188 if ownerName, ok := frontmatter["owner_name"].(string); ok {
189 task.Owner.Name = ownerName
190 }
191 if status, ok := frontmatter["status"].(string); ok {
192 task.Status = tm.TaskStatus(status)
193 }
194 if priority, ok := frontmatter["priority"].(string); ok {
195 task.Priority = tm.TaskPriority(priority)
196 }
197
198 // Parse timestamps
199 if createdAt, ok := frontmatter["created_at"].(string); ok {
200 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
201 task.CreatedAt = t
202 }
203 }
204 if updatedAt, ok := frontmatter["updated_at"].(string); ok {
205 if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
206 task.UpdatedAt = t
207 }
208 }
209 if dueDate, ok := frontmatter["due_date"].(string); ok {
210 if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
211 task.DueDate = &t
212 }
213 }
214 if completedAt, ok := frontmatter["completed_at"].(string); ok {
215 if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
216 task.CompletedAt = &t
217 }
218 }
219 if archivedAt, ok := frontmatter["archived_at"].(string); ok {
220 if t, err := time.Parse(time.RFC3339, archivedAt); err == nil {
221 task.ArchivedAt = &t
222 }
223 }
224
225 return task, nil
226}
227
228// readTaskFile reads a task from a file
229func (gtm *GitTaskManager) readTaskFile(taskID string) (*tm.Task, error) {
iomodo6cf9dda2025-07-27 15:18:07 +0400230 filePath := filepath.Join(gtm.tasksDir, taskID+TaskFileExt)
iomodo3b89bdf2025-07-25 15:19:22 +0400231
232 content, err := os.ReadFile(filePath)
233 if err != nil {
234 if os.IsNotExist(err) {
235 return nil, tm.ErrTaskNotFound
236 }
237 return nil, fmt.Errorf("failed to read task file: %w", err)
238 }
239
240 return gtm.parseTaskFromMarkdown(string(content))
241}
242
243// writeTaskFile writes a task to a file
244func (gtm *GitTaskManager) writeTaskFile(task *tm.Task) error {
245 content, err := gtm.taskToMarkdown(task)
246 if err != nil {
247 return fmt.Errorf("failed to convert task to markdown: %w", err)
248 }
249
iomodo6cf9dda2025-07-27 15:18:07 +0400250 filePath := filepath.Join(gtm.tasksDir, task.ID+TaskFileExt)
251 if err := os.WriteFile(filePath, []byte(content), TaskFileMode); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400252 return fmt.Errorf("failed to write task file: %w", err)
253 }
254
255 return nil
256}
257
258// listTaskFiles returns all task file paths
259func (gtm *GitTaskManager) listTaskFiles() ([]string, error) {
260 entries, err := os.ReadDir(gtm.tasksDir)
261 if err != nil {
262 if os.IsNotExist(err) {
263 return []string{}, nil
264 }
265 return nil, fmt.Errorf("failed to read tasks directory: %w", err)
266 }
267
268 var taskFiles []string
269 for _, entry := range entries {
iomodo6cf9dda2025-07-27 15:18:07 +0400270 if !entry.IsDir() && strings.HasSuffix(entry.Name(), TaskFileExt) {
271 taskID := strings.TrimSuffix(entry.Name(), TaskFileExt)
iomodo3b89bdf2025-07-25 15:19:22 +0400272 taskFiles = append(taskFiles, taskID)
273 }
274 }
275
276 return taskFiles, nil
277}
278
279// commitTaskChange commits a task change to git
iomodo97555d02025-07-27 15:07:14 +0400280func (gtm *GitTaskManager) commitTaskChange(taskID, operation string, owner tm.Owner) error {
iomodo3b89bdf2025-07-25 15:19:22 +0400281 ctx := context.Background()
282
283 // Add the task file
iomodo6cf9dda2025-07-27 15:18:07 +0400284 if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, taskID+TaskFileExt)}); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400285 return fmt.Errorf("failed to add task file: %w", err)
286 }
287
288 // Commit the change
289 message := fmt.Sprintf("task: %s - %s", taskID, operation)
iomodo97555d02025-07-27 15:07:14 +0400290 if err := gtm.git.Commit(ctx, message, git.CommitOptions{
291 Author: &git.Author{
292 Name: owner.Name,
293 },
294 }); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400295 return fmt.Errorf("failed to commit task change: %w", err)
296 }
297
298 return nil
299}
300
301// CreateTask creates a new task
302func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
303 if err := gtm.ensureTasksDir(); err != nil {
304 return nil, err
305 }
306
307 // Validate request
308 if req.Title == "" {
309 return nil, tm.ErrInvalidTaskData
310 }
311 if req.OwnerID == "" {
312 return nil, tm.ErrInvalidOwner
313 }
314
315 // Generate task ID
316 taskID := gtm.generateTaskID()
317 now := time.Now()
318
iomodo6cf9dda2025-07-27 15:18:07 +0400319 // Get owner name from user service
320 ownerName, err := gtm.userService.GetUserName(req.OwnerID)
321 if err != nil {
322 gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", req.OwnerID), slog.String("error", err.Error()))
323 ownerName = req.OwnerID
324 }
325
iomodo3b89bdf2025-07-25 15:19:22 +0400326 // Create task
327 task := &tm.Task{
328 ID: taskID,
329 Title: req.Title,
330 Description: req.Description,
331 Owner: tm.Owner{
332 ID: req.OwnerID,
iomodo6cf9dda2025-07-27 15:18:07 +0400333 Name: ownerName,
iomodo3b89bdf2025-07-25 15:19:22 +0400334 },
335 Status: tm.StatusToDo,
336 Priority: req.Priority,
337 CreatedAt: now,
338 UpdatedAt: now,
339 DueDate: req.DueDate,
340 }
341
342 // Write task file
343 if err := gtm.writeTaskFile(task); err != nil {
344 return nil, err
345 }
346
347 // Commit to git
iomodo97555d02025-07-27 15:07:14 +0400348 if err := gtm.commitTaskChange(taskID, "created", task.Owner); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400349 return nil, err
350 }
351
352 return task, nil
353}
354
355// GetTask retrieves a task by ID
356func (gtm *GitTaskManager) GetTask(ctx context.Context, id string) (*tm.Task, error) {
357 return gtm.readTaskFile(id)
358}
359
360// UpdateTask updates an existing task
361func (gtm *GitTaskManager) UpdateTask(ctx context.Context, id string, req *tm.TaskUpdateRequest) (*tm.Task, error) {
362 // Read existing task
363 task, err := gtm.readTaskFile(id)
364 if err != nil {
365 return nil, err
366 }
367
368 // Update fields
369 updated := false
370 if req.Title != nil {
371 task.Title = *req.Title
372 updated = true
373 }
374 if req.Description != nil {
375 task.Description = *req.Description
376 updated = true
377 }
378 if req.OwnerID != nil {
379 task.Owner.ID = *req.OwnerID
iomodo6cf9dda2025-07-27 15:18:07 +0400380 // Get owner name from user service
381 if ownerName, err := gtm.userService.GetUserName(*req.OwnerID); err == nil {
382 task.Owner.Name = ownerName
383 } else {
384 gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", *req.OwnerID), slog.String("error", err.Error()))
385 task.Owner.Name = *req.OwnerID
386 }
iomodo3b89bdf2025-07-25 15:19:22 +0400387 updated = true
388 }
389 if req.Status != nil {
390 task.Status = *req.Status
391 updated = true
392 }
393 if req.Priority != nil {
394 task.Priority = *req.Priority
395 updated = true
396 }
397 if req.DueDate != nil {
398 task.DueDate = req.DueDate
399 updated = true
400 }
401
402 if !updated {
403 return task, nil
404 }
405
406 // Update timestamps
407 task.UpdatedAt = time.Now()
408
409 // Handle status-specific timestamps
410 if req.Status != nil {
411 switch *req.Status {
412 case tm.StatusCompleted:
413 if task.CompletedAt == nil {
414 now := time.Now()
415 task.CompletedAt = &now
416 }
417 case tm.StatusArchived:
418 if task.ArchivedAt == nil {
419 now := time.Now()
420 task.ArchivedAt = &now
421 }
422 }
423 }
424
425 // Write updated task
426 if err := gtm.writeTaskFile(task); err != nil {
427 return nil, err
428 }
429
430 // Commit to git
iomodo97555d02025-07-27 15:07:14 +0400431 if err := gtm.commitTaskChange(id, "updated", task.Owner); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400432 return nil, err
433 }
434
435 return task, nil
436}
437
438// ArchiveTask archives a task
439func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
440 status := tm.StatusArchived
441 req := &tm.TaskUpdateRequest{
442 Status: &status,
443 }
444
445 _, err := gtm.UpdateTask(ctx, id, req)
446 return err
447}
448
449// ListTasks lists tasks with filtering and pagination
450func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
451 // Get all task files
452 taskFiles, err := gtm.listTaskFiles()
453 if err != nil {
454 return nil, err
455 }
456
457 // Read all tasks
458 var tasks []*tm.Task
459 for _, taskID := range taskFiles {
460 task, err := gtm.readTaskFile(taskID)
461 if err != nil {
462 continue // Skip corrupted files
463 }
464 tasks = append(tasks, task)
465 }
466
467 // Apply filters
468 if filter != nil {
469 tasks = gtm.filterTasks(tasks, filter)
470 }
471
472 // Sort by creation date (newest first)
473 sort.Slice(tasks, func(i, j int) bool {
474 return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
475 })
476
477 // Apply pagination
478 totalCount := len(tasks)
479 start := page * pageSize
480 end := start + pageSize
481
482 if start >= totalCount {
483 return &tm.TaskList{
484 Tasks: []*tm.Task{},
485 TotalCount: totalCount,
486 Page: page,
487 PageSize: pageSize,
488 HasMore: false,
489 }, nil
490 }
491
492 if end > totalCount {
493 end = totalCount
494 }
495
496 return &tm.TaskList{
497 Tasks: tasks[start:end],
498 TotalCount: totalCount,
499 Page: page,
500 PageSize: pageSize,
501 HasMore: end < totalCount,
502 }, nil
503}
504
505// filterTasks applies filters to a list of tasks
506func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
507 var filtered []*tm.Task
508
509 for _, task := range tasks {
510 if gtm.taskMatchesFilter(task, filter) {
511 filtered = append(filtered, task)
512 }
513 }
514
515 return filtered
516}
517
518// taskMatchesFilter checks if a task matches the given filter
519func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
520 if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
521 return false
522 }
523 if filter.Status != nil && task.Status != *filter.Status {
524 return false
525 }
526 if filter.Priority != nil && task.Priority != *filter.Priority {
527 return false
528 }
529 if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
530 return false
531 }
532 if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
533 return false
534 }
535 if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
536 return false
537 }
538 if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
539 return false
540 }
541
542 return true
543}
544
545// StartTask starts a task (changes status to in_progress)
546func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
547 status := tm.StatusInProgress
548 req := &tm.TaskUpdateRequest{
549 Status: &status,
550 }
551
552 return gtm.UpdateTask(ctx, id, req)
553}
554
555// CompleteTask completes a task (changes status to completed)
556func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
557 status := tm.StatusCompleted
558 req := &tm.TaskUpdateRequest{
559 Status: &status,
560 }
561
562 return gtm.UpdateTask(ctx, id, req)
563}
564
565// GetTasksByOwner gets tasks for a specific owner
566func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
567 filter := &tm.TaskFilter{
568 OwnerID: &ownerID,
569 }
570 return gtm.ListTasks(ctx, filter, page, pageSize)
571}
572
573// GetTasksByStatus gets tasks with a specific status
574func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
575 filter := &tm.TaskFilter{
576 Status: &status,
577 }
578 return gtm.ListTasks(ctx, filter, page, pageSize)
579}
580
581// GetTasksByPriority gets tasks with a specific priority
582func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
583 filter := &tm.TaskFilter{
584 Priority: &priority,
585 }
586 return gtm.ListTasks(ctx, filter, page, pageSize)
587}
588
589// Ensure GitTaskManager implements TaskManager interface
590var _ tm.TaskManager = (*GitTaskManager)(nil)