blob: f7d7a078739fe67dd4d16160a44ad03224ddf5db [file] [log] [blame]
package git_tm
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/iomodo/staff/config"
"github.com/iomodo/staff/git"
"github.com/iomodo/staff/tm"
"gopkg.in/yaml.v3"
)
const (
// File system constants
DefaultFileMode = 0755
TaskFileMode = 0644
TaskFileExt = ".md"
// Frontmatter constants
FrontmatterSeparator = "---\n"
// Task ID format
TaskIDPrefix = "task-"
)
// GitTaskManager implements TaskManager interface using git as the source of truth
type GitTaskManager struct {
git git.GitInterface
repoPath string
tasksDir string
config *config.Config
logger *slog.Logger
}
// NewGitTaskManager creates a new GitTaskManager instance
func NewGitTaskManager(gitInter git.GitInterface, cfg *config.Config, logger *slog.Logger) *GitTaskManager {
return &GitTaskManager{
git: gitInter,
repoPath: cfg.Tasks.StoragePath,
tasksDir: filepath.Join(cfg.Tasks.StoragePath, "tasks"),
config: cfg,
logger: logger,
}
}
// ensureTasksDir ensures the tasks directory exists
func (gtm *GitTaskManager) ensureTasksDir() error {
if err := os.MkdirAll(gtm.tasksDir, DefaultFileMode); err != nil {
return fmt.Errorf("failed to create tasks directory: %w", err)
}
return nil
}
// generateTaskID generates a unique task ID
func (gtm *GitTaskManager) generateTaskID() string {
timestamp := time.Now().Unix()
random := uuid.New().String()[:8]
return fmt.Sprintf("%s%d-%s", TaskIDPrefix, timestamp, random)
}
// taskToMarkdown converts a Task to markdown format
func (gtm *GitTaskManager) taskToMarkdown(task *tm.Task) (string, error) {
// Create frontmatter data
frontmatter := map[string]interface{}{
"id": task.ID,
"title": task.Title,
"description": task.Description,
"owner_id": task.Owner.ID,
"owner_name": task.Owner.Name,
"assignee": task.Assignee,
"status": task.Status,
"priority": task.Priority,
"created_at": task.CreatedAt.Format(time.RFC3339),
"updated_at": task.UpdatedAt.Format(time.RFC3339),
}
if task.DueDate != nil {
frontmatter["due_date"] = task.DueDate.Format(time.RFC3339)
}
if task.CompletedAt != nil {
frontmatter["completed_at"] = task.CompletedAt.Format(time.RFC3339)
}
if task.ArchivedAt != nil {
frontmatter["archived_at"] = task.ArchivedAt.Format(time.RFC3339)
}
// Marshal frontmatter to YAML
yamlData, err := yaml.Marshal(frontmatter)
if err != nil {
return "", fmt.Errorf("failed to marshal frontmatter: %w", err)
}
// Build markdown content
var content strings.Builder
content.WriteString(FrontmatterSeparator)
content.Write(yamlData)
content.WriteString(FrontmatterSeparator)
content.WriteString("\n")
if task.Description != "" {
content.WriteString("# Task Description\n\n")
content.WriteString(task.Description)
content.WriteString("\n\n")
}
return content.String(), nil
}
// parseTaskFromMarkdown parses a Task from markdown format
func (gtm *GitTaskManager) parseTaskFromMarkdown(content string) (*tm.Task, error) {
// Split content into frontmatter and body
parts := strings.SplitN(content, FrontmatterSeparator, 3)
if len(parts) < 3 {
return nil, fmt.Errorf("invalid markdown format: missing frontmatter")
}
// Parse YAML frontmatter
var frontmatter map[string]interface{}
if err := yaml.Unmarshal([]byte(parts[1]), &frontmatter); err != nil {
return nil, fmt.Errorf("failed to parse frontmatter: %w", err)
}
// Extract task data
task := &tm.Task{}
if id, ok := frontmatter["id"].(string); ok {
task.ID = id
}
if title, ok := frontmatter["title"].(string); ok {
task.Title = title
}
if description, ok := frontmatter["description"].(string); ok {
task.Description = description
}
if ownerID, ok := frontmatter["owner_id"].(string); ok {
task.Owner.ID = ownerID
}
if ownerName, ok := frontmatter["owner_name"].(string); ok {
task.Owner.Name = ownerName
}
if assignee, ok := frontmatter["assignee"].(string); ok {
task.Assignee = assignee
}
if status, ok := frontmatter["status"].(string); ok {
task.Status = tm.TaskStatus(status)
}
if priority, ok := frontmatter["priority"].(string); ok {
task.Priority = tm.TaskPriority(priority)
}
// Parse timestamps
if createdAt, ok := frontmatter["created_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
task.CreatedAt = t
}
}
if updatedAt, ok := frontmatter["updated_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
task.UpdatedAt = t
}
}
if dueDate, ok := frontmatter["due_date"].(string); ok {
if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
task.DueDate = &t
}
}
if completedAt, ok := frontmatter["completed_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
task.CompletedAt = &t
}
}
if archivedAt, ok := frontmatter["archived_at"].(string); ok {
if t, err := time.Parse(time.RFC3339, archivedAt); err == nil {
task.ArchivedAt = &t
}
}
return task, nil
}
// readTaskFile reads a task from a file
func (gtm *GitTaskManager) readTaskFile(taskFile string) (*tm.Task, error) {
filePath := filepath.Join(gtm.tasksDir, taskFile+TaskFileExt)
content, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, tm.ErrTaskNotFound
}
return nil, fmt.Errorf("failed to read task file: %w", err)
}
return gtm.parseTaskFromMarkdown(string(content))
}
// writeTaskFile writes a task to a file
func (gtm *GitTaskManager) writeTaskFile(task *tm.Task) error {
content, err := gtm.taskToMarkdown(task)
if err != nil {
return fmt.Errorf("failed to convert task to markdown: %w", err)
}
filePath := filepath.Join(gtm.tasksDir, task.ID+TaskFileExt)
if err := os.WriteFile(filePath, []byte(content), TaskFileMode); err != nil {
return fmt.Errorf("failed to write task file: %w", err)
}
return nil
}
// listTaskFiles returns all task file paths
func (gtm *GitTaskManager) listTaskFiles() ([]string, error) {
entries, err := os.ReadDir(gtm.tasksDir)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("failed to read tasks directory: %w", err)
}
var taskFiles []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), TaskFileExt) {
taskID := strings.TrimSuffix(entry.Name(), TaskFileExt)
taskFiles = append(taskFiles, taskID)
}
}
return taskFiles, nil
}
// commitTaskChange commits a task change to git
func (gtm *GitTaskManager) commitTaskChange(taskID, operation string, owner tm.Owner) error {
ctx := context.Background()
// Add the task file
if err := gtm.git.Add(ctx, []string{filepath.Join(gtm.tasksDir, taskID+TaskFileExt)}); err != nil {
return fmt.Errorf("failed to add task file: %w", err)
}
// Commit the change
message := fmt.Sprintf("task: %s - %s", taskID, operation)
if err := gtm.git.Commit(ctx, message, git.CommitOptions{
Author: &git.Author{
Name: owner.Name,
},
}); err != nil {
return fmt.Errorf("failed to commit task change: %w", err)
}
return nil
}
// CreateTask creates a new task
func (gtm *GitTaskManager) CreateTask(ctx context.Context, req *tm.TaskCreateRequest) (*tm.Task, error) {
if err := gtm.ensureTasksDir(); err != nil {
return nil, err
}
// Validate request
if req.Title == "" {
return nil, tm.ErrInvalidTaskData
}
if req.OwnerID == "" {
return nil, tm.ErrInvalidOwner
}
// Generate task ID
taskID := gtm.generateTaskID()
now := time.Now()
ownerName := (req.OwnerID) //TODO: Get owner name from user service
// Create task
task := &tm.Task{
ID: taskID,
Title: req.Title,
Description: req.Description,
Owner: tm.Owner{
ID: req.OwnerID,
Name: ownerName,
},
Status: tm.StatusToDo,
Priority: req.Priority,
CreatedAt: now,
UpdatedAt: now,
DueDate: req.DueDate,
}
// Write task file
if err := gtm.writeTaskFile(task); err != nil {
return nil, err
}
// Commit to git
if err := gtm.commitTaskChange(taskID, "created", task.Owner); err != nil {
return nil, err
}
return task, nil
}
// GetTask retrieves a task by ID
func (gtm *GitTaskManager) GetTask(id string) (*tm.Task, error) {
return gtm.readTaskFile(id)
}
// UpdateTask updates an existing task
func (gtm *GitTaskManager) UpdateTask(task *tm.Task) error {
// Set update time
task.UpdatedAt = time.Now()
// Write task to file
return gtm.writeTaskFile(task)
}
// readAllTasks reads all task files from disk
func (gtm *GitTaskManager) readAllTasks() ([]*tm.Task, error) {
taskFiles, err := gtm.listTaskFiles()
if err != nil {
return nil, err
}
var tasks []*tm.Task
for _, taskFile := range taskFiles {
filename := filepath.Base(taskFile)
if strings.HasPrefix(filename, "task-") {
task, err := gtm.readTaskFile(taskFile)
if err != nil {
gtm.logger.Warn("Failed to read task file", slog.String("file", taskFile), slog.String("error", err.Error()))
continue
}
tasks = append(tasks, task)
}
}
return tasks, nil
}
// GetTasksByAssignee retrieves tasks assigned to a specific agent (MVP method)
func (gtm *GitTaskManager) GetTasksByAssignee(assignee string) ([]*tm.Task, error) {
// Read all tasks and filter by assignee
tasks, err := gtm.readAllTasks()
if err != nil {
return nil, err
}
var assignedTasks []*tm.Task
for _, task := range tasks {
if task.Assignee == assignee {
assignedTasks = append(assignedTasks, task)
}
}
return assignedTasks, nil
}
// ArchiveTask archives a task
func (gtm *GitTaskManager) ArchiveTask(ctx context.Context, id string) error {
task, err := gtm.GetTask(id)
if err != nil {
return err
}
task.Status = tm.StatusArchived
now := time.Now()
task.ArchivedAt = &now
return gtm.UpdateTask(task)
}
// ListTasks lists tasks with filtering and pagination
func (gtm *GitTaskManager) ListTasks(ctx context.Context, filter *tm.TaskFilter, page, pageSize int) (*tm.TaskList, error) {
// Get all task files
taskFiles, err := gtm.listTaskFiles()
if err != nil {
return nil, err
}
// Read all tasks
var tasks []*tm.Task
for _, taskID := range taskFiles {
task, err := gtm.readTaskFile(taskID)
if err != nil {
continue // Skip corrupted files
}
tasks = append(tasks, task)
}
// Apply filters
if filter != nil {
tasks = gtm.filterTasks(tasks, filter)
}
// Sort by creation date (newest first)
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.After(tasks[j].CreatedAt)
})
// Apply pagination
totalCount := len(tasks)
start := page * pageSize
end := start + pageSize
if start >= totalCount {
return &tm.TaskList{
Tasks: []*tm.Task{},
TotalCount: totalCount,
Page: page,
PageSize: pageSize,
HasMore: false,
}, nil
}
if end > totalCount {
end = totalCount
}
return &tm.TaskList{
Tasks: tasks[start:end],
TotalCount: totalCount,
Page: page,
PageSize: pageSize,
HasMore: end < totalCount,
}, nil
}
// filterTasks applies filters to a list of tasks
func (gtm *GitTaskManager) filterTasks(tasks []*tm.Task, filter *tm.TaskFilter) []*tm.Task {
var filtered []*tm.Task
for _, task := range tasks {
if gtm.taskMatchesFilter(task, filter) {
filtered = append(filtered, task)
}
}
return filtered
}
// taskMatchesFilter checks if a task matches the given filter
func (gtm *GitTaskManager) taskMatchesFilter(task *tm.Task, filter *tm.TaskFilter) bool {
if filter.OwnerID != nil && task.Owner.ID != *filter.OwnerID {
return false
}
if filter.Status != nil && task.Status != *filter.Status {
return false
}
if filter.Priority != nil && task.Priority != *filter.Priority {
return false
}
if filter.DueBefore != nil && (task.DueDate == nil || !task.DueDate.Before(*filter.DueBefore)) {
return false
}
if filter.DueAfter != nil && (task.DueDate == nil || !task.DueDate.After(*filter.DueAfter)) {
return false
}
if filter.CreatedAfter != nil && !task.CreatedAt.After(*filter.CreatedAfter) {
return false
}
if filter.CreatedBefore != nil && !task.CreatedAt.Before(*filter.CreatedBefore) {
return false
}
return true
}
// StartTask starts a task (changes status to in_progress)
func (gtm *GitTaskManager) StartTask(ctx context.Context, id string) (*tm.Task, error) {
task, err := gtm.GetTask(id)
if err != nil {
return nil, err
}
task.Status = tm.StatusInProgress
err = gtm.UpdateTask(task)
if err != nil {
return nil, err
}
return task, nil
}
// CompleteTask completes a task (changes status to completed)
func (gtm *GitTaskManager) CompleteTask(ctx context.Context, id string) (*tm.Task, error) {
task, err := gtm.GetTask(id)
if err != nil {
return nil, err
}
task.Status = tm.StatusCompleted
now := time.Now()
task.CompletedAt = &now
err = gtm.UpdateTask(task)
if err != nil {
return nil, err
}
return task, nil
}
// GetTasksByOwner gets tasks for a specific owner
func (gtm *GitTaskManager) GetTasksByOwner(ctx context.Context, ownerID string, page, pageSize int) (*tm.TaskList, error) {
filter := &tm.TaskFilter{
OwnerID: &ownerID,
}
return gtm.ListTasks(ctx, filter, page, pageSize)
}
// GetTasksByStatus gets tasks with a specific status
func (gtm *GitTaskManager) GetTasksByStatus(ctx context.Context, status tm.TaskStatus, page, pageSize int) (*tm.TaskList, error) {
filter := &tm.TaskFilter{
Status: &status,
}
return gtm.ListTasks(ctx, filter, page, pageSize)
}
// GetTasksByPriority gets tasks with a specific priority
func (gtm *GitTaskManager) GetTasksByPriority(ctx context.Context, priority tm.TaskPriority, page, pageSize int) (*tm.TaskList, error) {
filter := &tm.TaskFilter{
Priority: &priority,
}
return gtm.ListTasks(ctx, filter, page, pageSize)
}
// GenerateSubtaskPR creates a PR with the proposed subtasks
func (gtm *GitTaskManager) ProposeSubTasks(ctx context.Context, task *tm.Task, analysis *tm.SubtaskAnalysis, agentName string) (string, error) {
branchName := generateBranchName("subtasks", task)
gtm.logger.Info("Creating subtask PR", slog.String("branch", branchName))
// Create Git branch and commit subtask proposal
if err := gtm.createSubtaskBranch(ctx, analysis, branchName, agentName); err != nil {
return "", fmt.Errorf("failed to create subtask branch: %w", err)
}
// Generate PR content
prContent := gtm.generateSubtaskPRContent(analysis)
title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
// Validate PR content
if title == "" {
return "", fmt.Errorf("PR title cannot be empty")
}
if prContent == "" {
return "", fmt.Errorf("PR description cannot be empty")
}
// Determine base branch (try main first, fallback to master)
baseBranch := gtm.determineBaseBranch(ctx, agentName)
gtm.logger.Info("Using base branch", slog.String("base_branch", baseBranch))
// Create the pull request
options := git.PullRequestOptions{
Title: title,
Description: prContent,
HeadBranch: branchName,
BaseBranch: baseBranch,
Labels: []string{"subtasks", "proposal", "ai-generated"},
Draft: false,
}
gtm.logger.Info("Creating PR with options",
slog.String("title", options.Title),
slog.String("head_branch", options.HeadBranch),
slog.String("base_branch", options.BaseBranch))
pr, err := gtm.git.CreatePullRequest(ctx, options)
if err != nil {
return "", fmt.Errorf("failed to create PR: %w", err)
}
gtm.logger.Info("Generated subtask proposal PR", slog.String("pr_url", pr.URL))
return pr.URL, nil
}
func (gtm *GitTaskManager) ProposeSolution(ctx context.Context, task *tm.Task, solution, agentName string) (string, error) {
branchName := generateBranchName("solution", task)
gtm.logger.Info("Creating solution PR", slog.String("branch", branchName))
if err := gtm.createSolutionBranch(ctx, task, solution, branchName, agentName); err != nil {
return "", fmt.Errorf("failed to create solution branch: %w", err)
}
// Build PR description from template
description := buildSolutionPRDescription(task, solution, gtm.config.Git.PRTemplate, agentName)
options := git.PullRequestOptions{
Title: fmt.Sprintf("Task %s: %s", task.ID, task.Title),
Description: description,
HeadBranch: branchName,
BaseBranch: "main",
Labels: []string{"ai-generated"},
Draft: false,
}
pr, err := gtm.git.CreatePullRequest(ctx, options)
if err != nil {
return "", fmt.Errorf("failed to create PR: %w", err)
}
gtm.logger.Info("Generated subtask proposal PR", slog.String("pr_url", pr.URL))
return pr.URL, nil
}
func (gtm *GitTaskManager) Close() error {
return gtm.git.CleanupAllClones()
}
// createSubtaskBranch creates a Git branch with subtask proposal content
func (gtm *GitTaskManager) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName, agentName string) error {
clonePath, err := gtm.git.GetAgentClonePath(agentName)
if err != nil {
return fmt.Errorf("failed to get clone path: %w", err)
}
// All Git operations use the clone directory
gitCmd := func(args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
}
// Ensure we're on main branch before creating new branch
cmd := gitCmd("checkout", "main")
if err := cmd.Run(); err != nil {
// Try master branch if main doesn't exist
cmd = gitCmd("checkout", "master")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to checkout main/master branch: %w", err)
}
}
// Pull latest changes
cmd = gitCmd("pull", "origin")
if err := cmd.Run(); err != nil {
gtm.logger.Warn("Failed to pull latest changes", slog.String("error", err.Error()))
}
// Delete branch if it exists (cleanup from previous attempts)
cmd = gitCmd("branch", "-D", branchName)
_ = cmd.Run() // Ignore error if branch doesn't exist
// Also delete remote tracking branch if it exists
cmd = gitCmd("push", "origin", "--delete", branchName)
_ = cmd.Run() // Ignore error if branch doesn't exist
// Create and checkout new branch
cmd = gitCmd("checkout", "-b", branchName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to create branch: %w", err)
}
// Create individual task files for each subtask
tasksDir := filepath.Join(clonePath, "operations", "tasks")
if err := os.MkdirAll(tasksDir, 0755); err != nil {
return fmt.Errorf("failed to create tasks directory: %w", err)
}
var stagedFiles []string
// Update parent task to mark as completed
parentTaskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", analysis.ParentTaskID))
if err := gtm.updateParentTaskAsCompleted(parentTaskFile, analysis); err != nil {
return fmt.Errorf("failed to update parent task: %w", err)
}
// Track parent task file for staging
parentRelativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", analysis.ParentTaskID))
stagedFiles = append(stagedFiles, parentRelativeFile)
gtm.logger.Info("Updated parent task file", slog.String("file", parentRelativeFile))
// Create a file for each subtask
for i, subtask := range analysis.Subtasks {
taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
taskFile := filepath.Join(tasksDir, fmt.Sprintf("%s.md", taskID))
taskContent := gtm.generateSubtaskFile(subtask, taskID, analysis.ParentTaskID)
if err := os.WriteFile(taskFile, []byte(taskContent), 0644); err != nil {
return fmt.Errorf("failed to write subtask file %s: %w", taskID, err)
}
// Track file for staging
relativeFile := filepath.Join("operations", "tasks", fmt.Sprintf("%s.md", taskID))
stagedFiles = append(stagedFiles, relativeFile)
gtm.logger.Info("Created subtask file", slog.String("file", relativeFile))
}
// Stage all subtask files
for _, file := range stagedFiles {
cmd = gitCmd("add", file)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to stage file %s: %w", file, err)
}
}
// Commit changes
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",
len(analysis.Subtasks), analysis.ParentTaskID, analysis.ParentTaskID)
// Add list of created files to commit message
for i := range analysis.Subtasks {
taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
commitMsg += fmt.Sprintf("- %s.md\n", taskID)
}
if len(analysis.AgentCreations) > 0 {
commitMsg += fmt.Sprintf("\nProposed %d new agents for specialized skills", len(analysis.AgentCreations))
}
cmd = gitCmd("commit", "-m", commitMsg)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit: %w", err)
}
// Push branch
cmd = gitCmd("push", "-u", "origin", branchName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to push branch: %w", err)
}
gtm.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
return nil
}
// updateParentTaskAsCompleted updates the parent task file to mark it as completed
func (gtm *GitTaskManager) updateParentTaskAsCompleted(taskFilePath string, analysis *tm.SubtaskAnalysis) error {
// Read the existing parent task file
content, err := os.ReadFile(taskFilePath)
if err != nil {
return fmt.Errorf("failed to read parent task file: %w", err)
}
taskContent := string(content)
// Find the YAML frontmatter boundaries
lines := strings.Split(taskContent, "\n")
var frontmatterStart, frontmatterEnd int = -1, -1
for i, line := range lines {
if line == "---" {
if frontmatterStart == -1 {
frontmatterStart = i
} else {
frontmatterEnd = i
break
}
}
}
if frontmatterStart == -1 || frontmatterEnd == -1 {
return fmt.Errorf("invalid task file format: missing YAML frontmatter")
}
// Update the frontmatter
now := time.Now().Format(time.RFC3339)
var updatedLines []string
// Add lines before frontmatter
updatedLines = append(updatedLines, lines[:frontmatterStart+1]...)
// Process frontmatter lines
for i := frontmatterStart + 1; i < frontmatterEnd; i++ {
line := lines[i]
if strings.HasPrefix(line, "status:") {
updatedLines = append(updatedLines, "status: completed")
} else if strings.HasPrefix(line, "updated_at:") {
updatedLines = append(updatedLines, fmt.Sprintf("updated_at: %s", now))
} else if strings.HasPrefix(line, "completed_at:") {
updatedLines = append(updatedLines, fmt.Sprintf("completed_at: %s", now))
} else {
updatedLines = append(updatedLines, line)
}
}
// Add closing frontmatter and rest of content
updatedLines = append(updatedLines, lines[frontmatterEnd:]...)
// Add subtask information to the task description
if frontmatterEnd+1 < len(lines) {
// Add subtask information
subtaskInfo := fmt.Sprintf("\n\n## Subtasks Created\n\nThis task has been broken down into %d subtasks:\n\n", len(analysis.Subtasks))
for i, subtask := range analysis.Subtasks {
subtaskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
subtaskInfo += fmt.Sprintf("- **%s**: %s (assigned to %s)\n", subtaskID, subtask.Title, subtask.AssignedTo)
}
subtaskInfo += fmt.Sprintf("\n**Total Estimated Hours:** %d\n", analysis.EstimatedTotalHours)
subtaskInfo += fmt.Sprintf("**Completed:** %s - Task broken down into actionable subtasks\n", now)
// Insert subtask info before any existing body content
updatedContent := strings.Join(updatedLines[:], "\n") + subtaskInfo
// Write the updated content back to the file
if err := os.WriteFile(taskFilePath, []byte(updatedContent), 0644); err != nil {
return fmt.Errorf("failed to write updated parent task file: %w", err)
}
}
gtm.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
return nil
}
// generateSubtaskFile creates the content for an individual subtask file
func (gtm *GitTaskManager) generateSubtaskFile(subtask tm.SubtaskProposal, taskID, parentTaskID string) string {
var content strings.Builder
// Generate YAML frontmatter
content.WriteString("---\n")
content.WriteString(fmt.Sprintf("id: %s\n", taskID))
content.WriteString(fmt.Sprintf("title: %s\n", subtask.Title))
content.WriteString(fmt.Sprintf("description: %s\n", subtask.Description))
content.WriteString(fmt.Sprintf("assignee: %s\n", subtask.AssignedTo))
content.WriteString(fmt.Sprintf("owner_id: %s\n", subtask.AssignedTo))
content.WriteString(fmt.Sprintf("owner_name: %s\n", subtask.AssignedTo))
content.WriteString("status: todo\n")
content.WriteString(fmt.Sprintf("priority: %s\n", strings.ToLower(string(subtask.Priority))))
content.WriteString(fmt.Sprintf("parent_task_id: %s\n", parentTaskID))
content.WriteString(fmt.Sprintf("estimated_hours: %d\n", subtask.EstimatedHours))
content.WriteString(fmt.Sprintf("created_at: %s\n", time.Now().Format(time.RFC3339)))
content.WriteString(fmt.Sprintf("updated_at: %s\n", time.Now().Format(time.RFC3339)))
content.WriteString("completed_at: null\n")
content.WriteString("archived_at: null\n")
// Add dependencies if any
if len(subtask.Dependencies) > 0 {
content.WriteString("dependencies:\n")
for _, dep := range subtask.Dependencies {
// Convert dependency index to actual subtask ID
if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
content.WriteString(fmt.Sprintf(" - %s\n", depTaskID))
}
}
}
// Add required skills if any
if len(subtask.RequiredSkills) > 0 {
content.WriteString("required_skills:\n")
for _, skill := range subtask.RequiredSkills {
content.WriteString(fmt.Sprintf(" - %s\n", skill))
}
}
content.WriteString("---\n\n")
// Add markdown content
content.WriteString("# Task Description\n\n")
content.WriteString(fmt.Sprintf("%s\n\n", subtask.Description))
if subtask.EstimatedHours > 0 {
content.WriteString("## Estimated Effort\n\n")
content.WriteString(fmt.Sprintf("**Estimated Hours:** %d\n\n", subtask.EstimatedHours))
}
if len(subtask.RequiredSkills) > 0 {
content.WriteString("## Required Skills\n\n")
for _, skill := range subtask.RequiredSkills {
content.WriteString(fmt.Sprintf("- %s\n", skill))
}
content.WriteString("\n")
}
if len(subtask.Dependencies) > 0 {
content.WriteString("## Dependencies\n\n")
content.WriteString("This task depends on the completion of:\n\n")
for _, dep := range subtask.Dependencies {
if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
depTaskID := fmt.Sprintf("%s-subtask-%d", parentTaskID, depIndex+1)
content.WriteString(fmt.Sprintf("- %s\n", depTaskID))
}
}
content.WriteString("\n")
}
content.WriteString("## Notes\n\n")
content.WriteString(fmt.Sprintf("This subtask was generated from parent task: %s\n", parentTaskID))
content.WriteString("Generated by Staff AI Agent System\n\n")
return content.String()
}
func (gtm *GitTaskManager) generateSubtaskPRContent(analysis *tm.SubtaskAnalysis) string {
var content strings.Builder
content.WriteString(fmt.Sprintf("# Subtasks Created for Task %s\n\n", analysis.ParentTaskID))
content.WriteString(fmt.Sprintf("This PR creates **%d individual task files** in `/operations/tasks/` ready for agent assignment.\n\n", len(analysis.Subtasks)))
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))
content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
// List the created task files
content.WriteString("## Created Task Files\n\n")
for i, subtask := range analysis.Subtasks {
taskID := fmt.Sprintf("%s-subtask-%d", analysis.ParentTaskID, i+1)
content.WriteString(fmt.Sprintf("### %d. `%s.md`\n", i+1, taskID))
content.WriteString(fmt.Sprintf("- **Title:** %s\n", subtask.Title))
content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
}
if analysis.RiskAssessment != "" {
content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
}
content.WriteString("## Proposed Subtasks\n\n")
for i, subtask := range analysis.Subtasks {
content.WriteString(fmt.Sprintf("### %d. %s\n", i+1, subtask.Title))
content.WriteString(fmt.Sprintf("- **Assigned to:** %s\n", subtask.AssignedTo))
content.WriteString(fmt.Sprintf("- **Priority:** %s\n", subtask.Priority))
content.WriteString(fmt.Sprintf("- **Estimated Hours:** %d\n", subtask.EstimatedHours))
if len(subtask.Dependencies) > 0 {
deps := strings.Join(subtask.Dependencies, ", ")
content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", deps))
}
content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
}
content.WriteString("---\n")
content.WriteString("*Generated by Staff AI Agent System*\n\n")
content.WriteString("**Instructions:**\n")
content.WriteString("- Review the proposed subtasks\n")
content.WriteString("- Approve or request changes\n")
content.WriteString("- When merged, the subtasks will be automatically created and assigned\n")
return content.String()
}
func (gtm *GitTaskManager) determineBaseBranch(ctx context.Context, agentName string) string {
// Get clone path to check branches
clonePath, err := gtm.git.GetAgentClonePath(agentName)
if err != nil {
gtm.logger.Warn("Failed to get clone path for base branch detection", slog.String("error", err.Error()))
return "main"
}
// Check if main branch exists
gitCmd := func(args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
}
// Try to checkout main branch
cmd := gitCmd("show-ref", "refs/remotes/origin/main")
if err := cmd.Run(); err == nil {
return "main"
}
// Try to checkout master branch
cmd = gitCmd("show-ref", "refs/remotes/origin/master")
if err := cmd.Run(); err == nil {
return "master"
}
// Default to main if neither can be detected
gtm.logger.Warn("Could not determine base branch, defaulting to 'main'")
return "main"
}
// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
func (gtm *GitTaskManager) createSolutionBranch(ctx context.Context, task *tm.Task, solution, branchName, agentName string) error {
// Get agent's dedicated Git clone
clonePath, err := gtm.git.GetAgentClonePath(agentName)
if err != nil {
return fmt.Errorf("failed to get agent clone: %w", err)
}
gtm.logger.Info("Agent working in clone",
slog.String("agent", agentName),
slog.String("clone_path", clonePath))
// Refresh the clone with latest changes
if err := gtm.git.RefreshAgentClone(agentName); err != nil {
gtm.logger.Warn("Failed to refresh clone for agent",
slog.String("agent", agentName),
slog.String("error", err.Error()))
}
// All Git operations use the agent's clone directory
gitCmd := func(args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
}
// Ensure we're on main branch before creating new branch
cmd := gitCmd("checkout", "main")
if err := cmd.Run(); err != nil {
// Try master branch if main doesn't exist
cmd = gitCmd("checkout", "master")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to checkout main/master branch: %w", err)
}
}
// Create branch
cmd = gitCmd("checkout", "-b", branchName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to create branch: %w", err)
}
// Create solution file in agent's clone
solutionDir := filepath.Join(clonePath, "tasks", "solutions")
if err := os.MkdirAll(solutionDir, 0755); err != nil {
return fmt.Errorf("failed to create solution directory: %w", err)
}
solutionFile := filepath.Join(solutionDir, fmt.Sprintf("%s-solution.md", task.ID))
solutionContent := fmt.Sprintf(`# Solution for Task: %s
**Agent:** %s
**Completed:** %s
## Task Description
%s
## Solution
%s
---
*Generated by Staff AI Agent System*
`, task.Title, agentName, time.Now().Format(time.RFC3339), task.Description, solution)
if err := os.WriteFile(solutionFile, []byte(solutionContent), 0644); err != nil {
return fmt.Errorf("failed to write solution file: %w", err)
}
// Stage files
relativeSolutionFile := filepath.Join("tasks", "solutions", fmt.Sprintf("%s-solution.md", task.ID))
cmd = gitCmd("add", relativeSolutionFile)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to stage files: %w", err)
}
// Commit changes
commitMsg := buildCommitMessage(task, gtm.config.Git.CommitMessageTemplate, agentName)
cmd = gitCmd("commit", "-m", commitMsg)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit: %w", err)
}
// Push branch
cmd = gitCmd("push", "-u", "origin", branchName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to push branch: %w", err)
}
gtm.logger.Info("Agent successfully pushed branch",
slog.String("agent", agentName),
slog.String("branch", branchName))
return nil
}
func buildCommitMessage(task *tm.Task, template, agentName string) string {
replacements := map[string]string{
"{task_id}": task.ID,
"{task_title}": task.Title,
"{agent_name}": agentName,
"{solution}": "See solution file for details",
}
result := template
for placeholder, value := range replacements {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
// parseDependencyIndex parses a dependency string to an integer index
func parseDependencyIndex(dep string) int {
var idx int
if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
return idx
}
return -1 // Invalid dependency format
}
// generateBranchName creates a Git branch name for the task
func generateBranchName(prefix string, task *tm.Task) string {
// Clean title for use in branch name
cleanTitle := strings.ToLower(task.Title)
cleanTitle = strings.ReplaceAll(cleanTitle, " ", "-")
cleanTitle = strings.ReplaceAll(cleanTitle, "/", "-")
// Remove special characters
var result strings.Builder
for _, r := range cleanTitle {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
}
}
cleanTitle = result.String()
// Limit length
if len(cleanTitle) > 40 {
cleanTitle = cleanTitle[:40]
}
return fmt.Sprintf("%s/[%s]-%s", prefix, task.ID, cleanTitle)
}
// buildSolutionPRDescription creates PR description from template
func buildSolutionPRDescription(task *tm.Task, solution, template, agentName string) string {
// Truncate solution for PR if too long
truncatedSolution := solution
if len(solution) > 1000 {
truncatedSolution = solution[:1000] + "...\n\n*See solution file for complete details*"
}
replacements := map[string]string{
"{task_id}": task.ID,
"{task_title}": task.Title,
"{task_description}": task.Description,
"{agent_name}": agentName,
"{priority}": string(task.Priority),
"{solution}": truncatedSolution,
"{files_changed}": fmt.Sprintf("- `tasks/solutions/%s-solution.md`", task.ID),
}
result := template
for placeholder, value := range replacements {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
// Ensure GitTaskManager implements TaskManager interface
var _ tm.TaskManager = (*GitTaskManager)(nil)