// Package bugseti provides services for the BugSETI distributed bug fixing application. package bugseti import ( "bytes" "context" "encoding/json" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "time" ) // SeederService prepares context for issues using the seed-agent-developer skill. type SeederService struct { config *ConfigService } // NewSeederService creates a new SeederService. func NewSeederService(config *ConfigService) *SeederService { return &SeederService{ config: config, } } // ServiceName returns the service name for Wails. func (s *SeederService) ServiceName() string { return "SeederService" } // SeedIssue prepares context for an issue by calling the seed-agent-developer skill. func (s *SeederService) SeedIssue(issue *Issue) (*IssueContext, error) { if issue == nil { return nil, fmt.Errorf("issue is nil") } // Create a temporary workspace for the issue workDir, err := s.prepareWorkspace(issue) if err != nil { return nil, fmt.Errorf("failed to prepare workspace: %w", err) } // Try to use the seed-agent-developer skill via plugin system ctx, err := s.runSeedSkill(issue, workDir) if err != nil { log.Printf("Seed skill failed, using fallback: %v", err) // Fallback to basic context preparation guard := getEthicsGuard(context.Background()) ctx = s.prepareBasicContext(issue, guard) } ctx.PreparedAt = time.Now() return ctx, nil } // prepareWorkspace creates a temporary workspace and clones the repo. func (s *SeederService) prepareWorkspace(issue *Issue) (string, error) { // Create workspace directory baseDir := s.config.GetWorkspaceDir() if baseDir == "" { baseDir = filepath.Join(os.TempDir(), "bugseti") } // Create issue-specific directory workDir := filepath.Join(baseDir, sanitizeRepoName(issue.Repo), fmt.Sprintf("issue-%d", issue.Number)) if err := os.MkdirAll(workDir, 0755); err != nil { return "", fmt.Errorf("failed to create workspace: %w", err) } // Check if repo already cloned if _, err := os.Stat(filepath.Join(workDir, ".git")); os.IsNotExist(err) { // Clone the repository ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "gh", "repo", "clone", issue.Repo, workDir, "--", "--depth=1") var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("failed to clone repo: %s: %w", stderr.String(), err) } } return workDir, nil } // runSeedSkill executes the seed-agent-developer skill to prepare context. func (s *SeederService) runSeedSkill(issue *Issue, workDir string) (*IssueContext, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() mcpCtx, mcpCancel := context.WithTimeout(ctx, 20*time.Second) defer mcpCancel() marketplace, err := newMarketplaceClient(mcpCtx) if err != nil { return nil, err } defer marketplace.Close() guard := guardFromMarketplace(mcpCtx, marketplace) scriptPath, err := findSeedSkillScript(mcpCtx, marketplace) if err != nil { return nil, err } // Run the analyze-issue script cmd := exec.CommandContext(ctx, "bash", scriptPath) cmd.Dir = workDir cmd.Env = append(os.Environ(), fmt.Sprintf("ISSUE_NUMBER=%d", issue.Number), fmt.Sprintf("ISSUE_REPO=%s", guard.SanitizeEnv(issue.Repo)), fmt.Sprintf("ISSUE_TITLE=%s", guard.SanitizeEnv(issue.Title)), fmt.Sprintf("ISSUE_URL=%s", guard.SanitizeEnv(issue.URL)), ) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("seed skill failed: %s: %w", stderr.String(), err) } // Parse the output as JSON var result struct { Summary string `json:"summary"` RelevantFiles []string `json:"relevant_files"` SuggestedFix string `json:"suggested_fix"` RelatedIssues []string `json:"related_issues"` Complexity string `json:"complexity"` EstimatedTime string `json:"estimated_time"` } if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { // If not JSON, treat as plain text summary return sanitizeIssueContext(&IssueContext{ Summary: stdout.String(), Complexity: "unknown", }, guard), nil } return sanitizeIssueContext(&IssueContext{ Summary: result.Summary, RelevantFiles: result.RelevantFiles, SuggestedFix: result.SuggestedFix, RelatedIssues: result.RelatedIssues, Complexity: result.Complexity, EstimatedTime: result.EstimatedTime, }, guard), nil } // prepareBasicContext creates a basic context without the seed skill. func (s *SeederService) prepareBasicContext(issue *Issue, guard *EthicsGuard) *IssueContext { // Extract potential file references from issue body files := extractFileReferences(issue.Body) // Estimate complexity based on labels and body length complexity := estimateComplexity(issue) return sanitizeIssueContext(&IssueContext{ Summary: fmt.Sprintf("Issue #%d in %s: %s", issue.Number, issue.Repo, issue.Title), RelevantFiles: files, Complexity: complexity, EstimatedTime: estimateTime(complexity), }, guard) } // sanitizeRepoName converts owner/repo to a safe directory name. func sanitizeRepoName(repo string) string { return strings.ReplaceAll(repo, "/", "-") } // extractFileReferences finds file paths mentioned in text. func extractFileReferences(text string) []string { var files []string seen := make(map[string]bool) // Common file patterns patterns := []string{ `.go`, `.js`, `.ts`, `.py`, `.rs`, `.java`, `.cpp`, `.c`, `.h`, `.json`, `.yaml`, `.yml`, `.toml`, `.xml`, `.md`, } words := strings.Fields(text) for _, word := range words { // Clean up the word word = strings.Trim(word, "`,\"'()[]{}:") // Check if it looks like a file path for _, ext := range patterns { if strings.HasSuffix(word, ext) && !seen[word] { files = append(files, word) seen[word] = true break } } } return files } // estimateComplexity guesses issue complexity from content. func estimateComplexity(issue *Issue) string { bodyLen := len(issue.Body) labelScore := 0 for _, label := range issue.Labels { lower := strings.ToLower(label) switch { case strings.Contains(lower, "good first issue"), strings.Contains(lower, "beginner"): labelScore -= 2 case strings.Contains(lower, "easy"): labelScore -= 1 case strings.Contains(lower, "complex"), strings.Contains(lower, "hard"): labelScore += 2 case strings.Contains(lower, "refactor"): labelScore += 1 } } // Combine body length and label score score := labelScore if bodyLen > 2000 { score += 2 } else if bodyLen > 500 { score += 1 } switch { case score <= -1: return "easy" case score <= 1: return "medium" default: return "hard" } } // estimateTime suggests time based on complexity. func estimateTime(complexity string) string { switch complexity { case "easy": return "15-30 minutes" case "medium": return "1-2 hours" case "hard": return "2-4 hours" default: return "unknown" } } const seedSkillName = "seed-agent-developer" func findSeedSkillScript(ctx context.Context, marketplace marketplaceClient) (string, error) { if marketplace == nil { return "", fmt.Errorf("marketplace client is nil") } plugins, err := marketplace.ListMarketplace(ctx) if err != nil { return "", err } for _, plugin := range plugins { info, err := marketplace.PluginInfo(ctx, plugin.Name) if err != nil || info == nil { continue } if !containsSkill(info.Skills, seedSkillName) { continue } scriptPath, err := safeJoinUnder(info.Path, "skills", seedSkillName, "scripts", "analyze-issue.sh") if err != nil { continue } if stat, err := os.Stat(scriptPath); err == nil && !stat.IsDir() { return scriptPath, nil } } return "", fmt.Errorf("seed-agent-developer skill not found in marketplace") } func containsSkill(skills []string, name string) bool { for _, skill := range skills { if skill == name { return true } } return false } func safeJoinUnder(base string, elems ...string) (string, error) { if base == "" { return "", fmt.Errorf("base path is empty") } baseAbs, err := filepath.Abs(base) if err != nil { return "", fmt.Errorf("failed to resolve base path: %w", err) } joined := filepath.Join(append([]string{baseAbs}, elems...)...) rel, err := filepath.Rel(baseAbs, joined) if err != nil { return "", fmt.Errorf("failed to resolve relative path: %w", err) } if strings.HasPrefix(rel, "..") { return "", fmt.Errorf("resolved path escapes base: %s", rel) } return joined, nil } func sanitizeIssueContext(ctx *IssueContext, guard *EthicsGuard) *IssueContext { if ctx == nil { return nil } if guard == nil { guard = &EthicsGuard{} } ctx.Summary = guard.SanitizeSummary(ctx.Summary) ctx.SuggestedFix = guard.SanitizeSummary(ctx.SuggestedFix) ctx.Complexity = guard.SanitizeTitle(ctx.Complexity) ctx.EstimatedTime = guard.SanitizeTitle(ctx.EstimatedTime) ctx.RelatedIssues = guard.SanitizeList(ctx.RelatedIssues, maxTitleRunes) ctx.RelevantFiles = guard.SanitizeFiles(ctx.RelevantFiles) return ctx } // GetWorkspaceDir returns the workspace directory for an issue. func (s *SeederService) GetWorkspaceDir(issue *Issue) string { baseDir := s.config.GetWorkspaceDir() if baseDir == "" { baseDir = filepath.Join(os.TempDir(), "bugseti") } return filepath.Join(baseDir, sanitizeRepoName(issue.Repo), fmt.Sprintf("issue-%d", issue.Number)) } // CleanupWorkspace removes the workspace for an issue. func (s *SeederService) CleanupWorkspace(issue *Issue) error { workDir := s.GetWorkspaceDir(issue) return os.RemoveAll(workDir) }