feat: add SDLC branch management, merge, archive, and orchestrator APIs

Add branch lifecycle commands (branch, merge, archive) to the SDLC CLI.
Introduce orchestrator handler and service for multi-step SDLC workflows.
Expand skeleton template with 15 Claude commands covering the full feature
lifecycle. Extend classifier rules, error types, and executor port for
branch operations. Split rules.go and classifier_test.go to stay within
500-line limit.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-02 12:30:03 -07:00
parent 425ef0f806
commit f22b220c6d
45 changed files with 4091 additions and 377 deletions

View File

@ -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

View File

@ -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 <slug> <phase> # Phase transition
sdlc feature block/unblock <slug> # Blocker management
sdlc artifact create/approve/reject # Artifact lifecycle
sdlc task add/start/complete/block # Task lifecycle
sdlc next [--for <feature>] [--json] # Classifier output
sdlc next [--for <feature>] [--json] [--execute] # Classifier output (--execute auto-runs transitions)
sdlc query blocked/ready/needs-approval # Queries
sdlc branch create/status/sync <slug> # Branch management
sdlc merge <slug> [--strategy squash] # Merge feature branch
sdlc archive <slug> # 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

View File

@ -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,
}
}

View File

@ -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

48
cmd/sdlc/cmd_archive.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"fmt"
"github.com/orchard9/rdev/internal/sdlc"
"github.com/spf13/cobra"
)
var archiveCmd = &cobra.Command{
Use: "archive <slug>",
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)
}

192
cmd/sdlc/cmd_branch.go Normal file
View File

@ -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 <slug>",
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 <slug>",
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 <slug>",
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)
}

131
cmd/sdlc/cmd_merge.go Normal file
View File

@ -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 <slug>",
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)
}

View File

@ -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)
}

283
cookbooks/scripts/sdlc-test.sh Executable file
View File

@ -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 <command> <project-id>"
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

View File

@ -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)

View File

@ -0,0 +1,47 @@
---
description: Archive a completed and released feature
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,103 @@
---
description: Perform a security and quality audit of a feature
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,77 @@
---
description: Break a feature into implementation tasks
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,79 @@
---
description: Create a QA test plan for a feature
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,68 @@
---
description: Orchestrate full feature delivery from current state to completion
argument-hint: <feature-slug>
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 <phase>` |
| `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

View File

@ -0,0 +1,76 @@
---
description: Create a technical design document for a feature
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,68 @@
---
description: Fix QA test failures
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,68 @@
---
description: Fix issues found during code review
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,69 @@
---
description: Implement a specific task from the feature breakdown
argument-hint: <feature-slug> <task-id>
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 <slug> <task-id>
```
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/<slug>/spec.md` -- requirements
- `.sdlc/features/<slug>/design.md` -- architecture decisions
- `.sdlc/features/<slug>/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 <slug> <task-id>
```
### 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

View File

@ -0,0 +1,61 @@
---
description: Merge a completed feature branch
argument-hint: <feature-slug>
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

View File

@ -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 <slug>` |
| `CREATE_DESIGN` | `/design-feature <slug>` |
| `CREATE_TASKS` | `/breakdown-feature <slug>` |
| `CREATE_QA_PLAN` | `/create-qa-plan <slug>` |
| `IMPLEMENT_TASK` | `/implement-task <slug> <task-id>` |
| `CREATE_REVIEW` | `/review-feature <slug>` |
| `CREATE_AUDIT` | `/audit-feature <slug>` |
| `RUN_QA` | `/run-qa <slug>` |
| `MERGE` | `/merge-feature <slug>` |
| `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

View File

@ -0,0 +1,70 @@
---
description: Remediate security audit findings
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,85 @@
---
description: Perform a comprehensive code review of a feature
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,92 @@
---
description: Execute the QA test plan for a feature
argument-hint: <feature-slug>
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

View File

@ -0,0 +1,76 @@
---
description: Create a feature specification document
argument-hint: <feature-slug>
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

View File

@ -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")
}

View File

@ -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"})
}

View File

@ -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())
}
}

View File

@ -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"})
}

View File

@ -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())
}
}

View File

@ -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)
}

View File

@ -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())
}
}

View File

@ -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 {

View File

@ -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).

137
internal/sdlc/branch.go Normal file
View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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")
)

View File

@ -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
},
}
}

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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