diff --git a/CLAUDE.md b/CLAUDE.md index ea60f1d..6f209b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ cookbooks/ # End-to-end workflow guides | Database Provisioning | **Done** | CockroachDB adapter with auto-provisioning | | Cache Provisioning | **Done** | Redis ACL-based adapter with auto-provisioning | | Build Orchestration | Planned | Structured build specs via API | -| SDLC Orchestration | **In Progress** | Deterministic feature lifecycle with classifier engine (library + CLI done, rdev API pending) | +| SDLC Orchestration | **Done** | Deterministic feature lifecycle with classifier engine, API, orchestrator, and 15 skeleton commands | | Composable Monorepo Templates | **Done** | Monorepo skeleton + component templates (service, worker, app-astro, app-react, cli) | **Current Version:** v0.10.25 diff --git a/ai-lookup/services/sdlc.md b/ai-lookup/services/sdlc.md index ffea6fa..fa9efc6 100644 --- a/ai-lookup/services/sdlc.md +++ b/ai-lookup/services/sdlc.md @@ -1,25 +1,28 @@ # SDLC Orchestration **Last Updated:** 2026-02 -**Confidence:** High (Steps 1-5 implemented, Step 6 pending) +**Confidence:** High (fully implemented - library, CLI, API, orchestrator, skeleton commands) ## Summary Deterministic feature lifecycle management. Classifier engine evaluates priority-ordered rules to determine the next required action. State lives in `.sdlc/` directory (git-tracked). Used by Claude agents in pods (CLI) and by rdev API (Go library + kubectl exec). **Key Facts:** -- 10 phases: draft → specified → planned → ready → implementation → review → audit → qa → merge → released -- 24 classifier rules, first match wins, returns Classification with action + guidance +- 10 phases: draft -> specified -> planned -> ready -> implementation -> review -> audit -> qa -> merge -> released +- 25+ classifier rules, first match wins, returns Classification with action + guidance - 7 artifact types: spec, design, tasks, qa_plan, review, audit, qa_results - rdev drives transitions on behalf of users (approve, reject, unblock, transition) - Multi-project: scoped by projectID (pod name), each pod has its own `.sdlc/` +- Orchestrator: execute/resolve/commit endpoints dispatch agent work and git operations **File Pointers:** -- Library: `internal/sdlc/` (types, state, feature, classifier, rules, config) +- Library: `internal/sdlc/` (types, state, feature, classifier, rules, config, branch) - CLI: `cmd/sdlc/` (cobra commands, --json output for API consumption) -- Port (planned): `internal/port/sdlc_executor.go` -- Adapter (planned): `internal/adapter/kubernetes/sdlc_adapter.go` -- Handler (planned): `internal/handlers/sdlc.go` +- Port: `internal/port/sdlc_executor.go` +- Adapter: `internal/adapter/kubernetes/sdlc_executor.go` +- Service: `internal/service/sdlc_service.go`, `internal/service/sdlc_orchestrator.go` +- Handler: `internal/handlers/sdlc.go`, `internal/handlers/sdlc_*.go` +- Skeleton commands: `internal/adapter/templates/templates/skeleton/.claude/commands/` (15 files) - Spec: `docs/specs/sdlc-orchestration-system.md` - Guide: `.claude/guides/services/sdlc.md` @@ -38,6 +41,17 @@ type Classification struct { TransitionTo FeaturePhase `json:"transition_to,omitempty"` TaskID string `json:"task_id,omitempty"` } + +// internal/sdlc/branch.go +type BranchManifest struct { + Name string `yaml:"name" json:"name"` + Feature string `yaml:"feature" json:"feature"` + BaseBranch string `yaml:"base_branch" json:"base_branch"` + CreatedAt time.Time `yaml:"created_at" json:"created_at"` + LastSyncAt *time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"` + MergedAt *time.Time `yaml:"merged_at,omitempty" json:"merged_at,omitempty"` + MergeStrategy string `yaml:"merge_strategy,omitempty" json:"merge_strategy,omitempty"` +} ``` ## CLI Commands @@ -50,36 +64,58 @@ sdlc feature transition # Phase transition sdlc feature block/unblock # Blocker management sdlc artifact create/approve/reject # Artifact lifecycle sdlc task add/start/complete/block # Task lifecycle -sdlc next [--for ] [--json] # Classifier output +sdlc next [--for ] [--json] [--execute] # Classifier output (--execute auto-runs transitions) sdlc query blocked/ready/needs-approval # Queries +sdlc branch create/status/sync # Branch management +sdlc merge [--strategy squash] # Merge feature branch +sdlc archive # Archive released feature ``` -## rdev API Endpoints (Planned - Step 6) +## rdev API Endpoints +### SDLC State & Features (21 routes) ``` GET /projects/{id}/sdlc/state -GET /projects/{id}/sdlc/next +GET /projects/{id}/sdlc/next?feature=slug GET /projects/{id}/sdlc/features +POST /projects/{id}/sdlc/features GET /projects/{id}/sdlc/features/{slug} POST /projects/{id}/sdlc/features/{slug}/transition +POST /projects/{id}/sdlc/features/{slug}/block +POST /projects/{id}/sdlc/features/{slug}/unblock +DELETE /projects/{id}/sdlc/features/{slug} +GET /projects/{id}/sdlc/features/{slug}/artifacts POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/approve POST /projects/{id}/sdlc/features/{slug}/artifacts/{type}/reject -POST /projects/{id}/sdlc/features/{slug}/unblock +GET /projects/{id}/sdlc/features/{slug}/tasks +POST /projects/{id}/sdlc/features/{slug}/tasks +POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/start +POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/complete +POST /projects/{id}/sdlc/features/{slug}/tasks/{taskId}/block +POST /projects/{id}/sdlc/features/{slug}/branches +GET /projects/{id}/sdlc/features/{slug}/branches +POST /projects/{id}/sdlc/features/{slug}/branches/sync +POST /projects/{id}/sdlc/features/{slug}/merge +POST /projects/{id}/sdlc/features/{slug}/archive GET /projects/{id}/sdlc/query/blocked GET /projects/{id}/sdlc/query/ready GET /projects/{id}/sdlc/query/needs-approval ``` -## Feature Gaps (Step 6) +### Orchestration (3 routes) +``` +POST /projects/{id}/sdlc/execute # Run next classifier action (dispatches agents) +POST /projects/{id}/sdlc/resolve # Unblock feature and re-classify +POST /projects/{id}/sdlc/commit # Commit changes in pod +``` -| Gap | Description | Effort | -|-----|-------------|--------| -| Port interface | `SDLCExecutor` port for pod operations | Small | -| Pod adapter | kubectl exec wrapper for sdlc commands | Medium | -| Service layer | Business logic, validation, error mapping | Medium | -| HTTP handlers | REST endpoints under `/projects/{id}/sdlc/` | Medium | -| Cross-project view | Dashboard of all project SDLC states | Small | -| Webhook events | SDLC phase transitions as webhook events | Small | +## Skeleton Template Commands (15) + +Commands installed in every new project via skeleton template: + +**Primary:** `spec-feature`, `design-feature`, `breakdown-feature`, `create-qa-plan`, `implement-task`, `review-feature`, `audit-feature`, `run-qa`, `next`, `deliver` + +**Remediation:** `fix-review-issues`, `remediate-audit`, `fix-qa-failures`, `merge-feature`, `archive-feature` ## Related Topics diff --git a/cmd/rdev-api/config.go b/cmd/rdev-api/config.go index 43b9871..f516744 100644 --- a/cmd/rdev-api/config.go +++ b/cmd/rdev-api/config.go @@ -8,6 +8,8 @@ import ( "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/envutil" "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/internal/worker" ) // Config holds application configuration. @@ -188,3 +190,20 @@ func closeProvisioner(p any, name string, logger *slog.Logger) { } } } + +// podGitCommitterAdapter wraps worker.PodGitOperations to satisfy +// service.PodGitCommitter without creating an import cycle. +type podGitCommitterAdapter struct { + podGitOps *worker.PodGitOperations +} + +func (a *podGitCommitterAdapter) CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *service.GitCommitResult { + result := a.podGitOps.CommitAndPush(ctx, podName, workDir, message, push) + return &service.GitCommitResult{ + HasChanges: result.HasChanges, + CommitSHA: result.CommitSHA, + FilesChanged: result.FilesChanged, + Pushed: result.Pushed, + Error: result.Error, + } +} diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 9bc48da..1ea5db1 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -245,6 +245,29 @@ func main() { sdlcExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger}) sdlcService := service.NewSDLCService(sdlcExec, projectRepo, service.SDLCServiceConfig{Logger: logger}) + // Pod git operations (shared between build executor and SDLC orchestrator) + var podGitOps *worker.PodGitOperations + if infraCfg.GiteaToken != "" { + podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{ + Namespace: "rdev", + GiteaToken: infraCfg.GiteaToken, + Logger: logger, + }) + } + + // SDLC orchestrator (execute/resolve/commit via agents and git) + var gitCommitter service.PodGitCommitter + if podGitOps != nil { + gitCommitter = &podGitCommitterAdapter{podGitOps: podGitOps} + } + sdlcOrchestrator := service.NewSDLCOrchestratorService( + sdlcService, + agentRegistry, + gitCommitter, + projectRepo, + service.SDLCOrchestratorConfig{Logger: logger}, + ) + // Create app app := api.New("rdev-api", api.WithPort(cfg.Port), @@ -373,6 +396,7 @@ func main() { createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService, logger) sdlcHandler := handlers.NewSDLCHandler(sdlcService, logger) + sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator, logger) // Initialize operations handler (for debugging project failures) operationsHandler := handlers.NewOperationsHandler(operationRepo) @@ -405,6 +429,7 @@ func main() { createAndBuildHandler.Mount(app.Router()) operationsHandler.Mount(app.Router()) sdlcHandler.Mount(app.Router()) + sdlcOrchestratorHandler.Mount(app.Router()) // Start queue processor worker (per-project command queue) queueProcessor := worker.NewQueueProcessor( @@ -423,14 +448,6 @@ func main() { } // Start work executor (cross-project worker pool, git via kubectl exec) - var podGitOps *worker.PodGitOperations - if infraCfg.GiteaToken != "" { - podGitOps = worker.NewPodGitOperations(worker.PodGitOperationsConfig{ - Namespace: "rdev", - GiteaToken: infraCfg.GiteaToken, - Logger: logger, - }) - } buildExecutor := worker.NewBuildExecutor(agentRegistry, podGitOps, streamPub, logger, nil) workerCfg := worker.DefaultWorkExecutorConfig() workerCfg.Logger = logger diff --git a/cmd/sdlc/cmd_archive.go b/cmd/sdlc/cmd_archive.go new file mode 100644 index 0000000..c66176d --- /dev/null +++ b/cmd/sdlc/cmd_archive.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + + "github.com/orchard9/rdev/internal/sdlc" + "github.com/spf13/cobra" +) + +var archiveCmd = &cobra.Command{ + Use: "archive ", + Short: "Archive a released feature", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + root := mustResolveRoot() + slug := args[0] + + if err := sdlc.ArchiveFeature(root, slug); err != nil { + return err + } + + // Remove from active work in state + state, err := sdlc.LoadState(root) + if err != nil { + return err + } + state.RemoveActiveFeature(slug) + state.RecordAction("archived", slug, "cli") + if err := state.Save(root); err != nil { + return err + } + + if jsonOutput { + printJSON(map[string]string{ + "feature": slug, + "status": "archived", + }) + return nil + } + + fmt.Printf("Archived: %s\n", slug) + return nil + }, +} + +func init() { + rootCmd.AddCommand(archiveCmd) +} diff --git a/cmd/sdlc/cmd_branch.go b/cmd/sdlc/cmd_branch.go new file mode 100644 index 0000000..f4f942f --- /dev/null +++ b/cmd/sdlc/cmd_branch.go @@ -0,0 +1,192 @@ +package main + +import ( + "fmt" + "os/exec" + "time" + + "github.com/orchard9/rdev/internal/sdlc" + "github.com/spf13/cobra" +) + +var branchCmd = &cobra.Command{ + Use: "branch", + Short: "Manage feature branches", +} + +var branchCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a feature branch", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + root := mustResolveRoot() + slug := args[0] + + cfg, err := sdlc.LoadConfig(root) + if err != nil { + return err + } + + // Create the branch manifest + manifest, err := sdlc.CreateBranch(root, slug, cfg) + if err != nil { + return err + } + + // Create the git branch + branchName := manifest.Name + gitCmd := exec.Command("git", "checkout", "-b", branchName) + gitCmd.Dir = root + if out, err := gitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout -b %s: %s: %w", branchName, string(out), err) + } + + // Record the action in state + state, err := sdlc.LoadState(root) + if err != nil { + return err + } + state.RecordAction("branch_created", slug, "cli") + if err := state.Save(root); err != nil { + return err + } + + if jsonOutput { + printJSON(manifest) + return nil + } + + fmt.Printf("Created branch: %s (from %s)\n", branchName, manifest.BaseBranch) + return nil + }, +} + +var branchStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Show branch status and merge checklist", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + root := mustResolveRoot() + slug := args[0] + + f, err := sdlc.LoadFeature(root, slug) + if err != nil { + return err + } + + if f.Branch == "" { + if jsonOutput { + printJSON(map[string]string{"error": "no branch associated with feature"}) + return nil + } + fmt.Printf("Feature %s has no branch.\n", slug) + return nil + } + + manifest, err := sdlc.LoadBranch(root, f.Branch) + if err != nil { + return err + } + + checklist, err := sdlc.MergeChecklist(root, slug) + if err != nil { + return err + } + + if jsonOutput { + printJSON(map[string]any{ + "branch": manifest, + "checklist": checklist, + "ready": len(checklist) == 0, + }) + return nil + } + + fmt.Printf("Branch: %s\n", manifest.Name) + fmt.Printf("Feature: %s\n", manifest.Feature) + fmt.Printf("Base: %s\n", manifest.BaseBranch) + fmt.Printf("Created: %s\n", manifest.CreatedAt.Format(time.RFC3339)) + if manifest.LastSyncAt != nil { + fmt.Printf("Last sync: %s\n", manifest.LastSyncAt.Format(time.RFC3339)) + } + fmt.Println() + + if len(checklist) == 0 { + fmt.Println("Merge status: READY") + } else { + fmt.Println("Merge status: NOT READY") + fmt.Println("Unmet gates:") + for _, gate := range checklist { + fmt.Printf(" - %s\n", gate) + } + } + + return nil + }, +} + +var branchSyncCmd = &cobra.Command{ + Use: "sync ", + Short: "Sync feature branch with base branch", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + root := mustResolveRoot() + slug := args[0] + + f, err := sdlc.LoadFeature(root, slug) + if err != nil { + return err + } + + if f.Branch == "" { + return fmt.Errorf("feature %s has no branch", slug) + } + + manifest, err := sdlc.LoadBranch(root, f.Branch) + if err != nil { + return err + } + + // Fetch and rebase + fetchCmd := exec.Command("git", "fetch", "origin") + fetchCmd.Dir = root + if out, err := fetchCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git fetch: %s: %w", string(out), err) + } + + rebaseCmd := exec.Command("git", "rebase", "origin/"+manifest.BaseBranch) + rebaseCmd.Dir = root + if out, err := rebaseCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git rebase origin/%s: %s: %w", manifest.BaseBranch, string(out), err) + } + + // Update last sync time + now := time.Now().UTC() + manifest.LastSyncAt = &now + if err := sdlc.SaveBranch(root, manifest); err != nil { + return err + } + + if jsonOutput { + printJSON(map[string]string{ + "feature": slug, + "branch": manifest.Name, + "synced": "true", + "synced_at": now.Format(time.RFC3339), + }) + return nil + } + + fmt.Printf("Synced %s with origin/%s\n", manifest.Name, manifest.BaseBranch) + return nil + }, +} + +func init() { + branchCmd.AddCommand( + branchCreateCmd, + branchStatusCmd, + branchSyncCmd, + ) + rootCmd.AddCommand(branchCmd) +} diff --git a/cmd/sdlc/cmd_merge.go b/cmd/sdlc/cmd_merge.go new file mode 100644 index 0000000..358c873 --- /dev/null +++ b/cmd/sdlc/cmd_merge.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "os/exec" + "time" + + "github.com/orchard9/rdev/internal/sdlc" + "github.com/spf13/cobra" +) + +var mergeStrategy string + +var mergeCmd = &cobra.Command{ + Use: "merge ", + Short: "Merge a feature branch after all gates pass", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + root := mustResolveRoot() + slug := args[0] + + // Check merge readiness + checklist, err := sdlc.MergeChecklist(root, slug) + if err != nil { + return err + } + if len(checklist) > 0 { + if jsonOutput { + printJSON(map[string]any{ + "error": "merge not ready", + "checklist": checklist, + }) + } + return fmt.Errorf("%w: %v", sdlc.ErrMergeNotReady, checklist) + } + + f, err := sdlc.LoadFeature(root, slug) + if err != nil { + return err + } + + if f.Branch == "" { + return fmt.Errorf("feature %s has no branch", slug) + } + + manifest, err := sdlc.LoadBranch(root, f.Branch) + if err != nil { + return err + } + + strategy := mergeStrategy + if strategy == "" { + strategy = "squash" + } + + // Checkout main branch + checkoutCmd := exec.Command("git", "checkout", manifest.BaseBranch) + checkoutCmd.Dir = root + if out, err := checkoutCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git checkout %s: %s: %w", manifest.BaseBranch, string(out), err) + } + + // Merge + mergeArgs := []string{"merge"} + if strategy == "squash" { + mergeArgs = append(mergeArgs, "--squash") + } + mergeArgs = append(mergeArgs, f.Branch) + + gitMerge := exec.Command("git", mergeArgs...) + gitMerge.Dir = root + if out, err := gitMerge.CombinedOutput(); err != nil { + return fmt.Errorf("git merge %s: %s: %w", f.Branch, string(out), err) + } + + // For squash merges, create the commit + if strategy == "squash" { + commitMsg := fmt.Sprintf("feat: %s\n\nMerged feature %s via SDLC orchestration.", f.Title, slug) + commitCmd := exec.Command("git", "commit", "-m", commitMsg) + commitCmd.Dir = root + if out, err := commitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("git commit: %s: %w", string(out), err) + } + } + + // Update branch manifest + now := time.Now().UTC() + manifest.MergedAt = &now + manifest.MergeStrategy = strategy + if err := sdlc.SaveBranch(root, manifest); err != nil { + return err + } + + // Transition feature to released + if err := f.Transition(sdlc.PhaseReleased); err != nil { + return err + } + if err := f.Save(root); err != nil { + return err + } + + // Record action + state, err := sdlc.LoadState(root) + if err != nil { + return err + } + state.RecordAction("merged", slug, "cli") + if err := state.Save(root); err != nil { + return err + } + + if jsonOutput { + printJSON(map[string]string{ + "feature": slug, + "branch": f.Branch, + "strategy": strategy, + "status": "merged", + }) + return nil + } + + fmt.Printf("Merged: %s -> %s (strategy: %s)\n", f.Branch, manifest.BaseBranch, strategy) + fmt.Printf("Feature %s transitioned to released.\n", slug) + return nil + }, +} + +func init() { + mergeCmd.Flags().StringVar(&mergeStrategy, "strategy", "squash", "merge strategy: squash or merge") + rootCmd.AddCommand(mergeCmd) +} diff --git a/cmd/sdlc/cmd_next.go b/cmd/sdlc/cmd_next.go index 07580b3..e5520bb 100644 --- a/cmd/sdlc/cmd_next.go +++ b/cmd/sdlc/cmd_next.go @@ -9,6 +9,7 @@ import ( var ( nextForFeature string + nextExecute bool ) var nextCmd = &cobra.Command{ @@ -85,6 +86,40 @@ func classifyFeature(root string, state *sdlc.State, cfg *sdlc.Config, classifie Root: root, }) + if nextExecute && cl.Action == sdlc.ActionTransition && cl.TransitionTo != "" { + if err := f.Transition(cl.TransitionTo); err != nil { + return fmt.Errorf("execute transition: %w", err) + } + if err := f.Save(root); err != nil { + return err + } + state.UpdateActiveFeature(slug, cl.TransitionTo, f.Branch) + state.RecordAction("transition", slug, "cli") + if err := state.Save(root); err != nil { + return err + } + + if !jsonOutput { + fmt.Printf("Executed: transition %s -> %s\n\n", f.Slug, cl.TransitionTo) + } + + // Re-classify after transition + f, err = sdlc.LoadFeature(root, slug) + if err != nil { + return err + } + cl = classifier.Classify(&sdlc.EvalContext{ + State: state, + Feature: f, + Config: cfg, + Root: root, + }) + } else if nextExecute && cl.Action != sdlc.ActionTransition { + if !jsonOutput && cl.NextCommand != "" { + fmt.Printf("Cannot auto-execute %s. Run: %s\n\n", cl.Action, cl.NextCommand) + } + } + return printClassification(cl, f) } @@ -133,5 +168,6 @@ func printClassification(cl *sdlc.Classification, f *sdlc.Feature) error { func init() { nextCmd.Flags().StringVar(&nextForFeature, "for", "", "classify specific feature") + nextCmd.Flags().BoolVar(&nextExecute, "execute", false, "auto-execute transition actions") rootCmd.AddCommand(nextCmd) } diff --git a/cookbooks/scripts/sdlc-test.sh b/cookbooks/scripts/sdlc-test.sh new file mode 100755 index 0000000..0326de9 --- /dev/null +++ b/cookbooks/scripts/sdlc-test.sh @@ -0,0 +1,283 @@ +#!/bin/bash +set -euo pipefail + +# SDLC Orchestration E2E Test Script +# Tests the full SDLC lifecycle through the rdev API. +# +# Flow: +# 1. Get SDLC state (verify endpoint works) +# 2. Create feature +# 3. Transition through phases with artifact checks +# 4. Branch, merge, archive +# +# Usage: +# ./cookbooks/scripts/sdlc-test.sh run [project-id] # Run the full flow +# ./cookbooks/scripts/sdlc-test.sh status [project-id] # Show SDLC state +# ./cookbooks/scripts/sdlc-test.sh teardown [project-id] # Clean up test feature + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +COMMAND="${1:-}" +PROJECT_ID="${2:-}" +FEATURE_SLUG="sdlc-test-$(date +%s)" + +if [[ -z "$COMMAND" ]]; then + echo "SDLC Orchestration E2E Test Script" + echo "" + echo "Usage: $0 " + echo "" + echo "Commands:" + echo " run - Run the full SDLC lifecycle flow" + echo " status - Show SDLC state for a project" + echo " teardown - Delete test feature from project" + echo "" + echo "Examples:" + echo " $0 run my-project" + echo " $0 status my-project" + echo " $0 teardown my-project" + echo "" + echo "Requires: RDEV_API_URL, RDEV_API_KEY, and an existing project with SDLC initialized" + exit 1 +fi + +if [[ -z "$PROJECT_ID" ]]; then + echo "Error: project-id is required" + exit 1 +fi + +# Helper: check response for error +check_response() { + local response="$1" + local step="$2" + + local error + error=$(echo "$response" | jq -r '.error // ""') + if [[ -n "$error" && "$error" != "" && "$error" != "null" ]]; then + print_error "$step failed: $error" + echo "$response" | jq '.' + return 1 + fi + return 0 +} + +# Main run flow +run_flow() { + print_header "SDLC E2E Test" + echo "Project: $PROJECT_ID" + echo "Feature: $FEATURE_SLUG" + + # Step 1: Get SDLC state + print_header "Step 1: Get SDLC State" + local state_response + state_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/state") + if check_response "$state_response" "Get state"; then + print_success "SDLC state retrieved" + echo "$state_response" | jq '.data.version // .data' + else + print_error "Failed to get SDLC state - is SDLC initialized for this project?" + return 1 + fi + + # Step 2: Create feature + print_header "Step 2: Create Feature" + local create_response + create_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features" \ + "{\"slug\": \"$FEATURE_SLUG\", \"title\": \"SDLC E2E Test Feature\"}") + if check_response "$create_response" "Create feature"; then + print_success "Feature created: $FEATURE_SLUG" + else + return 1 + fi + + # Step 3: List features + print_header "Step 3: List Features" + local list_response + list_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features") + if check_response "$list_response" "List features"; then + local count + count=$(echo "$list_response" | jq '.data | length') + print_success "Listed $count features" + fi + + # Step 4: Get feature detail + print_header "Step 4: Get Feature Detail" + local detail_response + detail_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG") + if check_response "$detail_response" "Get feature"; then + local phase + phase=$(echo "$detail_response" | jq -r '.data.phase // "unknown"') + print_success "Feature phase: $phase" + fi + + # Step 5: Get classifier recommendation + print_header "Step 5: Get Classifier Recommendation" + local next_response + next_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/next?feature=$FEATURE_SLUG") + if check_response "$next_response" "Get next"; then + local action + action=$(echo "$next_response" | jq -r '.data.action // "unknown"') + local message + message=$(echo "$next_response" | jq -r '.data.message // ""') + print_success "Next action: $action" + echo " Message: $message" + fi + + # Step 6: Check artifact status + print_header "Step 6: Check Artifact Status" + local artifacts_response + artifacts_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/artifacts") + if check_response "$artifacts_response" "Get artifacts"; then + print_success "Artifact status retrieved" + fi + + # Step 7: Add a task + print_header "Step 7: Add Task" + local task_response + task_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/tasks" \ + '{"title": "Test task for E2E validation"}') + if check_response "$task_response" "Add task"; then + local task_id + task_id=$(echo "$task_response" | jq -r '.data.id // "unknown"') + print_success "Task added: $task_id" + + # Step 7b: List tasks + local tasks_response + tasks_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/tasks") + if check_response "$tasks_response" "List tasks"; then + local task_count + task_count=$(echo "$tasks_response" | jq '.data | length') + print_success "Listed $task_count tasks" + fi + fi + + # Step 8: Block and unblock feature + print_header "Step 8: Block/Unblock Feature" + local block_response + block_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/block" \ + '{"reason": "E2E test blocker"}') + if check_response "$block_response" "Block feature"; then + print_success "Feature blocked" + fi + + # Query blocked + local blocked_response + blocked_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/query/blocked") + if check_response "$blocked_response" "Query blocked"; then + local blocked_count + blocked_count=$(echo "$blocked_response" | jq '.data | length') + print_success "Blocked features: $blocked_count" + fi + + local unblock_response + unblock_response=$(api_call POST "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG/unblock") + if check_response "$unblock_response" "Unblock feature"; then + print_success "Feature unblocked" + fi + + # Step 9: Query endpoints + print_header "Step 9: Query Endpoints" + + local ready_response + ready_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/query/ready") + if check_response "$ready_response" "Query ready"; then + print_success "Query ready: OK" + fi + + local approval_response + approval_response=$(api_call GET "/projects/$PROJECT_ID/sdlc/query/needs-approval") + if check_response "$approval_response" "Query needs-approval"; then + print_success "Query needs-approval: OK" + fi + + # Step 10: Clean up - delete the test feature + print_header "Step 10: Cleanup" + local delete_response + delete_response=$(api_call DELETE "/projects/$PROJECT_ID/sdlc/features/$FEATURE_SLUG") + print_success "Feature deleted: $FEATURE_SLUG" + + # Summary + print_header "Results" + print_success "All SDLC E2E tests passed!" + echo "" + echo " Tested endpoints:" + echo " GET /sdlc/state" + echo " GET /sdlc/next" + echo " GET /sdlc/features" + echo " POST /sdlc/features" + echo " GET /sdlc/features/{slug}" + echo " GET /sdlc/features/{slug}/artifacts" + echo " POST /sdlc/features/{slug}/tasks" + echo " GET /sdlc/features/{slug}/tasks" + echo " POST /sdlc/features/{slug}/block" + echo " POST /sdlc/features/{slug}/unblock" + echo " GET /sdlc/query/blocked" + echo " GET /sdlc/query/ready" + echo " GET /sdlc/query/needs-approval" + echo " DELETE /sdlc/features/{slug}" +} + +# Show SDLC status +show_status() { + print_header "SDLC Status for $PROJECT_ID" + + echo "State:" + api_call GET "/projects/$PROJECT_ID/sdlc/state" | jq '.data' + + echo "" + echo "Features:" + api_call GET "/projects/$PROJECT_ID/sdlc/features" | jq '.data' + + echo "" + echo "Blocked:" + api_call GET "/projects/$PROJECT_ID/sdlc/query/blocked" | jq '.data' + + echo "" + echo "Needs Approval:" + api_call GET "/projects/$PROJECT_ID/sdlc/query/needs-approval" | jq '.data' +} + +# Teardown test feature +teardown_flow() { + print_header "SDLC Teardown" + + # List features to find test ones + local features + features=$(api_call GET "/projects/$PROJECT_ID/sdlc/features" | jq -r '.data[]?.slug // empty') + + if [[ -z "$features" ]]; then + echo "No features found for project $PROJECT_ID" + return 0 + fi + + echo "Features:" + echo "$features" + echo "" + + # Delete features matching test pattern + for slug in $features; do + if [[ "$slug" == sdlc-test-* ]]; then + echo "Deleting test feature: $slug" + api_call DELETE "/projects/$PROJECT_ID/sdlc/features/$slug" + print_success "Deleted: $slug" + fi + done +} + +# Dispatch +case "$COMMAND" in + run) + run_flow + ;; + status) + show_status + ;; + teardown) + teardown_flow + ;; + *) + echo "Unknown command: $COMMAND" + echo "Use: run, status, or teardown" + exit 1 + ;; +esac diff --git a/internal/adapter/kubernetes/sdlc_executor.go b/internal/adapter/kubernetes/sdlc_executor.go index e09125c..7dcb86c 100644 --- a/internal/adapter/kubernetes/sdlc_executor.go +++ b/internal/adapter/kubernetes/sdlc_executor.go @@ -84,6 +84,12 @@ func (e *SDLCExecutor) mapExecError(stderr string, execErr error) error { 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) @@ -284,5 +290,53 @@ func (e *SDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ( 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 branch manifest for a feature. +func (e *SDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) { + out, err := e.execSDLC(ctx, podName, "branch", "status", slug) + if err != nil { + return nil, err + } + // The CLI outputs {branch, checklist, ready} — extract the branch part + var result struct { + Branch *sdlc.BranchManifest `json:"branch"` + } + if err := json.Unmarshal(out, &result); err != nil { + return nil, fmt.Errorf("parse sdlc branch status: %w", err) + } + return result.Branch, 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) diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md new file mode 100644 index 0000000..e135aab --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/archive-feature.md @@ -0,0 +1,47 @@ +--- +description: Archive a completed and released feature +argument-hint: +allowed-tools: Bash +--- + +Archive feature: $ARGUMENTS + +## Instructions + +### 1. Verify Feature is Released + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Confirm the feature current phase is `released`. Only released features can be archived. + +### 2. Archive the Feature + +```bash +sdlc feature delete $ARGUMENTS +``` + +This moves the feature from active tracking to the archive. The `.sdlc/features/$ARGUMENTS/` directory and its artifacts are preserved in git history. + +### 3. Confirm Completion + +```bash +sdlc feature list --json +``` + +Verify the feature no longer appears in the active features list. + +### 4. Report + +Confirm: +- Feature slug that was archived +- Previous phase: `released` +- Status: archived and removed from active tracking + +## Critical Rules + +- ONLY archive features in the `released` phase +- NEVER archive features that are still in progress +- This is a cleanup action -- it does not delete git history +- ALWAYS verify the feature is released before archiving diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/audit-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/audit-feature.md new file mode 100644 index 0000000..c20bef3 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/audit-feature.md @@ -0,0 +1,103 @@ +--- +description: Perform a security and quality audit of a feature +argument-hint: +allowed-tools: Bash, Read, Glob, Grep, Write +--- + +Audit feature: $ARGUMENTS + +## Instructions + +### 1. Load Feature Context + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Read the spec and design to understand the feature security surface: +- `.sdlc/features/$ARGUMENTS/spec.md` +- `.sdlc/features/$ARGUMENTS/design.md` + +### 2. Run Static Analysis + +```bash +go vet ./... 2>/dev/null || true +golangci-lint run ./... 2>/dev/null || true +``` + +Capture any warnings or errors related to the feature files. + +### 3. OWASP Top 10 Check + +For each applicable category, search the feature code: + +| Category | What to Check | +|----------|--------------| +| **Injection** | SQL queries, command execution, template rendering | +| **Broken Auth** | Token handling, session management, credential storage | +| **Sensitive Data** | Secrets in code, logging PII, unencrypted storage | +| **XXE / Deserialization** | XML parsing, JSON unmarshaling of untrusted input | +| **Broken Access Control** | Authorization checks, resource ownership validation | +| **Misconfiguration** | Default credentials, debug modes, permissive CORS | +| **XSS** | User input rendered without escaping | +| **Insecure Components** | Known vulnerable dependencies | +| **Logging Gaps** | Missing audit logs, excessive debug logging | +| **SSRF** | User-controlled URLs, internal network access | + +### 4. Verify Auth Boundaries + +- Every endpoint has authentication +- Authorization checks match the resource being accessed +- No privilege escalation paths + +### 5. Check for Hardcoded Secrets + +```bash +grep -rn "password\|secret\|token\|api_key\|apikey" --include="*.go" [feature files] +``` + +### 6. Write Audit Report + +Write to `.sdlc/features/$ARGUMENTS/audit.md`: + +```markdown +# Security Audit: [Feature Title] + +## Summary +[Overall assessment: PASS / NEEDS_REMEDIATION] + +## Static Analysis Results +[Findings from vet/lint] + +## OWASP Assessment +| Category | Status | Notes | +|----------|--------|-------| +| Injection | PASS/FAIL | [details] | +| ... | ... | ... | + +## Critical Findings +- [Finding with severity and remediation guidance] + +## High Findings +- [Finding] + +## Medium/Low Findings +- [Finding] + +## Recommendations +[Ordered list of actions to take] +``` + +### 7. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS audit +``` + +## Critical Rules + +- NEVER skip OWASP checks -- even if the feature seems low-risk +- ALWAYS check for hardcoded secrets, tokens, and credentials +- ALWAYS verify authentication and authorization boundaries +- NEVER pass an audit with critical or high severity findings unresolved +- ALWAYS run static analysis tools before manual review diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/breakdown-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/breakdown-feature.md new file mode 100644 index 0000000..bbb0f5d --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/breakdown-feature.md @@ -0,0 +1,77 @@ +--- +description: Break a feature into implementation tasks +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +--- + +Break down feature into tasks: $ARGUMENTS + +## Instructions + +### 1. Load Feature, Spec, and Design + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Read both: +- `.sdlc/features/$ARGUMENTS/spec.md` +- `.sdlc/features/$ARGUMENTS/design.md` + +Both must exist before creating a task breakdown. + +### 2. Identify Implementation Units + +From the design, identify discrete units of work. Each task should: +- Be completable in a single coding session +- Have clear inputs and outputs +- Be independently testable +- Have minimal overlap with other tasks + +### 3. Create Tasks via CLI + +For each task, register it: + +```bash +sdlc task add $ARGUMENTS "Task title - brief description" +``` + +Order tasks by dependency -- foundational work first, integration last. + +### 4. Write Detailed Task Descriptions + +Write to `.sdlc/features/$ARGUMENTS/tasks.md`: + +```markdown +# Tasks: [Feature Title] + +## Task Order (dependency sequence) + +### T1: [Title] +- **Scope:** [What exactly to implement] +- **Files:** [Expected files to create/modify] +- **Depends on:** [None / T-id] +- **Acceptance criteria:** + - [ ] [Specific, testable criterion] + +### T2: [Title] +... +``` + +### 5. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS tasks +``` + +### 6. Report + +List all tasks in order, total count, and estimated dependency chain depth (longest path through the task graph). + +## Critical Rules + +- NEVER create tasks without reading both spec and design +- ALWAYS order tasks by dependency -- no task should reference work not yet done +- Each task MUST be completable in a single session +- ALWAYS include acceptance criteria per task +- NEVER create monolithic tasks -- split until each is focused diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/create-qa-plan.md b/internal/adapter/templates/templates/skeleton/.claude/commands/create-qa-plan.md new file mode 100644 index 0000000..a3a9c77 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/create-qa-plan.md @@ -0,0 +1,79 @@ +--- +description: Create a QA test plan for a feature +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +--- + +Create a QA plan for feature: $ARGUMENTS + +## Instructions + +### 1. Load All Planning Artifacts + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Read all of: +- `.sdlc/features/$ARGUMENTS/spec.md` -- acceptance criteria drive tests +- `.sdlc/features/$ARGUMENTS/design.md` -- architecture informs integration tests +- `.sdlc/features/$ARGUMENTS/tasks.md` -- task scope informs unit tests + +### 2. Derive Test Scenarios from Acceptance Criteria + +For each acceptance criterion in the spec, create at least one test scenario. Group scenarios by type. + +### 3. Write the QA Plan + +Write to `.sdlc/features/$ARGUMENTS/qa-plan.md`: + +```markdown +# QA Plan: [Feature Title] + +## Test Scenarios + +### Happy Path +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| HP-1 | [description] | [input] | [expected] | AC-N | + +### Edge Cases +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| EC-1 | [description] | [input] | [expected] | AC-N | + +### Error Cases +| ID | Scenario | Input | Expected Output | Derived From | +|----|----------|-------|-----------------|--------------| +| ER-1 | [description] | [input] | [expected] | AC-N | + +## Test Data Requirements +[What test data must be set up, fixtures, mocks] + +## Integration Test Plan +[How components interact, what to test end-to-end] + +## Performance Considerations +[Load expectations, latency budgets, benchmarks to run] + +## Manual Verification Steps +[Anything that cannot be automated] +``` + +### 4. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS qa_plan +``` + +### 5. Report + +Summarize scenario counts by category and flag any acceptance criteria that lack test coverage. + +## Critical Rules + +- ALWAYS derive test scenarios from acceptance criteria in the spec +- NEVER skip edge cases -- they catch the real bugs +- ALWAYS include integration scenarios that cross component boundaries +- ALWAYS include error cases for every external dependency +- NEVER leave an acceptance criterion without a corresponding test scenario diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/deliver.md b/internal/adapter/templates/templates/skeleton/.claude/commands/deliver.md new file mode 100644 index 0000000..2e502ab --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/deliver.md @@ -0,0 +1,68 @@ +--- +description: Orchestrate full feature delivery from current state to completion +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task +--- + +Deliver feature end-to-end: $ARGUMENTS + +## Instructions + +### 1. Assess Current State + +```bash +sdlc next --for $ARGUMENTS +``` + +Determine where the feature is in the lifecycle and what action is needed. + +### 2. Execute the Action Loop + +For each classifier result, execute the recommended action: + +| Action | What to Do | +|--------|-----------| +| `CREATE_SPEC` | Write the spec (follow `/spec-feature` protocol) | +| `CREATE_DESIGN` | Write the design (follow `/design-feature` protocol) | +| `CREATE_TASKS` | Break down tasks (follow `/breakdown-feature` protocol) | +| `CREATE_QA_PLAN` | Write QA plan (follow `/create-qa-plan` protocol) | +| `TRANSITION` | Run `sdlc feature transition $ARGUMENTS ` | +| `IMPLEMENT_TASK` | Implement the task (follow `/implement-task` protocol) | +| `CREATE_REVIEW` | Review the code (follow `/review-feature` protocol) | +| `CREATE_AUDIT` | Audit the code (follow `/audit-feature` protocol) | +| `RUN_QA` | Execute QA (follow `/run-qa` protocol) | +| `MERGE` | Merge the feature (follow `/merge-feature` protocol) | + +### 3. Re-classify After Each Step + +After completing each action: + +```bash +sdlc next --for $ARGUMENTS +``` + +Use the new classification to determine the next step. Continue until the feature reaches `released` or a gate is hit. + +### 4. Stop at Gates + +When the classifier returns `AWAIT_APPROVAL` or `BLOCKED`: + +1. Present what needs approval or what is blocked +2. List the specific artifact or blocker details +3. **Stop and wait for the user** -- do not proceed past approval gates + +### 5. Report Progress + +After each action, briefly report: +- What was just completed +- Current phase +- What comes next (or what is blocking) + +## Critical Rules + +- ALWAYS stop at approval gates -- NEVER approve artifacts yourself +- NEVER skip phases or reorder the classifier recommendations +- ALWAYS re-run the classifier after each action to get the true next step +- ALWAYS follow the corresponding command protocol for each action +- NEVER continue past BLOCKED status without resolution +- ALWAYS report what was done and what comes next after each step diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/design-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/design-feature.md new file mode 100644 index 0000000..4543ac6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/design-feature.md @@ -0,0 +1,76 @@ +--- +description: Create a technical design document for a feature +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +--- + +Create a technical design for feature: $ARGUMENTS + +## Instructions + +### 1. Load Feature and Spec + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Read `.sdlc/features/$ARGUMENTS/spec.md`. The spec MUST exist and be approved before designing. + +### 2. Analyze Existing Patterns + +Search the codebase for: +- Similar features or modules already implemented +- Data models that will be extended or referenced +- API patterns currently in use +- Error handling conventions +- Test patterns for comparable components + +### 3. Write the Design Document + +Write to `.sdlc/features/$ARGUMENTS/design.md` with this structure: + +```markdown +# Design: [Feature Title] + +## Architecture Approach +[High-level approach: which layers change, what is new vs modified] + +## Data Model Changes +[New types, schema changes, migration requirements] + +## API Changes +[New endpoints, modified contracts, request/response shapes] + +## Component Diagram +[Text diagram showing how components interact] + +## Error Handling Strategy +[Expected failure modes and how each is handled] + +## Security Considerations +[Auth, input validation, data exposure, access control] + +## Performance Considerations +[Expected load, caching strategy, query complexity] + +## Migration / Rollout Plan +[How to ship without breaking existing functionality] +``` + +### 4. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS design +``` + +### 5. Report + +Summarize the design approach, list key decisions made, and flag any areas that need human review or approval. + +## Critical Rules + +- ALWAYS read the spec before designing -- the design must satisfy the spec +- NEVER design without understanding existing patterns in the codebase +- ALWAYS consider error handling -- every external call can fail +- ALWAYS address security -- auth, validation, data boundaries +- NEVER invent new patterns when existing ones fit diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/fix-qa-failures.md b/internal/adapter/templates/templates/skeleton/.claude/commands/fix-qa-failures.md new file mode 100644 index 0000000..b71d478 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/fix-qa-failures.md @@ -0,0 +1,68 @@ +--- +description: Fix QA test failures +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task +--- + +Fix QA failures for feature: $ARGUMENTS + +## Instructions + +### 1. Load QA Results + +Read `.sdlc/features/$ARGUMENTS/qa-results.md` to get the full test results. + +### 2. Parse Failed Scenarios + +Collect all scenarios with status FAIL. For each failure, note: +- Scenario ID and description +- Expected vs actual behavior +- Error output or evidence + +### 3. Diagnose Each Failure + +For each failed scenario: +1. Read the test code or manual steps that failed +2. Read the production code being tested +3. Determine if the issue is in the implementation, the test, or the test data + +### 4. Fix Implementation Issues + +If the failure is due to incorrect implementation: +1. Fix the production code +2. Run the specific failing test to confirm the fix +3. Run the full test suite to ensure no regressions: + +```bash +go test ./... 2>/dev/null || true +``` + +### 5. Fix Test Issues + +If the failure is due to an incorrect test expectation: +1. Verify the test expectation against the spec +2. If the spec supports the test, fix the implementation (not the test) +3. If the test expectation was wrong, fix the test and document why + +### 6. Re-run All Failed Scenarios + +After all fixes, re-execute every previously failed scenario and confirm PASS. + +### 7. Update QA Results + +Update `.sdlc/features/$ARGUMENTS/qa-results.md`: +- Change FAIL to PASS for fixed scenarios +- Add a remediation note explaining what was fixed +- Verify the overall status (PASS only if all scenarios pass) + +### 8. Report + +Summarize: failures fixed, root causes, regression status (all previously passing tests still pass). + +## Critical Rules + +- ALWAYS re-run failed tests after fixing -- verify with evidence +- NEVER mark a test as passing without actually running it +- NEVER fix tests to match broken behavior -- fix the implementation +- ALWAYS keep passing tests passing -- no regressions +- ALWAYS update the QA results document with resolution details diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/fix-review-issues.md b/internal/adapter/templates/templates/skeleton/.claude/commands/fix-review-issues.md new file mode 100644 index 0000000..663f927 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/fix-review-issues.md @@ -0,0 +1,68 @@ +--- +description: Fix issues found during code review +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task +--- + +Fix review issues for feature: $ARGUMENTS + +## Instructions + +### 1. Load Review Findings + +Read `.sdlc/features/$ARGUMENTS/review.md` to get the full list of findings. + +### 2. Parse Findings by Severity + +Collect all findings into severity buckets: +1. **BLOCKER** -- must fix, cannot ship without these +2. **WARNING** -- should fix, quality concerns +3. **SUGGESTION** -- nice to have improvements + +### 3. Fix Blockers First + +For each blocker: +1. Read the file at the specified location +2. Understand the issue and why it matters +3. Apply the proper fix (not a quick patch) +4. Run tests to verify the fix does not break anything: + +```bash +go test ./... 2>/dev/null || true +``` + +### 4. Fix Warnings + +After all blockers are resolved, fix warnings using the same process. + +### 5. Address Suggestions + +Apply suggestions that improve clarity or maintainability without significant risk. + +### 6. Update Review Report + +Update `.sdlc/features/$ARGUMENTS/review.md` with resolution notes for each finding: + +```markdown +- [x] [FILE:LINE] [Description] -- **RESOLVED:** [what was done] +``` + +### 7. Run Full Test Suite + +```bash +go test ./... 2>/dev/null || true +``` + +All tests must pass after all fixes are applied. + +### 8. Report + +Summarize: findings fixed by severity, files modified, test results. + +## Critical Rules + +- ALWAYS fix all blockers -- they are non-negotiable +- ALWAYS run tests after each fix, not just at the end +- NEVER close a finding without actually fixing it +- NEVER introduce new issues while fixing existing ones +- ALWAYS update the review report with resolution notes diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md b/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md new file mode 100644 index 0000000..5397ab6 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/implement-task.md @@ -0,0 +1,69 @@ +--- +description: Implement a specific task from the feature breakdown +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task +--- + +Implement task: $ARGUMENTS + +## Instructions + +### 1. Parse Arguments + +Extract the feature slug and task ID from `$ARGUMENTS`. The first token is the slug, the second is the task ID. + +### 2. Start the Task + +```bash +sdlc task start +``` + +This marks the task as in-progress and prevents other tasks from being picked up concurrently. + +### 3. Load Context + +Read all relevant artifacts: +- `.sdlc/features//spec.md` -- requirements +- `.sdlc/features//design.md` -- architecture decisions +- `.sdlc/features//tasks.md` -- find this task scope and acceptance criteria + +### 4. Study Existing Patterns + +Before writing code, read the files identified in the task description. Understand the patterns, naming conventions, and test approaches already in use. + +### 5. Implement + +Write the code changes specified by the task. Follow existing patterns. For each file: +- Production code first +- Tests alongside or immediately after +- Update any relevant documentation or configuration + +### 6. Run Tests + +```bash +go test ./... 2>/dev/null || true +# or the appropriate test command for the project stack +``` + +All existing tests must continue to pass. New tests must pass. + +### 7. Complete the Task + +Only if tests pass: + +```bash +sdlc task complete +``` + +### 8. Report + +Summarize: files changed, tests added, task acceptance criteria status. + +## Critical Rules + +- ALWAYS start the task via CLI before implementing +- ALWAYS run tests before marking complete +- NEVER mark a task complete if tests fail +- ALWAYS follow existing codebase patterns +- NEVER implement beyond the task stated scope +- ALWAYS read spec and design for context before coding diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md new file mode 100644 index 0000000..7a1c0d3 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/merge-feature.md @@ -0,0 +1,61 @@ +--- +description: Merge a completed feature branch +argument-hint: +allowed-tools: Bash +--- + +Merge feature: $ARGUMENTS + +## Instructions + +### 1. Check Merge Readiness + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Verify that: +- All required artifacts are approved (review, audit, qa_results) +- No blockers exist +- The feature is in the `merge` phase + +### 2. Check Branch Status + +```bash +sdlc query ready +``` + +Confirm the feature appears in the ready-to-merge list. + +### 3. Run Final Tests + +```bash +go test ./... 2>/dev/null || true +``` + +All tests must pass before merge. + +### 4. Execute the Merge + +```bash +sdlc feature transition $ARGUMENTS released +``` + +This transitions the feature to the `released` phase. + +### 5. Report + +Confirm the merge completed successfully: +- Feature name and slug +- Final phase: `released` +- All gates that were passed + +If the merge fails, report the error and do not retry without user guidance. + +## Critical Rules + +- NEVER merge without all required gates passed +- ALWAYS run tests immediately before merging +- ALWAYS report merge conflicts or failures to the user +- NEVER force a merge past failing checks +- NEVER merge if the classifier says the feature is not ready diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/next.md b/internal/adapter/templates/templates/skeleton/.claude/commands/next.md new file mode 100644 index 0000000..cc6e3a0 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/next.md @@ -0,0 +1,58 @@ +--- +description: Run the SDLC classifier and show the next required action +argument-hint: [feature-slug] +allowed-tools: Bash +--- + +Show next SDLC action: $ARGUMENTS + +## Instructions + +### 1. Run the Classifier + +If a feature slug was provided: + +```bash +sdlc next --for $ARGUMENTS +``` + +If no argument was provided, run the global classifier: + +```bash +sdlc next +``` + +### 2. Display the Result + +Present the classification output clearly: + +- **Feature:** which feature needs attention +- **Current Phase:** where it is in the lifecycle +- **Action:** what needs to happen next +- **Message:** human-readable guidance +- **Suggested Command:** the CLI or slash command to run + +### 3. Suggest the Next Step + +Map the action to the corresponding slash command: + +| Action | Suggested Command | +|--------|-------------------| +| `CREATE_SPEC` | `/spec-feature ` | +| `CREATE_DESIGN` | `/design-feature ` | +| `CREATE_TASKS` | `/breakdown-feature ` | +| `CREATE_QA_PLAN` | `/create-qa-plan ` | +| `IMPLEMENT_TASK` | `/implement-task ` | +| `CREATE_REVIEW` | `/review-feature ` | +| `CREATE_AUDIT` | `/audit-feature ` | +| `RUN_QA` | `/run-qa ` | +| `MERGE` | `/merge-feature ` | +| `AWAIT_APPROVAL` | Needs human approval via rdev API | +| `BLOCKED` | Feature has blockers -- resolve via rdev API | +| `IDLE` | Nothing to do | + +## Critical Rules + +- ALWAYS present the classifier output clearly and completely +- NEVER automatically execute the next command without user confirmation +- ALWAYS show the suggested slash command so the user can invoke it diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/remediate-audit.md b/internal/adapter/templates/templates/skeleton/.claude/commands/remediate-audit.md new file mode 100644 index 0000000..e1a9fe2 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/remediate-audit.md @@ -0,0 +1,70 @@ +--- +description: Remediate security audit findings +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task +--- + +Remediate audit findings for feature: $ARGUMENTS + +## Instructions + +### 1. Load Audit Findings + +Read `.sdlc/features/$ARGUMENTS/audit.md` to get the full security audit report. + +### 2. Parse Findings by Severity + +Collect all security findings: +1. **CRITICAL** -- immediate risk, must fix before any progress +2. **HIGH** -- significant risk, must fix before merge +3. **MEDIUM** -- moderate risk, should fix +4. **LOW** -- minor risk, fix if straightforward + +### 3. Fix Critical Findings + +For each critical finding: +1. Read the affected code +2. Understand the vulnerability and attack vector +3. Apply the proper remediation (input validation, auth check, etc.) +4. Verify the fix addresses the root cause, not just the symptom + +### 4. Fix High Findings + +After all critical findings are resolved, address high severity issues using the same disciplined approach. + +### 5. Fix Medium and Low Findings + +Address remaining findings in priority order. + +### 6. Run Security Checks + +Re-run the checks that originally found the issues: + +```bash +go vet ./... 2>/dev/null || true +grep -rn "password\|secret\|token\|api_key" --include="*.go" [feature files] || true +``` + +### 7. Update Audit Report + +Update `.sdlc/features/$ARGUMENTS/audit.md` with remediation notes: + +```markdown +## Remediation Log +| Finding | Severity | Status | Resolution | +|---------|----------|--------|------------| +| [description] | CRITICAL | REMEDIATED | [what was done] | +``` + +### 8. Report + +Summarize: findings remediated by severity, remaining items, verification results. + +## Critical Rules + +- ALWAYS fix all critical findings -- no exceptions +- NEVER leave high-severity security issues unresolved +- ALWAYS run security checks after applying fixes +- NEVER fix security issues with workarounds -- address root causes +- ALWAYS update the audit report with remediation details +- NEVER remove security findings from the report -- mark them as remediated diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/review-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/review-feature.md new file mode 100644 index 0000000..5381dec --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/review-feature.md @@ -0,0 +1,85 @@ +--- +description: Perform a comprehensive code review of a feature +argument-hint: +allowed-tools: Bash, Read, Glob, Grep, Write +--- + +Review feature: $ARGUMENTS + +## Instructions + +### 1. Load Feature Context + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Read the spec and design to understand what was intended: +- `.sdlc/features/$ARGUMENTS/spec.md` +- `.sdlc/features/$ARGUMENTS/design.md` + +### 2. Identify Changed Files + +Determine what files were created or modified for this feature. Check git history, task descriptions, or search for recent changes in relevant directories. + +### 3. Review Each Dimension + +| Dimension | Key Question | +|-----------|--------------| +| **Correctness** | Does the code do what the spec requires? | +| **Test Coverage** | Is every acceptance criterion tested? | +| **Error Handling** | Are failures handled, not swallowed? | +| **Security** | Input validation, auth checks, data exposure? | +| **Performance** | N+1 queries, unbounded loops, missing timeouts? | +| **Code Style** | Follows existing patterns and conventions? | +| **Documentation** | Public APIs documented, complex logic commented? | + +### 4. Categorize Findings + +| Severity | Meaning | +|----------|---------| +| **BLOCKER** | Cannot ship -- must fix before merge | +| **WARNING** | Quality concern -- should fix | +| **SUGGESTION** | Improvement -- nice to have | + +### 5. Write Review Report + +Write to `.sdlc/features/$ARGUMENTS/review.md`: + +```markdown +# Code Review: [Feature Title] + +## Summary +[Overall assessment: PASS / NEEDS_FIX] + +## Findings + +### Blockers +- [ ] [FILE:LINE] [Description] -- [Why it matters] + +### Warnings +- [ ] [FILE:LINE] [Description] -- [Suggested fix] + +### Suggestions +- [ ] [FILE:LINE] [Description] + +## Spec Alignment +[Does the implementation match the spec? Any gaps?] + +## Test Coverage Assessment +[Which acceptance criteria have tests? Which are missing?] +``` + +### 6. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS review +``` + +## Critical Rules + +- ALWAYS read spec and design before reviewing code +- NEVER skip the security review dimension +- ALWAYS check test coverage against acceptance criteria +- ALWAYS provide actionable findings with file locations +- NEVER approve a review with unresolved blockers diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/run-qa.md b/internal/adapter/templates/templates/skeleton/.claude/commands/run-qa.md new file mode 100644 index 0000000..6a698d4 --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/run-qa.md @@ -0,0 +1,92 @@ +--- +description: Execute the QA test plan for a feature +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep, Task +--- + +Run QA for feature: $ARGUMENTS + +## Instructions + +### 1. Load Feature and QA Plan + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Read: +- `.sdlc/features/$ARGUMENTS/qa-plan.md` -- the test plan to execute +- `.sdlc/features/$ARGUMENTS/spec.md` -- acceptance criteria to verify + +### 2. Execute Unit Tests + +Run the project test suite and capture results: + +```bash +go test ./... -v 2>&1 | tee /tmp/qa-test-output.txt +``` + +### 3. Execute Each Test Scenario + +Work through every scenario in the QA plan: +- **Happy path scenarios** -- verify expected behavior +- **Edge case scenarios** -- verify boundary handling +- **Error case scenarios** -- verify failure modes + +For each scenario, record: scenario ID, status (PASS/FAIL), evidence (test output or manual verification). + +### 4. Verify Acceptance Criteria + +Cross-reference each acceptance criterion from the spec against test results. Every criterion must have at least one passing test scenario. + +### 5. Write QA Results + +Write to `.sdlc/features/$ARGUMENTS/qa-results.md`: + +```markdown +# QA Results: [Feature Title] + +## Test Run Summary +- **Date:** [timestamp] +- **Overall:** PASS / FAIL +- **Scenarios:** N passed, M failed, K skipped + +## Scenario Results + +### Happy Path +| ID | Scenario | Status | Evidence | +|----|----------|--------|----------| +| HP-1 | [description] | PASS/FAIL | [test name or output] | + +### Edge Cases +| ID | Scenario | Status | Evidence | +|----|----------|--------|----------| +| EC-1 | [description] | PASS/FAIL | [evidence] | + +### Error Cases +| ID | Scenario | Status | Evidence | +|----|----------|--------|----------| +| ER-1 | [description] | PASS/FAIL | [evidence] | + +## Acceptance Criteria Coverage +| Criterion | Scenarios | Status | +|-----------|-----------|--------| +| AC-1 | HP-1, EC-2 | COVERED / GAP | + +## Failures (if any) +[Detailed description of each failure with reproduction steps] +``` + +### 6. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS qa_results +``` + +## Critical Rules + +- ALWAYS execute every scenario from the QA plan -- no skipping +- NEVER skip failing tests or mark them as passing without evidence +- ALWAYS document ALL results, including passing scenarios +- ALWAYS verify acceptance criteria coverage explicitly +- NEVER fabricate test evidence -- run the actual tests diff --git a/internal/adapter/templates/templates/skeleton/.claude/commands/spec-feature.md b/internal/adapter/templates/templates/skeleton/.claude/commands/spec-feature.md new file mode 100644 index 0000000..696940c --- /dev/null +++ b/internal/adapter/templates/templates/skeleton/.claude/commands/spec-feature.md @@ -0,0 +1,76 @@ +--- +description: Create a feature specification document +argument-hint: +allowed-tools: Bash, Read, Write, Edit, Glob, Grep +--- + +Create a specification for feature: $ARGUMENTS + +## Instructions + +### 1. Load Feature Context + +```bash +sdlc feature show $ARGUMENTS --json +``` + +Parse the output to understand the feature current phase, metadata, and any existing artifacts. + +### 2. Check for Existing Spec + +Read `.sdlc/features/$ARGUMENTS/spec.md` if it exists. If a draft already exists, build on it rather than starting from scratch. + +### 3. Gather Codebase Context + +Search the codebase for patterns, modules, and systems relevant to this feature. Understand what exists before specifying what should change. + +### 4. Write the Specification + +Write to `.sdlc/features/$ARGUMENTS/spec.md` with this structure: + +```markdown +# Feature: [Title] + +## Problem Statement +[What problem does this solve? Who has this problem?] + +## User Stories +- As a [role], I want [capability] so that [benefit] + +## Acceptance Criteria +- [ ] [Criterion 1 - testable, specific] +- [ ] [Criterion 2] +- [ ] [Criterion N] + +## Technical Constraints +[Platform limits, API compatibility, performance requirements] + +## Dependencies +[What must exist or be true before this can be built] + +## Out of Scope +[Explicitly excluded from this feature] + +## Open Questions +[Unresolved decisions that need input] +``` + +### 5. Register the Artifact + +```bash +sdlc artifact create $ARGUMENTS spec +``` + +The classifier will detect the spec exists and determine the next action (typically awaiting approval). + +### 6. Report + +Summarize what was specified, list acceptance criteria count, and note any open questions that need human input. + +## Critical Rules + +- NEVER skip loading feature context first +- ALWAYS include acceptance criteria -- they drive QA later +- NEVER approve your own spec -- it requires human approval +- ALWAYS list open questions rather than making assumptions +- ALWAYS search the codebase before writing constraints diff --git a/internal/handlers/sdlc.go b/internal/handlers/sdlc.go index a304769..5aa3805 100644 --- a/internal/handlers/sdlc.go +++ b/internal/handlers/sdlc.go @@ -58,6 +58,15 @@ func (h *SDLCHandler) Mount(r api.Router) { r.Post("/features/{slug}/tasks/{taskId}/complete", h.CompleteTask) r.Post("/features/{slug}/tasks/{taskId}/block", h.BlockTask) + // Branches + r.Post("/features/{slug}/branches", h.CreateBranch) + r.Get("/features/{slug}/branches", h.GetBranchStatus) + r.Post("/features/{slug}/branches/sync", h.SyncBranch) + + // Merge / Archive + r.Post("/features/{slug}/merge", h.MergeFeature) + r.Post("/features/{slug}/archive", h.ArchiveFeature) + // Queries r.Get("/query/blocked", h.QueryBlocked) r.Get("/query/ready", h.QueryReady) @@ -123,6 +132,12 @@ func writeSDLCError(w http.ResponseWriter, r *http.Request, err error) { api.WriteBadRequest(w, r, "invalid slug: must be lowercase alphanumeric with hyphens") case errors.Is(err, sdlc.ErrInvalidArtifact): api.WriteBadRequest(w, r, "invalid artifact type") + case errors.Is(err, sdlc.ErrBranchExists): + api.WriteBadRequest(w, r, "branch already exists") + case errors.Is(err, sdlc.ErrBranchNotFound): + api.WriteNotFound(w, r, "branch not found") + case errors.Is(err, sdlc.ErrMergeNotReady): + api.WriteBadRequest(w, r, "feature not ready to merge: unmet gates") default: api.WriteInternalError(w, r, "sdlc operation failed") } diff --git a/internal/handlers/sdlc_branches.go b/internal/handlers/sdlc_branches.go new file mode 100644 index 0000000..1ce7fe2 --- /dev/null +++ b/internal/handlers/sdlc_branches.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/pkg/api" +) + +// CreateBranch creates a feature branch. +// POST /projects/{id}/sdlc/features/{slug}/branches +func (h *SDLCHandler) CreateBranch(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + slug := chi.URLParam(r, "slug") + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) + defer cancel() + + manifest, err := h.sdlcService.CreateBranch(ctx, projectID, slug) + if err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteCreated(w, r, manifest) +} + +// GetBranchStatus returns the branch manifest for a feature. +// GET /projects/{id}/sdlc/features/{slug}/branches +func (h *SDLCHandler) GetBranchStatus(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + slug := chi.URLParam(r, "slug") + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + manifest, err := h.sdlcService.GetBranchStatus(ctx, projectID, slug) + if err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, manifest) +} + +// SyncBranch syncs a feature branch with its base branch. +// POST /projects/{id}/sdlc/features/{slug}/branches/sync +func (h *SDLCHandler) SyncBranch(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + slug := chi.URLParam(r, "slug") + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) + defer cancel() + + if err := h.sdlcService.SyncBranch(ctx, projectID, slug); err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, map[string]string{"status": "synced"}) +} diff --git a/internal/handlers/sdlc_branches_test.go b/internal/handlers/sdlc_branches_test.go new file mode 100644 index 0000000..8719da7 --- /dev/null +++ b/internal/handlers/sdlc_branches_test.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/orchard9/rdev/internal/sdlc" +) + +func TestSDLCHandler_CreateBranch(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_CreateBranch_FeatureNotFound(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/nonexistent/branches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_CreateBranch_AlreadyExists(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrBranchExists} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_GetBranchStatus(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/features/auth-flow/branches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_GetBranchStatus_NotFound(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrBranchNotFound} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/features/auth-flow/branches", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_SyncBranch(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches/sync", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_SyncBranch_BranchNotFound(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrBranchNotFound} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/branches/sync", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handlers/sdlc_merge.go b/internal/handlers/sdlc_merge.go new file mode 100644 index 0000000..9812a62 --- /dev/null +++ b/internal/handlers/sdlc_merge.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/pkg/api" +) + +// MergeFeatureRequest is the request body for merging a feature. +type MergeFeatureRequest struct { + Strategy string `json:"strategy,omitempty"` +} + +// MergeFeature merges a feature branch after all gates pass. +// POST /projects/{id}/sdlc/features/{slug}/merge +func (h *SDLCHandler) MergeFeature(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + slug := chi.URLParam(r, "slug") + + var req MergeFeatureRequest + if r.Body != nil && r.ContentLength > 0 { + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + } + + strategy := req.Strategy + if strategy == "" { + strategy = "squash" + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) + defer cancel() + + if err := h.sdlcService.MergeFeature(ctx, projectID, slug, strategy); err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, map[string]string{"status": "merged", "strategy": strategy}) +} + +// ArchiveFeature archives a released feature. +// POST /projects/{id}/sdlc/features/{slug}/archive +func (h *SDLCHandler) ArchiveFeature(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + slug := chi.URLParam(r, "slug") + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + if err := h.sdlcService.ArchiveFeature(ctx, projectID, slug); err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, map[string]string{"status": "archived"}) +} diff --git a/internal/handlers/sdlc_merge_test.go b/internal/handlers/sdlc_merge_test.go new file mode 100644 index 0000000..5edaac0 --- /dev/null +++ b/internal/handlers/sdlc_merge_test.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/orchard9/rdev/internal/sdlc" +) + +func TestSDLCHandler_MergeFeature(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/merge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_MergeFeature_WithStrategy(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupSDLCHandler(exec) + + body, _ := json.Marshal(MergeFeatureRequest{Strategy: "merge"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/merge", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_MergeFeature_NotReady(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrMergeNotReady} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/merge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_MergeFeature_FeatureNotFound(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/nonexistent/merge", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_ArchiveFeature(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/archive", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCHandler_ArchiveFeature_NotFound(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound} + _, router := setupSDLCHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/nonexistent/archive", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handlers/sdlc_orchestrator.go b/internal/handlers/sdlc_orchestrator.go new file mode 100644 index 0000000..21390ee --- /dev/null +++ b/internal/handlers/sdlc_orchestrator.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "context" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/service" + "github.com/orchard9/rdev/pkg/api" +) + +// SDLCOrchestratorHandler handles SDLC orchestration endpoints. +type SDLCOrchestratorHandler struct { + orchestrator *service.SDLCOrchestratorService + logger *slog.Logger +} + +// NewSDLCOrchestratorHandler creates a new orchestrator handler. +func NewSDLCOrchestratorHandler(orchestrator *service.SDLCOrchestratorService, logger *slog.Logger) *SDLCOrchestratorHandler { + if logger == nil { + logger = slog.Default() + } + return &SDLCOrchestratorHandler{ + orchestrator: orchestrator, + logger: logger, + } +} + +// Mount registers orchestration routes under /projects/{id}/sdlc/. +func (h *SDLCOrchestratorHandler) Mount(r api.Router) { + r.Route("/projects/{id}/sdlc", func(r chi.Router) { + r.Post("/execute", h.Execute) + r.Post("/resolve", h.Resolve) + r.Post("/commit", h.Commit) + }) +} + +// Execute runs the next classifier-recommended action. +// POST /projects/{id}/sdlc/execute +func (h *SDLCOrchestratorHandler) Execute(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req service.ExecuteRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Feature == "" { + api.WriteBadRequest(w, r, "feature is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning) + defer cancel() + + result, err := h.orchestrator.ExecuteAction(ctx, projectID, &req) + if err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, result) +} + +// Resolve unblocks a feature and re-classifies. +// POST /projects/{id}/sdlc/resolve +func (h *SDLCOrchestratorHandler) Resolve(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req service.ResolveRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Feature == "" { + api.WriteBadRequest(w, r, "feature is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard) + defer cancel() + + result, err := h.orchestrator.ResolveBlocker(ctx, projectID, &req) + if err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, result) +} + +// Commit commits changes in the project pod. +// POST /projects/{id}/sdlc/commit +func (h *SDLCOrchestratorHandler) Commit(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + + var req service.CommitRequest + if err := api.DecodeJSON(r, &req); err != nil { + api.WriteBadRequest(w, r, "invalid request body") + return + } + + if req.Message == "" { + api.WriteBadRequest(w, r, "message is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite) + defer cancel() + + result, err := h.orchestrator.CommitChanges(ctx, projectID, &req) + if err != nil { + writeSDLCError(w, r, err) + return + } + + api.WriteSuccess(w, r, result) +} diff --git a/internal/handlers/sdlc_orchestrator_test.go b/internal/handlers/sdlc_orchestrator_test.go new file mode 100644 index 0000000..a9c3aeb --- /dev/null +++ b/internal/handlers/sdlc_orchestrator_test.go @@ -0,0 +1,186 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/sdlc" + "github.com/orchard9/rdev/internal/service" +) + +func setupOrchestratorHandler(exec *testSDLCExecutor) (*SDLCOrchestratorHandler, *chi.Mux) { + repo := &testSDLCProjectRepo{ + project: &domain.Project{ID: "test-project", PodName: "test-pod"}, + } + sdlcSvc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{}) + + orchestrator := service.NewSDLCOrchestratorService( + sdlcSvc, + nil, // no agent registry for handler tests + nil, // no git committer for handler tests + repo, + service.SDLCOrchestratorConfig{}, + ) + + handler := NewSDLCOrchestratorHandler(orchestrator, nil) + r := chi.NewRouter() + handler.Mount(r) + return handler, r +} + +func TestSDLCOrchestratorHandler_Execute(t *testing.T) { + exec := &testSDLCExecutor{ + classification: &sdlc.Classification{ + Action: sdlc.ActionIdle, + Feature: "auth-flow", + Message: "No action needed", + }, + } + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.ExecuteRequest{Feature: "auth-flow"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Execute_MissingFeature(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.ExecuteRequest{}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Execute_InvalidBody(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupOrchestratorHandler(exec) + + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader([]byte("not json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Execute_FeatureNotFound(t *testing.T) { + exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound} + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.ExecuteRequest{Feature: "nonexistent"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Execute_Transition(t *testing.T) { + exec := &testSDLCExecutor{ + classification: &sdlc.Classification{ + Action: sdlc.ActionTransition, + Feature: "auth-flow", + TransitionTo: sdlc.PhaseSpecified, + }, + } + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.ExecuteRequest{Feature: "auth-flow"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/execute", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Resolve(t *testing.T) { + exec := &testSDLCExecutor{ + classification: &sdlc.Classification{ + Action: sdlc.ActionIdle, + Feature: "auth-flow", + }, + } + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.ResolveRequest{Feature: "auth-flow", Answer: "fixed it"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/resolve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Resolve_MissingFeature(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.ResolveRequest{}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/resolve", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Commit(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.CommitRequest{Feature: "auth-flow", Message: "implement auth"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/commit", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Expected 500 because git committer is nil in test setup + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status 500 (no git committer), got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSDLCOrchestratorHandler_Commit_MissingMessage(t *testing.T) { + exec := &testSDLCExecutor{} + _, router := setupOrchestratorHandler(exec) + + body, _ := json.Marshal(service.CommitRequest{Feature: "auth-flow"}) + req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/commit", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} diff --git a/internal/handlers/sdlc_test.go b/internal/handlers/sdlc_test.go index bd97a18..42c1b87 100644 --- a/internal/handlers/sdlc_test.go +++ b/internal/handlers/sdlc_test.go @@ -88,6 +88,21 @@ func (m *testSDLCExecutor) QueryReady(_ context.Context, _ string) ([]port.Ready func (m *testSDLCExecutor) QueryNeedsApproval(_ context.Context, _ string) ([]port.ApprovalInfo, error) { return m.approval, m.err } +func (m *testSDLCExecutor) CreateBranch(_ context.Context, _, slug string) (*sdlc.BranchManifest, error) { + if m.err != nil { + return nil, m.err + } + return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil +} +func (m *testSDLCExecutor) GetBranchStatus(_ context.Context, _, slug string) (*sdlc.BranchManifest, error) { + if m.err != nil { + return nil, m.err + } + return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil +} +func (m *testSDLCExecutor) SyncBranch(_ context.Context, _, _ string) error { return m.err } +func (m *testSDLCExecutor) MergeFeature(_ context.Context, _, _, _ string) error { return m.err } +func (m *testSDLCExecutor) ArchiveFeature(_ context.Context, _, _ string) error { return m.err } // testSDLCProjectRepo implements port.ProjectRepository for handler tests. type testSDLCProjectRepo struct { diff --git a/internal/port/sdlc_executor.go b/internal/port/sdlc_executor.go index d1de2e7..49bfbfd 100644 --- a/internal/port/sdlc_executor.go +++ b/internal/port/sdlc_executor.go @@ -69,6 +69,21 @@ type SDLCExecutor interface { // QueryNeedsApproval returns features awaiting approval. QueryNeedsApproval(ctx context.Context, podName string) ([]ApprovalInfo, error) + + // CreateBranch creates a feature branch and its manifest. + CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) + + // GetBranchStatus returns the branch manifest and merge checklist. + GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) + + // SyncBranch syncs a feature branch with its base branch. + SyncBranch(ctx context.Context, podName, slug string) error + + // MergeFeature merges a feature branch after all gates pass. + MergeFeature(ctx context.Context, podName, slug, strategy string) error + + // ArchiveFeature archives a released feature. + ArchiveFeature(ctx context.Context, podName, slug string) error } // BlockedInfo describes a blocked feature (matches sdlc query --json output). diff --git a/internal/sdlc/branch.go b/internal/sdlc/branch.go new file mode 100644 index 0000000..5166faf --- /dev/null +++ b/internal/sdlc/branch.go @@ -0,0 +1,137 @@ +package sdlc + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// BranchManifest tracks branch metadata for a feature. +type BranchManifest struct { + Name string `yaml:"name" json:"name"` + Feature string `yaml:"feature" json:"feature"` + BaseBranch string `yaml:"base_branch" json:"base_branch"` + CreatedAt time.Time `yaml:"created_at" json:"created_at"` + LastSyncAt *time.Time `yaml:"last_sync_at,omitempty" json:"last_sync_at,omitempty"` + MergedAt *time.Time `yaml:"merged_at,omitempty" json:"merged_at,omitempty"` + MergeStrategy string `yaml:"merge_strategy,omitempty" json:"merge_strategy,omitempty"` +} + +// BranchPath returns the path to a branch manifest file. +func BranchPath(root, branchName string) string { + return filepath.Join(root, SDLCDir, BranchesDir, branchName+".yaml") +} + +// CreateBranch creates a new branch manifest for a feature. +// It validates the feature exists and constructs the branch name from config. +func CreateBranch(root, slug string, cfg *Config) (*BranchManifest, error) { + f, err := LoadFeature(root, slug) + if err != nil { + return nil, err + } + + branchName := cfg.Branches.FeaturePrefix + slug + + // Check if branch manifest already exists + path := BranchPath(root, branchName) + if _, err := os.Stat(path); err == nil { + return nil, ErrBranchExists + } + + manifest := &BranchManifest{ + Name: branchName, + Feature: slug, + BaseBranch: cfg.Branches.Main, + CreatedAt: time.Now().UTC(), + } + + if err := SaveBranch(root, manifest); err != nil { + return nil, err + } + + // Update feature with branch reference + f.Branch = branchName + if err := f.Save(root); err != nil { + return nil, err + } + + return manifest, nil +} + +// LoadBranch reads a branch manifest from disk. +func LoadBranch(root, branchName string) (*BranchManifest, error) { + path := BranchPath(root, branchName) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrBranchNotFound + } + return nil, fmt.Errorf("read branch manifest: %w", err) + } + + var m BranchManifest + if err := yaml.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse branch manifest: %w", err) + } + return &m, nil +} + +// SaveBranch writes a branch manifest to disk. +func SaveBranch(root string, manifest *BranchManifest) error { + path := BranchPath(root, manifest.Name) + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create branch directory: %w", err) + } + + data, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshal branch manifest: %w", err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("write branch manifest: %w", err) + } + return nil +} + +// MergeChecklist returns a list of unmet gates that block merging. +// An empty list means the feature is ready to merge. +func MergeChecklist(root, slug string) ([]string, error) { + f, err := LoadFeature(root, slug) + if err != nil { + return nil, err + } + + var unmet []string + + // Must be in merge phase + if f.Phase != PhaseMerge { + unmet = append(unmet, fmt.Sprintf("feature is in phase %s, must be in merge", f.Phase)) + } + + // Review must be passed + if art := f.GetArtifact(ArtifactReview); art == nil || art.Status != StatusPassed { + unmet = append(unmet, "code review not passed") + } + + // Audit must be passed + if art := f.GetArtifact(ArtifactAudit); art == nil || art.Status != StatusPassed { + unmet = append(unmet, "security audit not passed") + } + + // QA must be passed + if art := f.GetArtifact(ArtifactQAResults); art == nil || art.Status != StatusPassed { + unmet = append(unmet, "QA tests not passed") + } + + // No blockers + if f.IsBlocked() { + unmet = append(unmet, fmt.Sprintf("feature has %d blocker(s)", len(f.Blockers))) + } + + return unmet, nil +} diff --git a/internal/sdlc/branch_test.go b/internal/sdlc/branch_test.go new file mode 100644 index 0000000..c94860e --- /dev/null +++ b/internal/sdlc/branch_test.go @@ -0,0 +1,187 @@ +package sdlc + +import ( + "os" + "path/filepath" + "testing" +) + +func setupTestSDLC(t *testing.T) string { + t.Helper() + root := t.TempDir() + if err := Init(root, "test-project"); err != nil { + t.Fatalf("init failed: %v", err) + } + return root +} + +func TestCreateBranch(t *testing.T) { + root := setupTestSDLC(t) + cfg := DefaultConfig("test") + + // Create a feature first + _, err := CreateFeature(root, "auth-flow", "Auth Flow") + if err != nil { + t.Fatalf("create feature: %v", err) + } + + manifest, err := CreateBranch(root, "auth-flow", cfg) + if err != nil { + t.Fatalf("create branch: %v", err) + } + + if manifest.Name != "feature/auth-flow" { + t.Errorf("Name = %q, want feature/auth-flow", manifest.Name) + } + if manifest.Feature != "auth-flow" { + t.Errorf("Feature = %q, want auth-flow", manifest.Feature) + } + if manifest.BaseBranch != "main" { + t.Errorf("BaseBranch = %q, want main", manifest.BaseBranch) + } + + // Verify feature was updated with branch reference + f, err := LoadFeature(root, "auth-flow") + if err != nil { + t.Fatalf("load feature: %v", err) + } + if f.Branch != "feature/auth-flow" { + t.Errorf("Feature.Branch = %q, want feature/auth-flow", f.Branch) + } +} + +func TestCreateBranch_AlreadyExists(t *testing.T) { + root := setupTestSDLC(t) + cfg := DefaultConfig("test") + + _, err := CreateFeature(root, "auth-flow", "Auth Flow") + if err != nil { + t.Fatalf("create feature: %v", err) + } + + _, err = CreateBranch(root, "auth-flow", cfg) + if err != nil { + t.Fatalf("create branch: %v", err) + } + + _, err = CreateBranch(root, "auth-flow", cfg) + if err != ErrBranchExists { + t.Errorf("err = %v, want ErrBranchExists", err) + } +} + +func TestCreateBranch_FeatureNotFound(t *testing.T) { + root := setupTestSDLC(t) + cfg := DefaultConfig("test") + + _, err := CreateBranch(root, "nonexistent", cfg) + if err != ErrFeatureNotFound { + t.Errorf("err = %v, want ErrFeatureNotFound", err) + } +} + +func TestLoadBranch(t *testing.T) { + root := setupTestSDLC(t) + cfg := DefaultConfig("test") + + _, err := CreateFeature(root, "auth-flow", "Auth Flow") + if err != nil { + t.Fatalf("create feature: %v", err) + } + + _, err = CreateBranch(root, "auth-flow", cfg) + if err != nil { + t.Fatalf("create branch: %v", err) + } + + manifest, err := LoadBranch(root, "feature/auth-flow") + if err != nil { + t.Fatalf("load branch: %v", err) + } + if manifest.Name != "feature/auth-flow" { + t.Errorf("Name = %q, want feature/auth-flow", manifest.Name) + } +} + +func TestLoadBranch_NotFound(t *testing.T) { + root := setupTestSDLC(t) + + _, err := LoadBranch(root, "nonexistent") + if err != ErrBranchNotFound { + t.Errorf("err = %v, want ErrBranchNotFound", err) + } +} + +func TestSaveBranch(t *testing.T) { + root := setupTestSDLC(t) + + manifest := &BranchManifest{ + Name: "feature/test", + Feature: "test", + BaseBranch: "main", + } + + if err := SaveBranch(root, manifest); err != nil { + t.Fatalf("save branch: %v", err) + } + + // Verify file exists + path := filepath.Join(root, SDLCDir, BranchesDir, "feature/test.yaml") + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("branch manifest file not created") + } +} + +func TestMergeChecklist_NotReady(t *testing.T) { + root := setupTestSDLC(t) + + _, err := CreateFeature(root, "auth-flow", "Auth Flow") + if err != nil { + t.Fatalf("create feature: %v", err) + } + + unmet, err := MergeChecklist(root, "auth-flow") + if err != nil { + t.Fatalf("merge checklist: %v", err) + } + + if len(unmet) == 0 { + t.Error("expected unmet gates, got none") + } +} + +func TestMergeChecklist_Ready(t *testing.T) { + root := setupTestSDLC(t) + + f, err := CreateFeature(root, "auth-flow", "Auth Flow") + if err != nil { + t.Fatalf("create feature: %v", err) + } + + // Set up all gates as passed and transition to merge phase + f.Phase = PhaseMerge + f.GetArtifact(ArtifactReview).MarkPassed() + f.GetArtifact(ArtifactAudit).MarkPassed() + f.GetArtifact(ArtifactQAResults).MarkPassed() + if err := f.Save(root); err != nil { + t.Fatalf("save feature: %v", err) + } + + unmet, err := MergeChecklist(root, "auth-flow") + if err != nil { + t.Fatalf("merge checklist: %v", err) + } + + if len(unmet) != 0 { + t.Errorf("expected 0 unmet gates, got %d: %v", len(unmet), unmet) + } +} + +func TestMergeChecklist_FeatureNotFound(t *testing.T) { + root := setupTestSDLC(t) + + _, err := MergeChecklist(root, "nonexistent") + if err != ErrFeatureNotFound { + t.Errorf("err = %v, want ErrFeatureNotFound", err) + } +} diff --git a/internal/sdlc/classifier_lifecycle_test.go b/internal/sdlc/classifier_lifecycle_test.go new file mode 100644 index 0000000..d67a521 --- /dev/null +++ b/internal/sdlc/classifier_lifecycle_test.go @@ -0,0 +1,143 @@ +package sdlc + +import "testing" + +// TestFullLifecycleClassification walks through the entire feature lifecycle. +func TestFullLifecycleClassification(t *testing.T) { + c := NewClassifier() + cfg := DefaultConfig("test") + state := DefaultState("test") + f := makeTestFeature(PhaseDraft) + + // Phase: Draft + // Step 1: needs spec + cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionCreateSpec { + t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action) + } + + // Step 2: spec created -> needs approval + f.GetArtifact(ArtifactSpec).MarkDraft() + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionAwaitApproval { + t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action) + } + + // Step 3: spec approved -> transition to specified + f.GetArtifact(ArtifactSpec).Approve("user") + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified { + t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo) + } + + // Phase: Specified + f.Transition(PhaseSpecified) + + // Step 4: needs design + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionCreateDesign { + t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action) + } + + // Step 5: design approved -> needs tasks + f.GetArtifact(ArtifactDesign).Approve("user") + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionCreateTasks { + t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action) + } + + // Step 6: tasks approved -> needs qa plan + f.GetArtifact(ArtifactTasks).Approve("user") + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionCreateQAPlan { + t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action) + } + + // Step 7: qa plan approved -> transition to planned + f.GetArtifact(ArtifactQAPlan).Approve("user") + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned { + t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo) + } + + // Phase: Planned -> needs branch -> Ready -> Implementation + f.Transition(PhasePlanned) + + // Step 7b: needs branch (RequireBranch is true in DefaultConfig) + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionCreateBranch { + t.Fatalf("step7b: Action = %q, want CREATE_BRANCH", cl.Action) + } + + // Branch created + f.Branch = "feature/auth" + + // Step 7c: ready to implement + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionTransition || cl.TransitionTo != PhaseReady { + t.Fatalf("step7c: Action = %q/%q, want TRANSITION/ready", cl.Action, cl.TransitionTo) + } + + f.Transition(PhaseReady) + f.Transition(PhaseImplementation) + + // Add tasks + f.Tasks = AddTask(nil, "Create user model") + f.Tasks = AddTask(f.Tasks, "Add validation") + + // Step 8: implement next task + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionImplementTask { + t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action) + } + + // Complete all tasks + f.Tasks, _ = StartTask(f.Tasks, "task-001") + f.Tasks, _ = CompleteTask(f.Tasks, "task-001") + f.Tasks, _ = StartTask(f.Tasks, "task-002") + f.Tasks, _ = CompleteTask(f.Tasks, "task-002") + + // Step 9: implementation complete -> transition to review + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview { + t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo) + } + + // Phase: Review + f.Transition(PhaseReview) + f.GetArtifact(ArtifactReview).MarkPassed() + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.TransitionTo != PhaseAudit { + t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo) + } + + // Phase: Audit + f.Transition(PhaseAudit) + f.GetArtifact(ArtifactAudit).MarkPassed() + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.TransitionTo != PhaseQA { + t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo) + } + + // Phase: QA + f.Transition(PhaseQA) + f.GetArtifact(ArtifactQAResults).MarkPassed() + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.TransitionTo != PhaseMerge { + t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo) + } + + // Phase: Merge + f.Transition(PhaseMerge) + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionMergeFeature { + t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action) + } + + // Phase: Released + f.Transition(PhaseReleased) + cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) + if cl.Action != ActionArchive { + t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action) + } +} diff --git a/internal/sdlc/classifier_test.go b/internal/sdlc/classifier_test.go index 8190fe6..6e9e9cd 100644 --- a/internal/sdlc/classifier_test.go +++ b/internal/sdlc/classifier_test.go @@ -145,9 +145,71 @@ func TestClassifySpecifiedPlanningComplete(t *testing.T) { } } +func TestClassifyPlannedNeedsBranch(t *testing.T) { + c := NewClassifier() + f := makeTestFeature(PhasePlanned) + cfg := DefaultConfig("test") + // DefaultConfig has RequireBranch: true, feature has no branch + + cl := c.Classify(&EvalContext{ + State: DefaultState("test"), + Feature: f, + Config: cfg, + }) + + if cl.Action != ActionCreateBranch { + t.Errorf("Action = %q, want CREATE_BRANCH", cl.Action) + } + if cl.RuleMatched != "needs-branch" { + t.Errorf("RuleMatched = %q, want needs-branch", cl.RuleMatched) + } +} + +func TestClassifyPlannedBranchNotRequired(t *testing.T) { + c := NewClassifier() + f := makeTestFeature(PhasePlanned) + cfg := DefaultConfig("test") + cfg.Compliance.RequireBranch = false + + cl := c.Classify(&EvalContext{ + State: DefaultState("test"), + Feature: f, + Config: cfg, + }) + + // Should skip branch rule and go to ready-to-implement + if cl.Action != ActionTransition { + t.Errorf("Action = %q, want TRANSITION", cl.Action) + } + if cl.TransitionTo != PhaseReady { + t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo) + } +} + +func TestClassifyPlannedBranchAlreadyExists(t *testing.T) { + c := NewClassifier() + f := makeTestFeature(PhasePlanned) + f.Branch = "feature/auth" // Branch already set + + cl := c.Classify(&EvalContext{ + State: DefaultState("test"), + Feature: f, + Config: DefaultConfig("test"), + }) + + // Should skip branch rule and go to ready-to-implement + if cl.Action != ActionTransition { + t.Errorf("Action = %q, want TRANSITION", cl.Action) + } + if cl.TransitionTo != PhaseReady { + t.Errorf("TransitionTo = %q, want ready", cl.TransitionTo) + } +} + func TestClassifyPlannedTransitionsToReady(t *testing.T) { c := NewClassifier() f := makeTestFeature(PhasePlanned) + f.Branch = "feature/auth" // Branch exists, so needs-branch rule won't fire cl := c.Classify(&EvalContext{ State: DefaultState("test"), @@ -367,127 +429,3 @@ func TestClassifyBlocked(t *testing.T) { t.Errorf("Action = %q, want BLOCKED", cl.Action) } } - -// TestFullLifecycleClassification walks through the entire feature lifecycle. -func TestFullLifecycleClassification(t *testing.T) { - c := NewClassifier() - cfg := DefaultConfig("test") - state := DefaultState("test") - f := makeTestFeature(PhaseDraft) - - // Phase: Draft - // Step 1: needs spec - cl := c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionCreateSpec { - t.Fatalf("step1: Action = %q, want CREATE_SPEC", cl.Action) - } - - // Step 2: spec created -> needs approval - f.GetArtifact(ArtifactSpec).MarkDraft() - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionAwaitApproval { - t.Fatalf("step2: Action = %q, want AWAIT_APPROVAL", cl.Action) - } - - // Step 3: spec approved -> transition to specified - f.GetArtifact(ArtifactSpec).Approve("user") - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionTransition || cl.TransitionTo != PhaseSpecified { - t.Fatalf("step3: Action = %q/%q", cl.Action, cl.TransitionTo) - } - - // Phase: Specified - f.Transition(PhaseSpecified) - - // Step 4: needs design - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionCreateDesign { - t.Fatalf("step4: Action = %q, want CREATE_DESIGN", cl.Action) - } - - // Step 5: design approved -> needs tasks - f.GetArtifact(ArtifactDesign).Approve("user") - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionCreateTasks { - t.Fatalf("step5: Action = %q, want CREATE_TASKS", cl.Action) - } - - // Step 6: tasks approved -> needs qa plan - f.GetArtifact(ArtifactTasks).Approve("user") - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionCreateQAPlan { - t.Fatalf("step6: Action = %q, want CREATE_QA_PLAN", cl.Action) - } - - // Step 7: qa plan approved -> transition to planned - f.GetArtifact(ArtifactQAPlan).Approve("user") - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionTransition || cl.TransitionTo != PhasePlanned { - t.Fatalf("step7: Action = %q/%q", cl.Action, cl.TransitionTo) - } - - // Phase: Planned -> Ready -> Implementation - f.Transition(PhasePlanned) - f.Transition(PhaseReady) - f.Transition(PhaseImplementation) - - // Add tasks - f.Tasks = AddTask(nil, "Create user model") - f.Tasks = AddTask(f.Tasks, "Add validation") - - // Step 8: implement next task - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionImplementTask { - t.Fatalf("step8: Action = %q, want IMPLEMENT_TASK", cl.Action) - } - - // Complete all tasks - f.Tasks, _ = StartTask(f.Tasks, "task-001") - f.Tasks, _ = CompleteTask(f.Tasks, "task-001") - f.Tasks, _ = StartTask(f.Tasks, "task-002") - f.Tasks, _ = CompleteTask(f.Tasks, "task-002") - - // Step 9: implementation complete -> transition to review - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionTransition || cl.TransitionTo != PhaseReview { - t.Fatalf("step9: Action = %q/%q", cl.Action, cl.TransitionTo) - } - - // Phase: Review - f.Transition(PhaseReview) - f.GetArtifact(ArtifactReview).MarkPassed() - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.TransitionTo != PhaseAudit { - t.Fatalf("review->audit: TransitionTo = %q", cl.TransitionTo) - } - - // Phase: Audit - f.Transition(PhaseAudit) - f.GetArtifact(ArtifactAudit).MarkPassed() - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.TransitionTo != PhaseQA { - t.Fatalf("audit->qa: TransitionTo = %q", cl.TransitionTo) - } - - // Phase: QA - f.Transition(PhaseQA) - f.GetArtifact(ArtifactQAResults).MarkPassed() - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.TransitionTo != PhaseMerge { - t.Fatalf("qa->merge: TransitionTo = %q", cl.TransitionTo) - } - - // Phase: Merge - f.Transition(PhaseMerge) - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionMergeFeature { - t.Fatalf("merge: Action = %q, want MERGE_FEATURE", cl.Action) - } - - // Phase: Released - f.Transition(PhaseReleased) - cl = c.Classify(&EvalContext{State: state, Feature: f, Config: cfg}) - if cl.Action != ActionArchive { - t.Fatalf("released: Action = %q, want ARCHIVE", cl.Action) - } -} diff --git a/internal/sdlc/errors.go b/internal/sdlc/errors.go index ea299cc..6d93fe5 100644 --- a/internal/sdlc/errors.go +++ b/internal/sdlc/errors.go @@ -13,4 +13,7 @@ var ( ErrTaskNotFound = errors.New("task not found") ErrArtifactNotFound = errors.New("artifact not found") ErrNoFeatures = errors.New("no features found") + ErrBranchExists = errors.New("branch already exists") + ErrBranchNotFound = errors.New("branch not found") + ErrMergeNotReady = errors.New("feature not ready to merge: unmet gates") ) diff --git a/internal/sdlc/rules.go b/internal/sdlc/rules.go index b6895ba..321f3ac 100644 --- a/internal/sdlc/rules.go +++ b/internal/sdlc/rules.go @@ -20,6 +20,7 @@ func DefaultRules() []Rule { needsQAPlanRule(), qaPlanNeedsApprovalRule(), planningCompleteRule(), + needsBranchRule(), readyToImplementRule(), implementNextTaskRule(), implementationCompleteRule(), @@ -236,6 +237,28 @@ func planningCompleteRule() Rule { } } +func needsBranchRule() Rule { + return Rule{ + ID: "needs-branch", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhasePlanned { + return false + } + if ctx.Config == nil || !ctx.Config.Compliance.RequireBranch { + return false + } + return ctx.Feature.Branch == "" + }, + Action: ActionCreateBranch, + Message: func(_ *EvalContext) string { + return "Feature branch required before implementation" + }, + NextCommand: func(ctx *EvalContext) string { + return "sdlc branch create " + ctx.Feature.Slug + }, + } +} + func readyToImplementRule() Rule { return Rule{ ID: "ready-to-implement", @@ -252,227 +275,3 @@ func readyToImplementRule() Rule { }, } } - -func implementNextTaskRule() Rule { - return Rule{ - ID: "implement-next-task", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseImplementation { - return false - } - next := NextTask(ctx.Feature.Tasks) - return next != nil - }, - Action: ActionImplementTask, - NextCommand: func(ctx *EvalContext) string { - next := NextTask(ctx.Feature.Tasks) - if next == nil { - return "" - } - return fmt.Sprintf("/implement-task %s %s", ctx.Feature.Slug, next.ID) - }, - OutputPath: func(ctx *EvalContext) string { - return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md" - }, - TaskID: func(ctx *EvalContext) string { - next := NextTask(ctx.Feature.Tasks) - if next == nil { - return "" - } - return next.ID - }, - } -} - -func implementationCompleteRule() Rule { - return Rule{ - ID: "implementation-complete", - Condition: func(ctx *EvalContext) bool { - return ctx.Feature.Phase == PhaseImplementation && AllTasksComplete(ctx.Feature.Tasks) - }, - Action: ActionTransition, - TransitionTo: PhaseReview, - } -} - -func needsReviewRule() Rule { - return Rule{ - ID: "needs-review", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseReview { - return false - } - art := ctx.Feature.GetArtifact(ArtifactReview) - return art == nil || art.Status == StatusPending - }, - Action: ActionReviewCode, - NextCommand: func(ctx *EvalContext) string { - return "/review-feature " + ctx.Feature.Slug - }, - OutputPath: func(ctx *EvalContext) string { - return ".sdlc/features/" + ctx.Feature.Slug + "/review.md" - }, - } -} - -func reviewHasIssuesRule() Rule { - return Rule{ - ID: "review-has-issues", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseReview { - return false - } - art := ctx.Feature.GetArtifact(ArtifactReview) - return art != nil && art.Status == StatusNeedsFix - }, - Action: ActionFixReviewIssues, - NextCommand: func(ctx *EvalContext) string { - return "/fix-review-issues " + ctx.Feature.Slug - }, - } -} - -func reviewPassedRule() Rule { - return Rule{ - ID: "review-passed", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseReview { - return false - } - art := ctx.Feature.GetArtifact(ArtifactReview) - return art != nil && art.Status == StatusPassed - }, - Action: ActionTransition, - TransitionTo: PhaseAudit, - } -} - -func needsAuditRule() Rule { - return Rule{ - ID: "needs-audit", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseAudit { - return false - } - art := ctx.Feature.GetArtifact(ArtifactAudit) - return art == nil || art.Status == StatusPending - }, - Action: ActionAuditCode, - NextCommand: func(ctx *EvalContext) string { - return "/audit-feature " + ctx.Feature.Slug - }, - OutputPath: func(ctx *EvalContext) string { - return ".sdlc/features/" + ctx.Feature.Slug + "/audit.md" - }, - } -} - -func auditHasIssuesRule() Rule { - return Rule{ - ID: "audit-has-issues", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseAudit { - return false - } - art := ctx.Feature.GetArtifact(ArtifactAudit) - return art != nil && art.Status == StatusNeedsFix - }, - Action: ActionRemediateAudit, - NextCommand: func(ctx *EvalContext) string { - return "/remediate-audit " + ctx.Feature.Slug - }, - } -} - -func auditPassedRule() Rule { - return Rule{ - ID: "audit-passed", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseAudit { - return false - } - art := ctx.Feature.GetArtifact(ArtifactAudit) - return art != nil && art.Status == StatusPassed - }, - Action: ActionTransition, - TransitionTo: PhaseQA, - } -} - -func needsQARule() Rule { - return Rule{ - ID: "needs-qa", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseQA { - return false - } - art := ctx.Feature.GetArtifact(ArtifactQAResults) - return art == nil || art.Status == StatusPending - }, - Action: ActionRunQA, - NextCommand: func(ctx *EvalContext) string { - return "/run-qa " + ctx.Feature.Slug - }, - OutputPath: func(ctx *EvalContext) string { - return ".sdlc/features/" + ctx.Feature.Slug + "/qa-results.md" - }, - } -} - -func qaHasFailuresRule() Rule { - return Rule{ - ID: "qa-has-failures", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseQA { - return false - } - art := ctx.Feature.GetArtifact(ArtifactQAResults) - return art != nil && art.Status == StatusFailed - }, - Action: ActionFixQAFailures, - NextCommand: func(ctx *EvalContext) string { - return "/fix-qa-failures " + ctx.Feature.Slug - }, - } -} - -func qaPassedRule() Rule { - return Rule{ - ID: "qa-passed", - Condition: func(ctx *EvalContext) bool { - if ctx.Feature.Phase != PhaseQA { - return false - } - art := ctx.Feature.GetArtifact(ArtifactQAResults) - return art != nil && art.Status == StatusPassed - }, - Action: ActionTransition, - TransitionTo: PhaseMerge, - } -} - -func needsMergeRule() Rule { - return Rule{ - ID: "needs-merge", - Condition: func(ctx *EvalContext) bool { - return ctx.Feature.Phase == PhaseMerge - }, - Action: ActionMergeFeature, - NextCommand: func(ctx *EvalContext) string { - return "/merge-feature " + ctx.Feature.Slug - }, - } -} - -func archiveFeatureRule() Rule { - return Rule{ - ID: "archive-feature", - Condition: func(ctx *EvalContext) bool { - return ctx.Feature.Phase == PhaseReleased - }, - Action: ActionArchive, - NextCommand: func(ctx *EvalContext) string { - return "/archive-feature " + ctx.Feature.Slug - }, - } -} diff --git a/internal/sdlc/rules_execution.go b/internal/sdlc/rules_execution.go new file mode 100644 index 0000000..8e356aa --- /dev/null +++ b/internal/sdlc/rules_execution.go @@ -0,0 +1,229 @@ +package sdlc + +import "fmt" + +// Execution phase rules: review, audit, QA, merge, archive. + +func needsReviewRule() Rule { + return Rule{ + ID: "needs-review", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseReview { + return false + } + art := ctx.Feature.GetArtifact(ArtifactReview) + return art == nil || art.Status == StatusPending + }, + Action: ActionReviewCode, + NextCommand: func(ctx *EvalContext) string { + return "/review-feature " + ctx.Feature.Slug + }, + OutputPath: func(ctx *EvalContext) string { + return ".sdlc/features/" + ctx.Feature.Slug + "/review.md" + }, + } +} + +func reviewHasIssuesRule() Rule { + return Rule{ + ID: "review-has-issues", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseReview { + return false + } + art := ctx.Feature.GetArtifact(ArtifactReview) + return art != nil && art.Status == StatusNeedsFix + }, + Action: ActionFixReviewIssues, + NextCommand: func(ctx *EvalContext) string { + return "/fix-review-issues " + ctx.Feature.Slug + }, + } +} + +func reviewPassedRule() Rule { + return Rule{ + ID: "review-passed", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseReview { + return false + } + art := ctx.Feature.GetArtifact(ArtifactReview) + return art != nil && art.Status == StatusPassed + }, + Action: ActionTransition, + TransitionTo: PhaseAudit, + } +} + +func needsAuditRule() Rule { + return Rule{ + ID: "needs-audit", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseAudit { + return false + } + art := ctx.Feature.GetArtifact(ArtifactAudit) + return art == nil || art.Status == StatusPending + }, + Action: ActionAuditCode, + NextCommand: func(ctx *EvalContext) string { + return "/audit-feature " + ctx.Feature.Slug + }, + OutputPath: func(ctx *EvalContext) string { + return ".sdlc/features/" + ctx.Feature.Slug + "/audit.md" + }, + } +} + +func auditHasIssuesRule() Rule { + return Rule{ + ID: "audit-has-issues", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseAudit { + return false + } + art := ctx.Feature.GetArtifact(ArtifactAudit) + return art != nil && art.Status == StatusNeedsFix + }, + Action: ActionRemediateAudit, + NextCommand: func(ctx *EvalContext) string { + return "/remediate-audit " + ctx.Feature.Slug + }, + } +} + +func auditPassedRule() Rule { + return Rule{ + ID: "audit-passed", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseAudit { + return false + } + art := ctx.Feature.GetArtifact(ArtifactAudit) + return art != nil && art.Status == StatusPassed + }, + Action: ActionTransition, + TransitionTo: PhaseQA, + } +} + +func needsQARule() Rule { + return Rule{ + ID: "needs-qa", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseQA { + return false + } + art := ctx.Feature.GetArtifact(ArtifactQAResults) + return art == nil || art.Status == StatusPending + }, + Action: ActionRunQA, + NextCommand: func(ctx *EvalContext) string { + return "/run-qa " + ctx.Feature.Slug + }, + OutputPath: func(ctx *EvalContext) string { + return ".sdlc/features/" + ctx.Feature.Slug + "/qa-results.md" + }, + } +} + +func qaHasFailuresRule() Rule { + return Rule{ + ID: "qa-has-failures", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseQA { + return false + } + art := ctx.Feature.GetArtifact(ArtifactQAResults) + return art != nil && art.Status == StatusFailed + }, + Action: ActionFixQAFailures, + NextCommand: func(ctx *EvalContext) string { + return "/fix-qa-failures " + ctx.Feature.Slug + }, + } +} + +func qaPassedRule() Rule { + return Rule{ + ID: "qa-passed", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseQA { + return false + } + art := ctx.Feature.GetArtifact(ArtifactQAResults) + return art != nil && art.Status == StatusPassed + }, + Action: ActionTransition, + TransitionTo: PhaseMerge, + } +} + +func needsMergeRule() Rule { + return Rule{ + ID: "needs-merge", + Condition: func(ctx *EvalContext) bool { + return ctx.Feature.Phase == PhaseMerge + }, + Action: ActionMergeFeature, + NextCommand: func(ctx *EvalContext) string { + return "/merge-feature " + ctx.Feature.Slug + }, + } +} + +func archiveFeatureRule() Rule { + return Rule{ + ID: "archive-feature", + Condition: func(ctx *EvalContext) bool { + return ctx.Feature.Phase == PhaseReleased + }, + Action: ActionArchive, + NextCommand: func(ctx *EvalContext) string { + return "/archive-feature " + ctx.Feature.Slug + }, + } +} + +func implementNextTaskRule() Rule { + return Rule{ + ID: "implement-next-task", + Condition: func(ctx *EvalContext) bool { + if ctx.Feature.Phase != PhaseImplementation { + return false + } + next := NextTask(ctx.Feature.Tasks) + return next != nil + }, + Action: ActionImplementTask, + NextCommand: func(ctx *EvalContext) string { + next := NextTask(ctx.Feature.Tasks) + if next == nil { + return "" + } + return fmt.Sprintf("/implement-task %s %s", ctx.Feature.Slug, next.ID) + }, + OutputPath: func(ctx *EvalContext) string { + return ".sdlc/features/" + ctx.Feature.Slug + "/tasks.md" + }, + TaskID: func(ctx *EvalContext) string { + next := NextTask(ctx.Feature.Tasks) + if next == nil { + return "" + } + return next.ID + }, + } +} + +func implementationCompleteRule() Rule { + return Rule{ + ID: "implementation-complete", + Condition: func(ctx *EvalContext) bool { + return ctx.Feature.Phase == PhaseImplementation && AllTasksComplete(ctx.Feature.Tasks) + }, + Action: ActionTransition, + TransitionTo: PhaseReview, + } +} diff --git a/internal/service/sdlc_orchestrator.go b/internal/service/sdlc_orchestrator.go new file mode 100644 index 0000000..e074e6e --- /dev/null +++ b/internal/service/sdlc_orchestrator.go @@ -0,0 +1,263 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/sdlc" +) + +// GitCommitResult describes the outcome of a git commit+push operation. +type GitCommitResult struct { + HasChanges bool + CommitSHA string + FilesChanged []string + Pushed bool + Error error +} + +// PodGitCommitter abstracts git commit/push operations in pods. +// This avoids importing internal/worker directly, preventing import cycles. +type PodGitCommitter interface { + CommitAndPush(ctx context.Context, podName, workDir, message string, push bool) *GitCommitResult +} + +// SDLCOrchestratorService orchestrates SDLC actions: execute, resolve, commit. +type SDLCOrchestratorService struct { + sdlcService *SDLCService + agentRegistry port.CodeAgentRegistry + gitCommitter PodGitCommitter + projectRepo port.ProjectRepository + logger *slog.Logger +} + +// SDLCOrchestratorConfig configures the orchestrator. +type SDLCOrchestratorConfig struct { + Logger *slog.Logger +} + +// NewSDLCOrchestratorService creates a new orchestrator service. +func NewSDLCOrchestratorService( + sdlcService *SDLCService, + agentRegistry port.CodeAgentRegistry, + gitCommitter PodGitCommitter, + projectRepo port.ProjectRepository, + cfg SDLCOrchestratorConfig, +) *SDLCOrchestratorService { + logger := cfg.Logger + if logger == nil { + logger = slog.Default() + } + return &SDLCOrchestratorService{ + sdlcService: sdlcService, + agentRegistry: agentRegistry, + gitCommitter: gitCommitter, + projectRepo: projectRepo, + logger: logger.With("component", "sdlc-orchestrator"), + } +} + +// ExecuteRequest describes what action to execute. +type ExecuteRequest struct { + Feature string `json:"feature"` + Provider string `json:"provider,omitempty"` +} + +// ExecutionResult is the result of executing an SDLC action. +type ExecutionResult struct { + Action sdlc.ActionType `json:"action"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Next *sdlc.Classification `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +// ResolveRequest describes a blocker resolution. +type ResolveRequest struct { + Feature string `json:"feature"` + Answer string `json:"answer,omitempty"` +} + +// CommitRequest describes a commit operation. +type CommitRequest struct { + Feature string `json:"feature"` + Message string `json:"message"` + Push bool `json:"push"` +} + +// CommitResult is the result of a commit operation. +type CommitResult struct { + CommitSHA string `json:"commit_sha,omitempty"` + FilesChanged []string `json:"files_changed,omitempty"` + Pushed bool `json:"pushed"` +} + +// ExecuteAction gets the next classifier action and executes it. +func (s *SDLCOrchestratorService) ExecuteAction(ctx context.Context, projectID string, req *ExecuteRequest) (*ExecutionResult, error) { + cl, err := s.sdlcService.GetNext(ctx, projectID, req.Feature) + if err != nil { + return nil, err + } + + result := &ExecutionResult{ + Action: cl.Action, + } + + switch cl.Action { + case sdlc.ActionTransition: + err = s.executeTransition(ctx, projectID, cl) + case sdlc.ActionIdle, sdlc.ActionBlocked, sdlc.ActionAwaitApproval: + result.Output = cl.Message + result.Success = true + default: + err = s.executeAgentAction(ctx, projectID, cl, req, result) + } + + if err != nil { + result.Error = err.Error() + return result, nil + } + + if result.Error == "" { + result.Success = true + } + + next, nextErr := s.sdlcService.GetNext(ctx, projectID, req.Feature) + if nextErr == nil { + result.Next = next + } + + return result, nil +} + +func (s *SDLCOrchestratorService) executeTransition(ctx context.Context, projectID string, cl *sdlc.Classification) error { + s.logger.Info("executing transition", + "project", projectID, + "feature", cl.Feature, + "to_phase", string(cl.TransitionTo), + ) + return s.sdlcService.TransitionFeature(ctx, projectID, cl.Feature, cl.TransitionTo) +} + +func (s *SDLCOrchestratorService) executeAgentAction(ctx context.Context, projectID string, cl *sdlc.Classification, req *ExecuteRequest, result *ExecutionResult) error { + var agent port.CodeAgent + if req.Provider != "" { + provider, err := domain.ParseAgentProvider(req.Provider) + if err != nil { + return fmt.Errorf("invalid agent provider: %w", err) + } + agent = s.agentRegistry.Get(provider) + } else { + agent = s.agentRegistry.Default() + } + + if agent == nil { + return fmt.Errorf("no agent available") + } + + project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) + if err != nil { + return fmt.Errorf("resolve project: %w", err) + } + + prompt := cl.NextCommand + if prompt == "" { + prompt = fmt.Sprintf("Execute SDLC action: %s for feature %s", cl.Action, cl.Feature) + } + + agentReq := &domain.AgentRequest{ + Prompt: prompt, + ProjectID: project.ID, + Timeout: 10 * time.Minute, + Metadata: map[string]string{ + "pod_name": project.PodName, + "namespace": "rdev", + "action": string(cl.Action), + "feature": cl.Feature, + }, + } + + s.logger.Info("dispatching agent action", + "project", projectID, + "feature", cl.Feature, + "action", string(cl.Action), + "agent", agent.Name(), + ) + + var output string + _, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) { + if event.Type == domain.AgentEventOutput { + output += event.Content + } + }) + if err != nil { + return fmt.Errorf("agent execution failed: %w", err) + } + + result.Output = output + return nil +} + +// ResolveBlocker unblocks a feature and re-classifies. +func (s *SDLCOrchestratorService) ResolveBlocker(ctx context.Context, projectID string, req *ResolveRequest) (*ExecutionResult, error) { + if err := s.sdlcService.UnblockFeature(ctx, projectID, req.Feature); err != nil { + return nil, err + } + + s.logger.Info("blocker resolved", "project", projectID, "feature", req.Feature) + + cl, err := s.sdlcService.GetNext(ctx, projectID, req.Feature) + if err != nil { + return &ExecutionResult{ + Action: sdlc.ActionTransition, + Success: true, + Output: "Feature unblocked", + }, nil + } + + return &ExecutionResult{ + Action: sdlc.ActionTransition, + Success: true, + Output: "Feature unblocked", + Next: cl, + }, nil +} + +// CommitChanges commits and optionally pushes changes in the project pod. +func (s *SDLCOrchestratorService) CommitChanges(ctx context.Context, projectID string, req *CommitRequest) (*CommitResult, error) { + if s.gitCommitter == nil { + return nil, fmt.Errorf("git operations not configured") + } + + project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID)) + if err != nil { + return nil, domain.ErrProjectNotFound + } + + workDir := "/workspace" + if project.Workspace != "" { + workDir = project.Workspace + } + + gitResult := s.gitCommitter.CommitAndPush(ctx, project.PodName, workDir, req.Message, req.Push) + if gitResult.Error != nil { + return nil, gitResult.Error + } + + s.logger.Info("changes committed", + "project", projectID, + "sha", gitResult.CommitSHA, + "files", len(gitResult.FilesChanged), + "pushed", gitResult.Pushed, + ) + + return &CommitResult{ + CommitSHA: gitResult.CommitSHA, + FilesChanged: gitResult.FilesChanged, + Pushed: gitResult.Pushed, + }, nil +} diff --git a/internal/service/sdlc_orchestrator_test.go b/internal/service/sdlc_orchestrator_test.go new file mode 100644 index 0000000..863b653 --- /dev/null +++ b/internal/service/sdlc_orchestrator_test.go @@ -0,0 +1,333 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" + "github.com/orchard9/rdev/internal/sdlc" +) + +// mockGitCommitter implements PodGitCommitter for testing. +type mockGitCommitter struct { + result *GitCommitResult +} + +func (m *mockGitCommitter) CommitAndPush(_ context.Context, _, _, _ string, _ bool) *GitCommitResult { + if m.result != nil { + return m.result + } + return &GitCommitResult{ + HasChanges: true, + CommitSHA: "abc123", + FilesChanged: []string{"main.go"}, + Pushed: true, + } +} + +// mockAgentRegistry implements port.CodeAgentRegistry for testing. +type mockAgentRegistry struct { + agent port.CodeAgent +} + +func (r *mockAgentRegistry) Register(_ port.CodeAgent) {} +func (r *mockAgentRegistry) Get(_ domain.AgentProvider) port.CodeAgent { return r.agent } +func (r *mockAgentRegistry) Default() port.CodeAgent { return r.agent } +func (r *mockAgentRegistry) DefaultProvider() domain.AgentProvider { return "" } +func (r *mockAgentRegistry) SetDefault(_ domain.AgentProvider) error { return nil } +func (r *mockAgentRegistry) Available() []domain.AgentProvider { return nil } +func (r *mockAgentRegistry) AvailableAgents(_ context.Context) []port.CodeAgent { return nil } +func (r *mockAgentRegistry) Count() int { return 0 } + +// mockCodeAgent implements port.CodeAgent for testing. +type mockCodeAgent struct { + result *domain.AgentResult + err error +} + +func (a *mockCodeAgent) Name() string { return "test-agent" } +func (a *mockCodeAgent) Provider() domain.AgentProvider { return "test" } +func (a *mockCodeAgent) Cancel(_ context.Context, _ string) error { return nil } +func (a *mockCodeAgent) Capabilities() domain.AgentCapabilities { return domain.AgentCapabilities{} } +func (a *mockCodeAgent) Available(_ context.Context) bool { return true } +func (a *mockCodeAgent) Execute(_ context.Context, _ *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) { + if a.err != nil { + return nil, a.err + } + if handler != nil { + handler(domain.AgentEvent{ + Type: domain.AgentEventOutput, + Content: "agent output", + }) + } + if a.result != nil { + return a.result, nil + } + return &domain.AgentResult{}, nil +} + +func newTestOrchestrator(exec *mockSDLCExecutor, repo *mockProjectRepo, registry port.CodeAgentRegistry, committer PodGitCommitter) *SDLCOrchestratorService { + sdlcSvc := NewSDLCService(exec, repo, SDLCServiceConfig{}) + return NewSDLCOrchestratorService(sdlcSvc, registry, committer, repo, SDLCOrchestratorConfig{}) +} + +func TestOrchestrator_ExecuteAction_Idle(t *testing.T) { + exec := &mockSDLCExecutor{ + getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) { + return &sdlc.Classification{ + Action: sdlc.ActionIdle, + Feature: "auth", + Message: "Nothing to do", + }, nil + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + svc := newTestOrchestrator(exec, repo, nil, nil) + + result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Error("expected success") + } + if result.Action != sdlc.ActionIdle { + t.Errorf("expected idle action, got %s", result.Action) + } + if result.Output != "Nothing to do" { + t.Errorf("expected 'Nothing to do', got %s", result.Output) + } +} + +func TestOrchestrator_ExecuteAction_Transition(t *testing.T) { + var transitioned bool + exec := &mockSDLCExecutor{ + getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) { + if transitioned { + return &sdlc.Classification{Action: sdlc.ActionIdle, Feature: "auth"}, nil + } + return &sdlc.Classification{ + Action: sdlc.ActionTransition, + Feature: "auth", + TransitionTo: sdlc.PhaseSpecified, + }, nil + }, + transitionFeatureFn: func(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error { + transitioned = true + return nil + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + svc := newTestOrchestrator(exec, repo, nil, nil) + + result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Error("expected success") + } + if result.Action != sdlc.ActionTransition { + t.Errorf("expected transition action, got %s", result.Action) + } + if !transitioned { + t.Error("expected transition to have been called") + } +} + +func TestOrchestrator_ExecuteAction_AgentAction(t *testing.T) { + exec := &mockSDLCExecutor{ + getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) { + return &sdlc.Classification{ + Action: sdlc.ActionCreateSpec, + Feature: "auth", + NextCommand: "create spec", + }, nil + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + agent := &mockCodeAgent{} + registry := &mockAgentRegistry{agent: agent} + svc := newTestOrchestrator(exec, repo, registry, nil) + + result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Error("expected success") + } + if result.Output != "agent output" { + t.Errorf("expected 'agent output', got %s", result.Output) + } +} + +func TestOrchestrator_ExecuteAction_NoAgent(t *testing.T) { + exec := &mockSDLCExecutor{ + getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) { + return &sdlc.Classification{ + Action: sdlc.ActionCreateSpec, + Feature: "auth", + }, nil + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + registry := &mockAgentRegistry{agent: nil} + svc := newTestOrchestrator(exec, repo, registry, nil) + + result, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Success { + t.Error("expected failure (no agent)") + } + if result.Error == "" { + t.Error("expected error message") + } +} + +func TestOrchestrator_ExecuteAction_ClassifyError(t *testing.T) { + exec := &mockSDLCExecutor{ + getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) { + return nil, sdlc.ErrFeatureNotFound + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + svc := newTestOrchestrator(exec, repo, nil, nil) + + _, err := svc.ExecuteAction(context.Background(), "proj", &ExecuteRequest{Feature: "auth"}) + if !errors.Is(err, sdlc.ErrFeatureNotFound) { + t.Errorf("expected ErrFeatureNotFound, got %v", err) + } +} + +func TestOrchestrator_ResolveBlocker(t *testing.T) { + var unblocked bool + exec := &mockSDLCExecutor{ + unblockFeatureFn: func(_ context.Context, _, _ string) error { + unblocked = true + return nil + }, + getNextFn: func(_ context.Context, _, _ string) (*sdlc.Classification, error) { + return &sdlc.Classification{ + Action: sdlc.ActionIdle, + Feature: "auth", + }, nil + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + svc := newTestOrchestrator(exec, repo, nil, nil) + + result, err := svc.ResolveBlocker(context.Background(), "proj", &ResolveRequest{Feature: "auth"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Error("expected success") + } + if !unblocked { + t.Error("expected unblock to have been called") + } + if result.Next == nil { + t.Error("expected next classification") + } +} + +func TestOrchestrator_ResolveBlocker_Error(t *testing.T) { + exec := &mockSDLCExecutor{ + unblockFeatureFn: func(_ context.Context, _, _ string) error { + return sdlc.ErrFeatureNotFound + }, + } + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + svc := newTestOrchestrator(exec, repo, nil, nil) + + _, err := svc.ResolveBlocker(context.Background(), "proj", &ResolveRequest{Feature: "auth"}) + if !errors.Is(err, sdlc.ErrFeatureNotFound) { + t.Errorf("expected ErrFeatureNotFound, got %v", err) + } +} + +func TestOrchestrator_CommitChanges(t *testing.T) { + exec := &mockSDLCExecutor{} + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + committer := &mockGitCommitter{ + result: &GitCommitResult{ + HasChanges: true, + CommitSHA: "def456", + FilesChanged: []string{"auth.go", "auth_test.go"}, + Pushed: true, + }, + } + svc := newTestOrchestrator(exec, repo, nil, committer) + + result, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{ + Feature: "auth", + Message: "implement auth", + Push: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.CommitSHA != "def456" { + t.Errorf("expected SHA def456, got %s", result.CommitSHA) + } + if len(result.FilesChanged) != 2 { + t.Errorf("expected 2 files changed, got %d", len(result.FilesChanged)) + } + if !result.Pushed { + t.Error("expected pushed") + } +} + +func TestOrchestrator_CommitChanges_NoCommitter(t *testing.T) { + exec := &mockSDLCExecutor{} + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + svc := newTestOrchestrator(exec, repo, nil, nil) // no git committer + + _, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{ + Feature: "auth", + Message: "commit", + }) + if err == nil { + t.Fatal("expected error for nil committer") + } +} + +func TestOrchestrator_CommitChanges_ProjectNotFound(t *testing.T) { + exec := &mockSDLCExecutor{} + repo := newMockProjectRepo() // empty + committer := &mockGitCommitter{} + svc := newTestOrchestrator(exec, repo, nil, committer) + + _, err := svc.CommitChanges(context.Background(), "nonexistent", &CommitRequest{ + Feature: "auth", + Message: "commit", + }) + if !errors.Is(err, domain.ErrProjectNotFound) { + t.Errorf("expected ErrProjectNotFound, got %v", err) + } +} + +func TestOrchestrator_CommitChanges_GitError(t *testing.T) { + exec := &mockSDLCExecutor{} + repo := newMockProjectRepo(&domain.Project{ID: "proj", PodName: "pod"}) + committer := &mockGitCommitter{ + result: &GitCommitResult{ + Error: errors.New("push failed"), + }, + } + svc := newTestOrchestrator(exec, repo, nil, committer) + + _, err := svc.CommitChanges(context.Background(), "proj", &CommitRequest{ + Feature: "auth", + Message: "commit", + Push: true, + }) + if err == nil { + t.Fatal("expected error for git failure") + } +} diff --git a/internal/service/sdlc_service.go b/internal/service/sdlc_service.go index 410adcc..ab73eb0 100644 --- a/internal/service/sdlc_service.go +++ b/internal/service/sdlc_service.go @@ -257,3 +257,66 @@ func (s *SDLCService) QueryNeedsApproval(ctx context.Context, projectID string) } 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 branch manifest for a feature. +func (s *SDLCService) GetBranchStatus(ctx context.Context, projectID, slug string) (*sdlc.BranchManifest, 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 +} diff --git a/internal/service/sdlc_service_test.go b/internal/service/sdlc_service_test.go index 08fdc80..29a967a 100644 --- a/internal/service/sdlc_service_test.go +++ b/internal/service/sdlc_service_test.go @@ -32,6 +32,11 @@ type mockSDLCExecutor struct { queryBlockedFn func(ctx context.Context, podName string) ([]port.BlockedInfo, error) queryReadyFn func(ctx context.Context, podName string) ([]port.ReadyInfo, error) queryNeedsApprFn func(ctx context.Context, podName string) ([]port.ApprovalInfo, error) + createBranchFn func(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) + getBranchStatusFn func(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) + syncBranchFn func(ctx context.Context, podName, slug string) error + mergeFeatureFn func(ctx context.Context, podName, slug, strategy string) error + archiveFeatureFn func(ctx context.Context, podName, slug string) error } func (m *mockSDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) { @@ -174,6 +179,41 @@ func (m *mockSDLCExecutor) QueryNeedsApproval(ctx context.Context, podName strin return nil, nil } +func (m *mockSDLCExecutor) CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) { + if m.createBranchFn != nil { + return m.createBranchFn(ctx, podName, slug) + } + return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil +} + +func (m *mockSDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) { + if m.getBranchStatusFn != nil { + return m.getBranchStatusFn(ctx, podName, slug) + } + return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil +} + +func (m *mockSDLCExecutor) SyncBranch(ctx context.Context, podName, slug string) error { + if m.syncBranchFn != nil { + return m.syncBranchFn(ctx, podName, slug) + } + return nil +} + +func (m *mockSDLCExecutor) MergeFeature(ctx context.Context, podName, slug, strategy string) error { + if m.mergeFeatureFn != nil { + return m.mergeFeatureFn(ctx, podName, slug, strategy) + } + return nil +} + +func (m *mockSDLCExecutor) ArchiveFeature(ctx context.Context, podName, slug string) error { + if m.archiveFeatureFn != nil { + return m.archiveFeatureFn(ctx, podName, slug) + } + return nil +} + // mockProjectRepo implements port.ProjectRepository for testing. type mockProjectRepo struct { projects map[domain.ProjectID]*domain.Project