blob: e86ab76e9c628961f79a0934a55890d4062b466c [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
iomodo7a8a3cb2025-07-31 18:30:39 +0400597 // Determine base branch (try main first, fallback to master)
598 baseBranch := gtm.determineBaseBranch(ctx, agentName)
599 gtm.logger.Info("Using base branch", slog.String("base_branch", baseBranch))
600
iomodoa53240a2025-07-30 17:33:35 +0400601 options := git.PullRequestOptions{
602 Title: fmt.Sprintf("Task %s: %s", task.ID, task.Title),
603 Description: description,
604 HeadBranch: branchName,
iomodo7a8a3cb2025-07-31 18:30:39 +0400605 BaseBranch: baseBranch,
iomodoa53240a2025-07-30 17:33:35 +0400606 Labels: []string{"ai-generated"},
607 Draft: false,
608 }
609
610 pr, err := gtm.git.CreatePullRequest(ctx, options)
611 if err != nil {
612 return "", fmt.Errorf("failed to create PR: %w", err)
613 }
614 gtm.logger.Info("Generated subtask proposal PR", slog.String("pr_url", pr.URL))
615 return pr.URL, nil
616}
617
iomodo1c1c60d2025-07-30 17:54:10 +0400618func (gtm *GitTaskManager) Close() error {
619 return gtm.git.CleanupAllClones()
620}
621
iomodoa53240a2025-07-30 17:33:35 +0400622// createSubtaskBranch creates a Git branch with subtask proposal content
iomodo1c1c60d2025-07-30 17:54:10 +0400623func (gtm *GitTaskManager) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName, agentName string) error {
624 clonePath, err := gtm.git.GetAgentClonePath(agentName)
iomodoa53240a2025-07-30 17:33:35 +0400625 if err != nil {
626 return fmt.Errorf("failed to get clone path: %w", err)
627 }
628
629 // All Git operations use the clone directory
630 gitCmd := func(args ...string) *exec.Cmd {
631 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
632 }
633
634 // Ensure we're on main branch before creating new branch
635 cmd := gitCmd("checkout", "main")
636 if err := cmd.Run(); err != nil {
637 // Try master branch if main doesn't exist
638 cmd = gitCmd("checkout", "master")
639 if err := cmd.Run(); err != nil {
640 return fmt.Errorf("failed to checkout main/master branch: %w", err)
641 }
642 }
643
644 // Pull latest changes
645 cmd = gitCmd("pull", "origin")
646 if err := cmd.Run(); err != nil {
647 gtm.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error()))
648 }
649
650 // Delete branch if it exists (cleanup from previous attempts)
651 cmd = gitCmd("branch", "-D", branchName)
652 _ = cmd.Run() // Ignore error if branch doesn't exist
653
654 // Also delete remote tracking branch if it exists
655 cmd = gitCmd("push", "origin", "--delete", branchName)
656 _ = cmd.Run() // Ignore error if branch doesn't exist
657
658 // Create and checkout new branch
659 cmd = gitCmd("checkout", "-b", branchName)
660 if err := cmd.Run(); err != nil {
661 return fmt.Errorf("failed to create branch: %w", err)
662 }
663
664 // Create individual task files for each subtask
665 tasksDir := filepath.Join(clonePath, "operations", "tasks")
666 if err := os.MkdirAll(tasksDir, 0755); err != nil {
667 return fmt.Errorf("failed to create tasks directory: %w", err)
668 }
669
670 var stagedFiles []string
671
672 // Update parent task to mark as completed
673 parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
674 if err := gtm.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
675 return fmt.Errorf("failed to update parent task: %w", err)
676 }
677
678 // Track parent task file for staging
679 parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
680 stagedFiles = append(stagedFiles, parentRelativeFile)
681 gtm.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile))
682
683 // Create a file for each subtask
684 for i, subtask := range analysis.Subtasks {
685 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
686 taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
687 taskContent := gtm.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
688
689 if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
690 return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
691 }
692
693 // Track file for staging
694 relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
695 stagedFiles = append(stagedFiles, relativeFile)
696 gtm.logger.Info("Created subtask file", slog.String("file", relativeFile))
697 }
698
699 // Stage all subtask files
700 for _, file := range stagedFiles {
701 cmd = gitCmd("add", file)
702 if err := cmd.Run(); err != nil {
703 return fmt.Errorf("failed to stage file %s: %w", file, err)
704 }
705 }
706
707 // Commit changes
708 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",
709 len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
710
711 // Add list of created files to commit message
712 for i := range analysis.Subtasks {
713 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
714 commitMsg += fmt.Sprintf("- %s.md\n", taskID)
715 }
716
717 if len(analysis.AgentCreations) > 0 {
718 commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
719 }
720 cmd = gitCmd("commit", "-m", commitMsg)
721 if err := cmd.Run(); err != nil {
722 return fmt.Errorf("failed to commit: %w", err)
723 }
724
725 // Push branch
726 cmd = gitCmd("push", "-u", "origin", branchName)
727 if err := cmd.Run(); err != nil {
728 return fmt.Errorf("failed to push branch: %w", err)
729 }
730
731 gtm.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
732 return nil
733}
734
735// updateParentTaskAsCompleted updates the parent task file to mark it as completed
736func (gtm *GitTaskManager) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
737 // Read the existing parent task file
738 content, err := os.ReadFile(taskFilePath)
739 if err != nil {
740 return fmt.Errorf("failed to read parent task file: %w", err)
741 }
742
743 taskContent := string(content)
744
745 // Find the YAML frontmatter boundaries
746 lines := strings.Split(taskContent, "\n")
747 var frontmatterStart, frontmatterEnd int = -1, -1
748
749 for i, line := range lines {
750 if line == "---" {
751 if frontmatterStart == -1 {
752 frontmatterStart = i
753 } else {
754 frontmatterEnd = i
755 break
756 }
757 }
758 }
759
760 if frontmatterStart == -1 || frontmatterEnd == -1 {
761 return fmt.Errorf("invalid task file format: missing YAML frontmatter")
762 }
763
764 // Update the frontmatter
765 now := time.Now().Format(time.RFC3339)
766 var updatedLines []string
767
768 // Add lines before frontmatter
769 updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
770
771 // Process frontmatter lines
772 for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
773 line := lines[i]
774 if strings.HasPrefix(line, "status:") {
775 updatedLines = append(updatedLines, "status: completed")
776 } else if strings.HasPrefix(line, "updated_at:") {
777 updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
778 } else if strings.HasPrefix(line, "completed_at:") {
779 updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
780 } else {
781 updatedLines = append(updatedLines, line)
782 }
783 }
784
785 // Add closing frontmatter and rest of content
786 updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
787
788 // Add subtask information to the task description
789 if frontmatterEnd+1 < len(lines) {
790 // Add subtask information
791 subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
792 for i, subtask := range analysis.Subtasks {
793 subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
794 subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
795 }
796 subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
797 subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
798
799 // Insert subtask info before any existing body content
800 updatedContent := strings.Join(updatedLines[:], "\n") + subtaskInfo
801
802 // Write the updated content back to the file
803 if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
804 return fmt.Errorf("failed to write updated parent task file: %w", err)
805 }
806 }
807
808 gtm.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
809 return nil
810}
811
812// generateSubtaskFile creates the content for an individual subtask file
813func (gtm *GitTaskManager) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
814 var content strings.Builder
815
816 // Generate YAML frontmatter
817 content.WriteString("---\n")
818 content.WriteString(fmt.Sprintf("id: %s\n", taskID))
819 content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
820 content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
821 content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
822 content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
823 content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
824 content.WriteString("status: todo\n")
825 content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
826 content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
827 content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
828 content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
829 content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
830 content.WriteString("completed_at: null\n")
831 content.WriteString("archived_at: null\n")
832
833 // Add dependencies if any
834 if len(subtask.Dependencies) > 0 {
835 content.WriteString("dependencies:\n")
836 for _, dep := range subtask.Dependencies {
837 // Convert dependency index to actual subtask ID
838 if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
839 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
840 content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
841 }
842 }
843 }
844
845 // Add required skills if any
846 if len(subtask.RequiredSkills) > 0 {
847 content.WriteString("required_skills:\n")
848 for _, skill := range subtask.RequiredSkills {
849 content.WriteString(fmt.Sprintf(" - %s\n", skill))
850 }
851 }
852
853 content.WriteString("---\n\n")
854
855 // Add markdown content
856 content.WriteString("# Task Description\n\n")
857 content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
858
859 if subtask.EstimatedHours > 0 {
860 content.WriteString("## Estimated Effort\n\n")
861 content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
862 }
863
864 if len(subtask.RequiredSkills) > 0 {
865 content.WriteString("## Required Skills\n\n")
866 for _, skill := range subtask.RequiredSkills {
867 content.WriteString(fmt.Sprintf("- %s\n", skill))
868 }
869 content.WriteString("\n")
870 }
871
872 if len(subtask.Dependencies) > 0 {
873 content.WriteString("## Dependencies\n\n")
874 content.WriteString("This task depends on the completion of:\n\n")
875 for _, dep := range subtask.Dependencies {
876 if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
877 depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
878 content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
879 }
880 }
881 content.WriteString("\n")
882 }
883
884 content.WriteString("## Notes\n\n")
885 content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
886 content.WriteString("Generated by Staff AI Agent System\n\n")
887
888 return content.String()
889}
890
891func (gtm *GitTaskManager) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
892 var content strings.Builder
893
894 content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
895 content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
896 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))
897 content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
898 content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
899 content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
900
901 // List the created task files
902 content.WriteString("## Created Task Files\n\n")
903 for i, subtask := range analysis.Subtasks {
904 taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
905 content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
906 content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
907 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
908 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
909 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
910 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
911 }
912
913 if analysis.RiskAssessment != "" {
914 content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
915 }
916
917 content.WriteString("## Proposed Subtasks\n\n")
918
919 for i, subtask := range analysis.Subtasks {
920 content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
921 content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
922 content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
923 content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
924
925 if len(subtask.Dependencies) > 0 {
926 deps := strings.Join(subtask.Dependencies, ", ")
927 content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
928 }
929
930 content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
931 }
932
933 content.WriteString("---\n")
934 content.WriteString("*Generated by Staff AI Agent System*\n\n")
935 content.WriteString("**Instructions:**\n")
936 content.WriteString("- Review the proposed subtasks\n")
937 content.WriteString("- Approve or request changes\n")
938 content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
939
940 return content.String()
941}
942
iomodo1c1c60d2025-07-30 17:54:10 +0400943func (gtm *GitTaskManager) determineBaseBranch(ctx context.Context, agentName string) string {
iomodoa53240a2025-07-30 17:33:35 +0400944 // Get clone path to check branches
iomodo1c1c60d2025-07-30 17:54:10 +0400945 clonePath, err := gtm.git.GetAgentClonePath(agentName)
iomodoa53240a2025-07-30 17:33:35 +0400946 if err != nil {
947 gtm.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error()))
948 return "main"
949 }
950
951 // Check if main branch exists
952 gitCmd := func(args ...string) *exec.Cmd {
953 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
954 }
955
956 // Try to checkout main branch
957 cmd := gitCmd("show-ref", "refs/remotes/origin/main")
958 if err := cmd.Run(); err == nil {
959 return "main"
960 }
961
962 // Try to checkout master branch
963 cmd = gitCmd("show-ref", "refs/remotes/origin/master")
964 if err := cmd.Run(); err == nil {
965 return "master"
966 }
967
968 // Default to main if neither can be detected
969 gtm.logger.Warn("Could not determine base branch, defaulting to 'main'")
970 return "main"
971}
972
973// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
974func (gtm *GitTaskManager) createSolutionBranch(ctx context.Context, task *tm.Task, solution, branchName, agentName string) error {
975 // Get agent's dedicated Git clone
976 clonePath, err := gtm.git.GetAgentClonePath(agentName)
977 if err != nil {
978 return fmt.Errorf("failed to get agent clone: %w", err)
979 }
980
981 gtm.logger.Info("Agent working in clone",
982 slog.String("agent", agentName),
983 slog.String("clone_path", clonePath))
984
985 // Refresh the clone with latest changes
986 if err := gtm.git.RefreshAgentClone(agentName); err != nil {
987 gtm.logger.Warn("Failed to refresh clone for agent",
988 slog.String("agent", agentName),
989 slog.String("error", err.Error()))
990 }
991
992 // All Git operations use the agent's clone directory
993 gitCmd := func(args ...string) *exec.Cmd {
994 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
995 }
996
997 // Ensure we're on main branch before creating new branch
998 cmd := gitCmd("checkout", "main")
999 if err := cmd.Run(); err != nil {
1000 // Try master branch if main doesn't exist
1001 cmd = gitCmd("checkout", "master")
1002 if err := cmd.Run(); err != nil {
1003 return fmt.Errorf("failed to checkout main/master branch: %w", err)
1004 }
1005 }
1006
1007 // Create branch
1008 cmd = gitCmd("checkout", "-b", branchName)
1009 if err := cmd.Run(); err != nil {
iomodo7a8a3cb2025-07-31 18:30:39 +04001010 return fmt.Errorf("failed to create branch %s: %w", branchName, err)
iomodoa53240a2025-07-30 17:33:35 +04001011 }
1012
1013 // Create solution file in agent's clone
1014 solutionDir := filepath.Join(clonePath, "tasks", "solutions")
1015 if err := os.MkdirAll(solutionDir, 0755); err != nil {
1016 return fmt.Errorf("failed to create solution directory: %w", err)
1017 }
1018
1019 solutionFile := filepath.Join(solutionDir, fmt.Sprintf("%s-solution.md", task.ID))
1020 solutionContent := fmt.Sprintf(`# Solution for Task: %s
1021
1022**Agent:** %s
1023**Completed:** %s
1024
1025## Task Description
1026%s
1027
1028## Solution
1029%s
1030
1031---
1032*Generated by Staff AI Agent System*
1033`, task.Title, agentName, time.Now().Format(time.RFC3339), task.Description, solution)
1034
1035 if err := os.WriteFile(solutionFile, []byte(solutionContent), 0644); err != nil {
1036 return fmt.Errorf("failed to write solution file: %w", err)
1037 }
1038
1039 // Stage files
1040 relativeSolutionFile := filepath.Join("tasks", "solutions", fmt.Sprintf("%s-solution.md", task.ID))
1041 cmd = gitCmd("add", relativeSolutionFile)
1042 if err := cmd.Run(); err != nil {
1043 return fmt.Errorf("failed to stage files: %w", err)
1044 }
1045
1046 // Commit changes
1047 commitMsg := buildCommitMessage(task, gtm.config.Git.CommitMessageTemplate, agentName)
1048 cmd = gitCmd("commit", "-m", commitMsg)
1049 if err := cmd.Run(); err != nil {
1050 return fmt.Errorf("failed to commit: %w", err)
1051 }
1052
1053 // Push branch
1054 cmd = gitCmd("push", "-u", "origin", branchName)
1055 if err := cmd.Run(); err != nil {
1056 return fmt.Errorf("failed to push branch: %w", err)
1057 }
1058
1059 gtm.logger.Info("Agent successfully pushed branch",
1060 slog.String("agent", agentName),
1061 slog.String("branch", branchName))
1062 return nil
1063}
1064
1065func buildCommitMessage(task *tm.Task, template, agentName string) string {
1066 replacements := map[string]string{
1067 "{task_id}": task.ID,
1068 "{task_title}": task.Title,
1069 "{agent_name}": agentName,
1070 "{solution}": "See solution file for details",
1071 }
1072
1073 result := template
1074 for placeholder, value := range replacements {
1075 result = strings.ReplaceAll(result, placeholder, value)
1076 }
1077
1078 return result
1079}
1080
1081// parseDependencyIndex parses a dependency string to an integer index
1082func parseDependencyIndex(dep string) int {
1083 var idx int
1084 if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
1085 return idx
1086 }
1087 return -1 // Invalid dependency format
1088}
1089
1090// generateBranchName creates a Git branch name for the task
1091func generateBranchName(prefix string, task *tm.Task) string {
1092 // Clean title for use in branch name
1093 cleanTitle := strings.ToLower(task.Title)
1094 cleanTitle = strings.ReplaceAll(cleanTitle, " ", "-")
1095 cleanTitle = strings.ReplaceAll(cleanTitle, "/", "-")
1096 // Remove special characters
1097 var result strings.Builder
1098 for _, r := range cleanTitle {
1099 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
1100 result.WriteRune(r)
1101 }
1102 }
1103 cleanTitle = result.String()
1104
1105 // Limit length
1106 if len(cleanTitle) > 40 {
1107 cleanTitle = cleanTitle[:40]
1108 }
1109
iomodo1409a182025-07-31 18:07:05 +04001110 // Use parentheses instead of square brackets for task ID to avoid Git branch name restrictions
1111 return fmt.Sprintf("%s/(%s)-%s", prefix, task.ID, cleanTitle)
iomodoa53240a2025-07-30 17:33:35 +04001112}
1113
1114// buildSolutionPRDescription creates PR description from template
1115func buildSolutionPRDescription(task *tm.Task, solution, template, agentName string) string {
1116 // Truncate solution for PR if too long
1117 truncatedSolution := solution
1118 if len(solution) > 1000 {
1119 truncatedSolution = solution[:1000] + "...\n\n*See solution file for complete details*"
1120 }
1121
1122 replacements := map[string]string{
1123 "{task_id}": task.ID,
1124 "{task_title}": task.Title,
1125 "{task_description}": task.Description,
iomodo75542322025-07-30 19:27:48 +04001126 "{agent_name}": agentName,
iomodoa53240a2025-07-30 17:33:35 +04001127 "{priority}": string(task.Priority),
1128 "{solution}": truncatedSolution,
1129 "{files_changed}": fmt.Sprintf("- `tasks/solutions/%s-solution.md`", task.ID),
1130 }
1131
1132 result := template
1133 for placeholder, value := range replacements {
1134 result = strings.ReplaceAll(result, placeholder, value)
1135 }
1136
1137 return result
1138}
1139
iomodo3b89bdf2025-07-25 15:19:22 +04001140// Ensure GitTaskManager implements TaskManager interface
1141var _ tm.TaskManager = (*GitTaskManager)(nil)