- Add auth.RequireScope() to all handler routes for proper authorization - Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator) - Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog) - Add artifact_test.go for SDLC artifact coverage - Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w - Fix error wrapping to use %w instead of %v throughout codebase - Improve CLI merge command with conflict detection and resolution - Fix handler tests to include auth middleware for RequireScope - Add cookbook tree runner scripts for automated testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
340 lines
11 KiB
Go
340 lines
11 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))
|
|
}
|
|
|
|
// 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)
|