Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.
Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence
CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods
API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval
Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)
Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
260 lines
8.2 KiB
Go
260 lines
8.2 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/sdlc"
|
|
)
|
|
|
|
// SDLCService provides SDLC operations for projects.
|
|
// It resolves project IDs to pod names and delegates to the SDLCExecutor.
|
|
type SDLCService struct {
|
|
sdlcExec port.SDLCExecutor
|
|
projectRepo port.ProjectRepository
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// SDLCServiceConfig configures the SDLC service.
|
|
type SDLCServiceConfig struct {
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewSDLCService creates a new SDLC service.
|
|
func NewSDLCService(sdlcExec port.SDLCExecutor, projectRepo port.ProjectRepository, cfg SDLCServiceConfig) *SDLCService {
|
|
logger := cfg.Logger
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
return &SDLCService{
|
|
sdlcExec: sdlcExec,
|
|
projectRepo: projectRepo,
|
|
logger: logger.With("component", "sdlc-service"),
|
|
}
|
|
}
|
|
|
|
// resolveProjectPod looks up a project and returns its pod name.
|
|
func (s *SDLCService) resolveProjectPod(ctx context.Context, projectID string) (string, error) {
|
|
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
|
|
if err != nil {
|
|
return "", domain.ErrProjectNotFound
|
|
}
|
|
if project.PodName == "" {
|
|
return "", fmt.Errorf("project %s has no pod", projectID)
|
|
}
|
|
return project.PodName, nil
|
|
}
|
|
|
|
// GetState returns the global SDLC state for a project.
|
|
func (s *SDLCService) GetState(ctx context.Context, projectID string) (*sdlc.State, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.GetState(ctx, podName)
|
|
}
|
|
|
|
// GetNext returns the classifier's recommendation for the next action.
|
|
func (s *SDLCService) GetNext(ctx context.Context, projectID, feature string) (*sdlc.Classification, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.GetNext(ctx, podName, feature)
|
|
}
|
|
|
|
// ListFeatures returns all features in a project.
|
|
func (s *SDLCService) ListFeatures(ctx context.Context, projectID string) ([]*sdlc.Feature, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.ListFeatures(ctx, podName)
|
|
}
|
|
|
|
// GetFeature returns a single feature by slug.
|
|
func (s *SDLCService) GetFeature(ctx context.Context, projectID, slug string) (*sdlc.Feature, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.GetFeature(ctx, podName, slug)
|
|
}
|
|
|
|
// CreateFeature creates a new feature.
|
|
func (s *SDLCService) CreateFeature(ctx context.Context, projectID, slug, title string) (*sdlc.Feature, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := s.sdlcExec.CreateFeature(ctx, podName, slug, title)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.logger.Info("feature created", "project", projectID, "feature", slug)
|
|
return f, nil
|
|
}
|
|
|
|
// TransitionFeature moves a feature to the specified phase.
|
|
func (s *SDLCService) TransitionFeature(ctx context.Context, projectID, slug string, phase sdlc.FeaturePhase) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sdlcExec.TransitionFeature(ctx, podName, slug, phase); err != nil {
|
|
s.logger.Error("transition feature failed", "project", projectID, "feature", slug, "phase", string(phase), "error", err)
|
|
return err
|
|
}
|
|
s.logger.Info("feature transitioned", "project", projectID, "feature", slug, "phase", string(phase))
|
|
return nil
|
|
}
|
|
|
|
// BlockFeature adds a blocker reason to a feature.
|
|
func (s *SDLCService) BlockFeature(ctx context.Context, projectID, slug, reason string) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sdlcExec.BlockFeature(ctx, podName, slug, reason); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("feature blocked", "project", projectID, "feature", slug, "reason", reason)
|
|
return nil
|
|
}
|
|
|
|
// UnblockFeature removes all blockers from a feature.
|
|
func (s *SDLCService) UnblockFeature(ctx context.Context, projectID, slug string) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sdlcExec.UnblockFeature(ctx, podName, slug); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("feature unblocked", "project", projectID, "feature", slug)
|
|
return nil
|
|
}
|
|
|
|
// DeleteFeature removes a feature entirely.
|
|
func (s *SDLCService) DeleteFeature(ctx context.Context, projectID, slug string) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sdlcExec.DeleteFeature(ctx, podName, slug); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("feature deleted", "project", projectID, "feature", slug)
|
|
return nil
|
|
}
|
|
|
|
// GetArtifactStatus returns artifact statuses for a feature.
|
|
func (s *SDLCService) GetArtifactStatus(ctx context.Context, projectID, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.GetArtifactStatus(ctx, podName, slug)
|
|
}
|
|
|
|
// ApproveArtifact approves a feature artifact.
|
|
func (s *SDLCService) ApproveArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sdlcExec.ApproveArtifact(ctx, podName, slug, artType); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("artifact approved", "project", projectID, "feature", slug, "artifact", string(artType))
|
|
return nil
|
|
}
|
|
|
|
// RejectArtifact rejects a feature artifact.
|
|
func (s *SDLCService) RejectArtifact(ctx context.Context, projectID, slug string, artType sdlc.ArtifactType) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.sdlcExec.RejectArtifact(ctx, podName, slug, artType); err != nil {
|
|
return err
|
|
}
|
|
s.logger.Info("artifact rejected", "project", projectID, "feature", slug, "artifact", string(artType))
|
|
return nil
|
|
}
|
|
|
|
// ListTasks returns all tasks for a feature.
|
|
func (s *SDLCService) ListTasks(ctx context.Context, projectID, slug string) ([]sdlc.Task, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.ListTasks(ctx, podName, slug)
|
|
}
|
|
|
|
// AddTask adds a new task to a feature.
|
|
func (s *SDLCService) AddTask(ctx context.Context, projectID, slug, title string) (*sdlc.Task, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.AddTask(ctx, podName, slug, title)
|
|
}
|
|
|
|
// StartTask marks a task as in-progress.
|
|
func (s *SDLCService) StartTask(ctx context.Context, projectID, slug, taskID string) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sdlcExec.StartTask(ctx, podName, slug, taskID)
|
|
}
|
|
|
|
// CompleteTask marks a task as complete.
|
|
func (s *SDLCService) CompleteTask(ctx context.Context, projectID, slug, taskID string) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sdlcExec.CompleteTask(ctx, podName, slug, taskID)
|
|
}
|
|
|
|
// BlockTask marks a task as blocked.
|
|
func (s *SDLCService) BlockTask(ctx context.Context, projectID, slug, taskID string) error {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sdlcExec.BlockTask(ctx, podName, slug, taskID)
|
|
}
|
|
|
|
// QueryBlocked returns all blocked features in a project.
|
|
func (s *SDLCService) QueryBlocked(ctx context.Context, projectID string) ([]port.BlockedInfo, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.QueryBlocked(ctx, podName)
|
|
}
|
|
|
|
// QueryReady returns features ready for work in a project.
|
|
func (s *SDLCService) QueryReady(ctx context.Context, projectID string) ([]port.ReadyInfo, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.QueryReady(ctx, podName)
|
|
}
|
|
|
|
// QueryNeedsApproval returns features awaiting approval in a project.
|
|
func (s *SDLCService) QueryNeedsApproval(ctx context.Context, projectID string) ([]port.ApprovalInfo, error) {
|
|
podName, err := s.resolveProjectPod(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.sdlcExec.QueryNeedsApproval(ctx, podName)
|
|
}
|