// Package service provides business logic services. package service import ( "context" "database/sql" "encoding/base64" "fmt" "log/slog" "path/filepath" "regexp" "strconv" "strings" giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/port" ) // procfilePatterns contains pre-compiled regex patterns for parsing Procfile entries // by component type. Compiled once at package init for performance. var procfilePatterns = make(map[domain.ComponentType]*regexp.Regexp) func init() { // Pre-compile regex patterns for each component type's Procfile entries for _, ct := range domain.ValidComponentTypes { destDir := ct.DestDir() if destDir != "" { // Pattern matches: "component-name: cd services/component-name && ..." procfilePatterns[ct] = regexp.MustCompile(`^([a-z][a-z0-9-]*): cd (` + destDir + `/[a-z0-9-]+)`) } } } // ComponentService manages components within monorepo projects. type ComponentService struct { db *sql.DB templateProvider port.TemplateProvider bulkClient *giteaadapter.BulkFileClient deployer port.Deployer defaultGitOwner string registryURL string logger *slog.Logger } // ComponentServiceConfig configures the component service. type ComponentServiceConfig struct { DefaultGitOwner string // e.g., "threesix" RegistryURL string // e.g., "registry.threesix.ai" Logger *slog.Logger } // NewComponentService creates a new component service. func NewComponentService( db *sql.DB, templateProvider port.TemplateProvider, bulkClient *giteaadapter.BulkFileClient, deployer port.Deployer, cfg ComponentServiceConfig, ) *ComponentService { logger := cfg.Logger if logger == nil { logger = slog.Default() } return &ComponentService{ db: db, templateProvider: templateProvider, bulkClient: bulkClient, deployer: deployer, defaultGitOwner: cfg.DefaultGitOwner, registryURL: cfg.RegistryURL, logger: logger, } } // Ensure ComponentService implements the interface. var _ port.ComponentService = (*ComponentService)(nil) // AddComponent adds a new component to a project's monorepo. func (s *ComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) { // 1. Validate component type if !domain.IsValidComponentType(req.Type) { return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type) } componentType := domain.ComponentType(req.Type) // 2. Validate component name if err := domain.ValidateComponentName(req.Name); err != nil { return nil, fmt.Errorf("%w: %s", err, req.Name) } // 3. Get project info from database var gitRepoOwner, gitRepoName, goModule 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 nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) } if err != nil { return nil, fmt.Errorf("failed to get project: %w", err) } // Build Go module path (use actual git host, not github.com) goModule = fmt.Sprintf("git.threesix.ai/%s/%s", gitRepoOwner, gitRepoName) // 4. Calculate component path destDir := componentType.DestDir() componentPath := filepath.Join(destDir, req.Name) // 5. 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 nil, fmt.Errorf("failed to check for existing component: %w", err) } if existingContent != nil { return nil, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath) } // 6. Assign port if needed port := req.Port if port == 0 && componentType.NeedsPort() { port, err = s.assignPort(ctx, projectID, componentType) if err != nil { return nil, fmt.Errorf("failed to assign port: %w", err) } } // 7. Prepare template variables vars := map[string]string{ "PROJECT_NAME": projectID, "GO_MODULE": goModule, "COMPONENT_NAME": req.Name, "PORT": strconv.Itoa(port), "DOMAIN": projectDomain, } // 8. Get component template files componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars) if err != nil { return nil, fmt.Errorf("failed to get component template files: %w", err) } // 9. Read and update monorepo files fileOps, err := s.prepareMonorepoUpdates(ctx, gitRepoOwner, gitRepoName, componentType, req.Name, componentPath, port, vars) if err != nil { return nil, fmt.Errorf("failed to prepare monorepo updates: %w", err) } // 10. Add component files to the operations for _, cf := range componentFiles { // Skip the .woodpecker.step.yml file - it's merged into main .woodpecker.yml if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") { continue } encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content)) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "create", Path: cf.Path, Content: encodedContent, }) } // 11. Commit all files in a single atomic commit opts := giteaadapter.ChangeFilesOptions{ Files: fileOps, Message: fmt.Sprintf("Add %s component: %s", req.Type, req.Name), } _, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts) if err != nil { return nil, fmt.Errorf("failed to commit component files: %w", err) } s.logger.Info("component added successfully", "project", projectID, "component_type", req.Type, "component_name", req.Name, "path", componentPath, "port", port, ) // 12. Build and return the component component := &domain.Component{ Type: componentType, Name: req.Name, Path: componentPath, Port: port, Template: req.Type, Dependencies: []string{}, // Could be parsed from component.yaml } // 13. Create initial K8s deployment for components that need one. // This ensures kubectl set image will find the deployment when CI runs. s.createInitialComponentDeployment(ctx, projectID, projectDomain, component) return component, nil } // assignPort finds the next available port for a component type. func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) { // Get existing components to find the highest used port components, err := s.ListComponents(ctx, projectID) if err != nil { return 0, err } startingPort := componentType.StartingPort() if startingPort == 0 { return 0, nil // Component type doesn't need a port } maxPort := startingPort - 1 for _, c := range components { // Only consider components that share the same port range if c.Type.StartingPort() == startingPort && c.Port > maxPort { maxPort = c.Port } } return maxPort + 1, nil } // createInitialComponentDeployment creates a K8s Deployment for a newly added component. // This ensures the deployment exists before CI runs, so kubectl set image succeeds. // Failures are logged but don't fail the component creation. func (s *ComponentService) createInitialComponentDeployment( ctx context.Context, projectID, projectDomain string, component *domain.Component, ) { // Skip if no deployer or component doesn't need a deployment if s.deployer == nil || !component.Type.NeedsPort() { return } // Build the image path - uses "latest" as placeholder until CI builds a real image image := fmt.Sprintf("%s/%s/%s:latest", s.registryURL, projectID, component.Name) spec := domain.DeploySpec{ ProjectName: projectID, ComponentPath: component.Path, Image: image, Domain: projectDomain, Port: component.Port, Replicas: 1, } if err := s.deployer.Deploy(ctx, spec); err != nil { s.logger.Warn("failed to create initial component deployment", "project", projectID, "component", component.Name, "error", err, ) return } s.logger.Info("created initial component deployment", "project", projectID, "component", component.Name, "image", image, ) } // prepareMonorepoUpdates reads existing monorepo files and prepares updates. func (s *ComponentService) prepareMonorepoUpdates( ctx context.Context, owner, repo string, componentType domain.ComponentType, componentName, componentPath string, port int, vars map[string]string, ) ([]giteaadapter.ChangeFileOperation, error) { var fileOps []giteaadapter.ChangeFileOperation // 1. Update Procfile procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "Procfile") if err != nil { return nil, fmt.Errorf("failed to get Procfile: %w", err) } if procfileContent != nil { updated := s.updateProcfile(string(procfileContent), componentType, componentName, componentPath, port) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "Procfile", Content: base64.StdEncoding.EncodeToString([]byte(updated)), SHA: procfileSHA, }) } // 2. Update go.work for Go components if componentType.IsGoComponent() { goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "go.work") if err != nil { return nil, fmt.Errorf("failed to get go.work: %w", err) } if goWorkContent != nil { updated := s.updateGoWork(string(goWorkContent), componentPath) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "go.work", Content: base64.StdEncoding.EncodeToString([]byte(updated)), SHA: goWorkSHA, }) } } // 3. Update .woodpecker.yml woodpeckerContent, woodpeckerSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, ".woodpecker.yml") if err != nil { return nil, fmt.Errorf("failed to get .woodpecker.yml: %w", err) } if woodpeckerContent != nil { // Get the CI step template for this component type stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, string(componentType), vars) if err != nil { s.logger.Warn("failed to get woodpecker step template", "error", err) } else { updated := s.updateWoodpeckerYml(string(woodpeckerContent), stepYaml) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: ".woodpecker.yml", Content: base64.StdEncoding.EncodeToString([]byte(updated)), SHA: woodpeckerSHA, }) } } // 4. Update CLAUDE.md claudeMdContent, claudeMdSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "CLAUDE.md") if err != nil { return nil, fmt.Errorf("failed to get CLAUDE.md: %w", err) } if claudeMdContent != nil { updated := s.updateClaudeMd(string(claudeMdContent), componentType, componentName, componentPath) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "CLAUDE.md", Content: base64.StdEncoding.EncodeToString([]byte(updated)), SHA: claudeMdSHA, }) } return fileOps, nil } // ListComponents lists all components in a project's monorepo. func (s *ComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) { // Get project info from database var gitRepoOwner, gitRepoName string err := s.db.QueryRowContext(ctx, ` SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1) FROM projects WHERE id = $1 `, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) } if err != nil { return nil, fmt.Errorf("failed to get project: %w", err) } // Read Procfile once (not inside the loop) procfileContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "Procfile") if err != nil { s.logger.Warn("failed to read Procfile", "error", err) return []domain.Component{}, nil } if procfileContent == nil { return []domain.Component{}, nil } var components []domain.Component procfileStr := string(procfileContent) // Check each component type's directory for _, ct := range domain.ValidComponentTypes { destDir := ct.DestDir() if destDir == "" { continue } // Parse Procfile to extract component info for this type comps := s.parseComponentsFromProcfile(procfileStr, ct) components = append(components, comps...) } return components, nil } // parseComponentsFromProcfile extracts component information from a Procfile. // Ports are assigned incrementally based on discovery order within each component type. func (s *ComponentService) parseComponentsFromProcfile(procfile string, componentType domain.ComponentType) []domain.Component { var components []domain.Component // Use pre-compiled pattern from package-level map pattern, ok := procfilePatterns[componentType] if !ok { return components } startingPort := componentType.StartingPort() portOffset := 0 for _, line := range strings.Split(procfile, "\n") { matches := pattern.FindStringSubmatch(strings.TrimSpace(line)) if len(matches) >= 3 { name := matches[1] path := matches[2] // Assign ports incrementally based on discovery order port := 0 if componentType.NeedsPort() { port = startingPort + portOffset portOffset++ } components = append(components, domain.Component{ Type: componentType, Name: name, Path: path, Port: port, Template: string(componentType), Dependencies: []string{}, }) } } return components } // RemoveComponent removes a component from a project's monorepo. func (s *ComponentService) RemoveComponent(ctx context.Context, projectID string, componentPath string) error { // Get project info from database var gitRepoOwner, gitRepoName string err := s.db.QueryRowContext(ctx, ` SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1) FROM projects WHERE id = $1 `, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName) if err == sql.ErrNoRows { return fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID) } if err != nil { return fmt.Errorf("failed to get project: %w", err) } // Verify component exists by checking for a file in the path checkFile := componentPath + "/go.mod" content, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile) if err != nil { return fmt.Errorf("failed to check component: %w", err) } if content == nil { // Try package.json for frontend apps checkFile = componentPath + "/package.json" content, _, err = s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile) if err != nil { return fmt.Errorf("failed to check component: %w", err) } if content == nil { return fmt.Errorf("%w: %s", domain.ErrComponentNotFound, componentPath) } } // Extract component name from path componentName := filepath.Base(componentPath) // Determine component type from path var componentType domain.ComponentType switch { case strings.HasPrefix(componentPath, "services/"): componentType = domain.ComponentTypeService case strings.HasPrefix(componentPath, "workers/"): componentType = domain.ComponentTypeWorker case strings.HasPrefix(componentPath, "apps/"): // Could be astro or react - check package.json later componentType = domain.ComponentTypeAppAstro case strings.HasPrefix(componentPath, "cli/"): componentType = domain.ComponentTypeCLI default: return fmt.Errorf("unknown component path structure: %s", componentPath) } // For now, we'll update the monorepo files to remove references // Actual file deletion would require listing all files in the directory // which the Gitea API doesn't support easily var fileOps []giteaadapter.ChangeFileOperation // 1. Update Procfile - remove the component entry procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "Procfile") if err != nil { return fmt.Errorf("failed to get Procfile: %w", err) } if procfileContent != nil { updated := s.removeProcfileEntry(string(procfileContent), componentName) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "Procfile", Content: base64.StdEncoding.EncodeToString([]byte(updated)), SHA: procfileSHA, }) } // 2. Update go.work if Go component if componentType.IsGoComponent() { goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, "go.work") if err != nil { return fmt.Errorf("failed to get go.work: %w", err) } if goWorkContent != nil { updated := s.removeGoWorkEntry(string(goWorkContent), componentPath) fileOps = append(fileOps, giteaadapter.ChangeFileOperation{ Operation: "update", Path: "go.work", Content: base64.StdEncoding.EncodeToString([]byte(updated)), SHA: goWorkSHA, }) } } // Note: Removing from .woodpecker.yml and CLAUDE.md is more complex // because we'd need to parse YAML and markdown tables properly. // For now, we'll leave those as manual cleanup tasks. if len(fileOps) > 0 { opts := giteaadapter.ChangeFilesOptions{ Files: fileOps, Message: fmt.Sprintf("Remove component references: %s", componentName), } _, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts) if err != nil { return fmt.Errorf("failed to commit changes: %w", err) } } s.logger.Info("component removed", "project", projectID, "path", componentPath, "note", "Component files remain in repo - delete manually if needed", ) return nil }