rdev/internal/service/sdlc_service.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

362 lines
12 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
}
// PassArtifact marks a feature artifact as passed.
func (s *SDLCService) PassArtifact(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.PassArtifact(ctx, podName, slug, artType); err != nil {
return err
}
s.logger.Info("artifact passed", "project", projectID, "feature", slug, "artifact", string(artType))
return nil
}
// FailArtifact marks a feature artifact as failed.
func (s *SDLCService) FailArtifact(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.FailArtifact(ctx, podName, slug, artType); err != nil {
return err
}
s.logger.Info("artifact failed", "project", projectID, "feature", slug, "artifact", string(artType))
return nil
}
// NeedsFixArtifact marks a feature artifact as needing fixes.
func (s *SDLCService) NeedsFixArtifact(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.NeedsFixArtifact(ctx, podName, slug, artType); err != nil {
return err
}
s.logger.Info("artifact needs fix", "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)
}
// CreateBranch creates a feature branch and its manifest.
func (s *SDLCService) CreateBranch(ctx context.Context, projectID, slug string) (*sdlc.BranchManifest, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
m, err := s.sdlcExec.CreateBranch(ctx, podName, slug)
if err != nil {
return nil, err
}
s.logger.Info("branch created", "project", projectID, "feature", slug, "branch", m.Name)
return m, nil
}
// GetBranchStatus returns the full branch status including checklist.
func (s *SDLCService) GetBranchStatus(ctx context.Context, projectID, slug string) (*port.BranchStatus, error) {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return nil, err
}
return s.sdlcExec.GetBranchStatus(ctx, podName, slug)
}
// SyncBranch syncs a feature branch with its base branch.
func (s *SDLCService) SyncBranch(ctx context.Context, projectID, slug string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.SyncBranch(ctx, podName, slug); err != nil {
return err
}
s.logger.Info("branch synced", "project", projectID, "feature", slug)
return nil
}
// MergeFeature merges a feature branch after all gates pass.
func (s *SDLCService) MergeFeature(ctx context.Context, projectID, slug, strategy string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.MergeFeature(ctx, podName, slug, strategy); err != nil {
s.logger.Error("merge feature failed", "project", projectID, "feature", slug, "error", err)
return err
}
s.logger.Info("feature merged", "project", projectID, "feature", slug, "strategy", strategy)
return nil
}
// ArchiveFeature archives a released feature.
func (s *SDLCService) ArchiveFeature(ctx context.Context, projectID, slug string) error {
podName, err := s.resolveProjectPod(ctx, projectID)
if err != nil {
return err
}
if err := s.sdlcExec.ArchiveFeature(ctx, podName, slug); err != nil {
return err
}
s.logger.Info("feature archived", "project", projectID, "feature", slug)
return nil
}