rdev/internal/service/component_listing.go
jordan d69da6d627 feat: add structured logging infrastructure and SDLC extensions
Major changes:
- Add internal/logging package with field constants, context propagation,
  sensitive data auto-redaction, and per-component log levels
- Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.)
- Extend SDLC with callback handlers, generate endpoints, and executor
- Add new cookbook trees for aeries and slackpath progression
- Add skeleton templates for queue, realtime, and microservices
- Add worker component template with async job processing
- Refactor services and handlers to use new logging infrastructure
- Split component.go into component_infra.go and component_listing.go

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 22:56:04 -07:00

216 lines
6.8 KiB
Go

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
}