blob: b1aa39cc3b29dea4d14d930ee9eb32b137302761 [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,
user5a7d60d2025-07-27 21:22:04 +0400121 "assignee": task.Assignee,
iomodo3b89bdf2025-07-25 15:19:22 +0400122 "status": task.Status,
123 "priority": task.Priority,
124 "created_at": task.CreatedAt.Format(time.RFC3339),
125 "updated_at": task.UpdatedAt.Format(time.RFC3339),
126 }
127
128 if task.DueDate != nil {
129 frontmatter["due_date"] = task.DueDate.Format(time.RFC3339)
130 }
131 if task.CompletedAt != nil {
132 frontmatter["completed_at"] = task.CompletedAt.Format(time.RFC3339)
133 }
134 if task.ArchivedAt != nil {
135 frontmatter["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
136 }
137
138 // Marshal frontmatter to YAML
139 yamlData, err := yaml.Marshal(frontmatter)
140 if err != nil {
141 return "", fmt.Errorf("failed to marshal frontmatter: %w", err)
142 }
143
144 // Build markdown content
145 var content strings.Builder
iomodo6cf9dda2025-07-27 15:18:07 +0400146 content.WriteString(FrontmatterSeparator)
iomodo3b89bdf2025-07-25 15:19:22 +0400147 content.Write(yamlData)
iomodo6cf9dda2025-07-27 15:18:07 +0400148 content.WriteString(FrontmatterSeparator)
149 content.WriteString("\n")
iomodo3b89bdf2025-07-25 15:19:22 +0400150
151 if task.Description != "" {
152 content.WriteString("# Task Description\n\n")
153 content.WriteString(task.Description)
154 content.WriteString("\n\n")
155 }
156
157 return content.String(), nil
158}
159
160// parseTaskFromMarkdown parses a Task from markdown format
161func (gtm *GitTaskManager) parseTaskFromMarkdown(content string) (*tm.Task, error) {
162 // Split content into frontmatter and body
iomodo6cf9dda2025-07-27 15:18:07 +0400163 parts := strings.SplitN(content, FrontmatterSeparator, 3)
iomodo3b89bdf2025-07-25 15:19:22 +0400164 if len(parts) < 3 {
165 return nil, fmt.Errorf("invalid markdown format: missing frontmatter")
166 }
167
168 // Parse YAML frontmatter
169 var frontmatter map[string]interface{}
170 if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil {
171 return nil, fmt.Errorf("failed to parse frontmatter: %w", err)
172 }
173
174 // Extract task data
175 task := &tm.Task{}
176
177 if id, ok := frontmatter["id"].(string); ok {
178 task.ID = id
179 }
180 if title, ok := frontmatter["title"].(string); ok {
181 task.Title = title
182 }
183 if description, ok := frontmatter["description"].(string); ok {
184 task.Description = description
185 }
186 if ownerID, ok := frontmatter["owner_id"].(string); ok {
187 task.Owner.ID = ownerID
188 }
189 if ownerName, ok := frontmatter["owner_name"].(string); ok {
190 task.Owner.Name = ownerName
191 }
user5a7d60d2025-07-27 21:22:04 +0400192 if assignee, ok := frontmatter["assignee"].(string); ok {
193 task.Assignee = assignee
194 }
iomodo3b89bdf2025-07-25 15:19:22 +0400195 if status, ok := frontmatter["status"].(string); ok {
196 task.Status = tm.TaskStatus(status)
197 }
198 if priority, ok := frontmatter["priority"].(string); ok {
199 task.Priority = tm.TaskPriority(priority)
200 }
201
202 // Parse timestamps
203 if createdAt, ok := frontmatter["created_at"].(string); ok {
204 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
205 task.CreatedAt = t
206 }
207 }
208 if updatedAt, ok := frontmatter["updated_at"].(string); ok {
209 if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
210 task.UpdatedAt = t
211 }
212 }
213 if dueDate, ok := frontmatter["due_date"].(string); ok {
214 if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
215 task.DueDate = &t
216 }
217 }
218 if completedAt, ok := frontmatter["completed_at"].(string); ok {
219 if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
220 task.CompletedAt = &t
221 }
222 }
223 if archivedAt, ok := frontmatter["archived_at"].(string); ok {
224 if t, err := time.Parse(time.RFC3339, archivedAt); err == nil {
225 task.ArchivedAt = &t
226 }
227 }
228
229 return task, nil
230}
231
232// readTaskFile reads a task from a file
233func (gtm *GitTaskManager) readTaskFile(taskID string) (*tm.Task, error) {
iomodo6cf9dda2025-07-27 15:18:07 +0400234 filePath := filepath.Join(gtm.tasksDir, taskID+TaskFileExt)
iomodo3b89bdf2025-07-25 15:19:22 +0400235
236 content, err := os.ReadFile(filePath)
237 if err != nil {
238 if os.IsNotExist(err) {
239 return nil, tm.ErrTaskNotFound
240 }
241 return nil, fmt.Errorf("failed to read task file: %w", err)
242 }
243
244 return gtm.parseTaskFromMarkdown(string(content))
245}
246
247// writeTaskFile writes a task to a file
248func (gtm *GitTaskManager) writeTaskFile(task *tm.Task) error {
249 content, err := gtm.taskToMarkdown(task)
250 if err != nil {
251 return fmt.Errorf("failed to convert task to markdown: %w", err)
252 }
253
iomodo6cf9dda2025-07-27 15:18:07 +0400254 filePath := filepath.Join(gtm.tasksDir, task.ID+TaskFileExt)
255 if err := os.WriteFile(filePath, []byte(content), TaskFileMode); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400256 return fmt.Errorf("failed to write task file: %w", err)
257 }
258
259 return nil
260}
261
262// listTaskFiles returns all task file paths
263func (gtm *GitTaskManager) listTaskFiles() ([]string, error) {
264 entries, err := os.ReadDir(gtm.tasksDir)
265 if err != nil {
266 if os.IsNotExist(err) {
267 return []string{}, nil
268 }
269 return nil, fmt.Errorf("failed to read tasks directory: %w", err)
270 }
271
272 var taskFiles []string
273 for _, entry := range entries {
iomodo6cf9dda2025-07-27 15:18:07 +0400274 if !entry.IsDir() && strings.HasSuffix(entry.Name(), TaskFileExt) {
275 taskID := strings.TrimSuffix(entry.Name(), TaskFileExt)
iomodo3b89bdf2025-07-25 15:19:22 +0400276 taskFiles = append(taskFiles, taskID)
277 }
278 }
279
280 return taskFiles, nil
281}
282
283// commitTaskChange commits a task change to git
iomodo97555d02025-07-27 15:07:14 +0400284func (gtm *GitTaskManager) commitTaskChange(taskID, operation string, owner tm.Owner) error {
iomodo3b89bdf2025-07-25 15:19:22 +0400285 ctx := context.Background()
286
287 // Add the task file
iomodo6cf9dda2025-07-27 15:18:07 +0400288 if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, taskID+TaskFileExt)}); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400289 return fmt.Errorf("failed to add task file: %w", err)
290 }
291
292 // Commit the change
293 message := fmt.Sprintf("task: %s - %s", taskID, operation)
iomodo97555d02025-07-27 15:07:14 +0400294 if err := gtm.git.Commit(ctx, message, git.CommitOptions{
295 Author: &git.Author{
296 Name: owner.Name,
297 },
298 }); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400299 return fmt.Errorf("failed to commit task change: %w", err)
300 }
301
302 return nil
303}
304
305// CreateTask creates a new task
306func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
307 if err := gtm.ensureTasksDir(); err != nil {
308 return nil, err
309 }
310
311 // Validate request
312 if req.Title == "" {
313 return nil, tm.ErrInvalidTaskData
314 }
315 if req.OwnerID == "" {
316 return nil, tm.ErrInvalidOwner
317 }
318
319 // Generate task ID
320 taskID := gtm.generateTaskID()
321 now := time.Now()
322
iomodo6cf9dda2025-07-27 15:18:07 +0400323 // Get owner name from user service
324 ownerName, err := gtm.userService.GetUserName(req.OwnerID)
325 if err != nil {
326 gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", req.OwnerID), slog.String("error", err.Error()))
327 ownerName = req.OwnerID
328 }
329
iomodo3b89bdf2025-07-25 15:19:22 +0400330 // Create task
331 task := &tm.Task{
332 ID: taskID,
333 Title: req.Title,
334 Description: req.Description,
335 Owner: tm.Owner{
336 ID: req.OwnerID,
iomodo6cf9dda2025-07-27 15:18:07 +0400337 Name: ownerName,
iomodo3b89bdf2025-07-25 15:19:22 +0400338 },
339 Status: tm.StatusToDo,
340 Priority: req.Priority,
341 CreatedAt: now,
342 UpdatedAt: now,
343 DueDate: req.DueDate,
344 }
345
346 // Write task file
347 if err := gtm.writeTaskFile(task); err != nil {
348 return nil, err
349 }
350
351 // Commit to git
iomodo97555d02025-07-27 15:07:14 +0400352 if err := gtm.commitTaskChange(taskID, "created", task.Owner); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400353 return nil, err
354 }
355
356 return task, nil
357}
358
359// GetTask retrieves a task by ID
user5a7d60d2025-07-27 21:22:04 +0400360func (gtm *GitTaskManager) GetTask(id string) (*tm.Task, error) {
iomodo3b89bdf2025-07-25 15:19:22 +0400361 return gtm.readTaskFile(id)
362}
363
364// UpdateTask updates an existing task
user5a7d60d2025-07-27 21:22:04 +0400365func (gtm *GitTaskManager) UpdateTask(task *tm.Task) error {
366 // Set update time
367 task.UpdatedAt = time.Now()
368
369 // Write task to file
370 return gtm.writeTaskFile(task)
371}
372
373// readAllTasks reads all task files from disk
374func (gtm *GitTaskManager) readAllTasks() ([]*tm.Task, error) {
375 taskFiles, err := gtm.listTaskFiles()
iomodo3b89bdf2025-07-25 15:19:22 +0400376 if err != nil {
377 return nil, err
378 }
user5a7d60d2025-07-27 21:22:04 +0400379
380 var tasks []*tm.Task
381 for _, taskFile := range taskFiles {
382 // Extract task ID from filename (task-{id}.md)
383 filename := filepath.Base(taskFile)
384 if strings.HasPrefix(filename, "task-") && strings.HasSuffix(filename, ".md") {
385 taskID := strings.TrimSuffix(strings.TrimPrefix(filename, "task-"), ".md")
386 task, err := gtm.readTaskFile(taskID)
387 if err != nil {
388 gtm.logger.Warn("Failed to read task file", slog.String("file", taskFile), slog.String("error", err.Error()))
389 continue
iomodo3b89bdf2025-07-25 15:19:22 +0400390 }
user5a7d60d2025-07-27 21:22:04 +0400391 tasks = append(tasks, task)
iomodo3b89bdf2025-07-25 15:19:22 +0400392 }
393 }
user5a7d60d2025-07-27 21:22:04 +0400394
395 return tasks, nil
396}
iomodo3b89bdf2025-07-25 15:19:22 +0400397
user5a7d60d2025-07-27 21:22:04 +0400398// GetTasksByAssignee retrieves tasks assigned to a specific agent (MVP method)
399func (gtm *GitTaskManager) GetTasksByAssignee(assignee string) ([]*tm.Task, error) {
400 // Read all tasks and filter by assignee
401 tasks, err := gtm.readAllTasks()
402 if err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400403 return nil, err
404 }
user5a7d60d2025-07-27 21:22:04 +0400405
406 var assignedTasks []*tm.Task
407 for _, task := range tasks {
408 if task.Assignee == assignee {
409 assignedTasks = append(assignedTasks, task)
410 }
iomodo3b89bdf2025-07-25 15:19:22 +0400411 }
user5a7d60d2025-07-27 21:22:04 +0400412
413 return assignedTasks, nil
iomodo3b89bdf2025-07-25 15:19:22 +0400414}
415
416// ArchiveTask archives a task
417func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
user5a7d60d2025-07-27 21:22:04 +0400418 task, err := gtm.GetTask(id)
419 if err != nil {
420 return err
iomodo3b89bdf2025-07-25 15:19:22 +0400421 }
user5a7d60d2025-07-27 21:22:04 +0400422
423 task.Status = tm.StatusArchived
424 now := time.Now()
425 task.ArchivedAt = &now
426
427 return gtm.UpdateTask(task)
iomodo3b89bdf2025-07-25 15:19:22 +0400428}
429
430// ListTasks lists tasks with filtering and pagination
431func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
432 // Get all task files
433 taskFiles, err := gtm.listTaskFiles()
434 if err != nil {
435 return nil, err
436 }
437
438 // Read all tasks
439 var tasks []*tm.Task
440 for _, taskID := range taskFiles {
441 task, err := gtm.readTaskFile(taskID)
442 if err != nil {
443 continue // Skip corrupted files
444 }
445 tasks = append(tasks, task)
446 }
447
448 // Apply filters
449 if filter != nil {
450 tasks = gtm.filterTasks(tasks, filter)
451 }
452
453 // Sort by creation date (newest first)
454 sort.Slice(tasks, func(i, j int) bool {
455 return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
456 })
457
458 // Apply pagination
459 totalCount := len(tasks)
460 start := page * pageSize
461 end := start + pageSize
462
463 if start >= totalCount {
464 return &tm.TaskList{
465 Tasks: []*tm.Task{},
466 TotalCount: totalCount,
467 Page: page,
468 PageSize: pageSize,
469 HasMore: false,
470 }, nil
471 }
472
473 if end > totalCount {
474 end = totalCount
475 }
476
477 return &tm.TaskList{
478 Tasks: tasks[start:end],
479 TotalCount: totalCount,
480 Page: page,
481 PageSize: pageSize,
482 HasMore: end < totalCount,
483 }, nil
484}
485
486// filterTasks applies filters to a list of tasks
487func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
488 var filtered []*tm.Task
489
490 for _, task := range tasks {
491 if gtm.taskMatchesFilter(task, filter) {
492 filtered = append(filtered, task)
493 }
494 }
495
496 return filtered
497}
498
499// taskMatchesFilter checks if a task matches the given filter
500func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
501 if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
502 return false
503 }
504 if filter.Status != nil && task.Status != *filter.Status {
505 return false
506 }
507 if filter.Priority != nil && task.Priority != *filter.Priority {
508 return false
509 }
510 if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
511 return false
512 }
513 if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
514 return false
515 }
516 if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
517 return false
518 }
519 if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
520 return false
521 }
522
523 return true
524}
525
526// StartTask starts a task (changes status to in_progress)
527func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
user5a7d60d2025-07-27 21:22:04 +0400528 task, err := gtm.GetTask(id)
529 if err != nil {
530 return nil, err
iomodo3b89bdf2025-07-25 15:19:22 +0400531 }
user5a7d60d2025-07-27 21:22:04 +0400532
533 task.Status = tm.StatusInProgress
534
535 err = gtm.UpdateTask(task)
536 if err != nil {
537 return nil, err
538 }
539
540 return task, nil
iomodo3b89bdf2025-07-25 15:19:22 +0400541}
542
543// CompleteTask completes a task (changes status to completed)
544func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
user5a7d60d2025-07-27 21:22:04 +0400545 task, err := gtm.GetTask(id)
546 if err != nil {
547 return nil, err
iomodo3b89bdf2025-07-25 15:19:22 +0400548 }
user5a7d60d2025-07-27 21:22:04 +0400549
550 task.Status = tm.StatusCompleted
551 now := time.Now()
552 task.CompletedAt = &now
553
554 err = gtm.UpdateTask(task)
555 if err != nil {
556 return nil, err
557 }
558
559 return task, nil
iomodo3b89bdf2025-07-25 15:19:22 +0400560}
561
562// GetTasksByOwner gets tasks for a specific owner
563func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
564 filter := &tm.TaskFilter{
565 OwnerID: &ownerID,
566 }
567 return gtm.ListTasks(ctx, filter, page, pageSize)
568}
569
570// GetTasksByStatus gets tasks with a specific status
571func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
572 filter := &tm.TaskFilter{
573 Status: &status,
574 }
575 return gtm.ListTasks(ctx, filter, page, pageSize)
576}
577
578// GetTasksByPriority gets tasks with a specific priority
579func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
580 filter := &tm.TaskFilter{
581 Priority: &priority,
582 }
583 return gtm.ListTasks(ctx, filter, page, pageSize)
584}
585
586// Ensure GitTaskManager implements TaskManager interface
587var _ tm.TaskManager = (*GitTaskManager)(nil)