Add Github integration to subtasks
Change-Id: If382f2f5238e9d2323d6c2761d3d70a626ff9065
diff --git a/server/agent/manager.go b/server/agent/manager.go
index ab625bf..45b0965 100644
--- a/server/agent/manager.go
+++ b/server/agent/manager.go
@@ -107,6 +107,10 @@
firstAgent.Provider,
m.taskManager,
agentRoles,
+ m.prProvider,
+ m.config.GitHub.Owner,
+ m.config.GitHub.Repo,
+ m.cloneManager,
)
return nil
diff --git a/server/subtasks/service.go b/server/subtasks/service.go
index 710dfaf..5f99f74 100644
--- a/server/subtasks/service.go
+++ b/server/subtasks/service.go
@@ -5,8 +5,13 @@
"encoding/json"
"fmt"
"log"
+ "os"
+ "os/exec"
+ "path/filepath"
"strings"
+ "time"
+ "github.com/iomodo/staff/git"
"github.com/iomodo/staff/llm"
"github.com/iomodo/staff/tm"
)
@@ -16,14 +21,22 @@
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
}
// NewSubtaskService creates a new subtask service
-func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string) *SubtaskService {
+func NewSubtaskService(provider llm.LLMProvider, taskManager tm.TaskManager, agentRoles []string, prProvider git.PullRequestProvider, githubOwner, githubRepo string, cloneManager *git.CloneManager) *SubtaskService {
return &SubtaskService{
- llmProvider: provider,
- taskManager: taskManager,
- agentRoles: agentRoles,
+ llmProvider: provider,
+ taskManager: taskManager,
+ agentRoles: agentRoles,
+ prProvider: prProvider,
+ githubOwner: githubOwner,
+ githubRepo: githubRepo,
+ cloneManager: cloneManager,
}
}
@@ -402,16 +415,40 @@
// GenerateSubtaskPR creates a PR with the proposed subtasks
func (s *SubtaskService) GenerateSubtaskPR(ctx context.Context, analysis *tm.SubtaskAnalysis) (string, error) {
- // Generate markdown content for the PR
+ 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)
+
+ // 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)
-
- // This would typically create a Git branch and PR
- // For now, we'll return a mock PR URL
- prURL := fmt.Sprintf("https://github.com/example/repo/pull/subtasks-%s", analysis.ParentTaskID)
-
+ title := fmt.Sprintf("Subtask Proposal: %s", analysis.ParentTaskID)
+
+ // Create the pull request
+ options := git.PullRequestOptions{
+ Title: title,
+ Description: prContent,
+ HeadBranch: branchName,
+ BaseBranch: "main",
+ Labels: []string{"subtasks", "proposal", "ai-generated"},
+ Draft: false,
+ }
+
+ 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)
log.Printf("Generated subtask proposal PR: %s", prURL)
- log.Printf("PR Content:\n%s", prContent)
-
+
return prURL, nil
}
@@ -454,6 +491,129 @@
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 {
+ log.Printf("Warning: failed to pull latest changes: %v", err)
+ }
+
+ // 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 subtask proposal file
+ proposalDir := filepath.Join(clonePath, "tasks", "subtasks")
+ if err := os.MkdirAll(proposalDir, 0755); err != nil {
+ return fmt.Errorf("failed to create proposal directory: %w", err)
+ }
+
+ proposalFile := filepath.Join(proposalDir, fmt.Sprintf("%s-proposal.md", analysis.ParentTaskID))
+ proposalContent := s.generateSubtaskProposalFile(analysis)
+
+ if err := os.WriteFile(proposalFile, []byte(proposalContent), 0644); err != nil {
+ return fmt.Errorf("failed to write proposal file: %w", err)
+ }
+
+ // Stage the file
+ relativeFile := filepath.Join("tasks", "subtasks", fmt.Sprintf("%s-proposal.md", analysis.ParentTaskID))
+ cmd = gitCmd("add", relativeFile)
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("failed to stage files: %w", err)
+ }
+
+ // Commit changes
+ commitMsg := fmt.Sprintf("Subtask proposal for task %s\n\nGenerated by Staff AI Agent System\nProposed %d subtasks with %d new agents",
+ analysis.ParentTaskID, len(analysis.Subtasks), 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)
+ }
+
+ log.Printf("Created subtask proposal branch: %s", branchName)
+ return nil
+}
+
+// generateSubtaskProposalFile creates the content for the subtask proposal file
+func (s *SubtaskService) generateSubtaskProposalFile(analysis *tm.SubtaskAnalysis) string {
+ var content strings.Builder
+
+ content.WriteString(fmt.Sprintf("# Subtask Proposal for Task %s\n\n", analysis.ParentTaskID))
+ content.WriteString(fmt.Sprintf("**Generated:** %s\n\n", time.Now().Format(time.RFC3339)))
+ content.WriteString(fmt.Sprintf("## Analysis Summary\n%s\n\n", analysis.AnalysisSummary))
+
+ if len(analysis.AgentCreations) > 0 {
+ content.WriteString("## Proposed New Agents\n\n")
+ for i, agent := range analysis.AgentCreations {
+ content.WriteString(fmt.Sprintf("### %d. %s Agent\n", i+1, strings.Title(agent.Role)))
+ content.WriteString(fmt.Sprintf("- **Skills:** %s\n", strings.Join(agent.Skills, ", ")))
+ content.WriteString(fmt.Sprintf("- **Description:** %s\n", agent.Description))
+ content.WriteString(fmt.Sprintf("- **Justification:** %s\n\n", agent.Justification))
+ }
+ }
+
+ 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.RequiredSkills) > 0 {
+ content.WriteString(fmt.Sprintf("- **Required Skills:** %s\n", strings.Join(subtask.RequiredSkills, ", ")))
+ }
+ if len(subtask.Dependencies) > 0 {
+ content.WriteString(fmt.Sprintf("- **Dependencies:** %s\n", strings.Join(subtask.Dependencies, ", ")))
+ }
+ content.WriteString(fmt.Sprintf("- **Description:** %s\n\n", subtask.Description))
+ }
+
+ content.WriteString(fmt.Sprintf("## Recommended Approach\n%s\n\n", analysis.RecommendedApproach))
+ content.WriteString(fmt.Sprintf("**Estimated Total Hours:** %d\n\n", analysis.EstimatedTotalHours))
+
+ if analysis.RiskAssessment != "" {
+ content.WriteString(fmt.Sprintf("## Risk Assessment\n%s\n\n", analysis.RiskAssessment))
+ }
+
+ content.WriteString("---\n\n")
+ content.WriteString("*This proposal was generated by the Staff AI Agent System. Review and approve to proceed with subtask creation.*\n")
+
+ return content.String()
+}
+
// Close cleans up the service
func (s *SubtaskService) Close() error {
if s.llmProvider != nil {
diff --git a/server/subtasks/service_test.go b/server/subtasks/service_test.go
index ade62fc..158bcdc 100644
--- a/server/subtasks/service_test.go
+++ b/server/subtasks/service_test.go
@@ -80,7 +80,7 @@
mockProvider := NewMockLLMProvider([]string{})
agentRoles := []string{"backend", "frontend", "qa"}
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
if service == nil {
t.Fatal("NewSubtaskService returned nil")
@@ -106,7 +106,7 @@
mockProvider := NewMockLLMProvider([]string{decisionResponse})
agentRoles := []string{"backend", "frontend", "qa"}
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
// Test the parseSubtaskDecision method directly since ShouldGenerateSubtasks is used by manager
decision, err := service.parseSubtaskDecision(decisionResponse)
@@ -165,7 +165,7 @@
mockProvider := NewMockLLMProvider([]string{jsonResponse})
agentRoles := []string{"backend", "frontend", "qa", "ceo"} // Include CEO for agent creation
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
task := &tm.Task{
ID: "test-task-123",
@@ -266,7 +266,7 @@
mockProvider := NewMockLLMProvider([]string{invalidResponse})
agentRoles := []string{"backend", "frontend"}
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
task := &tm.Task{
ID: "test-task-123",
@@ -302,7 +302,7 @@
mockProvider := NewMockLLMProvider([]string{jsonResponse})
agentRoles := []string{"backend", "frontend"}
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
task := &tm.Task{
ID: "test-task-123",
@@ -322,7 +322,7 @@
func TestGenerateSubtaskPR(t *testing.T) {
mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil)
analysis := &tm.SubtaskAnalysis{
ParentTaskID: "task-123",
@@ -342,21 +342,21 @@
},
}
- prURL, err := service.GenerateSubtaskPR(context.Background(), analysis)
- if err != nil {
- t.Fatalf("GenerateSubtaskPR failed: %v", err)
+ // 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")
}
- expectedURL := "https://github.com/example/repo/pull/subtasks-task-123"
- if prURL != expectedURL {
- t.Errorf("Expected PR URL %s, got %s", expectedURL, prURL)
+ 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)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
task := &tm.Task{
Title: "Build authentication system",
@@ -387,7 +387,7 @@
func TestGetSubtaskAnalysisSystemPrompt(t *testing.T) {
mockProvider := NewMockLLMProvider([]string{})
agentRoles := []string{"backend", "frontend", "qa", "devops"}
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
systemPrompt := service.getSubtaskAnalysisSystemPrompt()
@@ -411,7 +411,7 @@
func TestIsValidAgentRole(t *testing.T) {
mockProvider := NewMockLLMProvider([]string{})
agentRoles := []string{"backend", "frontend", "qa"}
- service := NewSubtaskService(mockProvider, nil, agentRoles)
+ service := NewSubtaskService(mockProvider, nil, agentRoles, nil, "example", "repo", nil)
if !service.isValidAgentRole("backend") {
t.Error("'backend' should be a valid agent role")
@@ -432,7 +432,7 @@
func TestParseSubtaskAnalysis_Priority(t *testing.T) {
mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil)
tests := []struct {
input string
@@ -484,7 +484,7 @@
func TestClose(t *testing.T) {
mockProvider := NewMockLLMProvider([]string{})
- service := NewSubtaskService(mockProvider, nil, []string{"backend"})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend"}, nil, "example", "repo", nil)
err := service.Close()
if err != nil {
@@ -511,7 +511,7 @@
}`
mockProvider := NewMockLLMProvider([]string{jsonResponse})
- service := NewSubtaskService(mockProvider, nil, []string{"backend", "frontend"})
+ service := NewSubtaskService(mockProvider, nil, []string{"backend", "frontend"}, nil, "example", "repo", nil)
task := &tm.Task{
ID: "benchmark-task",