rdev/internal/adapter/kubernetes/sdlc_executor.go
jordan 6e8f5821af feat: add artifact pass/fail/needs-fix lifecycle for SDLC execution phases
- Add pass/fail/needs-fix CLI commands to cmd/sdlc/cmd_artifact.go
- Add 3 new methods to SDLCExecutor interface in internal/port
- Implement methods in kubernetes adapter
- Add service methods to SDLCService
- Add HTTP handlers for POST .../artifacts/{type}/pass|fail|needs-fix
- Update 6 skeleton commands to evaluate and set artifact status
- Update test mocks

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

355 lines
12 KiB
Go

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)