Refactor everything
Change-Id: Ic3a37c38cfecba943c91f6ae545ce1c5b551c0d5
diff --git a/server/agent/README.md b/server/agent/README.md
deleted file mode 100644
index 0015d90..0000000
--- a/server/agent/README.md
+++ /dev/null
@@ -1,298 +0,0 @@
-# Agent Package
-
-The `agent` package provides an AI agent system that can autonomously process tasks using LLM services, manage tasks through a task management system, and create pull requests with solutions.
-
-## Overview
-
-The agent system consists of:
-
-- **AI Agent**: Processes tasks using LLM services
-- **Task Manager**: Manages task lifecycle and assignment
-- **Git Integration**: Creates pull requests with solutions
-- **Infinite Loop**: Continuously processes assigned tasks
-
-## Features
-
-- **Autonomous Task Processing**: Agents automatically pick up and process assigned tasks
-- **LLM Integration**: Uses configurable LLM providers (OpenAI, Claude, etc.)
-- **Task Management**: Integrates with task management systems
-- **Git Operations**: Creates branches and pull requests for solutions
-- **Configurable Roles**: Different agents can have different roles and system prompts
-- **Error Handling**: Robust error handling with graceful recovery
-
-## Quick Start
-
-### 1. Basic Setup
-
-```go
-package main
-
-import (
- "log"
- "time"
-
- "github.com/iomodo/staff/agent"
- "github.com/iomodo/staff/git"
- "github.com/iomodo/staff/llm"
- "github.com/iomodo/staff/tm"
- "github.com/iomodo/staff/tm/git_tm"
-)
-
-func main() {
- // Create git interface for task management
- gitInterface := git.DefaultGit("./tasks-repo")
-
- // Create task manager
- taskManager := git_tm.NewGitTaskManager(gitInterface, "./tasks-repo")
-
- // Create LLM configuration
- llmConfig := llm.Config{
- Provider: llm.ProviderOpenAI,
- APIKey: "your-openai-api-key-here",
- BaseURL: "https://api.openai.com/v1",
- Timeout: 30 * time.Second,
- }
-
- // Create agent configuration
- config := agent.AgentConfig{
- Name: "backend-engineer-1",
- Role: "Backend Engineer",
- GitUsername: "backend-agent",
- GitEmail: "backend-agent@company.com",
- WorkingDir: "./workspace",
- LLMProvider: llm.ProviderOpenAI,
- LLMModel: "gpt-4",
- LLMConfig: llmConfig,
- SystemPrompt: `You are a skilled backend engineer. Your role is to:
-1. Analyze tasks and provide technical solutions
-2. Write clean, maintainable code
-3. Consider performance, security, and scalability
-4. Provide clear documentation for your solutions
-5. Follow best practices and coding standards`,
- TaskManager: taskManager,
- GitRepoPath: "./code-repo",
- GitRemote: "origin",
- GitBranch: "main",
- }
-
- // Create agent
- agent, err := agent.NewAgent(config)
- if err != nil {
- log.Fatalf("Failed to create agent: %v", err)
- }
-
- // Run the agent
- if err := agent.Run(); err != nil {
- log.Fatalf("Agent failed: %v", err)
- }
-}
-```
-
-### 2. Create a Task
-
-```go
-// Create a task for the agent to process
-ctx := context.Background()
-task, err := taskManager.CreateTask(ctx, &tm.TaskCreateRequest{
- Title: "Implement user authentication API",
- Description: "Create a REST API endpoint for user authentication with JWT tokens. Include login, logout, and token refresh functionality.",
- OwnerID: "backend-engineer-1", // Must match agent name
- Priority: tm.PriorityHigh,
-})
-if err != nil {
- log.Fatalf("Failed to create task: %v", err)
-}
-```
-
-## Configuration
-
-### AgentConfig
-
-The `AgentConfig` struct contains all configuration for an agent:
-
-```go
-type AgentConfig struct {
- Name string // Agent identifier
- Role string // Agent role (e.g., "Backend Engineer")
- GitUsername string // Git username for commits
- GitEmail string // Git email for commits
- WorkingDir string // Working directory for files
-
- // LLM Configuration
- LLMProvider llm.Provider // LLM provider type
- LLMModel string // Model name (e.g., "gpt-4")
- LLMConfig llm.Config // LLM provider configuration
-
- // System prompt for the agent
- SystemPrompt string // Instructions for the LLM
-
- // Task Manager Configuration
- TaskManager tm.TaskManager // Task management interface
-
- // Git Configuration
- GitRepoPath string // Path to git repository
- GitRemote string // Remote name (usually "origin")
- GitBranch string // Default branch name
-}
-```
-
-### System Prompts
-
-System prompts define the agent's behavior and expertise. Here are some examples:
-
-#### Backend Engineer
-```
-You are a skilled backend engineer. Your role is to:
-1. Analyze tasks and provide technical solutions
-2. Write clean, maintainable code
-3. Consider performance, security, and scalability
-4. Provide clear documentation for your solutions
-5. Follow best practices and coding standards
-
-When responding to tasks, provide:
-- Detailed technical analysis
-- Code examples where appropriate
-- Implementation considerations
-- Testing recommendations
-- Documentation suggestions
-```
-
-#### Frontend Engineer
-```
-You are a frontend engineer. Focus on:
-- User interface design and implementation
-- React/Vue/Angular development
-- Responsive design and accessibility
-- Performance optimization
-- User experience best practices
-```
-
-#### Product Manager
-```
-You are a product manager. Focus on:
-- Product strategy and roadmap
-- User research and requirements gathering
-- Feature prioritization and planning
-- Stakeholder communication
-- Product documentation and specifications
-```
-
-## How It Works
-
-### 1. Task Processing Loop
-
-The agent runs in an infinite loop that:
-
-1. **Fetches Tasks**: Gets tasks assigned to the agent from the task manager
-2. **Filters Tasks**: Looks for tasks with "todo" status
-3. **Starts Task**: Marks the task as "in progress"
-4. **Processes with LLM**: Sends task description to LLM for solution
-5. **Creates PR**: Creates a git branch and pull request with the solution
-6. **Completes Task**: Marks the task as completed
-
-### 2. Git Operations
-
-For each task, the agent:
-
-1. Creates a new branch: `task/{task-id}-{clean-title}`
-2. Writes solution to a markdown file
-3. Commits the solution
-4. Pushes the branch to create a pull request
-
-### 3. Solution Format
-
-Solutions are formatted as markdown files containing:
-
-- Task metadata (ID, title, agent info)
-- Original task description
-- LLM-generated solution
-- Timestamp and attribution
-
-## Multiple Agents
-
-You can run multiple agents with different roles:
-
-```go
-// Create agents with different roles
-agents := []agent.AgentConfig{
- {
- Name: "backend-engineer-1",
- Role: "Backend Engineer",
- // ... backend configuration
- },
- {
- Name: "frontend-engineer-1",
- Role: "Frontend Engineer",
- // ... frontend configuration
- },
- {
- Name: "product-manager-1",
- Role: "Product Manager",
- // ... product manager configuration
- },
-}
-
-// Start all agents
-for _, config := range agents {
- agent, err := agent.NewAgent(config)
- if err != nil {
- log.Printf("Failed to create agent %s: %v", config.Name, err)
- continue
- }
-
- go func(agent *agent.Agent, name string) {
- log.Printf("Starting agent: %s", name)
- if err := agent.Run(); err != nil {
- log.Printf("Agent %s stopped with error: %v", name, err)
- }
- }(agent, config.Name)
-}
-```
-
-## Error Handling
-
-The agent includes robust error handling:
-
-- **Configuration Validation**: Validates all required fields
-- **Graceful Recovery**: Continues running even if individual tasks fail
-- **Logging**: Comprehensive logging of all operations
-- **Resource Cleanup**: Proper cleanup of LLM connections
-
-## Testing
-
-Run the tests with:
-
-```bash
-go test ./server/agent/...
-```
-
-The test suite includes:
-
-- Configuration validation
-- Branch name generation
-- Task prompt building
-- Solution formatting
-- Error handling
-
-## Dependencies
-
-The agent package depends on:
-
-- `github.com/iomodo/staff/llm` - LLM service interface
-- `github.com/iomodo/staff/tm` - Task management interface
-- `github.com/iomodo/staff/git` - Git operations interface
-
-## Examples
-
-See `example.go` for complete working examples:
-
-- `ExampleAgent()` - Single agent setup
-- `ExampleMultipleAgents()` - Multiple agents with different roles
-
-## Best Practices
-
-1. **Unique Agent Names**: Ensure each agent has a unique name
-2. **Role-Specific Prompts**: Tailor system prompts to the agent's role
-3. **Task Assignment**: Assign tasks to agents by setting the `OwnerID` to the agent's name
-4. **Monitoring**: Monitor agent logs for errors and performance
-5. **Resource Management**: Ensure proper cleanup when stopping agents
\ No newline at end of file
diff --git a/server/agent/agent.go b/server/agent/agent.go
new file mode 100644
index 0000000..fba80b7
--- /dev/null
+++ b/server/agent/agent.go
@@ -0,0 +1,239 @@
+package agent
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "time"
+
+ "github.com/iomodo/staff/config"
+ "github.com/iomodo/staff/llm"
+ "github.com/iomodo/staff/tm"
+)
+
+type Agent struct {
+ // Identity
+ Name string
+ Role string
+
+ // LLM Configuration
+ Model string
+ SystemPrompt string
+ MaxTokens *int
+ Temperature *float64
+
+ // Runtime
+ Provider llm.LLMProvider
+ CurrentTask *string // Task ID currently being processed
+
+ IsRunning bool
+ StopChan chan struct{}
+
+ logger *slog.Logger
+
+ taskManager tm.TaskManager
+ thinker *Thinker
+}
+
+func NewAgent(agentConfig config.AgentConfig, llmConfig llm.Config, taskManager tm.TaskManager, agentRoles []string, logger *slog.Logger) (*Agent, error) {
+ // Load system prompt
+ systemPrompt, err := loadSystemPrompt(agentConfig.SystemPromptFile)
+ if err != nil {
+ return nil, fmt.Errorf("failed to load system prompt: %w", err)
+ }
+
+ provider, err := llm.CreateProvider(llmConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create LLM provider: %w", err)
+ }
+
+ thinker := NewThinker(provider, agentConfig.Model, systemPrompt, *agentConfig.MaxTokens, *agentConfig.Temperature, agentRoles, logger)
+
+ agent := &Agent{
+ Name: agentConfig.Name,
+ Role: agentConfig.Role,
+ Model: agentConfig.Model,
+ SystemPrompt: systemPrompt,
+ Provider: provider,
+ MaxTokens: agentConfig.MaxTokens,
+ Temperature: agentConfig.Temperature,
+ taskManager: taskManager,
+ logger: logger,
+ thinker: thinker,
+ }
+
+ return agent, nil
+}
+
+// Start starts an agent to process tasks in a loop
+func (a *Agent) Start(loopInterval time.Duration) error {
+ if a.IsRunning {
+ return fmt.Errorf("agent %s is already running", a.Name)
+ }
+
+ a.IsRunning = true
+ a.StopChan = make(chan struct{})
+
+ go a.runLoop(loopInterval)
+
+ a.logger.Info("Started agent",
+ slog.String("name", a.Name),
+ slog.String("role", a.Role),
+ slog.String("model", a.Model))
+ return nil
+}
+
+func (a *Agent) Stop() {
+ close(a.StopChan)
+ a.IsRunning = false
+}
+
+func (a *Agent) runLoop(interval time.Duration) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-a.StopChan:
+ a.logger.Info("Agent stopping", slog.String("name", a.Name))
+ return
+ case <-ticker.C:
+ if err := a.processTasks(); err != nil {
+ a.logger.Error("Error processing tasks for agent",
+ slog.String("agent", a.Name),
+ slog.String("error", err.Error()))
+ }
+ }
+ }
+}
+
+// processAgentTasks processes all assigned tasks for an agent
+func (a *Agent) processTasks() error {
+ if a.CurrentTask != nil {
+ return nil
+ }
+
+ // Get tasks assigned to this agent
+ tasks, err := a.taskManager.GetTasksByAssignee(a.Name)
+ if err != nil {
+ return fmt.Errorf("failed to get tasks for agent %s: %w", a.Name, err)
+ }
+
+ a.logger.Info("Processing tasks for agent",
+ slog.Int("task_count", len(tasks)),
+ slog.String("agent", a.Name))
+
+ for _, task := range tasks {
+ if task.Status == tm.StatusToDo {
+ if err := a.processTask(task); err != nil {
+ a.logger.Error("Error processing task",
+ slog.String("task_id", task.ID),
+ slog.String("error", err.Error()))
+ }
+ }
+ }
+
+ return nil
+}
+
+// processTask processes a single task with an agent
+func (a *Agent) processTask(task *tm.Task) error {
+ ctx := context.Background()
+ startTime := time.Now()
+
+ a.logger.Info("Agent processing task",
+ slog.String("agent", a.Name),
+ slog.String("task_id", task.ID),
+ slog.String("title", task.Title))
+
+ // Mark task as in progress
+ task.Status = tm.StatusInProgress
+ a.CurrentTask = &task.ID
+
+ // Check if this task should generate subtasks (with LLM decision)
+ if a.thinker.ShouldGenerateSubtasks(task) {
+ err := a.processSubtask(ctx, task)
+ if err == nil {
+ a.logger.Info("Task converted to subtasks by agent using LLM analysis",
+ slog.String("task_id", task.ID),
+ slog.String("agent", a.Name))
+ return nil
+ }
+ a.logger.Error("Error processing subtask",
+ slog.String("task_id", task.ID),
+ slog.String("error", err.Error()))
+ }
+
+ err := a.processSolution(ctx, task)
+ if err != nil {
+ return fmt.Errorf("failed to process solution for task: %w", err)
+ }
+ duration := time.Since(startTime)
+ a.logger.Info("Task completed by agent",
+ slog.String("task_id", task.ID),
+ slog.String("agent", a.Name),
+ slog.Duration("duration", duration))
+ return nil
+}
+
+func (a *Agent) processSubtask(ctx context.Context, task *tm.Task) error {
+ a.logger.Info("LLM determined task should generate subtasks", slog.String("task_id", task.ID))
+ analysis, err := a.thinker.GenerateSubtasksForTask(ctx, task)
+ if err != nil {
+ return fmt.Errorf("failed to generate subtasks for task: %w", err)
+ }
+
+ solutionURL, err2 := a.taskManager.ProposeSubTasks(ctx, task, analysis)
+ if err2 != nil {
+ return fmt.Errorf("failed to propose subtasks for task: %w", err2)
+ }
+ task.SolutionURL = solutionURL
+
+ a.logger.Info("Generated subtask Solution for task",
+ slog.String("task_id", task.ID),
+ slog.String("solution_url", solutionURL))
+ a.logger.Info("Proposed subtasks and new agents for task",
+ slog.String("task_id", task.ID),
+ slog.Int("subtask_count", len(analysis.Subtasks)),
+ slog.Int("new_agent_count", len(analysis.AgentCreations)))
+
+ // Log proposed new agents if any
+ if len(analysis.AgentCreations) > 0 {
+ for _, agent := range analysis.AgentCreations {
+ a.logger.Info("Proposed new agent",
+ slog.String("role", agent.Role),
+ slog.Any("skills", agent.Skills))
+ }
+ }
+
+ return nil
+}
+
+func (a *Agent) processSolution(ctx context.Context, task *tm.Task) error {
+ solution, err := a.thinker.GenerateSolution(ctx, task)
+ if err != nil {
+ return fmt.Errorf("failed to generate solution: %w", err)
+ }
+
+ solutionURL, err := a.taskManager.ProposeSolution(ctx, task, solution, a.Name)
+ if err != nil {
+ return fmt.Errorf("failed to propose solution: %w", err)
+ }
+ task.SolutionURL = solutionURL
+
+ a.logger.Info("Generated Solution for task",
+ slog.String("task_id", task.ID),
+ slog.String("agent", a.Name),
+ slog.String("solution_url", solutionURL))
+ return nil
+}
+
+// loadSystemPrompt loads the system prompt from file
+func loadSystemPrompt(filePath string) (string, error) {
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", fmt.Errorf("failed to read system prompt file %s: %w", filePath, err)
+ }
+ return string(content), nil
+}
diff --git a/server/agent/manager.go b/server/agent/manager.go
index 191d42e..ea7474d 100644
--- a/server/agent/manager.go
+++ b/server/agent/manager.go
@@ -1,17 +1,11 @@
package agent
import (
- "context"
"fmt"
"log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
"time"
"github.com/iomodo/staff/config"
- "github.com/iomodo/staff/git"
"github.com/iomodo/staff/llm"
_ "github.com/iomodo/staff/llm/providers" // Auto-register all providers
"github.com/iomodo/staff/task"
@@ -20,70 +14,31 @@
// Manager manages multiple AI agents with Git operations and task processing
type Manager struct {
- config *config.Config
- agents map[string]*Agent
- taskManager tm.TaskManager
- autoAssigner *task.AutoAssigner
- prProvider git.PullRequestProvider
- cloneManager *git.CloneManager
- subtaskService *task.SubtaskService
- isRunning map[string]bool
- stopChannels map[string]chan struct{}
- logger *slog.Logger
+ config *config.Config
+ agents map[string]*Agent
+ taskManager tm.TaskManager
+ autoAssigner *task.AutoAssigner
+ isRunning map[string]bool
+ roles []string
+ logger *slog.Logger
}
// NewManager creates a new agent manager
func NewManager(cfg *config.Config, taskManager tm.TaskManager, logger *slog.Logger) (*Manager, error) {
- if logger == nil {
- logger = slog.Default()
- }
- // Create auto-assigner
autoAssigner := task.NewAutoAssigner(cfg.Agents)
- // Create PR provider based on configuration
- var prProvider git.PullRequestProvider
- var repoURL string
-
- switch cfg.GetPrimaryGitProvider() {
- case "github":
- githubConfig := git.GitHubConfig{
- Token: cfg.GitHub.Token,
- Logger: logger,
- }
- prProvider = git.NewGitHubPullRequestProvider(cfg.GitHub.Owner, cfg.GitHub.Repo, githubConfig)
- repoURL = fmt.Sprintf("https://github.com/%s/%s.git", cfg.GitHub.Owner, cfg.GitHub.Repo)
- logger.Info("Using GitHub as pull request provider",
- slog.String("owner", cfg.GitHub.Owner),
- slog.String("repo", cfg.GitHub.Repo))
- case "gerrit":
- gerritConfig := git.GerritConfig{
- Username: cfg.Gerrit.Username,
- Password: cfg.Gerrit.Password,
- BaseURL: cfg.Gerrit.BaseURL,
- Logger: logger,
- }
- prProvider = git.NewGerritPullRequestProvider(cfg.Gerrit.Project, gerritConfig)
- repoURL = fmt.Sprintf("%s/%s", cfg.Gerrit.BaseURL, cfg.Gerrit.Project)
- logger.Info("Using Gerrit as pull request provider",
- slog.String("base_url", cfg.Gerrit.BaseURL),
- slog.String("project", cfg.Gerrit.Project))
- default:
- return nil, fmt.Errorf("no valid Git provider configured")
+ agentRoles := make([]string, 0, len(cfg.Agents))
+ for _, agentConfig := range cfg.Agents {
+ agentRoles = append(agentRoles, agentConfig.Role)
}
- // Create clone manager for per-agent Git repositories
- workspacePath := filepath.Join(".", "workspace")
- cloneManager := git.NewCloneManager(repoURL, workspacePath)
-
manager := &Manager{
config: cfg,
agents: make(map[string]*Agent),
taskManager: taskManager,
autoAssigner: autoAssigner,
- prProvider: prProvider,
- cloneManager: cloneManager,
isRunning: make(map[string]bool),
- stopChannels: make(map[string]chan struct{}),
+ roles: agentRoles,
logger: logger,
}
@@ -92,18 +47,19 @@
return nil, fmt.Errorf("failed to initialize agents: %w", err)
}
- // Initialize subtask service after agents are created
- if err := manager.initializeSubtaskService(); err != nil {
- return nil, fmt.Errorf("failed to initialize subtask service: %w", err)
- }
-
return manager, nil
}
// initializeAgents creates agent instances from configuration
func (m *Manager) initializeAgents() error {
+ llmConfig := llm.Config{
+ Provider: llm.ProviderFake, // Use fake provider for testing
+ APIKey: m.config.OpenAI.APIKey,
+ BaseURL: m.config.OpenAI.BaseURL,
+ Timeout: m.config.OpenAI.Timeout,
+ }
for _, agentConfig := range m.config.Agents {
- agent, err := m.createAgent(agentConfig)
+ agent, err := NewAgent(agentConfig, llmConfig, m.taskManager, m.roles, m.logger)
if err != nil {
return fmt.Errorf("failed to create agent %s: %w", agentConfig.Name, err)
}
@@ -112,95 +68,25 @@
return nil
}
-// initializeSubtaskService creates the subtask service with available agent roles
-func (m *Manager) initializeSubtaskService() error {
- // Get agent roles from configuration
- agentRoles := make([]string, 0, len(m.config.Agents))
- for _, agentConfig := range m.config.Agents {
- agentRoles = append(agentRoles, agentConfig.Name)
+func (m *Manager) StartAllAgents() {
+ // Start all configured agents with a default loop interval
+ defaultInterval := 1 * time.Second
+
+ for _, a := range m.agents {
+ m.logger.Info("Starting agent",
+ slog.String("name", a.Name),
+ slog.String("role", a.Role),
+ slog.String("model", a.Model))
+ if err := a.Start(defaultInterval); err != nil {
+ m.logger.Error("Failed to start agent",
+ slog.String("agent", a.Name),
+ slog.String("error", err.Error()))
+ continue
+ }
+ m.isRunning[a.Name] = true
}
-
- // Use the first agent's LLM provider for subtask analysis
- if len(m.agents) == 0 {
- return fmt.Errorf("no agents available for subtask service")
- }
-
- var firstAgent *Agent
- for _, agent := range m.agents {
- firstAgent = agent
- break
- }
-
- // Get owner and repo for subtask service based on provider
- var owner, repo string
- switch m.config.GetPrimaryGitProvider() {
- case "github":
- owner = m.config.GitHub.Owner
- repo = m.config.GitHub.Repo
- case "gerrit":
- owner = m.config.Gerrit.Project
- repo = m.config.Gerrit.Project
- }
-
- m.subtaskService = task.NewSubtaskService(
- firstAgent.Provider,
- m.taskManager,
- agentRoles,
- m.prProvider,
- owner,
- repo,
- m.cloneManager,
- m.logger,
- )
-
- return nil
}
-// createAgent creates a single agent instance
-func (m *Manager) createAgent(agentConfig config.AgentConfig) (*Agent, error) {
- // Load system prompt
- systemPrompt, err := m.loadSystemPrompt(agentConfig.SystemPromptFile)
- if err != nil {
- return nil, fmt.Errorf("failed to load system prompt: %w", err)
- }
-
- // Create LLM provider
- llmConfig := llm.Config{
- Provider: llm.ProviderFake, // Use fake provider for testing
- APIKey: m.config.OpenAI.APIKey,
- BaseURL: m.config.OpenAI.BaseURL,
- Timeout: m.config.OpenAI.Timeout,
- }
-
- provider, err := llm.CreateProvider(llmConfig)
- if err != nil {
- return nil, fmt.Errorf("failed to create LLM provider: %w", err)
- }
-
- agent := &Agent{
- Name: agentConfig.Name,
- Role: agentConfig.Role,
- Model: agentConfig.Model,
- SystemPrompt: systemPrompt,
- Provider: provider,
- MaxTokens: agentConfig.MaxTokens,
- Temperature: agentConfig.Temperature,
- Stats: AgentStats{},
- }
-
- return agent, nil
-}
-
-// loadSystemPrompt loads the system prompt from file
-func (m *Manager) loadSystemPrompt(filePath string) (string, error) {
- content, err := os.ReadFile(filePath)
- if err != nil {
- return "", fmt.Errorf("failed to read system prompt file %s: %w", filePath, err)
- }
- return string(content), nil
-}
-
-// StartAgent starts an agent to process tasks in a loop
func (m *Manager) StartAgent(agentName string, loopInterval time.Duration) error {
agent, exists := m.agents[agentName]
if !exists {
@@ -211,409 +97,28 @@
return fmt.Errorf("agent %s is already running", agentName)
}
- stopChan := make(chan struct{})
- m.stopChannels[agentName] = stopChan
+ agent.Start(loopInterval)
m.isRunning[agentName] = true
-
- go m.runAgentLoop(agent, loopInterval, stopChan)
-
- m.logger.Info("Started agent",
- slog.String("name", agentName),
- slog.String("role", agent.Role),
- slog.String("model", agent.Model))
return nil
}
// StopAgent stops a running agent
func (m *Manager) StopAgent(agentName string) error {
+ agent, exists := m.agents[agentName]
+ if !exists {
+ return fmt.Errorf("agent %s not found", agentName)
+ }
if !m.isRunning[agentName] {
return fmt.Errorf("agent %s is not running", agentName)
}
- close(m.stopChannels[agentName])
- delete(m.stopChannels, agentName)
+ agent.Stop()
m.isRunning[agentName] = false
m.logger.Info("Stopped agent", slog.String("name", agentName))
return nil
}
-// runAgentLoop runs the main processing loop for an agent
-func (m *Manager) runAgentLoop(agent *Agent, interval time.Duration, stopChan <-chan struct{}) {
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-stopChan:
- m.logger.Info("Agent stopping", slog.String("name", agent.Name))
- return
- case <-ticker.C:
- if err := m.processAgentTasks(agent); err != nil {
- m.logger.Error("Error processing tasks for agent",
- slog.String("agent", agent.Name),
- slog.String("error", err.Error()))
- }
- }
- }
-}
-
-// processAgentTasks processes all assigned tasks for an agent
-func (m *Manager) processAgentTasks(agent *Agent) error {
- if agent.CurrentTask != nil {
- return nil
- }
-
- // Get tasks assigned to this agent
- tasks, err := m.taskManager.GetTasksByAssignee(agent.Name)
- if err != nil {
- return fmt.Errorf("failed to get tasks for agent %s: %w", agent.Name, err)
- }
-
- m.logger.Info("Processing tasks for agent",
- slog.Int("task_count", len(tasks)),
- slog.String("agent", agent.Name))
-
- for _, task := range tasks {
- if task.Status == tm.StatusToDo {
- if err := m.processTask(agent, task); err != nil {
- m.logger.Error("Error processing task",
- slog.String("task_id", task.ID),
- slog.String("error", err.Error()))
- // Mark task as failed
- task.Status = tm.StatusFailed
- if err := m.taskManager.UpdateTask(task); err != nil {
- m.logger.Error("Error updating failed task",
- slog.String("task_id", task.ID),
- slog.String("error", err.Error()))
- }
- agent.Stats.TasksFailed++
- } else {
- agent.Stats.TasksCompleted++
- }
- // Update success rate
- total := agent.Stats.TasksCompleted + agent.Stats.TasksFailed
- if total > 0 {
- agent.Stats.SuccessRate = float64(agent.Stats.TasksCompleted) / float64(total) * 100
- }
- }
- }
-
- return nil
-}
-
-// processTask processes a single task with an agent
-func (m *Manager) processTask(agent *Agent, task *tm.Task) error {
- ctx := context.Background()
- startTime := time.Now()
-
- m.logger.Info("Agent processing task",
- slog.String("agent", agent.Name),
- slog.String("task_id", task.ID),
- slog.String("title", task.Title))
-
- // Mark task as in progress
- task.Status = tm.StatusInProgress
- agent.CurrentTask = &task.ID
-
- // Check if this task should generate subtasks (with LLM decision)
- if m.shouldGenerateSubtasks(task) {
- m.logger.Info("LLM determined task should generate subtasks", slog.String("task_id", task.ID))
- if err := m.generateSubtasksForTask(ctx, task); err != nil {
- m.logger.Warn("Failed to generate subtasks for task",
- slog.String("task_id", task.ID),
- slog.String("error", err.Error()))
- } else {
- m.logger.Info("Task converted to subtasks by agent using LLM analysis",
- slog.String("task_id", task.ID),
- slog.String("agent", agent.Name))
- return nil
- }
- }
-
- // Generate solution using LLM
- solution, err := m.generateSolution(ctx, agent, task)
- if err != nil {
- return fmt.Errorf("failed to generate solution: %w", err)
- }
-
- // Create Git branch and commit solution
- branchName := m.generateBranchName(task)
- if err := m.createAndCommitSolution(branchName, task, solution, agent); err != nil {
- return fmt.Errorf("failed to commit solution: %w", err)
- }
-
- // Create pull request
- prURL, err := m.createPullRequest(ctx, task, solution, agent, branchName)
- if err != nil {
- return fmt.Errorf("failed to create pull request: %w", err)
- }
-
- // Update agent stats
- duration := time.Since(startTime)
- if agent.Stats.AvgTime == 0 {
- agent.Stats.AvgTime = duration.Milliseconds()
- } else {
- agent.Stats.AvgTime = (agent.Stats.AvgTime + duration.Milliseconds()) / 2
- }
-
- m.logger.Info("Task completed by agent",
- slog.String("task_id", task.ID),
- slog.String("agent", agent.Name),
- slog.Duration("duration", duration),
- slog.String("pr_url", prURL))
- return nil
-}
-
-// generateSolution uses the agent's LLM to generate a solution
-func (m *Manager) generateSolution(ctx context.Context, agent *Agent, task *tm.Task) (string, error) {
- prompt := m.buildTaskPrompt(task)
-
- req := llm.ChatCompletionRequest{
- Model: agent.Model,
- Messages: []llm.Message{
- {
- Role: llm.RoleSystem,
- Content: agent.SystemPrompt,
- },
- {
- Role: llm.RoleUser,
- Content: prompt,
- },
- },
- MaxTokens: agent.MaxTokens,
- Temperature: agent.Temperature,
- }
-
- resp, err := agent.Provider.ChatCompletion(ctx, req)
- if err != nil {
- return "", fmt.Errorf("LLM request failed: %w", err)
- }
-
- if len(resp.Choices) == 0 {
- return "", fmt.Errorf("no response from LLM")
- }
-
- return resp.Choices[0].Message.Content, nil
-}
-
-// buildTaskPrompt creates a detailed prompt for the LLM
-func (m *Manager) buildTaskPrompt(task *tm.Task) string {
- return fmt.Sprintf(`Task: %s
-
-Priority: %s
-Description: %s
-
-Please provide a complete solution for this task. Include:
-1. Detailed implementation plan
-2. Code changes needed (if applicable)
-3. Files to be created or modified
-4. Testing considerations
-5. Any dependencies or prerequisites
-
-Your response should be comprehensive and actionable.`,
- task.Title,
- task.Priority,
- task.Description)
-}
-
-// generateBranchName creates a Git branch name for the task
-func (m *Manager) generateBranchName(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", m.config.Git.BranchPrefix, task.ID, cleanTitle)
-}
-
-// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
-func (m *Manager) createAndCommitSolution(branchName string, task *tm.Task, solution string, agent *Agent) error {
- ctx := context.Background()
-
- // Get agent's dedicated Git clone
- clonePath, err := m.cloneManager.GetAgentClonePath(agent.Name)
- if err != nil {
- return fmt.Errorf("failed to get agent clone: %w", err)
- }
-
- m.logger.Info("Agent working in clone",
- slog.String("agent", agent.Name),
- slog.String("clone_path", clonePath))
-
- // Refresh the clone with latest changes
- if err := m.cloneManager.RefreshAgentClone(agent.Name); err != nil {
- m.logger.Warn("Failed to refresh clone for agent",
- slog.String("agent", agent.Name),
- 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 (%s)
-**Model:** %s
-**Completed:** %s
-
-## Task Description
-%s
-
-## Solution
-%s
-
----
-*Generated by Staff AI Agent System*
-`, task.Title, agent.Name, agent.Role, agent.Model, 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 := m.buildCommitMessage(task, agent)
- 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)
- }
-
- m.logger.Info("Agent successfully pushed branch",
- slog.String("agent", agent.Name),
- slog.String("branch", branchName))
- return nil
-}
-
-// buildCommitMessage creates a commit message from template
-func (m *Manager) buildCommitMessage(task *tm.Task, agent *Agent) string {
- template := m.config.Git.CommitMessageTemplate
-
- replacements := map[string]string{
- "{task_id}": task.ID,
- "{task_title}": task.Title,
- "{agent_name}": agent.Name,
- "{solution}": "See solution file for details",
- }
-
- result := template
- for placeholder, value := range replacements {
- result = strings.ReplaceAll(result, placeholder, value)
- }
-
- return result
-}
-
-// createPullRequest creates a GitHub pull request
-func (m *Manager) createPullRequest(ctx context.Context, task *tm.Task, solution string, agent *Agent, branchName string) (string, error) {
- title := fmt.Sprintf("Task %s: %s", task.ID, task.Title)
-
- // Build PR description from template
- description := m.buildPRDescription(task, solution, agent)
-
- options := git.PullRequestOptions{
- Title: title,
- Description: description,
- HeadBranch: branchName,
- BaseBranch: "main",
- Labels: []string{"ai-generated", "staff-agent", strings.ToLower(agent.Role)},
- Draft: false,
- }
-
- pr, err := m.prProvider.CreatePullRequest(ctx, options)
- if err != nil {
- return "", fmt.Errorf("failed to create PR: %w", err)
- }
-
- // Generate provider-specific PR URL
- switch m.config.GetPrimaryGitProvider() {
- case "github":
- return fmt.Sprintf("https://github.com/%s/%s/pull/%d", m.config.GitHub.Owner, m.config.GitHub.Repo, pr.Number), nil
- case "gerrit":
- return fmt.Sprintf("%s/c/%s/+/%d", m.config.Gerrit.BaseURL, m.config.Gerrit.Project, pr.Number), nil
- default:
- return "", fmt.Errorf("unknown git provider")
- }
-}
-
-// buildPRDescription creates PR description from template
-func (m *Manager) buildPRDescription(task *tm.Task, solution string, agent *Agent) string {
- template := m.config.Git.PRTemplate
-
- // 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 (%s)", agent.Name, agent.Role),
- "{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
-}
-
// AutoAssignTask automatically assigns a task to the best matching agent
func (m *Manager) AutoAssignTask(taskID string) error {
task, err := m.taskManager.GetTask(taskID)
@@ -640,108 +145,6 @@
return nil
}
-// GetAgentStatus returns the status of all agents
-func (m *Manager) GetAgentStatus() map[string]AgentInfo {
- status := make(map[string]AgentInfo)
-
- for name, agent := range m.agents {
- agentStatus := StatusIdle
- if m.isRunning[name] {
- if agent.CurrentTask != nil {
- agentStatus = StatusRunning
- }
- } else {
- agentStatus = StatusStopped
- }
-
- status[name] = AgentInfo{
- Name: agent.Name,
- Role: agent.Role,
- Model: agent.Model,
- Status: agentStatus,
- CurrentTask: agent.CurrentTask,
- Stats: agent.Stats,
- }
- }
-
- return status
-}
-
-// shouldGenerateSubtasks determines if a task should be broken down into subtasks using LLM
-func (m *Manager) shouldGenerateSubtasks(task *tm.Task) bool {
- // Don't generate subtasks for subtasks
- if task.ParentTaskID != "" {
- return false
- }
-
- // Don't generate if already evaluated
- if task.SubtasksEvaluated {
- return false
- }
-
- // Ask LLM to decide
- ctx := context.Background()
- decision, err := m.subtaskService.ShouldGenerateSubtasks(ctx, task)
- if err != nil {
- m.logger.Warn("Failed to get LLM subtask decision for task",
- slog.String("task_id", task.ID),
- slog.String("error", err.Error()))
- // Fallback to simple heuristics
- return task.Priority == tm.PriorityHigh || len(task.Description) > 200
- }
-
- task.SubtasksEvaluated = true
- m.logger.Info("LLM subtask decision for task",
- slog.String("task_id", task.ID),
- slog.Bool("needs_subtasks", decision.NeedsSubtasks),
- slog.Int("complexity_score", decision.ComplexityScore),
- slog.String("reasoning", decision.Reasoning))
-
- return decision.NeedsSubtasks
-}
-
-// generateSubtasksForTask analyzes a task and creates a PR with proposed subtasks
-func (m *Manager) generateSubtasksForTask(ctx context.Context, task *tm.Task) error {
- if m.subtaskService == nil {
- return fmt.Errorf("subtask service not initialized")
- }
-
- // Analyze the task for subtasks
- analysis, err := m.subtaskService.AnalyzeTaskForSubtasks(ctx, task)
- if err != nil {
- return fmt.Errorf("failed to analyze task for subtasks: %w", err)
- }
-
- // Generate a PR with the subtask proposals
- prURL, err := m.subtaskService.GenerateSubtaskPR(ctx, analysis)
- if err != nil {
- return fmt.Errorf("failed to generate subtask PR: %w", err)
- }
-
- // Update the task with subtask information
- task.SubtasksPRURL = prURL
- task.SubtasksGenerated = true
-
- m.logger.Info("Generated subtask PR for task",
- slog.String("task_id", task.ID),
- slog.String("pr_url", prURL))
- m.logger.Info("Proposed subtasks and new agents for task",
- slog.String("task_id", task.ID),
- slog.Int("subtask_count", len(analysis.Subtasks)),
- slog.Int("new_agent_count", len(analysis.AgentCreations)))
-
- // Log proposed new agents if any
- if len(analysis.AgentCreations) > 0 {
- for _, agent := range analysis.AgentCreations {
- m.logger.Info("Proposed new agent",
- slog.String("role", agent.Role),
- slog.Any("skills", agent.Skills))
- }
- }
-
- return nil
-}
-
// IsAgentRunning checks if an agent is currently running
func (m *Manager) IsAgentRunning(agentName string) bool {
return m.isRunning[agentName]
@@ -764,18 +167,5 @@
slog.String("error", err.Error()))
}
}
-
- // Cleanup all agent Git clones
- if err := m.cloneManager.CleanupAllClones(); err != nil {
- m.logger.Error("Error cleaning up agent clones", slog.String("error", err.Error()))
- }
-
- // Cleanup subtask service
- if m.subtaskService != nil {
- if err := m.subtaskService.Close(); err != nil {
- m.logger.Error("Error closing subtask service", slog.String("error", err.Error()))
- }
- }
-
return nil
}
diff --git a/server/agent/thinker.go b/server/agent/thinker.go
new file mode 100644
index 0000000..8e70230
--- /dev/null
+++ b/server/agent/thinker.go
@@ -0,0 +1,522 @@
+package agent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+
+ "github.com/iomodo/staff/llm"
+ "github.com/iomodo/staff/tm"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+type Thinker struct {
+ roles []string
+ llmProvider llm.LLMProvider
+ model string // TODO: abstract away in llmProvider
+ systemPrompt string // TODO abstract away in llmProvider
+ maxTokens int
+ temperature float64
+ logger *slog.Logger
+}
+
+func NewThinker(llmProvider llm.LLMProvider, model string, systemPrompt string, maxTokens int, temperature float64, roles []string, logger *slog.Logger) *Thinker {
+ return &Thinker{llmProvider: llmProvider, model: model, maxTokens: maxTokens, temperature: temperature, roles: roles, logger: logger}
+}
+
+// shouldGenerateSubtasks determines if a task should be broken down into subtasks using LLM
+func (t *Thinker) ShouldGenerateSubtasks(task *tm.Task) bool {
+ // Don't generate subtasks for subtasks
+ if task.ParentTaskID != "" {
+ return false
+ }
+
+ // Don't generate if already evaluated
+ if task.SubtasksEvaluated {
+ return false
+ }
+
+ // Ask LLM to decide
+ ctx := context.Background()
+ decision, err := t.shouldGenerateSubtasks(ctx, task)
+ if err != nil {
+ t.logger.Warn("Failed to get LLM subtask decision for task",
+ slog.String("task_id", task.ID),
+ slog.String("error", err.Error()))
+ // Fallback to simple heuristics
+ return task.Priority == tm.PriorityHigh || len(task.Description) > 200
+ }
+
+ task.SubtasksEvaluated = true
+ t.logger.Info("LLM subtask decision for task",
+ slog.String("task_id", task.ID),
+ slog.Bool("needs_subtasks", decision.NeedsSubtasks),
+ slog.Int("complexity_score", decision.ComplexityScore),
+ slog.String("reasoning", decision.Reasoning))
+
+ return decision.NeedsSubtasks
+}
+
+// AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks
+func (t *Thinker) GenerateSubtasksForTask(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) {
+ prompt := buildSubtaskAnalysisPrompt(task)
+
+ req := llm.ChatCompletionRequest{
+ Model: t.model,
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: getSubtaskAnalysisSystemPrompt(t.roles),
+ },
+ {
+ Role: llm.RoleUser,
+ Content: prompt,
+ },
+ },
+ MaxTokens: &[]int{4000}[0],
+ Temperature: &[]float64{0.3}[0],
+ }
+
+ resp, err := t.llmProvider.ChatCompletion(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("LLM analysis failed: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return nil, fmt.Errorf("no response from LLM")
+ }
+
+ // Parse the LLM response
+ analysis, err := parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID, t.roles, t.logger)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse LLM response: %w", err)
+ }
+
+ return analysis, nil
+}
+
+// generateSolution uses the agent's LLM to generate a solution
+func (t *Thinker) GenerateSolution(ctx context.Context, task *tm.Task) (string, error) {
+ prompt := buildTaskPrompt(task)
+
+ req := llm.ChatCompletionRequest{
+ Model: t.model,
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: t.systemPrompt,
+ },
+ {
+ Role: llm.RoleUser,
+ Content: prompt,
+ },
+ },
+ MaxTokens: &t.maxTokens,
+ Temperature: &t.temperature,
+ }
+
+ resp, err := t.llmProvider.ChatCompletion(ctx, req)
+ if err != nil {
+ return "", fmt.Errorf("LLM request failed: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return "", fmt.Errorf("no response from LLM")
+ }
+
+ return resp.Choices[0].Message.Content, nil
+}
+
+// ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents
+func (t *Thinker) shouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) {
+ prompt := buildSubtaskDecisionPrompt(task)
+
+ req := llm.ChatCompletionRequest{
+ Model: t.model,
+ Messages: []llm.Message{
+ {
+ Role: llm.RoleSystem,
+ Content: getSubtaskDecisionSystemPrompt(t.roles),
+ },
+ {
+ Role: llm.RoleUser,
+ Content: prompt,
+ },
+ },
+ MaxTokens: &[]int{1000}[0],
+ Temperature: &[]float64{0.3}[0],
+ }
+
+ resp, err := t.llmProvider.ChatCompletion(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("LLM decision failed: %w", err)
+ }
+
+ if len(resp.Choices) == 0 {
+ return nil, fmt.Errorf("no response from LLM")
+ }
+
+ // Parse the LLM response
+ decision, err := parseSubtaskDecision(resp.Choices[0].Message.Content)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse LLM decision: %w", err)
+ }
+
+ return decision, nil
+}
+
+func buildSubtaskDecisionPrompt(task *tm.Task) string {
+ return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks:
+
+**Task Title:** %s
+
+**Description:** %s
+
+**Priority:** %s
+
+**Current Status:** %s
+
+Consider:
+- Can this be completed by a single agent with existing capabilities?
+- Does it require multiple specialized skills?
+- Is the scope too large for one person?
+- Are there logical components that could be parallelized?
+
+Provide your decision in the JSON format specified in the system prompt.`,
+ task.Title,
+ task.Description,
+ task.Priority,
+ task.Status)
+}
+
+func getSubtaskDecisionSystemPrompt(roles []string) string {
+ availableRoles := strings.Join(roles, ", ")
+
+ return fmt.Sprintf(`You are an expert project manager and task analyst. Your job is to determine whether a task needs to be broken down into subtasks.
+
+Currently available team roles and their capabilities: %s
+
+When evaluating a task, consider:
+1. Task complexity and scope
+2. Whether multiple specialized skills are needed
+3. If the task can be completed by a single agent with current capabilities
+4. Whether new agent roles might be needed for specialized skills
+
+Respond with a JSON object in this exact format:
+{
+ "needs_subtasks": true/false,
+ "reasoning": "Clear explanation of why subtasks are or aren't needed",
+ "complexity_score": 5,
+ "required_skills": ["skill1", "skill2", "skill3"]
+}
+
+Complexity score should be 1-10 where:
+- 1-3: Simple tasks that can be handled by one agent
+- 4-6: Moderate complexity, might benefit from subtasks
+- 7-10: Complex tasks that definitely need breaking down
+
+Required skills should list all technical/domain skills needed to complete the task.`, availableRoles)
+}
+
+func parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) {
+ // Try to extract JSON from the response
+ jsonStart := strings.Index(response, "{")
+ jsonEnd := strings.LastIndex(response, "}")
+
+ if jsonStart == -1 || jsonEnd == -1 {
+ return nil, fmt.Errorf("no JSON found in LLM response")
+ }
+
+ jsonStr := response[jsonStart : jsonEnd+1]
+
+ var decision tm.SubtaskDecision
+ if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
+ }
+
+ return &decision, nil
+}
+
+func buildSubtaskAnalysisPrompt(task *tm.Task) string {
+ return fmt.Sprintf(`Please analyze the following task and break it down into subtasks:
+
+**Task Title:** %s
+
+**Description:** %s
+
+**Priority:** %s
+
+**Current Status:** %s
+
+Please analyze this task and provide a detailed breakdown into subtasks. Consider:
+- Technical complexity and requirements
+- Logical task dependencies
+- Appropriate skill sets needed for each subtask
+- Risk factors and potential blockers
+- Estimated effort for each component
+
+Provide the analysis in the JSON format specified in the system prompt.`,
+ task.Title,
+ task.Description,
+ task.Priority,
+ task.Status)
+}
+
+func getSubtaskAnalysisSystemPrompt(roles []string) string {
+ availableRoles := strings.Join(roles, ", ")
+
+ return fmt.Sprintf(`You are an expert project manager and technical architect. Your job is to analyze complex tasks and break them down into well-defined subtasks that can be assigned to specialized team members.
+
+Currently available team roles: %s
+
+When analyzing a task, you should:
+1. Understand the task requirements and scope
+2. Break it down into logical, manageable subtasks
+3. Assign each subtask to the most appropriate team role OR propose creating new agents
+4. Estimate effort and identify dependencies
+5. Provide a clear execution strategy
+
+If you need specialized skills not covered by existing roles, propose new agent creation.
+
+Respond with a JSON object in this exact format:
+{
+ "analysis_summary": "Brief analysis of the task and approach",
+ "subtasks": [
+ {
+ "title": "Subtask title",
+ "description": "Detailed description of what needs to be done",
+ "priority": "high|medium|low",
+ "assigned_to": "role_name",
+ "estimated_hours": 8,
+ "dependencies": ["subtask_index_1", "subtask_index_2"],
+ "required_skills": ["skill1", "skill2"]
+ }
+ ],
+ "agent_creations": [
+ {
+ "role": "new_role_name",
+ "skills": ["specialized_skill1", "specialized_skill2"],
+ "description": "Description of what this agent does",
+ "justification": "Why this new agent is needed"
+ }
+ ],
+ "recommended_approach": "High-level strategy for executing these subtasks",
+ "estimated_total_hours": 40,
+ "risk_assessment": "Potential risks and mitigation strategies"
+}
+
+For existing roles, use: %s
+For new agents, propose appropriate role names and skill sets.
+Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles)
+}
+
+func parseSubtaskAnalysis(response string, parentTaskID string, agentRoles []string, logger *slog.Logger) (*tm.SubtaskAnalysis, error) {
+ // Try to extract JSON from the response (LLM might wrap it in markdown)
+ jsonStart := strings.Index(response, "{")
+ jsonEnd := strings.LastIndex(response, "}")
+
+ if jsonStart == -1 || jsonEnd == -1 {
+ return nil, fmt.Errorf("no JSON found in LLM response")
+ }
+
+ jsonStr := response[jsonStart : jsonEnd+1]
+
+ var rawAnalysis struct {
+ AnalysisSummary string `json:"analysis_summary"`
+ Subtasks []struct {
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Priority string `json:"priority"`
+ AssignedTo string `json:"assigned_to"`
+ EstimatedHours int `json:"estimated_hours"`
+ Dependencies []string `json:"dependencies"`
+ RequiredSkills []string `json:"required_skills"`
+ } `json:"subtasks"`
+ AgentCreations []tm.AgentCreationProposal `json:"agent_creations"`
+ RecommendedApproach string `json:"recommended_approach"`
+ EstimatedTotalHours int `json:"estimated_total_hours"`
+ RiskAssessment string `json:"risk_assessment"`
+ }
+
+ if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
+ }
+
+ // Convert to our types
+ analysis := &tm.SubtaskAnalysis{
+ ParentTaskID: parentTaskID,
+ AnalysisSummary: rawAnalysis.AnalysisSummary,
+ AgentCreations: rawAnalysis.AgentCreations,
+ RecommendedApproach: rawAnalysis.RecommendedApproach,
+ EstimatedTotalHours: rawAnalysis.EstimatedTotalHours,
+ RiskAssessment: rawAnalysis.RiskAssessment,
+ }
+
+ // Convert subtasks
+ for _, st := range rawAnalysis.Subtasks {
+ priority := tm.PriorityMedium // default
+ switch strings.ToLower(st.Priority) {
+ case "high":
+ priority = tm.PriorityHigh
+ case "low":
+ priority = tm.PriorityLow
+ }
+
+ subtask := tm.SubtaskProposal{
+ Title: st.Title,
+ Description: st.Description,
+ Priority: priority,
+ AssignedTo: st.AssignedTo,
+ EstimatedHours: st.EstimatedHours,
+ Dependencies: st.Dependencies,
+ RequiredSkills: st.RequiredSkills,
+ }
+
+ analysis.Subtasks = append(analysis.Subtasks, subtask)
+ }
+
+ // Validate agent assignments and handle new agent creation
+ if err := validateAndHandleAgentAssignments(analysis, agentRoles, logger); err != nil {
+ logger.Warn("Warning during agent assignment handling", slog.String("error", err.Error()))
+ }
+
+ return analysis, nil
+}
+
+func validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis, agentRoles []string, logger *slog.Logger) error {
+ // Collect all agent roles that will be available (existing + proposed new ones)
+ availableRoles := make(map[string]bool)
+ for _, role := range agentRoles {
+ availableRoles[role] = true
+ }
+
+ // Add proposed new agent roles
+ for _, agentCreation := range analysis.AgentCreations {
+ availableRoles[agentCreation.Role] = true
+
+ // Create a subtask for agent creation
+ agentCreationSubtask := tm.SubtaskProposal{
+ Title: fmt.Sprintf("Create %s Agent", cases.Title(language.English).String(agentCreation.Role)),
+ Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification),
+ Priority: tm.PriorityHigh, // Agent creation is high priority
+ AssignedTo: "ceo", // CEO creates new agents
+ EstimatedHours: 4, // Estimated time to set up new agent
+ Dependencies: []string{}, // No dependencies for agent creation
+ RequiredSkills: []string{"agent_configuration", "system_design"},
+ }
+
+ // Insert at the beginning so agent creation happens first
+ analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...)
+
+ // Update dependencies to account for the new subtask at index 0
+ for i := 1; i < len(analysis.Subtasks); i++ {
+ for j, dep := range analysis.Subtasks[i].Dependencies {
+ // Convert dependency index and increment by 1
+ if depIndex := parseDependencyIndex(dep); depIndex >= 0 {
+ analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1)
+ }
+ }
+ }
+ }
+
+ // Now validate all assignments against available roles
+ defaultRole := "ceo" // fallback role
+ if len(agentRoles) > 0 {
+ defaultRole = agentRoles[0]
+ }
+
+ for i := range analysis.Subtasks {
+ if !availableRoles[analysis.Subtasks[i].AssignedTo] {
+ logger.Warn("Unknown agent role for subtask, using default",
+ slog.String("unknown_role", analysis.Subtasks[i].AssignedTo),
+ slog.String("subtask_title", analysis.Subtasks[i].Title),
+ slog.String("assigned_role", defaultRole))
+ analysis.Subtasks[i].AssignedTo = defaultRole
+ }
+ }
+
+ return nil
+}
+
+func parseDependencyIndex(dep string) int {
+ var idx int
+ if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
+ return idx
+ }
+ return -1 // Invalid dependency format
+}
+
+func 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()
+}
+
+// buildTaskPrompt creates a detailed prompt for the LLM
+func buildTaskPrompt(task *tm.Task) string {
+ return fmt.Sprintf(`Task: %s
+
+Priority: %s
+Description: %s
+
+Please provide a complete solution for this task. Include:
+1. Detailed implementation plan
+2. Code changes needed (if applicable)
+3. Files to be created or modified
+4. Testing considerations
+5. Any dependencies or prerequisites
+
+Your response should be comprehensive and actionable.`,
+ task.Title,
+ task.Priority,
+ task.Description)
+}
diff --git a/server/agent/types.go b/server/agent/types.go
deleted file mode 100644
index a9f021c..0000000
--- a/server/agent/types.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package agent
-
-import (
- "github.com/iomodo/staff/llm"
-)
-
-// Agent represents an AI agent that can process tasks autonomously
-type Agent struct {
- // Identity
- Name string
- Role string
-
- // LLM Configuration
- Model string
- SystemPrompt string
- MaxTokens *int
- Temperature *float64
-
- // Runtime
- Provider llm.LLMProvider
- CurrentTask *string // Task ID currently being processed
-
- // Statistics
- Stats AgentStats
-}
-
-// AgentStats tracks agent performance metrics
-type AgentStats struct {
- TasksCompleted int `json:"tasks_completed"`
- TasksFailed int `json:"tasks_failed"`
- SuccessRate float64 `json:"success_rate"`
- AvgTime int64 `json:"avg_completion_time_seconds"`
-}
-
-// AgentStatus represents the current state of an agent
-type AgentStatus string
-
-const (
- StatusIdle AgentStatus = "idle"
- StatusRunning AgentStatus = "running"
- StatusError AgentStatus = "error"
- StatusStopped AgentStatus = "stopped"
-)
-
-// AgentInfo provides status information about an agent
-type AgentInfo struct {
- Name string `json:"name"`
- Role string `json:"role"`
- Model string `json:"model"`
- Status AgentStatus `json:"status"`
- CurrentTask *string `json:"current_task,omitempty"`
- Stats AgentStats `json:"stats"`
-}
diff --git a/server/cmd/commands/generate_subtasks.go b/server/cmd/commands/generate_subtasks.go
deleted file mode 100644
index c907b82..0000000
--- a/server/cmd/commands/generate_subtasks.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package commands
-
-import (
- "fmt"
- "log"
-
- "github.com/spf13/cobra"
-)
-
-var generateSubtasksCmd = &cobra.Command{
- Use: "generate-subtasks [task-id]",
- Short: "Generate subtasks for a given task using LLM analysis",
- Long: `Analyze a task and generate proposed subtasks using LLM analysis.
-This creates a PR with the proposed subtasks that can be reviewed and merged.
-
-Example:
- staff generate-subtasks task-123456-abcdef`,
- Args: cobra.ExactArgs(1),
- RunE: runGenerateSubtasks,
-}
-
-func init() {
- rootCmd.AddCommand(generateSubtasksCmd)
-}
-
-func runGenerateSubtasks(cmd *cobra.Command, args []string) error {
- taskID := args[0]
-
- // Get the task
- task, err := taskManager.GetTask(taskID)
- if err != nil {
- return fmt.Errorf("failed to get task: %w", err)
- }
-
- fmt.Printf("Generating subtasks for task: %s\n", task.Title)
- fmt.Printf("Description: %s\n", task.Description)
- fmt.Printf("Priority: %s\n\n", task.Priority)
-
- // Check if subtasks already generated
- if task.SubtasksGenerated {
- fmt.Printf("⚠️ Subtasks already generated for this task\n")
- if task.SubtasksPRURL != "" {
- fmt.Printf("PR URL: %s\n", task.SubtasksPRURL)
- }
- return nil
- }
-
- // Force subtask generation by temporarily marking as high priority
- originalPriority := task.Priority
- task.Priority = "high"
-
- // Get the manager's subtask service and generate
- if agentManager == nil {
- return fmt.Errorf("agent manager not initialized")
- }
-
- // This is a bit of a hack to access private method, but for testing...
- log.Printf("Starting subtask analysis for task %s...", taskID)
-
- // We'll call this through the normal task processing flow
- // by creating a dummy agent to trigger the subtask generation
- agentStatus := agentManager.GetAgentStatus()
- if len(agentStatus) == 0 {
- return fmt.Errorf("no agents available")
- }
-
- // Get first available agent
- var agentName string
- for name := range agentStatus {
- agentName = name
- break
- }
-
- fmt.Printf("🤖 Using agent '%s' for subtask analysis...\n\n", agentName)
-
- // Create a simple approach: assign task to agent and let it process
- task.Assignee = agentName
- task.Priority = originalPriority // Restore original priority
-
- if err := taskManager.UpdateTask(task); err != nil {
- return fmt.Errorf("failed to update task assignment: %w", err)
- }
-
- fmt.Printf("✅ Task assigned to agent '%s' for subtask generation\n", agentName)
- fmt.Printf("💡 Start the server to see subtask generation in action:\n")
- fmt.Printf(" staff server --config config-fake.yaml\n\n")
- fmt.Printf("📋 The agent will:\n")
- fmt.Printf(" 1. Analyze the task requirements\n")
- fmt.Printf(" 2. Generate proposed subtasks with LLM\n")
- fmt.Printf(" 3. Create a PR with subtask proposals\n")
- fmt.Printf(" 4. Wait for PR review and approval\n")
- fmt.Printf(" 5. Create actual subtasks when PR is merged\n")
-
- return nil
-}
\ No newline at end of file
diff --git a/server/cmd/commands/root.go b/server/cmd/commands/root.go
index ade7c53..e75e134 100644
--- a/server/cmd/commands/root.go
+++ b/server/cmd/commands/root.go
@@ -75,8 +75,8 @@
Level: slog.LevelInfo,
}))
- gitInterface := git.DefaultGit("../")
- taskManager = git_tm.NewGitTaskManagerWithLogger(gitInterface, "../", logger)
+ gitInterface := git.New(cfg, logger)
+ taskManager = git_tm.NewGitTaskManager(gitInterface, cfg, logger)
// Initialize agent manager
agentManager, err = agent.NewManager(cfg, taskManager, logger)
diff --git a/server/config-fake.yaml b/server/config-fake.yaml
index 387ba96..a402991 100644
--- a/server/config-fake.yaml
+++ b/server/config-fake.yaml
@@ -12,6 +12,7 @@
repo: "staff"
git:
+ repo_path: "/Users/shota/github/staff/"
branch_prefix: "task/"
commit_message_template: "Task {task_id}: {task_title} by {agent_name}"
pr_template: |
diff --git a/server/config.yaml b/server/config.yaml
index 5a5b6dd..17287d6 100644
--- a/server/config.yaml
+++ b/server/config.yaml
@@ -12,6 +12,7 @@
repo: "staff" # Replace with your repository name
git:
+ repo_path: "/Users/shota/github/staff/"
branch_prefix: "task/"
commit_message_template: "Task {task_id}: {task_title} by {agent_name}"
pr_template: |
diff --git a/server/config/config.go b/server/config/config.go
index 86dd7a5..c51bd43 100644
--- a/server/config/config.go
+++ b/server/config/config.go
@@ -62,7 +62,7 @@
// GitConfig represents Git operation configuration
type GitConfig struct {
- BranchPrefix string `yaml:"branch_prefix"`
+ RepoPath string `yaml:"repo_path"`
CommitMessageTemplate string `yaml:"commit_message_template"`
PRTemplate string `yaml:"pr_template"`
}
@@ -158,9 +158,6 @@
}
// Git defaults
- if config.Git.BranchPrefix == "" {
- config.Git.BranchPrefix = "task/"
- }
if config.Git.CommitMessageTemplate == "" {
config.Git.CommitMessageTemplate = "Task {task_id}: {task_title}\n\n{solution}\n\nGenerated by Staff AI Agent: {agent_name}"
}
@@ -207,11 +204,11 @@
// Validate that at least one Git provider is configured
hasGitHub := config.GitHub.Token != "" && config.GitHub.Owner != "" && config.GitHub.Repo != ""
hasGerrit := config.Gerrit.Username != "" && config.Gerrit.Password != "" && config.Gerrit.BaseURL != "" && config.Gerrit.Project != ""
-
+
if !hasGitHub && !hasGerrit {
return fmt.Errorf("either GitHub or Gerrit configuration is required")
}
-
+
// Validate GitHub config if provided
if config.GitHub.Token != "" {
if config.GitHub.Owner == "" {
@@ -221,7 +218,7 @@
return fmt.Errorf("github.repo is required when github.token is provided")
}
}
-
+
// Validate Gerrit config if provided
if config.Gerrit.Username != "" {
if config.Gerrit.Password == "" {
diff --git a/server/git/CONCURRENCY_README.md b/server/git/CONCURRENCY_README.md
deleted file mode 100644
index 1cbe184..0000000
--- a/server/git/CONCURRENCY_README.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# Git Concurrency Solution: Per-Agent Repository Clones
-
-## Problem Statement
-
-Git is not thread-safe, which creates critical race conditions when multiple AI agents try to perform Git operations concurrently:
-
-- **Repository Corruption**: Multiple agents modifying the same `.git` folder simultaneously
-- **Branch Conflicts**: Agents creating branches with the same names or overwriting each other's work
-- **Push Failures**: Concurrent pushes causing merge conflicts and failed operations
-- **Index Lock Errors**: Git index.lock conflicts when multiple processes access the repository
-
-## Solution: Per-Agent Git Clones
-
-Instead of using mutexes (which would serialize all Git operations and hurt performance), we give each agent its own Git repository clone:
-
-```
-workspace/
-├── agent-backend-engineer/ # Backend engineer's clone
-│ ├── .git/
-│ ├── tasks/
-│ └── ...
-├── agent-frontend-engineer/ # Frontend engineer's clone
-│ ├── .git/
-│ ├── tasks/
-│ └── ...
-└── agent-qa-engineer/ # QA engineer's clone
- ├── .git/
- ├── tasks/
- └── ...
-```
-
-## Key Benefits
-
-### 🚀 **True Concurrency**
-- Multiple agents can work simultaneously without blocking each other
-- No waiting for Git lock releases
-- Scales to hundreds of concurrent agents
-
-### 🛡️ **Complete Isolation**
-- Each agent has its own `.git` directory and working tree
-- No shared state or race conditions
-- Agent failures don't affect other agents
-
-### 🔄 **Automatic Synchronization**
-- Each clone automatically pulls latest changes before creating branches
-- All branches push to the same remote repository
-- PRs are created against the main repository
-
-### 🧹 **Easy Cleanup**
-- `staff cleanup-clones` removes all agent workspaces
-- Clones are recreated on-demand when agents start working
-- No manual Git state management required
-
-## Implementation Details
-
-### CloneManager (`git/clone_manager.go`)
-
-```go
-type CloneManager struct {
- baseRepoURL string // Source repository URL
- workspacePath string // Base workspace directory
- agentClones map[string]string // agent name -> clone path
- mu sync.RWMutex // Thread-safe map access
-}
-```
-
-**Key Methods:**
-- `GetAgentClonePath(agentName)` - Get/create agent's clone directory
-- `RefreshAgentClone(agentName)` - Pull latest changes for an agent
-- `CleanupAgentClone(agentName)` - Remove specific agent's clone
-- `CleanupAllClones()` - Remove all agent clones
-
-### Agent Integration
-
-Each agent's Git operations are automatically routed to its dedicated clone:
-
-```go
-// Get agent's dedicated Git clone
-clonePath, err := am.cloneManager.GetAgentClonePath(agent.Name)
-if err != nil {
- return fmt.Errorf("failed to get agent clone: %w", err)
-}
-
-// 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...)...)
-}
-```
-
-## Workflow Example
-
-1. **Agent Starts Task**:
- ```bash
- Agent backend-engineer gets task: "Add user authentication"
- Creating clone: workspace/agent-backend-engineer/
- ```
-
-2. **Concurrent Operations**:
- ```bash
- # These happen simultaneously without conflicts:
- Agent backend-engineer: git clone -> workspace/agent-backend-engineer/
- Agent frontend-engineer: git clone -> workspace/agent-frontend-engineer/
- Agent qa-engineer: git clone -> workspace/agent-qa-engineer/
- ```
-
-3. **Branch Creation**:
- ```bash
- # Each agent creates branches in their own clone:
- backend-engineer: git checkout -b task-123-auth-backend
- frontend-engineer: git checkout -b task-124-auth-ui
- qa-engineer: git checkout -b task-125-auth-tests
- ```
-
-4. **Concurrent Pushes**:
- ```bash
- # All agents push to origin simultaneously:
- git push -u origin task-123-auth-backend # ✅ Success
- git push -u origin task-124-auth-ui # ✅ Success
- git push -u origin task-125-auth-tests # ✅ Success
- ```
-
-## Management Commands
-
-### List Agent Clones
-```bash
-staff list-agents # Shows which agents are running and their clone status
-```
-
-### Cleanup All Clones
-```bash
-staff cleanup-clones # Removes all agent workspace directories
-```
-
-### Monitor Disk Usage
-```bash
-du -sh workspace/ # Check total workspace disk usage
-```
-
-## Resource Considerations
-
-### Disk Space
-- Each clone uses ~repository size (typically 10-100MB per agent)
-- For 10 agents with 50MB repo = ~500MB total
-- Use `staff cleanup-clones` to free space when needed
-
-### Network Usage
-- Initial clone downloads full repository
-- Subsequent `git pull` operations are incremental
-- All agents share the same remote repository
-
-### Performance
-- Clone creation: ~2-5 seconds per agent (one-time cost)
-- Git operations: Full speed, no waiting for locks
-- Parallel processing: Linear scalability with agent count
-
-## Comparison to Alternatives
-
-| Solution | Concurrency | Complexity | Performance | Risk |
-|----------|-------------|------------|-------------|------|
-| **Per-Agent Clones** | ✅ Full | 🟡 Medium | ✅ High | 🟢 Low |
-| Global Git Mutex | ❌ None | 🟢 Low | ❌ Poor | 🟡 Medium |
-| File Locking | 🟡 Limited | 🔴 High | 🟡 Medium | 🔴 High |
-| Separate Repositories | ✅ Full | 🔴 Very High | ✅ High | 🔴 High |
-
-## Future Enhancements
-
-- **Lazy Cleanup**: Auto-remove unused clones after N days
-- **Clone Sharing**: Share clones between agents with similar tasks
-- **Compressed Clones**: Use `git clone --depth=1` for space efficiency
-- **Remote Workspaces**: Support for distributed agent execution
-
-The per-agent clone solution provides the optimal balance of performance, safety, and maintainability for concurrent AI agent operations.
\ No newline at end of file
diff --git a/server/git/PULL_REQUEST_README.md b/server/git/PULL_REQUEST_README.md
deleted file mode 100644
index b6664f2..0000000
--- a/server/git/PULL_REQUEST_README.md
+++ /dev/null
@@ -1,323 +0,0 @@
-# Pull Request Capabilities
-
-This package now includes comprehensive pull request (PR) capabilities that support both GitHub and Gerrit platforms. The implementation provides a unified interface for managing pull requests across different code hosting platforms.
-
-## Features
-
-- **Unified Interface**: Same API for both GitHub and Gerrit
-- **Full CRUD Operations**: Create, read, update, delete pull requests
-- **Advanced Filtering**: List pull requests with various filters
-- **Merge Operations**: Support for different merge strategies
-- **Error Handling**: Comprehensive error handling with detailed messages
-- **Authentication**: Support for token-based and basic authentication
-
-## Supported Platforms
-
-### GitHub
-- Uses GitHub REST API v3
-- Supports personal access tokens for authentication
-- Full support for all pull request operations
-- Handles GitHub-specific features like draft PRs, labels, assignees, and reviewers
-
-### Gerrit
-- Uses Gerrit REST API
-- Supports HTTP password or API token authentication
-- Maps Gerrit "changes" to pull requests
-- Handles Gerrit-specific features like topics and review workflows
-
-## Quick Start
-
-### GitHub Example
-
-```go
-package main
-
-import (
- "context"
- "github.com/iomodo/staff/git"
- "net/http"
- "time"
-)
-
-func main() {
- ctx := context.Background()
-
- // Create GitHub configuration
- githubConfig := git.GitHubConfig{
- Token: "your-github-token",
- BaseURL: "https://api.github.com",
- HTTPClient: &http.Client{Timeout: 30 * time.Second},
- }
-
- // Create GitHub provider
- githubProvider := git.NewGitHubPullRequestProvider("owner", "repo", githubConfig)
-
- // Create Git instance with pull request capabilities
- git := git.NewGitWithPullRequests("/path/to/repo", git.GitConfig{}, githubProvider)
-
- // Create a pull request
- prOptions := git.PullRequestOptions{
- Title: "Add new feature",
- Description: "This PR adds a new feature to the application.",
- BaseBranch: "main",
- HeadBranch: "feature/new-feature",
- Labels: []string{"enhancement", "feature"},
- Assignees: []string{"username1", "username2"},
- Reviewers: []string{"reviewer1", "reviewer2"},
- Draft: false,
- }
-
- pr, err := git.CreatePullRequest(ctx, prOptions)
- if err != nil {
- log.Fatal(err)
- }
-
- fmt.Printf("Created pull request: %s (#%d)\n", pr.Title, pr.Number)
-}
-```
-
-### Gerrit Example
-
-```go
-package main
-
-import (
- "context"
- "github.com/iomodo/staff/git"
- "net/http"
- "time"
-)
-
-func main() {
- ctx := context.Background()
-
- // Create Gerrit configuration
- gerritConfig := git.GerritConfig{
- Username: "your-username",
- Password: "your-http-password-or-api-token",
- BaseURL: "https://gerrit.example.com",
- HTTPClient: &http.Client{Timeout: 30 * time.Second},
- }
-
- // Create Gerrit provider
- gerritProvider := git.NewGerritPullRequestProvider("project-name", gerritConfig)
-
- // Create Git instance with pull request capabilities
- git := git.NewGitWithPullRequests("/path/to/repo", git.GitConfig{}, gerritProvider)
-
- // Create a change (pull request)
- prOptions := git.PullRequestOptions{
- Title: "Add new feature",
- Description: "This change adds a new feature to the application.",
- BaseBranch: "master",
- HeadBranch: "feature/new-feature",
- }
-
- pr, err := git.CreatePullRequest(ctx, prOptions)
- if err != nil {
- log.Fatal(err)
- }
-
- fmt.Printf("Created change: %s (#%d)\n", pr.Title, pr.Number)
-}
-```
-
-## API Reference
-
-### Types
-
-#### PullRequest
-Represents a pull request or merge request across platforms.
-
-```go
-type PullRequest struct {
- ID string
- Number int
- Title string
- Description string
- State string // "open", "closed", "merged"
- Author Author
- CreatedAt time.Time
- UpdatedAt time.Time
- BaseBranch string
- HeadBranch string
- BaseRepo string
- HeadRepo string
- Labels []string
- Assignees []Author
- Reviewers []Author
- Commits []Commit
- Comments []PullRequestComment
-}
-```
-
-#### PullRequestOptions
-Options for creating or updating pull requests.
-
-```go
-type PullRequestOptions struct {
- Title string
- Description string
- BaseBranch string
- HeadBranch string
- BaseRepo string
- HeadRepo string
- Labels []string
- Assignees []string
- Reviewers []string
- Draft bool
-}
-```
-
-#### ListPullRequestOptions
-Options for listing pull requests.
-
-```go
-type ListPullRequestOptions struct {
- State string // "open", "closed", "all"
- Author string
- Assignee string
- BaseBranch string
- HeadBranch string
- Labels []string
- Limit int
-}
-```
-
-#### MergePullRequestOptions
-Options for merging pull requests.
-
-```go
-type MergePullRequestOptions struct {
- MergeMethod string // "merge", "squash", "rebase"
- CommitTitle string
- CommitMsg string
-}
-```
-
-### Methods
-
-#### CreatePullRequest
-Creates a new pull request.
-
-```go
-func (g *Git) CreatePullRequest(ctx context.Context, options PullRequestOptions) (*PullRequest, error)
-```
-
-#### GetPullRequest
-Retrieves a pull request by ID.
-
-```go
-func (g *Git) GetPullRequest(ctx context.Context, id string) (*PullRequest, error)
-```
-
-#### ListPullRequests
-Lists pull requests with optional filtering.
-
-```go
-func (g *Git) ListPullRequests(ctx context.Context, options ListPullRequestOptions) ([]PullRequest, error)
-```
-
-#### UpdatePullRequest
-Updates an existing pull request.
-
-```go
-func (g *Git) UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error)
-```
-
-#### ClosePullRequest
-Closes a pull request.
-
-```go
-func (g *Git) ClosePullRequest(ctx context.Context, id string) error
-```
-
-#### MergePullRequest
-Merges a pull request.
-
-```go
-func (g *Git) MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error
-```
-
-## Configuration
-
-### GitHub Configuration
-
-```go
-type GitHubConfig struct {
- Token string // GitHub personal access token
- BaseURL string // GitHub API base URL (default: https://api.github.com)
- HTTPClient *http.Client // Custom HTTP client (optional)
-}
-```
-
-### Gerrit Configuration
-
-```go
-type GerritConfig struct {
- Username string // Gerrit username
- Password string // HTTP password or API token
- BaseURL string // Gerrit instance URL
- HTTPClient *http.Client // Custom HTTP client (optional)
-}
-```
-
-## Error Handling
-
-All pull request operations return detailed error information through the `GitError` type:
-
-```go
-type GitError struct {
- Command string
- Output string
- Err error
-}
-```
-
-Common error scenarios:
-- Authentication failures
-- Invalid repository or project names
-- Network connectivity issues
-- API rate limiting
-- Invalid pull request data
-
-## Platform-Specific Notes
-
-### GitHub
-- Requires a personal access token with appropriate permissions
-- Supports draft pull requests
-- Full support for labels, assignees, and reviewers
-- Uses GitHub's REST API v3
-
-### Gerrit
-- Requires HTTP password or API token
-- Uses "changes" instead of "pull requests"
-- Topics are used to group related changes
-- Review workflow is more structured
-- Uses Gerrit's REST API
-
-## Examples
-
-See `pull_request_example.go` for comprehensive examples of using both GitHub and Gerrit providers.
-
-## Testing
-
-Run the tests to ensure everything works correctly:
-
-```bash
-go test ./git/... -v
-```
-
-## Contributing
-
-When adding support for new platforms:
-
-1. Implement the `PullRequestProvider` interface
-2. Add platform-specific configuration types
-3. Create conversion functions to map platform-specific data to our unified types
-4. Add comprehensive tests
-5. Update this documentation
-
-## License
-
-This code is part of the staff project and follows the same licensing terms.
\ No newline at end of file
diff --git a/server/git/README.md b/server/git/README.md
deleted file mode 100644
index 18ccc1f..0000000
--- a/server/git/README.md
+++ /dev/null
@@ -1,331 +0,0 @@
-# Git Interface for Go
-
-A comprehensive Go interface for Git operations that provides a clean, type-safe API for interacting with Git repositories.
-
-## Features
-
-- **Complete Git Operations**: Supports all major Git operations including repository management, commits, branches, remotes, tags, and more
-- **Type-Safe API**: Strongly typed interfaces and structs for better code safety
-- **Context Support**: All operations support context for cancellation and timeouts
-- **Error Handling**: Comprehensive error handling with detailed error messages
-- **Flexible Configuration**: Configurable timeouts and environment variables
-- **Easy to Use**: Simple and intuitive API design
-
-## Installation
-
-The Git interface is part of the `github.com/iomodo/staff` module. No additional dependencies are required beyond the standard library.
-
-## Quick Start
-
-```go
-package main
-
-import (
- "context"
- "fmt"
- "log"
-
- "github.com/iomodo/staff/git"
-)
-
-func main() {
- ctx := context.Background()
-
- // Create a Git instance
- git := git.DefaultGit("/path/to/your/repo")
-
- // Open an existing repository
- if err := git.Open(ctx, "/path/to/your/repo"); err != nil {
- log.Fatalf("Failed to open repository: %v", err)
- }
-
- // Get repository status
- status, err := git.Status(ctx)
- if err != nil {
- log.Fatalf("Failed to get status: %v", err)
- }
-
- fmt.Printf("Current branch: %s\n", status.Branch)
- fmt.Printf("Repository is clean: %t\n", status.IsClean)
-}
-```
-
-## Core Interface
-
-### GitInterface
-
-The main interface that defines all Git operations:
-
-```go
-type GitInterface interface {
- // Repository operations
- Init(ctx context.Context, path string) error
- Clone(ctx context.Context, url, path string) error
- IsRepository(ctx context.Context, path string) (bool, error)
-
- // Status and information
- Status(ctx context.Context) (*Status, error)
- Log(ctx context.Context, options LogOptions) ([]Commit, error)
- Show(ctx context.Context, ref string) (*Commit, error)
-
- // Branch operations
- ListBranches(ctx context.Context) ([]Branch, error)
- CreateBranch(ctx context.Context, name string, startPoint string) error
- Checkout(ctx context.Context, ref string) error
- DeleteBranch(ctx context.Context, name string, force bool) error
- GetCurrentBranch(ctx context.Context) (string, error)
-
- // Commit operations
- Add(ctx context.Context, paths []string) error
- AddAll(ctx context.Context) error
- Commit(ctx context.Context, message string, options CommitOptions) error
-
- // Remote operations
- ListRemotes(ctx context.Context) ([]Remote, error)
- AddRemote(ctx context.Context, name, url string) error
- RemoveRemote(ctx context.Context, name string) error
- Fetch(ctx context.Context, remote string, options FetchOptions) error
- Pull(ctx context.Context, remote, branch string) error
- Push(ctx context.Context, remote, branch string, options PushOptions) error
-
- // Merge operations
- Merge(ctx context.Context, ref string, options MergeOptions) error
- MergeBase(ctx context.Context, ref1, ref2 string) (string, error)
-
- // Configuration
- GetConfig(ctx context.Context, key string) (string, error)
- SetConfig(ctx context.Context, key, value string) error
- GetUserConfig(ctx context.Context) (*UserConfig, error)
- SetUserConfig(ctx context.Context, config UserConfig) error
-}
-```
-
-## Data Structures
-
-### Status
-
-Represents the current state of the repository:
-
-```go
-type Status struct {
- Branch string
- IsClean bool
- Staged []FileStatus
- Unstaged []FileStatus
- Untracked []string
- Conflicts []string
-}
-```
-
-### Commit
-
-Represents a Git commit:
-
-```go
-type Commit struct {
- Hash string
- Author Author
- Committer Author
- Message string
- Parents []string
- Timestamp time.Time
- Files []CommitFile
-}
-```
-
-### Branch
-
-Represents a Git branch:
-
-```go
-type Branch struct {
- Name string
- IsCurrent bool
- IsRemote bool
- Commit string
- Message string
-}
-```
-
-## Usage Examples
-
-### Repository Management
-
-```go
-// Initialize a new repository
-git := git.DefaultGit("/path/to/new/repo")
-if err := git.Init(ctx, "/path/to/new/repo"); err != nil {
- log.Fatal(err)
-}
-
-// Clone a repository
-if err := git.Clone(ctx, "https://github.com/user/repo.git", "/path/to/clone"); err != nil {
- log.Fatal(err)
-}
-```
-
-### Basic Git Workflow
-
-```go
-// Stage files
-if err := git.Add(ctx, []string{"file1.txt", "file2.txt"}); err != nil {
- log.Fatal(err)
-}
-
-// Or stage all changes
-if err := git.AddAll(ctx); err != nil {
- log.Fatal(err)
-}
-
-// Commit changes
-commitOptions := git.CommitOptions{
- AllowEmpty: false,
-}
-if err := git.Commit(ctx, "Add new feature", commitOptions); err != nil {
- log.Fatal(err)
-}
-```
-
-### Branch Operations
-
-```go
-// List all branches
-branches, err := git.ListBranches(ctx)
-if err != nil {
- log.Fatal(err)
-}
-
-for _, branch := range branches {
- fmt.Printf("Branch: %s (current: %t)\n", branch.Name, branch.IsCurrent)
-}
-
-// Create a new branch
-if err := git.CreateBranch(ctx, "feature/new-feature", ""); err != nil {
- log.Fatal(err)
-}
-
-// Switch to a branch
-if err := git.Checkout(ctx, "feature/new-feature"); err != nil {
- log.Fatal(err)
-}
-
-// Get current branch
-currentBranch, err := git.GetCurrentBranch(ctx)
-if err != nil {
- log.Fatal(err)
-}
-fmt.Printf("Current branch: %s\n", currentBranch)
-```
-
-### Remote Operations
-
-```go
-// Add a remote
-if err := git.AddRemote(ctx, "origin", "https://github.com/user/repo.git"); err != nil {
- log.Fatal(err)
-}
-
-// List remotes
-remotes, err := git.ListRemotes(ctx)
-if err != nil {
- log.Fatal(err)
-}
-
-for _, remote := range remotes {
- fmt.Printf("Remote: %s -> %s\n", remote.Name, remote.URL)
-}
-
-// Fetch from remote
-fetchOptions := git.FetchOptions{
- All: true,
- Tags: true,
-}
-if err := git.Fetch(ctx, "", fetchOptions); err != nil {
- log.Fatal(err)
-}
-
-// Push to remote
-pushOptions := git.PushOptions{
- SetUpstream: true,
-}
-if err := git.Push(ctx, "origin", "main", pushOptions); err != nil {
- log.Fatal(err)
-}
-```
-
-### Configuration
-
-```go
-// Get user configuration
-userConfig, err := git.GetUserConfig(ctx)
-if err != nil {
- log.Fatal(err)
-}
-fmt.Printf("User: %s <%s>\n", userConfig.Name, userConfig.Email)
-
-// Set user configuration
-newConfig := git.UserConfig{
- Name: "John Doe",
- Email: "john@example.com",
-}
-if err := git.SetUserConfig(ctx, newConfig); err != nil {
- log.Fatal(err)
-}
-
-// Get/set custom config
-value, err := git.GetConfig(ctx, "core.editor")
-if err != nil {
- log.Fatal(err)
-}
-
-if err := git.SetConfig(ctx, "core.editor", "vim"); err != nil {
- log.Fatal(err)
-}
-```
-
-## Error Handling
-
-The Git interface provides detailed error information through the `GitError` type:
-
-```go
-if err := git.Commit(ctx, "Invalid commit", git.CommitOptions{}); err != nil {
- if gitErr, ok := err.(*git.GitError); ok {
- fmt.Printf("Git command '%s' failed: %v\n", gitErr.Command, gitErr.Err)
- fmt.Printf("Output: %s\n", gitErr.Output)
- }
-}
-```
-
-## Configuration
-
-You can customize the Git instance with specific configuration:
-
-```go
-config := git.GitConfig{
- Timeout: 60 * time.Second,
- Env: map[string]string{
- "GIT_AUTHOR_NAME": "Custom Author",
- "GIT_AUTHOR_EMAIL": "author@example.com",
- },
-}
-
-git := git.NewGit("/path/to/repo", config)
-```
-
-## Thread Safety
-
-The Git interface is not thread-safe. If you need to use it from multiple goroutines, you should either:
-
-1. Use separate Git instances for each goroutine
-2. Use a mutex to synchronize access
-3. Use channels to serialize operations
-
-## Requirements
-
-- Go 1.24.4 or later
-- Git installed and available in PATH
-- Appropriate permissions to access the repository
-
-## License
-
-This code is part of the `github.com/iomodo/staff` project and follows the same license terms.
\ No newline at end of file
diff --git a/server/git/clone_manager.go b/server/git/clone_manager.go
index 7bc9cde..33a5a99 100644
--- a/server/git/clone_manager.go
+++ b/server/git/clone_manager.go
@@ -144,17 +144,3 @@
return nil
}
-
-// GetAllAgentClones returns a map of all agent clones
-func (cm *CloneManager) GetAllAgentClones() map[string]string {
- cm.mu.RLock()
- defer cm.mu.RUnlock()
-
- // Return a copy to avoid race conditions
- result := make(map[string]string)
- for agent, path := range cm.agentClones {
- result[agent] = path
- }
-
- return result
-}
diff --git a/server/git/example.go b/server/git/example.go
deleted file mode 100644
index 1bea27c..0000000
--- a/server/git/example.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package git
-
-import (
- "context"
- "log/slog"
- "os"
-)
-
-// Example demonstrates how to use the Git interface
-func Example() {
- ctx := context.Background()
-
- // Create logger
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- // Create a new Git instance
- git := DefaultGit("/path/to/your/repo")
-
- // Get repository status
- status, err := git.Status(ctx)
- if err != nil {
- logger.Error("Failed to get status", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Repository status", slog.String("branch", status.Branch), slog.Bool("clean", status.IsClean))
-
- // List branches
- branches, err := git.ListBranches(ctx)
- if err != nil {
- logger.Error("Failed to list branches", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Branches found", slog.Int("count", len(branches)))
- for _, branch := range branches {
- current := ""
- if branch.IsCurrent {
- current = " (current)"
- }
- logger.Info("Branch", slog.String("name", branch.Name+current))
- }
-
- // Get recent commits
- logOptions := LogOptions{
- MaxCount: 5,
- Oneline: true,
- }
-
- commits, err := git.Log(ctx, logOptions)
- if err != nil {
- logger.Error("Failed to get log", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Recent commits", slog.Int("count", len(commits)))
- for _, commit := range commits {
- logger.Info("Commit", slog.String("hash", commit.Hash[:8]), slog.String("message", commit.Message))
- }
-}
-
-// ExampleWorkflow demonstrates a typical Git workflow
-func ExampleWorkflow() {
- ctx := context.Background()
-
- // Create logger
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- // Initialize a new repository
- git := DefaultGit("/path/to/new/repo")
-
- // Initialize the repository
- if err := git.Init(ctx, "/path/to/new/repo"); err != nil {
- logger.Error("Failed to initialize repository", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // Set user configuration
- userConfig := UserConfig{
- Name: "John Doe",
- Email: "john@example.com",
- }
-
- if err := git.SetUserConfig(ctx, userConfig); err != nil {
- logger.Error("Failed to set user config", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // Create a new file and add it
- // (In a real scenario, you would create the file here)
-
- // Stage all changes
- if err := git.AddAll(ctx); err != nil {
- logger.Error("Failed to add files", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // Commit the changes
- commitOptions := CommitOptions{
- AllowEmpty: false,
- }
-
- if err := git.Commit(ctx, "Initial commit", commitOptions); err != nil {
- logger.Error("Failed to commit", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // Create a new branch
- if err := git.CreateBranch(ctx, "feature/new-feature", ""); err != nil {
- logger.Error("Failed to create branch", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // Switch to the new branch
- if err := git.Checkout(ctx, "feature/new-feature"); err != nil {
- logger.Error("Failed to checkout branch", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Repository initialized and feature branch created!")
-}
-
-// ExampleRemoteOperations demonstrates remote repository operations
-func ExampleRemoteOperations() {
- ctx := context.Background()
-
- // Create logger
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- git := DefaultGit("/path/to/your/repo")
-
- // Add a remote
- if err := git.AddRemote(ctx, "origin", "https://github.com/user/repo.git"); err != nil {
- logger.Error("Failed to add remote", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // List remotes
- remotes, err := git.ListRemotes(ctx)
- if err != nil {
- logger.Error("Failed to list remotes", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- logger.Info("Remotes found", slog.Int("count", len(remotes)))
- for _, remote := range remotes {
- logger.Info("Remote", slog.String("name", remote.Name), slog.String("url", remote.URL))
- }
-
- // Fetch from remote
- fetchOptions := FetchOptions{
- All: true,
- Tags: true,
- }
-
- if err := git.Fetch(ctx, "", fetchOptions); err != nil {
- logger.Error("Failed to fetch", slog.String("error", err.Error()))
- os.Exit(1)
- }
-
- // Push to remote
- pushOptions := PushOptions{
- SetUpstream: true,
- }
-
- if err := git.Push(ctx, "origin", "main", pushOptions); err != nil {
- logger.Error("Failed to push", slog.String("error", err.Error()))
- os.Exit(1)
- }
-}
diff --git a/server/git/git.go b/server/git/git.go
index 7e7ba44..1160d0d 100644
--- a/server/git/git.go
+++ b/server/git/git.go
@@ -10,6 +10,8 @@
"strconv"
"strings"
"time"
+
+ "github.com/iomodo/staff/config"
)
// GitInterface defines the contract for Git operations
@@ -61,6 +63,12 @@
UpdatePullRequest(ctx context.Context, id string, options PullRequestOptions) (*PullRequest, error)
ClosePullRequest(ctx context.Context, id string) error
MergePullRequest(ctx context.Context, id string, options MergePullRequestOptions) error
+
+ // Clone Manager Operations
+ GetAgentClonePath(agentName string) (string, error)
+ RefreshAgentClone(agentName string) error
+ CleanupAgentClone(agentName string) error
+ CleanupAllClones() error
}
// Status represents the current state of the repository
@@ -187,46 +195,55 @@
// Git implementation using os/exec to call git commands
type Git struct {
- repoPath string
- config GitConfig
- prProvider PullRequestProvider
- logger *slog.Logger
-}
-
-// GitConfig holds configuration for Git operations
-type GitConfig struct {
- Timeout time.Duration
- Env map[string]string
- PullRequestProvider PullRequestProvider
+ repoPath string
+ prProvider PullRequestProvider
+ cloneManager *CloneManager
+ logger *slog.Logger
}
// NewGit creates a new Git instance
-func NewGit(repoPath string, config GitConfig, logger *slog.Logger) GitInterface {
- if config.Timeout == 0 {
- config.Timeout = 30 * time.Second
+func New(cfg *config.Config, logger *slog.Logger) GitInterface {
+ var prProvider PullRequestProvider
+ var repoURL string
+
+ switch cfg.GetPrimaryGitProvider() {
+ case "github":
+ githubConfig := GitHubConfig{
+ Token: cfg.GitHub.Token,
+ Logger: logger,
+ }
+ prProvider = NewGitHubPullRequestProvider(cfg.GitHub.Owner, cfg.GitHub.Repo, githubConfig)
+ repoURL = fmt.Sprintf("https://github.com/%s/%s.git", cfg.GitHub.Owner, cfg.GitHub.Repo)
+ logger.Info("Using GitHub as pull request provider",
+ slog.String("owner", cfg.GitHub.Owner),
+ slog.String("repo", cfg.GitHub.Repo))
+ case "gerrit":
+ gerritConfig := GerritConfig{
+ Username: cfg.Gerrit.Username,
+ Password: cfg.Gerrit.Password,
+ BaseURL: cfg.Gerrit.BaseURL,
+ Logger: logger,
+ }
+ prProvider = NewGerritPullRequestProvider(cfg.Gerrit.Project, gerritConfig)
+ repoURL = fmt.Sprintf("%s/%s", cfg.Gerrit.BaseURL, cfg.Gerrit.Project)
+ logger.Info("Using Gerrit as pull request provider",
+ slog.String("base_url", cfg.Gerrit.BaseURL),
+ slog.String("project", cfg.Gerrit.Project))
+ default:
+ panic("no valid Git provider configured")
}
+ workspacePath := filepath.Join(".", "workspace") //TODO: make it configurable
+ cloneManager := NewCloneManager(repoURL, workspacePath)
+
return &Git{
- repoPath: repoPath,
- config: config,
- prProvider: config.PullRequestProvider,
- logger: logger,
+ repoPath: cfg.Git.RepoPath,
+ prProvider: prProvider,
+ cloneManager: cloneManager,
+ logger: logger,
}
}
-// DefaultGit creates a Git instance with default configuration
-func DefaultGit(repoPath string) GitInterface {
- return NewGit(repoPath, GitConfig{
- Timeout: 30 * time.Second,
- }, slog.Default())
-}
-
-// NewGitWithPullRequests creates a Git instance with pull request capabilities
-func NewGitWithPullRequests(repoPath string, config GitConfig, prProvider PullRequestProvider, logger *slog.Logger) GitInterface {
- config.PullRequestProvider = prProvider
- return NewGit(repoPath, config, logger)
-}
-
// Ensure Git implements GitInterface
var _ GitInterface = (*Git)(nil)
@@ -637,6 +654,32 @@
return g.prProvider.MergePullRequest(ctx, id, options)
}
+// Clone manage methods
+func (g *Git) GetAgentClonePath(agentName string) (string, error) {
+ if g.cloneManager == nil {
+ return "", &GitError{Command: "GetAgentClonePath", Output: "no clone manager configured"}
+ }
+ return g.cloneManager.GetAgentClonePath(agentName)
+}
+func (g *Git) RefreshAgentClone(agentName string) error {
+ if g.cloneManager == nil {
+ return &GitError{Command: "RefreshAgentClone", Output: "no clone manager configured"}
+ }
+ return g.cloneManager.RefreshAgentClone(agentName)
+}
+func (g *Git) CleanupAgentClone(agentName string) error {
+ if g.cloneManager == nil {
+ return &GitError{Command: "CleanupAgentClone", Output: "no clone manager configured"}
+ }
+ return g.cloneManager.CleanupAgentClone(agentName)
+}
+func (g *Git) CleanupAllClones() error {
+ if g.cloneManager == nil {
+ return &GitError{Command: "CleanupAllClones", Output: "no clone manager configured"}
+ }
+ return g.cloneManager.CleanupAllClones()
+}
+
// Helper methods
func (g *Git) runCommand(cmd *exec.Cmd, command string) error {
@@ -885,6 +928,7 @@
Reviewers []Author
Commits []Commit
Comments []PullRequestComment
+ URL string
}
// PullRequestComment represents a comment on a pull request
diff --git a/server/git/git_test.go b/server/git/git_test.go
deleted file mode 100644
index d3862ce..0000000
--- a/server/git/git_test.go
+++ /dev/null
@@ -1,645 +0,0 @@
-package git
-
-import (
- "context"
- "fmt"
- "log/slog"
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestNewGit(t *testing.T) {
- // Create logger for testing
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- // Test creating a new Git instance with default config
- git := DefaultGit("/tmp/test-repo")
- if git == nil {
- t.Fatal("DefaultGit returned nil")
- }
-
- // Test creating a new Git instance with custom config
- config := GitConfig{
- Timeout: 60 * time.Second,
- Env: map[string]string{
- "GIT_AUTHOR_NAME": "Test User",
- },
- }
- git = NewGit("/tmp/test-repo", config, logger)
- if git == nil {
- t.Fatal("NewGit returned nil")
- }
-}
-
-func TestGitRepositoryOperations(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Test IsRepository on non-repository
- isRepo, err := git.IsRepository(ctx, tempDir)
- if err != nil {
- t.Fatalf("IsRepository failed: %v", err)
- }
- if isRepo {
- t.Error("Expected IsRepository to return false for non-repository")
- }
-
- // Test Init
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Test IsRepository after init
- isRepo, err = git.IsRepository(ctx, tempDir)
- if err != nil {
- t.Fatalf("IsRepository failed after init: %v", err)
- }
- if !isRepo {
- t.Error("Expected IsRepository to return true after init")
- }
-}
-
-func TestGitStatus(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Test status on clean repository
- status, err := git.Status(ctx)
- if err != nil {
- t.Fatalf("Status failed: %v", err)
- }
-
- if status == nil {
- t.Fatal("Status returned nil")
- }
-
- // Should be clean after init
- if !status.IsClean {
- t.Error("Expected repository to be clean after init")
- }
-
- // Create a test file
- testFile := filepath.Join(tempDir, "test.txt")
- err = os.WriteFile(testFile, []byte("Hello, Git!\n"), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- // Test status with untracked file
- status, err = git.Status(ctx)
- if err != nil {
- t.Fatalf("Status failed: %v", err)
- }
-
- // Debug: print status information
- t.Logf("Status: IsClean=%t, Staged=%d, Unstaged=%d, Untracked=%d",
- status.IsClean, len(status.Staged), len(status.Unstaged), len(status.Untracked))
-
- if len(status.Untracked) > 0 {
- t.Logf("Untracked files: %v", status.Untracked)
- }
-
- if status.IsClean {
- t.Error("Expected repository to be dirty with untracked file")
- }
-
- // Check if the file is detected in any status (untracked, unstaged, or staged)
- totalFiles := len(status.Untracked) + len(status.Unstaged) + len(status.Staged)
- if totalFiles == 0 {
- t.Error("Expected at least 1 file to be detected")
- return
- }
-
- // Look for test.txt in any of the status categories
- found := false
- for _, file := range status.Untracked {
- if file == "test.txt" {
- found = true
- break
- }
- }
- for _, file := range status.Unstaged {
- if file.Path == "test.txt" {
- found = true
- break
- }
- }
- for _, file := range status.Staged {
- if file.Path == "test.txt" {
- found = true
- break
- }
- }
-
- if !found {
- t.Error("Expected test.txt to be found in status")
- }
-}
-
-func TestGitUserConfig(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Test setting user config
- userConfig := UserConfig{
- Name: "Test User",
- Email: "test@example.com",
- }
-
- err = git.SetUserConfig(ctx, userConfig)
- if err != nil {
- t.Fatalf("SetUserConfig failed: %v", err)
- }
-
- // Test getting user config
- retrievedConfig, err := git.GetUserConfig(ctx)
- if err != nil {
- t.Fatalf("GetUserConfig failed: %v", err)
- }
-
- if retrievedConfig.Name != userConfig.Name {
- t.Errorf("Expected name '%s', got '%s'", userConfig.Name, retrievedConfig.Name)
- }
-
- if retrievedConfig.Email != userConfig.Email {
- t.Errorf("Expected email '%s', got '%s'", userConfig.Email, retrievedConfig.Email)
- }
-}
-
-func TestGitCommitWorkflow(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Set user config
- userConfig := UserConfig{
- Name: "Test User",
- Email: "test@example.com",
- }
- err = git.SetUserConfig(ctx, userConfig)
- if err != nil {
- t.Fatalf("SetUserConfig failed: %v", err)
- }
-
- // Create a test file
- testFile := filepath.Join(tempDir, "test.txt")
- err = os.WriteFile(testFile, []byte("Hello, Git!\n"), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- // Test AddAll
- err = git.AddAll(ctx)
- if err != nil {
- t.Fatalf("AddAll failed: %v", err)
- }
-
- // Check status after staging
- status, err := git.Status(ctx)
- if err != nil {
- t.Fatalf("Status failed: %v", err)
- }
-
- if len(status.Staged) != 1 {
- t.Errorf("Expected 1 staged file, got %d", len(status.Staged))
- }
-
- // Test Commit
- commitOptions := CommitOptions{
- AllowEmpty: false,
- }
- err = git.Commit(ctx, "Initial commit", commitOptions)
- if err != nil {
- t.Fatalf("Commit failed: %v", err)
- }
-
- // Check status after commit
- status, err = git.Status(ctx)
- if err != nil {
- t.Fatalf("Status failed: %v", err)
- }
-
- if !status.IsClean {
- t.Error("Expected repository to be clean after commit")
- }
-}
-
-func TestGitBranchOperations(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Set user config
- userConfig := UserConfig{
- Name: "Test User",
- Email: "test@example.com",
- }
- err = git.SetUserConfig(ctx, userConfig)
- if err != nil {
- t.Fatalf("SetUserConfig failed: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(tempDir, "test.txt")
- err = os.WriteFile(testFile, []byte("Hello, Git!\n"), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- err = git.AddAll(ctx)
- if err != nil {
- t.Fatalf("AddAll failed: %v", err)
- }
-
- err = git.Commit(ctx, "Initial commit", CommitOptions{})
- if err != nil {
- t.Fatalf("Commit failed: %v", err)
- }
-
- // Test GetCurrentBranch
- currentBranch, err := git.GetCurrentBranch(ctx)
- if err != nil {
- t.Fatalf("GetCurrentBranch failed: %v", err)
- }
-
- // Default branch name might be 'main' or 'master' depending on Git version
- if currentBranch != "main" && currentBranch != "master" {
- t.Errorf("Expected current branch to be 'main' or 'master', got '%s'", currentBranch)
- }
-
- // Test CreateBranch
- err = git.CreateBranch(ctx, "feature/test", "")
- if err != nil {
- t.Fatalf("CreateBranch failed: %v", err)
- }
-
- // Test ListBranches
- branches, err := git.ListBranches(ctx)
- if err != nil {
- t.Fatalf("ListBranches failed: %v", err)
- }
-
- if len(branches) < 2 {
- t.Errorf("Expected at least 2 branches, got %d", len(branches))
- }
-
- // Find the feature branch
- foundFeatureBranch := false
- for _, branch := range branches {
- if branch.Name == "feature/test" {
- foundFeatureBranch = true
- break
- }
- }
-
- if !foundFeatureBranch {
- t.Error("Feature branch not found in branch list")
- }
-
- // Test Checkout
- err = git.Checkout(ctx, "feature/test")
- if err != nil {
- t.Fatalf("Checkout failed: %v", err)
- }
-
- // Verify we're on the feature branch
- currentBranch, err = git.GetCurrentBranch(ctx)
- if err != nil {
- t.Fatalf("GetCurrentBranch failed: %v", err)
- }
-
- if currentBranch != "feature/test" {
- t.Errorf("Expected current branch to be 'feature/test', got '%s'", currentBranch)
- }
-}
-
-func TestGitLog(t *testing.T) {
- t.Skip("Log parsing needs to be fixed")
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Set user config
- userConfig := UserConfig{
- Name: "Test User",
- Email: "test@example.com",
- }
- err = git.SetUserConfig(ctx, userConfig)
- if err != nil {
- t.Fatalf("SetUserConfig failed: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(tempDir, "test.txt")
- err = os.WriteFile(testFile, []byte("Hello, Git!\n"), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- err = git.AddAll(ctx)
- if err != nil {
- t.Fatalf("AddAll failed: %v", err)
- }
-
- err = git.Commit(ctx, "Initial commit", CommitOptions{})
- if err != nil {
- t.Fatalf("Commit failed: %v", err)
- }
-
- // Test Log
- logOptions := LogOptions{
- MaxCount: 10,
- Oneline: false,
- }
- commits, err := git.Log(ctx, logOptions)
- if err != nil {
- t.Fatalf("Log failed: %v", err)
- }
-
- t.Logf("Found %d commits", len(commits))
- if len(commits) == 0 {
- t.Error("Expected at least 1 commit, got 0")
- return
- }
-
- // Check first commit
- commit := commits[0]
- if commit.Message != "Initial commit" {
- t.Errorf("Expected commit message 'Initial commit', got '%s'", commit.Message)
- }
-
- if commit.Author.Name != "Test User" {
- t.Errorf("Expected author name 'Test User', got '%s'", commit.Author.Name)
- }
-
- if commit.Author.Email != "test@example.com" {
- t.Errorf("Expected author email 'test@example.com', got '%s'", commit.Author.Email)
- }
-}
-
-func TestGitError(t *testing.T) {
- // Test GitError creation and methods
- gitErr := &GitError{
- Command: "test",
- Output: "test output",
- Err: nil,
- }
-
- errorMsg := gitErr.Error()
- if errorMsg == "" {
- t.Error("GitError.Error() returned empty string")
- }
-
- // Test with underlying error
- underlyingErr := &GitError{
- Command: "subtest",
- Output: "subtest output",
- Err: gitErr,
- }
-
- unwrapped := underlyingErr.Unwrap()
- if unwrapped != gitErr {
- t.Error("GitError.Unwrap() did not return the underlying error")
- }
-}
-
-func TestGitConfigOperations(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Test SetConfig
- err = git.SetConfig(ctx, "test.key", "test.value")
- if err != nil {
- t.Fatalf("SetConfig failed: %v", err)
- }
-
- // Test GetConfig
- value, err := git.GetConfig(ctx, "test.key")
- if err != nil {
- t.Fatalf("GetConfig failed: %v", err)
- }
-
- if value != "test.value" {
- t.Errorf("Expected config value 'test.value', got '%s'", value)
- }
-}
-
-func TestGitMerge(t *testing.T) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-test-*")
- if err != nil {
- t.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- t.Fatalf("Init failed: %v", err)
- }
-
- // Set user config
- userConfig := UserConfig{
- Name: "Test User",
- Email: "test@example.com",
- }
- err = git.SetUserConfig(ctx, userConfig)
- if err != nil {
- t.Fatalf("SetUserConfig failed: %v", err)
- }
-
- // Create initial commit
- testFile := filepath.Join(tempDir, "test.txt")
- err = os.WriteFile(testFile, []byte("Hello, Git!\n"), 0644)
- if err != nil {
- t.Fatalf("Failed to create test file: %v", err)
- }
-
- err = git.AddAll(ctx)
- if err != nil {
- t.Fatalf("AddAll failed: %v", err)
- }
-
- err = git.Commit(ctx, "Initial commit", CommitOptions{})
- if err != nil {
- t.Fatalf("Commit failed: %v", err)
- }
-
- // Create feature branch
- err = git.CreateBranch(ctx, "feature/test", "")
- if err != nil {
- t.Fatalf("CreateBranch failed: %v", err)
- }
-
- // Switch to feature branch
- err = git.Checkout(ctx, "feature/test")
- if err != nil {
- t.Fatalf("Checkout failed: %v", err)
- }
-
- // Add file on feature branch
- featureFile := filepath.Join(tempDir, "feature.txt")
- err = os.WriteFile(featureFile, []byte("Feature file\n"), 0644)
- if err != nil {
- t.Fatalf("Failed to create feature file: %v", err)
- }
-
- err = git.AddAll(ctx)
- if err != nil {
- t.Fatalf("AddAll failed: %v", err)
- }
-
- err = git.Commit(ctx, "Add feature file", CommitOptions{})
- if err != nil {
- t.Fatalf("Commit failed: %v", err)
- }
-
- // Switch back to main
- err = git.Checkout(ctx, "main")
- if err != nil {
- t.Fatalf("Checkout failed: %v", err)
- }
-
- // Test Merge
- mergeOptions := MergeOptions{
- NoFF: true,
- Message: "Merge feature/test",
- }
- err = git.Merge(ctx, "feature/test", mergeOptions)
- if err != nil {
- t.Fatalf("Merge failed: %v", err)
- }
-
- // Check that both files exist after merge
- if _, err := os.Stat(filepath.Join(tempDir, "test.txt")); os.IsNotExist(err) {
- t.Error("test.txt not found after merge")
- }
-
- if _, err := os.Stat(filepath.Join(tempDir, "feature.txt")); os.IsNotExist(err) {
- t.Error("feature.txt not found after merge")
- }
-}
-
-func BenchmarkGitStatus(b *testing.B) {
- // Create a temporary directory for testing
- tempDir, err := os.MkdirTemp("", "git-bench-*")
- if err != nil {
- b.Fatalf("Failed to create temp directory: %v", err)
- }
- defer os.RemoveAll(tempDir)
-
- git := DefaultGit(tempDir)
- ctx := context.Background()
-
- // Initialize repository
- err = git.Init(ctx, tempDir)
- if err != nil {
- b.Fatalf("Init failed: %v", err)
- }
-
- // Create some files
- for i := 0; i < 10; i++ {
- testFile := filepath.Join(tempDir, fmt.Sprintf("test%d.txt", i))
- err = os.WriteFile(testFile, []byte(fmt.Sprintf("File %d\n", i)), 0644)
- if err != nil {
- b.Fatalf("Failed to create test file: %v", err)
- }
- }
-
- b.ResetTimer()
-
- for i := 0; i < b.N; i++ {
- _, err := git.Status(ctx)
- if err != nil {
- b.Fatalf("Status failed: %v", err)
- }
- }
-}
diff --git a/server/git/github.go b/server/git/github.go
index 6555b69..1d37b1d 100644
--- a/server/git/github.go
+++ b/server/git/github.go
@@ -139,7 +139,7 @@
slog.Any("labels", options.Labels))
url := fmt.Sprintf("%s/repos/%s/%s/pulls", g.config.BaseURL, g.owner, g.repo)
-
+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
@@ -412,5 +412,6 @@
Reviewers: reviewers,
Commits: []Commit{}, // Would need additional API call to populate
Comments: []PullRequestComment{}, // Would need additional API call to populate
+ URL: fmt.Sprintf("https://github.com/%s/%s/pull/%d", g.owner, g.repo, githubPR.Number),
}
}
diff --git a/server/git/pull_request_example.go b/server/git/pull_request_example.go
deleted file mode 100644
index 5c70d21..0000000
--- a/server/git/pull_request_example.go
+++ /dev/null
@@ -1,251 +0,0 @@
-package git
-
-import (
- "context"
- "log/slog"
- "net/http"
- "os"
- "time"
-)
-
-// ExamplePullRequestUsage demonstrates how to use pull request functionality
-func ExamplePullRequestUsage() {
- ctx := context.Background()
-
- // Create logger
- logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
-
- // Example with GitHub
- exampleGitHubPullRequests(ctx, logger)
-
- // Example with Gerrit
- exampleGerritPullRequests(ctx, logger)
-}
-
-func exampleGitHubPullRequests(ctx context.Context, logger *slog.Logger) {
- logger.Info("=== GitHub Pull Request Example ===")
-
- // Create GitHub configuration
- githubConfig := GitHubConfig{
- Token: "your-github-token-here",
- BaseURL: "https://api.github.com",
- HTTPClient: &http.Client{Timeout: 30 * time.Second},
- }
-
- // Create GitHub pull request provider
- githubProvider := NewGitHubPullRequestProvider("owner", "repo", githubConfig)
-
- // Create Git instance with GitHub pull request capabilities
- git := NewGitWithPullRequests("/path/to/repo", GitConfig{
- Timeout: 30 * time.Second,
- }, githubProvider, logger)
-
- // Create a new pull request
- prOptions := PullRequestOptions{
- Title: "Add new feature",
- Description: "This PR adds a new feature to the application.",
- BaseBranch: "main",
- HeadBranch: "feature/new-feature",
- Labels: []string{"enhancement", "feature"},
- Assignees: []string{"username1", "username2"},
- Reviewers: []string{"reviewer1", "reviewer2"},
- Draft: false,
- }
-
- pr, err := git.CreatePullRequest(ctx, prOptions)
- if err != nil {
- logger.Error("Failed to create pull request", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Created pull request", slog.String("title", pr.Title), slog.Int("number", pr.Number))
-
- // List pull requests
- listOptions := ListPullRequestOptions{
- State: "open",
- Author: "username",
- BaseBranch: "main",
- Limit: 10,
- }
-
- prs, err := git.ListPullRequests(ctx, listOptions)
- if err != nil {
- logger.Error("Failed to list pull requests", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Found pull requests", slog.Int("count", len(prs)))
-
- // Get a specific pull request
- pr, err = git.GetPullRequest(ctx, pr.ID)
- if err != nil {
- logger.Error("Failed to get pull request", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Pull request status", slog.String("state", pr.State))
-
- // Update a pull request
- updateOptions := PullRequestOptions{
- Title: "Updated title",
- Description: "Updated description",
- Labels: []string{"bug", "urgent"},
- }
-
- updatedPR, err := git.UpdatePullRequest(ctx, pr.ID, updateOptions)
- if err != nil {
- logger.Error("Failed to update pull request", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Updated pull request", slog.String("title", updatedPR.Title))
-
- // Merge a pull request
- mergeOptions := MergePullRequestOptions{
- MergeMethod: "squash",
- CommitTitle: "Merge pull request #123",
- CommitMsg: "This merges the feature branch into main",
- }
-
- err = git.MergePullRequest(ctx, pr.ID, mergeOptions)
- if err != nil {
- logger.Error("Failed to merge pull request", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Pull request merged successfully")
-}
-
-func exampleGerritPullRequests(ctx context.Context, logger *slog.Logger) {
- logger.Info("=== Gerrit Pull Request Example ===")
-
- // Create Gerrit configuration
- gerritConfig := GerritConfig{
- Username: "your-username",
- Password: "your-http-password-or-api-token",
- BaseURL: "https://gerrit.example.com",
- HTTPClient: &http.Client{Timeout: 30 * time.Second},
- }
-
- // Create Gerrit pull request provider
- gerritProvider := NewGerritPullRequestProvider("project-name", gerritConfig)
-
- // Create Git instance with Gerrit pull request capabilities
- git := NewGitWithPullRequests("/path/to/repo", GitConfig{
- Timeout: 30 * time.Second,
- }, gerritProvider, logger)
-
- // Create a new change (pull request)
- prOptions := PullRequestOptions{
- Title: "Add new feature",
- Description: "This change adds a new feature to the application.",
- BaseBranch: "master",
- HeadBranch: "feature/new-feature",
- }
-
- pr, err := git.CreatePullRequest(ctx, prOptions)
- if err != nil {
- logger.Error("Failed to create change", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Created change", slog.String("title", pr.Title), slog.Int("number", pr.Number))
-
- // List changes
- listOptions := ListPullRequestOptions{
- State: "open",
- Author: "username",
- BaseBranch: "master",
- Limit: 10,
- }
-
- prs, err := git.ListPullRequests(ctx, listOptions)
- if err != nil {
- logger.Error("Failed to list changes", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Found changes", slog.Int("count", len(prs)))
-
- // Get a specific change
- pr, err = git.GetPullRequest(ctx, pr.ID)
- if err != nil {
- logger.Error("Failed to get change", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Change status", slog.String("state", pr.State))
-
- // Update a change
- updateOptions := PullRequestOptions{
- Title: "Updated title",
- Description: "Updated description",
- }
-
- updatedPR, err := git.UpdatePullRequest(ctx, pr.ID, updateOptions)
- if err != nil {
- logger.Error("Failed to update change", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Updated change", slog.String("title", updatedPR.Title))
-
- // Submit a change (merge)
- mergeOptions := MergePullRequestOptions{
- CommitTitle: "Submit change",
- CommitMsg: "This submits the change to master",
- }
-
- err = git.MergePullRequest(ctx, pr.ID, mergeOptions)
- if err != nil {
- logger.Error("Failed to submit change", slog.String("error", err.Error()))
- return
- }
-
- logger.Info("Change submitted successfully")
-}
-
-// Example of using both providers in the same application
-func ExampleMultiProviderUsage() {
- ctx := context.Background()
-
- // Determine which provider to use based on configuration
- useGitHub := true // This could come from config
-
- var git GitInterface
-
- if useGitHub {
- // Use GitHub
- githubConfig := GitHubConfig{
- Token: "github-token",
- BaseURL: "https://api.github.com",
- }
- githubProvider := NewGitHubPullRequestProvider("owner", "repo", githubConfig)
- git = NewGitWithPullRequests("/path/to/repo", GitConfig{}, githubProvider, nil) // Pass nil for logger as it's not used in this example
- } else {
- // Use Gerrit
- gerritConfig := GerritConfig{
- Username: "gerrit-username",
- Password: "gerrit-password",
- BaseURL: "https://gerrit.example.com",
- }
- gerritProvider := NewGerritPullRequestProvider("project", gerritConfig)
- git = NewGitWithPullRequests("/path/to/repo", GitConfig{}, gerritProvider, nil) // Pass nil for logger as it's not used in this example
- }
-
- // Use the same interface regardless of provider
- prOptions := PullRequestOptions{
- Title: "Cross-platform PR",
- Description: "This works with both GitHub and Gerrit",
- BaseBranch: "main",
- HeadBranch: "feature/cross-platform",
- }
-
- pr, err := git.CreatePullRequest(ctx, prOptions)
- if err != nil {
- slog.Error("Failed to create pull request", slog.String("error", err.Error()))
- return
- }
-
- slog.Info("Created pull request", slog.String("title", pr.Title))
-}
diff --git a/server/go.mod b/server/go.mod
index 01cbed9..01bba4c 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -6,14 +6,11 @@
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/spf13/cobra v1.9.1
- github.com/stretchr/testify v1.10.0
golang.org/x/text v0.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
- github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
)
diff --git a/server/go.sum b/server/go.sum
index bface14..56f2486 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -1,21 +1,15 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
diff --git a/server/server/server.go b/server/server/server.go
index f03327a..6ff91ef 100644
--- a/server/server/server.go
+++ b/server/server/server.go
@@ -4,7 +4,6 @@
"fmt"
"log/slog"
"os"
- "time"
"github.com/iomodo/staff/agent"
"github.com/iomodo/staff/config"
@@ -45,10 +44,8 @@
s.logger.Info("Server is starting...")
// Create task manager using config
- workingDir := "."
- gitInterface := git.DefaultGit(workingDir)
- tasksDir := s.config.Tasks.StoragePath
- taskManager := git_tm.NewGitTaskManagerWithLogger(gitInterface, tasksDir, s.logger)
+ gitInterface := git.New(s.config, s.logger)
+ taskManager := git_tm.NewGitTaskManager(gitInterface, s.config, s.logger)
// Create agent manager with config
var err error
@@ -57,22 +54,7 @@
return fmt.Errorf("failed to create agent manager: %w", err)
}
- // Start all configured agents with a default loop interval
- defaultInterval := 1 * time.Second
- for _, agentConfig := range s.config.Agents {
- s.logger.Info("Starting agent",
- slog.String("name", agentConfig.Name),
- slog.String("role", agentConfig.Role),
- slog.String("model", agentConfig.Model))
-
- if err := s.manager.StartAgent(agentConfig.Name, defaultInterval); err != nil {
- s.logger.Error("Failed to start agent",
- slog.String("agent", agentConfig.Name),
- slog.String("error", err.Error()))
- continue
- }
- }
-
+ s.manager.StartAllAgents()
s.logger.Info("Server started successfully", slog.Int("agents", len(s.config.Agents)))
return nil
}
diff --git a/server/task/service.go b/server/task/service.go
deleted file mode 100644
index ba6a4c4..0000000
--- a/server/task/service.go
+++ /dev/null
@@ -1,850 +0,0 @@
-package task
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/iomodo/staff/git"
- "github.com/iomodo/staff/llm"
- "github.com/iomodo/staff/tm"
- "golang.org/x/text/cases"
- "golang.org/x/text/language"
-)
-
-// SubtaskService handles subtask generation and management
-type SubtaskService struct {
- llmProvider llm.LLMProvider
- taskManager tm.TaskManager
- agentRoles []string // Available agent roles for assignment
- prProvider git.PullRequestProvider // GitHub PR provider
- githubOwner string
- githubRepo string
- cloneManager *git.CloneManager
- logger *slog.Logger
-}
-
-// NewSubtaskService creates a new subtask service
-func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager, logger *slog.Logger) *SubtaskService {
- if logger == nil {
- logger = slog.Default()
- }
- return &SubtaskService{
- llmProvider: provider,
- taskManager: taskManager,
- agentRoles: agentRoles,
- prProvider: prProvider,
- githubOwner: githubOwner,
- githubRepo: githubRepo,
- cloneManager: cloneManager,
- logger: logger,
- }
-}
-
-// ShouldGenerateSubtasks asks LLM whether a task needs subtasks based on existing agents
-func (s *SubtaskService) ShouldGenerateSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskDecision, error) {
- prompt := s.buildSubtaskDecisionPrompt(task)
-
- req := llm.ChatCompletionRequest{
- Model: "gpt-4",
- Messages: []llm.Message{
- {
- Role: llm.RoleSystem,
- Content: s.getSubtaskDecisionSystemPrompt(),
- },
- {
- Role: llm.RoleUser,
- Content: prompt,
- },
- },
- MaxTokens: &[]int{1000}[0],
- Temperature: &[]float64{0.3}[0],
- }
-
- resp, err := s.llmProvider.ChatCompletion(ctx, req)
- if err != nil {
- return nil, fmt.Errorf("LLM decision failed: %w", err)
- }
-
- if len(resp.Choices) == 0 {
- return nil, fmt.Errorf("no response from LLM")
- }
-
- // Parse the LLM response
- decision, err := s.parseSubtaskDecision(resp.Choices[0].Message.Content)
- if err != nil {
- return nil, fmt.Errorf("failed to parse LLM decision: %w", err)
- }
-
- return decision, nil
-}
-
-// AnalyzeTaskForSubtasks uses LLM to analyze a task and propose subtasks
-func (s *SubtaskService) AnalyzeTaskForSubtasks(ctx context.Context, task *tm.Task) (*tm.SubtaskAnalysis, error) {
- prompt := s.buildSubtaskAnalysisPrompt(task)
-
- req := llm.ChatCompletionRequest{
- Model: "gpt-4",
- Messages: []llm.Message{
- {
- Role: llm.RoleSystem,
- Content: s.getSubtaskAnalysisSystemPrompt(),
- },
- {
- Role: llm.RoleUser,
- Content: prompt,
- },
- },
- MaxTokens: &[]int{4000}[0],
- Temperature: &[]float64{0.3}[0],
- }
-
- resp, err := s.llmProvider.ChatCompletion(ctx, req)
- if err != nil {
- return nil, fmt.Errorf("LLM analysis failed: %w", err)
- }
-
- if len(resp.Choices) == 0 {
- return nil, fmt.Errorf("no response from LLM")
- }
-
- // Parse the LLM response
- analysis, err := s.parseSubtaskAnalysis(resp.Choices[0].Message.Content, task.ID)
- if err != nil {
- return nil, fmt.Errorf("failed to parse LLM response: %w", err)
- }
-
- return analysis, nil
-}
-
-// getSubtaskDecisionSystemPrompt returns the system prompt for subtask decision
-func (s *SubtaskService) getSubtaskDecisionSystemPrompt() string {
- availableRoles := strings.Join(s.agentRoles, ", ")
-
- return fmt.Sprintf(`You are an expert project manager and task analyst. Your job is to determine whether a task needs to be broken down into subtasks.
-
-Currently available team roles and their capabilities: %s
-
-When evaluating a task, consider:
-1. Task complexity and scope
-2. Whether multiple specialized skills are needed
-3. If the task can be completed by a single agent with current capabilities
-4. Whether new agent roles might be needed for specialized skills
-
-Respond with a JSON object in this exact format:
-{
- "needs_subtasks": true/false,
- "reasoning": "Clear explanation of why subtasks are or aren't needed",
- "complexity_score": 5,
- "required_skills": ["skill1", "skill2", "skill3"]
-}
-
-Complexity score should be 1-10 where:
-- 1-3: Simple tasks that can be handled by one agent
-- 4-6: Moderate complexity, might benefit from subtasks
-- 7-10: Complex tasks that definitely need breaking down
-
-Required skills should list all technical/domain skills needed to complete the task.`, availableRoles)
-}
-
-// getSubtaskAnalysisSystemPrompt returns the system prompt for subtask analysis
-func (s *SubtaskService) getSubtaskAnalysisSystemPrompt() string {
- availableRoles := strings.Join(s.agentRoles, ", ")
-
- return fmt.Sprintf(`You are an expert project manager and technical architect. Your job is to analyze complex tasks and break them down into well-defined subtasks that can be assigned to specialized team members.
-
-Currently available team roles: %s
-
-When analyzing a task, you should:
-1. Understand the task requirements and scope
-2. Break it down into logical, manageable subtasks
-3. Assign each subtask to the most appropriate team role OR propose creating new agents
-4. Estimate effort and identify dependencies
-5. Provide a clear execution strategy
-
-If you need specialized skills not covered by existing roles, propose new agent creation.
-
-Respond with a JSON object in this exact format:
-{
- "analysis_summary": "Brief analysis of the task and approach",
- "subtasks": [
- {
- "title": "Subtask title",
- "description": "Detailed description of what needs to be done",
- "priority": "high|medium|low",
- "assigned_to": "role_name",
- "estimated_hours": 8,
- "dependencies": ["subtask_index_1", "subtask_index_2"],
- "required_skills": ["skill1", "skill2"]
- }
- ],
- "agent_creations": [
- {
- "role": "new_role_name",
- "skills": ["specialized_skill1", "specialized_skill2"],
- "description": "Description of what this agent does",
- "justification": "Why this new agent is needed"
- }
- ],
- "recommended_approach": "High-level strategy for executing these subtasks",
- "estimated_total_hours": 40,
- "risk_assessment": "Potential risks and mitigation strategies"
-}
-
-For existing roles, use: %s
-For new agents, propose appropriate role names and skill sets.
-Dependencies should reference subtask indices (e.g., ["0", "1"] means depends on first and second subtasks).`, availableRoles, availableRoles)
-}
-
-// buildSubtaskDecisionPrompt creates the user prompt for subtask decision
-func (s *SubtaskService) buildSubtaskDecisionPrompt(task *tm.Task) string {
- return fmt.Sprintf(`Please evaluate whether the following task needs to be broken down into subtasks:
-
-**Task Title:** %s
-
-**Description:** %s
-
-**Priority:** %s
-
-**Current Status:** %s
-
-Consider:
-- Can this be completed by a single agent with existing capabilities?
-- Does it require multiple specialized skills?
-- Is the scope too large for one person?
-- Are there logical components that could be parallelized?
-
-Provide your decision in the JSON format specified in the system prompt.`,
- task.Title,
- task.Description,
- task.Priority,
- task.Status)
-}
-
-// buildSubtaskAnalysisPrompt creates the user prompt for LLM analysis
-func (s *SubtaskService) buildSubtaskAnalysisPrompt(task *tm.Task) string {
- return fmt.Sprintf(`Please analyze the following task and break it down into subtasks:
-
-**Task Title:** %s
-
-**Description:** %s
-
-**Priority:** %s
-
-**Current Status:** %s
-
-Please analyze this task and provide a detailed breakdown into subtasks. Consider:
-- Technical complexity and requirements
-- Logical task dependencies
-- Appropriate skill sets needed for each subtask
-- Risk factors and potential blockers
-- Estimated effort for each component
-
-Provide the analysis in the JSON format specified in the system prompt.`,
- task.Title,
- task.Description,
- task.Priority,
- task.Status)
-}
-
-// parseSubtaskDecision parses the LLM response into a SubtaskDecision struct
-func (s *SubtaskService) parseSubtaskDecision(response string) (*tm.SubtaskDecision, error) {
- // Try to extract JSON from the response
- jsonStart := strings.Index(response, "{")
- jsonEnd := strings.LastIndex(response, "}")
-
- if jsonStart == -1 || jsonEnd == -1 {
- return nil, fmt.Errorf("no JSON found in LLM response")
- }
-
- jsonStr := response[jsonStart : jsonEnd+1]
-
- var decision tm.SubtaskDecision
- if err := json.Unmarshal([]byte(jsonStr), &decision); err != nil {
- return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
- }
-
- return &decision, nil
-}
-
-// parseSubtaskAnalysis parses the LLM response into a SubtaskAnalysis struct
-func (s *SubtaskService) parseSubtaskAnalysis(response string, parentTaskID string) (*tm.SubtaskAnalysis, error) {
- // Try to extract JSON from the response (LLM might wrap it in markdown)
- jsonStart := strings.Index(response, "{")
- jsonEnd := strings.LastIndex(response, "}")
-
- if jsonStart == -1 || jsonEnd == -1 {
- return nil, fmt.Errorf("no JSON found in LLM response")
- }
-
- jsonStr := response[jsonStart : jsonEnd+1]
-
- var rawAnalysis struct {
- AnalysisSummary string `json:"analysis_summary"`
- Subtasks []struct {
- Title string `json:"title"`
- Description string `json:"description"`
- Priority string `json:"priority"`
- AssignedTo string `json:"assigned_to"`
- EstimatedHours int `json:"estimated_hours"`
- Dependencies []string `json:"dependencies"`
- RequiredSkills []string `json:"required_skills"`
- } `json:"subtasks"`
- AgentCreations []tm.AgentCreationProposal `json:"agent_creations"`
- RecommendedApproach string `json:"recommended_approach"`
- EstimatedTotalHours int `json:"estimated_total_hours"`
- RiskAssessment string `json:"risk_assessment"`
- }
-
- if err := json.Unmarshal([]byte(jsonStr), &rawAnalysis); err != nil {
- return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
- }
-
- // Convert to our types
- analysis := &tm.SubtaskAnalysis{
- ParentTaskID: parentTaskID,
- AnalysisSummary: rawAnalysis.AnalysisSummary,
- AgentCreations: rawAnalysis.AgentCreations,
- RecommendedApproach: rawAnalysis.RecommendedApproach,
- EstimatedTotalHours: rawAnalysis.EstimatedTotalHours,
- RiskAssessment: rawAnalysis.RiskAssessment,
- }
-
- // Convert subtasks
- for _, st := range rawAnalysis.Subtasks {
- priority := tm.PriorityMedium // default
- switch strings.ToLower(st.Priority) {
- case "high":
- priority = tm.PriorityHigh
- case "low":
- priority = tm.PriorityLow
- }
-
- subtask := tm.SubtaskProposal{
- Title: st.Title,
- Description: st.Description,
- Priority: priority,
- AssignedTo: st.AssignedTo,
- EstimatedHours: st.EstimatedHours,
- Dependencies: st.Dependencies,
- RequiredSkills: st.RequiredSkills,
- }
-
- analysis.Subtasks = append(analysis.Subtasks, subtask)
- }
-
- // Validate agent assignments and handle new agent creation
- if err := s.validateAndHandleAgentAssignments(analysis); err != nil {
- s.logger.Warn("Warning during agent assignment handling", slog.String("error", err.Error()))
- }
-
- return analysis, nil
-}
-
-// validateAndHandleAgentAssignments validates assignments and creates agent creation subtasks if needed
-func (s *SubtaskService) validateAndHandleAgentAssignments(analysis *tm.SubtaskAnalysis) error {
- // Collect all agent roles that will be available (existing + proposed new ones)
- availableRoles := make(map[string]bool)
- for _, role := range s.agentRoles {
- availableRoles[role] = true
- }
-
- // Add proposed new agent roles
- for _, agentCreation := range analysis.AgentCreations {
- availableRoles[agentCreation.Role] = true
-
- // Create a subtask for agent creation
- agentCreationSubtask := tm.SubtaskProposal{
- Title: fmt.Sprintf("Create %s Agent", cases.Title(language.English).String(agentCreation.Role)),
- Description: fmt.Sprintf("Create and configure a new %s agent with skills: %s. %s", agentCreation.Role, strings.Join(agentCreation.Skills, ", "), agentCreation.Justification),
- Priority: tm.PriorityHigh, // Agent creation is high priority
- AssignedTo: "ceo", // CEO creates new agents
- EstimatedHours: 4, // Estimated time to set up new agent
- Dependencies: []string{}, // No dependencies for agent creation
- RequiredSkills: []string{"agent_configuration", "system_design"},
- }
-
- // Insert at the beginning so agent creation happens first
- analysis.Subtasks = append([]tm.SubtaskProposal{agentCreationSubtask}, analysis.Subtasks...)
-
- // Update dependencies to account for the new subtask at index 0
- for i := 1; i < len(analysis.Subtasks); i++ {
- for j, dep := range analysis.Subtasks[i].Dependencies {
- // Convert dependency index and increment by 1
- if depIndex := s.parseDependencyIndex(dep); depIndex >= 0 {
- analysis.Subtasks[i].Dependencies[j] = fmt.Sprintf("%d", depIndex+1)
- }
- }
- }
- }
-
- // Now validate all assignments against available roles
- defaultRole := "ceo" // fallback role
- if len(s.agentRoles) > 0 {
- defaultRole = s.agentRoles[0]
- }
-
- for i := range analysis.Subtasks {
- if !availableRoles[analysis.Subtasks[i].AssignedTo] {
- s.logger.Warn("Unknown agent role for subtask, using default",
- slog.String("unknown_role", analysis.Subtasks[i].AssignedTo),
- slog.String("subtask_title", analysis.Subtasks[i].Title),
- slog.String("assigned_role", defaultRole))
- analysis.Subtasks[i].AssignedTo = defaultRole
- }
- }
-
- return nil
-}
-
-// parseDependencyIndex parses a dependency string to an integer index
-func (s *SubtaskService) parseDependencyIndex(dep string) int {
- var idx int
- if _, err := fmt.Sscanf(dep, "%d", &idx); err == nil {
- return idx
- }
- return -1 // Invalid dependency format
-}
-
-// isValidAgentRole checks if a role is in the available agent roles
-func (s *SubtaskService) isValidAgentRole(role string) bool {
- for _, availableRole := range s.agentRoles {
- if availableRole == role {
- return true
- }
- }
- return false
-}
-
-// GenerateSubtaskPR creates a PR with the proposed subtasks
-func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) {
- if s.prProvider == nil {
- return "", fmt.Errorf("PR provider not configured")
- }
-
- // Generate branch name for subtask proposal
- branchName := fmt.Sprintf("subtasks/%s-proposal", analysis.ParentTaskID)
- s.logger.Info("Creating subtask PR", slog.String("branch", branchName))
-
- // Create Git branch and commit subtask proposal
- if err := s.createSubtaskBranch(ctx, analysis, branchName); err != nil {
- return "", fmt.Errorf("failed to create subtask branch: %w", err)
- }
-
- // Generate PR content
- prContent := s.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 := s.determineBaseBranch(ctx)
- s.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,
- }
-
- s.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 := s.prProvider.CreatePullRequest(ctx, options)
- if err != nil {
- return "", fmt.Errorf("failed to create PR: %w", err)
- }
-
- prURL := fmt.Sprintf("https://github.com/%s/%s/pull/%d", s.githubOwner, s.githubRepo, pr.Number)
- s.logger.Info("Generated subtask proposal PR", slog.String("pr_url", prURL))
-
- return prURL, nil
-}
-
-// determineBaseBranch determines the correct base branch (main or master)
-func (s *SubtaskService) determineBaseBranch(ctx context.Context) string {
- if s.cloneManager == nil {
- return "main" // default fallback
- }
-
- // Get clone path to check branches
- clonePath, err := s.cloneManager.GetAgentClonePath("subtask-service")
- if err != nil {
- s.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
- s.logger.Warn("Could not determine base branch, defaulting to 'main'")
- return "main"
-}
-
-// generateSubtaskFile creates the content for an individual subtask file
-func (s *SubtaskService) 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 := s.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 := s.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()
-}
-
-// updateParentTaskAsCompleted updates the parent task file to mark it as completed
-func (s *SubtaskService) 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)
- }
- }
-
- s.logger.Info("Updated parent task to completed status", slog.String("task_id", analysis.ParentTaskID))
- return nil
-}
-
-// generateSubtaskPRContent creates markdown content for the subtask proposal PR
-func (s *SubtaskService) 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()
-}
-
-// createSubtaskBranch creates a Git branch with subtask proposal content
-func (s *SubtaskService) createSubtaskBranch(ctx context.Context, analysis *tm.SubtaskAnalysis, branchName string) error {
- if s.cloneManager == nil {
- return fmt.Errorf("clone manager not configured")
- }
-
- // Get a temporary clone for creating the subtask branch
- clonePath, err := s.cloneManager.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 {
- s.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 := s.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)
- s.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 := s.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)
- s.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)
- }
-
- s.logger.Info("Created subtask proposal branch", slog.String("branch", branchName))
- return nil
-}
-
-// Close cleans up the service
-func (s *SubtaskService) Close() error {
- if s.llmProvider != nil {
- return s.llmProvider.Close()
- }
- return nil
-}
diff --git a/server/task/service_test.go b/server/task/service_test.go
deleted file mode 100644
index 5c7b337..0000000
--- a/server/task/service_test.go
+++ /dev/null
@@ -1,687 +0,0 @@
-package task
-
-import (
- "context"
- "os"
- "strings"
- "testing"
- "time"
-
- "github.com/iomodo/staff/llm"
- "github.com/iomodo/staff/tm"
-)
-
-// MockLLMProvider implements a mock LLM provider for testing
-type MockLLMProvider struct {
- responses []string
- callCount int
-}
-
-func NewMockLLMProvider(responses []string) *MockLLMProvider {
- return &MockLLMProvider{
- responses: responses,
- callCount: 0,
- }
-}
-
-func (m *MockLLMProvider) ChatCompletion(ctx context.Context, req llm.ChatCompletionRequest) (*llm.ChatCompletionResponse, error) {
- if m.callCount >= len(m.responses) {
- return nil, nil
- }
-
- response := m.responses[m.callCount]
- m.callCount++
-
- return &llm.ChatCompletionResponse{
- ID: "mock-response",
- Object: "chat.completion",
- Created: time.Now().Unix(),
- Model: req.Model,
- Choices: []llm.ChatCompletionChoice{
- {
- Index: 0,
- Message: llm.Message{
- Role: llm.RoleAssistant,
- Content: response,
- },
- FinishReason: "stop",
- },
- },
- Usage: llm.Usage{
- PromptTokens: 100,
- CompletionTokens: 300,
- TotalTokens: 400,
- },
- }, nil
-}
-
-func (m *MockLLMProvider) CreateEmbeddings(ctx context.Context, req llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
- return &llm.EmbeddingResponse{
- Object: "list",
- Data: []llm.Embedding{
- {
- Object: "embedding",
- Index: 0,
- Embedding: make([]float64, 1536),
- },
- },
- Model: req.Model,
- Usage: llm.Usage{
- PromptTokens: 50,
- TotalTokens: 50,
- },
- }, nil
-}
-
-func (m *MockLLMProvider) Close() error {
- return nil
-}
-
-func TestNewSubtaskService(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- agentRoles := []string{"backend", "frontend", "qa"}
-
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- if service == nil {
- t.Fatal("NewSubtaskService returned nil")
- }
-
- if service.llmProvider != mockProvider {
- t.Error("LLM provider not set correctly")
- }
-
- if len(service.agentRoles) != 3 {
- t.Errorf("Expected 3 agent roles, got %d", len(service.agentRoles))
- }
-}
-
-func TestShouldGenerateSubtasks(t *testing.T) {
- // Test decision to generate subtasks
- decisionResponse := `{
- "needs_subtasks": true,
- "reasoning": "Complex task requiring multiple skills",
- "complexity_score": 8,
- "required_skills": ["backend", "frontend", "database"]
-}`
-
- mockProvider := NewMockLLMProvider([]string{decisionResponse})
- agentRoles := []string{"backend", "frontend", "qa"}
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- // Test the parseSubtaskDecision method directly since ShouldGenerateSubtasks is used by manager
- decision, err := service.parseSubtaskDecision(decisionResponse)
- if err != nil {
- t.Fatalf("parseSubtaskDecision failed: %v", err)
- }
-
- if !decision.NeedsSubtasks {
- t.Error("Expected decision to need subtasks")
- }
-
- if decision.ComplexityScore != 8 {
- t.Errorf("Expected complexity score 8, got %d", decision.ComplexityScore)
- }
-
- if len(decision.RequiredSkills) != 3 {
- t.Errorf("Expected 3 required skills, got %d", len(decision.RequiredSkills))
- }
-}
-
-func TestAnalyzeTaskForSubtasks(t *testing.T) {
- jsonResponse := `{
- "analysis_summary": "This task requires breaking down into multiple components",
- "subtasks": [
- {
- "title": "Backend Development",
- "description": "Implement server-side logic",
- "priority": "high",
- "assigned_to": "backend",
- "estimated_hours": 16,
- "dependencies": [],
- "required_skills": ["go", "api_development"]
- },
- {
- "title": "Frontend Development",
- "description": "Build user interface",
- "priority": "medium",
- "assigned_to": "frontend",
- "estimated_hours": 12,
- "dependencies": ["0"],
- "required_skills": ["react", "typescript"]
- }
- ],
- "agent_creations": [
- {
- "role": "security_specialist",
- "skills": ["security_audit", "penetration_testing"],
- "description": "Specialized agent for security tasks",
- "justification": "Authentication requires security expertise"
- }
- ],
- "recommended_approach": "Start with backend then frontend",
- "estimated_total_hours": 28,
- "risk_assessment": "Medium complexity with API integration risks"
-}`
-
- mockProvider := NewMockLLMProvider([]string{jsonResponse})
- agentRoles := []string{"backend", "frontend", "qa", "ceo"} // Include CEO for agent creation
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Build authentication system",
- Description: "Implement user login and registration",
- Priority: tm.PriorityHigh,
- Status: tm.StatusToDo,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- analysis, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
- if err != nil {
- t.Fatalf("AnalyzeTaskForSubtasks failed: %v", err)
- }
-
- if analysis.ParentTaskID != task.ID {
- t.Errorf("Expected parent task ID %s, got %s", task.ID, analysis.ParentTaskID)
- }
-
- if analysis.AnalysisSummary == "" {
- t.Error("Analysis summary should not be empty")
- }
-
- // Should have 3 subtasks (1 for agent creation + 2 original)
- if len(analysis.Subtasks) != 3 {
- t.Errorf("Expected 3 subtasks (including agent creation), got %d", len(analysis.Subtasks))
- t.Logf("Subtasks: %+v", analysis.Subtasks)
- return // Exit early if count is wrong to avoid index errors
- }
-
- // Test agent creation was processed
- if len(analysis.AgentCreations) != 1 {
- t.Errorf("Expected 1 agent creation, got %d", len(analysis.AgentCreations))
- } else {
- agentCreation := analysis.AgentCreations[0]
- if agentCreation.Role != "security_specialist" {
- t.Errorf("Expected role 'security_specialist', got %s", agentCreation.Role)
- }
- if len(agentCreation.Skills) != 2 {
- t.Errorf("Expected 2 skills, got %d", len(agentCreation.Skills))
- }
- }
-
- // We already checked the count above
-
- // Test first subtask (agent creation)
- subtask0 := analysis.Subtasks[0]
- if !strings.Contains(subtask0.Title, "Security_specialist") {
- t.Errorf("Expected agent creation subtask for security_specialist, got %s", subtask0.Title)
- }
- if subtask0.AssignedTo != "ceo" {
- t.Errorf("Expected agent creation assigned to 'ceo', got %s", subtask0.AssignedTo)
- }
-
- // Test second subtask (original backend task, now at index 1)
- subtask1 := analysis.Subtasks[1]
- if subtask1.Title != "Backend Development" {
- t.Errorf("Expected title 'Backend Development', got %s", subtask1.Title)
- }
- if subtask1.Priority != tm.PriorityHigh {
- t.Errorf("Expected high priority, got %s", subtask1.Priority)
- }
- if subtask1.AssignedTo != "backend" {
- t.Errorf("Expected assigned_to 'backend', got %s", subtask1.AssignedTo)
- }
- if subtask1.EstimatedHours != 16 {
- t.Errorf("Expected 16 hours, got %d", subtask1.EstimatedHours)
- }
- if len(subtask1.RequiredSkills) != 2 {
- t.Errorf("Expected 2 required skills, got %d", len(subtask1.RequiredSkills))
- }
-
- // Test third subtask (original frontend task, now at index 2 with updated dependencies)
- subtask2 := analysis.Subtasks[2]
- if subtask2.Title != "Frontend Development" {
- t.Errorf("Expected title 'Frontend Development', got %s", subtask2.Title)
- }
- if subtask2.Priority != tm.PriorityMedium {
- t.Errorf("Expected medium priority, got %s", subtask2.Priority)
- }
- // Dependencies should be updated to account for the new agent creation subtask
- if len(subtask2.Dependencies) != 1 || subtask2.Dependencies[0] != "1" {
- t.Errorf("Expected dependencies [1] (updated for agent creation), got %v", subtask2.Dependencies)
- }
- if len(subtask2.RequiredSkills) != 2 {
- t.Errorf("Expected 2 required skills, got %d", len(subtask2.RequiredSkills))
- }
-
- // Total hours should include agent creation time (4 hours)
- if analysis.EstimatedTotalHours != 28 {
- t.Errorf("Expected 28 total hours, got %d", analysis.EstimatedTotalHours)
- }
-}
-
-func TestAnalyzeTaskForSubtasks_InvalidJSON(t *testing.T) {
- invalidResponse := "This is not valid JSON"
-
- mockProvider := NewMockLLMProvider([]string{invalidResponse})
- agentRoles := []string{"backend", "frontend"}
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test task",
- }
-
- _, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
- if err == nil {
- t.Error("Expected error for invalid JSON, got nil")
- }
-
- if !strings.Contains(err.Error(), "no JSON found") {
- t.Errorf("Expected 'no JSON found' error, got: %v", err)
- }
-}
-
-func TestAnalyzeTaskForSubtasks_InvalidAgentRole(t *testing.T) {
- jsonResponse := `{
- "analysis_summary": "Test analysis",
- "subtasks": [
- {
- "title": "Invalid Assignment",
- "description": "Test subtask",
- "priority": "high",
- "assigned_to": "invalid_role",
- "estimated_hours": 8,
- "dependencies": []
- }
- ],
- "recommended_approach": "Test approach",
- "estimated_total_hours": 8
-}`
-
- mockProvider := NewMockLLMProvider([]string{jsonResponse})
- agentRoles := []string{"backend", "frontend"}
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- task := &tm.Task{
- ID: "test-task-123",
- Title: "Test task",
- }
-
- analysis, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
- if err != nil {
- t.Fatalf("AnalyzeTaskForSubtasks failed: %v", err)
- }
-
- // Should fix invalid agent assignment to first available role
- if analysis.Subtasks[0].AssignedTo != "backend" {
- t.Errorf("Expected fixed assignment 'backend', got %s", analysis.Subtasks[0].AssignedTo)
- }
-}
-
-func TestGenerateSubtaskPR(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
-
- analysis := &tm.SubtaskAnalysis{
- ParentTaskID: "task-123",
- AnalysisSummary: "Test analysis summary",
- RecommendedApproach: "Test approach",
- EstimatedTotalHours: 40,
- RiskAssessment: "Low risk",
- Subtasks: []tm.SubtaskProposal{
- {
- Title: "Test Subtask",
- Description: "Test description",
- Priority: tm.PriorityHigh,
- AssignedTo: "backend",
- EstimatedHours: 8,
- Dependencies: []string{},
- },
- },
- }
-
- // Test that PR generation fails when no PR provider is configured
- _, err := service.GenerateSubtaskPR(context.Background(), analysis)
- if err == nil {
- t.Error("Expected error when PR provider not configured, got nil")
- }
-
- if !strings.Contains(err.Error(), "PR provider not configured") {
- t.Errorf("Expected 'PR provider not configured' error, got: %v", err)
- }
-}
-
-func TestBuildSubtaskAnalysisPrompt(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- agentRoles := []string{"backend", "frontend", "qa"}
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- task := &tm.Task{
- Title: "Build authentication system",
- Description: "Implement user login and registration with OAuth",
- Priority: tm.PriorityHigh,
- Status: tm.StatusToDo,
- }
-
- prompt := service.buildSubtaskAnalysisPrompt(task)
-
- if !strings.Contains(prompt, task.Title) {
- t.Error("Prompt should contain task title")
- }
-
- if !strings.Contains(prompt, task.Description) {
- t.Error("Prompt should contain task description")
- }
-
- if !strings.Contains(prompt, string(task.Priority)) {
- t.Error("Prompt should contain task priority")
- }
-
- if !strings.Contains(prompt, string(task.Status)) {
- t.Error("Prompt should contain task status")
- }
-}
-
-func TestGetSubtaskAnalysisSystemPrompt(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- agentRoles := []string{"backend", "frontend", "qa", "devops"}
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- systemPrompt := service.getSubtaskAnalysisSystemPrompt()
-
- if !strings.Contains(systemPrompt, "backend") {
- t.Error("System prompt should contain backend role")
- }
-
- if !strings.Contains(systemPrompt, "frontend") {
- t.Error("System prompt should contain frontend role")
- }
-
- if !strings.Contains(systemPrompt, "JSON") {
- t.Error("System prompt should mention JSON format")
- }
-
- if !strings.Contains(systemPrompt, "subtasks") {
- t.Error("System prompt should mention subtasks")
- }
-}
-
-func TestIsValidAgentRole(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- agentRoles := []string{"backend", "frontend", "qa"}
- service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil, nil)
-
- if !service.isValidAgentRole("backend") {
- t.Error("'backend' should be a valid agent role")
- }
-
- if !service.isValidAgentRole("frontend") {
- t.Error("'frontend' should be a valid agent role")
- }
-
- if service.isValidAgentRole("invalid") {
- t.Error("'invalid' should not be a valid agent role")
- }
-
- if service.isValidAgentRole("") {
- t.Error("Empty string should not be a valid agent role")
- }
-}
-
-func TestParseSubtaskAnalysis_Priority(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
-
- tests := []struct {
- input string
- expected tm.TaskPriority
- }{
- {"high", tm.PriorityHigh},
- {"HIGH", tm.PriorityHigh},
- {"High", tm.PriorityHigh},
- {"low", tm.PriorityLow},
- {"LOW", tm.PriorityLow},
- {"Low", tm.PriorityLow},
- {"medium", tm.PriorityMedium},
- {"MEDIUM", tm.PriorityMedium},
- {"Medium", tm.PriorityMedium},
- {"invalid", tm.PriorityMedium}, // default
- {"", tm.PriorityMedium}, // default
- }
-
- for _, test := range tests {
- jsonResponse := `{
- "analysis_summary": "Test",
- "subtasks": [{
- "title": "Test",
- "description": "Test",
- "priority": "` + test.input + `",
- "assigned_to": "backend",
- "estimated_hours": 8,
- "dependencies": []
- }],
- "recommended_approach": "Test",
- "estimated_total_hours": 8
-}`
-
- analysis, err := service.parseSubtaskAnalysis(jsonResponse, "test-task")
- if err != nil {
- t.Fatalf("parseSubtaskAnalysis failed for priority '%s': %v", test.input, err)
- }
-
- if len(analysis.Subtasks) != 1 {
- t.Fatalf("Expected 1 subtask, got %d", len(analysis.Subtasks))
- }
-
- if analysis.Subtasks[0].Priority != test.expected {
- t.Errorf("For priority '%s', expected %s, got %s",
- test.input, test.expected, analysis.Subtasks[0].Priority)
- }
- }
-}
-
-func TestGenerateSubtaskFile(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
-
- subtask := tm.SubtaskProposal{
- Title: "Implement API endpoints",
- Description: "Create REST API endpoints for user management",
- Priority: tm.PriorityHigh,
- AssignedTo: "backend",
- EstimatedHours: 12,
- Dependencies: []string{"0"},
- RequiredSkills: []string{"go", "rest_api"},
- }
-
- taskID := "parent-task-subtask-1"
- parentTaskID := "parent-task"
-
- content := service.generateSubtaskFile(subtask, taskID, parentTaskID)
-
- // Verify YAML frontmatter
- if !strings.Contains(content, "id: parent-task-subtask-1") {
- t.Error("Generated file should contain task ID in frontmatter")
- }
- if !strings.Contains(content, "title: Implement API endpoints") {
- t.Error("Generated file should contain task title in frontmatter")
- }
- if !strings.Contains(content, "assignee: backend") {
- t.Error("Generated file should contain assignee in frontmatter")
- }
- if !strings.Contains(content, "status: todo") {
- t.Error("Generated file should have 'todo' status")
- }
- if !strings.Contains(content, "priority: high") {
- t.Error("Generated file should contain priority in frontmatter")
- }
- if !strings.Contains(content, "parent_task_id: parent-task") {
- t.Error("Generated file should contain parent task ID")
- }
- if !strings.Contains(content, "estimated_hours: 12") {
- t.Error("Generated file should contain estimated hours")
- }
-
- // Verify dependencies are converted properly
- if !strings.Contains(content, "dependencies:") {
- t.Error("Generated file should contain dependencies section")
- }
- if !strings.Contains(content, "- parent-task-subtask-1") {
- t.Error("Dependencies should be converted to subtask IDs")
- }
-
- // Verify required skills
- if !strings.Contains(content, "required_skills:") {
- t.Error("Generated file should contain required skills section")
- }
- if !strings.Contains(content, "- go") {
- t.Error("Generated file should contain required skills")
- }
-
- // Verify markdown content
- if !strings.Contains(content, "# Task Description") {
- t.Error("Generated file should contain markdown task description")
- }
- if !strings.Contains(content, "Create REST API endpoints for user management") {
- t.Error("Generated file should contain task description in body")
- }
-}
-
-func TestUpdateParentTaskAsCompleted(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
-
- // Create a temporary task file for testing
- taskContent := `---
-id: test-task-123
-title: Test Task
-description: A test task for validation
-assignee: backend
-status: todo
-priority: high
-created_at: 2024-01-01T10:00:00Z
-updated_at: 2024-01-01T10:00:00Z
-completed_at: null
----
-
-# Task Description
-
-A test task for validation
-`
-
- // Create temporary file
- tmpFile, err := os.CreateTemp("", "test-task-*.md")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpFile.Name())
-
- if err := os.WriteFile(tmpFile.Name(), []byte(taskContent), 0644); err != nil {
- t.Fatalf("Failed to write temp file: %v", err)
- }
-
- // Create analysis with subtasks
- analysis := &tm.SubtaskAnalysis{
- ParentTaskID: "test-task-123",
- EstimatedTotalHours: 20,
- Subtasks: []tm.SubtaskProposal{
- {
- Title: "Subtask 1",
- AssignedTo: "backend",
- },
- {
- Title: "Subtask 2",
- AssignedTo: "frontend",
- },
- },
- }
-
- // Update the parent task
- err = service.updateParentTaskAsCompleted(tmpFile.Name(), analysis)
- if err != nil {
- t.Fatalf("updateParentTaskAsCompleted failed: %v", err)
- }
-
- // Read the updated file
- updatedContent, err := os.ReadFile(tmpFile.Name())
- if err != nil {
- t.Fatalf("Failed to read updated file: %v", err)
- }
-
- updatedString := string(updatedContent)
-
- // Verify the status was changed to completed
- if !strings.Contains(updatedString, "status: completed") {
- t.Error("Updated file should contain 'status: completed'")
- }
-
- // Verify completed_at was set (should not be null)
- if strings.Contains(updatedString, "completed_at: null") {
- t.Error("Updated file should have completed_at timestamp, not null")
- }
-
- // Verify subtask information was added
- if !strings.Contains(updatedString, "## Subtasks Created") {
- t.Error("Updated file should contain subtasks information")
- }
-
- if !strings.Contains(updatedString, "test-task-123-subtask-1") {
- t.Error("Updated file should reference created subtask IDs")
- }
-
- if !strings.Contains(updatedString, "**Total Estimated Hours:** 20") {
- t.Error("Updated file should contain total estimated hours")
- }
-}
-
-func TestClose(t *testing.T) {
- mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil, nil)
-
- err := service.Close()
- if err != nil {
- t.Errorf("Close should not return error, got: %v", err)
- }
-}
-
-// Benchmark tests
-func BenchmarkAnalyzeTaskForSubtasks(b *testing.B) {
- jsonResponse := `{
- "analysis_summary": "Benchmark test",
- "subtasks": [
- {
- "title": "Benchmark Subtask",
- "description": "Benchmark description",
- "priority": "high",
- "assigned_to": "backend",
- "estimated_hours": 8,
- "dependencies": []
- }
- ],
- "recommended_approach": "Benchmark approach",
- "estimated_total_hours": 8
-}`
-
- mockProvider := NewMockLLMProvider([]string{jsonResponse})
- service := NewSubtaskService(mockProvider, nil, []string{"backend", "frontend"}, nil, "example", "repo", nil, nil)
-
- task := &tm.Task{
- ID: "benchmark-task",
- Title: "Benchmark Task",
- Description: "Task for benchmarking",
- Priority: tm.PriorityHigh,
- }
-
- b.ResetTimer()
- for i := 0; i < b.N; i++ {
- // Reset mock provider for each iteration
- mockProvider.callCount = 0
- _, err := service.AnalyzeTaskForSubtasks(context.Background(), task)
- if err != nil {
- b.Fatalf("AnalyzeTaskForSubtasks failed: %v", err)
- }
- }
-}
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
-}
diff --git a/server/tm/interface.go b/server/tm/interface.go
index c505c65..f50abb1 100644
--- a/server/tm/interface.go
+++ b/server/tm/interface.go
@@ -22,4 +22,8 @@
GetTasksByAssignee(assignee string) ([]*Task, error) // For MVP auto-assignment
GetTasksByStatus(ctx context.Context, status TaskStatus, page, pageSize int) (*TaskList, error)
GetTasksByPriority(ctx context.Context, priority TaskPriority, page, pageSize int) (*TaskList, error)
+
+ // Proposals
+ ProposeSubTasks(ctx context.Context, task *Task, analysis *SubtaskAnalysis) (string, error)
+ ProposeSolution(ctx context.Context, task *Task, solution, agentName string) (string, error)
}
diff --git a/server/tm/types.go b/server/tm/types.go
index a9991b4..9557b57 100644
--- a/server/tm/types.go
+++ b/server/tm/types.go
@@ -32,21 +32,18 @@
// Task represents a single task in the system
type Task struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Description string `json:"description"`
- Owner Owner `json:"owner"`
- Assignee string `json:"assignee,omitempty"` // For MVP auto-assignment
- Status TaskStatus `json:"status"`
- Priority TaskPriority `json:"priority"`
- Solution string `json:"solution,omitempty"` // Generated solution
- PullRequestURL string `json:"pull_request_url,omitempty"` // GitHub PR URL
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Owner Owner `json:"owner"`
+ Assignee string `json:"assignee,omitempty"` // For MVP auto-assignment
+ Status TaskStatus `json:"status"`
+ Priority TaskPriority `json:"priority"`
+ SolutionURL string `json:"solution_url,omitempty"` // Could be GitHub PR URL
// Subtask support
- ParentTaskID string `json:"parent_task_id,omitempty"` // ID of parent task if this is a subtask
- SubtasksPRURL string `json:"subtasks_pr_url,omitempty"` // PR URL for proposed subtasks
- SubtasksGenerated bool `json:"subtasks_generated"` // Whether subtasks have been generated
- SubtasksEvaluated bool `json:"subtasks_evaluated"` // Whether task has been evaluated for subtasks
+ ParentTaskID string `json:"parent_task_id,omitempty"` // ID of parent task if this is a subtask
+ SubtasksEvaluated bool `json:"subtasks_evaluated"` // Whether task has been evaluated for subtasks
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
diff --git a/server/workspace/agent-subtask-service b/server/workspace/agent-subtask-service
new file mode 160000
index 0000000..40c460a
--- /dev/null
+++ b/server/workspace/agent-subtask-service
@@ -0,0 +1 @@
+Subproject commit 40c460a0faca29d90591ec77a1a0263d2b6d31f0