Refactor everything
Change-Id: Ic3a37c38cfecba943c91f6ae545ce1c5b551c0d5
diff --git a/server/tm/git_tm/example.go b/server/tm/git_tm/example.go
deleted file mode 100644
index e1b0412..0000000
--- a/server/tm/git_tm/example.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package git_tm
-
-import (
- "context"
- "fmt"
- "log/slog"
- "os"
- "time"
-
- "github.com/iomodo/staff/git"
- "github.com/iomodo/staff/tm"
-)
-
-// Example demonstrates how to use the GitTaskManager
-func Example() {
- // Create logger
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- // Initialize git interface
- gitInterface := git.DefaultGit("./tasks-repo")
-
- // Create task manager
- taskManager := NewGitTaskManagerWithLogger(gitInterface, "./tasks-repo", logger)
-
- // Create a new task
- ctx := context.Background()
- dueDate := time.Now().AddDate(0, 0, 7) // Due in 7 days
-
- createReq := &tm.TaskCreateRequest{
- Title: "Implement user authentication",
- Description: "Add login/logout functionality with JWT tokens",
- OwnerID: "john.doe",
- Priority: tm.PriorityHigh,
- DueDate: &dueDate,
- }
-
- task, err := taskManager.CreateTask(ctx, createReq)
- if err != nil {
- logger.Error("Failed to create task", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Created task", slog.String("id", task.ID))
-
- // Get the task
- retrievedTask, err := taskManager.GetTask(task.ID)
- if err != nil {
- logger.Error("Failed to get task", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Retrieved task", slog.String("id", retrievedTask.ID), slog.String("title", retrievedTask.Title))
-
- // Start the task
- startedTask, err := taskManager.StartTask(ctx, task.ID)
- if err != nil {
- logger.Error("Failed to start task", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Started task", slog.String("id", startedTask.ID), slog.String("status", string(startedTask.Status)))
-
- // List all tasks
- taskList, err := taskManager.ListTasks(ctx, nil, 0, 10)
- if err != nil {
- logger.Error("Failed to list tasks", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Total tasks", slog.Int("count", taskList.TotalCount))
- for _, t := range taskList.Tasks {
- logger.Info("Task", slog.String("id", t.ID), slog.String("title", t.Title), slog.String("status", string(t.Status)))
- }
-
- // Complete the task
- completedTask, err := taskManager.CompleteTask(ctx, task.ID)
- if err != nil {
- logger.Error("Failed to complete task", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Completed task", slog.String("id", completedTask.ID), slog.String("completed_at", completedTask.CompletedAt.Format(time.RFC3339)))
-}
-
-// ExampleTaskFile shows the format of a task file
-func ExampleTaskFile() {
- fmt.Print(`Example task file (tasks/task-1704067200-abc123.md):
-
----
-id: task-1704067200-abc123
-title: Implement user authentication
-description: Add login/logout functionality with JWT tokens
-owner_id: john.doe
-owner_name: John Doe
-status: in_progress
-priority: high
-created_at: 2024-01-01T10:00:00Z
-updated_at: 2024-01-01T15:30:00Z
-due_date: 2024-01-08T17:00:00Z
-completed_at: null
-archived_at: null
----
-
-# Task Description
-
-Add login/logout functionality with JWT tokens for the web application.
-
-## Requirements
-
-- User registration and login forms
-- JWT token generation and validation
-- Password hashing with bcrypt
-- Session management
-- Logout functionality
-
-## Notes
-
-- Consider using bcrypt for password hashing
-- Implement refresh token mechanism
-- Add rate limiting for login attempts
-`)
-}
diff --git a/server/tm/git_tm/git_task_manager.go b/server/tm/git_tm/git_task_manager.go
index 515fa51..cca9da8 100644
--- a/server/tm/git_tm/git_task_manager.go
+++ b/server/tm/git_tm/git_task_manager.go
@@ -5,12 +5,14 @@
"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"
@@ -29,68 +31,23 @@
TaskIDPrefix = "task-"
)
-// UserService defines interface for user-related operations
-type UserService interface {
- GetUserName(userID string) (string, error)
-}
-
-// DefaultUserService provides a simple implementation that uses userID as name
-type DefaultUserService struct{}
-
-func (dus *DefaultUserService) GetUserName(userID string) (string, error) {
- // For now, just return the userID as the name
- // This can be enhanced to lookup from a proper user service
- return userID, nil
-}
-
// GitTaskManager implements TaskManager interface using git as the source of truth
type GitTaskManager struct {
- git git.GitInterface
- repoPath string
- tasksDir string
- logger *slog.Logger
- userService UserService
+ git git.GitInterface
+ repoPath string
+ tasksDir string
+ config *config.Config
+ logger *slog.Logger
}
// NewGitTaskManager creates a new GitTaskManager instance
-func NewGitTaskManager(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
+func NewGitTaskManager(gitInter git.GitInterface, cfg *config.Config, logger *slog.Logger) *GitTaskManager {
return &GitTaskManager{
- git: git,
- repoPath: repoPath,
- tasksDir: filepath.Join(repoPath, "tasks"),
- logger: logger,
- userService: &DefaultUserService{},
- }
-}
-
-// NewGitTaskManagerWithLogger creates a new GitTaskManager instance with a custom logger
-func NewGitTaskManagerWithLogger(git git.GitInterface, repoPath string, logger *slog.Logger) *GitTaskManager {
- if logger == nil {
- logger = slog.Default()
- }
- return &GitTaskManager{
- git: git,
- repoPath: repoPath,
- tasksDir: filepath.Join(repoPath, "tasks"),
- logger: logger,
- userService: &DefaultUserService{},
- }
-}
-
-// NewGitTaskManagerWithUserService creates a new GitTaskManager with custom user service
-func NewGitTaskManagerWithUserService(git git.GitInterface, repoPath string, logger *slog.Logger, userService UserService) *GitTaskManager {
- if logger == nil {
- logger = slog.Default()
- }
- if userService == nil {
- userService = &DefaultUserService{}
- }
- return &GitTaskManager{
- git: git,
- repoPath: repoPath,
- tasksDir: filepath.Join(repoPath, "tasks"),
- logger: logger,
- userService: userService,
+ git: gitInter,
+ repoPath: cfg.Tasks.StoragePath,
+ tasksDir: filepath.Join(cfg.Tasks.StoragePath, "tasks"),
+ config: cfg,
+ logger: logger,
}
}
@@ -320,12 +277,7 @@
taskID := gtm.generateTaskID()
now := time.Now()
- // Get owner name from user service
- ownerName, err := gtm.userService.GetUserName(req.OwnerID)
- if err != nil {
- gtm.logger.Warn("Failed to get owner name, using ID", slog.String("ownerID", req.OwnerID), slog.String("error", err.Error()))
- ownerName = req.OwnerID
- }
+ ownerName := (req.OwnerID) //TODO: Get owner name from user service
// Create task
task := &tm.Task{
@@ -581,5 +533,600 @@
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) (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); 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)
+ 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
+}
+
+// createSubtaskBranch creates a Git branch with subtask proposal content
+func (gtm *GitTaskManager) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error {
+ clonePath, err := gtm.git.GetAgentClonePath("subtask-service")
+ 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) string {
+ // Get clone path to check branches
+ clonePath, err := gtm.git.GetAgentClonePath("subtask-service")
+ 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}": fmt.Sprintf("%s", 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)
diff --git a/server/tm/git_tm/git_task_manager_test.go b/server/tm/git_tm/git_task_manager_test.go
deleted file mode 100644
index 751ad89..0000000
--- a/server/tm/git_tm/git_task_manager_test.go
+++ /dev/null
@@ -1,1037 +0,0 @@
-package git_tm
-
-import (
- "context"
- "log/slog"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/iomodo/staff/git"
- "github.com/iomodo/staff/tm"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-// Test helper functions
-func setupTestDir(t *testing.T) (string, func()) {
- tempDir, err := os.MkdirTemp("", "git-task-manager-test")
- require.NoError(t, err)
-
- cleanup := func() {
- os.RemoveAll(tempDir)
- }
-
- return tempDir, cleanup
-}
-
-func createTestTaskManager(t *testing.T, repoPath string) (*GitTaskManager, git.GitInterface) {
- // Initialize git repository
- gitImpl := git.DefaultGit(repoPath)
- ctx := context.Background()
-
- err := gitImpl.Init(ctx, repoPath)
- require.NoError(t, err)
-
- // Set up git user config for commits
- userConfig := git.UserConfig{
- Name: "Test User",
- Email: "test@example.com",
- }
- err = gitImpl.SetUserConfig(ctx, userConfig)
- require.NoError(t, err)
-
- // Create logger for testing
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- gtm := NewGitTaskManagerWithLogger(gitImpl, repoPath, logger)
- return gtm, gitImpl
-}
-
-// Test cases
-func TestNewGitTaskManager(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gitImpl := git.DefaultGit(tempDir)
-
- // Create logger for testing
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- gtm := NewGitTaskManagerWithLogger(gitImpl, tempDir, logger)
-
- assert.NotNil(t, gtm)
- assert.Equal(t, gitImpl, gtm.git)
- assert.Equal(t, tempDir, gtm.repoPath)
- assert.Equal(t, filepath.Join(tempDir, "tasks"), gtm.tasksDir)
-}
-
-func TestEnsureTasksDir(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Test creating tasks directory
- err := gtm.ensureTasksDir()
- assert.NoError(t, err)
-
- // Verify directory exists
- _, err = os.Stat(gtm.tasksDir)
- assert.NoError(t, err)
-
- // Test creating again (should not error)
- err = gtm.ensureTasksDir()
- assert.NoError(t, err)
-}
-
-func TestGenerateTaskID(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- id1 := gtm.generateTaskID()
- id2 := gtm.generateTaskID()
-
- assert.NotEmpty(t, id1)
- assert.NotEmpty(t, id2)
- assert.NotEqual(t, id1, id2)
- assert.Contains(t, id1, "task-")
-}
-
-func TestTaskToMarkdown(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- now := time.Now()
- dueDate := now.Add(24 * time.Hour)
- completedAt := now.Add(12 * time.Hour)
-
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "This is a test task",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityHigh,
- CreatedAt: now,
- UpdatedAt: now,
- DueDate: &dueDate,
- CompletedAt: &completedAt,
- }
-
- markdown, err := gtm.taskToMarkdown(task)
- assert.NoError(t, err)
- assert.NotEmpty(t, markdown)
- assert.Contains(t, markdown, "---")
- assert.Contains(t, markdown, "id: test-task-123")
- assert.Contains(t, markdown, "title: Test Task")
- assert.Contains(t, markdown, "description: This is a test task")
- assert.Contains(t, markdown, "owner_id: user123")
- assert.Contains(t, markdown, "owner_name: Test User")
- assert.Contains(t, markdown, "status: todo")
- assert.Contains(t, markdown, "priority: high")
- assert.Contains(t, markdown, "# Task Description")
- assert.Contains(t, markdown, "This is a test task")
-}
-
-func TestParseTaskFromMarkdown(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- markdown := `---
-id: test-task-123
-title: Test Task
-description: This is a test task
-owner_id: user123
-owner_name: Test User
-status: todo
-priority: high
-created_at: 2023-01-01T00:00:00Z
-updated_at: 2023-01-01T00:00:00Z
-due_date: 2023-01-02T00:00:00Z
-completed_at: 2023-01-01T12:00:00Z
----
-
-# Task Description
-
-This is a test task
-`
-
- task, err := gtm.parseTaskFromMarkdown(markdown)
- assert.NoError(t, err)
- assert.NotNil(t, task)
- assert.Equal(t, "test-task-123", task.ID)
- assert.Equal(t, "Test Task", task.Title)
- assert.Equal(t, "This is a test task", task.Description)
- assert.Equal(t, "user123", task.Owner.ID)
- assert.Equal(t, "Test User", task.Owner.Name)
- assert.Equal(t, tm.StatusToDo, task.Status)
- assert.Equal(t, tm.PriorityHigh, task.Priority)
-}
-
-func TestParseTaskFromMarkdownInvalid(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Test invalid markdown format
- invalidMarkdown := "This is not valid markdown"
-
- task, err := gtm.parseTaskFromMarkdown(invalidMarkdown)
- assert.Error(t, err)
- assert.Nil(t, task)
- assert.Contains(t, err.Error(), "invalid markdown format")
-}
-
-func TestWriteAndReadTaskFile(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Ensure tasks directory exists
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
-
- // Create test task
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "This is a test task",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityHigh,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- // Write task file
- err = gtm.writeTaskFile(task)
- assert.NoError(t, err)
-
- // Verify file exists
- filePath := filepath.Join(gtm.tasksDir, task.ID+".md")
- _, err = os.Stat(filePath)
- assert.NoError(t, err)
-
- // Read task file
- readTask, err := gtm.readTaskFile(task.ID)
- assert.NoError(t, err)
- assert.NotNil(t, readTask)
- assert.Equal(t, task.ID, readTask.ID)
- assert.Equal(t, task.Title, readTask.Title)
- assert.Equal(t, task.Description, readTask.Description)
- assert.Equal(t, task.Owner.ID, readTask.Owner.ID)
- assert.Equal(t, task.Owner.Name, readTask.Owner.Name)
- assert.Equal(t, task.Status, readTask.Status)
- assert.Equal(t, task.Priority, readTask.Priority)
-}
-
-func TestReadTaskFileNotFound(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Try to read non-existent task
- task, err := gtm.readTaskFile("non-existent-task")
- assert.Error(t, err)
- assert.Nil(t, task)
- assert.Equal(t, tm.ErrTaskNotFound, err)
-}
-
-func TestListTaskFiles(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Ensure tasks directory exists
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
-
- // Create some test task files
- taskIDs := []string{"task-1", "task-2", "task-3"}
- for _, id := range taskIDs {
- task := &tm.Task{
- ID: id,
- Title: "Test Task " + id,
- Description: "Test task description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
- }
-
- // Create a non-task file
- nonTaskFile := filepath.Join(gtm.tasksDir, "readme.txt")
- err = os.WriteFile(nonTaskFile, []byte("This is not a task"), 0644)
- require.NoError(t, err)
-
- // List task files
- taskFiles, err := gtm.listTaskFiles()
- assert.NoError(t, err)
- assert.Len(t, taskFiles, 3)
-
- // Verify all task IDs are present
- for _, id := range taskIDs {
- assert.Contains(t, taskFiles, id)
- }
-}
-
-func TestListTaskFilesEmpty(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // List task files in non-existent directory
- taskFiles, err := gtm.listTaskFiles()
- assert.NoError(t, err)
- assert.Empty(t, taskFiles)
-}
-
-func TestCommitTaskChange(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, gitImpl := createTestTaskManager(t, tempDir)
-
- // Create a test task file first
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
-
- // Test successful commit
- err = gtm.commitTaskChange("test-task-123", "created", task.Owner)
- assert.NoError(t, err)
-
- // Verify commit was created
- ctx := context.Background()
- commits, err := gitImpl.Log(ctx, git.LogOptions{MaxCount: 1})
- assert.NoError(t, err)
- if len(commits) > 0 {
- assert.Contains(t, commits[0].Message, "test-task-123")
- assert.Contains(t, commits[0].Message, "created")
- }
-}
-
-func TestCreateTask(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, gitImpl := createTestTaskManager(t, tempDir)
-
- ctx := context.Background()
- req := &tm.TaskCreateRequest{
- Title: "New Test Task",
- Description: "This is a new test task",
- OwnerID: "user123",
- Priority: tm.PriorityHigh,
- }
-
- task, err := gtm.CreateTask(ctx, req)
- assert.NoError(t, err)
- assert.NotNil(t, task)
-
- // Verify task properties
- assert.NotEmpty(t, task.ID)
- assert.Contains(t, task.ID, "task-")
- assert.Equal(t, req.Title, task.Title)
- assert.Equal(t, req.Description, task.Description)
- assert.Equal(t, req.OwnerID, task.Owner.ID)
- assert.Equal(t, req.OwnerID, task.Owner.Name) // TODO: Should look up actual name
- assert.Equal(t, tm.StatusToDo, task.Status)
- assert.Equal(t, req.Priority, task.Priority)
- assert.False(t, task.CreatedAt.IsZero())
- assert.False(t, task.UpdatedAt.IsZero())
-
- // Verify git commit was created
- commits, err := gitImpl.Log(ctx, git.LogOptions{MaxCount: 1})
- assert.NoError(t, err)
- if len(commits) > 0 {
- assert.Contains(t, commits[0].Message, task.ID)
- assert.Contains(t, commits[0].Message, "created")
- }
-}
-
-func TestCreateTaskInvalidData(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- ctx := context.Background()
-
- // Test empty title
- req := &tm.TaskCreateRequest{
- Title: "",
- OwnerID: "user123",
- }
-
- task, err := gtm.CreateTask(ctx, req)
- assert.Error(t, err)
- assert.Nil(t, task)
- assert.Equal(t, tm.ErrInvalidTaskData, err)
-
- // Test empty owner ID
- req = &tm.TaskCreateRequest{
- Title: "Valid Title",
- OwnerID: "",
- }
-
- task, err = gtm.CreateTask(ctx, req)
- assert.Error(t, err)
- assert.Nil(t, task)
- assert.Equal(t, tm.ErrInvalidOwner, err)
-}
-
-func TestGetTask(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create a test task
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test task description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
-
- // Get the task
- retrievedTask, err := gtm.GetTask(task.ID)
- assert.NoError(t, err)
- assert.NotNil(t, retrievedTask)
- assert.Equal(t, task.ID, retrievedTask.ID)
- assert.Equal(t, task.Title, retrievedTask.Title)
-}
-
-func TestGetTaskNotFound(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- task, err := gtm.GetTask("non-existent-task")
- assert.Error(t, err)
- assert.Nil(t, task)
- assert.Equal(t, tm.ErrTaskNotFound, err)
-}
-
-func TestUpdateTask(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, gitImpl := createTestTaskManager(t, tempDir)
-
- // Create a test task
- originalTask := &tm.Task{
- ID: "test-task-123",
- Title: "Original Title",
- Description: "Original description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Original User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityLow,
- CreatedAt: time.Now().Add(-time.Hour),
- UpdatedAt: time.Now().Add(-time.Hour),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(originalTask)
- require.NoError(t, err)
-
- // Commit the initial task
- err = gtm.commitTaskChange(originalTask.ID, "created", originalTask.Owner)
- require.NoError(t, err)
-
- // Update the task
- ctx := context.Background()
- newTitle := "Updated Title"
- newDescription := "Updated description"
- newStatus := tm.StatusInProgress
- newPriority := tm.PriorityHigh
- newOwnerID := "user456"
-
- // Get task and update fields
- taskToUpdate, err := gtm.GetTask(originalTask.ID)
- assert.NoError(t, err)
-
- taskToUpdate.Title = newTitle
- taskToUpdate.Description = newDescription
- taskToUpdate.Status = newStatus
- taskToUpdate.Priority = newPriority
- taskToUpdate.Owner.ID = newOwnerID
- taskToUpdate.Owner.Name = newOwnerID
-
- err = gtm.UpdateTask(taskToUpdate)
- assert.NoError(t, err)
-
- // Get updated task to verify
- updatedTask, err := gtm.GetTask(originalTask.ID)
- assert.NoError(t, err)
- assert.NotNil(t, updatedTask)
-
- // Verify updated properties
- assert.Equal(t, newTitle, updatedTask.Title)
- assert.Equal(t, newDescription, updatedTask.Description)
- assert.Equal(t, newStatus, updatedTask.Status)
- assert.Equal(t, newPriority, updatedTask.Priority)
- assert.Equal(t, newOwnerID, updatedTask.Owner.ID)
- assert.Equal(t, newOwnerID, updatedTask.Owner.Name)
-
- // Verify timestamps were updated
- assert.True(t, updatedTask.UpdatedAt.After(originalTask.UpdatedAt))
-
- // Verify git commit was created
- commits, err := gitImpl.Log(ctx, git.LogOptions{MaxCount: 2})
- assert.NoError(t, err)
- if len(commits) > 0 {
- assert.Contains(t, commits[0].Message, "updated")
- }
-}
-
-func TestUpdateTaskNotFound(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Try to update non-existent task
- fakeTask := &tm.Task{
- ID: "non-existent-task",
- Title: "Updated Title",
- }
-
- err := gtm.UpdateTask(fakeTask)
- assert.Error(t, err)
-}
-
-func TestUpdateTaskNoChanges(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create a test task
- originalTask := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-time.Hour),
- UpdatedAt: time.Now().Add(-time.Hour),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(originalTask)
- require.NoError(t, err)
-
- // Update with no changes (just call UpdateTask with same task)
- err = gtm.UpdateTask(originalTask)
- assert.NoError(t, err)
-
- // Get updated task to verify
- updatedTask, err := gtm.GetTask(originalTask.ID)
- assert.NoError(t, err)
- assert.NotNil(t, updatedTask)
-
- // Verify no changes were made to content
- assert.Equal(t, originalTask.Title, updatedTask.Title)
- assert.Equal(t, originalTask.Description, updatedTask.Description)
- assert.Equal(t, originalTask.Status, updatedTask.Status)
- assert.Equal(t, originalTask.Priority, updatedTask.Priority)
- assert.Equal(t, originalTask.Owner.ID, updatedTask.Owner.ID)
-}
-
-func TestUpdateTaskStatusTimestamps(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create a test task
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-time.Hour),
- UpdatedAt: time.Now().Add(-time.Hour),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
-
- // Test completing a task
- task.Status = tm.StatusCompleted
- now := time.Now()
- task.CompletedAt = &now
-
- err = gtm.UpdateTask(task)
- assert.NoError(t, err)
-
- // Get updated task to verify
- updatedTask, err := gtm.GetTask(task.ID)
- assert.NoError(t, err)
- assert.NotNil(t, updatedTask)
- assert.Equal(t, tm.StatusCompleted, updatedTask.Status)
- assert.NotNil(t, updatedTask.CompletedAt)
-
- // Test archiving a task
- task.Status = tm.StatusArchived
- now = time.Now()
- task.ArchivedAt = &now
-
- err = gtm.UpdateTask(task)
- assert.NoError(t, err)
-
- // Get updated task to verify
- updatedTask, err = gtm.GetTask(task.ID)
- assert.NoError(t, err)
- assert.NotNil(t, updatedTask)
- assert.Equal(t, tm.StatusArchived, updatedTask.Status)
- assert.NotNil(t, updatedTask.ArchivedAt)
-}
-
-func TestArchiveTask(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create a test task
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-time.Hour),
- UpdatedAt: time.Now().Add(-time.Hour),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
-
- // Archive the task
- ctx := context.Background()
- err = gtm.ArchiveTask(ctx, task.ID)
- assert.NoError(t, err)
-
- // Verify task was archived
- archivedTask, err := gtm.GetTask(task.ID)
- assert.NoError(t, err)
- assert.Equal(t, tm.StatusArchived, archivedTask.Status)
- assert.NotNil(t, archivedTask.ArchivedAt)
-}
-
-func TestStartTask(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create a test task
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusToDo,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-time.Hour),
- UpdatedAt: time.Now().Add(-time.Hour),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
-
- // Start the task
- ctx := context.Background()
- startedTask, err := gtm.StartTask(ctx, task.ID)
- assert.NoError(t, err)
- assert.NotNil(t, startedTask)
- assert.Equal(t, tm.StatusInProgress, startedTask.Status)
-}
-
-func TestCompleteTask(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create a test task
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test Task",
- Description: "Test description",
- Owner: tm.Owner{
- ID: "user123",
- Name: "Test User",
- },
- Status: tm.StatusInProgress,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-time.Hour),
- UpdatedAt: time.Now().Add(-time.Hour),
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
-
- // Complete the task
- ctx := context.Background()
- completedTask, err := gtm.CompleteTask(ctx, task.ID)
- assert.NoError(t, err)
- assert.NotNil(t, completedTask)
- assert.Equal(t, tm.StatusCompleted, completedTask.Status)
- assert.NotNil(t, completedTask.CompletedAt)
-}
-
-func TestListTasks(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create test tasks
- tasks := []*tm.Task{
- {
- ID: "task-1",
- Title: "Task 1",
- Description: "First task",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusToDo,
- Priority: tm.PriorityHigh,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- UpdatedAt: time.Now().Add(-2 * time.Hour),
- },
- {
- ID: "task-2",
- Title: "Task 2",
- Description: "Second task",
- Owner: tm.Owner{ID: "user2", Name: "User 2"},
- Status: tm.StatusInProgress,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-1 * time.Hour),
- UpdatedAt: time.Now().Add(-1 * time.Hour),
- },
- {
- ID: "task-3",
- Title: "Task 3",
- Description: "Third task",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusCompleted,
- Priority: tm.PriorityLow,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
-
- for _, task := range tasks {
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
- }
-
- ctx := context.Background()
-
- // Test listing all tasks
- taskList, err := gtm.ListTasks(ctx, nil, 0, 10)
- assert.NoError(t, err)
- assert.NotNil(t, taskList)
- assert.Len(t, taskList.Tasks, 3)
- assert.Equal(t, 3, taskList.TotalCount)
- assert.Equal(t, 0, taskList.Page)
- assert.Equal(t, 10, taskList.PageSize)
- assert.False(t, taskList.HasMore)
-
- // Test pagination
- taskList, err = gtm.ListTasks(ctx, nil, 0, 2)
- assert.NoError(t, err)
- assert.Len(t, taskList.Tasks, 2)
- assert.Equal(t, 3, taskList.TotalCount)
- assert.True(t, taskList.HasMore)
-
- // Test filtering by owner
- ownerFilter := &tm.TaskFilter{OwnerID: stringPtr("user1")}
- taskList, err = gtm.ListTasks(ctx, ownerFilter, 0, 10)
- assert.NoError(t, err)
- assert.Len(t, taskList.Tasks, 2)
-
- // Test filtering by status
- statusFilter := &tm.TaskFilter{Status: taskStatusPtr(tm.StatusToDo)}
- taskList, err = gtm.ListTasks(ctx, statusFilter, 0, 10)
- assert.NoError(t, err)
- assert.Len(t, taskList.Tasks, 1)
- assert.Equal(t, "task-1", taskList.Tasks[0].ID)
-
- // Test filtering by priority
- priorityFilter := &tm.TaskFilter{Priority: taskPriorityPtr(tm.PriorityHigh)}
- taskList, err = gtm.ListTasks(ctx, priorityFilter, 0, 10)
- assert.NoError(t, err)
- assert.Len(t, taskList.Tasks, 1)
- assert.Equal(t, "task-1", taskList.Tasks[0].ID)
-}
-
-func TestGetTasksByOwner(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create test tasks
- tasks := []*tm.Task{
- {
- ID: "task-1",
- Title: "Task 1",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusToDo,
- Priority: tm.PriorityHigh,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- UpdatedAt: time.Now().Add(-2 * time.Hour),
- },
- {
- ID: "task-2",
- Title: "Task 2",
- Owner: tm.Owner{ID: "user2", Name: "User 2"},
- Status: tm.StatusInProgress,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-1 * time.Hour),
- UpdatedAt: time.Now().Add(-1 * time.Hour),
- },
- {
- ID: "task-3",
- Title: "Task 3",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusCompleted,
- Priority: tm.PriorityLow,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
-
- for _, task := range tasks {
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
- }
-
- ctx := context.Background()
-
- // Get tasks by owner
- taskList, err := gtm.GetTasksByOwner(ctx, "user1", 0, 10)
- assert.NoError(t, err)
- assert.NotNil(t, taskList)
- assert.Len(t, taskList.Tasks, 2)
-
- for _, task := range taskList.Tasks {
- assert.Equal(t, "user1", task.Owner.ID)
- }
-}
-
-func TestGetTasksByStatus(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create test tasks
- tasks := []*tm.Task{
- {
- ID: "task-1",
- Title: "Task 1",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusToDo,
- Priority: tm.PriorityHigh,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- UpdatedAt: time.Now().Add(-2 * time.Hour),
- },
- {
- ID: "task-2",
- Title: "Task 2",
- Owner: tm.Owner{ID: "user2", Name: "User 2"},
- Status: tm.StatusInProgress,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-1 * time.Hour),
- UpdatedAt: time.Now().Add(-1 * time.Hour),
- },
- {
- ID: "task-3",
- Title: "Task 3",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusCompleted,
- Priority: tm.PriorityLow,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
-
- for _, task := range tasks {
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
- }
-
- ctx := context.Background()
-
- // Get tasks by status
- taskList, err := gtm.GetTasksByStatus(ctx, tm.StatusToDo, 0, 10)
- assert.NoError(t, err)
- assert.NotNil(t, taskList)
- assert.Len(t, taskList.Tasks, 1)
- assert.Equal(t, tm.StatusToDo, taskList.Tasks[0].Status)
-}
-
-func TestGetTasksByPriority(t *testing.T) {
- tempDir, cleanup := setupTestDir(t)
- defer cleanup()
-
- gtm, _ := createTestTaskManager(t, tempDir)
-
- // Create test tasks
- tasks := []*tm.Task{
- {
- ID: "task-1",
- Title: "Task 1",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusToDo,
- Priority: tm.PriorityHigh,
- CreatedAt: time.Now().Add(-2 * time.Hour),
- UpdatedAt: time.Now().Add(-2 * time.Hour),
- },
- {
- ID: "task-2",
- Title: "Task 2",
- Owner: tm.Owner{ID: "user2", Name: "User 2"},
- Status: tm.StatusInProgress,
- Priority: tm.PriorityMedium,
- CreatedAt: time.Now().Add(-1 * time.Hour),
- UpdatedAt: time.Now().Add(-1 * time.Hour),
- },
- {
- ID: "task-3",
- Title: "Task 3",
- Owner: tm.Owner{ID: "user1", Name: "User 1"},
- Status: tm.StatusCompleted,
- Priority: tm.PriorityLow,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- },
- }
-
- err := gtm.ensureTasksDir()
- require.NoError(t, err)
-
- for _, task := range tasks {
- err = gtm.writeTaskFile(task)
- require.NoError(t, err)
- }
-
- ctx := context.Background()
-
- // Get tasks by priority
- taskList, err := gtm.GetTasksByPriority(ctx, tm.PriorityHigh, 0, 10)
- assert.NoError(t, err)
- assert.NotNil(t, taskList)
- assert.Len(t, taskList.Tasks, 1)
- assert.Equal(t, tm.PriorityHigh, taskList.Tasks[0].Priority)
-}
-
-// Helper functions for creating pointers to string, TaskStatus, and TaskPriority
-func stringPtr(s string) *string {
- return &s
-}
-
-func taskStatusPtr(status tm.TaskStatus) *tm.TaskStatus {
- return &status
-}
-
-func taskPriorityPtr(priority tm.TaskPriority) *tm.TaskPriority {
- return &priority
-}