package kubernetes import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "os/exec" "strings" "github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/sdlc" ) // SDLCExecutor runs sdlc CLI commands inside pods via kubectl exec. type SDLCExecutor struct { namespace string logger *slog.Logger } // SDLCExecutorConfig configures the SDLC executor. type SDLCExecutorConfig struct { Namespace string Logger *slog.Logger } // NewSDLCExecutor creates a new SDLC executor. func NewSDLCExecutor(cfg SDLCExecutorConfig) *SDLCExecutor { logger := cfg.Logger if logger == nil { logger = slog.Default() } return &SDLCExecutor{ namespace: cfg.Namespace, logger: logger.With("component", "sdlc-executor"), } } // execSDLC runs a sdlc CLI command in the pod and returns stdout bytes. // All commands include --json for machine-readable output. func (e *SDLCExecutor) execSDLC(ctx context.Context, podName string, args ...string) ([]byte, error) { kubectlArgs := []string{"exec", "-n", e.namespace, podName, "--", "sdlc"} kubectlArgs = append(kubectlArgs, args...) kubectlArgs = append(kubectlArgs, "--json") cmd := exec.CommandContext(ctx, "kubectl", kubectlArgs...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, e.mapExecError(stderr.String(), err) } return stdout.Bytes(), nil } // execSDLCNoOutput runs a sdlc CLI command that produces no meaningful stdout. func (e *SDLCExecutor) execSDLCNoOutput(ctx context.Context, podName string, args ...string) error { _, err := e.execSDLC(ctx, podName, args...) return err } // mapExecError maps stderr text from the sdlc CLI to sentinel errors. func (e *SDLCExecutor) mapExecError(stderr string, execErr error) error { stderr = strings.TrimSpace(stderr) switch { case strings.Contains(stderr, "sdlc not initialized"): return sdlc.ErrNotInitialized case strings.Contains(stderr, "feature not found"): return sdlc.ErrFeatureNotFound case strings.Contains(stderr, "feature already exists"): return sdlc.ErrFeatureExists case strings.Contains(stderr, "invalid phase transition"): return sdlc.ErrInvalidTransition case strings.Contains(stderr, "invalid phase"): return sdlc.ErrInvalidPhase case strings.Contains(stderr, "task not found"): return sdlc.ErrTaskNotFound case strings.Contains(stderr, "artifact not found"): return sdlc.ErrArtifactNotFound case strings.Contains(stderr, "invalid slug"): return sdlc.ErrInvalidSlug case strings.Contains(stderr, "invalid artifact"): return sdlc.ErrInvalidArtifact case strings.Contains(stderr, "branch already exists"): return sdlc.ErrBranchExists case strings.Contains(stderr, "branch not found"): return sdlc.ErrBranchNotFound case strings.Contains(stderr, "not ready to merge"): return sdlc.ErrMergeNotReady default: if stderr != "" { return fmt.Errorf("sdlc exec: %s: %w", stderr, execErr) } return fmt.Errorf("sdlc exec: %w", execErr) } } // GetState returns the global SDLC state for a project pod. func (e *SDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) { out, err := e.execSDLC(ctx, podName, "state") if err != nil { return nil, err } var state sdlc.State if err := json.Unmarshal(out, &state); err != nil { return nil, fmt.Errorf("parse sdlc state: %w", err) } return &state, nil } // GetNext returns the classifier's recommendation for the next action. func (e *SDLCExecutor) GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error) { args := []string{"next"} if feature != "" { args = append(args, "--feature", feature) } out, err := e.execSDLC(ctx, podName, args...) if err != nil { return nil, err } var cl sdlc.Classification if err := json.Unmarshal(out, &cl); err != nil { return nil, fmt.Errorf("parse sdlc classification: %w", err) } return &cl, nil } // ListFeatures returns all features in the project. func (e *SDLCExecutor) ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error) { out, err := e.execSDLC(ctx, podName, "feature", "list") if err != nil { return nil, err } var features []*sdlc.Feature if err := json.Unmarshal(out, &features); err != nil { return nil, fmt.Errorf("parse sdlc features: %w", err) } return features, nil } // GetFeature returns a single feature by slug. func (e *SDLCExecutor) GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error) { out, err := e.execSDLC(ctx, podName, "feature", "show", slug) if err != nil { return nil, err } var f sdlc.Feature if err := json.Unmarshal(out, &f); err != nil { return nil, fmt.Errorf("parse sdlc feature: %w", err) } return &f, nil } // CreateFeature creates a new feature with the given slug and title. func (e *SDLCExecutor) CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error) { out, err := e.execSDLC(ctx, podName, "feature", "create", slug, "--title", title) if err != nil { return nil, err } var f sdlc.Feature if err := json.Unmarshal(out, &f); err != nil { return nil, fmt.Errorf("parse sdlc feature: %w", err) } return &f, nil } // TransitionFeature moves a feature to the specified phase. func (e *SDLCExecutor) TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error { return e.execSDLCNoOutput(ctx, podName, "feature", "transition", slug, string(phase)) } // BlockFeature adds a blocker reason to a feature. func (e *SDLCExecutor) BlockFeature(ctx context.Context, podName, slug, reason string) error { return e.execSDLCNoOutput(ctx, podName, "feature", "block", slug, "--reason", reason) } // UnblockFeature removes all blockers from a feature. func (e *SDLCExecutor) UnblockFeature(ctx context.Context, podName, slug string) error { return e.execSDLCNoOutput(ctx, podName, "feature", "unblock", slug) } // DeleteFeature removes a feature entirely. func (e *SDLCExecutor) DeleteFeature(ctx context.Context, podName, slug string) error { return e.execSDLCNoOutput(ctx, podName, "feature", "delete", slug, "--force") } // GetArtifactStatus returns artifact statuses for a feature. func (e *SDLCExecutor) GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) { out, err := e.execSDLC(ctx, podName, "artifact", "status", slug) if err != nil { return nil, err } var artifacts map[sdlc.ArtifactType]*sdlc.Artifact if err := json.Unmarshal(out, &artifacts); err != nil { return nil, fmt.Errorf("parse sdlc artifacts: %w", err) } return artifacts, nil } // ApproveArtifact approves a feature artifact. func (e *SDLCExecutor) ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error { return e.execSDLCNoOutput(ctx, podName, "artifact", "approve", slug, string(artType)) } // RejectArtifact rejects a feature artifact. func (e *SDLCExecutor) RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error { return e.execSDLCNoOutput(ctx, podName, "artifact", "reject", slug, string(artType)) } // PassArtifact marks a feature artifact as passed. func (e *SDLCExecutor) PassArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error { return e.execSDLCNoOutput(ctx, podName, "artifact", "pass", slug, string(artType)) } // FailArtifact marks a feature artifact as failed. func (e *SDLCExecutor) FailArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error { return e.execSDLCNoOutput(ctx, podName, "artifact", "fail", slug, string(artType)) } // NeedsFixArtifact marks a feature artifact as needing fixes. func (e *SDLCExecutor) NeedsFixArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error { return e.execSDLCNoOutput(ctx, podName, "artifact", "needs-fix", slug, string(artType)) } // ListTasks returns all tasks for a feature. func (e *SDLCExecutor) ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error) { out, err := e.execSDLC(ctx, podName, "task", "list", slug) if err != nil { return nil, err } var tasks []sdlc.Task if err := json.Unmarshal(out, &tasks); err != nil { return nil, fmt.Errorf("parse sdlc tasks: %w", err) } return tasks, nil } // AddTask adds a new task to a feature. func (e *SDLCExecutor) AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error) { out, err := e.execSDLC(ctx, podName, "task", "add", slug, "--title", title) if err != nil { return nil, err } var t sdlc.Task if err := json.Unmarshal(out, &t); err != nil { return nil, fmt.Errorf("parse sdlc task: %w", err) } return &t, nil } // StartTask marks a task as in-progress. func (e *SDLCExecutor) StartTask(ctx context.Context, podName, slug, taskID string) error { return e.execSDLCNoOutput(ctx, podName, "task", "start", slug, taskID) } // CompleteTask marks a task as complete. func (e *SDLCExecutor) CompleteTask(ctx context.Context, podName, slug, taskID string) error { return e.execSDLCNoOutput(ctx, podName, "task", "complete", slug, taskID) } // BlockTask marks a task as blocked. func (e *SDLCExecutor) BlockTask(ctx context.Context, podName, slug, taskID string) error { return e.execSDLCNoOutput(ctx, podName, "task", "block", slug, taskID) } // QueryBlocked returns all blocked features. func (e *SDLCExecutor) QueryBlocked(ctx context.Context, podName string) ([]port.BlockedInfo, error) { out, err := e.execSDLC(ctx, podName, "query", "blocked") if err != nil { return nil, err } var blocked []port.BlockedInfo if err := json.Unmarshal(out, &blocked); err != nil { return nil, fmt.Errorf("parse sdlc blocked query: %w", err) } return blocked, nil } // QueryReady returns features ready for work. func (e *SDLCExecutor) QueryReady(ctx context.Context, podName string) ([]port.ReadyInfo, error) { out, err := e.execSDLC(ctx, podName, "query", "ready") if err != nil { return nil, err } var ready []port.ReadyInfo if err := json.Unmarshal(out, &ready); err != nil { return nil, fmt.Errorf("parse sdlc ready query: %w", err) } return ready, nil } // QueryNeedsApproval returns features awaiting approval. func (e *SDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ([]port.ApprovalInfo, error) { out, err := e.execSDLC(ctx, podName, "query", "needs-approval") if err != nil { return nil, err } var pending []port.ApprovalInfo if err := json.Unmarshal(out, &pending); err != nil { return nil, fmt.Errorf("parse sdlc approval query: %w", err) } return pending, nil } // CreateBranch creates a feature branch and its manifest. func (e *SDLCExecutor) CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) { out, err := e.execSDLC(ctx, podName, "branch", "create", slug) if err != nil { return nil, err } var manifest sdlc.BranchManifest if err := json.Unmarshal(out, &manifest); err != nil { return nil, fmt.Errorf("parse sdlc branch manifest: %w", err) } return &manifest, nil } // GetBranchStatus returns the full branch status including checklist. func (e *SDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*port.BranchStatus, error) { out, err := e.execSDLC(ctx, podName, "branch", "status", slug) if err != nil { return nil, err } var result port.BranchStatus if err := json.Unmarshal(out, &result); err != nil { return nil, fmt.Errorf("parse sdlc branch status: %w", err) } return &result, nil } // SyncBranch syncs a feature branch with its base branch. func (e *SDLCExecutor) SyncBranch(ctx context.Context, podName, slug string) error { return e.execSDLCNoOutput(ctx, podName, "branch", "sync", slug) } // MergeFeature merges a feature branch after all gates pass. func (e *SDLCExecutor) MergeFeature(ctx context.Context, podName, slug, strategy string) error { args := []string{"merge", slug} if strategy != "" { args = append(args, "--strategy", strategy) } return e.execSDLCNoOutput(ctx, podName, args...) } // ArchiveFeature archives a released feature. func (e *SDLCExecutor) ArchiveFeature(ctx context.Context, podName, slug string) error { return e.execSDLCNoOutput(ctx, podName, "archive", slug) } // Compile-time interface check. var _ port.SDLCExecutor = (*SDLCExecutor)(nil)