cli/internal/bugseti/seeder.go
Athena a54ceb54dd fix(bugseti): add mutex protection to seeder concurrent access
Add sync.Mutex to SeederService to protect shared state during
concurrent SeedIssue, GetWorkspaceDir, and CleanupWorkspace calls.
Extract getWorkspaceDir as lock-free helper to avoid double-locking.

Closes #63

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00

368 lines
9.8 KiB
Go

// 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"
"sync"
"time"
)
// SeederService prepares context for issues using the seed-agent-developer skill.
type SeederService struct {
mu sync.Mutex
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) {
s.mu.Lock()
defer s.mu.Unlock()
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 := getEthicsGuardWithRoot(context.Background(), s.config.GetMarketplaceMCPRoot())
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, s.config.GetMarketplaceMCPRoot())
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 {
s.mu.Lock()
defer s.mu.Unlock()
return s.getWorkspaceDir(issue)
}
// getWorkspaceDir is the lock-free implementation; caller must hold s.mu.
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 {
s.mu.Lock()
defer s.mu.Unlock()
workDir := s.getWorkspaceDir(issue)
return os.RemoveAll(workDir)
}