package service import ( "context" "database/sql" "encoding/base64" "fmt" "path/filepath" "strings" giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" ) // 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 { log := logging.FromContext(ctx).WithService("component") log.Warn("failed to read Procfile", logging.FieldError, 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) } } log := logging.FromContext(ctx).WithService("component") log.Info("component removed", logging.FieldProjectID, projectID, "path", componentPath, "note", "Component files remain in repo - delete manually if needed", ) return nil }