package service import ( "context" "database/sql" "encoding/base64" "fmt" "path/filepath" "strconv" "strings" giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" "github.com/orchard9/rdev/internal/port" ) // AddComponentBatch adds multiple components in a single atomic operation. // All components are validated upfront, then committed in a single git commit. // Infrastructure components (postgres, redis) are provisioned sequentially before code components. func (s *ComponentService) AddComponentBatch(ctx context.Context, projectID string, reqs []port.AddComponentRequest) ([]*domain.Component, error) { if len(reqs) == 0 { return nil, fmt.Errorf("at least one component is required") } log := logging.FromContext(ctx).WithService("component") // 1. Validate all components upfront var infraReqs []port.AddComponentRequest var codeReqs []port.AddComponentRequest for _, req := range reqs { // Validate component type if !domain.IsValidComponentType(req.Type) { return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type) } componentType := domain.ComponentType(req.Type) // Validate component name if err := domain.ValidateComponentName(req.Name); err != nil { return nil, fmt.Errorf("%w: %s", err, req.Name) } // Separate infrastructure from code components if componentType.IsInfraComponent() { infraReqs = append(infraReqs, req) } else { codeReqs = append(codeReqs, req) } } // Check for duplicate names in the batch seen := make(map[string]bool) for _, req := range reqs { key := req.Type + ":" + req.Name if seen[key] { return nil, fmt.Errorf("%w: duplicate component %s/%s in batch", domain.ErrDuplicateComponent, req.Type, req.Name) } seen[key] = true } results := make([]*domain.Component, 0, len(reqs)) // 2. Provision infrastructure components first (these don't need git commits) for _, req := range infraReqs { componentType := domain.ComponentType(req.Type) component, err := s.addInfraComponent(ctx, projectID, componentType, req.Name) if err != nil { return results, fmt.Errorf("failed to provision %s component %s: %w", req.Type, req.Name, err) } results = append(results, component) } // 3. If no code components, we're done if len(codeReqs) == 0 { return results, nil } // 4. Get project info from database (needed for code components) var gitRepoOwner, gitRepoName string var projectDomain string err := s.db.QueryRowContext(ctx, ` SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1), COALESCE(domain, '') FROM projects WHERE id = $1 `, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName, &projectDomain) if err == sql.ErrNoRows { return results, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) } if err != nil { return results, fmt.Errorf("failed to get project: %w", err) } goModule := fmt.Sprintf("git.threesix.ai/%s/%s", gitRepoOwner, gitRepoName) // 5. Prepare all file operations for code components var allFileOps []giteaadapter.ChangeFileOperation var codeComponents []*domain.Component // Track files we've already fetched/modified to avoid duplicate fetches type fileState struct { content []byte sha string } fileCache := make(map[string]*fileState) // Helper to get file content (cached) getFile := func(path string) ([]byte, string, error) { if cached, ok := fileCache[path]; ok { return cached.content, cached.sha, nil } content, sha, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, path) if err != nil { return nil, "", err } fileCache[path] = &fileState{content: content, sha: sha} return content, sha, nil } // 6. Process each code component for _, req := range codeReqs { componentType := domain.ComponentType(req.Type) destDir := componentType.DestDir() componentPath := filepath.Join(destDir, req.Name) // Check for duplicate component by checking for key files checkFile := componentPath + "/go.mod" if componentType == domain.ComponentTypeAppAstro || componentType == domain.ComponentTypeAppReact { checkFile = componentPath + "/package.json" } existingContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile) if err != nil { return results, fmt.Errorf("failed to check for existing component %s: %w", req.Name, err) } if existingContent != nil { return results, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath) } // Assign port if needed port := req.Port if port == 0 && componentType.NeedsPort() { port, err = s.assignPort(ctx, projectID, componentType) if err != nil { return results, fmt.Errorf("failed to assign port for %s: %w", req.Name, err) } } // Prepare template variables vars := map[string]string{ "PROJECT_NAME": projectID, "GO_MODULE": goModule, "COMPONENT_NAME": req.Name, "PORT": strconv.Itoa(port), "DOMAIN": projectDomain, } // Get component template files componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars) if err != nil { return results, fmt.Errorf("failed to get component template files for %s: %w", req.Name, err) } // Add component files to operations for _, cf := range componentFiles { if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") { continue } encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content)) allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{ Operation: "create", Path: cf.Path, Content: encodedContent, }) } // Track component for later codeComponents = append(codeComponents, &domain.Component{ Type: componentType, Name: req.Name, Path: componentPath, Port: port, Template: req.Type, Dependencies: []string{}, }) } // 7. Prepare monorepo file updates (Procfile, go.work, .woodpecker.yml, CLAUDE.md) // These need to be accumulated across all components // Update Procfile procfileContent, procfileSHA, err := getFile("Procfile") if err != nil { return results, fmt.Errorf("failed to get Procfile: %w", err) } if procfileContent != nil { updatedProcfile := string(procfileContent) for i, comp := range codeComponents { updatedProcfile = s.updateProcfile(updatedProcfile, comp.Type, comp.Name, comp.Path, comp.Port) // Update cache for next iteration fileCache["Procfile"] = &fileState{content: []byte(updatedProcfile), sha: procfileSHA} _ = i // silence unused } allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "Procfile", Content: base64.StdEncoding.EncodeToString([]byte(updatedProcfile)), SHA: procfileSHA, }) } // Update go.work (only for Go components) goWorkContent, goWorkSHA, err := getFile("go.work") if err != nil { return results, fmt.Errorf("failed to get go.work: %w", err) } if goWorkContent != nil { updatedGoWork := string(goWorkContent) for _, comp := range codeComponents { if comp.Type.IsGoComponent() { updatedGoWork = s.updateGoWork(updatedGoWork, comp.Path) } } allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "go.work", Content: base64.StdEncoding.EncodeToString([]byte(updatedGoWork)), SHA: goWorkSHA, }) } // Update .woodpecker.yml woodpeckerContent, woodpeckerSHA, err := getFile(".woodpecker.yml") if err != nil { return results, fmt.Errorf("failed to get .woodpecker.yml: %w", err) } if woodpeckerContent != nil { updatedWoodpecker := string(woodpeckerContent) for i, req := range codeReqs { comp := codeComponents[i] vars := map[string]string{ "PROJECT_NAME": projectID, "GO_MODULE": goModule, "COMPONENT_NAME": comp.Name, "PORT": strconv.Itoa(comp.Port), "DOMAIN": projectDomain, } stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, req.Type, vars) if err != nil { log.Warn("failed to get woodpecker step template", logging.FieldError, err, "component", comp.Name) continue } updatedWoodpecker = s.updateWoodpeckerYml(updatedWoodpecker, stepYaml) } allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: ".woodpecker.yml", Content: base64.StdEncoding.EncodeToString([]byte(updatedWoodpecker)), SHA: woodpeckerSHA, }) } // Update CLAUDE.md claudeMdContent, claudeMdSHA, err := getFile("CLAUDE.md") if err != nil { return results, fmt.Errorf("failed to get CLAUDE.md: %w", err) } if claudeMdContent != nil { updatedClaudeMd := string(claudeMdContent) for _, comp := range codeComponents { updatedClaudeMd = s.updateClaudeMd(updatedClaudeMd, comp.Type, comp.Name, comp.Path) } allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "CLAUDE.md", Content: base64.StdEncoding.EncodeToString([]byte(updatedClaudeMd)), SHA: claudeMdSHA, }) } // 8. Commit all files in a single atomic commit componentNames := make([]string, len(codeReqs)) for i, req := range codeReqs { componentNames[i] = req.Type + "/" + req.Name } opts := giteaadapter.ChangeFilesOptions{ Files: allFileOps, Message: fmt.Sprintf("Add components: %s", strings.Join(componentNames, ", ")), } _, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts) if err != nil { return results, fmt.Errorf("failed to commit component files: %w", err) } log.Info("batch components added successfully", logging.FieldProjectID, projectID, "count", len(codeComponents), "components", componentNames, ) // 9. Create initial K8s deployments for components that need one for _, comp := range codeComponents { s.createInitialComponentDeployment(ctx, projectID, projectDomain, comp) } // 10. Combine infrastructure and code component results results = append(results, codeComponents...) return results, nil }