blob: a49a86682f837029fc056542755ccd902cb691ed [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"
iomodoa53240a2025-07-30 17:33:35 +04008 "os/exec"
iomodo3b89bdf2025-07-25 15:19:22 +04009 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "github.com/google/uuid"
iomodoa53240a2025-07-30 17:33:35 +040015 "github.com/iomodo/staff/config"
iomodo3b89bdf2025-07-25 15:19:22 +040016 "github.com/iomodo/staff/git"
17 "github.com/iomodo/staff/tm"
18 "gopkg.in/yaml.v3"
19)
20
iomodo6cf9dda2025-07-27 15:18:07 +040021const (
22 // File system constants
23 DefaultFileMode = 0755
24 TaskFileMode = 0644
25 TaskFileExt = ".md"
iomodo50598c62025-07-27 22:06:32 +040026
iomodo6cf9dda2025-07-27 15:18:07 +040027 // Frontmatter constants
28 FrontmatterSeparator = "---\n"
iomodo50598c62025-07-27 22:06:32 +040029
iomodo6cf9dda2025-07-27 15:18:07 +040030 // Task ID format
31 TaskIDPrefix = "task-"
32)
33
iomodo3b89bdf2025-07-25 15:19:22 +040034// GitTaskManager implements TaskManager interface using git as the source of truth
35type GitTaskManager struct {
iomodoa53240a2025-07-30 17:33:35 +040036 git git.GitInterface
37 repoPath string
38 tasksDir string
39 config *config.Config
40 logger *slog.Logger
iomodo3b89bdf2025-07-25 15:19:22 +040041}
42
43// NewGitTaskManager creates a new GitTaskManager instance
iomodoa53240a2025-07-30 17:33:35 +040044func NewGitTaskManager(gitInter git.GitInterface, cfg *config.Config, logger *slog.Logger) *GitTaskManager {
iomodo3b89bdf2025-07-25 15:19:22 +040045 return &GitTaskManager{
iomodoa53240a2025-07-30 17:33:35 +040046 git: gitInter,
47 repoPath: cfg.Tasks.StoragePath,
48 tasksDir: filepath.Join(cfg.Tasks.StoragePath, "tasks"),
49 config: cfg,
50 logger: logger,
iomodo3b89bdf2025-07-25 15:19:22 +040051 }
52}
53
54// ensureTasksDir ensures the tasks directory exists
55func (gtm *GitTaskManager) ensureTasksDir() error {
iomodo6cf9dda2025-07-27 15:18:07 +040056 if err := os.MkdirAll(gtm.tasksDir, DefaultFileMode); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +040057 return fmt.Errorf("failed to create tasks directory: %w", err)
58 }
59 return nil
60}
61
62// generateTaskID generates a unique task ID
63func (gtm *GitTaskManager) generateTaskID() string {
64 timestamp := time.Now().Unix()
65 random := uuid.New().String()[:8]
iomodo6cf9dda2025-07-27 15:18:07 +040066 return fmt.Sprintf("%s%d-%s", TaskIDPrefix, timestamp, random)
iomodo3b89bdf2025-07-25 15:19:22 +040067}
68
69// taskToMarkdown converts a Task to markdown format
70func (gtm *GitTaskManager) taskToMarkdown(task *tm.Task) (string, error) {
71 // Create frontmatter data
72 frontmatter := map[string]interface{}{
73 "id": task.ID,
74 "title": task.Title,
75 "description": task.Description,
76 "owner_id": task.Owner.ID,
77 "owner_name": task.Owner.Name,
user5a7d60d2025-07-27 21:22:04 +040078 "assignee": task.Assignee,
iomodo3b89bdf2025-07-25 15:19:22 +040079 "status": task.Status,
80 "priority": task.Priority,
81 "created_at": task.CreatedAt.Format(time.RFC3339),
82 "updated_at": task.UpdatedAt.Format(time.RFC3339),
83 }
84
85 if task.DueDate != nil {
86 frontmatter["due_date"] = task.DueDate.Format(time.RFC3339)
87 }
88 if task.CompletedAt != nil {
89 frontmatter["completed_at"] = task.CompletedAt.Format(time.RFC3339)
90 }
91 if task.ArchivedAt != nil {
92 frontmatter["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
93 }
94
95 // Marshal frontmatter to YAML
96 yamlData, err := yaml.Marshal(frontmatter)
97 if err != nil {
98 return "", fmt.Errorf("failed to marshal frontmatter: %w", err)
99 }
100
101 // Build markdown content
102 var content strings.Builder
iomodo6cf9dda2025-07-27 15:18:07 +0400103 content.WriteString(FrontmatterSeparator)
iomodo3b89bdf2025-07-25 15:19:22 +0400104 content.Write(yamlData)
iomodo6cf9dda2025-07-27 15:18:07 +0400105 content.WriteString(FrontmatterSeparator)
106 content.WriteString("\n")
iomodo3b89bdf2025-07-25 15:19:22 +0400107
108 if task.Description != "" {
109 content.WriteString("# Task Description\n\n")
110 content.WriteString(task.Description)
111 content.WriteString("\n\n")
112 }
113
114 return content.String(), nil
115}
116
117// parseTaskFromMarkdown parses a Task from markdown format
118func (gtm *GitTaskManager) parseTaskFromMarkdown(content string) (*tm.Task, error) {
119 // Split content into frontmatter and body
iomodo6cf9dda2025-07-27 15:18:07 +0400120 parts := strings.SplitN(content, FrontmatterSeparator, 3)
iomodo3b89bdf2025-07-25 15:19:22 +0400121 if len(parts) < 3 {
122 return nil, fmt.Errorf("invalid markdown format: missing frontmatter")
123 }
124
125 // Parse YAML frontmatter
126 var frontmatter map[string]interface{}
127 if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil {
128 return nil, fmt.Errorf("failed to parse frontmatter: %w", err)
129 }
130
131 // Extract task data
132 task := &tm.Task{}
133
134 if id, ok := frontmatter["id"].(string); ok {
135 task.ID = id
136 }
137 if title, ok := frontmatter["title"].(string); ok {
138 task.Title = title
139 }
140 if description, ok := frontmatter["description"].(string); ok {
141 task.Description = description
142 }
143 if ownerID, ok := frontmatter["owner_id"].(string); ok {
144 task.Owner.ID = ownerID
145 }
146 if ownerName, ok := frontmatter["owner_name"].(string); ok {
147 task.Owner.Name = ownerName
148 }
user5a7d60d2025-07-27 21:22:04 +0400149 if assignee, ok := frontmatter["assignee"].(string); ok {
150 task.Assignee = assignee
151 }
iomodo3b89bdf2025-07-25 15:19:22 +0400152 if status, ok := frontmatter["status"].(string); ok {
153 task.Status = tm.TaskStatus(status)
154 }
155 if priority, ok := frontmatter["priority"].(string); ok {
156 task.Priority = tm.TaskPriority(priority)
157 }
158
159 // Parse timestamps
160 if createdAt, ok := frontmatter["created_at"].(string); ok {
161 if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
162 task.CreatedAt = t
163 }
164 }
165 if updatedAt, ok := frontmatter["updated_at"].(string); ok {
166 if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
167 task.UpdatedAt = t
168 }
169 }
170 if dueDate, ok := frontmatter["due_date"].(string); ok {
171 if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
172 task.DueDate = &t
173 }
174 }
175 if completedAt, ok := frontmatter["completed_at"].(string); ok {
176 if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
177 task.CompletedAt = &t
178 }
179 }
180 if archivedAt, ok := frontmatter["archived_at"].(string); ok {
181 if t, err := time.Parse(time.RFC3339, archivedAt); err == nil {
182 task.ArchivedAt = &t
183 }
184 }
185
186 return task, nil
187}
188
189// readTaskFile reads a task from a file
iomodo50598c62025-07-27 22:06:32 +0400190func (gtm *GitTaskManager) readTaskFile(taskFile string) (*tm.Task, error) {
191 filePath := filepath.Join(gtm.tasksDir, taskFile+TaskFileExt)
iomodo3b89bdf2025-07-25 15:19:22 +0400192
193 content, err := os.ReadFile(filePath)
194 if err != nil {
195 if os.IsNotExist(err) {
196 return nil, tm.ErrTaskNotFound
197 }
198 return nil, fmt.Errorf("failed to read task file: %w", err)
199 }
200
201 return gtm.parseTaskFromMarkdown(string(content))
202}
203
204// writeTaskFile writes a task to a file
205func (gtm *GitTaskManager) writeTaskFile(task *tm.Task) error {
206 content, err := gtm.taskToMarkdown(task)
207 if err != nil {
208 return fmt.Errorf("failed to convert task to markdown: %w", err)
209 }
210
iomodo6cf9dda2025-07-27 15:18:07 +0400211 filePath := filepath.Join(gtm.tasksDir, task.ID+TaskFileExt)
212 if err := os.WriteFile(filePath, []byte(content), TaskFileMode); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400213 return fmt.Errorf("failed to write task file: %w", err)
214 }
215
216 return nil
217}
218
219// listTaskFiles returns all task file paths
220func (gtm *GitTaskManager) listTaskFiles() ([]string, error) {
221 entries, err := os.ReadDir(gtm.tasksDir)
222 if err != nil {
223 if os.IsNotExist(err) {
224 return []string{}, nil
225 }
226 return nil, fmt.Errorf("failed to read tasks directory: %w", err)
227 }
228
229 var taskFiles []string
230 for _, entry := range entries {
iomodo6cf9dda2025-07-27 15:18:07 +0400231 if !entry.IsDir() && strings.HasSuffix(entry.Name(), TaskFileExt) {
232 taskID := strings.TrimSuffix(entry.Name(), TaskFileExt)
iomodo3b89bdf2025-07-25 15:19:22 +0400233 taskFiles = append(taskFiles, taskID)
234 }
235 }
236
237 return taskFiles, nil
238}
239
240// commitTaskChange commits a task change to git
iomodo97555d02025-07-27 15:07:14 +0400241func (gtm *GitTaskManager) commitTaskChange(taskID, operation string, owner tm.Owner) error {
iomodo3b89bdf2025-07-25 15:19:22 +0400242 ctx := context.Background()
243
244 // Add the task file
iomodo6cf9dda2025-07-27 15:18:07 +0400245 if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, taskID+TaskFileExt)}); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400246 return fmt.Errorf("failed to add task file: %w", err)
247 }
248
249 // Commit the change
250 message := fmt.Sprintf("task: %s - %s", taskID, operation)
iomodo97555d02025-07-27 15:07:14 +0400251 if err := gtm.git.Commit(ctx, message, git.CommitOptions{
252 Author: &git.Author{
253 Name: owner.Name,
254 },
255 }); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400256 return fmt.Errorf("failed to commit task change: %w", err)
257 }
258
259 return nil
260}
261
262// CreateTask creates a new task
263func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
264 if err := gtm.ensureTasksDir(); err != nil {
265 return nil, err
266 }
267
268 // Validate request
269 if req.Title == "" {
270 return nil, tm.ErrInvalidTaskData
271 }
272 if req.OwnerID == "" {
273 return nil, tm.ErrInvalidOwner
274 }
275
276 // Generate task ID
277 taskID := gtm.generateTaskID()
278 now := time.Now()
279
iomodoa53240a2025-07-30 17:33:35 +0400280 ownerName := (req.OwnerID) //TODO: Get owner name from user service
iomodo6cf9dda2025-07-27 15:18:07 +0400281
iomodo3b89bdf2025-07-25 15:19:22 +0400282 // Create task
283 task := &tm.Task{
284 ID: taskID,
285 Title: req.Title,
286 Description: req.Description,
287 Owner: tm.Owner{
288 ID: req.OwnerID,
iomodo6cf9dda2025-07-27 15:18:07 +0400289 Name: ownerName,
iomodo3b89bdf2025-07-25 15:19:22 +0400290 },
291 Status: tm.StatusToDo,
292 Priority: req.Priority,
293 CreatedAt: now,
294 UpdatedAt: now,
295 DueDate: req.DueDate,
296 }
297
298 // Write task file
299 if err := gtm.writeTaskFile(task); err != nil {
300 return nil, err
301 }
302
303 // Commit to git
iomodo97555d02025-07-27 15:07:14 +0400304 if err := gtm.commitTaskChange(taskID, "created", task.Owner); err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400305 return nil, err
306 }
307
308 return task, nil
309}
310
311// GetTask retrieves a task by ID
user5a7d60d2025-07-27 21:22:04 +0400312func (gtm *GitTaskManager) GetTask(id string) (*tm.Task, error) {
iomodo3b89bdf2025-07-25 15:19:22 +0400313 return gtm.readTaskFile(id)
314}
315
316// UpdateTask updates an existing task
user5a7d60d2025-07-27 21:22:04 +0400317func (gtm *GitTaskManager) UpdateTask(task *tm.Task) error {
318 // Set update time
319 task.UpdatedAt = time.Now()
iomodo50598c62025-07-27 22:06:32 +0400320
user5a7d60d2025-07-27 21:22:04 +0400321 // Write task to file
322 return gtm.writeTaskFile(task)
323}
324
325// readAllTasks reads all task files from disk
326func (gtm *GitTaskManager) readAllTasks() ([]*tm.Task, error) {
327 taskFiles, err := gtm.listTaskFiles()
iomodo3b89bdf2025-07-25 15:19:22 +0400328 if err != nil {
329 return nil, err
330 }
iomodo50598c62025-07-27 22:06:32 +0400331
user5a7d60d2025-07-27 21:22:04 +0400332 var tasks []*tm.Task
333 for _, taskFile := range taskFiles {
user5a7d60d2025-07-27 21:22:04 +0400334 filename := filepath.Base(taskFile)
iomodo50598c62025-07-27 22:06:32 +0400335 if strings.HasPrefix(filename, "task-") {
336 task, err := gtm.readTaskFile(taskFile)
user5a7d60d2025-07-27 21:22:04 +0400337 if err != nil {
338 gtm.logger.Warn("Failed to read task file", slog.String("file", taskFile), slog.String("error", err.Error()))
339 continue
iomodo3b89bdf2025-07-25 15:19:22 +0400340 }
user5a7d60d2025-07-27 21:22:04 +0400341 tasks = append(tasks, task)
iomodo3b89bdf2025-07-25 15:19:22 +0400342 }
343 }
iomodo50598c62025-07-27 22:06:32 +0400344
user5a7d60d2025-07-27 21:22:04 +0400345 return tasks, nil
346}
iomodo3b89bdf2025-07-25 15:19:22 +0400347
user5a7d60d2025-07-27 21:22:04 +0400348// GetTasksByAssignee retrieves tasks assigned to a specific agent (MVP method)
349func (gtm *GitTaskManager) GetTasksByAssignee(assignee string) ([]*tm.Task, error) {
350 // Read all tasks and filter by assignee
351 tasks, err := gtm.readAllTasks()
352 if err != nil {
iomodo3b89bdf2025-07-25 15:19:22 +0400353 return nil, err
354 }
iomodo50598c62025-07-27 22:06:32 +0400355
user5a7d60d2025-07-27 21:22:04 +0400356 var assignedTasks []*tm.Task
357 for _, task := range tasks {
358 if task.Assignee == assignee {
359 assignedTasks = append(assignedTasks, task)
360 }
iomodo3b89bdf2025-07-25 15:19:22 +0400361 }
iomodo50598c62025-07-27 22:06:32 +0400362
user5a7d60d2025-07-27 21:22:04 +0400363 return assignedTasks, nil
iomodo3b89bdf2025-07-25 15:19:22 +0400364}
365
366// ArchiveTask archives a task
367func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
user5a7d60d2025-07-27 21:22:04 +0400368 task, err := gtm.GetTask(id)
369 if err != nil {
370 return err
iomodo3b89bdf2025-07-25 15:19:22 +0400371 }
iomodo50598c62025-07-27 22:06:32 +0400372
user5a7d60d2025-07-27 21:22:04 +0400373 task.Status = tm.StatusArchived
374 now := time.Now()
375 task.ArchivedAt = &now
iomodo50598c62025-07-27 22:06:32 +0400376
user5a7d60d2025-07-27 21:22:04 +0400377 return gtm.UpdateTask(task)
iomodo3b89bdf2025-07-25 15:19:22 +0400378}
379
380// ListTasks lists tasks with filtering and pagination
381func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
382 // Get all task files
383 taskFiles, err := gtm.listTaskFiles()
384 if err != nil {
385 return nil, err
386 }
387
388 // Read all tasks
389 var tasks []*tm.Task
390 for _, taskID := range taskFiles {
391 task, err := gtm.readTaskFile(taskID)
392 if err != nil {
393 continue // Skip corrupted files
394 }
395 tasks = append(tasks, task)
396 }
397
398 // Apply filters
399 if filter != nil {
400 tasks = gtm.filterTasks(tasks, filter)
401 }
402
403 // Sort by creation date (newest first)
404 sort.Slice(tasks, func(i, j int) bool {
405 return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
406 })
407
408 // Apply pagination
409 totalCount := len(tasks)
410 start := page * pageSize
411 end := start + pageSize
412
413 if start >= totalCount {
414 return &tm.TaskList{
415 Tasks: []*tm.Task{},
416 TotalCount: totalCount,
417 Page: page,
418 PageSize: pageSize,
419 HasMore: false,
420 }, nil
421 }
422
423 if end > totalCount {
424 end = totalCount
425 }
426
427 return &tm.TaskList{
428 Tasks: tasks[start:end],
429 TotalCount: totalCount,
430 Page: page,
431 PageSize: pageSize,
432 HasMore: end < totalCount,
433 }, nil
434}
435
436// filterTasks applies filters to a list of tasks
437func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
438 var filtered []*tm.Task
439
440 for _, task := range tasks {
441 if gtm.taskMatchesFilter(task, filter) {
442 filtered = append(filtered, task)
443 }
444 }
445
446 return filtered
447}
448
449// taskMatchesFilter checks if a task matches the given filter
450func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
451 if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
452 return false
453 }
454 if filter.Status != nil && task.Status != *filter.Status {
455 return false
456 }
457 if filter.Priority != nil && task.Priority != *filter.Priority {
458 return false
459 }
460 if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
461 return false
462 }
463 if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
464 return false
465 }
466 if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
467 return false
468 }
469 if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
470 return false
471 }
472
473 return true
474}
475
476// StartTask starts a task (changes status to in_progress)
477func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
user5a7d60d2025-07-27 21:22:04 +0400478 task, err := gtm.GetTask(id)
479 if err != nil {
480 return nil, err
iomodo3b89bdf2025-07-25 15:19:22 +0400481 }
iomodo50598c62025-07-27 22:06:32 +0400482
user5a7d60d2025-07-27 21:22:04 +0400483 task.Status = tm.StatusInProgress
iomodo50598c62025-07-27 22:06:32 +0400484
user5a7d60d2025-07-27 21:22:04 +0400485 err = gtm.UpdateTask(task)
486 if err != nil {
487 return nil, err
488 }
iomodo50598c62025-07-27 22:06:32 +0400489
user5a7d60d2025-07-27 21:22:04 +0400490 return task, nil
iomodo3b89bdf2025-07-25 15:19:22 +0400491}
492
493// CompleteTask completes a task (changes status to completed)
494func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
user5a7d60d2025-07-27 21:22:04 +0400495 task, err := gtm.GetTask(id)
496 if err != nil {
497 return nil, err
iomodo3b89bdf2025-07-25 15:19:22 +0400498 }
iomodo50598c62025-07-27 22:06:32 +0400499
user5a7d60d2025-07-27 21:22:04 +0400500 task.Status = tm.StatusCompleted
501 now := time.Now()
502 task.CompletedAt = &now
iomodo50598c62025-07-27 22:06:32 +0400503
user5a7d60d2025-07-27 21:22:04 +0400504 err = gtm.UpdateTask(task)
505 if err != nil {
506 return nil, err
507 }
iomodo50598c62025-07-27 22:06:32 +0400508
user5a7d60d2025-07-27 21:22:04 +0400509 return task, nil
iomodo3b89bdf2025-07-25 15:19:22 +0400510}
511
512// GetTasksByOwner gets tasks for a specific owner
513func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
514 filter := &tm.TaskFilter{
515 OwnerID: &ownerID,
516 }
517 return gtm.ListTasks(ctx, filter, page, pageSize)
518}
519
520// GetTasksByStatus gets tasks with a specific status
521func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
522 filter := &tm.TaskFilter{
523 Status: &status,
524 }
525 return gtm.ListTasks(ctx, filter, page, pageSize)
526}
527
528// GetTasksByPriority gets tasks with a specific priority
529func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
530 filter := &tm.TaskFilter{
531 Priority: &priority,
532 }
533 return gtm.ListTasks(ctx, filter, page, pageSize)
534}
535
iomodoa53240a2025-07-30 17:33:35 +0400536// GenerateSubtaskPR creates a PR with the proposed subtasks
iomodo1c1c60d2025-07-30 17:54:10 +0400537func (gtm *GitTaskManager) ProposeSubTasks(ctx context.Context, task *tm.Task, analysis *tm.SubtaskAnalysis, agentName string) (string, error) {
iomodoa53240a2025-07-30 17:33:35 +0400538 branchName := generateBranchName("subtasks", task)
539 gtm.logger.Info("Creating subtask PR", slog.String("branch", branchName))
540
541 // Create Git branch and commit subtask proposal
iomodo1c1c60d2025-07-30 17:54:10 +0400542 if err := gtm.createSubtaskBranch(ctx, analysis, branchName, agentName); err != nil {
iomodoa53240a2025-07-30 17:33:35 +0400543 return "", fmt.Errorf("failed to create subtask branch: %w", err)
544 }
545
546 // Generate PR content
547 prContent := gtm.generateSubtaskPRContent(analysis)
548 title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
549
550 // Validate PR content
551 if title == "" {
552 return "", fmt.Errorf("PR title cannot be empty")
553 }
554 if prContent == "" {
555 return "", fmt.Errorf("PR description cannot be empty")
556 }
557
558 // Determine base branch (try main first, fallback to master)
iomodo1c1c60d2025-07-30 17:54:10 +0400559 baseBranch := gtm.determineBaseBranch(ctx, agentName)
iomodoa53240a2025-07-30 17:33:35 +0400560 gtm.logger.Info("Using base branch", slog.String("base_branch", baseBranch))
561
562 // Create the pull request
563 options := git.PullRequestOptions{
564 Title: title,
565 Description: prContent,
566 HeadBranch: branchName,
567 BaseBranch: baseBranch,
568 Labels: []string{"subtasks", "proposal", "ai-generated"},
569 Draft: false,
570 }
571
572 gtm.logger.Info("Creating PR with options",
573 slog.String("title", options.Title),
574 slog.String("head_branch", options.HeadBranch),
575 slog.String("base_branch", options.BaseBranch))
576
577 pr, err := gtm.git.CreatePullRequest(ctx, options)
578 if err != nil {
579 return "", fmt.Errorf("failed to create PR: %w", err)
580 }
581
582 gtm.logger.Info("Generated subtask proposal PR", slog.String("pr_url", pr.URL))
583
584 return pr.URL, nil
585}
586
587func (gtm *GitTaskManager) ProposeSolution(ctx context.Context, task *tm.Task, solution, agentName string) (string, error) {
588 branchName := generateBranchName("solution", task)
589 gtm.logger.Info("Creating solution PR", slog.String("branch", branchName))
590
591 if err := gtm.createSolutionBranch(ctx, task, solution, branchName, agentName); err != nil {
592 return "", fmt.Errorf("failed to create solution branch: %w", err)
593 }
594 // Build PR description from template
595 description := buildSolutionPRDescription(task, solution, gtm.config.Git.PRTemplate, agentName)
596
597 options := git.PullRequestOptions{
598 Title: fmt.Sprintf("Task %s: %s", task.ID, task.Title),
599 Description: description,
600 HeadBranch: branchName,
601 BaseBranch: "main",
602 Labels: []string{"ai-generated"},
603 Draft: false,
604 }
605
606 pr, err := gtm.git.CreatePullRequest(ctx, options)
607 if err != nil {
608 return "", fmt.Errorf("failed to create PR: %w", err)
609 }
610 gtm.logger.Info("Generated subtask proposal PR", slog.String("pr_url", pr.URL))
611 return pr.URL, nil
612}
613
iomodo1c1c60d2025-07-30 17:54:10 +0400614func (gtm *GitTaskManager) Close() error {
615 return gtm.git.CleanupAllClones()
616}
617
iomodoa53240a2025-07-30 17:33:35 +0400618// createSubtaskBranch creates a Git branch with subtask proposal content
iomodo1c1c60d2025-07-30 17:54:10 +0400619func (gtm *GitTaskManager) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName, agentName string) error {
620 clonePath, err := gtm.git.GetAgentClonePath(agentName)
iomodoa53240a2025-07-30 17:33:35 +0400621 if err != nil {
622 return fmt.Errorf("failed to get clone path: %w", err)
623 }
624
625 // All Git operations use the clone directory
626 gitCmd := func(args ...string) *exec.Cmd {
627 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
628 }
629
630 // Ensure we're on main branch before creating new branch
631 cmd := gitCmd("checkout", "main")
632 if err := cmd.Run(); err != nil {
633 // Try master branch if main doesn't exist
634 cmd = gitCmd("checkout", "master")
635 if err := cmd.Run(); err != nil {
636 return fmt.Errorf("failed to checkout main/master branch: %w", err)
637 }
638 }
639
640 // Pull latest changes
641 cmd = gitCmd("pull", "origin")
642 if err := cmd.Run(); err != nil {
643 gtm.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error()))
644 }
645
646 // Delete branch if it exists (cleanup from previous attempts)
647 cmd = gitCmd("branch", "-D", branchName)
648 _ = cmd.Run() // Ignore error if branch doesn't exist
649
650 // Also delete remote tracking branch if it exists
651 cmd = gitCmd("push", "origin", "--delete", branchName)
652 _ = cmd.Run() // Ignore error if branch doesn't exist
653
654 // Create and checkout new branch
655 cmd = gitCmd("checkout", "-b", branchName)
656 if err := cmd.Run(); err != nil {
657 return fmt.Errorf("failed to create branch: %w", err)
658 }
659
660 // Create individual task files for each subtask
661 tasksDir := filepath.Join(clonePath, "operations", "tasks")
662 if err := os.MkdirAll(tasksDir, 0755); err != nil {
663 return fmt.Errorf("failed to create tasks directory: %w", err)
664 }
665
666 var stagedFiles []string
667
668 // Update parent task to mark as completed
669 parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
670 if err := gtm.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
671 return fmt.Errorf("failed to update parent task: %w", err)
672 }
673
674 // Track parent task file for staging
675 parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
676 stagedFiles = append(stagedFiles, parentRelativeFile)
677 gtm.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile))
678
679 // Create a file for each subtask
680 for i, subtask := range analysis.Subtasks {
681 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
682 taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
683 taskContent := gtm.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
684
685 if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
686 return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
687 }
688
689 // Track file for staging
690 relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
691 stagedFiles = append(stagedFiles, relativeFile)
692 gtm.logger.Info("Created subtask file", slog.String("file", relativeFile))
693 }
694
695 // Stage all subtask files
696 for _, file := range stagedFiles {
697 cmd = gitCmd("add", file)
698 if err := cmd.Run(); err != nil {
699 return fmt.Errorf("failed to stage file %s: %w", file, err)
700 }
701 }
702
703 // Commit changes
704 commitMsg := fmt.Sprintf("Create %d subtasks for task %s and mark parent as completed\n\nGenerated by Staff AI Agent System\n\nFiles modified:\n- %s.md (marked as completed)\n\nCreated individual task files:\n",
705 len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
706
707 // Add list of created files to commit message
708 for i := range analysis.Subtasks {
709 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
710 commitMsg += fmt.Sprintf("- %s.md\n", taskID)
711 }
712
713 if len(analysis.AgentCreations) > 0 {
714 commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
715 }
716 cmd = gitCmd("commit", "-m", commitMsg)
717 if err := cmd.Run(); err != nil {
718 return fmt.Errorf("failed to commit: %w", err)
719 }
720
721 // Push branch
722 cmd = gitCmd("push", "-u", "origin", branchName)
723 if err := cmd.Run(); err != nil {
724 return fmt.Errorf("failed to push branch: %w", err)
725 }
726
727 gtm.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
728 return nil
729}
730
731// updateParentTaskAsCompleted updates the parent task file to mark it as completed
732func (gtm *GitTaskManager) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
733 // Read the existing parent task file
734 content, err := os.ReadFile(taskFilePath)
735 if err != nil {
736 return fmt.Errorf("failed to read parent task file: %w", err)
737 }
738
739 taskContent := string(content)
740
741 // Find the YAML frontmatter boundaries
742 lines := strings.Split(taskContent, "\n")
743 var frontmatterStart, frontmatterEnd int = -1, -1
744
745 for i, line := range lines {
746 if line == "---" {
747 if frontmatterStart == -1 {
748 frontmatterStart = i
749 } else {
750 frontmatterEnd = i
751 break
752 }
753 }
754 }
755
756 if frontmatterStart == -1 || frontmatterEnd == -1 {
757 return fmt.Errorf("invalid task file format: missing YAML frontmatter")
758 }
759
760 // Update the frontmatter
761 now := time.Now().Format(time.RFC3339)
762 var updatedLines []string
763
764 // Add lines before frontmatter
765 updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
766
767 // Process frontmatter lines
768 for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
769 line := lines[i]
770 if strings.HasPrefix(line, "status:") {
771 updatedLines = append(updatedLines, "status: completed")
772 } else if strings.HasPrefix(line, "updated_at:") {
773 updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
774 } else if strings.HasPrefix(line, "completed_at:") {
775 updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
776 } else {
777 updatedLines = append(updatedLines, line)
778 }
779 }
780
781 // Add closing frontmatter and rest of content
782 updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
783
784 // Add subtask information to the task description
785 if frontmatterEnd+1 < len(lines) {
786 // Add subtask information
787 subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
788 for i, subtask := range analysis.Subtasks {
789 subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
790 subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
791 }
792 subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
793 subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
794
795 // Insert subtask info before any existing body content
796 updatedContent := strings.Join(updatedLines[:], "\n") + subtaskInfo
797
798 // Write the updated content back to the file
799 if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
800 return fmt.Errorf("failed to write updated parent task file: %w", err)
801 }
802 }
803
804 gtm.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
805 return nil
806}
807
808// generateSubtaskFile creates the content for an individual subtask file
809func (gtm *GitTaskManager) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
810 var content strings.Builder
811
812 // Generate YAML frontmatter
813 content.WriteString("---\n")
814 content.WriteString(fmt.Sprintf("id: %s\n", taskID))
815 content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
816 content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
817 content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
818 content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
819 content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
820 content.WriteString("status: todo\n")
821 content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
822 content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
823 content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
824 content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
825 content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
826 content.WriteString("completed_at: null\n")
827 content.WriteString("archived_at: null\n")
828
829 // Add dependencies if any
830 if len(subtask.Dependencies) > 0 {
831 content.WriteString("dependencies:\n")
832 for _, dep := range subtask.Dependencies {
833 // Convert dependency index to actual subtask ID
834 if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
835 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
836 content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
837 }
838 }
839 }
840
841 // Add required skills if any
842 if len(subtask.RequiredSkills) > 0 {
843 content.WriteString("required_skills:\n")
844 for _, skill := range subtask.RequiredSkills {
845 content.WriteString(fmt.Sprintf(" - %s\n", skill))
846 }
847 }
848
849 content.WriteString("---\n\n")
850
851 // Add markdown content
852 content.WriteString("# Task Description\n\n")
853 content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
854
855 if subtask.EstimatedHours > 0 {
856 content.WriteString("## Estimated Effort\n\n")
857 content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
858 }
859
860 if len(subtask.RequiredSkills) > 0 {
861 content.WriteString("## Required Skills\n\n")
862 for _, skill := range subtask.RequiredSkills {
863 content.WriteString(fmt.Sprintf("- %s\n", skill))
864 }
865 content.WriteString("\n")
866 }
867
868 if len(subtask.Dependencies) > 0 {
869 content.WriteString("## Dependencies\n\n")
870 content.WriteString("This task depends on the completion of:\n\n")
871 for _, dep := range subtask.Dependencies {
872 if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
873 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
874 content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
875 }
876 }
877 content.WriteString("\n")
878 }
879
880 content.WriteString("## Notes\n\n")
881 content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
882 content.WriteString("Generated by Staff AI Agent System\n\n")
883
884 return content.String()
885}
886
887func (gtm *GitTaskManager) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
888 var content strings.Builder
889
890 content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
891 content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
892 content.WriteString(fmt.Sprintf("✅ **Parent task `%s` has been marked as completed** - the complex task has been successfully broken down into actionable subtasks.\n\n", analysis.ParentTaskID))
893 content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
894 content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
895 content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
896
897 // List the created task files
898 content.WriteString("## Created Task Files\n\n")
899 for i, subtask := range analysis.Subtasks {
900 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
901 content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
902 content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
903 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
904 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
905 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
906 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
907 }
908
909 if analysis.RiskAssessment != "" {
910 content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
911 }
912
913 content.WriteString("## Proposed Subtasks\n\n")
914
915 for i, subtask := range analysis.Subtasks {
916 content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
917 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
918 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
919 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
920
921 if len(subtask.Dependencies) > 0 {
922 deps := strings.Join(subtask.Dependencies, ", ")
923 content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
924 }
925
926 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
927 }
928
929 content.WriteString("---\n")
930 content.WriteString("*Generated by Staff AI Agent System*\n\n")
931 content.WriteString("**Instructions:**\n")
932 content.WriteString("- Review the proposed subtasks\n")
933 content.WriteString("- Approve or request changes\n")
934 content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
935
936 return content.String()
937}
938
iomodo1c1c60d2025-07-30 17:54:10 +0400939func (gtm *GitTaskManager) determineBaseBranch(ctx context.Context, agentName string) string {
iomodoa53240a2025-07-30 17:33:35 +0400940 // Get clone path to check branches
iomodo1c1c60d2025-07-30 17:54:10 +0400941 clonePath, err := gtm.git.GetAgentClonePath(agentName)
iomodoa53240a2025-07-30 17:33:35 +0400942 if err != nil {
943 gtm.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error()))
944 return "main"
945 }
946
947 // Check if main branch exists
948 gitCmd := func(args ...string) *exec.Cmd {
949 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
950 }
951
952 // Try to checkout main branch
953 cmd := gitCmd("show-ref", "refs/remotes/origin/main")
954 if err := cmd.Run(); err == nil {
955 return "main"
956 }
957
958 // Try to checkout master branch
959 cmd = gitCmd("show-ref", "refs/remotes/origin/master")
960 if err := cmd.Run(); err == nil {
961 return "master"
962 }
963
964 // Default to main if neither can be detected
965 gtm.logger.Warn("Could not determine base branch, defaulting to 'main'")
966 return "main"
967}
968
969// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
970func (gtm *GitTaskManager) createSolutionBranch(ctx context.Context, task *tm.Task, solution, branchName, agentName string) error {
971 // Get agent's dedicated Git clone
972 clonePath, err := gtm.git.GetAgentClonePath(agentName)
973 if err != nil {
974 return fmt.Errorf("failed to get agent clone: %w", err)
975 }
976
977 gtm.logger.Info("Agent working in clone",
978 slog.String("agent", agentName),
979 slog.String("clone_path", clonePath))
980
981 // Refresh the clone with latest changes
982 if err := gtm.git.RefreshAgentClone(agentName); err != nil {
983 gtm.logger.Warn("Failed to refresh clone for agent",
984 slog.String("agent", agentName),
985 slog.String("error", err.Error()))
986 }
987
988 // All Git operations use the agent's clone directory
989 gitCmd := func(args ...string) *exec.Cmd {
990 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
991 }
992
993 // Ensure we're on main branch before creating new branch
994 cmd := gitCmd("checkout", "main")
995 if err := cmd.Run(); err != nil {
996 // Try master branch if main doesn't exist
997 cmd = gitCmd("checkout", "master")
998 if err := cmd.Run(); err != nil {
999 return fmt.Errorf("failed to checkout main/master branch: %w", err)
1000 }
1001 }
1002
1003 // Create branch
1004 cmd = gitCmd("checkout", "-b", branchName)
1005 if err := cmd.Run(); err != nil {
1006 return fmt.Errorf("failed to create branch: %w", err)
1007 }
1008
1009 // Create solution file in agent's clone
1010 solutionDir := filepath.Join(clonePath, "tasks", "solutions")
1011 if err := os.MkdirAll(solutionDir, 0755); err != nil {
1012 return fmt.Errorf("failed to create solution directory: %w", err)
1013 }
1014
1015 solutionFile := filepath.Join(solutionDir, fmt.Sprintf("%s-solution.md", task.ID))
1016 solutionContent := fmt.Sprintf(`# Solution for Task: %s
1017
1018**Agent:** %s
1019**Completed:** %s
1020
1021## Task Description
1022%s
1023
1024## Solution
1025%s
1026
1027---
1028*Generated by Staff AI Agent System*
1029`, task.Title, agentName, time.Now().Format(time.RFC3339), task.Description, solution)
1030
1031 if err := os.WriteFile(solutionFile, []byte(solutionContent), 0644); err != nil {
1032 return fmt.Errorf("failed to write solution file: %w", err)
1033 }
1034
1035 // Stage files
1036 relativeSolutionFile := filepath.Join("tasks", "solutions", fmt.Sprintf("%s-solution.md", task.ID))
1037 cmd = gitCmd("add", relativeSolutionFile)
1038 if err := cmd.Run(); err != nil {
1039 return fmt.Errorf("failed to stage files: %w", err)
1040 }
1041
1042 // Commit changes
1043 commitMsg := buildCommitMessage(task, gtm.config.Git.CommitMessageTemplate, agentName)
1044 cmd = gitCmd("commit", "-m", commitMsg)
1045 if err := cmd.Run(); err != nil {
1046 return fmt.Errorf("failed to commit: %w", err)
1047 }
1048
1049 // Push branch
1050 cmd = gitCmd("push", "-u", "origin", branchName)
1051 if err := cmd.Run(); err != nil {
1052 return fmt.Errorf("failed to push branch: %w", err)
1053 }
1054
1055 gtm.logger.Info("Agent successfully pushed branch",
1056 slog.String("agent", agentName),
1057 slog.String("branch", branchName))
1058 return nil
1059}
1060
1061func buildCommitMessage(task *tm.Task, template, agentName string) string {
1062 replacements := map[string]string{
1063 "{task_id}": task.ID,
1064 "{task_title}": task.Title,
1065 "{agent_name}": agentName,
1066 "{solution}": "See solution file for details",
1067 }
1068
1069 result := template
1070 for placeholder, value := range replacements {
1071 result = strings.ReplaceAll(result, placeholder, value)
1072 }
1073
1074 return result
1075}
1076
1077// parseDependencyIndex parses a dependency string to an integer index
1078func parseDependencyIndex(dep string) int {
1079 var idx int
1080 if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
1081 return idx
1082 }
1083 return -1 // Invalid dependency format
1084}
1085
1086// generateBranchName creates a Git branch name for the task
1087func generateBranchName(prefix string, task *tm.Task) string {
1088 // Clean title for use in branch name
1089 cleanTitle := strings.ToLower(task.Title)
1090 cleanTitle = strings.ReplaceAll(cleanTitle, " ", "-")
1091 cleanTitle = strings.ReplaceAll(cleanTitle, "/", "-")
1092 // Remove special characters
1093 var result strings.Builder
1094 for _, r := range cleanTitle {
1095 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
1096 result.WriteRune(r)
1097 }
1098 }
1099 cleanTitle = result.String()
1100
1101 // Limit length
1102 if len(cleanTitle) > 40 {
1103 cleanTitle = cleanTitle[:40]
1104 }
1105
iomodo8acd08d2025-07-31 16:22:08 +04001106 return fmt.Sprintf("%s/%s-%s", prefix, task.ID, cleanTitle)
iomodoa53240a2025-07-30 17:33:35 +04001107}
1108
1109// buildSolutionPRDescription creates PR description from template
1110func buildSolutionPRDescription(task *tm.Task, solution, template, agentName string) string {
1111 // Truncate solution for PR if too long
1112 truncatedSolution := solution
1113 if len(solution) > 1000 {
1114 truncatedSolution = solution[:1000] + "...\n\n*See solution file for complete details*"
1115 }
1116
1117 replacements := map[string]string{
1118 "{task_id}": task.ID,
1119 "{task_title}": task.Title,
1120 "{task_description}": task.Description,
iomodo75542322025-07-30 19:27:48 +04001121 "{agent_name}": agentName,
iomodoa53240a2025-07-30 17:33:35 +04001122 "{priority}": string(task.Priority),
1123 "{solution}": truncatedSolution,
1124 "{files_changed}": fmt.Sprintf("- `tasks/solutions/%s-solution.md`", task.ID),
1125 }
1126
1127 result := template
1128 for placeholder, value := range replacements {
1129 result = strings.ReplaceAll(result, placeholder, value)
1130 }
1131
1132 return result
1133}
1134
iomodo3b89bdf2025-07-25 15:19:22 +04001135// Ensure GitTaskManager implements TaskManager interface
1136var _ tm.TaskManager = (*GitTaskManager)(nil)