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