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>
216 lines
6.8 KiB
Go
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
|
|
}
|